Skip to content

Commit 558df24

Browse files
committed
Merge branch 'worktree-agent-ab3f7be54d1f95382'
# Conflicts: # examples/typescript.ts # src/api.ts # src/gen-lexer.ts # src/types.ts
2 parents 152bde6 + 7bcb27d commit 558df24

7 files changed

Lines changed: 95 additions & 16 deletions

File tree

examples/typescript.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
22
token, rule, defineGrammar,
33
left, right, none,
4-
op, prefix, postfix,
5-
sep, opt, many, many1, alt, exclude,
4+
op, prefix, postfix, sameLine,
5+
sep, opt, many, many1, alt, exclude, not,
66
} from '../src/api.ts';
77

88
// ── Tokens ──
@@ -76,6 +76,7 @@ const TypeMember = rule($ => {
7676
[':', Type, ']', opt(':', Type)], // index: k: T
7777
)],
7878
[Expr, ']', opt('?'), propOrMethod], // computed: expr
79+
[']', opt(':', Type)], // empty index sig: [] / []: T
7980
)],
8081
// readonly property (the readonly index signature is the bracketed branch above)
8182
['readonly', Ident, opt('?'), ':', Type],
@@ -89,7 +90,7 @@ const Type = rule($ => {
8990
return [
9091
[Ident, opt('is', $)], // T | type predicate `x is T`
9192
[$, '<', sep($, ','), '>'],
92-
[$, '[', ']'],
93+
[$, sameLine, '[', ']'], // array type T[] — `[` must be on the same line (no ASI)
9394
[$, '|', $],
9495
[$, '&', $],
9596
['|', $], // leading pipe: type T = | A | B
@@ -117,7 +118,7 @@ const Type = rule($ => {
117118
['unique', 'symbol'],
118119
['import', '(', $, ')'],
119120
Template,
120-
[$, '[', $, ']'],
121+
[$, sameLine, '[', $, ']'], // indexed access T[K] — `[` must be on the same line (no ASI)
121122
[$, '.', Ident],
122123
];
123124
}, { type: true });
@@ -168,7 +169,10 @@ const Expr = rule($ => [
168169
[$, postfix],
169170
['...', $],
170171
// instantiation / typed call / tagged template: f<T> | f<T>(…) | f<T>`…`
171-
[$, '<', sep(Type, ','), '>', opt(alt(['(', sep($, ','), ')'], Template))],
172+
// A bare instantiation `f<T>` (no call/tag) only when the next token can't
173+
// start an expression — otherwise `<`/`>` were comparisons (`f < a, b > 7`):
174+
// the same disambiguation TS makes via canFollowTypeArgumentsInExpression.
175+
[$, '<', sep(Type, ','), '>', alt(['(', sep($, ','), ')'], Template, not(Expr))],
172176
[$, '(', sep($, ','), ')'],
173177
[$, '.', alt(Ident, PrivateField)],
174178
// optional chaining: ?.x | ?.#x | ?.(args) | ?.[i] | ?.`…`

src/api.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function rule(def: RuleBody, opts?: RuleOptions): RuleRef {
6363
// ── Special slots for rules ──
6464

6565
interface OpMarker { readonly __kind: 'op' }
66+
interface SameLineMarker { readonly __kind: 'sameLine' }
6667
interface PrefixSlot {
6768
readonly __kind: 'prefix';
6869
(...ops: string[]): PrefixOps;
@@ -74,10 +75,13 @@ interface PostfixSlot {
7475
interface PrefixOps { readonly __kind: 'prefix-ops'; ops: string[] }
7576
interface PostfixOps { readonly __kind: 'postfix-ops'; ops: string[] }
7677

77-
type Marker = OpMarker | PrefixSlot | PostfixSlot;
78+
type Marker = OpMarker | PrefixSlot | PostfixSlot | SameLineMarker;
7879

7980
export const op: OpMarker = { __kind: 'op' };
8081

82+
// Zero-width "no LineTerminator here" assertion (see RuleExpr 'sameLine').
83+
export const sameLine: SameLineMarker = { __kind: 'sameLine' };
84+
8185
export const prefix: PrefixSlot = Object.assign(
8286
(...ops: string[]): PrefixOps => ({ __kind: 'prefix-ops' as const, ops }),
8387
{ __kind: 'prefix' as const },
@@ -132,8 +136,15 @@ class ExcludeNode {
132136
readonly items: Element[];
133137
constructor(connectors: string[], items: Element[]) { this.connectors = connectors; this.items = items; }
134138
}
139+
class NotNode {
140+
readonly __kind = 'not' as const;
141+
// Zero-width negative lookahead over a single element (wrap a sequence in a
142+
// group/alt if needed). Matches nothing; succeeds only when `item` can't match.
143+
readonly item: Element;
144+
constructor(item: Element) { this.item = item; }
145+
}
135146

136-
type Combinator = SepNode | OptNode | ManyNode | Many1Node | AltNode | ExcludeNode;
147+
type Combinator = SepNode | OptNode | ManyNode | Many1Node | AltNode | ExcludeNode | NotNode;
137148

138149
export function sep(item: Element, delimiter: string): SepNode {
139150
return new SepNode(item, delimiter);
@@ -162,6 +173,12 @@ export function exclude(connectors: string | string[], ...items: Element[]): Exc
162173
return new ExcludeNode(typeof connectors === 'string' ? [connectors] : connectors, items);
163174
}
164175

176+
// Zero-width negative lookahead: `not(x)` matches nothing and succeeds only when
177+
// `x` would NOT match here.
178+
export function not(item: Element): NotNode {
179+
return new NotNode(item);
180+
}
181+
165182
// ── Precedence ──
166183

167184
interface PrecLevelDef {
@@ -255,10 +272,14 @@ function toRuleExpr(el: Element, names: Map<object, string>): RuleExpr {
255272
}),
256273
};
257274
}
275+
if (el instanceof NotNode) {
276+
return { type: 'not', body: toRuleExpr(el.item, names) };
277+
}
258278
const marker = el as Marker;
259279
if (marker.__kind === 'op') return { type: 'op' };
260280
if (marker.__kind === 'prefix') return { type: 'prefix' };
261281
if (marker.__kind === 'postfix') return { type: 'postfix' };
282+
if (marker.__kind === 'sameLine') return { type: 'sameLine' };
262283
throw new Error(`Unknown element: ${JSON.stringify(el)}`);
263284
}
264285

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ function formatExpr(expr: RuleExpr): string {
9494
case 'alt': return expr.items.map(formatExpr).join(' | ');
9595
case 'quantifier': return `${formatExpr(expr.body)}${expr.kind}`;
9696
case 'group': return `(${formatExpr(expr.body)})`;
97+
case 'not': return `not(${formatExpr(expr.body)})`;
98+
case 'sameLine': return 'sameLine';
9799
case 'sep': return `sep(${formatExpr(expr.element)}, '${expr.delimiter}')`;
98100
}
99101
}

src/gen-lexer.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface Token {
88
type: string; // token decl name (e.g. 'Ident'), or '' for punctuation literals
99
text: string;
1010
offset: number;
11+
newlineBefore?: boolean; // a line terminator preceded this token (drives ASI / "no LineTerminator here" rules)
1112
}
1213

1314
// Build a standalone lexer from the grammar's token definitions + lexer hints.
@@ -84,11 +85,20 @@ export function createLexer(grammar: CstGrammar) {
8485
// that to the regex-vs-division check (consulted only when prev is `)`).
8586
const parenHeadStack: boolean[] = [];
8687
let lastCloseWasParenHead = false;
88+
// A line terminator was seen since the last emitted token (skipped whitespace
89+
// or comments). Stamped onto the next real token as `newlineBefore`, then
90+
// cleared — so the parser can honor "no LineTerminator here" restrictions
91+
// (e.g. an array/indexed-access type's `[` must be on the same line).
92+
let pendingNl = false;
93+
function push(t: Token): void {
94+
if (pendingNl) { t.newlineBefore = true; pendingNl = false; }
95+
tokens.push(t);
96+
}
8797

8898
while (pos < source.length) {
8999
// Skip whitespace
90100
const wsMatch = source.slice(pos).match(/^\s+/);
91-
if (wsMatch) { pos += wsMatch[0].length; continue; }
101+
if (wsMatch) { if (wsMatch[0].includes('\n')) pendingNl = true; pos += wsMatch[0].length; continue; }
92102

93103
// Close an interpolation hole (interpClose at baseline depth) → resume the template span.
94104
if (templateStack.length > 0 && source.startsWith(tplInterpClose, pos)) {
@@ -99,10 +109,10 @@ export function createLexer(grammar: CstGrammar) {
99109
pos += tplInterpClose.length;
100110
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
101111
if (endsWithInterp) {
102-
tokens.push({ type: '$templateMiddle', text: source.slice(startPos, end), offset: startPos });
112+
push({ type: '$templateMiddle', text: source.slice(startPos, end), offset: startPos });
103113
templateStack.push(0);
104114
} else {
105-
tokens.push({ type: '$templateTail', text: source.slice(startPos, end), offset: startPos });
115+
push({ type: '$templateTail', text: source.slice(startPos, end), offset: startPos });
106116
}
107117
pos = end;
108118
continue;
@@ -122,10 +132,10 @@ export function createLexer(grammar: CstGrammar) {
122132
pos += tplOpen.length;
123133
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
124134
if (endsWithInterp) {
125-
tokens.push({ type: '$templateHead', text: source.slice(startPos, end), offset: startPos });
135+
push({ type: '$templateHead', text: source.slice(startPos, end), offset: startPos });
126136
templateStack.push(0);
127137
} else {
128-
tokens.push({ type: templateTokenName!, text: source.slice(startPos, end), offset: startPos });
138+
push({ type: templateTokenName!, text: source.slice(startPos, end), offset: startPos });
129139
}
130140
pos = end;
131141
continue;
@@ -152,7 +162,9 @@ export function createLexer(grammar: CstGrammar) {
152162
const m = remaining.match(tm.regex);
153163
if (m) {
154164
if (!tm.skip) {
155-
tokens.push({ type: tm.name, text: m[0], offset: pos });
165+
push({ type: tm.name, text: m[0], offset: pos });
166+
} else if (m[0].includes('\n')) {
167+
pendingNl = true; // a skipped comment spanning a newline still terminates the previous line
156168
}
157169
pos += m[0].length;
158170
matched = true;
@@ -178,7 +190,7 @@ export function createLexer(grammar: CstGrammar) {
178190
} else if (lit === ')') {
179191
lastCloseWasParenHead = parenHeadStack.pop() ?? false;
180192
}
181-
tokens.push({ type: '', text: lit, offset: pos });
193+
push({ type: '', text: lit, offset: pos });
182194
pos += lit.length;
183195
matched = true;
184196
break;
@@ -191,7 +203,7 @@ export function createLexer(grammar: CstGrammar) {
191203
// missed (e.g. accented or non-Latin names). Tagged with that token's name.
192204
const identMatch = remaining.match(/^[\p{L}\p{Nl}_$][\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Mc}\p{Pc}_$]*/u);
193205
if (identMatch) {
194-
tokens.push({ type: identTokenName, text: identMatch[0], offset: pos });
206+
push({ type: identTokenName, text: identMatch[0], offset: pos });
195207
pos += identMatch[0].length;
196208
matched = true;
197209
}

src/gen-parser.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export function createParser(grammar: CstGrammar) {
228228
case 'alt': return e.items.some(exprNullable);
229229
case 'quantifier': return e.kind === '+' ? exprNullable(e.body) : true;
230230
case 'group': return exprNullable(e.body);
231+
case 'not': return true; // zero-width assertion: consumes nothing
231232
case 'sep': return true; // sep matches zero elements
232233
default: return true; // op/prefix/postfix markers don't consume
233234
}
@@ -250,7 +251,7 @@ export function createParser(grammar: CstGrammar) {
250251
const acc = new Set<string>();
251252
for (const item of e.items) {
252253
if (item.type === 'prefix') return null; // prefix op → any operator token: give up
253-
if (item.type === 'op' || item.type === 'postfix') continue; // non-consuming here
254+
if (item.type === 'op' || item.type === 'postfix' || item.type === 'not' || item.type === 'sameLine') continue; // non-consuming here
254255
const f = exprFirst(item);
255256
if (f === null) return null;
256257
for (const k of f) acc.add(k);
@@ -268,6 +269,7 @@ export function createParser(grammar: CstGrammar) {
268269
return acc;
269270
}
270271
case 'quantifier': case 'group': return exprFirst(e.body);
272+
case 'not': case 'sameLine': return new Set(); // zero-width: contributes no FIRST tokens
271273
case 'sep': return exprFirst(e.element);
272274
default: return null;
273275
}
@@ -697,6 +699,21 @@ export function createParser(grammar: CstGrammar) {
697699
// rule it wraps: stage them for the next parseRule to pick up.
698700
if (expr.suppress && expr.suppress.length) suppressNext = new Set(expr.suppress);
699701
return matchExpr(expr.body);
702+
case 'not': {
703+
// Zero-width negative lookahead: succeed (no children) iff the body
704+
// does NOT match here; never consume input either way.
705+
const saved = pos;
706+
const m = matchExpr(expr.body);
707+
pos = saved;
708+
return m === null ? [] : null;
709+
}
710+
case 'sameLine': {
711+
// Zero-width "no LineTerminator here": succeed (no children) iff the
712+
// next token isn't preceded by a newline. At EOF there's no token to
713+
// continue onto, so the assertion fails (nothing same-line follows).
714+
const tok = peek();
715+
return tok && !tok.newlineBefore ? [] : null;
716+
}
700717
case 'sep':
701718
return matchSep(expr.element, expr.delimiter);
702719
default:

src/gen-treesitter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,16 @@ function renderExpr(expr: RuleExpr, ctx: GrammarJsContext): string {
139139
}
140140
case 'group':
141141
return renderExpr(expr.body, ctx);
142+
case 'not':
143+
// Zero-width negative lookahead: not expressible in a tree-sitter CFG, and
144+
// it consumes nothing, so it drops to a no-op (the surrounding choice keeps
145+
// the bare form, as the grammar did before the assertion was added).
146+
return 'blank()';
147+
case 'sameLine':
148+
// Zero-width "no LineTerminator here" assertion — tree-sitter handles this
149+
// class of restriction with an external scanner, not the CFG; as a CFG node
150+
// it consumes nothing, so render a no-op.
151+
return 'blank()';
142152
case 'sep': {
143153
// sep(elem, ',') = optional(seq(elem, repeat(seq(',', elem)), optional(',')))
144154
// Trailing delimiter is allowed (matches the parser's matchSep behavior).

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ export type RuleExpr =
5252
| { type: 'ref'; name: string }
5353
| { type: 'quantifier'; body: RuleExpr; kind: '*' | '+' | '?' }
5454
| { type: 'group'; body: RuleExpr; suppress?: string[] } // suppress: LED connectors disabled while parsing body (e.g. no-`in`)
55+
// Zero-width negative lookahead: matches (consuming nothing) iff `body` does
56+
// NOT match at the current position. Used to express disambiguations the
57+
// longest-match parser can't reach by structure alone (e.g. a `<…>` type-arg
58+
// list in expression position is only a bare instantiation when it isn't
59+
// followed by something that starts an expression). Non-consuming → invisible
60+
// to highlighting / AST shape / other generators.
61+
| { type: 'not'; body: RuleExpr }
62+
// Zero-width "no LineTerminator here" assertion: matches (consuming nothing)
63+
// iff the NEXT token is on the same line (no preceding newline). Encodes
64+
// ECMAScript/TS restricted productions like an array/indexed-access type's `[`,
65+
// which must not follow a line terminator. Non-consuming → invisible to other
66+
// generators (they treat it as a no-op marker).
67+
| { type: 'sameLine' }
5568
| { type: 'sep'; element: RuleExpr; delimiter: string }
5669
| { type: 'op' }
5770
| { type: 'prefix' }

0 commit comments

Comments
 (0)