NickelEval.jl

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

commit 242a74f7a37936055e413df2bc74e1bf72894745
parent 2a0696ab744b8de9f50ad3619957d4391cf696b8
Author: Erik Loualiche <[email protected]>
Date:   Fri,  6 Feb 2026 17:33:14 -0600

Add nickel_eval_file_native for FFI file evaluation with imports

Enable evaluating Nickel files via FFI with proper import resolution.
Files can now use `import` statements to include other Nickel files,
with paths resolved relative to the evaluated file's directory.

- Add nickel_eval_file_native Rust function using Program::new_from_file
- Add Julia wrapper with automatic absolute path conversion
- Add comprehensive tests for imports, nested imports, subdirectories
- Update documentation with examples and import resolution rules
- 180 tests passing (39 Rust, 180 Julia)

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

Diffstat:
MTODO.md | 4++--
Mdocs/src/man/ffi.md | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mrust/nickel-jl/src/lib.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/NickelEval.jl | 2+-
Msrc/ffi.jl | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/test_ffi.jl | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 343 insertions(+), 4 deletions(-)

diff --git a/TODO.md b/TODO.md @@ -7,6 +7,7 @@ ### Core Evaluation - **Subprocess evaluation** - `nickel_eval`, `nickel_eval_file`, `nickel_read` via Nickel CLI - **FFI native evaluation** - `nickel_eval_native` via Rust binary protocol +- **FFI file evaluation** - `nickel_eval_file_native` with import support - **FFI JSON evaluation** - `nickel_eval_ffi` with typed parsing support ### Type System @@ -24,7 +25,7 @@ ### Infrastructure - Documentation site: https://louloulibs.github.io/NickelEval/dev/ -- 167 tests passing (53 subprocess + 114 FFI) +- 180 tests passing (53 subprocess + 127 FFI) - CI: tests + documentation deployment - Registry: loulouJL @@ -53,7 +54,6 @@ using BenchmarkTools ## Nice-to-Have - **File watching** - auto-reload config on file change -- **Multi-file evaluation** - support Nickel imports - **NamedTuple output** - optional record → NamedTuple conversion - **Nickel contracts** - expose type validation diff --git a/docs/src/man/ffi.md b/docs/src/man/ffi.md @@ -2,7 +2,7 @@ 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. -## Two FFI Functions +## FFI Functions ### `nickel_eval_native` - Native Types (Recommended) @@ -21,6 +21,57 @@ nickel_eval_native("{ x = 1 }") # => Dict("x" => 1) **Key benefit:** Type preservation. Integers stay `Int64`, decimals become `Float64`. +### `nickel_eval_file_native` - File Evaluation with Imports + +Evaluates Nickel files from the filesystem, supporting `import` statements: + +```julia +# config.ncl: +# let shared = import "shared.ncl" in +# { name = shared.project_name, version = "1.0" } + +nickel_eval_file_native("config.ncl") +# => Dict{String, Any}("name" => "MyProject", "version" => "1.0") +``` + +**Import resolution:** +- `import "other.ncl"` - resolved relative to the file's directory +- `import "lib/module.ncl"` - subdirectory paths supported +- `import "/absolute/path.ncl"` - absolute paths work too + +**Example with nested imports:** + +```julia +# Create a project structure: +# project/ +# ├── main.ncl (imports shared.ncl and lib/utils.ncl) +# ├── shared.ncl +# └── lib/ +# └── utils.ncl + +# shared.ncl +# { +# project_name = "MyApp" +# } + +# lib/utils.ncl +# { +# double = fun x => x * 2 +# } + +# main.ncl +# let shared = import "shared.ncl" in +# let utils = import "lib/utils.ncl" in +# { +# name = shared.project_name, +# result = utils.double 21 +# } + +result = nickel_eval_file_native("project/main.ncl") +result["name"] # => "MyApp" +result["result"] # => 42 +``` + ### `nickel_eval_ffi` - JSON-based Uses JSON serialization internally, supports typed parsing: diff --git a/rust/nickel-jl/src/lib.rs b/rust/nickel-jl/src/lib.rs @@ -125,6 +125,46 @@ pub unsafe extern "C" fn nickel_eval_native(code: *const c_char) -> NativeBuffer } } +/// Evaluate a Nickel file and return binary-encoded native types. +/// +/// This function evaluates a Nickel file from the filesystem, which allows +/// the file to use `import` statements to include other Nickel files. +/// +/// # Safety +/// - `path` must be a valid null-terminated C string containing a file path +/// - The returned buffer must be freed with `nickel_free_buffer` +/// - Returns NativeBuffer with null data on error; use `nickel_get_error` for message +#[no_mangle] +pub unsafe extern "C" fn nickel_eval_file_native(path: *const c_char) -> NativeBuffer { + let null_buffer = NativeBuffer { data: ptr::null_mut(), len: 0 }; + + if path.is_null() { + set_error("Null pointer passed to nickel_eval_file_native"); + return null_buffer; + } + + let path_str = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(e) => { + set_error(&format!("Invalid UTF-8 in path: {}", e)); + return null_buffer; + } + }; + + match eval_nickel_file_native(path_str) { + Ok(buffer) => { + let len = buffer.len(); + let boxed = buffer.into_boxed_slice(); + let data = Box::into_raw(boxed) as *mut u8; + NativeBuffer { data, len } + } + Err(e) => { + set_error(&e); + null_buffer + } + } +} + /// Internal function to evaluate Nickel code and return JSON. fn eval_nickel_json(code: &str) -> Result<String, String> { let source = Cursor::new(code.as_bytes()); @@ -154,6 +194,23 @@ fn eval_nickel_native(code: &str) -> Result<Vec<u8>, String> { Ok(buffer) } +/// Internal function to evaluate a Nickel file and return binary-encoded native types. +fn eval_nickel_file_native(path: &str) -> Result<Vec<u8>, String> { + use std::path::PathBuf; + + let file_path = PathBuf::from(path); + let mut program: Program<CBNCache> = Program::new_from_file(&file_path, std::io::sink()) + .map_err(|e| format!("Error loading file: {}", e))?; + + let result = program + .eval_full_for_export() + .map_err(|e| program.report_as_str(e))?; + + let mut buffer = Vec::new(); + encode_term(&result, &mut buffer)?; + Ok(buffer) +} + /// Encode a Nickel term to binary format fn encode_term(term: &RichTerm, buffer: &mut Vec<u8>) -> Result<(), String> { match term.as_ref() { @@ -791,4 +848,82 @@ mod tests { nickel_free_buffer(buffer); } } + + #[test] + fn test_file_eval_native() { + use std::fs; + use std::io::Write; + + // Create a temp directory with test files + let temp_dir = std::env::temp_dir().join("nickel_test"); + fs::create_dir_all(&temp_dir).unwrap(); + + // Create a simple file + let simple_file = temp_dir.join("simple.ncl"); + let mut f = fs::File::create(&simple_file).unwrap(); + writeln!(f, "{{ x = 42 }}").unwrap(); + + unsafe { + let path = CString::new(simple_file.to_str().unwrap()).unwrap(); + let buffer = nickel_eval_file_native(path.as_ptr()); + assert!(!buffer.data.is_null(), "Expected result, got error: {:?}", + CStr::from_ptr(nickel_get_error()).to_str()); + let data = std::slice::from_raw_parts(buffer.data, buffer.len); + assert_eq!(data[0], TYPE_RECORD); + nickel_free_buffer(buffer); + } + + // Clean up + fs::remove_file(simple_file).unwrap(); + } + + #[test] + fn test_file_eval_with_imports() { + use std::fs; + use std::io::Write; + + // Create a temp directory with test files + let temp_dir = std::env::temp_dir().join("nickel_import_test"); + fs::create_dir_all(&temp_dir).unwrap(); + + // Create shared.ncl + let shared_file = temp_dir.join("shared.ncl"); + let mut f = fs::File::create(&shared_file).unwrap(); + writeln!(f, "{{ name = \"test\", value = 42 }}").unwrap(); + + // Create main.ncl that imports shared.ncl + let main_file = temp_dir.join("main.ncl"); + let mut f = fs::File::create(&main_file).unwrap(); + writeln!(f, "let shared = import \"shared.ncl\" in").unwrap(); + writeln!(f, "{{ imported_name = shared.name, extra = \"added\" }}").unwrap(); + + unsafe { + let path = CString::new(main_file.to_str().unwrap()).unwrap(); + let buffer = nickel_eval_file_native(path.as_ptr()); + assert!(!buffer.data.is_null(), "Expected result, got error: {:?}", + CStr::from_ptr(nickel_get_error()).to_str()); + let data = std::slice::from_raw_parts(buffer.data, buffer.len); + // Should be a record with two fields + assert_eq!(data[0], TYPE_RECORD); + let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap()); + assert_eq!(field_count, 2); + nickel_free_buffer(buffer); + } + + // Clean up + fs::remove_file(main_file).unwrap(); + fs::remove_file(shared_file).unwrap(); + fs::remove_dir(temp_dir).unwrap(); + } + + #[test] + fn test_file_eval_not_found() { + unsafe { + let path = CString::new("/nonexistent/path/file.ncl").unwrap(); + let buffer = nickel_eval_file_native(path.as_ptr()); + assert!(buffer.data.is_null()); + let error = nickel_get_error(); + assert!(!error.is_null()); + } + } } diff --git a/src/NickelEval.jl b/src/NickelEval.jl @@ -4,7 +4,7 @@ using JSON export nickel_eval, nickel_eval_file, nickel_export, nickel_read, @ncl_str, NickelError export nickel_to_json, nickel_to_toml, nickel_to_yaml -export check_ffi_available, nickel_eval_ffi, nickel_eval_native +export check_ffi_available, nickel_eval_ffi, nickel_eval_native, nickel_eval_file_native export find_nickel_executable export NickelEnum diff --git a/src/ffi.jl b/src/ffi.jl @@ -153,6 +153,54 @@ function nickel_eval_native(code::String) return _decode_native(data) end +""" + nickel_eval_file_native(path::String) -> Any + +Evaluate a Nickel file using native FFI with binary protocol. +This function supports Nickel imports - files can use `import` statements +to include other Nickel files relative to the evaluated file's location. + +Returns Julia native types directly from Nickel's type system (same as `nickel_eval_native`). + +# Examples +```julia +# config.ncl: +# let shared = import "shared.ncl" in +# { name = shared.project_name, version = "1.0" } + +julia> nickel_eval_file_native("config.ncl") +Dict{String, Any}("name" => "MyProject", "version" => "1.0") +``` + +# Import Resolution +Imports are resolved relative to the file being evaluated: +- `import "other.ncl"` - relative to the file's directory +- `import "/absolute/path.ncl"` - absolute path +""" +function nickel_eval_file_native(path::String) + _check_ffi_available() + + # Convert to absolute path for proper import resolution + abs_path = abspath(path) + + buffer = ccall((:nickel_eval_file_native, LIB_PATH), + NativeBuffer, (Cstring,), abs_path) + + if buffer.data == C_NULL + _throw_ffi_error() + end + + # Copy data before freeing (Rust owns the memory) + data = Vector{UInt8}(undef, buffer.len) + unsafe_copyto!(pointer(data), buffer.data, buffer.len) + + # Free the Rust buffer + ccall((:nickel_free_buffer, LIB_PATH), Cvoid, (NativeBuffer,), buffer) + + # Decode the binary protocol + return _decode_native(data) +end + # Decode binary-encoded Nickel value to Julia native types. function _decode_native(data::Vector{UInt8}) io = IOBuffer(data) diff --git a/test/test_ffi.jl b/test/test_ffi.jl @@ -349,3 +349,108 @@ end @test result isa Dict{String, Int} @test result["x"] == 1 end + +@testset "FFI File Evaluation with Imports" begin + # Create temp files for testing imports + mktempdir() do dir + # Create a shared config file + shared_file = joinpath(dir, "shared.ncl") + write(shared_file, """ + { + project_name = "TestProject", + version = "1.0.0" + } + """) + + # Create a main file that imports shared + main_file = joinpath(dir, "main.ncl") + write(main_file, """ + let shared = import "shared.ncl" in + { + name = shared.project_name, + version = shared.version, + extra = "main-specific" + } + """) + + # Test basic file evaluation with import + result = nickel_eval_file_native(main_file) + @test result isa Dict{String, Any} + @test result["name"] == "TestProject" + @test result["version"] == "1.0.0" + @test result["extra"] == "main-specific" + + # Test nested imports + utils_file = joinpath(dir, "utils.ncl") + write(utils_file, """ + { + helper = fun x => x * 2 + } + """) + + complex_file = joinpath(dir, "complex.ncl") + write(complex_file, """ + let shared = import "shared.ncl" in + let utils = import "utils.ncl" in + { + project = shared.project_name, + doubled_value = utils.helper 21 + } + """) + + result = nickel_eval_file_native(complex_file) + @test result["project"] == "TestProject" + @test result["doubled_value"] === Int64(42) + + # Test file evaluation with enums + enum_file = joinpath(dir, "enum_config.ncl") + write(enum_file, """ + { + status = 'Active, + result = 'Ok 42 + } + """) + + result = nickel_eval_file_native(enum_file) + @test result["status"] isa NickelEnum + @test result["status"] == :Active + @test result["result"].tag == :Ok + @test result["result"].arg === Int64(42) + + # Test subdirectory imports + subdir = joinpath(dir, "lib") + mkdir(subdir) + lib_file = joinpath(subdir, "library.ncl") + write(lib_file, """ + { + lib_version = "2.0" + } + """) + + with_subdir_file = joinpath(dir, "use_lib.ncl") + write(with_subdir_file, """ + let lib = import "lib/library.ncl" in + { + using = lib.lib_version + } + """) + + result = nickel_eval_file_native(with_subdir_file) + @test result["using"] == "2.0" + end + + @testset "Error handling" begin + # File not found + @test_throws NickelError nickel_eval_file_native("/nonexistent/path/file.ncl") + + # Import not found + mktempdir() do dir + bad_import = joinpath(dir, "bad_import.ncl") + write(bad_import, """ + let missing = import "not_there.ncl" in + missing + """) + @test_throws NickelError nickel_eval_file_native(bad_import) + end + end +end