| // Pratt parser. +/- emit Sum nodes; */÷ emit Product nodes. node.js | |
| // constructors flatten and normalize on construction, while parenthesized | |
| // sums retain a grouping marker for the opaque-subtraction invariant. | |
| import { mkSum, mkProduct, negate, num, dim, ident, call } from './node.js'; | |
| /** | |
| * @typedef {import('./tokenizer.js').Token} Token | |
| * @typedef {import('./tokenizer.js').TokenType} TokenType | |
| * @typedef {import('./node.js').Node} Node | |
| * @typedef {(p: Parser, token: Token) => Node} PrefixParselet | |
| */ | |
| /** | |
| * §10.9 — case-insensitive except for NaN. | |
| * @param {string} name | |
| * @return {Node | null} | |
| */ | |
| function foldCalcKeyword(name) { | |
| // `NaN` and `-NaN` are spec-defined math constants (§10.7.1). The signed | |
| // form arrives as a single ident because CSS Syntax tokenizes leading | |
| // `-` + ident-start as one ident-token. | |
| if (name === 'NaN' || name === '-NaN') { | |
| return num(Number.NaN); | |
| } | |
| switch (name.toLowerCase()) { | |
| case 'pi': | |
| return num(Math.PI); | |
| case 'e': | |
| return num(Math.E); | |
| case 'infinity': | |
| return num(Infinity); | |
| case '-infinity': | |
| return num(-Infinity); | |
| } | |
| return null; | |
| } | |
| class Parser { | |
| /** | |
| * @param {Token[]} tokens | |
| */ | |
| constructor(tokens) { | |
| /** @private */ | |
| this.i = 0; | |
| /** @private @readonly */ | |
| this.tokens = tokens; | |
| } | |
| /** @return {Token} */ | |
| peek() { | |
| return this.tokens[this.i]; | |
| } | |
| /** @return {Token} */ | |
| next() { | |
| return this.tokens[this.i++]; | |
| } | |
| /** | |
| * @param {TokenType} type | |
| * @param {string} [value] | |
| * @return {Token} | |
| */ | |
| expect(type, value) { | |
| const t = this.next(); | |
| if (t.type !== type || (value !== undefined && t.value !== value)) { | |
| const want = value ?? type; | |
| throw new Error( | |
| `Expected ${want} at position ${t.pos}, got "${t.value}"` | |
| ); | |
| } | |
| return t; | |
| } | |
| /** | |
| * @param {string} value | |
| * @param {string} [value2] | |
| * @return {boolean} | |
| */ | |
| isPunct(value, value2) { | |
| const t = this.peek(); | |
| return ( | |
| t.type === 'punct' && | |
| (t.value === value || (value2 !== undefined && t.value === value2)) | |
| ); | |
| } | |
| /** | |
| * @param {string} value | |
| * @return {boolean} | |
| */ | |
| matchPunct(value) { | |
| if (this.isPunct(value)) { | |
| this.next(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * @param {string} value | |
| * @return {Token} | |
| */ | |
| expectPunct(value) { | |
| return this.expect('punct', value); | |
| } | |
| /** | |
| * @param {number} [minBp] | |
| * @return {Node} | |
| */ | |
| parseExpr(minBp = 0) { | |
| const t = this.next(); | |
| const key = t.type === 'punct' ? t.value : t.type; | |
| const prefix = PREFIX[key]; | |
| if (!prefix) { | |
| throw new Error(`Unexpected token "${t.value}" at position ${t.pos}`); | |
| } | |
| let left = prefix(this, t); | |
| while (true) { | |
| const nxt = this.peek(); | |
| const infixKey = nxt.type === 'punct' ? nxt.value : nxt.type; | |
| const rule = INFIX[infixKey]; | |
| if (!rule || rule.lbp < minBp) { | |
| break; | |
| } | |
| if (infixKey === '+' || infixKey === '-') { | |
| /** @type {import('./node.js').SumTerm[]} */ | |
| const terms = [{ sign: /** @type {1} */ (1), node: left }]; | |
| do { | |
| const token = this.next(); | |
| requireSurroundingWs(this, token); | |
| terms.push({ | |
| sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1), | |
| node: this.parseExpr(ADD_BP + 1), | |
| }); | |
| } while (this.isPunct('+', '-')); | |
| left = mkSum(terms); | |
| continue; | |
| } | |
| if (infixKey === '*' || infixKey === '/') { | |
| /** @type {import('./node.js').ProductFactor[]} */ | |
| const factors = [{ exponent: /** @type {1} */ (1), node: left }]; | |
| do { | |
| const token = this.next(); | |
| factors.push({ | |
| exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1), | |
| node: this.parseExpr(MUL_BP + 1), | |
| }); | |
| } while (this.isPunct('*', '/')); | |
| left = mkProduct(factors); | |
| continue; | |
| } | |
| // Every infix operator is handled above. Keep this defensive exit in | |
| // case a future parselet is added without a chain implementation. | |
| break; | |
| } | |
| return left; | |
| } | |
| } | |
| const ADD_BP = 1; | |
| const MUL_BP = 3; | |
| const UNARY_BP = 7; | |
| /** | |
| * Functions whose argument list isn't a comma-separated list of calc | |
| * expressions. Their bodies are slurped as opaque space-separated tokens | |
| * and round-tripped verbatim. anchor() / anchor-size() use the | |
| * `<anchor-name> <anchor-side>` shape (CSS Anchor Positioning). | |
| */ | |
| const OPAQUE_ARG_FUNCTIONS = new Set(['anchor', 'anchor-size']); | |
| /** | |
| * Reconstruct a token's source text for opaque-arg slurping. | |
| * @param {Token} t | |
| * @return {string} | |
| */ | |
| function tokenText(t) { | |
| if (t.type === 'dimension') { | |
| return `${t.value}${t.unit ?? ''}`; | |
| } | |
| return t.value; | |
| } | |
| /** | |
| * Parse the body of an opaque-arg call, with `(` already consumed. | |
| * @param {Parser} p | |
| * @param {string} name | |
| * @return {Node} | |
| */ | |
| function parseOpaqueCall(p, name) { | |
| /** @type {Node[]} */ | |
| const args = []; | |
| let buf = ''; | |
| let depth = 1; | |
| const flush = () => { | |
| const trimmed = buf.trim(); | |
| if (trimmed) { | |
| args.push(ident(trimmed)); | |
| } | |
| buf = ''; | |
| }; | |
| while (true) { | |
| const tk = p.peek(); | |
| if (tk.type === 'eof') { | |
| throw new Error(`Unclosed ${name}( at position ${tk.pos}`); | |
| } | |
| if (tk.type === 'punct') { | |
| if (tk.value === '(') { | |
| depth++; | |
| } else if (tk.value === ')') { | |
| depth--; | |
| if (depth === 0) { | |
| p.next(); | |
| flush(); | |
| return call(name, args); | |
| } | |
| } else if (tk.value === ',' && depth === 1) { | |
| p.next(); | |
| flush(); | |
| continue; | |
| } | |
| } | |
| if (tk.ws && buf) { | |
| buf += ' '; | |
| } | |
| buf += tokenText(tk); | |
| p.next(); | |
| } | |
| } | |
| /** | |
| * §10.1 requires whitespace on both sides of a binary `+` / `-`. Without | |
| * it, CSS tokenization treats `1px+2px` as two tokens with no operator | |
| * between them (browsers reject this). We enforce the rule here by | |
| * checking the token's `ws` flag (whitespace before the `+`/`-`) and the | |
| * following token's flag (whitespace after). | |
| * @param {Parser} p | |
| * @param {Token} token | |
| * @return {void} | |
| */ | |
| function requireSurroundingWs(p, token) { | |
| const next = p.peek(); | |
| if (!token.ws || !next.ws) { | |
| throw new Error( | |
| `"${token.value}" must be surrounded by whitespace at position ${token.pos}` | |
| ); | |
| } | |
| } | |
| /** @type {Record<string, PrefixParselet>} */ | |
| const PREFIX = { | |
| number: (_p, t) => num(Number.parseFloat(t.value)), | |
| // Unit case normalization per §10.12: `1PX` serializes as `1px`. | |
| dimension: (_p, t) => | |
| dim( | |
| Number.parseFloat(t.value), | |
| t.unit === '%' ? '%' : /** @type {string} */ (t.unit).toLowerCase() | |
| ), | |
| ident: (p, t) => { | |
| if (p.matchPunct('(')) { | |
| if (OPAQUE_ARG_FUNCTIONS.has(t.value.toLowerCase())) { | |
| return parseOpaqueCall(p, t.value); | |
| } | |
| /** @type {Node[]} */ | |
| const args = []; | |
| if (!p.isPunct(')')) { | |
| args.push(p.parseExpr(0)); | |
| while (p.matchPunct(',')) { | |
| args.push(p.parseExpr(0)); | |
| } | |
| } | |
| p.expectPunct(')'); | |
| return call(t.value, args); | |
| } | |
| const kw = foldCalcKeyword(t.value); | |
| if (kw) { | |
| return kw; | |
| } | |
| return ident(t.value); | |
| }, | |
| '(': (p) => { | |
| const e = p.parseExpr(0); | |
| p.expectPunct(')'); | |
| return e.type === 'Sum' ? { ...e, grouped: true } : e; | |
| }, | |
| '-': (p) => negate(p.parseExpr(UNARY_BP)), | |
| '+': (p) => p.parseExpr(UNARY_BP), | |
| }; | |
| /** @type {Record<string, {lbp: number}>} */ | |
| const INFIX = { | |
| '+': { lbp: ADD_BP }, | |
| '-': { lbp: ADD_BP }, | |
| '*': { lbp: MUL_BP }, | |
| '/': { lbp: MUL_BP }, | |
| }; | |
| /** | |
| * @param {Token[]} tokens | |
| * @return {Node} | |
| */ | |
| function parse(tokens) { | |
| const p = new Parser(tokens); | |
| const ast = p.parseExpr(0); | |
| const trailing = p.peek(); | |
| if (trailing.type !== 'eof') { | |
| throw new Error( | |
| `Unexpected token "${trailing.value}" at position ${trailing.pos}` | |
| ); | |
| } | |
| return ast; | |
| } | |
| export { parse }; |