From d4f8b0fcde59a5d2009712c7779c98ebc7ec16f8 Mon Sep 17 00:00:00 2001 From: Benoit Giannangeli Date: Tue, 2 Jun 2026 23:17:16 +0200 Subject: [PATCH 01/13] feat: Buzz package Manifest --- src/lib/manifest.buzz | 42 +++++++++++++++++++++++++ src/lib/static_headers.zig | 2 ++ src/lib/static_libraries.zig | 1 + src/main.zig | 1 + src/obj.zig | 33 ++++++++++---------- src/package.zig | 54 ++++++++++++++++++++++++++++++++ tests/manual/manifest.buzz | 60 ++++++++++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 src/lib/manifest.buzz create mode 100644 src/package.zig create mode 100644 tests/manual/manifest.buzz diff --git a/src/lib/manifest.buzz b/src/lib/manifest.buzz new file mode 100644 index 00000000..2845ea3d --- /dev/null +++ b/src/lib/manifest.buzz @@ -0,0 +1,42 @@ +namespace manifest; + +export enum VersionConstraint { + lessThan, + equalOrLessThan, + equalTo, + greaterThan, + equalOrGreater, + majorLessThan, + majorEqualOrLessThan, + majorEqualTo, + majorGreaterThan, + majorEqualOrGreater, + minorLessThan, + minorEqualOrLessThan, + minorEqualTo, + minorGreaterThan, + minorEqualOrGreater, +} + +export object Source { + url: str, + tag: str?, + hash: str?, + version: obj{ :VersionConstraint, :int, :int, :int }?, +} + +export object Manifest { + name: str, + version: obj{ :int, :int, :int }, + rootDir: str, + source: Source, + dependencies: {str: Source} = {}, + devDependencies: {str: Source} = {}, + build: {str: [str]} = {}, + + description: str?, + authors: [str] = [], + tags: [str] = [], + license: str?, + homepage: str?, +} diff --git a/src/lib/static_headers.zig b/src/lib/static_headers.zig index b2c6976a..eea26f08 100644 --- a/src/lib/static_headers.zig +++ b/src/lib/static_headers.zig @@ -17,6 +17,7 @@ pub const fs = Header{ .name = "fs", .path = "fs.buzz" }; pub const gc = Header{ .name = "gc", .path = "gc.buzz" }; pub const http = Header{ .name = "http", .path = "http.buzz" }; pub const io = Header{ .name = "io", .path = "io.buzz" }; +pub const manifest = Header{ .name = "manifest", .path = "manifest.buzz" }; pub const math = Header{ .name = "math", .path = "math.buzz" }; pub const os = Header{ .name = "os", .path = "os.buzz" }; pub const serialize = Header{ .name = "serialize", .path = "serialize.buzz" }; @@ -35,6 +36,7 @@ pub const all = [_]Header{ gc, http, io, + manifest, math, os, serialize, diff --git a/src/lib/static_libraries.zig b/src/lib/static_libraries.zig index c193a563..915a4e3b 100644 --- a/src/lib/static_libraries.zig +++ b/src/lib/static_libraries.zig @@ -25,6 +25,7 @@ pub const all = [_]Library{ .{ .header = static_headers.gc, .zig_path = "buzz_gc.zig", .wasm_native = true }, .{ .header = static_headers.http, .zig_path = "buzz_http.zig", .wasm_native = false }, .{ .header = static_headers.io, .zig_path = "buzz_io.zig", .wasm_native = false }, + .{ .header = static_headers.manifest, .zig_path = null, .wasm_native = false }, .{ .header = static_headers.math, .zig_path = "buzz_math.zig", .wasm_native = true }, .{ .header = static_headers.os, .zig_path = "buzz_os.zig", .wasm_native = false }, .{ .header = static_headers.serialize, .zig_path = "buzz_serialize.zig", .wasm_native = true }, diff --git a/src/main.zig b/src/main.zig index 81ac1c12..cbd80127 100644 --- a/src/main.zig +++ b/src/main.zig @@ -48,6 +48,7 @@ pub fn main(provided_init: Init) u8 { // FIXME: Use process.allocator everywhere? init.gpa = allocator; + // FIXME: everything should become a subcommand except for --line-width and --library const params = comptime clap.parseParamsComptime( \\-h, --help Show help and exit \\-t, --test Run test blocks in provided script diff --git a/src/obj.zig b/src/obj.zig index 500638f3..1a1a02aa 100644 --- a/src/obj.zig +++ b/src/obj.zig @@ -14,6 +14,7 @@ const GC = @import("GC.zig"); const TypeRegistry = @import("TypeRegistry.zig"); const v = @import("value.zig"); const Integer = v.Integer; +const Double = v.Double; const Value = v.Value; const Token = @import("Token.zig"); const is_wasm = builtin.cpu.arch.isWasm(); @@ -1457,22 +1458,6 @@ pub const ObjObjectInstance = struct { try gc.markObjDirty(&self.obj); } - /// Should not be called by runtime when possible - pub fn setFieldByName(self: *Self, gc: *GC, key: *ObjString, value: Value) !void { - const object_def = self.type_def.resolved_type.?.ObjectInstance.resolved_type.?.Object; - const index = std.mem.indexOf( - *ObjString, - object_def.fields.keys(), - key, - ); - - self.setField( - gc, - index, - value, - ); - } - pub fn init( vm: *VM, object: ?*ObjObject, @@ -1531,6 +1516,22 @@ pub const ObjObjectInstance = struct { return false; } + + /// Utility function to read a buzz object from zig, will break if field does not exists or type mismatch on the buzz side + pub fn get(self: *Self, comptime T: type, comptime field_name: []const u8) T { + const idx = self.type_def.resolved_type.?.ObjectInstance + .of.resolved_type.?.Object + .fields.get(field_name).?.index; + const field = self.fields[idx]; + + return switch (type) { + []const u8 => field.obj().cast(ObjString, .String).?.string, + Integer => field.integer(), + Double => field.double(), + bool => field.boolean(), + else => @compileError("Only scalar types are possible"), + }; + } }; /// FFI struct or union diff --git a/src/package.zig b/src/package.zig new file mode 100644 index 00000000..e7abb097 --- /dev/null +++ b/src/package.zig @@ -0,0 +1,54 @@ +const std = @import("std"); +const Runner = @import("Runner.zig"); +const o = @import("obj.zig"); + +const MANIFEST = "manifest.buzz"; + +pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var allocator = arena.allocator(); + + var file = try std.Io.Dir.cwd().openFile(process.io, MANIFEST, .{}); + defer file.close(process.io); + + const raw_source = try allocator.alloc(u8, (try file.stat(process.io)).size); + const source = std.mem.trim(u8, raw_source, " \n\r\t"); + _ = try file.readPositionalAll(process.io, source, 0); + + const manifest_source = std.ArrayList(u8).manifest_source; + // Wrap into a script that will leave the manifest as the last global of the VM + // We type the variable so that if user gives anything else, we get an error + try manifest_source.appendSlice( + allocator, + \\import "manifest" as _; + \\ + \\final manifest: Manifest = + , + ); + try manifest_source.appendSlice(allocator, source); + + if (!std.mem.endsWith(u8, source, ";")) { + try manifest_source.append(allocator, ';'); + } + + var runner: Runner = undefined; + try runner.init( + process, + gpa, // We want the parsed value to outlive this function + .Repl, + null, + null, + ); + + if (try runner.runSource(manifest_source.items, "manifest")) |manifest| { + // Buzz manifest to zig representation + if (manifest.obj().cast(o.ObjObjectInstance, .ObjectInstance)) |instance| { + const name = instance.get([]const u8, "name"); + + std.debug.print("Package name is {s}\n", .{name}); + } + } + + return error.ManifestNotProduced; +} diff --git a/tests/manual/manifest.buzz b/tests/manual/manifest.buzz new file mode 100644 index 00000000..18d3b3fe --- /dev/null +++ b/tests/manual/manifest.buzz @@ -0,0 +1,60 @@ +namespace some; + +import "manifest" as _; + +// A buzz std lib +// import "buzz:std" + +// A dependency (the script might be the root which exports all that the lib want to expose) +// import "pkg:mydep/mydep.buzz" + +// Something from the current codebase +// import "pkg:./something.buzz" +// or +// import "pkg:nameofthecurrentpackage/something.buzz" + +// Look at: https://github.com/luarocks/luarocks/blob/main/docs/rockspec_format.md +export final manifest = Manifest{ + name = "toml", + version = .{1,0,0}, + description = "A toml parser in buzz", + authors = [ + "giann ", + ], + license = "MIT", + homepage = "https://github.com/buzz-language/toml", + tags = [ "toml", "parser" ], + source = .{ + url = "git:https://github.com/buzz-language/toml.git", + tag = "1.0.0", + hash = "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed", + }, + // Root directory of the exposed buzz code + rootDir = "src", + // Tells how to build native code + build = { + // List of cli commands that will be ran in the checked out repo + "parser": [ + "zig build", + "cp zig-out/lib/parser.* src/" + ] + }, + dependencies = { + "tty": .{ + // We won't handle version to begin with because it requires a registry which is a whole problem on itself + version = .{ + .majorEqualOrLessThan, 1, 1, 0, + }, + // tag = "0.1.0", // or use explicit tag + url = "git:https://github.com/buzz-language/tty.git", + hash = "35e10a5e739edd72540c7cf0fa91c8257d128bfe", + }, + }, + devDependencies = { + "doc": .{ + url = "git:https://github.com/buzz-language/docgen.git", + tag = "2.1.0", + hash = "f7f029ecb98abe979074a3ab45b74dbd9af02d42", + } + } +}; From 9803de707b1c620e6dc50817de20255893e3f349 Mon Sep 17 00:00:00 2001 From: Benoit Giannangeli Date: Fri, 5 Jun 2026 11:57:54 +0200 Subject: [PATCH 02/13] fix: Reworked buzz command with subcommands --- AGENTS.md | 8 +- CHANGELOG.md | 5 + src/Package.zig | 342 +++++++++++++++++++++++++++++++++++++++ src/main.zig | 421 +++++++++++++++++++++++++++++++++++++----------- src/package.zig | 54 ------- 5 files changed, 677 insertions(+), 153 deletions(-) create mode 100644 src/Package.zig delete mode 100644 src/package.zig diff --git a/AGENTS.md b/AGENTS.md index dd12ac12..3b8efd2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,13 +80,13 @@ zig build test Run a single Buzz file containing `test` blocks: ```sh -zig build run -- -t tests/behavior/descriptive-name.buzz +zig build run -- test tests/behavior/descriptive-name.buzz ``` Run a regular Buzz script with a `main` function: ```sh -zig build run -- path/to/file.buzz +zig build run -- run path/to/file.buzz ``` Prefer `zig build run` because it builds Buzz and then runs the script. Do not bother setting `BUZZ_PATH` manually. @@ -104,7 +104,7 @@ Only Zig files need formatting checks for now. - If Zig code was touched, run `zig build` before finishing. - For language behavior changes, run `zig build test-behavior`. - For formatter changes, run `zig build test`. -- For focused behavior work, first run the relevant file with `zig build run -- -t `, then run the broader suite when appropriate. +- For focused behavior work, first run the relevant file with `zig build run -- test `, then run the broader suite when appropriate. - Tests are not expected to be flaky or platform-specific. - Supported platforms are macOS and Linux. Do not spend effort on Windows compatibility unless explicitly asked. - Do not test WASM unless the task is specifically WASM-related. @@ -130,7 +130,7 @@ Only Zig files need formatting checks for now. - Any non-trivial code added must be properly commented in the code. Comments should explain intent, invariants, or tricky control flow, not restate obvious assignments. - Any new Zig file under `src/` must start with a file docblock (`//! ...`) describing the general role of the file. - Any new functions, structs, objects, properties, and enums introduced in Zig or Buzz code must have a docblock. -- When modifying of creating a buzz file, always reformat it with `buzz -f` +- When modifying of creating a buzz file, always reformat it with `buzz format` ## Runtime And GC Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 302e0142..53b204e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ This release brings a lot of useful tools to write buzz code: LSP, formatter and ## Changed +- buzz binary now uses subcommands rather than options + - `buzz ` becomes `buzz run ` + - `buzz -t ` becomes `buzz test ` + - `buzz -f ` becomes `buzz format ` + - etc. - Extern libraries now must expose only one function which will be called by the compiler to lookup the functions of the library - `int` are now `i48` instead of `i32` (https://github.com/buzz-language/buzz/issues/306). If you're wondering why, it's because all buzz values live in a NaN boxed f64 and the maximum bits available for an integer in there is 48. However, C ABI does not understand `i48` so we're still stuck with `i32` in FFI for now. - `main` signature can omit `args` argument diff --git a/src/Package.zig b/src/Package.zig new file mode 100644 index 00000000..3c963fec --- /dev/null +++ b/src/Package.zig @@ -0,0 +1,342 @@ +const std = @import("std"); +const Runner = @import("Runner.zig"); +const o = @import("obj.zig"); +const builtin = @import("builtin"); +const is_windows = builtin.os.tag != .windows; +const ln = if (is_windows) @import("linenoise.zig") else void; +const bzio = @import("io.zig"); + +const MANIFEST = "manifest.buzz"; +const input_whitespace = " \n\r\t"; + +pub fn wrapManifest(allocator: std.mem.Allocator, raw_source: []const u8) ![]const u8 { + const source = std.mem.trim(u8, raw_source, " \n\r\t"); + + const manifest_source = std.ArrayList(u8).manifest_source; + // Wrap into a script that will leave the manifest as the last global of the VM + // We type the variable so that if user gives anything else, we get an error + try manifest_source.appendSlice( + allocator, + \\import "manifest" as _; + \\ + \\final manifest: Manifest = + , + ); + try manifest_source.appendSlice(allocator, source); + + if (!std.mem.endsWith(u8, source, ";")) { + try manifest_source.append(allocator, ';'); + } + + return try manifest_source.toOwnedSlice(allocator); +} + +pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator) !void { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var allocator = arena.allocator(); + + var file = try std.Io.Dir.cwd().openFile( + process.io, + MANIFEST, + .{ + .mode = .read_only, + }, + ); + defer file.close(process.io); + + const raw_source = try allocator.alloc(u8, (try file.stat(process.io)).size); + _ = try file.readPositionalAll(process.io, raw_source, 0); + const manifest_source = try wrapManifest(allocator, raw_source); + + var runner: Runner = undefined; + try runner.init( + process, + gpa, // We want the parsed value to outlive this function + .Repl, + null, + null, + ); + + if (try runner.runSource(manifest_source.items, "manifest")) |manifest| { + // Buzz manifest to zig representation + if (manifest.obj().cast(o.ObjObjectInstance, .ObjectInstance)) |instance| { + const name = instance.get([]const u8, "name"); + + std.debug.print("Package name is {s}\n", .{name}); + } + } + + return error.ManifestNotProduced; +} + +/// Init a new buzz package: writes a `manifest.buzz` and a minimal set of example files +pub fn init(process: std.process.Init) !void { + var already_there = true; + std.Io.Dir.cwd().access(process.io, "./" ++ MANIFEST, .{ .read = true }) catch { + already_there = false; + }; + + if (already_there) { + return error.ManifestAlreadyCreated; + } + + const full_cwd = try std.Io.Dir.cwd().realPathFileAlloc( + process.io, + ".", + process.gpa, + ); + defer process.gpa.free(full_cwd); + const cwd = std.Io.Dir.path.basename(full_cwd[0..]); + + var arena = std.heap.ArenaAllocator.init(process.gpa); + defer arena.deinit(); + const allocator = arena.allocator(); + + const package_name = try ask( + process, + allocator, + "package name: ", + cwd, + true, + ); + + const version = try ask( + process, + allocator, + "version: ", + "1.0.0", + true, + ); + + const description = try ask( + process, + allocator, + "description: ", + null, + false, + ); + + const root_dir = try ask( + process, + allocator, + "root directory: ", + "src", + true, + ); + + const git_repo = try ask( + process, + allocator, + "git repository: ", + null, + true, + ); + + const tags = try ask( + process, + allocator, + "tags (comma separated): ", + null, + false, + ); + + const author = try ask( + process, + allocator, + "author: ", + null, + false, + ); + + const license = try ask( + process, + allocator, + "license: ", + "MIT", + false, + ); + + // The manifest is intentionally a bare object: loadManifest wraps it with + // the import and typed assignment needed to validate it as Buzz code. + var version_parts = [_]u32{ 0, 0, 0 }; + var version_it = std.mem.splitScalar(u8, version, '.'); + var version_index: usize = 0; + while (version_it.next()) |part| { + if (version_index == version_parts.len) { + return error.InvalidVersion; + } + + const trimmed_part = std.mem.trim(u8, part, input_whitespace); + if (trimmed_part.len == 0) { + return error.InvalidVersion; + } + + version_parts[version_index] = try std.fmt.parseInt(u32, trimmed_part, 10); + version_index += 1; + } + if (version_index != version_parts.len) { + return error.InvalidVersion; + } + + var package_name_literal = std.ArrayList(u8).empty; + try appendBuzzStringLiteral(allocator, &package_name_literal, package_name); + + var root_dir_literal = std.ArrayList(u8).empty; + try appendBuzzStringLiteral(allocator, &root_dir_literal, root_dir); + + var git_repo_literal = std.ArrayList(u8).empty; + try appendBuzzStringLiteral(allocator, &git_repo_literal, git_repo); + + var description_field = std.ArrayList(u8).empty; + try appendOptionalStringField(allocator, &description_field, "description", description); + + var authors_field = std.ArrayList(u8).empty; + const trimmed_author = std.mem.trim(u8, author, input_whitespace); + if (trimmed_author.len > 0) { + try authors_field.appendSlice(allocator, "authors = [ "); + try appendBuzzStringLiteral(allocator, &authors_field, trimmed_author); + try authors_field.appendSlice(allocator, " ],\n"); + } + + var license_field = std.ArrayList(u8).empty; + try appendOptionalStringField(allocator, &license_field, "license", license); + + var tag_items = std.ArrayList(u8).empty; + var tag_it = std.mem.splitScalar(u8, tags, ','); + while (tag_it.next()) |tag| { + const trimmed_tag = std.mem.trim(u8, tag, input_whitespace); + if (trimmed_tag.len == 0) { + continue; + } + + if (tag_items.items.len > 0) { + try tag_items.appendSlice(allocator, ", "); + } + try appendBuzzStringLiteral(allocator, &tag_items, trimmed_tag); + } + + var tags_field = std.ArrayList(u8).empty; + if (tag_items.items.len > 0) { + try tags_field.appendSlice(allocator, "tags = [ "); + try tags_field.appendSlice(allocator, tag_items.items); + try tags_field.appendSlice(allocator, " ],\n"); + } + + var file = try std.Io.Dir.cwd().createFile(process.io, MANIFEST, .{}); + defer file.close(process.io); + + var writer = file.writer(process.io, &.{}); + + try writer.interface.print( + \\.{{ + \\ name = {s}, + \\ version = .{{ {}, {}, {} }}, + \\{s}{s}{s}{s}{s}{s}{s}{s} source = .{{ + \\ url = {s}, + \\ }}, + \\ rootDir = {s}, + \\}} + , + .{ + package_name_literal.items, + version_parts[0], + version_parts[1], + version_parts[2], + if (description_field.items.len > 0) " " else "", + description_field.items, + if (authors_field.items.len > 0) " " else "", + authors_field.items, + if (license_field.items.len > 0) " " else "", + license_field.items, + if (tags_field.items.len > 0) " " else "", + tags_field.items, + git_repo_literal.items, + root_dir_literal.items, + }, + ); + + var stdout = bzio.stdoutWriter(process.io); + + stdout.interface.print("Buzz package `manifest.buzz` created\n", .{}) catch {}; +} + +/// Appends a complete Buzz string literal when the value can be written unescaped. +fn appendBuzzStringLiteral( + allocator: std.mem.Allocator, + buffer: *std.ArrayList(u8), + value: []const u8, +) !void { + const trimmed_value = std.mem.trim(u8, value, input_whitespace); + + try buffer.append(allocator, '"'); + try buffer.appendSlice(allocator, trimmed_value); + try buffer.append(allocator, '"'); +} + +/// Appends an optional one-line string field without leading indentation. +fn appendOptionalStringField( + allocator: std.mem.Allocator, + buffer: *std.ArrayList(u8), + comptime name: []const u8, + value: []const u8, +) !void { + try buffer.appendSlice(allocator, name ++ " = "); + try appendBuzzStringLiteral(allocator, buffer, value); + try buffer.appendSlice(allocator, ",\n"); +} + +/// Prompts until the response is valid, and until a value is present for required prompts. +fn ask( + process: std.process.Init, + allocator: std.mem.Allocator, + comptime question: []const u8, + default: ?[]const u8, + required: bool, +) ![]const u8 { + var stdin_buffer: [1024]u8 = undefined; + var stdin = bzio.stdinReader(process.io, stdin_buffer[0..]); + var stdin_reader = bzio.AllocatedReader.init( + allocator, + &stdin.interface, + null, + ); + var stdout = bzio.stdoutWriter(process.io); + + while (true) { + try stdout.interface.writeAll(question); + if (default) |d| { + try stdout.interface.print("({s}) ", .{d}); + } + + const answer = if (!is_windows) answer: { + const line = ln.linenoise(question) orelse break :answer null; + defer ln.linenoiseFree(@ptrCast(@constCast(line))); + + break :answer try allocator.dupe(u8, std.mem.span(line)); + } else try stdin_reader.readUntilDelimiterOrEof('\n'); + + const value = if (answer) |a| value: { + const trimmed_answer = std.mem.trim(u8, a, input_whitespace); + if (trimmed_answer.len > 0) { + break :value trimmed_answer; + } + + break :value default orelse ""; + } else default orelse ""; + + if (value.len == 0 and required) { + try stdout.interface.writeAll("A value is required.\n"); + + continue; + } + + if (std.mem.findAny(u8, value, "\\\"\n\r\t") != null) { + try stdout.interface.writeAll("Input cannot contain quotes, backslashes, tabs, or newlines.\n"); + + continue; + } + + return value; + } +} diff --git a/src/main.zig b/src/main.zig index cbd80127..358a9a95 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,6 +24,7 @@ const Renderer = @import("renderer.zig").Renderer; const io = @import("io.zig"); const Runner = @import("Runner.zig"); const Perf = @import("Perf.zig"); +const Package = @import("Package.zig"); pub export const initRepl_export = wasm_repl.initRepl; pub export const runLine_export = wasm_repl.runLine; @@ -33,6 +34,51 @@ pub const os = if (is_wasm) else std.os; +const SubCommand = enum { + @"test", + check, + fetch, + format, + help, + init, + run, + version, +}; + +const main_params = clap.parseParamsComptime( + \\ +); + +const test_params = clap.parseParamsComptime( + \\-L, --library ... Add search path for external libraries + \\ Script to test +); + +const check_params = clap.parseParamsComptime( + \\-L, --library ... Add search path for external libraries + \\ Script to check +); + +const format_params = clap.parseParamsComptime( + \\--line-width Formatter line width (defaults to 80) + \\-L, --library ... Add search path for external libraries + \\ Script to format +); + +const run_params = clap.parseParamsComptime( + \\-L, --library ... Add search path for external libraries + \\ Script to run + \\... Arguments to pass to the script +); + +const help_params = clap.parseParamsComptime( + \\ Command for which you want help +); + +const main_parsers = .{ + .command = clap.parsers.enumeration(SubCommand), +}; + pub fn main(provided_init: Init) u8 { if (is_wasm) unreachable; @@ -48,31 +94,25 @@ pub fn main(provided_init: Init) u8 { // FIXME: Use process.allocator everywhere? init.gpa = allocator; - // FIXME: everything should become a subcommand except for --line-width and --library - const params = comptime clap.parseParamsComptime( - \\-h, --help Show help and exit - \\-t, --test Run test blocks in provided script - \\-f, --fmt Reformat script, output the result to stdout - \\--line-width Formatter line width, only valid with --fmt (defaults to 80) - \\-c, --check Check script for error without running it - \\-v, --version Print version and exit - \\-L, --library ... Add search path for external libraries - \\... Script to run followed by its eventual arguments - \\ - ); - var stderr = io.stderrWriter(init.io); var stdout = io.stdoutWriter(init.io); + var arg_iter = try init.minimal.args.iterateAllocator(init.gpa); + defer arg_iter.deinit(); + + _ = arg_iter.next(); + var diag = clap.Diagnostic{}; - var res = clap.parse( + var res = clap.parseEx( clap.Help, - ¶ms, - clap.parsers.default, - init.minimal.args, + &main_params, + main_parsers, + &arg_iter, .{ .allocator = allocator, .diagnostic = &diag, + // Stop parsing after we read the subcommand + .terminating_positional = 0, }, ) catch |err| { // Report useful error and exit @@ -81,107 +121,298 @@ pub fn main(provided_init: Init) u8 { }; defer res.deinit(); - if (res.args.version == 1) { - _repl.printBanner(&stdout.interface, true); + // No arguments, we run the REPL + if (res.positionals[0]) |command| { + return switch (command) { + .@"test" => run( + init, + allocator, + command, + clap.parseEx( + clap.Help, + &test_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }, + .{}, + ), + .check => run( + init, + allocator, + command, + clap.parseEx( + clap.Help, + &check_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }, + .{}, + ), + .format => { + const sub_res = clap.parseEx( + clap.Help, + &format_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }; - return 0; - } + return run( + init, + allocator, + command, + sub_res, + renderer_options: { + if (sub_res.args.@"line-width") |line_width| { + if (line_width < Renderer.min_line_width) { + stderr.interface.print( + "--line-width must be at least {}\n", + .{Renderer.min_line_width}, + ) catch {}; + return 1; + } - if (res.args.help == 1) { - io.print(init.io, "👨‍🚀 buzz A small/lightweight typed scripting language\n\nUsage: buzz ", .{}); + break :renderer_options .{ .line_width = line_width }; + } else break :renderer_options .{}; + }, + ); + }, + .run => run( + init, + allocator, + command, + clap.parseEx( + clap.Help, + &run_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }, + .{}, + ), + .fetch => 0, + .help => { + const sub_res = clap.parseEx( + clap.Help, + &help_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }; - clap.usage( - &stderr.interface, - clap.Help, - ¶ms, - ) catch return 1; + return help( + init, + &stderr.interface, + sub_res.positionals[0], + ); + }, + .init => { + Package.init(init) catch |err| { + switch (err) { + error.ManifestAlreadyCreated => stderr.interface.print("A `manifest.buzz` file already exists\n", .{}) catch @panic("Could not init buzz package"), + else => stderr.interface.print("Could not initialize buzz package: {s}\n", .{@errorName(err)}) catch @panic("Could not init buzz package"), + } - io.print(init.io, "\n\n", .{}); + return 1; + }; - clap.help( - &stderr.interface, - clap.Help, - ¶ms, - .{ - .description_on_new_line = false, - .description_indent = 4, - .spacing_between_parameters = 0, + return 0; }, - ) catch return 1; + .version => { + _repl.printBanner(&stdout.interface, true); - return 0; + return 0; + }, + }; + } else { + repl(init, allocator) catch { + return 1; + }; } - if (res.args.library.len > 0) { + return 0; +} + +fn run(init: Init, allocator: std.mem.Allocator, command: SubCommand, sub_res: anytype, renderer_options: Renderer.Options) u8 { + var perf: ?Perf = if (BuildOptions.show_perf) Perf.init(init.io) else null; + defer if (perf) |*p| p.report(); + + var runner: Runner = undefined; + runner.init( + init, + allocator, + switch (command) { + .@"test" => .Test, + .check => .Check, + .format => .Fmt, + .run => .Run, + else => unreachable, + }, + null, + if (perf) |*p| p else null, + ) catch { + return 1; + }; + defer runner.deinit(); + runner.renderer_options = renderer_options; + + if (sub_res.args.library.len > 0) { var list = std.ArrayList([]const u8).empty; - for (res.args.library) |path| { + for (sub_res.args.library) |path| { list.append(allocator, path) catch return 1; } Parser.user_library_paths = list.toOwnedSlice(allocator) catch return 1; } - var renderer_options: Renderer.Options = .{}; - if (res.args.@"line-width") |line_width| { - if (res.args.fmt != 1) { - stderr.interface.print("--line-width is only valid with -f/--fmt\n", .{}) catch {}; - return 1; - } + return runner.runFile( + sub_res.positionals[0] orelse &.{}, + if (sub_res.positionals.len > 1) sub_res.positionals[1] else &.{}, + ) catch { + return 1; + }; +} - if (line_width < Renderer.min_line_width) { - stderr.interface.print( - "--line-width must be at least {}\n", - .{Renderer.min_line_width}, - ) catch {}; - return 1; - } +fn help(init: Init, stderr: *std.Io.Writer, subcommand_opt: ?[]const u8) u8 { + io.print(init.io, "👨‍🚀 buzz A small/lightweight typed scripting language\n\nUsage: buzz ", .{}); - renderer_options.line_width = line_width; - } + if (subcommand_opt) |subcommand| { + io.print(init.io, "{s} ", .{subcommand}); - const flavor: RunFlavor = if (res.args.check == 1) - .Check - else if (res.args.@"test" == 1) - .Test - else if (res.args.fmt == 1) - .Fmt - else if (res.positionals[0].len == 0) - .Repl - else - .Run; + if (std.mem.eql(u8, subcommand, "test")) { + clap.usage( + stderr, + clap.Help, + &test_params, + ) catch return 1; - if (!is_wasm and flavor == .Repl) { - repl(init, allocator) catch { - return 1; - }; - } else if (!is_wasm and res.positionals[0].len > 0) { - var perf: ?Perf = if (BuildOptions.show_perf) Perf.init(init.io) else null; - defer if (perf) |*p| p.report(); - - var runner: Runner = undefined; - runner.init( - init, - allocator, - flavor, - null, - if (perf) |*p| p else null, - ) catch { - return 1; - }; - defer runner.deinit(); - runner.renderer_options = renderer_options; + io.print(init.io, "\n\n", .{}); - return runner.runFile( - res.positionals[0][0], - res.positionals[0][1..], - ) catch { - return 1; - }; - } else if (is_wasm) { - io.print(init.io, "NYI wasm repl", .{}); + clap.help( + stderr, + clap.Help, + &test_params, + .{ + .description_on_new_line = false, + .description_indent = 4, + .spacing_between_parameters = 0, + }, + ) catch return 1; + } else if (std.mem.eql(u8, subcommand, "check")) { + clap.usage( + stderr, + clap.Help, + &check_params, + ) catch return 1; + + io.print(init.io, "\n\n", .{}); + + clap.help( + stderr, + clap.Help, + &check_params, + .{ + .description_on_new_line = false, + .description_indent = 4, + .spacing_between_parameters = 0, + }, + ) catch return 1; + } else if (std.mem.eql(u8, subcommand, "format")) { + clap.usage( + stderr, + clap.Help, + &format_params, + ) catch return 1; + + io.print(init.io, "\n\n", .{}); + + clap.help( + stderr, + clap.Help, + &format_params, + .{ + .description_on_new_line = false, + .description_indent = 4, + .spacing_between_parameters = 0, + }, + ) catch return 1; + } else if (std.mem.eql(u8, subcommand, "run")) { + clap.usage( + stderr, + clap.Help, + &run_params, + ) catch return 1; + + io.print(init.io, "\n\n", .{}); + + clap.help( + stderr, + clap.Help, + &run_params, + .{ + .description_on_new_line = false, + .description_indent = 4, + .spacing_between_parameters = 0, + }, + ) catch return 1; + } } else { - io.print(init.io, "Nothing to run", .{}); + clap.usage( + stderr, + clap.Help, + &main_params, + ) catch return 1; + + io.print(init.io, "\n\n", .{}); + + clap.help( + stderr, + clap.Help, + &main_params, + .{ + .description_on_new_line = false, + .description_indent = 4, + .spacing_between_parameters = 0, + }, + ) catch return 1; } return 0; diff --git a/src/package.zig b/src/package.zig deleted file mode 100644 index e7abb097..00000000 --- a/src/package.zig +++ /dev/null @@ -1,54 +0,0 @@ -const std = @import("std"); -const Runner = @import("Runner.zig"); -const o = @import("obj.zig"); - -const MANIFEST = "manifest.buzz"; - -pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator) !void { - var arena = std.heap.ArenaAllocator.init(gpa); - defer arena.deinit(); - var allocator = arena.allocator(); - - var file = try std.Io.Dir.cwd().openFile(process.io, MANIFEST, .{}); - defer file.close(process.io); - - const raw_source = try allocator.alloc(u8, (try file.stat(process.io)).size); - const source = std.mem.trim(u8, raw_source, " \n\r\t"); - _ = try file.readPositionalAll(process.io, source, 0); - - const manifest_source = std.ArrayList(u8).manifest_source; - // Wrap into a script that will leave the manifest as the last global of the VM - // We type the variable so that if user gives anything else, we get an error - try manifest_source.appendSlice( - allocator, - \\import "manifest" as _; - \\ - \\final manifest: Manifest = - , - ); - try manifest_source.appendSlice(allocator, source); - - if (!std.mem.endsWith(u8, source, ";")) { - try manifest_source.append(allocator, ';'); - } - - var runner: Runner = undefined; - try runner.init( - process, - gpa, // We want the parsed value to outlive this function - .Repl, - null, - null, - ); - - if (try runner.runSource(manifest_source.items, "manifest")) |manifest| { - // Buzz manifest to zig representation - if (manifest.obj().cast(o.ObjObjectInstance, .ObjectInstance)) |instance| { - const name = instance.get([]const u8, "name"); - - std.debug.print("Package name is {s}\n", .{name}); - } - } - - return error.ManifestNotProduced; -} From a6c616589509ecc636d0fac0981e8f8cee801504 Mon Sep 17 00:00:00 2001 From: Benoit Giannangeli Date: Fri, 5 Jun 2026 20:10:17 +0200 Subject: [PATCH 03/13] feat(package): LSP understands manifest.buzz --- src/Package.zig | 22 +++-- src/Runner.zig | 18 ++++ src/behavior.zig | 4 +- src/lsp.zig | 133 ++++++++++++++++++++++++++++- src/main.zig | 70 ++++++++++++--- src/obj.zig | 4 +- tests/manual/package/manifest.buzz | 12 +++ 7 files changed, 230 insertions(+), 33 deletions(-) create mode 100644 tests/manual/package/manifest.buzz diff --git a/src/Package.zig b/src/Package.zig index 3c963fec..19f6279f 100644 --- a/src/Package.zig +++ b/src/Package.zig @@ -6,22 +6,18 @@ const is_windows = builtin.os.tag != .windows; const ln = if (is_windows) @import("linenoise.zig") else void; const bzio = @import("io.zig"); -const MANIFEST = "manifest.buzz"; +/// Standard package manifest filename. +pub const MANIFEST = "manifest.buzz"; +const manifest_wrapper_prefix = "import \"manifest\" as _;final manifest: Manifest = "; const input_whitespace = " \n\r\t"; pub fn wrapManifest(allocator: std.mem.Allocator, raw_source: []const u8) ![]const u8 { const source = std.mem.trim(u8, raw_source, " \n\r\t"); - const manifest_source = std.ArrayList(u8).manifest_source; + var manifest_source = std.ArrayList(u8).empty; // Wrap into a script that will leave the manifest as the last global of the VM // We type the variable so that if user gives anything else, we get an error - try manifest_source.appendSlice( - allocator, - \\import "manifest" as _; - \\ - \\final manifest: Manifest = - , - ); + try manifest_source.appendSlice(allocator, manifest_wrapper_prefix); try manifest_source.appendSlice(allocator, source); if (!std.mem.endsWith(u8, source, ";")) { @@ -31,14 +27,14 @@ pub fn wrapManifest(allocator: std.mem.Allocator, raw_source: []const u8) ![]con return try manifest_source.toOwnedSlice(allocator); } -pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator) !void { +pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator, manifest_path: []const u8) !void { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); var allocator = arena.allocator(); var file = try std.Io.Dir.cwd().openFile( process.io, - MANIFEST, + manifest_path, .{ .mode = .read_only, }, @@ -58,12 +54,14 @@ pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator) !void { null, ); - if (try runner.runSource(manifest_source.items, "manifest")) |manifest| { + if (try runner.runManifest(manifest_source, "manifest")) |manifest| { // Buzz manifest to zig representation if (manifest.obj().cast(o.ObjObjectInstance, .ObjectInstance)) |instance| { const name = instance.get([]const u8, "name"); std.debug.print("Package name is {s}\n", .{name}); + + return; } } diff --git a/src/Runner.zig b/src/Runner.zig index 48b512ad..3c8d5806 100644 --- a/src/Runner.zig +++ b/src/Runner.zig @@ -200,6 +200,24 @@ pub fn runSource(self: *Runner, source: []const u8, name: []const u8) !?Value { return null; } +/// Run a manifest.buzz and get the produced manifest object +pub fn runManifest(self: *Runner, source: []const u8, name: []const u8) !?Value { + if (try self.parser.parse(source, null, name)) |ast| { + const ast_slice = ast.slice(); + if (try self.codegen.generate(ast_slice)) |function| { + try self.vm.interpret( + ast_slice, + function, + null, + ); + + return self.vm.globals.items[self.vm.globals_count]; + } + } + + return null; +} + pub fn frameTop(self: *Runner, fiber: *Fiber, frame: *CallFrame) [*]Value { return if (self.vm.currentFrame() == frame) fiber.stack_top diff --git a/src/behavior.zig b/src/behavior.zig index 1c1223a5..86600bdb 100644 --- a/src/behavior.zig +++ b/src/behavior.zig @@ -172,7 +172,7 @@ fn testCompileErrors(process: std.process.Init, allocator: std.mem.Allocator, fa const run_result = try std.process.run(allocator, process.io, .{ .argv = &.{ arg0, - "-t", + "test", file_name.written(), }, }); @@ -233,7 +233,7 @@ fn testFuzzCrashes(process: std.process.Init, allocator: std.mem.Allocator, fail .{ .argv = &.{ arg0, - "-t", + "test", file_name.written(), }, .stdout_limit = .unlimited, diff --git a/src/lsp.zig b/src/lsp.zig index a80749eb..6d948c30 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -10,6 +10,7 @@ const Parser = @import("Parser.zig"); const Reporter = @import("Reporter.zig"); const Scanner = @import("Scanner.zig"); const CodeGen = @import("Codegen.zig"); +const Package = @import("Package.zig"); const Token = @import("Token.zig"); const Renderer = @import("renderer.zig").Renderer; const o = @import("obj.zig"); @@ -62,7 +63,12 @@ const Document = struct { arena: std.heap.ArenaAllocator, process: std.process.Init, + /// Client document text, unchanged by LSP-only parsing wrappers. src: [:0]const u8, + /// Source passed to the parser. Manifest documents may use wrapped text. + parse_src: [:0]const u8, + /// True when the parser source includes an LSP-only manifest wrapper. + is_wrapped_manifest: bool = false, /// Not owned by this struct uri: []const u8, ast: Ast, @@ -126,6 +132,12 @@ const Document = struct { const allocator = arena.allocator(); const owned_src = try allocator.dupeZ(u8, src); + const owned_uri = try allocator.dupe(u8, uri); + const wrap_manifest = try shouldWrapPackageManifest(allocator, owned_uri, owned_src); + const parse_src = if (wrap_manifest) + try allocator.dupeZ(u8, try Package.wrapManifest(allocator, owned_src)) + else + owned_src; var gc = try GC.init(allocator); gc.type_registry = TypeRegistry.init(&gc) catch return error.OutOfMemory; @@ -149,7 +161,6 @@ const Document = struct { false, ); - const owned_uri = try allocator.dupe(u8, uri); const std_lib_script_name = staticScriptNameFromUri(owned_uri); const document_script_name = std_lib_script_name orelse (try localPathFromFileUri(allocator, owned_uri)) orelse @@ -157,7 +168,7 @@ const Document = struct { // If there's parsing error `parse` does not return the AST, but we can still use it however incomplete const ast = (parser.parse( - owned_src, + parse_src, owned_uri, document_script_name, ) catch parser.ast) orelse @@ -178,6 +189,8 @@ const Document = struct { .arena = arena, .process = process, .src = owned_src, + .parse_src = parse_src, + .is_wrapped_manifest = wrap_manifest, .uri = owned_uri, .ast = ast, .errors = errors.items, @@ -206,6 +219,10 @@ const Document = struct { return false; } + if (self.is_wrapped_manifest) { + return token.line != 0; + } + return true; } @@ -257,14 +274,22 @@ const Document = struct { continue; } - try self.completion_labels.put(allocator, name, {}); - const location = locations[node]; const end_location = end_locations[node]; if (location >= ast_slice.tokens.len or end_location >= ast_slice.tokens.len) { continue; } + if (!self.isClientToken(ast_slice.tokens.get(location))) { + continue; + } + + if (tags[node] == .VarDeclaration and + !self.isClientToken(ast_slice.tokens.get(components[node].VarDeclaration.name))) + { + continue; + } + // Document symbols must only describe the document being served. // Imported nodes can share the AST backing lists but belong to // another script. @@ -273,6 +298,8 @@ const Document = struct { continue; } + try self.completion_labels.put(allocator, name, {}); + switch (tags[node]) { .VarDeclaration => { const comp = components[node].VarDeclaration; @@ -1167,6 +1194,10 @@ const Document = struct { try type_def.toStringWithoutUnresolved(&inlay.writer, false); if (suffix) |sx| try inlay.writer.writeAll(sx); + if (!self.document.isClientToken(location)) { + return; + } + try self.document.inlay_hints.append( allocator, .{ @@ -1595,6 +1626,86 @@ test "document owns source text across repeated reloads" { } } +test "document wraps only shallow package manifests" { + const allocator = std.testing.allocator; + + var process_arena = std.heap.ArenaAllocator.init(allocator); + defer process_arena.deinit(); + + var environ_map = try std.process.Environ.createMap(std.testing.environ, allocator); + defer environ_map.deinit(); + + const argv = [_][*:0]const u8{"buzz_lsp_test"}; + const process = std.process.Init{ + .minimal = .{ + .args = .{ .vector = &argv }, + .environ = std.testing.environ, + }, + .arena = &process_arena, + .gpa = allocator, + .io = std.testing.io, + .environ_map = &environ_map, + .preopens = try std.process.Preopens.init(process_arena.allocator()), + }; + + const manifest_source = + \\.{ + \\ name = "test", + \\ version = .{ 1, 0, 0 }, + \\ description = "some test", + \\ authors = [ "me" ], + \\ license = "MIT", + \\ tags = [ "hello", "world hey", "one" ], + \\ source = .{ + \\ url = "somewhere", + \\ }, + \\ rootDir = "src", + \\} + ; + + var manifest_doc = try Document.init( + process, + allocator, + manifest_source, + "file:///tmp/manifest.buzz", + ); + defer manifest_doc.deinit(); + + try std.testing.expectEqualStrings(manifest_source, manifest_doc.src); + try std.testing.expect(manifest_doc.parse_src.ptr != manifest_doc.src.ptr); + try std.testing.expect(std.mem.startsWith( + u8, + manifest_doc.parse_src, + "import \"manifest\" as _;final manifest: Manifest = .{", + )); + try std.testing.expectEqual(@as(usize, 0), manifest_doc.errors.len); + try std.testing.expect(manifest_doc.inlay_hints.items.len > 0); + try std.testing.expect(manifest_doc.completion_labels.get("manifest") == null); + for (manifest_doc.symbols.items) |symbol| { + try std.testing.expect(!std.mem.eql(u8, symbol.name, "manifest")); + } + + var non_manifest_doc = try Document.init( + process, + allocator, + manifest_source, + "file:///tmp/not-manifest.buzz", + ); + defer non_manifest_doc.deinit(); + + try std.testing.expect(non_manifest_doc.parse_src.ptr == non_manifest_doc.src.ptr); + + var non_object_manifest_doc = try Document.init( + process, + allocator, + "final value = 1;\n", + "file:///tmp/manifest.buzz", + ); + defer non_object_manifest_doc.deinit(); + + try std.testing.expect(non_object_manifest_doc.parse_src.ptr == non_object_manifest_doc.src.ptr); +} + extern fn getpid() std.os.linux.pid_t; pub fn main(init: std.process.Init) !void { @@ -2560,6 +2671,20 @@ fn tokenToRange(ast: Ast.Slice, location: Ast.TokenIndex, end_location: Ast.Toke }; } +/// Returns true when an opened document should be parsed through the package manifest wrapper. +fn shouldWrapPackageManifest(allocator: std.mem.Allocator, uri: []const u8, source: []const u8) !bool { + const file_name = if (try localPathFromFileUri(allocator, uri)) |path| + std.fs.path.basename(path) + else + std.fs.path.basename(uri); + + if (!std.mem.eql(u8, file_name, Package.MANIFEST)) { + return false; + } + + return std.mem.startsWith(u8, std.mem.trim(u8, source, " \n\r\t"), ".{"); +} + /// Converts a local `file://` document URI into the script path used by parser semantics. /// For example, `file:///tmp/project/main.buzz` becomes `/tmp/project/main.buzz`. fn localPathFromFileUri(allocator: std.mem.Allocator, uri: []const u8) !?[]const u8 { diff --git a/src/main.zig b/src/main.zig index 358a9a95..2de278d6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -75,6 +75,10 @@ const help_params = clap.parseParamsComptime( \\ Command for which you want help ); +const fetch_params = clap.parseParamsComptime( + \\-m, --manifest Path to manifest file (defaults to `./manifest.zig`) +); + const main_parsers = .{ .command = clap.parsers.enumeration(SubCommand), }; @@ -220,7 +224,41 @@ pub fn main(provided_init: Init) u8 { }, .{}, ), - .fetch => 0, + .fetch => { + const sub_res = clap.parseEx( + clap.Help, + &fetch_params, + clap.parsers.default, + &arg_iter, + .{ + .allocator = allocator, + .diagnostic = &diag, + }, + ) catch |err| { + // Report useful error and exit + diag.report(&stderr.interface, err) catch {}; + return 1; + }; + + const manifest_path = sub_res.args.manifest orelse ("./" ++ Package.MANIFEST); + + Package.loadManifest( + init, + allocator, + manifest_path, + ) catch |err| { + stderr.interface.print( + "Could not load manifest at `{s}`: {s}\n", + .{ + manifest_path, + @errorName(err), + }, + ) catch @panic("Could not load manifest"); + return 1; + }; + + return 0; + }, .help => { const sub_res = clap.parseEx( clap.Help, @@ -243,18 +281,7 @@ pub fn main(provided_init: Init) u8 { sub_res.positionals[0], ); }, - .init => { - Package.init(init) catch |err| { - switch (err) { - error.ManifestAlreadyCreated => stderr.interface.print("A `manifest.buzz` file already exists\n", .{}) catch @panic("Could not init buzz package"), - else => stderr.interface.print("Could not initialize buzz package: {s}\n", .{@errorName(err)}) catch @panic("Could not init buzz package"), - } - - return 1; - }; - - return 0; - }, + .init => return initPackage(init), .version => { _repl.printBanner(&stdout.interface, true); @@ -270,6 +297,23 @@ pub fn main(provided_init: Init) u8 { return 0; } +fn initPackage(init: Init) u8 { + var stderr = io.stderrWriter(init.io); + + Package.init(init) catch |err| { + switch (err) { + error.ManifestAlreadyCreated => stderr.interface.print("A `manifest.buzz` file already exists\n", .{}) catch + @panic("Could not init buzz package"), + else => stderr.interface.print("Could not initialize buzz package: {s}\n", .{@errorName(err)}) catch + @panic("Could not init buzz package"), + } + + return 1; + }; + + return 0; +} + fn run(init: Init, allocator: std.mem.Allocator, command: SubCommand, sub_res: anytype, renderer_options: Renderer.Options) u8 { var perf: ?Perf = if (BuildOptions.show_perf) Perf.init(init.io) else null; defer if (perf) |*p| p.report(); diff --git a/src/obj.zig b/src/obj.zig index 1a1a02aa..127b44ab 100644 --- a/src/obj.zig +++ b/src/obj.zig @@ -1524,12 +1524,12 @@ pub const ObjObjectInstance = struct { .fields.get(field_name).?.index; const field = self.fields[idx]; - return switch (type) { + return switch (T) { []const u8 => field.obj().cast(ObjString, .String).?.string, Integer => field.integer(), Double => field.double(), bool => field.boolean(), - else => @compileError("Only scalar types are possible"), + else => @compileError("Only scalar types are possible, got " ++ @typeName(T)), }; } }; diff --git a/tests/manual/package/manifest.buzz b/tests/manual/package/manifest.buzz new file mode 100644 index 00000000..e427c6f9 --- /dev/null +++ b/tests/manual/package/manifest.buzz @@ -0,0 +1,12 @@ +.{ + name = "test", + version = .{ 1, 0, 0 }, + description = "some test", + authors = [ "me" ], + license = "MIT", + tags = [ "hello", "world hey", "one" ], + source = .{ + url = "somewhere", + }, + rootDir = "src", +} \ No newline at end of file From 89d3428bbe3699732d334d882485ebdcf2f10865 Mon Sep 17 00:00:00 2001 From: Benoit Giannangeli Date: Sat, 6 Jun 2026 10:06:55 +0200 Subject: [PATCH 04/13] feat(package): buzz init creates basic buzz files --- package.md | 16 +++ src/Package.zig | 314 +++++++++++++++++++++++++++++++++--------- src/Parser.zig | 32 ++++- src/lib/manifest.buzz | 1 - src/main.zig | 2 +- src/obj.zig | 26 +++- 6 files changed, 315 insertions(+), 76 deletions(-) create mode 100644 package.md diff --git a/package.md b/package.md new file mode 100644 index 00000000..c85139f5 --- /dev/null +++ b/package.md @@ -0,0 +1,16 @@ +# Buzz package manager + +- Manifest tells buzz how to fetch the package +- Package are downloaded into `vendors/` +- Package should always have a `src` directory (so i guess we remove `rootDir`) from which import path for it are resolved +- `pkg:mypackage/some/dir/source.buzz` resolves then to `venors/mypackage/src/some/dir/source.buzz` +- buzz std lib is resolved with `buzz:` +- To not make the current package something special we could just make a symlink into vendor? +- [!] don't forget to not @embedFile all buzz std lib. Actuall we should only @embed for wasm and read files directly from BUZZ_PATH/lib/*.buzz on other builds + +The real question is: do we assume we run a buzz package/program from its package dir or from anywhere? + +-> either we're in the project directory +-> or we're running from something like /usr/share/buzz/ which contains /vendors etc. + +=> We must distinguish between cwd and root director from which we resolve buzz scripts diff --git a/src/Package.zig b/src/Package.zig index 19f6279f..3167cce4 100644 --- a/src/Package.zig +++ b/src/Package.zig @@ -5,12 +5,155 @@ const builtin = @import("builtin"); const is_windows = builtin.os.tag != .windows; const ln = if (is_windows) @import("linenoise.zig") else void; const bzio = @import("io.zig"); +const v = @import("value.zig"); /// Standard package manifest filename. pub const MANIFEST = "manifest.buzz"; const manifest_wrapper_prefix = "import \"manifest\" as _;final manifest: Manifest = "; const input_whitespace = " \n\r\t"; +/// Must match src/lib/manifest.buzz +pub const Manifest = struct { + name: []const u8, + version: std.SemanticVersion, + source: Source, + dependencies: std.StringHashMapUnmanaged(Source) = .empty, + dev_dependencies: std.StringHashMapUnmanaged(Source) = .empty, + build: std.StringHashMapUnmanaged([]const []const u8) = .empty, + description: ?[]const u8 = null, + authors: [][]const u8 = &.{}, + tags: [][]const u8 = &.{}, + license: ?[]const u8 = null, + homepage: ?[]const u8 = null, + + pub fn fromValue(value: v.Value, allocator: std.mem.Allocator) !Manifest { + const instance = value.obj().cast(o.ObjObjectInstance, .ObjectInstance).?; + const version = instance.getFieldValue("version").obj().cast(o.ObjObjectInstance, .ObjectInstance).?; + + const dependencies = instance.getFieldValue("dependencies").obj().cast(o.ObjMap, .Map).?; + var dep_list = std.StringHashMapUnmanaged(Source).empty; + var it = dependencies.map.iterator(); + while (it.next()) |entry| { + try dep_list.put( + allocator, + entry.key_ptr.*.obj().cast(o.ObjString, .String).?.string, + .fromValue(entry.value_ptr.*), + ); + } + + const dev_dependencies = instance.getFieldValue("devDependencies").obj().cast(o.ObjMap, .Map).?; + var dev_dep_list = std.StringHashMapUnmanaged(Source).empty; + it = dev_dependencies.map.iterator(); + while (it.next()) |entry| { + try dev_dep_list.put( + allocator, + entry.key_ptr.*.obj().cast(o.ObjString, .String).?.string, + .fromValue(entry.value_ptr.*), + ); + } + + var build = instance.getFieldValue("build").obj().cast(o.ObjMap, .Map).?; + var build_map = std.StringHashMapUnmanaged([]const []const u8).empty; + it = build.map.iterator(); + while (it.next()) |entry| { + const cmds = entry.value_ptr.*.obj().cast(o.ObjList, .List).?; + + var cmd_list = std.ArrayList([]const u8).empty; + for (cmds.items.items) |item| { + try cmd_list.append( + allocator, + item.obj().cast(o.ObjString, .String).?.string, + ); + } + + try build_map.put( + allocator, + entry.key_ptr.*.obj().cast(o.ObjString, .String).?.string, + try cmd_list.toOwnedSlice(allocator), + ); + } + + const authors = instance.getFieldValue("authors").obj().cast(o.ObjList, .List).?; + var author_list = std.ArrayList([]const u8).empty; + for (authors.items.items) |author| { + try author_list.append( + allocator, + author.obj().cast(o.ObjString, .String).?.string, + ); + } + + const tags = instance.getFieldValue("tags").obj().cast(o.ObjList, .List).?; + var tag_list = std.ArrayList([]const u8).empty; + for (tags.items.items) |tag| { + try tag_list.append( + allocator, + tag.obj().cast(o.ObjString, .String).?.string, + ); + } + + return .{ + .name = instance.get([]const u8, "name"), + .description = instance.get([]const u8, "description"), + .license = instance.get([]const u8, "license"), + .homepage = instance.get([]const u8, "homepage"), + .authors = try author_list.toOwnedSlice(allocator), + .tags = try tag_list.toOwnedSlice(allocator), + .version = .{ + .major = @intCast(version.get(v.Integer, comptime "0")), + .minor = @intCast(version.get(v.Integer, comptime "1")), + .patch = @intCast(version.get(v.Integer, comptime "2")), + }, + .source = .fromValue(instance.getFieldValue("source")), + .dependencies = dep_list, + .dev_dependencies = dev_dep_list, + .build = build_map, + }; + } + + pub const Source = struct { + url: []const u8, + tag: ?[]const u8, + hash: ?[]const u8 = null, + version: std.SemanticVersion, + constraint: Constraint = .equalTo, + + pub fn fromValue(value: v.Value) Source { + const instance = value.obj().cast(o.ObjObjectInstance, .ObjectInstance).?; + const version = instance.getFieldValue("version").obj().cast(o.ObjObjectInstance, .ObjectInstance).?; + + return .{ + .url = instance.get([]const u8, "url"), + .tag = instance.get(?[]const u8, "tag"), + .hash = instance.get(?[]const u8, "hash"), + .version = .{ + .major = @intCast(version.get(v.Integer, comptime "1")), + .minor = @intCast(version.get(v.Integer, comptime "2")), + .patch = @intCast(version.get(v.Integer, comptime "3")), + }, + .constraint = @enumFromInt(@as(u8, @intCast(version.get(v.Integer, "0")))), + }; + } + + pub const Constraint = enum(u8) { + lessThan, + equalOrLessThan, + equalTo, + greaterThan, + equalOrGreater, + majorLessThan, + majorEqualOrLessThan, + majorEqualTo, + majorGreaterThan, + majorEqualOrGreater, + minorLessThan, + minorEqualOrLessThan, + minorEqualTo, + minorGreaterThan, + minorEqualOrGreater, + }; + }; +}; + pub fn wrapManifest(allocator: std.mem.Allocator, raw_source: []const u8) ![]const u8 { const source = std.mem.trim(u8, raw_source, " \n\r\t"); @@ -27,7 +170,7 @@ pub fn wrapManifest(allocator: std.mem.Allocator, raw_source: []const u8) ![]con return try manifest_source.toOwnedSlice(allocator); } -pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator, manifest_path: []const u8) !void { +pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator, manifest_path: []const u8) !Manifest { var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); var allocator = arena.allocator(); @@ -55,14 +198,7 @@ pub fn loadManifest(process: std.process.Init, gpa: std.mem.Allocator, manifest_ ); if (try runner.runManifest(manifest_source, "manifest")) |manifest| { - // Buzz manifest to zig representation - if (manifest.obj().cast(o.ObjObjectInstance, .ObjectInstance)) |instance| { - const name = instance.get([]const u8, "name"); - - std.debug.print("Package name is {s}\n", .{name}); - - return; - } + return try .fromValue(manifest, allocator); } return error.ManifestNotProduced; @@ -115,14 +251,6 @@ pub fn init(process: std.process.Init) !void { false, ); - const root_dir = try ask( - process, - allocator, - "root directory: ", - "src", - true, - ); - const git_repo = try ask( process, allocator, @@ -177,28 +305,59 @@ pub fn init(process: std.process.Init) !void { return error.InvalidVersion; } - var package_name_literal = std.ArrayList(u8).empty; - try appendBuzzStringLiteral(allocator, &package_name_literal, package_name); + var file = try std.Io.Dir.cwd().createFile(process.io, MANIFEST, .{}); + defer file.close(process.io); - var root_dir_literal = std.ArrayList(u8).empty; - try appendBuzzStringLiteral(allocator, &root_dir_literal, root_dir); + var writer = file.writer(process.io, &.{}); - var git_repo_literal = std.ArrayList(u8).empty; - try appendBuzzStringLiteral(allocator, &git_repo_literal, git_repo); + try writer.interface.print( + \\.{{ + \\ name = "{s}", + \\ version = .{{ {}, {}, {} }}, + \\ source = .{{ url = "{s}" }}, + \\ + , + .{ + package_name, + version_parts[0], + version_parts[1], + version_parts[2], + git_repo, + }, + ); - var description_field = std.ArrayList(u8).empty; - try appendOptionalStringField(allocator, &description_field, "description", description); + if (description.len > 0) { + try writer.interface.print( + \\ description = "{s}" + \\ + , + .{ + description, + }, + ); + } - var authors_field = std.ArrayList(u8).empty; - const trimmed_author = std.mem.trim(u8, author, input_whitespace); - if (trimmed_author.len > 0) { - try authors_field.appendSlice(allocator, "authors = [ "); - try appendBuzzStringLiteral(allocator, &authors_field, trimmed_author); - try authors_field.appendSlice(allocator, " ],\n"); + if (author.len > 0) { + try writer.interface.print( + \\ authors = [ "{s}" ], + \\ + , + .{ + author, + }, + ); } - var license_field = std.ArrayList(u8).empty; - try appendOptionalStringField(allocator, &license_field, "license", license); + if (license.len > 0) { + try writer.interface.print( + \\ licenses = "{s}", + \\ + , + .{ + license, + }, + ); + } var tag_items = std.ArrayList(u8).empty; var tag_it = std.mem.splitScalar(u8, tags, ','); @@ -211,52 +370,77 @@ pub fn init(process: std.process.Init) !void { if (tag_items.items.len > 0) { try tag_items.appendSlice(allocator, ", "); } - try appendBuzzStringLiteral(allocator, &tag_items, trimmed_tag); + try tag_items.append(allocator, '"'); + try tag_items.appendSlice(allocator, trimmed_tag); + try tag_items.append(allocator, '"'); } - var tags_field = std.ArrayList(u8).empty; if (tag_items.items.len > 0) { - try tags_field.appendSlice(allocator, "tags = [ "); - try tags_field.appendSlice(allocator, tag_items.items); - try tags_field.appendSlice(allocator, " ],\n"); + try writer.interface.print( + \\tags = [ {s} ] + \\ + , + .{ + tag_items.items, + }, + ); } - var file = try std.Io.Dir.cwd().createFile(process.io, MANIFEST, .{}); - defer file.close(process.io); + try writer.interface.print("}}\n", .{}); - var writer = file.writer(process.io, &.{}); + var stdout = bzio.stdoutWriter(process.io); + + stdout.interface.print("Buzz package `manifest.buzz` created\n", .{}) catch {}; + + // Now we create basic files to get the developer started + + var lib_name = std.ArrayList(u8).empty; + try lib_name.appendSlice(allocator, package_name); + try lib_name.appendSlice(allocator, ".buzz"); + + // Create root dir + try std.Io.Dir.cwd().createDir(process.io, "src", .default_dir); + const src_dir = try std.Io.Dir.cwd().openDir(process.io, "src", .{}); + + // Write .buzz, a simple library + var lib_buzz = try src_dir.createFile(process.io, lib_name.items, .{}); + defer lib_buzz.close(process.io); + + writer = lib_buzz.writer(process.io, &.{}); try writer.interface.print( - \\.{{ - \\ name = {s}, - \\ version = .{{ {}, {}, {} }}, - \\{s}{s}{s}{s}{s}{s}{s}{s} source = .{{ - \\ url = {s}, - \\ }}, - \\ rootDir = {s}, - \\}} + \\namespace {s}; + \\ + \\import "buzz:std"; + \\ + \\export fun helloWorld(name: str) => std\print("Hello {{name}}"); + \\ , .{ - package_name_literal.items, - version_parts[0], - version_parts[1], - version_parts[2], - if (description_field.items.len > 0) " " else "", - description_field.items, - if (authors_field.items.len > 0) " " else "", - authors_field.items, - if (license_field.items.len > 0) " " else "", - license_field.items, - if (tags_field.items.len > 0) " " else "", - tags_field.items, - git_repo_literal.items, - root_dir_literal.items, + package_name, }, ); - var stdout = bzio.stdoutWriter(process.io); + // Write main.buzz that uses this library + var main_buzz = try src_dir.createFile(process.io, "main.buzz", .{}); + defer main_buzz.close(process.io); - stdout.interface.print("Buzz package `manifest.buzz` created\n", .{}) catch {}; + writer = main_buzz.writer(process.io, &.{}); + + try writer.interface.print( + \\import "pkg:{s}/{s}" + \\ + \\fun main(args: [str]) > int {{ + \\ {s}\helloWorld(args[?0] ?? "nobody"); + \\ return 0; + \\}} + , + .{ + package_name, + lib_name.items, + package_name, + }, + ); } /// Appends a complete Buzz string literal when the value can be written unescaped. diff --git a/src/Parser.zig b/src/Parser.zig index 31601ce5..c968f8d0 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -9184,12 +9184,36 @@ fn searchZdefLibPaths(self: *Self, file_name: []const u8) ![][]const u8 { return try paths.toOwnedSlice(self.gc.allocator); } -fn readStaticScript(self: *Self, file_name: []const u8) ?[2][]const u8 { +pub const Import = struct { + path: []const u8, + source: []const u8, +}; + +/// Resolve a import path to a actual file path and read it +pub fn resolveImportPath(self: *Self, path: []const u8) ?Import { + if (std.mem.startsWith(u8, path, "buzz:")) { + // A std lib + return self.readStaticScript(path["buzz:".len..]); + } else if (std.mem.startsWith(u8, path, "pkg:")) { + // A package path + const unprefixed = path["pkg:".len..]; + + if (std.mem.indexOf(u8, unprefixed, "/")) |first_slash| { + const pkg_name = unprefixed[0..first_slash]; + _ = unprefixed[pkg_name.len + 1 ..]; + } + } + + // A relative path to cwd +} + +fn readStaticScript(self: *Self, file_name: []const u8) ?Import { inline for (static_libraries.all) |library| { if (std.mem.eql(u8, file_name, library.header.name)) { - return [_][]const u8{ - @embedFile("lib/" ++ library.header.path), - library.header.name, + return .{ + // FIXME: on non wasm, read the file instead of @embedFile + .source = @embedFile("lib/" ++ library.header.path), + .path = library.header.name, }; } } diff --git a/src/lib/manifest.buzz b/src/lib/manifest.buzz index 2845ea3d..98451b5d 100644 --- a/src/lib/manifest.buzz +++ b/src/lib/manifest.buzz @@ -28,7 +28,6 @@ export object Source { export object Manifest { name: str, version: obj{ :int, :int, :int }, - rootDir: str, source: Source, dependencies: {str: Source} = {}, devDependencies: {str: Source} = {}, diff --git a/src/main.zig b/src/main.zig index 2de278d6..0b41a443 100644 --- a/src/main.zig +++ b/src/main.zig @@ -242,7 +242,7 @@ pub fn main(provided_init: Init) u8 { const manifest_path = sub_res.args.manifest orelse ("./" ++ Package.MANIFEST); - Package.loadManifest( + _ = Package.loadManifest( init, allocator, manifest_path, diff --git a/src/obj.zig b/src/obj.zig index 127b44ab..555ad947 100644 --- a/src/obj.zig +++ b/src/obj.zig @@ -1517,16 +1517,32 @@ pub const ObjObjectInstance = struct { return false; } - /// Utility function to read a buzz object from zig, will break if field does not exists or type mismatch on the buzz side - pub fn get(self: *Self, comptime T: type, comptime field_name: []const u8) T { + pub fn getFieldValue(self: *Self, comptime field_name: []const u8) Value { const idx = self.type_def.resolved_type.?.ObjectInstance .of.resolved_type.?.Object .fields.get(field_name).?.index; - const field = self.fields[idx]; - return switch (T) { + return self.fields[idx]; + } + + /// Utility function to read a buzz object from zig, will break if field does not exists or type mismatch on the buzz side + pub fn get(self: *Self, comptime T: type, comptime field_name: []const u8) T { + const field = self.getFieldValue(field_name); + const is_optional = @typeInfo(T) == .optional; + + if (is_optional and field.isNull()) return null; + + const UT = if (is_optional) + @typeInfo(T).optional.child + else + T; + + return switch (UT) { []const u8 => field.obj().cast(ObjString, .String).?.string, - Integer => field.integer(), + Integer => if (field.isObj()) + field.obj().cast(ObjEnumInstance, .EnumInstance).?.case + else + field.integer(), Double => field.double(), bool => field.boolean(), else => @compileError("Only scalar types are possible, got " ++ @typeName(T)), From dad4f8ff04299a05a7f42bc087946e06802fe535 Mon Sep 17 00:00:00 2001 From: Benoit Giannangeli Date: Mon, 8 Jun 2026 11:23:32 +0200 Subject: [PATCH 05/13] feat(package): Predictable import file lookup Instead on searching imported by looking up files within a search list, user must define more precisely where the script might be: - `buzz: