Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 93 additions & 61 deletions cli/src/commands/build/wasm/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -44,71 +44,86 @@ interface BuildAllContext {
}

export function buildAll(args: BuildAllArgs = {}) {
return new Listr<BuildAllContext>([
{
async task(ctx) {
const p = paths();
const index = await buildGrammarIndex(p.langsRoots);
ctx.index = index;
return new Listr<BuildAllContext>(
[
{
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(
Expand Down Expand Up @@ -150,3 +165,20 @@ function buildLang(
},
];
}

/**
* The deepest failed step under `task`, as "<step title>: <error>". A failed
* command contributes its full stderr, not just the tail in its message.
*/
function failureReason(task: ListrTaskObject<BuildAllContext>): 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;
}
75 changes: 63 additions & 12 deletions cli/src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()));
});
});
}
Expand Down Expand Up @@ -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 {
/** `<cmd> <args> exited with code <n>`. */
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, "_");
}
Loading