NickelEval.jl

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

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:
M.github/workflows/CI.yml | 14+++++++++-----
M.github/workflows/build-ffi.yml | 36++++++++++--------------------------
MArtifacts.toml | 49++++++++++++++-----------------------------------
MCLAUDE.md | 156+++++++++++++++++++++++++++++--------------------------------------------------
MProject.toml | 4+---
MREADME.md | 163+++++++++++++++++++++++--------------------------------------------------------
Adeps/Project.toml | 2++
Mdeps/build.jl | 79+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Adeps/generate_bindings.jl | 19+++++++++++++++++++
Adeps/generator.toml | 9+++++++++
Adeps/nickel_lang.h | 629+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/make.jl | 3---
Mdocs/src/index.md | 11+++++------
Mdocs/src/lib/public.md | 6------
Ddocs/src/man/export.md | 135-------------------------------------------------------------------------------
Ddocs/src/man/ffi.md | 410-------------------------------------------------------------------------------
Mdocs/src/man/quickstart.md | 17++++++++---------
Ddocs/src/man/typed.md | 107-------------------------------------------------------------------------------
Adocs/superpowers/plans/2026-03-18-c-api-migration.md | 1062+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/superpowers/specs/2026-03-17-c-api-migration-design.md | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Drust/nickel-jl/Cargo.toml | 17-----------------
Drust/nickel-jl/src/lib.rs | 929-------------------------------------------------------------------------------
Msrc/NickelEval.jl | 35++++++++++++++++++++++++-----------
Msrc/ffi.jl | 481++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Asrc/libnickel.jl | 553+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/subprocess.jl | 292-------------------------------------------------------------------------------
Mtest/runtests.jl | 20++------------------
Atest/test_eval.jl | 519+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtest/test_ffi.jl | 456-------------------------------------------------------------------------------
Dtest/test_subprocess.jl | 128-------------------------------------------------------------------------------
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