inkhaven 1.2.4

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Bund scripting integration.
//!
//! Inkhaven's foothold for Vladimir's Bund language — a stack-based
//! scripting layer (`bundcore` + `bund_language_parser` +
//! `rust_multistackvm`) intended to host user-authored hooks, custom
//! AI prompt templates, and save-time rules.
//!
//! ## Adam
//!
//! "Adam" is the canonical name for the process-wide singleton VM —
//! the first one, the one that already has stdlib loaded. The name
//! comes from bundcore itself (`BundVM::adam`). `init_adam()` is
//! called exactly once per process, lazily on the first `eval()`.
//!
//! ## Active store
//!
//! `ink.*` stdlib words (Phase 1) need access to the project's
//! `Store`. Inkhaven runs single-project-per-process, so we install
//! the store into a global `ACTIVE_STORE` slot once and read it out
//! of each word handler. CLI commands that don't open a project
//! (only `inkhaven bund` so far) simply leave the slot empty and
//! the ink words error gracefully.
//!
//! ## What's wired in each phase
//!
//! - **P0**: `init_adam()`, `eval()` — round-trip a Bund script.
//! - **P1** *(this file)*: `register_active_store()` + read-only
//!   `ink.*` stdlib words via `stdlib::register_ink_stdlib`.
//! - **P3**: sandbox policy.
//! - **P4**: hook points fired from `src/store/mod.rs`.
//! - **P5**: first-class `NodeKind::Script` + Bund-aware editor.
//! - **P6**: ephemeral worker pool + result queue.

pub mod hooks;
pub mod policy;
pub mod stdlib;

use std::cell::Cell;

thread_local! {
    /// Set while a `bund::eval` is in progress on this thread.
    /// `hooks::fire` checks this and short-circuits — otherwise
    /// any `ink.*` word that mutates the store (and so fires a
    /// hook) would re-enter `with_adam` and deadlock against
    /// the write lock the current eval is holding.
    static IN_BUND_EVAL: Cell<bool> = const { Cell::new(false) };

    /// Raw pointer to the live `App` set by `App::scripting_eval`
    /// for the duration of one Bund evaluation, then cleared.
    /// Stays null in CLI / non-TUI contexts — `ink.editor.*` /
    /// `ink.ai.*` / `ink.typst.*` words error with a clear
    /// "no active App" message when the slot is null.
    ///
    /// SAFETY contract:
    /// - The pointer is set from a `&mut self` on App; while it
    ///   is non-null, App is borrowed by the call that set it.
    /// - Bund word handlers cast back to `&mut App` via this
    ///   pointer. That creates a *second* mutable reference in
    ///   Rust's eyes — technically aliasing — but the TUI is
    ///   single-threaded and the access is strictly nested
    ///   inside the call that holds the outer borrow.
    /// - Always cleared by the RAII guard on drop, so panics +
    ///   early returns can't leak a stale pointer.
    static ACTIVE_APP: Cell<*mut crate::tui::app::App> = const {
        Cell::new(std::ptr::null_mut())
    };
}

/// True if the current thread is inside a `bund::eval`.
pub(crate) fn is_in_eval() -> bool {
    IN_BUND_EVAL.with(|c| c.get())
}

/// Run `f` with a mutable reference to the active `App`, if one
/// is installed. Returns `None` when called outside an
/// `App::scripting_eval` (e.g., from CLI `inkhaven bund` or from
/// a hook fired without an App in scope). Stdlib words use this
/// to reach App state for editor / AI / Typst mutation.
///
/// SAFETY: see the module-level doc-comment on `ACTIVE_APP`.
pub(crate) fn with_active_app<F, R>(f: F) -> Option<R>
where
    F: FnOnce(&mut crate::tui::app::App) -> R,
{
    let ptr = ACTIVE_APP.with(|c| c.get());
    if ptr.is_null() {
        return None;
    }
    // SAFETY: the pointer is non-null only between
    // App::scripting_eval entry (which sets the slot from
    // `self as *mut _`) and the matching RAII drop. The TUI is
    // single-threaded; nothing else can take a reference to
    // App during that window. The aliasing-with-the-outer-
    // borrow concern is documented in ACTIVE_APP's doc-comment.
    Some(f(unsafe { &mut *ptr }))
}

/// RAII guard for `ACTIVE_APP`. Constructed by
/// `App::scripting_eval`, restores the prior pointer on drop so
/// nested evals (we never nest in practice, but defensively)
/// don't leak the wrong reference.
pub(crate) struct AppGuard {
    prev: *mut crate::tui::app::App,
}

impl AppGuard {
    pub(crate) fn enter(app: &mut crate::tui::app::App) -> Self {
        let new: *mut crate::tui::app::App = app;
        let prev = ACTIVE_APP.with(|c| c.replace(new));
        Self { prev }
    }
}

impl Drop for AppGuard {
    fn drop(&mut self) {
        ACTIVE_APP.with(|c| c.set(self.prev));
    }
}

/// RAII guard that flips `IN_BUND_EVAL` true on construction and
/// resets to its prior value on drop. Used inside `eval` so a
/// panic still clears the flag and subsequent hooks fire
/// normally.
struct EvalGuard {
    prev: bool,
}

impl EvalGuard {
    fn enter() -> Self {
        let prev = IN_BUND_EVAL.with(|c| c.replace(true));
        Self { prev }
    }
}

impl Drop for EvalGuard {
    fn drop(&mut self) {
        IN_BUND_EVAL.with(|c| c.set(self.prev));
    }
}

use anyhow::{anyhow, Result};
use bundcore::bundcore::Bund;
use parking_lot::RwLock;
use rust_dynamic::value::Value;
use std::sync::OnceLock;

use crate::config::Config;
use crate::store::Store;
use policy::Policy;

/// Process-wide singleton Bund VM. Borrows the bundcore "Adam"
/// terminology — see module docs.
static ADAM: OnceLock<RwLock<Bund>> = OnceLock::new();

/// The project store, set once at startup (either when the TUI
/// opens a project or when the CLI's `bund` subcommand chooses to
/// expose the project). `None` is a valid state: scripts that try
/// to use `ink.*` words against a script-only invocation will see
/// a clean "no project store registered" error.
static ACTIVE_STORE: OnceLock<Store> = OnceLock::new();

/// Project Config, set alongside `ACTIVE_STORE`. Several
/// `Store::*` methods (`create_node`, snapshot semantics)
/// require it for hierarchy validation + artefact path
/// resolution.
static ACTIVE_CONFIG: OnceLock<Config> = OnceLock::new();

/// Sandbox policy to apply when Adam is built. Setters land before
/// the first `eval()` triggers lazy init; once Adam exists, the
/// policy is frozen for the process. `None` (no setter called) ⇒
/// the bundcore vanilla default, which deny destructive categories.
static POLICY: OnceLock<Policy> = OnceLock::new();

/// Initialise the Adam VM exactly once. Idempotent — subsequent
/// calls are no-ops. The order matters:
///
/// 1. `Bund::new()` loads bundcore's vanilla stdlib (arithmetic,
///    strings, conditionals, lambdas).
/// 2. `register_ink_stdlib` adds inkhaven's read-only `ink.*` words.
/// 3. `policy::apply_policy` re-registers denied words with a stub.
/// 4. The inline `scripting.bootstrap` HJSON script runs once.
/// 5. Every `NodeKind::Script` in the active store gets eval'd in
///    tree order — that's where most user-authored hook lambdas
///    actually live (P5).
///
/// Steps 4 and 5 are best-effort: a syntax error in a single
/// script logs a WARN and skips that script; the others still
/// run, and Adam still finishes constructing.
pub fn init_adam() -> Result<()> {
    if ADAM.get().is_some() {
        return Ok(());
    }
    let mut bund = Bund::new();
    stdlib::register_ink_stdlib(&mut bund.vm)
        .map_err(|e| anyhow!("register ink stdlib: {e}"))?;
    let p = POLICY.get().cloned().unwrap_or_default();
    if !p.is_open() {
        policy::apply_policy(&mut bund.vm, &p)
            .map_err(|e| anyhow!("apply policy: {e}"))?;
    }
    if !p.bootstrap.trim().is_empty() {
        if let Err(e) = bund.eval(p.bootstrap.clone()) {
            tracing::warn!(
                target: "inkhaven::scripting",
                "bootstrap script failed: {}",
                e
            );
        }
    }
    load_store_scripts(&mut bund);
    let _ = ADAM.set(RwLock::new(bund));
    Ok(())
}

/// Walk the active store for every `NodeKind::Script` node, read
/// its body, and `bund.eval` it against the supplied VM. Errors
/// per-script are logged and continue; no script can break the
/// rest of init.
///
/// No-ops cleanly when no project store is registered (e.g., a
/// pure `inkhaven bund "40 2 +"` invocation outside any project).
fn load_store_scripts(bund: &mut Bund) {
    let Some(store) = active_store() else { return };
    let hierarchy = match crate::store::hierarchy::Hierarchy::load(store) {
        Ok(h) => h,
        Err(e) => {
            tracing::warn!(
                target: "inkhaven::scripting",
                "load_store_scripts: hierarchy load failed: {}",
                e
            );
            return;
        }
    };
    for node in hierarchy.iter() {
        if node.kind != crate::store::NodeKind::Script {
            continue;
        }
        let bytes = match store.get_content(node.id) {
            Ok(Some(b)) => b,
            Ok(None) => continue,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::scripting",
                    "script {} read failed: {}",
                    node.id,
                    e
                );
                continue;
            }
        };
        let body = String::from_utf8_lossy(&bytes).into_owned();
        if body.trim().is_empty() {
            continue;
        }
        if let Err(e) = bund.eval(body) {
            tracing::warn!(
                target: "inkhaven::scripting",
                "script `{}` ({}) eval failed: {}",
                node.title,
                node.id,
                e
            );
        }
    }
}

/// Run `f` with a mutable reference to Adam. Returns `None` when
/// Adam hasn't been built yet — callers handle that as "no script
/// runtime available, skip". Used internally by `hooks::fire`.
pub(crate) fn with_adam<F, R>(f: F) -> Option<R>
where
    F: FnOnce(&mut Bund) -> R,
{
    let adam = ADAM.get()?;
    let mut guard = adam.write();
    Some(f(&mut guard))
}

/// Install the sandbox policy. Must be called BEFORE the first
/// `eval()` — otherwise Adam is already constructed under the
/// default policy and the call is a no-op. Idempotent in practice
/// (single-project-per-process).
pub fn set_policy(policy: Policy) {
    let _ = POLICY.set(policy);
}

/// Install the project store into the global slot. Called by the
/// TUI startup path and by the CLI when a subcommand wants its
/// Bund expressions to see the project. Idempotent in practice —
/// subsequent calls silently no-op (single-project-per-process).
pub fn register_active_store(store: Store) {
    let _ = ACTIVE_STORE.set(store);
}

/// Read access to the active project config. Used by `ink.tree.*`
/// write words that need `cfg` for hierarchy validation.
pub fn active_config() -> Option<&'static Config> {
    ACTIVE_CONFIG.get()
}

/// Install the project config into the global slot. Called from
/// `Store::open` via `configure`.
pub fn register_active_config(cfg: Config) {
    let _ = ACTIVE_CONFIG.set(cfg);
}

/// One-shot helper called from `Store::open` so every code path
/// that opens a project — TUI, `inkhaven bund`, `inkhaven add`,
/// `inkhaven reindex`, etc. — automatically arms the scripting
/// layer. Installs policy + store + config together.
///
/// All three inner calls are idempotent (single-project-per-
/// process), so a second open against the same project is harmless.
pub fn configure(policy: Policy, store: Store, cfg: Config) {
    set_policy(policy);
    register_active_store(store);
    register_active_config(cfg);
}

/// Read access to the active store, used by `ink.*` word handlers.
/// `None` means no project has been opened in this process.
pub fn active_store() -> Option<&'static Store> {
    ACTIVE_STORE.get()
}

/// Result of an `eval` call: any text written to bundcore's
/// (overridden) `print` / `println` stream, plus whatever value
/// remained on the top of the workbench when the script finished.
///
/// Both fields are optional in spirit:
///   * `stdout` is "" when nothing printed
///   * `top` is `None` when the stack was empty after the script
///
/// Callers decide how to surface them — CLI bund prints stdout to
/// the real terminal and then the top; TUI Ctrl+Z E concatenates
/// them on the status bar.
#[derive(Debug, Default)]
pub struct EvalOutput {
    pub stdout: String,
    pub top: Option<Value>,
}

/// Parse + evaluate `code` against Adam. Returns an `EvalOutput`
/// containing the captured print buffer and the top of the
/// workbench. Auto-initialises Adam on the first call.
pub fn eval(code: &str) -> Result<EvalOutput> {
    init_adam()?;
    let adam = ADAM.get().ok_or_else(|| anyhow!("Adam VM missing after init"))?;
    let _ = stdlib::io::drain_print_buffer();
    // Mark this thread as "inside eval" so hook fires that
    // originate from Store mutations the script triggers (via
    // `ink.tree.*`, `ink.paragraph.*`, etc.) short-circuit
    // instead of re-entering the write lock we're about to take.
    let _eval_guard = EvalGuard::enter();
    let mut guard = adam.write();
    guard
        .eval(code)
        .map_err(|e| anyhow!("bund eval failed: {e}"))?;
    let top = guard.vm.stack.pull();
    drop(guard);
    let stdout = stdlib::io::drain_print_buffer();
    Ok(EvalOutput { stdout, top })
}

/// Render a `rust_dynamic::Value` as a human-readable string. Used
/// by the CLI subcommand and (eventually) the TUI command output.
///
/// Strategy: scalar variants (string/int/float/bool) render as
/// their bare value so `inkhaven bund "40 2 +"` prints `42` rather
/// than `Value { … }`. Compound variants (list, map) go through
/// rust_dynamic's `cast_value_to_json` and get pretty-printed —
/// suitable for piping into `jq` or eyeballing the structure.
pub fn format_value(v: &Value) -> String {
    if let Ok(s) = v.clone().cast_string() {
        return s;
    }
    if let Ok(i) = v.clone().cast_int() {
        return i.to_string();
    }
    if let Ok(f) = v.clone().cast_float() {
        return f.to_string();
    }
    if let Ok(b) = v.clone().cast_bool() {
        return b.to_string();
    }
    let j = value_to_json(v);
    serde_json::to_string_pretty(&j).unwrap_or_else(|_| j.to_string())
}

/// Recursive `Value` → `serde_json::Value` converter that fills the
/// gap in `rust_dynamic::Value::cast_value_to_json`: the upstream
/// helper doesn't handle the STRING variant (it errors at the
/// `_ =>` arm), so a list-of-maps-of-strings — which is exactly
/// what every `ink.*` word returns — comes out as Debug noise.
/// This walks the value ourselves and falls through to a debug
/// stringification only for variants we don't recognise.
fn value_to_json(v: &Value) -> serde_json::Value {
    if let Ok(s) = v.clone().cast_string() {
        return serde_json::Value::String(s);
    }
    if let Ok(i) = v.clone().cast_int() {
        return serde_json::Value::from(i);
    }
    if let Ok(f) = v.clone().cast_float() {
        return serde_json::Value::from(f);
    }
    if let Ok(b) = v.clone().cast_bool() {
        return serde_json::Value::Bool(b);
    }
    if let Ok(list) = v.clone().cast_list() {
        return serde_json::Value::Array(list.iter().map(value_to_json).collect());
    }
    if let Ok(dict) = v.clone().cast_dict() {
        let mut m = serde_json::Map::new();
        for (k, val) in dict.iter() {
            m.insert(k.clone(), value_to_json(val));
        }
        return serde_json::Value::Object(m);
    }
    // Last resort: NONE, NODATA, unrecognised variants.
    serde_json::Value::Null
}