Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/backend_registry.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! backend_registry — the name→package-layout seam for the pluggable-backends
//! epic (#386, Phase 5).
//!
//! Every backend the assembler stages follows ONE uniform package-naming
//! convention, derived purely from its canonical name:
//!
//! package dir : `backends/{name}`
//! zon dep name: `labelle_{name}`
//! link name : `labelle-{name}`
//!
//! Before this module, ~8 splice/codegen sites re-derived these facts inline
//! from `@tagName(cfg.backend)` + `bufPrint`/`allocPrint`, each re-implementing
//! the convention and — crucially — keyed off a CLOSED `config.Backend` enum.
//! A third-party backend can't be an enum tag, so it could never resolve.
//!
//! This registry centralizes the convention in ONE place and keys it by a
//! plain string (`lookup(allocator, name)`), so a name that is NOT a built-in
//! enum tag still resolves to a valid `BackendInfo`. That string-keying is the
//! pluggability point — the seam that a future resolver (which parses an
//! arbitrary `.backend` name, the explicit follow-up) plugs into.
//!
//! NOTE: this is the NAME layer only. Selecting backend-specific *codegen*
//! (the behavioral `switch (cfg.backend)` sites in build_files.zig /
//! deps_linker.zig) is the manifest splice's job and is intentionally NOT
//! handled here.

const std = @import("std");
const config = @import("config.zig");

/// Package-layout facts for a single backend, derived from its canonical name.
/// The `subpath` / `zon_name` / `link_name` fields are allocator-owned (built
/// by `lookup`); free them with `free`.
pub const BackendInfo = struct {
/// Canonical backend name, e.g. "bgfx". Borrowed — points at the caller's
/// input string, NOT allocator-owned (so `free` leaves it alone).
name: []const u8,
/// Package directory under the staged assembler cache: `backends/{name}`.
/// Allocator-owned.
subpath: []const u8,
/// ZON dependency identifier: `labelle_{name}`. Allocator-owned.
zon_name: []const u8,
/// build.zig link/module name: `labelle-{name}`. Allocator-owned.
link_name: []const u8,
};

/// Resolve the package-layout facts for ANY backend name string.
///
/// Works for names that are NOT `config.Backend` enum tags — that is the whole
/// point: the convention is uniform string derivation, so the registry resolves
/// a plugin backend name exactly the way it resolves a built-in one. The
/// returned `BackendInfo`'s `subpath` / `zon_name` / `link_name` are
/// allocator-owned; free them with `free`. `name` borrows the caller's slice.
pub fn lookup(allocator: std.mem.Allocator, name: []const u8) !BackendInfo {
// Allocate incrementally with errdefer so a mid-sequence OOM doesn't leak the
// fields already allocated (the struct never returns, so callers never `free`).
const subpath = try std.fmt.allocPrint(allocator, "backends/{s}", .{name});
errdefer allocator.free(subpath);
const zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{name});
errdefer allocator.free(zon_name);
const link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{name});
return .{ .name = name, .subpath = subpath, .zon_name = zon_name, .link_name = link_name };
}
Comment on lines +53 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If any of the subsequent std.fmt.allocPrint calls fail (e.g., for zon_name or link_name), the previously allocated fields (like subpath) will be leaked. Use errdefer to clean up partially allocated fields on failure.

pub fn lookup(allocator: std.mem.Allocator, name: []const u8) !BackendInfo {
    const subpath = try std.fmt.allocPrint(allocator, "backends/{s}", .{name});
    errdefer allocator.free(subpath);
    const zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{name});
    errdefer allocator.free(zon_name);
    const link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{name});
    return .{
        .name = name,
        .subpath = subpath,
        .zon_name = zon_name,
        .link_name = link_name,
    };
}


/// Free the allocator-owned fields of an `info` returned by `lookup`.
/// (`name` is borrowed and is left untouched.)
pub fn free(allocator: std.mem.Allocator, info: BackendInfo) void {
allocator.free(info.subpath);
allocator.free(info.zon_name);
allocator.free(info.link_name);
}

/// The built-in backend names, seeded directly from `config.Backend`'s tags so
/// the registry and the enum cannot silently drift: adding an enum variant
/// adds a name here automatically, and the drift-guard test cross-checks the
/// two sets in both directions.
pub const builtin_names = blk: {
const fields = @typeInfo(config.Backend).@"enum".fields;
var names: [fields.len][]const u8 = undefined;
for (fields, 0..) |f, i| names[i] = f.name;
const frozen = names;
break :blk frozen;
};

/// True if `name` is one of the built-in backend names.
pub fn isBuiltin(name: []const u8) bool {
for (builtin_names) |b| {
if (std.mem.eql(u8, b, name)) return true;
}
return false;
}

// ── Tests ────────────────────────────────────────────────────────────

test "lookup derives the package convention for a built-in name" {
const alloc = std.testing.allocator;
const info = try lookup(alloc, "bgfx");
defer free(alloc, info);

try std.testing.expectEqualStrings("bgfx", info.name);
try std.testing.expectEqualStrings("backends/bgfx", info.subpath);
try std.testing.expectEqualStrings("labelle_bgfx", info.zon_name);
try std.testing.expectEqualStrings("labelle-bgfx", info.link_name);
}

test "pluggability: lookup resolves a name with NO enum tag" {
const alloc = std.testing.allocator;
// "fictional" is not a config.Backend tag — this is the seam working.
try std.testing.expect(!isBuiltin("fictional"));

const info = try lookup(alloc, "fictional");
defer free(alloc, info);

try std.testing.expectEqualStrings("fictional", info.name);
try std.testing.expectEqualStrings("backends/fictional", info.subpath);
try std.testing.expectEqualStrings("labelle_fictional", info.zon_name);
try std.testing.expectEqualStrings("labelle-fictional", info.link_name);
}

test "drift guard: builtin_names and config.Backend tags agree both ways" {
// Every enum tag must be a builtin name…
inline for (@typeInfo(config.Backend).@"enum".fields) |f| {
try std.testing.expect(isBuiltin(f.name));
}
// …and every builtin name must be an enum tag.
for (builtin_names) |name| {
var found = false;
inline for (@typeInfo(config.Backend).@"enum".fields) |f| {
if (std.mem.eql(u8, f.name, name)) found = true;
}
try std.testing.expect(found);
}
// And the counts match (catches a stray addition on either side).
try std.testing.expectEqual(
@typeInfo(config.Backend).@"enum".fields.len,
builtin_names.len,
);
}

test "lookup matches the inline convention for all 6 built-ins" {
const alloc = std.testing.allocator;
for (builtin_names) |name| {
const info = try lookup(alloc, name);
defer free(alloc, info);

const subpath = try std.fmt.allocPrint(alloc, "backends/{s}", .{name});
defer alloc.free(subpath);
const zon_name = try std.fmt.allocPrint(alloc, "labelle_{s}", .{name});
defer alloc.free(zon_name);
const link_name = try std.fmt.allocPrint(alloc, "labelle-{s}", .{name});
defer alloc.free(link_name);

try std.testing.expectEqualStrings(subpath, info.subpath);
try std.testing.expectEqualStrings(zon_name, info.zon_name);
try std.testing.expectEqualStrings(link_name, info.link_name);
}
}
9 changes: 5 additions & 4 deletions src/build_files.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const std = @import("std");
const tpl = @import("template.zig");
const config = @import("config.zig");
const cache = @import("cache.zig");
const backend_registry = @import("backend_registry.zig");
const scan = @import("codegen/scan.zig");
const manifest_splice = @import("codegen/manifest_splice.zig");
pub const deps_linker = @import("deps_linker.zig");
Expand Down Expand Up @@ -772,12 +773,12 @@ fn generateZonPathsFallback(allocator: std.mem.Allocator, cfg: ProjectConfig, ta
}

{
const bn = @tagName(cfg.backend);
const bn = cfg.backendName();
var sb: [64]u8 = undefined;
const section = std.fmt.bufPrint(&sb, "dep_{s}_path", .{bn}) catch unreachable;
var spb: [128]u8 = undefined;
const sp = std.fmt.bufPrint(&spb, "backends/{s}", .{bn}) catch unreachable;
const bp_abs = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, sp);
const backend_info = try backend_registry.lookup(allocator, bn);
defer backend_registry.free(allocator, backend_info);
const bp_abs = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath);
defer allocator.free(bp_abs);
const bp = try relativePath(allocator, abs_target, bp_abs);
defer allocator.free(bp);
Expand Down
21 changes: 12 additions & 9 deletions src/codegen/manifest_splice.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const std = @import("std");
const tpl = @import("../template.zig");
const config = @import("../config.zig");
const cache = @import("../cache.zig");
const backend_registry = @import("../backend_registry.zig");

const ProjectConfig = config.ProjectConfig;

Expand Down Expand Up @@ -110,16 +111,18 @@ fn manifestExists(allocator: std.mem.Allocator, cfg: ProjectConfig, project_dir:
/// slot) the same way `loadBackendTemplate` / `deps_linker` do. Caller owns the
/// returned path.
///
/// SEAM (Phase 5): this is the one spot the splice still uses `@tagName` — to
/// LOCATE the package so it can read the manifest (chicken-and-egg: the dir
/// name lives in the manifest we haven't read yet). A production
/// name→package registry would map a backend *name string* (from
/// project.labelle) here, dropping the enum entirely. Everything downstream of
/// the read uses manifest data (`dir_name`/`dep_name`), not the tag.
/// Locate the backend package so the splice can read its manifest (chicken-and-
/// egg: the dir name lives in the manifest we haven't read yet, so we resolve by
/// the backend's *name*). Now routed through the `backend_registry` — keyed by
/// `cfg.backendName()`, a string, NOT `@tagName` directly. The only residual
/// enum coupling is config *parsing* (`.backend` is still the closed enum, so
/// `backendName()` only yields built-in names today); opening config to an
/// arbitrary name+package is the next step, after which a third-party backend
/// flows through this same registry lookup with no enum entry.
fn backendPackageDir(allocator: std.mem.Allocator, cfg: ProjectConfig, project_dir: []const u8) ![]const u8 {
var subpath_buf: [128]u8 = undefined;
const subpath = std.fmt.bufPrint(&subpath_buf, "backends/{s}", .{@tagName(cfg.backend)}) catch unreachable;
return cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, subpath);
const backend_info = try backend_registry.lookup(allocator, cfg.backendName());
defer backend_registry.free(allocator, backend_info);
return cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath);
}

/// Load + parse `backend.manifest.zon` from the backend package.
Expand Down
14 changes: 14 additions & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,20 @@ pub const ProjectConfig = struct {
return self.resolved_gui != null;
}

/// The canonical backend NAME as a string (e.g. "bgfx").
///
/// This is the pluggable-backends seam (epic #386, Phase 5): name-layer
/// code reads `backendName()` instead of `@tagName(self.backend)` directly,
/// so the package-layout conventions (see `backend_registry`) are derived
/// from a string rather than a closed enum tag. Today this is just the
/// enum tag — the `Backend` enum remains the backward-compat shorthand and
/// `.backend` still parses as an enum value — but routing through this
/// method is what lets a future resolver hand the name layer a backend name
/// that has no enum tag (the explicit follow-up).
pub fn backendName(self: ProjectConfig) []const u8 {
return @tagName(self.backend);
}

/// The unset-`.y_axis` build guard (RFC-Y-AXIS-CONVENTION Migration §,
/// epic labelle-engine#640). During the transition release an *absent*
/// `.y_axis` is a hard error naming BOTH choices, so no existing game
Expand Down
25 changes: 18 additions & 7 deletions src/deps_linker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const std = @import("std");
const config = @import("config.zig");
const cache = @import("cache.zig");
const backend_registry = @import("backend_registry.zig");

const ProjectConfig = config.ProjectConfig;

Expand Down Expand Up @@ -51,13 +52,23 @@ pub fn createDepsLinks(
}

{
const backend_name = @tagName(cfg.backend);
var subpath_buf: [128]u8 = undefined;
const subpath = std.fmt.bufPrint(&subpath_buf, "backends/{s}", .{backend_name}) catch unreachable;
const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, subpath);
const zon_name = try std.fmt.allocPrint(allocator, "labelle_{s}", .{backend_name});
const link_name = try std.fmt.allocPrint(allocator, "labelle-{s}", .{backend_name});
try deps.append(allocator, .{ .zon_name = zon_name, .link_name = link_name, .abs_path = backend_path });
// Tight scope: `zon_name`/`link_name`/`backend_path` are MOVED into the
// DepEntry on a successful append (then owned by `deps`, freed by
// freeDepEntries). The errdefers cover only the allocate→append window —
// on success this inner block exits normally so they don't fire; on any
// error up to and including the append they free the not-yet-moved
// allocations. Scoped tightly so the later gamepad appends (also failable)
// can't re-trigger them into a double-free. `subpath` is never moved, so
// it's a plain `defer`.
{
const backend_info = try backend_registry.lookup(allocator, cfg.backendName());
defer allocator.free(backend_info.subpath);
errdefer allocator.free(backend_info.zon_name);
errdefer allocator.free(backend_info.link_name);
const backend_path = try cache.resolveBundledPackage(allocator, cfg.labelle_version, cfg.assembler_version, project_dir, backend_info.subpath);
errdefer allocator.free(backend_path);
try deps.append(allocator, .{ .zon_name = backend_info.zon_name, .link_name = backend_info.link_name, .abs_path = backend_path });
}

// Backend-owned transitive sub-package: the shared windowless-SDL
// desktop gamepad source (`backends/sdl_gamepad/`, core#28). Both the
Expand Down
6 changes: 3 additions & 3 deletions src/gui_resolve.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub fn resolveGuiPlugin(allocator: std.mem.Allocator, cfg: *config.ProjectConfig
};

const bridge_def = getBridgeForBackend(bridges, cfg.backend) orelse {
std.debug.print("labelle: GUI plugin '{s}' requires a bridge for backend '{s}', but none is declared in gui.labelle.\n", .{ manifest.name, @tagName(cfg.backend) });
std.debug.print("labelle: GUI plugin '{s}' requires a bridge for backend '{s}', but none is declared in gui.labelle.\n", .{ manifest.name, cfg.backendName() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using std.debug.print for user-facing errors in CLI tools. Use standard logging facilities like std.log.err instead.

            std.log.err("labelle: GUI plugin '{s}' requires a bridge for backend '{s}', but none is declared in gui.labelle.", .{ manifest.name, cfg.backendName() });
References
  1. In CLI tools, use standard logging facilities (e.g., std.log.warn) or write to stderr for user-facing warnings, rather than using debug-specific print functions (e.g., std.debug.print).

std.debug.print(" available bridges:", .{});
printAvailableBridges(bridges);
std.debug.print("\n", .{});
Expand All @@ -58,12 +58,12 @@ pub fn resolveGuiPlugin(allocator: std.mem.Allocator, cfg: *config.ProjectConfig
// Local bridge path (relative to plugin directory)
bridge_dir = try std.fs.path.resolve(allocator, &.{ plugin_dir, rel_path });
} else {
std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has no .path (remote bridge resolution not yet supported)\n", .{ manifest.name, @tagName(cfg.backend) });
std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has no .path (remote bridge resolution not yet supported)\n", .{ manifest.name, cfg.backendName() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using std.debug.print for user-facing errors in CLI tools. Use standard logging facilities like std.log.err instead.

            std.log.err("labelle: GUI plugin '{s}' bridge for '{s}' has no .path (remote bridge resolution not yet supported)", .{ manifest.name, cfg.backendName() });
References
  1. In CLI tools, use standard logging facilities (e.g., std.log.warn) or write to stderr for user-facing warnings, rather than using debug-specific print functions (e.g., std.debug.print).

return error.GuiBridgeResolutionNotSupported;
}

if (bridge_def.adapter.len == 0) {
std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has empty .adapter name\n", .{ manifest.name, @tagName(cfg.backend) });
std.debug.print("labelle: GUI plugin '{s}' bridge for '{s}' has empty .adapter name\n", .{ manifest.name, cfg.backendName() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using std.debug.print for user-facing errors in CLI tools. Use standard logging facilities like std.log.err instead.

            std.log.err("labelle: GUI plugin '{s}' bridge for '{s}' has empty .adapter name", .{ manifest.name, cfg.backendName() });
References
  1. In CLI tools, use standard logging facilities (e.g., std.log.warn) or write to stderr for user-facing warnings, rather than using debug-specific print functions (e.g., std.debug.print).

return error.GuiBridgeMissingAdapter;
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ fn cmdGenerate(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args
std.process.exit(1);
};

const target_name = try std.fmt.allocPrint(allocator, "{s}_{s}", .{ @tagName(cfg.backend), @tagName(cfg.platform) });
const target_name = try std.fmt.allocPrint(allocator, "{s}_{s}", .{ cfg.backendName(), @tagName(cfg.platform) });
defer allocator.free(target_name);
std.log.info("labelle-assembler: generated .labelle/{s}/", .{target_name});

Expand Down
Loading
Loading