Skip to content

Commit 7bcb27d

Browse files
committed
not()/sameLine + generic/computed grammar
1 parent a4ef43d commit 7bcb27d

7 files changed

Lines changed: 93 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,
4+
op, prefix, postfix, sameLine,
5+
sep, opt, many, many1, alt, not,
66
} from '../src/api.ts';
77

88
// ── Tokens ──
@@ -70,6 +70,7 @@ const TypeMember = rule($ => {
7070
[':', Type, ']', opt(':', Type)], // index: k: T
7171
)],
7272
[Expr, ']', opt('?'), propOrMethod], // computed: expr
73+
[']', opt(':', Type)], // empty index sig: [] / []: T
7374
)],
7475
// readonly property (the readonly index signature is the bracketed branch above)
7576
['readonly', Ident, opt('?'), ':', Type],
@@ -83,7 +84,7 @@ const Type = rule($ => {
8384
return [
8485
[Ident, opt('is', $)], // T | type predicate `x is T`
8586
[$, '<', sep($, ','), '>'],
86-
[$, '[', ']'],
87+
[$, sameLine, '[', ']'], // array type T[] — `[` must be on the same line (no ASI)
8788
[$, '|', $],
8889
[$, '&', $],
8990
['|', $], // leading pipe: type T = | A | B
@@ -111,7 +112,7 @@ const Type = rule($ => {
111112
['unique', 'symbol'],
112113
['import', '(', $, ')'],
113114
Template,
114-
[$, '[', $, ']'],
115+
[$, sameLine, '[', $, ']'], // indexed access T[K] — `[` must be on the same line (no ASI)
115116
[$, '.', Ident],
116117
];
117118
}, { type: true });
@@ -162,7 +163,10 @@ const Expr = rule($ => [
162163
[$, postfix],
163164
['...', $],
164165
// instantiation / typed call / tagged template: f<T> | f<T>(…) | f<T>`…`
165-
[$, '<', sep(Type, ','), '>', opt(alt(['(', sep($, ','), ')'], Template))],
166+
// A bare instantiation `f<T>` (no call/tag) only when the next token can't
167+
// start an expression — otherwise `<`/`>` were comparisons (`f < a, b > 7`):
168+
// the same disambiguation TS makes via canFollowTypeArgumentsInExpression.
169+
[$, '<', sep(Type, ','), '>', alt(['(', sep($, ','), ')'], Template, not(Expr))],
166170
[$, '(', sep($, ','), ')'],
167171
[$, '.', alt(Ident, PrivateField)],
168172
// optional chaining: ?.x | ?.#x | ?.(args) | ?.[i] | ?.`…`

src/api.ts

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

6363
interface OpMarker { readonly __kind: 'op' }
64+
interface SameLineMarker { readonly __kind: 'sameLine' }
6465
interface PrefixSlot {
6566
readonly __kind: 'prefix';
6667
(...ops: string[]): PrefixOps;
@@ -72,10 +73,13 @@ interface PostfixSlot {
7273
interface PrefixOps { readonly __kind: 'prefix-ops'; ops: string[] }
7374
interface PostfixOps { readonly __kind: 'postfix-ops'; ops: string[] }
7475

75-
type Marker = OpMarker | PrefixSlot | PostfixSlot;
76+
type Marker = OpMarker | PrefixSlot | PostfixSlot | SameLineMarker;
7677

7778
export const op: OpMarker = { __kind: 'op' };
7879

80+
// Zero-width "no LineTerminator here" assertion (see RuleExpr 'sameLine').
81+
export const sameLine: SameLineMarker = { __kind: 'sameLine' };
82+
7983
export const prefix: PrefixSlot = Object.assign(
8084
(...ops: string[]): PrefixOps => ({ __kind: 'prefix-ops' as const, ops }),
8185
{ __kind: 'prefix' as const },
@@ -119,8 +123,15 @@ class AltNode {
119123
readonly items: Alternative[];
120124
constructor(items: Alternative[]) { this.items = items; }
121125
}
126+
class NotNode {
127+
readonly __kind = 'not' as const;
128+
// Zero-width negative lookahead over a single element (wrap a sequence in a
129+
// group/alt if needed). Matches nothing; succeeds only when `item` can't match.
130+
readonly item: Element;
131+
constructor(item: Element) { this.item = item; }
132+
}
122133

123-
type Combinator = SepNode | OptNode | ManyNode | Many1Node | AltNode;
134+
type Combinator = SepNode | OptNode | ManyNode | Many1Node | AltNode | NotNode;
124135

125136
export function sep(item: Element, delimiter: string): SepNode {
126137
return new SepNode(item, delimiter);
@@ -142,6 +153,10 @@ export function alt(...items: Alternative[]): AltNode {
142153
return new AltNode(items);
143154
}
144155

156+
export function not(item: Element): NotNode {
157+
return new NotNode(item);
158+
}
159+
145160
// ── Precedence ──
146161

147162
interface PrecLevelDef {
@@ -227,10 +242,14 @@ function toRuleExpr(el: Element, names: Map<object, string>): RuleExpr {
227242
}),
228243
};
229244
}
245+
if (el instanceof NotNode) {
246+
return { type: 'not', body: toRuleExpr(el.item, names) };
247+
}
230248
const marker = el as Marker;
231249
if (marker.__kind === 'op') return { type: 'op' };
232250
if (marker.__kind === 'prefix') return { type: 'prefix' };
233251
if (marker.__kind === 'postfix') return { type: 'postfix' };
252+
if (marker.__kind === 'sameLine') return { type: 'sameLine' };
234253
throw new Error(`Unknown element: ${JSON.stringify(el)}`);
235254
}
236255

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.
@@ -73,11 +74,20 @@ export function createLexer(grammar: CstGrammar) {
7374
const tokens: Token[] = [];
7475
let pos = 0;
7576
const templateStack: number[] = [];
77+
// A line terminator was seen since the last emitted token (skipped whitespace
78+
// or comments). Stamped onto the next real token as `newlineBefore`, then
79+
// cleared — so the parser can honor "no LineTerminator here" restrictions
80+
// (e.g. an array/indexed-access type's `[` must be on the same line).
81+
let pendingNl = false;
82+
function push(t: Token): void {
83+
if (pendingNl) { t.newlineBefore = true; pendingNl = false; }
84+
tokens.push(t);
85+
}
7686

7787
while (pos < source.length) {
7888
// Skip whitespace
7989
const wsMatch = source.slice(pos).match(/^\s+/);
80-
if (wsMatch) { pos += wsMatch[0].length; continue; }
90+
if (wsMatch) { if (wsMatch[0].includes('\n')) pendingNl = true; pos += wsMatch[0].length; continue; }
8191

8292
// Close an interpolation hole (interpClose at baseline depth) → resume the template span.
8393
if (templateStack.length > 0 && source.startsWith(tplInterpClose, pos)) {
@@ -88,10 +98,10 @@ export function createLexer(grammar: CstGrammar) {
8898
pos += tplInterpClose.length;
8999
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
90100
if (endsWithInterp) {
91-
tokens.push({ type: '$templateMiddle', text: source.slice(startPos, end), offset: startPos });
101+
push({ type: '$templateMiddle', text: source.slice(startPos, end), offset: startPos });
92102
templateStack.push(0);
93103
} else {
94-
tokens.push({ type: '$templateTail', text: source.slice(startPos, end), offset: startPos });
104+
push({ type: '$templateTail', text: source.slice(startPos, end), offset: startPos });
95105
}
96106
pos = end;
97107
continue;
@@ -111,10 +121,10 @@ export function createLexer(grammar: CstGrammar) {
111121
pos += tplOpen.length;
112122
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
113123
if (endsWithInterp) {
114-
tokens.push({ type: '$templateHead', text: source.slice(startPos, end), offset: startPos });
124+
push({ type: '$templateHead', text: source.slice(startPos, end), offset: startPos });
115125
templateStack.push(0);
116126
} else {
117-
tokens.push({ type: templateTokenName!, text: source.slice(startPos, end), offset: startPos });
127+
push({ type: templateTokenName!, text: source.slice(startPos, end), offset: startPos });
118128
}
119129
pos = end;
120130
continue;
@@ -139,7 +149,9 @@ export function createLexer(grammar: CstGrammar) {
139149
const m = remaining.match(tm.regex);
140150
if (m) {
141151
if (!tm.skip) {
142-
tokens.push({ type: tm.name, text: m[0], offset: pos });
152+
push({ type: tm.name, text: m[0], offset: pos });
153+
} else if (m[0].includes('\n')) {
154+
pendingNl = true; // a skipped comment spanning a newline still terminates the previous line
143155
}
144156
pos += m[0].length;
145157
matched = true;
@@ -151,7 +163,7 @@ export function createLexer(grammar: CstGrammar) {
151163
// Try punctuation literals (longest first)
152164
for (const lit of punctLiterals) {
153165
if (remaining.startsWith(lit)) {
154-
tokens.push({ type: '', text: lit, offset: pos });
166+
push({ type: '', text: lit, offset: pos });
155167
pos += lit.length;
156168
matched = true;
157169
break;
@@ -164,7 +176,7 @@ export function createLexer(grammar: CstGrammar) {
164176
// missed (e.g. accented or non-Latin names). Tagged with that token's name.
165177
const identMatch = remaining.match(/^[\p{L}\p{Nl}_$][\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Mc}\p{Pc}_$]*/u);
166178
if (identMatch) {
167-
tokens.push({ type: identTokenName, text: identMatch[0], offset: pos });
179+
push({ type: identTokenName, text: identMatch[0], offset: pos });
168180
pos += identMatch[0].length;
169181
matched = true;
170182
}

src/gen-parser.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export function createParser(grammar: CstGrammar) {
201201
case 'alt': return e.items.some(exprNullable);
202202
case 'quantifier': return e.kind === '+' ? exprNullable(e.body) : true;
203203
case 'group': return exprNullable(e.body);
204+
case 'not': return true; // zero-width assertion: consumes nothing
204205
case 'sep': return true; // sep matches zero elements
205206
default: return true; // op/prefix/postfix markers don't consume
206207
}
@@ -223,7 +224,7 @@ export function createParser(grammar: CstGrammar) {
223224
const acc = new Set<string>();
224225
for (const item of e.items) {
225226
if (item.type === 'prefix') return null; // prefix op → any operator token: give up
226-
if (item.type === 'op' || item.type === 'postfix') continue; // non-consuming here
227+
if (item.type === 'op' || item.type === 'postfix' || item.type === 'not' || item.type === 'sameLine') continue; // non-consuming here
227228
const f = exprFirst(item);
228229
if (f === null) return null;
229230
for (const k of f) acc.add(k);
@@ -241,6 +242,7 @@ export function createParser(grammar: CstGrammar) {
241242
return acc;
242243
}
243244
case 'quantifier': case 'group': return exprFirst(e.body);
245+
case 'not': case 'sameLine': return new Set(); // zero-width: contributes no FIRST tokens
244246
case 'sep': return exprFirst(e.element);
245247
default: return null;
246248
}
@@ -626,6 +628,21 @@ export function createParser(grammar: CstGrammar) {
626628
return matchQuantifier(expr.body, expr.kind);
627629
case 'group':
628630
return matchExpr(expr.body);
631+
case 'not': {
632+
// Zero-width negative lookahead: succeed (no children) iff the body
633+
// does NOT match here; never consume input either way.
634+
const saved = pos;
635+
const m = matchExpr(expr.body);
636+
pos = saved;
637+
return m === null ? [] : null;
638+
}
639+
case 'sameLine': {
640+
// Zero-width "no LineTerminator here": succeed (no children) iff the
641+
// next token isn't preceded by a newline. At EOF there's no token to
642+
// continue onto, so the assertion fails (nothing same-line follows).
643+
const tok = peek();
644+
return tok && !tok.newlineBefore ? [] : null;
645+
}
629646
case 'sep':
630647
return matchSep(expr.element, expr.delimiter);
631648
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
@@ -44,6 +44,19 @@ export type RuleExpr =
4444
| { type: 'ref'; name: string }
4545
| { type: 'quantifier'; body: RuleExpr; kind: '*' | '+' | '?' }
4646
| { type: 'group'; body: RuleExpr }
47+
// Zero-width negative lookahead: matches (consuming nothing) iff `body` does
48+
// NOT match at the current position. Used to express disambiguations the
49+
// longest-match parser can't reach by structure alone (e.g. a `<…>` type-arg
50+
// list in expression position is only a bare instantiation when it isn't
51+
// followed by something that starts an expression). Non-consuming → invisible
52+
// to highlighting / AST shape / other generators.
53+
| { type: 'not'; body: RuleExpr }
54+
// Zero-width "no LineTerminator here" assertion: matches (consuming nothing)
55+
// iff the NEXT token is on the same line (no preceding newline). Encodes
56+
// ECMAScript/TS restricted productions like an array/indexed-access type's `[`,
57+
// which must not follow a line terminator. Non-consuming → invisible to other
58+
// generators (they treat it as a no-op marker).
59+
| { type: 'sameLine' }
4760
| { type: 'sep'; element: RuleExpr; delimiter: string }
4861
| { type: 'op' }
4962
| { type: 'prefix' }

0 commit comments

Comments
 (0)