Skip to content

Commit 0195ef0

Browse files
committed
feat(gen-tm): continuation brackets — multi-line constructs inside rich comments
A bracket left OPEN in a rich comment continues the construct across consecutive introducer-prefixed lines (env-spec multi-line decorator calls and literals): # @import( # ./.env.shared, # pick=[ # KEY1, # note # ], # ) Declared via `lineComment.continuationBrackets` (bracket pairs). Each pair emits a begin/end region nested inside the line-scoped rich region — a TextMate child region suspends its parent's $ end, so the construct spans lines while single-line comments still close at EOL. Inside a construct: a line-start introducer is a CONTINUATION MARKER (comment punctuation, not a new comment); any other introducer opens an embedded comment that dims to end-of-line; brackets nest recursively; everything else highlights via $self. An unclosed bracket runs the region to the next closer — the standard hand-written-grammar hazard; the parser stays the authority on validity. Opt-in as before: `npm run gen` produces no diffs for the built-in grammars; 41/41 gates pass (env-spec regression contracts extended to 18).
1 parent b20f443 commit 0195ef0

4 files changed

Lines changed: 85 additions & 3 deletions

File tree

src/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface TokenOptions {
2424
// Generators emit a to-EOL region in the token's comment scope so prose dims; `richStarters`
2525
// lists tokens that keep full token highlighting when one opens the comment body
2626
// (e.g. env-spec `# @decorator(...)`). See TokenDecl.lineComment.
27-
lineComment?: { richStarters?: TokenRef[] };
27+
lineComment?: { richStarters?: TokenRef[]; continuationBrackets?: [string, string][] };
2828
// A regex matching exactly one well-formed escape sequence. Engine-scanned tokens
2929
// (templates) validate each `\`-escape against it and reject any that don't match —
3030
// unlike `escape` (highlight-only), this drives tokenization. Skipped in tag
@@ -561,6 +561,7 @@ export function defineGrammar(config: GrammarConfig): CstGrammar & { name: strin
561561
if (!refName) throw new Error(`lineComment.richStarters on token '${name}' references an undeclared token`);
562562
return refName;
563563
}),
564+
continuationBrackets: tok.opts.lineComment.continuationBrackets?.map((pair) => [...pair] as [string, string]),
564565
}
565566
: undefined,
566567
embed: tok.opts.embed,

src/gen-tm.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5034,12 +5034,54 @@ export function generateTmLanguage(grammar: CstGrammar): TmGrammar {
50345034
.filter((t): t is TokenDecl => !!t);
50355035
if (richStarters.length) {
50365036
const startLookahead = richStarters.map((t) => `(?:${tokenPatternSource(t)})`).join('|');
5037+
// ── Multi-line constructs (`continuationBrackets`) ──
5038+
// A bracket left open in a rich comment continues the construct across consecutive
5039+
// introducer-prefixed lines (`# @import(` … `# KEY1,` … `# )`). Each pair becomes a
5040+
// begin/end region nested INSIDE the line-scoped rich region — a TextMate child region
5041+
// suspends its parent's `$` end, so the construct spans lines while the parent still
5042+
// closes single-line comments. Inside a construct:
5043+
// - a line-start introducer is a CONTINUATION MARKER (comment punctuation, not a new
5044+
// comment) — matched first (anchored `^`, earliest-match wins);
5045+
// - any other introducer opens an embedded comment to end-of-line that dims
5046+
// (`# KEY1, # note`), popping at `$` so the construct resumes next line;
5047+
// - brackets nest recursively; everything else highlights via $self.
5048+
// An unclosed bracket runs the region to the next closer (the same hazard every
5049+
// hand-written TextMate grammar with multi-line constructs has) — the parser is the
5050+
// authority on validity, the highlighter degrades soft.
5051+
const contBrackets = tok.lineComment.continuationBrackets ?? [];
5052+
const bracketKeyOf = (open: string) => `${key}-rich-cont-${[...open].map((c) => c.charCodeAt(0).toString(16)).join('')}`;
5053+
const bracketIncludes = contBrackets.map(([open]) => ({ include: `#${bracketKeyOf(open)}` }));
5054+
if (contBrackets.length) {
5055+
repository[`${key}-rich-cont-marker`] = {
5056+
match: `^[ \\t]*(${introducer})`,
5057+
captures: { '1': { name: `${scope}.${langName} ${commentPunct}` } },
5058+
};
5059+
repository[`${key}-rich-cont-comment`] = {
5060+
name: `${scope}.${langName}`,
5061+
begin: `(${introducer})`,
5062+
beginCaptures: { '1': { name: commentPunct } },
5063+
end: '$',
5064+
patterns: [],
5065+
};
5066+
for (const [open, close] of contBrackets) {
5067+
repository[bracketKeyOf(open)] = {
5068+
begin: escapeRegex(open),
5069+
end: escapeRegex(close),
5070+
patterns: [
5071+
{ include: `#${key}-rich-cont-marker` },
5072+
{ include: `#${key}-rich-cont-comment` },
5073+
...bracketIncludes,
5074+
{ include: '$self' },
5075+
],
5076+
};
5077+
}
5078+
}
50375079
repository[`${key}-rich`] = {
50385080
name: `${scope}.${langName}`,
50395081
begin: `(${introducer})(?=[ \\t]*(?:${startLookahead}))`,
50405082
beginCaptures: { '1': { name: commentPunct } },
50415083
end: '$',
5042-
patterns: [{ include: '$self' }],
5084+
patterns: [...bracketIncludes, { include: '$self' }],
50435085
};
50445086
topPatterns.push({ include: `#${key}-rich` });
50455087
}

src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ export interface TokenDecl {
3030
// `richStarters` names tokens that keep FULL token highlighting when one of them (after
3131
// optional blanks) opens the comment body (`# @decorator(...)`). The lexer/parser are
3232
// unaffected — exactly like `interpolation`, this is generator metadata.
33-
lineComment?: { richStarters?: string[] };
33+
// `continuationBrackets`: bracket pairs that, when left OPEN inside a rich comment, continue
34+
// the construct across consecutive introducer-prefixed lines (env-spec multi-line decorator
35+
// calls/literals — `# @import(` … `# KEY1,` … `# )`). Each opens a begin/end region that
36+
// outlives the line-scoped comment region (a TextMate child region suspends its parent's end),
37+
// with the line-start introducer scoped as a continuation marker rather than a new comment.
38+
lineComment?: { richStarters?: string[]; continuationBrackets?: [string, string][] };
3439
escapeValidPattern?: TokenPattern; // one well-formed escape; engine-scanned tokens reject non-matching `\`-escapes (skipped in tag position)
3540
embed?: string; // @embed(lang) — embedded language scope name
3641
// ── Lexer hints (keep the engine language-agnostic; all optional) ──

test/env-spec-regressions.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,40 @@ const check = (label: string, cond: boolean) => {
136136
}
137137
check('parser: lineComment metadata does not change parsing', !threw);
138138

139+
// continuationBrackets: a bracket left open in a rich comment continues the construct
140+
// across introducer-prefixed lines via nested begin/end regions
141+
const HASH_ML = token(seq(notPrecededBy(noneOf(' ', '\t', '\n', '\r')), '#'), {
142+
scope: 'comment.line',
143+
lineComment: { richStarters: [DEC_NAME], continuationBrackets: [['(', ')'], ['[', ']']] },
144+
});
145+
const CommentMl = rule(() => [[HASH_ML, many(Part)]]);
146+
const FileMl = rule(() => [[many(CommentMl)]]);
147+
const mlGrammar = defineGrammar({
148+
name: 'env-spec-ml',
149+
tokens: { WS, HASH_ML, DEC_NAME, KEY, TEXT },
150+
rules: { Part, CommentMl, FileMl },
151+
entry: FileMl,
152+
});
153+
const tmMl = generateTmLanguage(mlGrammar);
154+
const parenKey = Object.keys(tmMl.repository).find((k) => k.startsWith('hash_ml-rich-cont-') && tmMl.repository[k].begin === '\\(');
155+
check('tm: continuation bracket pair emits a begin/end region', !!parenKey && tmMl.repository[parenKey!].end === '\\)');
156+
const parenRegion = parenKey ? tmMl.repository[parenKey] : undefined;
157+
const parenIncludes = (parenRegion?.patterns ?? []).map((pp) => (pp as { include?: string }).include);
158+
check('tm: construct interior tries marker, embedded comment, nested brackets, then $self',
159+
parenIncludes[0] === '#hash_ml-rich-cont-marker'
160+
&& parenIncludes[1] === '#hash_ml-rich-cont-comment'
161+
&& parenIncludes.includes('$self')
162+
&& parenIncludes.filter((n) => n?.startsWith('#hash_ml-rich-cont-') && n !== '#hash_ml-rich-cont-marker' && n !== '#hash_ml-rich-cont-comment').length === 2);
163+
const marker = tmMl.repository['hash_ml-rich-cont-marker'];
164+
check('tm: continuation marker is line-anchored and scoped as comment punctuation',
165+
typeof marker?.match === 'string' && marker.match.startsWith('^[ \\t]*')
166+
&& JSON.stringify(marker?.captures?.['1']?.name ?? '').includes('punctuation.definition.comment'));
167+
const embedded = tmMl.repository['hash_ml-rich-cont-comment'];
168+
check('tm: embedded comment inside a construct dims to end-of-line', embedded?.end === '$' && Array.isArray(embedded?.patterns) && embedded.patterns.length === 0);
169+
const richMl = tmMl.repository['hash_ml-rich'];
170+
const richIncludes = (richMl?.patterns ?? []).map((pp) => (pp as { include?: string }).include);
171+
check('tm: rich region tries construct brackets before $self', richIncludes[richIncludes.length - 1] === '$self' && richIncludes.length === 3);
172+
139173
// a comment token WITHOUT the metadata still emits the flat rule (no behavior change)
140174
const HASH2 = token(seq(notPrecededBy(noneOf(' ', '\t', '\n', '\r')), '#'), { scope: 'comment.line' });
141175
const Comment2 = rule(() => [[HASH2, many(TEXT)]]);

0 commit comments

Comments
 (0)