commit 242a74f7a37936055e413df2bc74e1bf72894745
parent 2a0696ab744b8de9f50ad3619957d4391cf696b8
Author: Erik Loualiche <eloualic@umn.edu>
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 <noreply@anthropic.com>
Diffstat:
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