diff options
Diffstat (limited to 'Scripts')
| -rw-r--r-- | Scripts/main.js | 480 |
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" } ); |
