From df35e03ecf622ede0a6b89833b5446f7d5ec5143 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 11:32:55 -0400 Subject: [PATCH] refactor(lua): extract pmacs.index into its own module (F-016, tranche 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second tranche of the F-016 split. Extract the pmacs.index surface (the project symbol-index bindings) from src/lua_bindings/mod.rs into src/lua_bindings/index.rs, moved verbatim. index is the one genuinely clean remaining leaf: its private helpers (symbol_kind_from_lua, lua_symbol_from_table, search_hit_to_lua) are used only within its own range, and it has zero shared-core coupling — it depends only on crate::project_index, mlua, and std, reaching one stranded helper (lua_to_json, still in the lsp section) via `super::`. mod.rs declares `mod index;` and re-exports `SharedProjectIndexer` + `make_project_indexer` via `pub use`, so the crate::lua_bindings::… paths in editor.rs and completion_framework.rs (and an in-file completion- framework use) stay valid — no external file changes. Pure code motion, no behavior change. mod.rs: 14986 → 14603 lines. While vetting the next leaves I found the recon under-counted the misplaced shared helpers: parse/theme, window, and minibuffer trail off into shared style/color, caller_source, and command/menu helpers, so a dedicated helper-hoist tranche must precede them (framing tranche plan updated). This tranche stops at index rather than force a contaminated extraction. Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib suite 1437 passed / 0 failed under both luajit and lua54. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- docs/lua-bindings-split-framing.md | 52 +++- src/lua_bindings/index.rs | 395 +++++++++++++++++++++++++++++ src/lua_bindings/mod.rs | 389 +--------------------------- 3 files changed, 443 insertions(+), 393 deletions(-) create mode 100644 src/lua_bindings/index.rs diff --git a/docs/lua-bindings-split-framing.md b/docs/lua-bindings-split-framing.md index db940b7..61bee30 100644 --- a/docs/lua-bindings-split-framing.md +++ b/docs/lua-bindings-split-framing.md @@ -106,15 +106,27 @@ pattern; coupled domains follow their dependencies. directory module, the `super::`-access discipline, and the new-file CI path on the *simplest* real case before moving code in bulk. Subsequent tranches batch multiple domains now that the mechanics are proven. -1. **`parse` + `theme` and the other pure leaves.** `parse`/`theme` come - as one unit (`make_syntax_registry` installs both; it's the external - entry from `editor.rs`, so this tranche also establishes the - `pub(crate) use` re-export that keeps `crate::lua_bindings::…` paths - stable). Batch in `index`, `window`, `minibuffer`. -2. **Hoist the misplaced helpers.** JSON converters → shared core; ANSI - converters → a new `ansi` module; extract `ansi` and `packages` (after - lifting the misplaced core installers back to shared core). Dissolves - the JSON / ANSI cross-domain edges. +1. **`index` (the one genuinely clean remaining leaf).** Zero shared-core + coupling; establishes the `pub(crate) use` re-export for external + callers (`editor.rs`, `completion_framework.rs`) and `super::` access + to a stranded helper (`lua_to_json`). **Correction after tranche 1:** + the recon under-counted the misplaced helpers. Beyond the JSON/ANSI + clusters, the *style/color* converters (`lua_to_style`/`style_to_lua`/ + `color_to_lua`/…) sit in the `theme` section, and `caller_source` + + the command/menu builders (`build_command_from_spec`, + `build_menu_item_from_spec`, `BindArgs`, `register`, …) sit at the tail + of the `minibuffer` range — all shared, used across sections. So + `parse`/`theme`, `window`, and `minibuffer` are **not** clean + line-range extractions until the hoist below runs. +2. **Hoist the misplaced helpers (must precede the contaminated leaves).** + Move every shared helper stranded in a domain section to its proper + home: JSON converters → shared core; ANSI converters → the `ansi` + module; style/color converters + `caller_source` + command/menu + builders → shared core. This is a within-`mod.rs` repositioning (no + behavior change) that makes the domain sections clean line-range units. + Then extract `parse`/`theme`, `window`, `minibuffer`, `ansi`, + `packages` (the last after lifting the misplaced core installers out of + its range too). 3. **The `lsp` hub + its JSON consumers.** `lsp`, then `async`, `mcp`, `completion` (edge-free once the JSON helpers are hoisted). 4. **The coupled tail.** `process` (→ansi), `project` (→lsp), @@ -174,3 +186,25 @@ Validated: `cargo fmt` clean; `clippy --lib` clean under luajit **and** lua54; full lib suite **1437 passed / 0 failed** under luajit (the tests, which drive `pmacs.diag.*` through the Lua VM, are the behavioral oracle — unchanged outcomes, code merely relocated). + +**Tranche 1 (this PR).** Extracted `pmacs.index` (the project symbol-index +surface) into `src/lua_bindings/index.rs` (390 lines) — the one remaining +*clean* leaf (its 3 private helpers are used only within its own range). +`mod.rs` declares `mod index;` and `pub use index::{SharedProjectIndexer, +make_project_indexer};`, which keeps the `crate::lua_bindings::…` paths in +`editor.rs` + `completion_framework.rs` (and an in-file completion-framework +use) valid. `index.rs` has **zero shared-core coupling** — it depends only +on `crate::project_index`, mlua, std, and reaches one stranded helper +(`lua_to_json`, still in the `lsp` section) via `super::`. Verbatim move, +no logic change. `mod.rs`: 14,986 → 14,603 lines. + +While vetting the next leaves, discovered the recon under-counted the +misplaced helpers (see the corrected tranche plan above): `parse`/`theme`, +`window`, and `minibuffer` trail off into *shared* style/color, `caller_ +source`, and command/menu helpers, so they need the helper-hoist tranche +(now #2) before they can be extracted cleanly. This tranche stops at +`index` rather than force those. + +Validated: `cargo fmt` clean; `clippy --lib` clean under **both** flavors; +full lib suite **1437 passed / 0 failed** under **both** luajit and +lua54. diff --git a/src/lua_bindings/index.rs b/src/lua_bindings/index.rs new file mode 100644 index 0000000..9c9b8fc --- /dev/null +++ b/src/lua_bindings/index.rs @@ -0,0 +1,395 @@ +// lua_bindings/index.rs --- pmacs.index: project-scoped symbol index. + +//! `pmacs.index.*` — the project symbol index surface (T M4.10). Split out +//! of `lua_bindings.rs` verbatim (audit F-016); behavior unchanged. Pure +//! leaf: depends only on `crate::project_index`, mlua, and std — no +//! shared-core coupling. + +use std::cell::RefCell; +use std::rc::Rc; + +use mlua::{Lua, Table, Value}; + +// `lua_to_json` is a generic JSON converter that still physically lives in +// the `lsp` section of `mod.rs`; reachable here as a parent-private item. +// (A later tranche hoists it into shared core proper.) +use super::lua_to_json; + +use crate::project_index::{ + FileEntry, ProjectIndexer, SearchHit, Symbol, SymbolKind, SymbolSource, extract_heuristic, + fnv1a_64, ingest_lsp_symbols, +}; + +/// Cheaply-cloneable shared project index registry. +pub type SharedProjectIndexer = Rc>; + +fn symbol_kind_from_lua(tag: &str) -> SymbolKind { + match tag { + "function" => SymbolKind::Function, + "method" => SymbolKind::Method, + "struct" => SymbolKind::Struct, + "class" => SymbolKind::Class, + "trait" | "interface" => SymbolKind::Trait, + "enum" => SymbolKind::Enum, + "variable" => SymbolKind::Variable, + "constant" => SymbolKind::Constant, + "field" | "property" => SymbolKind::Field, + "module" | "namespace" => SymbolKind::Module, + "macro" => SymbolKind::Macro, + "type_alias" | "type" => SymbolKind::TypeAlias, + other => SymbolKind::Other(other.to_owned()), + } +} + +fn lua_symbol_from_table(t: &Table) -> mlua::Result { + let name: String = t.get("name")?; + let kind_tag: Option = t.get("kind").ok().flatten(); + let kind = kind_tag + .as_deref() + .map_or(SymbolKind::Other("unknown".into()), symbol_kind_from_lua); + let line: u32 = t.get("line").unwrap_or(0); + let col: u32 = t.get("col").unwrap_or(0); + let source_tag: Option = t.get("source").ok().flatten(); + let source = source_tag + .as_deref() + .and_then(SymbolSource::from_tag) + .unwrap_or(SymbolSource::Lua); + let container: Option = t.get("container").ok().flatten(); + Ok(Symbol { + name, + kind, + line, + col, + source, + container, + }) +} + +fn search_hit_to_lua(lua: &Lua, hit: &SearchHit) -> mlua::Result { + let t = lua.create_table_with_capacity(0, 9)?; + t.set("name", hit.name.as_str())?; + t.set("kind", hit.kind.tag())?; + t.set("source", hit.source.tag())?; + t.set("path", hit.path.display().to_string())?; + t.set("relative_path", hit.relative_path.display().to_string())?; + t.set("line", hit.line)?; + t.set("col", hit.col)?; + t.set("score", hit.score)?; + if let Some(c) = &hit.container { + t.set("container", c.as_str())?; + } + if let Some(l) = &hit.language { + t.set("language", l.as_str())?; + } + Ok(t) +} + +/// Install `pmacs.index.*` (T M4.10). Preserves any existing +/// `pmacs.index` keys (e.g. user-supplied indexer extensions +/// installed by builtin Lua chunks). +#[allow( + clippy::too_many_lines, + reason = "linear list of index bindings; splitting adds ceremony without clarity" +)] +pub fn install_project_index(lua: &Lua, indexer: &SharedProjectIndexer) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m: Table = match pmacs.get::>("index")? { + Some(t) => t, + None => lua.create_table()?, + }; + + { + // open(root) -> root_string. Ensures an index exists for + // `root`; idempotent. Returns the canonicalised root the + // caller should pass back to subsequent calls. + let ix = indexer.clone(); + m.set( + "open", + lua.create_function(move |_, root: String| { + let mut ix_ref = ix.borrow_mut(); + let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); + Ok(idx.root.display().to_string()) + })?, + )?; + } + + { + // close(root): drop the in-memory index. Does not touch disk. + let ix = indexer.clone(); + m.set( + "close", + lua.create_function(move |_, root: String| { + Ok(ix.borrow_mut().forget(std::path::Path::new(&root))) + })?, + )?; + } + + { + // upsert_file(root, path, language, source) -> { added }. + // Runs the heuristic extractor on `source`, hashes it, and + // replaces the entry for `path`. + let ix = indexer.clone(); + m.set( + "upsert_file", + lua.create_function( + move |lua, + (root, path, language, source): ( + String, + String, + Option, + String, + )| { + let mut ix_ref = ix.borrow_mut(); + let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); + let lang = language.as_deref().unwrap_or(""); + let symbols = if lang.is_empty() { + crate::project_index::extract_raw(&source) + } else { + extract_heuristic(lang, &source) + }; + let added = symbols.len(); + let entry = FileEntry { + path: std::path::PathBuf::from(&path), + mtime_secs: 0, + content_hash: fnv1a_64(source.as_bytes()), + language: language.clone(), + symbols, + }; + idx.upsert_file(entry); + let t = lua.create_table_with_capacity(0, 1)?; + t.set("added", added)?; + Ok(t) + }, + )?, + )?; + } + + { + // upsert_symbols(root, path, language, symbol_array): push + // pre-extracted symbols (e.g. from a Lua-side indexer) into + // the index. Each entry is a table with name/kind/line/col/ + // source/container fields. + let ix = indexer.clone(); + m.set( + "upsert_symbols", + lua.create_function( + move |_, + (root, path, language, symbols): ( + String, + String, + Option, + Vec
, + )| { + let mut parsed = Vec::with_capacity(symbols.len()); + for t in &symbols { + parsed.push(lua_symbol_from_table(t)?); + } + let added = parsed.len(); + let mut ix_ref = ix.borrow_mut(); + let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); + let entry = FileEntry { + path: std::path::PathBuf::from(&path), + mtime_secs: 0, + content_hash: 0, + language, + symbols: parsed, + }; + idx.upsert_file(entry); + Ok(added) + }, + )?, + )?; + } + + { + // ingest_lsp(root, lsp_response): merge symbols from a + // workspace/symbol or documentSymbol response. Groups + // results by path and replaces each path's entry. + let ix = indexer.clone(); + m.set( + "ingest_lsp", + lua.create_function(move |_, (root, value): (String, Value)| { + let json = lua_to_json(value)?; + let inbound = ingest_lsp_symbols(&json); + let mut ix_ref = ix.borrow_mut(); + let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); + let mut by_path: std::collections::HashMap< + std::path::PathBuf, + (Option, Vec), + > = std::collections::HashMap::new(); + for entry in inbound { + let bucket = by_path + .entry(entry.path) + .or_insert_with(|| (entry.language.clone(), Vec::new())); + bucket.1.push(entry.symbol); + } + let merged = by_path.len(); + for (path, (lang, symbols)) in by_path { + idx.upsert_file(FileEntry { + path, + mtime_secs: 0, + content_hash: 0, + language: lang, + symbols, + }); + } + Ok(merged) + })?, + )?; + } + + { + // invalidate(root, path): drop one file's entry. + let ix = indexer.clone(); + m.set( + "invalidate", + lua.create_function(move |_, (root, path): (String, String)| { + let mut ix_ref = ix.borrow_mut(); + Ok(ix_ref + .get_mut(std::path::Path::new(&root)) + .is_some_and(|idx| idx.forget_file(std::path::Path::new(&path)))) + })?, + )?; + } + + { + // is_fresh(root, path, mtime_secs, content_hash) -> bool + let ix = indexer.clone(); + m.set( + "is_fresh", + lua.create_function( + move |_, (root, path, mtime_secs, content_hash): (String, String, u64, u64)| { + let ix_ref = ix.borrow(); + Ok(ix_ref.get(std::path::Path::new(&root)).is_some_and(|idx| { + idx.is_fresh(std::path::Path::new(&path), mtime_secs, content_hash) + })) + }, + )?, + )?; + } + + { + // search(root, query [, limit]) -> array of hit tables. + let ix = indexer.clone(); + m.set( + "search", + lua.create_function( + move |lua, (root, query, limit): (String, String, Option)| { + let ix_ref = ix.borrow(); + let Some(idx) = ix_ref.get(std::path::Path::new(&root)) else { + return lua.create_table(); + }; + let hits = idx.search(&query, limit.unwrap_or(50)); + let out = lua.create_table_with_capacity(hits.len(), 0)?; + for (i, h) in hits.iter().enumerate() { + out.set(i + 1, search_hit_to_lua(lua, h)?)?; + } + Ok(out) + }, + )?, + )?; + } + + { + // save(root [, path]): persist the index. Path defaults to + // /.pmacs/index.json. + let ix = indexer.clone(); + m.set( + "save", + lua.create_function(move |_, (root, path): (String, Option)| { + let ix_ref = ix.borrow(); + let idx = ix_ref + .get(std::path::Path::new(&root)) + .ok_or_else(|| mlua::Error::external(format!("unknown index root: {root}")))?; + let dest = path.map_or_else(|| idx.default_cache_path(), std::path::PathBuf::from); + idx.save(&dest).map_err(mlua::Error::external)?; + Ok(dest.display().to_string()) + })?, + )?; + } + + { + // load(root [, path]): replace the in-memory index for + // `root` with the on-disk cache. A missing cache file + // results in an empty index (cold-start). + let ix = indexer.clone(); + m.set( + "load", + lua.create_function(move |_, (root, path): (String, Option)| { + let root_path = std::path::PathBuf::from(&root); + let cache_path = path.map_or_else( + || crate::project_index::ProjectIndex::cache_path_for(&root_path), + std::path::PathBuf::from, + ); + let idx = crate::project_index::ProjectIndex::load(root_path.clone(), &cache_path) + .map_err(mlua::Error::external)?; + let symbol_count = idx.symbol_count(); + let file_count = idx.file_count(); + let mut ix_ref = ix.borrow_mut(); + let key = idx.root.clone(); + ix_ref.forget(&key); + let slot = ix_ref.ensure(key); + *slot = idx; + Ok((file_count, symbol_count)) + })?, + )?; + } + + { + // stats(root) -> { files, symbols, generation } or nil. + let ix = indexer.clone(); + m.set( + "stats", + lua.create_function(move |lua, root: String| { + let ix_ref = ix.borrow(); + let Some(idx) = ix_ref.get(std::path::Path::new(&root)) else { + return Ok(Value::Nil); + }; + let t = lua.create_table_with_capacity(0, 4)?; + t.set("files", idx.file_count())?; + t.set("symbols", idx.symbol_count())?; + t.set("generation", idx.generation)?; + t.set("root", idx.root.display().to_string())?; + Ok(Value::Table(t)) + })?, + )?; + } + + { + // roots() -> array of registered index roots. + let ix = indexer.clone(); + m.set( + "roots", + lua.create_function(move |lua, ()| { + let ix_ref = ix.borrow(); + let mut roots: Vec = + ix_ref.roots().map(|p| p.display().to_string()).collect(); + roots.sort(); + let out = lua.create_table_with_capacity(roots.len(), 0)?; + for (i, r) in roots.iter().enumerate() { + out.set(i + 1, r.as_str())?; + } + Ok(out) + })?, + )?; + } + + { + // hash(text) -> u64. Exposes FNV-1a so Lua callers can + // produce stable cache keys without a separate hash crate. + m.set( + "hash", + lua.create_function(|_, text: String| Ok(fnv1a_64(text.as_bytes())))?, + )?; + } + + pmacs.set("index", m)?; + Ok(()) +} + +/// Build a fresh [`ProjectIndexer`] and install `pmacs.index.*` over it. +pub fn make_project_indexer(lua: &Lua) -> mlua::Result { + let ix: SharedProjectIndexer = Rc::new(RefCell::new(ProjectIndexer::new())); + install_project_index(lua, &ix)?; + Ok(ix) +} diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index b6494a5..842fe79 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -75,8 +75,12 @@ use crate::workers_buffer; // `BufferIdLua`, state holders, helpers, and `install()`) stays here. // Submodules reach shared-core items via `super::` (a child module can see // its ancestors' private items), so the split needs no visibility widening -// beyond call seams. +// beyond call seams. Public entry points a domain owns are re-exported here +// so external `crate::lua_bindings::` paths (and in-file uses) stay +// stable. mod diag; +mod index; +pub use index::{SharedProjectIndexer, make_project_indexer}; // --------------------------------------------------------------------------- // Shared registry alias @@ -10122,389 +10126,6 @@ pub fn make_workspace(lua: &Lua, lsp_manager: &SharedLspManager) -> mlua::Result Ok(ws) } -// --------------------------------------------------------------------------- -// pmacs.index: project-scoped symbol index (T M4.10) -// --------------------------------------------------------------------------- - -use crate::project_index::{ - FileEntry, ProjectIndexer, SearchHit, Symbol, SymbolKind, SymbolSource, extract_heuristic, - fnv1a_64, ingest_lsp_symbols, -}; - -/// Cheaply-cloneable shared project index registry. -pub type SharedProjectIndexer = Rc>; - -fn symbol_kind_from_lua(tag: &str) -> SymbolKind { - match tag { - "function" => SymbolKind::Function, - "method" => SymbolKind::Method, - "struct" => SymbolKind::Struct, - "class" => SymbolKind::Class, - "trait" | "interface" => SymbolKind::Trait, - "enum" => SymbolKind::Enum, - "variable" => SymbolKind::Variable, - "constant" => SymbolKind::Constant, - "field" | "property" => SymbolKind::Field, - "module" | "namespace" => SymbolKind::Module, - "macro" => SymbolKind::Macro, - "type_alias" | "type" => SymbolKind::TypeAlias, - other => SymbolKind::Other(other.to_owned()), - } -} - -fn lua_symbol_from_table(t: &Table) -> mlua::Result { - let name: String = t.get("name")?; - let kind_tag: Option = t.get("kind").ok().flatten(); - let kind = kind_tag - .as_deref() - .map_or(SymbolKind::Other("unknown".into()), symbol_kind_from_lua); - let line: u32 = t.get("line").unwrap_or(0); - let col: u32 = t.get("col").unwrap_or(0); - let source_tag: Option = t.get("source").ok().flatten(); - let source = source_tag - .as_deref() - .and_then(SymbolSource::from_tag) - .unwrap_or(SymbolSource::Lua); - let container: Option = t.get("container").ok().flatten(); - Ok(Symbol { - name, - kind, - line, - col, - source, - container, - }) -} - -fn search_hit_to_lua(lua: &Lua, hit: &SearchHit) -> mlua::Result
{ - let t = lua.create_table_with_capacity(0, 9)?; - t.set("name", hit.name.as_str())?; - t.set("kind", hit.kind.tag())?; - t.set("source", hit.source.tag())?; - t.set("path", hit.path.display().to_string())?; - t.set("relative_path", hit.relative_path.display().to_string())?; - t.set("line", hit.line)?; - t.set("col", hit.col)?; - t.set("score", hit.score)?; - if let Some(c) = &hit.container { - t.set("container", c.as_str())?; - } - if let Some(l) = &hit.language { - t.set("language", l.as_str())?; - } - Ok(t) -} - -/// Install `pmacs.index.*` (T M4.10). Preserves any existing -/// `pmacs.index` keys (e.g. user-supplied indexer extensions -/// installed by builtin Lua chunks). -#[allow( - clippy::too_many_lines, - reason = "linear list of index bindings; splitting adds ceremony without clarity" -)] -pub fn install_project_index(lua: &Lua, indexer: &SharedProjectIndexer) -> mlua::Result<()> { - let pmacs: Table = lua.globals().get("pmacs")?; - let m: Table = match pmacs.get::>("index")? { - Some(t) => t, - None => lua.create_table()?, - }; - - { - // open(root) -> root_string. Ensures an index exists for - // `root`; idempotent. Returns the canonicalised root the - // caller should pass back to subsequent calls. - let ix = indexer.clone(); - m.set( - "open", - lua.create_function(move |_, root: String| { - let mut ix_ref = ix.borrow_mut(); - let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); - Ok(idx.root.display().to_string()) - })?, - )?; - } - - { - // close(root): drop the in-memory index. Does not touch disk. - let ix = indexer.clone(); - m.set( - "close", - lua.create_function(move |_, root: String| { - Ok(ix.borrow_mut().forget(std::path::Path::new(&root))) - })?, - )?; - } - - { - // upsert_file(root, path, language, source) -> { added }. - // Runs the heuristic extractor on `source`, hashes it, and - // replaces the entry for `path`. - let ix = indexer.clone(); - m.set( - "upsert_file", - lua.create_function( - move |lua, - (root, path, language, source): ( - String, - String, - Option, - String, - )| { - let mut ix_ref = ix.borrow_mut(); - let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); - let lang = language.as_deref().unwrap_or(""); - let symbols = if lang.is_empty() { - crate::project_index::extract_raw(&source) - } else { - extract_heuristic(lang, &source) - }; - let added = symbols.len(); - let entry = FileEntry { - path: std::path::PathBuf::from(&path), - mtime_secs: 0, - content_hash: fnv1a_64(source.as_bytes()), - language: language.clone(), - symbols, - }; - idx.upsert_file(entry); - let t = lua.create_table_with_capacity(0, 1)?; - t.set("added", added)?; - Ok(t) - }, - )?, - )?; - } - - { - // upsert_symbols(root, path, language, symbol_array): push - // pre-extracted symbols (e.g. from a Lua-side indexer) into - // the index. Each entry is a table with name/kind/line/col/ - // source/container fields. - let ix = indexer.clone(); - m.set( - "upsert_symbols", - lua.create_function( - move |_, - (root, path, language, symbols): ( - String, - String, - Option, - Vec
, - )| { - let mut parsed = Vec::with_capacity(symbols.len()); - for t in &symbols { - parsed.push(lua_symbol_from_table(t)?); - } - let added = parsed.len(); - let mut ix_ref = ix.borrow_mut(); - let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); - let entry = FileEntry { - path: std::path::PathBuf::from(&path), - mtime_secs: 0, - content_hash: 0, - language, - symbols: parsed, - }; - idx.upsert_file(entry); - Ok(added) - }, - )?, - )?; - } - - { - // ingest_lsp(root, lsp_response): merge symbols from a - // workspace/symbol or documentSymbol response. Groups - // results by path and replaces each path's entry. - let ix = indexer.clone(); - m.set( - "ingest_lsp", - lua.create_function(move |_, (root, value): (String, Value)| { - let json = lua_to_json(value)?; - let inbound = ingest_lsp_symbols(&json); - let mut ix_ref = ix.borrow_mut(); - let idx = ix_ref.ensure(std::path::PathBuf::from(&root)); - let mut by_path: std::collections::HashMap< - std::path::PathBuf, - (Option, Vec), - > = std::collections::HashMap::new(); - for entry in inbound { - let bucket = by_path - .entry(entry.path) - .or_insert_with(|| (entry.language.clone(), Vec::new())); - bucket.1.push(entry.symbol); - } - let merged = by_path.len(); - for (path, (lang, symbols)) in by_path { - idx.upsert_file(FileEntry { - path, - mtime_secs: 0, - content_hash: 0, - language: lang, - symbols, - }); - } - Ok(merged) - })?, - )?; - } - - { - // invalidate(root, path): drop one file's entry. - let ix = indexer.clone(); - m.set( - "invalidate", - lua.create_function(move |_, (root, path): (String, String)| { - let mut ix_ref = ix.borrow_mut(); - Ok(ix_ref - .get_mut(std::path::Path::new(&root)) - .is_some_and(|idx| idx.forget_file(std::path::Path::new(&path)))) - })?, - )?; - } - - { - // is_fresh(root, path, mtime_secs, content_hash) -> bool - let ix = indexer.clone(); - m.set( - "is_fresh", - lua.create_function( - move |_, (root, path, mtime_secs, content_hash): (String, String, u64, u64)| { - let ix_ref = ix.borrow(); - Ok(ix_ref.get(std::path::Path::new(&root)).is_some_and(|idx| { - idx.is_fresh(std::path::Path::new(&path), mtime_secs, content_hash) - })) - }, - )?, - )?; - } - - { - // search(root, query [, limit]) -> array of hit tables. - let ix = indexer.clone(); - m.set( - "search", - lua.create_function( - move |lua, (root, query, limit): (String, String, Option)| { - let ix_ref = ix.borrow(); - let Some(idx) = ix_ref.get(std::path::Path::new(&root)) else { - return lua.create_table(); - }; - let hits = idx.search(&query, limit.unwrap_or(50)); - let out = lua.create_table_with_capacity(hits.len(), 0)?; - for (i, h) in hits.iter().enumerate() { - out.set(i + 1, search_hit_to_lua(lua, h)?)?; - } - Ok(out) - }, - )?, - )?; - } - - { - // save(root [, path]): persist the index. Path defaults to - // /.pmacs/index.json. - let ix = indexer.clone(); - m.set( - "save", - lua.create_function(move |_, (root, path): (String, Option)| { - let ix_ref = ix.borrow(); - let idx = ix_ref - .get(std::path::Path::new(&root)) - .ok_or_else(|| mlua::Error::external(format!("unknown index root: {root}")))?; - let dest = path.map_or_else(|| idx.default_cache_path(), std::path::PathBuf::from); - idx.save(&dest).map_err(mlua::Error::external)?; - Ok(dest.display().to_string()) - })?, - )?; - } - - { - // load(root [, path]): replace the in-memory index for - // `root` with the on-disk cache. A missing cache file - // results in an empty index (cold-start). - let ix = indexer.clone(); - m.set( - "load", - lua.create_function(move |_, (root, path): (String, Option)| { - let root_path = std::path::PathBuf::from(&root); - let cache_path = path.map_or_else( - || crate::project_index::ProjectIndex::cache_path_for(&root_path), - std::path::PathBuf::from, - ); - let idx = crate::project_index::ProjectIndex::load(root_path.clone(), &cache_path) - .map_err(mlua::Error::external)?; - let symbol_count = idx.symbol_count(); - let file_count = idx.file_count(); - let mut ix_ref = ix.borrow_mut(); - let key = idx.root.clone(); - ix_ref.forget(&key); - let slot = ix_ref.ensure(key); - *slot = idx; - Ok((file_count, symbol_count)) - })?, - )?; - } - - { - // stats(root) -> { files, symbols, generation } or nil. - let ix = indexer.clone(); - m.set( - "stats", - lua.create_function(move |lua, root: String| { - let ix_ref = ix.borrow(); - let Some(idx) = ix_ref.get(std::path::Path::new(&root)) else { - return Ok(Value::Nil); - }; - let t = lua.create_table_with_capacity(0, 4)?; - t.set("files", idx.file_count())?; - t.set("symbols", idx.symbol_count())?; - t.set("generation", idx.generation)?; - t.set("root", idx.root.display().to_string())?; - Ok(Value::Table(t)) - })?, - )?; - } - - { - // roots() -> array of registered index roots. - let ix = indexer.clone(); - m.set( - "roots", - lua.create_function(move |lua, ()| { - let ix_ref = ix.borrow(); - let mut roots: Vec = - ix_ref.roots().map(|p| p.display().to_string()).collect(); - roots.sort(); - let out = lua.create_table_with_capacity(roots.len(), 0)?; - for (i, r) in roots.iter().enumerate() { - out.set(i + 1, r.as_str())?; - } - Ok(out) - })?, - )?; - } - - { - // hash(text) -> u64. Exposes FNV-1a so Lua callers can - // produce stable cache keys without a separate hash crate. - m.set( - "hash", - lua.create_function(|_, text: String| Ok(fnv1a_64(text.as_bytes())))?, - )?; - } - - pmacs.set("index", m)?; - Ok(()) -} - -/// Build a fresh [`ProjectIndexer`] and install `pmacs.index.*` over it. -pub fn make_project_indexer(lua: &Lua) -> mlua::Result { - let ix: SharedProjectIndexer = Rc::new(RefCell::new(ProjectIndexer::new())); - install_project_index(lua, &ix)?; - Ok(ix) -} - // --------------------------------------------------------------------------- // pmacs.completion: unified completion framework (T M4.11) // ---------------------------------------------------------------------------