diff --git a/cli/src/commands/build/wasm/grammars.ts b/cli/src/commands/build/wasm/grammars.ts index acc6839..cce1c79 100644 --- a/cli/src/commands/build/wasm/grammars.ts +++ b/cli/src/commands/build/wasm/grammars.ts @@ -13,12 +13,12 @@ // output stays readable on a shared terminal. import { availableParallelism } from "node:os"; -import { Listr, type ListrTask } from "listr2"; +import { Listr, type ListrTask, type ListrTaskObject } from "listr2"; import { buildGrammarIndex, type GrammarIndexEntry, } from "../../../lib/arborium-yaml.ts"; -import { paths } from "../../../lib/util.ts"; +import { CommandError, paths } from "../../../lib/util.ts"; import { packageGrammar } from "../../package/wasm/grammars.ts"; import { buildGrammar } from "./grammar.ts"; @@ -44,71 +44,86 @@ interface BuildAllContext { } export function buildAll(args: BuildAllArgs = {}) { - return new Listr([ - { - async task(ctx) { - const p = paths(); - const index = await buildGrammarIndex(p.langsRoots); - ctx.index = index; + return new Listr( + [ + { + async task(ctx) { + const p = paths(); + const index = await buildGrammarIndex(p.langsRoots); + ctx.index = index; - let targets = - args.only && args.only.length > 0 - ? args.only.filter((id) => index.has(id)) - : [...index.keys()].sort(); - if (args.group) { - targets = targets.filter((id) => index.get(id)?.group === args.group); - } + let targets = + args.only && args.only.length > 0 + ? args.only.filter((id) => index.has(id)) + : [...index.keys()].sort(); + if (args.group) { + targets = targets.filter( + (id) => index.get(id)?.group === args.group, + ); + } - ctx.targets = targets; + ctx.targets = targets; + }, }, - }, - { - async task(ctx, task) { - ctx.perGrammar = task.newListr( - ctx.targets.map((id) => ({ - async task(_ctx, task) { - // `concurrent: false` is load-bearing: listr2 merges the parent - // list's options into every nested list (see Listr's - // `result.options = { ...this.options, ...result.options }`), so - // without an explicit override this sub-list would inherit the - // outer `concurrent: availableParallelism()` and run a grammar's - // build + package steps in parallel. The per-grammar steps are - // strictly ordered (rm/stage → generate → compile → link → - // package), so they must stay sequential. `exitOnError: true` - // likewise overrides the outer `false`, so a failed step (e.g. - // `tree-sitter generate`) stops this grammar instead of running - // the compile/link/package steps against missing outputs. - return task.newListr(buildLang(args, id), { - ctx, - concurrent: false, - exitOnError: true, - }); + { + async task(ctx, task) { + ctx.perGrammar = task.newListr( + ctx.targets.map((id) => ({ + async task(_ctx, task) { + // `concurrent: false` is load-bearing: listr2 merges the parent + // list's options into every nested list (see Listr's + // `result.options = { ...this.options, ...result.options }`), so + // without an explicit override this sub-list would inherit the + // outer `concurrent: availableParallelism()` and run a grammar's + // build + package steps in parallel. The per-grammar steps are + // strictly ordered (rm/stage → generate → compile → link → + // package), so they must stay sequential. `exitOnError: true` + // likewise overrides the outer `false`, so a failed step (e.g. + // `tree-sitter generate`) stops this grammar instead of running + // the compile/link/package steps against missing outputs. + return task.newListr(buildLang(args, id), { + ctx, + concurrent: false, + exitOnError: true, + }); + }, + })), + { + concurrent: availableParallelism(), + exitOnError: false, }, - })), - { - concurrent: availableParallelism(), - collectErrors: true, - exitOnError: false, - }, - ); - return ctx.perGrammar; - }, - }, - { - // The per-grammar list swallows failures so every grammar gets - // attempted; surface them here so the command (and CI) fails. - async task(ctx) { - const failed = ctx.targets.filter((_, i) => - ctx.perGrammar.tasks[i]?.hasFailed(), - ); - if (failed.length > 0) { - throw new Error( - `${failed.length} of ${ctx.targets.length} grammar(s) failed: ${failed.join(", ")}`, ); - } + return ctx.perGrammar; + }, }, - }, - ]); + { + // The per-grammar list swallows failures so every grammar gets + // attempted; surface them here so the command (and CI) fails. Each + // grammar's reason is repeated here because the streamed output of + // parallel builds interleaves, burying the failing line. + async task(ctx) { + const failed = ctx.targets.flatMap((id, i) => { + const task = ctx.perGrammar.tasks[i]; + return task?.hasFailed() + ? [{ id, reason: failureReason(task) }] + : []; + }); + if (failed.length > 0) { + const details = failed + .map(({ id, reason }) => `\n\n--- ${id} ---\n${reason}`) + .join(""); + throw new Error( + `${failed.length} of ${ctx.targets.length} grammar(s) failed: ${failed.map((f) => f.id).join(", ")}${details}`, + ); + } + }, + }, + ], + // Must be set on the root: every nested list shares the root's `errors` + // array (null unless the root collects), whatever its own options say. + // `failureReason` reads the original errors back out of it. + { collectErrors: true }, + ); } function buildLang( @@ -150,3 +165,20 @@ function buildLang( }, ]; } + +/** + * The deepest failed step under `task`, as ": ". A failed + * command contributes its full stderr, not just the tail in its message. + */ +function failureReason(task: ListrTaskObject): string { + // `subtasks` is typed as an array but is undefined on leaf tasks. + const failedChild = task.subtasks?.find((t) => t.hasFailed()); + if (failedChild) return failureReason(failedChild); + // The original Error, from the root list's shared `errors` array. + const error = task.listr.errors?.find((e) => e.task === task)?.error; + const message = + error instanceof CommandError + ? [error.command, error.stderr].filter(Boolean).join("\n") + : (task.message.error ?? "(no error message)"); + return task.title ? `${task.title}: ${message}` : message; +} diff --git a/cli/src/lib/util.ts b/cli/src/lib/util.ts index 4e18326..6e86e17 100644 --- a/cli/src/lib/util.ts +++ b/cli/src/lib/util.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import type { Writable } from "node:stream"; +import type { Readable, Writable } from "node:stream"; /** * Walk up from `start` until a directory contains a marker file identifying @@ -185,7 +185,9 @@ export interface RunOptions { /** * Run a command with its stdout/stderr piped to `output` (typically a listr * task's stream). Rejects on non-zero exit; the Error message includes the - * command for grep-ability. + * command for grep-ability plus the tail of the child's stderr, and is a + * `CommandError` carrying the full stderr (parallel grammar builds interleave + * the streamed output, so summaries reprint it). */ export async function run( output: Writable, @@ -205,15 +207,12 @@ export async function run( child.stdin?.end(options.input); } child.stdout?.pipe(output); - child.stderr?.pipe(output); + const stderr = teeStderr(child.stderr, output); child.once("error", rejectPromise); child.once("close", (code) => { if (code === 0) resolvePromise(); - else - rejectPromise( - new Error(`${cmd} ${args.join(" ")} exited with code ${code}`), - ); + else rejectPromise(exitError(cmd, args, code, stderr())); }); }); } @@ -245,18 +244,70 @@ export async function runCapture( child.stdout?.on("data", (chunk: string) => { stdout += chunk; }); - child.stderr?.pipe(output); + const stderr = teeStderr(child.stderr, output); child.once("error", rejectPromise); child.once("close", (code) => { if (code === 0) resolvePromise(stdout); - else - rejectPromise( - new Error(`${cmd} ${args.join(" ")} exited with code ${code}`), - ); + else rejectPromise(exitError(cmd, args, code, stderr())); }); }); } +/** How many trailing stderr lines a failed command's Error message carries. */ +const STDERR_TAIL_LINES = 20; + +/** + * A command exited non-zero. `message` carries the command line plus the last + * `STDERR_TAIL_LINES` of stderr, which keeps the per-step `[FAILED: …]` lines + * short; `stderr` holds all of it, for end-of-run summaries. + */ +export class CommandError extends Error { + /** ` exited with code `. */ + readonly command: string; + /** The child's complete stderr, blank lines dropped. */ + readonly stderr: string; + + constructor(command: string, stderr: string) { + const tail = stderr.split("\n").slice(-STDERR_TAIL_LINES).join("\n"); + super(tail ? `${command}\n${tail}` : command); + this.name = "CommandError"; + this.command = command; + this.stderr = stderr; + } +} + +/** + * Forward `stream` to `output` while also buffering it. Returns a getter for + * everything seen so far, blank lines dropped. + */ +function teeStderr(stream: Readable | null, output: Writable): () => string { + let buffered = ""; + if (stream) { + stream.pipe(output); + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + buffered += chunk; + }); + } + return () => + buffered + .split(/\r?\n/) + .filter((line) => line.trim() !== "") + .join("\n"); +} + +function exitError( + cmd: string, + args: readonly string[], + code: number | null, + stderr: string, +): CommandError { + return new CommandError( + `${cmd} ${args.join(" ")} exited with code ${code}`, + stderr, + ); +} + export function normalizeCSymbol(cSymbol: string | undefined, lang: string) { return cSymbol ?? lang.replace(/-/g, "_"); }