commit 9371d567fe1b4e0c5d22b2e861bbf0f37d31570f
parent bd0ee380695dbf0cb76c2ec4426fe946fc2890af
Author: Erik Loualiche <[email protected]>
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 <[email protected]>
Diffstat:
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