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