diff --git a/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts b/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts index aa4a7469849..2310df64ac0 100644 --- a/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts +++ b/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts @@ -12,12 +12,16 @@ // colours instead of a dead renderer. Worst case measured at this budget is 341 // levels (a whole line of `{a}`), against a ~1000-level ceiling in the same // runtime. +// +// Not safe to halve: at 256 a realistic ~430-character Tailwind class attribute +// stops entering the html embed at every re-entry point, so ordinary markup +// loses attribute-level highlighting. Measured A/B on real-shaped SFCs. export const EMBED_ENTRY_REST_OF_LINE_BUDGET = 512 -const restOfLineTooLong = `(?!.{${EMBED_ENTRY_REST_OF_LINE_BUDGET + 1}})` +const restOfLineWithinBudget = `(?!.{${EMBED_ENTRY_REST_OF_LINE_BUDGET + 1}})` /** Zero-width: matches only while the rest of the line is within budget. */ -export const restOfLineWithinEmbedBudget = new RegExp(restOfLineTooLong) +export const restOfLineWithinEmbedBudget = new RegExp(restOfLineWithinBudget) /** `>` (script/style tag close) followed by a within-budget rest of line. */ -export const tagCloseWithinEmbedBudget = new RegExp(`>${restOfLineTooLong}`) +export const tagCloseWithinEmbedBudget = new RegExp(`>${restOfLineWithinBudget}`) diff --git a/src/renderer/src/lib/monaco-languages/monarch-embedded-recursion-depth.test.ts b/src/renderer/src/lib/monaco-languages/monarch-embedded-recursion-depth.test.ts index f66ff5c7cce..33b545a336c 100644 --- a/src/renderer/src/lib/monaco-languages/monarch-embedded-recursion-depth.test.ts +++ b/src/renderer/src/lib/monaco-languages/monarch-embedded-recursion-depth.test.ts @@ -15,9 +15,11 @@ import { vueMonarchLanguage } from './register-vue' // levels and died with `RangeError: Maximum call stack size exceeded` — the // renderer-side STATUS_STACK_OVERFLOW this suite guards. +type MonarchEndState = { embeddedLanguageData?: { languageId: string } | null } + type MonarchTokenizerInstance = { getInitialState: () => unknown - tokenize: (line: string, hasEOL: boolean, state: unknown) => { endState: unknown } + tokenize: (line: string, hasEOL: boolean, state: unknown) => { endState: MonarchEndState } _nestedTokenize: (...args: unknown[]) => unknown } @@ -86,7 +88,24 @@ function measureNestedDepth( return { maxNestedDepth, error } } -const RAMP = [50, 200, 500, 1000, 2500] +// The embedded language each line *ends* in — `null` means the line left the +// tokenizer with no embed, i.e. that region renders unhighlighted. +function embeddedLanguagePerLine( + tokenizer: MonarchTokenizerInstance, + lines: string[] +): (string | null)[] { + let state: unknown = tokenizer.getInitialState() + return lines.map((line) => { + const endState = tokenizer.tokenize(line, true, state).endState + state = endState + return endState.embeddedLanguageData?.languageId ?? null + }) +} + +// 6600 is the largest `{a}` count under Monaco's line cap (19_800 chars); the +// filter below drops it for the longer chunk shapes, so the densest embed +// shape is the one that gets driven at maximum length. +const RAMP = [50, 200, 500, 1000, 2500, 6600] function interpolationLine(count: number): string { return `
${Array.from({ length: count }, (_, index) => `{a${index}}`).join('')}
` @@ -151,6 +170,24 @@ describe.each([ expect(measurement.maxNestedDepth).toBeGreaterThan(0) }) + it.each([ + ['script', 'ts', 'typescript'], + ['style', 'scss', 'scss'] + ])('re-embeds a %s body after an over-budget opening line', (tag, lang, embeddedLanguageId) => { + // The opening tag plus code on the same line pushes the tag close past the + // budget, so the body starts unembedded. Every following short line must + // recover the embed (and the `lang=` language) instead of leaving the whole + // block unhighlighted until the closing tag. + const embeds = embeddedLanguagePerLine(createMonarchTokenizer(languageId, language), [ + `<${tag} lang="${lang}">a = "${'x'.repeat(EMBED_ENTRY_REST_OF_LINE_BUDGET)}"`, + ' b', + ' c', + `${tag}>` + ]) + + expect(embeds).toEqual([null, embeddedLanguageId, embeddedLanguageId, null]) + }) + it('keeps tokenizing after an over-budget line and re-embeds on the next one', () => { const overBudget = `${'{{a}}'.repeat(count)}
` it('stays within the embed budget for a line of interpolations', () => { - const ramp = [50, 200, 1000, 2500].filter( + const ramp = [50, 200, 1000, 2500, 3900].filter( (count) => templateLine(count).length < DEFAULT_MAX_TOKENIZATION_LINE_LENGTH ) expect(ramp.length).toBeGreaterThanOrEqual(3) @@ -214,6 +251,19 @@ describe('vue embedded-tokenizer recursion depth', () => { ) }) + it.each([ + ['script', 'ts', 'typescript'], + ['style', 'scss', 'scss'] + ])('re-embeds a %s body after an over-budget opening line', (tag, lang, embeddedLanguageId) => { + const embeds = embeddedLanguagePerLine(createMonarchTokenizer('vue', vueMonarchLanguage), [ + `<${tag} lang="${lang}">a = "${'x'.repeat(EMBED_ENTRY_REST_OF_LINE_BUDGET)}"`, + ' b', + `${tag}>` + ]) + + expect(embeds).toEqual([null, embeddedLanguageId, null]) + }) + it('tokenizes a template interpolation without dropping the embed', () => { const measurement = measureNestedDepth(createMonarchTokenizer('vue', vueMonarchLanguage), [ '', diff --git a/src/renderer/src/lib/monaco-languages/register-astro.ts b/src/renderer/src/lib/monaco-languages/register-astro.ts index a02488e701d..3caac38c1f7 100644 --- a/src/renderer/src/lib/monaco-languages/register-astro.ts +++ b/src/renderer/src/lib/monaco-languages/register-astro.ts @@ -113,7 +113,7 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@scriptBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@scriptBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@scriptLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -143,9 +143,14 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { scriptBody: [ [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], - // Same body, no embed: reached only past the budget. + // Over-budget mirror of the body: re-enters `$S2` as soon as the rest of + // the line fits, so a long opening line does not grey out the whole block. scriptBodyPlain: [ [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ], @@ -155,7 +160,7 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@styleBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@styleBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@styleLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -187,6 +192,10 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { ], styleBodyPlain: [ [/<\/style\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ], diff --git a/src/renderer/src/lib/monaco-languages/register-svelte.ts b/src/renderer/src/lib/monaco-languages/register-svelte.ts index ab10f2c37fb..7e4b0cc0292 100644 --- a/src/renderer/src/lib/monaco-languages/register-svelte.ts +++ b/src/renderer/src/lib/monaco-languages/register-svelte.ts @@ -164,7 +164,7 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@scriptBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@scriptBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@scriptLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -194,9 +194,14 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { scriptBody: [ [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], - // Same body, no embed: reached only past the budget. + // Over-budget mirror of the body: re-enters `$S2` as soon as the rest of + // the line fits, so a long opening line does not grey out the whole block. scriptBodyPlain: [ [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ], @@ -206,7 +211,7 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@styleBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@styleBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@styleLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -238,6 +243,10 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { ], styleBodyPlain: [ [/<\/style\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ], diff --git a/src/renderer/src/lib/monaco-languages/register-vue.ts b/src/renderer/src/lib/monaco-languages/register-vue.ts index 7a914877734..a12f1557f7e 100644 --- a/src/renderer/src/lib/monaco-languages/register-vue.ts +++ b/src/renderer/src/lib/monaco-languages/register-vue.ts @@ -97,7 +97,7 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@scriptBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@scriptBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@scriptLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -125,9 +125,14 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { [/\s+/, 'white'] ], scriptBody: [[/<\/script\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }]], - // Same body, no embed: reached only past the budget. + // Over-budget mirror of the body: re-enters `$S2` as soon as the rest of + // the line fits, so a long opening line does not grey out the whole block. scriptBodyPlain: [ [/<\/script\s*>/, { token: 'tag', next: '@pop' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ], @@ -137,7 +142,7 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { tagCloseWithinEmbedBudget, { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } ], - [/>/, { token: 'tag', switchTo: '@styleBodyPlain' }], + [/>/, { token: 'tag', switchTo: '@styleBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@styleLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -167,6 +172,10 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { styleBody: [[/<\/style\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }]], styleBodyPlain: [ [/<\/style\s*>/, { token: 'tag', next: '@pop' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], [/[^<]+/, ''], [/./, ''] ],