Skip to content

Commit 1549fc5

Browse files
authored
Merge pull request #11 from zcg/feature/windows-auto-path
feat(windows): auto-add zvm bin to user PATH on use/install
2 parents 2a95a08 + fa3175e commit 1549fc5

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

src/command/install.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ fn installVersion(
176176
allocator.free(active);
177177
} else {
178178
try zvm.setBin(version);
179+
180+
// On Windows, ensure the bin directory is in the user PATH
181+
if (platform.isWindows()) {
182+
var bin_buf: [std.fs.max_path_bytes]u8 = undefined;
183+
const bin_path = zvm.binPath(&bin_buf);
184+
185+
if (platform.addToUserPath(zvm.io, bin_path)) |added| {
186+
if (added) {
187+
console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{});
188+
}
189+
} else |err| {
190+
console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path });
191+
}
192+
}
179193
}
180194

181195
// Clean up the downloaded archive

src/command/use.zig

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
//! Updates the bin symlink in the data directory to point to the requested version directory.
33

44
const std = @import("std");
5-
const zvm_mod = @import("../core/zvm.zig");
5+
66
const Console = @import("../core/Console.zig");
7+
const platform = @import("../core/platform.zig");
8+
const zvm_mod = @import("../core/zvm.zig");
79

810
/// Switch to an installed Zig version by updating the bin symlink.
911
/// Prints an error if the requested version is not installed.
@@ -24,4 +26,18 @@ pub fn run(
2426

2527
try zvm.setBin(version);
2628
console.plain("Now using Zig {s}", .{version});
29+
30+
// On Windows, ensure the bin directory is in the user PATH
31+
if (platform.isWindows()) {
32+
var bin_buf: [std.fs.max_path_bytes]u8 = undefined;
33+
const bin_path = zvm.binPath(&bin_buf);
34+
35+
if (platform.addToUserPath(zvm.io, bin_path)) |added| {
36+
if (added) {
37+
console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{});
38+
}
39+
} else |err| {
40+
console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path });
41+
}
42+
}
2743
}

src/core/platform.zig

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ pub fn getArchiveExtension() []const u8 {
6161
};
6262
}
6363

64+
/// Returns the platform-specific executable filename for a tool.
65+
/// Windows executables must keep their .exe suffix so shells and editors can find them.
66+
pub fn executableName(comptime base_name: []const u8) []const u8 {
67+
return switch (builtin.os.tag) {
68+
.windows => base_name ++ ".exe",
69+
else => base_name,
70+
};
71+
}
72+
73+
/// Returns true when the current target uses Windows executable semantics.
74+
pub fn isWindows() bool {
75+
return builtin.os.tag == .windows;
76+
}
77+
6478
/// Create a symbolic link at `link_path` pointing to `target`.
6579
/// Removes any existing file/link at `link_path` before creation.
6680
/// On Windows, creates a directory junction (no admin privileges required).
@@ -212,3 +226,160 @@ pub fn copyFile(io: std.Io, src_path: []const u8, dst_path: []const u8) !void {
212226
_ = src_reader.interface.streamRemaining(&dst_writer.interface) catch return error.CopyFailed;
213227
try dst_writer.interface.flush();
214228
}
229+
230+
// ─────────────────────────────────────────────────────────────────────────────
231+
// Windows PATH management — automatically add zvm bin directory to user PATH
232+
// ─────────────────────────────────────────────────────────────────────────────
233+
234+
/// Check if a directory is already in the Windows user PATH environment variable.
235+
/// Performs case-insensitive comparison (Windows paths are case-insensitive).
236+
/// Returns false on non-Windows platforms or on any error.
237+
pub fn isInUserPath(io: std.Io, dir_path: []const u8) bool {
238+
if (builtin.os.tag != .windows) return false;
239+
240+
// Normalize dir_path to use backslashes
241+
var dir_norm: [std.fs.max_path_bytes]u8 = undefined;
242+
if (dir_path.len > dir_norm.len) return false;
243+
@memcpy(dir_norm[0..dir_path.len], dir_path);
244+
std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\');
245+
const normalized = dir_norm[0..dir_path.len];
246+
247+
// Read current user PATH from registry
248+
const read_result = std.process.run(std.heap.page_allocator, io, .{
249+
.argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" },
250+
.stdout_limit = .limited(65536),
251+
.stderr_limit = .limited(4096),
252+
}) catch return false;
253+
defer std.heap.page_allocator.free(read_result.stdout);
254+
defer std.heap.page_allocator.free(read_result.stderr);
255+
256+
if (read_result.term != .exited or read_result.term.exited != 0) return false;
257+
258+
// Parse reg query output to find PATH value
259+
// Output format: " PATH REG_EXPAND_SZ C:\Users\..."
260+
const stdout = read_result.stdout;
261+
var line_iter = std.mem.splitScalar(u8, stdout, '\n');
262+
while (line_iter.next()) |line| {
263+
const trimmed = std.mem.trim(u8, line, " \r\t");
264+
// Look for REG_EXPAND_SZ or REG_SZ and extract the value
265+
if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| {
266+
const value_start = idx + "REG_EXPAND_SZ".len;
267+
const value = std.mem.trim(u8, trimmed[value_start..], " \t");
268+
return containsPathEntry(value, normalized);
269+
}
270+
if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| {
271+
const value_start = idx + "REG_SZ".len;
272+
const value = std.mem.trim(u8, trimmed[value_start..], " \t");
273+
return containsPathEntry(value, normalized);
274+
}
275+
}
276+
277+
return false;
278+
}
279+
280+
/// Add a directory to the Windows user PATH environment variable in the registry.
281+
/// Does nothing if the directory is already present.
282+
/// Returns true if PATH was updated, false if already present.
283+
/// No-op on non-Windows platforms (returns false).
284+
pub fn addToUserPath(io: std.Io, dir_path: []const u8) !bool {
285+
if (builtin.os.tag != .windows) return false;
286+
287+
// Normalize dir_path to use backslashes
288+
var dir_norm: [std.fs.max_path_bytes]u8 = undefined;
289+
if (dir_path.len > dir_norm.len) return error.PathTooLong;
290+
@memcpy(dir_norm[0..dir_path.len], dir_path);
291+
std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\');
292+
const normalized = dir_norm[0..dir_path.len];
293+
294+
// Read current user PATH from registry
295+
const read_result = std.process.run(std.heap.page_allocator, io, .{
296+
.argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" },
297+
.stdout_limit = .limited(65536),
298+
.stderr_limit = .limited(4096),
299+
}) catch return error.PathUpdateFailed;
300+
defer std.heap.page_allocator.free(read_result.stdout);
301+
defer std.heap.page_allocator.free(read_result.stderr);
302+
303+
// If reg query failed (e.g., PATH doesn't exist), create initial PATH
304+
if (read_result.term != .exited or read_result.term.exited != 0) {
305+
return try createInitialUserPath(io, normalized);
306+
}
307+
308+
// Parse reg query output to find current PATH value
309+
const stdout = read_result.stdout;
310+
var current_path: []const u8 = "";
311+
var line_iter = std.mem.splitScalar(u8, stdout, '\n');
312+
while (line_iter.next()) |line| {
313+
const trimmed = std.mem.trim(u8, line, " \r\t");
314+
if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| {
315+
const value_start = idx + "REG_EXPAND_SZ".len;
316+
current_path = std.mem.trim(u8, trimmed[value_start..], " \t");
317+
break;
318+
}
319+
if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| {
320+
const value_start = idx + "REG_SZ".len;
321+
current_path = std.mem.trim(u8, trimmed[value_start..], " \t");
322+
break;
323+
}
324+
}
325+
326+
// Check if already in PATH
327+
if (containsPathEntry(current_path, normalized)) return false;
328+
329+
// Build new PATH with the directory appended
330+
var new_path_buf: [65536]u8 = undefined;
331+
const separator = if (current_path.len > 0) ";" else "";
332+
const new_path = std.fmt.bufPrint(&new_path_buf, "{s}{s}{s}", .{ current_path, separator, normalized }) catch return error.PathTooLong;
333+
334+
// Write updated PATH to registry using reg add
335+
try writeUserPath(io, new_path);
336+
337+
return true;
338+
}
339+
340+
/// Create an initial user PATH entry in the registry with just the given directory.
341+
fn createInitialUserPath(io: std.Io, dir_path: []const u8) !bool {
342+
try writeUserPath(io, dir_path);
343+
return true;
344+
}
345+
346+
/// Write a value to the user PATH in the Windows registry.
347+
fn writeUserPath(io: std.Io, path_value: []const u8) !void {
348+
const result = std.process.run(std.heap.page_allocator, io, .{
349+
.argv = &.{ "reg", "add", "HKCU\\Environment", "/v", "PATH", "/t", "REG_EXPAND_SZ", "/d", path_value, "/f" },
350+
.stdout_limit = .limited(4096),
351+
.stderr_limit = .limited(4096),
352+
}) catch return error.PathUpdateFailed;
353+
defer std.heap.page_allocator.free(result.stdout);
354+
defer std.heap.page_allocator.free(result.stderr);
355+
356+
if (result.term != .exited or result.term.exited != 0)
357+
return error.PathUpdateFailed;
358+
}
359+
360+
/// Check if a semicolon-separated PATH string contains a specific entry.
361+
/// Performs case-insensitive, slash-normalized comparison and trims trailing backslashes.
362+
fn containsPathEntry(path_str: []const u8, entry: []const u8) bool {
363+
var iter = std.mem.splitScalar(u8, path_str, ';');
364+
while (iter.next()) |part| {
365+
var p = std.mem.trim(u8, part, " \t");
366+
// Trim trailing backslashes for comparison
367+
while (p.len > 0 and p[p.len - 1] == '\\') {
368+
p = p[0 .. p.len - 1];
369+
}
370+
// Case-insensitive, slash-normalized comparison
371+
if (pathEqual(p, entry)) return true;
372+
}
373+
return false;
374+
}
375+
376+
/// Compare two path strings case-insensitively, treating '/' and '\\' as equal.
377+
fn pathEqual(a: []const u8, b: []const u8) bool {
378+
if (a.len != b.len) return false;
379+
for (0..a.len) |i| {
380+
const ca: u8 = if (a[i] == '/') '\\' else a[i];
381+
const cb: u8 = if (b[i] == '/') '\\' else b[i];
382+
if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false;
383+
}
384+
return true;
385+
}

0 commit comments

Comments
 (0)