131. Length of a Node from Its Range
- Status: Implemented
- Date: 2026-10-31
Problem and Mechanism
len of a string node counted the characters of its matched text. Under
code-point positions, and under byte positions in TypeScript, whose strings are
UTF-16, that takes time in proportion to the text, and with
budget controls it spends value budget per character. Grammars that take the
length of an indentation or another capture in a predicate, as the YAML and
outline grammars do, paid it at every line.
The matched text of a string node is always the input of its range: a literal
node holds the input it matched, a missing node has an empty text and an empty
range, an error node holds the skipped input, and conversions keep both. The
range is already in the position unit, so len of a string node returns the
end of the range minus its start, in constant time and without budget. String
values keep their representation, and len of a string value still counts its
characters. Nodes of a Document keep their range lengths across edits, since
an edit shifts both ends of a reused node.
The complexity classifier follows: it counts len as a copying operation only
when the type of the operand, from the checker, may be a string value.
Applicability
| Execution profile | Status | Reason |
|---|---|---|
| Closure, recursive VM, iterative VM | Applied | One shared built-in |
| Generated Go Node and recognition | Applied | Runtime Length |
| Generated typed Go | Applied | Typed runtime Length |
| Generated TypeScript | Applied | Runtime length |
| Bytes position unit | Applied | Already constant time in Go; TypeScript no longer counts UTF-8 bytes |
Reproduction
Baseline 284e779a, candidate the commit of this record; Go 1.27.1,
darwin/arm64, Apple M3 Max. Test binaries of both revisions were built with
-trimpath and run alternately, ten rounds each, one count per round, under
the benchmark lock:
cd bench
./bench.test -test.run '^$' -test.benchmem -test.count 1 \
-test.bench 'BenchmarkParse/Outline/(closure|generated)/codepoints'
The focused benchmark parses 2,000 lines whose indentation runs from 0 to 63
spaces with rule line = s:" "* #atomic {len($s) >= 0} [a-z]* "\n" on the
closure backend in both units. Results were compared with benchstat.
Results
Medians of ten interleaved runs; ± is the benchstat confidence interval of
the baseline and the candidate, and every listed change has p=0.000. Each round
started with the one-minute load average below 8, and the load stayed between
5.5 and 7.5. The bytes control of the focused benchmark, which the change does
not affect, shows no difference.
| Workload | Baseline | Candidate | Change | Uncertainty |
|---|---|---|---|---|
| Outline, closure, code points | 6.839 ms | 6.443 ms | −5.8% | ±4%, ±2% |
| Outline, generated Go, code points | 2.825 ms | 2.662 ms | −5.8% | ±2%, ±1% |
| Focused, closure, code points | 1.810 ms | 1.604 ms | −11.4% | ±2%, ±7% |
| Focused, closure, bytes | 1.466 ms | 1.468 ms | ~ (p=0.971) | ±7%, ±7% |
Allocations and bytes per operation stay within 0.05%. The generated code does not change either: the length is taken in the runtimes, and regenerating the parsers and benchmarks gives no difference.
Correctness and Tradeoffs
A corpus case takes len of string, node[T], list, tuple and optional
nodes, of a node's Value and of text, with BMP, astral and empty inputs, in
both units; every engine, the generated Go parsers, typed and untyped, and the
TypeScript parsers agree with fixed expected values, and Documents after edits
agree with fresh parses. Budget totals drop where grammars take len of
nodes, and with them the context-poll counts of some of them, since a parse
polls its context once per fixed amount of budget; no total or count grows.
The classifier needs the types of the grammar, which come from compiling it
without building matchers. Compiling costs about as much as classifying, so
the classifier compiles only when a len without types counted an operand of
unbounded size, and keeps the untyped classification when the grammar does
not compile. The classes of the corpus grammars do not change; the Python
grammar loses 18 causes, all len of list captures.
Decision and Limits
Adopted on every backend. len of a string value is unchanged: under code
points it remains proportional to the string.