diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..3c6800d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.ts', + '**/?(*.)+(spec|test).ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/index.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + moduleFileExtensions: ['ts', 'js', 'json'], + verbose: true +}; diff --git a/package-lock.json b/package-lock.json index eda81fb..d6a6eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@typescript-eslint/parser": "^6.13.1", "eslint": "^8.54.0", "jest": "^29.7.0", + "ts-jest": "^29.1.1", "typescript": "^5.3.2" } }, @@ -2203,6 +2204,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3180,6 +3194,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3496,6 +3532,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4229,6 +4266,13 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4262,6 +4306,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -4329,6 +4380,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4361,6 +4422,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -5363,6 +5431,72 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5414,6 +5548,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5518,6 +5666,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index e8c36a7..2a6b394 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "build:esm": "tsc --module esnext --outDir dist", "prepublishOnly": "npm run build", "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "lint": "eslint src --ext .ts,.js,.cjs" }, "keywords": [ @@ -54,6 +56,7 @@ "@typescript-eslint/parser": "^6.13.1", "eslint": "^8.54.0", "jest": "^29.7.0", + "ts-jest": "^29.1.1", "typescript": "^5.3.2" }, "repository": { diff --git a/src/matchers/context/Stylesheet.ts b/src/matchers/context/Stylesheet.ts index ad6e867..9675172 100644 --- a/src/matchers/context/Stylesheet.ts +++ b/src/matchers/context/Stylesheet.ts @@ -1,7 +1,19 @@ import css, { CssRuleAST, CssStylesheetAST } from '@adobe/css-tools'; export type StylesheetRule = { - selectors: string; + /** + * The full selector string (e.g. ".class1, .class2"). + */ + selector: string; + + /** + * The individual selector parts (e.g. [".class1", ".class2"]). + */ + selectorParts: string[]; + + /** + * The declarations within the rule (e.g. { color: "red", fontSize: "12px" }). + */ declarations: Record; } @@ -27,7 +39,8 @@ export class Stylesheet { this.rules = this.ast!.stylesheet.rules.reduce>((acc, rule) => { if (rule.type === 'rule') { const cssRule = rule as CssRuleAST; - const selectors = cssRule.selectors.join(', '); + const { selectors: selectorParts } = cssRule; + const selector = selectorParts.join(', '); const declarations: Record = rule.declarations.reduce((declAcc, decl) => { if (decl.type === 'declaration') { @@ -38,8 +51,9 @@ export class Stylesheet { return { ...acc, - [selectors]: { - selectors, + [selector]: { + selector, + selectorParts: cssRule.selectors, declarations, } }; diff --git a/src/matchers/css/ParsingError.ts b/src/matchers/css/ParsingError.ts new file mode 100644 index 0000000..b4be17d --- /dev/null +++ b/src/matchers/css/ParsingError.ts @@ -0,0 +1,14 @@ +import { ParsingErrorPosition } from "./types"; + +/** + * Represents a parsing error with a specific message. + */ +export class ParsingError extends Error { + public location: ParsingErrorPosition; + + constructor(message: string, location: ParsingErrorPosition) { + super(`Parsing Error [${location.line}:${location.column}]: ${message}`); + + this.location = location; + } +} diff --git a/src/matchers/css/SpecificityCalculator.ts b/src/matchers/css/SpecificityCalculator.ts new file mode 100644 index 0000000..35dfd49 --- /dev/null +++ b/src/matchers/css/SpecificityCalculator.ts @@ -0,0 +1,48 @@ +type Specificity = { + inline: number; + idSelectors: number; + classSelectors: number; + typeSelectors: number; +} + +const isTypeSelector = (part: string): boolean => { + const pseudoElement = /(::[a-zA-Z]+[a-zA-Z0-9-]*)*/; + const elementSelector = /^[a-zA-Z]+[a-zA-Z0-9-]*$/; + return elementSelector.test(part) || pseudoElement.test(part); +} + +const isClassSelector = (part: string): boolean => { + const classSelector = /^\.[a-zA-Z]+[a-zA-Z0-9-]*$/; + const attributeSelector = /^\[.+\]$/; + const pseudoClassSelector = /^:[^:].*$/; + return false; +} + +export class SpecificityCalculator { + constructor(public selector: string) {} + + calculate() { + console.log(this.selector); + + const parts = this.selector.split(/\s+/); + console.log(parts); + + const specificity = parts.reduce((specificity, part) => { + if (part.startsWith('.')) { + specificity.classSelectors += 1; + } + + if (part.startsWith('#')) { + specificity.idSelectors += 1; + } + + if (isTypeSelector(part)) { + specificity.typeSelectors += 1; + } + + return specificity; + }, { inline: 0, idSelectors: 0, classSelectors: 0, typeSelectors: 0 }); + + console.log(specificity); + } +} \ No newline at end of file diff --git a/src/matchers/css/TokenStream.ts b/src/matchers/css/TokenStream.ts new file mode 100644 index 0000000..a742f74 --- /dev/null +++ b/src/matchers/css/TokenStream.ts @@ -0,0 +1,116 @@ +import { ParsingError } from "./ParsingError"; +import { Token, TokenType, ParsingErrorPosition } from "./types"; + +/** + * Represents a stream of tokens for parsing, including methods to consume and peek tokens, + * as well as state and method for managing the parsing position. + */ +export class TokenStream { + public position: number; + public positionStack: number[]; + + constructor (private tokens: Token[]) { + this.position = 0; + this.positionStack = []; + } + + /** + * Peeks at the next token in the stream, without consuming it. + * @returns The next token, or null if we're at the end of the stream. + */ + public peek(): Token | null { + return this.tokens[this.position] || null; + } + + /** + * Consumes and returns the next token in the stream. + * @returns The consumed token, or null if we're at the end of the stream. + */ + public consume(): Token | null { + return this.tokens[this.position++] || null; + } + + /** + * Consumes a token if it matches the expected type. + * @returns The consumed token, or null if the next token does not match the expected type. + */ + public consumeIf(...types: TokenType[]): Token | null { + const token = this.peek(); + if (token && types.includes(token.type)) { + return this.consume(); + } + return null; + } + + /** + * Consumes a token matching one of the expected types. + * @returns The consumed token. + * @throws {ParsingError} If the next token does not match any of the expected types. + */ + public consumeExpect(...types: TokenType[]): Token { + const token = this.consume(); + + if (!token || !types.includes(token.type)) { + throw new ParsingError(`Expected token of type ${types.join(', ')}, but got ${token?.type || 'end of input'}`, this.getPositionForError()); + } + + return token; + } + + /** + * Consumes a whitespace token. + */ + public eatWhitespace() { + this.consumeIf('whitespace'); + } + + /** + * Stores the current position in the position stack. + */ + public storePosition() { + this.positionStack.push(this.position); + } + + /** + * Clears the last stored position without restoring it. + */ + public clearPosition() { + this.positionStack.pop(); + } + + /** + * Restores the last stored position from the position stack. + */ + public restorePosition() { + const pos = this.positionStack.pop(); + if (pos !== undefined) { + this.position = pos; + } + } + + /** + * Expects the end of input, throwing an error if not. + * @throws {ParsingError} If the end of the input has not been reached. + */ + public expectEndOfInput() { + // Consume any trailing whitespace. + this.eatWhitespace(); + + if (this.peek() !== null) { + throw new ParsingError(`Expected end of input, but got token of type ${this.peek()!.type}`, this.getPositionForError()); + } + } + + public peekRemainder(): string { + return this.tokens.slice(this.position).map(t => t.value).join(''); + } + + public getPositionForError(): ParsingErrorPosition { + const tokenToUse = this.peek() || this.tokens[this.tokens.length - 1]; + + return { + position: this.position, + ...tokenToUse.position, + }; + } +} \ No newline at end of file diff --git a/src/matchers/css/TryParseResult.ts b/src/matchers/css/TryParseResult.ts new file mode 100644 index 0000000..4ae60fe --- /dev/null +++ b/src/matchers/css/TryParseResult.ts @@ -0,0 +1,54 @@ +import { ParsingError } from "./ParsingError"; +import { ParsingErrorPosition } from "./types"; + +/** + * Represents the result of a try-parse operation, including any errors encountered, and the parsed result if successful. + */ +export type TryParseResult = { + errors: ParsingError[]; + result: T | null; +} + +/** + * Type guard to check if an object is a TryParseResult. + */ +export const isTryParseResult = (obj: any): obj is TryParseResult => { + return obj && typeof obj === 'object' && 'errors' in obj && 'result' in obj; +}; + +/** + * Unwraps a TryParseResult, returning a consistent structure. + */ +export const unwrapResult = (tryParseResult: T | TryParseResult): TryParseResult => { + let result: T | null, errors: ParsingError[]; + + if (isTryParseResult(tryParseResult)) { + result = tryParseResult.result; + errors = tryParseResult.errors; + } else { + result = tryParseResult as T; + errors = []; + } + + return { result, errors }; +}; + +/** + * Unwraps a TryParseResult or throws an error with the sum of all accumulated errors, or a custom error message, if parsing failed. + * @param tryParseResult The TryParseResult to unwrap. + * @param tokenStream The TokenStream to get the error position from. + * @param errorMessage Optional custom error message to use if parsing failed. + * @returns The parsed result if successful. + * @throws {ParsingError} If parsing failed, with accumulated error messages. + */ +export const unwrapResultOrThrow = (tryParseResult: T | TryParseResult, errorPosition: ParsingErrorPosition, errorMessage?: string): T => { + const { result, errors } = unwrapResult(tryParseResult); + + if (result === null || (Array.isArray(result) && result.length === 0)) { + // Combine all error messages into one. + const errorMessages = errors.map(err => err.message.replace(/Parsing Error: /, '')).join('; '); + throw new ParsingError(errorMessage ?? errorMessages, errorPosition); + } + + return result; +}; diff --git a/src/matchers/css/ast.ts b/src/matchers/css/ast.ts new file mode 100644 index 0000000..33162b3 --- /dev/null +++ b/src/matchers/css/ast.ts @@ -0,0 +1,39 @@ +import { Token } from "./tokenize"; + +interface Node { + type: string; +}; + +export interface SelectorNode extends Node { + type: 'Selector'; + value: string; +} + +export interface StringNode extends Node { + type: 'String'; + value: string; +} + +export interface Expression extends Node { + type: 'Expression', + attribute: SelectorNode; + operator?: string; + value?: SelectorNode | StringNode; +} + +export interface AttributeSelectorNode extends Node { + type: 'AttributeSelector'; + expression: Expression; +} + +export interface CompoundSelectorNode extends Node { + type: 'CompoundSelector'; + selectors: SelectorNode[]; +} + +export interface CombinatorNode extends Node { + type: 'Combinator', + operator: Token; + left: Node | null; + right: Node | null; +} \ No newline at end of file diff --git a/src/matchers/css/index.ts b/src/matchers/css/index.ts new file mode 100644 index 0000000..31e876d --- /dev/null +++ b/src/matchers/css/index.ts @@ -0,0 +1 @@ +export { Parser } from "./Parser"; \ No newline at end of file diff --git a/src/matchers/css/parser.test.ts b/src/matchers/css/parser.test.ts new file mode 100644 index 0000000..1a4bfdc --- /dev/null +++ b/src/matchers/css/parser.test.ts @@ -0,0 +1,37 @@ +import { Parser } from './Parser'; + +describe('CSS Parser', () => { + describe('complex combinator', () => { + test('test successful', () => { + const parser = new Parser('#test[data-testid="value"] > p.example[data-role="main"]'); + const parsed = parser.parse(); + expect(parsed).toBeDefined(); + }); + + test('test failure', () => { + const parser = new Parser('#test[data-testid="value" > p.example[data-role="main"]'); // Missing closing bracket + expect(() => parser.parse()).toThrow('Parsing Error [1:28]: Expected token of type right_bracket, but got whitespace'); + }); + + test('another failure', () => { + const parser = new Parser('div[data-testid="this is unterminated"] + div.example[d='); + expect(() => parser.parse()).toThrow('Parsing Error [1:57]: Expected token of type letter, but got end of input'); + }) + }); + + describe('attribute selector', () => { + test('simple attribute selector', () => { + const parser = new Parser('div[title]'); + const parsed = parser.parse(); + + expect(parsed).toBeDefined(); + }); + + test('attribute selector with operator and value', () => { + const parser = new Parser('div[class^="header"]'); + const parsed = parser.parse(); + + expect(parsed).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/matchers/css/parser.ts b/src/matchers/css/parser.ts new file mode 100644 index 0000000..e6d76fb --- /dev/null +++ b/src/matchers/css/parser.ts @@ -0,0 +1,374 @@ +import { AttributeSelectorNode, CombinatorNode, CompoundSelectorNode, Expression, SelectorNode, StringNode } from "./ast"; +import { ParsingError } from "./ParsingError"; +import { tokenize } from "./tokenize"; +import { TokenStream } from "./TokenStream"; +import { TryParseResult, unwrapResult, unwrapResultOrThrow } from "./TryParseResult"; +import { Token, TokenType } from "./types"; + +/** + * Backtracking parser for CSS selectors, implemented using combinators, utilises a TokenStream to read tokens and build an AST. + */ +export class Parser { + /** + * The stream of tokens from the input selector string. + */ + private tokenStream: TokenStream; + + /** + * The deepest parsing error encountered during parsing. + * + * Used to track the most relevant error to report back to the user. + */ + private deepestError: ParsingError | null; + + /** + * Creates a new CSS selector parser instance. + * @param selector The CSS selector string to parse. + */ + constructor(selector: string) { + this.tokenStream = new TokenStream(tokenize(selector)); + this.deepestError = null; + } + + private onErrorRaised(error: ParsingError) { + if (!!this.deepestError && this.deepestError.location.position > error.location.position) { + return; + } + + this.deepestError = error; + } + + // === Combinator Parsing Helpers === + + /** + * Attempts to parse using the provided parse function. If the parsing fails, the token stream is reset to its original state. + * Foundation for implementing backtracking in the parser. + * @return {TryParseResult} The result of the parsing attempt, including any errors encountered. + * @typeParam T The type of the parsing result. + */ + private tryParse(parseFn: () => T): TryParseResult { + this.tokenStream.storePosition(); + + try { + const { result, errors } = unwrapResult(parseFn()); + this.tokenStream.clearPosition(); + + return { + errors: [], + result, + }; + } catch (err: ParsingError | any) { + this.tokenStream.restorePosition(); + + // If this is the deepest error so far (parser that made it to the furthest point), we store it in the parser + // state for later reporting. + this.onErrorRaised(err); + + return { + errors: [err], + result: null, + }; + } + } + + /** + * Tries to parse using a given set of parsers, returning the first successful result, if any. + * @return {TryParseResult} The result of the parsing attempt, including accumulated errors encountered. + * @typeParam T The type of the parsing result. + */ + private tryParseMultiple(...parseFns: (() => T)[]): TryParseResult { + const totalErrors: ParsingError[] = []; + + for (const parseFn of parseFns) { + const { result, errors } = this.tryParse(parseFn); + + if (result !== null) { + return { result, errors: [] }; + } + + totalErrors.push(...errors); + } + + return { result: null, errors: totalErrors }; + } + + /** + * Tries to repeatedly parse with the given parsing function, until it fails. + * @return {TryParseResult} The result of the parsing attempt, including accumulated errors encountered. + * @typeParam T The type of the parsing result. + */ + private tryParseUntil(parseFn: (() => any)): TryParseResult { + const totalErrors: ParsingError[] = []; + const results: any[] = []; + + let count = 0; + + while (true) { + const { result, errors } = this.tryParse(parseFn); + + if (result === null) { + totalErrors.push(...errors); + break; + } + + results.push(result); + } + + return { + errors: results.length === 0 ? totalErrors : [], + result: results, + }; + } + + /** + * Combines two parsing functions into one, returning a tuple of their results. + * @return {() => [T, U]} A new parsing function that returns a tuple of the results from the two input parsing functions. + * @typeParam T The type of the first parsing result. + * @typeParam U The type of the second parsing result. + * @throws {ParsingError} If either of the parsing functions fail. + */ + private and(parseFn1: () => T, parseFn2: () => U): () => [T, U] { + return () => { + const result1 = parseFn1(); + const result2 = parseFn2(); + + return [result1, result2]; + }; + } + + // /** + // * Parses a number from the token stream. + // */ + // private parseNumber(): number { + // let value = ''; + // let token: Token | null; + + // while (token = this.tokenStream.peek()) { + // if (token.type !== 'digit') { + // break; + // } + + // const next = this.tokenStream.consume(); + // value += next?.value; + // } + + // return Number(value); + // } + + // === Selectors === + + /** + * Tries to parse a valid CSS name (identifier) from the token stream. + */ + private parseName(): SelectorNode { + let value = ''; + let token: Token | null; + + const validTokenTypes: TokenType[] = ['letter', 'digit', 'minus', 'underscore']; + value += this.tokenStream.consumeExpect('letter').value; + + while (token = this.tokenStream.peek()) { + if (!validTokenTypes.includes(token.type)) { + break; + } + + const next = this.tokenStream.consume(); + value += next?.value; + } + + return { + type: 'Selector', + value, + } + } + + /** + * Parses an ID selector from the token stream. + */ + private parseIdentifier(): SelectorNode { + this.tokenStream.consumeExpect('hash'); + let { value: name } = this.parseName(); + + return { + type: 'Selector', + value: `#${name}`, + } + } + + /** + * Parses a class selector from the token stream. + */ + private parseClass(): SelectorNode { + this.tokenStream.consumeExpect('period'); + let { value: className } = this.parseName(); + + return { + type: 'Selector', + value: `.${className}`, + } + } + + private parseString(): StringNode { + let value = ''; + + value += this.tokenStream.consumeExpect('quote').value; + + while (true) { + const next = this.tokenStream.consume(); + if (next === null) { + throw new ParsingError('Unterminated string literal', this.tokenStream.getPositionForError()); + } + + if (next.type === 'quote') { + value += next.value; + break; + } + + value += next.value; + } + + return { + type: 'String', + value, + }; + } + + private parseExpression(): Expression { + const attribute = this.parseName(); + + const parseComplexExpressionParts = () => { + this.tokenStream.eatWhitespace(); + + let operator = this.tokenStream.consumeIf('tilde', 'pipe', 'caret', 'dollar', 'asterisk')?.value || ''; + operator += this.tokenStream.consumeExpect('equals').value; + + this.tokenStream.eatWhitespace(); + + const value = unwrapResultOrThrow( + this.tryParseMultiple(this.parseString.bind(this), this.parseName.bind(this)), + this.tokenStream.getPositionForError(), + 'Expected string or name as attribute selector value' + ); + + return { + operator, + value, + } + }; + + const complexPartsResult = this.tryParse(parseComplexExpressionParts); + + return { + type: 'Expression', + attribute, + ...complexPartsResult.result, + }; + } + + private parseAttributeSelector(): AttributeSelectorNode { + this.tokenStream.consumeExpect('left_bracket').value; + + const expression = this.parseExpression(); + this.tokenStream.consumeExpect('right_bracket').value; + + return { + type: 'AttributeSelector', + expression, + }; + } + + /** + * Parses a single CSS selector from the token stream. + */ + private parseSelector(): SelectorNode | AttributeSelectorNode { + const result = this.tryParseMultiple( + this.parseIdentifier.bind(this), + this.parseClass.bind(this), + this.parseName.bind(this), + this.parseAttributeSelector.bind(this) + ); + + return unwrapResultOrThrow(result, this.tokenStream.getPositionForError()); + } + + /** + * Parses a compound CSS selector (one or more selectors not separated by a combinator) from the token stream. + */ + private parseCompoundSelector(): CompoundSelectorNode { + const result = this.tryParseUntil(this.parseSelector.bind(this)); + + const selectors = unwrapResultOrThrow(result, this.tokenStream.getPositionForError(), 'Expected at least one selector'); + + const node: CompoundSelectorNode = { + type: 'CompoundSelector', + selectors, + } + + return node; + } + + // === CSS Combinators === + + private parseDescendantCombinator(): CombinatorNode { + const left = this.parseCompoundSelector(); + const operator = this.tokenStream.consumeExpect('whitespace'); + const right = this.parseComplexSelector(); + + return { + type: 'Combinator', + operator, + left, + right, + } + } + + private parseOtherCombinator(): CombinatorNode { + const left = this.parseCompoundSelector(); + this.tokenStream.eatWhitespace(); + + const validCombinators: TokenType[] = ['left_angle_bracket', 'plus', 'tilde']; + const operator = this.tokenStream.consumeExpect(...validCombinators); + + this.tokenStream.eatWhitespace(); + const right = this.parseComplexSelector(); + + return { + type: 'Combinator', + operator, + left, + right, + } + } + + /** + * Parses a complex CSS selector, which may include combinators, from the token stream. + */ + private parseComplexSelector(): CombinatorNode | CompoundSelectorNode | SelectorNode | AttributeSelectorNode { + const tryParseResult = this.tryParseMultiple( + this.parseOtherCombinator.bind(this), + this.parseDescendantCombinator.bind(this), + this.parseCompoundSelector.bind(this), + )!; + + const selector = unwrapResultOrThrow(tryParseResult, this.tokenStream.getPositionForError()); + return selector; + } + + // == Entry Point == + + public parse() { + try { + const test = this.parseComplexSelector(); + this.tokenStream.expectEndOfInput(); + return test; + } catch (err: ParsingError | any) { + if (err instanceof ParsingError) { + if (this.deepestError && this.deepestError.location.position > err.location.position) { + throw this.deepestError; + } + } + + throw err; + } + } +} \ No newline at end of file diff --git a/src/matchers/css/tokenize.ts b/src/matchers/css/tokenize.ts new file mode 100644 index 0000000..32e1517 --- /dev/null +++ b/src/matchers/css/tokenize.ts @@ -0,0 +1,104 @@ +import { Token } from "./types"; + +const letterRegex = /[a-zA-Z]/; +const digitRegex = /[0-9]/; +const whitespaceRegex = /\s+/; +const newLineRegex = /\n/; + +/** + * Tokenizer for CSS selectors. Converts a selector string into an array of tokens. + * @param selector The CSS selector string to tokenize. + * @returns An array of tokens representing the selector, including their types and positions. + */ +export const tokenize = (selector: string): Token[] => { + const position = { + line: 1, + column: 1, + } + + return selector.split('').reduce((tokens, char) => { + position.column += 1; + + if (letterRegex.test(char)) { + tokens.push({ type: 'letter', value: char, position: { ...position } }); + } else if (digitRegex.test(char)) { + tokens.push({ type: 'digit', value: char, position: { ...position } }); + } else if (whitespaceRegex.test(char)) { + if (newLineRegex.test(char)) { + position.line += 1; + position.column = 1; + } + + tokens.push({ type: 'whitespace', value: char, position: { ...position } }); + } else { + switch (char) { + case '[': + tokens.push({ type: 'left_bracket', value: char, position: { ...position } }); + break; + case ']': + tokens.push({ type: 'right_bracket', value: char, position: { ...position } }); + break; + case '(': + tokens.push({ type: 'left_paren', value: char, position: { ...position } }); + break; + case ')': + tokens.push({ type: 'right_parent', value: char, position: { ...position } }); + break; + case ':': + tokens.push({ type: 'colon', value: char, position: { ...position } }); + break; + case '.': + tokens.push({ type: 'period', value: char, position: { ...position } }); + break; + case '#': + tokens.push({ type: 'hash', value: char, position: { ...position } }); + break; + case '*': + tokens.push({ type: 'asterisk', value: char, position: { ...position } }); + break; + case '=': + tokens.push({ type: 'equals', value: char, position: { ...position } }); + break; + case '"': + case "'": + tokens.push({ type: 'quote', value: char, position: { ...position } }); + break; + case '~': + tokens.push({ type: 'tilde', value: char, position: { ...position } }); + break; + case '>': + tokens.push({ type: 'left_angle_bracket', value: char, position: { ...position } }); + break; + case '<': + tokens.push({ type: 'right_angle_bracket', value: char, position: { ...position } }); + break; + case '$': + tokens.push({ type: 'dollar', value: char, position: { ...position } }); + break; + case '^': + tokens.push({ type: 'caret', value: char, position: { ...position } }); + break; + case '|': + tokens.push({ type: 'pipe', value: char, position: { ...position } }); + break; + case ',': + tokens.push({ type: 'comma', value: char, position: { ...position } }); + break; + case '-': + tokens.push({ type: 'minus', value: char, position: { ...position } }); + break; + case '+': + tokens.push({ type: 'plus', value: char, position: { ...position } }); + break; + case '_': + tokens.push({ type: 'underscore', value: char, position: { ...position } }); + break; + default: + tokens.push({ type: 'other', value: char, position: { ...position } }); + break; + } + } + + return tokens; + }, []); +} diff --git a/src/matchers/css/types.ts b/src/matchers/css/types.ts new file mode 100644 index 0000000..36c417b --- /dev/null +++ b/src/matchers/css/types.ts @@ -0,0 +1,41 @@ +export type TokenType = 'letter' + | 'whitespace' + | 'digit' + | 'left_bracket' + | 'right_bracket' + | 'left_paren' + | 'right_parent' + | 'colon' + | 'period' + | 'hash' + | 'asterisk' + | 'equals' + | 'quote' + | 'tilde' + | 'left_angle_bracket' + | 'right_angle_bracket' + | 'dollar' + | 'caret' + | 'pipe' + | 'comma' + | 'plus' + | 'minus' + | 'underscore' + | 'other'; + +export type TokenPosition = { + line: number; + column: number; +} + +export type Token = { + value: string; + type: TokenType; + position: TokenPosition; +} + +export type ParsingErrorPosition = { + line?: number; + column?: number; + position: number; +} \ No newline at end of file diff --git a/src/matchers/toHaveCssStyle.ts b/src/matchers/toHaveCssStyle.ts index 29c2a9b..30b3f7d 100644 --- a/src/matchers/toHaveCssStyle.ts +++ b/src/matchers/toHaveCssStyle.ts @@ -1,6 +1,8 @@ import type { MatcherFunction } from 'expect'; import { CSSModuleSnapshotsContext } from './context'; +import { SpecificityCalculator } from './css/SpecificityCalculator'; +import { StylesheetRule } from './context/Stylesheet'; type UnmatchedProperties = { property: string; @@ -42,7 +44,22 @@ export const toHaveCssStyle: MatcherFunction<[expectedStyles: Record actual.matches(rule.selectors)); + styleRules.forEach((rule) => { + rule.selectorParts.forEach((part) => { + const specificityCalculator = new SpecificityCalculator(part); + specificityCalculator.calculate(); + }) + }); + + const matchingStyleRules = styleRules.reduce((rules, rule) => { + const matchingParts = rule.selectorParts.filter((part) => actual.matches(part)); + + rules.push(...matchingParts.map((part) => ({ + part, + rule + }))); + return rules; + }, [] as { part: string; rule: StylesheetRule }[]); const unmatchedProperties: { property: string; value: string | number }[] = []; @@ -50,7 +67,7 @@ export const toHaveCssStyle: MatcherFunction<[expectedStyles: Record { - const isMatched = matchingStyleRules.some((rule) => { + const isMatched = matchingStyleRules.some(({ part, rule }) => { // Get only property declarations (ignore comments, etc). const propertyName = camelCaseToKebabCase(property); return rule.declarations[propertyName] && rule.declarations[propertyName] === value.toString(); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..3cf32b0 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "noEmit": true + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +}