aboutsummaryrefslogtreecommitdiff
path: root/Scripts
diff options
context:
space:
mode:
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" }
);