1use 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, to_agent_path, to_agent_path_str,
27};
28use crate::value_args;
29
30pub type SharedIndex = Arc<Mutex<Option<IndexState>>>;
37
38pub(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";
55pub(super) const BUILTIN_FILE_HASH_SNAPSHOT: &str = "hostlib_code_index_file_hash_snapshot";
56
57pub(super) const BUILTIN_READ_RANGE: &str = "hostlib_code_index_read_range";
58pub(super) const BUILTIN_REINDEX_FILE: &str = "hostlib_code_index_reindex_file";
59pub(super) const BUILTIN_TRIGRAM_QUERY: &str = "hostlib_code_index_trigram_query";
60pub(super) const BUILTIN_EXTRACT_TRIGRAMS: &str = "hostlib_code_index_extract_trigrams";
61pub(super) const BUILTIN_WORD_GET: &str = "hostlib_code_index_word_get";
62pub(super) const BUILTIN_DEPS_GET: &str = "hostlib_code_index_deps_get";
63pub(super) const BUILTIN_OUTLINE_GET: &str = "hostlib_code_index_outline_get";
64
65pub(super) const BUILTIN_CURRENT_SEQ: &str = "hostlib_code_index_current_seq";
66pub(super) const BUILTIN_CHANGES_SINCE: &str = "hostlib_code_index_changes_since";
67pub(super) const BUILTIN_VERSION_RECORD: &str = "hostlib_code_index_version_record";
68
69pub(super) const BUILTIN_AGENT_REGISTER: &str = "hostlib_code_index_agent_register";
70pub(super) const BUILTIN_AGENT_HEARTBEAT: &str = "hostlib_code_index_agent_heartbeat";
71pub(super) const BUILTIN_AGENT_UNREGISTER: &str = "hostlib_code_index_agent_unregister";
72pub(super) const BUILTIN_LOCK_TRY: &str = "hostlib_code_index_lock_try";
73pub(super) const BUILTIN_LOCK_RELEASE: &str = "hostlib_code_index_lock_release";
74pub(super) const BUILTIN_STATUS: &str = "hostlib_code_index_status";
75pub(super) const BUILTIN_CURRENT_AGENT_ID: &str = "hostlib_code_index_current_agent_id";
76
77pub(super) const BUILTIN_CYPHER: &str = "hostlib_code_index_cypher";
78pub(super) const BUILTIN_BRANCH_OVERLAY: &str = "hostlib_code_index_branch_overlay";
79pub(super) const BUILTIN_FRESHNESS: &str = "hostlib_code_index_freshness";
80
81pub(super) fn run_query_merged(
89 index: &SharedIndex,
90 readonly: Option<&super::readonly::ReadonlyRoots>,
91 args: &[VmValue],
92) -> Result<VmValue, HostlibError> {
93 let raw = dict_arg(BUILTIN_QUERY, args)?;
94 let dict = raw.as_ref();
95 let needle = require_string(BUILTIN_QUERY, dict, "needle")?;
96 if needle.is_empty() {
97 return Err(HostlibError::InvalidParameter {
98 builtin: BUILTIN_QUERY,
99 param: "needle",
100 message: "must not be empty".to_string(),
101 });
102 }
103 let case_sensitive = optional_bool(BUILTIN_QUERY, dict, "case_sensitive", false)?;
104 let max_results = optional_positive_usize(BUILTIN_QUERY, dict, "max_results")?.unwrap_or(100);
105 let scope = optional_string_list(BUILTIN_QUERY, dict, "scope")?;
106
107 let mut hits: Vec<Hit> = Vec::new();
108 {
109 let guard = index.lock().expect("code_index mutex poisoned");
110 if let Some(state) = guard.as_ref() {
111 collect_hits_scoped(state, &needle, case_sensitive, &scope, &mut hits);
112 }
113 }
114 if let Some(readonly) = readonly {
115 if scope.is_empty() {
118 hits.extend(super::readonly::query_readonly_hits(
119 readonly,
120 &needle,
121 case_sensitive,
122 ));
123 }
124 }
125
126 hits.sort_by(|a, b| {
127 b.match_count
128 .cmp(&a.match_count)
129 .then_with(|| a.path.cmp(&b.path))
130 });
131 let truncated = hits.len() > max_results;
132 if truncated {
133 hits.truncate(max_results);
134 }
135 Ok(build_dict([
136 (
137 "results",
138 VmValue::List(Arc::new(hits.into_iter().map(hit_to_value).collect())),
139 ),
140 ("truncated", VmValue::Bool(truncated)),
141 ]))
142}
143
144fn collect_hits_scoped(
149 state: &IndexState,
150 needle: &str,
151 case_sensitive: bool,
152 scope: &[String],
153 hits: &mut Vec<Hit>,
154) {
155 let candidate_ids = candidates_for(state, needle);
156 for id in candidate_ids {
157 let Some(file) = state.files.get(&id) else {
158 continue;
159 };
160 if !scope_allows(scope, &file.relative_path) {
161 continue;
162 }
163 let Some(text) = read_file_text(&state.root, &file.relative_path) else {
164 continue;
165 };
166 let count = count_matches(&text, needle, case_sensitive);
167 if count == 0 {
168 continue;
169 }
170 hits.push(Hit {
171 path: file.relative_path.clone(),
172 match_count: count,
173 root: None,
174 });
175 }
176}
177
178pub(super) fn collect_hits_into(
182 state: &IndexState,
183 needle: &str,
184 case_sensitive: bool,
185 hits: &mut Vec<Hit>,
186) {
187 let before = hits.len();
188 collect_hits_scoped(state, needle, case_sensitive, &[], hits);
189 let root = to_agent_path(&state.root);
190 for hit in &mut hits[before..] {
191 hit.root = Some(root.clone());
192 }
193}
194
195pub(super) fn run_rebuild(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
196 let raw = dict_arg(BUILTIN_REBUILD, args)?;
197 let dict = raw.as_ref();
198 let _force = optional_bool(BUILTIN_REBUILD, dict, "force", false)?;
199 let root = optional_string(BUILTIN_REBUILD, dict, "root")?
200 .map(PathBuf::from)
201 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
202 if !root.exists() {
203 return Err(HostlibError::InvalidParameter {
204 builtin: BUILTIN_REBUILD,
205 param: "root",
206 message: format!("path `{}` does not exist", root.display()),
207 });
208 }
209 if !root.is_dir() {
210 return Err(HostlibError::InvalidParameter {
211 builtin: BUILTIN_REBUILD,
212 param: "root",
213 message: format!("path `{}` is not a directory", root.display()),
214 });
215 }
216 let started = Instant::now();
217 let (state, outcome) = IndexState::build_from_root(&root);
218 let elapsed_ms = started.elapsed().as_millis() as i64;
219 {
220 let mut guard = index.lock().expect("code_index mutex poisoned");
221 *guard = Some(state);
222 }
223 Ok(build_dict([
224 ("files_indexed", VmValue::Int(outcome.files_indexed as i64)),
225 ("files_skipped", VmValue::Int(outcome.files_skipped as i64)),
226 ("elapsed_ms", VmValue::Int(elapsed_ms)),
227 ]))
228}
229
230pub(super) fn run_stats(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
231 let guard = index.lock().expect("code_index mutex poisoned");
232 let Some(state) = guard.as_ref() else {
233 return Ok(empty_stats_response());
234 };
235 Ok(build_dict([
236 ("indexed_files", VmValue::Int(state.files.len() as i64)),
237 (
238 "trigrams",
239 VmValue::Int(state.trigrams.distinct_trigrams() as i64),
240 ),
241 ("words", VmValue::Int(state.words.distinct_words() as i64)),
242 ("memory_bytes", VmValue::Int(state.estimated_bytes() as i64)),
243 (
244 "last_rebuild_unix_ms",
245 VmValue::Int(state.last_built_unix_ms),
246 ),
247 ]))
248}
249
250pub(super) fn run_imports_for(
251 index: &SharedIndex,
252 args: &[VmValue],
253) -> Result<VmValue, HostlibError> {
254 let raw = dict_arg(BUILTIN_IMPORTS_FOR, args)?;
255 let dict = raw.as_ref();
256 let path = require_string(BUILTIN_IMPORTS_FOR, dict, "path")?;
257 let guard = index.lock().expect("code_index mutex poisoned");
258 let Some(state) = guard.as_ref() else {
259 return Ok(empty_imports_response(&path));
260 };
261 let Some(file_id) = state.lookup_path(&path) else {
262 return Ok(empty_imports_response(&path));
263 };
264 let Some(file) = state.files.get(&file_id) else {
265 return Ok(empty_imports_response(&path));
266 };
267 let kind = imports::import_kind(&file.language).to_string();
268 let base_dir = imports::parent_dir(&file.relative_path);
269 let resolved_ids: HashSet<FileId> = state.deps.imports_of(file_id).into_iter().collect();
270 let mut entries: Vec<VmValue> = Vec::with_capacity(file.imports.len());
271 for raw_import in &file.imports {
272 let resolved_path =
273 imports::resolve_module(raw_import, &file.language, &base_dir, &state.path_to_id)
274 .filter(|id| resolved_ids.contains(id))
275 .and_then(|id| state.files.get(&id).map(|f| f.relative_path.clone()));
276 entries.push(import_entry(raw_import, resolved_path.as_deref(), &kind));
277 }
278 Ok(build_dict([
279 ("path", str_value(&file.relative_path)),
280 ("imports", VmValue::List(Arc::new(entries))),
281 ]))
282}
283
284pub(super) fn run_importers_of(
285 index: &SharedIndex,
286 args: &[VmValue],
287) -> Result<VmValue, HostlibError> {
288 let raw = dict_arg(BUILTIN_IMPORTERS_OF, args)?;
289 let dict = raw.as_ref();
290 let module = require_string(BUILTIN_IMPORTERS_OF, dict, "module")?;
291 let guard = index.lock().expect("code_index mutex poisoned");
292 let Some(state) = guard.as_ref() else {
293 return Ok(empty_importers_response(&module));
294 };
295
296 let target_id = state.lookup_path(&module).or_else(|| {
297 let needle = format!("/{module}");
301 state
302 .path_to_id
303 .iter()
304 .find(|(p, _)| p.ends_with(&needle) || *p == &module)
305 .map(|(_, id)| *id)
306 });
307
308 let mut importers: Vec<String> = match target_id {
309 Some(id) => state
310 .deps
311 .importers_of(id)
312 .into_iter()
313 .filter_map(|importer_id| {
314 state
315 .files
316 .get(&importer_id)
317 .map(|f| f.relative_path.clone())
318 })
319 .collect(),
320 None => Vec::new(),
321 };
322 importers.sort();
323 Ok(build_dict([
324 ("module", str_value(&module)),
325 (
326 "importers",
327 VmValue::List(Arc::new(importers.into_iter().map(str_value).collect())),
328 ),
329 ]))
330}
331
332pub(super) fn run_path_to_id(
335 index: &SharedIndex,
336 args: &[VmValue],
337) -> Result<VmValue, HostlibError> {
338 let raw = dict_arg(BUILTIN_PATH_TO_ID, args)?;
339 let path = require_string(BUILTIN_PATH_TO_ID, raw.as_ref(), "path")?;
340 let guard = index.lock().expect("code_index mutex poisoned");
341 let id = guard.as_ref().and_then(|s| s.lookup_path(&path));
342 Ok(match id {
343 Some(id) => VmValue::Int(id as i64),
344 None => VmValue::Nil,
345 })
346}
347
348pub(super) fn run_id_to_path(
349 index: &SharedIndex,
350 args: &[VmValue],
351) -> Result<VmValue, HostlibError> {
352 let raw = dict_arg(BUILTIN_ID_TO_PATH, args)?;
353 let id = require_positive_file_id(BUILTIN_ID_TO_PATH, raw.as_ref(), "file_id")?;
354 let guard = index.lock().expect("code_index mutex poisoned");
355 let path = guard
356 .as_ref()
357 .and_then(|s| s.files.get(&id))
358 .map(|f| f.relative_path.clone());
359 Ok(match path {
360 Some(p) => str_value(&p),
361 None => VmValue::Nil,
362 })
363}
364
365pub(super) fn run_file_ids(
366 index: &SharedIndex,
367 _args: &[VmValue],
368) -> Result<VmValue, HostlibError> {
369 let guard = index.lock().expect("code_index mutex poisoned");
370 let mut ids: Vec<FileId> = guard
371 .as_ref()
372 .map(|s| s.files.keys().copied().collect())
373 .unwrap_or_default();
374 ids.sort_unstable();
375 Ok(VmValue::List(Arc::new(
376 ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
377 )))
378}
379
380pub(super) fn run_file_meta(
381 index: &SharedIndex,
382 args: &[VmValue],
383) -> Result<VmValue, HostlibError> {
384 let raw = dict_arg(BUILTIN_FILE_META, args)?;
385 let dict = raw.as_ref();
386 let guard = index.lock().expect("code_index mutex poisoned");
387 let Some(state) = guard.as_ref() else {
388 return Ok(VmValue::Nil);
389 };
390 let id_opt: Option<FileId> = if dict.contains_key("file_id") {
391 Some(require_positive_file_id(
392 BUILTIN_FILE_META,
393 dict,
394 "file_id",
395 )?)
396 } else if let Some(VmValue::String(p)) = dict.get("path") {
397 state.lookup_path(p)
398 } else {
399 return Err(HostlibError::MissingParameter {
400 builtin: BUILTIN_FILE_META,
401 param: "file_id|path",
402 });
403 };
404 let Some(id) = id_opt else {
405 return Ok(VmValue::Nil);
406 };
407 let Some(file) = state.files.get(&id) else {
408 return Ok(VmValue::Nil);
409 };
410 let last_edit_seq = state
411 .versions
412 .last_entry(&file.relative_path)
413 .map(|e| e.seq)
414 .unwrap_or(0);
415 Ok(build_dict([
416 ("id", VmValue::Int(file.id as i64)),
417 ("path", str_value(&file.relative_path)),
418 ("language", str_value(&file.language)),
419 ("size", VmValue::Int(file.size_bytes as i64)),
420 ("line_count", VmValue::Int(file.line_count as i64)),
421 ("hash", str_value(file.content_hash.to_string())),
422 ("mtime_ms", VmValue::Int(file.mtime_ms)),
423 ("last_edit_seq", VmValue::Int(last_edit_seq as i64)),
424 ]))
425}
426
427pub(super) fn run_file_hash(
428 index: &SharedIndex,
429 args: &[VmValue],
430) -> Result<VmValue, HostlibError> {
431 let raw = dict_arg(BUILTIN_FILE_HASH, args)?;
432 let path = require_string(BUILTIN_FILE_HASH, raw.as_ref(), "path")?;
433 let guard = index.lock().expect("code_index mutex poisoned");
434 let Some(state) = guard.as_ref() else {
435 return Ok(VmValue::Nil);
436 };
437 let Some(abs) = state.absolute_path(&path) else {
438 return Ok(VmValue::Nil);
439 };
440 let bytes = match crate::fs::read(&abs, None) {
441 Some(result) => result,
442 None => std::fs::read(&abs),
443 };
444 match bytes {
445 Ok(bytes) => Ok(str_value(fnv1a64(&bytes).to_string())),
446 Err(_) => Ok(VmValue::Nil),
447 }
448}
449
450pub(super) fn run_file_hash_snapshot(
451 index: &SharedIndex,
452 args: &[VmValue],
453) -> Result<VmValue, HostlibError> {
454 let raw = dict_arg(BUILTIN_FILE_HASH_SNAPSHOT, args)?;
455 let dict = raw.as_ref();
456 if !dict.contains_key("paths") {
457 return Err(HostlibError::MissingParameter {
458 builtin: BUILTIN_FILE_HASH_SNAPSHOT,
459 param: "paths",
460 });
461 }
462 let paths = optional_string_list(BUILTIN_FILE_HASH_SNAPSHOT, dict, "paths")?;
463 if paths.is_empty() {
464 return Err(HostlibError::InvalidParameter {
465 builtin: BUILTIN_FILE_HASH_SNAPSHOT,
466 param: "paths",
467 message: "must contain at least one path".to_string(),
468 });
469 }
470 if paths.len() > 4096 {
471 return Err(HostlibError::InvalidParameter {
472 builtin: BUILTIN_FILE_HASH_SNAPSHOT,
473 param: "paths",
474 message: "must contain at most 4096 paths".to_string(),
475 });
476 }
477
478 let guard = index.lock().expect("code_index mutex poisoned");
479 let Some(state) = guard.as_ref() else {
480 return Ok(build_dict([
481 ("seq", VmValue::Int(0)),
482 ("captured_at_ms", VmValue::Int(now_unix_ms())),
483 ("algorithm", str_value("fnv1a64")),
484 ("snapshot", VmValue::dict(harn_vm::value::DictMap::new())),
485 (
486 "missing",
487 VmValue::List(Arc::new(paths.into_iter().map(str_value).collect())),
488 ),
489 ("files", VmValue::List(Arc::new(Vec::new()))),
490 ]));
491 };
492 let seq = state.versions.current_seq as i64;
493 let captured_at_ms = now_unix_ms();
494 let mut files = Vec::with_capacity(paths.len());
495 let mut snapshot = harn_vm::value::DictMap::new();
496 let mut missing = Vec::new();
497 for path in paths {
498 let entry = file_hash_snapshot_entry(state, &path);
499 if let Some(hash) = &entry.hash {
500 snapshot.insert(harn_vm::value::intern_key(&entry.path), str_value(hash));
501 } else {
502 missing.push(str_value(&entry.path));
503 }
504 files.push(entry.value);
505 }
506 Ok(build_dict([
507 ("seq", VmValue::Int(seq)),
508 ("captured_at_ms", VmValue::Int(captured_at_ms)),
509 ("algorithm", str_value("fnv1a64")),
510 ("snapshot", VmValue::dict(snapshot)),
511 ("missing", VmValue::List(Arc::new(missing))),
512 ("files", VmValue::List(Arc::new(files))),
513 ]))
514}
515
516pub(super) fn run_read_range_merged(
525 index: &SharedIndex,
526 readonly: Option<&super::readonly::ReadonlyRoots>,
527 args: &[VmValue],
528) -> Result<VmValue, HostlibError> {
529 let raw = dict_arg(BUILTIN_READ_RANGE, args)?;
530 let dict = raw.as_ref();
531 let path = require_string(BUILTIN_READ_RANGE, dict, "path")?;
532 let start = optional_positive_i64(BUILTIN_READ_RANGE, dict, "start")?;
533 let end = optional_positive_i64(BUILTIN_READ_RANGE, dict, "end")?;
534 let abs =
535 match readonly {
536 Some(readonly) => super::readonly::resolve_read_path(index, readonly, &path)
537 .ok_or_else(|| HostlibError::InvalidParameter {
538 builtin: BUILTIN_READ_RANGE,
539 param: "path",
540 message: "path must stay within the indexed workspace root or a read-only \
541 dependency root"
542 .to_string(),
543 })?,
544 None => {
545 let guard = index.lock().expect("code_index mutex poisoned");
546 match guard.as_ref() {
547 Some(state) => state.absolute_path(&path).ok_or_else(|| {
548 HostlibError::InvalidParameter {
549 builtin: BUILTIN_READ_RANGE,
550 param: "path",
551 message: "path must stay within the indexed workspace root".to_string(),
552 }
553 })?,
554 None => PathBuf::from(&path),
555 }
556 }
557 };
558
559 let content_result = match crate::fs::read_to_string(&abs, None) {
560 Some(result) => result,
561 None => std::fs::read_to_string(&abs),
562 };
563 let content = match content_result {
564 Ok(s) => s,
565 Err(_) => {
566 return Err(HostlibError::Backend {
567 builtin: BUILTIN_READ_RANGE,
568 message: format!("file not found: {path}"),
569 })
570 }
571 };
572
573 if start.is_none() && end.is_none() {
574 return Ok(build_dict([("content", str_value(&content))]));
575 }
576 let lines: Vec<&str> = if content.is_empty() {
581 Vec::new()
582 } else {
583 let mut parts: Vec<&str> = content.split('\n').collect();
584 if content.ends_with('\n') {
585 parts.pop();
586 }
587 parts
588 };
589 let total = lines.len() as i64;
590 let lo = (start.unwrap_or(1) - 1).max(0) as usize;
591 let hi = end.unwrap_or(total).min(total).max(0) as usize;
592 if lo >= hi {
593 return Ok(build_dict([
594 ("content", str_value("")),
595 ("start", VmValue::Int((lo as i64) + 1)),
596 ("end", VmValue::Int(hi as i64)),
597 ]));
598 }
599 let slice = lines[lo..hi].join("\n");
600 Ok(build_dict([
601 ("content", str_value(&slice)),
602 ("start", VmValue::Int((lo as i64) + 1)),
603 ("end", VmValue::Int(hi as i64)),
604 ]))
605}
606
607pub(super) fn run_reindex_file(
608 index: &SharedIndex,
609 args: &[VmValue],
610) -> Result<VmValue, HostlibError> {
611 let raw = dict_arg(BUILTIN_REINDEX_FILE, args)?;
612 let path = require_string(BUILTIN_REINDEX_FILE, raw.as_ref(), "path")?;
613 let mut guard = index.lock().expect("code_index mutex poisoned");
614 let Some(state) = guard.as_mut() else {
615 return Ok(build_dict([
616 ("indexed", VmValue::Bool(false)),
617 ("file_id", VmValue::Nil),
618 ]));
619 };
620 let Some(abs) = state.absolute_path(&path) else {
621 return Err(HostlibError::InvalidParameter {
622 builtin: BUILTIN_REINDEX_FILE,
623 param: "path",
624 message: "path must stay within the indexed workspace root".to_string(),
625 });
626 };
627 let id = state.reindex_file(&abs);
628 Ok(build_dict([
629 ("indexed", VmValue::Bool(id.is_some())),
630 (
631 "file_id",
632 id.map(|i| VmValue::Int(i as i64)).unwrap_or(VmValue::Nil),
633 ),
634 ]))
635}
636
637pub(super) fn run_trigram_query(
638 index: &SharedIndex,
639 args: &[VmValue],
640) -> Result<VmValue, HostlibError> {
641 let raw = dict_arg(BUILTIN_TRIGRAM_QUERY, args)?;
642 let dict = raw.as_ref();
643 let trigrams_raw = optional_int_list(BUILTIN_TRIGRAM_QUERY, dict, "trigrams")?;
644 let max_files = optional_positive_usize(BUILTIN_TRIGRAM_QUERY, dict, "max_files")?;
645 let mut trigrams = Vec::with_capacity(trigrams_raw.len());
646 for n in trigrams_raw {
647 if n < 0 {
648 return Err(HostlibError::InvalidParameter {
649 builtin: BUILTIN_TRIGRAM_QUERY,
650 param: "trigrams",
651 message: "entries must be >= 0".to_string(),
652 });
653 }
654 trigrams.push(n as u32);
655 }
656 let guard = index.lock().expect("code_index mutex poisoned");
657 let mut ids: Vec<FileId> = match guard.as_ref() {
658 Some(state) => state.trigrams.query(&trigrams).into_iter().collect(),
659 None => Vec::new(),
660 };
661 ids.sort_unstable();
662 if let Some(limit) = max_files {
663 ids.truncate(limit);
664 }
665 Ok(VmValue::List(Arc::new(
666 ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
667 )))
668}
669
670pub(super) fn run_extract_trigrams(
671 _index: &SharedIndex,
672 args: &[VmValue],
673) -> Result<VmValue, HostlibError> {
674 let raw = dict_arg(BUILTIN_EXTRACT_TRIGRAMS, args)?;
675 let query = require_string(BUILTIN_EXTRACT_TRIGRAMS, raw.as_ref(), "query")?;
676 let mut tgs = trigram::query_trigrams(&query);
677 tgs.sort_unstable();
678 Ok(VmValue::List(Arc::new(
679 tgs.into_iter().map(|n| VmValue::Int(n as i64)).collect(),
680 )))
681}
682
683pub(super) fn run_word_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
684 let raw = dict_arg(BUILTIN_WORD_GET, args)?;
685 let word = require_string(BUILTIN_WORD_GET, raw.as_ref(), "word")?;
686 let guard = index.lock().expect("code_index mutex poisoned");
687 let hits: Vec<VmValue> = match guard.as_ref() {
688 Some(state) => state
689 .words
690 .get(&word)
691 .iter()
692 .map(|h| {
693 build_dict([
694 ("file_id", VmValue::Int(h.file as i64)),
695 ("line", VmValue::Int(h.line as i64)),
696 ])
697 })
698 .collect(),
699 None => Vec::new(),
700 };
701 Ok(VmValue::List(Arc::new(hits)))
702}
703
704pub(super) fn run_deps_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
705 let raw = dict_arg(BUILTIN_DEPS_GET, args)?;
706 let dict = raw.as_ref();
707 let id = require_positive_file_id(BUILTIN_DEPS_GET, dict, "file_id")?;
708 let direction = optional_string(BUILTIN_DEPS_GET, dict, "direction")?
709 .unwrap_or_else(|| "importers".to_string());
710 let guard = index.lock().expect("code_index mutex poisoned");
711 let mut neighbors: Vec<FileId> = match guard.as_ref() {
712 Some(state) => match direction.as_str() {
713 "importers" => state.deps.importers_of(id),
714 "imports" => state.deps.imports_of(id),
715 _ => {
716 return Err(HostlibError::InvalidParameter {
717 builtin: BUILTIN_DEPS_GET,
718 param: "direction",
719 message: format!("expected \"importers\" or \"imports\", got {direction:?}"),
720 })
721 }
722 },
723 None => Vec::new(),
724 };
725 neighbors.sort_unstable();
726 Ok(VmValue::List(Arc::new(
727 neighbors
728 .into_iter()
729 .map(|id| VmValue::Int(id as i64))
730 .collect(),
731 )))
732}
733
734pub(super) fn run_outline_get(
735 index: &SharedIndex,
736 args: &[VmValue],
737) -> Result<VmValue, HostlibError> {
738 let raw = dict_arg(BUILTIN_OUTLINE_GET, args)?;
739 let id = require_positive_file_id(BUILTIN_OUTLINE_GET, raw.as_ref(), "file_id")?;
740 let guard = index.lock().expect("code_index mutex poisoned");
741 let symbols: Vec<VmValue> = match guard.as_ref().and_then(|s| s.files.get(&id)) {
742 Some(file) => file
743 .symbols
744 .iter()
745 .map(|sym| {
746 build_dict([
747 ("name", str_value(&sym.name)),
748 ("kind", str_value(&sym.kind)),
749 (
750 "access_level",
751 sym.access_level
752 .as_deref()
753 .map(str_value)
754 .unwrap_or(VmValue::Nil),
755 ),
756 ("start_line", VmValue::Int(sym.start_line as i64)),
757 ("end_line", VmValue::Int(sym.end_line as i64)),
758 ("signature", str_value(&sym.signature)),
759 ])
760 })
761 .collect(),
762 None => Vec::new(),
763 };
764 Ok(VmValue::List(Arc::new(symbols)))
765}
766
767pub(super) fn run_current_seq(
770 index: &SharedIndex,
771 _args: &[VmValue],
772) -> Result<VmValue, HostlibError> {
773 let guard = index.lock().expect("code_index mutex poisoned");
774 let seq = guard.as_ref().map(|s| s.versions.current_seq).unwrap_or(0);
775 Ok(VmValue::Int(seq as i64))
776}
777
778pub(super) fn run_changes_since(
779 index: &SharedIndex,
780 args: &[VmValue],
781) -> Result<VmValue, HostlibError> {
782 let raw = dict_arg(BUILTIN_CHANGES_SINCE, args)?;
783 let dict = raw.as_ref();
784 let seq = optional_non_negative_u64(BUILTIN_CHANGES_SINCE, dict, "seq", 0)?;
785 let limit = optional_positive_usize(BUILTIN_CHANGES_SINCE, dict, "limit")?;
786 let guard = index.lock().expect("code_index mutex poisoned");
787 let records = match guard.as_ref() {
788 Some(state) => state.versions.changes_since(seq, limit),
789 None => Vec::new(),
790 };
791 Ok(VmValue::List(Arc::new(
792 records
793 .into_iter()
794 .map(|r| {
795 build_dict([
796 ("path", str_value(&r.path)),
797 ("seq", VmValue::Int(r.seq as i64)),
798 ("agent_id", VmValue::Int(r.agent_id as i64)),
799 ("op", str_value(r.op.as_str())),
800 ("hash", str_value(r.hash.to_string())),
801 ("size", VmValue::Int(r.size as i64)),
802 ("timestamp_ms", VmValue::Int(r.timestamp_ms)),
803 ])
804 })
805 .collect(),
806 )))
807}
808
809pub(super) fn run_version_record(
810 index: &SharedIndex,
811 args: &[VmValue],
812) -> Result<VmValue, HostlibError> {
813 let raw = dict_arg(BUILTIN_VERSION_RECORD, args)?;
814 let dict = raw.as_ref();
815 let agent_id = require_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "agent_id")?;
816 let path = require_string(BUILTIN_VERSION_RECORD, dict, "path")?;
817 let op_str =
818 optional_string(BUILTIN_VERSION_RECORD, dict, "op")?.unwrap_or_else(|| "write".to_string());
819 let op = EditOp::parse(&op_str).unwrap_or(EditOp::Write);
820 let hash = parse_hash(BUILTIN_VERSION_RECORD, dict, "hash")?;
821 let size = optional_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "size", 0)?;
822 let now = now_unix_ms();
823 let mut guard = index.lock().expect("code_index mutex poisoned");
824 let state = ensure_state(BUILTIN_VERSION_RECORD, &mut guard)?;
825 let normalized = normalize_relative_path(state, &path);
826 let seq = state
827 .versions
828 .record(normalized, agent_id, op, hash, size, now);
829 state.agents.note_edit(agent_id, now);
830 Ok(VmValue::Int(seq as i64))
831}
832
833pub(super) fn run_agent_register(
836 index: &SharedIndex,
837 args: &[VmValue],
838) -> Result<VmValue, HostlibError> {
839 let raw = dict_arg(BUILTIN_AGENT_REGISTER, args)?;
840 let dict = raw.as_ref();
841 let name = optional_string(BUILTIN_AGENT_REGISTER, dict, "name")?
842 .unwrap_or_else(|| "agent".to_string());
843 let requested_id = optional_positive_u64(BUILTIN_AGENT_REGISTER, dict, "agent_id")?;
844 let now = now_unix_ms();
845 let mut guard = index.lock().expect("code_index mutex poisoned");
846 let state = ensure_state(BUILTIN_AGENT_REGISTER, &mut guard)?;
847 let id = match requested_id {
848 Some(id) => state.agents.register_with_id(id, name, now),
849 None => state.agents.register(name, now),
850 };
851 Ok(VmValue::Int(id as i64))
852}
853
854pub(super) fn run_agent_heartbeat(
855 index: &SharedIndex,
856 args: &[VmValue],
857) -> Result<VmValue, HostlibError> {
858 let raw = dict_arg(BUILTIN_AGENT_HEARTBEAT, args)?;
859 let id = require_positive_u64(BUILTIN_AGENT_HEARTBEAT, raw.as_ref(), "agent_id")?;
860 let now = now_unix_ms();
861 let mut guard = index.lock().expect("code_index mutex poisoned");
862 let state = ensure_state(BUILTIN_AGENT_HEARTBEAT, &mut guard)?;
863 state.agents.heartbeat(id, now);
864 Ok(VmValue::Bool(true))
865}
866
867pub(super) fn run_agent_unregister(
868 index: &SharedIndex,
869 args: &[VmValue],
870) -> Result<VmValue, HostlibError> {
871 let raw = dict_arg(BUILTIN_AGENT_UNREGISTER, args)?;
872 let id = require_positive_u64(BUILTIN_AGENT_UNREGISTER, raw.as_ref(), "agent_id")?;
873 let mut guard = index.lock().expect("code_index mutex poisoned");
874 let state = ensure_state(BUILTIN_AGENT_UNREGISTER, &mut guard)?;
875 state.agents.unregister(id);
876 Ok(VmValue::Bool(true))
877}
878
879pub(super) fn run_lock_try(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
880 let raw = dict_arg(BUILTIN_LOCK_TRY, args)?;
881 let dict = raw.as_ref();
882 let agent_id = require_positive_u64(BUILTIN_LOCK_TRY, dict, "agent_id")?;
883 let path = require_string(BUILTIN_LOCK_TRY, dict, "path")?;
884 let ttl = optional_positive_i64(BUILTIN_LOCK_TRY, dict, "ttl_ms")?;
885 let now = now_unix_ms();
886 let mut guard = index.lock().expect("code_index mutex poisoned");
887 let state = ensure_state(BUILTIN_LOCK_TRY, &mut guard)?;
888 let granted = state.agents.try_lock(agent_id, &path, ttl, now);
889 if granted {
890 return Ok(build_dict([
891 ("locked", VmValue::Bool(true)),
892 ("holder", VmValue::Int(agent_id as i64)),
893 ]));
894 }
895 let holder = state.agents.lock_holder(&path, now);
896 Ok(build_dict([
897 ("locked", VmValue::Bool(false)),
898 (
899 "holder",
900 holder
901 .map(|id| VmValue::Int(id as i64))
902 .unwrap_or(VmValue::Nil),
903 ),
904 ]))
905}
906
907pub(super) fn run_lock_release(
908 index: &SharedIndex,
909 args: &[VmValue],
910) -> Result<VmValue, HostlibError> {
911 let raw = dict_arg(BUILTIN_LOCK_RELEASE, args)?;
912 let dict = raw.as_ref();
913 let agent_id = require_positive_u64(BUILTIN_LOCK_RELEASE, dict, "agent_id")?;
914 let path = require_string(BUILTIN_LOCK_RELEASE, dict, "path")?;
915 let mut guard = index.lock().expect("code_index mutex poisoned");
916 let state = ensure_state(BUILTIN_LOCK_RELEASE, &mut guard)?;
917 state.agents.release_lock(agent_id, &path);
918 Ok(VmValue::Bool(true))
919}
920
921pub(super) fn run_status(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
922 let guard = index.lock().expect("code_index mutex poisoned");
923 match guard.as_ref() {
924 Some(state) => Ok(build_dict([
925 ("file_count", VmValue::Int(state.files.len() as i64)),
926 (
927 "current_seq",
928 VmValue::Int(state.versions.current_seq as i64),
929 ),
930 ("last_indexed_at_ms", VmValue::Int(state.last_built_unix_ms)),
931 (
932 "git_head",
933 state
934 .git_head
935 .as_deref()
936 .map(str_value)
937 .unwrap_or(VmValue::Nil),
938 ),
939 (
940 "agents",
941 VmValue::List(Arc::new(
942 state
943 .agents
944 .agents()
945 .map(|info| {
946 build_dict([
947 ("id", VmValue::Int(info.id as i64)),
948 ("name", str_value(&info.name)),
949 (
950 "state",
951 str_value(match info.state {
952 super::agents::AgentState::Active => "active",
953 super::agents::AgentState::Crashed => "crashed",
954 super::agents::AgentState::Gone => "gone",
955 }),
956 ),
957 ("last_seen_ms", VmValue::Int(info.last_seen_ms)),
958 ("edit_count", VmValue::Int(info.edit_count as i64)),
959 ("lock_count", VmValue::Int(info.locked_paths.len() as i64)),
960 ])
961 })
962 .collect(),
963 )),
964 ),
965 ])),
966 None => Ok(build_dict([
967 ("file_count", VmValue::Int(0)),
968 ("current_seq", VmValue::Int(0)),
969 ("last_indexed_at_ms", VmValue::Int(0)),
970 ("git_head", VmValue::Nil),
971 ("agents", VmValue::List(Arc::new(Vec::new()))),
972 ])),
973 }
974}
975
976pub(super) fn run_current_agent_id(
977 slot: &Arc<Mutex<Option<AgentId>>>,
978 _args: &[VmValue],
979) -> Result<VmValue, HostlibError> {
980 let guard = slot.lock().expect("current_agent slot poisoned");
981 Ok(match *guard {
982 Some(id) => VmValue::Int(id as i64),
983 None => VmValue::Nil,
984 })
985}
986
987pub(super) fn run_cypher(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
990 let raw = dict_arg(BUILTIN_CYPHER, args)?;
991 let dict = raw.as_ref();
992 let query = require_string(BUILTIN_CYPHER, dict, "query")?;
993
994 let guard = index.lock().expect("code_index mutex poisoned");
995 let Some(state) = guard.as_ref() else {
996 return Ok(build_dict([
997 ("rows", VmValue::List(Arc::new(Vec::new()))),
998 ("overlay", VmValue::Nil),
999 ]));
1000 };
1001
1002 let graph = state.overlays.graph(&state.symbols);
1003 let rows = super::cypher::execute(&query, graph).map_err(|err| HostlibError::Backend {
1004 builtin: BUILTIN_CYPHER,
1005 message: err.to_string(),
1006 })?;
1007
1008 let rows_vm: Vec<VmValue> = rows
1009 .into_iter()
1010 .map(|row| {
1011 let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
1012 for (k, v) in row {
1013 map.insert(harn_vm::value::intern_key(&k), v.to_vm());
1014 }
1015 VmValue::dict(map)
1016 })
1017 .collect();
1018
1019 Ok(build_dict([
1020 ("rows", VmValue::List(Arc::new(rows_vm))),
1021 (
1022 "overlay",
1023 match state.overlays.active() {
1024 Some(name) => str_value(name),
1025 None => VmValue::Nil,
1026 },
1027 ),
1028 ]))
1029}
1030
1031pub(super) fn run_branch_overlay(
1032 index: &SharedIndex,
1033 args: &[VmValue],
1034) -> Result<VmValue, HostlibError> {
1035 let raw = dict_arg(BUILTIN_BRANCH_OVERLAY, args)?;
1036 let dict = raw.as_ref();
1037 let branch = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "branch")?;
1038 let activate = optional_bool(BUILTIN_BRANCH_OVERLAY, dict, "activate", true)?;
1039 let action = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "action")?;
1040
1041 let mut guard = index.lock().expect("code_index mutex poisoned");
1042 let state = ensure_state(BUILTIN_BRANCH_OVERLAY, &mut guard)?;
1043
1044 let mut reuse: f64 = 1.0;
1045 match action.as_deref().unwrap_or("activate") {
1046 "deactivate" => {
1047 state.overlays.activate(None);
1048 }
1049 "create" => {
1050 let branch_name = branch.ok_or(HostlibError::MissingParameter {
1051 builtin: BUILTIN_BRANCH_OVERLAY,
1052 param: "branch",
1053 })?;
1054 let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
1055 overlay.materialize(&state.symbols);
1056 state.overlays.set(overlay);
1057 if activate {
1058 state.overlays.activate(Some(branch_name));
1059 }
1060 reuse = state.overlays.reuse_fraction(&state.symbols);
1061 }
1062 "activate" => {
1063 let branch_name = branch.ok_or(HostlibError::MissingParameter {
1064 builtin: BUILTIN_BRANCH_OVERLAY,
1065 param: "branch",
1066 })?;
1067 if state.overlays.get(&branch_name).is_none() {
1071 let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
1072 overlay.materialize(&state.symbols);
1073 state.overlays.set(overlay);
1074 }
1075 state.overlays.activate(Some(branch_name));
1076 reuse = state.overlays.reuse_fraction(&state.symbols);
1077 }
1078 other => {
1079 return Err(HostlibError::InvalidParameter {
1080 builtin: BUILTIN_BRANCH_OVERLAY,
1081 param: "action",
1082 message: format!("expected one of activate|deactivate|create, got `{other}`"),
1083 })
1084 }
1085 }
1086
1087 Ok(build_dict([
1088 (
1089 "active",
1090 match state.overlays.active() {
1091 Some(name) => str_value(name),
1092 None => VmValue::Nil,
1093 },
1094 ),
1095 ("reuse_fraction", VmValue::Float(reuse)),
1096 ]))
1097}
1098
1099pub(super) fn run_freshness(
1100 index: &SharedIndex,
1101 args: &[VmValue],
1102) -> Result<VmValue, HostlibError> {
1103 let raw = dict_arg(BUILTIN_FRESHNESS, args)?;
1104 let dict = raw.as_ref();
1105 let path = require_string(BUILTIN_FRESHNESS, dict, "path")?;
1106
1107 let guard = index.lock().expect("code_index mutex poisoned");
1108 let state = guard.as_ref().ok_or_else(|| HostlibError::Backend {
1109 builtin: BUILTIN_FRESHNESS,
1110 message: "code index has not been initialised — call \
1111 `hostlib_code_index_rebuild` first"
1112 .to_string(),
1113 })?;
1114
1115 let normalized = normalize_relative_path(state, &path);
1116 let file = state
1117 .lookup_path(&normalized)
1118 .and_then(|id| state.files.get(&id));
1119 let Some(file) = file else {
1120 return Ok(unknown_freshness_response(&path));
1121 };
1122
1123 let abs = state.root.join(&file.relative_path);
1124 let (disk_mtime, disk_hash) = match std::fs::read(&abs) {
1125 Ok(bytes) => {
1126 let hash = fnv1a64(&bytes);
1127 let mtime = std::fs::metadata(&abs)
1128 .ok()
1129 .and_then(|m| m.modified().ok())
1130 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1131 .map(|d| d.as_millis() as i64)
1132 .unwrap_or(0);
1133 (mtime, Some(hash))
1134 }
1135 Err(_) => (0, None),
1136 };
1137 let stale = disk_hash != Some(file.content_hash);
1138 Ok(build_dict([
1139 ("path", str_value(&file.relative_path)),
1140 ("known", VmValue::Bool(true)),
1141 ("stale", VmValue::Bool(stale)),
1142 (
1143 "indexed_hash",
1144 VmValue::String(arcstr::ArcStr::from(
1145 format!("{:016x}", file.content_hash).as_str(),
1146 )),
1147 ),
1148 ("indexed_mtime_ms", VmValue::Int(file.mtime_ms)),
1149 (
1150 "disk_hash",
1151 match disk_hash {
1152 Some(h) => VmValue::String(arcstr::ArcStr::from(format!("{h:016x}").as_str())),
1153 None => VmValue::Nil,
1154 },
1155 ),
1156 ("disk_mtime_ms", VmValue::Int(disk_mtime)),
1157 ]))
1158}
1159
1160struct FileHashSnapshotEntry {
1163 value: VmValue,
1164 path: String,
1165 hash: Option<String>,
1166}
1167
1168fn file_hash_snapshot_entry(state: &IndexState, path: &str) -> FileHashSnapshotEntry {
1169 let normalized = normalize_relative_path(state, path);
1170 let indexed_file = state
1171 .lookup_path(&normalized)
1172 .and_then(|id| state.files.get(&id));
1173 let abs = state
1174 .absolute_path(path)
1175 .or_else(|| state.absolute_path(&normalized));
1176 let (readable, hash, hash_source, disk_size, disk_mtime_ms) = match abs {
1177 Some(abs) => {
1178 let metadata = std::fs::metadata(&abs).ok();
1179 let mtime_ms = metadata
1180 .as_ref()
1181 .and_then(|m| m.modified().ok())
1182 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1183 .map(|d| d.as_millis() as i64);
1184 if let (Some(file), Some(metadata), Some(mtime_ms)) =
1185 (indexed_file, metadata.as_ref(), mtime_ms)
1186 {
1187 if metadata.len() == file.size_bytes && mtime_ms == file.mtime_ms {
1188 return file_hash_snapshot_value(
1189 state,
1190 normalized,
1191 indexed_file,
1192 true,
1193 Some(file.content_hash.to_string()),
1194 "indexed",
1195 VmValue::Int(file.size_bytes as i64),
1196 VmValue::Int(file.mtime_ms),
1197 );
1198 }
1199 }
1200 let bytes = match crate::fs::read(&abs, None) {
1201 Some(result) => result,
1202 None => std::fs::read(&abs),
1203 };
1204 match bytes {
1205 Ok(bytes) => {
1206 let hash = fnv1a64(&bytes).to_string();
1207 (
1208 true,
1209 Some(hash),
1210 "disk",
1211 VmValue::Int(bytes.len() as i64),
1212 mtime_ms.map(VmValue::Int).unwrap_or(VmValue::Nil),
1213 )
1214 }
1215 Err(_) => (false, None, "missing", VmValue::Nil, VmValue::Nil),
1216 }
1217 }
1218 None => (false, None, "missing", VmValue::Nil, VmValue::Nil),
1219 };
1220 file_hash_snapshot_value(
1221 state,
1222 normalized,
1223 indexed_file,
1224 readable,
1225 hash,
1226 hash_source,
1227 disk_size,
1228 disk_mtime_ms,
1229 )
1230}
1231
1232fn file_hash_snapshot_value(
1233 state: &IndexState,
1234 normalized: String,
1235 indexed_file: Option<&super::file_table::IndexedFile>,
1236 readable: bool,
1237 hash: Option<String>,
1238 hash_source: &str,
1239 disk_size: VmValue,
1240 disk_mtime_ms: VmValue,
1241) -> FileHashSnapshotEntry {
1242 let indexed_hash = indexed_file
1243 .map(|file| str_value(file.content_hash.to_string()))
1244 .unwrap_or(VmValue::Nil);
1245 let indexed_mtime_ms = indexed_file
1246 .map(|file| VmValue::Int(file.mtime_ms))
1247 .unwrap_or(VmValue::Nil);
1248 let last_edit_seq = state
1249 .versions
1250 .last_entry(&normalized)
1251 .map(|entry| entry.seq as i64)
1252 .unwrap_or(0);
1253 let hash_value = hash.as_ref().map(str_value).unwrap_or(VmValue::Nil);
1254 let value = build_dict([
1255 ("path", str_value(&normalized)),
1256 ("known", VmValue::Bool(indexed_file.is_some())),
1257 ("readable", VmValue::Bool(readable)),
1258 ("hash", hash_value),
1259 ("hash_source", str_value(hash_source)),
1260 ("size", disk_size),
1261 ("mtime_ms", disk_mtime_ms),
1262 ("indexed_hash", indexed_hash),
1263 ("indexed_mtime_ms", indexed_mtime_ms),
1264 ("last_edit_seq", VmValue::Int(last_edit_seq)),
1265 ]);
1266 FileHashSnapshotEntry {
1267 value,
1268 path: normalized,
1269 hash,
1270 }
1271}
1272
1273fn ensure_state<'a>(
1274 builtin: &'static str,
1275 guard: &'a mut std::sync::MutexGuard<'_, Option<IndexState>>,
1276) -> Result<&'a mut IndexState, HostlibError> {
1277 if guard.is_none() {
1278 return Err(HostlibError::Backend {
1279 builtin,
1280 message: "code index has not been initialised — call \
1281 `hostlib_code_index_rebuild` or restore from a snapshot first"
1282 .to_string(),
1283 });
1284 }
1285 Ok(guard.as_mut().unwrap())
1286}
1287
1288fn parse_hash(
1289 builtin: &'static str,
1290 dict: &harn_vm::value::DictMap,
1291 key: &'static str,
1292) -> Result<u64, HostlibError> {
1293 match dict.get(key) {
1294 None | Some(VmValue::Nil) => Ok(0),
1295 Some(VmValue::Int(n)) if *n >= 0 => Ok(*n as u64),
1296 Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1297 builtin,
1298 param: key,
1299 message: format!("must be >= 0, got {n}"),
1300 }),
1301 Some(VmValue::String(s)) => s
1302 .parse::<u64>()
1303 .map_err(|_| HostlibError::InvalidParameter {
1304 builtin,
1305 param: key,
1306 message: format!("expected u64-parseable string, got {s:?}"),
1307 }),
1308 Some(other) => Err(HostlibError::InvalidParameter {
1309 builtin,
1310 param: key,
1311 message: format!(
1312 "expected integer or numeric string, got {}",
1313 other.type_name()
1314 ),
1315 }),
1316 }
1317}
1318
1319fn require_positive_u64(
1320 builtin: &'static str,
1321 dict: &harn_vm::value::DictMap,
1322 key: &'static str,
1323) -> Result<u64, HostlibError> {
1324 let raw = require_non_negative_u64(builtin, dict, key)?;
1325 if raw == 0 {
1326 return Err(HostlibError::InvalidParameter {
1327 builtin,
1328 param: key,
1329 message: "must be >= 1".to_string(),
1330 });
1331 }
1332 Ok(raw)
1333}
1334
1335fn require_positive_file_id(
1336 builtin: &'static str,
1337 dict: &harn_vm::value::DictMap,
1338 key: &'static str,
1339) -> Result<FileId, HostlibError> {
1340 let raw = require_positive_u64(builtin, dict, key)?;
1341 FileId::try_from(raw).map_err(|_| HostlibError::InvalidParameter {
1342 builtin,
1343 param: key,
1344 message: "does not fit in file id".to_string(),
1345 })
1346}
1347
1348fn require_non_negative_u64(
1349 builtin: &'static str,
1350 dict: &harn_vm::value::DictMap,
1351 key: &'static str,
1352) -> Result<u64, HostlibError> {
1353 match value_args::optional_i64_no_default(builtin, dict, key)? {
1354 Some(value) if value >= 0 => Ok(value as u64),
1355 Some(value) => Err(HostlibError::InvalidParameter {
1356 builtin,
1357 param: key,
1358 message: format!("must be >= 0, got {value}"),
1359 }),
1360 None => Err(HostlibError::MissingParameter {
1361 builtin,
1362 param: key,
1363 }),
1364 }
1365}
1366
1367fn optional_positive_u64(
1368 builtin: &'static str,
1369 dict: &harn_vm::value::DictMap,
1370 key: &'static str,
1371) -> Result<Option<u64>, HostlibError> {
1372 match dict.get(key) {
1373 None | Some(VmValue::Nil) => Ok(None),
1374 Some(_) => require_positive_u64(builtin, dict, key).map(Some),
1375 }
1376}
1377
1378fn optional_non_negative_u64(
1379 builtin: &'static str,
1380 dict: &harn_vm::value::DictMap,
1381 key: &'static str,
1382 default: u64,
1383) -> Result<u64, HostlibError> {
1384 match dict.get(key) {
1385 None | Some(VmValue::Nil) => Ok(default),
1386 Some(_) => require_non_negative_u64(builtin, dict, key),
1387 }
1388}
1389
1390fn optional_positive_i64(
1391 builtin: &'static str,
1392 dict: &harn_vm::value::DictMap,
1393 key: &'static str,
1394) -> Result<Option<i64>, HostlibError> {
1395 match value_args::optional_i64_no_default(builtin, dict, key)? {
1396 None => Ok(None),
1397 Some(value) if value >= 1 => Ok(Some(value)),
1398 Some(value) => Err(HostlibError::InvalidParameter {
1399 builtin,
1400 param: key,
1401 message: format!("must be >= 1, got {value}"),
1402 }),
1403 }
1404}
1405
1406fn optional_positive_usize(
1407 builtin: &'static str,
1408 dict: &harn_vm::value::DictMap,
1409 key: &'static str,
1410) -> Result<Option<usize>, HostlibError> {
1411 match optional_positive_u64(builtin, dict, key)? {
1412 Some(value) => {
1413 usize::try_from(value)
1414 .map(Some)
1415 .map_err(|_| HostlibError::InvalidParameter {
1416 builtin,
1417 param: key,
1418 message: "does not fit in usize".to_string(),
1419 })
1420 }
1421 None => Ok(None),
1422 }
1423}
1424
1425pub(super) fn normalize_relative_path_for(state: &IndexState, path: &str) -> String {
1431 normalize_relative_path(state, path)
1432}
1433
1434fn normalize_relative_path(state: &IndexState, path: &str) -> String {
1435 if let Some(rel) = state
1436 .lookup_path(path)
1437 .and_then(|id| state.files.get(&id))
1438 .map(|f| f.relative_path.clone())
1439 {
1440 return rel;
1441 }
1442 let p = std::path::Path::new(path);
1443 if p.is_absolute() {
1444 if let Ok(rel) = p.strip_prefix(&state.root) {
1445 return to_agent_path(rel);
1446 }
1447 }
1448 to_agent_path_str(path)
1449}
1450
1451fn candidates_for(state: &IndexState, needle: &str) -> Vec<FileId> {
1452 if needle.len() >= 3 {
1453 let trigrams = trigram::query_trigrams(needle);
1454 return state.trigrams.query(&trigrams).into_iter().collect();
1455 }
1456 state.files.keys().copied().collect()
1457}
1458
1459fn read_file_text(root: &std::path::Path, relative: &str) -> Option<String> {
1460 let path = root.join(relative);
1461 match crate::fs::read_to_string(&path, None) {
1462 Some(result) => result.ok(),
1463 None => std::fs::read_to_string(path).ok(),
1464 }
1465}
1466
1467fn count_matches(haystack: &str, needle: &str, case_sensitive: bool) -> u64 {
1468 if case_sensitive {
1469 haystack.matches(needle).count() as u64
1470 } else {
1471 let lower_h = haystack.to_lowercase();
1472 let lower_n = needle.to_lowercase();
1473 lower_h.matches(&lower_n).count() as u64
1474 }
1475}
1476
1477fn scope_allows(scope: &[String], relative: &str) -> bool {
1478 if scope.is_empty() {
1479 return true;
1480 }
1481 scope
1482 .iter()
1483 .any(|s| relative == s || relative.starts_with(&format!("{s}/")) || s.is_empty())
1484}
1485
1486pub(super) struct Hit {
1487 pub(super) path: String,
1488 pub(super) match_count: u64,
1489 pub(super) root: Option<String>,
1492}
1493
1494fn hit_to_value(hit: Hit) -> VmValue {
1495 let Hit {
1496 path,
1497 match_count,
1498 root,
1499 } = hit;
1500 build_dict([
1501 ("path", str_value(&path)),
1502 ("score", VmValue::Float(match_count as f64)),
1503 ("match_count", VmValue::Int(match_count as i64)),
1504 (
1505 "root",
1506 match root {
1507 Some(r) => str_value(&r),
1508 None => VmValue::Nil,
1509 },
1510 ),
1511 ])
1512}
1513
1514fn import_entry(module: &str, resolved: Option<&str>, kind: &str) -> VmValue {
1515 let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
1516 map.insert(harn_vm::value::intern_key("module"), str_value(module));
1517 map.insert(
1518 harn_vm::value::intern_key("resolved_path"),
1519 match resolved {
1520 Some(p) => str_value(p),
1521 None => VmValue::Nil,
1522 },
1523 );
1524 map.insert(harn_vm::value::intern_key("kind"), str_value(kind));
1525 VmValue::dict(map)
1526}
1527
1528fn empty_stats_response() -> VmValue {
1529 build_dict([
1530 ("indexed_files", VmValue::Int(0)),
1531 ("trigrams", VmValue::Int(0)),
1532 ("words", VmValue::Int(0)),
1533 ("memory_bytes", VmValue::Int(0)),
1534 ("last_rebuild_unix_ms", VmValue::Nil),
1535 ])
1536}
1537
1538fn empty_imports_response(path: &str) -> VmValue {
1539 build_dict([
1540 ("path", str_value(path)),
1541 ("imports", VmValue::List(Arc::new(Vec::new()))),
1542 ])
1543}
1544
1545fn empty_importers_response(module: &str) -> VmValue {
1546 build_dict([
1547 ("module", str_value(module)),
1548 ("importers", VmValue::List(Arc::new(Vec::new()))),
1549 ])
1550}
1551
1552fn unknown_freshness_response(path: &str) -> VmValue {
1553 build_dict([
1554 ("path", str_value(path)),
1555 ("known", VmValue::Bool(false)),
1556 ("stale", VmValue::Bool(true)),
1557 ("indexed_hash", VmValue::Nil),
1558 ("indexed_mtime_ms", VmValue::Nil),
1559 ("disk_hash", VmValue::Nil),
1560 ("disk_mtime_ms", VmValue::Nil),
1561 ])
1562}