// Compiler: lowers AST -> Semantic IR -> Execution IR // Pure function, no browser dependencies import type { RelationAssertion, SemanticIr, ExecutionIr, SemanticClause, SemanticSubject, SemanticFrame, SemanticState, SemanticTimeline, SemanticTolerance, SemanticEnvGuard, Diagnostic, Position, ImhotepId, FormulaNode, VariableRef, DomainRef, TupleBinding, PredicateCall, AssertionNode, SizeAssertion, TopologyAssertion, CompoundAssertion, SelectorRef, } from 'imhotep-core' import { createEmptySemanticIr, getDefaultContext } from 'imhotep-core' import { parseTolerance, parseGap } from './validator.js' // DSL grammar FOL types (distinct from solver FormulaNode imported above) import type { FormulaNode as DslFormulaNode, ForAllFormula as DslForAll, ExistsFormula as DslExists, AndFormula as DslAnd, OrFormula as DslOr, NotFormula as DslNot, ImpliesFormula as DslImplies, PredicateCall as DslPredicateCall, VariableRef as DslVariableRef, DomainRef as DslDomainRef, } from './logic-fluent.js' // ---- Compiler Options (dependency injection) ---- export interface CompilerOptions { // Injected id generator for determinism in tests generateId?: (prefix: string) => string // Default frame when none specified defaultFrameKind?: string // Default tolerance when none specified defaultTolerance?: { value: number; unit: 'px' | 'jnd' } } // ---- Id Generator ---- function defaultNextId(prefix: string): string { return `${prefix}_${getDefaultContext().idGenerator()}` } // ---- Option Value Normalization ---- // INVARIANT: Both fluent API and string DSL must lower to equivalent Execution IR. // The string parser produces LiteralNode / ToleranceLiteralNode AST wrappers, // while the fluent API stores raw numbers. These normalizers extract the scalar // value from both formats so parseGap / parseTolerance receive consistent input. // // Bug fixed: Previously parseGap(LiteralNode) silently returned null because // String(object) === "[object Object]", causing string DSL gap options to be // lost in compilation. The extractGapValue helper now handles range literals. function normalizeOptionValue(raw: unknown): number | string | undefined { if (raw === undefined || raw === null) return undefined if (typeof raw === 'number' || typeof raw === 'string') return raw // String-parser/fluent path: LiteralNode or ToleranceLiteralNode if (typeof raw === 'object') { const obj = raw as Record if ('value' in obj && typeof obj.value === 'number') { if ('unit' in obj && typeof obj.unit === 'string') { return `${obj.value}${obj.unit}` } return obj.value } } return undefined } // Extract a numeric gap value from fluent raw numbers, string-parser LiteralNodes, // or string-parser range LiteralNodes ( picking min or max edge ). function extractGapValue(raw: unknown, edge: 'min' | 'max'): number | undefined { if (raw === undefined || raw === null) return undefined if (typeof raw === 'number') return raw if (typeof raw === 'string') { const num = parseFloat(raw.trim()) if (!Number.isNaN(num)) return num return undefined } if (typeof raw === 'object') { const obj = raw as Record // Simple length literal: { type: 'Literal', kind: 'length', value: number } if ('value' in obj && typeof obj.value === 'number') { return obj.value } // Range literal: { type: 'Literal', kind: 'range', value: { min: { value }, max: { value } } } if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') { const range = obj.value as Record const target = edge === 'min' ? range.min : range.max if (target && typeof target === 'object') { const targetObj = target as Record if (typeof targetObj.value === 'number') { return targetObj.value } } } } return undefined } // ---- Semantic Lowering ---- function toSemanticIr( ast: RelationAssertion[], opts: CompilerOptions, ): { semanticIr: SemanticIr; diagnostics: Diagnostic[] } { const generateId = opts.generateId || defaultNextId const ir = createEmptySemanticIr() const diagnostics: Diagnostic[] = [] // Default frame const defaultFrameId = generateId('frame') ir.frames.set(defaultFrameId, { id: defaultFrameId, kind: (opts.defaultFrameKind || 'viewport') as SemanticFrame['kind'], originX: 0, originY: 0, writingMode: 'horizontal-tb', origin: { astNodeIds: [], positions: [] }, }) // Default state const defaultStateId = generateId('state') ir.states.set(defaultStateId, { id: defaultStateId, kind: 'default', origin: { astNodeIds: [], positions: [] }, }) // Default timeline const defaultTimelineId = generateId('timeline') ir.timelines.set(defaultTimelineId, { id: defaultTimelineId, mode: 'static', origin: { astNodeIds: [], positions: [] }, }) // Default tolerance const defaultToleranceId = generateId('tolerance') ir.tolerances.set(defaultToleranceId, { id: defaultToleranceId, value: opts.defaultTolerance?.value ?? 0, unit: opts.defaultTolerance?.unit ?? 'px', origin: { astNodeIds: [], positions: [] }, }) for (const assertion of ast) { // Build origin from source span const origin = buildOrigin(assertion.position) // Access extended DSL properties via cast const extended = assertion as unknown as Record // Subject const subjectId = generateId('subject') const subjectDef: SemanticSubject = { id: subjectId, selector: assertion.subject.value, kind: 'element', origin, } ir.subjects.set(subjectId, subjectDef) // Reference const referenceId = generateId('subject') const referenceDef: SemanticSubject = { id: referenceId, selector: assertion.reference.value, kind: 'element', origin, } ir.subjects.set(referenceId, referenceDef) // Frame let frameId = defaultFrameId const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined if (frameNode) { frameId = generateId('frame') const frameDef: SemanticFrame = { id: frameId, kind: frameNode.kind as SemanticFrame['kind'], selector: frameNode.selector, name: frameNode.name, originX: 0, originY: 0, writingMode: 'horizontal-tb', origin, } ir.frames.set(frameId, frameDef) } // State let stateId = defaultStateId const stateNode = extended.state as { kind: string; name?: string } | undefined if (stateNode) { stateId = generateId('state') const stateDef: SemanticState = { id: stateId, kind: stateNode.kind as SemanticState['kind'], name: stateNode.name, origin, } ir.states.set(stateId, stateDef) } // Tolerance let toleranceId = defaultToleranceId const rawOpts = assertion.options as unknown as Record | undefined const tol = parseTolerance(normalizeOptionValue(rawOpts?.tolerance)) if (tol) { toleranceId = generateId('tolerance') ir.tolerances.set(toleranceId, { id: toleranceId, value: tol.value, unit: tol.unit, origin, }) } // Environment guard const envGuardId = generateId('guard') const envGuard = extended.envGuard as { condition: string } | undefined const guardDef: SemanticEnvGuard = { id: envGuardId, expression: envGuard?.condition || 'true', normalizedCases: [], origin, } ir.envGuards.set(envGuardId, guardDef) // Bounds const bounds: SemanticClause['bounds'] = {} if (rawOpts) { if ('minGap' in rawOpts) { const minVal = extractGapValue(rawOpts.minGap, 'min') if (minVal !== undefined) { bounds.minGap = { value: minVal, unit: 'px' } } } if ('maxGap' in rawOpts) { const maxVal = extractGapValue(rawOpts.maxGap, 'max') if (maxVal !== undefined) { bounds.maxGap = { value: maxVal, unit: 'px' } } } } // Flags let flags = 0 const quantifier = extended.quantifier as string | undefined if (quantifier === 'all') flags |= 1 if (quantifier === 'any') flags |= 2 if (quantifier === 'none') flags |= 4 if (rawOpts?.inStackingContext) flags |= 8 if ((assertion as any).negated) flags |= 16 // Clause const clauseId = generateId('clause') const clause: SemanticClause = { id: clauseId, origin, subjectRef: subjectId, referenceRef: referenceId, relation: assertion.relation, frameRef: frameId, stateRef: stateId, timelineRef: defaultTimelineId, envGuardRef: envGuardId, toleranceRef: toleranceId, bounds, } // Attach flags via cast since core SemanticClause doesn't have flags field yet ;(clause as unknown as Record).flags = flags ir.clauses.set(clauseId, clause) } return { semanticIr: ir, diagnostics } } function buildOrigin(position?: Position): { astNodeIds: string[]; positions: Position[] } { if (position) { return { astNodeIds: [], positions: [position] } } return { astNodeIds: [], positions: [] } } // ---- Execution IR Compilation ---- function toExecutionIr(semanticIr: SemanticIr): ExecutionIr { const clauses = Array.from(semanticIr.clauses.values()) const count = clauses.length if (count === 0) { return { clauseCount: 0, clauseType: new Uint16Array(0), clauseSubject: new Uint32Array(0), clauseReference: new Uint32Array(0), clauseFrame: new Uint32Array(0), clauseState: new Uint32Array(0), clauseTimeline: new Uint32Array(0), clauseTolerance: new Uint32Array(0), clauseEnvGuard: new Uint32Array(0), clauseArg0: new Float64Array(0), clauseArg1: new Float64Array(0), clauseFlags: new Uint32Array(0), clauseOrigin: new Uint32Array(0), } } // Build lookup maps for numeric indices — iterate Maps directly to avoid Array.from(). const subjectIndex = new Map() let idx = 0 for (const k of semanticIr.subjects.keys()) subjectIndex.set(k, idx++) const frameIndex = new Map() idx = 0 for (const k of semanticIr.frames.keys()) frameIndex.set(k, idx++) const stateIndex = new Map() idx = 0 for (const k of semanticIr.states.keys()) stateIndex.set(k, idx++) const timelineIndex = new Map() idx = 0 for (const k of semanticIr.timelines.keys()) timelineIndex.set(k, idx++) const toleranceIndex = new Map() idx = 0 for (const k of semanticIr.tolerances.keys()) toleranceIndex.set(k, idx++) const guardIndex = new Map() idx = 0 for (const k of semanticIr.envGuards.keys()) guardIndex.set(k, idx++) // Encode relation as small integer const relationCodes: Record = { leftOf: 1, rightOf: 2, above: 3, below: 4, alignedWith: 5, leftAlignedWith: 6, rightAlignedWith: 7, topAlignedWith: 8, bottomAlignedWith: 9, centeredWithin: 10, inside: 11, contains: 12, overlaps: 13, separatedFrom: 14, aspectRatioBetween: 15, between: 16, } const clauseType = new Uint16Array(count) const clauseSubject = new Uint32Array(count) const clauseReference = new Uint32Array(count) const clauseFrame = new Uint32Array(count) const clauseState = new Uint32Array(count) const clauseTimeline = new Uint32Array(count) const clauseTolerance = new Uint32Array(count) const clauseEnvGuard = new Uint32Array(count) const clauseArg0 = new Float64Array(count) const clauseArg1 = new Float64Array(count) const clauseFlags = new Uint32Array(count) const clauseOrigin = new Uint32Array(count) for (let i = 0; i < count; i++) { const c = clauses[i] clauseType[i] = relationCodes[c.relation] || 0 clauseSubject[i] = subjectIndex.get(c.subjectRef) ?? 0 clauseReference[i] = c.referenceRef ? (subjectIndex.get(c.referenceRef) ?? 0) : 0 clauseFrame[i] = frameIndex.get(c.frameRef) ?? 0 clauseState[i] = stateIndex.get(c.stateRef) ?? 0 clauseTimeline[i] = timelineIndex.get(c.timelineRef) ?? 0 clauseTolerance[i] = toleranceIndex.get(c.toleranceRef) ?? 0 clauseEnvGuard[i] = guardIndex.get(c.envGuardRef) ?? 0 clauseOrigin[i] = i // origin index same as clause index for now // Retrieve flags via cast const flags = (c as unknown as Record).flags as number | undefined clauseFlags[i] = flags || 0 // Pack bounds into arg0/arg1 where applicable if (c.bounds.minGap && c.bounds.maxGap) { clauseArg0[i] = c.bounds.minGap.value clauseArg1[i] = c.bounds.maxGap.value } else if (c.bounds.minGap) { clauseArg0[i] = c.bounds.minGap.value clauseArg1[i] = Number.POSITIVE_INFINITY } else if (c.bounds.maxGap) { clauseArg0[i] = Number.NEGATIVE_INFINITY clauseArg1[i] = c.bounds.maxGap.value } else { clauseArg0[i] = Number.NEGATIVE_INFINITY clauseArg1[i] = Number.POSITIVE_INFINITY } } return { clauseCount: count, clauseType, clauseSubject, clauseReference, clauseFrame, clauseState, clauseTimeline, clauseTolerance, clauseEnvGuard, clauseArg0, clauseArg1, clauseFlags, clauseOrigin, } } // ---- FOL Formula Compilation ---- const subjectVar: VariableRef = { type: 'VariableRef', name: '$subject' } const referenceVar: VariableRef = { type: 'VariableRef', name: '$reference' } function makeDomain(selector: string): DomainRef { return { type: 'DomainRef', domain: 'elements', selector } } function makeBinding(varName: string, selector: string): TupleBinding { return { type: 'TupleBinding', variables: [varName], domain: makeDomain(selector), } } function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion | TopologyAssertion): Record { const options: Record = {} if (assertion.type === 'RelationAssertion') { const opts = assertion.options as unknown as Record if (opts?.minGap !== undefined) { const v = normalizeOptionValue(opts.minGap) if (v !== undefined) options.minGap = v } if (opts?.maxGap !== undefined) { const v = normalizeOptionValue(opts.maxGap) if (v !== undefined) options.maxGap = v } if (opts?.tolerance !== undefined) { const tol = parseTolerance(normalizeOptionValue(opts.tolerance)) if (tol) { options.tolerance = tol.value options.toleranceUnit = tol.unit } } if (opts?.axis !== undefined) options.axis = opts.axis if (opts?.inStackingContext === true) options.inStackingContext = true } if (assertion.type === 'SizeAssertion') { const bounds = assertion.bounds as unknown as Record if (bounds?.min && typeof (bounds.min as any).value === 'number') { options.min = (bounds.min as any).value options.value = (bounds.min as any).value } if (bounds?.max && typeof (bounds.max as any).value === 'number') { options.max = (bounds.max as any).value } if (bounds?.exact && typeof (bounds.exact as any).value === 'number') { options.value = (bounds.exact as any).value } if (assertion.property) { options.dimension = assertion.property } } if (assertion.type === 'TopologyAssertion') { const opts = assertion.options as unknown as Record if (opts?.tolerance !== undefined) { const tol = parseTolerance(normalizeOptionValue(opts.tolerance)) if (tol) { options.tolerance = tol.value options.toleranceUnit = tol.unit } } } return options } function compileSimpleAssertionToFormula( assertion: RelationAssertion | SizeAssertion | TopologyAssertion, ): FormulaNode { const options = buildOptionsFromAssertion(assertion) const isUnary = assertion.type === 'SizeAssertion' || (assertion.type === 'RelationAssertion' && (assertion.relation === 'atLeast' || assertion.relation === 'atMost')) let predicateName: string let args: VariableRef[] if (assertion.type === 'RelationAssertion') { predicateName = assertion.relation args = isUnary ? [subjectVar] : [subjectVar, referenceVar] } else if (assertion.type === 'SizeAssertion') { if (assertion.property === 'aspectRatio') { predicateName = 'aspectRatio' } else if (assertion.bounds.min && assertion.bounds.max) { predicateName = 'between' } else if (assertion.bounds.max) { predicateName = 'atMost' } else if (assertion.bounds.min) { predicateName = 'atLeast' } else { predicateName = 'atLeast' } args = [subjectVar] } else { predicateName = assertion.predicate args = assertion.reference ? [subjectVar, referenceVar] : [subjectVar] } let body: FormulaNode = { type: 'FormulaNode', kind: 'predicate', predicate: predicateName, args, ...(Object.keys(options).length > 0 ? { options } : {}), } as FormulaNode // Negation if ((assertion as any).negated) { body = { type: 'FormulaNode', kind: 'not', operand: body, } } // Bindings const subjectBinding = makeBinding('$subject', assertion.subject.value) const bindings: TupleBinding[] = [subjectBinding] if (!isUnary && assertion.type !== 'TopologyAssertion') { const ref = (assertion as RelationAssertion).reference if (ref?.value) { bindings.push(makeBinding('$reference', ref.value)) } } if (assertion.type === 'TopologyAssertion' && assertion.reference?.value) { bindings.push(makeBinding('$reference', assertion.reference.value)) } return { type: 'FormulaNode', kind: 'forall', bindings, body, } as FormulaNode } function compileQuantifierToFormula( assertion: CompoundAssertion, body: FormulaNode, ): FormulaNode { const quantifier = assertion.quantifier?.kind const subject = (assertion.children[0] as any).subject as SelectorRef const reference = (assertion.children[0] as any).reference as SelectorRef | undefined const subjectBinding = makeBinding('$subject', subject.value) if (quantifier === 'any') { const refBinding = reference?.value ? makeBinding('$reference', reference.value) : null return { type: 'FormulaNode', kind: 'exists', bindings: [subjectBinding], body: refBinding ? { type: 'FormulaNode', kind: 'forall', bindings: [refBinding], body, } : body, } as FormulaNode } if (quantifier === 'none') { const refBinding = reference?.value ? makeBinding('$reference', reference.value) : null return { type: 'FormulaNode', kind: 'forall', bindings: [subjectBinding], body: { type: 'FormulaNode', kind: 'not', operand: refBinding ? { type: 'FormulaNode', kind: 'exists', bindings: [refBinding], body, } : body, }, } as FormulaNode } // Default / 'all' const bindings: TupleBinding[] = [subjectBinding] if (reference?.value) { bindings.push(makeBinding('$reference', reference.value)) } return { type: 'FormulaNode', kind: 'forall', bindings, body, } as FormulaNode } /** * Compile a single DSL assertion AST node to a FOL FormulaNode. * Handles RelationAssertion, SizeAssertion, TopologyAssertion, * and CompoundAssertion (with operators and/or quantifiers). */ export function compileToFormula(assertion: AssertionNode): FormulaNode | null { if (!assertion) return null // Compound assertion: operator (and/or) or quantifier wrapper if (assertion.type === 'CompoundAssertion') { const compound = assertion as CompoundAssertion if (compound.operator) { // Logical compound: compile children and wrap in and/or const children = (compound.children || []) .map((child) => compileToFormula(child)) .filter((f): f is FormulaNode => f !== null) if (children.length === 0) return null if (children.length === 1) return children[0] let result = children[0] for (let i = 1; i < children.length; i++) { result = { type: 'FormulaNode', kind: compound.operator, left: result, right: children[i], } as FormulaNode } // If quantifier is present, wrap the whole compound if (compound.quantifier) { return compileQuantifierToFormula(compound, result) } return result } // Quantifier-only compound const childFormulas = (compound.children || []) .map((child) => compileToFormula(child)) .filter((f): f is FormulaNode => f !== null) if (childFormulas.length === 0) return null const body = childFormulas.length === 1 ? childFormulas[0] : childFormulas.reduce((left, right) => ({ type: 'FormulaNode', kind: 'and', left, right, } as FormulaNode)) return compileQuantifierToFormula(compound, body) } // Simple assertions if ( assertion.type === 'RelationAssertion' || assertion.type === 'SizeAssertion' || assertion.type === 'TopologyAssertion' ) { return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion) } return null } // ---- Dense DSL FOL Compilation (bypasses canonical lowering) ---- /** * Convert a DSL grammar FOL formula AST node to a solver FormulaNode. * * Dense DSL forall/exists formulas use a distinct AST shape from the solver's * FormulaNode (e.g. type: 'ForAll' vs type: 'FormulaNode' kind: 'forall'). * This function bridges the two representations so that parsed dense FOL specs * can be evaluated directly by evaluateLogic() without going through canonical * clause descriptors. * * String arguments inside PredicateCall are lifted into implicit forall * bindings because the solver only accepts VariableRef and AccessorTerm * as predicate arguments. */ export function compileDenseFOLToFormula(dslFormula: DslFormulaNode): FormulaNode { let freshVarCounter = 0 function makeFreshVar(): string { freshVarCounter += 1 return `$str_${freshVarCounter}` } function compile(node: DslFormulaNode): FormulaNode { switch (node.type) { case 'ForAll': { const fa = node as DslForAll return { type: 'FormulaNode', kind: 'forall', bindings: [makeTupleBinding(fa.variable, fa.domain)], body: compile(fa.body), } as FormulaNode } case 'Exists': { const ex = node as DslExists return { type: 'FormulaNode', kind: 'exists', bindings: [makeTupleBinding(ex.variable, ex.domain)], body: compile(ex.body), } as FormulaNode } case 'And': { const a = node as DslAnd return { type: 'FormulaNode', kind: 'and', left: compile(a.left), right: compile(a.right), } as FormulaNode } case 'Or': { const o = node as DslOr return { type: 'FormulaNode', kind: 'or', left: compile(o.left), right: compile(o.right), } as FormulaNode } case 'Not': { const n = node as DslNot return { type: 'FormulaNode', kind: 'not', operand: compile(n.operand), } as FormulaNode } case 'Implies': { const imp = node as DslImplies return { type: 'FormulaNode', kind: 'implies', antecedent: compile(imp.left), consequent: compile(imp.right), } as FormulaNode } case 'PredicateCall': { const pc = node as DslPredicateCall const implicitBindings: TupleBinding[] = [] const compiledArgs: VariableRef[] = [] for (const arg of pc.args) { if (typeof arg === 'string') { const varName = makeFreshVar() implicitBindings.push({ type: 'TupleBinding', variables: [varName], domain: { type: 'DomainRef', domain: 'elements', selector: arg }, }) compiledArgs.push({ type: 'VariableRef', name: varName }) } else if (typeof arg === 'number') { // Numbers are not supported as predicate args in the solver. // Skip them — the caller should have encoded numeric values // in predicate options instead. continue } else { compiledArgs.push(convertTerm(arg) as VariableRef) } } // Size predicates with comparison operators: width($x) >= 44 → size.atLeast let predicateBody: FormulaNode if (['width', 'height', 'size'].includes(pc.name) && pc.operator && pc.right !== undefined) { const options: Record = {} if (pc.name === 'width') options.dimension = 'width' else if (pc.name === 'height') options.dimension = 'height' else options.dimension = 'width' // size defaults to width let predicateName: string switch (pc.operator) { case '>=': predicateName = 'atLeast' options.min = pc.right break case '<=': predicateName = 'atMost' options.max = pc.right break case '==': predicateName = 'between' options.min = pc.right options.max = pc.right break case '!=': // Not-equal is compiled as negated equality predicateBody = { type: 'FormulaNode', kind: 'not', operand: { type: 'FormulaNode', kind: 'predicate', predicate: 'between', args: compiledArgs, options: { ...options, min: pc.right, max: pc.right }, } as FormulaNode, } as FormulaNode // Wrap implicit bindings if any return wrapInForAll(implicitBindings, predicateBody) case '>': predicateName = 'atLeast' options.min = pc.right break case '<': predicateName = 'atMost' options.max = pc.right break default: predicateName = pc.name } predicateBody = { type: 'FormulaNode', kind: 'predicate', predicate: predicateName, args: compiledArgs, ...(Object.keys(options).length > 0 ? { options } : {}), } as FormulaNode } else { predicateBody = { type: 'FormulaNode', kind: 'predicate', predicate: pc.name, args: compiledArgs, } as FormulaNode } // Wrap implicit forall bindings around the predicate body. // String literals in predicate args are treated as universally // quantified domains (same semantics as canonical clause compilation). return wrapInForAll(implicitBindings, predicateBody) } case 'VariableRef': case 'DomainRef': // VariableRef and DomainRef are terms, not formulas. They should only // appear as arguments inside PredicateCall, never as top-level formulas. throw new Error(`DSL ${node.type} is a term, not a formula, and cannot be compiled standalone`) default: throw new Error(`Unknown DSL formula node type: ${(node as any).type}`) } } return compile(dslFormula) } function wrapInForAll(bindings: TupleBinding[], body: FormulaNode): FormulaNode { if (bindings.length === 0) return body let result = body // Wrap from last binding to first so that the outermost forall // corresponds to the leftmost argument. for (let i = bindings.length - 1; i >= 0; i--) { result = { type: 'FormulaNode', kind: 'forall', bindings: [bindings[i]], body: result, } as FormulaNode } return result } function makeTupleBinding(variable: DslVariableRef, domain: DslDomainRef): TupleBinding { return { type: 'TupleBinding', variables: [variable.name], domain: convertDomain(domain), } } function convertDomain(domain: DslDomainRef): DomainRef { const selectorFromVar = domain.variableSelector ? `$${domain.variableSelector.name}` : undefined const extraArgFromVar = domain.variableExtraArg ? `$${domain.variableExtraArg.name}` : undefined // Descendant domains use the first argument as parent and second as filter: // descendants($card, '.title') => parentVar: '$card', selector: '.title' if (domain.kind === 'descendants') { const parentVar = selectorFromVar ?? domain.selector const selector = extraArgFromVar ?? (domain as any).extraArg return { type: 'DomainRef', domain: domain.kind, selector, parentVar, } } // Default mapping for non-descendant domains. const selector = selectorFromVar ?? domain.selector const parentVar = extraArgFromVar ?? (domain as any).extraArg return { type: 'DomainRef', domain: domain.kind, selector, parentVar, } } function convertTerm(term: DslVariableRef | string | number): VariableRef | string | number { if (typeof term === 'object' && term !== null && term.type === 'VariableRef') { return { type: 'VariableRef', name: (term as DslVariableRef).name } } return term } // ---- Public Compiler ---- export interface CompileResult { ast: { type: 'Program'; children: RelationAssertion[] } semanticIr: SemanticIr executionIr: ExecutionIr diagnostics: Diagnostic[] } export function compile( ast: RelationAssertion[], options: CompilerOptions = {}, ): CompileResult { const programNode = { type: 'Program' as const, children: ast, } const { semanticIr, diagnostics } = toSemanticIr(ast, options) const executionIr = toExecutionIr(semanticIr) return { ast: programNode, semanticIr, executionIr, diagnostics, } }