From 9e81aa983bea2ad065830a2c3e53fa12e0baaee9 Mon Sep 17 00:00:00 2001 From: vkurilyak <320287930+vkurilyak@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:56:23 +0200 Subject: [PATCH 1/2] fix: avoid treating modules named build.zig as build scripts --- src/DocumentStore.zig | 79 +++++++++++++++++++++++++++++++++++-------- src/Server.zig | 9 +++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/DocumentStore.zig b/src/DocumentStore.zig index cc5fd07ed..044a71442 100644 --- a/src/DocumentStore.zig +++ b/src/DocumentStore.zig @@ -763,11 +763,21 @@ pub fn getBuildFile(self: *DocumentStore, uri: Uri) ?*BuildFile { /// invalidates any pointers into `DocumentStore.build_files` /// **Thread safe** takes an exclusive lock /// This function does not protect against data races from modifying the BuildFile -fn getOrLoadBuildFile(self: *DocumentStore, uri: Uri) error{ Canceled, OutOfMemory }!*BuildFile { +fn getOrLoadBuildFile( + self: *DocumentStore, + uri: Uri, + source_handle: ?*const Handle, +) error{ Canceled, OutOfMemory }!?*BuildFile { comptime std.debug.assert(supports_build_system); + // A filename alone is not sufficient because ordinary modules may be named `build.zig`. + if (source_handle) |handle| std.debug.assert(handle.uri.eql(uri)); + if (self.getBuildFile(uri)) |build_file| return build_file; + const handle = source_handle orelse (try self.getOrLoadHandle(uri) orelse return null); + if (!isBuildScript(&handle.tree)) return null; + const new_build_file: *BuildFile = blk: { try self.mutex.lock(self.io); defer self.mutex.unlock(self.io); @@ -1244,6 +1254,43 @@ pub fn isBuildFile(uri: Uri) bool { return std.mem.endsWith(u8, uri.raw, "/build.zig"); } +/// Returns whether the parsed source exposes the entry point required by `zig build`. +pub fn isBuildScript(tree: *const Ast) bool { + std.debug.assert(tree.mode == .zig); + + for (tree.rootDecls()) |decl| { + var buffer: [1]Ast.Node.Index = undefined; + const fn_proto = tree.fullFnProto(&buffer, decl) orelse continue; + const visibility_token = fn_proto.visib_token orelse continue; + const name_token = fn_proto.name_token orelse continue; + + std.debug.assert(tree.tokenTag(visibility_token) == .keyword_pub); + std.debug.assert(tree.tokenTag(name_token) == .identifier); + if (std.mem.eql(u8, tree.tokenSlice(name_token), "build")) return true; + } + return false; +} + +test isBuildScript { + var build_script = try Ast.parse(std.testing.allocator, + \\const std = @import("std"); + \\pub fn build(b: *std.Build) void { + \\ _ = b; + \\} + , .zig); + defer build_script.deinit(std.testing.allocator); + try std.testing.expect(isBuildScript(&build_script)); + + var module = try Ast.parse(std.testing.allocator, + \\const ParseOptions = @import("../error_handling.zig").ParseOptions; + \\pub fn parse(options: ParseOptions) void { + \\ _ = options; + \\} + , .zig); + defer module.deinit(std.testing.allocator); + try std.testing.expect(!isBuildScript(&module)); +} + pub fn isBuiltinFile(uri: Uri) bool { return std.mem.endsWith(u8, uri.raw, "/builtin.zig"); } @@ -1460,7 +1507,7 @@ fn collectPotentialBuildFiles(self: *DocumentStore, uri: Uri) error{ Canceled, O const build_file_uri: Uri = try .fromPath(self.allocator, build_path); defer build_file_uri.deinit(self.allocator); - const build_file = try self.getOrLoadBuildFile(build_file_uri); + const build_file = try self.getOrLoadBuildFile(build_file_uri, null) orelse continue; potential_build_files.appendAssumeCapacity(build_file); } // The potential build files that come first should have higher priority. @@ -1541,18 +1588,6 @@ fn createAndStoreDocument( .uri => {}, }; - if (supports_build_system and options.lsp_synced and isBuildFile(uri) and !isInStd(uri)) { - switch (options.load_build_file_behaviour) { - .load_but_dont_update => { - _ = try store.getOrLoadBuildFile(uri); - }, - .only_update => { - store.invalidateBuildFile(uri); - }, - .never => {}, - } - } - const handle_future: *Handle.Future = handle_future: { try store.mutex.lock(store.io); defer store.mutex.unlock(store.io); @@ -1628,6 +1663,22 @@ fn createAndStoreDocument( }; old_handle.deinit(store.allocator); + if (supports_build_system and options.lsp_synced and isBuildFile(uri) and !isInStd(uri) and isBuildScript(&handle_future.handle.tree)) { + switch (options.load_build_file_behaviour) { + .load_but_dont_update => { + _ = try store.getOrLoadBuildFile(uri, &handle_future.handle); + }, + .only_update => { + if (store.getBuildFile(uri)) |build_file| { + store.invalidateBuildFile(build_file.uri); + } else { + _ = try store.getOrLoadBuildFile(uri, &handle_future.handle); + } + }, + .never => {}, + } + } + handle_future.err = null; return &handle_future.handle; } diff --git a/src/Server.zig b/src/Server.zig index 94e87e9de..fbc12d4b3 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -833,6 +833,15 @@ const Workspace = struct { }; defer args.server.allocator.free(workspace_path); + const build_file_path = try std.Io.Dir.path.resolve(args.server.allocator, &.{ workspace_path, "build.zig" }); + defer args.server.allocator.free(build_file_path); + const build_file_uri: Uri = try .fromPath(args.server.allocator, build_file_path); + defer build_file_uri.deinit(args.server.allocator); + + // Avoid spawning `zig build` for a regular module named `build.zig`. + const build_file_handle = try args.server.document_store.getOrLoadHandle(build_file_uri) orelse return; + if (!DocumentStore.isBuildScript(&build_file_handle.tree)) return; + std.debug.assert(workspace.build_on_save == null); workspace.build_on_save = BuildOnSave.init(.{ .io = args.server.io, From 267f18760b8d0311115e62da9b19fd110dd713fd Mon Sep 17 00:00:00 2001 From: vkurilyak <320287930+vkurilyak@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:56:23 +0200 Subject: [PATCH 2/2] docs: explain build script detection --- src/DocumentStore.zig | 5 +++++ src/Server.zig | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/DocumentStore.zig b/src/DocumentStore.zig index 044a71442..ae11b81e3 100644 --- a/src/DocumentStore.zig +++ b/src/DocumentStore.zig @@ -773,8 +773,10 @@ fn getOrLoadBuildFile( // A filename alone is not sufficient because ordinary modules may be named `build.zig`. if (source_handle) |handle| std.debug.assert(handle.uri.eql(uri)); + // Reuse a build file that was already classified and loaded. if (self.getBuildFile(uri)) |build_file| return build_file; + // Classify the current source before treating its `build.zig` filename as a build script. const handle = source_handle orelse (try self.getOrLoadHandle(uri) orelse return null); if (!isBuildScript(&handle.tree)) return null; @@ -1271,6 +1273,7 @@ pub fn isBuildScript(tree: *const Ast) bool { return false; } +// A module may be named `build.zig` without exposing the `zig build` entry point. test isBuildScript { var build_script = try Ast.parse(std.testing.allocator, \\const std = @import("std"); @@ -1507,6 +1510,7 @@ fn collectPotentialBuildFiles(self: *DocumentStore, uri: Uri) error{ Canceled, O const build_file_uri: Uri = try .fromPath(self.allocator, build_path); defer build_file_uri.deinit(self.allocator); + // A module named `build.zig` cannot provide build configuration without the public entry point. const build_file = try self.getOrLoadBuildFile(build_file_uri, null) orelse continue; potential_build_files.appendAssumeCapacity(build_file); } @@ -1663,6 +1667,7 @@ fn createAndStoreDocument( }; old_handle.deinit(store.allocator); + // Only activate build integration after the refreshed document is known to be a build script. if (supports_build_system and options.lsp_synced and isBuildFile(uri) and !isInStd(uri) and isBuildScript(&handle_future.handle.tree)) { switch (options.load_build_file_behaviour) { .load_but_dont_update => { diff --git a/src/Server.zig b/src/Server.zig index fbc12d4b3..b78429cd7 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -833,8 +833,11 @@ const Workspace = struct { }; defer args.server.allocator.free(workspace_path); + // Build-on-save is scoped to the workspace root, so check its candidate `build.zig`. const build_file_path = try std.Io.Dir.path.resolve(args.server.allocator, &.{ workspace_path, "build.zig" }); defer args.server.allocator.free(build_file_path); + + // DocumentStore works with URIs, so convert the path before reading and classifying it. const build_file_uri: Uri = try .fromPath(args.server.allocator, build_file_path); defer build_file_uri.deinit(args.server.allocator);