1use crate::CliError;
54use code_extract::{
55 call_lookup_names, extract, indexed_under, resolve_call, resolve_import, resolve_mention,
56 CallScope, FileFacts, SymbolIndex, MAX_FILE_BYTES,
57};
58use core_api::repograph::rules::{about_rule, concept_sources_rule, ABOUT_LABELS};
59use core_api::{default_max_edges, BatchOp, Predicate, RuleDef, Value};
60use std::collections::{BTreeMap, BTreeSet};
61use std::path::Path;
62
63pub type Db = <core_api::WriteGuard<'static> as std::ops::Deref>::Target;
67
68pub const MAX_SYMBOLS_PER_FILE: usize = 2_000;
72
73const BATCH_FILES: usize = 500;
75
76pub const DEFINES_RULE: &str = "auto_fk_symbol_file_id";
81
82pub const FULLTEXT: [(&str, &str); 8] = [
89 ("Concept", "name"),
90 ("Concept", "summary"),
91 ("File", "body"),
92 ("File", "headings"),
93 ("File", "path"),
94 ("Note", "text"),
95 ("Symbol", "doc"),
96 ("Symbol", "name"),
97];
98
99#[derive(Debug, Default, Clone, PartialEq, Eq)]
102pub struct StructureReport {
103 pub files_scanned: usize,
105 pub symbols: usize,
107 pub imports: usize,
109 pub mentions: usize,
111 pub calls: usize,
113 pub skipped_large: usize,
116 pub symbols_capped: usize,
118}
119
120#[must_use]
131pub fn rules() -> Vec<RuleDef> {
132 let mut out = vec![
133 key_rule(DEFINES_RULE, "Symbol", "File", "file_id", "DEFINES"),
134 key_rule("imports", "File", "File", "imports", "IMPORTS"),
135 key_rule("calls", "Symbol", "Symbol", "calls_to", "CALLS"),
136 key_rule("mentions", "File", "File", "mentions", "MENTIONS"),
137 concept_sources_rule(),
138 ];
139 for label in ABOUT_LABELS {
140 out.push(about_rule(label));
141 }
142 out
143}
144
145fn key_rule(name: &str, src: &str, dst: &str, field: &str, edge: &str) -> RuleDef {
148 let predicate = Predicate::KeyMatch {
149 field: field.into(),
150 };
151 let max_edges = Some(default_max_edges(&predicate));
152 RuleDef {
153 name: name.into(),
154 src_label: src.into(),
155 dst_label: dst.into(),
156 predicate,
157 edge_type: edge.into(),
158 weight_prop: None,
159 max_edges,
160 approximate: false,
161 via_label: None,
162 via_edge: None,
163 via_dir: None,
164 }
165}
166
167pub fn ensure_rules_and_fulltext(w: &mut Db) -> Result<Vec<String>, CliError> {
175 let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
176 let mut created = Vec::new();
177 for def in rules() {
178 if existing.contains(&def.name) {
179 continue;
180 }
181 if def.src_label == "Note" && !label_present(w, &def.dst_label)? {
184 continue;
185 }
186 let name = def.name.clone();
187 w.create_rule(def)?;
188 created.push(name);
189 }
190 for (label, field) in FULLTEXT {
191 if !w
192 .fulltext_pairs()
193 .contains(&(label.to_string(), field.to_string()))
194 {
195 w.enable_fulltext(label, field)?;
196 }
197 }
198 Ok(created)
199}
200
201fn label_present(w: &Db, label: &str) -> Result<bool, CliError> {
204 let rs = w.query(
205 &format!("MATCH (n:{label}) RETURN n.id AS id LIMIT 1"),
206 &BTreeMap::new(),
207 )?;
208 Ok(!rs.is_empty())
209}
210
211const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
215
216const SYMBOL_QUERY: &str =
218 "MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
219
220const LINK_LISTS_QUERY: &str =
222 "MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
223
224pub fn refresh_all(
229 w: &mut Db,
230 repo: &Path,
231 prefix: &str,
232 with_docs: bool,
233) -> Result<StructureReport, CliError> {
234 refresh(w, repo, prefix, None, with_docs)
235}
236
237pub fn refresh_files(
243 w: &mut Db,
244 repo: &Path,
245 prefix: &str,
246 paths: &[String],
247 with_docs: bool,
248) -> Result<StructureReport, CliError> {
249 refresh(w, repo, prefix, Some(paths), with_docs)
250}
251
252pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
261 if keys.is_empty() {
262 return Ok(Vec::new());
263 }
264 let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
265 let mut out = BTreeSet::new();
266 for i in 0..rs.len() {
267 let Some(Value::Str(id)) = rs.get(i, "id") else {
268 continue;
269 };
270 let names = |field: &str| {
271 matches!(rs.get(i, field), Some(Value::List(l))
272 if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
273 };
274 if names("imports") || names("mentions") {
275 out.insert(id.clone());
276 }
277 }
278 Ok(out.into_iter().collect())
279}
280
281#[derive(Default)]
289struct Tree {
290 files: BTreeSet<String>,
291 by_dir: BTreeMap<String, Vec<String>>,
292 by_base: BTreeMap<String, Vec<String>>,
293}
294
295impl Tree {
296 fn build(keys: impl IntoIterator<Item = String>) -> Tree {
297 let mut tree = Tree::default();
298 for key in keys {
299 let (dir, base) = match key.rsplit_once('/') {
300 Some((d, b)) => (d.to_string(), b.to_string()),
301 None => (String::new(), key.clone()),
302 };
303 tree.by_base.entry(base).or_default().push(key.clone());
304 tree.by_dir.entry(dir).or_default().push(key.clone());
305 tree.files.insert(key);
306 }
307 tree
308 }
309
310 fn known(&self, path: &str) -> bool {
311 self.files.contains(path)
312 }
313
314 fn files_in(&self, dir: &str) -> Vec<String> {
315 self.by_dir.get(dir).cloned().unwrap_or_default()
316 }
317
318 fn by_basename(&self, name: &str) -> Vec<String> {
319 self.by_base.get(name).cloned().unwrap_or_default()
320 }
321
322 fn roots(&self) -> BTreeSet<String> {
330 let mut out = BTreeSet::new();
331 for path in &self.files {
332 for (at, part) in path.split('/').enumerate() {
333 let last = at + 1 == path.split('/').count();
334 let name = match last {
335 true => part.rsplit_once('.').map_or(part, |(stem, _)| stem),
336 false => part,
337 };
338 if name.is_empty() {
339 continue;
340 }
341 out.insert(name.to_string());
342 if name.contains('-') {
343 out.insert(name.replace('-', "_"));
344 } else if name.contains('_') {
345 out.insert(name.replace('_', "-"));
346 }
347 }
348 }
349 out
350 }
351}
352
353struct SymbolWrite {
355 key: String,
356 name: String,
357 kind: &'static str,
358 line_start: u32,
359 line_end: u32,
360 signature: String,
361 doc: String,
362 calls: Vec<String>,
364 call_lines: Vec<String>,
366}
367
368struct FileWrite {
370 path: String,
371 hash: String,
372 lines: u32,
373 lang: &'static str,
374 imports: Vec<String>,
375 import_lines: Vec<String>,
376 mentions: Vec<String>,
377 headings: Vec<String>,
378 body: Option<String>,
379 symbols: Vec<SymbolWrite>,
380}
381
382fn list(items: &[String]) -> Value {
383 Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
384}
385
386fn some_list(items: &[String]) -> Option<Value> {
389 (!items.is_empty()).then(|| list(items))
390}
391
392impl FileWrite {
393 fn props(&self) -> Vec<(&'static str, Option<Value>)> {
396 vec![
397 ("hash", Some(Value::Str(self.hash.clone()))),
398 ("lines", Some(Value::Int(i64::from(self.lines)))),
399 ("lang", Some(Value::Str(self.lang.to_string()))),
400 ("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
401 ("imports", some_list(&self.imports)),
402 ("import_lines", some_list(&self.import_lines)),
403 ("mentions", some_list(&self.mentions)),
404 ("headings", some_list(&self.headings)),
405 ("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
406 ]
407 }
408}
409
410impl SymbolWrite {
411 fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
412 vec![
413 ("id", Some(Value::Str(self.key.clone()))),
414 ("name", Some(Value::Str(self.name.clone()))),
415 ("kind", Some(Value::Str(self.kind.to_string()))),
416 ("path", Some(Value::Str(file.to_string()))),
417 ("file_id", Some(Value::Str(file.to_string()))),
418 ("line_start", Some(Value::Int(i64::from(self.line_start)))),
419 ("line_end", Some(Value::Int(i64::from(self.line_end)))),
420 ("signature", Some(Value::Str(self.signature.clone()))),
421 ("doc", Some(Value::Str(self.doc.clone()))),
422 ("calls_to", some_list(&self.calls)),
423 ("call_lines", some_list(&self.call_lines)),
424 ]
425 }
426}
427
428fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
432 let mut seen = BTreeSet::new();
433 let mut out = Vec::new();
434 let mut capped = false;
435 for (at, sym) in facts.symbols.iter().enumerate() {
436 let key = format!("{path}#{}", sym.name);
437 if !seen.insert(key.clone()) {
438 continue;
439 }
440 if out.len() == MAX_SYMBOLS_PER_FILE {
441 capped = true;
442 break;
443 }
444 out.push((key, at));
445 }
446 (out, capped)
447}
448
449fn call_names(facts: &BTreeMap<String, FileFacts>) -> BTreeSet<String> {
461 let mut out = BTreeSet::new();
462 for f in facts.values() {
463 for sym in &f.symbols {
464 for call in &sym.calls {
465 out.extend(call_lookup_names(&call.callee));
466 }
467 }
468 }
469 out
470}
471
472fn refresh(
473 w: &mut Db,
474 repo: &Path,
475 prefix: &str,
476 only: Option<&[String]>,
477 with_docs: bool,
478) -> Result<StructureReport, CliError> {
479 let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
481 let rs = w.query(FILE_KEYS_QUERY, ¶ms)?;
482 let mut candidates = Vec::new();
483 for i in 0..rs.len() {
484 if let Some(Value::Str(id)) = rs.get(i, "id") {
485 if repo.join(id).is_file() {
486 candidates.push(id.clone());
487 }
488 }
489 }
490 let tree = Tree::build(candidates.iter().cloned());
491
492 let targets: Vec<String> = match only {
494 None => candidates,
495 Some(paths) => {
496 let wanted: BTreeSet<&String> = paths.iter().collect();
497 candidates
498 .into_iter()
499 .filter(|p| wanted.contains(p))
500 .collect()
501 }
502 };
503
504 let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
507 let mut hash_only: BTreeSet<String> = BTreeSet::new();
508 for path in &targets {
509 let Ok(bytes) = std::fs::read(repo.join(path)) else {
510 continue; };
512 if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
513 hash_only.insert(path.clone());
514 }
515 facts.insert(path.clone(), extract(path, &bytes));
516 }
517
518 let looked_up = only.map(|_| call_names(&facts));
528 let responsible: Option<BTreeSet<&str>> =
529 only.map(|paths| paths.iter().map(String::as_str).collect());
530
531 let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
532 let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
533 let mut orphans: Vec<String> = Vec::new();
534 let mut index = SymbolIndex::new();
535 for i in 0..stored.len() {
536 let (Some(Value::Str(id)), Some(Value::Str(file))) =
537 (stored.get(i, "id"), stored.get(i, "file_id"))
538 else {
539 continue;
540 };
541 if responsible
549 .as_ref()
550 .is_none_or(|r| r.contains(file.as_str()))
551 {
552 if !w.has_node(file.as_str()) {
553 orphans.push(id.clone());
554 continue;
555 }
556 by_file.entry(file.clone()).or_default().insert(id.clone());
557 }
558 if facts.contains_key(file) || !tree.known(file) {
559 continue; }
561 let Some(Value::Str(name)) = stored.get(i, "name") else {
562 continue;
563 };
564 if looked_up.as_ref().is_none_or(|names| {
568 indexed_under(name)
569 .iter()
570 .any(|under| names.contains(under))
571 }) {
572 index.insert(name, id);
573 }
574 }
575
576 let mut report = StructureReport::default();
579 let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
580 for (path, f) in &facts {
581 let (keys, capped) = symbol_keys(path, f);
582 for (key, at) in &keys {
583 index.insert(&f.symbols[*at].name, key);
584 }
585 report.symbols_capped += usize::from(capped);
586 keyed.insert(path, keys);
587 }
588
589 let roots = tree.roots();
591 let pass = Pass {
592 tree: &tree,
593 index: &index,
594 roots: &roots,
595 with_docs,
596 };
597
598 let mut writes: Vec<FileWrite> = Vec::new();
599 for (path, f) in &facts {
600 let write = resolve_file(path, f, &keyed[path], &pass);
601 report.files_scanned += 1;
602 report.symbols += write.symbols.len();
603 report.imports += write.imports.len();
604 report.mentions += write.mentions.len();
605 report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
606 report.skipped_large += usize::from(hash_only.contains(path));
607 writes.push(write);
608 }
609
610 if !orphans.is_empty() {
613 let ops = orphans
614 .iter()
615 .map(|key| BatchOp::DeleteNode { key: key.clone() })
616 .collect();
617 commit(w, ops)?;
618 }
619 for chunk in writes.chunks(BATCH_FILES) {
620 let mut ops = Vec::new();
621 for file in chunk {
622 plan_file(w, file, by_file.get(&file.path), &mut ops);
623 }
624 commit(w, ops)?;
625 }
626 Ok(report)
627}
628
629fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
631 if ops.is_empty() {
632 return Ok(());
633 }
634 let (results, sync) = w.commit_group(vec![ops]);
635 for r in results {
636 r?;
637 }
638 match sync {
639 Some(e) => Err(CliError(e.to_string())),
640 None => Ok(()),
641 }
642}
643
644fn is_binary(bytes: &[u8]) -> bool {
647 bytes[..bytes.len().min(8 * 1024)].contains(&0)
648}
649
650struct Pass<'a> {
653 tree: &'a Tree,
654 index: &'a SymbolIndex,
655 roots: &'a BTreeSet<String>,
657 with_docs: bool,
658}
659
660fn resolve_file(path: &str, f: &FileFacts, keys: &[(String, usize)], pass: &Pass<'_>) -> FileWrite {
662 let (tree, index, with_docs) = (pass.tree, pass.index, pass.with_docs);
663 let known = |p: &str| tree.known(p);
664 let files_in = |d: &str| tree.files_in(d);
665 let by_base = |n: &str| tree.by_basename(n);
666
667 let mut imports = BTreeSet::new();
668 let mut import_lines = BTreeSet::new();
669 for imp in &f.imports {
670 for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
671 if target == path {
672 continue;
673 }
674 import_lines.insert(format!("{target}\t{}", imp.line));
675 imports.insert(target);
676 }
677 }
678
679 let mut mentions = BTreeSet::new();
680 if with_docs {
681 for token in &f.mentions {
682 if let Some(target) = resolve_mention(path, token, &known, &by_base) {
683 if target != path {
684 mentions.insert(target);
685 }
686 }
687 }
688 }
689
690 let imported: Vec<String> = imports.iter().cloned().collect();
694 let scope = CallScope {
695 imports: &imported,
696 roots: pass.roots,
697 };
698
699 let mut symbols = Vec::with_capacity(keys.len());
700 for (key, at) in keys {
701 let fact = &f.symbols[*at];
702 let mut calls = BTreeSet::new();
703 let mut call_lines = BTreeSet::new();
704 for call in &fact.calls {
705 let Some(target) = resolve_call(path, call, index, &scope) else {
706 continue;
707 };
708 if &target == key {
709 continue; }
711 call_lines.insert(format!("{target}\t{}", call.line));
712 calls.insert(target);
713 }
714 symbols.push(SymbolWrite {
715 key: key.clone(),
716 name: fact.name.clone(),
717 kind: fact.kind,
718 line_start: fact.line_start,
719 line_end: fact.line_end,
720 signature: fact.signature.clone(),
721 doc: fact.doc.clone(),
722 calls: calls.into_iter().collect(),
723 call_lines: call_lines.into_iter().collect(),
724 });
725 }
726
727 FileWrite {
728 path: path.to_string(),
729 hash: f.hash.clone(),
730 lines: f.lines,
731 lang: f.lang.as_str(),
732 imports: imports.into_iter().collect(),
733 import_lines: import_lines.into_iter().collect(),
734 mentions: mentions.into_iter().collect(),
735 headings: if with_docs {
736 f.headings.clone()
737 } else {
738 Vec::new()
739 },
740 body: if with_docs { f.body.clone() } else { None },
741 symbols,
742 }
743}
744
745fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
750 for (field, want) in file.props() {
751 diff_prop(w, &file.path, field, want, ops);
752 }
753
754 let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
755 for key in held.into_iter().flatten() {
756 if !wanted.contains(key) {
757 ops.push(BatchOp::DeleteNode { key: key.clone() });
758 }
759 }
760 for sym in &file.symbols {
761 let props = sym.props(&file.path);
762 match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
763 Some(label) if label != "Symbol" => continue,
769 Some(_) => {
770 for (field, want) in props {
771 diff_prop(w, &sym.key, field, want, ops);
772 }
773 }
774 None => ops.push(BatchOp::InsertNode {
775 label: "Symbol".into(),
776 key: sym.key.clone(),
777 props: props
778 .into_iter()
779 .filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
780 .collect(),
781 }),
782 }
783 }
784}
785
786fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
788 let current = w.node_ref(key).and_then(|n| n.prop(field));
789 if current == want {
790 return;
791 }
792 match want {
793 Some(value) => ops.push(BatchOp::SetProp {
794 key: key.to_string(),
795 field: field.to_string(),
796 value,
797 }),
798 None => ops.push(BatchOp::RemoveProp {
799 key: key.to_string(),
800 field: field.to_string(),
801 }),
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn rules_cover_every_derived_structure_edge() {
811 let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
812 for want in [
813 DEFINES_RULE,
814 "imports",
815 "calls",
816 "mentions",
817 "concept_sources",
818 "about_author",
819 "about_concept",
820 "about_file",
821 "about_note",
822 "about_symbol",
823 ] {
824 assert!(names.contains(&want.to_string()), "missing rule {want}");
825 }
826 for def in rules() {
827 assert_eq!(
828 def.max_edges,
829 Some(default_max_edges(&def.predicate)),
830 "{} must state its fan-out",
831 def.name
832 );
833 }
834 }
835
836 #[test]
837 fn the_tree_answers_every_lookup_the_resolvers_need() {
838 let tree = Tree::build([
839 "src/lib.rs".to_string(),
840 "src/net/mod.rs".to_string(),
841 "README.md".to_string(),
842 ]);
843 assert!(tree.known("src/lib.rs"));
844 assert!(!tree.known("src/gone.rs"));
845 assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
846 assert_eq!(tree.files_in("nope"), Vec::<String>::new());
847 assert_eq!(
848 tree.by_basename("mod.rs"),
849 vec!["src/net/mod.rs".to_string()]
850 );
851 assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
852 }
853
854 #[test]
855 fn binary_probe_matches_the_extractors() {
856 assert!(!is_binary(b"pub fn a() {}"));
857 assert!(is_binary(b"pub fn a() {}\0"));
858 assert!(!is_binary(b""));
859 }
860}