From 2fefc083022e8a225efbbe2fe33cf2f0febb0d73 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Tue, 18 Jan 2022 11:34:48 +0200 Subject: [PATCH] feat: wip on matcher --- .../src/ast-tools/matcher-iterator.ts | 76 +++ .../css-value-parser/src/ast-tools/matcher.ts | 447 ++++++++++++++++++ packages/css-value-parser/src/index.ts | 2 + .../src/value-syntax-parser.ts | 33 +- .../test/ast-tools/matcher.spec.ts | 389 +++++++++++++++ 5 files changed, 933 insertions(+), 14 deletions(-) create mode 100644 packages/css-value-parser/src/ast-tools/matcher-iterator.ts create mode 100644 packages/css-value-parser/src/ast-tools/matcher.ts create mode 100644 packages/css-value-parser/test/ast-tools/matcher.spec.ts diff --git a/packages/css-value-parser/src/ast-tools/matcher-iterator.ts b/packages/css-value-parser/src/ast-tools/matcher-iterator.ts new file mode 100644 index 00000000..7cb63cf6 --- /dev/null +++ b/packages/css-value-parser/src/ast-tools/matcher-iterator.ts @@ -0,0 +1,76 @@ +export type CombinationGenerator = Generator< + CombinationResult, + CombinationResult, + boolean | undefined +>; +export type CombinationResult = { + combination: T[]; + last: T; + takeBack: T[]; +}; +export function* combinationIterator(allOptions: T[]): CombinationGenerator { + const stack: { options: T[]; start: T[]; index: number }[] = [ + { options: [...allOptions], start: [], index: 0 }, + ]; + const prevCombinations: T[] = []; + while (true) { + const context = stack[stack.length - 1]!; + const { options, start, index } = context; + // add results + const added = options[index]; + const combination = start.concat(added); + const result = { + combination: combination, + last: added, + takeBack: + combination.length >= prevCombinations.length + ? [] + : prevCombinations.slice(prevCombinations.length - combination.length), + }; + prevCombinations.length = 0; + prevCombinations.push(...combination); + // progress position + if (context.index < options.length - 1) { + context.index++; + } else { + stack.pop(); + } + // add sub options to context + if (options.length > 1) { + stack.push({ + options: [...options.slice(0, index), ...options.slice(index + 1)], + start: combination, + index: 0, + }); + } + const stopStack = yield result; + if (stopStack === false) { + stack.pop(); + yield result; + } + if (!stack.length) { + return result; + } + } +} + +// const ABC = combinationIterator(["A", "B", "C"]); +// for (const option of ABC) { +// console.log(option.combination.join(" ")); +// if (option.combination[1] === "B") { +// ABC.next(false); +// } +// } + +// const iterator = combinationIterator(["A", "B", "C", "D", "E", "F", "G", "H"]); +// let i = 0; +// // eslint-disable-next-line no-constant-condition +// while (true) { +// const { value, done } = iterator.next(); +// console.log(value.combination.join(" ")); +// if (done) { +// break; +// } +// i++ +// } +// console.log(i); diff --git a/packages/css-value-parser/src/ast-tools/matcher.ts b/packages/css-value-parser/src/ast-tools/matcher.ts new file mode 100644 index 00000000..d4de329f --- /dev/null +++ b/packages/css-value-parser/src/ast-tools/matcher.ts @@ -0,0 +1,447 @@ +import type { BaseAstNode } from '../ast-types'; +import type { + ValueSyntaxAstNode, + DataTypeNode, + KeywordNode, + Combinators, + JuxtaposingNode, + BarNode, + DoubleBarNode, +} from '../value-syntax-parser'; +import { combinationIterator, CombinationGenerator } from './matcher-iterator'; + +interface MatchOptions { + type: `valid` | `ambiguous`; + customIdentExclude: string[]; + composedSyntax: Record; +} +interface Match { + syntax: ValueSyntaxAstNode; + value: BaseAstNode[]; +} +interface MatchResult { + isValid: boolean; + errors: any[]; + matches: Match[]; +} +const defaultOptions: MatchOptions = { + type: `valid`, + customIdentExclude: [], + composedSyntax: {}, +}; +export function match( + value: BaseAstNode[], + syntax: ValueSyntaxAstNode, + options: Partial = {} +): MatchResult { + const _options: MatchOptions = { ...defaultOptions, ...options }; + const matcher = createMatcher(value, syntax); + let isSearching = true; + while (isSearching) { + matcher.forward(); + const { isValid } = matcher.test(_options); + const valueMatchedSize = matcher.matchedSize(); + if (matcher.isExhausted()) { + // matcher run through all combinations + isSearching = false; + if (valueMatchedSize < value.length - 1) { + if (isValid) { + // more value to the syntax: report value overflow + matcher.result.isValid = false; + matcher.result.errors.length = 0; + matcher.result.errors.push({ type: `valueOverflow` }); + matcher.result.matches.length = 0; + } + } + } else { + // matcher has more combinations + if (isValid) { + if (valueMatchedSize === value.length) { + // found match! + isSearching = false; + // ToDo: handle claims + } + } else { + // ToDo: end-of-input case + } + } + } + + return matcher.result; +} +/*** matchers ***/ +class BaseMatcher< + PROGRESS extends Record = any, + SYNTAX extends ValueSyntaxAstNode = any +> { + public isMatched = false; + public valueRange: [start: number, end: number] = [0, 0]; + public progress = {} as PROGRESS; + public result = defaultMatchResult(); + constructor(public value: BaseAstNode[], public syntax: SYNTAX) { + this.init(); + } + public init() { + /**/ + } + public forward() { + /**/ + } + public trackBack() { + // + } + public setValueStartIndex(valueStartIndex: number) { + // maybe reset? + this.valueRange[0] = valueStartIndex; + this.valueRange[1] = valueStartIndex; + } + public test(_options: MatchOptions): MatchResult { + this.isMatched = true; + // skip spaces + let valueStartIndex = this.valueRange[1]; + while (this.value[valueStartIndex].type === `space`) { + valueStartIndex++; + } + this.valueRange[1] = valueStartIndex; + return this.result; + } + public isExhausted(): boolean { + // stop if not override + return true; + } + public matchedSize() { + return this.valueRange[1] - this.valueRange[0]; + } + // + protected addMatch(syntax: ValueSyntaxAstNode, value: BaseAstNode[]) { + this.result.matches.push({ syntax, value }); + } + protected appendMatch(syntax: ValueSyntaxAstNode, value: BaseAstNode[]) { + const { matches } = this.result; + const lastMatch = matches[matches.length - 1]; + if (lastMatch && lastMatch.syntax === syntax) { + lastMatch.value.push(...value); + } else { + matches.push({ syntax, value }); + } + } +} + +class SingleMatcher extends BaseMatcher< + { index: number; isDone: boolean }, + SYNTAX +> { + init() { + this.progress.index = 0; + this.progress.isDone = false; + } + isExhausted() { + return this.isMatched; + } +} +class DataTypeMatcher extends SingleMatcher { + // isDone() { + // if (this.progress.isDone) { + // return true; + // } + // const { multipliers } = this.syntax; + // if (multipliers?.range) { + // // const [min, max] = multipliers.range; + // return false; + // } + // return super.isDone(); + // } + test(options: MatchOptions) { + super.test(options); + const syntax = this.syntax; + const valueStartIndex = this.valueRange[1]; + const valueNode = this.value[valueStartIndex]; + const acceptableValue = isAcceptedAsType(valueNode.type, syntax.name); + if (acceptableValue) { + this.valueRange[1] = valueStartIndex + 1; + this.result.isValid = true; + // this.result.matches.push({ syntax, value: [valueNode] }); + this.appendMatch(syntax, [valueNode]); + } else { + if (this.result.matches.length) { + this.progress.isDone = true; + } else { + this.progress.isDone = true; + this.result.errors.length = 0; + this.result.errors.push({ type: `mismatch`, syntax }); + } + } + return this.result; + } +} +class KeywordMatcher extends SingleMatcher { + test(options: MatchOptions) { + super.test(options); + const syntax = this.syntax; + const valueStartIndex = this.valueRange[1]; + const valueNode = this.value[valueStartIndex]; + if ( + valueNode.type === `` && + valueNode.value.toLowerCase() === syntax.name.toLowerCase() + ) { + this.valueRange[1] = valueStartIndex + 1; + this.result.isValid = true; + this.result.matches.push({ syntax, value: [valueNode] }); + } else { + this.result.errors.length = 0; + this.result.errors.push({ type: `mismatch`, syntax }); + } + return this.result; + } +} + +class MultiMatcher< + SYNTAX extends Combinators, + PROGRESS extends Record +> extends BaseMatcher { + protected matchers!: BaseMatcher[]; + init() { + this.matchers = this.syntax.nodes.map((subSyntax) => { + return createMatcher(this.value, subSyntax); + }); + } +} +class JuxtaposedMatcher extends MultiMatcher { + init() { + super.init(); + this.progress.index = 0; + } + forward() { + this.progress.index = 0; + for (const matcher of this.matchers) { + if (!matcher.isExhausted()) { + matcher.forward(); + break; + } + this.progress.index++; + } + } + test(options: MatchOptions) { + super.test(options); + const syntax = this.syntax; + const valueStartIndex = this.valueRange[1]; + const progress = this.progress; + this.result.errors.length = 0; + const subIndex = progress.index; + const isLast = subIndex === this.matchers.length - 1; + const subMatcher = this.matchers[subIndex]; + subMatcher.setValueStartIndex(valueStartIndex); + subMatcher.test(options); + const subMatch = subMatcher.result; + if (subMatch.isValid) { + this.valueRange[1] = subMatcher.valueRange[1]; + this.result.matches.push( + ...subMatch.matches.map(({ value, syntax }) => ({ value, syntax })) + ); + if (isLast) { + this.result.isValid = true; + } + } else { + // collect errors + this.result.errors.push({ + type: `mismatch`, + syntax, + }); + } + return this.result; + } + isExhausted() { + const index = this.progress.index; + const isInBounds = index < this.syntax.nodes.length; + if (!isInBounds) { + // out of bound error + return true; + } + const isInLast = index === this.syntax.nodes.length - 1; + const currentMatcher = this.matchers[index]; + const isDone = currentMatcher.isExhausted(); + const isValid = currentMatcher.result.isValid; + if (isDone && !isValid) { + return true; + } + return isInLast && isDone; + } +} +class OneOfMatcher extends MultiMatcher { + init() { + super.init(); + this.progress.index = 0; + } + forward() { + const prevIndex = this.progress.index; + this.progress.index = 0; + for (const matcher of this.matchers) { + if (!matcher.isExhausted()) { + matcher.forward(); + break; + } + this.progress.index++; + } + if (prevIndex !== this.progress.index) { + this.valueRange[1] = this.valueRange[0]; + } + } + test(options: MatchOptions) { + super.test(options); + const syntax = this.syntax; + const valueStartIndex = this.valueRange[1]; + const progress = this.progress; + + const subIndex = progress.index; + const subMatcher = this.matchers[subIndex]; + subMatcher.test(options); + const subMatch = subMatcher.result; + this.result.errors.length = 0; + this.result.matches.length = 0; + if (subMatch.isValid) { + this.valueRange[1] = valueStartIndex + subMatcher.matchedSize(); + this.result.isValid = true; + this.result.matches.push( + ...subMatch.matches.map(({ value, syntax }) => ({ value, syntax })) + ); + } else { + this.result.isValid = false; + this.valueRange[1] = valueStartIndex; + } + if (subIndex >= this.matchers.length - 1) { + if (!this.result.isValid) { + // should filter valid sub syntax(s) + this.result.errors.push({ + type: `mismatch`, + syntax: syntax, + options: syntax.nodes, + }); + } + } + return this.result; + } + isExhausted() { + const index = this.progress.index; + const isInBounds = index < this.syntax.nodes.length; + if (!isInBounds) { + // out of bound error + return true; + } + const isInLast = index === this.syntax.nodes.length - 1; + if (isInLast) { + const lastMatcher = this.matchers[index]; + return lastMatcher.isExhausted(); + } + return false; + } +} + +class AnyOfMatcher extends MultiMatcher< + DoubleBarNode, + { + iterator: CombinationGenerator; + current: BaseMatcher | null; + done: boolean; + } +> { + init() { + super.init(); + this.progress.iterator = combinationIterator(this.matchers); + this.progress.current = null; + this.progress.done = false; + } + forward() { + const { current, iterator } = this.progress; + if (current?.isExhausted() === false) { + current.forward(); + } else { + // + const { value, done } = iterator.next(); + this.progress.current = value.last; + this.progress.done = done === true; + if (value.takeBack.length) { + // reset matches + const matches = this.result.matches; + matches.length -= value.takeBack.length; + // reset value position + this.valueRange[1] = matches.length + ? matches[matches.length - 1].value[0].start + : this.valueRange[0]; + // reset matchers + for (const matcher of value.takeBack) { + matcher.init(); // maybe there should be reset? + } + } + } + } + test(options: MatchOptions) { + super.test(options); + const progress = this.progress; + + // const subIndex = progress.index; + const subMatcher = progress.current!; //this.matchers[subIndex]; + subMatcher.setValueStartIndex(this.valueRange[1]); + subMatcher.test(options); + const subMatch = subMatcher.result; + // match.errors.length = 0; + if (subMatch.isValid) { + this.valueRange[1] = subMatcher.valueRange[1]; + this.result.isValid = true; + this.result.matches.push( + ...subMatch.matches.map(({ value, syntax }) => ({ value, syntax })) + ); + } else { + // stop any nested iterations + progress.iterator.next(false); + } + // ToDo: maybe move to forward() + // if (subIndex >= this.matchers.length - 1) { + // // state.isDone = true; + // if (!this.result.isValid) { + // // should filter valid sub syntax(s) + // this.result.errors.push({ + // type: `mismatch`, + // syntax: syntax, + // options: syntax.nodes, + // }); + // } + // } + return this.result; + } + isExhausted() { + return this.progress.done; + } +} + +const matcherMap: Record = { + 'data-type': DataTypeMatcher as any, + keyword: KeywordMatcher as any, + juxtaposing: JuxtaposedMatcher as any, + '|': OneOfMatcher as any, + '||': AnyOfMatcher as any, +} as any; // ToDo: finish matchers and fix types +function createMatcher(value: BaseAstNode[], syntax: ValueSyntaxAstNode) { + if (!matcherMap[syntax.type]) { + // ToDo: report error + } + return new matcherMap[syntax.type](value, syntax); +} +function defaultMatchResult() { + return { + isValid: false, + errors: [], + matches: [], + } as MatchResult; +} +function isAcceptedAsType(valueType: BaseAstNode[`type`], syntaxType: string) { + if (valueType === `<${syntaxType}>`) { + return true; + } else if ( + (syntaxType === `number` && valueType === ``) || + (syntaxType === `custom-ident` && valueType === ``) + ) { + // base inclusion + return true; + } + return false; +} diff --git a/packages/css-value-parser/src/index.ts b/packages/css-value-parser/src/index.ts index acf3b729..7acaac65 100644 --- a/packages/css-value-parser/src/index.ts +++ b/packages/css-value-parser/src/index.ts @@ -7,3 +7,5 @@ export { background } from './properties/background'; export { margin } from './properties/margin'; // syntax export { parseValueSyntax } from './value-syntax-parser'; +// tools +export { match } from './ast-tools/matcher'; diff --git a/packages/css-value-parser/src/value-syntax-parser.ts b/packages/css-value-parser/src/value-syntax-parser.ts index af40e88f..01f24c0f 100644 --- a/packages/css-value-parser/src/value-syntax-parser.ts +++ b/packages/css-value-parser/src/value-syntax-parser.ts @@ -63,68 +63,73 @@ export function parseValueSyntax(source: string) { ); } -type Range = [min: number, max: number]; +export type Range = [min: number, max: number]; -interface Multipliers { +export interface Multipliers { range?: Range; list?: boolean; } -interface DataTypeNode { +export interface DataTypeNode { type: 'data-type'; name: string; range?: Range; multipliers?: Multipliers; } -interface PropertyNode { +export interface PropertyNode { type: 'property'; name: string; range?: Range; multipliers?: Multipliers; } -interface LiteralNode { +export interface LiteralNode { type: 'literal'; name: string; enclosed: boolean; multipliers?: Multipliers; } -interface KeywordNode { +export interface KeywordNode { type: 'keyword'; name: string; multipliers?: Multipliers; } -interface CombinatorGroup { +export interface CombinatorGroup { nodes: ValueSyntaxAstNode[]; } -interface JuxtaposingNode extends CombinatorGroup { +export interface JuxtaposingNode extends CombinatorGroup { type: 'juxtaposing'; } -interface DoubleAmpersandNode extends CombinatorGroup { +export interface DoubleAmpersandNode extends CombinatorGroup { type: '&&'; } -interface DoubleBarNode extends CombinatorGroup { +export interface DoubleBarNode extends CombinatorGroup { type: '||'; } -interface BarNode extends CombinatorGroup { +export interface BarNode extends CombinatorGroup { type: '|'; } -interface GroupNode extends CombinatorGroup { +export interface GroupNode extends CombinatorGroup { type: 'group'; multipliers?: Multipliers; } -type Combinators = GroupNode | JuxtaposingNode | DoubleAmpersandNode | DoubleBarNode | BarNode; +export type Combinators = + | GroupNode + | JuxtaposingNode + | DoubleAmpersandNode + | DoubleBarNode + | BarNode; -type Components = DataTypeNode | PropertyNode; +export type Components = DataTypeNode | PropertyNode; export type ValueSyntaxAstNode = Components | KeywordNode | LiteralNode | Combinators; export function literal(name: string, enclosed = false, multipliers?: Multipliers): LiteralNode { diff --git a/packages/css-value-parser/test/ast-tools/matcher.spec.ts b/packages/css-value-parser/test/ast-tools/matcher.spec.ts new file mode 100644 index 00000000..41522ebd --- /dev/null +++ b/packages/css-value-parser/test/ast-tools/matcher.spec.ts @@ -0,0 +1,389 @@ +import { parseCSSValue, parseValueSyntax, match } from '@tokey/css-value-parser'; +import type { Combinators } from '@tokey/css-value-parser/dist/value-syntax-parser'; +import { expect } from 'chai'; + +describe(`ast-tools/matcher`, () => { + describe(`data-type`, () => { + it(`should match type`, () => { + const value = parseCSSValue(`5`); + const syntax = parseValueSyntax(``); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntax, // + value: [value[0]], // 5 + }, + ]); + }); + it(`should deny mismatched type`, () => { + const value = parseCSSValue(`5`); + const syntax = parseValueSyntax(``); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `invalid`).to.equal(false); + expect(errors, `errors`).to.eql([{ type: `mismatch`, syntax }]); + expect(matches, `matches`).to.eql([]); + }); + describe(`valid as`, () => { + [ + // base + { + type: ` as `, + value: `5`, + syntax: ``, + }, + { + type: ` as `, + value: `--a`, + syntax: ``, + }, + ].forEach(({ type, value, syntax }) => { + it(`should match ${type}`, () => { + const valueAst = parseCSSValue(value); + const syntaxAst = parseValueSyntax(syntax); + const { isValid, errors, matches } = match(valueAst, syntaxAst); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntaxAst, + value: [valueAst[0]], + }, + ]); + }); + }); + }); + }); + describe(`keyword`, () => { + it(`should match keyword`, () => { + const value = parseCSSValue(`disc`); + const syntax = parseValueSyntax(`disc`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntax, // disc + value: [value[0]], // disc + }, + ]); + }); + it(`should match case-insensitively`, () => { + const value = parseCSSValue(`DiSc`); + const syntax = parseValueSyntax(`disc`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntax, // disc + value: [value[0]], // DiSc + }, + ]); + }); + it(`should deny mismatched keyword`, () => { + const value = parseCSSValue(`abc`); + const syntax = parseValueSyntax(`xyz`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `invalid`).to.equal(false); + expect(errors, `errors`).to.eql([{ type: `mismatch`, syntax }]); + expect(matches, `matches`).to.eql([]); + }); + it(`should deny quoted keyword (string)`, () => { + const value = parseCSSValue(`"abc"`); + const syntax = parseValueSyntax(`abc`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `invalid`).to.equal(false); + expect(errors, `errors`).to.eql([{ type: `mismatch`, syntax }]); + expect(matches, `matches`).to.eql([]); + }); + }); + describe(`multipliers`, () => { + describe(`one or more (+)`, () => { + it(`should match one`, () => { + const value = parseCSSValue(`5`); + const syntax = parseValueSyntax(`+`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntax, // + + value: [value[0]], // 5 + }, + ]); + }); + it.skip(`should match many`, () => { + const value = parseCSSValue(`5 6`); + const syntax = parseValueSyntax(`+`); + + const { isValid, errors, matches } = match(value, syntax); + + expect(isValid, `valid`).to.equal(true); + expect(errors, `errors`).to.eql([]); + expect(matches, `matches`).to.eql([ + { + syntax: syntax, // + + value: [value[0], value[2]], // 5 6 + }, + ]); + }); + it.skip(`should match type after many`, () => { + const value = parseCSSValue(`5 6 7s`); + const syntax = parseValueSyntax(`+