blob: cd90ff2effd80a44b25d094a046bbdfa008134d6 [file] [edit]
// 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 };