NickelEval.jl

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

commit 9371d567fe1b4e0c5d22b2e861bbf0f37d31570f
parent bd0ee380695dbf0cb76c2ec4426fe946fc2890af
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Fri,  6 Feb 2026 09:23:03 -0600

Add typed evaluation, TOML/YAML export, and README

- Switch from JSON3.jl to JSON.jl 1.0 for native typed parsing
- Add nickel_eval(code, T) for typed evaluation to Julia types
- Add convenience export functions: nickel_to_json, nickel_to_toml, nickel_to_yaml
- JSON.Object return type supports dot-access for records
- Support NamedTuples, Dict{String,V}, Dict{Symbol,V}, Vector{T}
- Add comprehensive README with examples
- Update tests for JSON.jl 1.0 API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
MProject.toml | 4++--
AREADME.md | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/NickelEval.jl | 6++++--
Msrc/ffi.jl | 43++++++++++++++++++++++++++++++++-----------
Msrc/subprocess.jl | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtest/test_subprocess.jl | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
6 files changed, 471 insertions(+), 40 deletions(-)

diff --git a/Project.toml b/Project.toml @@ -4,10 +4,10 @@ authors = ["NickelJL Contributors"] version = "0.1.0" [deps] -JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" [compat] -JSON3 = "1" +JSON = "1" julia = "1.6" [extras] diff --git a/README.md b/README.md @@ -0,0 +1,230 @@ +# NickelEval.jl + +Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language. + +Evaluate Nickel code directly from Julia with native type conversion and export to JSON/TOML/YAML. + +## Installation + +```julia +using Pkg +Pkg.add(url="https://github.com/LouLouLibs/NickelEval") +``` + +**Prerequisite:** Install the Nickel CLI from https://nickel-lang.org/ + +## Quick Start + +```julia +using NickelEval + +# Simple evaluation +nickel_eval("1 + 2") # => 3 + +# Records return JSON.Object with dot-access +config = nickel_eval("{ name = \"alice\", age = 30 }") +config.name # => "alice" +config.age # => 30 + +# String macro for inline Nickel +ncl"[1, 2, 3] |> std.array.map (fun x => x * 2)" +# => [2, 4, 6] +``` + +## Typed Evaluation + +Convert Nickel values directly to Julia types: + +```julia +# Typed dictionaries +nickel_eval("{ a = 1, b = 2 }", Dict{String, Int}) +# => Dict{String, Int64}("a" => 1, "b" => 2) + +# Symbol keys +nickel_eval("{ x = 1.5, y = 2.5 }", Dict{Symbol, Float64}) +# => Dict{Symbol, Float64}(:x => 1.5, :y => 2.5) + +# Typed arrays +nickel_eval("[1, 2, 3, 4, 5]", Vector{Int}) +# => [1, 2, 3, 4, 5] + +# NamedTuples for structured data +config = nickel_eval(""" +{ + host = "localhost", + port = 8080, + debug = true +} +""", @NamedTuple{host::String, port::Int, debug::Bool}) +# => (host = "localhost", port = 8080, debug = true) + +config.port # => 8080 +``` + +## Export to Configuration Formats + +Generate JSON, TOML, or YAML from Nickel: + +```julia +# JSON +nickel_to_json("{ name = \"myapp\", port = 8080 }") +# => "{\n \"name\": \"myapp\",\n \"port\": 8080\n}" + +# TOML +nickel_to_toml("{ name = \"myapp\", port = 8080 }") +# => "name = \"myapp\"\nport = 8080\n" + +# YAML +nickel_to_yaml("{ name = \"myapp\", port = 8080 }") +# => "name: myapp\nport: 8080\n" + +# Or use nickel_export with format option +nickel_export("{ a = 1 }"; format=:toml) +nickel_export("{ a = 1 }"; format=:yaml) +nickel_export("{ a = 1 }"; format=:json) +``` + +### Generate Config Files + +```julia +# Generate a TOML config file from Nickel +config_ncl = """ +{ + database = { + host = "localhost", + port = 5432, + name = "mydb" + }, + server = { + host = "0.0.0.0", + port = 8080 + } +} +""" + +# Write TOML +write("config.toml", nickel_to_toml(config_ncl)) + +# Write YAML +write("config.yaml", nickel_to_yaml(config_ncl)) +``` + +## Custom Structs + +Define your own types and parse Nickel directly into them: + +```julia +struct ServerConfig + host::String + port::Int + workers::Int +end + +config = nickel_eval(""" +{ + host = "0.0.0.0", + port = 3000, + workers = 4 +} +""", ServerConfig) +# => ServerConfig("0.0.0.0", 3000, 4) +``` + +## File Evaluation + +```julia +# config.ncl: +# { +# environment = "production", +# features = ["auth", "logging", "metrics"] +# } + +# Untyped (returns JSON.Object with dot-access) +config = nickel_eval_file("config.ncl") +config.environment # => "production" + +# Typed +nickel_eval_file("config.ncl", @NamedTuple{environment::String, features::Vector{String}}) +# => (environment = "production", features = ["auth", "logging", "metrics"]) +``` + +## FFI Mode (High Performance) + +For repeated evaluations, use the native FFI bindings (no subprocess overhead): + +```julia +# Check if FFI is available +check_ffi_available() # => true/false + +# Use FFI evaluation +nickel_eval_ffi("1 + 2") # => 3 +nickel_eval_ffi("{ x = 1 }", Dict{String, Int}) # => Dict("x" => 1) +``` + +### Building FFI + +The FFI library requires Rust. To build: + +```bash +cd rust/nickel-jl +cargo build --release +cp target/release/libnickel_jl.dylib ../../deps/ # macOS +# or libnickel_jl.so on Linux, nickel_jl.dll on Windows +``` + +## API Reference + +### Evaluation Functions + +| Function | Description | +|----------|-------------| +| `nickel_eval(code)` | Evaluate Nickel code, return `JSON.Object` | +| `nickel_eval(code, T)` | Evaluate and convert to type `T` | +| `nickel_eval_file(path)` | Evaluate a `.ncl` file | +| `nickel_eval_file(path, T)` | Evaluate file and convert to type `T` | +| `nickel_read(code, T)` | Alias for `nickel_eval(code, T)` | +| `@ncl_str` | String macro for inline evaluation | + +### Export Functions + +| Function | Description | +|----------|-------------| +| `nickel_to_json(code)` | Export to JSON string | +| `nickel_to_toml(code)` | Export to TOML string | +| `nickel_to_yaml(code)` | Export to YAML string | +| `nickel_export(code; format=:json)` | Export to format (`:json`, `:yaml`, `:toml`) | + +### FFI Functions + +| Function | Description | +|----------|-------------| +| `nickel_eval_ffi(code)` | FFI-based evaluation (faster) | +| `nickel_eval_ffi(code, T)` | FFI evaluation with type conversion | +| `check_ffi_available()` | Check if FFI bindings are available | + +## Type Conversion + +| Nickel Type | Julia Type | +|-------------|------------| +| Number | `Int64` or `Float64` | +| String | `String` | +| Bool | `Bool` | +| Array | `Vector{Any}` or `Vector{T}` | +| Record | `JSON.Object` (dot-access) or `Dict{K,V}` or `NamedTuple` or struct | +| Null | `nothing` | + +## Error Handling + +```julia +try + nickel_eval("{ x = }") # syntax error +catch e + if e isa NickelError + println("Nickel error: ", e.message) + end +end +``` + +## License + +MIT diff --git a/src/NickelEval.jl b/src/NickelEval.jl @@ -1,8 +1,10 @@ module NickelEval -using JSON3 +using JSON -export nickel_eval, nickel_eval_file, nickel_export, @ncl_str, NickelError +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 # Custom exception for Nickel errors struct NickelError <: Exception diff --git a/src/ffi.jl b/src/ffi.jl @@ -13,8 +13,6 @@ # - Direct memory sharing # - Better performance for repeated evaluations -using JSON3 - # Determine platform-specific library name const LIB_NAME = if Sys.iswindows() "nickel_jl.dll" @@ -41,16 +39,40 @@ function check_ffi_available() end """ - nickel_eval_ffi(code::String) -> Any + nickel_eval_ffi(code::String) -> JSON.Object + nickel_eval_ffi(code::String, ::Type{T}) -> T + +Evaluate Nickel code using native FFI bindings (faster than subprocess). +Returns the parsed JSON result, optionally typed. + +Throws `NickelError` if FFI is not available or if evaluation fails. + +# Examples +```julia +julia> nickel_eval_ffi("1 + 2") +3 -Evaluate Nickel code using native FFI bindings. -Returns the parsed JSON result. +julia> result = nickel_eval_ffi("{ x = 1, y = 2 }") +julia> result.x # dot-access supported +1 -Throws an error if FFI is not available or if evaluation fails. +julia> nickel_eval_ffi("{ x = 1, y = 2 }", Dict{String, Int}) +Dict{String, Int64}("x" => 1, "y" => 2) +``` """ function nickel_eval_ffi(code::String) + result_json = _eval_ffi_to_json(code) + return JSON.parse(result_json) +end + +function nickel_eval_ffi(code::String, ::Type{T}) where T + result_json = _eval_ffi_to_json(code) + return JSON.parse(result_json, T) +end + +function _eval_ffi_to_json(code::String) if !FFI_AVAILABLE - error("FFI not available. Build the Rust library with: NICKELEVAL_BUILD_FFI=true julia --project=. -e 'using Pkg; Pkg.build()'") + error("FFI not available. Build the Rust library with: cd rust/nickel-jl && cargo build --release && cp target/release/libnickel_jl.dylib ../../deps/") end # Call the Rust function @@ -62,9 +84,9 @@ function nickel_eval_ffi(code::String) error_ptr = ccall((:nickel_get_error, LIB_PATH), Ptr{Cchar}, ()) if error_ptr != C_NULL error_msg = unsafe_string(error_ptr) - error("Nickel evaluation error: $error_msg") + throw(NickelError(error_msg)) else - error("Nickel evaluation failed with unknown error") + throw(NickelError("Nickel evaluation failed with unknown error")) end end @@ -74,6 +96,5 @@ function nickel_eval_ffi(code::String) # Free the allocated memory ccall((:nickel_free_string, LIB_PATH), Cvoid, (Ptr{Cchar},), result_ptr) - # Parse JSON and return - return JSON3.read(result_json) + return result_json end diff --git a/src/subprocess.jl b/src/subprocess.jl @@ -67,28 +67,27 @@ function nickel_export(code::String; format::Symbol=:json) end """ - nickel_eval(code::String) -> Any + nickel_eval(code::String) -> JSON.Object Evaluate Nickel code and return a Julia value. -The Nickel code is exported to JSON and parsed into Julia types: -- Objects become `Dict{String, Any}` -- Arrays become `Vector{Any}` -- Numbers, strings, booleans map directly +Returns a `JSON.Object` for records (supports dot-access), or native Julia types +for primitives and arrays. # Arguments - `code::String`: Nickel code to evaluate # Returns -- `Any`: The evaluated result as a Julia value +- Result as Julia value (JSON.Object for records, Vector for arrays, etc.) # Examples ```julia julia> nickel_eval("1 + 2") 3 -julia> nickel_eval("{ a = 1, b = 2 }") -Dict{String, Any}("a" => 1, "b" => 2) +julia> result = nickel_eval("{ a = 1, b = 2 }") +julia> result.a # dot-access supported +1 julia> nickel_eval("let x = 5 in x * 2") 10 @@ -96,24 +95,100 @@ julia> nickel_eval("let x = 5 in x * 2") """ function nickel_eval(code::String) json_str = nickel_export(code; format=:json) - return JSON3.read(json_str) + return JSON.parse(json_str) end """ - nickel_eval_file(path::String) -> Any + nickel_eval(code::String, ::Type{T}) -> T + +Evaluate Nickel code and parse the result directly into a specific Julia type. + +Uses JSON.jl 1.0's native typed parsing. Works with: +- Primitive types: `Int`, `Float64`, `String`, `Bool` +- Typed dictionaries: `Dict{String, Int}`, `Dict{Symbol, Float64}` +- Typed arrays: `Vector{Int}`, `Vector{String}` +- NamedTuples for quick typed record access +- Custom structs + +# Arguments +- `code::String`: Nickel code to evaluate +- `T::Type`: Target Julia type + +# Returns +- `T`: The evaluated result as the specified type + +# Examples +```julia +julia> nickel_eval("1 + 2", Int) +3 + +julia> nickel_eval("{ a = 1, b = 2 }", Dict{String, Int}) +Dict{String, Int64}("a" => 1, "b" => 2) + +julia> nickel_eval("[1, 2, 3]", Vector{Int}) +[1, 2, 3] + +julia> nickel_eval("{ x = 1.5, y = 2.5 }", @NamedTuple{x::Float64, y::Float64}) +(x = 1.5, y = 2.5) +``` +""" +function nickel_eval(code::String, ::Type{T}) where T + json_str = nickel_export(code; format=:json) + return JSON.parse(json_str, T) +end + +""" + nickel_read(code::String, ::Type{T}) -> T + +Alias for `nickel_eval(code, T)`. Evaluate Nickel code into a typed Julia value. + +# Examples +```julia +julia> nickel_read("{ port = 8080, host = \"localhost\" }", @NamedTuple{port::Int, host::String}) +(port = 8080, host = "localhost") +``` +""" +nickel_read(code::String, ::Type{T}) where T = nickel_eval(code, T) + +""" + nickel_eval_file(path::String) -> JSON.Object + nickel_eval_file(path::String, ::Type{T}) -> T Evaluate a Nickel file and return a Julia value. # Arguments - `path::String`: Path to the Nickel file +- `T::Type`: (optional) Target Julia type for typed parsing # Returns -- `Any`: The evaluated result as a Julia value +- `JSON.Object` or `T`: The evaluated result as a Julia value # Throws - `NickelError`: If file doesn't exist or evaluation fails + +# Examples +```julia +# Untyped evaluation (returns JSON.Object with dot-access) +julia> config = nickel_eval_file("config.ncl") +julia> config.port +8080 + +# Typed evaluation +julia> nickel_eval_file("config.ncl", @NamedTuple{port::Int, host::String}) +(port = 8080, host = "localhost") +``` """ function nickel_eval_file(path::String) + json_str = _eval_file_to_json(path) + return JSON.parse(json_str) +end + +function nickel_eval_file(path::String, ::Type{T}) where T + json_str = _eval_file_to_json(path) + return JSON.parse(json_str, T) +end + +function _eval_file_to_json(path::String) if !isfile(path) throw(NickelError("File not found: $path")) end @@ -127,8 +202,7 @@ function nickel_eval_file(path::String) try run(pipeline(cmd, stdout=stdout_buf, stderr=stderr_buf), wait=true) - json_str = String(take!(stdout_buf)) - return JSON3.read(json_str) + return String(take!(stdout_buf)) catch e stderr_content = String(take!(stderr_buf)) stdout_content = String(take!(stdout_buf)) @@ -150,8 +224,8 @@ String macro for inline Nickel evaluation. julia> ncl"1 + 2" 3 -julia> ncl"{ name = \"test\", value = 42 }" -Dict{String, Any}("name" => "test", "value" => 42) +julia> ncl"{ name = \"test\", value = 42 }".name +"test" julia> ncl\"\"\" let @@ -167,3 +241,44 @@ macro ncl_str(code) nickel_eval($code) end end + +# Convenience export functions + +""" + nickel_to_json(code::String) -> String + +Export Nickel code to JSON string. + +# Examples +```julia +julia> nickel_to_json("{ a = 1, b = 2 }") +"{\\n \\"a\\": 1,\\n \\"b\\": 2\\n}" +``` +""" +nickel_to_json(code::String) = nickel_export(code; format=:json) + +""" + nickel_to_toml(code::String) -> String + +Export Nickel code to TOML string. + +# Examples +```julia +julia> nickel_to_toml("{ name = \"myapp\", port = 8080 }") +"name = \\"myapp\\"\\nport = 8080\\n" +``` +""" +nickel_to_toml(code::String) = nickel_export(code; format=:toml) + +""" + nickel_to_yaml(code::String) -> String + +Export Nickel code to YAML string. + +# Examples +```julia +julia> nickel_to_yaml("{ name = \"myapp\", port = 8080 }") +"name: myapp\\nport: 8080\\n" +``` +""" +nickel_to_yaml(code::String) = nickel_export(code; format=:yaml) diff --git a/test/test_subprocess.jl b/test/test_subprocess.jl @@ -16,12 +16,12 @@ @testset "Records (Objects)" begin result = nickel_eval("{ a = 1, b = 2 }") - @test result["a"] == 1 - @test result["b"] == 2 + @test result.a == 1 + @test result.b == 2 result = nickel_eval("{ name = \"test\", value = 42 }") - @test result["name"] == "test" - @test result["value"] == 42 + @test result.name == "test" + @test result.value == 42 end @testset "Arrays" begin @@ -32,29 +32,39 @@ @testset "Nested structures" begin result = nickel_eval("{ outer = { inner = 42 } }") - @test result["outer"]["inner"] == 42 + @test result.outer.inner == 42 result = nickel_eval("{ items = [1, 2, 3] }") - @test result["items"] == [1, 2, 3] + @test result.items == [1, 2, 3] end @testset "String macro" begin @test ncl"1 + 1" == 2 - @test ncl"{ x = 10 }"["x"] == 10 + @test ncl"{ x = 10 }".x == 10 end @testset "File evaluation" begin fixture_path = joinpath(@__DIR__, "fixtures", "simple.ncl") result = nickel_eval_file(fixture_path) - @test result["name"] == "test" - @test result["value"] == 42 - @test result["computed"] == 84 + @test result.name == "test" + @test result.value == 42 + @test result.computed == 84 end @testset "Export formats" begin + # JSON json_output = nickel_export("{ a = 1 }"; format=:json) @test occursin("\"a\"", json_output) @test occursin("1", json_output) + + # TOML + toml_output = nickel_export("{ a = 1 }"; format=:toml) + @test occursin("a", toml_output) + @test occursin("1", toml_output) + + # YAML + yaml_output = nickel_export("{ a = 1, b = \"hello\" }"; format=:yaml) + @test occursin("a:", yaml_output) || occursin("a :", yaml_output) end @testset "Error handling" begin @@ -62,4 +72,57 @@ @test_throws NickelError nickel_eval_file("/nonexistent/path.ncl") @test_throws NickelError nickel_export("1"; format=:invalid) end + + @testset "Typed evaluation - primitives" begin + @test nickel_eval("42", Int) === 42 + @test nickel_eval("3.14", Float64) === 3.14 + @test nickel_eval("\"hello\"", String) == "hello" + @test nickel_eval("true", Bool) === true + end + + @testset "Typed evaluation - Dict{String, V}" begin + result = nickel_eval("{ a = 1, b = 2 }", Dict{String, Int}) + @test result isa Dict{String, Int} + @test result["a"] === 1 + @test result["b"] === 2 + end + + @testset "Typed evaluation - Dict{Symbol, V}" begin + result = nickel_eval("{ x = 1.5, y = 2.5 }", Dict{Symbol, Float64}) + @test result isa Dict{Symbol, Float64} + @test result[:x] === 1.5 + @test result[:y] === 2.5 + end + + @testset "Typed evaluation - Vector{T}" begin + result = nickel_eval("[1, 2, 3]", Vector{Int}) + @test result isa Vector{Int} + @test result == [1, 2, 3] + + result = nickel_eval("[\"a\", \"b\", \"c\"]", Vector{String}) + @test result isa Vector{String} + @test result == ["a", "b", "c"] + end + + @testset "Typed evaluation - NamedTuple" begin + result = nickel_eval("{ host = \"localhost\", port = 8080 }", + @NamedTuple{host::String, port::Int}) + @test result isa NamedTuple{(:host, :port), Tuple{String, Int}} + @test result.host == "localhost" + @test result.port === 8080 + end + + @testset "Typed file evaluation" begin + fixture_path = joinpath(@__DIR__, "fixtures", "simple.ncl") + result = nickel_eval_file(fixture_path, @NamedTuple{name::String, value::Int, computed::Int}) + @test result.name == "test" + @test result.value === 42 + @test result.computed === 84 + end + + @testset "nickel_read alias" begin + result = nickel_read("{ a = 1 }", Dict{String, Int}) + @test result isa Dict{String, Int} + @test result["a"] === 1 + end end