diff --git a/CHANGELOG.md b/CHANGELOG.md index 97afb2c..4ad1d9f 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,18 @@ All notable changes to this project will be documented in this file. See [commit ### Bug fixes -- `reduceCalc` and the plugin no longer print an error for a - value nested more than 1024 levels deep which is not a math function, for - example when `calc(` only appears inside `url()`, a string or a comment. The +- `reduceCalc` and the plugin no longer report the nesting-limit error for a + value nested more than 1024 levels deep that has no supported math function + outside `url()`, a string or a comment, such as `url(calc(1+2))((((…`. The output is unchanged. +### Changes + +- `onParseError` and `onWarn` are now called in source order, one candidate at + a time, instead of all parse errors first. A value with several errors may + report a different first error, for example `clamp(.5/*c*/+!;)` now reports + the `+` error at position 13 instead of the `!`. The output is unchanged. + ## 11.2.3 (2026-10-10) ### Bug fixes diff --git a/scripts/benchmark/benchmark-serialization.js b/scripts/benchmark/benchmark-serialization.js index 0532497..0a99464 100644 --- a/scripts/benchmark/benchmark-serialization.js +++ b/scripts/benchmark/benchmark-serialization.js @@ -3,10 +3,9 @@ // through both the worktree serializer and the serializer from HEAD. import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { serialize as serializeWorktree } from '../../src/lib/serialize.js'; import { num, @@ -143,7 +142,12 @@ function benchmarkPair(worktreeSerializer, headSerializer, node, materialize) { /** @return {Promise} */ async function loadHeadSerializer() { - const root = mkdtempSync(join(tmpdir(), 'postcss-calc-serialize-')); + // Extract under the repository so the HEAD sources resolve node_modules. + const cache = fileURLToPath( + new URL('../../node_modules/.cache/', import.meta.url) + ); + mkdirSync(cache, { recursive: true }); + const root = mkdtempSync(join(cache, 'postcss-calc-serialize-')); try { const archive = execFileSync('git', ['archive', 'HEAD', '--', 'src/lib']); execFileSync('tar', ['-x', '-f', '-', '-C', root], { input: archive }); diff --git a/scripts/benchmark/parser-benchmark-worker.js b/scripts/benchmark/parser-benchmark-worker.js index b1e59db..a23d177 100644 --- a/scripts/benchmark/parser-benchmark-worker.js +++ b/scripts/benchmark/parser-benchmark-worker.js @@ -14,21 +14,15 @@ if (typeof sourceRoot !== 'string') throw new Error('missing parser source root'); const parser = await import(pathToFileURL(`${sourceRoot}/lib/parser.js`).href); -let blockIndex; -try { - blockIndex = await import( - pathToFileURL(`${sourceRoot}/lib/block-index.js`).href - ); -} catch (error) { - if ( - !(error instanceof Error) || - !('code' in error) || - error.code !== 'ERR_MODULE_NOT_FOUND' - ) { - throw error; - } - blockIndex = undefined; -} +// The candidate builds a closer array with `matchBlocks`; the baseline builds a +// `BlockIndex` with `indexBlocks`. Either is passed to `parse` as the fourth +// argument, so one timed closure serves both. +const closerBuilder = await importOptional(`${sourceRoot}/lib/blocks.js`).then( + (blocks) => blocks && ((tokens) => blocks.matchBlocks(tokens).close) +); +const buildCloser = + closerBuilder ?? + (await importOptional(`${sourceRoot}/lib/block-index.js`))?.indexBlocks; if (typeof parser.parse !== 'function') throw new Error('incompatible baseline parser API'); @@ -37,7 +31,51 @@ const MIN_WARMUPS = payload.warmupMinimum ?? 5; const MAX_WARMUPS = payload.warmupMaximum ?? 10; const MEASURED_BATCHES = payload.measuredBatchCount ?? 6; -function digest(value) { +/** @param {string} url @return {Promise} undefined when the module is absent */ +async function importOptional(url) { + try { + return await import(pathToFileURL(url).href); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ERR_MODULE_NOT_FOUND' + ) { + return undefined; + } + throw error; + } +} + +/** + * Opaque components were nested arrays before the flat representation. Both + * shapes describe the same text, so compare them by their flattened form: + * arrays are spliced in and adjacent strings are merged. + */ +function normalizeTree(value) { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(normalizeTree); + if (value.type === 'OpaqueCall') { + const parts = []; + const pushPart = (part) => { + if (Array.isArray(part)) { + for (const child of part) pushPart(child); + } else if (typeof part === 'string' && typeof parts.at(-1) === 'string') { + parts[parts.length - 1] += part; + } else { + parts.push(normalizeTree(part)); + } + }; + for (const part of value.components) pushPart(part); + return { ...value, components: parts }; + } + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, normalizeTree(child)]) + ); +} + +function digest(input) { + const value = normalizeTree(input); const hash = createHash('sha256'); const stack = [value]; while (stack.length) { @@ -75,14 +113,12 @@ function consume(value) { function makeRun(workload) { const tokens = tokenize({ css: workload.source }); - const index = - blockIndex && workload.mode === 'hot-shared-index' - ? blockIndex.indexBlocks(tokens) - : undefined; + const shared = buildCloser && workload.mode === 'hot-shared-index'; + const closer = shared ? buildCloser(tokens) : undefined; return () => { - if (!blockIndex) return parser.parse(tokens, 0, tokens.length); - const currentIndex = index ?? blockIndex.indexBlocks(tokens); - return parser.parse(tokens, 0, tokens.length, currentIndex); + if (!buildCloser) return parser.parse(tokens, 0, tokens.length); + const current = closer ?? buildCloser(tokens); + return parser.parse(tokens, 0, tokens.length, current); }; } diff --git a/scripts/lib/corpus-policy.js b/scripts/lib/corpus-policy.js index fd9a363..c2617cd 100644 --- a/scripts/lib/corpus-policy.js +++ b/scripts/lib/corpus-policy.js @@ -3,7 +3,6 @@ import { createHash } from 'node:crypto'; import { calc as csstoolsCalc } from '@csstools/css-calc'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -52,10 +51,9 @@ export const NEUTRAL_CORPUS_CATEGORIES = new Set([ export function ourOutput(input) { try { const tokens = tokenize({ css: input }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: COMPARE_PRECISION } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION, + }); } catch { return null; } @@ -137,7 +135,7 @@ export function stableHash(value) { export function rootShape(input) { const tokens = tokenize({ css: input }); - const ast = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const ast = parse(tokens, 0, tokens.length); const root = ast.type === 'Call' && ast.name.toLowerCase().endsWith('calc') && diff --git a/scripts/randomizer.js b/scripts/randomizer.js index 25e6869..5f06c99 100644 --- a/scripts/randomizer.js +++ b/scripts/randomizer.js @@ -11,7 +11,6 @@ import { fileURLToPath } from 'node:url'; import fc from 'fast-check'; import { calc as csstoolsCalc } from '@csstools/css-calc'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; @@ -32,10 +31,9 @@ const COMPARE_PRECISION = 9; function ourOut(input) { try { const tokens = tokenize({ css: input }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: COMPARE_PRECISION } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION, + }); } catch { return null; } diff --git a/scripts/show-divergences.js b/scripts/show-divergences.js index b95dee0..7aa7e07 100644 --- a/scripts/show-divergences.js +++ b/scripts/show-divergences.js @@ -1,6 +1,5 @@ // Bucket github-pure corpus divergences against @csstools/css-calc. import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; @@ -10,10 +9,9 @@ const lines = loadCorpus(); const ours = (s) => { try { const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: 10 } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: 10, + }); } catch { return null; } diff --git a/scripts/split-corpus.js b/scripts/split-corpus.js index f419285..530d049 100644 --- a/scripts/split-corpus.js +++ b/scripts/split-corpus.js @@ -6,7 +6,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; @@ -20,7 +19,7 @@ const PREPROC_RE = /#\{|\$[A-Za-z_]|@[A-Za-z_]|~["']/; function ourParserAccepts(s) { try { const tokens = tokenize({ css: s }); - serialize(simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), { + serialize(simplify(parse(tokens, 0, tokens.length)), { precision: 10, }); return true; diff --git a/src/lib/block-index.js b/src/lib/block-index.js deleted file mode 100644 index 667bf12..0000000 --- a/src/lib/block-index.js +++ /dev/null @@ -1,108 +0,0 @@ -import { TokenType as CssType } from '@csstools/css-tokenizer'; - -/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ - -const BLOCK_CLOSE = new Map([ - [CssType.Function, CssType.CloseParen], - [CssType.OpenParen, CssType.CloseParen], - [CssType.OpenSquare, CssType.CloseSquare], - [CssType.OpenCurly, CssType.CloseCurly], -]); - -/** - * Read-only delimiter navigation for one native token stream. - */ -class BlockIndex { - /** @type {CSSToken[]} */ - #tokens; - /** @type {Map} */ - #closes = new Map(); - /** @type {number} */ - #maxDepth = 0; - - /** - * Build the delimiter index once for a token stream. Matching remains - * LIFO: a mismatched closer is ignored and does not disturb the open stack. - * - * @param {CSSToken[]} tokens - */ - constructor(tokens) { - this.#tokens = tokens; - /** @type {{index: number, close: import('@csstools/css-tokenizer').TokenType}[]} */ - const stack = []; - - for (let i = 0; i < tokens.length; i++) { - const type = tokens[i][0]; - const close = BLOCK_CLOSE.get(type); - if (close !== undefined) { - stack.push({ index: i, close }); - this.#maxDepth = Math.max(this.#maxDepth, stack.length); - continue; - } - - if ( - type === CssType.CloseParen || - type === CssType.CloseSquare || - type === CssType.CloseCurly - ) { - const open = stack.at(-1); - if (open === undefined || open.close !== type) { - continue; - } - stack.pop(); - this.#closes.set(open.index, i); - } - } - } - - /** @return {number} */ - get maxDepth() { - return this.#maxDepth; - } - - /** @param {number | undefined} end @return {number} */ - #boundedEnd(end) { - return Math.min( - this.#tokens.length, - Math.max(0, end ?? this.#tokens.length) - ); - } - - /** @param {number} openPosition @param {number} [endPosition] @return {number} */ - closeOf(openPosition, endPosition) { - const close = this.#closes.get(openPosition) ?? -1; - const bound = this.#boundedEnd(endPosition); - return close < bound ? close : -1; - } - - /** @param {number} position @param {number} endPosition @return {number} */ - nextComponent(position, endPosition) { - const bound = this.#boundedEnd(endPosition); - if (position < 0 || position >= bound) return bound; - const close = this.closeOf(position, bound); - return close === -1 ? position + 1 : close + 1; - } - - /** @param {number} startPosition @param {number} endPosition @return {number} */ - firstTopLevelComma(startPosition, endPosition) { - const bound = this.#boundedEnd(endPosition); - for (let i = Math.max(0, startPosition); i < bound;) { - if (this.#tokens[i][0] === CssType.Comma) return i; - i = this.nextComponent(i, bound); - } - return -1; - } -} - -/** - * Build the delimiter index once for a token stream. Matching remains LIFO: a - * mismatched closer is ignored and does not disturb the open stack. - * - * @param {CSSToken[]} tokens - * @return {BlockIndex} - */ -function indexBlocks(tokens) { - return new BlockIndex(tokens); -} - -export { indexBlocks }; diff --git a/src/lib/blocks.js b/src/lib/blocks.js new file mode 100644 index 0000000..15fc4f2 --- /dev/null +++ b/src/lib/blocks.js @@ -0,0 +1,105 @@ +// Delimiter matching for one token list. `matchBlocks` pairs every opening +// token (`Function`, `(`, `[`, `{`) with its closing token, so navigation is +// index arithmetic on the result. +import { TokenType as CssType } from '@csstools/css-tokenizer'; + +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ + +/** + * Closing token type for each opening token type. + * + * @param {CssType} type + * @return {CssType | undefined} + */ +function closerOf(type) { + switch (type) { + case CssType.Function: + case CssType.OpenParen: + return CssType.CloseParen; + case CssType.OpenSquare: + return CssType.CloseSquare; + case CssType.OpenCurly: + return CssType.CloseCurly; + } + return undefined; +} + +/** + * Match every block opener in the list to its closer. Matching is LIFO: a + * closer that does not match the innermost open block is ignored, so the + * open stack survives the mismatch. Openers with no closer map to -1. + * + * `maxDepth` is the largest number of blocks open at once. The caller decides + * whether that exceeds the nesting limit. + * + * @param {CSSToken[]} tokens + * @return {{close: Int32Array, maxDepth: number}} `close[i]` is the closer index for opener `i`, or -1. + */ +function matchBlocks(tokens) { + const close = new Int32Array(tokens.length).fill(-1); + /** @type {number[]} */ + const openers = []; + /** @type {CssType[]} */ + const expected = []; + let maxDepth = 0; + for (let i = 0; i < tokens.length; i++) { + const type = tokens[i][0]; + const closer = closerOf(type); + if (closer !== undefined) { + openers.push(i); + expected.push(closer); + maxDepth = Math.max(maxDepth, openers.length); + continue; + } + if (expected.at(-1) !== type) continue; + close[/** @type {number} */ (openers.pop())] = i; + expected.pop(); + } + return { close, maxDepth }; +} + +/** + * The closer of the opener at `index`, or -1 when it has none inside + * `[index, end)`. + * + * @param {Int32Array} close + * @param {number} index + * @param {number} end + * @return {number} + */ +function closeIn(close, index, end) { + const closer = close[index]; + return closer !== -1 && closer < end ? closer : -1; +} + +/** + * Index after the component (a token, or a whole block) that starts at `index`, + * never past `end`. + * + * @param {Int32Array} close + * @param {number} index + * @param {number} end + * @return {number} + */ +function nextComponent(close, index, end) { + const closer = closeIn(close, index, end); + return closer === -1 ? index + 1 : closer + 1; +} + +/** + * First comma outside every block in `[start, end)`, or -1. + * + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {number} + */ +function firstTopLevelComma(tokens, close, start, end) { + for (let i = start; i < end; i = nextComponent(close, i, end)) { + if (tokens[i][0] === CssType.Comma) return i; + } + return -1; +} + +export { closeIn, firstTopLevelComma, matchBlocks, nextComponent }; diff --git a/src/lib/compile.js b/src/lib/compile.js deleted file mode 100644 index cffe100..0000000 --- a/src/lib/compile.js +++ /dev/null @@ -1,80 +0,0 @@ -import { parse } from './parser.js'; -import { simplify } from './simplify.js'; -import { analyze } from './analyze.js'; - -/** @typedef {import('./scan.js').Candidate} Candidate */ -/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ -/** @typedef {import('../reduce.js').Replacement} Replacement */ -/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ -/** @typedef {ReturnType} BlockIndex */ -/** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */ - -/** - * Parse, analyze, and simplify one candidate. Analysis is the validity/status - * gate over the original tree; simplification then runs independently as a - * composable AST transformation that may synthesize nodes. - * - * @param {Candidate} candidate - * @param {CompileContext} ctx - * @return {Replacement} - */ -function compileCandidate(candidate, ctx) { - if (!candidate.closed) { - throw new Error( - `Unclosed ${candidate.name}( at position ${candidate.start}` - ); - } - const parsed = parse( - ctx.tokens, - candidate.sliceStart, - candidate.sliceEnd, - ctx.index - ); - const analysis = analyze(parsed); - if (!analysis.valid) { - throw new Error('Invalid CSS calculation type'); - } - const tree = simplify(parsed, ctx.options.precision); - const original = - analysis.unresolved && !candidate.calculation - ? ctx.value.slice(candidate.start, candidate.end) - : undefined; - return { - start: candidate.start, - end: candidate.end, - result: { - tree, - status: analysis.unresolved ? 'unresolved' : 'resolved', - rootName: candidate.normalizedName, - rootSpelling: candidate.rootSpelling, - calculation: candidate.calculation, - original, - }, - }; -} - -/** - * Compile candidates independently so one malformed calculation is preserved - * without preventing unrelated candidates from being reduced. - * - * @param {Candidate[]} candidates - * @param {CompileContext} ctx - * @return {Replacement[]} - */ -function compileCandidates(candidates, ctx) { - /** @type {Replacement[]} */ - const replacements = []; - for (const candidate of candidates) { - try { - replacements.push(compileCandidate(candidate, ctx)); - } catch (error) { - ctx.options.onParseError?.( - error instanceof Error ? error : new Error('Error', { cause: error }), - ctx.value.slice(candidate.start, candidate.end) - ); - } - } - return replacements; -} - -export { compileCandidate, compileCandidates }; diff --git a/src/lib/core.js b/src/lib/core.js new file mode 100644 index 0000000..1bcda4e --- /dev/null +++ b/src/lib/core.js @@ -0,0 +1,146 @@ +// The reducer core: scan a token list for supported math functions, compile +// each one, and report every changed range. The string reducer owns +// tokenizing and splicing; this module owns nothing else. +import { parseRange } from './parser.js'; +import { ParseError, positionedError } from './parser/state.js'; +import { simplify } from './simplify.js'; +import { analyze } from './analyze.js'; +import { serializeResult } from './serialize.js'; +import { matchBlocks } from './blocks.js'; +import { assertDepth } from './limits.js'; +import { hasMathFunctionToken, mathFunctionAt } from './functions.js'; +import { joinTokens, offsetLookup } from './offsets.js'; + +/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +/** @typedef {import('./functions.js').MathFunction} MathFunction */ + +/** + * Receives one replaced range. `start` and `end` are token indices, `end` + * exclusive. + * + * @callback EmitReplacement + * @param {number} start + * @param {number} end + * @param {string} text + * @return {void} + */ + +/** + * @param {unknown} error + * @return {Error} + */ +function asError(error) { + return error instanceof Error ? error : new Error('Error', { cause: error }); +} + +/** + * Parse, analyze and simplify one supported math function, then serialize it. + * A throw from parsing or analysis means the range is not a valid calculation. + * A throw from serialization is reported and yields no replacement text. + * + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start Index of the function token. + * @param {number} end Index after its closer, or `tokens.length` when unclosed. + * @param {{normalizedName: string, definition: MathFunction}} lookup + * @param {ResolvedReduceCalcOptions} options + * @return {{text: string | undefined, unresolved: boolean}} + */ +function compileCandidate(tokens, close, start, end, lookup, options) { + const name = /** @type {{value: string}} */ (tokens[start][4]).value; + const isCalc = lookup.definition.calculation === true; + if (close[start] === -1) throw new ParseError(`Unclosed ${name}(`, start); + // Unpositioned parse errors: the caller positions them with a shared lookup. + const parsed = isCalc + ? parseRange(tokens, close, start + 1, close[start]) + : parseRange(tokens, close, start, end); + const analysis = analyze(parsed); + if (!analysis.valid) { + throw new Error('Invalid CSS calculation type'); + } + const tree = simplify(parsed, options.precision); + const unresolved = analysis.unresolved; + const original = + unresolved && !isCalc ? joinTokens(tokens, start, end) : undefined; + /** @type {'resolved' | 'unresolved'} */ + const status = unresolved ? 'unresolved' : 'resolved'; + const result = { + tree, + status, + rootName: lookup.normalizedName, + rootSpelling: tokens[start][1].slice(0, -1), + calculation: isCalc, + original, + }; + try { + return { + text: serializeResult(result, { + precision: options.precision, + unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber, + unwrapSingleValue: options.unwrapSingleValue, + }), + unresolved, + }; + } catch (error) { + options.onParseError?.(asError(error), joinTokens(tokens, start, end)); + return { text: undefined, unresolved }; + } +} + +/** + * Reduce every supported math function in a token list. Each function is one + * candidate, so a nested function is never reduced on its own, and an invalid + * outer function is reported without partial output. + * + + * @param {CSSToken[]} tokens + * @param {ResolvedReduceCalcOptions} options + * @param {EmitReplacement} emit + * @param {string} source The joined token text. + * @return {void} + */ +function reduceTokenList(tokens, options, emit, source) { + if (!hasMathFunctionToken(tokens)) return; + + /** @type {ReturnType} */ + let blocks; + try { + blocks = matchBlocks(tokens); + assertDepth(blocks.maxDepth); + } catch (error) { + options.onParseError?.(asError(error), source); + return; + } + const { close } = blocks; + + const offsetOf = offsetLookup(tokens); + for (let i = 0; i < tokens.length; i++) { + const lookup = mathFunctionAt(tokens, i); + if (lookup === undefined) continue; + const end = close[i] === -1 ? tokens.length : close[i] + 1; + + /** @type {ReturnType | undefined} */ + let result; + try { + result = compileCandidate(tokens, close, i, end, lookup, options); + } catch (error) { + options.onParseError?.( + positionedError(asError(error), offsetOf), + joinTokens(tokens, i, end) + ); + } + if (result !== undefined) { + if (options.warnWhenCannotResolve && result.unresolved) { + options.onWarn?.('Could not reduce expression: ' + source); + } + if (result.text !== undefined) emit(i, end, result.text); + } + // A supported function is one candidate, so its nested calls are skipped. + // An unclosed one runs to the end of the list. + if (close[i] === -1) break; + i = close[i]; + } +} + +export { reduceTokenList }; diff --git a/src/lib/functions.js b/src/lib/functions.js index a17ebfc..12b58e3 100644 --- a/src/lib/functions.js +++ b/src/lib/functions.js @@ -239,6 +239,21 @@ function hasMathFunctionToken(tokens) { return false; } +/** + * The supported math function named by the function token at `index`, or + * `undefined` when that token is not a supported math function. + * + * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens + * @param {number} index + * @return {ReturnType} + */ +function mathFunctionAt(tokens, index) { + const token = tokens[index]; + return token[0] === CssType.Function + ? lookupMathFunction(token[4].value) + : undefined; +} + export { addTypes, failureType, @@ -248,6 +263,7 @@ export { isFailure, isPercentage, lookupMathFunction, + mathFunctionAt, mathFunctions, numberType, percentageType, diff --git a/src/lib/limits.js b/src/lib/limits.js index 19d1f36..8c5b32c 100644 --- a/src/lib/limits.js +++ b/src/lib/limits.js @@ -11,17 +11,6 @@ function assertDepth(depth) { } } -/** @param {unknown} part @param {number} depth @return {void} */ -function checkOpaquePartDepth(part, depth) { - assertDepth(depth); - if (typeof part === 'string') return; - if (Array.isArray(part)) { - for (const child of part) checkOpaquePartDepth(child, depth + 1); - return; - } - checkCalculationDepth(/** @type {Node} */ (part), depth + 1); -} - /** @param {Node} node @param {number} [depth] @return {void} */ function checkCalculationDepth(node, depth = 0) { assertDepth(depth); @@ -40,8 +29,12 @@ function checkCalculationDepth(node, depth = 0) { for (const child of node.args) checkCalculationDepth(child, depth + 1); return; case 'OpaqueCall': + // Components sit one level below the call, and a node component one + // level deeper again. This keeps the accepted depth unchanged from when + // components were nested arrays. for (const part of node.components) { - checkOpaquePartDepth(part, depth + 1); + assertDepth(depth + 1); + if (typeof part !== 'string') checkCalculationDepth(part, depth + 2); } } } diff --git a/src/lib/node.js b/src/lib/node.js index fb58898..b046664 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -27,7 +27,7 @@ * @typedef {{type: 'Dim', value: number, unit: string, rawUnit?: string}} Dim * @typedef {{type: 'Ident', name: string, rawName?: string}} Ident * @typedef {{type: 'Call', name: string, args: Node[], rawName?: string}} Call - * @typedef {string | Node | OpaqueComponent[]} OpaqueComponent + * @typedef {string | Node} OpaqueComponent * @typedef {{type: 'OpaqueCall', name: string, components: OpaqueComponent[], rawName?: string}} OpaqueCall * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim. * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum diff --git a/src/lib/offsets.js b/src/lib/offsets.js new file mode 100644 index 0000000..1638a92 --- /dev/null +++ b/src/lib/offsets.js @@ -0,0 +1,41 @@ +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ + +/** + * Maps a token index to the offset in the joined raw text of `tokens` where + * that token starts, or the total length past the end. Only diagnostics need + * offsets, and a fallback can report many of them, so the table is built on + * the first call and each lookup after that is constant time. + * + * @param {CSSToken[]} tokens + * @return {(index: number) => number} + */ +function offsetLookup(tokens) { + /** @type {Int32Array | undefined} */ + let starts; + return (index) => { + if (starts === undefined) { + starts = new Int32Array(tokens.length + 1); + let offset = 0; + for (let i = 0; i < tokens.length; i++) { + starts[i] = offset; + offset += tokens[i][1].length; + } + starts[tokens.length] = offset; + } + return starts[Math.min(index, tokens.length)]; + }; +} + +/** + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + * @return {string} + */ +function joinTokens(tokens, start, end) { + let text = ''; + for (let i = start; i < end; i++) text += tokens[i][1]; + return text; +} + +export { joinTokens, offsetLookup }; diff --git a/src/lib/opaque.js b/src/lib/opaque.js index cca92ff..4a2cdb8 100644 --- a/src/lib/opaque.js +++ b/src/lib/opaque.js @@ -1,20 +1,28 @@ /** @typedef {import('./node.js').Node} Node */ /** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */ -/** @param {OpaqueComponent[]} components @param {(node: Node) => Node} simplify @return {OpaqueComponent[]} */ +/** + * @param {OpaqueComponent[]} components + * @param {(node: Node) => Node} simplify + * @return {OpaqueComponent[]} + */ function simplifyComponents(components, simplify) { - return components.map((part) => { - if (typeof part === 'string') return part; - if (Array.isArray(part)) return simplifyComponents(part, simplify); - return simplify(part); - }); + return components.map((part) => + typeof part === 'string' ? part : simplify(part) + ); } -/** @param {OpaqueComponent[]} components @param {string[]} buffer @param {(node: Node, buffer: string[]) => void} serialize @return {void} */ + +/** + * @param {OpaqueComponent[]} components + * @param {string[]} buffer + * @param {(node: Node, buffer: string[]) => void} serialize + * @return {void} + */ function serializeComponents(components, buffer, serialize) { for (const part of components) { if (typeof part === 'string') buffer.push(part); - else if (Array.isArray(part)) serializeComponents(part, buffer, serialize); else serialize(part, buffer); } } + export { simplifyComponents, serializeComponents }; diff --git a/src/lib/options.js b/src/lib/options.js new file mode 100644 index 0000000..617bdd8 --- /dev/null +++ b/src/lib/options.js @@ -0,0 +1,19 @@ +/** @typedef {import('../reduce.js').ReduceCalcOptions} ReduceCalcOptions */ +/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ + +/** + * @param {ReduceCalcOptions} [opts] + * @return {ResolvedReduceCalcOptions} + */ +function resolveOptions(opts) { + return { + warnWhenCannotResolve: false, + unwrapSingleNegativeNumber: false, + unwrapSingleValue: false, + ...opts, + // An explicit `precision: undefined` keeps the default. + precision: opts?.precision ?? 5, + }; +} + +export { resolveOptions }; diff --git a/src/lib/parser.js b/src/lib/parser.js index 05e6560..07c7ad4 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,4 +1,11 @@ -// Pratt parser over native @csstools/css-tokenizer tokens. +// Recursive-descent parser over native @csstools/css-tokenizer tokens. +// +// A range is `[start, end)` of token indices. Whitespace, comments and +// end-of-file tokens are trivia: they separate tokens but are never parsed. +// A parenthesized group or argument list is parsed over a sub-range that +// includes its closing parenthesis, so the closer is read as an ordinary token +// and must be the next token once the contents are consumed. +import { TokenType as CssType } from '@csstools/css-tokenizer'; import { baseOf } from './convertUnits.js'; import { call, @@ -11,26 +18,38 @@ import { } from './node.js'; import { lookupMathFunction } from './functions.js'; import { assertDepth } from './limits.js'; +import { closeIn, matchBlocks } from './blocks.js'; +import { offsetLookup } from './offsets.js'; import { parseOpaqueCall, parseVar } from './parser/opaque.js'; +import { sourceSpelling } from './parser/tokens.js'; +import { CSS_NUMBER_PREFIX } from './regex.js'; +/** @typedef {import('./parser/state.js').ParseState} ParseState */ import { - Cursor, - expectPunct, - isPunct, - matchPunct, - peekToken, - sourceSpelling, - takeToken, -} from './parser/tokens.js'; + ParseError, + positionedError, + newState, + peek, + rejectSignedNumber, + requireSurroundingWhitespace, + tokenValue, +} from './parser/state.js'; /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ /** @typedef {import('./node.js').Node} Node */ -/** @typedef {import('./parser/tokens.js').BlockIndex} BlockIndex */ -/** @typedef {import('./parser/tokens.js').ParseInput} ParseInput */ -/** @typedef {import('./parser/tokens.js').Token} Token */ -/** @typedef {import('./parser/tokens.js').FunctionToken} FunctionToken */ +/** @typedef {import('./node.js').SumTerm} SumTerm */ +/** @typedef {import('./node.js').ProductFactor} ProductFactor */ /** - * CSS numeric tokens do not retain a signed-zero distinction. Keep that + * @callback ParseRange + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {Node} + */ + +/** + * CSS numeric tokens do not retain a signed-zero distinction. Keep that * normalization at the source boundary so a later IEEE-754 `-0` can only * have been introduced by calculation evaluation. * @@ -41,91 +60,81 @@ function normalizeSourceZero(value) { return value === 0 ? 0 : value; } -/** @param {ParseInput} input @param {Cursor} cursor @param {Token} token @param {number} depth @return {Node} */ -function parsePrefix(input, cursor, token, depth) { - switch (token.type) { - case 'number': - return num(normalizeSourceZero(token.value)); - case 'dimension': { - const unit = token.unit.toLowerCase(); - return dim( - normalizeSourceZero(token.value), - unit, - baseOf(unit) || token.rawUnit === unit ? undefined : token.rawUnit - ); - } - case 'ident': - return ( - foldCalcKeyword(token.value) ?? - ident(token.value, sourceSpelling(token.raw, token.value)) - ); - case 'function': - return parseCall(input, cursor, token, depth); - case 'punct': - switch (token.value) { - case '(': { - const expression = parseExpr(input, cursor, 0, depth + 1); - expectPunct(input, cursor, ')'); - return groupSubstitution(expression); - } - } - // No unary `+`/`-` production exists in the grammar; a - // sign is only valid inside a number/dimension token or as a binary - // operator. `-(...)` therefore fails to parse and is preserved. - } - throw new Error(`Unexpected token "${token.raw}" at position ${token.pos}`); +/** + * The delimiter at `index` when it is `a` or `b`, else undefined. + * + * @param {ParseState} state + * @param {number} index + * @param {string} a + * @param {string} b + * @return {string | undefined} + */ +function delimAt(state, index, a, b) { + const token = state.tokens[index]; + if (token[0] !== CssType.Delim) return undefined; + const op = token[4].value; + return op === a || op === b ? op : undefined; } -/** @param {ParseInput} input @param {Cursor} cursor @param {number} minBp @param {number} depth @return {Node} */ -function parseExpr(input, cursor, minBp = 0, depth = 0) { +/** + * Sum: products joined by `+` and `-`, which must be surrounded by whitespace. + * + * @param {ParseState} state + * @param {number} depth + * @return {Node} + */ +function parseSum(state, depth) { assertDepth(depth); - const t = takeToken(input, cursor); - let left = parsePrefix(input, cursor, t, depth); - - while (true) { - const nxt = peekToken(input, cursor); - if ( - (nxt.type === 'number' || nxt.type === 'dimension') && - nxt.signCharacter !== undefined - ) { - throw new Error( - `"${nxt.signCharacter}" must be surrounded by whitespace at position ${nxt.pos}` - ); - } - if (nxt.type !== 'punct') break; - const op = nxt.value; - if (op === '+' || op === '-') { - if (ADD_BP < minBp) break; - /** @type {import('./node.js').SumTerm[]} */ - const terms = [{ sign: /** @type {1} */ (1), node: left }]; - do { - const token = takeToken(input, cursor); - requireSurroundingWs(input, cursor, token); - terms.push({ - sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1), - node: parseExpr(input, cursor, ADD_BP + 1, depth), - }); - } while (isPunct(input, cursor, '+', '-')); - left = mkSum(terms); - continue; - } - if (op === '*' || op === '/') { - if (MUL_BP < minBp) break; - /** @type {import('./node.js').ProductFactor[]} */ - const factors = [{ exponent: /** @type {1} */ (1), node: left }]; - do { - const token = takeToken(input, cursor); - factors.push({ - exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1), - node: parseExpr(input, cursor, MUL_BP + 1, depth), - }); - } while (isPunct(input, cursor, '*', '/')); - left = mkProduct(factors); - continue; - } - break; + const first = parseProduct(state, depth); + /** @type {SumTerm[]} */ + const terms = [{ sign: 1, node: first }]; + for (let i = peek(state); i < state.end; i = peek(state)) { + const op = delimAt(state, i, '+', '-'); + if (op === undefined) break; + requireSurroundingWhitespace(state, i); + state.pos = i + 1; + terms.push({ + sign: op === '+' ? 1 : -1, + node: parseProduct(state, depth), + }); } - return left; + return terms.length === 1 ? first : mkSum(terms); +} + +/** + * Product: operands joined by `*` and `/`. Whitespace is not required. + * + * @param {ParseState} state + * @param {number} depth + * @return {Node} + */ +function parseProduct(state, depth) { + const first = parseOperand(state, depth); + /** @type {ProductFactor[]} */ + const factors = [{ exponent: 1, node: first }]; + for (let i = peek(state); i < state.end; i = peek(state)) { + const op = delimAt(state, i, '*', '/'); + if (op === undefined) break; + state.pos = i + 1; + factors.push({ + exponent: op === '*' ? 1 : -1, + node: parseOperand(state, depth), + }); + } + return factors.length === 1 ? first : mkProduct(factors); +} + +/** + * One operand, followed by the check that no signed number directly follows it. + * + * @param {ParseState} state + * @param {number} depth + * @return {Node} + */ +function parseOperand(state, depth) { + const node = parseValue(state, depth); + rejectSignedNumber(state); + return node; } /** §10.9 — case-insensitive except for NaN. @param {string} name @return {Node | null} */ @@ -144,62 +153,196 @@ function foldCalcKeyword(name) { return null; } -const ADD_BP = 1; -const MUL_BP = 3; -/** @param {ParseInput} input @param {Cursor} cursor @param {Token} token */ -function requireSurroundingWs(input, cursor, token) { - if (!token.ws || !peekToken(input, cursor).ws) - throw new Error( - `"${token.value}" must be surrounded by whitespace at position ${token.pos}` - ); +/** + * One value: a number, dimension, keyword, function, or parenthesized group. + * A unary sign is not part of the grammar, so `-(...)` is + * rejected here. + * + * @param {ParseState} state + * @param {number} depth + * @return {Node} + */ +function parseValue(state, depth) { + const i = peek(state); + if (i >= state.end) throw new ParseError('Unexpected token ""', i); + const token = state.tokens[i]; + state.pos = i + 1; + switch (token[0]) { + case CssType.Number: + return num(normalizeSourceZero(token[4].value)); + case CssType.Dimension: + case CssType.Percentage: + return dimension(token); + case CssType.Ident: + return ( + foldCalcKeyword(token[4].value) ?? + ident(token[4].value, sourceSpelling(token[1], token[4].value)) + ); + case CssType.Function: + return parseCall(state, i, depth); + case CssType.OpenParen: + return parseGroup(state, i, depth); + } + throw new ParseError(`Unexpected token "${token[1]}"`, i); } -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} t @param {number} depth @return {Node} */ -function parseCall(input, cursor, t, depth) { - const name = t.value; - const rawName = t.raw.slice(0, -1); +/** + * @param {CSSToken} token + * @return {Node} + */ +function dimension(token) { + const detail = /** @type {{value: number, unit: string}} */ (token[4]); + const value = normalizeSourceZero(detail.value); + if (token[0] === CssType.Percentage) return dim(value, '%'); + const unit = detail.unit.toLowerCase(); + const match = CSS_NUMBER_PREFIX.exec(token[1]); + const rawUnit = match ? token[1].slice(match[0].length) : detail.unit; + return dim( + value, + unit, + baseOf(unit) || rawUnit === unit ? undefined : rawUnit + ); +} + +/** + * A parenthesized group. Its contents must be followed by the closing + * parenthesis. + * + * @param {ParseState} state + * @param {number} open Index of the `(` token. + * @param {number} depth + * @return {Node} + */ +function parseGroup(state, open, depth) { + const closer = closeIn(state.close, open, state.end); + const sub = subRange(state, open, closer); + const expression = parseSum(sub, depth + 1); + expectCloser(sub, closer); + state.pos = closer + 1; + return groupSubstitution(expression); +} + +/** + * @param {ParseState} state + * @param {number} open + * @param {number} closer `-1` when unclosed. + * @return {ParseState} + */ +function subRange(state, open, closer) { + const end = closer === -1 ? state.end : closer + 1; + return newState(state.tokens, state.close, open + 1, end); +} + +/** + * Consume the closing parenthesis of a sub-range, or fail on the token that + * stands in its place. + * + * @param {ParseState} sub + * @param {number} closer + * @return {void} + */ +function expectCloser(sub, closer) { + const i = peek(sub); + if (closer !== -1 && i === closer) { + sub.pos = i + 1; + return; + } + throw new ParseError( + 'Expected )', + i, + `, got "${i < sub.end ? tokenValue(sub.tokens[i]) : ''}"` + ); +} + +/** + * A function call to a supported math function (`min()`, `clamp()`, ...), or + * an opaque or substitution function. + * + * @param {ParseState} state + * @param {number} index Index of the function token. + * @param {number} depth + * @return {Node} + */ +function parseCall(state, index, depth) { + const token = state.tokens[index]; + const name = /** @type {{value: string}} */ (token[4]).value; + const rawName = token[1].slice(0, -1); if (name.toLowerCase() === 'var') - return parseVar(input, cursor, t, name, rawName, parseRange); + return parseVar(state, index, name, rawName, parseRange); if (lookupMathFunction(name) === undefined) - return parseOpaqueCall(input, cursor, t, name, rawName, parseRange); - /** @type {Node[]} */ const args = []; - if (!isPunct(input, cursor, ')')) { - args.push(parseExpr(input, cursor, 0, depth + 1)); - while (matchPunct(input, cursor, ',')) - args.push(parseExpr(input, cursor, 0, depth + 1)); + return parseOpaqueCall(state, index, name, rawName, parseRange); + const closer = closeIn(state.close, index, state.end); + const sub = subRange(state, index, closer); + /** @type {Node[]} */ + const args = []; + if (peek(sub) !== closer) { + args.push(parseSum(sub, depth + 1)); + for ( + let i = peek(sub); + i < sub.end && sub.tokens[i][0] === CssType.Comma; + i = peek(sub) + ) { + sub.pos = i + 1; + args.push(parseSum(sub, depth + 1)); + } } - expectPunct(input, cursor, ')'); + expectCloser(sub, closer); + state.pos = closer + 1; return call(name, args, sourceSpelling(rawName, name)); } /** - * @param {ParseInput} input + * Parse `[start, end)` as one complete expression. + * + * @param {CSSToken[]} tokens + * @param {Int32Array} close * @param {number} start * @param {number} end * @return {Node} */ -function parseRange(input, start, end) { - const bounded = - input.end === end - ? input - : /** @type {ParseInput} */ ({ - tokens: input.tokens, - end, - index: input.index, - }); - const cursor = new Cursor(start); - const ast = parseExpr(bounded, cursor, 0, 0); - const trailing = peekToken(bounded, cursor); - if (trailing.type !== 'eof') - throw new Error( - `Unexpected token "${trailing.raw}" at position ${trailing.pos}` - ); +function parseRange(tokens, close, start, end) { + const state = newState(tokens, close, start, end); + const ast = parseSum(state, 0); + const trailing = peek(state); + if (trailing < end) { + throw new ParseError(`Unexpected token "${tokens[trailing][1]}"`, trailing); + } return ast; } -/** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {BlockIndex} index @return {Node} */ -function parse(tokens, start, end, index) { - return parseRange({ tokens, end, index }, start, end); +/** + * The closer array for `tokens`, rejected when the list nests past the limit. + * `parse()` uses it when its caller supplies no closer array. + * + * @param {CSSToken[]} tokens + * @return {Int32Array} + */ +function closersWithinLimit(tokens) { + const { close, maxDepth } = matchBlocks(tokens); + assertDepth(maxDepth); + return close; +} + +/** + * Parse the token range `[start, end)` of `tokens` as one calculation. + * `close` is the closer array from `matchBlocks(tokens)`; when omitted, it is + * built and depth-checked from `tokens`. + * + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + * @param {Int32Array} [close] + * @return {Node} + */ +function parse(tokens, start, end, close = closersWithinLimit(tokens)) { + try { + return parseRange(tokens, close, start, end); + } catch (error) { + if (error instanceof ParseError) { + throw positionedError(error, offsetLookup(tokens)); + } + throw error; + } } -export { parse }; +export { parse, parseRange }; diff --git a/src/lib/parser/opaque.js b/src/lib/parser/opaque.js index 47ba169..b554e94 100644 --- a/src/lib/parser/opaque.js +++ b/src/lib/parser/opaque.js @@ -2,16 +2,16 @@ import { TokenType as CssType } from '@csstools/css-tokenizer'; import { ident, opaqueCall } from '../node.js'; import { lookupMathFunction } from '../functions.js'; -import { assertDepth } from '../limits.js'; -import { eofPositionAt, sourceSpelling } from './tokens.js'; +import { closeIn, firstTopLevelComma } from '../blocks.js'; +import { joinTokens } from '../offsets.js'; +import { ParseError, isTrivia } from './state.js'; +import { sourceSpelling } from './tokens.js'; /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ /** @typedef {import('../node.js').Node} Node */ /** @typedef {import('../node.js').OpaqueComponent} OpaqueComponent */ -/** @typedef {import('./tokens.js').ParseInput} ParseInput */ -/** @typedef {import('./tokens.js').Cursor} Cursor */ -/** @typedef {import('./tokens.js').FunctionToken} FunctionToken */ -/** @typedef {(input: ParseInput, start: number, end: number) => Node} ParseRange */ +/** @typedef {import('./state.js').ParseState} ParseState */ +/** @typedef {import('../parser.js').ParseRange} ParseRange */ /** @param {OpaqueComponent[]} target @param {OpaqueComponent} part */ function pushComponent(target, part) { @@ -20,41 +20,52 @@ function pushComponent(target, part) { else target.push(part); } -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @return {number} */ -function closeOfCall(input, cursor, token, name) { - const close = input.index.closeOf(token.index, input.end); - if (close === -1) - throw new Error( - `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` - ); +/** + * The closer of the call whose function token is `index`. The state has + * already moved past that token, so the position names the token after it. + * + * @param {ParseState} state + * @param {number} index + * @param {string} name + * @return {number} + */ +function closeOfCall(state, index, name) { + const close = closeIn(state.close, index, state.end); + if (close === -1) throw new ParseError(`Unclosed ${name}(`, state.pos); return close; } -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ -function parseOpaqueCall(input, cursor, token, name, rawName, parseRange) { - const close = closeOfCall(input, cursor, token, name); - cursor.skipTo(close + 1); +/** + * @param {ParseState} state + * @param {number} index + * @param {string} name + * @param {string} rawName + * @param {ParseRange} parseRange + * @return {Node} + */ +function parseOpaqueCall(state, index, name, rawName, parseRange) { + const close = closeOfCall(state, index, name); + state.pos = close + 1; return opaqueCall( name, - componentTree(input, token.index + 1, close, parseRange, []), + componentTree(state, index + 1, close, parseRange, []), sourceSpelling(rawName, name) ); } -/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ -function rawTokens(tokens, start, end) { - let raw = ''; - for (let i = start; i < end; i++) raw += tokens[i][1]; - return raw; -} -/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ +/** + * The custom-property ident that is the only token before any comma. + * + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + */ function customProperty(tokens, start, end) { /** @type {CSSToken | null} */ let found = null; let foundIndex = -1; for (let i = start; i < end; i++) { - const type = tokens[i][0]; - if (type === CssType.Whitespace || type === CssType.Comment) continue; + if (isTrivia(tokens[i][0])) continue; if (found !== null) return null; found = tokens[i]; foundIndex = i; @@ -65,83 +76,69 @@ function customProperty(tokens, start, end) { ? { decoded, raw: found[1], index: foundIndex } : null; } + /** - * Append the component tree of `[start, end)` to `root` and return it. - * Callers seed `root` so leading components need no copy or spread. - * @param {ParseInput} input @param {number} start @param {number} rangeEnd @param {ParseRange} parseRange @param {OpaqueComponent[]} root @return {OpaqueComponent[]} + * Append the components of `[start, rangeEnd)` to `root` and return it. Raw + * token text is merged into the preceding string. A supported math function + * becomes a parsed node, or raw text when it does not parse, and the scan + * resumes after its closer. Other blocks are plain text, so their contents + * are scanned as ordinary tokens. + * + * @param {ParseState} state + * @param {number} start + * @param {number} rangeEnd + * @param {ParseRange} parseRange + * @param {OpaqueComponent[]} root Callers seed `root` so leading components need no copy. + * @return {OpaqueComponent[]} */ -function componentTree(input, start, rangeEnd, parseRange, root) { - /** @type {OpaqueComponent[]} */ let tree = root; - let end = rangeEnd; - /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */ - const frames = []; - const { tokens } = input; +function componentTree(state, start, rangeEnd, parseRange, root) { + const { tokens, close: closers } = state; let i = start; - while (true) { - if (i >= end) { - if (frames.length === 0) break; - const frame = - /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}} */ ( - frames.pop() - ); - tree = frame.parent; - pushComponent(tree, frame.tree); - pushComponent(tree, tokens[frame.close][1]); - end = frame.end; - i = frame.close + 1; - continue; - } - + while (i < rangeEnd) { const token = tokens[i]; - const close = input.index.closeOf(i, end); - if (close === -1) { - pushComponent(tree, token[1]); - i++; - continue; - } - - const isMathFunction = + const close = closeIn(closers, i, rangeEnd); + if ( + close !== -1 && token[0] === CssType.Function && - lookupMathFunction(token[4].value) !== undefined; - if (isMathFunction) { + lookupMathFunction(token[4].value) !== undefined + ) { try { - pushComponent(tree, parseRange(input, i, close + 1)); + pushComponent(root, parseRange(tokens, closers, i, close + 1)); } catch { - pushComponent(tree, rawTokens(tokens, i, close + 1)); + pushComponent(root, joinTokens(tokens, i, close + 1)); } i = close + 1; continue; } - - pushComponent(tree, token[1]); - assertDepth(frames.length + 1); - /** @type {OpaqueComponent[]} */ - const child = []; - frames.push({ parent: tree, tree: child, close, end }); - tree = child; - end = close; + pushComponent(root, token[1]); i++; } return root; } -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ -function parseVar(input, cursor, token, name, rawName, parseRange) { - const { tokens } = input; - const start = token.index + 1; - const close = closeOfCall(input, cursor, token, name); - const comma = input.index.firstTopLevelComma(start, close); + +/** + * @param {ParseState} state + * @param {number} index + * @param {string} name + * @param {string} rawName + * @param {ParseRange} parseRange + * @return {Node} + */ +function parseVar(state, index, name, rawName, parseRange) { + const { tokens } = state; + const start = index + 1; + const close = closeOfCall(state, index, name); + const comma = firstTopLevelComma(tokens, state.close, start, close); const property = customProperty(tokens, start, comma === -1 ? close : comma); if (!property) - throw new Error( - `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? eofPositionAt(input, cursor.index)}` - ); - cursor.skipTo(close + 1); + throw new ParseError(`Invalid custom property in ${name}()`, start); + state.pos = close + 1; /** @type {OpaqueComponent[]} */ const components = [ ident(property.decoded, sourceSpelling(property.raw, property.decoded)), ]; if (comma !== -1) - componentTree(input, property.index + 1, close, parseRange, components); + componentTree(state, property.index + 1, close, parseRange, components); return opaqueCall(name, components, sourceSpelling(rawName, name)); } diff --git a/src/lib/parser/state.js b/src/lib/parser/state.js new file mode 100644 index 0000000..5ddd6f2 --- /dev/null +++ b/src/lib/parser/state.js @@ -0,0 +1,233 @@ +// Token-range state for the recursive-descent parser: the cursor over one +// range, the whitespace rule for operators, and the checks that read a token +// without consuming it. +import { TokenType as CssType } from '@csstools/css-tokenizer'; + +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ + +/** + * A syntax error at a token. The position is not formatted here: a fallback + * that fails to parse is discarded, so formatting it would be wasted work. + * `parse()` formats the position when the error leaves the parser. + */ +class ParseError extends Error { + /** + * @param {string} message Text before the position. + * @param {number} index Token whose source offset is the position. + * @param {string} [suffix] Text after the position. + */ + constructor(message, index, suffix = '') { + super(message); + this.index = index; + this.suffix = suffix; + } +} + +/** + * The error to report for `error`: a parse error gets the position added to + * its message, and any other error is returned unchanged. The parse error is + * reused because capturing a second stack trace dominates the cost of a + * reported failure. + * + * @param {Error} error + * @param {(index: number) => number} offsetOf Offset lookup for the list. + * @return {Error} + */ +function positionedError(error, offsetOf) { + if (!(error instanceof ParseError)) return error; + error.message = `${error.message} at position ${offsetOf(error.index)}${error.suffix}`; + return error; +} + +/** @param {CssType} type @return {boolean} */ +function isTrivia(type) { + return ( + type === CssType.Whitespace || + type === CssType.Comment || + type === CssType.EOF + ); +} + +/** + * One parse range. `pos` is the next token to read. `close` is the closer + * array for the whole list, shared by every range. + * + * @typedef {object} ParseState + * @property {CSSToken[]} tokens + * @property {Int32Array} close + * @property {number} start First token of the range, for the leading-whitespace rule. + * @property {number} end Exclusive bound. + * @property {number} pos + */ + +/** @param {string} value @return {value is '+' | '-' | '*' | '/'} */ +function isOperator(value) { + return value === '+' || value === '-' || value === '*' || value === '/'; +} + +/** + * Whether the grammar can contain this token. Anything else is rejected where + * it is read. + * + * @param {CSSToken} token + * @return {boolean} + */ +function isExpressionToken(token) { + switch (token[0]) { + case CssType.Number: + case CssType.Dimension: + case CssType.Percentage: + case CssType.Ident: + case CssType.Function: + case CssType.OpenParen: + case CssType.CloseParen: + case CssType.Comma: + return true; + case CssType.Delim: + return isOperator(token[4].value); + } + return false; +} + +/** + * Index of the next token that is not trivia, or `state.end`. + * + * @param {ParseState} state + * @return {number} + */ +function peek(state) { + const { tokens, end } = state; + let i = state.pos; + while (i < end && isTrivia(tokens[i][0])) i++; + if (i < end && !isExpressionToken(tokens[i])) { + throw new ParseError(`Unexpected character "${tokens[i][1][0] ?? ''}"`, i); + } + return i; +} + +/** + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {ParseState} + */ +function newState(tokens, close, start, end) { + return { + tokens, + close, + start, + end, + pos: start, + }; +} + +/** + * Whether trivia (whitespace or a comment) separates the token at `index` from + * the token before it. The start of the range counts as whitespace. + * + * @param {ParseState} state + * @param {number} index + * @return {boolean} + */ +function spaceBefore(state, index) { + const { tokens, start } = state; + for (let i = index - 1; i >= start; i--) { + const type = tokens[i][0]; + if (type === CssType.Whitespace || type === CssType.Comment) return true; + if (type !== CssType.EOF) return false; + } + return true; +} + +/** + * Whether trivia follows the token at `index` before the next token or the end + * of the range. The end of the range counts as whitespace only if trivia + * precedes it. + * + * @param {ParseState} state + * @param {number} index + * @return {boolean} + */ +function spaceAfter(state, index) { + const { tokens, end } = state; + for (let i = index + 1; i < end; i++) { + const type = tokens[i][0]; + if (type === CssType.Whitespace || type === CssType.Comment) return true; + if (type !== CssType.EOF) return false; + } + return false; +} + +/** + * The text a parse error shows for an expression token, as + * `Expected ) ... got "..."`. Numbers show their value without the unit, and + * names their decoded value. + * + * @param {CSSToken} token + * @return {string} + */ +function tokenValue(token) { + switch (token[0]) { + case CssType.Number: + case CssType.Dimension: + case CssType.Percentage: + return String(token[4].value); + case CssType.Ident: + case CssType.Function: + return token[4].value; + } + return token[1]; +} + +/** @param {string} text @param {number} index @return {ParseError} */ +function notSurrounded(text, index) { + return new ParseError(`"${text}" must be surrounded by whitespace`, index); +} + +/** + * Read the operator at `index` and check that it is surrounded by whitespace. + * + * @param {ParseState} state + * @param {number} index + * @return {void} + */ +function requireSurroundingWhitespace(state, index) { + if (!spaceBefore(state, index) || !spaceAfter(state, index)) { + throw notSurrounded(state.tokens[index][1], index); + } +} + +/** + * Read a numeric token that carries an explicit sign directly after an + * operand. Such a token is rejected: the sign must be a separate operator. + * + * @param {ParseState} state + * @return {void} + */ +function rejectSignedNumber(state) { + const i = peek(state); + if (i >= state.end) return; + const token = state.tokens[i]; + if ( + token[0] !== CssType.Number && + token[0] !== CssType.Dimension && + token[0] !== CssType.Percentage + ) { + return; + } + const sign = token[4].signCharacter; + if (sign === undefined) return; + throw notSurrounded(sign, i); +} + +export { + ParseError, + isTrivia, + positionedError, + newState, + peek, + rejectSignedNumber, + requireSurroundingWhitespace, + tokenValue, +}; diff --git a/src/lib/parser/tokens.js b/src/lib/parser/tokens.js index 2abe3f1..b4b7158 100644 --- a/src/lib/parser/tokens.js +++ b/src/lib/parser/tokens.js @@ -1,242 +1,13 @@ -// Token scanning and cursor navigation for the Pratt parser. -import { TokenType as CssType } from '@csstools/css-tokenizer'; -import { CSS_NUMBER_PREFIX } from '../regex.js'; - -/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ -/** @typedef {ReturnType} BlockIndex */ -/** @typedef {{raw: string, pos: number, ws: boolean, index: number}} TokenBase */ -/** @typedef {TokenBase & {type: 'number', value: number, signCharacter?: '+' | '-'}} NumberToken */ -/** @typedef {TokenBase & {type: 'dimension', value: number, unit: string, rawUnit: string, signCharacter?: '+' | '-'}} DimensionToken */ -/** @typedef {TokenBase & {type: 'ident', value: string}} IdentToken */ -/** @typedef {TokenBase & {type: 'function', value: string}} FunctionToken */ -/** @typedef {'(' | ')' | ',' | '+' | '-' | '*' | '/'} Punctuator */ -/** @typedef {TokenBase & {type: 'punct', value: Punctuator}} PunctToken */ -/** @typedef {TokenBase & {type: 'eof', value: '', raw: ''}} EofToken */ -/** @typedef {NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken} Token */ /** - * Immutable bounds and shared block index for one parse range. - * @typedef {Readonly<{tokens: CSSToken[], end: number, index: BlockIndex}>} ParseInput + * The source spelling of a name, or `undefined` when it has none. A name + * that was escaped in the source keeps its raw text so it round-trips. + * + * @param {string} raw + * @param {string} decoded + * @return {string | undefined} */ - -/** @param {string} value @return {value is '+' | '-' | '*' | '/'} */ -function isOperator(value) { - return value === '+' || value === '-' || value === '*' || value === '/'; -} - -/** @param {string} raw @param {string} decoded */ function sourceSpelling(raw, decoded) { return raw === decoded ? undefined : raw; } -/** - * Mutable navigation state only. `index` is always the next native token - * position; trivia is intentionally left visible to `scanToken`. - */ -class Cursor { - /** @param {number} start */ - constructor(start) { - /** @type {number} */ - this.index = start; - /** @type {boolean} */ - this.firstToken = true; - /** @type {Token | null} */ - this.lookahead = null; - /** @type {number} */ - this.lookaheadNextIndex = start; - } - - /** @param {number} index @return {void} */ - skipTo(index) { - this.index = index; - this.lookaheadNextIndex = index; - this.firstToken = false; - this.lookahead = null; - } -} - -/** - * Scan one token without consuming it. `firstToken` supplies the virtual - * leading trivia at a bounded parse boundary; all later whitespace state is - * derived from the native tokens encountered in this scan. - * - * @param {ParseInput} input - * @param {Cursor} cursor - * @return {Token} - */ -function scanToken(input, cursor) { - let i = cursor.index; - let ws = cursor.firstToken; - while (i < input.end) { - const native = input.tokens[i]; - if (native[0] === CssType.Whitespace || native[0] === CssType.Comment) { - ws = true; - i++; - continue; - } - if (native[0] === CssType.EOF) break; - const token = normalizeToken(native, i, ws); - cursor.lookahead = token; - cursor.lookaheadNextIndex = i + 1; - return token; - } - /** @type {EofToken} */ - const token = { - type: 'eof', - value: '', - raw: '', - pos: eofPositionAt(input, i), - ws, - index: i, - }; - cursor.lookahead = token; - // Native EOF is a real token and is consumed past its array index. When - // the bounded range ends before native EOF, this is a virtual EOF and must - // remain at the range boundary. - cursor.lookaheadNextIndex = - i < input.end && input.tokens[i][0] === CssType.EOF ? i + 1 : input.end; - return token; -} - -/** @param {CSSToken} t @param {number} index @param {boolean} ws @return {Token} */ -function normalizeToken(t, index, ws) { - const [type, raw, pos, , detail] = t; - switch (type) { - case CssType.Number: - return { - type: 'number', - value: detail.value, - raw, - pos, - ws, - index, - signCharacter: detail.signCharacter, - }; - case CssType.Dimension: { - const match = CSS_NUMBER_PREFIX.exec(raw); - return { - type: 'dimension', - value: detail.value, - raw, - pos, - ws, - index, - unit: detail.unit, - rawUnit: match ? raw.slice(match[0].length) : detail.unit, - signCharacter: detail.signCharacter, - }; - } - case CssType.Percentage: - return { - type: 'dimension', - value: detail.value, - raw, - pos, - ws, - index, - unit: '%', - rawUnit: '%', - signCharacter: detail.signCharacter, - }; - case CssType.Ident: - case CssType.Function: - return { - type: type === CssType.Ident ? 'ident' : 'function', - value: detail.value, - raw, - pos, - ws, - index, - }; - case CssType.OpenParen: - return { type: 'punct', value: '(', raw, pos, ws, index }; - case CssType.CloseParen: - return { type: 'punct', value: ')', raw, pos, ws, index }; - case CssType.Comma: - return { type: 'punct', value: ',', raw, pos, ws, index }; - case CssType.Delim: - if (isOperator(detail.value)) - return { - type: 'punct', - value: detail.value, - raw, - pos, - ws, - index, - }; - } - throw new Error(`Unexpected character "${raw[0] ?? ''}" at position ${pos}`); -} - -/** @param {ParseInput} input @param {number} index @return {number} */ -function eofPositionAt(input, index) { - if (index < input.end && input.tokens[index][0] !== CssType.EOF) { - return input.tokens[index][2]; - } - if ( - input.end < input.tokens.length && - input.tokens[input.end][0] !== CssType.EOF - ) { - return input.tokens[input.end][2]; - } - for (let i = Math.min(input.end, input.tokens.length) - 1; i >= 0; i--) { - const token = input.tokens[i]; - if (token[0] !== CssType.EOF) return token[3] + 1; - } - return 0; -} - -/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ -function peekToken(input, cursor) { - return cursor.lookahead ?? scanToken(input, cursor); -} - -/** - * Consume the cached token and advance to its native next index. This is one - * of the only two operations allowed to advance `cursor.index`. - * @param {ParseInput} input - * @param {Cursor} cursor - * @return {Token} - */ -function takeToken(input, cursor) { - const token = peekToken(input, cursor); - cursor.index = cursor.lookaheadNextIndex; - cursor.firstToken = false; - cursor.lookahead = null; - return token; -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ -function isPunct(input, cursor, value, value2) { - const t = peekToken(input, cursor); - return ( - t.type === 'punct' && - (t.value === value || (value2 !== undefined && t.value === value2)) - ); -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ -function matchPunct(input, cursor, value) { - if (!isPunct(input, cursor, value)) return false; - takeToken(input, cursor); - return true; -} - -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ -function expectPunct(input, cursor, value) { - const t = takeToken(input, cursor); - if (t.type !== 'punct' || t.value !== value) { - throw new Error(`Expected ${value} at position ${t.pos}, got "${t.value}"`); - } - return t; -} - -export { - Cursor, - eofPositionAt, - expectPunct, - isPunct, - matchPunct, - peekToken, - sourceSpelling, - takeToken, -}; +export { sourceSpelling }; diff --git a/src/lib/print.js b/src/lib/print.js deleted file mode 100644 index b96a18b..0000000 --- a/src/lib/print.js +++ /dev/null @@ -1,75 +0,0 @@ -import { serializeResult } from './serialize.js'; - -/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ -/** @typedef {import('../reduce.js').Replacement} Replacement */ -/** @typedef {import('./serialize.js').SerializeOptions} SerializeOptions */ - -/** - * @param {string} value - * @param {Replacement} replacement - * @param {ResolvedReduceCalcOptions} options - * @param {SerializeOptions} serializeOptions - * @return {string} - */ -function serializeReplacement(value, replacement, options, serializeOptions) { - let text; - try { - text = serializeResult(replacement.result, serializeOptions); - } catch (error) { - const err = error instanceof Error ? error : new Error('Error'); - const original = - replacement.result.original ?? - value.slice(replacement.start, replacement.end); - options.onParseError?.(err, original); - text = original; - } - if ( - options.warnWhenCannotResolve && - replacement.result.status === 'unresolved' - ) { - options.onWarn?.('Could not reduce expression: ' + value); - } - return text; -} - -/** - * Serialize compiled candidates and splice the resulting text into the - * original source. Replacements are already non-overlapping because the - * finder treats a supported outer function as one range. - * - * @param {string} value - * @param {Replacement[]} replacements - * @param {ResolvedReduceCalcOptions} options - * @param {SerializeOptions} serializeOptions - * @return {string} - */ -function applyReplacements(value, replacements, options, serializeOptions) { - if (replacements.length === 1) { - const replacement = replacements[0]; - const text = serializeReplacement( - value, - replacement, - options, - serializeOptions - ); - return ( - value.slice(0, replacement.start) + text + value.slice(replacement.end) - ); - } - - let output = ''; - let lastIndex = 0; - for (const replacement of replacements) { - const text = serializeReplacement( - value, - replacement, - options, - serializeOptions - ); - output += value.slice(lastIndex, replacement.start) + text; - lastIndex = replacement.end; - } - return output + value.slice(lastIndex); -} - -export { applyReplacements }; diff --git a/src/lib/scan.js b/src/lib/scan.js deleted file mode 100644 index 0980da1..0000000 --- a/src/lib/scan.js +++ /dev/null @@ -1,61 +0,0 @@ -import { TokenType as CssType } from '@csstools/css-tokenizer'; -import { lookupMathFunction } from './functions.js'; - -/** - * @typedef {object} Candidate - * @property {string} name - * @property {string} normalizedName - * @property {number} start - * @property {number} end - * @property {string} rootSpelling - * @property {boolean} calculation - * @property {number} sliceStart - * @property {number} sliceEnd - * @property {boolean} closed - */ - -/** - * Find complete supported math-function ranges. A supported function is - * treated as one candidate even when parsing it later fails, so nested - * calculations cannot produce partial output for an invalid outer function. - * - * @param {string} value - * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens - * @param {ReturnType} index - * @return {Candidate[]} - */ -function findCalculations(value, tokens, index) { - /** @type {Candidate[]} */ - const candidates = []; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (token[0] !== CssType.Function) continue; - - const name = token[4].value; - const lookup = lookupMathFunction(name); - if (!lookup) continue; - const isCalc = lookup.definition.calculation === true; - - const close = index.closeOf(i, tokens.length); - const closed = close !== -1; - const end = closed ? tokens[close][3] + 1 : value.length; - const sliceStart = isCalc ? i + 1 : i; - const sliceEnd = closed ? close + (isCalc ? 0 : 1) : tokens.length - 1; - candidates.push({ - name, - normalizedName: lookup.normalizedName, - start: token[2], - end, - rootSpelling: value.slice(token[2], token[3]), - calculation: isCalc, - sliceStart, - sliceEnd, - closed, - }); - if (!closed) break; - i = close; - } - return candidates; -} - -export { findCalculations }; diff --git a/src/reduce.js b/src/reduce.js index 267be8c..dd6502a 100644 --- a/src/reduce.js +++ b/src/reduce.js @@ -2,15 +2,9 @@ // dependency so it can also be used for individual declaration values, // at-rule parameters, or selector text. import { tokenize as cssTokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from './lib/block-index.js'; -import { - hasMathFunctionToken, - hasPotentialMathFunction, -} from './lib/functions.js'; -import { assertDepth } from './lib/limits.js'; -import { findCalculations } from './lib/scan.js'; -import { compileCandidates } from './lib/compile.js'; -import { applyReplacements } from './lib/print.js'; +import { hasPotentialMathFunction } from './lib/functions.js'; +import { resolveOptions } from './lib/options.js'; +import { reduceTokenList } from './lib/core.js'; /** * @typedef {object} ReduceCalcOptions @@ -24,31 +18,6 @@ import { applyReplacements } from './lib/print.js'; /** @typedef {Required> & Pick} ResolvedReduceCalcOptions */ -/** - * Fields threaded through the internal token-range walk. - * - * @typedef {object} TransformContext - * @property {ResolvedReduceCalcOptions} options - * @property {string} value - * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens - */ - -/** - * @typedef {object} Replacement - * @property {number} start - * @property {number} end - * @property {CalculationResult} result - */ -/** - * @typedef {object} CalculationResult - * @property {import('./lib/node.js').Node} tree - * @property {'resolved' | 'unresolved'} status - * @property {string} rootName - * @property {string} rootSpelling - * @property {boolean} calculation - * @property {string | undefined} original - */ - /** * Simplify every supported CSS math function in a component-value string. * Text outside those functions is preserved byte-for-byte. @@ -62,27 +31,11 @@ function reduceCalc(value, opts) { return value; } - /** @type {ResolvedReduceCalcOptions} */ - const options = { - warnWhenCannotResolve: false, - unwrapSingleNegativeNumber: false, - unwrapSingleValue: false, - ...opts, - // An explicit `precision: undefined` keeps the default. - precision: opts?.precision ?? 5, - }; + const options = resolveOptions(opts); /** @type {import('@csstools/css-tokenizer').CSSToken[]} */ let tokens; - /** @type {ReturnType} */ - let index; try { tokens = cssTokenize({ css: value }); - // Nothing to reduce, so skip block indexing and its depth limit. - if (!hasMathFunctionToken(tokens)) { - return value; - } - index = indexBlocks(tokens); - assertDepth(index.maxDepth); } catch (error) { options.onParseError?.( error instanceof Error ? error : new Error('Error', { cause: error }), @@ -91,22 +44,24 @@ function reduceCalc(value, opts) { return value; } - const candidates = findCalculations(value, tokens, index); - const replacements = compileCandidates(candidates, { - options, - value, + // The tokens are fresh, so their offsets locate each range in `value`. + let output = ''; + let lastIndex = 0; + let changed = false; + reduceTokenList( tokens, - index, - }); - if (replacements.length === 0) { - return value; - } - const serializationOptions = { - precision: options.precision, - unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber, - unwrapSingleValue: options.unwrapSingleValue, - }; - return applyReplacements(value, replacements, options, serializationOptions); + options, + (start, end, text) => { + const from = tokens[start][2]; + const to = tokens[end - 1][3] + 1; + if (to - from === text.length && value.startsWith(text, from)) return; + output += value.slice(lastIndex, from) + text; + lastIndex = to; + changed = true; + }, + value + ); + return changed ? output + value.slice(lastIndex) : value; } export default reduceCalc; diff --git a/test/helpers/corpus-selection.js b/test/helpers/corpus-selection.js index e052cd0..4dc7880 100644 --- a/test/helpers/corpus-selection.js +++ b/test/helpers/corpus-selection.js @@ -6,7 +6,6 @@ // real parser rather than from source spelling so whitespace and literal // churn do not crowd out distinct calculation forms. import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { baseOf, convert } from '../../src/lib/convertUnits.js'; import { parse } from '../../src/lib/parser.js'; @@ -86,7 +85,7 @@ export function classifyCorpusExpression(input) { try { const literals = []; const tokens = tokenize({ css: input }); - const ast = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const ast = parse(tokens, 0, tokens.length); // A calc() function accepts exactly one expression. The harvested GitHub // pool also contains malformed calc-like calls; those remain covered by // invalid-corpus resilience tests instead of becoming differential noise. diff --git a/test/helpers/css-math-arbitraries.js b/test/helpers/css-math-arbitraries.js index c1f75ec..0b9fd83 100644 --- a/test/helpers/css-math-arbitraries.js +++ b/test/helpers/css-math-arbitraries.js @@ -1,6 +1,5 @@ import fc from 'fast-check'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; // CSS Values & Units Level 4 math grammar, bounded to the functions exposed // by isSupportedMathFunction in this package. This intentionally generates @@ -23,7 +22,7 @@ function checkedSource(source) { // Keep the generator tied to the production grammar. Throwing here makes a // generator update fail immediately instead of silently reducing coverage. const tokens = tokenize({ css: source }); - parse(tokens, 0, tokens.length, indexBlocks(tokens)); + parse(tokens, 0, tokens.length); return source; } diff --git a/test/helpers/out.js b/test/helpers/out.js index 147ae4e..4405e25 100644 --- a/test/helpers/out.js +++ b/test/helpers/out.js @@ -1,13 +1,9 @@ // End-to-end pipeline helper: tokenize → parse → simplify → serialize. import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; export const out = (input, opts) => { const tokens = tokenize({ css: input }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - opts - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), opts); }; diff --git a/test/helpers/parse-source.js b/test/helpers/parse-source.js index 4fc007a..6d4da14 100644 --- a/test/helpers/parse-source.js +++ b/test/helpers/parse-source.js @@ -1,13 +1,12 @@ // Parse CSS source over its full token range with the production parser. import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { sexpr } from './sexpr.js'; export const parseSource = (css) => { const tokens = tokenize({ css }); - return parse(tokens, 0, tokens.length, indexBlocks(tokens)); + return parse(tokens, 0, tokens.length); }; export const parseSexpr = (css) => sexpr(parseSource(css)); diff --git a/test/helpers/resilience.js b/test/helpers/resilience.js index d6d9e99..4a8154f 100644 --- a/test/helpers/resilience.js +++ b/test/helpers/resilience.js @@ -2,7 +2,6 @@ // synchronous Error. No hangs, no non-Error throws, no infinite loops. import { readFileSync } from 'node:fs'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -24,10 +23,7 @@ export function runCorpus(corpusPath) { const start = performance.now(); try { const tokens = tokenize({ css: input }); - serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: 10 } - ); + serialize(simplify(parse(tokens, 0, tokens.length)), { precision: 10 }); result.ok++; } catch (err) { if (err instanceof Error) { diff --git a/test/helpers/sexpr.js b/test/helpers/sexpr.js index f0eb250..561eff1 100644 --- a/test/helpers/sexpr.js +++ b/test/helpers/sexpr.js @@ -37,7 +37,5 @@ export function sexpr(node) { function opaqueComponentSexpr(component) { if (typeof component === 'string') return JSON.stringify(component); - if (Array.isArray(component)) - return `[${component.map(opaqueComponentSexpr).join(' ')}]`; return sexpr(component); } diff --git a/test/property/differential.test.js b/test/property/differential.test.js index 7e89bc4..c96e2e9 100644 --- a/test/property/differential.test.js +++ b/test/property/differential.test.js @@ -10,7 +10,6 @@ import assert from 'node:assert/strict'; import fc from 'fast-check'; import { calc as csstoolsCalc } from '@csstools/css-calc'; import { TokenType, tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -32,12 +31,9 @@ const COMPARE_PRECISION = 9; const CSTOOLS_PRECISION = 24; function ourOut(input) { const tokens = tokenize({ css: input }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { - precision: COMPARE_PRECISION, - } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION, + }); } function theirOut(input) { try { @@ -86,12 +82,9 @@ function hasSignedZeroToken(input) { function canonicalize(s) { const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { - precision: COMPARE_PRECISION, - } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION, + }); } // Generator depth 3 keeps the input small enough to debug counterexamples // by hand; fast-check still explores hundreds of variations in seconds. @@ -111,21 +104,15 @@ const trigExpInputArb = trigExpFlatArb.map((ast) => astToCalc(ast)); const COMPARE_PRECISION_LOOSE = 8; function ourOutLoose(input) { const tokens = tokenize({ css: input }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { - precision: COMPARE_PRECISION_LOOSE, - } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION_LOOSE, + }); } function canonicalizeLoose(s) { const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { - precision: COMPARE_PRECISION_LOOSE, - } - ); + return serialize(simplify(parse(tokens, 0, tokens.length)), { + precision: COMPARE_PRECISION_LOOSE, + }); } function checkAgreement(input) { const ours = ourOut(input); diff --git a/test/property/fuzz.test.js b/test/property/fuzz.test.js index 7c28d1f..7cd19e2 100644 --- a/test/property/fuzz.test.js +++ b/test/property/fuzz.test.js @@ -22,7 +22,6 @@ import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -55,7 +54,7 @@ function isPlannedError(error) { function tryPipeline(input) { try { const tokens = tokenize({ css: input }); - parse(tokens, 0, tokens.length, indexBlocks(tokens)); + parse(tokens, 0, tokens.length); return { ok: true, planned: true }; } catch (error) { return { ok: false, planned: isPlannedError(error) }; diff --git a/test/property/opaque-grouping.test.js b/test/property/opaque-grouping.test.js index 6d22505..483744d 100644 --- a/test/property/opaque-grouping.test.js +++ b/test/property/opaque-grouping.test.js @@ -5,7 +5,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import fc from 'fast-check'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -20,18 +19,11 @@ describe('opaque CSS math properties', () => { fc.assert( fc.property(cssMathSourceArb, (input) => { const tokens = tokenize({ css: input }); - const output = serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))) - ); + const output = serialize(simplify(parse(tokens, 0, tokens.length))); const outputTokens = tokenize({ css: output }); return ( typeof output === 'string' && - parse( - outputTokens, - 0, - outputTokens.length, - indexBlocks(outputTokens) - ) !== null + parse(outputTokens, 0, outputTokens.length) !== null ); }), { numRuns: 300 } diff --git a/test/unit/block-index.test.js b/test/unit/block-index.test.js deleted file mode 100644 index c218380..0000000 --- a/test/unit/block-index.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; -import { MAX_CALCULATION_DEPTH } from '../../src/lib/limits.js'; - -const nested = (depth) => `${'unknown('.repeat(depth)}x${')'.repeat(depth)}`; -const tokenIndex = (tokens, raw, occurrence = 0) => { - let seen = 0; - for (let i = 0; i < tokens.length; i++) { - if (tokens[i][1] !== raw) continue; - if (seen++ === occurrence) return i; - } - return -1; -}; - -test('BlockIndex navigates mixed nested delimiters and top-level commas', () => { - const tokens = tokenize({ css: 'f(a, [b, {c, d}], e), z' }); - const index = indexBlocks(tokens); - const open = tokenIndex(tokens, 'f('); - const close = tokenIndex(tokens, ')'); - const square = tokenIndex(tokens, '['); - const firstComma = tokenIndex(tokens, ',', 0); - const secondComma = tokenIndex(tokens, ',', 3); - - assert.equal(index.closeOf(open), close); - assert.equal(index.nextComponent(open, tokens.length), close + 1); - assert.equal(index.nextComponent(square, close), tokenIndex(tokens, ']') + 1); - assert.equal(index.firstTopLevelComma(open + 1, close), firstComma); - assert.equal(index.firstTopLevelComma(firstComma + 1, close), secondComma); - assert.equal(index.maxDepth, 3); -}); - -test('BlockIndex preserves the opener stack across a genuine mismatch', () => { - const tokens = tokenize({ css: 'f([a), b]' }); - const index = indexBlocks(tokens); - const open = tokenIndex(tokens, 'f('); - const square = tokenIndex(tokens, '['); - const squareClose = tokenIndex(tokens, ']'); - - assert.equal(index.closeOf(open), -1); - assert.equal(index.closeOf(square), squareClose); - assert.equal(index.nextComponent(open, tokens.length), open + 1); - assert.equal(index.nextComponent(square, tokens.length), squareClose + 1); -}); - -test('BlockIndex bounds closeOf and nextComponent by an exclusive end', () => { - const tokens = tokenize({ css: 'f(a), b' }); - const index = indexBlocks(tokens); - const open = tokenIndex(tokens, 'f('); - const close = tokenIndex(tokens, ')'); - - assert.equal(index.closeOf(open, close), -1); - assert.equal(index.closeOf(open, close + 1), close); - assert.equal(index.closeOf(open, tokens.length + 10), close); - assert.equal(index.nextComponent(open, close), open + 1); - assert.equal(index.nextComponent(open, close + 1), close + 1); - assert.equal(index.nextComponent(close, close), close); - assert.equal(index.nextComponent(close + 1, close), close); -}); - -test('BlockIndex tolerates unmatched openers without rejecting the stream', () => { - const tokens = tokenize({ css: 'f([a, b]' }); - const index = indexBlocks(tokens); - - assert.equal(index.maxDepth, 2); - assert.doesNotThrow(() => indexBlocks(tokens)); -}); - -test('opaque component traversal is iterative at the nesting limit', () => { - const acceptedTokens = tokenize({ css: nested(MAX_CALCULATION_DEPTH + 1) }); - assert.doesNotThrow(() => - parse(acceptedTokens, 0, acceptedTokens.length, indexBlocks(acceptedTokens)) - ); - let error; - const rejectedTokens = tokenize({ css: nested(MAX_CALCULATION_DEPTH + 2) }); - try { - parse( - rejectedTokens, - 0, - rejectedTokens.length, - indexBlocks(rejectedTokens) - ); - } catch (candidate) { - error = candidate; - } - assert.ok(error instanceof Error); -}); diff --git a/test/unit/parser-core.test.js b/test/unit/parser-core.test.js index 5d0f963..75a77b3 100644 --- a/test/unit/parser-core.test.js +++ b/test/unit/parser-core.test.js @@ -4,7 +4,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { TokenType, tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; import { sexpr } from '../helpers/sexpr.js'; @@ -22,25 +21,19 @@ test('parser: accepts a bounded range of a shared native token stream', () => { const start = tokens.findIndex((token) => token[1] === '/* gap */'); const end = tokens.findIndex((token) => token[1] === ')'); - assert.equal( - sexpr(parse(tokens, start, end, indexBlocks(tokens))), - '(+ -2px 3px)' - ); + assert.equal(sexpr(parse(tokens, start, end)), '(+ -2px 3px)'); }); test('parser: bounded virtual EOF keeps its source-relative position', () => { const { tokens, start, end } = boundedRange('prefix calc(1 *) suffix'); - assert.throws( - () => parse(tokens, start, end, indexBlocks(tokens)), - /position 15/ - ); + assert.throws(() => parse(tokens, start, end), /position 15/); }); test('parser: bounded virtual EOF scans trailing trivia before reporting its position', () => { const { tokens, start, end } = boundedRange('prefix calc(1 + ) suffix'); - assert.throws(() => parse(tokens, start, end, indexBlocks(tokens)), { + assert.throws(() => parse(tokens, start, end), { message: `Unexpected token "" at position ${tokens[end][2]}`, }); }); @@ -63,7 +56,7 @@ test('parser: bounded range shares block boundaries through var fallbacks', () = ); assert.equal( - serialize(parse(tokens, start + 1, end, indexBlocks(tokens))), + serialize(parse(tokens, start + 1, end)), 'var(--x, [calc(1px + 2px), {a: calc(3px + 4px)}], calc(5px + 6px))' ); }); diff --git a/test/unit/parser-keywords-errors.test.js b/test/unit/parser-keywords-errors.test.js index a639b3b..55b17e5 100644 --- a/test/unit/parser-keywords-errors.test.js +++ b/test/unit/parser-keywords-errors.test.js @@ -1,7 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; @@ -58,13 +57,9 @@ test('parser: asymmetric additive whitespace reports the operator position', () }); test('parser: * / do not require whitespace (spec allows both)', () => { const productTokens = tokenize({ css: '1px*2' }); - assert.doesNotThrow(() => - parse(productTokens, 0, productTokens.length, indexBlocks(productTokens)) - ); + assert.doesNotThrow(() => parse(productTokens, 0, productTokens.length)); const quotientTokens = tokenize({ css: '1px/2' }); - assert.doesNotThrow(() => - parse(quotientTokens, 0, quotientTokens.length, indexBlocks(quotientTokens)) - ); + assert.doesNotThrow(() => parse(quotientTokens, 0, quotientTokens.length)); }); test('parser: tab / newline satisfy the §10.1 whitespace rule', () => { assert.equal(ast('1\t+\n2'), '(+ 1 2)'); @@ -108,7 +103,7 @@ test('parser: unclosed call throws with expected-token message', () => { test('parser: unclosed var throws with unclosed message and position', () => { const tokens = tokenize({ css: 'var(--foo' }); assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), + () => parse(tokens, 0, tokens.length), /Unclosed var\( at position 4/ ); }); @@ -150,19 +145,27 @@ describe('Trailing tokens', () => { test('parser: skipped blocks do not hide trailing tokens', () => { const varTokens = tokenize({ css: 'var(--x) 1px' }); assert.throws( - () => parse(varTokens, 0, varTokens.length, indexBlocks(varTokens)), + () => parse(varTokens, 0, varTokens.length), /Unexpected token/ ); const unknownTokens = tokenize({ css: 'unknown(1px + 2px) 3px' }); assert.throws( - () => - parse( - unknownTokens, - 0, - unknownTokens.length, - indexBlocks(unknownTokens) - ), + () => parse(unknownTokens, 0, unknownTokens.length), /Unexpected token/ ); }); }); + +test('parse: plain nesting past the limit throws the nesting-limit error', () => { + // `calc(` plus 1023 parentheses is 1024 openers, the most that are allowed. + const accepted = tokenize({ + css: `calc(${'('.repeat(1023)}1px${')'.repeat(1023)})`, + }); + assert.doesNotThrow(() => parse(accepted, 0, accepted.length)); + const rejected = tokenize({ + css: `calc(${'('.repeat(1024)}1px${')'.repeat(1024)})`, + }); + assert.throws(() => parse(rejected, 0, rejected.length), { + message: 'Calculation nesting exceeds the limit of 1024', + }); +}); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index fb530cd..6cb062b 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -1,7 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; import { parseSource } from '../helpers/parse-source.js'; @@ -68,23 +67,17 @@ describe('parser: opaque expressions and invalid syntax', () => { test('parser: opaque calls expose their component trees', () => { const varTokens = tokenize({ css: 'var(--x, 1px)' }); - assert.deepEqual( - parse(varTokens, 0, varTokens.length, indexBlocks(varTokens)), - { - type: 'OpaqueCall', - name: 'var', - components: [{ type: 'Ident', name: '--x' }, ', 1px'], - } - ); + assert.deepEqual(parse(varTokens, 0, varTokens.length), { + type: 'OpaqueCall', + name: 'var', + components: [{ type: 'Ident', name: '--x' }, ', 1px'], + }); const unknownTokens = tokenize({ css: 'unknown(1px + 2px)' }); - assert.deepEqual( - parse(unknownTokens, 0, unknownTokens.length, indexBlocks(unknownTokens)), - { - type: 'OpaqueCall', - name: 'unknown', - components: ['1px + 2px'], - } - ); + assert.deepEqual(parse(unknownTokens, 0, unknownTokens.length), { + type: 'OpaqueCall', + name: 'unknown', + components: ['1px + 2px'], + }); }); test('parser: opaque component trees retain nested math ASTs', () => { @@ -110,9 +103,7 @@ describe('parser: opaque expressions and invalid syntax', () => { type: 'OpaqueCall', name: 'f', components: [ - String.raw` /*lead*/ \66 oo\20 bar, raw(`, - ['[', ['x, y'], '], {', ['z: q'], '}'], - '), ', + String.raw` /*lead*/ \66 oo\20 bar, raw([x, y], {z: q}), `, { type: 'Call', name: 'calc', diff --git a/test/unit/reduceCalc-contract.test.js b/test/unit/reduceCalc-contract.test.js new file mode 100644 index 0000000..9e5ff54 --- /dev/null +++ b/test/unit/reduceCalc-contract.test.js @@ -0,0 +1,247 @@ +// Whitespace around operators, range edges, unclosed blocks, mismatched +// closers, nesting limits and negated opaque groups, through `reduceCalc`. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; + +/** Run `reduceCalc` and collect each parse error as `[message, input]`. */ +function reduceWithErrors(value, options = {}) { + const errors = []; + const output = reduceCalc(value, { + ...options, + onParseError: (error, input) => errors.push([error.message, input]), + }); + return { output, errors }; +} + +/** Assert that `value` is left as is and reports exactly one error. */ +function assertRejected(value, message) { + assert.deepEqual(reduceWithErrors(value), { + output: value, + errors: [[message, value]], + }); +} + +describe('comments and whitespace around operators', () => { + test('a comment counts as whitespace before and after an operator', () => { + for (const value of [ + 'calc(1px/**/+ 2px)', + 'calc(1px +/**/2px)', + 'calc(1px/**/+/**/2px)', + ]) { + assert.deepEqual(reduceWithErrors(value), { + output: 'calc(3px)', + errors: [], + }); + } + }); +}); + +describe('operators at range edges', () => { + test('a trailing operator without following whitespace is rejected', () => { + assertRejected( + 'calc(1px +)', + '"+" must be surrounded by whitespace at position 9' + ); + }); + + test('a trailing operator with whitespace before the end is a missing operand', () => { + assertRejected('calc(1px + )', 'Unexpected token "" at position 11'); + }); + + test('an operator before a comma needs whitespace after it', () => { + assertRejected( + 'min(1px +, 2px)', + '"+" must be surrounded by whitespace at position 8' + ); + }); + + test('an operator at the end of a parenthesized group', () => { + assertRejected( + 'calc((1px +) )', + '"+" must be surrounded by whitespace at position 10' + ); + }); + + test('a leading operator is not a unary sign', () => { + assertRejected('calc(+ 1px)', 'Unexpected token "+" at position 5'); + }); + + test('a signed number directly after an operand is rejected', () => { + assertRejected( + 'calc(1px+ 2px)', + '"+" must be surrounded by whitespace at position 8' + ); + assertRejected( + 'calc(1px -2px)', + '"-" must be surrounded by whitespace at position 9' + ); + }); + + test('a double negative after an operator is a valid sign', () => { + assert.deepEqual(reduceWithErrors('calc(1px - -2px)'), { + output: 'calc(3px)', + errors: [], + }); + }); +}); + +describe('unclosed functions', () => { + test('a top-level unclosed calc() reports its position', () => { + assertRejected('calc(1px + 2px', 'Unclosed calc( at position 0'); + }); + + test('an unclosed calc() nested in a var() fallback reports the enclosing range', () => { + assertRejected( + 'calc(1px + var(--a, calc(2px', + 'Unclosed calc( at position 0' + ); + }); + + test('an unclosed calc() after a reduced one reports its own position', () => { + assert.deepEqual(reduceWithErrors('calc(1px + 1px) calc('), { + output: 'calc(2px) calc(', + errors: [['Unclosed calc( at position 16', 'calc(']], + }); + }); + + test('an unclosed var() is not a math function and stays silent', () => { + assert.deepEqual(reduceWithErrors('var(--a, 1px'), { + output: 'var(--a, 1px', + errors: [], + }); + }); +}); + +const LIMIT = 1024; +const nestedParens = (n) => `calc(${'('.repeat(n)}1px${')'.repeat(n)})`; +const nestedFunctions = (n) => + `calc(${'unknown('.repeat(n)}1px${')'.repeat(n)})`; +const nestedSquares = (n) => `calc(var(--a, ${'['.repeat(n)}${']'.repeat(n)}))`; +const nestedCurlies = (n) => `calc(var(--a, ${'{'.repeat(n)}${'}'.repeat(n)}))`; +const nestedVarFunctions = (n) => + `calc(var(--a, ${'unknown('.repeat(n)}${')'.repeat(n)}))`; +const LIMIT_MESSAGE = `Calculation nesting exceeds the limit of ${LIMIT}`; + +describe('nesting limit', () => { + test('parentheses at the limit are accepted and past it are rejected', () => { + assert.equal(reduceCalc(nestedParens(LIMIT - 1)), 'calc(1px)'); + assertRejected(nestedParens(LIMIT), LIMIT_MESSAGE); + }); + + test('functions at the limit are accepted and past it are rejected', () => { + assert.deepEqual(reduceWithErrors(nestedFunctions(LIMIT - 1)).errors, []); + assertRejected(nestedFunctions(LIMIT), LIMIT_MESSAGE); + }); + + test('functions inside a var() fallback count toward the limit', () => { + assert.deepEqual( + reduceWithErrors(nestedVarFunctions(LIMIT - 2)).errors, + [] + ); + assert.deepEqual(reduceWithErrors(nestedVarFunctions(LIMIT - 1)).errors, [ + [LIMIT_MESSAGE, nestedVarFunctions(LIMIT - 1)], + ]); + }); + + test('square brackets inside a var() fallback count toward the limit', () => { + assert.deepEqual(reduceWithErrors(nestedSquares(LIMIT - 2)).errors, []); + assert.deepEqual(reduceWithErrors(nestedSquares(LIMIT - 1)).errors, [ + [LIMIT_MESSAGE, nestedSquares(LIMIT - 1)], + ]); + }); + + test('curly braces inside a var() fallback count toward the limit', () => { + assert.deepEqual(reduceWithErrors(nestedCurlies(LIMIT - 2)).errors, []); + assert.deepEqual(reduceWithErrors(nestedCurlies(LIMIT - 1)).errors, [ + [LIMIT_MESSAGE, nestedCurlies(LIMIT - 1)], + ]); + }); + + test('parentheses inside a url() do not count', () => { + const value = `calc(1px + 2px) url(${'('.repeat(3000)})`; + assert.deepEqual(reduceWithErrors(value), { + output: `calc(3px) url(${'('.repeat(3000)})`, + errors: [], + }); + }); + + test('a bad url() does not count either', () => { + const value = `calc(1px + 2px) url(a b${'('.repeat(3000)})`; + assert.deepEqual(reduceWithErrors(value).errors, []); + }); + + test('a valid calc() beside over-limit nesting reports once and changes nothing', () => { + const value = `calc(1px + 1px) x${'('.repeat(1100)}1px${')'.repeat(1100)}`; + assertRejected(value, LIMIT_MESSAGE); + }); +}); + +describe('mismatched closers', () => { + // A closer that does not match the innermost open block is ignored, so an + // enclosing block can still be closed by a later closer. + test('a closer that does not match leaves the calc() unclosed', () => { + assertRejected('calc(1px + [2px)', 'Unclosed calc( at position 0'); + }); + + test('a mismatched closer inside a fallback is ignored and the calc() reduces', () => { + assert.deepEqual(reduceWithErrors('var(--a, calc(1px ) ] )'), { + output: 'var(--a, calc(1px) ] )', + errors: [], + }); + }); + + test('a mismatched closer inside a var() fallback is ignored', () => { + assert.deepEqual(reduceWithErrors('calc(1px + var(--a, [b) 2px] ))'), { + output: 'calc(1px + var(--a, [b) 2px] ))', + errors: [], + }); + }); + + test('a mismatched closer that leaves the calc() open reports it unclosed', () => { + assertRejected( + 'calc(1px + var(--a, [b)) 2px)', + 'Unclosed calc( at position 0' + ); + }); + + test('an unclosed block does not stop a later calc() from reducing', () => { + assert.equal( + reduceCalc('var(--a, [b) calc(1px + 2px)'), + 'var(--a, [b) calc(3px)' + ); + }); +}); + +describe('negated grouped sums with opaque terms', () => { + test('a negated group with an opaque term keeps its grouping', () => { + assert.deepEqual(reduceWithErrors('calc(-1 * (1px + var(--a)))'), { + output: 'calc(-1 * (1px + var(--a)))', + errors: [], + }); + }); + + test('a negated group keeps its opaque terms grouped after reduction', () => { + assert.equal( + reduceCalc('calc(-1 * (2px + 1px + var(--a)))'), + 'calc(-1 * (3px + var(--a)))' + ); + assert.equal( + reduceCalc('calc(-1 * (var(--a) + 2px))'), + 'calc(-1 * (2px + var(--a)))' + ); + }); +}); + +describe('diagnostic positions', () => { + const cases = [ + ['calc(1px+ 2px)', '"+" must be surrounded by whitespace at position 8'], + ['calc(1px 2px)', 'Unexpected token "2px" at position 9'], + ['calc(1px + #)', 'Unexpected character "#" at position 11'], + ['min(1px 2px)', 'Expected ) at position 8, got "2"'], + ['calc(1px + var(1px))', 'Invalid custom property in var() at position 15'], + ]; + for (const [value, message] of cases) { + test(message, () => assertRejected(value, message)); + } +}); diff --git a/test/unit/reduceCalc-positions.test.js b/test/unit/reduceCalc-positions.test.js new file mode 100644 index 0000000..74c0684 --- /dev/null +++ b/test/unit/reduceCalc-positions.test.js @@ -0,0 +1,35 @@ +// Diagnostic positions reported by `reduceCalc` after a long prefix. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; + +describe('error position after a long prefix', () => { + test('a parse error far into a calc() reports its exact offset', () => { + const prefix = `calc(${'1px + '.repeat(500)}1px`; + const value = `${prefix}+2px)`; + const errors = []; + const output = reduceCalc(value, { + onParseError: (error, input) => errors.push([error.message, input]), + }); + assert.equal(output, value); + assert.deepEqual(errors, [ + [ + `"+" must be surrounded by whitespace at position ${prefix.length}`, + value, + ], + ]); + }); + + test('the reported stack names the positioned message', () => { + const errors = []; + reduceCalc('calc(1px +2px)', { + onParseError: (error) => errors.push(error), + }); + assert.equal(errors.length, 1); + assert.ok(errors[0] instanceof Error); + assert.equal( + errors[0].stack.split('\n')[0], + 'Error: "+" must be surrounded by whitespace at position 9' + ); + }); +}); diff --git a/test/unit/serialize-policy.test.js b/test/unit/serialize-policy.test.js index 2916909..b351611 100644 --- a/test/unit/serialize-policy.test.js +++ b/test/unit/serialize-policy.test.js @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import { serialize as serializeSource } from '../../src/lib/serialize.js'; import { num, dim } from '../../src/lib/node.js'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; @@ -89,9 +88,7 @@ describe('serialize: scalar context policy', () => { assert.equal(output, scalarCase.expected[policyIndex]); const tokens = tokenize({ css: output }); - const reparsed = simplify( - parse(tokens, 0, tokens.length, indexBlocks(tokens)) - ); + const reparsed = simplify(parse(tokens, 0, tokens.length)); assert.equal( serialize(reparsed, policy.options), output, diff --git a/test/unit/simplify/index.test.js b/test/unit/simplify/index.test.js index 3bbafcb..2065df2 100644 --- a/test/unit/simplify/index.test.js +++ b/test/unit/simplify/index.test.js @@ -2,7 +2,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { out } from '../../helpers/out.js'; import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../../src/lib/block-index.js'; import { parse } from '../../../src/lib/parser.js'; import { simplify } from '../../../src/lib/simplify.js'; @@ -51,24 +50,14 @@ describe('simplify: math constants and non-finite values', () => { test('simplify: source signed zero normalizes before evaluation', () => { const sourceZeroTokens = tokenize({ css: 'calc(-0)' }); const sourceZero = simplify( - parse( - sourceZeroTokens, - 0, - sourceZeroTokens.length, - indexBlocks(sourceZeroTokens) - ) + parse(sourceZeroTokens, 0, sourceZeroTokens.length) ); assert.equal(sourceZero.type, 'Num'); assert.equal(Object.is(sourceZero.value, -0), false); const positiveZeroTokens = tokenize({ css: 'calc(0 - 0)' }); const positiveZero = simplify( - parse( - positiveZeroTokens, - 0, - positiveZeroTokens.length, - indexBlocks(positiveZeroTokens) - ) + parse(positiveZeroTokens, 0, positiveZeroTokens.length) ); assert.equal(positiveZero.type, 'Num'); assert.equal(Object.is(positiveZero.value, -0), false); diff --git a/test/unit/token-core-diagnostics.test.js b/test/unit/token-core-diagnostics.test.js new file mode 100644 index 0000000..557a740 --- /dev/null +++ b/test/unit/token-core-diagnostics.test.js @@ -0,0 +1,39 @@ +// Diagnostic order and wording when one value has several problems. Callbacks +// are reported in candidate order, so the first error can differ from the one +// the reducer reported before the token-core refactor. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; + +describe('callback order', () => { + test('parse errors and warnings interleave in source order', () => { + const value = 'calc(1px +) calc(1px + var(--a)) calc(1px 2px)'; + const events = []; + reduceCalc(value, { + warnWhenCannotResolve: true, + onParseError: (error) => events.push(['parse', error.message]), + onWarn: (message) => events.push(['warn', message]), + }); + assert.deepEqual(events, [ + ['parse', '"+" must be surrounded by whitespace at position 9'], + ['warn', `Could not reduce expression: ${value}`], + ['parse', 'Unexpected token "2px" at position 42'], + ]); + }); +}); + +describe('several errors in one function', () => { + test('the operator error is reported before the later character error', () => { + const errors = []; + const output = reduceCalc('clamp(.5/*c*/+!;)', { + onParseError: (error, input) => errors.push([error.message, input]), + }); + assert.equal(output, 'clamp(.5/*c*/+!;)'); + assert.deepEqual(errors, [ + [ + '"+" must be surrounded by whitespace at position 13', + 'clamp(.5/*c*/+!;)', + ], + ]); + }); +}); diff --git a/types/lib/block-index.d.ts b/types/lib/block-index.d.ts deleted file mode 100644 index 5141745..0000000 --- a/types/lib/block-index.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -export type CSSToken = import('@csstools/css-tokenizer').CSSToken; -/** - * Read-only delimiter navigation for one native token stream. - */ -declare class BlockIndex { - #private; - /** - * Build the delimiter index once for a token stream. Matching remains - * LIFO: a mismatched closer is ignored and does not disturb the open stack. - * - * @param {CSSToken[]} tokens - */ - constructor(tokens: CSSToken[]); - /** @return {number} */ - get maxDepth(): number; - /** @param {number} openPosition @param {number} [endPosition] @return {number} */ - closeOf(openPosition: number, endPosition?: number): number; - /** @param {number} position @param {number} endPosition @return {number} */ - nextComponent(position: number, endPosition: number): number; - /** @param {number} startPosition @param {number} endPosition @return {number} */ - firstTopLevelComma(startPosition: number, endPosition: number): number; -} -/** - * Build the delimiter index once for a token stream. Matching remains LIFO: a - * mismatched closer is ignored and does not disturb the open stack. - * - * @param {CSSToken[]} tokens - * @return {BlockIndex} - */ -declare function indexBlocks(tokens: CSSToken[]): BlockIndex; -export { indexBlocks }; diff --git a/types/lib/blocks.d.ts b/types/lib/blocks.d.ts new file mode 100644 index 0000000..2f08a55 --- /dev/null +++ b/types/lib/blocks.d.ts @@ -0,0 +1,47 @@ +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +/** + * Match every block opener in the list to its closer. Matching is LIFO: a + * closer that does not match the innermost open block is ignored, so the + * open stack survives the mismatch. Openers with no closer map to -1. + * + * `maxDepth` is the largest number of blocks open at once. The caller decides + * whether that exceeds the nesting limit. + * + * @param {CSSToken[]} tokens + * @return {{close: Int32Array, maxDepth: number}} `close[i]` is the closer index for opener `i`, or -1. + */ +declare function matchBlocks(tokens: CSSToken[]): { + close: Int32Array; + maxDepth: number; +}; +/** + * The closer of the opener at `index`, or -1 when it has none inside + * `[index, end)`. + * + * @param {Int32Array} close + * @param {number} index + * @param {number} end + * @return {number} + */ +declare function closeIn(close: Int32Array, index: number, end: number): number; +/** + * Index after the component (a token, or a whole block) that starts at `index`, + * never past `end`. + * + * @param {Int32Array} close + * @param {number} index + * @param {number} end + * @return {number} + */ +declare function nextComponent(close: Int32Array, index: number, end: number): number; +/** + * First comma outside every block in `[start, end)`, or -1. + * + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {number} + */ +declare function firstTopLevelComma(tokens: CSSToken[], close: Int32Array, start: number, end: number): number; +export { closeIn, firstTopLevelComma, matchBlocks, nextComponent }; diff --git a/types/lib/compile.d.ts b/types/lib/compile.d.ts deleted file mode 100644 index 4cfc8e8..0000000 --- a/types/lib/compile.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type Candidate = import('./scan.js').Candidate; -export type ResolvedReduceCalcOptions = import('../reduce.js').ResolvedReduceCalcOptions; -export type Replacement = import('../reduce.js').Replacement; -export type CSSToken = import('@csstools/css-tokenizer').CSSToken; -export type BlockIndex = ReturnType; -export type CompileContext = { - options: ResolvedReduceCalcOptions; - value: string; - tokens: CSSToken[]; - index: BlockIndex; -}; -/** @typedef {import('./scan.js').Candidate} Candidate */ -/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ -/** @typedef {import('../reduce.js').Replacement} Replacement */ -/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ -/** @typedef {ReturnType} BlockIndex */ -/** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */ -/** - * Parse, analyze, and simplify one candidate. Analysis is the validity/status - * gate over the original tree; simplification then runs independently as a - * composable AST transformation that may synthesize nodes. - * - * @param {Candidate} candidate - * @param {CompileContext} ctx - * @return {Replacement} - */ -declare function compileCandidate(candidate: Candidate, ctx: CompileContext): Replacement; -/** - * Compile candidates independently so one malformed calculation is preserved - * without preventing unrelated candidates from being reduced. - * - * @param {Candidate[]} candidates - * @param {CompileContext} ctx - * @return {Replacement[]} - */ -declare function compileCandidates(candidates: Candidate[], ctx: CompileContext): Replacement[]; -export { compileCandidate, compileCandidates }; diff --git a/types/lib/core.d.ts b/types/lib/core.d.ts new file mode 100644 index 0000000..f3f6cef --- /dev/null +++ b/types/lib/core.d.ts @@ -0,0 +1,18 @@ +export type ResolvedReduceCalcOptions = import('../reduce.js').ResolvedReduceCalcOptions; +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +export type MathFunction = import('./functions.js').MathFunction; +export type EmitReplacement = (start: number, end: number, text: string) => void; +/** + * Reduce every supported math function in a token list. Each function is one + * candidate, so a nested function is never reduced on its own, and an invalid + * outer function is reported without partial output. + * + + * @param {CSSToken[]} tokens + * @param {ResolvedReduceCalcOptions} options + * @param {EmitReplacement} emit + * @param {string} source The joined token text. + * @return {void} + */ +declare function reduceTokenList(tokens: CSSToken[], options: ResolvedReduceCalcOptions, emit: EmitReplacement, source: string): void; +export { reduceTokenList }; diff --git a/types/lib/functions.d.ts b/types/lib/functions.d.ts index e9a4926..e34a606 100644 --- a/types/lib/functions.d.ts +++ b/types/lib/functions.d.ts @@ -37,4 +37,13 @@ declare function hasPotentialMathFunction(value: string): boolean; * @return {boolean} */ declare function hasMathFunctionToken(tokens: import('@csstools/css-tokenizer').CSSToken[]): boolean; -export { addTypes, failureType, hasMathFunctionToken, hasPotentialMathFunction, isCalculationFunction, isFailure, isPercentage, lookupMathFunction, mathFunctions, numberType, percentageType, QUICK_MATH_TEST, unknownType, }; +/** + * The supported math function named by the function token at `index`, or + * `undefined` when that token is not a supported math function. + * + * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens + * @param {number} index + * @return {ReturnType} + */ +declare function mathFunctionAt(tokens: import('@csstools/css-tokenizer').CSSToken[], index: number): ReturnType; +export { addTypes, failureType, hasMathFunctionToken, hasPotentialMathFunction, isCalculationFunction, isFailure, isPercentage, lookupMathFunction, mathFunctionAt, mathFunctions, numberType, percentageType, QUICK_MATH_TEST, unknownType, }; diff --git a/types/lib/node.d.ts b/types/lib/node.d.ts index 4467042..ef9f6b7 100644 --- a/types/lib/node.d.ts +++ b/types/lib/node.d.ts @@ -19,7 +19,7 @@ export type Call = { args: Node[]; rawName?: string; }; -export type OpaqueComponent = string | Node | OpaqueComponent[]; +export type OpaqueComponent = string | Node; export type OpaqueCall = { type: 'OpaqueCall'; name: string; @@ -50,7 +50,7 @@ export type Node = Num | Dim | Ident | Call | OpaqueCall | Sum | Product; * @typedef {{type: 'Dim', value: number, unit: string, rawUnit?: string}} Dim * @typedef {{type: 'Ident', name: string, rawName?: string}} Ident * @typedef {{type: 'Call', name: string, args: Node[], rawName?: string}} Call - * @typedef {string | Node | OpaqueComponent[]} OpaqueComponent + * @typedef {string | Node} OpaqueComponent * @typedef {{type: 'OpaqueCall', name: string, components: OpaqueComponent[], rawName?: string}} OpaqueCall * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim. * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum diff --git a/types/lib/offsets.d.ts b/types/lib/offsets.d.ts new file mode 100644 index 0000000..c50e01e --- /dev/null +++ b/types/lib/offsets.d.ts @@ -0,0 +1,20 @@ +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +/** + * Maps a token index to the offset in the joined raw text of `tokens` where + * that token starts, or the total length past the end. Only diagnostics need + * offsets, and a fallback can report many of them, so the table is built on + * the first call and each lookup after that is constant time. + * + * @param {CSSToken[]} tokens + * @return {(index: number) => number} + */ +declare function offsetLookup(tokens: CSSToken[]): (index: number) => number; +/** + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + * @return {string} + */ +declare function joinTokens(tokens: CSSToken[], start: number, end: number): string; +export { joinTokens, offsetLookup }; diff --git a/types/lib/opaque.d.ts b/types/lib/opaque.d.ts index f9d664b..d3e1712 100644 --- a/types/lib/opaque.d.ts +++ b/types/lib/opaque.d.ts @@ -2,8 +2,17 @@ /** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */ export type Node = import('./node.js').Node; export type OpaqueComponent = import('./node.js').OpaqueComponent; -/** @param {OpaqueComponent[]} components @param {(node: Node) => Node} simplify @return {OpaqueComponent[]} */ +/** + * @param {OpaqueComponent[]} components + * @param {(node: Node) => Node} simplify + * @return {OpaqueComponent[]} + */ declare function simplifyComponents(components: OpaqueComponent[], simplify: (node: Node) => Node): OpaqueComponent[]; -/** @param {OpaqueComponent[]} components @param {string[]} buffer @param {(node: Node, buffer: string[]) => void} serialize @return {void} */ +/** + * @param {OpaqueComponent[]} components + * @param {string[]} buffer + * @param {(node: Node, buffer: string[]) => void} serialize + * @return {void} + */ declare function serializeComponents(components: OpaqueComponent[], buffer: string[], serialize: (node: Node, buffer: string[]) => void): void; export { simplifyComponents, serializeComponents }; diff --git a/types/lib/options.d.ts b/types/lib/options.d.ts new file mode 100644 index 0000000..c5df490 --- /dev/null +++ b/types/lib/options.d.ts @@ -0,0 +1,10 @@ +/** @typedef {import('../reduce.js').ReduceCalcOptions} ReduceCalcOptions */ +/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ +export type ReduceCalcOptions = import('../reduce.js').ReduceCalcOptions; +export type ResolvedReduceCalcOptions = import('../reduce.js').ResolvedReduceCalcOptions; +/** + * @param {ReduceCalcOptions} [opts] + * @return {ResolvedReduceCalcOptions} + */ +declare function resolveOptions(opts?: ReduceCalcOptions): ResolvedReduceCalcOptions; +export { resolveOptions }; diff --git a/types/lib/parser.d.ts b/types/lib/parser.d.ts index d88fcca..42d34fc 100644 --- a/types/lib/parser.d.ts +++ b/types/lib/parser.d.ts @@ -1,9 +1,29 @@ +export type ParseState = import('./parser/state.js').ParseState; export type CSSToken = import('@csstools/css-tokenizer').CSSToken; export type Node = import('./node.js').Node; -export type BlockIndex = import('./parser/tokens.js').BlockIndex; -export type ParseInput = import('./parser/tokens.js').ParseInput; -export type Token = import('./parser/tokens.js').Token; -export type FunctionToken = import('./parser/tokens.js').FunctionToken; -/** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {BlockIndex} index @return {Node} */ -declare function parse(tokens: CSSToken[], start: number, end: number, index: BlockIndex): Node; -export { parse }; +export type SumTerm = import('./node.js').SumTerm; +export type ProductFactor = import('./node.js').ProductFactor; +export type ParseRange = (tokens: CSSToken[], close: Int32Array, start: number, end: number) => Node; +/** + * Parse `[start, end)` as one complete expression. + * + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {Node} + */ +declare function parseRange(tokens: CSSToken[], close: Int32Array, start: number, end: number): Node; +/** + * Parse the token range `[start, end)` of `tokens` as one calculation. + * `close` is the closer array from `matchBlocks(tokens)`; when omitted, it is + * built and depth-checked from `tokens`. + * + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + * @param {Int32Array} [close] + * @return {Node} + */ +declare function parse(tokens: CSSToken[], start: number, end: number, close?: Int32Array): Node; +export { parse, parseRange }; diff --git a/types/lib/parser/opaque.d.ts b/types/lib/parser/opaque.d.ts index 1c07428..95536c0 100644 --- a/types/lib/parser/opaque.d.ts +++ b/types/lib/parser/opaque.d.ts @@ -1,12 +1,24 @@ export type CSSToken = import('@csstools/css-tokenizer').CSSToken; export type Node = import('../node.js').Node; export type OpaqueComponent = import('../node.js').OpaqueComponent; -export type ParseInput = import('./tokens.js').ParseInput; -export type Cursor = import('./tokens.js').Cursor; -export type FunctionToken = import('./tokens.js').FunctionToken; -export type ParseRange = (input: ParseInput, start: number, end: number) => Node; -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ -declare function parseOpaqueCall(input: ParseInput, cursor: Cursor, token: FunctionToken, name: string, rawName: string, parseRange: ParseRange): Node; -/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */ -declare function parseVar(input: ParseInput, cursor: Cursor, token: FunctionToken, name: string, rawName: string, parseRange: ParseRange): Node; +export type ParseState = import('./state.js').ParseState; +export type ParseRange = import('../parser.js').ParseRange; +/** + * @param {ParseState} state + * @param {number} index + * @param {string} name + * @param {string} rawName + * @param {ParseRange} parseRange + * @return {Node} + */ +declare function parseOpaqueCall(state: ParseState, index: number, name: string, rawName: string, parseRange: ParseRange): Node; +/** + * @param {ParseState} state + * @param {number} index + * @param {string} name + * @param {string} rawName + * @param {ParseRange} parseRange + * @return {Node} + */ +declare function parseVar(state: ParseState, index: number, name: string, rawName: string, parseRange: ParseRange): Node; export { parseOpaqueCall, parseVar }; diff --git a/types/lib/parser/state.d.ts b/types/lib/parser/state.d.ts new file mode 100644 index 0000000..3a4088d --- /dev/null +++ b/types/lib/parser/state.d.ts @@ -0,0 +1,85 @@ +import { TokenType as CssType } from '@csstools/css-tokenizer'; +export type CSSToken = import('@csstools/css-tokenizer').CSSToken; +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +/** + * A syntax error at a token. The position is not formatted here: a fallback + * that fails to parse is discarded, so formatting it would be wasted work. + * `parse()` formats the position when the error leaves the parser. + */ +declare class ParseError extends Error { + index: number; + suffix: string; + /** + * @param {string} message Text before the position. + * @param {number} index Token whose source offset is the position. + * @param {string} [suffix] Text after the position. + */ + constructor(message: string, index: number, suffix?: string); +} +/** + * The error to report for `error`: a parse error gets the position added to + * its message, and any other error is returned unchanged. The parse error is + * reused because capturing a second stack trace dominates the cost of a + * reported failure. + * + * @param {Error} error + * @param {(index: number) => number} offsetOf Offset lookup for the list. + * @return {Error} + */ +declare function positionedError(error: Error, offsetOf: (index: number) => number): Error; +/** @param {CssType} type @return {boolean} */ +declare function isTrivia(type: CssType): boolean; +export type ParseState = { + tokens: CSSToken[]; + close: Int32Array; + /** + * First token of the range, for the leading-whitespace rule. + */ + start: number; + /** + * Exclusive bound. + */ + end: number; + pos: number; +}; +/** + * Index of the next token that is not trivia, or `state.end`. + * + * @param {ParseState} state + * @return {number} + */ +declare function peek(state: ParseState): number; +/** + * @param {CSSToken[]} tokens + * @param {Int32Array} close + * @param {number} start + * @param {number} end + * @return {ParseState} + */ +declare function newState(tokens: CSSToken[], close: Int32Array, start: number, end: number): ParseState; +/** + * The text a parse error shows for an expression token, as + * `Expected ) ... got "..."`. Numbers show their value without the unit, and + * names their decoded value. + * + * @param {CSSToken} token + * @return {string} + */ +declare function tokenValue(token: CSSToken): string; +/** + * Read the operator at `index` and check that it is surrounded by whitespace. + * + * @param {ParseState} state + * @param {number} index + * @return {void} + */ +declare function requireSurroundingWhitespace(state: ParseState, index: number): void; +/** + * Read a numeric token that carries an explicit sign directly after an + * operand. Such a token is rejected: the sign must be a separate operator. + * + * @param {ParseState} state + * @return {void} + */ +declare function rejectSignedNumber(state: ParseState): void; +export { ParseError, isTrivia, positionedError, newState, peek, rejectSignedNumber, requireSurroundingWhitespace, tokenValue, }; diff --git a/types/lib/parser/tokens.d.ts b/types/lib/parser/tokens.d.ts index c9c33d6..e812ba8 100644 --- a/types/lib/parser/tokens.d.ts +++ b/types/lib/parser/tokens.d.ts @@ -1,83 +1,10 @@ -export type CSSToken = import('@csstools/css-tokenizer').CSSToken; -export type BlockIndex = ReturnType; -export type TokenBase = { - raw: string; - pos: number; - ws: boolean; - index: number; -}; -export type NumberToken = TokenBase & { - type: 'number'; - value: number; - signCharacter?: '+' | '-'; -}; -export type DimensionToken = TokenBase & { - type: 'dimension'; - value: number; - unit: string; - rawUnit: string; - signCharacter?: '+' | '-'; -}; -export type IdentToken = TokenBase & { - type: 'ident'; - value: string; -}; -export type FunctionToken = TokenBase & { - type: 'function'; - value: string; -}; -export type Punctuator = '(' | ')' | ',' | '+' | '-' | '*' | '/'; -export type PunctToken = TokenBase & { - type: 'punct'; - value: Punctuator; -}; -export type EofToken = TokenBase & { - type: 'eof'; - value: ''; - raw: ''; -}; -export type Token = NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken; -export type ParseInput = Readonly<{ - tokens: CSSToken[]; - end: number; - index: BlockIndex; -}>; -/** @param {string} raw @param {string} decoded */ -declare function sourceSpelling(raw: string, decoded: string): string | undefined; -/** - * Mutable navigation state only. `index` is always the next native token - * position; trivia is intentionally left visible to `scanToken`. - */ -declare class Cursor { - /** @type {number} */ - index: number; - /** @type {boolean} */ - firstToken: boolean; - /** @type {Token | null} */ - lookahead: Token | null; - /** @type {number} */ - lookaheadNextIndex: number; - /** @param {number} start */ - constructor(start: number); - /** @param {number} index @return {void} */ - skipTo(index: number): void; -} -/** @param {ParseInput} input @param {number} index @return {number} */ -declare function eofPositionAt(input: ParseInput, index: number): number; -/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ -declare function peekToken(input: ParseInput, cursor: Cursor): Token; /** - * Consume the cached token and advance to its native next index. This is one - * of the only two operations allowed to advance `cursor.index`. - * @param {ParseInput} input - * @param {Cursor} cursor - * @return {Token} + * The source spelling of a name, or `undefined` when it has none. A name + * that was escaped in the source keeps its raw text so it round-trips. + * + * @param {string} raw + * @param {string} decoded + * @return {string | undefined} */ -declare function takeToken(input: ParseInput, cursor: Cursor): Token; -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ -declare function isPunct(input: ParseInput, cursor: Cursor, value: Punctuator, value2?: Punctuator): boolean; -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ -declare function matchPunct(input: ParseInput, cursor: Cursor, value: Punctuator): boolean; -/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ -declare function expectPunct(input: ParseInput, cursor: Cursor, value: Punctuator): PunctToken; -export { Cursor, eofPositionAt, expectPunct, isPunct, matchPunct, peekToken, sourceSpelling, takeToken, }; +declare function sourceSpelling(raw: string, decoded: string): string | undefined; +export { sourceSpelling }; diff --git a/types/lib/print.d.ts b/types/lib/print.d.ts deleted file mode 100644 index 81ef162..0000000 --- a/types/lib/print.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type ResolvedReduceCalcOptions = import('../reduce.js').ResolvedReduceCalcOptions; -export type Replacement = import('../reduce.js').Replacement; -export type SerializeOptions = import('./serialize.js').SerializeOptions; -/** - * Serialize compiled candidates and splice the resulting text into the - * original source. Replacements are already non-overlapping because the - * finder treats a supported outer function as one range. - * - * @param {string} value - * @param {Replacement[]} replacements - * @param {ResolvedReduceCalcOptions} options - * @param {SerializeOptions} serializeOptions - * @return {string} - */ -declare function applyReplacements(value: string, replacements: Replacement[], options: ResolvedReduceCalcOptions, serializeOptions: SerializeOptions): string; -export { applyReplacements }; diff --git a/types/lib/scan.d.ts b/types/lib/scan.d.ts deleted file mode 100644 index ecf2b4e..0000000 --- a/types/lib/scan.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type Candidate = { - name: string; - normalizedName: string; - start: number; - end: number; - rootSpelling: string; - calculation: boolean; - sliceStart: number; - sliceEnd: number; - closed: boolean; -}; -/** - * @typedef {object} Candidate - * @property {string} name - * @property {string} normalizedName - * @property {number} start - * @property {number} end - * @property {string} rootSpelling - * @property {boolean} calculation - * @property {number} sliceStart - * @property {number} sliceEnd - * @property {boolean} closed - */ -/** - * Find complete supported math-function ranges. A supported function is - * treated as one candidate even when parsing it later fails, so nested - * calculations cannot produce partial output for an invalid outer function. - * - * @param {string} value - * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens - * @param {ReturnType} index - * @return {Candidate[]} - */ -declare function findCalculations(value: string, tokens: import('@csstools/css-tokenizer').CSSToken[], index: ReturnType): Candidate[]; -export { findCalculations }; diff --git a/types/reduce.d.ts b/types/reduce.d.ts index cdfcd50..a8b3dcb 100644 --- a/types/reduce.d.ts +++ b/types/reduce.d.ts @@ -19,24 +19,6 @@ export type ReduceCalcOptions = { onWarn?: (message: string) => void; }; export type ResolvedReduceCalcOptions = Required> & Pick; -export type TransformContext = { - options: ResolvedReduceCalcOptions; - value: string; - tokens: import('@csstools/css-tokenizer').CSSToken[]; -}; -export type Replacement = { - start: number; - end: number; - result: CalculationResult; -}; -export type CalculationResult = { - tree: import('./lib/node.js').Node; - status: 'resolved' | 'unresolved'; - rootName: string; - rootSpelling: string; - calculation: boolean; - original: string | undefined; -}; /** * @typedef {object} ReduceCalcOptions * @property {number | false} [precision] @@ -47,29 +29,6 @@ export type CalculationResult = { * @property {(message: string) => void} [onWarn] Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value. */ /** @typedef {Required> & Pick} ResolvedReduceCalcOptions */ -/** - * Fields threaded through the internal token-range walk. - * - * @typedef {object} TransformContext - * @property {ResolvedReduceCalcOptions} options - * @property {string} value - * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens - */ -/** - * @typedef {object} Replacement - * @property {number} start - * @property {number} end - * @property {CalculationResult} result - */ -/** - * @typedef {object} CalculationResult - * @property {import('./lib/node.js').Node} tree - * @property {'resolved' | 'unresolved'} status - * @property {string} rootName - * @property {string} rootSpelling - * @property {boolean} calculation - * @property {string | undefined} original - */ /** * Simplify every supported CSS math function in a component-value string. * Text outside those functions is preserved byte-for-byte.