refactor(lua): extract pmacs.index into its own module (F-016, tranche 1)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-07-06 11:32:55 -04:00
parent f0e121ab05
commit df35e03ecf
3 changed files with 443 additions and 393 deletions

View File

@ -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.

395
src/lua_bindings/index.rs Normal file
View File

@ -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<RefCell<ProjectIndexer>>;
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<Symbol> {
let name: String = t.get("name")?;
let kind_tag: Option<String> = 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<String> = t.get("source").ok().flatten();
let source = source_tag
.as_deref()
.and_then(SymbolSource::from_tag)
.unwrap_or(SymbolSource::Lua);
let container: Option<String> = t.get("container").ok().flatten();
Ok(Symbol {
name,
kind,
line,
col,
source,
container,
})
}
fn search_hit_to_lua(lua: &Lua, hit: &SearchHit) -> mlua::Result<Table> {
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::<Option<Table>>("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>,
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<String>,
Vec<Table>,
)| {
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<String>, Vec<Symbol>),
> = 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<usize>)| {
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
// <root>/.pmacs/index.json.
let ix = indexer.clone();
m.set(
"save",
lua.create_function(move |_, (root, path): (String, Option<String>)| {
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<String>)| {
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<String> =
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<SharedProjectIndexer> {
let ix: SharedProjectIndexer = Rc::new(RefCell::new(ProjectIndexer::new()));
install_project_index(lua, &ix)?;
Ok(ix)
}

View File

@ -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::<item>` 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<RefCell<ProjectIndexer>>;
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<Symbol> {
let name: String = t.get("name")?;
let kind_tag: Option<String> = 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<String> = t.get("source").ok().flatten();
let source = source_tag
.as_deref()
.and_then(SymbolSource::from_tag)
.unwrap_or(SymbolSource::Lua);
let container: Option<String> = t.get("container").ok().flatten();
Ok(Symbol {
name,
kind,
line,
col,
source,
container,
})
}
fn search_hit_to_lua(lua: &Lua, hit: &SearchHit) -> mlua::Result<Table> {
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::<Option<Table>>("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>,
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<String>,
Vec<Table>,
)| {
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<String>, Vec<Symbol>),
> = 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<usize>)| {
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
// <root>/.pmacs/index.json.
let ix = indexer.clone();
m.set(
"save",
lua.create_function(move |_, (root, path): (String, Option<String>)| {
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<String>)| {
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<String> =
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<SharedProjectIndexer> {
let ix: SharedProjectIndexer = Rc::new(RefCell::new(ProjectIndexer::new()));
install_project_index(lua, &ix)?;
Ok(ix)
}
// ---------------------------------------------------------------------------
// pmacs.completion: unified completion framework (T M4.11)
// ---------------------------------------------------------------------------