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
4 changes: 4 additions & 0 deletions src/plugin_manifest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub const ConventionDir = plugin.ConventionDir;
pub const PluginManifest = plugin.PluginManifest;
pub const loadOptional = plugin.loadOptional;
pub const loadFromDir = plugin.loadFromDir;
// Language capability rows (RFC-LANGUAGE-PLUGINS rev 17 §7, #619/#774).
pub const LanguageKind = plugin.LanguageKind;
pub const DeclareCapability = plugin.DeclareCapability;
pub const LanguageRow = plugin.LanguageRow;

// ── Pack manifest (`pack.labelle`) (plugin_manifest/pack.zig) ────────
pub const PackConventionMode = pack.PackConventionMode;
Expand Down
133 changes: 133 additions & 0 deletions src/plugin_manifest/plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,48 @@ pub const ConventionDir = struct {
mode: ConventionDirMode,
};

/// How a language's game sources reach the built binary
/// (RFC-LANGUAGE-PLUGINS rev 17 §7). `.embedded` = an in-process VM
/// (`@embedFile` + registerScript: lua, ruby, typescript); `.native` =
/// compiled and linked (rust, crystal). The declare phase reads it only
/// for messaging — the declare MECHANISM is the same for both (see
/// `DeclareCapability`).
pub const LanguageKind = enum { embedded, native };

/// A language's declare-tool capability (RFC-LANGUAGE-PLUGINS rev 17 §7).
/// IDENTICAL shape for embedded and native languages — no per-mechanism
/// discriminant: the assembler builds `tool` via `zig build <tool>` in the
/// plugin package, runs it passing the declaration files + a persistent
/// per-project cache dir, and reads schema JSON from stdout. What the tool
/// does with the cache dir is opaque (an embedded VM ignores it; a native
/// probe uses it as a cargo target-dir). `dir` is the tool's source
/// directory — its presence in the resolved pin gates the capability
/// (older pins without it skip gracefully). `events` is the self-describing
/// capability that replaces the assembler's `events_min_pin` table: present
/// and true ⇒ the tool records `events/*` declarations.
pub const DeclareCapability = struct {
tool: []const u8,
dir: []const u8,
events: bool = false,
};

/// One row of the manifest `.languages` capability table
/// (RFC-LANGUAGE-PLUGINS rev 17 §7). Everything the assembler knows per
/// language: name, source extensions, embed `kind`, the native crate's
/// module root (native only), and — when the language supports declared
/// components/events — a `declare` capability. The assembler reads these
/// GENERICALLY (it never learns "rust"/"cargo"); a language that omits
/// `.declare` simply has no declare phase. Unknown row keys (e.g. a future
/// `.transpile`) are tolerated by the manifest-wide `ignore_unknown_fields`
/// parse, so a new capability never breaks a bystander assembler.
pub const LanguageRow = struct {
name: []const u8,
extensions: []const []const u8 = &.{},
kind: LanguageKind,
module_root: ?[]const u8 = null,
declare: ?DeclareCapability = null,
};

/// Parsed and validated `plugin.labelle` manifest.
///
/// Ownership: every string field (`name`, each `ConventionDir.name`
Expand Down Expand Up @@ -122,11 +164,29 @@ pub const PluginManifest = struct {
/// `labelle plugins`. Optional.
author: ?[]const u8 = null,

/// Per-language capability rows (RFC-LANGUAGE-PLUGINS rev 17 §7,
/// labelle-engine#619/#774). The assembler reads these generically for
/// the declare phase: a row with a `.declare` capability names the tool
/// to build + run over the language's declaration files. Empty/absent =
/// the plugin declares no `.languages` (every plugin before rev 17, and
/// languages still on the assembler's hardcoded runner table) →
/// byte-identical output.
languages: []const LanguageRow = &.{},

/// Allocator that owns the parsed strings and slice. Stored on
/// the manifest so the caller doesn't have to remember to pass
/// the right allocator to deinit.
allocator: std.mem.Allocator,

/// The `.languages` row for `language`, or null. The declare phase reads
/// `row.declare` to drive the generic invocation contract.
pub fn languageRow(self: *const PluginManifest, language: []const u8) ?LanguageRow {
for (self.languages) |row| {
if (std.mem.eql(u8, row.name, language)) return row;
}
return null;
}

pub fn deinit(self: *PluginManifest) void {
// Free every heap-allocated field individually.
// std.zon.parse.free walks slices and structs recursively,
Expand All @@ -141,6 +201,7 @@ pub const PluginManifest = struct {
std.zon.parse.free(self.allocator, self.requires_language);
std.zon.parse.free(self.allocator, self.license);
std.zon.parse.free(self.allocator, self.author);
std.zon.parse.free(self.allocator, self.languages);
// Not parser-allocated (the strict schema walk owns its copies) but
// shape-compatible; freed through its own helper for symmetry.
plugin_params.freeSchema(self.allocator, self.params_schema);
Expand Down Expand Up @@ -375,6 +436,7 @@ pub fn loadFromDir(
.params_schema = params_schema,
.license = parsed.license,
.author = parsed.author,
.languages = parsed.languages,
.allocator = allocator,
};
}
Expand All @@ -397,6 +459,10 @@ const ZonManifest = struct {
// Language plugins P1 (#584). Optional/additive — absent parses to the
// byte-identical null default.
requires_language: ?[]const u8 = null,
// Language plugins rev 17 (#619/#774). Optional/additive — absent parses
// to the byte-identical empty default. Unknown row keys (a future
// `.transpile`) ride the manifest-wide `ignore_unknown_fields`.
languages: []const LanguageRow = &.{},
};

// ============================================================================
Expand Down Expand Up @@ -473,6 +539,73 @@ test "ZonManifest: parses manifest with no convention_dirs" {
try testing.expectEqual(@as(usize, 0), parsed.convention_dirs.len);
}

test "ZonManifest: parses a .languages row with a declare capability (rev 17)" {
const src =
\\.{
\\ .name = "scripting",
\\ .manifest_version = 1,
\\ .languages = .{
\\ .{ .name = "rust", .extensions = .{"rs"}, .kind = .native,
\\ .module_root = "mod.rs",
\\ .declare = .{ .tool = "labelle-declare-rs", .dir = "tools/declare-rs", .events = true } },
\\ },
\\}
;
const src_z = try testing.allocator.dupeZ(u8, src);
defer testing.allocator.free(src_z);

const parsed = try std.zon.parse.fromSliceAlloc(ZonManifest, testing.allocator, src_z, null, .{});
defer std.zon.parse.free(testing.allocator, parsed);

try testing.expectEqual(@as(usize, 1), parsed.languages.len);
const row = parsed.languages[0];
try testing.expectEqualStrings("rust", row.name);
try testing.expectEqual(LanguageKind.native, row.kind);
try testing.expectEqual(@as(usize, 1), row.extensions.len);
try testing.expectEqualStrings("rs", row.extensions[0]);
try testing.expectEqualStrings("mod.rs", row.module_root.?);
try testing.expect(row.declare != null);
try testing.expectEqualStrings("labelle-declare-rs", row.declare.?.tool);
try testing.expectEqualStrings("tools/declare-rs", row.declare.?.dir);
try testing.expect(row.declare.?.events);
}

test "ZonManifest: a .languages row tolerates unknown keys under ignore_unknown_fields (forward-compat)" {
// A future row key (e.g. `.transpile`) must not break a bystander
// assembler: the manifest-wide `ignore_unknown_fields` (loadFromDir's
// parse mode) must reach nested rows too. A `.declare`-less row (an
// embedded language not yet on this table) parses to a null capability.
const src =
\\.{
\\ .name = "scripting",
\\ .manifest_version = 1,
\\ .languages = .{
\\ .{ .name = "typescript", .extensions = .{"ts"}, .kind = .embedded,
\\ .transpile = .{ .emits = "js", .toolchain = "tsc" },
\\ .declare = .{ .tool = "labelle-declare-ts", .dir = "tools/declare-ts", .events = true } },
\\ .{ .name = "lua", .extensions = .{"lua"}, .kind = .embedded },
\\ },
\\}
;
const src_z = try testing.allocator.dupeZ(u8, src);
defer testing.allocator.free(src_z);

const parsed = try std.zon.parse.fromSliceAlloc(
ZonManifest,
testing.allocator,
src_z,
null,
.{ .ignore_unknown_fields = true },
);
defer std.zon.parse.free(testing.allocator, parsed);

try testing.expectEqual(@as(usize, 2), parsed.languages.len);
try testing.expectEqualStrings("labelle-declare-ts", parsed.languages[0].declare.?.tool);
// The `.declare`-less lua row → null capability, no events.
try testing.expect(parsed.languages[1].declare == null);
try testing.expect(parsed.languages[1].module_root == null);
}

test "ZonManifest: parses ship_from_plugin mode with extension" {
const src =
\\.{
Expand Down
48 changes: 35 additions & 13 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -581,13 +581,33 @@ pub fn generate(
// slice, never through freeEmbedScripts.
var combined_embeds: ?[]scripting_splice.EmbedScript = null;
defer if (combined_embeds) |cb| allocator.free(cb);
// The native family's declaration-file set (components/*.<ext> ++
// events/*.<ext>), fed to the generic `.languages` declare tool
// (RFC-LANGUAGE-PLUGINS rev 17, rust #774). SHALLOW over
// `component_embeds`/`event_embeds` (freed as a bare slice). Unlike the
// embed family this is NOT assigned to `s.scripts` — a native splice
// embeds nothing; gameplay scripts are staged for the compiler
// (`stageNativeSources`), never fed to the declare probe (they would not
// compile as standalone declaration modules).
var native_decl_embeds: ?[]scripting_splice.EmbedScript = null;
defer if (native_decl_embeds) |nd| allocator.free(nd);
if (maybe_scripting) |*s| {
if (s.family == .embed) {
component_embeds = try scripting_splice.collectComponentEmbeds(allocator, game_dir, s.*);
event_embeds = try scripting_splice.collectEventEmbeds(allocator, game_dir, s.*);
script_embeds = try scripting_splice.collectEmbedScripts(allocator, game_dir, target_dir, s.*);
combined_embeds = try scripting_splice.concatEmbeds3(allocator, component_embeds.?, event_embeds.?, script_embeds.?);
s.scripts = combined_embeds.?;
} else {
// Native family (rust): collect ONLY the declaration files. The
// Zig `components/`/`events/` links (below) expose them in the
// target, so the declare tool's `target_dir/<file>` argv resolves.
component_embeds = try scripting_splice.collectComponentEmbeds(allocator, game_dir, s.*);
event_embeds = try scripting_splice.collectEventEmbeds(allocator, game_dir, s.*);
const nd = try allocator.alloc(scripting_splice.EmbedScript, component_embeds.?.len + event_embeds.?.len);
@memcpy(nd[0..component_embeds.?.len], component_embeds.?);
@memcpy(nd[component_embeds.?.len..], event_embeds.?);
native_decl_embeds = nd;
Comment on lines +607 to +610

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

Using .? on component_embeds and event_embeds is unsafe because these optional slices can be null if no component or event files are found. To prevent potential runtime panics, use safe optional unwrapping with if or orelse to determine the lengths and copy the slices defensively.

            const comp_len = if (component_embeds) |ce| ce.len else 0;
            const event_len = if (event_embeds) |ee| ee.len else 0;
            const nd = try allocator.alloc(scripting_splice.EmbedScript, comp_len + event_len);
            if (component_embeds) |ce| @memcpy(nd[0..comp_len], ce);
            if (event_embeds) |ee| @memcpy(nd[comp_len..], ee);
            native_decl_embeds = nd;

}
}

Expand Down Expand Up @@ -920,20 +940,22 @@ pub fn generate(
var declare_schema: ?scripting_declare.Schema = null;
defer if (declare_schema) |*sch| sch.deinit();
if (maybe_scripting) |*s| {
// The declare phase is embed-only by construction: a native splice
// keeps `scripts` empty (nothing embeds), so `runPhase` returns
// null at its zero-files gate before the runner-row gate is even
// consulted. The runner gets the collected TARGET-RELATIVE files
// (not stems — ordering prefixes are stripped from stems, so only
// the file column can rebuild the path): `components/*.<ext>`
// declarations first, then `events/*.<ext>` declarations
// (labelle-engine#772), then the script dir's files — in-script
// chunk-scope declarations remain legal, all sources feed ONE
// schema (the runner owns duplicate detection with
// first-declared-in attribution).
const script_files = try allocator.alloc([]const u8, s.scripts.len);
// The declare tool's input files, TARGET-RELATIVE (the `EmbedScript.file`
// column — not stems, whose ordering prefixes are stripped, so only the
// file column rebuilds the path). For the EMBED family: every collected
// source (`components/*.<ext>` declarations, then `events/*.<ext>`, then
// the script dir — in-script chunk-scope declarations are legal, all
// feed ONE schema with the runner owning duplicate detection). For the
// NATIVE family (rev 17, rust #774): ONLY the declaration files
// (`native_decl_embeds` = components ++ events); `s.scripts` is empty
// (nothing embeds) and gameplay scripts are compiler-staged, never fed
// to the declare probe. Either way the generic/hardcoded runPhase gets
// components-first order.
const declare_inputs: []const scripting_splice.EmbedScript =
if (s.family == .native) (native_decl_embeds orelse &.{}) else s.scripts;
const script_files = try allocator.alloc([]const u8, declare_inputs.len);
defer allocator.free(script_files);
for (s.scripts, script_files) |sc, *f| f.* = sc.file;
for (declare_inputs, script_files) |sc, *f| f.* = sc.file;
// The events-dir subset rides along separately (labelle-engine
// #772): the phase's events gates (`events_min_pin` floor,
// no-runner hard error, declares-nothing error) fire on these
Expand Down
Loading
Loading