Skip to content

Commit 41eef25

Browse files
committed
feat(newline): 'terminator' mode, a NEWLINE at every line break
newline mode was built for dotenv-style grammars, but its NEWLINE is a statement SEPARATOR: one token between two content-bearing lines, placed at the start of the later line, with blank and comment-only lines collapsed and nothing before the first or after the last line. A grammar whose AST keeps blank lines and comments as lines (env-spec: File = many(Line), Line = Stmt NEWLINE? | NEWLINE) cannot be written on that, which is why env-spec sits on indent mode today with INDENT/DEDENT filler in every rule and an indent-stack tree-sitter scanner it never uses. NewlineConfig.mode: 'terminator' emits one NEWLINE at EVERY block-context line break, placed at the break: blank lines, comment-only lines, leading and trailing breaks included; breaks inside flow delimiters stay suspended; a final line with no break has none. Positions are exact per line, so a blank-line node maps to its own line. This is also the shape the derived tree-sitter scanner already has (stateless, one NEWLINE per break where the grammar permits one), so the two agree more closely than in separator mode. 'separator' stays the default and is byte-identical. The emitted engine inherits the mode through the createLexer fallback. The portable TS/Go/Rust newline lexers on the portable-newline-mode branch will need the same three emission points once that lands. Gate: test/newline-mode.ts section 7.
1 parent 8c489e2 commit 41eef25

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

src/gen-lexer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
306306
const kVoidNameTok = kOf(markup?.voidNameToken ?? null);
307307
const tTagOpen = markup ? (puLitOf.get(markup.tagOpen) ?? 0) : 0;
308308
const kNewlineModeTok = kOf(newline?.token ?? null);
309+
// newline-only 'terminator' mode: a NEWLINE at EVERY block-context line break, placed at the break
310+
// (see NewlineConfig.mode). The line-start boundary emission below is then skipped; the three
311+
// places a break is consumed (a content line's break, a blank line, a tab-blank line) emit instead.
312+
const nlTerminators = !indent && newline?.mode === 'terminator';
309313
const kIndentTok = kOf(indent?.indentToken ?? null), kDedentTok = kOf(indent?.dedentToken ?? null), kIndentNewlineTok = kOf(indent?.newlineToken ?? null);
310314
const kBlockScalarTok = kOf(indent?.blockScalar?.token ?? null);
311315
const kRawBlockTok = kOf(indent?.rawBlock?.token ?? null);
@@ -682,6 +686,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
682686
const ch = source[p];
683687
if (p >= source.length) { pos = p; lineStart = false; continue; } // EOF — final DEDENTs emitted after the loop
684688
if (ch === '\n' || ch === '\r') { // blank line — ignored for structure
689+
if (nlTerminators) push(mkNamed(newline!.token, '', p, kNewlineModeTok)); // …but a line break all the same
685690
pos = p + 1; if (ch === '\r' && source[pos] === '\n') pos++;
686691
continue; // still at a line start
687692
}
@@ -696,6 +701,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
696701
let b = p; while (b < source.length && (source[b] === ' ' || source[b] === '\t')) b++;
697702
const bc = source[b];
698703
if (b >= source.length || bc === '\n' || bc === '\r') {
704+
if (nlTerminators && bc !== undefined) push(mkNamed(newline!.token, '', b, kNewlineModeTok));
699705
pos = b; if (bc === '\r' && source[pos + 1] === '\n') pos += 2; else if (bc !== undefined) pos++;
700706
continue;
701707
}
@@ -728,7 +734,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
728734
// ── newline-only mode: no indent stack — emit ONE NEWLINE at this real line boundary (a
729735
// leading boundary before any content is suppressed via emittedContent) and move on. ──
730736
if (!indent) {
731-
if (emittedContent) push(mkNamed(newline!.token, '', pos, kNewlineModeTok));
737+
if (emittedContent && !nlTerminators) push(mkNamed(newline!.token, '', pos, kNewlineModeTok));
732738
lineStart = false;
733739
atLineLead = true;
734740
continue;
@@ -811,6 +817,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
811817
pos++; continue;
812818
}
813819
if (c === '\n' || c === '\r') {
820+
if (nlTerminators && flowDepth === 0) push(mkNamed(newline!.token, '', pos, kNewlineModeTok));
814821
pos++; if (c === '\r' && source[pos] === '\n') pos++;
815822
if (flowDepth === 0) lineStart = true;
816823
else if (indent) {

src/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,19 @@ export interface NewlineConfig {
438438
token: string; // token TYPE emitted at each significant line boundary (engine-emitted, like the indent tokens)
439439
flowOpen?: string[]; // punctuation that SUSPENDS newline significance while open (e.g. ['(', '[', '{'])
440440
flowClose?: string[]; // matching closers (e.g. [')', ']', '}'])
441-
comment?: string; // line-comment introducer; a comment-only line emits no NEWLINE (e.g. '#')
441+
comment?: string; // line-comment introducer; a comment-only line emits no NEWLINE in 'separator' mode (e.g. '#')
442+
// WHAT a NEWLINE token means:
443+
// 'separator' (default) — one NEWLINE between two content-bearing lines, placed at the start of
444+
// the later line's content. Blank and comment-only lines collapse into it; nothing is emitted
445+
// before the first content or after the last. The right shape for a statement SEPARATOR.
446+
// 'terminator' — one NEWLINE at EVERY line break outside flow delimiters, placed AT the break
447+
// (zero-width, before the `\n` / `\r\n`): blank lines, comment-only lines, leading and
448+
// trailing breaks included; only a final line with no break has none. The right shape for a
449+
// grammar whose AST keeps blank lines and comments as lines (dotenv / env-spec: `File =
450+
// many(Line)`, `Line = [Stmt, opt(NEWLINE)] | [NEWLINE]`), and the shape the derived
451+
// tree-sitter scanner already has (it is stateless: one NEWLINE per break where the grammar
452+
// permits one). Positions are exact per line, so a blank-line node maps to its own line.
453+
mode?: 'separator' | 'terminator';
442454
}
443455

444456
export interface PrecOperator {

test/newline-mode.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,52 @@ if (hasCli()) {
167167
console.log('\ntree-sitter CLI not found — structural validation only (not a failure).');
168168
}
169169

170+
171+
// ── 7. `mode: 'terminator'`: one NEWLINE at EVERY block-context line break, placed at the break ──
172+
// (blank / comment-only / leading / trailing breaks included; none inside flow; none at EOF
173+
// without a break). The shape a grammar that keeps blank lines and comments as AST lines needs.
174+
{
175+
const LineT = rule(() => [[Stmt, opt(Newline)], [Newline]]); // Line = Stmt NEWLINE? | NEWLINE (a blank line)
176+
const ProgramT = rule(() => [[many(LineT)]]);
177+
const gT = defineGrammar({
178+
name: 'envspec-t', scopeName: 'source.envspec-t',
179+
tokens: { Comment, Ident, Newline },
180+
rules: { Value, Stmt, Line: LineT, Program: ProgramT }, entry: ProgramT,
181+
newline: { ...newline, mode: 'terminator' },
182+
});
183+
const lexT = createLexer(gT).tokenize;
184+
const nls = (s: string) => lexT(s).filter(t => t.type === 'Newline').map(t => t.offset);
185+
check("terminator: one NEWLINE between two statements, AT the break", JSON.stringify(nls('A=1\nB=2')) === '[3]');
186+
check("terminator: every blank line is its own NEWLINE", JSON.stringify(nls('A=1\n\n\nB=2')) === '[3,4,5]');
187+
check("terminator: leading breaks are emitted", JSON.stringify(nls('\n\nA=1')) === '[0,1]');
188+
check("terminator: a trailing break is emitted", JSON.stringify(nls('A=1\n')) === '[3]');
189+
check("terminator: no break, no NEWLINE", JSON.stringify(nls('A=1')) === '[]');
190+
check("terminator: a comment-only line still breaks", JSON.stringify(nls('A=1\n# note\nB=2')) === '[3,10]');
191+
check("terminator: a whitespace-only line (tabs) is a break", JSON.stringify(nls('A=1\n \t\nB=2')) === '[3,6]');
192+
check("terminator: CRLF counts once, at the CR", JSON.stringify(nls('A=1\r\n\r\nB=2')) === '[3,5]');
193+
check("terminator: breaks INSIDE flow ( … ) stay suspended", JSON.stringify(nls('A=fn(1,\n2)\nB=3')) === '[10]');
194+
check("terminator: still no INDENT/DEDENT", !lexT('A=1\n\nB=2').some(t => t.type === 'Indent' || t.type === 'Dedent'));
195+
check("separator (default) is unchanged by the option's existence", countNL('A=1\n\n\nB=2') === 1 && tokenize('\n\nA=1')[0]?.type !== 'Newline');
196+
197+
const parseT = createParser(gT).parse;
198+
const acceptsT = (s: string) => { try { return parseT(s).rule !== undefined; } catch { return false; } };
199+
check('terminator: parses statements with blank lines between and a trailing break', acceptsT('A=1\n\nB=2\n'));
200+
check('terminator: parses leading blank lines', acceptsT('\n\nA=1'));
201+
check('terminator: parses a lone statement without a break', acceptsT('A=1'));
202+
check('terminator: parses a flow value spanning lines', acceptsT('A=fn(1,\n2)\nB=3'));
203+
check('terminator: still rejects a malformed statement', !acceptsT('A B'));
204+
// A blank line is its own Line node, at its own offset (what an editor maps to a line number).
205+
const cst = parseT('A=1\n\nB=2') as any;
206+
const nlLeaves = JSON.stringify(cst).match(/"tokenType":"Newline","offset":(\d+)/g) ?? [];
207+
check('terminator: the blank line and the statement break are distinct leaves at 3 and 4', nlLeaves.length === 2 && nlLeaves[0].endsWith(':3') && nlLeaves[1].endsWith(':4'));
208+
209+
// The generators do not care which mode is set (the tree-sitter scanner is already per-break).
210+
check('terminator: TextMate generates', Object.keys(generateTmLanguage(gT).repository).length > 0);
211+
check('terminator: Monarch generates', !!generateMonarch(gT).tokenizer.root);
212+
const tsT = generateTreeSitter(gT, 'envspec_t');
213+
check('terminator: tree-sitter externals still include newline', tsT.externalTokens.includes('newline'));
214+
check('terminator: tree-sitter scanner unchanged (stateless per-break scan_newline)', tsT.scannerC.includes('scan_newline'));
215+
}
216+
170217
console.log(fail === 0 ? `\n${ok}/${ok} newline-mode checks pass` : `\n${fail} of ${ok + fail} FAILED`);
171218
process.exit(fail === 0 ? 0 : 1);

0 commit comments

Comments
 (0)