Skip to main content

harn_hostlib/code_index/
builtins.rs

1//! Host-builtin handlers for the `code_index` module.
2//!
3//! Each handler shape mirrors the schema in
4//! `schemas/code_index/<method>.{request,response}.json`. A single shared
5//! [`SharedIndex`] cell is captured by the closure of every handler so
6//! every builtin observes the same in-memory state. The `current_agent_id`
7//! op also reads from the capability's `current_agent` slot, but for
8//! every other op the index mutex is the source of truth.
9
10use std::collections::{BTreeMap, HashSet};
11use std::path::PathBuf;
12use std::rc::Rc;
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use harn_vm::VmValue;
17
18use super::agents::AgentId;
19use super::file_table::{fnv1a64, FileId};
20use super::imports;
21use super::state::{now_unix_ms, IndexState};
22use super::trigram;
23use super::versions::EditOp;
24use crate::error::HostlibError;
25use crate::tools::args::{
26    build_dict, dict_arg, optional_bool, optional_int, optional_int_list, optional_string,
27    optional_string_list, require_int, require_string, str_value,
28};
29
30/// Shared, mutable cell carrying the (at most one) live workspace index.
31/// `Mutex` rather than `RwLock` because rebuilds flip the slot wholesale
32/// and every mutating op (record_edit, agent_register, lock_try, etc.)
33/// needs exclusive access. Single-threaded VM scripts pay no real cost
34/// from the choice; embedders that fan out across threads are still
35/// safe because the mutex serialises everyone.
36pub type SharedIndex = Arc<Mutex<Option<IndexState>>>;
37
38// === Builtin name constants ===
39//
40// Every handler routes through one of these. They double as the module's
41// public surface area so cross-repo schema-drift tests can discover them
42// without scraping source.
43
44pub(super) const BUILTIN_QUERY: &str = "hostlib_code_index_query";
45pub(super) const BUILTIN_REBUILD: &str = "hostlib_code_index_rebuild";
46pub(super) const BUILTIN_STATS: &str = "hostlib_code_index_stats";
47pub(super) const BUILTIN_IMPORTS_FOR: &str = "hostlib_code_index_imports_for";
48pub(super) const BUILTIN_IMPORTERS_OF: &str = "hostlib_code_index_importers_of";
49
50pub(super) const BUILTIN_PATH_TO_ID: &str = "hostlib_code_index_path_to_id";
51pub(super) const BUILTIN_ID_TO_PATH: &str = "hostlib_code_index_id_to_path";
52pub(super) const BUILTIN_FILE_IDS: &str = "hostlib_code_index_file_ids";
53pub(super) const BUILTIN_FILE_META: &str = "hostlib_code_index_file_meta";
54pub(super) const BUILTIN_FILE_HASH: &str = "hostlib_code_index_file_hash";
55
56pub(super) const BUILTIN_READ_RANGE: &str = "hostlib_code_index_read_range";
57pub(super) const BUILTIN_REINDEX_FILE: &str = "hostlib_code_index_reindex_file";
58pub(super) const BUILTIN_TRIGRAM_QUERY: &str = "hostlib_code_index_trigram_query";
59pub(super) const BUILTIN_EXTRACT_TRIGRAMS: &str = "hostlib_code_index_extract_trigrams";
60pub(super) const BUILTIN_WORD_GET: &str = "hostlib_code_index_word_get";
61pub(super) const BUILTIN_DEPS_GET: &str = "hostlib_code_index_deps_get";
62pub(super) const BUILTIN_OUTLINE_GET: &str = "hostlib_code_index_outline_get";
63
64pub(super) const BUILTIN_CURRENT_SEQ: &str = "hostlib_code_index_current_seq";
65pub(super) const BUILTIN_CHANGES_SINCE: &str = "hostlib_code_index_changes_since";
66pub(super) const BUILTIN_VERSION_RECORD: &str = "hostlib_code_index_version_record";
67
68pub(super) const BUILTIN_AGENT_REGISTER: &str = "hostlib_code_index_agent_register";
69pub(super) const BUILTIN_AGENT_HEARTBEAT: &str = "hostlib_code_index_agent_heartbeat";
70pub(super) const BUILTIN_AGENT_UNREGISTER: &str = "hostlib_code_index_agent_unregister";
71pub(super) const BUILTIN_LOCK_TRY: &str = "hostlib_code_index_lock_try";
72pub(super) const BUILTIN_LOCK_RELEASE: &str = "hostlib_code_index_lock_release";
73pub(super) const BUILTIN_STATUS: &str = "hostlib_code_index_status";
74pub(super) const BUILTIN_CURRENT_AGENT_ID: &str = "hostlib_code_index_current_agent_id";
75
76// === Search / rebuild / stats ===
77
78pub(super) fn run_query(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
79    let raw = dict_arg(BUILTIN_QUERY, args)?;
80    let dict = raw.as_ref();
81    let needle = require_string(BUILTIN_QUERY, dict, "needle")?;
82    if needle.is_empty() {
83        return Err(HostlibError::InvalidParameter {
84            builtin: BUILTIN_QUERY,
85            param: "needle",
86            message: "must not be empty".to_string(),
87        });
88    }
89    let case_sensitive = optional_bool(BUILTIN_QUERY, dict, "case_sensitive", false)?;
90    let max_results = optional_int(BUILTIN_QUERY, dict, "max_results", 100)?;
91    if max_results < 1 {
92        return Err(HostlibError::InvalidParameter {
93            builtin: BUILTIN_QUERY,
94            param: "max_results",
95            message: "must be >= 1".to_string(),
96        });
97    }
98    let scope = optional_string_list(BUILTIN_QUERY, dict, "scope")?;
99
100    let guard = index.lock().expect("code_index mutex poisoned");
101    let Some(state) = guard.as_ref() else {
102        return Ok(empty_query_response());
103    };
104
105    let candidate_ids = candidates_for(state, &needle);
106    let mut hits: Vec<Hit> = Vec::new();
107    for id in candidate_ids {
108        let Some(file) = state.files.get(&id) else {
109            continue;
110        };
111        if !scope_allows(&scope, &file.relative_path) {
112            continue;
113        }
114        let Some(text) = read_file_text(&state.root, &file.relative_path) else {
115            continue;
116        };
117        let count = count_matches(&text, &needle, case_sensitive);
118        if count == 0 {
119            continue;
120        }
121        hits.push(Hit {
122            path: file.relative_path.clone(),
123            score: count as f64,
124            match_count: count,
125        });
126    }
127    hits.sort_by(|a, b| {
128        b.match_count
129            .cmp(&a.match_count)
130            .then_with(|| a.path.cmp(&b.path))
131    });
132    let max = max_results as usize;
133    let truncated = hits.len() > max;
134    if truncated {
135        hits.truncate(max);
136    }
137    Ok(build_dict([
138        (
139            "results",
140            VmValue::List(Rc::new(hits.into_iter().map(hit_to_value).collect())),
141        ),
142        ("truncated", VmValue::Bool(truncated)),
143    ]))
144}
145
146pub(super) fn run_rebuild(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
147    let raw = dict_arg(BUILTIN_REBUILD, args)?;
148    let dict = raw.as_ref();
149    let _force = optional_bool(BUILTIN_REBUILD, dict, "force", false)?;
150    let root = optional_string(BUILTIN_REBUILD, dict, "root")?
151        .map(PathBuf::from)
152        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
153    if !root.exists() {
154        return Err(HostlibError::InvalidParameter {
155            builtin: BUILTIN_REBUILD,
156            param: "root",
157            message: format!("path `{}` does not exist", root.display()),
158        });
159    }
160    if !root.is_dir() {
161        return Err(HostlibError::InvalidParameter {
162            builtin: BUILTIN_REBUILD,
163            param: "root",
164            message: format!("path `{}` is not a directory", root.display()),
165        });
166    }
167    let started = Instant::now();
168    let (state, outcome) = IndexState::build_from_root(&root);
169    let elapsed_ms = started.elapsed().as_millis() as i64;
170    {
171        let mut guard = index.lock().expect("code_index mutex poisoned");
172        *guard = Some(state);
173    }
174    Ok(build_dict([
175        ("files_indexed", VmValue::Int(outcome.files_indexed as i64)),
176        ("files_skipped", VmValue::Int(outcome.files_skipped as i64)),
177        ("elapsed_ms", VmValue::Int(elapsed_ms)),
178    ]))
179}
180
181pub(super) fn run_stats(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
182    let guard = index.lock().expect("code_index mutex poisoned");
183    let Some(state) = guard.as_ref() else {
184        return Ok(empty_stats_response());
185    };
186    Ok(build_dict([
187        ("indexed_files", VmValue::Int(state.files.len() as i64)),
188        (
189            "trigrams",
190            VmValue::Int(state.trigrams.distinct_trigrams() as i64),
191        ),
192        ("words", VmValue::Int(state.words.distinct_words() as i64)),
193        ("memory_bytes", VmValue::Int(state.estimated_bytes() as i64)),
194        (
195            "last_rebuild_unix_ms",
196            VmValue::Int(state.last_built_unix_ms),
197        ),
198    ]))
199}
200
201pub(super) fn run_imports_for(
202    index: &SharedIndex,
203    args: &[VmValue],
204) -> Result<VmValue, HostlibError> {
205    let raw = dict_arg(BUILTIN_IMPORTS_FOR, args)?;
206    let dict = raw.as_ref();
207    let path = require_string(BUILTIN_IMPORTS_FOR, dict, "path")?;
208    let guard = index.lock().expect("code_index mutex poisoned");
209    let Some(state) = guard.as_ref() else {
210        return Ok(empty_imports_response(&path));
211    };
212    let Some(file_id) = state.lookup_path(&path) else {
213        return Ok(empty_imports_response(&path));
214    };
215    let Some(file) = state.files.get(&file_id) else {
216        return Ok(empty_imports_response(&path));
217    };
218    let kind = imports::import_kind(&file.language).to_string();
219    let base_dir = imports::parent_dir(&file.relative_path);
220    let resolved_ids: HashSet<FileId> = state.deps.imports_of(file_id).into_iter().collect();
221    let mut entries: Vec<VmValue> = Vec::with_capacity(file.imports.len());
222    for raw_import in &file.imports {
223        let resolved_path =
224            imports::resolve_module(raw_import, &file.language, &base_dir, &state.path_to_id)
225                .filter(|id| resolved_ids.contains(id))
226                .and_then(|id| state.files.get(&id).map(|f| f.relative_path.clone()));
227        entries.push(import_entry(raw_import, resolved_path.as_deref(), &kind));
228    }
229    Ok(build_dict([
230        ("path", str_value(&file.relative_path)),
231        ("imports", VmValue::List(Rc::new(entries))),
232    ]))
233}
234
235pub(super) fn run_importers_of(
236    index: &SharedIndex,
237    args: &[VmValue],
238) -> Result<VmValue, HostlibError> {
239    let raw = dict_arg(BUILTIN_IMPORTERS_OF, args)?;
240    let dict = raw.as_ref();
241    let module = require_string(BUILTIN_IMPORTERS_OF, dict, "module")?;
242    let guard = index.lock().expect("code_index mutex poisoned");
243    let Some(state) = guard.as_ref() else {
244        return Ok(empty_importers_response(&module));
245    };
246
247    let target_id = state.lookup_path(&module).or_else(|| {
248        // Fallback: suffix-match on relative paths so callers can request
249        // by basename (matching the `allowSuffixMatch` convention used by
250        // the resolver itself).
251        let needle = format!("/{module}");
252        state
253            .path_to_id
254            .iter()
255            .find(|(p, _)| p.ends_with(&needle) || *p == &module)
256            .map(|(_, id)| *id)
257    });
258
259    let mut importers: Vec<String> = match target_id {
260        Some(id) => state
261            .deps
262            .importers_of(id)
263            .into_iter()
264            .filter_map(|importer_id| {
265                state
266                    .files
267                    .get(&importer_id)
268                    .map(|f| f.relative_path.clone())
269            })
270            .collect(),
271        None => Vec::new(),
272    };
273    importers.sort();
274    Ok(build_dict([
275        ("module", str_value(&module)),
276        (
277            "importers",
278            VmValue::List(Rc::new(importers.into_iter().map(str_value).collect())),
279        ),
280    ]))
281}
282
283// === File table accessors ===
284
285pub(super) fn run_path_to_id(
286    index: &SharedIndex,
287    args: &[VmValue],
288) -> Result<VmValue, HostlibError> {
289    let raw = dict_arg(BUILTIN_PATH_TO_ID, args)?;
290    let path = require_string(BUILTIN_PATH_TO_ID, raw.as_ref(), "path")?;
291    let guard = index.lock().expect("code_index mutex poisoned");
292    let id = guard.as_ref().and_then(|s| s.lookup_path(&path));
293    Ok(match id {
294        Some(id) => VmValue::Int(id as i64),
295        None => VmValue::Nil,
296    })
297}
298
299pub(super) fn run_id_to_path(
300    index: &SharedIndex,
301    args: &[VmValue],
302) -> Result<VmValue, HostlibError> {
303    let raw = dict_arg(BUILTIN_ID_TO_PATH, args)?;
304    let id = require_int(BUILTIN_ID_TO_PATH, raw.as_ref(), "file_id")? as FileId;
305    let guard = index.lock().expect("code_index mutex poisoned");
306    let path = guard
307        .as_ref()
308        .and_then(|s| s.files.get(&id))
309        .map(|f| f.relative_path.clone());
310    Ok(match path {
311        Some(p) => str_value(&p),
312        None => VmValue::Nil,
313    })
314}
315
316pub(super) fn run_file_ids(
317    index: &SharedIndex,
318    _args: &[VmValue],
319) -> Result<VmValue, HostlibError> {
320    let guard = index.lock().expect("code_index mutex poisoned");
321    let mut ids: Vec<FileId> = guard
322        .as_ref()
323        .map(|s| s.files.keys().copied().collect())
324        .unwrap_or_default();
325    ids.sort_unstable();
326    Ok(VmValue::List(Rc::new(
327        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
328    )))
329}
330
331pub(super) fn run_file_meta(
332    index: &SharedIndex,
333    args: &[VmValue],
334) -> Result<VmValue, HostlibError> {
335    let raw = dict_arg(BUILTIN_FILE_META, args)?;
336    let dict = raw.as_ref();
337    let guard = index.lock().expect("code_index mutex poisoned");
338    let Some(state) = guard.as_ref() else {
339        return Ok(VmValue::Nil);
340    };
341    let id_opt: Option<FileId> = if let Some(VmValue::Int(n)) = dict.get("file_id") {
342        Some(*n as FileId)
343    } else if let Some(VmValue::String(p)) = dict.get("path") {
344        state.lookup_path(p)
345    } else {
346        return Err(HostlibError::MissingParameter {
347            builtin: BUILTIN_FILE_META,
348            param: "file_id|path",
349        });
350    };
351    let Some(id) = id_opt else {
352        return Ok(VmValue::Nil);
353    };
354    let Some(file) = state.files.get(&id) else {
355        return Ok(VmValue::Nil);
356    };
357    let last_edit_seq = state
358        .versions
359        .last_entry(&file.relative_path)
360        .map(|e| e.seq)
361        .unwrap_or(0);
362    Ok(build_dict([
363        ("id", VmValue::Int(file.id as i64)),
364        ("path", str_value(&file.relative_path)),
365        ("language", str_value(&file.language)),
366        ("size", VmValue::Int(file.size_bytes as i64)),
367        ("line_count", VmValue::Int(file.line_count as i64)),
368        ("hash", str_value(file.content_hash.to_string())),
369        ("mtime_ms", VmValue::Int(file.mtime_ms)),
370        ("last_edit_seq", VmValue::Int(last_edit_seq as i64)),
371    ]))
372}
373
374pub(super) fn run_file_hash(
375    index: &SharedIndex,
376    args: &[VmValue],
377) -> Result<VmValue, HostlibError> {
378    let raw = dict_arg(BUILTIN_FILE_HASH, args)?;
379    let path = require_string(BUILTIN_FILE_HASH, raw.as_ref(), "path")?;
380    let guard = index.lock().expect("code_index mutex poisoned");
381    let Some(state) = guard.as_ref() else {
382        return Ok(VmValue::Nil);
383    };
384    let Some(abs) = state.absolute_path(&path) else {
385        return Ok(VmValue::Nil);
386    };
387    let bytes = match crate::fs::read(&abs, None) {
388        Some(result) => result,
389        None => std::fs::read(&abs),
390    };
391    match bytes {
392        Ok(bytes) => Ok(str_value(fnv1a64(&bytes).to_string())),
393        Err(_) => Ok(VmValue::Nil),
394    }
395}
396
397// === Cached reads ===
398
399pub(super) fn run_read_range(
400    index: &SharedIndex,
401    args: &[VmValue],
402) -> Result<VmValue, HostlibError> {
403    let raw = dict_arg(BUILTIN_READ_RANGE, args)?;
404    let dict = raw.as_ref();
405    let path = require_string(BUILTIN_READ_RANGE, dict, "path")?;
406    let start = match dict.get("start") {
407        None | Some(VmValue::Nil) => None,
408        Some(VmValue::Int(n)) => Some(*n),
409        Some(other) => {
410            return Err(HostlibError::InvalidParameter {
411                builtin: BUILTIN_READ_RANGE,
412                param: "start",
413                message: format!("expected integer, got {}", other.type_name()),
414            });
415        }
416    };
417    let end = match dict.get("end") {
418        None | Some(VmValue::Nil) => None,
419        Some(VmValue::Int(n)) => Some(*n),
420        Some(other) => {
421            return Err(HostlibError::InvalidParameter {
422                builtin: BUILTIN_READ_RANGE,
423                param: "end",
424                message: format!("expected integer, got {}", other.type_name()),
425            });
426        }
427    };
428    let guard = index.lock().expect("code_index mutex poisoned");
429    let abs = match guard.as_ref() {
430        Some(state) => {
431            state
432                .absolute_path(&path)
433                .ok_or_else(|| HostlibError::InvalidParameter {
434                    builtin: BUILTIN_READ_RANGE,
435                    param: "path",
436                    message: "path must stay within the indexed workspace root".to_string(),
437                })?
438        }
439        None => PathBuf::from(&path),
440    };
441    drop(guard);
442
443    let content_result = match crate::fs::read_to_string(&abs, None) {
444        Some(result) => result,
445        None => std::fs::read_to_string(&abs),
446    };
447    let content = match content_result {
448        Ok(s) => s,
449        Err(_) => {
450            return Err(HostlibError::Backend {
451                builtin: BUILTIN_READ_RANGE,
452                message: format!("file not found: {path}"),
453            })
454        }
455    };
456
457    if start.is_none() && end.is_none() {
458        return Ok(build_dict([("content", str_value(&content))]));
459    }
460    let lines: Vec<&str> = content.split('\n').collect();
461    let total = lines.len() as i64;
462    let lo = (start.unwrap_or(1) - 1).max(0) as usize;
463    let hi = end.unwrap_or(total).min(total).max(0) as usize;
464    if lo >= hi {
465        return Ok(build_dict([
466            ("content", str_value("")),
467            ("start", VmValue::Int((lo as i64) + 1)),
468            ("end", VmValue::Int(hi as i64)),
469        ]));
470    }
471    let slice = lines[lo..hi].join("\n");
472    Ok(build_dict([
473        ("content", str_value(&slice)),
474        ("start", VmValue::Int((lo as i64) + 1)),
475        ("end", VmValue::Int(hi as i64)),
476    ]))
477}
478
479pub(super) fn run_reindex_file(
480    index: &SharedIndex,
481    args: &[VmValue],
482) -> Result<VmValue, HostlibError> {
483    let raw = dict_arg(BUILTIN_REINDEX_FILE, args)?;
484    let path = require_string(BUILTIN_REINDEX_FILE, raw.as_ref(), "path")?;
485    let mut guard = index.lock().expect("code_index mutex poisoned");
486    let Some(state) = guard.as_mut() else {
487        return Ok(build_dict([
488            ("indexed", VmValue::Bool(false)),
489            ("file_id", VmValue::Nil),
490        ]));
491    };
492    let Some(abs) = state.absolute_path(&path) else {
493        return Err(HostlibError::InvalidParameter {
494            builtin: BUILTIN_REINDEX_FILE,
495            param: "path",
496            message: "path must stay within the indexed workspace root".to_string(),
497        });
498    };
499    let id = state.reindex_file(&abs);
500    Ok(build_dict([
501        ("indexed", VmValue::Bool(id.is_some())),
502        (
503            "file_id",
504            id.map(|i| VmValue::Int(i as i64)).unwrap_or(VmValue::Nil),
505        ),
506    ]))
507}
508
509pub(super) fn run_trigram_query(
510    index: &SharedIndex,
511    args: &[VmValue],
512) -> Result<VmValue, HostlibError> {
513    let raw = dict_arg(BUILTIN_TRIGRAM_QUERY, args)?;
514    let dict = raw.as_ref();
515    let trigrams_raw = optional_int_list(BUILTIN_TRIGRAM_QUERY, dict, "trigrams")?;
516    let max_files = match dict.get("max_files") {
517        None | Some(VmValue::Nil) => None,
518        Some(VmValue::Int(n)) => Some(*n as usize),
519        Some(other) => {
520            return Err(HostlibError::InvalidParameter {
521                builtin: BUILTIN_TRIGRAM_QUERY,
522                param: "max_files",
523                message: format!("expected integer, got {}", other.type_name()),
524            })
525        }
526    };
527    let trigrams: Vec<u32> = trigrams_raw.into_iter().map(|n| n as u32).collect();
528    let guard = index.lock().expect("code_index mutex poisoned");
529    let mut ids: Vec<FileId> = match guard.as_ref() {
530        Some(state) => state.trigrams.query(&trigrams).into_iter().collect(),
531        None => Vec::new(),
532    };
533    ids.sort_unstable();
534    if let Some(limit) = max_files {
535        ids.truncate(limit);
536    }
537    Ok(VmValue::List(Rc::new(
538        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
539    )))
540}
541
542pub(super) fn run_extract_trigrams(
543    _index: &SharedIndex,
544    args: &[VmValue],
545) -> Result<VmValue, HostlibError> {
546    let raw = dict_arg(BUILTIN_EXTRACT_TRIGRAMS, args)?;
547    let query = require_string(BUILTIN_EXTRACT_TRIGRAMS, raw.as_ref(), "query")?;
548    let mut tgs = trigram::query_trigrams(&query);
549    tgs.sort_unstable();
550    Ok(VmValue::List(Rc::new(
551        tgs.into_iter().map(|n| VmValue::Int(n as i64)).collect(),
552    )))
553}
554
555pub(super) fn run_word_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
556    let raw = dict_arg(BUILTIN_WORD_GET, args)?;
557    let word = require_string(BUILTIN_WORD_GET, raw.as_ref(), "word")?;
558    let guard = index.lock().expect("code_index mutex poisoned");
559    let hits: Vec<VmValue> = match guard.as_ref() {
560        Some(state) => state
561            .words
562            .get(&word)
563            .iter()
564            .map(|h| {
565                build_dict([
566                    ("file_id", VmValue::Int(h.file as i64)),
567                    ("line", VmValue::Int(h.line as i64)),
568                ])
569            })
570            .collect(),
571        None => Vec::new(),
572    };
573    Ok(VmValue::List(Rc::new(hits)))
574}
575
576pub(super) fn run_deps_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
577    let raw = dict_arg(BUILTIN_DEPS_GET, args)?;
578    let dict = raw.as_ref();
579    let id = require_int(BUILTIN_DEPS_GET, dict, "file_id")? as FileId;
580    let direction = optional_string(BUILTIN_DEPS_GET, dict, "direction")?
581        .unwrap_or_else(|| "importers".to_string());
582    let guard = index.lock().expect("code_index mutex poisoned");
583    let mut neighbors: Vec<FileId> = match guard.as_ref() {
584        Some(state) => match direction.as_str() {
585            "importers" => state.deps.importers_of(id),
586            "imports" => state.deps.imports_of(id),
587            _ => {
588                return Err(HostlibError::InvalidParameter {
589                    builtin: BUILTIN_DEPS_GET,
590                    param: "direction",
591                    message: format!("expected \"importers\" or \"imports\", got {direction:?}"),
592                })
593            }
594        },
595        None => Vec::new(),
596    };
597    neighbors.sort_unstable();
598    Ok(VmValue::List(Rc::new(
599        neighbors
600            .into_iter()
601            .map(|id| VmValue::Int(id as i64))
602            .collect(),
603    )))
604}
605
606pub(super) fn run_outline_get(
607    index: &SharedIndex,
608    args: &[VmValue],
609) -> Result<VmValue, HostlibError> {
610    let raw = dict_arg(BUILTIN_OUTLINE_GET, args)?;
611    let id = require_int(BUILTIN_OUTLINE_GET, raw.as_ref(), "file_id")? as FileId;
612    let guard = index.lock().expect("code_index mutex poisoned");
613    let symbols: Vec<VmValue> = match guard.as_ref().and_then(|s| s.files.get(&id)) {
614        Some(file) => file
615            .symbols
616            .iter()
617            .map(|sym| {
618                build_dict([
619                    ("name", str_value(&sym.name)),
620                    ("kind", str_value(&sym.kind)),
621                    ("start_line", VmValue::Int(sym.start_line as i64)),
622                    ("end_line", VmValue::Int(sym.end_line as i64)),
623                    ("signature", str_value(&sym.signature)),
624                ])
625            })
626            .collect(),
627        None => Vec::new(),
628    };
629    Ok(VmValue::List(Rc::new(symbols)))
630}
631
632// === Change log ===
633
634pub(super) fn run_current_seq(
635    index: &SharedIndex,
636    _args: &[VmValue],
637) -> Result<VmValue, HostlibError> {
638    let guard = index.lock().expect("code_index mutex poisoned");
639    let seq = guard.as_ref().map(|s| s.versions.current_seq).unwrap_or(0);
640    Ok(VmValue::Int(seq as i64))
641}
642
643pub(super) fn run_changes_since(
644    index: &SharedIndex,
645    args: &[VmValue],
646) -> Result<VmValue, HostlibError> {
647    let raw = dict_arg(BUILTIN_CHANGES_SINCE, args)?;
648    let dict = raw.as_ref();
649    let seq = optional_int(BUILTIN_CHANGES_SINCE, dict, "seq", 0)?.max(0) as u64;
650    let limit = match dict.get("limit") {
651        None | Some(VmValue::Nil) => None,
652        Some(VmValue::Int(n)) => Some(*n as usize),
653        Some(other) => {
654            return Err(HostlibError::InvalidParameter {
655                builtin: BUILTIN_CHANGES_SINCE,
656                param: "limit",
657                message: format!("expected integer, got {}", other.type_name()),
658            })
659        }
660    };
661    let guard = index.lock().expect("code_index mutex poisoned");
662    let records = match guard.as_ref() {
663        Some(state) => state.versions.changes_since(seq, limit),
664        None => Vec::new(),
665    };
666    Ok(VmValue::List(Rc::new(
667        records
668            .into_iter()
669            .map(|r| {
670                build_dict([
671                    ("path", str_value(&r.path)),
672                    ("seq", VmValue::Int(r.seq as i64)),
673                    ("agent_id", VmValue::Int(r.agent_id as i64)),
674                    ("op", str_value(r.op.as_str())),
675                    ("hash", str_value(r.hash.to_string())),
676                    ("size", VmValue::Int(r.size as i64)),
677                    ("timestamp_ms", VmValue::Int(r.timestamp_ms)),
678                ])
679            })
680            .collect(),
681    )))
682}
683
684pub(super) fn run_version_record(
685    index: &SharedIndex,
686    args: &[VmValue],
687) -> Result<VmValue, HostlibError> {
688    let raw = dict_arg(BUILTIN_VERSION_RECORD, args)?;
689    let dict = raw.as_ref();
690    let agent_id = require_int(BUILTIN_VERSION_RECORD, dict, "agent_id")? as AgentId;
691    let path = require_string(BUILTIN_VERSION_RECORD, dict, "path")?;
692    let op_str =
693        optional_string(BUILTIN_VERSION_RECORD, dict, "op")?.unwrap_or_else(|| "write".to_string());
694    let op = EditOp::parse(&op_str).unwrap_or(EditOp::Write);
695    let hash = parse_hash(BUILTIN_VERSION_RECORD, dict, "hash")?;
696    let size = optional_int(BUILTIN_VERSION_RECORD, dict, "size", 0)?.max(0) as u64;
697    let now = now_unix_ms();
698    let mut guard = index.lock().expect("code_index mutex poisoned");
699    let state = ensure_state(BUILTIN_VERSION_RECORD, &mut guard)?;
700    let normalized = normalize_relative_path(state, &path);
701    let seq = state
702        .versions
703        .record(normalized, agent_id, op, hash, size, now);
704    state.agents.note_edit(agent_id, now);
705    Ok(VmValue::Int(seq as i64))
706}
707
708// === Agent registry + locks ===
709
710pub(super) fn run_agent_register(
711    index: &SharedIndex,
712    args: &[VmValue],
713) -> Result<VmValue, HostlibError> {
714    let raw = dict_arg(BUILTIN_AGENT_REGISTER, args)?;
715    let dict = raw.as_ref();
716    let name = optional_string(BUILTIN_AGENT_REGISTER, dict, "name")?
717        .unwrap_or_else(|| "agent".to_string());
718    let requested_id = match dict.get("agent_id") {
719        None | Some(VmValue::Nil) => None,
720        Some(VmValue::Int(n)) => Some(*n as AgentId),
721        Some(other) => {
722            return Err(HostlibError::InvalidParameter {
723                builtin: BUILTIN_AGENT_REGISTER,
724                param: "agent_id",
725                message: format!("expected integer, got {}", other.type_name()),
726            })
727        }
728    };
729    let now = now_unix_ms();
730    let mut guard = index.lock().expect("code_index mutex poisoned");
731    let state = ensure_state(BUILTIN_AGENT_REGISTER, &mut guard)?;
732    let id = match requested_id {
733        Some(id) => state.agents.register_with_id(id, name, now),
734        None => state.agents.register(name, now),
735    };
736    Ok(VmValue::Int(id as i64))
737}
738
739pub(super) fn run_agent_heartbeat(
740    index: &SharedIndex,
741    args: &[VmValue],
742) -> Result<VmValue, HostlibError> {
743    let raw = dict_arg(BUILTIN_AGENT_HEARTBEAT, args)?;
744    let id = require_int(BUILTIN_AGENT_HEARTBEAT, raw.as_ref(), "agent_id")? as AgentId;
745    let now = now_unix_ms();
746    let mut guard = index.lock().expect("code_index mutex poisoned");
747    let state = ensure_state(BUILTIN_AGENT_HEARTBEAT, &mut guard)?;
748    state.agents.heartbeat(id, now);
749    Ok(VmValue::Bool(true))
750}
751
752pub(super) fn run_agent_unregister(
753    index: &SharedIndex,
754    args: &[VmValue],
755) -> Result<VmValue, HostlibError> {
756    let raw = dict_arg(BUILTIN_AGENT_UNREGISTER, args)?;
757    let id = require_int(BUILTIN_AGENT_UNREGISTER, raw.as_ref(), "agent_id")? as AgentId;
758    let mut guard = index.lock().expect("code_index mutex poisoned");
759    let state = ensure_state(BUILTIN_AGENT_UNREGISTER, &mut guard)?;
760    state.agents.unregister(id);
761    Ok(VmValue::Bool(true))
762}
763
764pub(super) fn run_lock_try(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
765    let raw = dict_arg(BUILTIN_LOCK_TRY, args)?;
766    let dict = raw.as_ref();
767    let agent_id = require_int(BUILTIN_LOCK_TRY, dict, "agent_id")? as AgentId;
768    let path = require_string(BUILTIN_LOCK_TRY, dict, "path")?;
769    let ttl = match dict.get("ttl_ms") {
770        None | Some(VmValue::Nil) => None,
771        Some(VmValue::Int(n)) => Some(*n),
772        Some(other) => {
773            return Err(HostlibError::InvalidParameter {
774                builtin: BUILTIN_LOCK_TRY,
775                param: "ttl_ms",
776                message: format!("expected integer, got {}", other.type_name()),
777            })
778        }
779    };
780    let now = now_unix_ms();
781    let mut guard = index.lock().expect("code_index mutex poisoned");
782    let state = ensure_state(BUILTIN_LOCK_TRY, &mut guard)?;
783    let granted = state.agents.try_lock(agent_id, &path, ttl, now);
784    if granted {
785        return Ok(build_dict([
786            ("locked", VmValue::Bool(true)),
787            ("holder", VmValue::Int(agent_id as i64)),
788        ]));
789    }
790    let holder = state.agents.lock_holder(&path, now);
791    Ok(build_dict([
792        ("locked", VmValue::Bool(false)),
793        (
794            "holder",
795            holder
796                .map(|id| VmValue::Int(id as i64))
797                .unwrap_or(VmValue::Nil),
798        ),
799    ]))
800}
801
802pub(super) fn run_lock_release(
803    index: &SharedIndex,
804    args: &[VmValue],
805) -> Result<VmValue, HostlibError> {
806    let raw = dict_arg(BUILTIN_LOCK_RELEASE, args)?;
807    let dict = raw.as_ref();
808    let agent_id = require_int(BUILTIN_LOCK_RELEASE, dict, "agent_id")? as AgentId;
809    let path = require_string(BUILTIN_LOCK_RELEASE, dict, "path")?;
810    let mut guard = index.lock().expect("code_index mutex poisoned");
811    let state = ensure_state(BUILTIN_LOCK_RELEASE, &mut guard)?;
812    state.agents.release_lock(agent_id, &path);
813    Ok(VmValue::Bool(true))
814}
815
816pub(super) fn run_status(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
817    let guard = index.lock().expect("code_index mutex poisoned");
818    match guard.as_ref() {
819        Some(state) => Ok(build_dict([
820            ("file_count", VmValue::Int(state.files.len() as i64)),
821            (
822                "current_seq",
823                VmValue::Int(state.versions.current_seq as i64),
824            ),
825            ("last_indexed_at_ms", VmValue::Int(state.last_built_unix_ms)),
826            (
827                "git_head",
828                state
829                    .git_head
830                    .as_deref()
831                    .map(str_value)
832                    .unwrap_or(VmValue::Nil),
833            ),
834            (
835                "agents",
836                VmValue::List(Rc::new(
837                    state
838                        .agents
839                        .agents()
840                        .map(|info| {
841                            build_dict([
842                                ("id", VmValue::Int(info.id as i64)),
843                                ("name", str_value(&info.name)),
844                                (
845                                    "state",
846                                    str_value(match info.state {
847                                        super::agents::AgentState::Active => "active",
848                                        super::agents::AgentState::Crashed => "crashed",
849                                        super::agents::AgentState::Gone => "gone",
850                                    }),
851                                ),
852                                ("last_seen_ms", VmValue::Int(info.last_seen_ms)),
853                                ("edit_count", VmValue::Int(info.edit_count as i64)),
854                                ("lock_count", VmValue::Int(info.locked_paths.len() as i64)),
855                            ])
856                        })
857                        .collect(),
858                )),
859            ),
860        ])),
861        None => Ok(build_dict([
862            ("file_count", VmValue::Int(0)),
863            ("current_seq", VmValue::Int(0)),
864            ("last_indexed_at_ms", VmValue::Int(0)),
865            ("git_head", VmValue::Nil),
866            ("agents", VmValue::List(Rc::new(Vec::new()))),
867        ])),
868    }
869}
870
871pub(super) fn run_current_agent_id(
872    slot: &Arc<Mutex<Option<AgentId>>>,
873    _args: &[VmValue],
874) -> Result<VmValue, HostlibError> {
875    let guard = slot.lock().expect("current_agent slot poisoned");
876    Ok(match *guard {
877        Some(id) => VmValue::Int(id as i64),
878        None => VmValue::Nil,
879    })
880}
881
882// === Helpers ===
883
884fn ensure_state<'a>(
885    builtin: &'static str,
886    guard: &'a mut std::sync::MutexGuard<'_, Option<IndexState>>,
887) -> Result<&'a mut IndexState, HostlibError> {
888    if guard.is_none() {
889        return Err(HostlibError::Backend {
890            builtin,
891            message: "code index has not been initialised — call \
892                 `hostlib_code_index_rebuild` or restore from a snapshot first"
893                .to_string(),
894        });
895    }
896    Ok(guard.as_mut().unwrap())
897}
898
899fn parse_hash(
900    builtin: &'static str,
901    dict: &BTreeMap<String, VmValue>,
902    key: &'static str,
903) -> Result<u64, HostlibError> {
904    match dict.get(key) {
905        None | Some(VmValue::Nil) => Ok(0),
906        Some(VmValue::Int(n)) => Ok(*n as u64),
907        Some(VmValue::String(s)) => s
908            .parse::<u64>()
909            .map_err(|_| HostlibError::InvalidParameter {
910                builtin,
911                param: key,
912                message: format!("expected u64-parseable string, got {s:?}"),
913            }),
914        Some(other) => Err(HostlibError::InvalidParameter {
915            builtin,
916            param: key,
917            message: format!(
918                "expected integer or numeric string, got {}",
919                other.type_name()
920            ),
921        }),
922    }
923}
924
925fn normalize_relative_path(state: &IndexState, path: &str) -> String {
926    if let Some(rel) = state
927        .lookup_path(path)
928        .and_then(|id| state.files.get(&id))
929        .map(|f| f.relative_path.clone())
930    {
931        return rel;
932    }
933    let p = std::path::Path::new(path);
934    if p.is_absolute() {
935        if let Ok(rel) = p.strip_prefix(&state.root) {
936            return rel.to_string_lossy().replace('\\', "/");
937        }
938    }
939    path.to_string()
940}
941
942fn candidates_for(state: &IndexState, needle: &str) -> Vec<FileId> {
943    if needle.len() >= 3 {
944        let trigrams = trigram::query_trigrams(needle);
945        return state.trigrams.query(&trigrams).into_iter().collect();
946    }
947    state.files.keys().copied().collect()
948}
949
950fn read_file_text(root: &std::path::Path, relative: &str) -> Option<String> {
951    let path = root.join(relative);
952    match crate::fs::read_to_string(&path, None) {
953        Some(result) => result.ok(),
954        None => std::fs::read_to_string(path).ok(),
955    }
956}
957
958fn count_matches(haystack: &str, needle: &str, case_sensitive: bool) -> u64 {
959    if case_sensitive {
960        haystack.matches(needle).count() as u64
961    } else {
962        let lower_h = haystack.to_lowercase();
963        let lower_n = needle.to_lowercase();
964        lower_h.matches(&lower_n).count() as u64
965    }
966}
967
968fn scope_allows(scope: &[String], relative: &str) -> bool {
969    if scope.is_empty() {
970        return true;
971    }
972    scope
973        .iter()
974        .any(|s| relative == s || relative.starts_with(&format!("{s}/")) || s.is_empty())
975}
976
977struct Hit {
978    path: String,
979    score: f64,
980    match_count: u64,
981}
982
983fn hit_to_value(hit: Hit) -> VmValue {
984    let Hit {
985        path,
986        score,
987        match_count,
988    } = hit;
989    build_dict([
990        ("path", str_value(&path)),
991        ("score", VmValue::Float(score)),
992        ("match_count", VmValue::Int(match_count as i64)),
993    ])
994}
995
996fn import_entry(module: &str, resolved: Option<&str>, kind: &str) -> VmValue {
997    let mut map: BTreeMap<String, VmValue> = BTreeMap::new();
998    map.insert("module".into(), str_value(module));
999    map.insert(
1000        "resolved_path".into(),
1001        match resolved {
1002            Some(p) => str_value(p),
1003            None => VmValue::Nil,
1004        },
1005    );
1006    map.insert("kind".into(), str_value(kind));
1007    VmValue::Dict(Rc::new(map))
1008}
1009
1010fn empty_query_response() -> VmValue {
1011    build_dict([
1012        ("results", VmValue::List(Rc::new(Vec::new()))),
1013        ("truncated", VmValue::Bool(false)),
1014    ])
1015}
1016
1017fn empty_stats_response() -> VmValue {
1018    build_dict([
1019        ("indexed_files", VmValue::Int(0)),
1020        ("trigrams", VmValue::Int(0)),
1021        ("words", VmValue::Int(0)),
1022        ("memory_bytes", VmValue::Int(0)),
1023        ("last_rebuild_unix_ms", VmValue::Nil),
1024    ])
1025}
1026
1027fn empty_imports_response(path: &str) -> VmValue {
1028    build_dict([
1029        ("path", str_value(path)),
1030        ("imports", VmValue::List(Rc::new(Vec::new()))),
1031    ])
1032}
1033
1034fn empty_importers_response(module: &str) -> VmValue {
1035    build_dict([
1036        ("module", str_value(module)),
1037        ("importers", VmValue::List(Rc::new(Vec::new()))),
1038    ])
1039}