aboutsummaryrefslogtreecommitdiff
path: root/Scripts
diff options
context:
space:
mode:
authorDavid Czihak <git@dcz.at>2026-05-08 03:00:15 +0200
committerDavid Czihak <git@dcz.at>2026-05-08 03:00:15 +0200
commitb9d713336bd4fdc5e40899257b1fe7a356ca8dcf (patch)
treee1d2860ddf998a9507db45f04a67670c34c336aa /Scripts
parentfa99e9c7564bafef500ec3b2218859319098ee74 (diff)
Feat: New task system
This commit brings a complete rewrite of the task system to align the Zig build system and Nova task system as closely as possible. Lots of new options and settings have been added to the tasks, and the Zig extension will auto-detect tasks from the `zig build --list-steps` command. Should you prefer to add tasks manually, or need a more fine-grained control, the auto-detection can be disabled. This rewrite has not yet been thoroughly tested and will likely be a little rough around the edges.
Diffstat (limited to 'Scripts')
-rw-r--r--Scripts/main.js480
1 files changed, 390 insertions, 90 deletions
diff --git a/Scripts/main.js b/Scripts/main.js
index 6f0a54e..70af784 100644
--- a/Scripts/main.js
+++ b/Scripts/main.js
@@ -4,6 +4,8 @@ const EXTENSION_ID = "at.dcz.nova-zig";
const TASK_ASSISTANT_ID = `${EXTENSION_ID}.tasks`;
const LANGUAGE_CLIENT_ID = `${EXTENSION_ID}.zls`;
const ISSUE_MATCHER = "zig.compiler";
+const USER_OPTION_REGEX = /^[A-Za-z_][A-Za-z0-9_-]*(=.*)?$/;
+const STEP_CACHE_TTL_MS = 5 * 60 * 1000;
const CONFIG_KEYS = {
zigPath: `${EXTENSION_ID}.toolchain.zig-path`,
@@ -13,6 +15,7 @@ const CONFIG_KEYS = {
zlsBuildOnSave: `${EXTENSION_ID}.zls.build-on-save`,
zlsDebug: `${EXTENSION_ID}.zls.debug`,
lldbDapDebug: `${EXTENSION_ID}.debug-adapter.debug`,
+ discoverSteps: `${EXTENSION_ID}.tasks.discover-steps`,
};
let languageServer = null;
@@ -143,6 +146,229 @@ function getTaskCwd(config) {
return nova.workspace.path || null;
}
+// Returns the configured step string, or null if the step should be omitted.
+// An explicit empty string means "no step argument" — `zig build` then runs the
+// default install step (Ziglings-style projects rely on this). Missing/null
+// returns the caller-supplied fallback.
+function resolveStep(config, key, fallback) {
+ const raw = config ? config.get(key) : undefined;
+ if (raw === undefined || raw === null) {
+ return fallback === undefined ? null : fallback;
+ }
+ const trimmed = String(raw).trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+// Backwards-compatible wrapper: zigBuildRun's `runStep` field. The legacy
+// `step` key is still honored for users on pre-0.1 task configs.
+function resolveRunStep(config) {
+ const raw = config ? config.get("runStep") : undefined;
+ if (raw === undefined || raw === null) {
+ const legacy = getTaskConfigValue(config, "step");
+ return legacy ? legacy : null;
+ }
+ const trimmed = String(raw).trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+// Builds the `zig build` argv prefix shared by Build/Run/Test/Watch resolvers.
+// Order: ["build", -Doptimize, -Dtarget, -D<userOptions>, ...buildArgs, step?].
+// `runArgs` are the caller's responsibility — they go after `--`.
+function buildZigArgv(config, options) {
+ const opts = options || {};
+ const argv = ["build"];
+
+ const optimize = getTaskConfigValue(config, "optimize") || opts.defaultOptimize || null;
+ if (optimize) argv.push(`-Doptimize=${optimize}`);
+
+ const target = getTaskConfigValue(config, "target");
+ if (target) argv.push(`-Dtarget=${target}`);
+
+ for (const entry of getTaskArgs(config, "userOptions")) {
+ if (USER_OPTION_REGEX.test(entry)) {
+ argv.push(`-D${entry}`);
+ } else {
+ console.warn(`[zig-task] Skipping invalid userOptions entry: ${entry}`);
+ }
+ }
+
+ argv.push(...getTaskArgs(config, "buildArgs"));
+
+ if (opts.step) argv.push(opts.step);
+ return argv;
+}
+
+// Refuses to clean unless `cwd` is non-root, not $HOME, and inside the workspace.
+// Returns the array of cache directories to remove, or null after warning the user.
+function safeCleanPaths(cwd) {
+ const showWarning = () => {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.clean.unsafe_cwd",
+ "Refusing to clean: the working directory must be inside this workspace."
+ )
+ );
+ };
+
+ if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
+ showWarning();
+ return null;
+ }
+
+ const normalized = nova.path.normalize(cwd);
+ if (normalized === "/" || normalized === "") {
+ showWarning();
+ return null;
+ }
+
+ const home = nova.environment ? nova.environment.HOME : null;
+ if (home && (normalized === home || normalized === nova.path.normalize(home))) {
+ showWarning();
+ return null;
+ }
+
+ const workspacePath = nova.workspace.path;
+ if (!workspacePath) {
+ showWarning();
+ return null;
+ }
+
+ const workspaceNormalized = nova.path.normalize(workspacePath);
+ if (normalized !== workspaceNormalized && !normalized.startsWith(workspaceNormalized + "/")) {
+ showWarning();
+ return null;
+ }
+
+ return [".zig-cache", "zig-cache", "zig-out"];
+}
+
+// Walks up from `startDir` to the nearest ancestor containing `build.zig`,
+// stopping at the workspace root. Returns the workspace root if no build.zig
+// is found above startDir, or null if no workspace is open.
+function nearestBuildZigDir(startDir) {
+ const workspacePath = nova.workspace.path
+ ? nova.path.normalize(nova.workspace.path)
+ : null;
+
+ if (!startDir) return workspacePath || null;
+
+ let current = nova.path.normalize(startDir);
+ for (let i = 0; i < 64; i++) {
+ if (nova.fs.stat(nova.path.join(current, "build.zig"))) {
+ return current;
+ }
+ if (current === "/" || (workspacePath && current === workspacePath)) {
+ return workspacePath || null;
+ }
+ const parent = nova.path.dirname(current);
+ if (!parent || parent === current) break;
+ current = parent;
+ }
+ return workspacePath || null;
+}
+
+// Best-effort regex extraction of the package name from build.zig.zon. Returns
+// null when the file is missing, unreadable, or doesn't match the expected
+// `\.name = "<name>"` / `\.name = .<ident>` shapes. Used to default
+// `programPath` for the Debug template.
+function parseProjectName(cwd) {
+ if (!cwd) return null;
+ const zonPath = nova.path.join(cwd, "build.zig.zon");
+ if (!nova.fs.stat(zonPath)) return null;
+
+ let content = "";
+ try {
+ const file = nova.fs.open(zonPath, "r");
+ content = file.read() || "";
+ file.close();
+ } catch (_) {
+ return null;
+ }
+ if (typeof content !== "string" || content.length === 0) return null;
+
+ const match = content.match(/\.name\s*=\s*(?:"([^"]+)"|\.([A-Za-z_][\w]*))/);
+ if (!match) return null;
+ return match[1] || match[2] || null;
+}
+
+// Per-cwd cache for `zig build --list-steps`. Invalidated by mtime changes on
+// build.zig / build.zig.zon and a soft 5-minute TTL. `getOrFetch` returns
+// cached steps synchronously and kicks off a background refresh when stale,
+// nudging Nova to reload tasks once the refresh lands.
+const stepCache = {
+ entries: new Map(),
+ pending: new Map(),
+
+ getOrFetch(cwd) {
+ if (!cwd) return null;
+
+ const buildZigPath = nova.path.join(cwd, "build.zig");
+ const buildZigStat = nova.fs.stat(buildZigPath);
+ if (!buildZigStat) return null;
+
+ const buildZonStat = nova.fs.stat(nova.path.join(cwd, "build.zig.zon"));
+ const buildZigMtimeMs = buildZigStat.mtime ? buildZigStat.mtime.getTime() : 0;
+ const buildZonMtimeMs = buildZonStat && buildZonStat.mtime ? buildZonStat.mtime.getTime() : 0;
+
+ const cached = this.entries.get(cwd);
+ const fresh =
+ cached &&
+ cached.buildZigMtimeMs === buildZigMtimeMs &&
+ cached.buildZonMtimeMs === buildZonMtimeMs &&
+ Date.now() - cached.fetchedAt < STEP_CACHE_TTL_MS;
+
+ if (fresh) return cached.steps;
+
+ if (!this.pending.has(cwd)) {
+ const promise = this.fetch(cwd, buildZigMtimeMs, buildZonMtimeMs).finally(() => {
+ this.pending.delete(cwd);
+ if (typeof nova.workspace.reloadTasks === "function") {
+ try {
+ nova.workspace.reloadTasks(TASK_ASSISTANT_ID);
+ } catch (_) {}
+ }
+ });
+ this.pending.set(cwd, promise);
+ }
+
+ return cached ? cached.steps : null;
+ },
+
+ async fetch(cwd, buildZigMtimeMs, buildZonMtimeMs) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) return null;
+
+ const result = await runProcess(zigPath, {
+ args: ["build", "--list-steps"],
+ cwd,
+ });
+ if (result.status !== 0) return null;
+
+ const steps = result.stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => line.split(/\s+/, 1)[0])
+ .filter((name) => /^[A-Za-z_][\w-]*$/.test(name));
+
+ this.entries.set(cwd, {
+ steps,
+ buildZigMtimeMs,
+ buildZonMtimeMs,
+ fetchedAt: Date.now(),
+ });
+ return steps;
+ },
+
+ invalidate(cwd) {
+ if (cwd) {
+ this.entries.delete(cwd);
+ } else {
+ this.entries.clear();
+ }
+ },
+};
+
function activeZigFilePath() {
const editor = nova.workspace.activeTextEditor;
if (!editor || !editor.document || !editor.document.path) {
@@ -221,6 +447,22 @@ async function resolveExecutable(configKey, defaultCommand) {
return findExecutableOnPath(defaultCommand);
}
+// Resolves the Zig executable, surfacing a single localized warning when it's
+// missing. Returns null when no path is found — callers must short-circuit.
+async function requireZig() {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+ return zigPath;
+}
+
async function findExecutableWithXcode(commandName) {
const result = await runProcess("/usr/bin/xcrun", {
args: ["--find", commandName],
@@ -593,18 +835,41 @@ class ZigTaskAssistant {
}
provideTasks() {
- const task = new Task(localizedText("task.current_file.name", "Current Zig File"));
- task.setAction(Task.Run, new TaskResolvableAction({
+ const tasks = [];
+
+ const currentFile = new Task(localizedText("task.current_file.name", "Current Zig File"));
+ currentFile.setAction(Task.Run, new TaskResolvableAction({
data: {
type: "current-file-run",
},
}));
- task.setAction(Task.Clean, new TaskResolvableAction({
+ currentFile.setAction(Task.Clean, new TaskResolvableAction({
data: {
type: "current-file-clean",
},
}));
- return [task];
+ tasks.push(currentFile);
+
+ const workspacePath = nova.workspace.path;
+ if (workspacePath && getBooleanConfigValue(CONFIG_KEYS.discoverSteps, true)) {
+ const steps = stepCache.getOrFetch(workspacePath);
+ if (steps && steps.length > 0) {
+ for (const step of steps) {
+ const task = new Task(
+ localizedText("task.build_step.name", "Zig Build: {step}", { step })
+ );
+ task.setAction(Task.Run, new TaskResolvableAction({
+ data: {
+ type: "build-step",
+ step,
+ },
+ }));
+ tasks.push(task);
+ }
+ }
+ }
+
+ return tasks;
}
async resolveTaskAction(context) {
@@ -623,14 +888,19 @@ class ZigTaskAssistant {
return this.resolveDebugBuildAction(config, cwd);
case "build-run":
return this.resolveBuildRunAction(config, cwd);
- case "build-run-terminal":
- return this.resolveBuildRunTerminalAction(config, cwd);
+ case "build-step":
+ return this.resolveBuildStepAction(context.data && context.data.step);
case "current-file-run":
return this.resolveCurrentFileRunAction();
case "current-file-clean":
return this.resolveCurrentFileCleanAction();
case "debug":
return this.resolveDebugAction(config, cwd);
+ case "test-build":
+ case "test-run":
+ return this.resolveTestAction(config, cwd);
+ case "watch":
+ return this.resolveWatchAction(config, cwd);
default:
return null;
}
@@ -647,7 +917,7 @@ class ZigTaskAssistant {
});
}
- resolveCleanAction(cwd) {
+ async resolveCleanAction(cwd) {
if (!cwd) {
nova.workspace.showWarningMessage(
localizedText(
@@ -658,109 +928,129 @@ class ZigTaskAssistant {
return null;
}
- return new TaskProcessAction("/bin/rm", {
- args: ["-rf", ".zig-cache", "zig-cache", "zig-out"],
- cwd,
- });
+ const paths = safeCleanPaths(cwd);
+ if (!paths) return null;
+
+ // If the project exposes an `uninstall` step, prefer running it before
+ // wiping the cache directories. Use only what's already cached — don't
+ // block clean on a fresh `--list-steps` invocation.
+ const cached = stepCache.entries.get(cwd);
+ const args = ["-rf", ...paths];
+
+ if (cached && Array.isArray(cached.steps) && cached.steps.includes("uninstall")) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (zigPath) {
+ const command = `${quoteShellArgument(zigPath)} build uninstall; /bin/rm ${args
+ .map(quoteShellArgument)
+ .join(" ")}`;
+ return new TaskProcessAction("/bin/zsh", {
+ args: ["-lc", command],
+ cwd,
+ matchers: [ISSUE_MATCHER],
+ });
+ }
+ }
+
+ return new TaskProcessAction("/bin/rm", { args, cwd });
}
async resolveBuildAction(config, cwd) {
- const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
- if (!zigPath) {
- nova.workspace.showWarningMessage(
- localizedText(
- "warning.zig.not_found",
- "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
- )
- );
- return null;
- }
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
- return this.createAction(zigPath, ["build", ...getTaskArgs(config, "buildArgs")], cwd);
+ return this.createAction(zigPath, buildZigArgv(config), cwd);
}
async resolveDebugBuildAction(config, cwd) {
- const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
- if (!zigPath) {
- nova.workspace.showWarningMessage(
- localizedText(
- "warning.zig.not_found",
- "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
- )
- );
- return null;
- }
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
- return this.createAction(zigPath, ["build", "-Doptimize=Debug", ...getTaskArgs(config, "buildArgs")], cwd);
+ return this.createAction(
+ zigPath,
+ buildZigArgv(config, { defaultOptimize: "Debug" }),
+ cwd
+ );
}
- async resolveBuildRunAction(config, cwd) {
- const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
- if (!zigPath) {
- nova.workspace.showWarningMessage(
- localizedText(
- "warning.zig.not_found",
- "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
- )
- );
- return null;
- }
+ async resolveBuildRunAction(config, cwd, forceConsole) {
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
- const step =
- getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
- const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
+ const step = resolveRunStep(config);
+ const argv = buildZigArgv(config, { step });
const runArgs = getTaskArgs(config, "runArgs");
+ if (runArgs.length > 0) argv.push("--", ...runArgs);
+
+ const consoleMode =
+ forceConsole || getTaskConfigValue(config, "console") || "internalConsole";
- if (runArgs.length > 0) {
- args.push("--", ...runArgs);
+ if (consoleMode === "externalTerminal") {
+ return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, {
+ args: [
+ {
+ command: zigPath,
+ args: argv,
+ cwd,
+ },
+ ],
+ });
}
- return this.createAction(zigPath, args, cwd);
+ return this.createAction(zigPath, argv, cwd);
}
- async resolveBuildRunTerminalAction(config, cwd) {
- const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
- if (!zigPath) {
- nova.workspace.showWarningMessage(
- localizedText(
- "warning.zig.not_found",
- "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
- )
- );
- return null;
- }
+ async resolveBuildStepAction(step) {
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
+
+ const cwd = nova.workspace.path || null;
+ if (!cwd || !step || !/^[A-Za-z_][\w-]*$/.test(step)) return null;
+
+ return this.createAction(zigPath, ["build", step], cwd);
+ }
+
+ async resolveTestAction(config, cwd) {
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
+
+ const argv = buildZigArgv(config, { step: "test" });
+
+ const summary = getTaskConfigValue(config, "summary");
+ if (summary) argv.push(`--summary=${summary}`);
+
+ const testFilter = getTaskConfigValue(config, "testFilter");
+ if (testFilter) argv.push("--test-filter", testFilter);
- const step =
- getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
- const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
const runArgs = getTaskArgs(config, "runArgs");
+ if (runArgs.length > 0) argv.push("--", ...runArgs);
- if (runArgs.length > 0) {
- args.push("--", ...runArgs);
+ return this.createAction(zigPath, argv, cwd);
+ }
+
+ async resolveWatchAction(config, cwd) {
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
+
+ const step = resolveStep(config, "step", null);
+ const argv = buildZigArgv(config, { step });
+ argv.push("--watch");
+
+ const debounce = getTaskConfigValue(config, "debounceMs");
+ if (debounce !== null && debounce !== undefined && debounce !== "") {
+ const n = Number(debounce);
+ if (Number.isFinite(n) && n >= 0) argv.push("--debounce", String(Math.floor(n)));
}
- return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, {
- args: [
- {
- command: zigPath,
- args,
- cwd,
- },
- ],
- });
+ const incremental = getTaskConfigValue(config, "incremental");
+ if (incremental === "on") argv.push("-fincremental");
+ else if (incremental === "off") argv.push("-fno-incremental");
+
+ return this.createAction(zigPath, argv, cwd);
}
async resolveCurrentFileRunAction() {
- const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
- if (!zigPath) {
- nova.workspace.showWarningMessage(
- localizedText(
- "warning.zig.not_found",
- "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
- )
- );
- return null;
- }
+ const zigPath = await requireZig();
+ if (!zigPath) return null;
const filePath = activeZigFilePath();
const cwd = filePath ? nova.path.dirname(filePath) : null;
@@ -777,9 +1067,9 @@ class ZigTaskAssistant {
return this.createAction(zigPath, ["run", filePath], cwd);
}
- resolveCurrentFileCleanAction() {
- const cwd = activeZigFileDirectory();
- if (!cwd) {
+ async resolveCurrentFileCleanAction() {
+ const startDir = activeZigFileDirectory();
+ if (!startDir) {
nova.workspace.showWarningMessage(
localizedText(
"warning.current_file.focus_editor_for_clean",
@@ -789,6 +1079,7 @@ class ZigTaskAssistant {
return null;
}
+ const cwd = nearestBuildZigDir(startDir);
return this.resolveCleanAction(cwd);
}
@@ -805,7 +1096,16 @@ class ZigTaskAssistant {
}
const configuredProgramPath = getTaskConfigValue(config, "programPath");
- const programPath = resolvePathAgainstBase(configuredProgramPath, cwd);
+ let programPath = resolvePathAgainstBase(configuredProgramPath, cwd);
+ if (!programPath) {
+ const projectName = parseProjectName(cwd);
+ if (projectName) {
+ const candidate = nova.path.join(cwd, "zig-out", "bin", projectName);
+ if (nova.fs.stat(candidate)) {
+ programPath = candidate;
+ }
+ }
+ }
if (!programPath) {
nova.workspace.showWarningMessage(
localizedText(
@@ -859,7 +1159,7 @@ class ZigTaskAssistant {
class ZigIssueAssistant {
constructor() {
this.disposable = nova.assistants.registerIssueAssistant(
- [{ syntax: "zig" }, { syntax: "zig-package" }],
+ [{ syntax: "zig" }],
this,
{ event: "onChange" }
);