NickelEval.jl

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

ffi.jl (27922B)


      1 # FFI bindings to Nickel's official C API (v2.0.0)
      2 # Low-level wrappers in libnickel.jl (generated by Clang.jl)
      3 # This file provides the convenience layer: library discovery, eval, tree-walk.
      4 
      5 using LazyArtifacts
      6 using Libdl
      7 
      8 # Platform-specific library name
      9 const LIB_NAME = if Sys.isapple()
     10     "libnickel_lang.dylib"
     11 elseif Sys.iswindows()
     12     "nickel_lang.dll"
     13 else
     14     "libnickel_lang.so"
     15 end
     16 
     17 # Find library: local deps/ -> artifact -> not found
     18 function _find_library_path()
     19     # Local deps/ (custom builds, HPC overrides)
     20     local_path = joinpath(@__DIR__, "..", "deps", LIB_NAME)
     21     if isfile(local_path)
     22         return local_path
     23     end
     24 
     25     # Artifact (auto-selects platform, triggers lazy download)
     26     try
     27         artifact_dir = @artifact_str("libnickel_lang")
     28         lib_path = joinpath(artifact_dir, LIB_NAME)
     29         if isfile(lib_path)
     30             return lib_path
     31         end
     32     catch
     33     end
     34 
     35     return nothing
     36 end
     37 
     38 # Library path and availability resolved at runtime via __init__(),
     39 # not at precompile time. This allows Pkg.build() to install the library
     40 # after initial precompilation (JLL pattern, requires Julia 1.6+).
     41 libnickel_lang::String = ""
     42 FFI_AVAILABLE::Bool = false
     43 
     44 function __init_ffi__()
     45     path = _find_library_path()
     46     if path !== nothing
     47         try
     48             Libdl.dlopen(path)
     49             global libnickel_lang = path
     50             global FFI_AVAILABLE = true
     51         catch e
     52             @warn "Found libnickel_lang at $path but failed to load it: $e\n" *
     53                   "Build from source with: build_ffi()"
     54         end
     55     end
     56 end
     57 
     58 include("libnickel.jl")
     59 import .LibNickel
     60 const L = LibNickel
     61 
     62 """
     63     check_ffi_available() -> Bool
     64 
     65 Check if the Nickel C API library is available.
     66 """
     67 check_ffi_available() = FFI_AVAILABLE
     68 
     69 """
     70     build_ffi()
     71 
     72 Build the Nickel C API library from source. Requires Rust (cargo).
     73 Restarts the FFI after building so you don't need to restart Julia.
     74 """
     75 function build_ffi()
     76     build_script = joinpath(@__DIR__, "..", "deps", "build.jl")
     77     @info "Building Nickel C API library from source..."
     78     withenv("NICKELEVAL_BUILD_FFI" => "true") do
     79         include(build_script)
     80     end
     81     # Re-initialize to pick up the newly built library
     82     __init_ffi__()
     83     if FFI_AVAILABLE
     84         @info "FFI ready. nickel_eval() is now available."
     85     else
     86         error("Build completed but library still not loadable. Check the build output above.")
     87     end
     88 end
     89 
     90 function _check_ffi_available()
     91     FFI_AVAILABLE && return
     92     error("Nickel C API library not available.\n\n" *
     93           "Build from Julia:  using NickelEval; build_ffi()\n" *
     94           "Build from shell:  NICKELEVAL_BUILD_FFI=true julia -e 'using Pkg; Pkg.build(\"NickelEval\")'\n")
     95 end
     96 
     97 # ── Tree-walk: convert C API expr to Julia value ─────────────────────────────
     98 
     99 function _walk_expr(expr::Ptr{L.nickel_expr})
    100     if L.nickel_expr_is_null(expr) != 0
    101         return nothing
    102     elseif L.nickel_expr_is_bool(expr) != 0
    103         return L.nickel_expr_as_bool(expr) != 0
    104     elseif L.nickel_expr_is_number(expr) != 0
    105         num = L.nickel_expr_as_number(expr)  # borrowed, no free
    106         if L.nickel_number_is_i64(num) != 0
    107             return L.nickel_number_as_i64(num)
    108         else
    109             return Float64(L.nickel_number_as_f64(num))
    110         end
    111     elseif L.nickel_expr_is_str(expr) != 0
    112         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    113         len = L.nickel_expr_as_str(expr, out_ptr)
    114         return unsafe_string(out_ptr[], len)
    115     elseif L.nickel_expr_is_array(expr) != 0
    116         arr = L.nickel_expr_as_array(expr)  # borrowed, no free
    117         n = Int(L.nickel_array_len(arr))
    118         result = Vector{Any}(undef, n)
    119         if n > 0
    120             elem = L.nickel_expr_alloc()
    121             try
    122                 for i in 0:(n-1)
    123                     L.nickel_array_get(arr, UInt(i), elem)
    124                     result[i+1] = _walk_expr(elem)
    125                 end
    126             finally
    127                 L.nickel_expr_free(elem)
    128             end
    129         end
    130         return result
    131     elseif L.nickel_expr_is_record(expr) != 0
    132         rec = L.nickel_expr_as_record(expr)  # borrowed, no free
    133         n = Int(L.nickel_record_len(rec))
    134         result = Dict{String, Any}()
    135         if n > 0
    136             key_ptr = Ref{Ptr{Cchar}}(C_NULL)
    137             key_len = Ref{Csize_t}(0)
    138             val_expr = L.nickel_expr_alloc()
    139             try
    140                 for i in 0:(n-1)
    141                     L.nickel_record_key_value_by_index(rec, UInt(i), key_ptr, key_len, val_expr)
    142                     key = unsafe_string(key_ptr[], key_len[])
    143                     result[key] = _walk_expr(val_expr)
    144                 end
    145             finally
    146                 L.nickel_expr_free(val_expr)
    147             end
    148         end
    149         return result
    150     elseif L.nickel_expr_is_enum_variant(expr) != 0
    151         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    152         arg_expr = L.nickel_expr_alloc()
    153         try
    154             len = L.nickel_expr_as_enum_variant(expr, out_ptr, arg_expr)
    155             tag = Symbol(unsafe_string(out_ptr[], len))
    156             arg = _walk_expr(arg_expr)
    157             return NickelEnum(tag, arg)
    158         finally
    159             L.nickel_expr_free(arg_expr)
    160         end
    161     elseif L.nickel_expr_is_enum_tag(expr) != 0
    162         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    163         len = L.nickel_expr_as_enum_tag(expr, out_ptr)
    164         tag = Symbol(unsafe_string(out_ptr[], len))
    165         return NickelEnum(tag, nothing)
    166     else
    167         error("Unknown Nickel expression type")
    168     end
    169 end
    170 
    171 # ── Error extraction ──────────────────────────────────────────────────────────
    172 
    173 function _throw_nickel_error(err::Ptr{L.nickel_error})
    174     out_str = L.nickel_string_alloc()
    175     try
    176         L.nickel_error_format_as_string(err, out_str, L.NICKEL_ERROR_FORMAT_TEXT)
    177         data_ptr = Ref{Ptr{Cchar}}(C_NULL)
    178         data_len = Ref{Csize_t}(0)
    179         L.nickel_string_data(out_str, data_ptr, data_len)
    180         msg = unsafe_string(data_ptr[], data_len[])
    181         throw(NickelError(msg))
    182     finally
    183         L.nickel_string_free(out_str)
    184     end
    185 end
    186 
    187 # ── Public API ────────────────────────────────────────────────────────────────
    188 
    189 """
    190     nickel_eval(code::String) -> Any
    191 
    192 Evaluate Nickel code and return a Julia value.
    193 
    194 Returns native Julia types: Int64, Float64, Bool, String, nothing,
    195 Vector{Any}, Dict{String,Any}, or NickelEnum.
    196 
    197 # Examples
    198 ```julia
    199 julia> nickel_eval("1 + 2")
    200 3
    201 
    202 julia> nickel_eval("{ a = 1, b = 2 }")
    203 Dict{String, Any}("a" => 1, "b" => 2)
    204 
    205 julia> nickel_eval("let x = 5 in x * 2")
    206 10
    207 ```
    208 """
    209 function nickel_eval(code::String)
    210     _check_ffi_available()
    211     ctx = L.nickel_context_alloc()
    212     expr = L.nickel_expr_alloc()
    213     err = L.nickel_error_alloc()
    214     try
    215         result = L.nickel_context_eval_deep(ctx, code, expr, err)
    216         if result == L.NICKEL_RESULT_ERR
    217             _throw_nickel_error(err)
    218         end
    219         return _walk_expr(expr)
    220     finally
    221         L.nickel_error_free(err)
    222         L.nickel_expr_free(expr)
    223         L.nickel_context_free(ctx)
    224     end
    225 end
    226 
    227 """
    228     nickel_eval(code::String, ::Type{T}) -> T
    229 
    230 Evaluate Nickel code and convert to type T.
    231 Supports Dict, Vector, and NamedTuple conversions.
    232 
    233 # Examples
    234 ```julia
    235 julia> nickel_eval("42", Int)
    236 42
    237 
    238 julia> nickel_eval("{ a = 1, b = 2 }", Dict{String, Int})
    239 Dict{String, Int64}("a" => 1, "b" => 2)
    240 
    241 julia> nickel_eval("[1, 2, 3]", Vector{Int})
    242 [1, 2, 3]
    243 
    244 julia> nickel_eval("{ x = 1.5, y = 2.5 }", @NamedTuple{x::Float64, y::Float64})
    245 (x = 1.5, y = 2.5)
    246 ```
    247 """
    248 function nickel_eval(code::String, ::Type{T}) where T
    249     result = nickel_eval(code)
    250     return _convert_result(T, result)
    251 end
    252 
    253 # ── Type conversion helpers ───────────────────────────────────────────────────
    254 
    255 _convert_result(::Type{T}, x) where T = convert(T, x)
    256 
    257 function _convert_result(::Type{T}, d::Dict{String,Any}) where T <: NamedTuple
    258     fields = fieldnames(T)
    259     types = fieldtypes(T)
    260     values = Tuple(_convert_result(types[i], d[String(fields[i])]) for i in eachindex(fields))
    261     return T(values)
    262 end
    263 
    264 function _convert_result(::Type{Dict{K,V}}, d::Dict{String,Any}) where {K,V}
    265     return Dict{K,V}(K(k) => _convert_result(V, v) for (k, v) in d)
    266 end
    267 
    268 function _convert_result(::Type{Vector{T}}, v::Vector{Any}) where T
    269     return T[_convert_result(T, x) for x in v]
    270 end
    271 
    272 # ── File evaluation ───────────────────────────────────────────────────────────
    273 
    274 """
    275     nickel_eval_file(path::String) -> Any
    276 
    277 Evaluate a Nickel file. Supports `import` statements resolved relative
    278 to the file's directory.
    279 
    280 Returns native Julia types: Int64, Float64, Bool, String, nothing,
    281 Vector{Any}, Dict{String,Any}, or NickelEnum.
    282 
    283 # Examples
    284 ```julia
    285 julia> nickel_eval_file("config.ncl")
    286 Dict{String, Any}("host" => "localhost", "port" => 8080)
    287 ```
    288 """
    289 function nickel_eval_file(path::String)
    290     _check_ffi_available()
    291     abs_path = abspath(path)
    292     if !isfile(abs_path)
    293         throw(NickelError("File not found: $abs_path"))
    294     end
    295     code = read(abs_path, String)
    296     ctx = L.nickel_context_alloc()
    297     expr = L.nickel_expr_alloc()
    298     err = L.nickel_error_alloc()
    299     try
    300         # Set source name to the absolute file path so Nickel can resolve imports
    301         # relative to the file's directory.
    302         GC.@preserve abs_path begin
    303             L.nickel_context_set_source_name(ctx, Base.unsafe_convert(Ptr{Cchar}, abs_path))
    304         end
    305         result = L.nickel_context_eval_deep(ctx, code, expr, err)
    306         if result == L.NICKEL_RESULT_ERR
    307             _throw_nickel_error(err)
    308         end
    309         return _walk_expr(expr)
    310     finally
    311         L.nickel_error_free(err)
    312         L.nickel_expr_free(expr)
    313         L.nickel_context_free(ctx)
    314     end
    315 end
    316 
    317 # ── Export (serialization) ────────────────────────────────────────────────────
    318 
    319 function _eval_and_serialize(code::String, serialize_fn)
    320     _check_ffi_available()
    321     ctx = L.nickel_context_alloc()
    322     expr = L.nickel_expr_alloc()
    323     err = L.nickel_error_alloc()
    324     out_str = L.nickel_string_alloc()
    325     try
    326         result = L.nickel_context_eval_deep_for_export(ctx, code, expr, err)
    327         if result == L.NICKEL_RESULT_ERR
    328             _throw_nickel_error(err)
    329         end
    330         ser_result = serialize_fn(ctx, expr, out_str, err)
    331         if ser_result == L.NICKEL_RESULT_ERR
    332             _throw_nickel_error(err)
    333         end
    334         data_ptr = Ref{Ptr{Cchar}}(C_NULL)
    335         data_len = Ref{Csize_t}(0)
    336         L.nickel_string_data(out_str, data_ptr, data_len)
    337         return unsafe_string(data_ptr[], data_len[])
    338     finally
    339         L.nickel_string_free(out_str)
    340         L.nickel_error_free(err)
    341         L.nickel_expr_free(expr)
    342         L.nickel_context_free(ctx)
    343     end
    344 end
    345 
    346 """
    347     nickel_to_json(code::String) -> String
    348 
    349 Evaluate Nickel code and export to a JSON string.
    350 
    351 # Examples
    352 ```julia
    353 julia> nickel_to_json("{ a = 1, b = \"hello\" }")
    354 "{\\"a\\": 1,\\"b\\": \\"hello\\"}"
    355 ```
    356 """
    357 nickel_to_json(code::String) = _eval_and_serialize(code, L.nickel_context_expr_to_json)
    358 
    359 """
    360     nickel_to_yaml(code::String) -> String
    361 
    362 Evaluate Nickel code and export to a YAML string.
    363 
    364 # Examples
    365 ```julia
    366 julia> nickel_to_yaml("{ a = 1 }")
    367 "a: 1\\n"
    368 ```
    369 """
    370 nickel_to_yaml(code::String) = _eval_and_serialize(code, L.nickel_context_expr_to_yaml)
    371 
    372 """
    373     nickel_to_toml(code::String) -> String
    374 
    375 Evaluate Nickel code and export to a TOML string.
    376 
    377 # Examples
    378 ```julia
    379 julia> nickel_to_toml("{ a = 1 }")
    380 "a = 1\\n"
    381 ```
    382 """
    383 nickel_to_toml(code::String) = _eval_and_serialize(code, L.nickel_context_expr_to_toml)
    384 
    385 # ── Lazy evaluation ──────────────────────────────────────────────────────────
    386 
    387 function _check_session_open(session::NickelSession)
    388     session.closed && throw(ArgumentError("NickelSession is closed"))
    389 end
    390 
    391 # Allocate a new expr tracked by the session
    392 function _tracked_expr_alloc(session::NickelSession)
    393     expr = L.nickel_expr_alloc()
    394     push!(session.exprs, Ptr{Cvoid}(expr))
    395     return expr
    396 end
    397 
    398 function Base.close(session::NickelSession)
    399     session.closed && return
    400     session.closed = true
    401     for expr_ptr in session.exprs
    402         L.nickel_expr_free(Ptr{L.nickel_expr}(expr_ptr))
    403     end
    404     empty!(session.exprs)
    405     L.nickel_context_free(Ptr{L.nickel_context}(session.ctx))
    406     return nothing
    407 end
    408 
    409 Base.close(v::NickelValue) = close(getfield(v, :session))
    410 
    411 """
    412     nickel_open(f, code::String)
    413     nickel_open(code::String) -> NickelValue
    414 
    415 Evaluate Nickel code shallowly and return a lazy `NickelValue`.
    416 Sub-expressions are evaluated on demand when accessed via `.field` or `["field"]`.
    417 
    418 # Do-block (preferred)
    419 ```julia
    420 nickel_open("{ x = 1, y = 2 }") do cfg
    421     cfg.x  # => 1 (only evaluates x)
    422 end
    423 ```
    424 
    425 # Manual
    426 ```julia
    427 cfg = nickel_open("{ x = 1, y = 2 }")
    428 cfg.x  # => 1
    429 close(cfg)
    430 ```
    431 """
    432 function nickel_open(f::Function, path_or_code::String)
    433     val = nickel_open(path_or_code)
    434     try
    435         return f(val)
    436     finally
    437         close(val)
    438     end
    439 end
    440 
    441 function nickel_open(path_or_code::String)
    442     _check_ffi_available()
    443     # Detect file path: ends with .ncl AND exists on disk
    444     if endswith(path_or_code, ".ncl") && isfile(abspath(path_or_code))
    445         return _nickel_open_file(path_or_code)
    446     end
    447     return _nickel_open_code(path_or_code)
    448 end
    449 
    450 function _nickel_open_file(path::String)
    451     abs_path = abspath(path)
    452     if !isfile(abs_path)
    453         throw(NickelError("File not found: $abs_path"))
    454     end
    455     code = read(abs_path, String)
    456     ctx = L.nickel_context_alloc()
    457     session = NickelSession(Ptr{Cvoid}(ctx), Ptr{Cvoid}[], false)
    458     finalizer(close, session)
    459     expr = _tracked_expr_alloc(session)
    460     err = L.nickel_error_alloc()
    461     try
    462         GC.@preserve abs_path begin
    463             L.nickel_context_set_source_name(ctx, Base.unsafe_convert(Ptr{Cchar}, abs_path))
    464         end
    465         result = L.nickel_context_eval_shallow(ctx, code, expr, err)
    466         if result == L.NICKEL_RESULT_ERR
    467             _throw_nickel_error(err)
    468         end
    469         return NickelValue(session, Ptr{Cvoid}(expr))
    470     catch
    471         close(session)
    472         rethrow()
    473     finally
    474         L.nickel_error_free(err)
    475     end
    476 end
    477 
    478 function _nickel_open_code(code::String)
    479     ctx = L.nickel_context_alloc()
    480     session = NickelSession(Ptr{Cvoid}(ctx), Ptr{Cvoid}[], false)
    481     finalizer(close, session)
    482     expr = _tracked_expr_alloc(session)
    483     err = L.nickel_error_alloc()
    484     try
    485         result = L.nickel_context_eval_shallow(ctx, code, expr, err)
    486         if result == L.NICKEL_RESULT_ERR
    487             _throw_nickel_error(err)
    488         end
    489         return NickelValue(session, Ptr{Cvoid}(expr))
    490     catch
    491         close(session)
    492         rethrow()
    493     finally
    494         L.nickel_error_free(err)
    495     end
    496 end
    497 
    498 # Given a shallow-eval'd expr, return a Julia value (primitive) or NickelValue (compound).
    499 # Primitives (null, bool, number, string, bare enum tag) are converted immediately.
    500 # Compound types (record, array, enum variant) stay lazy as a new NickelValue.
    501 function _resolve_value(session::NickelSession, expr::Ptr{L.nickel_expr})
    502     if L.nickel_expr_is_null(expr) != 0
    503         return nothing
    504     elseif L.nickel_expr_is_bool(expr) != 0
    505         return L.nickel_expr_as_bool(expr) != 0
    506     elseif L.nickel_expr_is_number(expr) != 0
    507         num = L.nickel_expr_as_number(expr)
    508         if L.nickel_number_is_i64(num) != 0
    509             return L.nickel_number_as_i64(num)
    510         else
    511             return Float64(L.nickel_number_as_f64(num))
    512         end
    513     elseif L.nickel_expr_is_str(expr) != 0
    514         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    515         len = L.nickel_expr_as_str(expr, out_ptr)
    516         return unsafe_string(out_ptr[], len)
    517     elseif L.nickel_expr_is_enum_tag(expr) != 0
    518         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    519         len = L.nickel_expr_as_enum_tag(expr, out_ptr)
    520         tag = Symbol(unsafe_string(out_ptr[], len))
    521         return NickelEnum(tag, nothing)
    522     else
    523         # record, array, or enum variant — stay lazy
    524         return NickelValue(session, Ptr{Cvoid}(expr))
    525     end
    526 end
    527 
    528 # Evaluate a sub-expression shallowly, then resolve to Julia value or NickelValue.
    529 function _eval_and_resolve(session::NickelSession, sub_expr::Ptr{L.nickel_expr})
    530     ctx = Ptr{L.nickel_context}(session.ctx)
    531     out_expr = _tracked_expr_alloc(session)
    532     err = L.nickel_error_alloc()
    533     try
    534         result = L.nickel_context_eval_expr_shallow(ctx, sub_expr, out_expr, err)
    535         if result == L.NICKEL_RESULT_ERR
    536             _throw_nickel_error(err)
    537         end
    538         return _resolve_value(session, out_expr)
    539     finally
    540         L.nickel_error_free(err)
    541     end
    542 end
    543 
    544 """
    545     nickel_kind(v::NickelValue) -> Symbol
    546 
    547 Return the kind of a lazy Nickel value without evaluating its children.
    548 
    549 Returns one of: `:record`, `:array`, `:number`, `:string`, `:bool`, `:null`, `:enum`.
    550 """
    551 function nickel_kind(v::NickelValue)
    552     _check_session_open(getfield(v, :session))
    553     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    554     if L.nickel_expr_is_null(expr) != 0
    555         return :null
    556     elseif L.nickel_expr_is_bool(expr) != 0
    557         return :bool
    558     elseif L.nickel_expr_is_number(expr) != 0
    559         return :number
    560     elseif L.nickel_expr_is_str(expr) != 0
    561         return :string
    562     elseif L.nickel_expr_is_array(expr) != 0
    563         return :array
    564     elseif L.nickel_expr_is_record(expr) != 0
    565         return :record
    566     elseif L.nickel_expr_is_enum_variant(expr) != 0 || L.nickel_expr_is_enum_tag(expr) != 0
    567         return :enum
    568     else
    569         error("Unknown Nickel expression type")
    570     end
    571 end
    572 
    573 function Base.show(io::IO, v::NickelValue)
    574     session = getfield(v, :session)
    575     if session.closed
    576         print(io, "NickelValue(<closed>)")
    577         return
    578     end
    579     k = nickel_kind(v)
    580     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    581     if k == :record
    582         rec = L.nickel_expr_as_record(expr)
    583         n = Int(L.nickel_record_len(rec))
    584         print(io, "NickelValue(:record, $n field", n == 1 ? "" : "s", ")")
    585     elseif k == :array
    586         arr = L.nickel_expr_as_array(expr)
    587         n = Int(L.nickel_array_len(arr))
    588         print(io, "NickelValue(:array, $n element", n == 1 ? "" : "s", ")")
    589     else
    590         print(io, "NickelValue(:$k)")
    591     end
    592 end
    593 
    594 # ── Materialization ───────────────────────────────────────────────────────────
    595 
    596 """
    597     collect(v::NickelValue) -> Any
    598 
    599 Recursively evaluate and materialize the entire subtree rooted at `v`.
    600 Returns the same types as `nickel_eval`: Dict, Vector, Int64, Float64, etc.
    601 """
    602 function Base.collect(v::NickelValue)
    603     session = getfield(v, :session)
    604     _check_session_open(session)
    605     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    606     return _collect_expr(session, expr)
    607 end
    608 
    609 # Recursive collect: shallow-eval each sub-expression, then convert.
    610 # Unlike _walk_expr, this must eval each child before inspecting its type.
    611 function _collect_expr(session::NickelSession, expr::Ptr{L.nickel_expr})
    612     ctx = Ptr{L.nickel_context}(session.ctx)
    613 
    614     if L.nickel_expr_is_null(expr) != 0
    615         return nothing
    616     elseif L.nickel_expr_is_bool(expr) != 0
    617         return L.nickel_expr_as_bool(expr) != 0
    618     elseif L.nickel_expr_is_number(expr) != 0
    619         num = L.nickel_expr_as_number(expr)
    620         if L.nickel_number_is_i64(num) != 0
    621             return L.nickel_number_as_i64(num)
    622         else
    623             return Float64(L.nickel_number_as_f64(num))
    624         end
    625     elseif L.nickel_expr_is_str(expr) != 0
    626         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    627         len = L.nickel_expr_as_str(expr, out_ptr)
    628         return unsafe_string(out_ptr[], len)
    629     elseif L.nickel_expr_is_array(expr) != 0
    630         arr = L.nickel_expr_as_array(expr)
    631         n = Int(L.nickel_array_len(arr))
    632         result = Vector{Any}(undef, n)
    633         for i in 0:(n-1)
    634             elem = _tracked_expr_alloc(session)
    635             L.nickel_array_get(arr, Csize_t(i), elem)
    636             evaled = _tracked_expr_alloc(session)
    637             err = L.nickel_error_alloc()
    638             try
    639                 r = L.nickel_context_eval_expr_shallow(ctx, elem, evaled, err)
    640                 if r == L.NICKEL_RESULT_ERR
    641                     _throw_nickel_error(err)
    642                 end
    643             finally
    644                 L.nickel_error_free(err)
    645             end
    646             result[i+1] = _collect_expr(session, evaled)
    647         end
    648         return result
    649     elseif L.nickel_expr_is_record(expr) != 0
    650         rec = L.nickel_expr_as_record(expr)
    651         n = Int(L.nickel_record_len(rec))
    652         result = Dict{String, Any}()
    653         key_ptr = Ref{Ptr{Cchar}}(C_NULL)
    654         key_len = Ref{Csize_t}(0)
    655         for i in 0:(n-1)
    656             val_expr = _tracked_expr_alloc(session)
    657             L.nickel_record_key_value_by_index(rec, Csize_t(i), key_ptr, key_len, val_expr)
    658             key = unsafe_string(key_ptr[], key_len[])
    659             evaled = _tracked_expr_alloc(session)
    660             err = L.nickel_error_alloc()
    661             try
    662                 r = L.nickel_context_eval_expr_shallow(ctx, val_expr, evaled, err)
    663                 if r == L.NICKEL_RESULT_ERR
    664                     _throw_nickel_error(err)
    665                 end
    666             finally
    667                 L.nickel_error_free(err)
    668             end
    669             result[key] = _collect_expr(session, evaled)
    670         end
    671         return result
    672     elseif L.nickel_expr_is_enum_variant(expr) != 0
    673         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    674         arg_expr = _tracked_expr_alloc(session)
    675         len = L.nickel_expr_as_enum_variant(expr, out_ptr, arg_expr)
    676         tag = Symbol(unsafe_string(out_ptr[], len))
    677         evaled = _tracked_expr_alloc(session)
    678         err = L.nickel_error_alloc()
    679         try
    680             r = L.nickel_context_eval_expr_shallow(ctx, arg_expr, evaled, err)
    681             if r == L.NICKEL_RESULT_ERR
    682                 _throw_nickel_error(err)
    683             end
    684         finally
    685             L.nickel_error_free(err)
    686         end
    687         return NickelEnum(tag, _collect_expr(session, evaled))
    688     elseif L.nickel_expr_is_enum_tag(expr) != 0
    689         out_ptr = Ref{Ptr{Cchar}}(C_NULL)
    690         len = L.nickel_expr_as_enum_tag(expr, out_ptr)
    691         return NickelEnum(Symbol(unsafe_string(out_ptr[], len)), nothing)
    692     else
    693         error("Unknown Nickel expression type")
    694     end
    695 end
    696 
    697 # ── Inspection ────────────────────────────────────────────────────────────────
    698 
    699 function Base.keys(v::NickelValue)
    700     session = getfield(v, :session)
    701     _check_session_open(session)
    702     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    703     if L.nickel_expr_is_record(expr) == 0
    704         throw(ArgumentError("Cannot get keys: NickelValue is not a record"))
    705     end
    706     rec = L.nickel_expr_as_record(expr)
    707     n = Int(L.nickel_record_len(rec))
    708     result = Vector{String}(undef, n)
    709     key_ptr = Ref{Ptr{Cchar}}(C_NULL)
    710     key_len = Ref{Csize_t}(0)
    711     for i in 0:(n-1)
    712         L.nickel_record_key_value_by_index(rec, Csize_t(i), key_ptr, key_len,
    713                                            Ptr{L.nickel_expr}(C_NULL))
    714         result[i+1] = unsafe_string(key_ptr[], key_len[])
    715     end
    716     return result
    717 end
    718 
    719 function Base.length(v::NickelValue)
    720     session = getfield(v, :session)
    721     _check_session_open(session)
    722     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    723     if L.nickel_expr_is_record(expr) != 0
    724         return Int(L.nickel_record_len(L.nickel_expr_as_record(expr)))
    725     elseif L.nickel_expr_is_array(expr) != 0
    726         return Int(L.nickel_array_len(L.nickel_expr_as_array(expr)))
    727     else
    728         throw(ArgumentError("Cannot get length: NickelValue is not a record or array"))
    729     end
    730 end
    731 
    732 # ── Iteration ─────────────────────────────────────────────────────────────────
    733 
    734 function Base.iterate(v::NickelValue, state=1)
    735     session = getfield(v, :session)
    736     _check_session_open(session)
    737     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    738 
    739     if L.nickel_expr_is_record(expr) != 0
    740         rec = L.nickel_expr_as_record(expr)
    741         n = Int(L.nickel_record_len(rec))
    742         state > n && return nothing
    743         key_ptr = Ref{Ptr{Cchar}}(C_NULL)
    744         key_len = Ref{Csize_t}(0)
    745         val_expr = _tracked_expr_alloc(session)
    746         L.nickel_record_key_value_by_index(rec, Csize_t(state - 1), key_ptr, key_len, val_expr)
    747         key = unsafe_string(key_ptr[], key_len[])
    748         val = _eval_and_resolve(session, val_expr)
    749         return (key => val, state + 1)
    750     elseif L.nickel_expr_is_array(expr) != 0
    751         arr = L.nickel_expr_as_array(expr)
    752         n = Int(L.nickel_array_len(arr))
    753         state > n && return nothing
    754         elem = _tracked_expr_alloc(session)
    755         L.nickel_array_get(arr, Csize_t(state - 1), elem)
    756         val = _eval_and_resolve(session, elem)
    757         return (val, state + 1)
    758     else
    759         throw(ArgumentError("Cannot iterate: NickelValue is not a record or array"))
    760     end
    761 end
    762 
    763 # ── Navigation ───────────────────────────────────────────────────────────────
    764 
    765 function Base.getproperty(v::NickelValue, name::Symbol)
    766     return _lazy_field_access(v, String(name))
    767 end
    768 
    769 function Base.getindex(v::NickelValue, key::String)
    770     return _lazy_field_access(v, key)
    771 end
    772 
    773 function Base.getindex(v::NickelValue, idx::Integer)
    774     session = getfield(v, :session)
    775     _check_session_open(session)
    776     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    777     if L.nickel_expr_is_array(expr) == 0
    778         throw(ArgumentError("Cannot index with integer: NickelValue is not an array"))
    779     end
    780     arr = L.nickel_expr_as_array(expr)
    781     n = Int(L.nickel_array_len(arr))
    782     if idx < 1 || idx > n
    783         throw(BoundsError(v, idx))
    784     end
    785     out_expr = _tracked_expr_alloc(session)
    786     L.nickel_array_get(arr, Csize_t(idx - 1), out_expr)  # 0-based C API
    787     return _eval_and_resolve(session, out_expr)
    788 end
    789 
    790 function _lazy_field_access(v::NickelValue, key::String)
    791     session = getfield(v, :session)
    792     _check_session_open(session)
    793     expr = Ptr{L.nickel_expr}(getfield(v, :expr))
    794     if L.nickel_expr_is_record(expr) == 0
    795         throw(ArgumentError("Cannot access field '$key': NickelValue is not a record"))
    796     end
    797     rec = L.nickel_expr_as_record(expr)
    798     out_expr = _tracked_expr_alloc(session)
    799     has_value = L.nickel_record_value_by_name(rec, key, out_expr)
    800     if has_value == 0
    801         # Check whether the key exists at all
    802         n = Int(L.nickel_record_len(rec))
    803         found = false
    804         key_ptr = Ref{Ptr{Cchar}}(C_NULL)
    805         key_len = Ref{Csize_t}(0)
    806         for i in 0:(n-1)
    807             L.nickel_record_key_value_by_index(rec, Csize_t(i), key_ptr, key_len,
    808                                                Ptr{L.nickel_expr}(C_NULL))
    809             if unsafe_string(key_ptr[], key_len[]) == key
    810                 found = true
    811                 break
    812             end
    813         end
    814         if !found
    815             throw(NickelError("Field '$key' not found in record"))
    816         end
    817         throw(NickelError("Field '$key' has no value (contract-only or unevaluated)"))
    818     end
    819     return _eval_and_resolve(session, out_expr)
    820 end