Skip to content

Commit 2e664dd

Browse files
Add a newline-sensitive lexer mode independent of indent (#14)
Some DSLs are newline-aware but not indent-aware: statements are line-delimited, but nesting is via delimiters/expressions rather than indentation (e.g. dotenv-style env specs). Until now the only way to get significant newlines was to opt into `indent`, which also drags in INDENT/DEDENT stack management and YAML block-scalar semantics. This adds a `newline` mode as the line-boundary + flow-suspension layer that `indent` already builds on (indent = newline + indent stack + YAML semantics). A single `lineSensitive` gate shares the lexer machinery; the line-start routine forks so a newline-only grammar emits one NEWLINE token per significant boundary with no indent stack. The two modes are mutually exclusive. - types.ts: NewlineConfig + CstGrammar.newline - api.ts: passthrough + indent/newline mutual-exclusion check - gen-lexer.ts: the layered emission - emit-parser.ts: bake the newline config for the standalone parser - gen-treesitter.ts: NEWLINE as a stateless external scanner token - test/newline-mode.ts: an inline env-spec grammar exercising all four backends plus a real tree-sitter generate + parse All existing grammars regenerate byte-identically; TS parser conformance, the agnostic gate, and the tree-sitter accuracy gate are unchanged.
1 parent 5f477ba commit 2e664dd

6 files changed

Lines changed: 266 additions & 19 deletions

File tree

src/api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CstGrammar, TokenDecl, PrecLevel, PrecOperator, RuleDecl, RuleExpr, MarkupConfig, IndentConfig, TokenPattern } from './types.ts';
1+
import type { CstGrammar, TokenDecl, PrecLevel, PrecOperator, RuleDecl, RuleExpr, MarkupConfig, IndentConfig, NewlineConfig, TokenPattern } from './types.ts';
22
import {
33
altPattern, anyChar, followedBy, isTokenPattern, lit, never, noneOf, notFollowedBy,
44
notPrecededBy, oneOf, optPattern, plus, precededBy, range, repeat,
@@ -381,13 +381,19 @@ interface GrammarConfig {
381381
entry: RuleRef;
382382
markup?: MarkupConfig; // opt-in markup-mode tokenization (HTML/Vue)
383383
indent?: IndentConfig; // opt-in indentation-sensitive tokenization (YAML)
384+
newline?: NewlineConfig; // opt-in NEWLINE-sensitive tokenization, independent of indent (no indent stack)
384385
expression?: RuleRef; // the rule that produces an EXPRESSION; enables a derived `#expression` sub-grammar (expression-only embeds)
385386
aliasScopes?: { scope: string; file: string }[]; // extra grammars re-exposing this one under another scopeName (e.g. text.html.derivative)
386387
canonicalRepoNames?: Record<string, string | string[]>; // official repo KEY NAME → structural key(s) for the SAME construct; gen-tm RENAMES the structural key (or synthesises a union wrapper) to emit the official name natively (the 限制器; see CstGrammar.canonicalRepoNames)
387388
manifest?: import('./types.ts').ContributesManifest; // VS Code `contributes` packaging (emits a pasteable snippet)
388389
}
389390

390391
export function defineGrammar(config: GrammarConfig): CstGrammar & { name: string; scopeName?: string } {
392+
// `indent` is the richer layer built on top of newline-significant line boundaries, so the two
393+
// modes are mutually exclusive — declaring both is a configuration error, not a merge.
394+
if (config.indent && config.newline) {
395+
throw new Error('A grammar may declare `indent` OR `newline`, not both — `indent` already implies newline-significant line boundaries.');
396+
}
391397
const names = new Map<object, string>();
392398
for (const [name, tok] of Object.entries(config.tokens)) {
393399
names.set(tok, name);
@@ -453,5 +459,5 @@ export function defineGrammar(config: GrammarConfig): CstGrammar & { name: strin
453459
}
454460
}
455461

456-
return { name: config.name, scopeName: config.scopeName, tokens, precs, rules, scopeOverrides, markup: config.markup, indent: config.indent, expressionRule: config.expression ? names.get(config.expression) : undefined, aliasScopes: config.aliasScopes, canonicalRepoNames: config.canonicalRepoNames, manifest: config.manifest };
462+
return { name: config.name, scopeName: config.scopeName, tokens, precs, rules, scopeOverrides, markup: config.markup, indent: config.indent, newline: config.newline, expressionRule: config.expression ? names.get(config.expression) : undefined, aliasScopes: config.aliasScopes, canonicalRepoNames: config.canonicalRepoNames, manifest: config.manifest };
457463
}

src/emit-parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,7 @@ export function emitParser(grammar: CstGrammar): string {
635635
rules: [{ name: '$lits', body: litRuleBody, flags: [] }],
636636
markup: grammar.markup,
637637
indent: grammar.indent,
638+
newline: grammar.newline,
638639
scopeOverrides: [],
639640
};
640641

src/gen-lexer.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -185,16 +185,24 @@ export function createLexer(grammar: CstGrammar) {
185185
// → null, and a `tagOpen` always opens a tag (legacy behaviour, unchanged for other grammars).
186186
const tagOpenAfterRe = markup?.tagOpenAfter ? new RegExp('[' + markup.tagOpenAfter + ']') : null;
187187

188-
// ── Indentation mode (opt-in; dormant unless the grammar declares `indent`) ──
188+
// ── Indentation / newline mode (opt-in; dormant unless the grammar declares `indent` or `newline`) ──
189189
// Like markup, the INDENT/DEDENT/NEWLINE tokens are EMITTED by a state machine (not matched
190190
// by a regex) — so they are skipped in the regex loop and their grammar patterns are
191191
// placeholders. Indentation is suspended inside flow delimiters via a flow-depth counter.
192+
// `newline` is the line-boundary + flow-suspension LAYER that `indent` builds on: an indent
193+
// grammar gets the full stack + INDENT/DEDENT/NEWLINE; a newline-only grammar emits just the
194+
// NEWLINE token at each significant line boundary (no stack). `lineSensitive` gates the shared
195+
// machinery; `indent`/`newline` are mutually exclusive (defineGrammar rejects declaring both).
192196
const indent = grammar.indent;
197+
const newline = grammar.newline;
198+
const lineSensitive = !!indent || !!newline;
199+
const lineComment = (indent ?? newline)?.comment; // line-comment introducer (both modes skip comment-only lines)
193200
const indentTokenNames = new Set<string>(
194-
indent ? ([indent.indentToken, indent.dedentToken, indent.newlineToken].filter(Boolean) as string[]) : [],
201+
indent ? ([indent.indentToken, indent.dedentToken, indent.newlineToken].filter(Boolean) as string[])
202+
: newline ? [newline.token] : [],
195203
);
196-
const flowOpenSet = new Set(indent?.flowOpen ?? []);
197-
const flowCloseSet = new Set(indent?.flowClose ?? []);
204+
const flowOpenSet = new Set((indent ?? newline)?.flowOpen ?? []);
205+
const flowCloseSet = new Set((indent ?? newline)?.flowClose ?? []);
198206
// String-literal token names (the `string`-flagged tokens — quoted scalars in YAML). Used by the
199207
// flow mapping-separator guard below: a quoted scalar can never run past its closing quote, so a
200208
// `:` immediately after one (inside flow) is ALWAYS the mapping `key: value` separator, never the
@@ -416,7 +424,7 @@ export function createLexer(grammar: CstGrammar) {
416424
&& tagOpenAfterRe.test(source[i + markup!.tagOpen.length]));
417425
// Indentation state — active only when `indent` is declared (dormant otherwise).
418426
let flowDepth = 0; // >0 while inside flow delimiters ([ ] { }) → indentation suspended
419-
let lineStart = !!indent; // at a block-context line boundary (file start counts as one)
427+
let lineStart = lineSensitive; // at a block-context line boundary (file start counts as one)
420428
let emittedContent = false; // any real (non-structural) token emitted yet — suppress a leading NEWLINE/DEDENT
421429
let currentLineCol = 0; // leading-space column of the current logical line (bounds block scalars)
422430
let atLineLead = false; // the next emitted token is the FIRST content token of its line (compact-indicator probe)
@@ -446,7 +454,7 @@ export function createLexer(grammar: CstGrammar) {
446454
if (pendingComment) { t.commentBefore = true; pendingComment = false; }
447455
if (pendingMultilineFlow) { t.multilineFlowBefore = true; pendingMultilineFlow = false; }
448456
tokens.push(t);
449-
if (indent) {
457+
if (lineSensitive) {
450458
if (!indentTokenNames.has(t.type)) {
451459
emittedContent = true; // a real token (not INDENT/DEDENT/NEWLINE)
452460
atLineLead = false; // line-lead consumed once a real token lands
@@ -456,7 +464,8 @@ export function createLexer(grammar: CstGrammar) {
456464
// Entering the OUTERMOST flow (0→1): if it opens right after a `:`/`-` block indicator,
457465
// it is a block VALUE/ITEM → arm the §7.4 indent rule with n = the current block column
458466
// (the indent-stack top). Anywhere else (top-level / after `,` / as a key) the rule is OFF.
459-
if (flowDepth === 0) {
467+
// The §7.4 / multi-line-flow bookkeeping is indent-only (a newline grammar has no stack).
468+
if (flowDepth === 0 && indent) {
460469
const prevTok = tokens[tokens.length - 2]; // the token before this just-pushed open
461470
flowValueIndent = (prevTok && prevTok.type === '' && (prevTok.text === ':' || prevTok.text === '-'))
462471
? indentStack[indentStack.length - 1] : -1;
@@ -465,7 +474,7 @@ export function createLexer(grammar: CstGrammar) {
465474
flowDepth++;
466475
} else if (flowCloseSet.has(t.text)) {
467476
flowDepth = Math.max(0, flowDepth - 1);
468-
if (flowDepth === 0) {
477+
if (flowDepth === 0 && indent) {
469478
flowValueIndent = -1;
470479
if (flowSawNewline) pendingMultilineFlow = true; // a multi-line flow just closed → flag the next token
471480
flowSawNewline = false;
@@ -566,7 +575,7 @@ export function createLexer(grammar: CstGrammar) {
566575
// ── Indentation mode: at a block-context line start, skip blank/comment lines, measure
567576
// the next content line's leading-space column, and emit NEWLINE / INDENT / DEDENT(s)
568577
// before that line's tokens (relative to the indentation stack). ──
569-
if (indent && flowDepth === 0 && lineStart) {
578+
if (lineSensitive && flowDepth === 0 && lineStart) {
570579
let p = pos, col = 0;
571580
while (p < source.length && source[p] === ' ') { p++; col++; }
572581
const ch = source[p];
@@ -600,18 +609,26 @@ export function createLexer(grammar: CstGrammar) {
600609
// (its emitted NEWLINE/DEDENT either reject in value position or are harmless between
601610
// siblings — matching the `yaml` oracle, which is context-sensitive there). We reject only
602611
// the structural case, so no valid leaf-continuation is mis-rejected.
603-
if (ch === '\t') {
612+
if (indent && ch === '\t') { // §6.1 tab-in-indentation error is YAML-specific (newline mode has no stack)
604613
let q = p; while (q < source.length && (source[q] === ' ' || source[q] === '\t')) q++;
605614
const after = source[q];
606615
if (q < source.length && after !== '\n' && after !== '\r' && startsBlockStructuralNode(source, q)) {
607616
throw new Error(`Tab character used in indentation at offset ${p}`);
608617
}
609618
}
610-
if (indent.comment && source.startsWith(indent.comment, p)) { // comment-only line — ignored
619+
if (lineComment && source.startsWith(lineComment, p)) { // comment-only line — ignored
611620
let e = p; while (e < source.length && source[e] !== '\n') e++;
612621
pos = e; pendingComment = true; continue; // next iteration consumes the newline
613622
}
614623
pos = p; // consume the leading indentation
624+
// ── newline-only mode: no indent stack — emit ONE NEWLINE at this real line boundary (a
625+
// leading boundary before any content is suppressed via emittedContent) and move on. ──
626+
if (!indent) {
627+
if (emittedContent) push({ type: newline!.token, text: '', offset: pos });
628+
lineStart = false;
629+
atLineLead = true;
630+
continue;
631+
}
615632
currentLineCol = col; // bounds a block scalar started on this line
616633
const top = indentStack[indentStack.length - 1];
617634
if (col > top) {
@@ -664,10 +681,10 @@ export function createLexer(grammar: CstGrammar) {
664681
continue;
665682
}
666683

667-
// Whitespace. In indentation mode, inline spaces/tabs are skipped but a NEWLINE is a
668-
// block-context line boundary (sets lineStart so the routine above runs next) — except
669-
// inside flow delimiters, where newlines are insignificant. Otherwise skip any run.
670-
if (indent) {
684+
// Whitespace. In an indentation / newline grammar, inline spaces/tabs are skipped but a
685+
// NEWLINE is a block-context line boundary (sets lineStart so the routine above runs next) —
686+
// except inside flow delimiters, where newlines are insignificant. Otherwise skip any run.
687+
if (lineSensitive) {
671688
const c = source[pos];
672689
if (c === ' ' || c === '\t') {
673690
// A TAB between a block indicator (`-`/`?`/map-`:`) and a NESTED block-structural node it
@@ -677,7 +694,7 @@ export function createLexer(grammar: CstGrammar) {
677694
// separation, so the structural sniff gates it. After a `:` a node PROPERTY is the inline
678695
// value (`key:\t&a x` is legal), so the `:` case excludes properties (allowProperty=false)
679696
// while `-`/`?` include them (`-\t&a x` IS an error). Block context only (flowDepth===0).
680-
if (flowDepth === 0) {
697+
if (indent && flowDepth === 0) { // §6.1 tab-after-indicator error is YAML-specific
681698
const prev = tokens[tokens.length - 1];
682699
const isIndicator = prev && prev.type === '' && (prev.text === '-' || prev.text === '?' || prev.text === ':');
683700
if (isIndicator) {
@@ -692,7 +709,7 @@ export function createLexer(grammar: CstGrammar) {
692709
if (c === '\n' || c === '\r') {
693710
pos++; if (c === '\r' && source[pos] === '\n') pos++;
694711
if (flowDepth === 0) lineStart = true;
695-
else {
712+
else if (indent) {
696713
flowSawNewline = true; // this outermost flow spans >1 line → it can't be an implicit block key
697714
// §7.4: inside a value/item-position flow, a CONTENT line must be indented MORE than the
698715
// enclosing block column `n` (flowValueIndent). The indentation column is the leading-SPACE

src/gen-treesitter.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,10 @@ function planTemplate(grammar: CstGrammar): TemplatePlan | null {
541541
/** Determine which tokens the external scanner must provide. */
542542
function planScannerTokens(grammar: CstGrammar): Map<string, string> {
543543
const map = new Map<string, string>();
544+
// A newline-sensitive grammar's NEWLINE token is engine-emitted; in tree-sitter it becomes a
545+
// stateless external token (the scanner emits it at each significant line boundary). Listed
546+
// FIRST so it heads the enum / externals order.
547+
if (grammar.newline) map.set(grammar.newline.token, toSnake(grammar.newline.token));
544548
// The regex token: '/' is context-sensitive (regex vs division). The scanner
545549
// resolves it.
546550
const regexTok = grammar.tokens.find(t => t.flags.includes('regex'));
@@ -1625,6 +1629,26 @@ function buildScannerC(
16251629
L.push('static inline void skip(TSLexer *lexer) { lexer->advance(lexer, true); }');
16261630
L.push('');
16271631

1632+
const nl = grammar.newline;
1633+
if (nl) {
1634+
const nlSym = ctx.scannerTokenFor.get(nl.token)!.toUpperCase();
1635+
L.push('// ── Newline scan ────────────────────────────────────────────────');
1636+
L.push('// A newline-sensitive grammar emits one NEWLINE token at each significant line');
1637+
L.push('// boundary. tree-sitter only asks for it where the grammar permits it (statement');
1638+
L.push('// boundaries); inside flow delimiters the rules never reference NEWLINE, so');
1639+
L.push('// valid_symbols[NEWLINE] is false there and the line break falls through to');
1640+
L.push('// `extras` as ordinary whitespace. Stateless: one line break (\\n / \\r / \\r\\n) per token.');
1641+
L.push('static bool scan_newline(TSLexer *lexer) {');
1642+
L.push(' if (lexer->lookahead == \'\\r\') { advance(lexer); if (lexer->lookahead == \'\\n\') advance(lexer); }');
1643+
L.push(' else if (lexer->lookahead == \'\\n\') advance(lexer);');
1644+
L.push(' else return false;');
1645+
L.push(` lexer->result_symbol = ${nlSym};`);
1646+
L.push(' lexer->mark_end(lexer);');
1647+
L.push(' return true;');
1648+
L.push('}');
1649+
L.push('');
1650+
}
1651+
16281652
if (regexTok) {
16291653
// Derive the regex literal scan from the token pattern + hints.
16301654
const flagChars = tokenPatternTrailingCharClass(regexTok) ?? 'gimsuyd';
@@ -1734,6 +1758,14 @@ function buildScannerC(
17341758
L.push(' const bool *valid_symbols) {');
17351759
L.push(' (void)payload;');
17361760
L.push('');
1761+
if (grammar.newline) {
1762+
const nlSym = ctx.scannerTokenFor.get(grammar.newline.token)!.toUpperCase();
1763+
L.push(' // Newline first: a significant line boundary outranks every other external token.');
1764+
L.push(` if (valid_symbols[${nlSym}] && (lexer->lookahead == '\\n' || lexer->lookahead == '\\r')) {`);
1765+
L.push(' if (scan_newline(lexer)) return true;');
1766+
L.push(' }');
1767+
L.push('');
1768+
}
17371769
if (tp && regexTok) {
17381770
const charsSym = tp.charsSnake.toUpperCase();
17391771
const regexSym = ctx.scannerTokenFor.get(regexTok.name)!.toUpperCase();

src/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,25 @@ export interface IndentConfig {
329329
};
330330
}
331331

332+
/**
333+
* Opt-in NEWLINE-sensitive tokenization, INDEPENDENT of `indent`. For grammars that are
334+
* newline-aware but NOT indentation-aware — statements are line-delimited, but nesting is via
335+
* delimiters / expressions, not indentation (e.g. dotenv-style env specs). The lexer emits a single
336+
* NEWLINE token at each significant line boundary (suppressed inside flow delimiters, and on blank /
337+
* comment-only lines), with NO indent stack and NO INDENT/DEDENT tokens. `indent` is the richer
338+
* layer built ON TOP of this same line-boundary + flow-suspension machinery (indent = newline +
339+
* indent stack + YAML block-scalar semantics), so declaring BOTH is rejected. The NEWLINE token is
340+
* engine-emitted (declared with a placeholder `never()` pattern and named here), exactly like the
341+
* indent tokens. ABSENT for token-stream / indentation languages → dormant, tokenization
342+
* byte-identical.
343+
*/
344+
export interface NewlineConfig {
345+
token: string; // token TYPE emitted at each significant line boundary (engine-emitted, like the indent tokens)
346+
flowOpen?: string[]; // punctuation that SUSPENDS newline significance while open (e.g. ['(', '[', '{'])
347+
flowClose?: string[]; // matching closers (e.g. [')', ']', '}'])
348+
comment?: string; // line-comment introducer; a comment-only line emits no NEWLINE (e.g. '#')
349+
}
350+
332351
export interface PrecOperator {
333352
value: string;
334353
position: 'infix' | 'prefix' | 'postfix';
@@ -391,6 +410,7 @@ export interface CstGrammar {
391410
scopeName?: string; // declared TextMate scope name (e.g. source.ts); its suffix drives every scope's language tag
392411
markup?: MarkupConfig; // opt-in markup-mode tokenization (HTML/Vue); absent for token-stream languages
393412
indent?: IndentConfig; // opt-in indentation-sensitive tokenization (YAML); absent → byte-identical token stream
413+
newline?: NewlineConfig; // opt-in NEWLINE-sensitive tokenization, independent of indent (no indent stack); absent → byte-identical token stream
394414
expressionRule?: string; // name of the rule that produces an EXPRESSION; lets gen-tm derive a `#expression` sub-grammar (for expression-only embeds, e.g. Vue `{{ }}`)
395415
// Extra TextMate grammars that just RE-EXPOSE this one under another scopeName (thin
396416
// `{scopeName, patterns:[{include: <this.scopeName>}]}` wrappers). HTML declares

0 commit comments

Comments
 (0)