fix: parse Quarto schema YAML errors and stop heading style bleeding
Build and Deploy Verso / deploy (push) Successful in 9m46s

Two unrelated fixes:

1. quarto-log-parser: handle the two-line Quarto schema-validation
   error format:
     ERROR: In file main.qmd
     (line 6, columns 24--27) Field "section-numbering" has value …
   Previously neither the file name nor the line number were extracted,
   so the error appeared without a red highlight. Now the first line
   stores the filename in pendingLocation and the second line creates
   the log entry with the correct file and line so the editor can jump
   to and highlight it.

2. headingTitleTokenizer: change contextual: false → contextual: true
   and guard with stack.canShift(HeadingTitle). With contextual: false
   Lezer calls the tokenizer speculatively at positions beyond the strict
   post-HeadingMark state; in some LALR-merged states the resulting token
   was accepted for body-text lines, making them render as bold-blue
   heading text. The contextual guard ensures the tokenizer only fires
   in the one state where HeadingTitle is legitimately valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-08 13:31:26 +00:00
co-authored by Claude Sonnet 4.6
parent 55e8208892
commit 1c323351a2
2 changed files with 52 additions and 4 deletions
@@ -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 ────────────────────────────────────────────────────────
@@ -58,6 +58,11 @@ const PY_MODULE_TO_PACKAGE: Record<string, string> = {
}
// 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 <f>`:
// (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 <filename>` 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 <filename>` 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))) {