// 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) }