Files
Verso/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts
T
claude 96fc1a90a1
Build and Deploy Verso / deploy (push) Successful in 14m45s
Surface missing-Python-package errors clearly in the Quarto log
When a {python} cell fails with ModuleNotFoundError/ImportError, the Quarto
log parser now emits an actionable error ('Python package "X" is not
installed on the server') noting which scientific packages are pre-installed,
instead of leaking an opaque traceback line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:32:08 +00:00

175 lines
6.1 KiB
TypeScript

import { LatexLogEntry, ParseResult } from './latex-log-parser'
// Parser for the combined stdout/stderr that `quarto render` writes to
// output.log (see services/clsi/app/js/QuartoRunner.js). Quarto orchestrates
// several tools, each with its own diagnostic style:
//
// - Typst (the engine for .qmd -> PDF): emits
// error: unexpected end of block comment
// ┌─ main.typ:5:10
// ...and the analogous `warning: ...` form. Older builds use `-->` instead
// of the box-drawing arrow.
// - Pandoc (markdown -> typst/html): emits `[WARNING] ...` / `[ERROR] ...`.
// - Quarto CLI itself (YAML validation, project errors, Deno crashes): emits
// `ERROR: ...` / `WARNING: ...` (upper-case) or `error: Uncaught ...`.
// - knitr/R (.Rmd / executable cells): emits `Quitting from lines 3-7 (x.qmd)`
// followed by an `Error: ...` message.
//
// This is deliberately a flat, line-oriented parser rather than the stateful
// LaTeX one: Quarto's output has no nested-file `(...)` structure to track.
// It returns the same ParseResult shape so the rest of the log pipeline
// (HumanReadableLogs consumers, the errors/warnings tabs, editor annotations)
// can treat Quarto entries exactly like LaTeX ones.
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\x1b\[[0-9;]*m/g
// Typst / Deno: `error: message`, `warning: message` (lower-case prefix)
const LOWER_DIAG_REGEX = /^(error|warning): (.*)$/
// Quarto CLI: `ERROR: message`, `WARNING: message` (upper-case prefix)
const UPPER_DIAG_REGEX = /^(ERROR|WARNING): (.*)$/
// Pandoc: `[WARNING] message`, `[ERROR] message`, `[INFO] message`
const PANDOC_REGEX = /^\[(WARNING|ERROR|INFO)\] (.*)$/
// knitr/R: `Quitting from lines 3-7 (slides.qmd)`
const R_QUITTING_REGEX = /^Quitting from lines? (\d+)(?:-\d+)?\s*(?:\(([^)]+)\))?/
// Python (Jupyter cell execution): a missing dependency, e.g.
// ModuleNotFoundError: No module named 'pandas'
// ImportError: No module named scipy
const PY_MODULE_REGEX =
/^(?:ModuleNotFoundError|ImportError): No module named ['"]?([\w.]+)['"]?/
// A typst diagnostic location line: ` ┌─ main.typ:5:10` / ` --> main.typ:5:10`
const TYPST_LOCATION_REGEX = /(?:[┌╭]─|-->)\s*(.+?):(\d+):(\d+)/
function stripAnsi(line: string): string {
return line.replace(ANSI_REGEX, '')
}
function isDiagnosticStart(trimmed: string): boolean {
return (
LOWER_DIAG_REGEX.test(trimmed) ||
UPPER_DIAG_REGEX.test(trimmed) ||
PANDOC_REGEX.test(trimmed)
)
}
export default function parseQuartoLog(rawLog: string): ParseResult {
const lines = rawLog.replace(/\r\n?/g, '\n').split('\n')
const data: LatexLogEntry[] = []
let pendingLocation: { file?: string; line?: number } = {}
for (let i = 0; i < lines.length; i++) {
const clean = stripAnsi(lines[i])
const trimmed = clean.trimStart()
// Remember the most recent knitr location; it precedes the `Error:` line.
const quitting = trimmed.match(R_QUITTING_REGEX)
if (quitting) {
pendingLocation = {
line: parseInt(quitting[1], 10),
file: quitting[2],
}
continue
}
// A missing Python package when executing a {python} cell. Turn the raw
// traceback line into an actionable message rather than letting it slip
// through as an opaque error (or not be surfaced at all).
const pyModule = trimmed.match(PY_MODULE_REGEX)
if (pyModule) {
const pkg = pyModule[1]
data.push({
line: pendingLocation.line ?? null,
file: pendingLocation.file,
level: 'error',
message: `Python package "${pkg}" is not installed on the server`,
content:
`${clean}\n\nThe Python package "${pkg}" is not available in the ` +
`compile environment. Common scientific packages (numpy, pandas, ` +
`scipy, matplotlib, seaborn, scikit-learn, sympy, plotly) are ` +
`pre-installed; others must be added to the server image.`,
raw: clean,
})
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))) {
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
} else if ((m = trimmed.match(PANDOC_REGEX))) {
if (m[1] === 'INFO') continue // pandoc INFO lines are not actionable
level = m[1] === 'ERROR' ? 'error' : 'warning'
message = m[2]
}
if (level === null || message === null) continue
// Accumulate any following indented/diagnostic lines (the typst box, a Deno
// stack trace, R traceback) as the entry's content, and pick up a
// file:line:col location from the typst box if present. Stop at a blank
// line or the start of the next diagnostic.
let file = pendingLocation.file
let line: number | null = pendingLocation.line ?? null
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
const loc = next.match(TYPST_LOCATION_REGEX)
if (loc && !file) {
file = loc[1]
line = parseInt(loc[2], 10)
}
}
i = j - 1
data.push({
line,
file,
level,
message: message.trim(),
content,
raw: content,
})
pendingLocation = {}
}
return postProcess(data)
}
function postProcess(data: LatexLogEntry[]): ParseResult {
const all: LatexLogEntry[] = []
const byLevel: Record<'error' | 'warning' | 'typesetting', LatexLogEntry[]> = {
error: [],
warning: [],
typesetting: [],
}
const seen = new Set<string>()
for (const entry of data) {
if (seen.has(entry.raw)) continue
seen.add(entry.raw)
byLevel[entry.level]?.push(entry)
all.push(entry)
}
return {
errors: byLevel.error,
warnings: byLevel.warning,
typesetting: byLevel.typesetting,
all,
files: [],
}
}