@@ -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
172181function 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 ) {
0 commit comments