diff --git a/src/apprt/gtk/build/blueprint.zig b/src/apprt/gtk/build/blueprint.zig index fb3f6ae23..5ad0246df 100644 --- a/src/apprt/gtk/build/blueprint.zig +++ b/src/apprt/gtk/build/blueprint.zig @@ -1,9 +1,14 @@ -//! Compiles a blueprint file using `blueprint-compiler`. This performs -//! additional checks to ensure that various minimum versions are met. +//! Checks that `libadwaita` is at least the given version and that +//! `blueprint-compiler` is on the PATH and new enough. The blueprints +//! themselves are compiled by `blueprint-compiler` directly from the build +//! system; see `gtkNgDistResources` in `src/build/SharedDeps.zig`. //! -//! Usage: blueprint.zig +//! Usage: blueprint.zig //! -//! Example: blueprint.zig 1 5 output.ui input.blp +//! Example: blueprint.zig 1 5 blueprint-check.stamp +//! +//! `` is written when every check passes, so the build system has an +//! output to cache this step by. const std = @import("std"); const adw_c = @import("adw_c"); @@ -45,8 +50,7 @@ pub fn main(init: std.process.Init) !void { _ = it.next(); // Skip argv0 const arg_major = it.next() orelse return error.NoMajorVersion; const arg_minor = it.next() orelse return error.NoMinorVersion; - const output = it.next() orelse return error.NoOutput; - const input = it.next() orelse return error.NoInput; + const stamp = it.next() orelse return error.NoStamp; const required_adwaita_version = std.SemanticVersion{ .major = try std.fmt.parseUnsigned(u8, arg_major, 10), @@ -58,7 +62,7 @@ pub fn main(init: std.process.Init) !void { \\`libadwaita` is too old. \\ \\Ghostty requires a version {f} or newer of `libadwaita` to - \\compile this blueprint. Please install it, ensure that it is + \\compile its blueprints. Please install it, ensure that it is \\available on your PATH, and then retry building Ghostty. , .{required_adwaita_version}); std.process.exit(1); @@ -104,43 +108,7 @@ pub fn main(init: std.process.Init) !void { } } - // Compilation - { - const blueprint_compiler = std.process.run(alloc, init.io, .{ - .argv = &.{ - "blueprint-compiler", - "compile", - "--output", - output, - input, - }, - }) catch |err| switch (err) { - error.FileNotFound => { - std.debug.print( - \\`blueprint-compiler` not found. - ++ blueprint_compiler_help, - .{required_blueprint_version}, - ); - std.process.exit(1); - }, - else => return err, - }; - defer { - alloc.free(blueprint_compiler.stdout); - alloc.free(blueprint_compiler.stderr); - } - - switch (blueprint_compiler.term) { - .exited => |rc| { - if (rc != 0) { - std.debug.print("{s}", .{blueprint_compiler.stderr}); - std.process.exit(1); - } - }, - else => { - std.debug.print("{s}", .{blueprint_compiler.stderr}); - std.process.exit(1); - }, - } - } + // Everything passed. + const file = try std.Io.Dir.cwd().createFile(init.io, stamp, .{}); + file.close(init.io); } diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index 57c32a353..20b3c6f45 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -5,7 +5,6 @@ //! Litmus test: `src/apprt/gtk` should exist relative to the pwd. const std = @import("std"); -const Allocator = std.mem.Allocator; const build_info = @import("info.zig"); @@ -123,25 +122,6 @@ pub fn blueprint(comptime bp: Blueprint) [:0]const u8 { } pub fn main(init: std.process.Init) !void { - const alloc = init.arena.allocator(); - - // Collect the UI files that are passed in as arguments. - var ui_files: std.ArrayList([]const u8) = .empty; - defer { - for (ui_files.items) |item| alloc.free(item); - ui_files.deinit(alloc); - } - - var it = try init.minimal.args.iterateAllocator(alloc); - defer it.deinit(); - while (it.next()) |arg| { - if (!std.mem.endsWith(u8, arg, ".ui")) continue; - try ui_files.append( - alloc, - try alloc.dupe(u8, arg), - ); - } - var buf: [4096]u8 = undefined; var stdout = std.Io.File.stdout().writer(init.io, &buf); const writer = &stdout.interface; @@ -153,7 +133,7 @@ pub fn main(init: std.process.Init) !void { try genRoot(init.io, writer); try genIcons(init.io, writer); - try genUi(alloc, writer, &ui_files); + try genUi(init.io, writer); try writer.writeAll( \\ @@ -235,38 +215,44 @@ fn genRoot(io: std.Io, writer: *std.Io.Writer) !void { ); } -/// Generate all the UI resources. This works by looking up all the -/// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and -/// assuming these will be -fn genUi( - alloc: Allocator, - writer: *std.Io.Writer, - files: *const std.ArrayList([]const u8), -) !void { +/// Generate all the UI resources. +/// +/// Each file is written as a *relative* path, `{major}.{minor}/{name}.ui`, +/// which `glib-compile-resources` resolves against a `--sourcedir`. An +/// absolute path into the Zig cache would change whenever the step that +/// produced the `.ui` did, and this XML with it. +/// +/// The compiled `.ui` files are not inputs here for the same reason. We +/// check that the blueprint *sources* exist instead, as `genRoot` and +/// `genIcons` do for the CSS and icons. +fn genUi(io: std.Io, writer: *std.Io.Writer) !void { + // Two `comptimePrint` calls per blueprint exceed the default quota. + @setEvalBranchQuota(100_000); + try writer.print( \\ \\ , .{build_info.resource_path}); - for (files.items) |ui_file| { - for (blueprints) |bp| { - const expected = try std.fmt.allocPrint( - alloc, - "/{d}.{d}/{s}.ui", - .{ bp.major, bp.minor, bp.name }, - ); - defer alloc.free(expected); - if (!std.mem.endsWith(u8, ui_file, expected)) continue; - try writer.print( - " {s}\n", - .{ bp.major, bp.minor, bp.name, ui_file }, - ); - break; - } else { - // The for loop never broke which means it didn't find - // a matching blueprint for this input. - return error.BlueprintNotFound; - } + const cwd: std.Io.Dir = .cwd(); + inline for (blueprints) |bp| { + const source = std.fmt.comptimePrint("{s}/{d}.{d}/{s}.blp", .{ + ui_path, + bp.major, + bp.minor, + bp.name, + }); + try cwd.access(io, source, .{}); + + const alias = std.fmt.comptimePrint("{d}.{d}/{s}.ui", .{ + bp.major, + bp.minor, + bp.name, + }); + try writer.print( + \\ {s} + \\ + , .{ alias, alias }); } try writer.writeAll( diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 42f09eeb3..f6d9c119a 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -983,14 +983,121 @@ pub fn addSimd( } } -/// Creates the resources that can be prebuilt for our dist build. -pub fn gtkNgDistResources( - b: *std.Build, -) struct { +pub const GtkNgResources = struct { resources_c: DistResource, resources_h: DistResource, -} { +}; + +/// Memoized result of `gtkNgDistResources`, keyed on the `*std.Build`. +/// The configure pass is single-threaded, so a file-scope map is enough. +var gtk_ng_resources: std.AutoHashMapUnmanaged(*std.Build, GtkNgResources) = .empty; + +/// Creates the resources that can be prebuilt for our dist build. +/// +/// Memoized because `add` calls this once per artifact that links GTK and +/// `GhosttyDist` calls it too. Each call used to build its own copy of the +/// whole pipeline, and since Zig's cache hashes input *paths* as well as +/// contents, the copies did not share results downstream. +pub fn gtkNgDistResources(b: *std.Build) GtkNgResources { + if (gtk_ng_resources.get(b)) |cached| return cached; + const resources = gtkNgDistResourcesUncached(b); + gtk_ng_resources.put(b.allocator, b, resources) catch @panic("OOM"); + return resources; +} + +fn gtkNgDistResourcesUncached(b: *std.Build) GtkNgResources { const gresource = @import("../apprt/gtk/build/gresource.zig"); + const gresource_file_inputs = gresource.file_inputs; + + // Compile every blueprint into one directory laid out as + // `{major}.{minor}/{name}.ui`, so that `glib-compile-resources` gets a + // single `--sourcedir` and the gresource XML needs no absolute paths. + // + // `blueprint-compiler` is run directly, not through a compiled wrapper. + // A run step hashes the bytes of the executable it runs, and a Zig + // binary does not relink to the same bytes (anonymous declaration + // numbering depends on compilation history), so a branch switch that + // touched the wrapper would move every `.ui`, re-run the gresource + // compiler and recompile the whole app for identical output. Run + // directly, a `.ui` depends only on its `.blp`. + const ui_dir = ui_dir: { + // The version checks, done once. This links libadwaita for the + // version macros and so relinks as described above, which is + // harmless: nothing reads its output, the compile steps only + // depend on it having succeeded. + const check_exe = b.addExecutable(.{ + .name = "gtk_blueprint_check", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"), + .target = b.graph.host, + .link_libc = true, + }), + }); + + // Adwaita headers + translate_c.addImportToModule(b, "adw_c", check_exe.root_module, .{ + .source = .{ .includes = .{ .files = &.{.{ .path = "adwaita.h" }} } }, + .target = b.graph.host, + .optimize = .Debug, + .link_system_libs = &.{"libadwaita-1"}, + }) catch unreachable; + + // The headers have to satisfy the newest blueprint. + var required: struct { major: u16, minor: u16 } = .{ .major = 0, .minor = 0 }; + for (gresource.blueprints) |bp| { + if (bp.major > required.major or + (bp.major == required.major and bp.minor > required.minor)) + { + required = .{ .major = bp.major, .minor = bp.minor }; + } + } + + const check_run = b.addRunArtifact(check_exe); + check_run.addArgs(&.{ + b.fmt("{d}", .{required.major}), + b.fmt("{d}", .{required.minor}), + }); + // An output, so the check is cached instead of run every build. + _ = check_run.addOutputFileArg("blueprint-check.stamp"); + + // `WriteFile` hashes source paths as well as bytes, but these paths + // only move when a `.blp` changes, which reaches the gresource + // compiler regardless since the `.blp` files are its inputs too. + const ui_files = b.addWriteFiles(); + for (gresource.blueprints) |bp| { + const sub_path = b.fmt("{d}.{d}/{s}.ui", .{ + bp.major, + bp.minor, + bp.name, + }); + + const compile = b.addSystemCommand(&.{ + "blueprint-compiler", + "compile", + "--output", + }); + const ui_file = compile.addOutputFileArg(sub_path); + compile.addFileArg(b.path(b.fmt( + "{s}/{d}.{d}/{s}.blp", + .{ + gresource.ui_path, + bp.major, + bp.minor, + bp.name, + }, + ))); + compile.step.dependOn(&check_run.step); + + _ = ui_files.addCopyFile(ui_file, sub_path); + } + + break :ui_dir ui_files.getDirectory(); + }; + + // The gresource XML. Its only inputs are source tree files, so its path + // and contents are stable. The compiled `.ui` files are deliberately + // not inputs: it names them relative to the `--sourcedir` below, so no + // cache path ever appears in it. const gresource_xml = gresource_xml: { const xml_exe = b.addExecutable(.{ .name = "generate_gresource_xml", @@ -1001,88 +1108,61 @@ pub fn gtkNgDistResources( }); const xml_run = b.addRunArtifact(xml_exe); - // Run our blueprint compiler across all of our blueprint files. - const blueprint_exe = b.addExecutable(.{ - .name = "gtk_blueprint_compiler", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"), - .target = b.graph.host, - .link_libc = true, - }), - }); - - // Adwaita headers - translate_c.addImportToModule(b, "adw_c", blueprint_exe.root_module, .{ - .source = .{ .includes = .{ .files = &.{.{ .path = "adwaita.h" }} } }, - .target = b.graph.host, - .optimize = .Debug, - .link_system_libs = &.{"libadwaita-1"}, - }) catch unreachable; - - for (gresource.blueprints) |bp| { - const blueprint_run = b.addRunArtifact(blueprint_exe); - blueprint_run.addArgs(&.{ - b.fmt("{d}", .{bp.major}), - b.fmt("{d}", .{bp.minor}), - }); - const ui_file = blueprint_run.addOutputFileArg(b.fmt( - "{d}.{d}/{s}.ui", - .{ - bp.major, - bp.minor, - bp.name, - }, - )); - blueprint_run.addFileArg(b.path(b.fmt( - "{s}/{d}.{d}/{s}.blp", - .{ - gresource.ui_path, - bp.major, - bp.minor, - bp.name, - }, - ))); - - xml_run.addFileArg(ui_file); - } + // Named in the XML by relative path; the program only `access`es them. + for (gresource.file_inputs) |path| xml_run.addFileInput(b.path(path)); break :gresource_xml xml_run.captureStdOut(.{}); }; - const generate_c = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-source", - "--target", - }); - const resources_c = generate_c.addOutputFileArg("ghostty_resources.c"); - generate_c.addFileArg(gresource_xml); - for (gresource.file_inputs) |path| { - generate_c.addFileInput(b.path(path)); - } + const generate = struct { + fn step( + bb: *std.Build, + dir: std.Build.LazyPath, + xml: std.Build.LazyPath, + mode: []const u8, + name: []const u8, + ) std.Build.LazyPath { + const run = bb.addSystemCommand(&.{"glib-compile-resources"}); - const generate_h = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-header", - "--target", - }); - const resources_h = generate_h.addOutputFileArg("ghostty_resources.h"); - generate_h.addFileArg(gresource_xml); - for (gresource.file_inputs) |path| { - generate_h.addFileInput(b.path(path)); - } + // The build root for the icons and CSS, the collected directory + // for the compiled blueprints. Any `--sourcedir` replaces the + // default of the working directory, so the root must be named. + run.addArgs(&.{ "--sourcedir", "." }); + run.addArg("--sourcedir"); + run.addDirectoryArg(dir); + + run.addArgs(&.{ "--c-name", "ghostty", mode, "--target" }); + const out = run.addOutputFileArg(name); + run.addFileArg(xml); + + // `glib-compile-resources` reads these itself, so they are + // inputs here as well as of the XML step. + for (gresource_file_inputs) |path| run.addFileInput(bb.path(path)); + + return out; + } + }.step; return .{ .resources_c = .{ .dist = "src/apprt/gtk/ghostty_resources.c", - .generated = resources_c, + .generated = generate( + b, + ui_dir, + gresource_xml, + "--generate-source", + "ghostty_resources.c", + ), }, .resources_h = .{ .dist = "src/apprt/gtk/ghostty_resources.h", - .generated = resources_h, + .generated = generate( + b, + ui_dir, + gresource_xml, + "--generate-header", + "ghostty_resources.h", + ), }, }; }