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