commit 9444b313180cbd90431b3f41bb865accca7fbf01
parent b6669452679a49b3ff825daf266b546fbca08a7b
Author: Erik Loualiche <[email protected]>
Date: Fri, 6 Feb 2026 16:46:01 -0600
Add NickelEnum type for proper enum representation
- New NickelEnum struct with tag::Symbol and arg::Any
- TYPE_ENUM (7) in binary protocol for distinct enum encoding
- Convenience: NickelEnum == :Symbol comparison
- Pretty printing: 'Some 42 format
- 116 tests passing
Enums are now distinct from records, preserving Nickel's
enum semantics in Julia.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
Diffstat:
6 files changed, 129 insertions(+), 54 deletions(-)
diff --git a/docs/src/lib/public.md b/docs/src/lib/public.md
@@ -31,3 +31,10 @@ nickel_eval_native
```@docs
@ncl_str
```
+
+## Types
+
+```@docs
+NickelEnum
+NickelError
+```
diff --git a/docs/src/man/ffi.md b/docs/src/man/ffi.md
@@ -50,32 +50,49 @@ nickel_eval_ffi("{ a = 1 }", Dict{String, Int}) # Typed Dict
|--------|-------|---------|
| Arrays | `Vector{Any}` | `[1, 2, 3]` → `Any[1, 2, 3]` |
| Records | `Dict{String, Any}` | `{ x = 1 }` → `Dict("x" => 1)` |
+| Enums | `NickelEnum` | `'Some 42` → `NickelEnum(:Some, 42)` |
### Enums
-Nickel enums are converted to `Dict{String, Any}` matching the format of `std.enum.to_tag_and_arg`:
+Nickel enums are converted to the `NickelEnum` type, preserving enum semantics:
+
+```julia
+struct NickelEnum
+ tag::Symbol
+ arg::Any # nothing for simple enums
+end
+```
**Simple enum** (no argument):
```julia
-nickel_eval_native("let x = 'Foo in x")
-# => Dict("tag" => "Foo")
+result = nickel_eval_native("let x = 'Foo in x")
+# => NickelEnum(:Foo, nothing)
+
+result.tag # => :Foo
+result.arg # => nothing
+result == :Foo # => true (convenience comparison)
```
**Enum with argument**:
```julia
-nickel_eval_native("let x = 'Some 42 in x")
-# => Dict("tag" => "Some", "arg" => 42)
+result = nickel_eval_native("let x = 'Some 42 in x")
+# => NickelEnum(:Some, 42)
-nickel_eval_native("let x = 'Ok { value = 123 } in x")
-# => Dict("tag" => "Ok", "arg" => Dict("value" => 123))
+result.tag # => :Some
+result.arg # => 42
+
+result = nickel_eval_native("let x = 'Ok { value = 123 } in x")
+result.arg["value"] # => 123
```
-This matches Nickel's standard library convention:
-```nickel
-'Some 42 |> std.enum.to_tag_and_arg
-# => { tag = "Some", arg = 42 }
+**Pretty printing**:
+```julia
+repr(nickel_eval_native("let x = 'None in x")) # => "'None"
+repr(nickel_eval_native("let x = 'Some 42 in x")) # => "'Some 42"
```
+This mirrors Nickel's `std.enum.to_tag_and_arg` semantics while using a proper Julia type.
+
### Nested Structures
Arbitrary nesting is fully supported:
diff --git a/rust/nickel-jl/src/lib.rs b/rust/nickel-jl/src/lib.rs
@@ -37,6 +37,7 @@ const TYPE_FLOAT: u8 = 3;
const TYPE_STRING: u8 = 4;
const TYPE_ARRAY: u8 = 5;
const TYPE_RECORD: u8 = 6;
+const TYPE_ENUM: u8 = 7;
/// Result buffer for native evaluation
#[repr(C)]
@@ -206,37 +207,22 @@ fn encode_term(term: &RichTerm, buffer: &mut Vec<u8>) -> Result<(), String> {
}
}
Term::Enum(tag) => {
- // Simple enum: encode as { tag = "Name" } (matches std.enum.to_tag_and_arg)
- buffer.push(TYPE_RECORD);
- buffer.extend_from_slice(&1u32.to_le_bytes()); // 1 field
-
- // tag field
- let tag_key = b"tag";
- buffer.extend_from_slice(&(tag_key.len() as u32).to_le_bytes());
- buffer.extend_from_slice(tag_key);
- buffer.push(TYPE_STRING);
+ // Simple enum without argument
+ // Format: TYPE_ENUM | tag_len (u32) | tag_bytes | has_arg (u8 = 0)
+ buffer.push(TYPE_ENUM);
let tag_bytes = tag.label().as_bytes();
buffer.extend_from_slice(&(tag_bytes.len() as u32).to_le_bytes());
buffer.extend_from_slice(tag_bytes);
+ buffer.push(0); // no argument
}
Term::EnumVariant { tag, arg, .. } => {
- // Enum with argument: encode as { tag = "Name", arg = value } (matches std.enum.to_tag_and_arg)
- buffer.push(TYPE_RECORD);
- buffer.extend_from_slice(&2u32.to_le_bytes()); // 2 fields
-
- // tag field
- let tag_key = b"tag";
- buffer.extend_from_slice(&(tag_key.len() as u32).to_le_bytes());
- buffer.extend_from_slice(tag_key);
- buffer.push(TYPE_STRING);
+ // Enum with argument
+ // Format: TYPE_ENUM | tag_len (u32) | tag_bytes | has_arg (u8 = 1) | arg_value
+ buffer.push(TYPE_ENUM);
let tag_bytes = tag.label().as_bytes();
buffer.extend_from_slice(&(tag_bytes.len() as u32).to_le_bytes());
buffer.extend_from_slice(tag_bytes);
-
- // arg field
- let arg_key = b"arg";
- buffer.extend_from_slice(&(arg_key.len() as u32).to_le_bytes());
- buffer.extend_from_slice(arg_key);
+ buffer.push(1); // has argument
encode_term(arg, buffer)?;
}
other => {
@@ -760,10 +746,12 @@ mod tests {
let buffer = nickel_eval_native(code.as_ptr());
assert!(!buffer.data.is_null());
let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- // Should be a record with 1 field (_tag)
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 1);
+ // TYPE_ENUM | tag_len | "Foo" | has_arg=0
+ assert_eq!(data[0], TYPE_ENUM);
+ let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
+ assert_eq!(tag_len, 3); // "Foo"
+ assert_eq!(&data[5..8], b"Foo");
+ assert_eq!(data[8], 0); // no argument
nickel_free_buffer(buffer);
}
}
@@ -775,10 +763,13 @@ mod tests {
let buffer = nickel_eval_native(code.as_ptr());
assert!(!buffer.data.is_null());
let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- // Should be a record with 2 fields (_tag and _value)
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 2);
+ // TYPE_ENUM | tag_len | "Some" | has_arg=1 | TYPE_INT | 42
+ assert_eq!(data[0], TYPE_ENUM);
+ let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
+ assert_eq!(tag_len, 4); // "Some"
+ assert_eq!(&data[5..9], b"Some");
+ assert_eq!(data[9], 1); // has argument
+ assert_eq!(data[10], TYPE_INT);
nickel_free_buffer(buffer);
}
}
@@ -790,9 +781,13 @@ mod tests {
let buffer = nickel_eval_native(code.as_ptr());
assert!(!buffer.data.is_null());
let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 2);
+ // TYPE_ENUM | tag_len | "Ok" | has_arg=1 | TYPE_RECORD | ...
+ assert_eq!(data[0], TYPE_ENUM);
+ let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
+ assert_eq!(tag_len, 2); // "Ok"
+ assert_eq!(&data[5..7], b"Ok");
+ assert_eq!(data[7], 1); // has argument
+ assert_eq!(data[8], TYPE_RECORD);
nickel_free_buffer(buffer);
}
}
diff --git a/src/NickelEval.jl b/src/NickelEval.jl
@@ -6,6 +6,7 @@ export nickel_eval, nickel_eval_file, nickel_export, nickel_read, @ncl_str, Nick
export nickel_to_json, nickel_to_toml, nickel_to_yaml
export check_ffi_available, nickel_eval_ffi, nickel_eval_native
export find_nickel_executable
+export NickelEnum
# Custom exception for Nickel errors
struct NickelError <: Exception
@@ -14,6 +15,45 @@ end
Base.showerror(io::IO, e::NickelError) = print(io, "NickelError: ", e.message)
+"""
+ NickelEnum
+
+Represents a Nickel enum value. Matches the format of `std.enum.to_tag_and_arg`.
+
+# Fields
+- `tag::Symbol`: The enum variant name
+- `arg::Any`: The argument (nothing for simple enums)
+
+# Examples
+```julia
+result = nickel_eval_native("let x = 'Some 42 in x")
+result.tag # => :Some
+result.arg # => 42
+result == :Some # => true
+
+result = nickel_eval_native("let x = 'None in x")
+result.tag # => :None
+result.arg # => nothing
+```
+"""
+struct NickelEnum
+ tag::Symbol
+ arg::Any
+end
+
+# Convenience: compare enum to symbol
+Base.:(==)(e::NickelEnum, s::Symbol) = e.tag == s
+Base.:(==)(s::Symbol, e::NickelEnum) = e.tag == s
+
+# Pretty printing
+function Base.show(io::IO, e::NickelEnum)
+ if e.arg === nothing
+ print(io, "'", e.tag)
+ else
+ print(io, "'", e.tag, " ", repr(e.arg))
+ end
+end
+
include("subprocess.jl")
include("ffi.jl")
diff --git a/src/ffi.jl b/src/ffi.jl
@@ -35,6 +35,7 @@ const TYPE_FLOAT = 0x03
const TYPE_STRING = 0x04
const TYPE_ARRAY = 0x05
const TYPE_RECORD = 0x06
+const TYPE_ENUM = 0x07
# C struct for native buffer (must match Rust NativeBuffer)
struct NativeBuffer
@@ -189,6 +190,13 @@ function _decode_value(io::IOBuffer)
dict[key] = _decode_value(io)
end
return dict
+ elseif tag == TYPE_ENUM
+ # Format: tag_len (u32) | tag_bytes | has_arg (u8) | [arg_value]
+ tag_len = ltoh(read(io, UInt32))
+ tag_name = Symbol(String(read(io, tag_len)))
+ has_arg = read(io, UInt8) != 0x00
+ arg = has_arg ? _decode_value(io) : nothing
+ return NickelEnum(tag_name, arg)
else
error("Unknown type tag in binary protocol: $tag")
end
diff --git a/test/test_ffi.jl b/test/test_ffi.jl
@@ -84,25 +84,33 @@
end
@testset "Enums" begin
- # Simple enum (no argument) - matches std.enum.to_tag_and_arg format
+ # Simple enum (no argument)
result = nickel_eval_native("let x = 'Foo in x")
- @test result isa Dict{String, Any}
- @test result["tag"] == "Foo"
- @test !haskey(result, "arg")
+ @test result isa NickelEnum
+ @test result.tag == :Foo
+ @test result.arg === nothing
+ @test result == :Foo # convenience comparison
# Enum with integer argument
result = nickel_eval_native("let x = 'Some 42 in x")
- @test result["tag"] == "Some"
- @test result["arg"] === Int64(42)
+ @test result isa NickelEnum
+ @test result.tag == :Some
+ @test result.arg === Int64(42)
+ @test result == :Some
# Enum with record argument
result = nickel_eval_native("let x = 'Ok { value = 123 } in x")
- @test result["tag"] == "Ok"
- @test result["arg"]["value"] === Int64(123)
+ @test result.tag == :Ok
+ @test result.arg isa Dict{String, Any}
+ @test result.arg["value"] === Int64(123)
- # Match expression
+ # Match expression (returns the matched value, not an enum)
result = nickel_eval_native("let x = 'Success 42 in x |> match { 'Success v => v, 'Failure _ => 0 }")
@test result === Int64(42)
+
+ # Pretty printing
+ @test repr(nickel_eval_native("let x = 'None in x")) == "'None"
+ @test repr(nickel_eval_native("let x = 'Some 42 in x")) == "'Some 42"
end
@testset "Deeply nested structures" begin