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.