commit eee6e22231d55bb07e3e9a05918b84911905781a
parent ab9e20e277a75d135fdaaaca618256e4d924c1dd
Author: Erik Loualiche <[email protected]>
Date: Wed, 18 Mar 2026 11:22:37 -0400
Merge pull request #2 from LouLouLibs/feature/c-api-bindings
Replace custom Rust FFI with official Nickel C API
Diffstat:
30 files changed, 3457 insertions(+), 3061 deletions(-)
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
@@ -16,10 +16,14 @@ jobs:
with:
version: '1'
- uses: julia-actions/cache@v2
- - name: Install Nickel
- run: |
- curl -L https://github.com/tweag/nickel/releases/download/1.15.1/nickel-x86_64-linux -o nickel
- chmod +x nickel
- sudo mv nickel /usr/local/bin/
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ - name: Cache Rust build
+ uses: actions/cache@v4
+ with:
+ path: deps/_nickel_src/target
+ key: nickel-capi-${{ runner.os }}-1.16.0
+ - name: Build Nickel C API
+ run: NICKELEVAL_BUILD_FFI=true julia --project=. deps/build.jl
- name: Run tests
run: julia --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()'
diff --git a/.github/workflows/build-ffi.yml b/.github/workflows/build-ffi.yml
@@ -14,25 +14,18 @@ jobs:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- artifact: libnickel_jl.so
- artifact_name: libnickel_jl-x86_64-linux-gnu.tar.gz
- - os: macos-14
- target: x86_64-apple-darwin
- artifact: libnickel_jl.dylib
- artifact_name: libnickel_jl-x86_64-apple-darwin.tar.gz
+ artifact: libnickel_lang.so
+ artifact_name: libnickel_lang-x86_64-linux-gnu.tar.gz
- os: macos-14
target: aarch64-apple-darwin
- artifact: libnickel_jl.dylib
- artifact_name: libnickel_jl-aarch64-apple-darwin.tar.gz
- - os: windows-latest
- target: x86_64-pc-windows-msvc
- artifact: nickel_jl.dll
- artifact_name: nickel_jl-x86_64-windows.tar.gz
+ artifact: libnickel_lang.dylib
+ artifact_name: libnickel_lang-aarch64-apple-darwin.tar.gz
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v4
+ - name: Clone Nickel
+ run: git clone --depth 1 --branch 1.16.0 https://github.com/nickel-lang/nickel.git
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -40,24 +33,15 @@ jobs:
targets: ${{ matrix.target }}
- name: Build library
- working-directory: rust/nickel-jl
- run: cargo build --release --target ${{ matrix.target }}
+ working-directory: nickel
+ run: cargo build --release -p nickel-lang --features capi --target ${{ matrix.target }}
- - name: Package artifact (Unix)
- if: runner.os != 'Windows'
+ - name: Package artifact
run: |
- cd rust/nickel-jl/target/${{ matrix.target }}/release
+ cd nickel/target/${{ matrix.target }}/release
tar -czvf ${{ matrix.artifact_name }} ${{ matrix.artifact }}
mv ${{ matrix.artifact_name }} ${{ github.workspace }}/
- - name: Package artifact (Windows)
- if: runner.os == 'Windows'
- shell: pwsh
- run: |
- cd rust/nickel-jl/target/${{ matrix.target }}/release
- tar -czvf ${{ matrix.artifact_name }} ${{ matrix.artifact }}
- Move-Item ${{ matrix.artifact_name }} ${{ github.workspace }}
-
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
diff --git a/Artifacts.toml b/Artifacts.toml
@@ -1,47 +1,26 @@
-# Pre-built FFI library binaries
-# Built by GitHub Actions: .github/workflows/build-ffi.yml
-# Release: https://github.com/LouLouLibs/NickelEval.jl/releases/tag/v0.5.0
+# Pre-built Nickel C API library
+# Built by GitHub Actions from nickel-lang/nickel v1.16.0 with --features capi
+# Platforms: aarch64-darwin, x86_64-linux
+# Release: https://github.com/LouLouLibs/NickelEval.jl/releases/tag/v0.6.0-rc1
# macOS Apple Silicon (aarch64)
-[[libnickel_jl]]
+[[libnickel_lang]]
arch = "aarch64"
-git-tree-sha1 = "650aae37b4208762e0f4d7134b7bf53cd2faa4a4"
+git-tree-sha1 = "850a922c119a49528bb5ca2e92d5f47adc25940c"
os = "macos"
lazy = true
- [[libnickel_jl.download]]
- url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.5.0/libnickel_jl-aarch64-apple-darwin.tar.gz"
- sha256 = "ee01809a7439404932665c5ecbca223d6366013b29441a2a528e5724b1237e35"
-
-# macOS Intel (x86_64)
-[[libnickel_jl]]
-arch = "x86_64"
-git-tree-sha1 = "c16dc26899c353f310833867f8609076f136af76"
-os = "macos"
-lazy = true
-
- [[libnickel_jl.download]]
- url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.5.0/libnickel_jl-x86_64-apple-darwin.tar.gz"
- sha256 = "53f68b6f1d45e7e9d9b295adee7fa6db4f6dd6d83dac80fea49734196ca21549"
+ [[libnickel_lang.download]]
+ url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.6.0-rc1/libnickel_lang-aarch64-apple-darwin.tar.gz"
+ sha256 = "f379b07859fb5e9b41694920164c98fc31860510a410991624c542141f274a5e"
# Linux x86_64
-[[libnickel_jl]]
+[[libnickel_lang]]
arch = "x86_64"
-git-tree-sha1 = "f5c565d2115df3f94ebb6e3cc964240630502826"
+git-tree-sha1 = "f0b91ffa4d0c655cf1b45897c810777928f45728"
os = "linux"
lazy = true
- [[libnickel_jl.download]]
- url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.5.0/libnickel_jl-x86_64-linux-gnu.tar.gz"
- sha256 = "724737db98179cbf3950dbdfc2090e6af9286323ca468d0ddeee6aad60060411"
-
-# Windows x86_64
-[[libnickel_jl]]
-arch = "x86_64"
-git-tree-sha1 = "b8ec9ea4b8f474f04ec3bdc010e1d96bce0b64f9"
-os = "windows"
-lazy = true
-
- [[libnickel_jl.download]]
- url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.5.0/nickel_jl-x86_64-windows.tar.gz"
- sha256 = "30feedd4e255e0b87499e2f99c37d454ccfa44bca89e02ba0e6900f326315e74"
+ [[libnickel_lang.download]]
+ url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.6.0-rc1/libnickel_lang-x86_64-linux-gnu.tar.gz"
+ sha256 = "dd9ff0c4070e2f033666548fc2b4507b9128f6a067de7923baded7dec39d8096"
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -2,7 +2,7 @@
## Project Overview
-NickelEval.jl provides Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language. It supports both subprocess-based evaluation (using the Nickel CLI) and native FFI evaluation (using a Rust wrapper around nickel-lang-core).
+NickelEval.jl provides Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language using the official Nickel C API.
## Architecture
@@ -10,43 +10,42 @@ NickelEval.jl provides Julia bindings for the [Nickel](https://nickel-lang.org/)
NickelEval/
├── src/
│ ├── NickelEval.jl # Main module
-│ ├── subprocess.jl # CLI-based evaluation
-│ └── ffi.jl # Native FFI bindings
+│ ├── libnickel.jl # C API FFI bindings and tree-walk evaluation
+│ └── ffi.jl # High-level Julia API (nickel_eval, exports, etc.)
├── rust/
-│ └── nickel-jl/ # Rust FFI wrapper
-│ ├── Cargo.toml
-│ └── src/lib.rs
+│ └── nickel-lang/ # Nickel crate built with --features capi
+│ └── Cargo.toml
├── deps/
-│ └── build.jl # Build script for FFI
-├── Artifacts.toml # Pre-built FFI library URLs/hashes
+│ └── build.jl # Build script (source build or artifact download)
+├── Artifacts.toml # Pre-built library URLs/hashes (aarch64-darwin, x86_64-linux)
├── .github/workflows/
│ ├── CI.yml # Julia tests
│ ├── Documentation.yml
│ └── build-ffi.yml # Cross-platform FFI builds
└── test/
- └── test_subprocess.jl
+ └── runtests.jl
```
## Key Design Decisions
-### 1. Use JSON.jl 1.0 (not JSON3.jl)
+### 1. Official Nickel C API
-JSON.jl 1.0 provides:
-- Native typed parsing with `JSON.parse(json, T)`
-- `JSON.Object` return type with dot-access for records
-- Better Julia integration
+NickelEval uses the official C API exposed by the `nickel-lang` crate (v2.0.0+) built with `--features capi`. This provides:
+- A stable, supported interface to the Nickel evaluator
+- Tree-walk value extraction without a custom binary protocol
+- No Nickel CLI dependency
-### 2. Types from Nickel FFI, Not JSON
+### 2. Types via C API Tree-Walk
-The Rust FFI returns a binary protocol with native type information:
-- Type tags: 0=Null, 1=Bool, 2=Int64, 3=Float64, 4=String, 5=Array, 6=Record, 7=Enum
-- Direct memory encoding without JSON serialization overhead
-- Preserves integer vs float distinction
-- Enum variants preserved as `NickelEnum(tag, arg)`
+The C API is walked recursively on the Julia side using `ccall`. Values are converted to Julia native types:
+- Records → `Dict{String, Any}`
+- Arrays → `Vector{Any}`
+- Integers → `Int64`, Floats → `Float64`
+- Enums → `NickelEnum(tag, arg)`
### 3. Avoid `unwrap()` in Rust
-Use proper error handling:
+Use proper error handling in any Rust glue code:
```rust
// Bad
let f = value.to_f64().unwrap();
@@ -55,33 +54,21 @@ let f = value.to_f64().unwrap();
let f = f64::try_from(value).map_err(|e| format!("Error: {:?}", e))?;
```
-For number conversion, use malachite's `RoundingFrom` trait to handle inexact conversions:
-```rust
-use malachite::rounding_modes::RoundingMode;
-use malachite::num::conversion::traits::RoundingFrom;
-
-let (f, _) = f64::rounding_from(&rational, RoundingMode::Nearest);
-```
-
## Building
-### Rust FFI Library
+### Nickel C API Library
```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
+cd rust/nickel-lang
+cargo build -p nickel-lang --features capi --release
+cp target/release/libnickel_lang.dylib ../../deps/ # macOS
+# or libnickel_lang.so on Linux
```
### Running Tests
```bash
-# Rust tests
-cd rust/nickel-jl
-cargo test
-
-# Julia tests (requires Nickel CLI installed)
+# Julia tests
julia --project=. -e 'using Pkg; Pkg.test()'
```
@@ -115,21 +102,21 @@ git push origin vX.Y.Z
### FFI Artifact Release
-When the Rust FFI code changes, new pre-built binaries must be released:
+When the C API library changes, new pre-built binaries must be released:
1. **Trigger the build workflow:**
```bash
gh workflow run build-ffi.yml --ref main
```
-2. **Download built artifacts** from the workflow run (4 platforms: aarch64-darwin, x86_64-darwin, x86_64-linux, x86_64-windows)
+2. **Download built artifacts** from the workflow run (2 platforms: aarch64-darwin, x86_64-linux)
3. **Create GitHub Release** and upload the `.tar.gz` files
4. **Calculate tree hashes** for each artifact:
```bash
# For each tarball:
- tar -xzf libnickel_jl-PLATFORM.tar.gz
+ tar -xzf libnickel_lang-PLATFORM.tar.gz
julia -e 'using Pkg; println(Pkg.GitTools.tree_hash("."))'
```
@@ -140,19 +127,19 @@ When the Rust FFI code changes, new pre-built binaries must be released:
Use the `[[artifact_name]]` array format with platform properties:
```toml
-[[libnickel_jl]]
+[[libnickel_lang]]
arch = "aarch64"
git-tree-sha1 = "TREE_HASH_HERE"
os = "macos"
lazy = true
- [[libnickel_jl.download]]
- url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/vX.Y.Z/libnickel_jl-aarch64-apple-darwin.tar.gz"
+ [[libnickel_lang.download]]
+ url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/vX.Y.Z/libnickel_lang-aarch64-apple-darwin.tar.gz"
sha256 = "SHA256_HASH_HERE"
```
Platform values:
-- `os`: "macos", "linux", "windows"
+- `os`: "macos", "linux"
- `arch`: "aarch64", "x86_64"
### Documentation Requirements
@@ -176,17 +163,11 @@ Location: `/Users/loulou/Dropbox/projects_code/julia_packages/loulouJL/N/NickelE
```toml
["X.Y-0"]
Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
- JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
LazyArtifacts = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
```
**Important:** Version ranges must not overlap. Use `"X.Y-Z.W"` for ranges.
-3. **Compat.toml** - If compat bounds changed:
- ```toml
- ["X.Y-0"]
- JSON = ["0.21", "1"]
- ```
- Use arrays for multiple compatible versions: `["0.21", "1"]`
+3. **Compat.toml** - If compat bounds changed, update accordingly.
**Registry format rules:**
- Section headers must be quoted: `["0.5"]` not `[0.5]`
@@ -194,56 +175,37 @@ Location: `/Users/loulou/Dropbox/projects_code/julia_packages/loulouJL/N/NickelE
- No spaces in ranges
- Ranges must not overlap for the same dependency
-## Binary Protocol Specification
-
-The FFI uses a binary protocol for native type encoding:
-
-| Type Tag | Encoding |
-|----------|----------|
-| 0 (Null) | Just the tag byte |
-| 1 (Bool) | Tag + 1 byte (0=false, 1=true) |
-| 2 (Int64) | Tag + 8 bytes (little-endian i64) |
-| 3 (Float64) | Tag + 8 bytes (little-endian f64) |
-| 4 (String) | Tag + 4 bytes length + UTF-8 bytes |
-| 5 (Array) | Tag + 4 bytes count + elements |
-| 6 (Record) | Tag + 4 bytes field count + (key_len, key, value)* |
-| 7 (Enum) | Tag + 4 bytes tag_len + tag_bytes + 1 byte has_arg + [arg_value] |
-
## API Functions
-### Evaluation (Subprocess - requires Nickel CLI)
+### Evaluation
-- `nickel_eval(code)` - Evaluate to `JSON.Object`
+- `nickel_eval(code)` - Evaluate Nickel code, returns Julia-native types
- `nickel_eval(code, T)` - Evaluate and convert to type `T`
-- `nickel_eval_file(path)` - Evaluate a `.ncl` file
-
-### Evaluation (Native FFI - no CLI needed)
-
-- `nickel_eval_ffi(code)` - FFI evaluation via JSON (supports typed parsing)
-- `nickel_eval_ffi(code, T)` - FFI evaluation with type conversion
-- `nickel_eval_native(code)` - FFI with binary protocol (preserves types)
-- `nickel_eval_file_native(path)` - Evaluate file with import support
-- `check_ffi_available()` - Check if FFI library is loaded
+- `nickel_eval_file(path)` - Evaluate a `.ncl` file (supports imports)
+- `check_ffi_available()` - Check if the C API library is loaded
### Export
- `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 any format
+
+### String Macro
+
+- `ncl"..."` / `@ncl_str` - Evaluate inline Nickel code
## Type Conversion
-| Nickel Type | Julia Type (FFI native) | Julia Type (JSON) |
-|-------------|-------------------------|-------------------|
-| Null | `nothing` | `nothing` |
-| Bool | `Bool` | `Bool` |
-| Number (integer) | `Int64` | `Int64` |
-| Number (float) | `Float64` | `Float64` |
-| String | `String` | `String` |
-| Array | `Vector{Any}` | `JSON.Array` |
-| Record | `Dict{String,Any}` | `JSON.Object` |
-| Enum | `NickelEnum(tag, arg)` | N/A (JSON export) |
+| Nickel Type | Julia Type |
+|-------------|------------|
+| Null | `nothing` |
+| Bool | `Bool` |
+| Number (integer) | `Int64` |
+| Number (float) | `Float64` |
+| String | `String` |
+| Array | `Vector{Any}` |
+| Record | `Dict{String, Any}` |
+| Enum | `NickelEnum(tag, arg)` |
## Nickel Language Reference
@@ -275,18 +237,14 @@ let double = fun x => x * 2 in double 21
## Dependencies
### Julia
-- JSON.jl >= 0.21 or >= 1.0
- Artifacts (stdlib)
- LazyArtifacts (stdlib)
-### Rust (for building FFI locally)
-- nickel-lang-core = "0.9"
-- malachite = "0.4"
-- serde_json = "1.0"
+### Rust (for building C API library locally)
+- nickel-lang = "2.0.0" with `--features capi`
## Future Improvements
-1. Complete Julia-side binary protocol decoder
-2. Support for Nickel contracts/types in Julia
-3. Streaming evaluation for large configs
-4. REPL integration
+1. Support for Nickel contracts/types in Julia
+2. Streaming evaluation for large configs
+3. REPL integration
diff --git a/Project.toml b/Project.toml
@@ -1,15 +1,13 @@
name = "NickelEval"
uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
authors = ["NickelJL Contributors"]
-version = "0.5.2"
+version = "0.6.0"
[deps]
Artifacts = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
-JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
LazyArtifacts = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
[compat]
-JSON = "0.21, 1"
julia = "1.6"
[extras]
diff --git a/README.md b/README.md
@@ -1,8 +1,8 @@
# NickelEval.jl
-Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language.
+Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language, using the official Nickel C API.
-Evaluate Nickel code directly from Julia with native type conversion and export to JSON/TOML/YAML.
+Evaluate Nickel code directly from Julia with native type conversion and export to JSON/TOML/YAML. No Nickel CLI required.
## Installation
@@ -21,7 +21,11 @@ using Pkg
Pkg.add(url="https://github.com/LouLouLibs/NickelEval.jl")
```
-**Prerequisite:** Install the Nickel CLI from https://nickel-lang.org/
+Pre-built binaries are downloaded automatically on supported platforms (macOS Apple Silicon, Linux x86_64). For other platforms, build from source:
+
+```bash
+NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build("NickelEval")'
+```
## Quick Start
@@ -31,10 +35,10 @@ using NickelEval
# Simple evaluation
nickel_eval("1 + 2") # => 3
-# Records return JSON.Object with dot-access
+# Records return Dict{String, Any}
config = nickel_eval("{ name = \"alice\", age = 30 }")
-config.name # => "alice"
-config.age # => 30
+config["name"] # => "alice"
+config["age"] # => 30
# String macro for inline Nickel
ncl"[1, 2, 3] |> std.array.map (fun x => x * 2)"
@@ -50,10 +54,6 @@ Convert Nickel values directly to Julia types:
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]
@@ -71,6 +71,19 @@ config = nickel_eval("""
config.port # => 8080
```
+## Enums
+
+Nickel enum types are preserved:
+
+```julia
+result = nickel_eval("'Some 42")
+result.tag # => :Some
+result.arg # => 42
+result == :Some # => true
+
+nickel_eval("'None").tag # => :None
+```
+
## Export to Configuration Formats
Generate JSON, TOML, or YAML from Nickel:
@@ -87,17 +100,11 @@ nickel_to_toml("{ name = \"myapp\", port = 8080 }")
# 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 = {
@@ -112,157 +119,81 @@ config_ncl = """
}
"""
-# 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
-
-# Native evaluation (recommended) - preserves integer vs float distinction
-nickel_eval_native("42") # => 42::Int64
-nickel_eval_native("3.14") # => 3.14::Float64
-nickel_eval_native("{ x = 1 }") # => Dict("x" => 1)
-
-# JSON-based FFI evaluation - supports typed parsing
-nickel_eval_ffi("1 + 2") # => 3
-nickel_eval_ffi("{ x = 1 }", Dict{String, Int}) # => Dict("x" => 1)
-```
-
-### File Evaluation via FFI
-
-Evaluate `.ncl` files with full import support, no subprocess needed:
+Evaluate `.ncl` files with full import support:
```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")
+config = nickel_eval_file("config.ncl")
+config["name"] # => "MyProject"
+
+# Typed
+nickel_eval_file("config.ncl", @NamedTuple{name::String, version::String})
```
Import paths are resolved relative to the file's directory.
-### Building FFI
+## Building from Source
-Pre-built binaries are downloaded automatically as Julia artifacts on supported platforms. If `check_ffi_available()` returns `false`, you can build from source (requires Rust):
+Pre-built binaries are downloaded automatically on macOS Apple Silicon and Linux x86_64. On other platforms (or if `check_ffi_available()` returns `false`), build from source:
```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
+# Requires Rust (https://rustup.rs/)
+NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build("NickelEval")'
```
-### FFI on HPC / Slurm Clusters
+### HPC / Slurm Clusters
-The pre-built Linux binary may fail on clusters with an older glibc (e.g., RHEL 8 / CentOS 8). To build from source in the installed package directory:
+The pre-built Linux binary may fail on clusters with an older glibc. Build from source:
```bash
-# Install Rust if needed
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
-
-# Build in the installed package
-cd $(julia -e 'using NickelEval; print(pkgdir(NickelEval))')
-cd rust/nickel-jl
-cargo build --release
-mkdir -p ../../deps
-cp target/release/libnickel_jl.so ../../deps/
+NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build("NickelEval")'
```
-Restart Julia after building. The FFI functions (`nickel_eval_native`, `nickel_eval_ffi`, etc.) do not require the Nickel CLI — only the subprocess functions do.
+Restart Julia after building.
## API Reference
-### Evaluation Functions
+### Evaluation
| Function | Description |
|----------|-------------|
-| `nickel_eval(code)` | Evaluate Nickel code, return `JSON.Object` |
+| `nickel_eval(code)` | Evaluate Nickel code, return Julia native types |
| `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)` |
+| `nickel_eval_file(path)` | Evaluate a `.ncl` file with import support |
| `@ncl_str` | String macro for inline evaluation |
+| `check_ffi_available()` | Check if the C API library is loaded |
-### Export Functions
+### Export
| 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_native(code)` | Native FFI evaluation (preserves types) |
-| `nickel_eval_file_native(path)` | Evaluate `.ncl` file via FFI with import support |
-| `nickel_eval_ffi(code)` | JSON-based FFI evaluation |
-| `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` |
+| Number (integer) | `Int64` |
+| Number (float) | `Float64` |
| String | `String` |
| Bool | `Bool` |
-| Array | `Vector{Any}` or `Vector{T}` |
-| Record | `JSON.Object` (dot-access) or `Dict{K,V}` or `NamedTuple` or struct |
+| Array | `Vector{Any}` (or `Vector{T}` with typed eval) |
+| Record | `Dict{String, Any}` (or `NamedTuple` / `Dict{K,V}` with typed eval) |
| Null | `nothing` |
+| Enum | `NickelEnum(tag, arg)` |
## Error Handling
diff --git a/deps/Project.toml b/deps/Project.toml
@@ -0,0 +1,2 @@
+[deps]
+Clang = "40e3b903-d033-50b4-a0cc-940c62c95e31"
diff --git a/deps/build.jl b/deps/build.jl
@@ -1,71 +1,74 @@
-# Build script for the Rust FFI library
-#
-# This script is run by Pkg.build() to compile the Rust wrapper library.
-# Currently a stub for Phase 2 implementation.
+# Build script: compile Nickel's C API library from source
+# Triggered by NICKELEVAL_BUILD_FFI=true
-const RUST_PROJECT = joinpath(@__DIR__, "..", "rust", "nickel-jl")
+const NICKEL_VERSION = "1.16.0"
+const NICKEL_REPO = "https://github.com/nickel-lang/nickel.git"
-# Determine the correct library extension for the platform
-function library_extension()
- if Sys.iswindows()
- return ".dll"
- elseif Sys.isapple()
- return ".dylib"
- else
- return ".so"
- end
-end
-
-# Determine library name with platform-specific prefix
function library_name()
- if Sys.iswindows()
- return "nickel_jl$(library_extension())"
+ if Sys.isapple()
+ return "libnickel_lang.dylib"
+ elseif Sys.iswindows()
+ return "nickel_lang.dll"
else
- return "libnickel_jl$(library_extension())"
+ return "libnickel_lang.so"
end
end
-function build_rust_library()
- if !isdir(RUST_PROJECT)
- @warn "Rust project not found at $RUST_PROJECT, skipping FFI build"
- return false
- end
-
- # Check if cargo is available
+function build_nickel_capi()
cargo = Sys.which("cargo")
if cargo === nothing
- @warn "Cargo not found in PATH, skipping FFI build. Install Rust: https://rustup.rs/"
+ @warn "cargo not found in PATH. Install Rust: https://rustup.rs/"
return false
end
- @info "Building Rust FFI library..."
+ src_dir = joinpath(@__DIR__, "_nickel_src")
+ # Clone or update
+ if isdir(src_dir)
+ @info "Updating Nickel source..."
+ cd(src_dir) do
+ run(`git fetch --depth 1 origin tag $(NICKEL_VERSION)`)
+ run(`git checkout $(NICKEL_VERSION)`)
+ end
+ else
+ @info "Cloning Nickel $(NICKEL_VERSION)..."
+ run(`git clone --depth 1 --branch $(NICKEL_VERSION) $(NICKEL_REPO) $(src_dir)`)
+ end
+
+ @info "Building Nickel C API library..."
try
- cd(RUST_PROJECT) do
- run(`cargo build --release`)
+ cd(src_dir) do
+ run(`cargo build --release -p nickel-lang --features capi`)
end
- # Copy the built library to deps/
- src_lib = joinpath(RUST_PROJECT, "target", "release", library_name())
+ src_lib = joinpath(src_dir, "target", "release", library_name())
dst_lib = joinpath(@__DIR__, library_name())
if isfile(src_lib)
cp(src_lib, dst_lib; force=true)
- @info "FFI library built successfully: $dst_lib"
+ @info "Library built: $(dst_lib)"
+
+ # Also generate header if cbindgen is available
+ if Sys.which("cbindgen") !== nothing
+ cd(joinpath(src_dir, "nickel")) do
+ run(`cbindgen --config cbindgen.toml --crate nickel-lang --output $(joinpath(@__DIR__, "nickel_lang.h"))`)
+ end
+ @info "Header generated: $(joinpath(@__DIR__, "nickel_lang.h"))"
+ end
+
return true
else
- @warn "Built library not found at $src_lib"
+ @warn "Built library not found at $(src_lib)"
return false
end
catch e
- @warn "Failed to build Rust library: $e"
+ @warn "Build failed: $(e)"
return false
end
end
-# Only build if explicitly requested or in a CI environment
if get(ENV, "NICKELEVAL_BUILD_FFI", "false") == "true"
- build_rust_library()
+ build_nickel_capi()
else
@info "Skipping FFI build (set NICKELEVAL_BUILD_FFI=true to enable)"
end
diff --git a/deps/generate_bindings.jl b/deps/generate_bindings.jl
@@ -0,0 +1,19 @@
+#!/usr/bin/env julia
+# Developer tool: regenerate src/libnickel.jl from deps/nickel_lang.h
+# Usage: julia --project=deps/ deps/generate_bindings.jl
+
+using Clang
+using Clang.Generators
+
+cd(@__DIR__)
+
+header = joinpath(@__DIR__, "nickel_lang.h")
+if !isfile(header)
+ error("deps/nickel_lang.h not found. Build the Nickel C API first.")
+end
+
+options = load_options(joinpath(@__DIR__, "generator.toml"))
+ctx = create_context([header], get_default_args(), options)
+build!(ctx)
+
+println("Bindings generated at src/libnickel.jl")
diff --git a/deps/generator.toml b/deps/generator.toml
@@ -0,0 +1,9 @@
+[general]
+library_name = "libnickel_lang"
+output_file_path = "../src/libnickel.jl"
+module_name = "LibNickel"
+jll_pkg_name = ""
+export_symbol_prefixes = ["nickel_", "NICKEL_"]
+
+[codegen]
+use_ccall_macro = true
diff --git a/deps/nickel_lang.h b/deps/nickel_lang.h
@@ -0,0 +1,629 @@
+// SPDX-License-Identifier: MIT
+
+#ifndef NICKEL_LANG_H
+#define NICKEL_LANG_H
+
+#include <stdint.h>
+
+/**
+ * For functions that can fail, these are the interpretations of the return value.
+ */
+typedef enum {
+ /**
+ * A successful result.
+ */
+ NICKEL_RESULT_OK = 0,
+ /**
+ * A bad result.
+ */
+ NICKEL_RESULT_ERR = 1,
+} nickel_result;
+
+/**
+ * For functions that can fail, these are the interpretations of the return value.
+ */
+typedef enum {
+ /**
+ * Format an error as human-readable text.
+ */
+ NICKEL_ERROR_FORMAT_TEXT = 0,
+ /**
+ * Format an error as human-readable text, with ANSI color codes.
+ */
+ NICKEL_ERROR_FORMAT_ANSI_TEXT = 1,
+ /**
+ * Format an error as JSON.
+ */
+ NICKEL_ERROR_FORMAT_JSON = 2,
+ /**
+ * Format an error as YAML.
+ */
+ NICKEL_ERROR_FORMAT_YAML = 3,
+ /**
+ * Format an error as TOML.
+ */
+ NICKEL_ERROR_FORMAT_TOML = 4,
+} nickel_error_format;
+
+/**
+ * A Nickel array.
+ *
+ * See [`nickel_expr_is_array`] and [`nickel_expr_as_array`].
+ */
+typedef struct nickel_array nickel_array;
+
+/**
+ * The main entry point.
+ */
+typedef struct nickel_context nickel_context;
+
+/**
+ * A Nickel error.
+ *
+ * If you want to collect an error message from a fallible function
+ * (like `nickel_context_eval_deep`), first allocate an error using
+ * `nickel_error_alloc`, and then pass the resulting pointer to your fallible
+ * function. If that function fails, it will save the error data in your
+ * `nickel_error`.
+ */
+typedef struct nickel_error nickel_error;
+
+/**
+ * A Nickel expression.
+ *
+ * This might be fully evaluated (for example, if you got it from [`nickel_context_eval_deep`])
+ * or might have unevaluated sub-expressions (if you got it from [`nickel_context_eval_shallow`]).
+ */
+typedef struct nickel_expr nickel_expr;
+
+/**
+ * A Nickel number.
+ *
+ * See [`nickel_expr_is_number`] and [`nickel_expr_as_number`].
+ */
+typedef struct nickel_number nickel_number;
+
+/**
+ * A Nickel record.
+ *
+ * See [`nickel_expr_is_record`] and [`nickel_expr_as_record`].
+ */
+typedef struct nickel_record nickel_record;
+
+/**
+ * A Nickel string.
+ */
+typedef struct nickel_string nickel_string;
+
+/**
+ * A callback function for writing data.
+ *
+ * This function will be called with a buffer (`buf`) of data, having length
+ * `len`. It need not consume the entire buffer, and should return the number
+ * of bytes consumed.
+ */
+typedef uintptr_t (*nickel_write_callback)(void *context, const uint8_t *buf, uintptr_t len);
+
+/**
+ * A callback function for flushing data that was written by a write callback.
+ */
+typedef void (*nickel_flush_callback)(const void *context);
+
+#ifdef __cplusplus
+extern "C" {
+#endif // __cplusplus
+
+/**
+ * Allocate a new [`nickel_context`], which can be used to evaluate Nickel expressions.
+ *
+ * Returns a newly-allocated [`nickel_context`] that can be freed with [`nickel_context_free`].
+ */
+nickel_context *nickel_context_alloc(void);
+
+/**
+ * Free a [`nickel_context`] that was created with [`nickel_context_alloc`].
+ */
+void nickel_context_free(nickel_context *ctx);
+
+/**
+ * Provide a callback that will be called when evaluating Nickel
+ * code that uses `std.trace`.
+ */
+void nickel_context_set_trace_callback(nickel_context *ctx,
+ nickel_write_callback write,
+ nickel_flush_callback flush,
+ void *user_data);
+
+/**
+ * Provide a name for the main input program.
+ *
+ * This is used to format error messages. If you read the main input
+ * program from a file, its path is a good choice.
+ *
+ * `name` should be a UTF-8-encoded, null-terminated string. It is only
+ * borrowed temporarily; the pointer need not remain valid.
+ */
+void nickel_context_set_source_name(nickel_context *ctx, const char *name);
+
+/**
+ * Evaluate a Nickel program deeply.
+ *
+ * "Deeply" means that we recursively evaluate records and arrays. For
+ * an alternative, see [`nickel_context_eval_shallow`].
+ *
+ * - `src` is a null-terminated string containing UTF-8-encoded Nickel source.
+ * - `out_expr` either NULL or something that was created with [`nickel_expr_alloc`]
+ * - `out_error` can be NULL if you aren't interested in getting detailed
+ * error messages
+ *
+ * If evaluation is successful, returns `NICKEL_RESULT_OK` and replaces
+ * the value at `out_expr` (if non-NULL) with the newly-evaluated Nickel expression.
+ *
+ * If evaluation fails, returns `NICKEL_RESULT_ERR` and replaces the
+ * value at `out_error` (if non-NULL) by a pointer to a newly-allocated Nickel error.
+ * That error should be freed with `nickel_error_free` when you are
+ * done with it.
+ */
+nickel_result nickel_context_eval_deep(nickel_context *ctx,
+ const char *src,
+ nickel_expr *out_expr,
+ nickel_error *out_error);
+
+/**
+ * Evaluate a Nickel program deeply.
+ *
+ * This differs from [`nickel_context_eval_deep`] in that it ignores
+ * fields marked as `not_exported`.
+ *
+ * - `src` is a null-terminated string containing UTF-8-encoded Nickel source.
+ * - `out_expr` either NULL or something that was created with [`nickel_expr_alloc`]
+ * - `out_error` can be NULL if you aren't interested in getting detailed
+ * error messages
+ *
+ * If evaluation is successful, returns `NICKEL_RESULT_OK` and replaces
+ * the value at `out_expr` (if non-NULL) with the newly-evaluated Nickel expression.
+ *
+ * If evaluation fails, returns `NICKEL_RESULT_ERR` and replaces the
+ * value at `out_error` (if non-NULL) by a pointer to a newly-allocated Nickel error.
+ * That error should be freed with `nickel_error_free` when you are
+ * done with it.
+ */
+nickel_result nickel_context_eval_deep_for_export(nickel_context *ctx,
+ const char *src,
+ nickel_expr *out_expr,
+ nickel_error *out_error);
+
+/**
+ * Evaluate a Nickel program to weak head normal form (WHNF).
+ *
+ * The result of this evaluation is a null, bool, number, string,
+ * enum, record, or array. In case it's a record, array, or enum
+ * variant, the payload (record values, array elements, or enum
+ * payloads) will be left unevaluated.
+ *
+ * Sub-expressions of the result can be evaluated further by [nickel_context_eval_expr_shallow].
+ *
+ * - `src` is a null-terminated string containing UTF-8-encoded Nickel source.
+ * - `out_expr` is either NULL or something that was created with [`nickel_expr_alloc`]
+ * - `out_error` can be NULL if you aren't interested in getting detailed
+ * error messages
+ *
+ * If evaluation is successful, returns `NICKEL_RESULT_OK` and replaces the value at `out_expr`
+ * (if non-NULL) with the newly-evaluated Nickel expression.
+ *
+ * If evaluation fails, returns `NICKEL_RESULT_ERR` and replaces the value at `out_error` (if
+ * non-NULL) by a pointer to a newly-allocated Nickel error. That error should be freed with
+ * `nickel_error_free` when you are done with it.
+ */
+nickel_result nickel_context_eval_shallow(nickel_context *ctx,
+ const char *src,
+ nickel_expr *out_expr,
+ nickel_error *out_error);
+
+/**
+ * Allocate a new Nickel expression.
+ *
+ * The returned expression pointer can be used to store the results of
+ * evaluation, for example by passing it as the `out_expr` location of
+ * `nickel_context_eval_deep`.
+ *
+ * Each call to `nickel_expr_alloc` should be paired with a call to
+ * `nickel_expr_free`. The various functions (like `nickel_context_eval_deep`)
+ * that take an `out_expr` parameter overwrite the existing expression
+ * contents, and do not affect the pairing of `nickel_expr_alloc` and
+ * `nickel_expr_free`.
+ *
+ * For example:
+ *
+ * ```c
+ * nickel_context *ctx = nickel_context_alloc();
+ * nickel_context *expr = nickel_expr_alloc();
+ *
+ * nickel_context_eval_deep(ctx, "{ foo = 1 }", expr, NULL);
+ *
+ * // now expr is a record
+ * printf("record: %d\n", nickel_expr_is_record(expr));
+ *
+ * nickel_context_eval_deep(ctx, "[1, 2, 3]", expr, NULL);
+ *
+ * // now expr is an array
+ * printf("array: %d\n", nickel_expr_is_array(expr));
+ *
+ * // the calls to nickel_context_eval_deep haven't created any new exprs:
+ * // we only need to free it once
+ * nickel_expr_free(expr);
+ * nickel_context_free(ctx);
+ * ```
+ *
+ * An `Expr` owns its data. There are various ways to get a reference to
+ * data owned by an expression, which are then invalidated when the expression
+ * is freed (by `nickel_expr_free`) or overwritten (for example, by
+ * `nickel_context_deep_eval`).
+ *
+ * ```c
+ * nickel_context *ctx = nickel_context_alloc();
+ * nickel_expr *expr = nickel_expr_alloc();
+ *
+ * nickel_context_eval_deep(ctx, "{ foo = 1 }", expr, NULL);
+ *
+ * nickel_record *rec = nickel_expr_as_record(expr);
+ * nickel_expr *field = nickel_expr_alloc();
+ * nickel_record_value_by_name(rec, "foo", field);
+ *
+ * // Now `rec` points to data owned by `expr`, but `field`
+ * // owns its own data. The following deallocation invalidates
+ * // `rec`, but not `field`.
+ * nickel_expr_free(expr);
+ * printf("number: %d\n", nickel_expr_is_number(field));
+ * ```
+ */
+nickel_expr *nickel_expr_alloc(void);
+
+/**
+ * Free a Nickel expression.
+ *
+ * See [`nickel_expr_alloc`].
+ */
+void nickel_expr_free(nickel_expr *expr);
+
+/**
+ * Is this expression a boolean?
+ */
+int nickel_expr_is_bool(const nickel_expr *expr);
+
+/**
+ * Is this expression a number?
+ */
+int nickel_expr_is_number(const nickel_expr *expr);
+
+/**
+ * Is this expression a string?
+ */
+int nickel_expr_is_str(const nickel_expr *expr);
+
+/**
+ * Is this expression an enum tag?
+ */
+int nickel_expr_is_enum_tag(const nickel_expr *expr);
+
+/**
+ * Is this expression an enum variant?
+ */
+int nickel_expr_is_enum_variant(const nickel_expr *expr);
+
+/**
+ * Is this expression a record?
+ */
+int nickel_expr_is_record(const nickel_expr *expr);
+
+/**
+ * Is this expression an array?
+ */
+int nickel_expr_is_array(const nickel_expr *expr);
+
+/**
+ * Has this expression been evaluated?
+ *
+ * An evaluated expression is either null, or it's a number, bool, string, record, array, or enum.
+ * If this expression is not a value, you probably got it from looking inside the result of
+ * [`nickel_context_eval_shallow`], and you can use the [`nickel_context_eval_expr_shallow`] to
+ * evaluate this expression further.
+ */
+int nickel_expr_is_value(const nickel_expr *expr);
+
+/**
+ * Is this expression null?
+ */
+int nickel_expr_is_null(const nickel_expr *expr);
+
+/**
+ * If this expression is a boolean, returns that boolean.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not a boolean.
+ */
+int nickel_expr_as_bool(const nickel_expr *expr);
+
+/**
+ * If this expression is a string, returns that string.
+ *
+ * A pointer to the string contents, which are UTF-8 encoded, is returned in
+ * `out_str`. These contents are *not* null-terminated. The return value of this
+ * function is the length of these contents.
+ *
+ * The returned string contents are owned by this `Expr`, and will be invalidated
+ * when the `Expr` is freed with [`nickel_expr_free`].
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not a string.
+ */
+uintptr_t nickel_expr_as_str(const nickel_expr *expr, const char **out_str);
+
+/**
+ * If this expression is a number, returns the number.
+ *
+ * The returned number pointer borrows from `expr`, and will be invalidated
+ * when `expr` is overwritten or freed.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not an number.
+ */
+const nickel_number *nickel_expr_as_number(const nickel_expr *expr);
+
+/**
+ * If this expression is an enum tag, returns its string value.
+ *
+ * A pointer to the string contents, which are UTF-8 encoded, is returned in
+ * `out_str`. These contents are *not* null-terminated. The return value of this
+ * function is the length of these contents.
+ *
+ * The returned string contents point to an interned string and will never be
+ * invalidated.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is null or is not an enum tag.
+ */
+uintptr_t nickel_expr_as_enum_tag(const nickel_expr *expr, const char **out_str);
+
+/**
+ * If this expression is an enum variant, returns its string value and its payload.
+ *
+ * A pointer to the string contents, which are UTF-8 encoded, is returned in
+ * `out_str`. These contents are *not* null-terminated. The return value of this
+ * function is the length of these contents.
+ *
+ * The returned string contents point to an interned string and will never be
+ * invalidated.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not an enum tag.
+ */
+uintptr_t nickel_expr_as_enum_variant(const nickel_expr *expr,
+ const char **out_str,
+ nickel_expr *out_expr);
+
+/**
+ * If this expression is a record, returns the record.
+ *
+ * The returned record pointer borrows from `expr`, and will be invalidated
+ * when `expr` is overwritten or freed.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not an record.
+ */
+const nickel_record *nickel_expr_as_record(const nickel_expr *expr);
+
+/**
+ * If this expression is an array, returns the array.
+ *
+ * The returned array pointer borrows from `expr`, and will be invalidated
+ * when `expr` is overwritten or freed.
+ *
+ * # Panics
+ *
+ * Panics if `expr` is not an array.
+ */
+const nickel_array *nickel_expr_as_array(const nickel_expr *expr);
+
+/**
+ * Converts an expression to JSON.
+ *
+ * This is fallible because enum variants have no canonical conversion to
+ * JSON: if the expression contains any enum variants, this will fail.
+ * This also fails if the expression contains any unevaluated sub-expressions.
+ */
+nickel_result nickel_context_expr_to_json(nickel_context *ctx,
+ const nickel_expr *expr,
+ nickel_string *out_string,
+ nickel_error *out_err);
+
+/**
+ * Converts an expression to YAML.
+ *
+ * This is fallible because enum variants have no canonical conversion to
+ * YAML: if the expression contains any enum variants, this will fail.
+ * This also fails if the expression contains any unevaluated sub-expressions.
+ */
+nickel_result nickel_context_expr_to_yaml(nickel_context *ctx,
+ const nickel_expr *expr,
+ nickel_string *out_string,
+ nickel_error *out_err);
+
+/**
+ * Converts an expression to TOML.
+ *
+ * This is fallible because enum variants have no canonical conversion to
+ * TOML: if the expression contains any enum variants, this will fail.
+ * This also fails if the expression contains any unevaluated sub-expressions.
+ */
+nickel_result nickel_context_expr_to_toml(nickel_context *ctx,
+ const nickel_expr *expr,
+ nickel_string *out_string,
+ nickel_error *out_err);
+
+/**
+ * Is this number an integer within the range of an `int64_t`?
+ */
+int nickel_number_is_i64(const nickel_number *num);
+
+/**
+ * If this number is an integer within the range of an `int64_t`, returns it.
+ *
+ * # Panics
+ *
+ * Panics if this number is not an integer in the appropriate range (you should
+ * check with [`nickel_number_is_i64`] first).
+ */
+int64_t nickel_number_as_i64(const nickel_number *num);
+
+/**
+ * The value of this number, rounded to the nearest `double`.
+ */
+double nickel_number_as_f64(const nickel_number *num);
+
+/**
+ * The value of this number, as an exact rational number.
+ *
+ * - `out_numerator` must have been allocated with [`nickel_string_alloc`]. It
+ * will be overwritten with the numerator, as a decimal string.
+ * - `out_denominator` must have been allocated with [`nickel_string_alloc`].
+ * It will be overwritten with the denominator, as a decimal string.
+ */
+void nickel_number_as_rational(const nickel_number *num,
+ nickel_string *out_numerator,
+ nickel_string *out_denominator);
+
+/**
+ * The number of elements of this Nickel array.
+ */
+uintptr_t nickel_array_len(const nickel_array *arr);
+
+/**
+ * Retrieve the element at the given array index.
+ *
+ * The retrieved element will be written to `out_expr`, which must have been allocated with
+ * [`nickel_expr_alloc`].
+ *
+ * # Panics
+ *
+ * Panics if the given index is out of bounds.
+ */
+void nickel_array_get(const nickel_array *arr, uintptr_t idx, nickel_expr *out_expr);
+
+/**
+ * The number of keys in this Nickel record.
+ */
+uintptr_t nickel_record_len(const nickel_record *rec);
+
+/**
+ * Retrieve the key and value at the given index.
+ *
+ * If this record was deeply evaluated, every key will come with a value.
+ * However, shallowly evaluated records may have fields with no value.
+ *
+ * Returns 1 if the key came with a value, and 0 if it didn't. The value
+ * will be written to `out_expr` if it is non-NULL.
+ *
+ * # Panics
+ *
+ * Panics if `idx` is out of range.
+ */
+int nickel_record_key_value_by_index(const nickel_record *rec,
+ uintptr_t idx,
+ const char **out_key,
+ uintptr_t *out_key_len,
+ nickel_expr *out_expr);
+
+/**
+ * Look up a key in this record and return its value, if there is one.
+ *
+ * Returns 1 if the key has a value, and 0 if it didn't. The value is
+ * written to `out_expr` if it is non-NULL.
+ */
+int nickel_record_value_by_name(const nickel_record *rec, const char *key, nickel_expr *out_expr);
+
+/**
+ * Allocates a new string.
+ *
+ * The lifecycle management of a string is much like that of an expression
+ * (see `nickel_expr_alloc`). It gets allocated here, modified by various other
+ * functions, and finally is freed by a call to `nickel_string_free`.
+ */
+nickel_string *nickel_string_alloc(void);
+
+/**
+ * Frees a string.
+ */
+void nickel_string_free(nickel_string *s);
+
+/**
+ * Retrieve the data inside a string.
+ *
+ * A pointer to the string contents, which are UTF-8 encoded, is written to
+ * `data`. These contents are *not* null-terminated, but their length (in bytes)
+ * is written to `len`. The string contents will be invalidated when `s` is
+ * freed or overwritten.
+ */
+void nickel_string_data(const nickel_string *s, const char **data, uintptr_t *len);
+
+/**
+ * Evaluate an expression to weak head normal form (WHNF).
+ *
+ * This has no effect if the expression is already evaluated (see
+ * [`nickel_expr_is_value`]).
+ *
+ * The result of this evaluation is a null, bool, number, string,
+ * enum, record, or array. In case it's a record, array, or enum
+ * variant, the payload (record values, array elements, or enum
+ * payloads) will be left unevaluated.
+ */
+nickel_result nickel_context_eval_expr_shallow(nickel_context *ctx,
+ const nickel_expr *expr,
+ nickel_expr *out_expr,
+ nickel_error *out_error);
+
+/**
+ * Allocate a new `nickel_error`.
+ */
+nickel_error *nickel_error_alloc(void);
+
+/**
+ * Frees a `nickel_error`.
+ */
+void nickel_error_free(nickel_error *err);
+
+/**
+ * Write out an error as a user- or machine-readable diagnostic.
+ *
+ * - `err` must have been allocated by `nickel_error_alloc` and initialized by some failing
+ * function (like `nickel_context_eval_deep`).
+ * - `write` is a callback function that will be invoked with UTF-8 encoded data.
+ * - `write_payload` is optional extra data to pass to `write`
+ * - `format` selects the error-rendering format.
+ */
+nickel_result nickel_error_display(const nickel_error *err,
+ nickel_write_callback write,
+ void *write_payload,
+ nickel_error_format format);
+
+/**
+ * Write out an error as a user- or machine-readable diagnostic.
+ *
+ * This is like `nickel_error_format`, but writes the error to a string instead
+ * of via a callback function.
+ */
+nickel_result nickel_error_format_as_string(const nickel_error *err,
+ nickel_string *out_string,
+ nickel_error_format format);
+
+#ifdef __cplusplus
+} // extern "C"
+#endif // __cplusplus
+
+#endif /* NICKEL_LANG_H */
diff --git a/docs/make.jl b/docs/make.jl
@@ -16,9 +16,6 @@ makedocs(
"Home" => "index.md",
"Manual" => [
"man/quickstart.md",
- "man/typed.md",
- "man/export.md",
- "man/ffi.md",
],
"Library" => [
"lib/public.md",
diff --git a/docs/src/index.md b/docs/src/index.md
@@ -7,8 +7,7 @@ Julia bindings for the [Nickel](https://nickel-lang.org/) configuration language
- **Evaluate Nickel code** directly from Julia
- **Native type conversion** to Julia types (`Dict`, `NamedTuple`, custom structs)
- **Export to multiple formats** (JSON, TOML, YAML)
-- **High-performance FFI** mode using Rust bindings
-- **Dot-access** for configuration records via `JSON.Object`
+- **High-performance C API** using the official Nickel C API — no CLI needed
## Installation
@@ -27,7 +26,7 @@ using Pkg
Pkg.add(url="https://github.com/LouLouLibs/NickelEval.jl")
```
-**Prerequisite:** Install the Nickel CLI from [nickel-lang.org](https://nickel-lang.org/)
+No external tools are required. The Nickel evaluator is bundled as a pre-built native library.
## Quick Example
@@ -37,10 +36,10 @@ using NickelEval
# Simple evaluation
nickel_eval("1 + 2") # => 3
-# Records with dot-access
+# Records return Dict{String, Any}
config = nickel_eval("{ host = \"localhost\", port = 8080 }")
-config.host # => "localhost"
-config.port # => 8080
+config["host"] # => "localhost"
+config["port"] # => 8080
# Typed evaluation
nickel_eval("{ x = 1, y = 2 }", Dict{String, Int})
diff --git a/docs/src/lib/public.md b/docs/src/lib/public.md
@@ -5,14 +5,11 @@
```@docs
nickel_eval
nickel_eval_file
-nickel_read
-find_nickel_executable
```
## Export Functions
```@docs
-nickel_export
nickel_to_json
nickel_to_toml
nickel_to_yaml
@@ -22,9 +19,6 @@ nickel_to_yaml
```@docs
check_ffi_available
-nickel_eval_ffi
-nickel_eval_native
-nickel_eval_file_native
```
## String Macro
diff --git a/docs/src/man/export.md b/docs/src/man/export.md
@@ -1,135 +0,0 @@
-# Export to Config Formats
-
-NickelEval can export Nickel code to JSON, TOML, or YAML strings for generating configuration files.
-
-## JSON Export
-
-```julia
-nickel_to_json("{ name = \"myapp\", port = 8080 }")
-```
-
-Output:
-```json
-{
- "name": "myapp",
- "port": 8080
-}
-```
-
-## TOML Export
-
-```julia
-nickel_to_toml("{ name = \"myapp\", port = 8080 }")
-```
-
-Output:
-```toml
-name = "myapp"
-port = 8080
-```
-
-## YAML Export
-
-```julia
-nickel_to_yaml("{ name = \"myapp\", port = 8080 }")
-```
-
-Output:
-```yaml
-name: myapp
-port: 8080
-```
-
-## Generic Export Function
-
-Use `nickel_export` with the `format` keyword:
-
-```julia
-nickel_export("{ a = 1 }"; format=:json)
-nickel_export("{ a = 1 }"; format=:toml)
-nickel_export("{ a = 1 }"; format=:yaml)
-```
-
-## Generating Config Files
-
-### Example: Generate Multiple Formats
-
-```julia
-config = """
-{
- database = {
- host = "localhost",
- port = 5432,
- name = "mydb"
- },
- server = {
- host = "0.0.0.0",
- port = 8080
- },
- logging = {
- level = "info",
- file = "/var/log/app.log"
- }
-}
-"""
-
-# Generate TOML config
-write("config.toml", nickel_to_toml(config))
-
-# Generate YAML config
-write("config.yaml", nickel_to_yaml(config))
-
-# Generate JSON config
-write("config.json", nickel_to_json(config))
-```
-
-### Example: Environment-Specific Configs
-
-```julia
-base_config = """
-{
- app_name = "myapp",
- log_level = "info"
-}
-"""
-
-dev_overrides = """
-{
- debug = true,
- database = { host = "localhost" }
-}
-"""
-
-prod_overrides = """
-{
- debug = false,
- database = { host = "db.production.com" }
-}
-"""
-
-# Merge and export
-dev_config = nickel_export("$base_config & $dev_overrides"; format=:toml)
-prod_config = nickel_export("$base_config & $prod_overrides"; format=:toml)
-```
-
-## Nested Structures
-
-TOML handles nested records as sections:
-
-```julia
-nickel_to_toml("""
-{
- server = {
- host = "0.0.0.0",
- port = 8080
- }
-}
-""")
-```
-
-Output:
-```toml
-[server]
-host = "0.0.0.0"
-port = 8080
-```
diff --git a/docs/src/man/ffi.md b/docs/src/man/ffi.md
@@ -1,410 +0,0 @@
-# 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 and preserves Nickel's type semantics.
-
-## FFI Functions
-
-### `nickel_eval_native` - Native Types (Recommended)
-
-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)
-```
-
-**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:
-
-```julia
-nickel_eval_ffi("{ a = 1, b = 2 }") # JSON.Object with dot-access
-nickel_eval_ffi("{ a = 1 }", Dict{String, Int}) # Typed Dict
-```
-
-## Supported Types
-
-### Primitive Types
-
-| Nickel | Julia | Example |
-|--------|-------|---------|
-| Integer numbers | `Int64` | `42` → `42::Int64` |
-| Decimal numbers | `Float64` | `3.14` → `3.14::Float64` |
-| Booleans | `Bool` | `true` → `true::Bool` |
-| Strings | `String` | `"hello"` → `"hello"::String` |
-| Null | `Nothing` | `null` → `nothing` |
-
-**Note:** Nickel has a single `Number` type. Whole numbers (like `42` or `42.0`) become `Int64`. Only true decimals (like `3.14`) become `Float64`.
-
-### Compound Types
-
-| Nickel | Julia | Example |
-|--------|-------|---------|
-| Arrays | `Vector{Any}` | `[1, 2, 3]` → `Any[1, 2, 3]` |
-| Records | `Dict{String, Any}` | `{ x = 1 }` → `Dict("x" => 1)` |
-| Enums | `NickelEnum` | `'Some 42` → `NickelEnum(:Some, 42)` |
-
-### Enums
-
-Nickel enums (also called "enum tags" or "variants") are converted to the `NickelEnum` type, preserving enum semantics distinct from regular records.
-
-#### The NickelEnum Type
-
-```julia
-struct NickelEnum
- tag::Symbol # The variant name as a Julia Symbol
- arg::Any # The argument (nothing for simple enums)
-end
-```
-
-#### Simple Enums (No Argument)
-
-Simple enums are tags without associated data, commonly used for status flags or options:
-
-```julia
-# Boolean-like enums
-result = nickel_eval_native("let x = 'True in x")
-result.tag # => :True
-result.arg # => nothing
-
-# Status enums
-result = nickel_eval_native("let status = 'Pending in status")
-result == :Pending # => true (convenience comparison)
-
-# Multiple variants
-code = \"\"\"
-let color = 'Red in color
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :Red
-```
-
-#### Enums with Primitive Arguments
-
-Enums can carry a single value of any type:
-
-```julia
-# Integer argument
-result = nickel_eval_native("let x = 'Count 42 in x")
-result.tag # => :Count
-result.arg # => 42 (Int64)
-
-# String argument
-result = nickel_eval_native("let x = 'Message \"hello\" in x")
-result.tag # => :Message
-result.arg # => "hello"
-
-# Float argument
-result = nickel_eval_native("let x = 'Temperature 98.6 in x")
-result.arg # => 98.6 (Float64)
-
-# Boolean argument
-result = nickel_eval_native("let x = 'Flag true in x")
-result.arg # => true
-```
-
-#### Enums with Record Arguments
-
-Enums can carry complex record data:
-
-```julia
-# Record argument
-code = \"\"\"
-let result = 'Ok { value = 123, message = "success" } in result
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :Ok
-result.arg # => Dict{String, Any}
-result.arg["value"] # => 123
-result.arg["message"] # => "success"
-
-# Error with details
-code = \"\"\"
-let err = 'Error { code = 404, reason = "not found" } in err
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :Error
-result.arg["code"] # => 404
-result.arg["reason"] # => "not found"
-```
-
-#### Enums with Array Arguments
-
-```julia
-# Array argument
-code = \"\"\"
-let batch = 'Batch [1, 2, 3, 4, 5] in batch
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :Batch
-result.arg # => Any[1, 2, 3, 4, 5]
-```
-
-#### Nested Enums
-
-Enums can contain other enums:
-
-```julia
-# Nested enum in record
-code = \"\"\"
-let outer = 'Container { inner = 'Value 42 } in outer
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :Container
-result.arg["inner"].tag # => :Value
-result.arg["inner"].arg # => 42
-
-# Enum in array
-code = \"\"\"
-let items = 'List ['Some 1, 'None, 'Some 3] in items
-\"\"\"
-result = nickel_eval_native(code)
-result.tag # => :List
-result.arg[1].tag # => :Some
-result.arg[1].arg # => 1
-result.arg[2].tag # => :None
-result.arg[2].arg # => nothing
-```
-
-#### Pattern Matching with Nickel
-
-When Nickel's `match` expression resolves an enum, you get the matched value:
-
-```julia
-# Match resolves to the extracted value
-code = \"\"\"
-let x = 'Some 42 in
-x |> match {
- 'Some v => v,
- 'None => 0
-}
-\"\"\"
-result = nickel_eval_native(code)
-# => 42 (the matched value, not an enum)
-
-# Match with record destructuring
-code = \"\"\"
-let result = 'Ok { value = 100 } in
-result |> match {
- 'Ok r => r.value,
- 'Error _ => -1
-}
-\"\"\"
-result = nickel_eval_native(code)
-# => 100
-```
-
-#### Working with NickelEnum in Julia
-
-```julia
-# Type checking
-result = nickel_eval_native("let x = 'Some 42 in x")
-result isa NickelEnum # => true
-
-# Symbol comparison (both directions work)
-result == :Some # => true
-:Some == result # => true
-
-# Accessing fields
-result.tag # => :Some
-result.arg # => 42
-
-# Pretty printing
-repr(result) # => "'Some 42"
-
-# Simple enum printing
-repr(nickel_eval_native("let x = 'None in x")) # => "'None"
-```
-
-#### Real-World Example: Result Type
-
-```julia
-# Simulating Rust-like Result type
-code = \"\"\"
-let divide = fun a b =>
- if b == 0 then
- 'Err "division by zero"
- else
- 'Ok (a / b)
-in
-divide 10 2
-\"\"\"
-result = nickel_eval_native(code)
-if result == :Ok
- println("Result: ", result.arg) # => 5
-else
- println("Error: ", result.arg)
-end
-```
-
-This representation mirrors Nickel's `std.enum.to_tag_and_arg` semantics while providing a proper Julia type that preserves enum identity.
-
-### Nested Structures
-
-Arbitrary nesting is fully supported:
-
-```julia
-# Deeply nested records
-result = nickel_eval_native("{ a = { b = { c = 42 } } }")
-result["a"]["b"]["c"] # => 42
-
-# Arrays of records
-result = nickel_eval_native("[{ id = 1 }, { id = 2 }]")
-result[1]["id"] # => 1
-
-# Records with arrays
-result = nickel_eval_native("{ items = [1, 2, 3], name = \"test\" }")
-result["items"] # => Any[1, 2, 3]
-
-# Mixed nesting
-result = nickel_eval_native("{ data = [{ a = 1 }, { b = [true, false] }] }")
-result["data"][2]["b"] # => Any[true, false]
-```
-
-### Computed Values
-
-Functions and expressions are evaluated before conversion:
-
-```julia
-nickel_eval_native("1 + 2") # => 3
-nickel_eval_native("let x = 10 in x * 2") # => 20
-nickel_eval_native("[1, 2, 3] |> std.array.map (fun x => x * 2)") # => Any[2, 4, 6]
-nickel_eval_native("{ a = 1 } & { b = 2 }") # => Dict("a" => 1, "b" => 2)
-```
-
-## Checking FFI Availability
-
-```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
-
-- Rust toolchain (install from [rustup.rs](https://rustup.rs))
-- Cargo
-
-### Build Steps
-
-```bash
-cd rust/nickel-jl
-cargo build --release
-```
-
-Then copy the library to `deps/`:
-
-```bash
-# macOS
-cp target/release/libnickel_jl.dylib ../../deps/
-
-# Linux
-cp target/release/libnickel_jl.so ../../deps/
-
-# Windows
-cp target/release/nickel_jl.dll ../../deps/
-```
-
-## Performance Comparison
-
-FFI mode is faster for repeated evaluations because it:
-
-1. **No process spawn**: Direct library calls instead of subprocess
-2. **Shared memory**: Values transfer directly without serialization
-3. **Persistent state**: Library remains loaded
-
-For single evaluations, the difference is minimal. For batch processing or interactive use, FFI mode is significantly faster.
-
-## Fallback Behavior
-
-If FFI is not available, you can still use the subprocess-based functions:
-
-```julia
-# Always works (uses CLI)
-nickel_eval("1 + 2")
-
-# Requires FFI library
-nickel_eval_native("1 + 2") # Error if not built
-```
-
-## Troubleshooting
-
-### "FFI not available" Error
-
-Build the Rust library:
-
-```bash
-cd rust/nickel-jl
-cargo build --release
-cp target/release/libnickel_jl.* ../../deps/
-```
-
-### Library Not Found
-
-Ensure the library has the correct name for your platform:
-- macOS: `libnickel_jl.dylib`
-- Linux: `libnickel_jl.so`
-- Windows: `nickel_jl.dll`
diff --git a/docs/src/man/quickstart.md b/docs/src/man/quickstart.md
@@ -17,9 +17,8 @@ using Pkg
Pkg.add(url="https://github.com/LouLouLibs/NickelEval.jl")
```
-Make sure you have the Nickel CLI installed:
-- macOS: `brew install nickel`
-- Other: See [nickel-lang.org](https://nickel-lang.org/)
+No external tools or CLI are required. NickelEval uses the official Nickel C API,
+bundled as a pre-built native library for your platform.
## Basic Usage
@@ -34,7 +33,7 @@ nickel_eval("\"hello\"") # => "hello"
## Working with Records
-Nickel records become `JSON.Object` with dot-access:
+Nickel records become `Dict{String, Any}`:
```julia
config = nickel_eval("""
@@ -47,9 +46,9 @@ config = nickel_eval("""
}
""")
-config.database.host # => "localhost"
-config.database.port # => 5432
-config.debug # => true
+config["database"]["host"] # => "localhost"
+config["database"]["port"] # => 5432
+config["debug"] # => true
```
## Let Bindings and Functions
@@ -79,7 +78,7 @@ nickel_eval("[1, 2, 3] |> std.array.map (fun x => x * 2)")
```julia
nickel_eval("{ a = 1 } & { b = 2 }")
-# => JSON.Object with a=1, b=2
+# => Dict{String, Any}("a" => 1, "b" => 2)
```
## String Macro
@@ -90,7 +89,7 @@ For inline Nickel code:
ncl"1 + 1" # => 2
config = ncl"{ host = \"localhost\" }"
-config.host # => "localhost"
+config["host"] # => "localhost"
```
## File Evaluation
diff --git a/docs/src/man/typed.md b/docs/src/man/typed.md
@@ -1,107 +0,0 @@
-# Typed Evaluation
-
-NickelEval supports converting Nickel values directly to typed Julia values using `JSON.jl 1.0`'s native typed parsing.
-
-## Basic Types
-
-```julia
-nickel_eval("42", Int) # => 42
-nickel_eval("3.14", Float64) # => 3.14
-nickel_eval("\"hi\"", String) # => "hi"
-nickel_eval("true", Bool) # => true
-```
-
-## Typed Dictionaries
-
-### String Keys
-
-```julia
-result = nickel_eval("{ a = 1, b = 2 }", Dict{String, Int})
-# => Dict{String, Int64}("a" => 1, "b" => 2)
-
-result["a"] # => 1
-```
-
-### Symbol Keys
-
-```julia
-result = nickel_eval("{ x = 1.5, y = 2.5 }", Dict{Symbol, Float64})
-# => Dict{Symbol, Float64}(:x => 1.5, :y => 2.5)
-
-result[:x] # => 1.5
-```
-
-## Typed Arrays
-
-```julia
-nickel_eval("[1, 2, 3]", Vector{Int})
-# => [1, 2, 3]
-
-nickel_eval("[\"a\", \"b\", \"c\"]", Vector{String})
-# => ["a", "b", "c"]
-```
-
-## NamedTuples
-
-For structured configuration access:
-
-```julia
-config = nickel_eval("""
-{
- host = "localhost",
- port = 8080,
- debug = true
-}
-""", @NamedTuple{host::String, port::Int, debug::Bool})
-
-# => (host = "localhost", port = 8080, debug = true)
-
-config.host # => "localhost"
-config.port # => 8080
-config.debug # => true
-```
-
-## Custom Structs
-
-Define your own types:
-
-```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 with Types
-
-```julia
-# config.ncl:
-# { environment = "production", max_connections = 100 }
-
-Config = @NamedTuple{environment::String, max_connections::Int}
-config = nickel_eval_file("config.ncl", Config)
-
-config.environment # => "production"
-config.max_connections # => 100
-```
-
-## The `nickel_read` Alias
-
-`nickel_read` is an alias for typed `nickel_eval`:
-
-```julia
-nickel_read("{ a = 1 }", Dict{String, Int})
-# equivalent to
-nickel_eval("{ a = 1 }", Dict{String, Int})
-```
diff --git a/docs/superpowers/plans/2026-03-18-c-api-migration.md b/docs/superpowers/plans/2026-03-18-c-api-migration.md
@@ -0,0 +1,1062 @@
+# C API Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the custom Rust FFI wrapper with direct Julia bindings to Nickel's official C API, and drop the subprocess evaluation path.
+
+**Architecture:** Build the upstream `nickel-lang` crate (with `--features capi`) as a cdylib. Use Clang.jl to generate `ccall` wrappers from `nickel_lang.h`. Write a thin Julia convenience layer that allocates C API handles, evaluates, walks the result tree, and frees handles in `try/finally`. Library discovery: local `deps/` first, then artifacts, then build from source.
+
+**Tech Stack:** Julia, Nickel C API (v1.16.0 / `nickel-lang` crate v2.0.0), Clang.jl (dev tool), Artifacts.jl
+
+**Spec:** `docs/superpowers/specs/2026-03-17-c-api-migration-design.md`
+
+---
+
+### Task 1: Build Nickel C API library locally and generate bindings
+
+This task gets the cdylib built on your machine and produces the generated `ccall` wrappers. Everything else depends on this.
+
+**Files:**
+- Create: `deps/generate_bindings.jl`
+- Create: `deps/generator.toml`
+- Create: `deps/Project.toml` (isolated env for Clang.jl)
+- Create: `src/libnickel.jl` (generated output)
+
+- [ ] **Step 1: Verify Nickel v1.16.0 exists, then clone and build**
+
+```bash
+# Verify the tag exists first
+git ls-remote --tags https://github.com/nickel-lang/nickel.git 1.16.0
+# If tag doesn't exist, find the latest release with capi support (>= 1.15.1)
+# and update NICKEL_VERSION accordingly throughout the plan
+
+cd /tmp
+git clone --depth 1 --branch 1.16.0 https://github.com/nickel-lang/nickel.git nickel-capi
+cd nickel-capi
+cargo build --release -p nickel-lang --features capi
+```
+
+Verify: a `libnickel_lang.dylib` (macOS) or `libnickel_lang.so` (Linux) exists in `target/release/`.
+
+- [ ] **Step 2: Generate the C header**
+
+```bash
+cd /tmp/nickel-capi/nickel
+cargo install cbindgen
+cbindgen --config cbindgen.toml --crate nickel-lang --output /tmp/nickel_lang.h
+```
+
+Verify: `/tmp/nickel_lang.h` exists and contains `nickel_context_alloc`, `nickel_expr_is_bool`, etc.
+
+- [ ] **Step 3: Document actual C API signatures**
+
+Read `/tmp/nickel_lang.h` and verify the function signatures match our plan's assumptions. Key functions to check:
+
+- `nickel_context_eval_deep` — what args does it take? Does it accept `const char*` for source code?
+- `nickel_expr_as_str` — does it return length and take an out-pointer for the string data?
+- `nickel_expr_as_number` — does it return a `nickel_number*`?
+- `nickel_record_key_value_by_index` — what are the exact out-pointer types?
+- `nickel_context_eval_deep_for_export` — does this exist with the same signature as `eval_deep`?
+- `nickel_context_expr_to_json/yaml/toml` — what args?
+- `nickel_error_format_as_string` — what args?
+- `nickel_context_set_source_name` — does this exist?
+- `nickel_result` enum — what are the actual values?
+
+If any signatures differ from the plan, note them. The `_walk_expr` code in Task 2 must be adapted to match the real signatures. The logic (predicate -> extract -> recurse) stays the same; only types and arg positions may change.
+
+- [ ] **Step 4: Copy library and header to deps/**
+
+```bash
+cp /tmp/nickel-capi/target/release/libnickel_lang.dylib deps/ # or .so on Linux
+cp /tmp/nickel_lang.h deps/
+```
+
+- [ ] **Step 5: Create isolated Clang.jl environment**
+
+Create `deps/Project.toml` for the binding generator (keeps Clang.jl out of the main project):
+
+```toml
+[deps]
+Clang = "40e3b903-d033-50b4-a0cc-940c62c95e31"
+```
+
+- [ ] **Step 6: Write `deps/generate_bindings.jl`**
+
+```julia
+#!/usr/bin/env julia
+# Developer tool: regenerate src/libnickel.jl from deps/nickel_lang.h
+# Usage: julia --project=deps/ deps/generate_bindings.jl
+
+using Clang
+using Clang.Generators
+
+cd(@__DIR__)
+
+header = joinpath(@__DIR__, "nickel_lang.h")
+if !isfile(header)
+ error("deps/nickel_lang.h not found. Build the Nickel C API first.")
+end
+
+options = load_options(joinpath(@__DIR__, "generator.toml"))
+ctx = create_context([header], get_default_args(), options)
+build!(ctx)
+
+println("Bindings generated at src/libnickel.jl")
+```
+
+Also create `deps/generator.toml`:
+
+```toml
+[general]
+library_name = "libnickel_lang"
+output_file_path = "../src/libnickel.jl"
+module_name = "LibNickel"
+jll_pkg_name = ""
+export_symbol_prefixes = ["nickel_"]
+
+[codegen]
+use_ccall_macro = true
+```
+
+- [ ] **Step 7: Run generation and review output**
+
+```bash
+julia --project=deps/ -e 'using Pkg; Pkg.instantiate()'
+julia --project=deps/ deps/generate_bindings.jl
+```
+
+Review the generated `src/libnickel.jl`:
+- All ~44 C API functions present
+- Return types and argument types reasonable (opaque pointers as `Ptr{Cvoid}` or typed structs)
+- Library reference is parameterized (not hardcoded) — if hardcoded to `"libnickel_lang"`, edit to use `LIB_PATH` constant (defined later in `ffi.jl`)
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add deps/generate_bindings.jl deps/generator.toml deps/Project.toml src/libnickel.jl
+git commit -m "feat: add Clang.jl binding generator and generated C API wrappers"
+```
+
+---
+
+### Task 2: Delete old code and rewrite `src/ffi.jl` — core tree-walk logic
+
+Delete `subprocess.jl` first to avoid name collision with the new `nickel_eval`, then replace the binary protocol decoder with a tree-walker.
+
+**Files:**
+- Delete: `src/subprocess.jl`
+- Delete: `rust/nickel-jl/` (entire directory)
+- Rewrite: `src/ffi.jl`
+- Rewrite: `src/NickelEval.jl` (remove subprocess include)
+
+**Depends on:** Task 1 (need `src/libnickel.jl` and library in `deps/`)
+
+- [ ] **Step 1: Delete old code and update module to prevent name collision**
+
+```bash
+git rm src/subprocess.jl
+git rm -r rust/nickel-jl/
+```
+
+Then rewrite `src/NickelEval.jl` — this must happen now because both `subprocess.jl` and the new `ffi.jl` define `nickel_eval(code::String)`:
+
+```julia
+module NickelEval
+
+export nickel_eval, nickel_eval_file, @ncl_str, NickelError, NickelEnum
+export nickel_to_json, nickel_to_yaml, nickel_to_toml
+export check_ffi_available
+
+struct NickelError <: Exception
+ message::String
+end
+Base.showerror(io::IO, e::NickelError) = print(io, "NickelError: ", e.message)
+
+struct NickelEnum
+ tag::Symbol
+ arg::Any
+end
+Base.:(==)(e::NickelEnum, s::Symbol) = e.tag == s
+Base.:(==)(s::Symbol, e::NickelEnum) = e.tag == s
+function Base.show(io::IO, e::NickelEnum)
+ if e.arg === nothing
+ print(io, "'", e.tag)
+ else
+ print(io, "'", e.tag, " ", repr(e.arg))
+ end
+end
+
+macro ncl_str(code)
+ :(nickel_eval($code))
+end
+
+include("ffi.jl")
+
+end # module
+```
+
+Also update `Project.toml`: remove JSON from `[deps]` and `[compat]`, bump version to `0.6.0`.
+
+- [ ] **Step 2: Commit the deletion**
+
+```bash
+git add src/NickelEval.jl Project.toml
+git commit -m "chore: remove subprocess path, rust wrapper, and JSON dependency"
+```
+
+- [ ] **Step 3: Write the test for `nickel_eval` with a simple integer**
+
+Create `test/test_eval.jl`:
+
+```julia
+@testset "C API Evaluation" begin
+ @testset "Primitive types" begin
+ @test nickel_eval("42") === Int64(42)
+ @test nickel_eval("-42") === Int64(-42)
+ @test nickel_eval("0") === Int64(0)
+ end
+end
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+julia --project=. -e 'using Pkg; Pkg.test()'
+```
+
+Expected: FAIL — `nickel_eval` still calls subprocess.
+
+- [ ] **Step 3: Write library loading in `src/ffi.jl`**
+
+Replace the entire file with:
+
+```julia
+# FFI bindings to Nickel's official C API
+# Generated wrappers in libnickel.jl, convenience layer here.
+
+using Artifacts: artifact_hash
+using LazyArtifacts
+
+include("libnickel.jl")
+
+# Platform-specific library name
+const LIB_NAME = if Sys.isapple()
+ "libnickel_lang.dylib"
+elseif Sys.iswindows()
+ "nickel_lang.dll"
+else
+ "libnickel_lang.so"
+end
+
+# Find library: local deps/ -> artifact -> not found
+function _find_library_path()
+ # Local deps/ (custom builds, HPC overrides)
+ local_path = joinpath(@__DIR__, "..", "deps", LIB_NAME)
+ if isfile(local_path)
+ return local_path
+ end
+
+ # Artifact (auto-selects platform)
+ try
+ artifact_dir = @artifact_str("libnickel_lang")
+ lib_path = joinpath(artifact_dir, LIB_NAME)
+ if isfile(lib_path)
+ return lib_path
+ end
+ catch
+ end
+
+ return nothing
+end
+
+const LIB_PATH = _find_library_path()
+const FFI_AVAILABLE = LIB_PATH !== nothing
+
+"""
+ check_ffi_available() -> Bool
+
+Check if the Nickel C API library is available.
+"""
+check_ffi_available() = FFI_AVAILABLE
+
+function _check_ffi_available()
+ FFI_AVAILABLE && return
+ error("Nickel C API library not available.\n\n" *
+ "Install options:\n" *
+ " 1. Build from source: NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build(\"NickelEval\")'\n" *
+ " 2. Place $(LIB_NAME) in deps/ manually\n")
+end
+```
+
+- [ ] **Step 4: Write the core `_walk_expr` function**
+
+Append to `src/ffi.jl`:
+
+```julia
+# Convert a C API nickel_expr to a Julia value by walking the type tree.
+# The expr must have been fully evaluated (eval_deep).
+function _walk_expr(expr::Ptr{Cvoid})
+ if nickel_expr_is_null(expr) != 0
+ return nothing
+ elseif nickel_expr_is_bool(expr) != 0
+ return nickel_expr_as_bool(expr) != 0
+ elseif nickel_expr_is_number(expr) != 0
+ num = nickel_expr_as_number(expr) # borrowed pointer, no free
+ if nickel_number_is_i64(num) != 0
+ return nickel_number_as_i64(num)
+ else
+ return nickel_number_as_f64(num)
+ end
+ elseif nickel_expr_is_str(expr) != 0
+ out_ptr = Ref{Ptr{UInt8}}(C_NULL)
+ len = nickel_expr_as_str(expr, out_ptr)
+ return unsafe_string(out_ptr[], len)
+ elseif nickel_expr_is_array(expr) != 0
+ arr = nickel_expr_as_array(expr) # borrowed pointer
+ n = nickel_array_len(arr)
+ result = Vector{Any}(undef, n)
+ elem = nickel_expr_alloc()
+ try
+ for i in 0:(n-1)
+ nickel_array_get(arr, i, elem)
+ result[i+1] = _walk_expr(elem)
+ end
+ finally
+ nickel_expr_free(elem)
+ end
+ return result
+ elseif nickel_expr_is_record(expr) != 0
+ rec = nickel_expr_as_record(expr) # borrowed pointer
+ n = nickel_record_len(rec)
+ result = Dict{String, Any}()
+ key_ptr = Ref{Ptr{UInt8}}(C_NULL)
+ key_len = Ref{Csize_t}(0)
+ val_expr = nickel_expr_alloc()
+ try
+ for i in 0:(n-1)
+ nickel_record_key_value_by_index(rec, i, key_ptr, key_len, val_expr)
+ key = unsafe_string(key_ptr[], key_len[])
+ result[key] = _walk_expr(val_expr)
+ end
+ finally
+ nickel_expr_free(val_expr)
+ end
+ return result
+ elseif nickel_expr_is_enum_variant(expr) != 0
+ out_ptr = Ref{Ptr{UInt8}}(C_NULL)
+ arg_expr = nickel_expr_alloc()
+ try
+ len = nickel_expr_as_enum_variant(expr, out_ptr, arg_expr)
+ tag = Symbol(unsafe_string(out_ptr[], len))
+ arg = _walk_expr(arg_expr)
+ return NickelEnum(tag, arg)
+ finally
+ nickel_expr_free(arg_expr)
+ end
+ elseif nickel_expr_is_enum_tag(expr) != 0
+ out_ptr = Ref{Ptr{UInt8}}(C_NULL)
+ len = nickel_expr_as_enum_tag(expr, out_ptr)
+ tag = Symbol(unsafe_string(out_ptr[], len))
+ return NickelEnum(tag, nothing)
+ else
+ error("Unknown Nickel expression type")
+ end
+end
+```
+
+**Note:** The exact `ccall` wrapper signatures from `libnickel.jl` may differ. Adjust argument types (e.g., `Ptr{Cvoid}` vs named opaque types) to match the generated bindings. The key logic — predicate checks, extraction, recursion — stays the same.
+
+- [ ] **Step 5: Write `nickel_eval(code::String)`**
+
+Append to `src/ffi.jl`:
+
+```julia
+"""
+ nickel_eval(code::String) -> Any
+
+Evaluate Nickel code and return a Julia value.
+
+Returns native Julia types: Int64, Float64, Bool, String, nothing,
+Vector{Any}, Dict{String,Any}, or NickelEnum.
+"""
+function nickel_eval(code::String)
+ _check_ffi_available()
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ try
+ result = nickel_context_eval_deep(ctx, code, expr, err)
+ if result != 0 # NICKEL_RESULT_ERR
+ _throw_nickel_error(err)
+ end
+ return _walk_expr(expr)
+ finally
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
+ end
+end
+
+"""
+ nickel_eval(code::String, ::Type{T}) -> T
+
+Evaluate Nickel code and convert to type T.
+Supports Dict, Vector, and NamedTuple conversions from the tree-walked result.
+"""
+function nickel_eval(code::String, ::Type{T}) where T
+ result = nickel_eval(code)
+ return _convert_result(T, result)
+end
+
+# Recursive type conversion for tree-walked results.
+# Handles cases that Julia's convert() does not (NamedTuple from Dict, typed containers).
+_convert_result(::Type{T}, x) where T = convert(T, x)
+
+function _convert_result(::Type{T}, d::Dict{String,Any}) where T <: NamedTuple
+ fields = fieldnames(T)
+ types = fieldtypes(T)
+ values = Tuple(_convert_result(types[i], d[String(fields[i])]) for i in eachindex(fields))
+ return T(values)
+end
+
+function _convert_result(::Type{Dict{K,V}}, d::Dict{String,Any}) where {K,V}
+ return Dict{K,V}(K(k) => _convert_result(V, v) for (k, v) in d)
+end
+
+function _convert_result(::Type{Vector{T}}, v::Vector{Any}) where T
+ return T[_convert_result(T, x) for x in v]
+end
+
+function _throw_nickel_error(err::Ptr{Cvoid})
+ out_str = nickel_string_alloc()
+ try
+ nickel_error_format_as_string(err, out_str)
+ ptr = nickel_string_data(out_str)
+ # nickel_string_data returns pointer; need length
+ # The exact API may vary — check generated bindings
+ msg = unsafe_string(ptr)
+ throw(NickelError(msg))
+ finally
+ nickel_string_free(out_str)
+ end
+end
+```
+
+- [ ] **Step 6: Run primitive type tests**
+
+```bash
+julia --project=. -e '
+using NickelEval, Test
+@test nickel_eval("42") === Int64(42)
+@test nickel_eval("3.14") ≈ 3.14
+@test nickel_eval("true") === true
+@test nickel_eval("null") === nothing
+@test nickel_eval("\"hello\"") == "hello"
+println("All primitive tests passed")
+'
+```
+
+Expected: PASS. If any fail, debug by checking the generated binding signatures against the actual C API.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/ffi.jl
+git commit -m "feat: rewrite ffi.jl to use Nickel C API tree-walk"
+```
+
+---
+
+### Task 3: Add file evaluation, export functions, and string macro
+
+**Files:**
+- Modify: `src/ffi.jl` (append functions)
+
+**Depends on:** Task 2
+
+- [ ] **Step 1: Write tests for file evaluation**
+
+Add to `test/test_eval.jl`:
+
+```julia
+@testset "File Evaluation" begin
+ mktempdir() do dir
+ # Simple file
+ f = joinpath(dir, "test.ncl")
+ write(f, "{ x = 42 }")
+ result = nickel_eval_file(f)
+ @test result["x"] === Int64(42)
+
+ # File with import
+ shared = joinpath(dir, "shared.ncl")
+ write(shared, "{ val = 100 }")
+ main = joinpath(dir, "main.ncl")
+ write(main, """
+ let s = import "shared.ncl" in
+ { result = s.val }
+ """)
+ result = nickel_eval_file(main)
+ @test result["result"] === Int64(100)
+ end
+end
+```
+
+- [ ] **Step 2: Implement `nickel_eval_file`**
+
+Append to `src/ffi.jl`:
+
+```julia
+"""
+ nickel_eval_file(path::String) -> Any
+
+Evaluate a Nickel file. Supports `import` statements resolved relative
+to the file's directory.
+"""
+function nickel_eval_file(path::String)
+ _check_ffi_available()
+ abs_path = abspath(path)
+ if !isfile(abs_path)
+ throw(NickelError("File not found: $abs_path"))
+ end
+ code = read(abs_path, String)
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ try
+ nickel_context_set_source_name(ctx, abs_path)
+ result = nickel_context_eval_deep(ctx, code, expr, err)
+ if result != 0
+ _throw_nickel_error(err)
+ end
+ return _walk_expr(expr)
+ finally
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
+ end
+end
+```
+
+- [ ] **Step 3: Run file evaluation tests**
+
+```bash
+julia --project=. -e 'using NickelEval, Test; include("test/test_eval.jl")'
+```
+
+Expected: PASS
+
+- [ ] **Step 4: Write tests for export functions**
+
+Add to `test/test_eval.jl`:
+
+```julia
+@testset "Export formats" begin
+ json = nickel_to_json("{ a = 1 }")
+ @test occursin("\"a\"", json)
+ @test occursin("1", json)
+
+ yaml = nickel_to_yaml("{ a = 1 }")
+ @test occursin("a:", yaml)
+
+ toml = nickel_to_toml("{ a = 1 }")
+ @test occursin("a = 1", toml)
+end
+```
+
+- [ ] **Step 5: Implement export functions**
+
+Append to `src/ffi.jl`:
+
+```julia
+function _eval_and_serialize(code::String, serialize_fn::Function)
+ _check_ffi_available()
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ out_str = nickel_string_alloc()
+ try
+ result = nickel_context_eval_deep_for_export(ctx, code, expr, err)
+ if result != 0
+ _throw_nickel_error(err)
+ end
+ serialize_fn(ctx, expr, out_str)
+ ptr = nickel_string_data(out_str)
+ return unsafe_string(ptr)
+ finally
+ nickel_string_free(out_str)
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
+ end
+end
+
+"""
+ nickel_to_json(code::String) -> String
+
+Export Nickel code to a JSON string.
+"""
+nickel_to_json(code::String) = _eval_and_serialize(code, nickel_context_expr_to_json)
+
+"""
+ nickel_to_yaml(code::String) -> String
+
+Export Nickel code to a YAML string.
+"""
+nickel_to_yaml(code::String) = _eval_and_serialize(code, nickel_context_expr_to_yaml)
+
+"""
+ nickel_to_toml(code::String) -> String
+
+Export Nickel code to a TOML string.
+"""
+nickel_to_toml(code::String) = _eval_and_serialize(code, nickel_context_expr_to_toml)
+```
+
+- [ ] **Step 6: Run export tests**
+
+```bash
+julia --project=. -e 'using NickelEval, Test; include("test/test_eval.jl")'
+```
+
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/ffi.jl test/test_eval.jl
+git commit -m "feat: add file evaluation and export functions via C API"
+```
+
+---
+
+### Task 4: Complete the test suite
+
+Port all tests from `test/test_ffi.jl` into `test/test_eval.jl` (started in Task 2), add export and typed conversion tests, and rewrite `test/runtests.jl`.
+
+**Files:**
+- Rewrite: `test/test_eval.jl` (expand with full test port from `test_ffi.jl`)
+- Rewrite: `test/runtests.jl`
+- Delete: `test/test_subprocess.jl`, `test/test_ffi.jl`
+
+**Depends on:** Task 3
+
+- [ ] **Step 1: Port `test/test_ffi.jl` into `test/test_eval.jl`**
+
+Copy the content of `test/test_ffi.jl` into `test/test_eval.jl`, applying these substitutions:
+- `nickel_eval_native(x)` → `nickel_eval(x)` (throughout)
+- `nickel_eval_file_native(x)` → `nickel_eval_file(x)` (throughout)
+- Remove the `FFI JSON Evaluation` testset entirely (lines 341-351 of test_ffi.jl)
+- Keep ALL other testsets: primitive types, arrays, records, type preservation, computed values, record operations, array operations, all enum testsets, deeply nested structures, file evaluation with imports, error handling
+
+The test content is ~95% identical — just function name changes.
+
+- [ ] **Step 2: Add export format, string macro, error, and typed conversion tests**
+
+Append to `test/test_eval.jl`:
+
+```julia
+@testset "Export Formats" begin
+ json = nickel_to_json("{ a = 1, b = 2 }")
+ @test occursin("\"a\"", json)
+ @test occursin("\"b\"", json)
+
+ yaml = nickel_to_yaml("{ name = \"test\", port = 8080 }")
+ @test occursin("name:", yaml)
+
+ toml = nickel_to_toml("{ name = \"test\", port = 8080 }")
+ @test occursin("name", toml)
+end
+
+@testset "String macro" begin
+ @test ncl"42" === Int64(42)
+ @test ncl"\"hello\"" == "hello"
+end
+
+@testset "Error handling" begin
+ @test_throws NickelError nickel_eval("{ x = }")
+ @test_throws NickelError nickel_eval_file("/nonexistent/path.ncl")
+end
+
+@testset "Typed conversion" begin
+ # Dict{String, Int}
+ result = nickel_eval("{ a = 1, b = 2 }", Dict{String, Int})
+ @test result isa Dict{String, Int}
+ @test result["a"] == 1
+
+ # Vector{Int}
+ result = nickel_eval("[1, 2, 3]", Vector{Int})
+ @test result isa Vector{Int}
+ @test result == [1, 2, 3]
+
+ # NamedTuple
+ result = nickel_eval("{ x = 1, y = 2 }", @NamedTuple{x::Int, y::Int})
+ @test result isa @NamedTuple{x::Int, y::Int}
+ @test result.x == 1
+ @test result.y == 2
+end
+
+@testset "File evaluation import resolution" begin
+ # Verify that set_source_name correctly resolves relative imports
+ mktempdir() do dir
+ write(joinpath(dir, "dep.ncl"), "{ val = 99 }")
+ write(joinpath(dir, "main.ncl"), """
+ let d = import "dep.ncl" in
+ { result = d.val }
+ """)
+ result = nickel_eval_file(joinpath(dir, "main.ncl"))
+ @test result["result"] === Int64(99)
+ end
+end
+```
+
+- [ ] **Step 3: Write `test/runtests.jl`**
+
+```julia
+using NickelEval
+using Test
+
+@testset "NickelEval.jl" begin
+ if check_ffi_available()
+ include("test_eval.jl")
+ else
+ @warn "Nickel C API library not available. Build with: NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build(\"NickelEval\")'"
+ @test_skip "C API not available"
+ end
+end
+```
+
+- [ ] **Step 4: Delete old test files**
+
+```bash
+git rm test/test_subprocess.jl test/test_ffi.jl
+```
+
+- [ ] **Step 5: Run full test suite**
+
+```bash
+julia --project=. -e 'using Pkg; Pkg.test()'
+```
+
+Expected: All tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add test/runtests.jl test/test_eval.jl
+git commit -m "test: port test suite to C API, remove old test files"
+```
+
+---
+
+### Task 5: Rewrite `deps/build.jl` for source builds
+
+**Files:**
+- Rewrite: `deps/build.jl`
+
+**Depends on:** Task 4
+
+- [ ] **Step 1: Rewrite `deps/build.jl`**
+
+```julia
+# Build script: compile Nickel's C API library from source
+# Triggered by NICKELEVAL_BUILD_FFI=true or when no artifact/local lib exists
+
+const NICKEL_VERSION = "1.16.0"
+const NICKEL_REPO = "https://github.com/nickel-lang/nickel.git"
+
+function library_name()
+ if Sys.isapple()
+ return "libnickel_lang.dylib"
+ elseif Sys.iswindows()
+ return "nickel_lang.dll"
+ else
+ return "libnickel_lang.so"
+ end
+end
+
+function build_nickel_capi()
+ cargo = Sys.which("cargo")
+ if cargo === nothing
+ @warn "cargo not found in PATH. Install Rust: https://rustup.rs/"
+ return false
+ end
+
+ src_dir = joinpath(@__DIR__, "_nickel_src")
+
+ # Clone or update
+ if isdir(src_dir)
+ @info "Updating Nickel source..."
+ cd(src_dir) do
+ run(`git fetch --depth 1 origin tag $(NICKEL_VERSION)`)
+ run(`git checkout $(NICKEL_VERSION)`)
+ end
+ else
+ @info "Cloning Nickel $(NICKEL_VERSION)..."
+ run(`git clone --depth 1 --branch $(NICKEL_VERSION) $(NICKEL_REPO) $(src_dir)`)
+ end
+
+ @info "Building Nickel C API library..."
+ try
+ cd(src_dir) do
+ run(`cargo build --release -p nickel-lang --features capi`)
+ end
+
+ src_lib = joinpath(src_dir, "target", "release", library_name())
+ dst_lib = joinpath(@__DIR__, library_name())
+
+ if isfile(src_lib)
+ cp(src_lib, dst_lib; force=true)
+ @info "Library built: $(dst_lib)"
+
+ # Also copy header if cbindgen is available
+ if Sys.which("cbindgen") !== nothing
+ cd(joinpath(src_dir, "nickel")) do
+ run(`cbindgen --config cbindgen.toml --crate nickel-lang --output $(joinpath(@__DIR__, "nickel_lang.h"))`)
+ end
+ @info "Header generated: $(joinpath(@__DIR__, "nickel_lang.h"))"
+ end
+
+ return true
+ else
+ @warn "Built library not found at $(src_lib)"
+ return false
+ end
+ catch e
+ @warn "Build failed: $(e)"
+ return false
+ end
+end
+
+if get(ENV, "NICKELEVAL_BUILD_FFI", "false") == "true"
+ build_nickel_capi()
+else
+ @info "Skipping FFI build (set NICKELEVAL_BUILD_FFI=true to enable)"
+end
+```
+
+- [ ] **Step 2: Test build from source**
+
+```bash
+NICKELEVAL_BUILD_FFI=true julia --project=. deps/build.jl
+```
+
+Expected: Library builds and is copied to `deps/`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add deps/build.jl
+git commit -m "feat: rewrite build.jl to build upstream Nickel C API from source"
+```
+
+---
+
+### Task 6: Update artifacts and CI
+
+**Files:**
+- Modify: `Artifacts.toml`
+- Modify: `.github/workflows/build-ffi.yml`
+- Modify: `.github/workflows/CI.yml`
+
+**Depends on:** Task 4, Task 5
+
+- [ ] **Step 1: Update `Artifacts.toml`**
+
+Replace with placeholder for new artifact name (`libnickel_lang` instead of `libnickel_jl`), two platforms only:
+
+```toml
+# Pre-built Nickel C API library
+# Built by GitHub Actions from nickel-lang/nickel v1.16.0 with --features capi
+# Platforms: aarch64-darwin, x86_64-linux
+
+# macOS Apple Silicon (aarch64)
+[[libnickel_lang]]
+arch = "aarch64"
+git-tree-sha1 = "TODO"
+os = "macos"
+lazy = true
+
+ [[libnickel_lang.download]]
+ url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.6.0/libnickel_lang-aarch64-apple-darwin.tar.gz"
+ sha256 = "TODO"
+
+# Linux x86_64
+[[libnickel_lang]]
+arch = "x86_64"
+git-tree-sha1 = "TODO"
+os = "linux"
+lazy = true
+
+ [[libnickel_lang.download]]
+ url = "https://github.com/LouLouLibs/NickelEval.jl/releases/download/v0.6.0/libnickel_lang-x86_64-linux-gnu.tar.gz"
+ sha256 = "TODO"
+```
+
+- [ ] **Step 3: Update `.github/workflows/build-ffi.yml`**
+
+Reduce to 2 platforms. Build upstream `nickel-lang` crate instead of `rust/nickel-jl`:
+
+```yaml
+name: Build FFI Library
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ artifact: libnickel_lang.so
+ artifact_name: libnickel_lang-x86_64-linux-gnu.tar.gz
+ - os: macos-14
+ target: aarch64-apple-darwin
+ artifact: libnickel_lang.dylib
+ artifact_name: libnickel_lang-aarch64-apple-darwin.tar.gz
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Clone Nickel
+ run: git clone --depth 1 --branch 1.16.0 https://github.com/nickel-lang/nickel.git
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Build library
+ working-directory: nickel
+ run: cargo build --release -p nickel-lang --features capi --target ${{ matrix.target }}
+
+ - name: Package artifact
+ run: |
+ cd nickel/target/${{ matrix.target }}/release
+ tar -czvf ${{ matrix.artifact_name }} ${{ matrix.artifact }}
+ mv ${{ matrix.artifact_name }} ${{ github.workspace }}/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: ${{ matrix.artifact_name }}
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: artifacts/**/*.tar.gz
+ generate_release_notes: true
+```
+
+- [ ] **Step 4: Update `.github/workflows/CI.yml`**
+
+Remove Nickel CLI install. Build C API from source with Rust cargo caching to avoid rebuilding on every CI run:
+
+```yaml
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ tags: '*'
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: julia-actions/setup-julia@v2
+ with:
+ version: '1'
+ - uses: julia-actions/cache@v2
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ - name: Cache Rust build
+ uses: actions/cache@v4
+ with:
+ path: deps/_nickel_src/target
+ key: nickel-capi-${{ runner.os }}-1.16.0
+ - name: Build Nickel C API
+ run: NICKELEVAL_BUILD_FFI=true julia --project=. deps/build.jl
+ - name: Run tests
+ run: julia --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()'
+```
+
+- [ ] **Step 5: Run tests one more time**
+
+```bash
+julia --project=. -e 'using Pkg; Pkg.test()'
+```
+
+Expected: All tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add Artifacts.toml .github/workflows/build-ffi.yml .github/workflows/CI.yml
+git commit -m "chore: update artifacts and CI for C API"
+```
+
+---
+
+### Task 7: Update documentation
+
+**Files:**
+- Modify: `docs/src/index.md`
+- Modify: `docs/src/lib/public.md`
+- Modify: `docs/src/man/quickstart.md`
+- Delete: `docs/src/man/ffi.md` (old FFI docs — now the only path)
+- Delete: `docs/src/man/typed.md` (references `nickel_read` and JSON.jl typed parsing — both removed)
+- Delete: `docs/src/man/export.md` (references `nickel_export` with format kwarg — removed)
+- Modify: `docs/make.jl` (remove deleted pages from nav)
+- Modify: `CLAUDE.md` and `.claude/CLAUDE.md`
+
+**Depends on:** Task 6
+
+- [ ] **Step 1: Update `docs/src/lib/public.md`**
+
+Remove all old function docs (`nickel_eval_ffi`, `nickel_eval_native`, `nickel_eval_file_native`, `nickel_export`, `nickel_read`, `find_nickel_executable`). Keep/add docs for: `nickel_eval`, `nickel_eval_file`, `nickel_to_json`, `nickel_to_yaml`, `nickel_to_toml`, `check_ffi_available`, `@ncl_str`, `NickelError`, `NickelEnum`.
+
+- [ ] **Step 2: Update quickstart and index**
+
+Update examples to use `nickel_eval` instead of `nickel_eval_native`. Remove subprocess references. Update install instructions to mention the C API library.
+
+- [ ] **Step 3: Remove obsolete doc pages and update `docs/make.jl`**
+
+```bash
+git rm docs/src/man/ffi.md docs/src/man/typed.md docs/src/man/export.md
+```
+
+Update `docs/make.jl` to remove `"man/ffi.md"`, `"man/typed.md"`, and `"man/export.md"` from the pages list.
+
+- [ ] **Step 4: Update `CLAUDE.md`**
+
+Update the root `CLAUDE.md` to reflect the new architecture: no more `rust/nickel-jl`, no more `subprocess.jl`, no more binary protocol, no more JSON.jl. Update the API functions section and building instructions. Also update `.claude/CLAUDE.md` if it references old architecture.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add CLAUDE.md .claude/CLAUDE.md docs/
+git commit -m "docs: update documentation for C API migration"
+```
diff --git a/docs/superpowers/specs/2026-03-17-c-api-migration-design.md b/docs/superpowers/specs/2026-03-17-c-api-migration-design.md
@@ -0,0 +1,177 @@
+# NickelEval.jl: Migration to Nickel's Official C API
+
+## Goal
+
+Replace the custom Rust FFI wrapper (`rust/nickel-jl/`) with direct bindings to Nickel's official C API. Drop the subprocess evaluation path. Reduce maintenance burden.
+
+## Motivation
+
+The current implementation wraps `nickel-lang-core` v0.9 internals (`Term`, `Program`, `CBNCache`) through a custom Rust library that encodes results in a hand-rolled binary protocol. Both the Rust encoder and the Julia decoder must be maintained in lockstep. When Nickel's internal API changes, the wrapper breaks.
+
+Nickel now ships an official C API (`nickel/src/capi.rs`, 44 functions) behind a `capi` feature flag. It provides a stable ABI with opaque types, type predicates, value extractors, and built-in serialization. A Nickel maintainer recommended this approach in [discussion #2540](https://github.com/nickel-lang/nickel/discussions/2540).
+
+## Breaking Changes
+
+This is a major rewrite. All previously exported subprocess functions are removed: `nickel_eval` (subprocess version), `nickel_eval_file` (subprocess version), `nickel_export`, `nickel_read`, `find_nickel_executable`, `nickel_eval_ffi`, `nickel_eval_native`, `nickel_eval_file_native`. The Nickel CLI is no longer required.
+
+`nickel_eval` now returns `Dict{String,Any}` for records instead of `JSON.Object`. Code using dot-access (`result.name`) must switch to bracket-access (`result["name"]`).
+
+JSON.jl is no longer a dependency. The C API handles serialization internally.
+
+## Target Nickel Version
+
+Pin to Nickel **v1.16.0** (Feb 2026). The C API crate is `nickel-lang` v2.0.0 within the monorepo workspace. Source: [`nickel/src/capi.rs`](https://github.com/nickel-lang/nickel/blob/1.16.0/nickel/src/capi.rs).
+
+## What Changes
+
+**Deleted:**
+- `rust/nickel-jl/` — the entire custom Rust wrapper
+- `src/subprocess.jl` — CLI-based evaluation
+- The custom binary protocol (Rust encoder + Julia decoder)
+- Windows and x86_64-darwin artifact targets
+- `JSON.jl` dependency
+
+**Added:**
+- `src/libnickel.jl` — Clang.jl-generated `ccall` wrappers from `nickel_lang.h`
+- `deps/generate_bindings.jl` — developer tool to regenerate bindings
+- Build-from-source fallback in `deps/build.jl`
+
+**Modified:**
+- `src/ffi.jl` — rewritten to walk the C API's opaque tree instead of decoding a binary buffer
+- `src/NickelEval.jl` — simplified exports
+- `Artifacts.toml` — two platforms only (aarch64-darwin, x86_64-linux)
+- `build-ffi.yml` — builds upstream `nickel-lang` crate with `--features capi`
+
+## Project Structure
+
+```
+NickelEval/
+├── src/
+│ ├── NickelEval.jl # Module, exports, types
+│ ├── libnickel.jl # Generated ccall wrappers (checked in)
+│ └── ffi.jl # Convenience layer: eval, tree-walk, type conversion
+├── deps/
+│ ├── build.jl # Build nickel-lang cdylib from source (fallback)
+│ └── generate_bindings.jl # Clang.jl: regenerate libnickel.jl from header
+├── Artifacts.toml # aarch64-darwin, x86_64-linux
+├── .github/workflows/
+│ ├── CI.yml
+│ └── build-ffi.yml # 2-platform artifact builds
+└── test/
+ └── runtests.jl
+```
+
+## Public API
+
+```julia
+# Evaluation
+nickel_eval(code::String) # Returns Julia native types
+nickel_eval(code::String, ::Type{T}) # Typed conversion
+nickel_eval_file(path::String) # Evaluate .ncl file with import support
+
+# Export to string formats (via C API built-in serialization)
+nickel_to_json(code::String) -> String
+nickel_to_yaml(code::String) -> String
+nickel_to_toml(code::String) -> String
+
+# Utility
+check_ffi_available() -> Bool
+
+# String macro
+@ncl_str # ncl"{ x = 1 }" sugar for nickel_eval
+
+# Types
+NickelError
+NickelEnum
+```
+
+## Type Mapping
+
+| Nickel (C API predicate) | Julia |
+|--------------------------|-------|
+| null (`is_null`) | `nothing` |
+| bool (`is_bool`) | `Bool` |
+| number, integer (`is_number`, `number_is_i64`) | `Int64` |
+| number, float (`is_number`, not `number_is_i64`) | `Float64` |
+| string (`is_str`) | `String` |
+| array (`is_array`) | `Vector{Any}` |
+| record (`is_record`) | `Dict{String,Any}` |
+| enum tag (`is_enum_tag`) | `NickelEnum(:Tag, nothing)` |
+| enum variant (`is_enum_variant`) | `NickelEnum(:Tag, value)` |
+
+## Internal Flow
+
+### `nickel_eval(code)`
+
+1. Allocate context, expression, and error handles
+2. Call `nickel_context_eval_deep(ctx, code, expr, err)`
+3. Check result; on error, extract message via `nickel_error_format_as_string` and throw `NickelError`
+4. Walk the expression tree recursively:
+ - Test type with `nickel_expr_is_*` predicates
+ - Extract scalars: `nickel_expr_as_bool`, `nickel_expr_as_str` (returns length, sets pointer), `nickel_expr_as_number` (then `nickel_number_is_i64` / `nickel_number_as_i64` / `nickel_number_as_f64`)
+ - For arrays: `nickel_array_len` + `nickel_array_get` per index, recurse
+ - For records: `nickel_record_len` + `nickel_record_key_value_by_index` per index, recurse
+ - For enums: `nickel_expr_as_enum_tag` (simple) or `nickel_expr_as_enum_variant` (with argument, recurse on argument)
+5. Free all handles in a `try/finally` block to prevent leaks on error
+
+### `nickel_eval_file(path)`
+
+Read the file contents in Julia, then pass to `nickel_context_eval_deep` with `nickel_context_set_source_name(ctx, path)` so that relative imports resolve correctly.
+
+### `nickel_to_json/yaml/toml(code)`
+
+1. Allocate context, expression, error, and output string handles
+2. Evaluate with `nickel_context_eval_deep_for_export`
+3. Serialize with `nickel_context_expr_to_json` / `_to_yaml` / `_to_toml`
+4. Extract string from `nickel_string` handle via `nickel_string_data`
+5. Free all handles in `try/finally`
+
+### String handling
+
+C API strings are not null-terminated. All string functions return a length and set a pointer. Use `unsafe_string(ptr, len)` in Julia.
+
+## Binding Generation
+
+`deps/generate_bindings.jl` uses Clang.jl to parse `nickel_lang.h` and produce `src/libnickel.jl`. This script is a developer tool, not part of the install pipeline. The generated file is checked into git so users need only the compiled `libnickel_lang` shared library.
+
+To regenerate after a Nickel version bump:
+
+```bash
+julia --project=. deps/generate_bindings.jl
+```
+
+## Build & Artifacts
+
+**Artifact platforms:** aarch64-darwin, x86_64-linux
+
+**Build-from-source fallback (`deps/build.jl`):**
+1. Check for `cargo` in PATH
+2. Clone or fetch `nickel-lang/nickel` at a pinned tag
+3. Run `cargo build --release -p nickel-lang --features capi`
+4. Copy `libnickel_lang.{dylib,so}` and `nickel_lang.h` to `deps/`
+
+**Library discovery order:**
+1. Local `deps/` (dev builds, HPC overrides)
+2. Artifact from `Artifacts.toml`
+3. Trigger source build if neither found
+
+## Testing
+
+Preserve coverage from the existing `test_ffi.jl` (~300 lines). Test categories to port:
+
+- Primitive types: null, bool, integer, float, string
+- Collections: arrays (empty, nested, mixed), records (simple, nested, merged)
+- Type preservation: Int64 vs Float64 distinction
+- Computed values: arithmetic, let bindings, functions
+- Enums: simple tags, with arguments, nested, pattern matching
+- File evaluation: basic file, imports, nested imports, subdirectory imports
+- Error handling: syntax errors, file not found, import not found
+- Export formats: JSON, YAML, TOML output strings
+- Memory safety: no handle leaks on error paths
+
+## Risks
+
+- **C API stability:** Pinned to Nickel v1.16.0. Breaking changes in future versions require regenerating bindings and possibly updating the tree-walk logic.
+- **Rust toolchain requirement:** Needs edition 2024 / rust >= 1.89 for source builds. Users without Rust get artifacts only.
+- **Clang.jl output quality:** Generated wrappers may need minor hand-editing for Julia idioms. Mitigation: review generated output before checking in.
+- **File evaluation via source name:** `nickel_context_set_source_name` may not fully replicate the import resolution behavior of evaluating a file directly. Needs verification against the C API.
diff --git a/rust/nickel-jl/Cargo.toml b/rust/nickel-jl/Cargo.toml
@@ -1,17 +0,0 @@
-[package]
-name = "nickel-jl"
-version = "0.1.0"
-edition = "2021"
-description = "C-compatible wrapper around Nickel for Julia FFI"
-
-[lib]
-crate-type = ["cdylib"]
-
-[dependencies]
-nickel-lang-core = "0.9"
-serde_json = "1.0"
-malachite = "0.4"
-
-[profile.release]
-opt-level = 3
-lto = true
diff --git a/rust/nickel-jl/src/lib.rs b/rust/nickel-jl/src/lib.rs
@@ -1,929 +0,0 @@
-//! C-compatible wrapper around Nickel for Julia FFI
-//!
-//! This library provides C-compatible functions that can be called from Julia
-//! via ccall/FFI to evaluate Nickel code without spawning a subprocess.
-//!
-//! # Functions
-//!
-//! - `nickel_eval_string`: Evaluate Nickel code and return JSON string
-//! - `nickel_eval_native`: Evaluate Nickel code and return binary-encoded native types
-//! - `nickel_get_error`: Get the last error message
-//! - `nickel_free_string`: Free allocated string memory
-//! - `nickel_free_buffer`: Free allocated binary buffer
-
-use std::ffi::{CStr, CString};
-use std::io::Cursor;
-use std::os::raw::c_char;
-use std::ptr;
-
-use nickel_lang_core::eval::cache::lazy::CBNCache;
-use nickel_lang_core::program::Program;
-use nickel_lang_core::serialize::{self, ExportFormat};
-use nickel_lang_core::term::{RichTerm, Term};
-
-use malachite::rounding_modes::RoundingMode;
-use malachite::num::conversion::traits::RoundingFrom;
-
-// Thread-local storage for the last error message
-thread_local! {
- static LAST_ERROR: std::cell::RefCell<Option<CString>> = const { std::cell::RefCell::new(None) };
-}
-
-// Type tags for binary protocol
-const TYPE_NULL: u8 = 0;
-const TYPE_BOOL: u8 = 1;
-const TYPE_INT: u8 = 2;
-const TYPE_FLOAT: u8 = 3;
-const TYPE_STRING: u8 = 4;
-const TYPE_ARRAY: u8 = 5;
-const TYPE_RECORD: u8 = 6;
-const TYPE_ENUM: u8 = 7;
-
-/// Result buffer for native evaluation
-#[repr(C)]
-pub struct NativeBuffer {
- pub data: *mut u8,
- pub len: usize,
-}
-
-/// Evaluate a Nickel code string and return the result as a JSON string.
-///
-/// # Safety
-/// - `code` must be a valid null-terminated C string
-/// - The returned pointer must be freed with `nickel_free_string`
-/// - Returns NULL on error; use `nickel_get_error` to retrieve error message
-#[no_mangle]
-pub unsafe extern "C" fn nickel_eval_string(code: *const c_char) -> *const c_char {
- if code.is_null() {
- set_error("Null pointer passed to nickel_eval_string");
- return ptr::null();
- }
-
- let code_str = match CStr::from_ptr(code).to_str() {
- Ok(s) => s,
- Err(e) => {
- set_error(&format!("Invalid UTF-8 in input: {}", e));
- return ptr::null();
- }
- };
-
- match eval_nickel_json(code_str) {
- Ok(json) => {
- match CString::new(json) {
- Ok(cstr) => cstr.into_raw(),
- Err(e) => {
- set_error(&format!("Result contains null byte: {}", e));
- ptr::null()
- }
- }
- }
- Err(e) => {
- set_error(&e);
- ptr::null()
- }
- }
-}
-
-/// Evaluate Nickel code and return binary-encoded native types.
-///
-/// Binary protocol:
-/// - Type tag (1 byte): 0=Null, 1=Bool, 2=Int64, 3=Float64, 4=String, 5=Array, 6=Record
-/// - Value data (varies by type)
-///
-/// # Safety
-/// - `code` must be a valid null-terminated C string
-/// - 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_native(code: *const c_char) -> NativeBuffer {
- let null_buffer = NativeBuffer { data: ptr::null_mut(), len: 0 };
-
- if code.is_null() {
- set_error("Null pointer passed to nickel_eval_native");
- return null_buffer;
- }
-
- let code_str = match CStr::from_ptr(code).to_str() {
- Ok(s) => s,
- Err(e) => {
- set_error(&format!("Invalid UTF-8 in input: {}", e));
- return null_buffer;
- }
- };
-
- match eval_nickel_native(code_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
- }
- }
-}
-
-/// 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());
- let mut program: Program<CBNCache> = Program::new_from_source(source, "<ffi>", std::io::sink())
- .map_err(|e| format!("Parse error: {}", e))?;
-
- let result = program
- .eval_full_for_export()
- .map_err(|e| program.report_as_str(e))?;
-
- serialize::to_string(ExportFormat::Json, &result)
- .map_err(|e| format!("Serialization error: {:?}", e))
-}
-
-/// Internal function to evaluate Nickel code and return binary-encoded native types.
-fn eval_nickel_native(code: &str) -> Result<Vec<u8>, String> {
- let source = Cursor::new(code.as_bytes());
- let mut program: Program<CBNCache> = Program::new_from_source(source, "<ffi>", std::io::sink())
- .map_err(|e| format!("Parse error: {}", 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)
-}
-
-/// 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() {
- Term::Null => {
- buffer.push(TYPE_NULL);
- }
- Term::Bool(b) => {
- buffer.push(TYPE_BOOL);
- buffer.push(if *b { 1 } else { 0 });
- }
- Term::Num(n) => {
- // Convert to f64 using nearest rounding mode
- let (f, _) = f64::rounding_from(n, RoundingMode::Nearest);
- // Try to represent as integer if possible
- if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
- buffer.push(TYPE_INT);
- buffer.extend_from_slice(&(f as i64).to_le_bytes());
- } else {
- buffer.push(TYPE_FLOAT);
- buffer.extend_from_slice(&f.to_le_bytes());
- }
- }
- Term::Str(s) => {
- buffer.push(TYPE_STRING);
- let bytes = s.as_str().as_bytes();
- buffer.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
- buffer.extend_from_slice(bytes);
- }
- Term::Array(arr, _) => {
- buffer.push(TYPE_ARRAY);
- buffer.extend_from_slice(&(arr.len() as u32).to_le_bytes());
- for elem in arr.iter() {
- encode_term(elem, buffer)?;
- }
- }
- Term::Record(record) => {
- buffer.push(TYPE_RECORD);
- let fields: Vec<_> = record.fields.iter().collect();
- buffer.extend_from_slice(&(fields.len() as u32).to_le_bytes());
- for (key, field) in fields {
- // Encode field name
- let key_bytes = key.label().as_bytes();
- buffer.extend_from_slice(&(key_bytes.len() as u32).to_le_bytes());
- buffer.extend_from_slice(key_bytes);
- // Encode field value
- if let Some(ref value) = field.value {
- encode_term(value, buffer)?;
- } else {
- buffer.push(TYPE_NULL);
- }
- }
- }
- Term::Enum(tag) => {
- // Simple enum without argument
- // Format: TYPE_ENUM | tag_len (u32) | tag_bytes | has_arg (u8 = 0)
- buffer.push(TYPE_ENUM);
- let tag_bytes = tag.label().as_bytes();
- buffer.extend_from_slice(&(tag_bytes.len() as u32).to_le_bytes());
- buffer.extend_from_slice(tag_bytes);
- buffer.push(0); // no argument
- }
- Term::EnumVariant { tag, arg, .. } => {
- // Enum with argument
- // Format: TYPE_ENUM | tag_len (u32) | tag_bytes | has_arg (u8 = 1) | arg_value
- buffer.push(TYPE_ENUM);
- let tag_bytes = tag.label().as_bytes();
- buffer.extend_from_slice(&(tag_bytes.len() as u32).to_le_bytes());
- buffer.extend_from_slice(tag_bytes);
- buffer.push(1); // has argument
- encode_term(arg, buffer)?;
- }
- other => {
- return Err(format!("Unsupported term type for native encoding: {:?}", other));
- }
- }
- Ok(())
-}
-
-/// Get the last error message.
-///
-/// # Safety
-/// - The returned pointer is valid until the next call to any nickel_* function
-/// - Do not free this pointer; it is managed internally
-#[no_mangle]
-pub unsafe extern "C" fn nickel_get_error() -> *const c_char {
- LAST_ERROR.with(|e| {
- e.borrow()
- .as_ref()
- .map(|s| s.as_ptr())
- .unwrap_or(ptr::null())
- })
-}
-
-/// Free a string allocated by this library.
-///
-/// # Safety
-/// - `ptr` must have been returned by `nickel_eval_string`
-/// - `ptr` must not be used after this call
-/// - Passing NULL is safe (no-op)
-#[no_mangle]
-pub unsafe extern "C" fn nickel_free_string(ptr: *const c_char) {
- if !ptr.is_null() {
- drop(CString::from_raw(ptr as *mut c_char));
- }
-}
-
-/// Free a binary buffer allocated by this library.
-///
-/// # Safety
-/// - `buffer` must have been returned by `nickel_eval_native`
-/// - The buffer must not be used after this call
-#[no_mangle]
-pub unsafe extern "C" fn nickel_free_buffer(buffer: NativeBuffer) {
- if !buffer.data.is_null() && buffer.len > 0 {
- let _ = Box::from_raw(std::slice::from_raw_parts_mut(buffer.data, buffer.len));
- }
-}
-
-fn set_error(msg: &str) {
- LAST_ERROR.with(|e| {
- *e.borrow_mut() = CString::new(msg).ok();
- });
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::ffi::CString;
-
- #[test]
- fn test_null_input() {
- unsafe {
- let result = nickel_eval_string(ptr::null());
- assert!(result.is_null());
- let error = nickel_get_error();
- assert!(!error.is_null());
- }
- }
-
- #[test]
- fn test_free_null() {
- unsafe {
- nickel_free_string(ptr::null());
- }
- }
-
- #[test]
- fn test_eval_simple_number() {
- unsafe {
- let code = CString::new("1 + 2").unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(!result.is_null(), "Expected result, got error: {:?}",
- CStr::from_ptr(nickel_get_error()).to_str());
- let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert_eq!(result_str, "3");
- nickel_free_string(result);
- }
- }
-
- #[test]
- fn test_eval_string() {
- unsafe {
- let code = CString::new(r#""hello""#).unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(!result.is_null(), "Expected result, got error: {:?}",
- CStr::from_ptr(nickel_get_error()).to_str());
- let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert_eq!(result_str, "\"hello\"");
- nickel_free_string(result);
- }
- }
-
- #[test]
- fn test_eval_record() {
- unsafe {
- let code = CString::new("{ x = 1, y = 2 }").unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(!result.is_null(), "Expected result, got error: {:?}",
- CStr::from_ptr(nickel_get_error()).to_str());
- let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert!(result_str.contains("\"x\""));
- assert!(result_str.contains("\"y\""));
- nickel_free_string(result);
- }
- }
-
- #[test]
- fn test_eval_array() {
- unsafe {
- let code = CString::new("[1, 2, 3]").unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(!result.is_null(), "Expected result, got error: {:?}",
- CStr::from_ptr(nickel_get_error()).to_str());
- let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert!(result_str.contains("1"));
- assert!(result_str.contains("2"));
- assert!(result_str.contains("3"));
- nickel_free_string(result);
- }
- }
-
- #[test]
- fn test_eval_function_application() {
- unsafe {
- let code = CString::new("let add = fun x y => x + y in add 3 4").unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(!result.is_null(), "Expected result, got error: {:?}",
- CStr::from_ptr(nickel_get_error()).to_str());
- let result_str = CStr::from_ptr(result).to_str().unwrap();
- assert_eq!(result_str, "7");
- nickel_free_string(result);
- }
- }
-
- #[test]
- fn test_eval_syntax_error() {
- unsafe {
- let code = CString::new("{ x = }").unwrap();
- let result = nickel_eval_string(code.as_ptr());
- assert!(result.is_null());
- let error = nickel_get_error();
- assert!(!error.is_null());
- let error_str = CStr::from_ptr(error).to_str().unwrap();
- assert!(!error_str.is_empty());
- }
- }
-
- #[test]
- fn test_native_int() {
- unsafe {
- let code = CString::new("42").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_INT);
- let value = i64::from_le_bytes(data[1..9].try_into().unwrap());
- assert_eq!(value, 42);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_float() {
- unsafe {
- let code = CString::new("3.14").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- if buffer.data.is_null() {
- let err = nickel_get_error();
- if !err.is_null() {
- panic!("Error: {:?}", CStr::from_ptr(err).to_str());
- }
- }
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_FLOAT);
- let value = f64::from_le_bytes(data[1..9].try_into().unwrap());
- assert!((value - 3.14).abs() < 0.001);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_string() {
- unsafe {
- let code = CString::new(r#""hello""#).unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_STRING);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- let s = std::str::from_utf8(&data[5..5+len]).unwrap();
- assert_eq!(s, "hello");
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_bool() {
- unsafe {
- let code = CString::new("true").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_BOOL);
- assert_eq!(data[1], 1);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_array() {
- unsafe {
- let code = CString::new("[1, 2, 3]").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_ARRAY);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(len, 3);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_record() {
- unsafe {
- let code = CString::new("{ x = 1 }").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 1);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_eval_json_internal() {
- let result = eval_nickel_json("42").unwrap();
- assert_eq!(result, "42");
-
- let result = eval_nickel_json("{ a = 1 }").unwrap();
- assert!(result.contains("\"a\""));
- assert!(result.contains("1"));
- }
-
- // Comprehensive tests for all Nickel types
-
- #[test]
- fn test_native_null() {
- unsafe {
- let code = CString::new("null").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_NULL);
- assert_eq!(buffer.len, 1);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_bool_false() {
- unsafe {
- let code = CString::new("false").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_BOOL);
- assert_eq!(data[1], 0);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_negative_int() {
- unsafe {
- let code = CString::new("-42").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_INT);
- let value = i64::from_le_bytes(data[1..9].try_into().unwrap());
- assert_eq!(value, -42);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_large_int() {
- unsafe {
- let code = CString::new("1000000000000").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_INT);
- let value = i64::from_le_bytes(data[1..9].try_into().unwrap());
- assert_eq!(value, 1000000000000i64);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_negative_float() {
- unsafe {
- let code = CString::new("-2.718").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_FLOAT);
- let value = f64::from_le_bytes(data[1..9].try_into().unwrap());
- assert!((value - (-2.718)).abs() < 0.001);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_empty_string() {
- unsafe {
- let code = CString::new(r#""""#).unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_STRING);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- assert_eq!(len, 0);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_unicode_string() {
- unsafe {
- let code = CString::new(r#""hello 世界 🌍""#).unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_STRING);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- let s = std::str::from_utf8(&data[5..5+len]).unwrap();
- assert_eq!(s, "hello 世界 🌍");
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_empty_array() {
- unsafe {
- let code = CString::new("[]").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_ARRAY);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(len, 0);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_mixed_array() {
- unsafe {
- // Array with int, string, bool
- let code = CString::new(r#"[1, "two", true]"#).unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_ARRAY);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(len, 3);
- // First element: int 1
- assert_eq!(data[5], TYPE_INT);
- // (rest of elements follow)
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_nested_array() {
- unsafe {
- let code = CString::new("[[1, 2], [3, 4]]").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_ARRAY);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(len, 2);
- // First element should be an array
- assert_eq!(data[5], TYPE_ARRAY);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_empty_record() {
- unsafe {
- let code = CString::new("{}").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 0);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_nested_record() {
- unsafe {
- let code = CString::new("{ outer = { inner = 42 } }").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 1);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_record_with_mixed_types() {
- unsafe {
- let code = CString::new(r#"{ name = "test", count = 42, active = true, data = null }"#).unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_RECORD);
- let field_count = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(field_count, 4);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_computed_value() {
- unsafe {
- let code = CString::new("let x = 10 in let y = 20 in x + y").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_INT);
- let value = i64::from_le_bytes(data[1..9].try_into().unwrap());
- assert_eq!(value, 30);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_function_result() {
- unsafe {
- let code = CString::new("let double = fun x => x * 2 in double 21").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_INT);
- let value = i64::from_le_bytes(data[1..9].try_into().unwrap());
- assert_eq!(value, 42);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_array_operations() {
- unsafe {
- // Test array map
- let code = CString::new("[1, 2, 3] |> std.array.map (fun x => x * 2)").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- assert_eq!(data[0], TYPE_ARRAY);
- let len = u32::from_le_bytes(data[1..5].try_into().unwrap());
- assert_eq!(len, 3);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_record_merge() {
- unsafe {
- let code = CString::new("{ a = 1 } & { b = 2 }").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- 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);
- }
- }
-
- #[test]
- fn test_json_all_types() {
- // Test JSON serialization for all types
- assert_eq!(eval_nickel_json("null").unwrap(), "null");
- assert_eq!(eval_nickel_json("true").unwrap(), "true");
- assert_eq!(eval_nickel_json("false").unwrap(), "false");
- assert_eq!(eval_nickel_json("42").unwrap(), "42");
- assert!(eval_nickel_json("3.14").unwrap().starts_with("3.14"));
- assert_eq!(eval_nickel_json(r#""hello""#).unwrap(), "\"hello\"");
- assert!(eval_nickel_json("[]").unwrap().contains("[]") || eval_nickel_json("[]").unwrap().contains("[\n]"));
- }
-
- #[test]
- fn test_native_simple_enum() {
- unsafe {
- let code = CString::new("let x = 'Foo in x").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- // TYPE_ENUM | tag_len | "Foo" | has_arg=0
- assert_eq!(data[0], TYPE_ENUM);
- let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- assert_eq!(tag_len, 3); // "Foo"
- assert_eq!(&data[5..8], b"Foo");
- assert_eq!(data[8], 0); // no argument
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_enum_variant() {
- unsafe {
- let code = CString::new("let x = 'Some 42 in x").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- // TYPE_ENUM | tag_len | "Some" | has_arg=1 | TYPE_INT | 42
- assert_eq!(data[0], TYPE_ENUM);
- let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- assert_eq!(tag_len, 4); // "Some"
- assert_eq!(&data[5..9], b"Some");
- assert_eq!(data[9], 1); // has argument
- assert_eq!(data[10], TYPE_INT);
- nickel_free_buffer(buffer);
- }
- }
-
- #[test]
- fn test_native_enum_with_record() {
- unsafe {
- let code = CString::new("let x = 'Ok { value = 123 } in x").unwrap();
- let buffer = nickel_eval_native(code.as_ptr());
- assert!(!buffer.data.is_null());
- let data = std::slice::from_raw_parts(buffer.data, buffer.len);
- // TYPE_ENUM | tag_len | "Ok" | has_arg=1 | TYPE_RECORD | ...
- assert_eq!(data[0], TYPE_ENUM);
- let tag_len = u32::from_le_bytes(data[1..5].try_into().unwrap()) as usize;
- assert_eq!(tag_len, 2); // "Ok"
- assert_eq!(&data[5..7], b"Ok");
- assert_eq!(data[7], 1); // has argument
- assert_eq!(data[8], TYPE_RECORD);
- 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
@@ -1,12 +1,8 @@
module NickelEval
-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, nickel_eval_file_native
-export find_nickel_executable
-export NickelEnum
+export nickel_eval, nickel_eval_file, @ncl_str, NickelError, NickelEnum
+export nickel_to_json, nickel_to_yaml, nickel_to_toml
+export check_ffi_available
"""
NickelError <: Exception
@@ -36,7 +32,7 @@ Base.showerror(io::IO, e::NickelError) = print(io, "NickelError: ", e.message)
"""
NickelEnum
-Represents a Nickel enum value. Matches the format of `std.enum.to_tag_and_arg`.
+Represents a Nickel enum value.
# Fields
- `tag::Symbol`: The enum variant name
@@ -44,12 +40,12 @@ Represents a Nickel enum value. Matches the format of `std.enum.to_tag_and_arg`.
# Examples
```julia
-result = nickel_eval_native("let x = 'Some 42 in x")
+result = nickel_eval("let x = 'Some 42 in x")
result.tag # => :Some
result.arg # => 42
result == :Some # => true
-result = nickel_eval_native("let x = 'None in x")
+result = nickel_eval("let x = 'None in x")
result.tag # => :None
result.arg # => nothing
```
@@ -72,7 +68,24 @@ function Base.show(io::IO, e::NickelEnum)
end
end
-include("subprocess.jl")
+"""
+ @ncl_str -> Any
+
+String macro for inline Nickel evaluation.
+
+# Examples
+```julia
+julia> ncl"1 + 1"
+2
+
+julia> ncl"{ x = 10 }"["x"]
+10
+```
+"""
+macro ncl_str(code)
+ :(nickel_eval($code))
+end
+
include("ffi.jl")
end # module
diff --git a/src/ffi.jl b/src/ffi.jl
@@ -1,297 +1,354 @@
-# FFI bindings for Nickel
-#
-# Native FFI bindings to a Rust wrapper around Nickel for high-performance evaluation
-# without subprocess overhead.
-#
-# Two modes:
-# - nickel_eval_ffi: Uses JSON serialization (supports typed parsing)
-# - nickel_eval_native: Uses binary protocol (preserves Nickel types directly)
-#
-# Benefits over subprocess:
-# - No process spawn overhead
-# - Direct memory sharing
-# - Better performance for repeated evaluations
-
-using Artifacts: artifact_hash
+# FFI bindings to Nickel's official C API (v2.0.0)
+# Low-level wrappers in libnickel.jl (generated by Clang.jl)
+# This file provides the convenience layer: library discovery, eval, tree-walk.
+
using LazyArtifacts
-# Determine platform-specific library name
-const LIB_NAME = if Sys.iswindows()
- "nickel_jl.dll"
-elseif Sys.isapple()
- "libnickel_jl.dylib"
+# Platform-specific library name
+const LIB_NAME = if Sys.isapple()
+ "libnickel_lang.dylib"
+elseif Sys.iswindows()
+ "nickel_lang.dll"
else
- "libnickel_jl.so"
+ "libnickel_lang.so"
end
-# Find library path: try local deps/ first (custom builds, HPC clusters with old glibc),
-# then fall back to pre-built artifact
+# Find library: local deps/ -> artifact -> not found
function _find_library_path()
- # Try local deps/ folder first (custom builds override artifacts)
+ # Local deps/ (custom builds, HPC overrides)
local_path = joinpath(@__DIR__, "..", "deps", LIB_NAME)
if isfile(local_path)
return local_path
end
- # Try artifact (Julia auto-selects platform based on arch/os in Artifacts.toml)
+ # Artifact (auto-selects platform, triggers lazy download)
try
- # @artifact_str triggers lazy download if needed
- artifact_dir = @artifact_str("libnickel_jl")
+ artifact_dir = @artifact_str("libnickel_lang")
lib_path = joinpath(artifact_dir, LIB_NAME)
if isfile(lib_path)
return lib_path
end
- catch e
- # Artifact not available (platform not supported or download failed)
+ catch
end
return nothing
end
const LIB_PATH = _find_library_path()
-
-# Check if FFI library is available
const FFI_AVAILABLE = LIB_PATH !== nothing
-# Binary protocol type tags (must match Rust definitions)
-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
-const TYPE_ENUM = 0x07
-
-# C struct for native buffer (must match Rust NativeBuffer)
-struct NativeBuffer
- data::Ptr{UInt8}
- len::Csize_t
-end
+# Set the library path for LibNickel's @ccall wrappers.
+# CRITICAL: this const must come before include("libnickel.jl") because the
+# LibNickel module's @ccall uses `libnickel_lang` as a bare identifier
+# imported from the parent module.
+# Always define the const (even with empty string) so the import doesn't fail
+# at compile time. The runtime check in _check_ffi_available() gates actual use.
+const libnickel_lang = FFI_AVAILABLE ? LIB_PATH : ""
+
+include("libnickel.jl")
+using .LibNickel
"""
check_ffi_available() -> Bool
-Check if FFI bindings are available.
-Returns true if the native library is compiled and available.
+Check if the Nickel C API library is available.
"""
-function check_ffi_available()
- return FFI_AVAILABLE
+check_ffi_available() = FFI_AVAILABLE
+
+function _check_ffi_available()
+ FFI_AVAILABLE && return
+ error("Nickel C API library not available.\n\n" *
+ "Install options:\n" *
+ " 1. Build from source: NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build(\"NickelEval\")'\n" *
+ " 2. Place $(LIB_NAME) in deps/ manually\n")
+end
+
+# ── Tree-walk: convert C API expr to Julia value ─────────────────────────────
+
+function _walk_expr(expr::Ptr{LibNickel.nickel_expr})
+ if nickel_expr_is_null(expr) != 0
+ return nothing
+ elseif nickel_expr_is_bool(expr) != 0
+ return nickel_expr_as_bool(expr) != 0
+ elseif nickel_expr_is_number(expr) != 0
+ num = nickel_expr_as_number(expr) # borrowed, no free
+ if nickel_number_is_i64(num) != 0
+ return nickel_number_as_i64(num)
+ else
+ return Float64(nickel_number_as_f64(num))
+ end
+ elseif nickel_expr_is_str(expr) != 0
+ out_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ len = nickel_expr_as_str(expr, out_ptr)
+ return unsafe_string(out_ptr[], len)
+ elseif nickel_expr_is_array(expr) != 0
+ arr = nickel_expr_as_array(expr) # borrowed, no free
+ n = Int(nickel_array_len(arr))
+ result = Vector{Any}(undef, n)
+ if n > 0
+ elem = nickel_expr_alloc()
+ try
+ for i in 0:(n-1)
+ nickel_array_get(arr, UInt(i), elem)
+ result[i+1] = _walk_expr(elem)
+ end
+ finally
+ nickel_expr_free(elem)
+ end
+ end
+ return result
+ elseif nickel_expr_is_record(expr) != 0
+ rec = nickel_expr_as_record(expr) # borrowed, no free
+ n = Int(nickel_record_len(rec))
+ result = Dict{String, Any}()
+ if n > 0
+ key_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ key_len = Ref{Csize_t}(0)
+ val_expr = nickel_expr_alloc()
+ try
+ for i in 0:(n-1)
+ nickel_record_key_value_by_index(rec, UInt(i), key_ptr, key_len, val_expr)
+ key = unsafe_string(key_ptr[], key_len[])
+ result[key] = _walk_expr(val_expr)
+ end
+ finally
+ nickel_expr_free(val_expr)
+ end
+ end
+ return result
+ elseif nickel_expr_is_enum_variant(expr) != 0
+ out_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ arg_expr = nickel_expr_alloc()
+ try
+ len = nickel_expr_as_enum_variant(expr, out_ptr, arg_expr)
+ tag = Symbol(unsafe_string(out_ptr[], len))
+ arg = _walk_expr(arg_expr)
+ return NickelEnum(tag, arg)
+ finally
+ nickel_expr_free(arg_expr)
+ end
+ elseif nickel_expr_is_enum_tag(expr) != 0
+ out_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ len = nickel_expr_as_enum_tag(expr, out_ptr)
+ tag = Symbol(unsafe_string(out_ptr[], len))
+ return NickelEnum(tag, nothing)
+ else
+ error("Unknown Nickel expression type")
+ end
+end
+
+# ── Error extraction ──────────────────────────────────────────────────────────
+
+function _throw_nickel_error(err::Ptr{LibNickel.nickel_error})
+ out_str = nickel_string_alloc()
+ try
+ nickel_error_format_as_string(err, out_str, NICKEL_ERROR_FORMAT_TEXT)
+ data_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ data_len = Ref{Csize_t}(0)
+ nickel_string_data(out_str, data_ptr, data_len)
+ msg = unsafe_string(data_ptr[], data_len[])
+ throw(NickelError(msg))
+ finally
+ nickel_string_free(out_str)
+ end
end
+# ── Public API ────────────────────────────────────────────────────────────────
+
"""
- nickel_eval_ffi(code::String) -> Any
- nickel_eval_ffi(code::String, ::Type{T}) -> T
+ nickel_eval(code::String) -> Any
-Evaluate Nickel code using native FFI bindings via JSON serialization.
-Returns the parsed result, optionally typed.
+Evaluate Nickel code and return a Julia value.
-Throws `NickelError` if FFI is not available or if evaluation fails.
+Returns native Julia types: Int64, Float64, Bool, String, nothing,
+Vector{Any}, Dict{String,Any}, or NickelEnum.
# Examples
```julia
-julia> nickel_eval_ffi("1 + 2")
+julia> nickel_eval("1 + 2")
3
-julia> result = nickel_eval_ffi("{ x = 1, y = 2 }")
-julia> result.x # dot-access supported
-1
+julia> nickel_eval("{ a = 1, b = 2 }")
+Dict{String, Any}("a" => 1, "b" => 2)
-julia> nickel_eval_ffi("{ x = 1, y = 2 }", Dict{String, Int})
-Dict{String, Int64}("x" => 1, "y" => 2)
+julia> nickel_eval("let x = 5 in x * 2")
+10
```
"""
-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)
- if !hasmethod(JSON.parse, Tuple{String, Type})
- error("Typed parsing requires JSON.jl >= 1.0. " *
- "Either upgrade JSON.jl or use nickel_eval_native() which doesn't require JSON.")
- end
- return JSON.parse(result_json, T)
-end
-
-function _eval_ffi_to_json(code::String)
+function nickel_eval(code::String)
_check_ffi_available()
-
- result_ptr = ccall((:nickel_eval_string, LIB_PATH),
- Ptr{Cchar}, (Cstring,), code)
-
- if result_ptr == C_NULL
- _throw_ffi_error()
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ try
+ result = nickel_context_eval_deep(ctx, code, expr, err)
+ if result == NICKEL_RESULT_ERR
+ _throw_nickel_error(err)
+ end
+ return _walk_expr(expr)
+ finally
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
end
-
- result_json = unsafe_string(result_ptr)
- ccall((:nickel_free_string, LIB_PATH), Cvoid, (Ptr{Cchar},), result_ptr)
-
- return result_json
end
"""
- nickel_eval_native(code::String) -> Any
-
-Evaluate Nickel code using native FFI with binary protocol.
-Returns Julia native types directly from Nickel's type system:
-
-- Nickel `Number` (integer) → `Int64`
-- Nickel `Number` (decimal) → `Float64`
-- Nickel `String` → `String`
-- Nickel `Bool` → `Bool`
-- Nickel `null` → `nothing`
-- Nickel `Array` → `Vector{Any}`
-- Nickel `Record` → `Dict{String, Any}`
+ nickel_eval(code::String, ::Type{T}) -> T
-This preserves type information that would be lost through JSON serialization.
+Evaluate Nickel code and convert to type T.
+Supports Dict, Vector, and NamedTuple conversions.
# Examples
```julia
-julia> nickel_eval_native("42")
+julia> nickel_eval("42", Int)
42
-julia> typeof(nickel_eval_native("42"))
-Int64
+julia> nickel_eval("{ a = 1, b = 2 }", Dict{String, Int})
+Dict{String, Int64}("a" => 1, "b" => 2)
-julia> typeof(nickel_eval_native("42.0"))
-Float64
+julia> nickel_eval("[1, 2, 3]", Vector{Int})
+[1, 2, 3]
-julia> nickel_eval_native("{ name = \"test\", count = 5 }")
-Dict{String, Any}("name" => "test", "count" => 5)
+julia> nickel_eval("{ x = 1.5, y = 2.5 }", @NamedTuple{x::Float64, y::Float64})
+(x = 1.5, y = 2.5)
```
"""
-function nickel_eval_native(code::String)
- _check_ffi_available()
+function nickel_eval(code::String, ::Type{T}) where T
+ result = nickel_eval(code)
+ return _convert_result(T, result)
+end
- buffer = ccall((:nickel_eval_native, LIB_PATH),
- NativeBuffer, (Cstring,), code)
+# ── Type conversion helpers ───────────────────────────────────────────────────
- if buffer.data == C_NULL
- _throw_ffi_error()
- end
+_convert_result(::Type{T}, x) where T = convert(T, x)
- # Copy data before freeing (Rust owns the memory)
- data = Vector{UInt8}(undef, buffer.len)
- unsafe_copyto!(pointer(data), buffer.data, buffer.len)
+function _convert_result(::Type{T}, d::Dict{String,Any}) where T <: NamedTuple
+ fields = fieldnames(T)
+ types = fieldtypes(T)
+ values = Tuple(_convert_result(types[i], d[String(fields[i])]) for i in eachindex(fields))
+ return T(values)
+end
- # Free the Rust buffer
- ccall((:nickel_free_buffer, LIB_PATH), Cvoid, (NativeBuffer,), buffer)
+function _convert_result(::Type{Dict{K,V}}, d::Dict{String,Any}) where {K,V}
+ return Dict{K,V}(K(k) => _convert_result(V, v) for (k, v) in d)
+end
- # Decode the binary protocol
- return _decode_native(data)
+function _convert_result(::Type{Vector{T}}, v::Vector{Any}) where T
+ return T[_convert_result(T, x) for x in v]
end
+# ── File evaluation ───────────────────────────────────────────────────────────
+
"""
- nickel_eval_file_native(path::String) -> Any
+ nickel_eval_file(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.
+Evaluate a Nickel file. Supports `import` statements resolved relative
+to the file's directory.
-Returns Julia native types directly from Nickel's type system (same as `nickel_eval_native`).
+Returns native Julia types: Int64, Float64, Bool, String, nothing,
+Vector{Any}, Dict{String,Any}, or NickelEnum.
# 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")
+julia> nickel_eval_file("config.ncl")
+Dict{String, Any}("host" => "localhost", "port" => 8080)
```
-
-# 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)
+function nickel_eval_file(path::String)
_check_ffi_available()
-
- # Convert to absolute path for proper import resolution
abs_path = abspath(path)
+ if !isfile(abs_path)
+ throw(NickelError("File not found: $abs_path"))
+ end
+ code = read(abs_path, String)
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ try
+ # Set source name to the absolute file path so Nickel can resolve imports
+ # relative to the file's directory. nickel_context_set_source_name expects
+ # a null-terminated C string; GC.@preserve keeps the string alive during
+ # the ccall inside the wrapper.
+ GC.@preserve abs_path begin
+ nickel_context_set_source_name(ctx, Base.unsafe_convert(Ptr{Cchar}, abs_path))
+ end
+ result = nickel_context_eval_deep(ctx, code, expr, err)
+ if result == NICKEL_RESULT_ERR
+ _throw_nickel_error(err)
+ end
+ return _walk_expr(expr)
+ finally
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
+ end
+end
- buffer = ccall((:nickel_eval_file_native, LIB_PATH),
- NativeBuffer, (Cstring,), abs_path)
+# ── Export (serialization) ────────────────────────────────────────────────────
- if buffer.data == C_NULL
- _throw_ffi_error()
+function _eval_and_serialize(code::String, serialize_fn)
+ _check_ffi_available()
+ ctx = nickel_context_alloc()
+ expr = nickel_expr_alloc()
+ err = nickel_error_alloc()
+ out_str = nickel_string_alloc()
+ try
+ result = nickel_context_eval_deep_for_export(ctx, code, expr, err)
+ if result == NICKEL_RESULT_ERR
+ _throw_nickel_error(err)
+ end
+ ser_result = serialize_fn(ctx, expr, out_str, err)
+ if ser_result == NICKEL_RESULT_ERR
+ _throw_nickel_error(err)
+ end
+ data_ptr = Ref{Ptr{Cchar}}(C_NULL)
+ data_len = Ref{Csize_t}(0)
+ nickel_string_data(out_str, data_ptr, data_len)
+ return unsafe_string(data_ptr[], data_len[])
+ finally
+ nickel_string_free(out_str)
+ nickel_error_free(err)
+ nickel_expr_free(expr)
+ nickel_context_free(ctx)
end
+end
- # Copy data before freeing (Rust owns the memory)
- data = Vector{UInt8}(undef, buffer.len)
- unsafe_copyto!(pointer(data), buffer.data, buffer.len)
+"""
+ nickel_to_json(code::String) -> String
- # Free the Rust buffer
- ccall((:nickel_free_buffer, LIB_PATH), Cvoid, (NativeBuffer,), buffer)
+Evaluate Nickel code and export to a JSON string.
- # Decode the binary protocol
- return _decode_native(data)
-end
+# Examples
+```julia
+julia> nickel_to_json("{ a = 1, b = \"hello\" }")
+"{\\"a\\": 1,\\"b\\": \\"hello\\"}"
+```
+"""
+nickel_to_json(code::String) = _eval_and_serialize(code, nickel_context_expr_to_json)
-# Decode binary-encoded Nickel value to Julia native types.
-function _decode_native(data::Vector{UInt8})
- io = IOBuffer(data)
- return _decode_value(io)
-end
+"""
+ nickel_to_yaml(code::String) -> String
-function _decode_value(io::IOBuffer)
- tag = read(io, UInt8)
+Evaluate Nickel code and export to a YAML string.
- if tag == TYPE_NULL
- return nothing
- elseif tag == TYPE_BOOL
- return read(io, UInt8) != 0x00
- elseif tag == TYPE_INT
- return ltoh(read(io, Int64)) # little-endian to host
- elseif tag == TYPE_FLOAT
- return ltoh(read(io, Float64))
- elseif tag == TYPE_STRING
- len = ltoh(read(io, UInt32))
- bytes = read(io, len)
- return String(bytes)
- elseif tag == TYPE_ARRAY
- len = ltoh(read(io, UInt32))
- return Any[_decode_value(io) for _ in 1:len]
- elseif tag == TYPE_RECORD
- len = ltoh(read(io, UInt32))
- dict = Dict{String, Any}()
- for _ in 1:len
- key_len = ltoh(read(io, UInt32))
- key = String(read(io, key_len))
- dict[key] = _decode_value(io)
- end
- return dict
- elseif tag == TYPE_ENUM
- # Format: tag_len (u32) | tag_bytes | has_arg (u8) | [arg_value]
- tag_len = ltoh(read(io, UInt32))
- tag_name = Symbol(String(read(io, tag_len)))
- has_arg = read(io, UInt8) != 0x00
- arg = has_arg ? _decode_value(io) : nothing
- return NickelEnum(tag_name, arg)
- else
- error("Unknown type tag in binary protocol: $tag")
- end
-end
+# Examples
+```julia
+julia> nickel_to_yaml("{ a = 1 }")
+"a: 1\\n"
+```
+"""
+nickel_to_yaml(code::String) = _eval_and_serialize(code, nickel_context_expr_to_yaml)
-function _check_ffi_available()
- if !FFI_AVAILABLE
- error("FFI library not available.\n\n" *
- "The FFI functions require a pre-built native library.\n" *
- "Use subprocess mode instead: nickel_eval() and nickel_eval_file()\n\n" *
- "To build locally (requires Rust):\n" *
- " cd rust/nickel-jl && cargo build --release\n" *
- " mkdir -p deps && cp target/release/$LIB_NAME ../../deps/")
- end
-end
+"""
+ nickel_to_toml(code::String) -> String
-function _throw_ffi_error()
- error_ptr = ccall((:nickel_get_error, LIB_PATH), Ptr{Cchar}, ())
- if error_ptr != C_NULL
- throw(NickelError(unsafe_string(error_ptr)))
- else
- throw(NickelError("Nickel evaluation failed with unknown error"))
- end
-end
+Evaluate Nickel code and export to a TOML string.
+
+# Examples
+```julia
+julia> nickel_to_toml("{ a = 1 }")
+"a = 1\\n"
+```
+"""
+nickel_to_toml(code::String) = _eval_and_serialize(code, nickel_context_expr_to_toml)
diff --git a/src/libnickel.jl b/src/libnickel.jl
@@ -0,0 +1,553 @@
+# Julia bindings for the Nickel C API (nickel-lang 2.0.0 / capi feature)
+#
+# Generated from deps/nickel_lang.h by Clang.jl (deps/generate_bindings.jl),
+# with manual docstrings and minor type corrections applied.
+#
+# All functions reference `libnickel_lang` as the library symbol.
+# The actual library path must be assigned to `libnickel_lang` before use.
+
+module LibNickel
+
+# Import library path from parent module (set in ffi.jl before include)
+import ..libnickel_lang
+
+# ── Enums ────────────────────────────────────────────────────────────────────
+
+"""
+ nickel_result
+
+Return value for fallible C API functions.
+
+- `NICKEL_RESULT_OK = 0` — success
+- `NICKEL_RESULT_ERR = 1` — failure
+"""
+@enum nickel_result::UInt32 begin
+ NICKEL_RESULT_OK = 0
+ NICKEL_RESULT_ERR = 1
+end
+
+"""
+ nickel_error_format
+
+Format selector for error diagnostics.
+
+- `NICKEL_ERROR_FORMAT_TEXT = 0` — plain text
+- `NICKEL_ERROR_FORMAT_ANSI_TEXT = 1` — text with ANSI color codes
+- `NICKEL_ERROR_FORMAT_JSON = 2` — JSON
+- `NICKEL_ERROR_FORMAT_YAML = 3` — YAML
+- `NICKEL_ERROR_FORMAT_TOML = 4` — TOML
+"""
+@enum nickel_error_format::UInt32 begin
+ NICKEL_ERROR_FORMAT_TEXT = 0
+ NICKEL_ERROR_FORMAT_ANSI_TEXT = 1
+ NICKEL_ERROR_FORMAT_JSON = 2
+ NICKEL_ERROR_FORMAT_YAML = 3
+ NICKEL_ERROR_FORMAT_TOML = 4
+end
+
+# ── Opaque types ─────────────────────────────────────────────────────────────
+# Opaque structs from C; only used via `Ptr{T}`.
+
+mutable struct nickel_array end
+mutable struct nickel_context end
+mutable struct nickel_error end
+mutable struct nickel_expr end
+mutable struct nickel_number end
+mutable struct nickel_record end
+mutable struct nickel_string end
+
+# ── Callback types ───────────────────────────────────────────────────────────
+# typedef uintptr_t (*nickel_write_callback)(void *context, const uint8_t *buf, uintptr_t len);
+# typedef void (*nickel_flush_callback)(const void *context);
+
+const nickel_write_callback = Ptr{Cvoid}
+const nickel_flush_callback = Ptr{Cvoid}
+
+# ── Context lifecycle ────────────────────────────────────────────────────────
+
+"""
+ nickel_context_alloc() -> Ptr{nickel_context}
+
+Allocate a new context for evaluating Nickel expressions.
+Must be freed with [`nickel_context_free`](@ref).
+"""
+function nickel_context_alloc()
+ @ccall libnickel_lang.nickel_context_alloc()::Ptr{nickel_context}
+end
+
+"""
+ nickel_context_free(ctx)
+
+Free a context allocated with [`nickel_context_alloc`](@ref).
+"""
+function nickel_context_free(ctx)
+ @ccall libnickel_lang.nickel_context_free(ctx::Ptr{nickel_context})::Cvoid
+end
+
+# ── Context configuration ───────────────────────────────────────────────────
+
+"""
+ nickel_context_set_trace_callback(ctx, write, flush, user_data)
+
+Provide a callback for `std.trace` output during evaluation.
+"""
+function nickel_context_set_trace_callback(ctx, write, flush, user_data)
+ @ccall libnickel_lang.nickel_context_set_trace_callback(ctx::Ptr{nickel_context}, write::nickel_write_callback, flush::nickel_flush_callback, user_data::Ptr{Cvoid})::Cvoid
+end
+
+"""
+ nickel_context_set_source_name(ctx, name)
+
+Set a name for the main input program (used in error messages).
+`name` must be a null-terminated UTF-8 C string; it is only borrowed temporarily.
+"""
+function nickel_context_set_source_name(ctx, name)
+ @ccall libnickel_lang.nickel_context_set_source_name(ctx::Ptr{nickel_context}, name::Ptr{Cchar})::Cvoid
+end
+
+# ── Evaluation ───────────────────────────────────────────────────────────────
+
+"""
+ nickel_context_eval_deep(ctx, src, out_expr, out_error) -> nickel_result
+
+Evaluate Nickel source deeply (recursively evaluating records and arrays).
+
+- `src`: null-terminated UTF-8 Nickel source code
+- `out_expr`: allocated with `nickel_expr_alloc`, or `C_NULL`
+- `out_error`: allocated with `nickel_error_alloc`, or `C_NULL`
+
+Returns `NICKEL_RESULT_OK` on success, `NICKEL_RESULT_ERR` on failure.
+"""
+function nickel_context_eval_deep(ctx, src, out_expr, out_error)
+ @ccall libnickel_lang.nickel_context_eval_deep(ctx::Ptr{nickel_context}, src::Ptr{Cchar}, out_expr::Ptr{nickel_expr}, out_error::Ptr{nickel_error})::nickel_result
+end
+
+"""
+ nickel_context_eval_deep_for_export(ctx, src, out_expr, out_error) -> nickel_result
+
+Like [`nickel_context_eval_deep`](@ref), but ignores fields marked `not_exported`.
+"""
+function nickel_context_eval_deep_for_export(ctx, src, out_expr, out_error)
+ @ccall libnickel_lang.nickel_context_eval_deep_for_export(ctx::Ptr{nickel_context}, src::Ptr{Cchar}, out_expr::Ptr{nickel_expr}, out_error::Ptr{nickel_error})::nickel_result
+end
+
+"""
+ nickel_context_eval_shallow(ctx, src, out_expr, out_error) -> nickel_result
+
+Evaluate Nickel source to weak head normal form (WHNF).
+Sub-expressions of records, arrays, and enum variants are left unevaluated.
+Use [`nickel_context_eval_expr_shallow`](@ref) to evaluate them further.
+"""
+function nickel_context_eval_shallow(ctx, src, out_expr, out_error)
+ @ccall libnickel_lang.nickel_context_eval_shallow(ctx::Ptr{nickel_context}, src::Ptr{Cchar}, out_expr::Ptr{nickel_expr}, out_error::Ptr{nickel_error})::nickel_result
+end
+
+"""
+ nickel_context_eval_expr_shallow(ctx, expr, out_expr, out_error) -> nickel_result
+
+Further evaluate an unevaluated expression to WHNF. Useful for evaluating
+sub-expressions obtained from a shallow evaluation.
+"""
+function nickel_context_eval_expr_shallow(ctx, expr, out_expr, out_error)
+ @ccall libnickel_lang.nickel_context_eval_expr_shallow(ctx::Ptr{nickel_context}, expr::Ptr{nickel_expr}, out_expr::Ptr{nickel_expr}, out_error::Ptr{nickel_error})::nickel_result
+end
+
+# ── Expression lifecycle ─────────────────────────────────────────────────────
+
+"""
+ nickel_expr_alloc() -> Ptr{nickel_expr}
+
+Allocate a new expression. Must be freed with [`nickel_expr_free`](@ref).
+Can be reused across multiple evaluations (overwritten in place).
+"""
+function nickel_expr_alloc()
+ @ccall libnickel_lang.nickel_expr_alloc()::Ptr{nickel_expr}
+end
+
+"""
+ nickel_expr_free(expr)
+
+Free an expression allocated with [`nickel_expr_alloc`](@ref).
+"""
+function nickel_expr_free(expr)
+ @ccall libnickel_lang.nickel_expr_free(expr::Ptr{nickel_expr})::Cvoid
+end
+
+# ── Expression type checks ──────────────────────────────────────────────────
+
+"""
+ nickel_expr_is_bool(expr) -> Cint
+
+Returns non-zero if the expression is a boolean.
+"""
+function nickel_expr_is_bool(expr)
+ @ccall libnickel_lang.nickel_expr_is_bool(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_number(expr) -> Cint
+
+Returns non-zero if the expression is a number.
+"""
+function nickel_expr_is_number(expr)
+ @ccall libnickel_lang.nickel_expr_is_number(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_str(expr) -> Cint
+
+Returns non-zero if the expression is a string.
+"""
+function nickel_expr_is_str(expr)
+ @ccall libnickel_lang.nickel_expr_is_str(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_enum_tag(expr) -> Cint
+
+Returns non-zero if the expression is an enum tag (no payload).
+"""
+function nickel_expr_is_enum_tag(expr)
+ @ccall libnickel_lang.nickel_expr_is_enum_tag(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_enum_variant(expr) -> Cint
+
+Returns non-zero if the expression is an enum variant (tag with payload).
+"""
+function nickel_expr_is_enum_variant(expr)
+ @ccall libnickel_lang.nickel_expr_is_enum_variant(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_record(expr) -> Cint
+
+Returns non-zero if the expression is a record.
+"""
+function nickel_expr_is_record(expr)
+ @ccall libnickel_lang.nickel_expr_is_record(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_array(expr) -> Cint
+
+Returns non-zero if the expression is an array.
+"""
+function nickel_expr_is_array(expr)
+ @ccall libnickel_lang.nickel_expr_is_array(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_value(expr) -> Cint
+
+Returns non-zero if the expression has been evaluated to a value
+(null, bool, number, string, record, array, or enum).
+Unevaluated sub-expressions from shallow eval return zero.
+"""
+function nickel_expr_is_value(expr)
+ @ccall libnickel_lang.nickel_expr_is_value(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_is_null(expr) -> Cint
+
+Returns non-zero if the expression is null.
+"""
+function nickel_expr_is_null(expr)
+ @ccall libnickel_lang.nickel_expr_is_null(expr::Ptr{nickel_expr})::Cint
+end
+
+# ── Expression accessors ─────────────────────────────────────────────────────
+
+"""
+ nickel_expr_as_bool(expr) -> Cint
+
+Extract a boolean value. **Panics** (in Rust) if expr is not a bool.
+"""
+function nickel_expr_as_bool(expr)
+ @ccall libnickel_lang.nickel_expr_as_bool(expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_expr_as_str(expr, out_str) -> Csize_t
+
+Extract a string value. Writes a pointer to the UTF-8 bytes (NOT null-terminated)
+into `out_str`. Returns the byte length.
+
+The string data borrows from `expr` and is invalidated on free/overwrite.
+**Panics** (in Rust) if expr is not a string.
+"""
+function nickel_expr_as_str(expr, out_str)
+ @ccall libnickel_lang.nickel_expr_as_str(expr::Ptr{nickel_expr}, out_str::Ptr{Ptr{Cchar}})::Csize_t
+end
+
+"""
+ nickel_expr_as_number(expr) -> Ptr{nickel_number}
+
+Extract a number reference. The returned pointer borrows from `expr`.
+**Panics** (in Rust) if expr is not a number.
+"""
+function nickel_expr_as_number(expr)
+ @ccall libnickel_lang.nickel_expr_as_number(expr::Ptr{nickel_expr})::Ptr{nickel_number}
+end
+
+"""
+ nickel_expr_as_enum_tag(expr, out_str) -> Csize_t
+
+Extract an enum tag string. Writes a pointer to the UTF-8 bytes (NOT null-terminated)
+into `out_str`. Returns the byte length.
+
+The string points to an interned string and will never be invalidated.
+**Panics** (in Rust) if expr is not an enum tag.
+"""
+function nickel_expr_as_enum_tag(expr, out_str)
+ @ccall libnickel_lang.nickel_expr_as_enum_tag(expr::Ptr{nickel_expr}, out_str::Ptr{Ptr{Cchar}})::Csize_t
+end
+
+"""
+ nickel_expr_as_enum_variant(expr, out_str, out_expr) -> Csize_t
+
+Extract an enum variant's tag string and payload.
+
+- Writes tag string pointer to `out_str` (NOT null-terminated)
+- Writes payload expression into `out_expr` (must be allocated)
+- Returns the tag string byte length
+
+**Panics** (in Rust) if expr is not an enum variant.
+"""
+function nickel_expr_as_enum_variant(expr, out_str, out_expr)
+ @ccall libnickel_lang.nickel_expr_as_enum_variant(expr::Ptr{nickel_expr}, out_str::Ptr{Ptr{Cchar}}, out_expr::Ptr{nickel_expr})::Csize_t
+end
+
+"""
+ nickel_expr_as_record(expr) -> Ptr{nickel_record}
+
+Extract a record reference. The returned pointer borrows from `expr`.
+**Panics** (in Rust) if expr is not a record.
+"""
+function nickel_expr_as_record(expr)
+ @ccall libnickel_lang.nickel_expr_as_record(expr::Ptr{nickel_expr})::Ptr{nickel_record}
+end
+
+"""
+ nickel_expr_as_array(expr) -> Ptr{nickel_array}
+
+Extract an array reference. The returned pointer borrows from `expr`.
+**Panics** (in Rust) if expr is not an array.
+"""
+function nickel_expr_as_array(expr)
+ @ccall libnickel_lang.nickel_expr_as_array(expr::Ptr{nickel_expr})::Ptr{nickel_array}
+end
+
+# ── Serialization (export) ───────────────────────────────────────────────────
+
+"""
+ nickel_context_expr_to_json(ctx, expr, out_string, out_err) -> nickel_result
+
+Serialize an evaluated expression to JSON. Fails if the expression contains
+enum variants or unevaluated sub-expressions.
+"""
+function nickel_context_expr_to_json(ctx, expr, out_string, out_err)
+ @ccall libnickel_lang.nickel_context_expr_to_json(ctx::Ptr{nickel_context}, expr::Ptr{nickel_expr}, out_string::Ptr{nickel_string}, out_err::Ptr{nickel_error})::nickel_result
+end
+
+"""
+ nickel_context_expr_to_yaml(ctx, expr, out_string, out_err) -> nickel_result
+
+Serialize an evaluated expression to YAML.
+"""
+function nickel_context_expr_to_yaml(ctx, expr, out_string, out_err)
+ @ccall libnickel_lang.nickel_context_expr_to_yaml(ctx::Ptr{nickel_context}, expr::Ptr{nickel_expr}, out_string::Ptr{nickel_string}, out_err::Ptr{nickel_error})::nickel_result
+end
+
+"""
+ nickel_context_expr_to_toml(ctx, expr, out_string, out_err) -> nickel_result
+
+Serialize an evaluated expression to TOML.
+"""
+function nickel_context_expr_to_toml(ctx, expr, out_string, out_err)
+ @ccall libnickel_lang.nickel_context_expr_to_toml(ctx::Ptr{nickel_context}, expr::Ptr{nickel_expr}, out_string::Ptr{nickel_string}, out_err::Ptr{nickel_error})::nickel_result
+end
+
+# ── Number accessors ─────────────────────────────────────────────────────────
+
+"""
+ nickel_number_is_i64(num) -> Cint
+
+Returns non-zero if the number is an integer within `Int64` range.
+"""
+function nickel_number_is_i64(num)
+ @ccall libnickel_lang.nickel_number_is_i64(num::Ptr{nickel_number})::Cint
+end
+
+"""
+ nickel_number_as_i64(num) -> Int64
+
+Extract the integer value. **Panics** (in Rust) if not an in-range integer
+(check with [`nickel_number_is_i64`](@ref) first).
+"""
+function nickel_number_as_i64(num)
+ @ccall libnickel_lang.nickel_number_as_i64(num::Ptr{nickel_number})::Int64
+end
+
+"""
+ nickel_number_as_f64(num) -> Cdouble
+
+Extract the number as a `Float64`, rounding to nearest if necessary.
+"""
+function nickel_number_as_f64(num)
+ @ccall libnickel_lang.nickel_number_as_f64(num::Ptr{nickel_number})::Cdouble
+end
+
+"""
+ nickel_number_as_rational(num, out_numerator, out_denominator)
+
+Extract the exact rational representation as decimal strings.
+Both out-params must be allocated with [`nickel_string_alloc`](@ref).
+"""
+function nickel_number_as_rational(num, out_numerator, out_denominator)
+ @ccall libnickel_lang.nickel_number_as_rational(num::Ptr{nickel_number}, out_numerator::Ptr{nickel_string}, out_denominator::Ptr{nickel_string})::Cvoid
+end
+
+# ── Array accessors ──────────────────────────────────────────────────────────
+
+"""
+ nickel_array_len(arr) -> Csize_t
+
+Return the number of elements in the array.
+A null pointer (empty array) returns 0.
+"""
+function nickel_array_len(arr)
+ @ccall libnickel_lang.nickel_array_len(arr::Ptr{nickel_array})::Csize_t
+end
+
+"""
+ nickel_array_get(arr, idx, out_expr)
+
+Retrieve the element at 0-based index `idx` into `out_expr`.
+`out_expr` must be allocated with [`nickel_expr_alloc`](@ref).
+**Panics** (in Rust) if `idx` is out of bounds.
+"""
+function nickel_array_get(arr, idx, out_expr)
+ @ccall libnickel_lang.nickel_array_get(arr::Ptr{nickel_array}, idx::Csize_t, out_expr::Ptr{nickel_expr})::Cvoid
+end
+
+# ── Record accessors ─────────────────────────────────────────────────────────
+
+"""
+ nickel_record_len(rec) -> Csize_t
+
+Return the number of fields in the record.
+A null pointer (empty record) returns 0.
+"""
+function nickel_record_len(rec)
+ @ccall libnickel_lang.nickel_record_len(rec::Ptr{nickel_record})::Csize_t
+end
+
+"""
+ nickel_record_key_value_by_index(rec, idx, out_key, out_key_len, out_expr) -> Cint
+
+Retrieve the key and value at 0-based index `idx`.
+
+- Writes key pointer to `out_key` (UTF-8, NOT null-terminated)
+- Writes key byte length to `out_key_len`
+- Writes value into `out_expr` if non-NULL (must be allocated)
+- Returns 1 if the field has a value, 0 if it doesn't (shallow eval)
+
+**Panics** (in Rust) if `idx` is out of range.
+"""
+function nickel_record_key_value_by_index(rec, idx, out_key, out_key_len, out_expr)
+ @ccall libnickel_lang.nickel_record_key_value_by_index(rec::Ptr{nickel_record}, idx::Csize_t, out_key::Ptr{Ptr{Cchar}}, out_key_len::Ptr{Csize_t}, out_expr::Ptr{nickel_expr})::Cint
+end
+
+"""
+ nickel_record_value_by_name(rec, key, out_expr) -> Cint
+
+Look up a field by name. `key` must be a null-terminated C string.
+Returns 1 if found and has a value, 0 otherwise.
+"""
+function nickel_record_value_by_name(rec, key, out_expr)
+ @ccall libnickel_lang.nickel_record_value_by_name(rec::Ptr{nickel_record}, key::Ptr{Cchar}, out_expr::Ptr{nickel_expr})::Cint
+end
+
+# ── String lifecycle and access ──────────────────────────────────────────────
+
+"""
+ nickel_string_alloc() -> Ptr{nickel_string}
+
+Allocate a new string. Must be freed with [`nickel_string_free`](@ref).
+"""
+function nickel_string_alloc()
+ @ccall libnickel_lang.nickel_string_alloc()::Ptr{nickel_string}
+end
+
+"""
+ nickel_string_free(s)
+
+Free a string allocated with [`nickel_string_alloc`](@ref).
+"""
+function nickel_string_free(s)
+ @ccall libnickel_lang.nickel_string_free(s::Ptr{nickel_string})::Cvoid
+end
+
+"""
+ nickel_string_data(s, data, len)
+
+Retrieve the contents of a string. Writes a pointer to the UTF-8 bytes
+(NOT null-terminated) into `data`, and the byte length into `len`.
+Data is invalidated when `s` is freed or overwritten.
+"""
+function nickel_string_data(s, data, len)
+ @ccall libnickel_lang.nickel_string_data(s::Ptr{nickel_string}, data::Ptr{Ptr{Cchar}}, len::Ptr{Csize_t})::Cvoid
+end
+
+# ── Error lifecycle and formatting ───────────────────────────────────────────
+
+"""
+ nickel_error_alloc() -> Ptr{nickel_error}
+
+Allocate a new error. Must be freed with [`nickel_error_free`](@ref).
+"""
+function nickel_error_alloc()
+ @ccall libnickel_lang.nickel_error_alloc()::Ptr{nickel_error}
+end
+
+"""
+ nickel_error_free(err)
+
+Free an error allocated with [`nickel_error_alloc`](@ref).
+"""
+function nickel_error_free(err)
+ @ccall libnickel_lang.nickel_error_free(err::Ptr{nickel_error})::Cvoid
+end
+
+"""
+ nickel_error_display(err, write, write_payload, format) -> nickel_result
+
+Format an error via a write callback function.
+"""
+function nickel_error_display(err, write, write_payload, format)
+ @ccall libnickel_lang.nickel_error_display(err::Ptr{nickel_error}, write::nickel_write_callback, write_payload::Ptr{Cvoid}, format::nickel_error_format)::nickel_result
+end
+
+"""
+ nickel_error_format_as_string(err, out_string, format) -> nickel_result
+
+Format an error into a [`nickel_string`](@ref).
+`out_string` must be allocated with [`nickel_string_alloc`](@ref).
+"""
+function nickel_error_format_as_string(err, out_string, format)
+ @ccall libnickel_lang.nickel_error_format_as_string(err::Ptr{nickel_error}, out_string::Ptr{nickel_string}, format::nickel_error_format)::nickel_result
+end
+
+# ── Exports ──────────────────────────────────────────────────────────────────
+
+const PREFIXES = ["nickel_", "NICKEL_"]
+for name in names(@__MODULE__; all=true), prefix in PREFIXES
+ if startswith(string(name), prefix)
+ @eval export $name
+ end
+end
+
+end # module LibNickel
diff --git a/src/subprocess.jl b/src/subprocess.jl
@@ -1,292 +0,0 @@
-# Subprocess-based Nickel evaluation using CLI
-
-"""
- find_nickel_executable() -> String
-
-Find the Nickel executable in PATH.
-"""
-function find_nickel_executable()
- nickel_cmd = Sys.iswindows() ? "nickel.exe" : "nickel"
- nickel_path = Sys.which(nickel_cmd)
- if nickel_path === nothing
- throw(NickelError("Nickel executable not found in PATH. Please install Nickel: https://nickel-lang.org/"))
- end
- return nickel_path
-end
-
-"""
- nickel_export(code::String; format::Symbol=:json) -> String
-
-Export Nickel code to the specified format string.
-
-# Arguments
-- `code::String`: Nickel code to evaluate
-- `format::Symbol`: Output format, one of `:json`, `:yaml`, `:toml` (default: `:json`)
-
-# Returns
-- `String`: The exported content in the specified format
-
-# Throws
-- `NickelError`: If evaluation fails or format is unsupported
-"""
-function nickel_export(code::String; format::Symbol=:json)
- valid_formats = (:json, :yaml, :toml, :raw)
- if format ∉ valid_formats
- throw(NickelError("Unsupported format: $format. Valid formats: $(join(valid_formats, ", "))"))
- end
-
- nickel_path = find_nickel_executable()
-
- # Create a temporary file for the Nickel code
- result = mktempdir() do tmpdir
- ncl_file = joinpath(tmpdir, "input.ncl")
- write(ncl_file, code)
-
- # Build the command
- cmd = `$nickel_path export --format=$(string(format)) $ncl_file`
-
- # Run the command and capture output
- stdout_buf = IOBuffer()
- stderr_buf = IOBuffer()
-
- try
- proc = run(pipeline(cmd, stdout=stdout_buf, stderr=stderr_buf), wait=true)
- return String(take!(stdout_buf))
- catch e
- stderr_content = String(take!(stderr_buf))
- stdout_content = String(take!(stdout_buf))
- error_msg = isempty(stderr_content) ? stdout_content : stderr_content
- if isempty(error_msg)
- error_msg = "Nickel evaluation failed with unknown error"
- end
- throw(NickelError(strip(error_msg)))
- end
- end
-
- return result
-end
-
-"""
- nickel_eval(code::String) -> JSON.Object
-
-Evaluate Nickel code and return a Julia value.
-
-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
-- Result as Julia value (JSON.Object for records, Vector for arrays, etc.)
-
-# Examples
-```julia
-julia> nickel_eval("1 + 2")
-3
-
-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
-```
-"""
-function nickel_eval(code::String)
- json_str = nickel_export(code; format=:json)
- return JSON.parse(json_str)
-end
-
-"""
- 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)
- if !hasmethod(JSON.parse, Tuple{String, Type})
- error("Typed parsing requires JSON.jl >= 1.0. " *
- "Either upgrade JSON.jl or use nickel_eval_native() which doesn't require JSON.")
- end
- 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
-- `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)
- if !hasmethod(JSON.parse, Tuple{String, Type})
- error("Typed parsing requires JSON.jl >= 1.0. " *
- "Either upgrade JSON.jl or use nickel_eval_file_native() which doesn't require JSON.")
- end
- return JSON.parse(json_str, T)
-end
-
-function _eval_file_to_json(path::String)
- if !isfile(path)
- throw(NickelError("File not found: $path"))
- end
-
- nickel_path = find_nickel_executable()
-
- cmd = `$nickel_path export --format=json $path`
-
- stdout_buf = IOBuffer()
- stderr_buf = IOBuffer()
-
- try
- run(pipeline(cmd, stdout=stdout_buf, stderr=stderr_buf), wait=true)
- return String(take!(stdout_buf))
- catch e
- stderr_content = String(take!(stderr_buf))
- stdout_content = String(take!(stdout_buf))
- error_msg = isempty(stderr_content) ? stdout_content : stderr_content
- if isempty(error_msg)
- error_msg = "Nickel evaluation failed with unknown error"
- end
- throw(NickelError(strip(error_msg)))
- end
-end
-
-"""
- @ncl_str -> Any
-
-String macro for inline Nickel evaluation.
-
-# Examples
-```julia
-julia> ncl"1 + 2"
-3
-
-julia> ncl"{ name = \"test\", value = 42 }".name
-"test"
-
-julia> ncl\"\"\"
- let
- x = 1,
- y = 2
- in x + y
- \"\"\"
-3
-```
-"""
-macro ncl_str(code)
- quote
- 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/runtests.jl b/test/runtests.jl
@@ -1,27 +1,11 @@
using NickelEval
using Test
-# Check if Nickel CLI is available
-function nickel_available()
- try
- Sys.which("nickel") !== nothing
- catch
- false
- end
-end
-
@testset "NickelEval.jl" begin
- if nickel_available()
- include("test_subprocess.jl")
- else
- @warn "Nickel executable not found in PATH, skipping subprocess tests. Install from: https://nickel-lang.org/"
- @test_skip "Nickel CLI not available"
- end
-
if check_ffi_available()
- include("test_ffi.jl")
+ include("test_eval.jl")
else
- @warn "FFI library not available, skipping FFI tests. Build with: cd rust/nickel-jl && cargo build --release"
+ @warn "FFI library not available, skipping tests. Place libnickel_lang in deps/"
@test_skip "FFI not available"
end
end
diff --git a/test/test_eval.jl b/test/test_eval.jl
@@ -0,0 +1,519 @@
+@testset "C API Evaluation" begin
+ @testset "Primitive types" begin
+ # Integers
+ @test nickel_eval("42") === Int64(42)
+ @test nickel_eval("-42") === Int64(-42)
+ @test nickel_eval("0") === Int64(0)
+ @test nickel_eval("1000000000000") === Int64(1000000000000)
+
+ # Floats (only true decimals)
+ @test nickel_eval("3.14") ≈ 3.14
+ @test nickel_eval("-2.718") ≈ -2.718
+ @test nickel_eval("0.5") ≈ 0.5
+ @test typeof(nickel_eval("3.14")) == Float64
+
+ # Booleans
+ @test nickel_eval("true") === true
+ @test nickel_eval("false") === false
+
+ # Null
+ @test nickel_eval("null") === nothing
+
+ # Strings
+ @test nickel_eval("\"hello\"") == "hello"
+ @test nickel_eval("\"\"") == ""
+ @test nickel_eval("\"hello 世界\"") == "hello 世界"
+ end
+
+ @testset "Arrays" begin
+ @test nickel_eval("[]") == Any[]
+ @test nickel_eval("[1, 2, 3]") == Any[1, 2, 3]
+ @test nickel_eval("[true, false]") == Any[true, false]
+ @test nickel_eval("[\"a\", \"b\"]") == Any["a", "b"]
+
+ # Nested arrays
+ result = nickel_eval("[[1, 2], [3, 4]]")
+ @test result == Any[Any[1, 2], Any[3, 4]]
+
+ # Mixed types
+ result = nickel_eval("[1, \"two\", true, null]")
+ @test result == Any[1, "two", true, nothing]
+ end
+
+ @testset "Records" begin
+ result = nickel_eval("{ x = 1 }")
+ @test result isa Dict{String, Any}
+ @test result["x"] === Int64(1)
+
+ result = nickel_eval("{ name = \"test\", count = 42 }")
+ @test result["name"] == "test"
+ @test result["count"] === Int64(42)
+
+ # Empty record
+ @test nickel_eval("{}") == Dict{String, Any}()
+
+ # Nested records
+ result = nickel_eval("{ outer = { inner = 42 } }")
+ @test result["outer"]["inner"] === Int64(42)
+ end
+
+ @testset "Type preservation" begin
+ @test typeof(nickel_eval("42")) == Int64
+ @test typeof(nickel_eval("42.5")) == Float64
+ @test typeof(nickel_eval("42.0")) == Int64 # whole numbers -> Int64
+ end
+
+ @testset "Computed values" begin
+ @test nickel_eval("1 + 2") === Int64(3)
+ @test nickel_eval("10 - 3") === Int64(7)
+ @test nickel_eval("let x = 10 in x * 2") === Int64(20)
+ @test nickel_eval("let add = fun x y => x + y in add 3 4") === Int64(7)
+ end
+
+ @testset "Record operations" begin
+ result = nickel_eval("{ a = 1 } & { b = 2 }")
+ @test result["a"] === Int64(1)
+ @test result["b"] === Int64(2)
+ end
+
+ @testset "Array operations" begin
+ result = nickel_eval("[1, 2, 3] |> std.array.map (fun x => x * 2)")
+ @test result == Any[2, 4, 6]
+ end
+
+ @testset "Enums - Simple (no argument)" begin
+ result = nickel_eval("let x = 'Foo in x")
+ @test result isa NickelEnum
+ @test result.tag == :Foo
+ @test result.arg === nothing
+
+ # Convenience comparison
+ @test result == :Foo
+ @test :Foo == result
+ @test result != :Bar
+
+ @test nickel_eval("let x = 'None in x").tag == :None
+ @test nickel_eval("let x = 'True in x").tag == :True
+ @test nickel_eval("let x = 'False in x").tag == :False
+ @test nickel_eval("let x = 'Pending in x").tag == :Pending
+ @test nickel_eval("let x = 'Red in x").tag == :Red
+ end
+
+ @testset "Enums - With primitive arguments" begin
+ # Integer argument
+ result = nickel_eval("let x = 'Count 42 in x")
+ @test result.tag == :Count
+ @test result.arg === Int64(42)
+
+ # Negative integer (needs parentheses in Nickel)
+ result = nickel_eval("let x = 'Offset (-100) in x")
+ @test result.arg === Int64(-100)
+
+ # Float argument
+ result = nickel_eval("let x = 'Temperature 98.6 in x")
+ @test result.tag == :Temperature
+ @test result.arg ≈ 98.6
+
+ # String argument
+ result = nickel_eval("let x = 'Message \"hello world\" in x")
+ @test result.tag == :Message
+ @test result.arg == "hello world"
+
+ # Empty string argument
+ result = nickel_eval("let x = 'Empty \"\" in x")
+ @test result.arg == ""
+
+ # Boolean arguments
+ result = nickel_eval("let x = 'Flag true in x")
+ @test result.arg === true
+ result = nickel_eval("let x = 'Flag false in x")
+ @test result.arg === false
+
+ # Null argument
+ result = nickel_eval("let x = 'Nullable null in x")
+ @test result.arg === nothing
+ end
+
+ @testset "Enums - With record arguments" begin
+ # Simple record argument
+ result = nickel_eval("let x = 'Ok { value = 123 } in x")
+ @test result.tag == :Ok
+ @test result.arg isa Dict{String, Any}
+ @test result.arg["value"] === Int64(123)
+
+ # Record with multiple fields
+ code = """
+ let result = 'Ok { value = 123, message = "success" } in result
+ """
+ result = nickel_eval(code)
+ @test result.arg["value"] === Int64(123)
+ @test result.arg["message"] == "success"
+
+ # Error with details
+ code = """
+ let err = 'Error { code = 404, reason = "not found" } in err
+ """
+ result = nickel_eval(code)
+ @test result.tag == :Error
+ @test result.arg["code"] === Int64(404)
+ @test result.arg["reason"] == "not found"
+
+ # Nested record in enum
+ code = """
+ let x = 'Data { outer = { inner = 42 } } in x
+ """
+ result = nickel_eval(code)
+ @test result.arg["outer"]["inner"] === Int64(42)
+ end
+
+ @testset "Enums - With array arguments" begin
+ # Array of integers
+ result = nickel_eval("let x = 'Batch [1, 2, 3, 4, 5] in x")
+ @test result.tag == :Batch
+ @test result.arg == Any[1, 2, 3, 4, 5]
+
+ # Empty array
+ result = nickel_eval("let x = 'Empty [] in x")
+ @test result.arg == Any[]
+
+ # Array of strings
+ result = nickel_eval("let x = 'Names [\"alice\", \"bob\"] in x")
+ @test result.arg == Any["alice", "bob"]
+
+ # Array of records
+ code = """
+ let x = 'Users [{ name = "alice" }, { name = "bob" }] in x
+ """
+ result = nickel_eval(code)
+ @test result.arg[1]["name"] == "alice"
+ @test result.arg[2]["name"] == "bob"
+ end
+
+ @testset "Enums - Nested enums" begin
+ # Enum inside record inside enum
+ code = """
+ let outer = 'Container { inner = 'Value 42 } in outer
+ """
+ result = nickel_eval(code)
+ @test result.tag == :Container
+ @test result.arg["inner"] isa NickelEnum
+ @test result.arg["inner"].tag == :Value
+ @test result.arg["inner"].arg === Int64(42)
+
+ # Array of enums inside enum
+ code = """
+ let items = 'List ['Some 1, 'None, 'Some 3] in items
+ """
+ result = nickel_eval(code)
+ @test result.tag == :List
+ @test length(result.arg) == 3
+ @test result.arg[1].tag == :Some
+ @test result.arg[1].arg === Int64(1)
+ @test result.arg[2].tag == :None
+ @test result.arg[2].arg === nothing
+ @test result.arg[3].tag == :Some
+ @test result.arg[3].arg === Int64(3)
+
+ # Deeply nested enums
+ code = """
+ let x = 'L1 { a = 'L2 { b = 'L3 42 } } in x
+ """
+ result = nickel_eval(code)
+ @test result.arg["a"].arg["b"].arg === Int64(42)
+ end
+
+ @testset "Enums - Pattern matching" begin
+ # Match resolves to extracted value
+ code = """
+ let x = 'Some 42 in
+ x |> match {
+ 'Some v => v,
+ 'None => 0
+ }
+ """
+ result = nickel_eval(code)
+ @test result === Int64(42)
+
+ # Match with record destructuring
+ code = """
+ let result = 'Ok { value = 100 } in
+ result |> match {
+ 'Ok r => r.value,
+ 'Error _ => -1
+ }
+ """
+ result = nickel_eval(code)
+ @test result === Int64(100)
+
+ # Match returning enum
+ code = """
+ let x = 'Some 42 in
+ x |> match {
+ 'Some v => 'Doubled (v * 2),
+ 'None => 'Zero 0
+ }
+ """
+ result = nickel_eval(code)
+ @test result.tag == :Doubled
+ @test result.arg === Int64(84)
+ end
+
+ @testset "Enums - Pretty printing" begin
+ # Simple enum
+ @test repr(nickel_eval("let x = 'None in x")) == "'None"
+ @test repr(nickel_eval("let x = 'Foo in x")) == "'Foo"
+
+ # Enum with simple argument
+ @test repr(nickel_eval("let x = 'Some 42 in x")) == "'Some 42"
+
+ # Enum with string argument
+ result = nickel_eval("let x = 'Msg \"hi\" in x")
+ @test startswith(repr(result), "'Msg")
+ end
+
+ @testset "Enums - Real-world patterns" begin
+ # Result type pattern
+ code = """
+ let divide = fun a b =>
+ if b == 0 then
+ 'Err "division by zero"
+ else
+ 'Ok (a / b)
+ in
+ divide 10 2
+ """
+ result = nickel_eval(code)
+ @test result == :Ok
+ @test result.arg === Int64(5)
+
+ # Option type pattern
+ code = """
+ let find = fun arr pred =>
+ let matches = std.array.filter pred arr in
+ if std.array.length matches == 0 then
+ 'None
+ else
+ 'Some (std.array.first matches)
+ in
+ find [1, 2, 3, 4] (fun x => x > 2)
+ """
+ result = nickel_eval(code)
+ @test result == :Some
+ @test result.arg === Int64(3)
+
+ # State machine pattern
+ code = """
+ let state = 'Running { progress = 75, task = "downloading" } in state
+ """
+ result = nickel_eval(code)
+ @test result.tag == :Running
+ @test result.arg["progress"] === Int64(75)
+ @test result.arg["task"] == "downloading"
+ end
+
+ @testset "Deeply nested structures" begin
+ # Deep nesting
+ result = nickel_eval("{ a = { b = { c = { d = 42 } } } }")
+ @test result["a"]["b"]["c"]["d"] === Int64(42)
+
+ # Array of records
+ result = nickel_eval("[{ x = 1 }, { x = 2 }, { x = 3 }]")
+ @test length(result) == 3
+ @test result[1]["x"] === Int64(1)
+ @test result[3]["x"] === Int64(3)
+
+ # Records containing arrays
+ result = nickel_eval("{ items = [1, 2, 3], name = \"test\" }")
+ @test result["items"] == Any[1, 2, 3]
+ @test result["name"] == "test"
+
+ # Mixed deep nesting
+ result = nickel_eval("{ data = [{ a = 1 }, { b = [true, false] }] }")
+ @test result["data"][1]["a"] === Int64(1)
+ @test result["data"][2]["b"] == Any[true, false]
+ 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 "String macro" begin
+ @test ncl"42" === Int64(42)
+ @test ncl"1 + 1" == 2
+ @test ncl"true" === true
+ @test ncl"{ x = 10 }"["x"] === Int64(10)
+ end
+
+ @testset "check_ffi_available" begin
+ @test check_ffi_available() === true
+ end
+
+ @testset "Error handling" begin
+ # Undefined variable
+ @test_throws NickelError nickel_eval("undefined_variable")
+ # Syntax error
+ @test_throws NickelError nickel_eval("{ x = }")
+ end
+end
+
+@testset "File Evaluation" begin
+ mktempdir() do dir
+ # Simple file
+ f = joinpath(dir, "test.ncl")
+ write(f, "{ x = 42 }")
+ result = nickel_eval_file(f)
+ @test result["x"] === Int64(42)
+
+ # File returning a primitive
+ f2 = joinpath(dir, "prim.ncl")
+ write(f2, "1 + 2")
+ @test nickel_eval_file(f2) === Int64(3)
+
+ # File with import
+ shared = joinpath(dir, "shared.ncl")
+ write(shared, """
+ {
+ project_name = "TestProject",
+ version = "1.0.0"
+ }
+ """)
+ main = joinpath(dir, "main.ncl")
+ write(main, """
+let shared = import "shared.ncl" in
+{
+ name = shared.project_name,
+ version = shared.version,
+ extra = "main-specific"
+}
+""")
+ result = nickel_eval_file(main)
+ @test result isa Dict{String, Any}
+ @test result["name"] == "TestProject"
+ @test result["version"] == "1.0.0"
+ @test result["extra"] == "main-specific"
+
+ # 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(complex_file)
+ @test result["project"] == "TestProject"
+ @test result["doubled_value"] === Int64(42)
+
+ # File evaluation with enums
+ enum_file = joinpath(dir, "enum_config.ncl")
+ write(enum_file, """
+ {
+ status = 'Active,
+ result = 'Ok 42
+ }
+ """)
+
+ result = nickel_eval_file(enum_file)
+ @test result["status"] isa NickelEnum
+ @test result["status"] == :Active
+ @test result["result"].tag == :Ok
+ @test result["result"].arg === Int64(42)
+
+ # 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(with_subdir_file)
+ @test result["using"] == "2.0"
+ end
+
+ # Non-existent file
+ @test_throws NickelError nickel_eval_file("/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(bad_import)
+ end
+end
+
+@testset "Export formats" begin
+ json = nickel_to_json("{ a = 1 }")
+ @test occursin("\"a\"", json)
+ @test occursin("1", json)
+
+ yaml = nickel_to_yaml("{ a = 1 }")
+ @test occursin("a:", yaml)
+
+ toml = nickel_to_toml("{ a = 1 }")
+ @test occursin("a = 1", toml)
+
+ # Export more complex structures
+ json2 = nickel_to_json("{ name = \"test\", values = [1, 2, 3] }")
+ @test occursin("\"name\"", json2)
+ @test occursin("\"test\"", json2)
+
+ # Export error: expression that can't be evaluated
+ @test_throws NickelError nickel_to_json("undefined_variable")
+end
diff --git a/test/test_ffi.jl b/test/test_ffi.jl
@@ -1,456 +0,0 @@
-@testset "FFI Native Evaluation" begin
- @testset "Primitive types" begin
- # Integers
- @test nickel_eval_native("42") === Int64(42)
- @test nickel_eval_native("-42") === Int64(-42)
- @test nickel_eval_native("0") === Int64(0)
- @test nickel_eval_native("1000000000000") === Int64(1000000000000)
-
- # Floats (only true decimals)
- @test nickel_eval_native("3.14") ≈ 3.14
- @test nickel_eval_native("-2.718") ≈ -2.718
- @test nickel_eval_native("0.5") ≈ 0.5
- @test typeof(nickel_eval_native("3.14")) == Float64
-
- # Booleans
- @test nickel_eval_native("true") === true
- @test nickel_eval_native("false") === false
-
- # Null
- @test nickel_eval_native("null") === nothing
-
- # Strings
- @test nickel_eval_native("\"hello\"") == "hello"
- @test nickel_eval_native("\"\"") == ""
- @test nickel_eval_native("\"hello 世界\"") == "hello 世界"
- end
-
- @testset "Arrays" begin
- @test nickel_eval_native("[]") == Any[]
- @test nickel_eval_native("[1, 2, 3]") == Any[1, 2, 3]
- @test nickel_eval_native("[true, false]") == Any[true, false]
- @test nickel_eval_native("[\"a\", \"b\"]") == Any["a", "b"]
-
- # Nested arrays
- result = nickel_eval_native("[[1, 2], [3, 4]]")
- @test result == Any[Any[1, 2], Any[3, 4]]
-
- # Mixed types
- result = nickel_eval_native("[1, \"two\", true, null]")
- @test result == Any[1, "two", true, nothing]
- end
-
- @testset "Records" begin
- result = nickel_eval_native("{ x = 1 }")
- @test result isa Dict{String, Any}
- @test result["x"] === Int64(1)
-
- result = nickel_eval_native("{ name = \"test\", count = 42 }")
- @test result["name"] == "test"
- @test result["count"] === Int64(42)
-
- # Empty record
- @test nickel_eval_native("{}") == Dict{String, Any}()
-
- # Nested records
- result = nickel_eval_native("{ outer = { inner = 42 } }")
- @test result["outer"]["inner"] === Int64(42)
- end
-
- @testset "Type preservation" begin
- # The key feature: integers stay Int64, floats stay Float64
- @test typeof(nickel_eval_native("42")) == Int64
- @test typeof(nickel_eval_native("42.5")) == Float64
- @test typeof(nickel_eval_native("42.0")) == Int64 # whole numbers → Int64
- end
-
- @testset "Computed values" begin
- @test nickel_eval_native("1 + 2") === Int64(3)
- @test nickel_eval_native("10 - 3") === Int64(7)
- @test nickel_eval_native("let x = 10 in x * 2") === Int64(20)
- @test nickel_eval_native("let add = fun x y => x + y in add 3 4") === Int64(7)
- end
-
- @testset "Record operations" begin
- # Merge
- result = nickel_eval_native("{ a = 1 } & { b = 2 }")
- @test result["a"] === Int64(1)
- @test result["b"] === Int64(2)
- end
-
- @testset "Array operations" begin
- result = nickel_eval_native("[1, 2, 3] |> std.array.map (fun x => x * 2)")
- @test result == Any[2, 4, 6]
- end
-
- @testset "Enums - Simple (no argument)" begin
- # Basic simple enum
- result = nickel_eval_native("let x = 'Foo in x")
- @test result isa NickelEnum
- @test result.tag == :Foo
- @test result.arg === nothing
-
- # Convenience comparison
- @test result == :Foo
- @test :Foo == result
- @test result != :Bar
-
- # Various tag names
- @test nickel_eval_native("let x = 'None in x").tag == :None
- @test nickel_eval_native("let x = 'True in x").tag == :True
- @test nickel_eval_native("let x = 'False in x").tag == :False
- @test nickel_eval_native("let x = 'Pending in x").tag == :Pending
- @test nickel_eval_native("let x = 'Red in x").tag == :Red
- end
-
- @testset "Enums - With primitive arguments" begin
- # Integer argument
- result = nickel_eval_native("let x = 'Count 42 in x")
- @test result.tag == :Count
- @test result.arg === Int64(42)
-
- # Negative integer (needs parentheses in Nickel)
- result = nickel_eval_native("let x = 'Offset (-100) in x")
- @test result.arg === Int64(-100)
-
- # Float argument
- result = nickel_eval_native("let x = 'Temperature 98.6 in x")
- @test result.tag == :Temperature
- @test result.arg ≈ 98.6
-
- # String argument
- result = nickel_eval_native("let x = 'Message \"hello world\" in x")
- @test result.tag == :Message
- @test result.arg == "hello world"
-
- # Empty string argument
- result = nickel_eval_native("let x = 'Empty \"\" in x")
- @test result.arg == ""
-
- # Boolean arguments
- result = nickel_eval_native("let x = 'Flag true in x")
- @test result.arg === true
- result = nickel_eval_native("let x = 'Flag false in x")
- @test result.arg === false
-
- # Null argument
- result = nickel_eval_native("let x = 'Nullable null in x")
- @test result.arg === nothing
- end
-
- @testset "Enums - With record arguments" begin
- # Simple record argument
- result = nickel_eval_native("let x = 'Ok { value = 123 } in x")
- @test result.tag == :Ok
- @test result.arg isa Dict{String, Any}
- @test result.arg["value"] === Int64(123)
-
- # Record with multiple fields
- code = """
- let result = 'Ok { value = 123, message = "success" } in result
- """
- result = nickel_eval_native(code)
- @test result.arg["value"] === Int64(123)
- @test result.arg["message"] == "success"
-
- # Error with details
- code = """
- let err = 'Error { code = 404, reason = "not found" } in err
- """
- result = nickel_eval_native(code)
- @test result.tag == :Error
- @test result.arg["code"] === Int64(404)
- @test result.arg["reason"] == "not found"
-
- # Nested record in enum
- code = """
- let x = 'Data { outer = { inner = 42 } } in x
- """
- result = nickel_eval_native(code)
- @test result.arg["outer"]["inner"] === Int64(42)
- end
-
- @testset "Enums - With array arguments" begin
- # Array of integers
- result = nickel_eval_native("let x = 'Batch [1, 2, 3, 4, 5] in x")
- @test result.tag == :Batch
- @test result.arg == Any[1, 2, 3, 4, 5]
-
- # Empty array
- result = nickel_eval_native("let x = 'Empty [] in x")
- @test result.arg == Any[]
-
- # Array of strings
- result = nickel_eval_native("let x = 'Names [\"alice\", \"bob\"] in x")
- @test result.arg == Any["alice", "bob"]
-
- # Array of records
- code = """
- let x = 'Users [{ name = "alice" }, { name = "bob" }] in x
- """
- result = nickel_eval_native(code)
- @test result.arg[1]["name"] == "alice"
- @test result.arg[2]["name"] == "bob"
- end
-
- @testset "Enums - Nested enums" begin
- # Enum inside record inside enum
- code = """
- let outer = 'Container { inner = 'Value 42 } in outer
- """
- result = nickel_eval_native(code)
- @test result.tag == :Container
- @test result.arg["inner"] isa NickelEnum
- @test result.arg["inner"].tag == :Value
- @test result.arg["inner"].arg === Int64(42)
-
- # Array of enums inside enum
- code = """
- let items = 'List ['Some 1, 'None, 'Some 3] in items
- """
- result = nickel_eval_native(code)
- @test result.tag == :List
- @test length(result.arg) == 3
- @test result.arg[1].tag == :Some
- @test result.arg[1].arg === Int64(1)
- @test result.arg[2].tag == :None
- @test result.arg[2].arg === nothing
- @test result.arg[3].tag == :Some
- @test result.arg[3].arg === Int64(3)
-
- # Deeply nested enums
- code = """
- let x = 'L1 { a = 'L2 { b = 'L3 42 } } in x
- """
- result = nickel_eval_native(code)
- @test result.arg["a"].arg["b"].arg === Int64(42)
- end
-
- @testset "Enums - Pattern matching" begin
- # Match resolves to extracted value
- code = """
- let x = 'Some 42 in
- x |> match {
- 'Some v => v,
- 'None => 0
- }
- """
- result = nickel_eval_native(code)
- @test result === Int64(42)
-
- # Match with record destructuring
- code = """
- let result = 'Ok { value = 100 } in
- result |> match {
- 'Ok r => r.value,
- 'Error _ => -1
- }
- """
- result = nickel_eval_native(code)
- @test result === Int64(100)
-
- # Match returning enum
- code = """
- let x = 'Some 42 in
- x |> match {
- 'Some v => 'Doubled (v * 2),
- 'None => 'Zero 0
- }
- """
- result = nickel_eval_native(code)
- @test result.tag == :Doubled
- @test result.arg === Int64(84)
- end
-
- @testset "Enums - Pretty printing" begin
- # Simple enum
- @test repr(nickel_eval_native("let x = 'None in x")) == "'None"
- @test repr(nickel_eval_native("let x = 'Foo in x")) == "'Foo"
-
- # Enum with simple argument
- @test repr(nickel_eval_native("let x = 'Some 42 in x")) == "'Some 42"
-
- # Enum with string argument
- result = nickel_eval_native("let x = 'Msg \"hi\" in x")
- @test startswith(repr(result), "'Msg")
- end
-
- @testset "Enums - Real-world patterns" begin
- # Result type pattern
- code = """
- let divide = fun a b =>
- if b == 0 then
- 'Err "division by zero"
- else
- 'Ok (a / b)
- in
- divide 10 2
- """
- result = nickel_eval_native(code)
- @test result == :Ok
- @test result.arg === Int64(5)
-
- # Option type pattern
- code = """
- let find = fun arr pred =>
- let matches = std.array.filter pred arr in
- if std.array.length matches == 0 then
- 'None
- else
- 'Some (std.array.first matches)
- in
- find [1, 2, 3, 4] (fun x => x > 2)
- """
- result = nickel_eval_native(code)
- @test result == :Some
- @test result.arg === Int64(3)
-
- # State machine pattern
- code = """
- let state = 'Running { progress = 75, task = "downloading" } in state
- """
- result = nickel_eval_native(code)
- @test result.tag == :Running
- @test result.arg["progress"] === Int64(75)
- @test result.arg["task"] == "downloading"
- end
-
- @testset "Deeply nested structures" begin
- # Deep nesting
- result = nickel_eval_native("{ a = { b = { c = { d = 42 } } } }")
- @test result["a"]["b"]["c"]["d"] === Int64(42)
-
- # Array of records
- result = nickel_eval_native("[{ x = 1 }, { x = 2 }, { x = 3 }]")
- @test length(result) == 3
- @test result[1]["x"] === Int64(1)
- @test result[3]["x"] === Int64(3)
-
- # Records containing arrays
- result = nickel_eval_native("{ items = [1, 2, 3], name = \"test\" }")
- @test result["items"] == Any[1, 2, 3]
- @test result["name"] == "test"
-
- # Mixed deep nesting
- result = nickel_eval_native("{ data = [{ a = 1 }, { b = [true, false] }] }")
- @test result["data"][1]["a"] === Int64(1)
- @test result["data"][2]["b"] == Any[true, false]
- end
-end
-
-@testset "FFI JSON Evaluation" begin
- # Test the JSON path still works
- @test nickel_eval_ffi("42") == 42
- @test nickel_eval_ffi("\"hello\"") == "hello"
- @test nickel_eval_ffi("{ x = 1 }").x == 1
-
- # Typed evaluation
- result = nickel_eval_ffi("{ x = 1, y = 2 }", Dict{String, Int})
- @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
diff --git a/test/test_subprocess.jl b/test/test_subprocess.jl
@@ -1,128 +0,0 @@
-@testset "Subprocess Evaluation" begin
- @testset "Basic expressions" begin
- @test nickel_eval("1 + 2") == 3
- @test nickel_eval("10 - 3") == 7
- @test nickel_eval("4 * 5") == 20
- @test nickel_eval("true") == true
- @test nickel_eval("false") == false
- @test nickel_eval("\"hello\"") == "hello"
- end
-
- @testset "Let expressions" begin
- @test nickel_eval("let x = 1 in x + 2") == 3
- @test nickel_eval("let x = 5 in x * 2") == 10
- @test nickel_eval("let x = 1 in let y = 2 in x + y") == 3
- end
-
- @testset "Records (Objects)" begin
- result = nickel_eval("{ a = 1, 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
- end
-
- @testset "Arrays" begin
- @test nickel_eval("[1, 2, 3]") == [1, 2, 3]
- @test nickel_eval("[\"a\", \"b\"]") == ["a", "b"]
- @test nickel_eval("[]") == []
- end
-
- @testset "Nested structures" begin
- result = nickel_eval("{ outer = { inner = 42 } }")
- @test result.outer.inner == 42
-
- result = nickel_eval("{ 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
- 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
- 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
- @test_throws NickelError nickel_eval("undefined_variable")
- @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