Skip to content

Commit 5eda203

Browse files
tree-sitter: decouple the TS type sub-grammar from expr (9819 → 7612 states) (#49)
1 parent b5bc77b commit 5eda203

6 files changed

Lines changed: 125 additions & 19 deletions

File tree

src/api.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,13 @@ class RelaxNode {
252252
readonly __kind = 'relax' as const;
253253
readonly strict: Element[];
254254
readonly relaxed: Element[];
255-
constructor(strict: Element[], relaxed: Element[]) { this.strict = strict; this.relaxed = relaxed; }
255+
// When set, gen-treesitter emits `relaxed` ONCE as a shared rule of this name and renders
256+
// every reference as `$.<ruleName>`, instead of inlining `relaxed` at each site. Inlining a
257+
// relaxed form duplicates its states at every use; a shared rule keeps them in ONE place
258+
// (the difference is large — see issue #46). Visibility follows tree-sitter's `_`-prefix
259+
// convention (a leading `_` hides the node).
260+
readonly ruleName?: string;
261+
constructor(strict: Element[], relaxed: Element[], ruleName?: string) { this.strict = strict; this.relaxed = relaxed; this.ruleName = ruleName; }
256262
}
257263

258264
class CapExprNode {
@@ -303,8 +309,8 @@ export function exclude(connectors: string | string[], ...items: Element[]): Exc
303309
// Parse `strict` (in the parser and all generators) but render `relaxed` for tree-sitter.
304310
// For a parser-correct constraint that explodes / inflates the tree-sitter GLR table while
305311
// the highlighter doesn't need it. Each side is a single element or an array (a seq).
306-
export function tsRelax(strict: Element | Element[], relaxed: Element | Element[]): RelaxNode {
307-
return new RelaxNode(Array.isArray(strict) ? strict : [strict], Array.isArray(relaxed) ? relaxed : [relaxed]);
312+
export function tsRelax(strict: Element | Element[], relaxed: Element | Element[], ruleName?: string): RelaxNode {
313+
return new RelaxNode(Array.isArray(strict) ? strict : [strict], Array.isArray(relaxed) ? relaxed : [relaxed], ruleName);
308314
}
309315

310316
// Mark a NUD alternative as a complete assignment-level expression (an ArrowFunction —
@@ -437,7 +443,7 @@ function toRuleExpr(el: Element, names: Map<object, string>): RuleExpr {
437443
const build = (items: Element[]): RuleExpr => items.length === 1
438444
? toRuleExpr(items[0], names)
439445
: { type: 'seq', items: items.map(i => toRuleExpr(i, names)) };
440-
return { type: 'group', body: build(el.strict), tsRelaxed: build(el.relaxed) };
446+
return { type: 'group', body: build(el.strict), tsRelaxed: build(el.relaxed), tsRuleName: el.ruleName };
441447
}
442448
if (el instanceof CapExprNode) {
443449
// Reuse the transparent `group` node (every walker recurses into `body`); `capBelow`

src/gen-treesitter.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ interface GrammarJsContext {
167167
* Keyed by node identity (the exact RuleExpr object in the grammar AST).
168168
*/
169169
nameFieldNodes: Set<RuleExpr>;
170+
/**
171+
* Shared rules extracted from tsRelax(..., ruleName) groups: name → relaxed body. The body
172+
* is emitted ONCE as a rule and every reference renders as `$.<name>`, so a tree-sitter-only
173+
* relaxed form is SHARED across all its use sites instead of inlined (and duplicated) at each
174+
* — the difference is large for a rule used in many type positions (the fn-type/method param,
175+
* issue #46). Visibility follows tree-sitter's `_`-prefix convention. Empty when the grammar
176+
* uses no named tsRelax.
177+
*/
178+
tsSharedRules: Map<string, RuleExpr>;
170179
}
171180

172181
function hasMarker(expr: RuleExpr): boolean {
@@ -177,6 +186,27 @@ function hasMarker(expr: RuleExpr): boolean {
177186
return false;
178187
}
179188

189+
/** Pre-scan every rule body for named tsRelax groups (`tsRuleName`), collecting name → relaxed
190+
* body. Done before conflicts/rules are emitted so the shared rule names are known to
191+
* deriveConflicts (a closure tuple may name one) and the bodies can be emitted as shared rules. */
192+
function collectTsSharedRules(grammar: CstGrammar): Map<string, RuleExpr> {
193+
const out = new Map<string, RuleExpr>();
194+
const walk = (e: RuleExpr): void => {
195+
switch (e.type) {
196+
case 'group':
197+
if (e.tsRuleName && !out.has(e.tsRuleName)) out.set(e.tsRuleName, e.tsRelaxed ?? e.body);
198+
// Walk the relaxed subtree (what tree-sitter actually emits), not the strict body.
199+
walk(e.tsRelaxed ?? e.body); break;
200+
case 'seq': case 'alt': e.items.forEach(walk); break;
201+
case 'quantifier': walk(e.body); break;
202+
case 'sep': walk(e.element); break;
203+
case 'not': walk(e.body); break;
204+
}
205+
};
206+
for (const r of grammar.rules) walk(r.body);
207+
return out;
208+
}
209+
180210
/**
181211
* Render a non-Pratt RuleExpr to a tree-sitter DSL expression string.
182212
* Pratt markers (op/prefix/postfix) are handled separately by buildPrattRule,
@@ -222,11 +252,19 @@ function renderExpr(expr: RuleExpr, ctx: GrammarJsContext): string {
222252
if (expr.kind === '*') return `repeat(${body})`;
223253
return `repeat1(${body})`;
224254
}
225-
case 'group':
255+
case 'group': {
226256
// A tsRelax group carries a tree-sitter-only alternate rendering (a parser-strict
227257
// constraint the highlighter relaxes — see RuleExpr.group.tsRelaxed). Render that
228258
// instead of the strict body; every other consumer uses `body`.
229-
return renderExpr(expr.tsRelaxed ?? expr.body, ctx);
259+
const relaxed = expr.tsRelaxed ?? expr.body;
260+
// Named tsRelax: emit the relaxed body ONCE as a shared rule and reference it, so its
261+
// states are shared across all use sites rather than inlined at each.
262+
if (expr.tsRuleName) {
263+
if (!ctx.tsSharedRules.has(expr.tsRuleName)) ctx.tsSharedRules.set(expr.tsRuleName, relaxed);
264+
return `$.${expr.tsRuleName}`;
265+
}
266+
return renderExpr(relaxed, ctx);
267+
}
230268
case 'not':
231269
// Zero-width negative lookahead: not expressible in a tree-sitter CFG, and
232270
// it consumes nothing, so it drops to a no-op (the surrounding choice keeps
@@ -572,6 +610,15 @@ const LR_CONFLICT_CLOSURE: string[][] = [
572610
['decorator_expr', 'new_target'],
573611
['binding_element'],
574612
['type_member', 'expr', 'prop', 'member_name'],
613+
// issue #46: the type-only fn/method/index param (`type_fn_param`, a shared tsRelax rule)
614+
// decouples the type sub-grammar from `expr` — a function-TYPE param can no longer reach a
615+
// default-value expression — which collapses ~2.2k GLR states. These are the residual
616+
// conflicts at the `(` boundary where a param could still be value-context.
617+
['type_fn_param', 'type'],
618+
['type_fn_param', 'type', 'expr', 'param'],
619+
['type_fn_param', 'expr', 'param'],
620+
['type_fn_param', 'expr'],
621+
['type_fn_param', 'param'],
575622
// YAML (issue #3): an indentation grammar is massively ambiguous — a newline may continue a node or
576623
// start the next document, a `:` may open a value or be an empty-key map, a scalar may be a key or a
577624
// leaf, a flow collection may be a value or an implicit block key. tree-sitter's GLR absorbs all of
@@ -645,7 +692,9 @@ function deriveConflicts(ctx: GrammarJsContext): string[][] {
645692
// both name sets count — `$.key` is a valid conflict symbol whether key is a rule or a token.
646693
const tokenSnakes = new Set(ctx.tokenSnake.values());
647694
for (const tuple of LR_CONFLICT_CLOSURE) {
648-
if (tuple.every(r => ruleSnakes.has(r) || tokenSnakes.has(r))) push(tuple);
695+
// A tuple symbol may be a rule, a token, or a shared hidden tsRelax rule (e.g. the
696+
// type-only fn-param `_type_fn_param`); all three are valid `$.<name>` conflict symbols.
697+
if (tuple.every(r => ruleSnakes.has(r) || tokenSnakes.has(r) || ctx.tsSharedRules.has(r))) push(tuple);
649698
}
650699

651700
return conflicts;
@@ -1059,6 +1108,7 @@ export function generateTreeSitter(grammar: CstGrammar, langName?: string): Tree
10591108
templatePlan,
10601109
interpolationPlans,
10611110
nameFieldNodes: nameFields.nodes,
1111+
tsSharedRules: collectTsSharedRules(grammar),
10621112
};
10631113

10641114
const grammarJs = buildGrammarJs(ctx, grammarName);
@@ -1147,6 +1197,20 @@ function buildGrammarJs(ctx: GrammarJsContext, grammarName: string): string {
11471197
ruleEntries.push(` ${snake}: $ => ${body}`);
11481198
}
11491199

1200+
// Shared rules from named tsRelax (e.g. the type-only fn-param). Emitted right AFTER the
1201+
// entry rule (index 1), never first: the entry rule must stay the start symbol, AND
1202+
// tree-sitter's state count is sensitive to rule ORDER — placing the type-param rule EARLY
1203+
// collapses ~2k more states than placing it last (measured, issue #46). Emitted once so
1204+
// their states are shared across every reference rather than inlined at each use site.
1205+
// (Visibility follows the tree-sitter convention: a `_`-prefixed name is hidden; the
1206+
// type-fn-param is deliberately VISIBLE so its param-name child is not spliced into the
1207+
// enclosing `type` node — see typescript.ts.)
1208+
const sharedEntries: string[] = [];
1209+
for (const [name, body] of ctx.tsSharedRules) {
1210+
sharedEntries.push(` ${name}: $ => ${renderExpr(body, ctx)}`);
1211+
}
1212+
if (sharedEntries.length) ruleEntries.splice(1, 0, ...sharedEntries);
1213+
11501214
// Token rules (named) — those not provided by the scanner. Skip tokens are
11511215
// included so `extras` references resolve.
11521216
for (const tok of grammar.tokens) {

src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,11 @@ export type RuleExpr =
453453
// parsed only when the enclosing Pratt minBp is LOOSER than the named connector's
454454
// binding power, and once parsed admits NO led (a tighter operator can neither take it
455455
// as an operand nor continue it). Read only by the expression-engine Pratt core.
456-
| { type: 'group'; body: RuleExpr; suppress?: string[]; ctxMode?: 'await' | 'yield' | 'asyncgen' | 'reset'; tsRelaxed?: RuleExpr; capBelow?: string } // suppress: LED connectors disabled while parsing body (e.g. no-`in`)
456+
// `tsRuleName`: when a tsRelaxed group carries it, gen-treesitter emits `tsRelaxed` ONCE
457+
// as a shared rule of this name and renders each reference as `$.<name>` — sharing its
458+
// states instead of inlining (and duplicating) them at every use site (issue #46).
459+
// Visibility follows tree-sitter's `_`-prefix convention.
460+
| { type: 'group'; body: RuleExpr; suppress?: string[]; ctxMode?: 'await' | 'yield' | 'asyncgen' | 'reset'; tsRelaxed?: RuleExpr; tsRuleName?: string; capBelow?: string } // suppress: LED connectors disabled while parsing body (e.g. no-`in`)
457461
// Zero-width negative lookahead: matches (consuming nothing) iff `body` does
458462
// NOT match at the current position. Used to express disambiguations the
459463
// longest-match parser can't reach by structure alone (e.g. a `<…>` type-arg

0 commit comments

Comments
 (0)