96fc1a90a1
Build and Deploy Verso / deploy (push) Successful in 14m45s
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>
175 lines
6.1 KiB
TypeScript
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: [],
|
|
}
|
|
}
|