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::{default_max_edges, BatchOp, Predicate, RuleDef, Value};
59use std::collections::{BTreeMap, BTreeSet};
60use std::path::Path;
61
62pub type Db = <core_api::WriteGuard<'static> as std::ops::Deref>::Target;
66
67pub const MAX_SYMBOLS_PER_FILE: usize = 2_000;
71
72const BATCH_FILES: usize = 500;
74
75pub const DEFINES_RULE: &str = "auto_fk_symbol_file_id";
80
81pub const FULLTEXT: [(&str, &str); 8] = [
88 ("Concept", "name"),
89 ("Concept", "summary"),
90 ("File", "body"),
91 ("File", "headings"),
92 ("File", "path"),
93 ("Note", "text"),
94 ("Symbol", "doc"),
95 ("Symbol", "name"),
96];
97
98#[derive(Debug, Default, Clone, PartialEq, Eq)]
101pub struct StructureReport {
102 pub files_scanned: usize,
104 pub symbols: usize,
106 pub imports: usize,
108 pub mentions: usize,
110 pub calls: usize,
112 pub skipped_large: usize,
115 pub symbols_capped: usize,
117}
118
119#[must_use]
133pub fn rules() -> Vec<RuleDef> {
134 vec![
135 key_rule(DEFINES_RULE, "Symbol", "File", "file_id", "DEFINES"),
136 key_rule("imports", "File", "File", "imports", "IMPORTS"),
137 key_rule("calls", "Symbol", "Symbol", "calls_to", "CALLS"),
138 key_rule("mentions", "File", "File", "mentions", "MENTIONS"),
139 concept_sources_rule(),
140 ]
141}
142
143#[must_use]
150pub fn concept_sources_rule() -> RuleDef {
151 key_rule(
152 "concept_sources",
153 "Concept",
154 "File",
155 "source_files",
156 "DESCRIBED_IN",
157 )
158}
159
160fn key_rule(name: &str, src: &str, dst: &str, field: &str, edge: &str) -> RuleDef {
163 let predicate = Predicate::KeyMatch {
164 field: field.into(),
165 };
166 let max_edges = Some(default_max_edges(&predicate));
167 RuleDef {
168 name: name.into(),
169 src_label: src.into(),
170 dst_label: dst.into(),
171 predicate,
172 edge_type: edge.into(),
173 weight_prop: None,
174 max_edges,
175 approximate: false,
176 via_label: None,
177 via_edge: None,
178 via_dir: None,
179 namespace: None,
180 }
181}
182
183#[must_use]
194pub fn ingest_git_schema() -> core_api::schema::Schema {
195 let mut schema = core_api::memory_schema::memory_defaults();
196 schema.rules.extend(rules());
197 for (label, field) in FULLTEXT {
198 schema
199 .fulltext
200 .push(((*label).to_string(), (*field).to_string()));
201 }
202 schema.fulltext.sort();
203 schema.fulltext.dedup();
204 schema
205}
206
207#[must_use]
217pub fn missing_structure_schema(w: &Db) -> core_api::schema::Schema {
218 let live: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
219 let declared = w.fulltext_pairs();
220 core_api::schema::Schema {
221 rules: rules()
222 .into_iter()
223 .filter(|r| !live.contains(&r.name))
224 .collect(),
225 fulltext: FULLTEXT
226 .iter()
227 .map(|(l, f)| ((*l).to_string(), (*f).to_string()))
228 .filter(|pair| !declared.contains(pair))
229 .collect(),
230 ..Default::default()
231 }
232}
233
234const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
238
239const SYMBOL_QUERY: &str =
241 "MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
242
243const LINK_LISTS_QUERY: &str =
245 "MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
246
247pub fn refresh_all(
252 w: &mut Db,
253 repo: &Path,
254 prefix: &str,
255 with_docs: bool,
256) -> Result<StructureReport, CliError> {
257 refresh(w, repo, prefix, None, with_docs)
258}
259
260pub fn refresh_files(
266 w: &mut Db,
267 repo: &Path,
268 prefix: &str,
269 paths: &[String],
270 with_docs: bool,
271) -> Result<StructureReport, CliError> {
272 refresh(w, repo, prefix, Some(paths), with_docs)
273}
274
275pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
284 if keys.is_empty() {
285 return Ok(Vec::new());
286 }
287 let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
288 let mut out = BTreeSet::new();
289 for i in 0..rs.len() {
290 let Some(Value::Str(id)) = rs.get(i, "id") else {
291 continue;
292 };
293 let names = |field: &str| {
294 matches!(rs.get(i, field), Some(Value::List(l))
295 if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
296 };
297 if names("imports") || names("mentions") {
298 out.insert(id.clone());
299 }
300 }
301 Ok(out.into_iter().collect())
302}
303
304#[derive(Default)]
312struct Tree {
313 files: BTreeSet<String>,
314 by_dir: BTreeMap<String, Vec<String>>,
315 by_base: BTreeMap<String, Vec<String>>,
316}
317
318impl Tree {
319 fn build(keys: impl IntoIterator<Item = String>) -> Tree {
320 let mut tree = Tree::default();
321 for key in keys {
322 let (dir, base) = match key.rsplit_once('/') {
323 Some((d, b)) => (d.to_string(), b.to_string()),
324 None => (String::new(), key.clone()),
325 };
326 tree.by_base.entry(base).or_default().push(key.clone());
327 tree.by_dir.entry(dir).or_default().push(key.clone());
328 tree.files.insert(key);
329 }
330 tree
331 }
332
333 fn known(&self, path: &str) -> bool {
334 self.files.contains(path)
335 }
336
337 fn files_in(&self, dir: &str) -> Vec<String> {
338 self.by_dir.get(dir).cloned().unwrap_or_default()
339 }
340
341 fn by_basename(&self, name: &str) -> Vec<String> {
342 self.by_base.get(name).cloned().unwrap_or_default()
343 }
344
345 fn roots(&self) -> BTreeSet<String> {
353 let mut out = BTreeSet::new();
354 for path in &self.files {
355 for (at, part) in path.split('/').enumerate() {
356 let last = at + 1 == path.split('/').count();
357 let name = match last {
358 true => part.rsplit_once('.').map_or(part, |(stem, _)| stem),
359 false => part,
360 };
361 if name.is_empty() {
362 continue;
363 }
364 out.insert(name.to_string());
365 if name.contains('-') {
366 out.insert(name.replace('-', "_"));
367 } else if name.contains('_') {
368 out.insert(name.replace('_', "-"));
369 }
370 }
371 }
372 out
373 }
374}
375
376struct SymbolWrite {
378 key: String,
379 name: String,
380 kind: &'static str,
381 line_start: u32,
382 line_end: u32,
383 signature: String,
384 doc: String,
385 calls: Vec<String>,
387 call_lines: Vec<String>,
389}
390
391struct FileWrite {
393 path: String,
394 hash: String,
395 lines: u32,
396 lang: &'static str,
397 imports: Vec<String>,
398 import_lines: Vec<String>,
399 mentions: Vec<String>,
400 headings: Vec<String>,
401 body: Option<String>,
402 symbols: Vec<SymbolWrite>,
403}
404
405fn list(items: &[String]) -> Value {
406 Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
407}
408
409fn some_list(items: &[String]) -> Option<Value> {
412 (!items.is_empty()).then(|| list(items))
413}
414
415impl FileWrite {
416 fn props(&self) -> Vec<(&'static str, Option<Value>)> {
419 vec![
420 ("hash", Some(Value::Str(self.hash.clone()))),
421 ("lines", Some(Value::Int(i64::from(self.lines)))),
422 ("lang", Some(Value::Str(self.lang.to_string()))),
423 ("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
424 ("imports", some_list(&self.imports)),
425 ("import_lines", some_list(&self.import_lines)),
426 ("mentions", some_list(&self.mentions)),
427 ("headings", some_list(&self.headings)),
428 ("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
429 ]
430 }
431}
432
433impl SymbolWrite {
434 fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
435 vec![
436 ("id", Some(Value::Str(self.key.clone()))),
437 ("name", Some(Value::Str(self.name.clone()))),
438 ("kind", Some(Value::Str(self.kind.to_string()))),
439 ("path", Some(Value::Str(file.to_string()))),
440 ("file_id", Some(Value::Str(file.to_string()))),
441 ("line_start", Some(Value::Int(i64::from(self.line_start)))),
442 ("line_end", Some(Value::Int(i64::from(self.line_end)))),
443 ("signature", Some(Value::Str(self.signature.clone()))),
444 ("doc", Some(Value::Str(self.doc.clone()))),
445 ("calls_to", some_list(&self.calls)),
446 ("call_lines", some_list(&self.call_lines)),
447 ]
448 }
449}
450
451fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
455 let mut seen = BTreeSet::new();
456 let mut out = Vec::new();
457 let mut capped = false;
458 for (at, sym) in facts.symbols.iter().enumerate() {
459 let key = format!("{path}#{}", sym.name);
460 if !seen.insert(key.clone()) {
461 continue;
462 }
463 if out.len() == MAX_SYMBOLS_PER_FILE {
464 capped = true;
465 break;
466 }
467 out.push((key, at));
468 }
469 (out, capped)
470}
471
472fn call_names(facts: &BTreeMap<String, FileFacts>) -> BTreeSet<String> {
484 let mut out = BTreeSet::new();
485 for f in facts.values() {
486 for sym in &f.symbols {
487 for call in &sym.calls {
488 out.extend(call_lookup_names(&call.callee));
489 }
490 }
491 }
492 out
493}
494
495fn refresh(
496 w: &mut Db,
497 repo: &Path,
498 prefix: &str,
499 only: Option<&[String]>,
500 with_docs: bool,
501) -> Result<StructureReport, CliError> {
502 let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
504 let rs = w.query(FILE_KEYS_QUERY, ¶ms)?;
505 let mut candidates = Vec::new();
506 for i in 0..rs.len() {
507 if let Some(Value::Str(id)) = rs.get(i, "id") {
508 if repo.join(id).is_file() {
509 candidates.push(id.clone());
510 }
511 }
512 }
513 let tree = Tree::build(candidates.iter().cloned());
514
515 let targets: Vec<String> = match only {
517 None => candidates,
518 Some(paths) => {
519 let wanted: BTreeSet<&String> = paths.iter().collect();
520 candidates
521 .into_iter()
522 .filter(|p| wanted.contains(p))
523 .collect()
524 }
525 };
526
527 let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
530 let mut hash_only: BTreeSet<String> = BTreeSet::new();
531 for path in &targets {
532 let Ok(bytes) = std::fs::read(repo.join(path)) else {
533 continue; };
535 if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
536 hash_only.insert(path.clone());
537 }
538 facts.insert(path.clone(), extract(path, &bytes));
539 }
540
541 let looked_up = only.map(|_| call_names(&facts));
551 let responsible: Option<BTreeSet<&str>> =
552 only.map(|paths| paths.iter().map(String::as_str).collect());
553
554 let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
555 let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
556 let mut orphans: Vec<String> = Vec::new();
557 let mut index = SymbolIndex::new();
558 for i in 0..stored.len() {
559 let (Some(Value::Str(id)), Some(Value::Str(file))) =
560 (stored.get(i, "id"), stored.get(i, "file_id"))
561 else {
562 continue;
563 };
564 if responsible
572 .as_ref()
573 .is_none_or(|r| r.contains(file.as_str()))
574 {
575 if !w.has_node(file.as_str()) {
576 orphans.push(id.clone());
577 continue;
578 }
579 by_file.entry(file.clone()).or_default().insert(id.clone());
580 }
581 if facts.contains_key(file) || !tree.known(file) {
582 continue; }
584 let Some(Value::Str(name)) = stored.get(i, "name") else {
585 continue;
586 };
587 if looked_up.as_ref().is_none_or(|names| {
591 indexed_under(name)
592 .iter()
593 .any(|under| names.contains(under))
594 }) {
595 index.insert(name, id);
596 }
597 }
598
599 let mut report = StructureReport::default();
602 let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
603 for (path, f) in &facts {
604 let (keys, capped) = symbol_keys(path, f);
605 for (key, at) in &keys {
606 index.insert(&f.symbols[*at].name, key);
607 }
608 report.symbols_capped += usize::from(capped);
609 keyed.insert(path, keys);
610 }
611
612 let roots = tree.roots();
614 let pass = Pass {
615 tree: &tree,
616 index: &index,
617 roots: &roots,
618 with_docs,
619 };
620
621 let mut writes: Vec<FileWrite> = Vec::new();
622 for (path, f) in &facts {
623 let write = resolve_file(path, f, &keyed[path], &pass);
624 report.files_scanned += 1;
625 report.symbols += write.symbols.len();
626 report.imports += write.imports.len();
627 report.mentions += write.mentions.len();
628 report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
629 report.skipped_large += usize::from(hash_only.contains(path));
630 writes.push(write);
631 }
632
633 if !orphans.is_empty() {
636 let ops = orphans
637 .iter()
638 .map(|key| BatchOp::DeleteNode { key: key.clone() })
639 .collect();
640 commit(w, ops)?;
641 }
642 for chunk in writes.chunks(BATCH_FILES) {
643 let mut ops = Vec::new();
644 for file in chunk {
645 plan_file(w, file, by_file.get(&file.path), &mut ops);
646 }
647 commit(w, ops)?;
648 }
649 Ok(report)
650}
651
652fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
654 if ops.is_empty() {
655 return Ok(());
656 }
657 let (results, sync) = w.commit_group(vec![ops]);
658 for r in results {
659 r?;
660 }
661 match sync {
662 Some(e) => Err(CliError(e.to_string())),
663 None => Ok(()),
664 }
665}
666
667fn is_binary(bytes: &[u8]) -> bool {
670 bytes[..bytes.len().min(8 * 1024)].contains(&0)
671}
672
673struct Pass<'a> {
676 tree: &'a Tree,
677 index: &'a SymbolIndex,
678 roots: &'a BTreeSet<String>,
680 with_docs: bool,
681}
682
683fn resolve_file(path: &str, f: &FileFacts, keys: &[(String, usize)], pass: &Pass<'_>) -> FileWrite {
685 let (tree, index, with_docs) = (pass.tree, pass.index, pass.with_docs);
686 let known = |p: &str| tree.known(p);
687 let files_in = |d: &str| tree.files_in(d);
688 let by_base = |n: &str| tree.by_basename(n);
689
690 let mut imports = BTreeSet::new();
691 let mut import_lines = BTreeSet::new();
692 for imp in &f.imports {
693 for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
694 if target == path {
695 continue;
696 }
697 import_lines.insert(format!("{target}\t{}", imp.line));
698 imports.insert(target);
699 }
700 }
701
702 let mut mentions = BTreeSet::new();
703 if with_docs {
704 for token in &f.mentions {
705 if let Some(target) = resolve_mention(path, token, &known, &by_base) {
706 if target != path {
707 mentions.insert(target);
708 }
709 }
710 }
711 }
712
713 let imported: Vec<String> = imports.iter().cloned().collect();
717 let scope = CallScope {
718 imports: &imported,
719 roots: pass.roots,
720 };
721
722 let mut symbols = Vec::with_capacity(keys.len());
723 for (key, at) in keys {
724 let fact = &f.symbols[*at];
725 let mut calls = BTreeSet::new();
726 let mut call_lines = BTreeSet::new();
727 for call in &fact.calls {
728 let Some(target) = resolve_call(path, call, index, &scope) else {
729 continue;
730 };
731 if &target == key {
732 continue; }
734 call_lines.insert(format!("{target}\t{}", call.line));
735 calls.insert(target);
736 }
737 symbols.push(SymbolWrite {
738 key: key.clone(),
739 name: fact.name.clone(),
740 kind: fact.kind,
741 line_start: fact.line_start,
742 line_end: fact.line_end,
743 signature: fact.signature.clone(),
744 doc: fact.doc.clone(),
745 calls: calls.into_iter().collect(),
746 call_lines: call_lines.into_iter().collect(),
747 });
748 }
749
750 FileWrite {
751 path: path.to_string(),
752 hash: f.hash.clone(),
753 lines: f.lines,
754 lang: f.lang.as_str(),
755 imports: imports.into_iter().collect(),
756 import_lines: import_lines.into_iter().collect(),
757 mentions: mentions.into_iter().collect(),
758 headings: if with_docs {
759 f.headings.clone()
760 } else {
761 Vec::new()
762 },
763 body: if with_docs { f.body.clone() } else { None },
764 symbols,
765 }
766}
767
768fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
773 for (field, want) in file.props() {
774 diff_prop(w, &file.path, field, want, ops);
775 }
776
777 let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
778 for key in held.into_iter().flatten() {
779 if !wanted.contains(key) {
780 ops.push(BatchOp::DeleteNode { key: key.clone() });
781 }
782 }
783 for sym in &file.symbols {
784 let props = sym.props(&file.path);
785 match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
786 Some(label) if label != "Symbol" => continue,
792 Some(_) => {
793 for (field, want) in props {
794 diff_prop(w, &sym.key, field, want, ops);
795 }
796 }
797 None => ops.push(BatchOp::InsertNode {
798 label: "Symbol".into(),
799 key: sym.key.clone(),
800 props: props
801 .into_iter()
802 .filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
803 .collect(),
804 }),
805 }
806 }
807}
808
809fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
811 let current = w.node_ref(key).and_then(|n| n.prop(field));
812 if current == want {
813 return;
814 }
815 match want {
816 Some(value) => ops.push(BatchOp::SetProp {
817 key: key.to_string(),
818 field: field.to_string(),
819 value,
820 }),
821 None => ops.push(BatchOp::RemoveProp {
822 key: key.to_string(),
823 field: field.to_string(),
824 }),
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 #[test]
833 fn rules_cover_every_derived_structure_edge() {
834 let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
835 for want in [
836 DEFINES_RULE,
837 "imports",
838 "calls",
839 "mentions",
840 "concept_sources",
841 ] {
842 assert!(names.contains(&want.to_string()), "missing rule {want}");
843 }
844 assert!(
847 !names.iter().any(|n| n.starts_with("about_")),
848 "ingest-git must declare no about_* rule: {names:?}"
849 );
850 for def in rules() {
851 assert_eq!(
852 def.max_edges,
853 Some(default_max_edges(&def.predicate)),
854 "{} must state its fan-out",
855 def.name
856 );
857 }
858 }
859
860 #[test]
861 fn concept_sources_rule_is_concept_to_file() {
862 let r = concept_sources_rule();
863 assert_eq!(r.name, "concept_sources");
864 assert_eq!(r.src_label, "Concept");
865 assert_eq!(r.dst_label, "File");
866 assert_eq!(r.edge_type, "DESCRIBED_IN");
867 }
868
869 #[test]
870 fn the_tree_answers_every_lookup_the_resolvers_need() {
871 let tree = Tree::build([
872 "src/lib.rs".to_string(),
873 "src/net/mod.rs".to_string(),
874 "README.md".to_string(),
875 ]);
876 assert!(tree.known("src/lib.rs"));
877 assert!(!tree.known("src/gone.rs"));
878 assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
879 assert_eq!(tree.files_in("nope"), Vec::<String>::new());
880 assert_eq!(
881 tree.by_basename("mod.rs"),
882 vec!["src/net/mod.rs".to_string()]
883 );
884 assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
885 }
886
887 #[test]
888 fn binary_probe_matches_the_extractors() {
889 assert!(!is_binary(b"pub fn a() {}"));
890 assert!(is_binary(b"pub fn a() {}\0"));
891 assert!(!is_binary(b""));
892 }
893}