NickelEval.jl

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

2026-03-21-lazy-evaluation-design.md (8368B)


      1 # Lazy Evaluation API for NickelEval.jl
      2 
      3 ## Problem
      4 
      5 `nickel_eval` and `nickel_eval_file` evaluate the entire Nickel expression tree eagerly. For large configuration files, this wastes time evaluating fields the caller never reads. The Nickel C API already supports shallow evaluation (`nickel_context_eval_shallow`) and on-demand sub-expression evaluation (`nickel_context_eval_expr_shallow`). NickelEval.jl wraps both functions in `libnickel.jl` but exposes neither to users.
      6 
      7 ## Design Deviations
      8 
      9 - `NickelSession` has no `root` field (avoids circular type reference). `nickel_open` returns `NickelValue` directly. `close(::NickelValue)` delegates to `close(session)`.
     10 - Types use `Ptr{Cvoid}` instead of `Ptr{LibNickel.nickel_expr}` to avoid forward reference to `LibNickel` module (which is loaded after type definitions).
     11 - `nickel_open` always returns `NickelValue`, even for top-level primitives/enums. Use `collect` to materialize. Field access via `getproperty`/`getindex` still resolves primitives immediately.
     12 - File detection uses `endswith(".ncl") && isfile(abspath(...))` heuristic rather than a keyword argument.
     13 
     14 ## Solution
     15 
     16 Add a `nickel_open` function that evaluates shallowly and returns a lazy `NickelValue` wrapper. Users navigate the result with `.field` and `["field"]` syntax. Each access evaluates only the requested sub-expression. A `collect` call materializes an entire subtree into plain Julia types.
     17 
     18 ## Types
     19 
     20 ### `NickelSession`
     21 
     22 Owns the `nickel_context` and tracks all allocated expressions for cleanup.
     23 
     24 ```julia
     25 mutable struct NickelSession
     26     ctx::Ptr{LibNickel.nickel_context}
     27     root::NickelValue                          # top-level lazy value
     28     exprs::Vector{Ptr{LibNickel.nickel_expr}}  # all allocations, freed on close
     29     closed::Bool
     30 end
     31 ```
     32 
     33 - `close(session)` frees every tracked expression, then the context.
     34 - A `closed` flag prevents use-after-free.
     35 - A GC finalizer calls `close` as a safety net, but users should not rely on GC timing.
     36 - `root` holds the top-level `NickelValue` for manual (non-do-block) use.
     37 
     38 ### `NickelValue`
     39 
     40 Wraps a single Nickel expression with a back-reference to its session.
     41 
     42 ```julia
     43 struct NickelValue
     44     session::NickelSession
     45     expr::Ptr{LibNickel.nickel_expr}
     46 end
     47 ```
     48 
     49 - Does not own `expr` — the session tracks and frees it.
     50 - The back-reference keeps the session reachable by the GC as long as any `NickelValue` exists.
     51 
     52 **Important:** Because `getproperty` is overridden on `NickelValue`, all internal access to struct fields must use `getfield(v, :session)` and `getfield(v, :expr)`.
     53 
     54 ## Public API
     55 
     56 ### `nickel_open`
     57 
     58 ```julia
     59 # Do-block (preferred) — receives the root NickelValue
     60 nickel_open("config.ncl") do cfg::NickelValue
     61     cfg.database.port  # => 5432
     62 end
     63 
     64 # Code string
     65 nickel_open(code="{ a = 1 }") do cfg
     66     cfg.a  # => 1
     67 end
     68 
     69 # Manual (REPL exploration) — returns the NickelSession
     70 session = nickel_open("config.ncl")
     71 port = session.root.database.port
     72 close(session)
     73 ```
     74 
     75 Internally:
     76 1. Allocates a `nickel_context`.
     77 2. For file paths: reads the file, sets the source name via `nickel_context_set_source_name`, then calls `nickel_context_eval_shallow` on the code string.
     78 3. For code strings: calls `nickel_context_eval_shallow` directly.
     79 4. Wraps the root expression in a `NickelValue`.
     80 5. Do-block variant: passes the root `NickelValue` to the block, calls `close(session)` in a `finally` clause. Returns the block's result.
     81 6. Manual variant: returns the `NickelSession` (which holds `.root`).
     82 
     83 ### Navigation
     84 
     85 ```julia
     86 Base.getproperty(v::NickelValue, name::Symbol)  # v.field
     87 Base.getindex(v::NickelValue, key::String)       # v["field"]
     88 Base.getindex(v::NickelValue, idx::Integer)      # v[1]
     89 ```
     90 
     91 Each access:
     92 1. Checks the session is open.
     93 2. Extracts the sub-expression: uses `nickel_record_value_by_name` for field access (both `getproperty` and string `getindex`), or `nickel_array_get` for integer indexing.
     94 3. Allocates a new `nickel_expr` via `nickel_expr_alloc`, registers it in the session's `exprs` vector.
     95 4. Calls `nickel_context_eval_expr_shallow` to evaluate the sub-expression to WHNF.
     96 5. If the result is a primitive (number, string, bool, null, or bare enum tag), returns the Julia value directly.
     97 6. If the result is a record, array, or enum variant, returns a new `NickelValue`.
     98 
     99 ### Materialization
    100 
    101 ```julia
    102 Base.collect(v::NickelValue) -> Any
    103 ```
    104 
    105 Recursively evaluates the entire subtree rooted at `v` and converts it to plain Julia types (`Dict`, `Vector`, `Int64`, etc.) — the same types that `nickel_eval` returns today. Uses a modified `_walk_expr` that calls `nickel_context_eval_expr_shallow` on each sub-expression before inspecting its type. The C API has no `eval_expr_deep`, so `collect` must walk and shallow-eval recursively.
    106 
    107 ### Inspection
    108 
    109 ```julia
    110 Base.keys(v::NickelValue)       # field names of a record, without evaluating values
    111 Base.length(v::NickelValue)     # field count (record) or element count (array)
    112 nickel_kind(v::NickelValue)     # :record, :array, :number, :string, :bool, :null, :enum
    113 ```
    114 
    115 `keys` returns a `Vector{String}`. It iterates `nickel_record_key_value_by_index` with a `C_NULL` out-expression (the C API explicitly supports NULL here to skip value extraction).
    116 
    117 ### Iteration
    118 
    119 ```julia
    120 # Records: iterate key-value pairs (values are lazy NickelValues or primitives)
    121 for (key, val) in cfg
    122     println(key, " => ", val)
    123 end
    124 
    125 # Arrays: iterate elements
    126 for item in cfg.items
    127     println(item)
    128 end
    129 ```
    130 
    131 Implements Julia's `iterate` protocol. Record iteration yields `Pair{String, Any}` (where values follow the same lazy-or-primitive rule as navigation). Array iteration yields elements.
    132 
    133 ### `show`
    134 
    135 ```julia
    136 Base.show(io::IO, v::NickelValue)
    137 # NickelValue(:record, 3 fields)
    138 # NickelValue(:array, 10 elements)
    139 # NickelValue(:number)
    140 ```
    141 
    142 Displays the kind and size without evaluating children.
    143 
    144 ## Exports
    145 
    146 ```julia
    147 export nickel_open, NickelValue, NickelSession, nickel_kind
    148 ```
    149 
    150 New exports must be added to `docs/src/lib/public.md` for the documentation build.
    151 
    152 ## File Organization
    153 
    154 All new code goes in `src/ffi.jl`, below the existing public API section. No new files (except test file).
    155 
    156 ## Lifetime Rules
    157 
    158 1. **Do-block**: session opens before the block, closes in `finally`. All `NickelValue` references become invalid after the block. Accessing a closed session throws an error.
    159 2. **Manual**: caller must call `close(session)`. The `NickelSession` finalizer also calls `close` as a safety net, but users should not rely on GC timing.
    160 3. **Nesting**: `NickelValue` objects returned from navigation hold a reference to the session. They do not extend the session's lifetime beyond the do-block — the do-block closes the session regardless.
    161 
    162 ## Thread Safety
    163 
    164 `NickelSession` and `NickelValue` are not thread-safe. The underlying `nickel_context` holds mutable Rust state. All access to a session must occur on a single thread.
    165 
    166 ## Error Handling
    167 
    168 - Accessing a field that does not exist: throws `NickelError` with a message from the C API.
    169 - Accessing a closed session: throws `ArgumentError("NickelSession is closed")`.
    170 - Evaluating a sub-expression that fails (e.g., contract violation): throws `NickelError`.
    171 - Using `.field` on an array or `[index]` on a record of the wrong kind: throws `ArgumentError`.
    172 
    173 ## Testing
    174 
    175 Tests go in `test/test_lazy.jl`, included from `test/runtests.jl` alongside `test_eval.jl`.
    176 
    177 Test cases:
    178 1. **Shallow record access**: open a record, access one field, verify correct value returned.
    179 2. **Nested navigation**: `cfg.a.b.c` returns the correct primitive.
    180 3. **Array access**: `cfg.items[1]` works.
    181 4. **`collect`**: materializes the full subtree, matches `nickel_eval` output.
    182 5. **`keys` and `length`**: return correct values without evaluating children.
    183 6. **File evaluation**: `nickel_open("file.ncl")` works with imports.
    184 7. **Do-block cleanup**: after the block, accessing a value throws.
    185 8. **Error on missing field**: throws `NickelError`.
    186 9. **Enum handling**: enum tags return immediately, enum variants with record payloads return lazy `NickelValue`.
    187 10. **`nickel_kind`**: returns correct symbol for each Nickel type.
    188 11. **Iteration**: `for (k, v) in record` and `for item in array` work correctly.
    189 12. **Manual session**: `nickel_open` without do-block returns a session, `session.root` navigates, `close` cleans up.