NickelEval.jl

Julia FFI bindings for Nickel configuration language
Log | Files | Refs | README | LICENSE

commit 9444b313180cbd90431b3f41bb865accca7fbf01
parent b6669452679a49b3ff825daf266b546fbca08a7b
Author: Erik Loualiche <eloualic@umn.edu>
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 <noreply@anthropic.com>

Diffstat:
Mdocs/src/lib/public.md | 7+++++++
Mdocs/src/man/ffi.md | 39++++++++++++++++++++++++++++-----------
Mrust/nickel-jl/src/lib.rs | 63+++++++++++++++++++++++++++++----------------------------------
Msrc/NickelEval.jl | 40++++++++++++++++++++++++++++++++++++++++
Msrc/ffi.jl | 8++++++++
Mtest/test_ffi.jl | 26+++++++++++++++++---------
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