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::HashSet;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13use std::time::Instant;
14
15use harn_vm::VmValue;
16
17use super::agents::AgentId;
18use super::file_table::{fnv1a64, FileId};
19use super::imports;
20use super::state::{now_unix_ms, IndexState};
21use super::trigram;
22use super::versions::EditOp;
23use crate::error::HostlibError;
24use crate::tools::args::{
25    build_dict, dict_arg, optional_bool, optional_int_list, optional_string, optional_string_list,
26    require_string, str_value,
27};
28use crate::value_args;
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
76pub(super) const BUILTIN_CYPHER: &str = "hostlib_code_index_cypher";
77pub(super) const BUILTIN_BRANCH_OVERLAY: &str = "hostlib_code_index_branch_overlay";
78pub(super) const BUILTIN_FRESHNESS: &str = "hostlib_code_index_freshness";
79
80// === Search / rebuild / stats ===
81
82/// Shared body for `query`. When `readonly` is supplied, hits from every
83/// read-only secondary root (issue #2403 follow-up) are merged in after the
84/// primary index so library/dependency symbols are discoverable without
85/// clobbering the project index. Primary hits keep `root: nil`; read-only
86/// hits carry the absolute path of their dependency root.
87pub(super) fn run_query_merged(
88    index: &SharedIndex,
89    readonly: Option<&super::readonly::ReadonlyRoots>,
90    args: &[VmValue],
91) -> Result<VmValue, HostlibError> {
92    let raw = dict_arg(BUILTIN_QUERY, args)?;
93    let dict = raw.as_ref();
94    let needle = require_string(BUILTIN_QUERY, dict, "needle")?;
95    if needle.is_empty() {
96        return Err(HostlibError::InvalidParameter {
97            builtin: BUILTIN_QUERY,
98            param: "needle",
99            message: "must not be empty".to_string(),
100        });
101    }
102    let case_sensitive = optional_bool(BUILTIN_QUERY, dict, "case_sensitive", false)?;
103    let max_results = optional_positive_usize(BUILTIN_QUERY, dict, "max_results")?.unwrap_or(100);
104    let scope = optional_string_list(BUILTIN_QUERY, dict, "scope")?;
105
106    let mut hits: Vec<Hit> = Vec::new();
107    {
108        let guard = index.lock().expect("code_index mutex poisoned");
109        if let Some(state) = guard.as_ref() {
110            collect_hits_scoped(state, &needle, case_sensitive, &scope, &mut hits);
111        }
112    }
113    if let Some(readonly) = readonly {
114        // Dependency roots ignore `scope` (it is a project-relative
115        // restriction) — they are an additive symbol-discovery surface.
116        if scope.is_empty() {
117            hits.extend(super::readonly::query_readonly_hits(
118                readonly,
119                &needle,
120                case_sensitive,
121            ));
122        }
123    }
124
125    hits.sort_by(|a, b| {
126        b.match_count
127            .cmp(&a.match_count)
128            .then_with(|| a.path.cmp(&b.path))
129    });
130    let truncated = hits.len() > max_results;
131    if truncated {
132        hits.truncate(max_results);
133    }
134    Ok(build_dict([
135        (
136            "results",
137            VmValue::List(Arc::new(hits.into_iter().map(hit_to_value).collect())),
138        ),
139        ("truncated", VmValue::Bool(truncated)),
140    ]))
141}
142
143/// Score `needle` against every file in `state`, honoring `scope`, and push
144/// matching files onto `hits`. Hits are tagged with the index's root only
145/// when it is a read-only secondary root (the caller passes the primary
146/// index with an empty `scope` to leave `root: nil`).
147fn collect_hits_scoped(
148    state: &IndexState,
149    needle: &str,
150    case_sensitive: bool,
151    scope: &[String],
152    hits: &mut Vec<Hit>,
153) {
154    let candidate_ids = candidates_for(state, needle);
155    for id in candidate_ids {
156        let Some(file) = state.files.get(&id) else {
157            continue;
158        };
159        if !scope_allows(scope, &file.relative_path) {
160            continue;
161        }
162        let Some(text) = read_file_text(&state.root, &file.relative_path) else {
163            continue;
164        };
165        let count = count_matches(&text, needle, case_sensitive);
166        if count == 0 {
167            continue;
168        }
169        hits.push(Hit {
170            path: file.relative_path.clone(),
171            match_count: count,
172            root: None,
173        });
174    }
175}
176
177/// Read-only entry point used by [`super::readonly::query_readonly_hits`]:
178/// score `needle` against `state` (a dependency root) with no scope filter
179/// and tag every hit with the root's absolute path.
180pub(super) fn collect_hits_into(
181    state: &IndexState,
182    needle: &str,
183    case_sensitive: bool,
184    hits: &mut Vec<Hit>,
185) {
186    let before = hits.len();
187    collect_hits_scoped(state, needle, case_sensitive, &[], hits);
188    let root = state.root.to_string_lossy().to_string();
189    for hit in &mut hits[before..] {
190        hit.root = Some(root.clone());
191    }
192}
193
194pub(super) fn run_rebuild(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
195    let raw = dict_arg(BUILTIN_REBUILD, args)?;
196    let dict = raw.as_ref();
197    let _force = optional_bool(BUILTIN_REBUILD, dict, "force", false)?;
198    let root = optional_string(BUILTIN_REBUILD, dict, "root")?
199        .map(PathBuf::from)
200        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
201    if !root.exists() {
202        return Err(HostlibError::InvalidParameter {
203            builtin: BUILTIN_REBUILD,
204            param: "root",
205            message: format!("path `{}` does not exist", root.display()),
206        });
207    }
208    if !root.is_dir() {
209        return Err(HostlibError::InvalidParameter {
210            builtin: BUILTIN_REBUILD,
211            param: "root",
212            message: format!("path `{}` is not a directory", root.display()),
213        });
214    }
215    let started = Instant::now();
216    let (state, outcome) = IndexState::build_from_root(&root);
217    let elapsed_ms = started.elapsed().as_millis() as i64;
218    {
219        let mut guard = index.lock().expect("code_index mutex poisoned");
220        *guard = Some(state);
221    }
222    Ok(build_dict([
223        ("files_indexed", VmValue::Int(outcome.files_indexed as i64)),
224        ("files_skipped", VmValue::Int(outcome.files_skipped as i64)),
225        ("elapsed_ms", VmValue::Int(elapsed_ms)),
226    ]))
227}
228
229pub(super) fn run_stats(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
230    let guard = index.lock().expect("code_index mutex poisoned");
231    let Some(state) = guard.as_ref() else {
232        return Ok(empty_stats_response());
233    };
234    Ok(build_dict([
235        ("indexed_files", VmValue::Int(state.files.len() as i64)),
236        (
237            "trigrams",
238            VmValue::Int(state.trigrams.distinct_trigrams() as i64),
239        ),
240        ("words", VmValue::Int(state.words.distinct_words() as i64)),
241        ("memory_bytes", VmValue::Int(state.estimated_bytes() as i64)),
242        (
243            "last_rebuild_unix_ms",
244            VmValue::Int(state.last_built_unix_ms),
245        ),
246    ]))
247}
248
249pub(super) fn run_imports_for(
250    index: &SharedIndex,
251    args: &[VmValue],
252) -> Result<VmValue, HostlibError> {
253    let raw = dict_arg(BUILTIN_IMPORTS_FOR, args)?;
254    let dict = raw.as_ref();
255    let path = require_string(BUILTIN_IMPORTS_FOR, dict, "path")?;
256    let guard = index.lock().expect("code_index mutex poisoned");
257    let Some(state) = guard.as_ref() else {
258        return Ok(empty_imports_response(&path));
259    };
260    let Some(file_id) = state.lookup_path(&path) else {
261        return Ok(empty_imports_response(&path));
262    };
263    let Some(file) = state.files.get(&file_id) else {
264        return Ok(empty_imports_response(&path));
265    };
266    let kind = imports::import_kind(&file.language).to_string();
267    let base_dir = imports::parent_dir(&file.relative_path);
268    let resolved_ids: HashSet<FileId> = state.deps.imports_of(file_id).into_iter().collect();
269    let mut entries: Vec<VmValue> = Vec::with_capacity(file.imports.len());
270    for raw_import in &file.imports {
271        let resolved_path =
272            imports::resolve_module(raw_import, &file.language, &base_dir, &state.path_to_id)
273                .filter(|id| resolved_ids.contains(id))
274                .and_then(|id| state.files.get(&id).map(|f| f.relative_path.clone()));
275        entries.push(import_entry(raw_import, resolved_path.as_deref(), &kind));
276    }
277    Ok(build_dict([
278        ("path", str_value(&file.relative_path)),
279        ("imports", VmValue::List(Arc::new(entries))),
280    ]))
281}
282
283pub(super) fn run_importers_of(
284    index: &SharedIndex,
285    args: &[VmValue],
286) -> Result<VmValue, HostlibError> {
287    let raw = dict_arg(BUILTIN_IMPORTERS_OF, args)?;
288    let dict = raw.as_ref();
289    let module = require_string(BUILTIN_IMPORTERS_OF, dict, "module")?;
290    let guard = index.lock().expect("code_index mutex poisoned");
291    let Some(state) = guard.as_ref() else {
292        return Ok(empty_importers_response(&module));
293    };
294
295    let target_id = state.lookup_path(&module).or_else(|| {
296        // Fallback: suffix-match on relative paths so callers can request
297        // by basename (matching the `allowSuffixMatch` convention used by
298        // the resolver itself).
299        let needle = format!("/{module}");
300        state
301            .path_to_id
302            .iter()
303            .find(|(p, _)| p.ends_with(&needle) || *p == &module)
304            .map(|(_, id)| *id)
305    });
306
307    let mut importers: Vec<String> = match target_id {
308        Some(id) => state
309            .deps
310            .importers_of(id)
311            .into_iter()
312            .filter_map(|importer_id| {
313                state
314                    .files
315                    .get(&importer_id)
316                    .map(|f| f.relative_path.clone())
317            })
318            .collect(),
319        None => Vec::new(),
320    };
321    importers.sort();
322    Ok(build_dict([
323        ("module", str_value(&module)),
324        (
325            "importers",
326            VmValue::List(Arc::new(importers.into_iter().map(str_value).collect())),
327        ),
328    ]))
329}
330
331// === File table accessors ===
332
333pub(super) fn run_path_to_id(
334    index: &SharedIndex,
335    args: &[VmValue],
336) -> Result<VmValue, HostlibError> {
337    let raw = dict_arg(BUILTIN_PATH_TO_ID, args)?;
338    let path = require_string(BUILTIN_PATH_TO_ID, raw.as_ref(), "path")?;
339    let guard = index.lock().expect("code_index mutex poisoned");
340    let id = guard.as_ref().and_then(|s| s.lookup_path(&path));
341    Ok(match id {
342        Some(id) => VmValue::Int(id as i64),
343        None => VmValue::Nil,
344    })
345}
346
347pub(super) fn run_id_to_path(
348    index: &SharedIndex,
349    args: &[VmValue],
350) -> Result<VmValue, HostlibError> {
351    let raw = dict_arg(BUILTIN_ID_TO_PATH, args)?;
352    let id = require_positive_file_id(BUILTIN_ID_TO_PATH, raw.as_ref(), "file_id")?;
353    let guard = index.lock().expect("code_index mutex poisoned");
354    let path = guard
355        .as_ref()
356        .and_then(|s| s.files.get(&id))
357        .map(|f| f.relative_path.clone());
358    Ok(match path {
359        Some(p) => str_value(&p),
360        None => VmValue::Nil,
361    })
362}
363
364pub(super) fn run_file_ids(
365    index: &SharedIndex,
366    _args: &[VmValue],
367) -> Result<VmValue, HostlibError> {
368    let guard = index.lock().expect("code_index mutex poisoned");
369    let mut ids: Vec<FileId> = guard
370        .as_ref()
371        .map(|s| s.files.keys().copied().collect())
372        .unwrap_or_default();
373    ids.sort_unstable();
374    Ok(VmValue::List(Arc::new(
375        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
376    )))
377}
378
379pub(super) fn run_file_meta(
380    index: &SharedIndex,
381    args: &[VmValue],
382) -> Result<VmValue, HostlibError> {
383    let raw = dict_arg(BUILTIN_FILE_META, args)?;
384    let dict = raw.as_ref();
385    let guard = index.lock().expect("code_index mutex poisoned");
386    let Some(state) = guard.as_ref() else {
387        return Ok(VmValue::Nil);
388    };
389    let id_opt: Option<FileId> = if dict.contains_key("file_id") {
390        Some(require_positive_file_id(
391            BUILTIN_FILE_META,
392            dict,
393            "file_id",
394        )?)
395    } else if let Some(VmValue::String(p)) = dict.get("path") {
396        state.lookup_path(p)
397    } else {
398        return Err(HostlibError::MissingParameter {
399            builtin: BUILTIN_FILE_META,
400            param: "file_id|path",
401        });
402    };
403    let Some(id) = id_opt else {
404        return Ok(VmValue::Nil);
405    };
406    let Some(file) = state.files.get(&id) else {
407        return Ok(VmValue::Nil);
408    };
409    let last_edit_seq = state
410        .versions
411        .last_entry(&file.relative_path)
412        .map(|e| e.seq)
413        .unwrap_or(0);
414    Ok(build_dict([
415        ("id", VmValue::Int(file.id as i64)),
416        ("path", str_value(&file.relative_path)),
417        ("language", str_value(&file.language)),
418        ("size", VmValue::Int(file.size_bytes as i64)),
419        ("line_count", VmValue::Int(file.line_count as i64)),
420        ("hash", str_value(file.content_hash.to_string())),
421        ("mtime_ms", VmValue::Int(file.mtime_ms)),
422        ("last_edit_seq", VmValue::Int(last_edit_seq as i64)),
423    ]))
424}
425
426pub(super) fn run_file_hash(
427    index: &SharedIndex,
428    args: &[VmValue],
429) -> Result<VmValue, HostlibError> {
430    let raw = dict_arg(BUILTIN_FILE_HASH, args)?;
431    let path = require_string(BUILTIN_FILE_HASH, raw.as_ref(), "path")?;
432    let guard = index.lock().expect("code_index mutex poisoned");
433    let Some(state) = guard.as_ref() else {
434        return Ok(VmValue::Nil);
435    };
436    let Some(abs) = state.absolute_path(&path) else {
437        return Ok(VmValue::Nil);
438    };
439    let bytes = match crate::fs::read(&abs, None) {
440        Some(result) => result,
441        None => std::fs::read(&abs),
442    };
443    match bytes {
444        Ok(bytes) => Ok(str_value(fnv1a64(&bytes).to_string())),
445        Err(_) => Ok(VmValue::Nil),
446    }
447}
448
449// === Cached reads ===
450
451/// Shared body for `read_range`. When `readonly` is supplied, a path that
452/// is not inside the primary workspace root is resolved against the
453/// read-only secondary roots (issue #2403 follow-up) so a symbol
454/// discovered in a dependency root can be read back. Resolution stays
455/// confined to a known indexed root in every case — arbitrary host paths
456/// are still rejected.
457pub(super) fn run_read_range_merged(
458    index: &SharedIndex,
459    readonly: Option<&super::readonly::ReadonlyRoots>,
460    args: &[VmValue],
461) -> Result<VmValue, HostlibError> {
462    let raw = dict_arg(BUILTIN_READ_RANGE, args)?;
463    let dict = raw.as_ref();
464    let path = require_string(BUILTIN_READ_RANGE, dict, "path")?;
465    let start = optional_positive_i64(BUILTIN_READ_RANGE, dict, "start")?;
466    let end = optional_positive_i64(BUILTIN_READ_RANGE, dict, "end")?;
467    let abs =
468        match readonly {
469            Some(readonly) => super::readonly::resolve_read_path(index, readonly, &path)
470                .ok_or_else(|| HostlibError::InvalidParameter {
471                    builtin: BUILTIN_READ_RANGE,
472                    param: "path",
473                    message: "path must stay within the indexed workspace root or a read-only \
474                          dependency root"
475                        .to_string(),
476                })?,
477            None => {
478                let guard = index.lock().expect("code_index mutex poisoned");
479                match guard.as_ref() {
480                    Some(state) => state.absolute_path(&path).ok_or_else(|| {
481                        HostlibError::InvalidParameter {
482                            builtin: BUILTIN_READ_RANGE,
483                            param: "path",
484                            message: "path must stay within the indexed workspace root".to_string(),
485                        }
486                    })?,
487                    None => PathBuf::from(&path),
488                }
489            }
490        };
491
492    let content_result = match crate::fs::read_to_string(&abs, None) {
493        Some(result) => result,
494        None => std::fs::read_to_string(&abs),
495    };
496    let content = match content_result {
497        Ok(s) => s,
498        Err(_) => {
499            return Err(HostlibError::Backend {
500                builtin: BUILTIN_READ_RANGE,
501                message: format!("file not found: {path}"),
502            })
503        }
504    };
505
506    if start.is_none() && end.is_none() {
507        return Ok(build_dict([("content", str_value(&content))]));
508    }
509    let lines: Vec<&str> = content.split('\n').collect();
510    let total = lines.len() as i64;
511    let lo = (start.unwrap_or(1) - 1).max(0) as usize;
512    let hi = end.unwrap_or(total).min(total).max(0) as usize;
513    if lo >= hi {
514        return Ok(build_dict([
515            ("content", str_value("")),
516            ("start", VmValue::Int((lo as i64) + 1)),
517            ("end", VmValue::Int(hi as i64)),
518        ]));
519    }
520    let slice = lines[lo..hi].join("\n");
521    Ok(build_dict([
522        ("content", str_value(&slice)),
523        ("start", VmValue::Int((lo as i64) + 1)),
524        ("end", VmValue::Int(hi as i64)),
525    ]))
526}
527
528pub(super) fn run_reindex_file(
529    index: &SharedIndex,
530    args: &[VmValue],
531) -> Result<VmValue, HostlibError> {
532    let raw = dict_arg(BUILTIN_REINDEX_FILE, args)?;
533    let path = require_string(BUILTIN_REINDEX_FILE, raw.as_ref(), "path")?;
534    let mut guard = index.lock().expect("code_index mutex poisoned");
535    let Some(state) = guard.as_mut() else {
536        return Ok(build_dict([
537            ("indexed", VmValue::Bool(false)),
538            ("file_id", VmValue::Nil),
539        ]));
540    };
541    let Some(abs) = state.absolute_path(&path) else {
542        return Err(HostlibError::InvalidParameter {
543            builtin: BUILTIN_REINDEX_FILE,
544            param: "path",
545            message: "path must stay within the indexed workspace root".to_string(),
546        });
547    };
548    let id = state.reindex_file(&abs);
549    Ok(build_dict([
550        ("indexed", VmValue::Bool(id.is_some())),
551        (
552            "file_id",
553            id.map(|i| VmValue::Int(i as i64)).unwrap_or(VmValue::Nil),
554        ),
555    ]))
556}
557
558pub(super) fn run_trigram_query(
559    index: &SharedIndex,
560    args: &[VmValue],
561) -> Result<VmValue, HostlibError> {
562    let raw = dict_arg(BUILTIN_TRIGRAM_QUERY, args)?;
563    let dict = raw.as_ref();
564    let trigrams_raw = optional_int_list(BUILTIN_TRIGRAM_QUERY, dict, "trigrams")?;
565    let max_files = optional_positive_usize(BUILTIN_TRIGRAM_QUERY, dict, "max_files")?;
566    let mut trigrams = Vec::with_capacity(trigrams_raw.len());
567    for n in trigrams_raw {
568        if n < 0 {
569            return Err(HostlibError::InvalidParameter {
570                builtin: BUILTIN_TRIGRAM_QUERY,
571                param: "trigrams",
572                message: "entries must be >= 0".to_string(),
573            });
574        }
575        trigrams.push(n as u32);
576    }
577    let guard = index.lock().expect("code_index mutex poisoned");
578    let mut ids: Vec<FileId> = match guard.as_ref() {
579        Some(state) => state.trigrams.query(&trigrams).into_iter().collect(),
580        None => Vec::new(),
581    };
582    ids.sort_unstable();
583    if let Some(limit) = max_files {
584        ids.truncate(limit);
585    }
586    Ok(VmValue::List(Arc::new(
587        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
588    )))
589}
590
591pub(super) fn run_extract_trigrams(
592    _index: &SharedIndex,
593    args: &[VmValue],
594) -> Result<VmValue, HostlibError> {
595    let raw = dict_arg(BUILTIN_EXTRACT_TRIGRAMS, args)?;
596    let query = require_string(BUILTIN_EXTRACT_TRIGRAMS, raw.as_ref(), "query")?;
597    let mut tgs = trigram::query_trigrams(&query);
598    tgs.sort_unstable();
599    Ok(VmValue::List(Arc::new(
600        tgs.into_iter().map(|n| VmValue::Int(n as i64)).collect(),
601    )))
602}
603
604pub(super) fn run_word_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
605    let raw = dict_arg(BUILTIN_WORD_GET, args)?;
606    let word = require_string(BUILTIN_WORD_GET, raw.as_ref(), "word")?;
607    let guard = index.lock().expect("code_index mutex poisoned");
608    let hits: Vec<VmValue> = match guard.as_ref() {
609        Some(state) => state
610            .words
611            .get(&word)
612            .iter()
613            .map(|h| {
614                build_dict([
615                    ("file_id", VmValue::Int(h.file as i64)),
616                    ("line", VmValue::Int(h.line as i64)),
617                ])
618            })
619            .collect(),
620        None => Vec::new(),
621    };
622    Ok(VmValue::List(Arc::new(hits)))
623}
624
625pub(super) fn run_deps_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
626    let raw = dict_arg(BUILTIN_DEPS_GET, args)?;
627    let dict = raw.as_ref();
628    let id = require_positive_file_id(BUILTIN_DEPS_GET, dict, "file_id")?;
629    let direction = optional_string(BUILTIN_DEPS_GET, dict, "direction")?
630        .unwrap_or_else(|| "importers".to_string());
631    let guard = index.lock().expect("code_index mutex poisoned");
632    let mut neighbors: Vec<FileId> = match guard.as_ref() {
633        Some(state) => match direction.as_str() {
634            "importers" => state.deps.importers_of(id),
635            "imports" => state.deps.imports_of(id),
636            _ => {
637                return Err(HostlibError::InvalidParameter {
638                    builtin: BUILTIN_DEPS_GET,
639                    param: "direction",
640                    message: format!("expected \"importers\" or \"imports\", got {direction:?}"),
641                })
642            }
643        },
644        None => Vec::new(),
645    };
646    neighbors.sort_unstable();
647    Ok(VmValue::List(Arc::new(
648        neighbors
649            .into_iter()
650            .map(|id| VmValue::Int(id as i64))
651            .collect(),
652    )))
653}
654
655pub(super) fn run_outline_get(
656    index: &SharedIndex,
657    args: &[VmValue],
658) -> Result<VmValue, HostlibError> {
659    let raw = dict_arg(BUILTIN_OUTLINE_GET, args)?;
660    let id = require_positive_file_id(BUILTIN_OUTLINE_GET, raw.as_ref(), "file_id")?;
661    let guard = index.lock().expect("code_index mutex poisoned");
662    let symbols: Vec<VmValue> = match guard.as_ref().and_then(|s| s.files.get(&id)) {
663        Some(file) => file
664            .symbols
665            .iter()
666            .map(|sym| {
667                build_dict([
668                    ("name", str_value(&sym.name)),
669                    ("kind", str_value(&sym.kind)),
670                    ("start_line", VmValue::Int(sym.start_line as i64)),
671                    ("end_line", VmValue::Int(sym.end_line as i64)),
672                    ("signature", str_value(&sym.signature)),
673                ])
674            })
675            .collect(),
676        None => Vec::new(),
677    };
678    Ok(VmValue::List(Arc::new(symbols)))
679}
680
681// === Change log ===
682
683pub(super) fn run_current_seq(
684    index: &SharedIndex,
685    _args: &[VmValue],
686) -> Result<VmValue, HostlibError> {
687    let guard = index.lock().expect("code_index mutex poisoned");
688    let seq = guard.as_ref().map(|s| s.versions.current_seq).unwrap_or(0);
689    Ok(VmValue::Int(seq as i64))
690}
691
692pub(super) fn run_changes_since(
693    index: &SharedIndex,
694    args: &[VmValue],
695) -> Result<VmValue, HostlibError> {
696    let raw = dict_arg(BUILTIN_CHANGES_SINCE, args)?;
697    let dict = raw.as_ref();
698    let seq = optional_non_negative_u64(BUILTIN_CHANGES_SINCE, dict, "seq", 0)?;
699    let limit = optional_positive_usize(BUILTIN_CHANGES_SINCE, dict, "limit")?;
700    let guard = index.lock().expect("code_index mutex poisoned");
701    let records = match guard.as_ref() {
702        Some(state) => state.versions.changes_since(seq, limit),
703        None => Vec::new(),
704    };
705    Ok(VmValue::List(Arc::new(
706        records
707            .into_iter()
708            .map(|r| {
709                build_dict([
710                    ("path", str_value(&r.path)),
711                    ("seq", VmValue::Int(r.seq as i64)),
712                    ("agent_id", VmValue::Int(r.agent_id as i64)),
713                    ("op", str_value(r.op.as_str())),
714                    ("hash", str_value(r.hash.to_string())),
715                    ("size", VmValue::Int(r.size as i64)),
716                    ("timestamp_ms", VmValue::Int(r.timestamp_ms)),
717                ])
718            })
719            .collect(),
720    )))
721}
722
723pub(super) fn run_version_record(
724    index: &SharedIndex,
725    args: &[VmValue],
726) -> Result<VmValue, HostlibError> {
727    let raw = dict_arg(BUILTIN_VERSION_RECORD, args)?;
728    let dict = raw.as_ref();
729    let agent_id = require_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "agent_id")?;
730    let path = require_string(BUILTIN_VERSION_RECORD, dict, "path")?;
731    let op_str =
732        optional_string(BUILTIN_VERSION_RECORD, dict, "op")?.unwrap_or_else(|| "write".to_string());
733    let op = EditOp::parse(&op_str).unwrap_or(EditOp::Write);
734    let hash = parse_hash(BUILTIN_VERSION_RECORD, dict, "hash")?;
735    let size = optional_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "size", 0)?;
736    let now = now_unix_ms();
737    let mut guard = index.lock().expect("code_index mutex poisoned");
738    let state = ensure_state(BUILTIN_VERSION_RECORD, &mut guard)?;
739    let normalized = normalize_relative_path(state, &path);
740    let seq = state
741        .versions
742        .record(normalized, agent_id, op, hash, size, now);
743    state.agents.note_edit(agent_id, now);
744    Ok(VmValue::Int(seq as i64))
745}
746
747// === Agent registry + locks ===
748
749pub(super) fn run_agent_register(
750    index: &SharedIndex,
751    args: &[VmValue],
752) -> Result<VmValue, HostlibError> {
753    let raw = dict_arg(BUILTIN_AGENT_REGISTER, args)?;
754    let dict = raw.as_ref();
755    let name = optional_string(BUILTIN_AGENT_REGISTER, dict, "name")?
756        .unwrap_or_else(|| "agent".to_string());
757    let requested_id = optional_positive_u64(BUILTIN_AGENT_REGISTER, dict, "agent_id")?;
758    let now = now_unix_ms();
759    let mut guard = index.lock().expect("code_index mutex poisoned");
760    let state = ensure_state(BUILTIN_AGENT_REGISTER, &mut guard)?;
761    let id = match requested_id {
762        Some(id) => state.agents.register_with_id(id, name, now),
763        None => state.agents.register(name, now),
764    };
765    Ok(VmValue::Int(id as i64))
766}
767
768pub(super) fn run_agent_heartbeat(
769    index: &SharedIndex,
770    args: &[VmValue],
771) -> Result<VmValue, HostlibError> {
772    let raw = dict_arg(BUILTIN_AGENT_HEARTBEAT, args)?;
773    let id = require_positive_u64(BUILTIN_AGENT_HEARTBEAT, raw.as_ref(), "agent_id")?;
774    let now = now_unix_ms();
775    let mut guard = index.lock().expect("code_index mutex poisoned");
776    let state = ensure_state(BUILTIN_AGENT_HEARTBEAT, &mut guard)?;
777    state.agents.heartbeat(id, now);
778    Ok(VmValue::Bool(true))
779}
780
781pub(super) fn run_agent_unregister(
782    index: &SharedIndex,
783    args: &[VmValue],
784) -> Result<VmValue, HostlibError> {
785    let raw = dict_arg(BUILTIN_AGENT_UNREGISTER, args)?;
786    let id = require_positive_u64(BUILTIN_AGENT_UNREGISTER, raw.as_ref(), "agent_id")?;
787    let mut guard = index.lock().expect("code_index mutex poisoned");
788    let state = ensure_state(BUILTIN_AGENT_UNREGISTER, &mut guard)?;
789    state.agents.unregister(id);
790    Ok(VmValue::Bool(true))
791}
792
793pub(super) fn run_lock_try(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
794    let raw = dict_arg(BUILTIN_LOCK_TRY, args)?;
795    let dict = raw.as_ref();
796    let agent_id = require_positive_u64(BUILTIN_LOCK_TRY, dict, "agent_id")?;
797    let path = require_string(BUILTIN_LOCK_TRY, dict, "path")?;
798    let ttl = optional_positive_i64(BUILTIN_LOCK_TRY, dict, "ttl_ms")?;
799    let now = now_unix_ms();
800    let mut guard = index.lock().expect("code_index mutex poisoned");
801    let state = ensure_state(BUILTIN_LOCK_TRY, &mut guard)?;
802    let granted = state.agents.try_lock(agent_id, &path, ttl, now);
803    if granted {
804        return Ok(build_dict([
805            ("locked", VmValue::Bool(true)),
806            ("holder", VmValue::Int(agent_id as i64)),
807        ]));
808    }
809    let holder = state.agents.lock_holder(&path, now);
810    Ok(build_dict([
811        ("locked", VmValue::Bool(false)),
812        (
813            "holder",
814            holder
815                .map(|id| VmValue::Int(id as i64))
816                .unwrap_or(VmValue::Nil),
817        ),
818    ]))
819}
820
821pub(super) fn run_lock_release(
822    index: &SharedIndex,
823    args: &[VmValue],
824) -> Result<VmValue, HostlibError> {
825    let raw = dict_arg(BUILTIN_LOCK_RELEASE, args)?;
826    let dict = raw.as_ref();
827    let agent_id = require_positive_u64(BUILTIN_LOCK_RELEASE, dict, "agent_id")?;
828    let path = require_string(BUILTIN_LOCK_RELEASE, dict, "path")?;
829    let mut guard = index.lock().expect("code_index mutex poisoned");
830    let state = ensure_state(BUILTIN_LOCK_RELEASE, &mut guard)?;
831    state.agents.release_lock(agent_id, &path);
832    Ok(VmValue::Bool(true))
833}
834
835pub(super) fn run_status(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
836    let guard = index.lock().expect("code_index mutex poisoned");
837    match guard.as_ref() {
838        Some(state) => Ok(build_dict([
839            ("file_count", VmValue::Int(state.files.len() as i64)),
840            (
841                "current_seq",
842                VmValue::Int(state.versions.current_seq as i64),
843            ),
844            ("last_indexed_at_ms", VmValue::Int(state.last_built_unix_ms)),
845            (
846                "git_head",
847                state
848                    .git_head
849                    .as_deref()
850                    .map(str_value)
851                    .unwrap_or(VmValue::Nil),
852            ),
853            (
854                "agents",
855                VmValue::List(Arc::new(
856                    state
857                        .agents
858                        .agents()
859                        .map(|info| {
860                            build_dict([
861                                ("id", VmValue::Int(info.id as i64)),
862                                ("name", str_value(&info.name)),
863                                (
864                                    "state",
865                                    str_value(match info.state {
866                                        super::agents::AgentState::Active => "active",
867                                        super::agents::AgentState::Crashed => "crashed",
868                                        super::agents::AgentState::Gone => "gone",
869                                    }),
870                                ),
871                                ("last_seen_ms", VmValue::Int(info.last_seen_ms)),
872                                ("edit_count", VmValue::Int(info.edit_count as i64)),
873                                ("lock_count", VmValue::Int(info.locked_paths.len() as i64)),
874                            ])
875                        })
876                        .collect(),
877                )),
878            ),
879        ])),
880        None => Ok(build_dict([
881            ("file_count", VmValue::Int(0)),
882            ("current_seq", VmValue::Int(0)),
883            ("last_indexed_at_ms", VmValue::Int(0)),
884            ("git_head", VmValue::Nil),
885            ("agents", VmValue::List(Arc::new(Vec::new()))),
886        ])),
887    }
888}
889
890pub(super) fn run_current_agent_id(
891    slot: &Arc<Mutex<Option<AgentId>>>,
892    _args: &[VmValue],
893) -> Result<VmValue, HostlibError> {
894    let guard = slot.lock().expect("current_agent slot poisoned");
895    Ok(match *guard {
896        Some(id) => VmValue::Int(id as i64),
897        None => VmValue::Nil,
898    })
899}
900
901// === Symbol graph: cypher, branch_overlay, freshness (issue #2434) ===
902
903pub(super) fn run_cypher(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
904    let raw = dict_arg(BUILTIN_CYPHER, args)?;
905    let dict = raw.as_ref();
906    let query = require_string(BUILTIN_CYPHER, dict, "query")?;
907
908    let guard = index.lock().expect("code_index mutex poisoned");
909    let Some(state) = guard.as_ref() else {
910        return Ok(build_dict([
911            ("rows", VmValue::List(Arc::new(Vec::new()))),
912            ("overlay", VmValue::Nil),
913        ]));
914    };
915
916    let graph = state.overlays.graph(&state.symbols);
917    let rows = super::cypher::execute(&query, graph).map_err(|err| HostlibError::Backend {
918        builtin: BUILTIN_CYPHER,
919        message: err.to_string(),
920    })?;
921
922    let rows_vm: Vec<VmValue> = rows
923        .into_iter()
924        .map(|row| {
925            let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
926            for (k, v) in row {
927                map.insert(k, v.to_vm());
928            }
929            VmValue::dict(map)
930        })
931        .collect();
932
933    Ok(build_dict([
934        ("rows", VmValue::List(Arc::new(rows_vm))),
935        (
936            "overlay",
937            match state.overlays.active() {
938                Some(name) => str_value(name),
939                None => VmValue::Nil,
940            },
941        ),
942    ]))
943}
944
945pub(super) fn run_branch_overlay(
946    index: &SharedIndex,
947    args: &[VmValue],
948) -> Result<VmValue, HostlibError> {
949    let raw = dict_arg(BUILTIN_BRANCH_OVERLAY, args)?;
950    let dict = raw.as_ref();
951    let branch = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "branch")?;
952    let activate = optional_bool(BUILTIN_BRANCH_OVERLAY, dict, "activate", true)?;
953    let action = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "action")?;
954
955    let mut guard = index.lock().expect("code_index mutex poisoned");
956    let state = ensure_state(BUILTIN_BRANCH_OVERLAY, &mut guard)?;
957
958    let mut reuse: f64 = 1.0;
959    match action.as_deref().unwrap_or("activate") {
960        "deactivate" => {
961            state.overlays.activate(None);
962        }
963        "create" => {
964            let branch_name = branch.ok_or(HostlibError::MissingParameter {
965                builtin: BUILTIN_BRANCH_OVERLAY,
966                param: "branch",
967            })?;
968            let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
969            overlay.materialize(&state.symbols);
970            state.overlays.set(overlay);
971            if activate {
972                state.overlays.activate(Some(branch_name));
973            }
974            reuse = state.overlays.reuse_fraction(&state.symbols);
975        }
976        "activate" => {
977            let branch_name = branch.ok_or(HostlibError::MissingParameter {
978                builtin: BUILTIN_BRANCH_OVERLAY,
979                param: "branch",
980            })?;
981            // If the overlay doesn't exist, create an empty pass-through
982            // one — the base graph then serves it untouched, giving the
983            // 100% reuse / 0-change baseline.
984            if state.overlays.get(&branch_name).is_none() {
985                let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
986                overlay.materialize(&state.symbols);
987                state.overlays.set(overlay);
988            }
989            state.overlays.activate(Some(branch_name));
990            reuse = state.overlays.reuse_fraction(&state.symbols);
991        }
992        other => {
993            return Err(HostlibError::InvalidParameter {
994                builtin: BUILTIN_BRANCH_OVERLAY,
995                param: "action",
996                message: format!("expected one of activate|deactivate|create, got `{other}`"),
997            })
998        }
999    }
1000
1001    Ok(build_dict([
1002        (
1003            "active",
1004            match state.overlays.active() {
1005                Some(name) => str_value(name),
1006                None => VmValue::Nil,
1007            },
1008        ),
1009        ("reuse_fraction", VmValue::Float(reuse)),
1010    ]))
1011}
1012
1013pub(super) fn run_freshness(
1014    index: &SharedIndex,
1015    args: &[VmValue],
1016) -> Result<VmValue, HostlibError> {
1017    let raw = dict_arg(BUILTIN_FRESHNESS, args)?;
1018    let dict = raw.as_ref();
1019    let path = require_string(BUILTIN_FRESHNESS, dict, "path")?;
1020
1021    let guard = index.lock().expect("code_index mutex poisoned");
1022    let state = guard.as_ref().ok_or_else(|| HostlibError::Backend {
1023        builtin: BUILTIN_FRESHNESS,
1024        message: "code index has not been initialised — call \
1025            `hostlib_code_index_rebuild` first"
1026            .to_string(),
1027    })?;
1028
1029    let normalized = normalize_relative_path(state, &path);
1030    let file = state
1031        .lookup_path(&normalized)
1032        .and_then(|id| state.files.get(&id));
1033    let Some(file) = file else {
1034        return Ok(unknown_freshness_response(&path));
1035    };
1036
1037    let abs = state.root.join(&file.relative_path);
1038    let (disk_mtime, disk_hash) = match std::fs::read(&abs) {
1039        Ok(bytes) => {
1040            let hash = fnv1a64(&bytes);
1041            let mtime = std::fs::metadata(&abs)
1042                .ok()
1043                .and_then(|m| m.modified().ok())
1044                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1045                .map(|d| d.as_millis() as i64)
1046                .unwrap_or(0);
1047            (mtime, Some(hash))
1048        }
1049        Err(_) => (0, None),
1050    };
1051    let stale = disk_hash != Some(file.content_hash);
1052    Ok(build_dict([
1053        ("path", str_value(&file.relative_path)),
1054        ("known", VmValue::Bool(true)),
1055        ("stale", VmValue::Bool(stale)),
1056        (
1057            "indexed_hash",
1058            VmValue::String(Arc::from(format!("{:016x}", file.content_hash).as_str())),
1059        ),
1060        ("indexed_mtime_ms", VmValue::Int(file.mtime_ms)),
1061        (
1062            "disk_hash",
1063            match disk_hash {
1064                Some(h) => VmValue::String(Arc::from(format!("{h:016x}").as_str())),
1065                None => VmValue::Nil,
1066            },
1067        ),
1068        ("disk_mtime_ms", VmValue::Int(disk_mtime)),
1069    ]))
1070}
1071
1072// === Helpers ===
1073
1074fn ensure_state<'a>(
1075    builtin: &'static str,
1076    guard: &'a mut std::sync::MutexGuard<'_, Option<IndexState>>,
1077) -> Result<&'a mut IndexState, HostlibError> {
1078    if guard.is_none() {
1079        return Err(HostlibError::Backend {
1080            builtin,
1081            message: "code index has not been initialised — call \
1082                 `hostlib_code_index_rebuild` or restore from a snapshot first"
1083                .to_string(),
1084        });
1085    }
1086    Ok(guard.as_mut().unwrap())
1087}
1088
1089fn parse_hash(
1090    builtin: &'static str,
1091    dict: &harn_vm::value::DictMap,
1092    key: &'static str,
1093) -> Result<u64, HostlibError> {
1094    match dict.get(key) {
1095        None | Some(VmValue::Nil) => Ok(0),
1096        Some(VmValue::Int(n)) if *n >= 0 => Ok(*n as u64),
1097        Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1098            builtin,
1099            param: key,
1100            message: format!("must be >= 0, got {n}"),
1101        }),
1102        Some(VmValue::String(s)) => s
1103            .parse::<u64>()
1104            .map_err(|_| HostlibError::InvalidParameter {
1105                builtin,
1106                param: key,
1107                message: format!("expected u64-parseable string, got {s:?}"),
1108            }),
1109        Some(other) => Err(HostlibError::InvalidParameter {
1110            builtin,
1111            param: key,
1112            message: format!(
1113                "expected integer or numeric string, got {}",
1114                other.type_name()
1115            ),
1116        }),
1117    }
1118}
1119
1120fn require_positive_u64(
1121    builtin: &'static str,
1122    dict: &harn_vm::value::DictMap,
1123    key: &'static str,
1124) -> Result<u64, HostlibError> {
1125    let raw = require_non_negative_u64(builtin, dict, key)?;
1126    if raw == 0 {
1127        return Err(HostlibError::InvalidParameter {
1128            builtin,
1129            param: key,
1130            message: "must be >= 1".to_string(),
1131        });
1132    }
1133    Ok(raw)
1134}
1135
1136fn require_positive_file_id(
1137    builtin: &'static str,
1138    dict: &harn_vm::value::DictMap,
1139    key: &'static str,
1140) -> Result<FileId, HostlibError> {
1141    let raw = require_positive_u64(builtin, dict, key)?;
1142    FileId::try_from(raw).map_err(|_| HostlibError::InvalidParameter {
1143        builtin,
1144        param: key,
1145        message: "does not fit in file id".to_string(),
1146    })
1147}
1148
1149fn require_non_negative_u64(
1150    builtin: &'static str,
1151    dict: &harn_vm::value::DictMap,
1152    key: &'static str,
1153) -> Result<u64, HostlibError> {
1154    match value_args::optional_i64_no_default(builtin, dict, key)? {
1155        Some(value) if value >= 0 => Ok(value as u64),
1156        Some(value) => Err(HostlibError::InvalidParameter {
1157            builtin,
1158            param: key,
1159            message: format!("must be >= 0, got {value}"),
1160        }),
1161        None => Err(HostlibError::MissingParameter {
1162            builtin,
1163            param: key,
1164        }),
1165    }
1166}
1167
1168fn optional_positive_u64(
1169    builtin: &'static str,
1170    dict: &harn_vm::value::DictMap,
1171    key: &'static str,
1172) -> Result<Option<u64>, HostlibError> {
1173    match dict.get(key) {
1174        None | Some(VmValue::Nil) => Ok(None),
1175        Some(_) => require_positive_u64(builtin, dict, key).map(Some),
1176    }
1177}
1178
1179fn optional_non_negative_u64(
1180    builtin: &'static str,
1181    dict: &harn_vm::value::DictMap,
1182    key: &'static str,
1183    default: u64,
1184) -> Result<u64, HostlibError> {
1185    match dict.get(key) {
1186        None | Some(VmValue::Nil) => Ok(default),
1187        Some(_) => require_non_negative_u64(builtin, dict, key),
1188    }
1189}
1190
1191fn optional_positive_i64(
1192    builtin: &'static str,
1193    dict: &harn_vm::value::DictMap,
1194    key: &'static str,
1195) -> Result<Option<i64>, HostlibError> {
1196    match value_args::optional_i64_no_default(builtin, dict, key)? {
1197        None => Ok(None),
1198        Some(value) if value >= 1 => Ok(Some(value)),
1199        Some(value) => Err(HostlibError::InvalidParameter {
1200            builtin,
1201            param: key,
1202            message: format!("must be >= 1, got {value}"),
1203        }),
1204    }
1205}
1206
1207fn optional_positive_usize(
1208    builtin: &'static str,
1209    dict: &harn_vm::value::DictMap,
1210    key: &'static str,
1211) -> Result<Option<usize>, HostlibError> {
1212    match optional_positive_u64(builtin, dict, key)? {
1213        Some(value) => {
1214            usize::try_from(value)
1215                .map(Some)
1216                .map_err(|_| HostlibError::InvalidParameter {
1217                    builtin,
1218                    param: key,
1219                    message: "does not fit in usize".to_string(),
1220                })
1221        }
1222        None => Ok(None),
1223    }
1224}
1225
1226/// Re-export of [`normalize_relative_path`] for sibling modules
1227/// (e.g. [`super::rename`]). Inputs may be a workspace-relative path,
1228/// an absolute path inside the workspace, or an unknown path; the
1229/// returned string is always workspace-relative when resolvable and
1230/// falls back to the raw input otherwise.
1231pub(super) fn normalize_relative_path_for(state: &IndexState, path: &str) -> String {
1232    normalize_relative_path(state, path)
1233}
1234
1235fn normalize_relative_path(state: &IndexState, path: &str) -> String {
1236    if let Some(rel) = state
1237        .lookup_path(path)
1238        .and_then(|id| state.files.get(&id))
1239        .map(|f| f.relative_path.clone())
1240    {
1241        return rel;
1242    }
1243    let p = std::path::Path::new(path);
1244    if p.is_absolute() {
1245        if let Ok(rel) = p.strip_prefix(&state.root) {
1246            return rel.to_string_lossy().replace('\\', "/");
1247        }
1248    }
1249    path.to_string()
1250}
1251
1252fn candidates_for(state: &IndexState, needle: &str) -> Vec<FileId> {
1253    if needle.len() >= 3 {
1254        let trigrams = trigram::query_trigrams(needle);
1255        return state.trigrams.query(&trigrams).into_iter().collect();
1256    }
1257    state.files.keys().copied().collect()
1258}
1259
1260fn read_file_text(root: &std::path::Path, relative: &str) -> Option<String> {
1261    let path = root.join(relative);
1262    match crate::fs::read_to_string(&path, None) {
1263        Some(result) => result.ok(),
1264        None => std::fs::read_to_string(path).ok(),
1265    }
1266}
1267
1268fn count_matches(haystack: &str, needle: &str, case_sensitive: bool) -> u64 {
1269    if case_sensitive {
1270        haystack.matches(needle).count() as u64
1271    } else {
1272        let lower_h = haystack.to_lowercase();
1273        let lower_n = needle.to_lowercase();
1274        lower_h.matches(&lower_n).count() as u64
1275    }
1276}
1277
1278fn scope_allows(scope: &[String], relative: &str) -> bool {
1279    if scope.is_empty() {
1280        return true;
1281    }
1282    scope
1283        .iter()
1284        .any(|s| relative == s || relative.starts_with(&format!("{s}/")) || s.is_empty())
1285}
1286
1287pub(super) struct Hit {
1288    pub(super) path: String,
1289    pub(super) match_count: u64,
1290    /// Absolute path of the read-only dependency root this hit came from,
1291    /// or `None` for a primary-workspace hit (issue #2403 follow-up).
1292    pub(super) root: Option<String>,
1293}
1294
1295fn hit_to_value(hit: Hit) -> VmValue {
1296    let Hit {
1297        path,
1298        match_count,
1299        root,
1300    } = hit;
1301    build_dict([
1302        ("path", str_value(&path)),
1303        ("score", VmValue::Float(match_count as f64)),
1304        ("match_count", VmValue::Int(match_count as i64)),
1305        (
1306            "root",
1307            match root {
1308                Some(r) => str_value(&r),
1309                None => VmValue::Nil,
1310            },
1311        ),
1312    ])
1313}
1314
1315fn import_entry(module: &str, resolved: Option<&str>, kind: &str) -> VmValue {
1316    let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
1317    map.insert("module".into(), str_value(module));
1318    map.insert(
1319        "resolved_path".into(),
1320        match resolved {
1321            Some(p) => str_value(p),
1322            None => VmValue::Nil,
1323        },
1324    );
1325    map.insert("kind".into(), str_value(kind));
1326    VmValue::dict(map)
1327}
1328
1329fn empty_stats_response() -> VmValue {
1330    build_dict([
1331        ("indexed_files", VmValue::Int(0)),
1332        ("trigrams", VmValue::Int(0)),
1333        ("words", VmValue::Int(0)),
1334        ("memory_bytes", VmValue::Int(0)),
1335        ("last_rebuild_unix_ms", VmValue::Nil),
1336    ])
1337}
1338
1339fn empty_imports_response(path: &str) -> VmValue {
1340    build_dict([
1341        ("path", str_value(path)),
1342        ("imports", VmValue::List(Arc::new(Vec::new()))),
1343    ])
1344}
1345
1346fn empty_importers_response(module: &str) -> VmValue {
1347    build_dict([
1348        ("module", str_value(module)),
1349        ("importers", VmValue::List(Arc::new(Vec::new()))),
1350    ])
1351}
1352
1353fn unknown_freshness_response(path: &str) -> VmValue {
1354    build_dict([
1355        ("path", str_value(path)),
1356        ("known", VmValue::Bool(false)),
1357        ("stale", VmValue::Bool(true)),
1358        ("indexed_hash", VmValue::Nil),
1359        ("indexed_mtime_ms", VmValue::Nil),
1360        ("disk_hash", VmValue::Nil),
1361        ("disk_mtime_ms", VmValue::Nil),
1362    ])
1363}