Files
Verso/services/web/frontend/js/ide/log-parser/quarto-log-parser.ts
T
claude c9727a26e4
Build and Deploy Verso / deploy (push) Successful in 9m46s
Python deps: smart missing-package hint + switch to .vrf requirements file
Option A: when a {python} cell fails with ModuleNotFoundError/ImportError, the
log now suggests the exact PyPI package to add (with a module->package map, e.g.
cv2 -> opencv-python, sklearn -> scikit-learn), names the Verso requirements
file, and notes it could instead be a local module — so the langmuirthermalstudy
case isn't mistaken for a PyPI package.

Switch the per-project requirements file from requirements.txt to a Verso-
specific requirements.vrf (so it won't be confused with arbitrary .txt files);
QuartoRunner now looks for requirements.vrf, and 'vrf' is registered as an
editable text extension. The dedicated in-UI editor (and hiding it from the
file tree) follows in a separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:19:01 +00:00

200 lines
7.0 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.]+)['"]?/
// Import (module) name -> PyPI package name, for the common cases where they
// differ. Anything not listed defaults to the module name itself.
const PY_MODULE_TO_PACKAGE: Record<string, string> = {
cv2: 'opencv-python',
sklearn: 'scikit-learn',
skimage: 'scikit-image',
PIL: 'Pillow',
yaml: 'PyYAML',
bs4: 'beautifulsoup4',
Crypto: 'pycryptodome',
OpenSSL: 'pyOpenSSL',
dateutil: 'python-dateutil',
dotenv: 'python-dotenv',
serial: 'pyserial',
usb: 'pyusb',
cairo: 'pycairo',
gi: 'PyGObject',
win32com: 'pywin32',
}
// 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 moduleName = pyModule[1]
// Suggest the PyPI package for the top-level module (cv2 -> opencv-python).
const topLevel = moduleName.split('.')[0]
const suggestion = PY_MODULE_TO_PACKAGE[topLevel] || topLevel
data.push({
line: pendingLocation.line ?? null,
file: pendingLocation.file,
level: 'error',
message: `Python module "${moduleName}" is not available`,
content:
`${clean}\n\n` +
`If "${topLevel}" is a PyPI package, add \`${suggestion}\` to your ` +
`Verso requirements file (requirements.vrf) and recompile as the ` +
`project owner or a collaborator. If it is your own module, add its ` +
`.py file(s) to the project instead.\n` +
`Pre-installed: numpy, pandas, scipy, matplotlib, seaborn, ` +
`scikit-learn, sympy, plotly, tabulate, opencv-python (cv2), tqdm.`,
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: [],
}
}