Files
Imhotep/packages/imhotep-dsl/src/grammar.ts
T

1532 lines
44 KiB
TypeScript
Raw Normal View History

// 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<Token['kind']> = [
'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<Token['kind']>
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()
}