diff --git a/README.md b/README.md index 385176b..dfe8961 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,25 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat --- +## Constants and Enums + +Scalar and string `pub const` decls (int, float, bool, `[]const u8`) export as enumerable value properties — at the module root, inside namespaces, and on classes (as statics on the constructor): + +```zig +pub const SHUFFLE_ROUNDS_MAINNET: u32 = 90; // → exports.SHUFFLE_ROUNDS_MAINNET +pub const LIBRARY = "zapi"; // → exports.LIBRARY +``` + +A `pub const` enum exports as a frozen plain object mapping each tag name (verbatim, no case conversion) to its integer value — also at the module root, inside namespaces, and on classes: + +```zig +pub const ByteCount = enum(u8) { one = 1, two = 2 }; // → exports.ByteCount = {one: 1, two: 2} +``` + +`pub var` decls are mutable state, not constants, and are never exported. Other const shapes (struct values, arrays, etc.) are skipped; export those via the `.register` hook. Integer consts and enum tags must fit in an `i64` (compile error otherwise). + +--- + ## Module Lifecycle `exportModule` accepts optional lifecycle hooks with atomic env refcounting: diff --git a/examples/js_dsl/constants.zig b/examples/js_dsl/constants.zig new file mode 100644 index 0000000..f3636de --- /dev/null +++ b/examples/js_dsl/constants.zig @@ -0,0 +1,9 @@ +//! Namespace demonstrating scalar/string const and enum export. + +pub const MAX_ITERATIONS: u32 = 90; +pub const EPSILON: f64 = 0.001; +pub const LIBRARY = "zapi"; +pub const IS_FAST = true; + +/// Exported to JS as a frozen plain object `{single: 1, double: 2}`. +pub const Precision = enum(u8) { single = 1, double = 2 }; diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 6bac397..47471ff 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -783,3 +783,60 @@ describe("static class fields", () => { expect(Object.prototype.hasOwnProperty.call(pk, "COMPRESS_SIZE")).toBe(false); }); }); + +// Section 17: Module-Level Constants and Enums +describe("module and namespace constants", () => { + it("exports module-root scalar and string consts", () => { + expect(mod.VERSION_MAJOR).toEqual(3); + expect(mod.MODULE_NAME).toEqual("js_dsl_example"); + }); + + it("exports namespace consts", () => { + expect(mod.constants.MAX_ITERATIONS).toEqual(90); + expect(mod.constants.EPSILON).toEqual(0.001); + expect(mod.constants.LIBRARY).toEqual("zapi"); + expect(mod.constants.IS_FAST).toEqual(true); + }); + + it("exports a namespace containing only consts", () => { + expect(mod.limits.MAX_U8).toEqual(255); + }); + + it("consts are enumerable, matching namespace functions", () => { + expect(Object.keys(mod.constants)).toContain("MAX_ITERATIONS"); + expect(Object.keys(mod)).toContain("VERSION_MAJOR"); + }); + + it("does not export pub var decls", () => { + expect(mod.mutable_counter).toBeUndefined(); + expect(mod.BlsPublicKey.instance_count).toBeUndefined(); + }); + + it("skips non-scalar const shapes", () => { + expect(mod.IDENTITY_MATRIX).toBeUndefined(); + expect(mod.VERSION_INFO).toBeUndefined(); + }); +}); + +describe("enum export", () => { + it("exports enums as plain objects with verbatim tag names", () => { + expect(mod.ByteCount).toEqual({ one: 1, two: 2 }); + expect(mod.constants.Precision).toEqual({ single: 1, double: 2 }); + }); + + it("preserves signed tag values", () => { + expect(mod.Direction).toEqual({ backward: -1, forward: 1 }); + }); + + it("exported enum objects are frozen", () => { + expect(Object.isFrozen(mod.ByteCount)).toBe(true); + expect(() => { + mod.ByteCount.one = 99; + }).toThrow(TypeError); + }); + + it("exports class-level enums as frozen objects on the constructor", () => { + expect(mod.BlsPublicKey.Encoding).toEqual({ compressed: 1, uncompressed: 2 }); + expect(Object.isFrozen(mod.BlsPublicKey.Encoding)).toBe(true); + }); +}); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index a28d108..d2de845 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -681,6 +681,12 @@ pub const BlsPublicKey = struct { pub const COMPRESS_SIZE = 48; pub const SERIALIZE_SIZE = 96; + /// Class-level enums export like namespace enums, on the constructor. + pub const Encoding = enum(u8) { compressed = 1, uncompressed = 2 }; + + /// Mutable state is never exported as a static. + pub var instance_count: u32 = 0; + bytes: [96]u8, pub fn init() BlsPublicKey { @@ -688,6 +694,34 @@ pub const BlsPublicKey = struct { } }; +// ============================================================================ +// Section 17: Module-Level Constants and Enums +// ============================================================================ + +pub const VERSION_MAJOR: u32 = 3; +pub const MODULE_NAME = "js_dsl_example"; + +/// Sub-module demonstrating namespace-level consts and enums. +pub const constants = @import("constants.zig"); + +/// Enums export as frozen plain objects mapping tag name to integer value. +pub const ByteCount = enum(u8) { one = 1, two = 2 }; + +/// Signed tag values survive the mapping. +pub const Direction = enum(i8) { backward = -1, forward = 1 }; + +/// A namespace containing only constants still exports. +pub const limits = struct { + pub const MAX_U8: u8 = 255; +}; + +/// `pub var` decls are mutable state, not constants — never exported. +pub var mutable_counter: u32 = 5; + +/// Non-scalar const shapes (arrays, struct values) are skipped. +pub const IDENTITY_MATRIX = [_]u32{ 1, 0, 0, 1 }; +pub const VERSION_INFO = .{ .major = 3, .minor = 1 }; + comptime { js.exportModule(@This(), .{ .identity = @import("zapi_addon_identity"), diff --git a/src/js/export_module.zig b/src/js/export_module.zig index ce74053..1afc57b 100644 --- a/src/js/export_module.zig +++ b/src/js/export_module.zig @@ -8,12 +8,19 @@ const wrap_class = @import("wrap_class.zig"); const class_meta = @import("class_meta.zig"); const class_runtime = @import("class_runtime.zig"); -/// Registers a Zig `Module`'s public declarations (functions, classes, namespaces) -/// as JavaScript exports in the current Node-API environment at compile time. +/// Registers a Zig `Module`'s public declarations (functions, classes, +/// namespaces, constants, enums) as JavaScript exports in the current +/// Node-API environment at compile time. /// /// This is the primary entry point for integrating ZAPI DSL-based Zig code into /// Node.js. It inspects the `Module`'s `pub` declarations and automatically /// creates corresponding JavaScript functions, classes, and sub-namespaces. +/// Scalar/string `pub const` decls (int, float, bool, `[]const u8`) export as +/// enumerable value properties at the module root and inside namespaces, +/// matching class static fields (also enumerable, on the constructor). +/// `pub const X = enum {...}` exports as a frozen plain object mapping each +/// tag name (verbatim, no case conversion) to its integer value, in all three +/// scopes. `pub var` decls are never exported (a snapshot would go stale). /// /// Addons that export DSL classes pass the build-generated identity module: /// `.identity = @import("zapi_addon_identity")`. The addon's `build.zig` creates @@ -215,7 +222,31 @@ fn registerDecls( exported_any = true; } } + } else if (@typeInfo(InnerType) == .@"enum") { + // Enum exports as a frozen plain object mapping tag name (verbatim) + // to its integer value, mirroring napi-rs `#[napi] pub enum`. + const enum_obj = try wrap_class.createEnumObject( + InnerType, + @typeName(Module) ++ "." ++ decl.name, + env, + ); + const name: [:0]const u8 = decl.name ++ ""; + try module.setNamedProperty(name, enum_obj); + exported_any = true; } + } else if (comptime (wrap_class.isConstDecl(Module, decl.name) and + wrap_class.isStaticValueType(FieldType))) + { + // Scalar/string const (never a `pub var` snapshot) — same + // auto-export as class static fields. + comptime wrap_class.assertExportableInt( + @typeName(Module) ++ "." ++ decl.name, + @field(Module, decl.name), + ); + const const_val = try wrap_class.createStaticFieldValue(env, field); + const name: [:0]const u8 = decl.name ++ ""; + try module.setNamedProperty(name, const_val); + exported_any = true; } } return exported_any; diff --git a/src/js/typed_arrays.zig b/src/js/typed_arrays.zig index 34e8439..f1ce88a 100644 --- a/src/js/typed_arrays.zig +++ b/src/js/typed_arrays.zig @@ -65,11 +65,20 @@ pub fn TypedArray(comptime Element: type, comptime array_type: TypedarrayType) t /// Creates a new JavaScript TypedArray backed by an *external* (native-heap) /// ArrayBuffer. /// - /// The contents of `slice` are copied into a freshly allocated native buffer - /// (via `context.allocator()`). + /// Copies and duplicates the contents of `slice` + /// into a freshly allocated native buffer (via `context.allocator()`). + /// Caller keeps ownership of `slice`. + /// + /// To transfer ownership of an + /// existing allocation without copying, use the owned typed arrays + /// (e.g. `js.OwnedUint8Array.fromOwnedSlice`). /// /// V8 holds the pointer to manage the JS-side lifetime; the /// native buffer is freed by a finalizer when V8 collects the ArrayBuffer. + /// + /// Panics if called outside a DSL callback (`context.env()` requires the + /// wrapped-function context) — e.g. inside a raw `napi.AsyncWork` complete + /// callback. Use the owned typed arrays' env-explicit `intoValue(env)` there. pub fn fromExternal(slice: []const Element) !Self { const e = context.env(); const buf = try context.allocator().dupe(Element, slice); diff --git a/src/js/wrap_class.zig b/src/js/wrap_class.zig index d558e72..694a6c2 100644 --- a/src/js/wrap_class.zig +++ b/src/js/wrap_class.zig @@ -228,6 +228,8 @@ pub fn wrapClass(comptime T: type, comptime Identity: type) type { inline for (all_decls, 0..) |decl, idx| { const name = decl.name; if (shouldSkipDecl(name) or consumed_methods[idx]) continue; + // A `pub var`'s value can't be read at comptime. + if (!isConstDecl(T, name)) continue; const field = @field(T, name); const field_info = @typeInfo(@TypeOf(field)); @@ -744,26 +746,72 @@ pub fn wrapClass(comptime T: type, comptime Identity: type) type { }; } -/// Walks `T`'s public declarations and attaches each scalar/string `pub const` -/// as an own property of the just-defined JS class constructor `class_val`. +/// Walks `T`'s public declarations and attaches each scalar/string/enum +/// `pub const` as an own property of the just-defined JS class constructor +/// `class_val`. /// /// Called by `export_module.zig` right after `napi_define_class` returns, so /// the values land on the constructor itself (i.e. `MyClass.MY_CONST` in JS), -/// not on instances. A decl is exposed iff its value's type is an int, float, -/// bool, or string (`[]const u8` or `*const [N]u8`); functions, types, and -/// other decls are silently skipped — so existing methods/getters and the -/// `js_meta` decl naturally fall out. +/// not on instances. A decl is exposed iff it is a `const` (never a `var`) and +/// its value is an int, float, bool, string (`[]const u8` or `*const [N]u8`), +/// or an enum type (exported via `createEnumObject`); functions, other types, +/// and remaining decls are silently skipped — so existing methods/getters and +/// the `js_meta` decl naturally fall out. pub fn applyStaticFields(comptime T: type, env: napi.Env, class_val: napi.Value) !void { inline for (@typeInfo(T).@"struct".decls) |decl| { + if (comptime !isConstDecl(T, decl.name)) continue; const value = @field(T, decl.name); - if (comptime !isStaticValueType(@TypeOf(value))) continue; - const napi_value = try createStaticFieldValue(env, value); - const name: [:0]const u8 = decl.name ++ ""; - try class_val.setNamedProperty(name, napi_value); + const ValueType = @TypeOf(value); + if (comptime ValueType == type and @typeInfo(value) == .@"enum") { + const enum_obj = try createEnumObject(value, @typeName(T) ++ "." ++ decl.name, env); + const name: [:0]const u8 = decl.name ++ ""; + try class_val.setNamedProperty(name, enum_obj); + } else if (comptime isStaticValueType(ValueType)) { + comptime assertExportableInt(@typeName(T) ++ "." ++ decl.name, @field(T, decl.name)); + const napi_value = try createStaticFieldValue(env, value); + const name: [:0]const u8 = decl.name ++ ""; + try class_val.setNamedProperty(name, napi_value); + } } } -fn isStaticValueType(comptime T: type) bool { +/// True when `T`'s decl `name` is a `const` (not a `var`). Reading a `var`'s +/// value at comptime is a compile error and its runtime value would only be a +/// registration-time snapshot, so reflection checks this before `@field`. +pub fn isConstDecl(comptime T: type, comptime name: []const u8) bool { + return @typeInfo(@TypeOf(&@field(T, name))).pointer.is_const; +} + +/// Builds the frozen plain JS object for an exported enum type: tag names +/// verbatim, values as numbers. `path` names the decl in compile errors. +pub fn createEnumObject(comptime E: type, comptime path: []const u8, env: napi.Env) !napi.Value { + const enum_obj = try env.createObject(); + inline for (@typeInfo(E).@"enum".fields) |tag| { + comptime assertExportableInt(path ++ "." ++ tag.name, tag.value); + const tag_value = try createStaticFieldValue(env, tag.value); + const tag_name: [:0]const u8 = tag.name ++ ""; + try enum_obj.setNamedProperty(tag_name, tag_value); + } + try enum_obj.objectFreeze(); + return enum_obj; +} + +/// Comptime guard: exported integer consts and enum tags must fit in an i64 +/// (`createStaticFieldValue` caps at `napi_create_int64`), otherwise the +/// `@intCast` there fails without naming the offending decl. +pub fn assertExportableInt(comptime path: []const u8, comptime value: anytype) void { + switch (@typeInfo(@TypeOf(value))) { + .comptime_int, .int => if (value < std.math.minInt(i64) or value > std.math.maxInt(i64)) { + @compileError("zapi: cannot export `" ++ path ++ "` — integer value doesn't fit in an i64"); + }, + else => {}, + } +} + +/// True for types exportable as plain JS values: ints, floats, bools, and +/// `[]const u8`/`*const [N]u8` strings. Shared by class statics and +/// namespace/module-level const export in `export_module.zig`. +pub fn isStaticValueType(comptime T: type) bool { return switch (@typeInfo(T)) { .comptime_int, .int, .comptime_float, .float, .bool => true, .pointer => |ptr| blk: { @@ -776,7 +824,8 @@ fn isStaticValueType(comptime T: type) bool { }; } -fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value { +/// Converts a static-value const (see `isStaticValueType`) to a `napi.Value`. +pub fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value { const T = @TypeOf(value); switch (@typeInfo(T)) { .comptime_int => { @@ -818,3 +867,12 @@ fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value { test "wrapClass compile-time validation requires class metadata" { try std.testing.expect(true); } + +test "isConstDecl distinguishes const from var decls" { + const S = struct { + pub const answer: u32 = 42; + pub var counter: u32 = 0; + }; + try std.testing.expect(isConstDecl(S, "answer")); + try std.testing.expect(!isConstDecl(S, "counter")); +}