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