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