diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs index bdcadf8e1a..26710b6ed3 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs @@ -56,8 +56,16 @@ export const headingTokenizer = new ExternalTokenizer( // Using an external token (terminal) instead of grammar sub-items avoids // LALR(1) conflicts: any token shared between headingTitleItem and document- // level item causes the automaton to merge the two contexts. +// +// contextual: true — the tokenizer checks stack.canShift(HeadingTitle) before +// doing anything, so it ONLY fires in the LR state reached after HeadingMark. +// With contextual: false, Lezer calls the tokenizer speculatively at any +// position where HeadingTitle is *conceivably* valid (due to LALR state +// merging), which can cause whole lines of body text to be consumed as +// HeadingTitle tokens and styled as headings. export const headingTitleTokenizer = new ExternalTokenizer( - (input, _stack) => { + (input, stack) => { + if (!stack.canShift(HeadingTitle)) return let hasContent = false while (input.next !== -1 && input.next !== NEWLINE) { // Stop before a line comment (//) or block comment (/*) so that @@ -69,7 +77,7 @@ export const headingTitleTokenizer = new ExternalTokenizer( } if (hasContent) input.acceptToken(HeadingTitle) }, - { contextual: false } + { contextual: true } ) // ── rawTokenizer ──────────────────────────────────────────────────────── diff --git a/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts b/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts index a1dbdfada0..3078285a17 100644 --- a/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts +++ b/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts @@ -58,6 +58,11 @@ const PY_MODULE_TO_PACKAGE: Record = { } // A typst diagnostic location line: ` ┌─ main.typ:5:10` / ` --> main.typ:5:10` const TYPST_LOCATION_REGEX = /(?:[┌╭]─|-->)\s*(.+?):(\d+):(\d+)/ +// Quarto schema-validation location line emitted after `ERROR: In file `: +// (line 6, columns 24--27) Field "section-numbering" has value … +const QUARTO_SCHEMA_LOC_REGEX = /^\(line (\d+), columns? \d+(?:--\d+)?\)\s+(.*)/ +// Extracts the filename from an `ERROR: In file ` message. +const QUARTO_IN_FILE_REGEX = /^In file (.+)$/ function stripAnsi(line: string): string { return line.replace(ANSI_REGEX, '') @@ -67,7 +72,8 @@ function isDiagnosticStart(trimmed: string): boolean { return ( LOWER_DIAG_REGEX.test(trimmed) || UPPER_DIAG_REGEX.test(trimmed) || - PANDOC_REGEX.test(trimmed) + PANDOC_REGEX.test(trimmed) || + QUARTO_SCHEMA_LOC_REGEX.test(trimmed) ) } @@ -119,14 +125,48 @@ export default function parseQuartoLog(rawLog: string): ParseResult { continue } + // Quarto schema-validation errors span two lines: + // ERROR: In file main.qmd ← stores pendingLocation.file + // (line 6, columns 24--27) Field … ← this line carries line + message + let m: RegExpMatchArray | null + if ((m = trimmed.match(QUARTO_SCHEMA_LOC_REGEX))) { + const logLine = parseInt(m[1], 10) + const msg = m[2].trim() + let content = clean + let j = i + 1 + for (; j < lines.length; j++) { + const next = stripAnsi(lines[j]) + if (next.trim() === '') break + if (isDiagnosticStart(next.trimStart())) break + content += '\n' + next + } + i = j - 1 + data.push({ + line: logLine, + file: pendingLocation.file, + level: 'error', + message: msg, + content, + raw: content, + }) + pendingLocation = {} + continue + } + let level: LatexLogEntry['level'] | null = null let message: string | null = null - let m: RegExpMatchArray | null if ((m = trimmed.match(LOWER_DIAG_REGEX))) { level = m[1] === 'error' ? 'error' : 'warning' message = m[2] } else if ((m = trimmed.match(UPPER_DIAG_REGEX))) { + // `ERROR: In file ` is a preamble for a schema-validation error; + // store the filename and defer the entry to the (line N, …) line that follows. + const inFile = m[2].trim().match(QUARTO_IN_FILE_REGEX) + if (inFile) { + pendingLocation = { ...pendingLocation, file: inFile[1].trim() } + continue + } level = m[1] === 'ERROR' ? 'error' : 'warning' message = m[2] } else if ((m = trimmed.match(PANDOC_REGEX))) {