// Grammar rules and recursive-descent AST construction for the Imhotep DSL // Builds unist-style AST nodes with source positions import type { Point, Position, Program, FrameBlock, AcrossBlock, AcrossAxis, AlwaysBlock, WhenBlock, GuardExpression, RelationAssertion, TopologyAssertion, SizeAssertion, CompoundAssertion, QuantifierNode, SelectorRef, FrameRef, StateSelector, RelationOptions, TopologyOptions, SizeBounds, LiteralNode, RangeLiteralNode, ToleranceLiteralNode, } from 'imhotep-core' import { isPredicateName, collectSpatialPredicateNames } from 'imhotep-core' import type { Token } from './lexer.js' // --------------------------------------------------------------------------- // Extended TokenKind for grammar checks // --------------------------------------------------------------------------- type GrammarTokenKind = Token['kind'] // --------------------------------------------------------------------------- // FOL AST Node Types (temporary local definitions until Stream 1 lands) // --------------------------------------------------------------------------- export type FormulaNode = | ForAllFormula | ExistsFormula | AndFormula | OrFormula | NotFormula | ImpliesFormula | PredicateCall | VariableRef | DomainRef export interface ForAllFormula { type: 'ForAll' variable: VariableRef domain: DomainRef body: FormulaNode position?: Position } export interface ExistsFormula { type: 'Exists' variable: VariableRef domain: DomainRef body: FormulaNode position?: Position } export interface AndFormula { type: 'And' left: FormulaNode right: FormulaNode position?: Position } export interface OrFormula { type: 'Or' left: FormulaNode right: FormulaNode position?: Position } export interface NotFormula { type: 'Not' operand: FormulaNode position?: Position } export interface ImpliesFormula { type: 'Implies' left: FormulaNode right: FormulaNode position?: Position } export interface PredicateCall { type: 'PredicateCall' name: string args: (VariableRef | string | number)[] /** Comparison operator for size predicates: width($x) >= 44 */ operator?: string /** Right-hand side of comparison operator */ right?: number | string position?: Position } export interface VariableRef { type: 'VariableRef' name: string position?: Position } export interface DomainRef { type: 'DomainRef' kind: string selector?: string extraArg?: string /** Variable reference used as selector (e.g. descendants($card, '.title')) */ variableSelector?: VariableRef /** Variable reference used as extra argument */ variableExtraArg?: VariableRef position?: Position } // --------------------------------------------------------------------------- // Property-Run AST Node Types // --------------------------------------------------------------------------- export interface PropertyRunBlock { type: 'PropertyRunBlock' mode: 'forAllProps' | 'exhaustivelyForAllProps' component?: string renderer?: string props?: unknown body: any[] position?: Position } // --------------------------------------------------------------------------- // Parse Error // --------------------------------------------------------------------------- export class ParseError extends Error { position: Position code: string constructor(message: string, position: Position, code: string = 'IMH_PARSE_ERROR') { super(message) this.name = 'ParseError' this.position = position this.code = code } } // --------------------------------------------------------------------------- // Position Helpers // --------------------------------------------------------------------------- function makePosition(start: Point, end: Point): Position { return { start, end } } // --------------------------------------------------------------------------- // AST Node Helpers // --------------------------------------------------------------------------- function makeSelectorRef(value: string, position: Position): SelectorRef { return { type: 'SelectorRef', value, position } } function makeFrameRef(kind: string, selector?: string, name?: string, position?: Position): FrameRef { const ref: FrameRef = { type: 'FrameRef', kind } if (selector !== undefined) ref.selector = selector if (name !== undefined) ref.name = name if (position !== undefined) ref.position = position return ref } function makeLiteral(kind: LiteralNode['kind'], value: unknown, unit?: string, position?: Position): LiteralNode { const lit: LiteralNode = { type: 'Literal', kind, value } if (unit !== undefined) lit.unit = unit if (position !== undefined) lit.position = position return lit } function makeToleranceLiteral(value: number, unit: 'jnd' | 'px', position?: Position): ToleranceLiteralNode { const lit: ToleranceLiteralNode = { type: 'ToleranceLiteral', value, unit } if (position !== undefined) lit.position = position return lit } function makeRangeLiteral(min: LiteralNode, max: LiteralNode, position?: Position): RangeLiteralNode { const lit: RangeLiteralNode = { type: 'RangeLiteral', min, max } if (position !== undefined) lit.position = position return lit } function makeSizeBounds(min?: LiteralNode, max?: LiteralNode, exact?: LiteralNode, position?: Position): SizeBounds { const bounds: SizeBounds = { type: 'SizeBounds' } if (min !== undefined) bounds.min = min as any if (max !== undefined) bounds.max = max as any if (exact !== undefined) bounds.exact = exact as any if (position !== undefined) bounds.position = position return bounds } function makeRelationOptions(position?: Position): RelationOptions { const opts: RelationOptions = { type: 'RelationOptions' } if (position !== undefined) opts.position = position return opts } function makeTopologyOptions(tolerance?: LiteralNode | ToleranceLiteralNode, position?: Position): TopologyOptions { const opts: TopologyOptions = { type: 'TopologyOptions' } if (tolerance !== undefined) opts.tolerance = tolerance as any if (position !== undefined) opts.position = position return opts } function makeQuantifier(kind: string, position?: Position): QuantifierNode { const q: QuantifierNode = { type: 'Quantifier', kind } if (position !== undefined) q.position = position return q } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- export class GrammarParser { private tokens: Token[] private current: number = 0 constructor(tokens: Token[]) { this.tokens = tokens } parse(): any { const start = this.currentToken().start const blocks = this.parseBlocks() const end = this.previousToken().end return { type: 'Program', children: blocks, position: makePosition(start, end), } } // ------------------------------------------------------------------------- // Block-Level Parsing // ------------------------------------------------------------------------- private parseBlocks(): any[] { const blocks: any[] = [] while (!this.isAtEnd()) { if (this.match('newline')) { continue } if (this.check('indent')) { this.advance() // consume unexpected indent continue } if (this.check('dedent')) { this.advance() // consume dedent continue } if (this.check('eof')) { break } const block = this.parseBlock() if (block) { blocks.push(block) } } return blocks } private parseBlock(): any | null { if (this.check('in')) { return this.parseFrameBlock() } if (this.check('across')) { return this.parseAcrossBlock() } if (this.check('always')) { return this.parseAlwaysBlock() } if (this.check('when')) { return this.parseWhenBlock() } // FOL quantifiers if (this.check('forall')) { return this.parseForAllFormula() } if (this.check('exists')) { return this.parseExistsFormula() } // Property-run blocks if (this.check('identifier') && this.isPropertyRunKeyword()) { return this.parsePropertyRunBlock() } // Top-level assertion without block wrapper if (this.check('string') || this.check('identifier')) { return this.parseAssertion() } // Quantifier before assertion if (this.check('all') || this.check('any') || this.check('none')) { return this.parseAssertion() } this.advance() // skip unknown token return null } private isPropertyRunKeyword(): boolean { const val = this.currentToken().value return val === 'forAllProps' || val === 'exhaustivelyForAllProps' || val === 'component' } private parseFrameBlock(): FrameBlock { const start = this.consume('in').start const frame = this.parseFrameRef() this.consume('colon') this.consumeNewlineOrIndent() const assertions = this.parseBlockAssertions() const end = this.previousToken().end return { type: 'FrameBlock', frame, children: assertions, position: makePosition(start, end), } } private parseAcrossBlock(): AcrossBlock { const start = this.consume('across').start const axes: AcrossAxis[] = [] do { axes.push(this.parseEnvAxis()) } while (this.check('identifier')) this.consume('colon') this.consumeNewlineOrIndent() const children: AcrossBlock['children'] = [] // Parse always/when/frame blocks within across while (!this.isAtEnd() && !this.check('dedent') && !this.check('eof')) { if (this.match('newline')) continue if (this.check('always')) { children.push(this.parseAlwaysBlock()) } else if (this.check('when')) { children.push(this.parseWhenBlock()) } else if (this.check('in')) { children.push(this.parseFrameBlock()) } else { // Assertion at across level const assertion = this.parseAssertion() if (assertion) children.push(assertion as any) } } const end = this.previousToken().end return { type: 'AcrossBlock', axes, children, position: makePosition(start, end), } } private parseAlwaysBlock(): AlwaysBlock { const start = this.consume('always').start this.consume('colon') this.consumeNewlineOrIndent() const assertions = this.parseBlockAssertions() const end = this.previousToken().end return { type: 'AlwaysBlock', children: assertions, position: makePosition(start, end), } } // ------------------------------------------------------------------------- // FOL Formula Parsing // ------------------------------------------------------------------------- private parseForAllFormula(): FormulaNode { const start = this.consume('forall').start const variables = this.parseQuantifierVariableList() this.consume('in') const domain = this.parseDomainRef() this.consume('colon') this.consumeNewlineOrIndent() const body = this.parseFormula() const end = this.previousToken().end return this.wrapQuantifierVariables('ForAll', variables, domain, body, start, end) } private parseExistsFormula(): FormulaNode { const start = this.consume('exists').start const variables = this.parseQuantifierVariableList() this.consume('in') const domain = this.parseDomainRef() this.consume('colon') this.consumeNewlineOrIndent() const body = this.parseFormula() const end = this.previousToken().end return this.wrapQuantifierVariables('Exists', variables, domain, body, start, end) } private parseQuantifierVariableList(): VariableRef[] { this.consume('dollar') const variables: VariableRef[] = [{ type: 'VariableRef', name: this.consume('identifier').value }] while (this.match('comma')) { this.consume('dollar') variables.push({ type: 'VariableRef', name: this.consume('identifier').value }) } return variables } private wrapQuantifierVariables( quantifierType: 'ForAll' | 'Exists', variables: VariableRef[], domain: DomainRef, body: FormulaNode, start: Point, end: Point, ): FormulaNode { let nested: FormulaNode = body for (let i = variables.length - 1; i >= 0; i--) { nested = { type: quantifierType, variable: variables[i], domain, body: nested, position: makePosition(start, end), } as ForAllFormula | ExistsFormula } return nested } private parseDomainRef(): DomainRef { const start = this.currentToken().start const kind = this.consume('identifier').value let selector: string | undefined let extraArg: string | undefined let variableSelector: VariableRef | undefined let variableExtraArg: VariableRef | undefined if (this.check('lparen')) { this.advance() // First argument: can be a string selector or a variable reference if (this.check('dollar')) { this.advance() const varName = this.consume('identifier').value variableSelector = { type: 'VariableRef', name: varName } } else { selector = this.consume('string').value } if (this.check('comma')) { this.advance() // Second argument: can also be a string or variable reference if (this.check('dollar')) { this.advance() const varName = this.consume('identifier').value variableExtraArg = { type: 'VariableRef', name: varName } } else { extraArg = this.consume('string').value } } this.consume('rparen') } const end = this.previousToken().end return { type: 'DomainRef', kind, selector, extraArg, variableSelector, variableExtraArg, position: makePosition(start, end), } } private parseFormula(): FormulaNode { return this.parseImplies() } private parseImplies(): FormulaNode { let left = this.parseOr() while (this.match('implies')) { const right = this.parseOr() left = { type: 'Implies', left, right, } } return left } private parseOr(): FormulaNode { let left = this.parseAnd() while (this.match('or')) { const right = this.parseAnd() left = { type: 'Or', left, right, } } return left } private parseAnd(): FormulaNode { let left = this.parseNot() while (this.match('and')) { const right = this.parseNot() left = { type: 'And', left, right, } } return left } private parseNot(): FormulaNode { if (this.match('not')) { const operand = this.parseNot() return { type: 'Not', operand, } } return this.parsePredicateOrAtomic() } private parsePredicateOrAtomic(): FormulaNode { // Quantifiers can appear as primary formulas if (this.check('forall')) { return this.parseForAllFormula() } if (this.check('exists')) { return this.parseExistsFormula() } // Predicate call: predicateName(arg1, arg2, ...) // Accept both identifiers and keywords (relations like leftOf, above, etc.) if (this.check('identifier') || this.isKeywordThatCanBePredicate()) { const start = this.currentToken().start const name = this.advance().value if (this.check('lparen')) { this.advance() const args: (VariableRef | string | number)[] = [] if (!this.check('rparen')) { do { if (this.check('dollar')) { this.advance() const varName = this.consume('identifier').value args.push({ type: 'VariableRef', name: varName }) } else if (this.check('string')) { args.push(this.advance().value) } else if (this.check('number')) { args.push(parseFloat(this.advance().value)) } else if (this.check('identifier') || this.isKeywordThatCanBePredicate()) { args.push(this.advance().value) } else { throw this.error('Expected predicate argument') } } while (this.match('comma')) } this.consume('rparen') let end = this.previousToken().end // Size predicate with comparison operator: width($x) >= 44 let operator: string | undefined let right: number | string | undefined if (this.check('ge') || this.check('le') || this.check('eq') || this.check('ne') || this.check('gt') || this.check('lt')) { operator = this.advance().value if (this.check('number')) { right = parseFloat(this.advance().value) end = this.previousToken().end } else if (this.check('string')) { right = this.advance().value end = this.previousToken().end } else { throw this.error('Expected number or string after comparison operator') } } return { type: 'PredicateCall', name, args, operator, right, position: makePosition(start, end), } } // Just a boolean atom by name const end = this.previousToken().end return { type: 'PredicateCall', name, args: [], position: makePosition(start, end), } } // Parenthesized formula if (this.check('lparen')) { this.advance() const formula = this.parseFormula() this.consume('rparen') return formula } throw this.error('Expected predicate call or formula') } private isKeywordThatCanBePredicate(): boolean { const kind = this.currentToken().kind // 'size' is a fluent API keyword for size assertions in FOL bodies; // all other predicate names derive from the spec table. return isPredicateName(kind) || kind === 'size' } // ------------------------------------------------------------------------- // Property-Run Block Parsing // ------------------------------------------------------------------------- private parsePropertyRunBlock(): PropertyRunBlock { const start = this.currentToken().start const mode = this.advance().value as 'forAllProps' | 'exhaustivelyForAllProps' let component: string | undefined let renderer: string | undefined let props: unknown | undefined if (this.check('lparen')) { this.advance() // Parse simple key-value pairs for component/renderer/props while (!this.check('rparen') && !this.isAtEnd()) { if (this.check('identifier')) { const key = this.advance().value if (this.check('colon')) this.advance() if (key === 'component') { component = this.consume('identifier').value } else if (key === 'renderer') { renderer = this.consume('identifier').value } else { // Skip unknown keys this.advance() } } else { this.advance() } } this.consume('rparen') } this.consume('colon') this.consumeNewlineOrIndent() const body = this.parseBlockAssertions() const end = this.previousToken().end return { type: 'PropertyRunBlock', mode, component, renderer, props, body: body as any[], position: makePosition(start, end), } } private parseWhenBlock(): WhenBlock { const start = this.consume('when').start const guard = this.parsePredicate() this.consume('colon') this.consumeNewlineOrIndent() const assertions = this.parseBlockAssertions() const end = this.previousToken().end return { type: 'WhenBlock', guard, children: assertions, position: makePosition(start, end), } } private parseBlockAssertions(): any[] { const assertions: any[] = [] while (!this.isAtEnd() && !this.check('dedent') && !this.check('eof')) { if (this.match('newline')) continue if (this.check('forall')) { assertions.push(this.parseForAllFormula()) continue } if (this.check('exists')) { assertions.push(this.parseExistsFormula()) continue } const assertion = this.parseAssertion() if (assertion) { assertions.push(assertion) } } return assertions } // ------------------------------------------------------------------------- // Frame Reference // ------------------------------------------------------------------------- private parseFrameRef(): FrameRef { const start = this.currentToken().start if (this.match('viewport')) { const end = this.previousToken().end return makeFrameRef('viewport', undefined, undefined, makePosition(start, end)) } if (this.match('document')) { const end = this.previousToken().end return makeFrameRef('document', undefined, undefined, makePosition(start, end)) } if (this.check('nearestPositionedAncestor')) { this.advance() this.consume('lparen') const selector = this.consume('string').value this.consume('rparen') const end = this.previousToken().end return makeFrameRef('nearestPositionedAncestor', selector, undefined, makePosition(start, end)) } if (this.check('containingBlock')) { this.advance() this.consume('lparen') const selector = this.consume('string').value this.consume('rparen') const end = this.previousToken().end return makeFrameRef('containingBlock', selector, undefined, makePosition(start, end)) } if (this.check('scrollContainer')) { this.advance() this.consume('lparen') const selector = this.consume('string').value this.consume('rparen') const end = this.previousToken().end return makeFrameRef('scrollContainer', selector, undefined, makePosition(start, end)) } if (this.check('namedGridArea')) { this.advance() this.consume('lparen') const name = this.consume('string').value this.consume('rparen') const end = this.previousToken().end return makeFrameRef('namedGridArea', undefined, name, makePosition(start, end)) } if (this.check('landmark')) { this.advance() this.consume('lparen') const name = this.consume('string').value this.consume('rparen') const end = this.previousToken().end return makeFrameRef('landmark', undefined, name, makePosition(start, end)) } throw this.error('Expected frame reference (viewport, document, or frame constructor)') } // ------------------------------------------------------------------------- // Environment Axis // ------------------------------------------------------------------------- private parseEnvAxis(): AcrossAxis { // Axis names can be identifiers or size keywords (width, height) let name: string if (this.check('identifier') || this.check('width') || this.check('height') || this.check('size')) { name = this.advance().value } else { throw this.error('Expected axis name') } this.consume('lbracket') const values = this.parseValueList() this.consume('rbracket') return { name, values: values as (string | number)[] } } private parseValueList(): (string | number | boolean)[] { const values: (string | number | boolean)[] = [] do { if (this.check('number')) { values.push(parseFloat(this.advance().value)) } else if (this.check('string')) { values.push(this.advance().value) } else if (this.check('true')) { this.advance() values.push(true) } else if (this.check('false')) { this.advance() values.push(false) } else if (this.check('identifier')) { values.push(this.advance().value) } else { throw this.error('Expected value in axis value list') } } while (this.match('comma')) return values } // ------------------------------------------------------------------------- // Predicate (Guard Expression) // ------------------------------------------------------------------------- private parsePredicate(): GuardExpression { const start = this.currentToken().start let left: string | number if (this.check('identifier') || this.check('width') || this.check('height') || this.check('size')) { left = this.advance().value } else if (this.check('number')) { left = parseFloat(this.advance().value) } else if (this.check('string')) { left = this.advance().value } else { throw this.error('Expected predicate left operand') } let op: string if (this.match('eq')) op = '==' else if (this.match('ne')) op = '!=' else if (this.match('lt')) op = '<' else if (this.match('gt')) op = '>' else if (this.match('le')) op = '<=' else if (this.match('ge')) op = '>=' else throw this.error('Expected comparator (==, !=, <, >, <=, >=)') let right: string | number | boolean | undefined if (this.check('number')) { right = parseFloat(this.advance().value) } else if (this.check('string')) { right = this.advance().value } else if (this.check('true')) { this.advance() right = true } else if (this.check('false')) { this.advance() right = false } else if (this.check('identifier')) { right = this.advance().value } const end = this.previousToken().end return { type: 'GuardExpression', op, left, right: right as any, position: makePosition(start, end), } } // ------------------------------------------------------------------------- // Assertion Parsing // ------------------------------------------------------------------------- private parseAssertion(): any | null { let quantifier: QuantifierNode | undefined if (this.check('all') || this.check('any') || this.check('none')) { const qStart = this.currentToken().start const kind = this.advance().value quantifier = makeQuantifier(kind, makePosition(qStart, this.previousToken().end)) } // Prefix negation: not '.a' leftOf '.b' let negated = false if (this.match('not')) { negated = true } const start = this.currentToken().start if (!this.check('string') && !this.check('identifier')) { // Not an assertion if (quantifier) { throw this.error(`Expected assertion after quantifier '${quantifier.kind}'`) } if (negated) { throw this.error(`Expected assertion after 'not'`) } console.warn(`[imhotep-dsl] parseAssertion: unexpected token "${this.currentToken().value}" at line ${this.currentToken().start?.line}, skipping`) this.advance() return null } const subject = this.parseSubjectRef() // Infix negation: '.a' not leftOf '.b' if (!negated && this.match('not')) { negated = true } let left = this.parseClause(subject, start, negated) if (!left) { if (quantifier) { throw this.error(`Expected assertion after quantifier '${quantifier.kind}'`) } console.warn(`[imhotep-dsl] parseAssertion: could not parse clause for subject "${subject.value}" at line ${start?.line}, skipping`) return null } // Parse compound operators left-associatively with shared subject. // '.a' leftOf '.b' and above '.c' -> CompoundAssertion(operator='and', children=[leftOf, above]) while (this.check('and') || this.check('or')) { const operator = this.advance().value as 'and' | 'or' const clauseStart = this.currentToken().start // Infix negation before compound clause relation let clauseNegated = false if (this.match('not')) { clauseNegated = true } const right = this.parseClause(subject, clauseStart, clauseNegated) if (!right) { throw this.error(`Expected clause after '${operator}'`) } left = { type: 'CompoundAssertion', operator, children: [left, right], position: makePosition(left.position!.start, right.position!.end), } as CompoundAssertion } if (quantifier) { return this.wrapInCompound(left, quantifier) } return left } private parseClause(subject: SelectorRef, start: Point, negated: boolean): any | null { let assertion: any // Size assertion if (this.check('atLeast') || this.check('atMost') || this.check('aspectRatio') || this.check('between')) { assertion = this.parseSizeAssertion(subject, start) } else if (this.check('clippedBy') || this.check('attachedToScrollContainer') || this.check('escapeClippingChainOf') || this.check('inStackingContext')) { // Topology assertion assertion = this.parseTopologyAssertion(subject, start) } else { // Relation assertion assertion = this.parseRelationAssertion(subject, start) } // Optional inline state attachment: // '.btn' leftOf '.label' in disabled // '.btn' atLeast 44px wide in hover if (this.match('in')) { ;(assertion as any).state = this.parseStateSelector() } if (negated) { ;(assertion as any).negated = true } return assertion } private parseStateSelector(): StateSelector { const start = this.currentToken().start const supportedStates: Array = [ 'default', 'hover', 'focus', 'focusVisible', 'active', 'disabled', 'checked', 'expanded', 'collapsed', 'selected', 'pressed', 'visited', ] for (const kind of supportedStates) { if (this.match(kind)) { const end = this.previousToken().end return { type: 'StateSelector', kind, position: makePosition(start, end), } } } throw this.error( "Expected state after 'in' (default, hover, focus, focusVisible, active, disabled, checked, expanded, collapsed, selected, pressed, visited)", ) } private wrapInCompound(assertion: any, quantifier: QuantifierNode): CompoundAssertion { const start = quantifier.position!.start const end = assertion.position!.end const compound: CompoundAssertion = { type: 'CompoundAssertion', quantifier, children: assertion.type === 'CompoundAssertion' ? assertion.children : [assertion], position: makePosition(start, end), } if (assertion.type === 'CompoundAssertion' && assertion.operator) { ;(compound as any).operator = assertion.operator } return compound } private parseSubjectRef(): SelectorRef { const start = this.currentToken().start const token = this.currentToken() if (token.kind === 'is') { throw this.errorWithCode( `Unexpected keyword 'is'. Remove 'is' and write the relation directly, e.g. '${token.value}' leftOf 'other'.`, 'IMH_PARSE_IS_KEYWORD', ) } if (token.kind === 'identifier') { throw this.errorWithCode( `Selector '${token.value}' must be wrapped in single quotes. Try '${token.value}' instead.`, 'IMH_PARSE_MISSING_QUOTES', ) } if (this.check('string')) { const strToken = this.advance() if (strToken.quoteStyle === 'double') { throw this.errorWithCode( `Double quotes are not allowed. Use single quotes: '${strToken.value}' instead of "${strToken.value}".`, 'IMH_PARSE_WRONG_QUOTE_STYLE', ) } const end = this.previousToken().end return makeSelectorRef(strToken.value, makePosition(start, end)) } throw this.error('Expected subject reference (single-quoted string)') } private parseReferenceRef(): SelectorRef { return this.parseSubjectRef() } // ------------------------------------------------------------------------- // Relation Assertion // ------------------------------------------------------------------------- private parseRelationAssertion(subject: SelectorRef, start: Point): RelationAssertion { const relation = this.consumeRelation() let reference: SelectorRef | undefined if (this.check('string') || this.check('identifier')) { reference = this.parseReferenceRef() } const options = this.parseOptions() const end = this.previousToken().end const assertion: RelationAssertion = { type: 'RelationAssertion', relation, subject, reference: reference || makeSelectorRef('', makePosition(end, end)), options: options || makeRelationOptions(), position: makePosition(start, end), } return assertion } private consumeRelation(): string { // Detect common mistake: using 'is' before relation if (this.check('is')) { throw this.errorWithCode( "Unexpected keyword 'is'. Remove 'is' and write the relation directly, e.g. '.a' leftOf '.b'.", 'IMH_PARSE_IS_KEYWORD', ) } const relationKinds = collectSpatialPredicateNames() as Array for (const kind of relationKinds) { if (this.match(kind)) { return kind } } throw this.error(`Expected relation (${relationKinds.join(', ')})`) } // ------------------------------------------------------------------------- // Size Assertion // ------------------------------------------------------------------------- private parseSizeAssertion(subject: SelectorRef, start: Point): SizeAssertion { if (this.match('atLeast')) { const value = this.parseLengthLiteral() const dimension = this.consumeDimension() const end = this.previousToken().end return { type: 'SizeAssertion', property: dimension === 'wide' ? 'width' : 'height', subject, bounds: makeSizeBounds(value, undefined, undefined, makePosition(value.position!.start, end)), position: makePosition(start, end), } } if (this.match('atMost')) { const value = this.parseLengthLiteral() const dimension = this.consumeDimension() const end = this.previousToken().end return { type: 'SizeAssertion', property: dimension === 'wide' ? 'width' : 'height', subject, bounds: makeSizeBounds(undefined, value, undefined, makePosition(value.position!.start, end)), position: makePosition(start, end), } } // between for size assertions: '.button' between 44px and 48px wide if (this.match('between')) { const min = this.parseLengthLiteral() this.consume('and') const max = this.parseLengthLiteral() const dimension = this.consumeDimension() const end = this.previousToken().end return { type: 'SizeAssertion', property: dimension === 'wide' ? 'width' : 'height', subject, bounds: makeSizeBounds(min, max, undefined, makePosition(min.position!.start, end)), position: makePosition(start, end), } } if (this.match('aspectRatio')) { let bounds: SizeBounds if (this.match('between')) { const min = this.parseLiteralValue() this.consume('and') const max = this.parseLiteralValue() bounds = makeSizeBounds(min, max, undefined, makePosition(min.position!.start, max.position!.end)) } else { const exact = this.parseLiteralValue() bounds = makeSizeBounds(undefined, undefined, exact, makePosition(exact.position!.start, exact.position!.end)) } const end = this.previousToken().end return { type: 'SizeAssertion', property: 'aspectRatio', subject, bounds, position: makePosition(start, end), } } throw this.error('Expected size assertion (atLeast, atMost, between, aspectRatio)') } private parseLiteralValue(): LiteralNode { const start = this.currentToken().start if (this.check('number')) { const num = parseFloat(this.advance().value) const end = this.previousToken().end return makeLiteral('number', num, undefined, makePosition(start, end)) } if (this.check('string')) { const str = this.advance().value const end = this.previousToken().end return makeLiteral('string', str, undefined, makePosition(start, end)) } throw this.error('Expected literal value') } private parseLengthLiteral(): LiteralNode { const start = this.currentToken().start const num = parseFloat(this.consume('number').value) const unit = this.consumeUnit() const end = this.previousToken().end return makeLiteral('length', num, unit, makePosition(start, end)) } private consumeDimension(): string { if (this.match('wide')) return 'wide' if (this.match('tall')) return 'tall' throw this.error('Expected dimension (wide or tall)') } // ------------------------------------------------------------------------- // Topology Assertion // ------------------------------------------------------------------------- private parseTopologyAssertion(subject: SelectorRef, start: Point): TopologyAssertion { if (this.match('clippedBy')) { const reference = this.parseReferenceRef() const end = this.previousToken().end return { type: 'TopologyAssertion', predicate: 'clippedBy', subject, reference, position: makePosition(start, end), } } if (this.match('attachedToScrollContainer')) { const reference = this.parseReferenceRef() const end = this.previousToken().end return { type: 'TopologyAssertion', predicate: 'attachedToScrollContainer', subject, reference, position: makePosition(start, end), } } if (this.match('escapeClippingChainOf')) { const reference = this.parseReferenceRef() const end = this.previousToken().end return { type: 'TopologyAssertion', predicate: 'escapeClippingChainOf', subject, reference, position: makePosition(start, end), } } if (this.match('inStackingContext')) { const reference = (this.check('string') || this.check('identifier')) ? this.parseReferenceRef() : undefined const end = this.previousToken().end return { type: 'TopologyAssertion', predicate: 'inStackingContext', subject, reference, position: makePosition(start, end), } } throw this.error('Expected topology assertion') } // ------------------------------------------------------------------------- // Options // ------------------------------------------------------------------------- private parseOptions(): RelationOptions | null { let options: RelationOptions | null = null while (true) { if (this.check('gap')) { if (!options) options = makeRelationOptions() this.advance() const gapValue = this.parseGapOption() if (gapValue.kind === 'range') { options.minGap = gapValue as any options.maxGap = gapValue as any } else { options.minGap = gapValue as any } } else if (this.check('tolerance')) { if (!options) options = makeRelationOptions() this.advance() const tol = this.parseToleranceOption() options.tolerance = tol as any } else if (this.check('axis')) { if (!options) options = makeRelationOptions() this.advance() const axis = this.consumeAxis() ;(options as any).axis = axis } else if (this.check('inStackingContext')) { if (!options) options = makeRelationOptions() this.advance() if (this.check('true')) { this.advance() options.inStackingContext = true } else if (this.check('false')) { this.advance() options.inStackingContext = false } else { options.inStackingContext = true // default when present without value } } else if (this.check('space')) { if (!options) options = makeRelationOptions() this.advance() if (this.check('identifier')) { const spaceValue = this.consume('identifier').value if (spaceValue === 'layout' || spaceValue === 'visual') { ;(options as any).space = spaceValue } else { throw this.error(`Expected 'layout' or 'visual' after space keyword, got '${spaceValue}'`) } } else { throw this.error(`Expected 'layout' or 'visual' after space keyword`) } } else { break } } return options } private parseGapOption(): LiteralNode { const start = this.currentToken().start const num1 = parseFloat(this.consume('number').value) const unit1 = this.consumeUnitInGap() if (this.match('dotDot')) { const num2 = parseFloat(this.consume('number').value) const unit2 = this.consumeUnitInGap() const end = this.previousToken().end return makeLiteral( 'range', { min: { value: num1, unit: unit1 }, max: { value: num2, unit: unit2 }, }, undefined, makePosition(start, end), ) } const end = this.previousToken().end return makeLiteral('length', num1, unit1, makePosition(start, end)) } private parseToleranceOption(): ToleranceLiteralNode { const start = this.currentToken().start const num = parseFloat(this.consume('number').value) let unit: 'px' | 'jnd' = 'px' if (this.check('jnd')) { this.advance() unit = 'jnd' } else if (this.check('px')) { this.advance() unit = 'px' } const end = this.previousToken().end return makeToleranceLiteral(num, unit, makePosition(start, end)) } private consumeAxis(): string { const axisKinds = ['centerX', 'centerY', 'left', 'right', 'top', 'bottom'] if (this.check('identifier')) { const val = this.advance().value if (axisKinds.includes(val)) return val throw this.error(`Expected axis (centerX, centerY, left, right, top, bottom), got '${val}'`) } throw this.error('Expected axis value') } private consumeUnit(): string { if (this.match('px')) return 'px' if (this.match('em')) return 'em' if (this.match('rem')) return 'rem' if (this.match('percent')) return '%' if (this.match('jnd')) return 'jnd' if (this.match('ch')) return 'ch' if (this.match('vw')) return 'vw' if (this.match('vh')) return 'vh' throw this.error('Expected unit (px, em, rem, %, jnd, ch, vw, vh)') } private consumeUnitInGap(): string { if (this.match('px')) return 'px' if (this.match('em')) return 'em' if (this.match('rem')) return 'rem' if (this.match('percent')) return '%' if (this.match('jnd')) return 'jnd' if (this.match('ch')) return 'ch' if (this.match('vw')) return 'vw' if (this.match('vh')) return 'vh' throw this.errorWithCode( 'Missing unit on gap value. Add a unit, e.g. gap 8px or gap 8px..16px.', 'IMH_PARSE_MISSING_UNIT', ) } // ------------------------------------------------------------------------- // Token Helpers // ------------------------------------------------------------------------- private currentToken(): Token { if (this.current >= this.tokens.length) { return this.tokens[this.tokens.length - 1] } return this.tokens[this.current] } private previousToken(): Token { if (this.current === 0) return this.tokens[0] return this.tokens[this.current - 1] } private advance(): Token { if (!this.isAtEnd()) this.current++ return this.previousToken() } private isAtEnd(): boolean { return this.currentToken().kind === 'eof' } private check(kind: Token['kind']): boolean { if (this.isAtEnd()) return false return this.currentToken().kind === kind } private match(kind: Token['kind']): boolean { if (this.check(kind)) { this.advance() return true } return false } private consume(kind: Token['kind']): Token { if (this.check(kind)) { return this.advance() } throw this.error(`Expected ${kind}, got ${this.currentToken().kind}`) } private consumeNewlineOrIndent(): void { if (this.check('newline')) { this.advance() if (this.check('indent')) { this.advance() } } else if (this.check('indent')) { this.advance() } } private error(message: string): ParseError { const token = this.currentToken() const pos = makePosition(token.start, token.end) return new ParseError(message, pos) } private errorWithCode(message: string, code: string): ParseError { const token = this.currentToken() const pos = makePosition(token.start, token.end) return new ParseError(message, pos, code) } } // --------------------------------------------------------------------------- // Convenience Export // --------------------------------------------------------------------------- export function parseGrammar(tokens: Token[]): Program { return new GrammarParser(tokens).parse() }