1pub mod call_edges;
4pub mod js_workspace;
5pub mod load;
6pub mod other_edges;
7pub mod routes;
8pub mod semantic_edges;
9pub mod type_edges;
10
11use crate::models::{ParseResult, ProjectInfo, SourceRoot};
12use crate::parsers::{detect_languages, get_parser, language_for_path};
13use kglite::api::DirGraph;
18use rayon::prelude::*;
19use std::collections::{BTreeMap, BTreeSet, HashSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use walkdir::WalkDir;
23
24pub(crate) fn class_node_type(kind: &str) -> &'static str {
33 match kind {
34 "struct" => "Struct",
35 "mixin" => "Mixin",
36 _ => "Class",
37 }
38}
39
40pub(crate) fn timing_enabled(verbose: bool) -> bool {
54 verbose || std::env::var_os("KGLITE_CODE_TREE_VERBOSE").is_some()
55}
56
57#[allow(clippy::too_many_arguments)]
62pub fn run_with_options(
63 input: &Path,
64 verbose: bool,
65 include_tests: bool,
66 save_to: Option<&Path>,
67 max_loc_per_file: Option<usize>,
68 include_docs: bool,
69) -> Result<Arc<DirGraph>, String> {
70 run_with_options_stats(
71 input,
72 verbose,
73 include_tests,
74 save_to,
75 max_loc_per_file,
76 include_docs,
77 )
78 .map(|(graph, _stats)| graph)
79}
80
81#[allow(clippy::too_many_arguments)]
94pub fn run_with_options_stats(
95 input: &Path,
96 verbose: bool,
97 include_tests: bool,
98 save_to: Option<&Path>,
99 max_loc_per_file: Option<usize>,
100 include_docs: bool,
101) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
102 let input = input.canonicalize().unwrap_or_else(|_| input.to_path_buf());
103
104 let t_manifest = std::time::Instant::now();
107 let (project_root, mut project_info) = if input.is_file() {
108 let project_root = input
109 .parent()
110 .map(PathBuf::from)
111 .unwrap_or_else(|| input.clone());
112 let info =
113 crate::manifest::try_read_manifest_file(&input, &project_root)?.ok_or_else(|| {
114 format!(
115 "Not a recognised manifest file: {}",
116 input.file_name().and_then(|o| o.to_str()).unwrap_or(""),
117 )
118 })?;
119 (project_root, Some(info))
120 } else if input.is_dir() {
121 let info = crate::manifest::try_read_manifest(&input)?;
122 (input.clone(), info)
123 } else {
124 return Err(format!("Not a file or directory: {}", input.display()));
125 };
126 if timing_enabled(verbose) {
127 eprintln!(
128 "[timing] manifest discovery: {:.3}s",
129 t_manifest.elapsed().as_secs_f64()
130 );
131 }
132
133 let mut combined = ParseResult::new();
134 let mut parsed_any = false;
135
136 if let Some(info) = &mut project_info {
137 if info.source_roots.is_empty() {
138 if verbose {
143 eprintln!(
144 "Manifest: {} ({}) — no source roots declared, scanning whole repo",
145 info.manifest_path,
146 info.build_system.as_deref().unwrap_or("")
147 );
148 }
149 } else {
150 let mut roots: Vec<_> = info.source_roots.clone();
151 if include_tests {
152 roots.extend(info.test_roots.iter().cloned());
153 }
154 let roots = collapse_covered_roots(roots);
155 if verbose {
156 eprintln!(
157 "Manifest: {} ({})",
158 info.manifest_path,
159 info.build_system.as_deref().unwrap_or("")
160 );
161 let labels: Vec<String> = roots
162 .iter()
163 .map(|r| {
164 r.path
165 .strip_prefix(&project_root)
166 .map(|p| p.display().to_string())
167 .unwrap_or_else(|_| r.path.display().to_string())
168 })
169 .collect();
170 eprintln!("Source roots: {}", labels.join(", "));
171 }
172 let t_parse = std::time::Instant::now();
173 let walk_dirs: Vec<&Path> = roots
177 .iter()
178 .map(|root| root.path.as_path())
179 .filter(|path| path.is_dir())
180 .collect();
181 if !walk_dirs.is_empty() {
182 let result = parse_roots(&walk_dirs, &project_root, verbose, max_loc_per_file);
183 combined.merge(result);
184 parsed_any = true;
185 }
186 if timing_enabled(verbose) && parsed_any {
187 eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
188 }
189 }
190 }
191
192 if !parsed_any {
193 if !project_root.is_dir() {
194 return Err(format!("Not a directory: {}", project_root.display()));
195 }
196 let t_parse = std::time::Instant::now();
197 let result = parse_roots(
198 &[project_root.as_path()],
199 &project_root,
200 verbose,
201 max_loc_per_file,
202 );
203 combined.merge(result);
204 if timing_enabled(verbose) {
205 eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
206 }
207 }
208
209 if !include_tests {
210 retain_non_test_entities(&mut combined);
211 }
212 if let Some(info) = &mut project_info {
213 reconcile_project_languages(info, &combined);
214 } else {
215 project_info = infer_project_info(&project_root, &combined);
219 }
220
221 finalize_and_load(
222 combined,
223 project_info,
224 &project_root,
225 include_docs,
226 verbose,
227 save_to,
228 )
229}
230
231fn collapse_covered_roots(roots: Vec<SourceRoot>) -> Vec<SourceRoot> {
235 let mut kept: Vec<(PathBuf, SourceRoot)> = Vec::new();
236 for root in roots {
237 let path = root
238 .path
239 .canonicalize()
240 .unwrap_or_else(|_| root.path.clone());
241 if kept.iter().any(|(ancestor, _)| path.starts_with(ancestor)) {
242 continue;
243 }
244 kept.retain(|(candidate, _)| !candidate.starts_with(&path));
245 kept.push((path, root));
246 }
247 kept.into_iter().map(|(_, root)| root).collect()
248}
249
250fn retain_non_test_entities(result: &mut ParseResult) {
254 let test_paths: HashSet<String> = result
255 .files
256 .iter()
257 .filter(|file| file.is_test)
258 .map(|file| file.path.clone())
259 .collect();
260 if test_paths.is_empty() {
261 return;
262 }
263
264 let removed_types: HashSet<String> = result
265 .classes
266 .iter()
267 .filter(|item| test_paths.contains(&item.file_path))
268 .map(|item| item.qualified_name.clone())
269 .chain(
270 result
271 .interfaces
272 .iter()
273 .filter(|item| test_paths.contains(&item.file_path))
274 .map(|item| item.qualified_name.clone()),
275 )
276 .collect();
277
278 result.files.retain(|item| !item.is_test);
279 result
280 .functions
281 .retain(|item| !test_paths.contains(&item.file_path));
282 result
283 .classes
284 .retain(|item| !test_paths.contains(&item.file_path));
285 result
286 .enums
287 .retain(|item| !test_paths.contains(&item.file_path));
288 result
289 .interfaces
290 .retain(|item| !test_paths.contains(&item.file_path));
291 result
292 .attributes
293 .retain(|item| !test_paths.contains(&item.file_path));
294 result
295 .constants
296 .retain(|item| !test_paths.contains(&item.file_path));
297 result
298 .elements
299 .retain(|item| !test_paths.contains(&item.file_path));
300 result
301 .selectors
302 .retain(|item| !test_paths.contains(&item.file_path));
303
304 let retained_functions: HashSet<&str> = result
305 .functions
306 .iter()
307 .map(|item| item.qualified_name.as_str())
308 .collect();
309 let retained_constants: HashSet<&str> = result
310 .constants
311 .iter()
312 .map(|item| item.qualified_name.as_str())
313 .collect();
314 result.control_transfers.retain(|site| {
315 retained_functions.contains(site.caller.as_str())
316 && site
317 .target
318 .as_deref()
319 .is_none_or(|target| retained_functions.contains(target))
320 });
321 result.reference_sites.retain(|site| {
322 retained_functions.contains(site.caller.as_str())
323 && retained_constants.contains(site.target.as_str())
324 });
325 result.symbol_relationships.retain(|relationship| {
326 retained_constants.contains(relationship.source.as_str())
327 && match relationship.target_kind {
328 crate::models::SymbolTargetKind::Constant => {
329 retained_constants.contains(relationship.target.as_str())
330 }
331 crate::models::SymbolTargetKind::Function => {
332 retained_functions.contains(relationship.target.as_str())
333 }
334 }
335 });
336
337 let retained_types: HashSet<&str> = result
338 .classes
339 .iter()
340 .map(|item| item.qualified_name.as_str())
341 .chain(
342 result
343 .interfaces
344 .iter()
345 .map(|item| item.qualified_name.as_str()),
346 )
347 .collect();
348 result.type_relationships.retain_mut(|relationship| {
349 relationship
350 .methods
351 .retain(|method| !test_paths.contains(&method.file_path));
352 !removed_types.contains(&relationship.source_type)
353 || retained_types.contains(relationship.source_type.as_str())
354 });
355}
356
357fn infer_project_info(project_root: &Path, result: &ParseResult) -> Option<ProjectInfo> {
376 let name = project_root.file_name().and_then(|name| name.to_str())?;
377 if name.is_empty() {
378 return None;
379 }
380 let mut info = ProjectInfo {
381 name: name.to_string(),
382 manifest_path: crate::models::INFERRED_MANIFEST.to_string(),
383 ..ProjectInfo::default()
384 };
385 reconcile_project_languages(&mut info, result);
386 Some(info)
387}
388
389fn reconcile_project_languages(project: &mut ProjectInfo, result: &ParseResult) {
390 let present: BTreeSet<&str> = result
391 .files
392 .iter()
393 .map(|file| file.language.as_str())
394 .filter(|language| !language.is_empty())
395 .collect();
396 let mut known: HashSet<String> = project.languages.iter().cloned().collect();
397 for language in crate::parsers::registry::LANGUAGES {
398 if present.contains(language.id) && known.insert(language.id.to_string()) {
399 project.languages.push(language.id.to_string());
400 }
401 }
402 for language in present {
403 if known.insert(language.to_string()) {
404 project.languages.push(language.to_string());
405 }
406 }
407}
408
409fn collect_files_by_lang(walk_dir: &Path, verbose: bool) -> BTreeMap<&'static str, Vec<PathBuf>> {
418 let t_walk = std::time::Instant::now();
424 let mut by_lang: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new();
425 for entry in WalkDir::new(walk_dir)
430 .into_iter()
431 .filter_entry(crate::manifest::walk_filter)
432 .filter_map(Result::ok)
433 {
434 if !entry.file_type().is_file() {
435 continue;
436 }
437 if let Some(lang) = language_for_path(entry.path()) {
438 by_lang
439 .entry(lang)
440 .or_default()
441 .push(entry.path().to_path_buf());
442 }
443 }
444
445 if by_lang.contains_key("cpp") {
453 if let Some(c_files) = by_lang.remove("c") {
454 let (h_files, c_only): (Vec<PathBuf>, Vec<PathBuf>) = c_files
455 .into_iter()
456 .partition(|p| p.extension().and_then(|e| e.to_str()) == Some("h"));
457 if !h_files.is_empty() {
458 by_lang.entry("cpp").or_default().extend(h_files);
459 }
460 if !c_only.is_empty() {
461 by_lang.insert("c", c_only);
462 }
463 }
464 }
465
466 for files in by_lang.values_mut() {
470 files.sort();
471 }
472
473 if verbose {
474 let langs: Vec<&'static str> = by_lang.keys().copied().collect();
475 eprintln!(
476 " Detected languages in {}: {:?}",
477 walk_dir.display(),
478 langs
479 );
480 for lang in &langs {
481 eprintln!(" Found {} {} files", by_lang[lang].len(), lang);
482 }
483 }
484 if timing_enabled(verbose) {
485 eprintln!("[timing] walk: {:.3}s", t_walk.elapsed().as_secs_f64());
486 }
487
488 by_lang
489}
490
491struct ParseGroup {
495 parser: Box<dyn crate::parsers::LanguageParser + Send + Sync>,
496 file_count: usize,
497 skipped: Vec<crate::models::FileInfo>,
498}
499
500fn parse_roots(
520 walk_dirs: &[&Path],
521 project_root: &Path,
522 verbose: bool,
523 max_loc_per_file: Option<usize>,
524) -> ParseResult {
525 let mut worklist: Vec<(usize, PathBuf)> = Vec::new();
527 let mut groups: Vec<ParseGroup> = Vec::new();
528
529 for walk_dir in walk_dirs {
530 for (lang, files) in collect_files_by_lang(walk_dir, verbose) {
531 let Some(parser) = get_parser(lang) else {
532 continue;
533 };
534 let (to_parse, skipped) = match max_loc_per_file {
538 Some(threshold) => prefilter_oversized(&files, threshold, project_root, lang),
539 None => (files, Vec::new()),
540 };
541 if verbose && !skipped.is_empty() {
542 eprintln!(
543 " Skipped {} {} files over max_loc_per_file (threshold {})",
544 skipped.len(),
545 lang,
546 max_loc_per_file.unwrap_or(0)
547 );
548 }
549 let group_index = groups.len();
550 groups.push(ParseGroup {
551 parser,
552 file_count: to_parse.len(),
553 skipped,
554 });
555 worklist.extend(to_parse.into_iter().map(|file| (group_index, file)));
556 }
557 }
558
559 let t_dispatch = std::time::Instant::now();
560 let per_file: Vec<ParseResult> = crate::parsers::parser_pool().install(|| {
563 worklist
564 .par_iter()
565 .map(|(group_index, file)| groups[*group_index].parser.parse_file(file, project_root))
566 .collect()
567 });
568 if timing_enabled(verbose) {
569 eprintln!(
570 "[timing] parse dispatch: {:.3}s ({} files)",
571 t_dispatch.elapsed().as_secs_f64(),
572 worklist.len()
573 );
574 }
575
576 let mut results = per_file.into_iter();
577 let mut combined = ParseResult::new();
578 for group in groups {
579 let slice: Vec<ParseResult> = results.by_ref().take(group.file_count).collect();
580 let mut result = group.parser.finalize(slice);
581 result.files.extend(group.skipped);
582 combined.merge(result);
583 }
584 combined
585}
586
587fn prefilter_oversized(
595 files: &[PathBuf],
596 threshold: usize,
597 src_root: &Path,
598 language: &str,
599) -> (Vec<PathBuf>, Vec<crate::models::FileInfo>) {
600 use std::io::{BufRead, BufReader};
601 let mut to_parse = Vec::with_capacity(files.len());
602 let mut skipped = Vec::new();
603 for fp in files {
604 let size_bytes = std::fs::metadata(fp).map(|m| m.len() as usize).unwrap_or(0);
607 if size_bytes <= threshold {
608 to_parse.push(fp.clone());
609 continue;
610 }
611 let Ok(file) = std::fs::File::open(fp) else {
613 to_parse.push(fp.clone());
614 continue;
615 };
616 let mut reader = BufReader::new(file);
617 let mut buf = Vec::new();
618 let mut loc: usize = 0;
619 let mut over = false;
620 while let Ok(n) = reader.read_until(b'\n', &mut buf) {
621 if n == 0 {
622 break;
623 }
624 loc += 1;
625 buf.clear();
626 if loc > threshold {
627 over = true;
628 break;
629 }
630 }
631 if over {
632 while let Ok(n) = reader.read_until(b'\n', &mut buf) {
635 if n == 0 {
636 break;
637 }
638 loc += 1;
639 buf.clear();
640 }
641 let rel_path = fp.strip_prefix(src_root).unwrap_or(fp);
642 let filename = fp
643 .file_name()
644 .and_then(|s| s.to_str())
645 .unwrap_or("")
646 .to_string();
647 skipped.push(crate::models::FileInfo {
648 path: rel_path.display().to_string(),
649 filename,
650 loc: loc as u32,
651 module_path: String::new(),
652 language: language.to_string(),
653 submodule_declarations: Vec::new(),
654 imports: Vec::new(),
655 exports: Vec::new(),
656 annotations: None,
657 is_test: false,
658 skip_reason: Some("too_large".into()),
659 });
660 } else {
661 to_parse.push(fp.clone());
662 }
663 }
664 (to_parse, skipped)
665}
666
667#[allow(clippy::too_many_arguments)]
668fn finalize_and_load(
669 mut combined: ParseResult,
670 project_info: Option<crate::models::ProjectInfo>,
671 project_root: &Path,
672 include_docs: bool,
673 verbose: bool,
674 save_to: Option<&Path>,
675) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
676 if verbose {
677 eprintln!(
678 "Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
679 combined.files.len(),
680 combined.functions.len(),
681 combined.classes.len(),
682 combined.enums.len(),
683 combined.interfaces.len(),
684 combined.attributes.len(),
685 combined.constants.len()
686 );
687 }
688
689 let t_dedup = std::time::Instant::now();
690 dedup_by_key(&mut combined.files, |f| f.path.clone());
691 preserve_overloaded_function_ids(&mut combined.functions, &mut combined.type_relationships);
692 dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
693 dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
694 dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
695 dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
696 dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
697 combined.control_transfers.sort();
698 combined.control_transfers.dedup();
699 combined.reference_sites.sort();
700 combined.reference_sites.dedup();
701 combined.symbol_relationships.sort();
702 combined.symbol_relationships.dedup();
703 if timing_enabled(verbose) {
704 eprintln!("[timing] dedup: {:.3}s", t_dedup.elapsed().as_secs_f64());
705 }
706
707 let t_load = std::time::Instant::now();
708 let t_ws = std::time::Instant::now();
712 let js_workspace = if combined
713 .files
714 .iter()
715 .any(|f| crate::parsers::registry::uses_module_path_imports(&f.language))
716 {
717 js_workspace::JsWorkspace::discover(project_root)
718 } else {
719 js_workspace::JsWorkspace::default()
720 };
721 if timing_enabled(verbose) {
722 eprintln!(
723 "[timing] js workspace discovery: {:.3}s",
724 t_ws.elapsed().as_secs_f64()
725 );
726 }
727 let (mut graph, call_stats) =
729 load::load_into_graph(&combined, project_info.as_ref(), &js_workspace)?;
730 if timing_enabled(verbose) {
731 eprintln!("[timing] load: {:.3}s", t_load.elapsed().as_secs_f64());
732 }
733
734 if include_docs {
738 let t_docs = std::time::Instant::now();
739 #[cfg(feature = "docs")]
740 {
741 let g =
742 std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
743 crate::docs::ingest_and_link(
744 g,
745 project_root,
746 project_info.as_ref().map(|info| info.name.as_str()),
747 verbose,
748 )?;
749 if timing_enabled(verbose) {
750 eprintln!("[timing] docs: {:.3}s", t_docs.elapsed().as_secs_f64());
751 }
752 }
753 #[cfg(not(feature = "docs"))]
754 {
755 let _ = (project_root, t_docs);
756 return Err("include_docs=true requires the 'okf' Cargo feature".into());
757 }
758 }
759
760 {
765 let g = std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
766 let t_xlang = std::time::Instant::now();
767 crate::cross_lang::ingest_http_cross_edges(g, project_root, verbose)?;
768 if timing_enabled(verbose) {
769 eprintln!(
770 "[timing] cross-lang: {:.3}s",
771 t_xlang.elapsed().as_secs_f64()
772 );
773 }
774 }
775
776 if let Some(dest) = save_to {
777 let mut graph = graph;
789 kglite::api::io::prepare_kgl_write(&mut graph);
790 let dest_str = dest.to_string_lossy();
791 kglite::api::io::write_kgl(&graph, &dest_str).map_err(|e| e.to_string())?;
792 return Ok((graph, call_stats));
793 }
794 Ok((graph, call_stats))
795}
796
797pub fn run(src_dir: &Path, verbose: bool) -> Result<Arc<DirGraph>, String> {
799 let mut combined = ParseResult::new();
800 let languages = detect_languages(src_dir);
801 if verbose {
802 eprintln!("Detected languages: {:?}", languages);
803 }
804 for lang in languages {
805 let Some(parser) = get_parser(lang) else {
806 if verbose {
807 eprintln!(" (no Rust parser yet for {lang})");
808 }
809 continue;
810 };
811 if verbose {
812 eprintln!("Parsing {} files...", lang);
813 }
814 let result = parser.parse_directory(src_dir, verbose);
815 combined.merge(result);
816 }
817
818 dedup_by_key(&mut combined.files, |f| f.path.clone());
821 dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
822 dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
823 dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
824 dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
825 dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
826 combined.control_transfers.sort();
827 combined.control_transfers.dedup();
828 combined.reference_sites.sort();
829 combined.reference_sites.dedup();
830 combined.symbol_relationships.sort();
831 combined.symbol_relationships.dedup();
832
833 if verbose {
834 eprintln!(
835 "Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
836 combined.files.len(),
837 combined.functions.len(),
838 combined.classes.len(),
839 combined.enums.len(),
840 combined.interfaces.len(),
841 combined.attributes.len(),
842 combined.constants.len()
843 );
844 }
845
846 load::load_into_graph(&combined, None, &js_workspace::JsWorkspace::default())
847 .map(|(graph, _stats)| graph)
848}
849
850fn dedup_by_key<T, K, F>(items: &mut Vec<T>, mut key: F)
852where
853 K: Eq + std::hash::Hash,
854 F: FnMut(&T) -> K,
855{
856 let mut seen: std::collections::HashMap<K, usize> = std::collections::HashMap::new();
857 for (idx, item) in items.iter().enumerate() {
858 seen.insert(key(item), idx);
859 }
860 if seen.len() == items.len() {
861 return;
862 }
863 let mut keep: Vec<usize> = seen.into_values().collect();
864 keep.sort_unstable();
865 let mut out: Vec<T> = Vec::with_capacity(keep.len());
866 for (idx, item) in std::mem::take(items).into_iter().enumerate() {
867 if keep.binary_search(&idx).is_ok() {
868 out.push(item);
869 }
870 }
871 *items = out;
872}
873
874const OVERLOAD_ID_SEPARATOR: char = '#';
875const SHA256_HEX_LEN: usize = 64;
876
877fn preserve_overloaded_function_ids(
884 functions: &mut Vec<crate::models::FunctionInfo>,
885 type_relationships: &mut [crate::models::TypeRelationship],
886) {
887 dedup_by_key(functions, |function| {
888 (function.qualified_name.clone(), function.signature.clone())
889 });
890
891 let mut signatures_by_id: std::collections::HashMap<&str, HashSet<&str>> =
892 std::collections::HashMap::new();
893 for function in functions.iter() {
894 signatures_by_id
895 .entry(function.qualified_name.as_str())
896 .or_default()
897 .insert(function.signature.as_str());
898 }
899 let overloaded: HashSet<String> = signatures_by_id
900 .into_iter()
901 .filter_map(|(qualified_name, signatures)| {
902 (signatures.len() > 1).then(|| qualified_name.to_string())
903 })
904 .collect();
905 if overloaded.is_empty() {
906 return;
907 }
908
909 let replacements: std::collections::HashMap<(String, String), String> = functions
910 .iter_mut()
911 .filter(|function| overloaded.contains(function.qualified_name.as_str()))
912 .map(|function| {
913 let base = function.qualified_name.clone();
914 let signature = function.signature.clone();
915 let qualified_name = overloaded_qualified_name(&base, &signature);
916 function.qualified_name.clone_from(&qualified_name);
917 ((base, signature), qualified_name)
918 })
919 .collect();
920
921 for relationship in type_relationships {
922 for method in &mut relationship.methods {
923 if let Some(qualified_name) =
924 replacements.get(&(method.qualified_name.clone(), method.signature.clone()))
925 {
926 method.qualified_name.clone_from(qualified_name);
927 }
928 }
929 }
930}
931
932fn overloaded_qualified_name(base: &str, signature: &str) -> String {
933 use sha2::{Digest, Sha256};
934
935 let discriminator: String = Sha256::digest(signature)
936 .iter()
937 .map(|byte| format!("{byte:02x}"))
938 .collect();
939 format!("{base}{OVERLOAD_ID_SEPARATOR}{discriminator}")
940}
941
942pub(super) fn overload_base_qualified_name(qualified_name: &str) -> Option<&str> {
943 let (base, discriminator) = qualified_name.rsplit_once(OVERLOAD_ID_SEPARATOR)?;
944 (discriminator.len() == SHA256_HEX_LEN
945 && discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()))
946 .then_some(base)
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use crate::models::{ClassInfo, FileInfo, FunctionInfo, TypeRelationship};
953 use crate::parsers::get_parser;
954 use kglite::api::GraphRead;
955 use kglite::datatypes::Value;
956
957 #[test]
968 fn agc_cross_file_pass_stays_scoped_to_one_source_root() {
969 let temp = tempfile::tempdir().expect("tempdir");
970 let project = temp.path().join("project");
971 let root_a = project.join("src/a");
972 let root_b = project.join("src/b");
973 std::fs::create_dir_all(&root_a).expect("create root a");
974 std::fs::create_dir_all(&root_b).expect("create root b");
975 std::fs::write(root_a.join("MAIN.agc"), "CALLER TC HELPER\n").expect("write caller");
976 std::fs::write(root_b.join("HELP.agc"), "HELPER TC Q\n").expect("write helper");
977
978 let split = parse_roots(&[root_a.as_path(), root_b.as_path()], &project, false, None);
979 let helper = split
980 .functions
981 .iter()
982 .find(|function| function.qualified_name == "src.HELPER")
983 .expect("helper parsed");
984 assert!(
985 !helper.metadata.contains_key("role_hint"),
986 "AGC finalize resolved a call across two source roots: {:?}",
987 helper.metadata
988 );
989
990 std::fs::rename(root_b.join("HELP.agc"), root_a.join("HELP.agc")).expect("move helper");
994 let single = parse_roots(&[root_a.as_path()], &project, false, None);
995 let helper = single
996 .functions
997 .iter()
998 .find(|function| function.qualified_name == "src.HELPER")
999 .expect("helper parsed");
1000 assert_eq!(helper.metadata["role_hint"], "routine");
1001 }
1002
1003 #[test]
1004 fn overlapping_roots_collapse_to_the_covering_ancestor() {
1005 let temp = tempfile::tempdir().expect("tempdir");
1006 let project = temp.path().join("project");
1007 std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
1008 std::fs::write(
1009 project.join("pyproject.toml"),
1010 "[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
1011 )
1012 .expect("write manifest");
1013 let info = crate::manifest::try_read_manifest(&project)
1014 .expect("read manifest")
1015 .expect("manifest exists");
1016 assert_eq!(info.source_roots.len(), 1);
1017 assert_eq!(info.test_roots.len(), 1);
1018
1019 let collapsed = collapse_covered_roots(
1020 info.source_roots
1021 .into_iter()
1022 .chain(info.test_roots)
1023 .collect(),
1024 );
1025 assert_eq!(collapsed.len(), 1);
1026 assert_eq!(collapsed[0].label.as_deref(), Some("setuptools-find"));
1027 }
1028
1029 #[test]
1030 fn test_filter_removes_test_files_and_every_owned_entity() {
1031 let source = FileInfo {
1032 path: "src/app.py".into(),
1033 language: "python".into(),
1034 ..FileInfo::default()
1035 };
1036 let test = FileInfo {
1037 path: "tests/test_app.py".into(),
1038 language: "python".into(),
1039 is_test: true,
1040 ..FileInfo::default()
1041 };
1042 let source_fn = FunctionInfo {
1043 qualified_name: "app.run".into(),
1044 file_path: source.path.clone(),
1045 ..FunctionInfo::default()
1046 };
1047 let test_fn = FunctionInfo {
1048 qualified_name: "test_app.test_run".into(),
1049 file_path: test.path.clone(),
1050 ..FunctionInfo::default()
1051 };
1052 let test_class = ClassInfo {
1053 qualified_name: "test_app.Case".into(),
1054 file_path: test.path.clone(),
1055 ..ClassInfo::default()
1056 };
1057 let mut result = ParseResult {
1058 files: vec![source, test],
1059 functions: vec![source_fn, test_fn.clone()],
1060 classes: vec![test_class],
1061 type_relationships: vec![TypeRelationship {
1062 source_type: "test_app.Case".into(),
1063 relationship: "inherent".into(),
1064 methods: vec![test_fn],
1065 ..TypeRelationship::default()
1066 }],
1067 ..ParseResult::default()
1068 };
1069
1070 retain_non_test_entities(&mut result);
1071 assert_eq!(result.files.len(), 1);
1072 assert_eq!(result.functions.len(), 1);
1073 assert!(result.classes.is_empty());
1074 assert!(result.type_relationships.is_empty());
1075 }
1076
1077 #[test]
1078 fn fallback_scan_honours_include_tests_false() {
1079 let temp = tempfile::tempdir().expect("tempdir");
1080 let project = temp.path().join("project");
1081 std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
1082 std::fs::write(project.join("app.py"), "def run():\n return 1\n").expect("write source");
1083 std::fs::write(
1084 project.join("tests/test_app.py"),
1085 "def test_run():\n assert True\n",
1086 )
1087 .expect("write test");
1088
1089 let graph = run_with_options(&project, false, false, None, None, false)
1090 .expect("build fallback project");
1091 let nodes: Vec<(String, String)> = graph
1092 .graph
1093 .node_indices()
1094 .filter_map(|index| graph.node_view(index))
1095 .map(|node| {
1096 (
1097 node.node_type_str(&graph.interner).to_string(),
1098 node.id().to_string(),
1099 )
1100 })
1101 .collect();
1102 assert!(
1103 nodes
1104 .iter()
1105 .any(|(kind, id)| kind == "File" && id.contains("app.py")),
1106 "{nodes:?}"
1107 );
1108 assert!(nodes.iter().all(|(_, id)| !id.contains("test_app")));
1109 }
1110
1111 #[test]
1117 fn build_rooted_at_an_ignored_dir_name_is_not_empty() {
1118 const SOURCE: &str = "def run():\n return 1\n";
1119
1120 fn node_set(root: &Path) -> Vec<(String, String)> {
1128 std::fs::create_dir_all(root).expect("create root");
1129 std::fs::write(root.join("app.py"), SOURCE).expect("write source");
1130 let graph =
1131 run_with_options(root, false, false, None, None, false).expect("build project");
1132 let base = root
1133 .file_name()
1134 .and_then(|n| n.to_str())
1135 .expect("root basename");
1136 let trimmed = base.trim_start_matches('.');
1137 let mut nodes: Vec<(String, String)> = graph
1138 .graph
1139 .node_indices()
1140 .filter_map(|index| graph.node_view(index))
1141 .map(|node| {
1142 (
1143 node.node_type_str(&graph.interner).to_string(),
1144 node.id()
1145 .to_string()
1146 .replace(base, "plain")
1147 .replace(trimmed, "plain"),
1148 )
1149 })
1150 .collect();
1151 nodes.sort();
1152 nodes
1153 }
1154
1155 let temp = tempfile::tempdir().expect("tempdir");
1156 let baseline = node_set(&temp.path().join("plain"));
1157 assert!(
1158 baseline
1159 .iter()
1160 .any(|(kind, id)| kind == "File" && id.contains("app.py")),
1161 "baseline build must see the source file: {baseline:?}"
1162 );
1163
1164 for name in [".hidden", "target", "venv", "node_modules"] {
1165 let root = temp.path().join(name);
1166 assert!(crate::manifest::is_ignored_dir_name(name), "{name}");
1168 let nodes = node_set(&root);
1169 assert!(
1170 !nodes.is_empty(),
1171 "build rooted at `{name}` produced an EMPTY graph"
1172 );
1173 assert!(
1179 !nodes
1180 .iter()
1181 .any(|(kind, id)| kind == "Module" && id == "\"\""),
1182 "build rooted at `{name}` synthesized an empty-id Module node"
1183 );
1184 assert_eq!(
1185 nodes, baseline,
1186 "build rooted at `{name}` differs from an ordinary root"
1187 );
1188 }
1189 }
1190
1191 #[test]
1192 fn malformed_manifest_fails_directory_and_explicit_file_builds() {
1193 let temp = tempfile::tempdir().expect("tempdir");
1194 let project = temp.path().join("project");
1195 std::fs::create_dir(&project).expect("create project");
1196 let manifest = project.join("pyproject.toml");
1197 std::fs::write(&manifest, "[project\nname = broken").expect("write manifest");
1198
1199 for input in [&project, &manifest] {
1200 let error = match run_with_options(input, false, false, None, None, false) {
1201 Ok(_) => panic!("malformed manifest must fail"),
1202 Err(error) => error,
1203 };
1204 assert!(error.contains("pyproject.toml"), "{error}");
1205 }
1206 }
1207
1208 #[test]
1209 fn project_languages_include_parsed_polyglot_files_in_registry_order() {
1210 let temp = tempfile::tempdir().expect("tempdir");
1211 let project = temp.path().join("project");
1212 std::fs::create_dir_all(project.join("demo")).expect("create Python package");
1213 std::fs::write(
1214 project.join("pyproject.toml"),
1215 "[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
1216 )
1217 .expect("write manifest");
1218 std::fs::write(
1219 project.join("demo/__init__.py"),
1220 "def run():\n return 1\n",
1221 )
1222 .expect("write Python");
1223 std::fs::write(
1224 project.join("app.ts"),
1225 "export function render() { return 1; }\n",
1226 )
1227 .expect("write TypeScript");
1228 std::fs::write(project.join("style.css"), ".app { color: red; }\n").expect("write CSS");
1229
1230 let graph = run_with_options(&project, false, false, None, None, false)
1231 .expect("build polyglot project");
1232 let languages = graph
1233 .graph
1234 .node_indices()
1235 .filter_map(|index| graph.node_view(index))
1236 .find(|node| node.node_type_str(&graph.interner) == "Project")
1237 .and_then(|node| node.get_property("languages"))
1238 .expect("Project.languages");
1239 assert_eq!(
1240 languages.as_ref(),
1241 &Value::String("python, typescript, css".into())
1242 );
1243 }
1244
1245 #[test]
1246 fn fallback_parsers_keep_nested_same_stem_module_identity() {
1247 let temp = tempfile::tempdir().expect("tempdir");
1248 let project = temp.path().join("project");
1249 for dir in ["a", "b"] {
1250 std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
1251 }
1252 let fixtures = [
1253 ("html", "html", "<!doctype html><h1>Title</h1>\n", "."),
1254 ("css", "css", ".item { color: red; }\n", "."),
1255 ("php", "php", "<?php function run() { return 1; }\n", "\\"),
1256 ("swift", "swift", "func run() -> Int { return 1 }\n", "."),
1257 ("dart", "dart", "int run() { return 1; }\n", "."),
1258 ];
1259 for (language, extension, source, separator) in fixtures {
1260 for dir in ["a", "b"] {
1261 std::fs::write(project.join(format!("{dir}/index.{extension}")), source)
1262 .expect("write fixture");
1263 }
1264 let result = get_parser(language)
1265 .expect("registered parser")
1266 .parse_directory(&project, false);
1267 let mut modules: Vec<_> = result
1268 .files
1269 .iter()
1270 .filter(|file| file.filename == format!("index.{extension}"))
1271 .map(|file| file.module_path.clone())
1272 .collect();
1273 modules.sort();
1274 assert_eq!(
1275 modules,
1276 [
1277 format!("project{separator}a{separator}index"),
1278 format!("project{separator}b{separator}index"),
1279 ],
1280 "{language}"
1281 );
1282
1283 let leaf_modules: HashSet<_> = load::build_modules(&result.files)
1284 .into_iter()
1285 .map(|module| module.qualified_name)
1286 .collect();
1287 assert!(modules.iter().all(|module| leaf_modules.contains(module)));
1288 let contains = other_edges::build_module_contains_file_edges(&result.files);
1289 assert!(modules
1290 .iter()
1291 .all(|module| contains.iter().any(|edge| &edge.module == module)));
1292 }
1293 }
1294
1295 #[test]
1296 fn nested_html_and_css_imports_resolve_to_sibling_directories() {
1297 let temp = tempfile::tempdir().expect("tempdir");
1298 let project = temp.path().join("project");
1299 for dir in ["a", "b"] {
1300 std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
1301 std::fs::write(
1302 project.join(format!("{dir}/index.html")),
1303 "<!doctype html><link rel=\"stylesheet\" href=\"theme.css\">\n",
1304 )
1305 .expect("write HTML");
1306 std::fs::write(
1307 project.join(format!("{dir}/index.css")),
1308 "@import \"theme.css\";\n.item { color: red; }\n",
1309 )
1310 .expect("write CSS index");
1311 std::fs::write(
1312 project.join(format!("{dir}/theme.css")),
1313 ".theme { color: blue; }\n",
1314 )
1315 .expect("write CSS target");
1316 }
1317
1318 let mut result = get_parser("html")
1319 .expect("HTML parser")
1320 .parse_directory(&project, false);
1321 result.merge(
1322 get_parser("css")
1323 .expect("CSS parser")
1324 .parse_directory(&project, false),
1325 );
1326 let known_modules: HashSet<_> = result
1327 .files
1328 .iter()
1329 .map(|file| file.module_path.clone())
1330 .collect();
1331 let module_pairs: HashSet<_> = other_edges::build_import_edges(
1332 &result.files,
1333 &known_modules,
1334 &js_workspace::JsWorkspace::default(),
1335 )
1336 .into_iter()
1337 .map(|edge| (edge.file_path, edge.module))
1338 .collect();
1339 let module_to_file = result
1340 .files
1341 .iter()
1342 .map(|file| (file.module_path.clone(), file.path.clone()))
1343 .collect();
1344 let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
1345 &result.files,
1346 &module_to_file,
1347 &js_workspace::JsWorkspace::default(),
1348 )
1349 .into_iter()
1350 .map(|edge| (edge.source, edge.target))
1351 .collect();
1352
1353 for dir in ["a", "b"] {
1354 for source in [format!("{dir}/index.html"), format!("{dir}/index.css")] {
1355 assert!(module_pairs.contains(&(source.clone(), format!("project.{dir}.theme"))));
1356 assert!(file_pairs.contains(&(source, format!("{dir}/theme.css"))));
1357 }
1358 }
1359 }
1360
1361 #[test]
1367 fn dart_imports_resolve_with_directory_structure() {
1368 let temp = tempfile::tempdir().expect("tempdir");
1369 let project = temp.path().join("dart_import");
1370 std::fs::create_dir_all(project.join("lib/a")).expect("create lib/a");
1371 std::fs::create_dir_all(project.join("lib/b")).expect("create lib/b");
1372 std::fs::write(
1373 project.join("lib/main.dart"),
1374 "import 'dart:core';\n\
1375 import 'package:dart_import/a/x.dart';\n\
1376 import 'package:dart_import/b/x.dart' as bx;\n\
1377 import 'a/x.dart' show aValue;\n\
1378 int combine() { return aValue() + bx.bValue(); }\n",
1379 )
1380 .expect("write main");
1381 std::fs::write(
1382 project.join("lib/a/x.dart"),
1383 "int aValue() { return 10; }\n",
1384 )
1385 .expect("write a/x");
1386 std::fs::write(
1387 project.join("lib/b/x.dart"),
1388 "int bValue() { return 20; }\n",
1389 )
1390 .expect("write b/x");
1391
1392 let result = get_parser("dart")
1393 .expect("Dart parser")
1394 .parse_directory(&project, false);
1395 let known_modules: HashSet<_> = result
1396 .files
1397 .iter()
1398 .map(|file| file.module_path.clone())
1399 .collect();
1400 let module_pairs: Vec<_> = other_edges::build_import_edges(
1401 &result.files,
1402 &known_modules,
1403 &js_workspace::JsWorkspace::default(),
1404 )
1405 .into_iter()
1406 .map(|edge| (edge.file_path, edge.module))
1407 .collect();
1408 assert_eq!(
1411 module_pairs
1412 .iter()
1413 .filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.a.x")
1414 .count(),
1415 2,
1416 "package + relative imports of a/x: {module_pairs:?}"
1417 );
1418 assert_eq!(
1419 module_pairs
1420 .iter()
1421 .filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.b.x")
1422 .count(),
1423 1,
1424 "package import of b/x: {module_pairs:?}"
1425 );
1426 assert_eq!(module_pairs.len(), 3, "{module_pairs:?}");
1429
1430 let module_to_file = result
1431 .files
1432 .iter()
1433 .map(|file| (file.module_path.clone(), file.path.clone()))
1434 .collect();
1435 let file_edges: Vec<_> = other_edges::build_file_import_edges(
1436 &result.files,
1437 &module_to_file,
1438 &js_workspace::JsWorkspace::default(),
1439 )
1440 .into_iter()
1441 .map(|edge| (edge.source, edge.target, edge.import_count))
1442 .collect();
1443 assert_eq!(
1446 file_edges,
1447 vec![
1448 (
1449 "lib/main.dart".to_string(),
1450 "lib/a/x.dart".to_string(),
1451 2i64
1452 ),
1453 (
1454 "lib/main.dart".to_string(),
1455 "lib/b/x.dart".to_string(),
1456 1i64
1457 ),
1458 ]
1459 );
1460 }
1461
1462 #[test]
1469 fn r_source_resolves_as_file_paths() {
1470 let temp = tempfile::tempdir().expect("tempdir");
1471 let project = temp.path().join("r_proj");
1472 std::fs::create_dir_all(project.join("sub")).expect("create sub");
1473 std::fs::write(
1474 project.join("main.R"),
1475 "source(\"utils.R\")\n\
1476 source(\"sub/helpers.R\")\n\
1477 source(paste0(dir, \"/dyn.R\"))\n\
1478 main <- function() run()\n",
1479 )
1480 .expect("write main");
1481 std::fs::write(project.join("utils.R"), "add_one <- function(x) x + 1\n")
1482 .expect("write utils");
1483 std::fs::write(
1484 project.join("sub/helpers.R"),
1485 "source(\"deep.R\")\nhelp_fn <- function() 1\n",
1486 )
1487 .expect("write helpers");
1488 std::fs::write(project.join("sub/deep.R"), "deep_fn <- function() 2\n")
1489 .expect("write deep");
1490
1491 let result = get_parser("r")
1492 .expect("R parser")
1493 .parse_directory(&project, false);
1494 let known_modules: HashSet<_> = result
1495 .files
1496 .iter()
1497 .map(|file| file.module_path.clone())
1498 .collect();
1499 let module_pairs: HashSet<_> = other_edges::build_import_edges(
1500 &result.files,
1501 &known_modules,
1502 &js_workspace::JsWorkspace::default(),
1503 )
1504 .into_iter()
1505 .map(|edge| (edge.file_path, edge.module))
1506 .collect();
1507 let expected_modules: HashSet<_> = [
1508 ("main.R", "r_proj.utils"),
1509 ("main.R", "r_proj.sub.helpers"),
1510 ("sub/helpers.R", "r_proj.sub.deep"),
1511 ]
1512 .into_iter()
1513 .map(|(f, m)| (f.to_string(), m.to_string()))
1514 .collect();
1515 assert_eq!(module_pairs, expected_modules, "{module_pairs:?}");
1516
1517 let module_to_file = result
1518 .files
1519 .iter()
1520 .map(|file| (file.module_path.clone(), file.path.clone()))
1521 .collect();
1522 let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
1523 &result.files,
1524 &module_to_file,
1525 &js_workspace::JsWorkspace::default(),
1526 )
1527 .into_iter()
1528 .map(|edge| (edge.source, edge.target))
1529 .collect();
1530 let expected_files: HashSet<_> = [
1531 ("main.R", "utils.R"),
1532 ("main.R", "sub/helpers.R"),
1533 ("sub/helpers.R", "sub/deep.R"),
1534 ]
1535 .into_iter()
1536 .map(|(s, t)| (s.to_string(), t.to_string()))
1537 .collect();
1538 assert_eq!(file_pairs, expected_files, "{file_pairs:?}");
1539 }
1540
1541 #[test]
1553 fn r_library_naming_a_local_module_yields_no_file_edge() {
1554 let temp = tempfile::tempdir().expect("tempdir");
1555 let project = temp.path().join("r_proj");
1556 std::fs::create_dir_all(&project).expect("create project");
1557 std::fs::write(
1558 project.join("main.R"),
1559 "library(stats)\nlibrary(tools)\nmain <- function() pad_left(\"x\", 3)\n",
1560 )
1561 .expect("write main");
1562 std::fs::write(
1563 project.join("tools.R"),
1564 "pad_left <- function(s, n) formatC(s, width = n)\n",
1565 )
1566 .expect("write tools");
1567
1568 let result = get_parser("r")
1569 .expect("R parser")
1570 .parse_directory(&project, false);
1571 let known_modules: HashSet<_> = result
1572 .files
1573 .iter()
1574 .map(|file| file.module_path.clone())
1575 .collect();
1576 let module_pairs: Vec<_> = other_edges::build_import_edges(
1577 &result.files,
1578 &known_modules,
1579 &js_workspace::JsWorkspace::default(),
1580 )
1581 .into_iter()
1582 .map(|edge| (edge.file_path, edge.module))
1583 .collect();
1584 assert!(
1588 module_pairs.is_empty(),
1589 "a package reference must not land on a colliding local module: {module_pairs:?}"
1590 );
1591
1592 let module_to_file = result
1593 .files
1594 .iter()
1595 .map(|file| (file.module_path.clone(), file.path.clone()))
1596 .collect();
1597 let file_pairs: Vec<_> = other_edges::build_file_import_edges(
1598 &result.files,
1599 &module_to_file,
1600 &js_workspace::JsWorkspace::default(),
1601 )
1602 .into_iter()
1603 .map(|edge| (edge.source, edge.target))
1604 .collect();
1605 assert!(
1606 file_pairs.is_empty(),
1607 "a package reference must not manufacture a File→File edge: {file_pairs:?}"
1608 );
1609 }
1610
1611 #[test]
1612 fn overload_identity_keeps_last_exact_duplicate_and_leaves_ordinary_ids_alone() {
1613 let ordinary = FunctionInfo {
1614 name: "run".into(),
1615 qualified_name: "demo.run".into(),
1616 signature: "run()".into(),
1617 ..FunctionInfo::default()
1618 };
1619 let first = FunctionInfo {
1620 name: "pick".into(),
1621 qualified_name: "demo.Picker.pick".into(),
1622 signature: "pick(int)".into(),
1623 line_number: 1,
1624 ..FunctionInfo::default()
1625 };
1626 let duplicate = FunctionInfo {
1627 line_number: 2,
1628 ..first.clone()
1629 };
1630 let second = FunctionInfo {
1631 signature: "pick(string)".into(),
1632 line_number: 3,
1633 ..first.clone()
1634 };
1635 let mut functions = vec![ordinary, first, duplicate, second];
1636 let mut relationships = vec![TypeRelationship {
1637 source_type: "demo.Picker".into(),
1638 relationship: "inherent".into(),
1639 methods: functions[1..].to_vec(),
1640 ..TypeRelationship::default()
1641 }];
1642
1643 preserve_overloaded_function_ids(&mut functions, &mut relationships);
1644
1645 assert_eq!(functions.len(), 3);
1646 assert_eq!(functions[0].qualified_name, "demo.run");
1647 let overloads: Vec<_> = functions
1648 .iter()
1649 .filter(|function| function.name == "pick")
1650 .collect();
1651 assert_eq!(overloads.len(), 2);
1652 assert!(overloads.iter().any(|function| function.line_number == 2));
1653 assert!(!overloads.iter().any(|function| function.line_number == 1));
1654 for function in overloads {
1655 assert_eq!(
1656 overload_base_qualified_name(&function.qualified_name),
1657 Some("demo.Picker.pick")
1658 );
1659 }
1660 assert!(relationships[0]
1661 .methods
1662 .iter()
1663 .all(
1664 |method| overload_base_qualified_name(&method.qualified_name)
1665 == Some("demo.Picker.pick")
1666 ));
1667 }
1668
1669 #[test]
1670 fn polyglot_overloads_have_stable_nodes_and_conservative_calls() {
1671 let fixtures = [
1672 (
1673 "cpp",
1674 "Picker.cpp",
1675 r#"class Picker {
1676public:
1677 int pick(int value) { return value; }
1678 int pick(double value) { return static_cast<int>(value); }
1679 int run() { return pick(1); }
1680};
1681"#,
1682 ),
1683 (
1684 "java",
1685 "Picker.java",
1686 r#"class Picker {
1687 int pick(int value) { return value; }
1688 int pick(long value) { return (int) value; }
1689 int run() { return pick(1); }
1690}
1691"#,
1692 ),
1693 (
1694 "csharp",
1695 "Picker.cs",
1696 r#"class Picker {
1697 public int pick(int value) { return value; }
1698 public int pick(string value) { return value.Length; }
1699 public int run() { return pick(1); }
1700}
1701"#,
1702 ),
1703 (
1704 "typescript",
1705 "Picker.ts",
1706 r#"class Picker {
1707 pick(value: number) { return value; }
1708 pick(value: string) { return value.length; }
1709 run() { return this.pick(1); }
1710}
1711"#,
1712 ),
1713 ];
1714
1715 for (language, filename, source) in fixtures {
1716 let temp = tempfile::tempdir().expect("tempdir");
1717 let project = temp.path().join("project");
1718 std::fs::create_dir(&project).expect("create fixture root");
1719 std::fs::write(project.join(filename), source).expect("write overload fixture");
1720
1721 let first = run_with_options(&project, false, false, None, None, false)
1722 .unwrap_or_else(|error| panic!("{language} first build: {error}"));
1723 let second = run_with_options(&project, false, false, None, None, false)
1724 .unwrap_or_else(|error| panic!("{language} second build: {error}"));
1725
1726 let overload_rows = |graph: &DirGraph| {
1727 let mut rows: Vec<(String, String)> = graph
1728 .graph
1729 .node_indices()
1730 .filter_map(|index| graph.node_view(index))
1731 .filter(|node| {
1732 node.node_type_str(&graph.interner) == "Function"
1733 && node.title().as_ref() == &Value::String("pick".into())
1734 })
1735 .map(|node| {
1736 let signature = match node.get_property_value("signature") {
1737 Some(Value::String(value)) => value,
1738 value => panic!("{language} signature missing: {value:?}"),
1739 };
1740 let qualified_name = match node.id().as_ref() {
1741 Value::String(value) => value.clone(),
1742 value => panic!("{language} function ID is not text: {value:?}"),
1743 };
1744 (qualified_name, signature)
1745 })
1746 .collect();
1747 rows.sort();
1748 rows
1749 };
1750 let first_rows = overload_rows(&first);
1751 let second_rows = overload_rows(&second);
1752 assert_eq!(first_rows.len(), 2, "{language}: {first_rows:?}");
1753 assert_eq!(first_rows, second_rows, "{language} IDs changed");
1754 assert_ne!(first_rows[0].0, first_rows[1].0, "{language}");
1755 assert_ne!(first_rows[0].1, first_rows[1].1, "{language}");
1756 for (qualified_name, _) in &first_rows {
1757 let (_, discriminator) = qualified_name
1758 .rsplit_once(OVERLOAD_ID_SEPARATOR)
1759 .unwrap_or_else(|| {
1760 panic!("{language} ID lacks discriminator: {qualified_name}")
1761 });
1762 assert_eq!(discriminator.len(), SHA256_HEX_LEN, "{language}");
1763 assert!(discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()));
1764 }
1765
1766 let calls_to_overloads = first
1767 .graph
1768 .edge_indices()
1769 .filter(|&edge_index| {
1770 let Some(edge) = first.graph.edge_weight(edge_index) else {
1771 return false;
1772 };
1773 if edge.connection_type_str(&first.interner) != "CALLS" {
1774 return false;
1775 }
1776 let Some((source, target)) = first.graph.edge_endpoints(edge_index) else {
1777 return false;
1778 };
1779 let (Some(source), Some(target)) =
1780 (first.node_view(source), first.node_view(target))
1781 else {
1782 return false;
1783 };
1784 source.title().as_ref() == &Value::String("run".into())
1785 && target.title().as_ref() == &Value::String("pick".into())
1786 })
1787 .count();
1788 assert_eq!(calls_to_overloads, 2, "{language}");
1789 }
1790 }
1791
1792 fn count_label(graph: &DirGraph, label: &str) -> usize {
1795 graph
1796 .graph
1797 .node_indices()
1798 .filter_map(|index| graph.node_view(index))
1799 .filter(|node| node.node_type_str(&graph.interner) == label)
1800 .count()
1801 }
1802
1803 fn count_conn(graph: &DirGraph, conn: &str) -> usize {
1804 graph
1805 .graph
1806 .edge_indices()
1807 .filter_map(|index| graph.graph.edge_weight(index))
1808 .filter(|edge| edge.connection_type_str(&graph.interner) == conn)
1809 .count()
1810 }
1811
1812 fn sole_project(graph: &DirGraph) -> (String, BTreeMap<String, Value>) {
1815 let mut found: Vec<(String, BTreeMap<String, Value>)> = graph
1816 .graph
1817 .node_indices()
1818 .filter_map(|index| graph.node_view(index))
1819 .filter(|node| node.node_type_str(&graph.interner) == "Project")
1820 .map(|node| {
1821 let id = match &*node.id() {
1822 Value::String(name) => name.clone(),
1823 other => panic!("unexpected :Project id {other:?}"),
1824 };
1825 (
1826 id,
1827 node.properties_cloned(&graph.interner)
1828 .into_iter()
1829 .collect(),
1830 )
1831 })
1832 .collect();
1833 assert_eq!(found.len(), 1, "expected exactly one :Project node");
1834 found.pop().expect("one project")
1835 }
1836
1837 #[test]
1843 fn manifestless_repository_gets_an_inferred_project_anchor() {
1844 let temp = tempfile::tempdir().expect("tempdir");
1845 let project = temp.path().join("plainrepo");
1846 std::fs::create_dir_all(&project).expect("create project dir");
1847 std::fs::write(project.join("app.py"), "def compute():\n return 1\n")
1848 .expect("write source");
1849 std::fs::write(
1850 project.join("README.md"),
1851 "# Plain repo\nCalls `compute` on start.\n",
1852 )
1853 .expect("write doc");
1854
1855 let include_docs = cfg!(feature = "docs");
1856 let graph = run_with_options(&project, false, true, None, None, include_docs)
1857 .expect("build manifestless project");
1858
1859 let (id, props) = sole_project(&graph);
1860 assert_eq!(id, "plainrepo", "project is named after the project root");
1861 assert_eq!(
1862 props.get("manifest"),
1863 Some(&Value::String(crate::models::INFERRED_MANIFEST.to_string())),
1864 "inferred projects carry the sentinel in `manifest`: {props:?}"
1865 );
1866 assert_eq!(
1867 props.get("languages"),
1868 Some(&Value::String("python".to_string())),
1869 "languages are reconciled from the parsed files: {props:?}"
1870 );
1871
1872 let files = count_label(&graph, "File");
1873 assert!(files >= 1, "the source file was parsed");
1874 assert_eq!(
1875 count_conn(&graph, "HAS_SOURCE"),
1876 files,
1877 "every File is owned by the inferred Project"
1878 );
1879
1880 #[cfg(feature = "docs")]
1881 {
1882 let docs = count_label(&graph, "Doc");
1883 assert!(docs >= 1, "the README became a :Doc node");
1884 assert_eq!(
1885 count_conn(&graph, "HAS_DOC"),
1886 docs,
1887 "every Doc is anchored to the Project"
1888 );
1889 }
1890 }
1891
1892 const PROJECT_PROPERTY_COLUMNS: &[&str] = &[
1898 "version",
1899 "description",
1900 "languages",
1901 "authors",
1902 "license",
1903 "repository",
1904 "build_system",
1905 "crate_type",
1906 "manifest",
1907 ];
1908
1909 #[test]
1920 fn manifest_backed_project_keeps_the_same_property_columns() {
1921 let temp = tempfile::tempdir().expect("tempdir");
1922 let include_docs = cfg!(feature = "docs");
1923
1924 let inferred_root = temp.path().join("inferred");
1925 std::fs::create_dir_all(&inferred_root).expect("create dir");
1926 std::fs::write(
1927 inferred_root.join("app.py"),
1928 "def compute():\n return 1\n",
1929 )
1930 .expect("write source");
1931 std::fs::write(inferred_root.join("README.md"), "# Demo\nRuns `compute`.\n")
1932 .expect("write doc");
1933 let inferred = run_with_options(&inferred_root, false, true, None, None, include_docs)
1934 .expect("build manifestless project");
1935
1936 let backed_root = temp.path().join("backed");
1937 std::fs::create_dir_all(&backed_root).expect("create dir");
1938 std::fs::write(backed_root.join("app.py"), "def compute():\n return 1\n")
1939 .expect("write source");
1940 std::fs::write(backed_root.join("README.md"), "# Demo\nRuns `compute`.\n")
1941 .expect("write doc");
1942 std::fs::write(
1943 backed_root.join("pyproject.toml"),
1944 "[project]\nname = \"demo\"\nversion = \"1.2.3\"\ndescription = \"demo project\"\n\
1948 license = \"MIT\"\nauthors = [{ name = \"A. Dev\" }]\ndependencies = [\"requests\"]\n\
1949 [project.urls]\nRepository = \"https://example.invalid/demo\"\n\
1950 [build-system]\nbuild-backend = \"setuptools.build_meta\"\n",
1951 )
1952 .expect("write manifest");
1953 let backed = run_with_options(&backed_root, false, true, None, None, include_docs)
1954 .expect("build manifest-backed project");
1955
1956 let (inferred_id, inferred_props) = sole_project(&inferred);
1957 let (backed_id, backed_props) = sole_project(&backed);
1958 assert_eq!(inferred_id, "inferred");
1959 assert_eq!(backed_id, "demo", "manifest-declared name wins");
1960 assert_eq!(
1961 backed_props.get("manifest"),
1962 Some(&Value::String("pyproject.toml".to_string())),
1963 "manifest-backed projects keep the real manifest path: {backed_props:?}"
1964 );
1965
1966 let vocabulary: BTreeSet<&str> = PROJECT_PROPERTY_COLUMNS.iter().copied().collect();
1967 let inferred_keys: BTreeSet<&str> = inferred_props.keys().map(|key| key.as_str()).collect();
1968 let backed_keys: BTreeSet<&str> = backed_props.keys().map(|key| key.as_str()).collect();
1969 assert!(
1970 inferred_keys.is_subset(&vocabulary) && backed_keys.is_subset(&vocabulary),
1971 "a Project property outside the fixed column set: inferred={inferred_keys:?} backed={backed_keys:?}"
1972 );
1973 assert!(
1974 inferred_keys.is_subset(&backed_keys),
1975 "inference must not introduce a Project property column: inferred={inferred_keys:?} backed={backed_keys:?}"
1976 );
1977
1978 #[cfg(feature = "docs")]
1979 {
1980 let docs = count_label(&backed, "Doc");
1981 assert!(docs >= 1, "the README became a :Doc node");
1982 assert_eq!(
1983 count_conn(&backed, "HAS_DOC"),
1984 docs,
1985 "manifest-backed projects anchor their docs too"
1986 );
1987 }
1988 }
1989}