NickelEval.jl

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

commit 03074280f75e2fe223bc12db75aa6f7775ef815f
parent 2f40f4b6d4b6bf56459e910fdd52f3ff10b2be66
Author: Erik Loualiche <[email protected]>
Date:   Fri,  6 Feb 2026 10:57:09 -0600

Update documentation for nickel_eval_native

- Add nickel_eval_native to public API docs
- Update FFI guide with two-function comparison
- Document type mapping from Nickel to Julia
- Update TODO with completed status

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Diffstat:
MTODO.md | 144+++++++++++++++++++++----------------------------------------------------------
Mdocs/src/lib/public.md | 1+
Mdocs/src/man/ffi.md | 75+++++++++++++++++++++++++++++++++++++++++++--------------------------------
3 files changed, 81 insertions(+), 139 deletions(-)

diff --git a/TODO.md b/TODO.md @@ -1,131 +1,61 @@ -# NickelEval.jl - Next Session TODOs +# NickelEval.jl - Status & TODOs -## Goal +## Completed -**Parse Nickel directly into Julia native types** via FFI binary protocol. +### Core Features +- **Subprocess evaluation** - `nickel_eval`, `nickel_eval_file` via CLI +- **FFI native evaluation** - `nickel_eval_native` via Rust binary protocol +- **Type preservation** - Int64 vs Float64 from Nickel types directly +- **Typed evaluation** - `nickel_eval(code, T)` for Dict, NamedTuple, etc. +- **Export functions** - JSON, TOML, YAML via subprocess +- **Documentation** - VitePress site at https://louloulibs.github.io/NickelEval/ -The Rust FFI encodes Nickel values with type tags: -- `TYPE_INT (2)` → `Int64` -- `TYPE_FLOAT (3)` → `Float64` -- `TYPE_STRING (4)` → `String` -- `TYPE_BOOL (1)` → `Bool` -- `TYPE_NULL (0)` → `nothing` -- `TYPE_ARRAY (5)` → `Vector` -- `TYPE_RECORD (6)` → `Dict{String, Any}` or `NamedTuple` - -This preserves Nickel's type semantics directly—no JSON round-trip. - ---- - -## Current State - -**Done:** -- Rust FFI library (`rust/nickel-jl/src/lib.rs`) - encodes to binary protocol -- 33 Rust tests passing -- Julia FFI skeleton (`src/ffi.jl`) - calls Rust, but only JSON path implemented - -**TODO:** -- Julia binary decoder (`decode_native`) -- Build Rust library -- Test end-to-end +### Test Coverage +- 94 tests passing (53 subprocess + 41 FFI) --- -## Next Session Tasks +## Next Steps -### 1. Add Julia Binary Decoder +### 1. Cross-Platform FFI Distribution +Currently FFI requires local Rust build. Options: +- **BinaryBuilder.jl** - Create `NickelEval_jll` for automatic binary distribution +- Support Linux (x86_64, aarch64), macOS (x86_64, aarch64), Windows -In `src/ffi.jl`, add: +### 2. CI FFI Testing +Update CI workflow to build Rust library and run FFI tests. +### 3. Performance Benchmarks +Compare subprocess vs FFI: ```julia -const TYPE_NULL = 0x00 -const TYPE_BOOL = 0x01 -const TYPE_INT = 0x02 -const TYPE_FLOAT = 0x03 -const TYPE_STRING = 0x04 -const TYPE_ARRAY = 0x05 -const TYPE_RECORD = 0x06 - -function decode_native(data::Vector{UInt8}) - io = IOBuffer(data) - return _decode_value(io) -end - -function _decode_value(io::IOBuffer) - tag = read(io, UInt8) - if tag == TYPE_NULL - return nothing - elseif tag == TYPE_BOOL - return read(io, UInt8) != 0 - elseif tag == TYPE_INT - return read(io, Int64) - elseif tag == TYPE_FLOAT - return read(io, Float64) - elseif tag == TYPE_STRING - len = read(io, UInt32) - return String(read(io, len)) - elseif tag == TYPE_ARRAY - len = read(io, UInt32) - return [_decode_value(io) for _ in 1:len] - elseif tag == TYPE_RECORD - len = read(io, UInt32) - dict = Dict{String, Any}() - for _ in 1:len - key_len = read(io, UInt32) - key = String(read(io, key_len)) - dict[key] = _decode_value(io) - end - return dict - else - error("Unknown type tag: $tag") - end -end +using BenchmarkTools +@benchmark nickel_eval("{ x = 1 }") # subprocess +@benchmark nickel_eval_native("{ x = 1 }") # FFI ``` -### 2. Add `nickel_eval_native_ffi` - -```julia -function nickel_eval_native_ffi(code::String) - if !FFI_AVAILABLE - error("FFI not available. Build with: cd rust/nickel-jl && cargo build --release") - end - - buffer = ccall((:nickel_eval_native, LIB_PATH), - NativeBuffer, (Cstring,), code) - - if buffer.data == C_NULL - error_ptr = ccall((:nickel_get_error, LIB_PATH), Ptr{Cchar}, ()) - throw(NickelError(unsafe_string(error_ptr))) - end +--- - data = unsafe_wrap(Array, buffer.data, buffer.len; own=false) - result = decode_native(copy(data)) +## Nice-to-Have - ccall((:nickel_free_buffer, LIB_PATH), Cvoid, (NativeBuffer,), buffer) +- File watching for config reload +- Multi-file evaluation with imports +- NamedTuple output option for records +- Nickel contracts integration - return result -end -``` +--- -### 3. Build & Test +## Quick Reference +**Build FFI locally:** ```bash cd rust/nickel-jl && cargo build --release -mkdir -p ../../deps -cp target/release/libnickel_jl.dylib ../../deps/ # macOS +cp target/release/libnickel_jl.dylib ../deps/ # macOS +cp target/release/libnickel_jl.so ../deps/ # Linux ``` +**Test FFI:** ```julia using NickelEval -nickel_eval_native_ffi("42") # => 42::Int64 -nickel_eval_native_ffi("3.14") # => 3.14::Float64 -nickel_eval_native_ffi("{ x = 1 }") # => Dict("x" => 1) +check_ffi_available() # true if library found +nickel_eval_native("42") # => 42::Int64 ``` - ---- - -## Later (nice-to-have) - -- Cross-platform distribution via BinaryBuilder.jl -- TOML/YAML export (already works via subprocess) -- File watching diff --git a/docs/src/lib/public.md b/docs/src/lib/public.md @@ -23,6 +23,7 @@ nickel_to_yaml ```@docs check_ffi_available nickel_eval_ffi +nickel_eval_native ``` ## String Macro diff --git a/docs/src/man/ffi.md b/docs/src/man/ffi.md @@ -1,32 +1,45 @@ # FFI Mode (High Performance) -For repeated evaluations, NickelEval provides native FFI bindings to a Rust library that wraps `nickel-lang-core`. This eliminates subprocess overhead. +For repeated evaluations, NickelEval provides native FFI bindings to a Rust library that wraps `nickel-lang-core`. This eliminates subprocess overhead and preserves Nickel's type semantics. -## Checking FFI Availability +## Two FFI Functions -```julia -using NickelEval +### `nickel_eval_native` - Native Types (Recommended) -check_ffi_available() # => true or false +Parses Nickel directly into Julia native types using a binary protocol: + +```julia +nickel_eval_native("42") # => 42::Int64 +nickel_eval_native("3.14") # => 3.14::Float64 +nickel_eval_native("true") # => true::Bool +nickel_eval_native("\"hello\"") # => "hello"::String +nickel_eval_native("null") # => nothing + +nickel_eval_native("[1, 2, 3]") # => Any[1, 2, 3] +nickel_eval_native("{ x = 1 }") # => Dict("x" => 1) ``` -FFI is available when the compiled Rust library exists in the `deps/` folder. +**Key benefit:** Type preservation. Integers stay `Int64`, decimals become `Float64`. + +### `nickel_eval_ffi` - JSON-based -## Using FFI Evaluation +Uses JSON serialization internally, supports typed parsing: ```julia -# Basic evaluation -nickel_eval_ffi("1 + 2") # => 3 +nickel_eval_ffi("{ a = 1, b = 2 }") # JSON.Object with dot-access +nickel_eval_ffi("{ a = 1 }", Dict{String, Int}) # Typed Dict +``` -# With dot-access -config = nickel_eval_ffi("{ host = \"localhost\", port = 8080 }") -config.host # => "localhost" +## Checking FFI Availability -# Typed evaluation -nickel_eval_ffi("{ a = 1, b = 2 }", Dict{String, Int}) -# => Dict{String, Int64}("a" => 1, "b" => 2) +```julia +using NickelEval + +check_ffi_available() # => true or false ``` +FFI is available when the compiled Rust library exists in the `deps/` folder. + ## Building the FFI Library ### Requirements @@ -54,6 +67,20 @@ cp target/release/libnickel_jl.so ../../deps/ cp target/release/nickel_jl.dll ../../deps/ ``` +## Type Mapping + +| Nickel | Julia (native) | +|--------|----------------| +| Integer numbers | `Int64` | +| Decimal numbers | `Float64` | +| Bool | `Bool` | +| String | `String` | +| null | `nothing` | +| Array | `Vector{Any}` | +| Record | `Dict{String, Any}` | + +Note: Nickel has a single `Number` type. Whole numbers (like `42` or `42.0`) become `Int64`. Only true decimals (like `3.14`) become `Float64`. + ## Performance Comparison FFI mode is faster for repeated evaluations because it: @@ -64,22 +91,6 @@ FFI mode is faster for repeated evaluations because it: For single evaluations, the difference is minimal. For batch processing or interactive use, FFI mode is significantly faster. -## Binary Protocol - -The FFI uses a binary protocol that preserves type information: - -| Type Tag | Nickel Type | -|----------|-------------| -| 0 | Null | -| 1 | Bool | -| 2 | Int64 | -| 3 | Float64 | -| 4 | String | -| 5 | Array | -| 6 | Record | - -This allows direct conversion to Julia types without JSON parsing overhead. - ## Fallback Behavior If FFI is not available, you can still use the subprocess-based functions: @@ -89,7 +100,7 @@ If FFI is not available, you can still use the subprocess-based functions: nickel_eval("1 + 2") # Requires FFI library -nickel_eval_ffi("1 + 2") # Error if not built +nickel_eval_native("1 + 2") # Error if not built ``` ## Troubleshooting