1use anyhow::Result;
2use lazily::{Computed, Context as LazyContext, Source};
3use serde::{Deserialize, Serialize};
4use std::cell::{Cell, RefCell};
5use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
6use std::path::{Path, PathBuf};
7use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
8use tsift_core::{GraphEdge, GraphNode, GraphProjection, GraphProvenance};
9
10pub mod lang;
11pub use lang::{Lang, Symbol};
12
13pub mod complexity;
14pub use complexity::{ComplexityMetrics, LanguageExtractor, LanguageRegistry};
15
16pub mod extract;
17pub use extract::{ExtractionPlan, ExtractionRefusal, plan_extraction, render_extraction};
18
19pub mod rename;
20pub use rename::{
21 IdentifierOccurrence, RenameTarget, identifier_occurrences, identifier_occurrences_for,
22 replace_occurrences,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CallSite {
27 pub callee: String,
28 pub line: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CallEdge {
33 pub caller: String,
34 pub callee: String,
35 pub caller_line: usize,
36 pub call_site_line: usize,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct FileMtime {
41 pub secs: i64,
42 pub nanos: u32,
43}
44
45impl FileMtime {
46 pub fn new(secs: i64, nanos: u32) -> Self {
47 Self { secs, nanos }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52struct ResolveEdgesKey {
53 file: PathBuf,
54 content_hash: String,
55}
56
57#[derive(Clone, Copy)]
58struct ResolveEdgesSlot {
59 mtime: Source<FileMtime>,
60 edges: Computed<Vec<CallEdge>>,
61}
62
63pub struct ResolveEdgesCache {
64 ctx: LazyContext,
65 slots: RefCell<HashMap<ResolveEdgesKey, ResolveEdgesSlot>>,
66 hits: Cell<usize>,
67 misses: Cell<usize>,
68}
69
70impl Default for ResolveEdgesCache {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl ResolveEdgesCache {
77 pub fn new() -> Self {
78 Self {
79 ctx: LazyContext::new(),
80 slots: RefCell::new(HashMap::new()),
81 hits: Cell::new(0),
82 misses: Cell::new(0),
83 }
84 }
85
86 pub fn resolve_edges_for_file(
87 &self,
88 file: &Path,
89 content_hash: &str,
90 mtime: FileMtime,
91 symbols: &[Symbol],
92 call_sites: &[CallSite],
93 ) -> Vec<CallEdge> {
94 let key = ResolveEdgesKey {
95 file: file.to_path_buf(),
96 content_hash: content_hash.to_string(),
97 };
98 let slot = {
99 let mut slots = self.slots.borrow_mut();
100 if let Some(slot) = slots.get(&key) {
101 self.ctx.set(&slot.mtime, mtime);
102 *slot
103 } else {
104 let mtime_cell = self.ctx.source(mtime);
105 let symbols = symbols.to_vec();
106 let call_sites = call_sites.to_vec();
107 let edges = self.ctx.slot(move |ctx| {
108 let _mtime = ctx.get(&mtime_cell);
109 resolve_edges_uncached(&symbols, &call_sites)
110 });
111 let slot = ResolveEdgesSlot {
112 mtime: mtime_cell,
113 edges,
114 };
115 slots.insert(key, slot);
116 slot
117 }
118 };
119 if self.ctx.is_set(&slot.edges) {
120 self.hits.set(self.hits.get() + 1);
121 } else {
122 self.misses.set(self.misses.get() + 1);
123 }
124 self.ctx.get(&slot.edges)
125 }
126
127 pub fn stats(&self) -> (usize, usize) {
128 (self.hits.get(), self.misses.get())
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RouteSite {
134 pub framework: String,
135 pub method: Option<String>,
136 pub path: String,
137 pub handler: String,
138 pub line: usize,
139 pub handler_line: Option<usize>,
140}
141
142#[derive(Debug, Clone)]
143struct PendingRoute {
144 framework: String,
145 method: Option<String>,
146 path: String,
147 line: usize,
148}
149
150pub fn extract_call_sites(lang: Lang, source: &[u8]) -> Result<Vec<CallSite>> {
151 let query_str = match lang.call_query() {
152 Some(q) => q,
153 None => return Ok(Vec::new()),
154 };
155 let mut parser = Parser::new();
156 let ts_lang = lang.tree_sitter_language();
157 parser.set_language(&ts_lang)?;
158 let tree = parser
159 .parse(source, None)
160 .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
161 let query = Query::new(&ts_lang, query_str)?;
162 let mut cursor = QueryCursor::new();
163 let mut sites = Vec::new();
164 let capture_names: Vec<String> = query
165 .capture_names()
166 .iter()
167 .map(|s| s.to_string())
168 .collect();
169
170 let mut matches = cursor.matches(&query, tree.root_node(), source);
171 while let Some(m) = matches.next() {
172 for capture in m.captures {
173 let name = &capture_names[capture.index as usize];
174 if name == "call.name" {
175 let callee = capture
176 .node
177 .utf8_text(source)
178 .unwrap_or("<invalid utf8>")
179 .to_string();
180 sites.push(CallSite {
181 callee,
182 line: capture.node.start_position().row,
183 });
184 }
185 }
186 }
187 Ok(sites)
188}
189
190pub fn source_content_hash(source: &[u8]) -> String {
191 blake3::hash(source).to_hex().to_string()
192}
193
194pub fn extract_route_sites(lang: Lang, source: &[u8]) -> Result<Vec<RouteSite>> {
195 let text = std::str::from_utf8(source)?;
196 Ok(match lang {
197 #[cfg(feature = "lang-rust")]
198 Lang::Rust => extract_rust_routes(text),
199 #[cfg(feature = "lang-python")]
200 Lang::Python => extract_python_routes(text),
201 #[cfg(feature = "lang-typescript")]
202 Lang::TypeScript | Lang::Tsx => extract_typescript_routes(text),
203 #[cfg(feature = "lang-javascript")]
204 Lang::JavaScript | Lang::Jsx => extract_typescript_routes(text),
205 _ => Vec::new(),
206 })
207}
208
209fn extract_string_literal(input: &str) -> Option<(String, usize)> {
210 let mut chars = input.char_indices();
211 while let Some((start, ch)) = chars.next() {
212 if ch != '"' && ch != '\'' {
213 continue;
214 }
215 let quote = ch;
216 let mut escaped = false;
217 let mut value = String::new();
218 for (offset, current) in chars.by_ref() {
219 if escaped {
220 value.push(current);
221 escaped = false;
222 continue;
223 }
224 if current == '\\' {
225 escaped = true;
226 continue;
227 }
228 if current == quote {
229 return Some((value, offset + current.len_utf8()));
230 }
231 value.push(current);
232 }
233 return Some((input[start + quote.len_utf8()..].to_string(), input.len()));
234 }
235 None
236}
237
238fn first_identifier(input: &str) -> Option<String> {
239 let mut start = None;
240 for (idx, ch) in input.char_indices() {
241 if start.is_none() {
242 if ch == '_' || ch.is_ascii_alphabetic() {
243 start = Some(idx);
244 }
245 continue;
246 }
247 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
248 let value = input[start.unwrap()..idx].to_string();
249 return (!is_handler_keyword(&value)).then_some(value);
250 }
251 }
252 start
253 .map(|idx| input[idx..].to_string())
254 .filter(|value| !is_handler_keyword(value))
255}
256
257fn is_handler_keyword(value: &str) -> bool {
258 matches!(
259 value,
260 "async" | "await" | "function" | "move" | "None" | "Some" | "lambda"
261 )
262}
263
264fn route_methods() -> &'static [&'static str] {
265 &[
266 "get", "post", "put", "patch", "delete", "head", "options", "any", "route",
267 ]
268}
269
270fn parse_wrapped_handler(input: &str) -> (Option<String>, Option<String>) {
271 for method in route_methods() {
272 let needle = format!("{method}(");
273 if let Some(pos) = input.find(&needle) {
274 let inside = &input[pos + needle.len()..];
275 return (
276 Some((*method).to_string()),
277 first_identifier(inside).or_else(|| Some("<inline>".to_string())),
278 );
279 }
280 }
281 (None, first_identifier(input))
282}
283
284fn parse_rust_fn_name(line: &str) -> Option<String> {
285 let pos = line.find("fn ")?;
286 first_identifier(&line[pos + 3..])
287}
288
289fn parse_route_attribute(line: &str, framework: &str) -> Option<PendingRoute> {
290 let trimmed = line.trim_start();
291 let rest = trimmed.strip_prefix("#[")?;
292 for method in route_methods() {
293 let Some(method_rest) = rest.strip_prefix(method) else {
294 continue;
295 };
296 if !method_rest.trim_start().starts_with('(') {
297 continue;
298 }
299 let (path, _) = extract_string_literal(method_rest)?;
300 return Some(PendingRoute {
301 framework: framework.to_string(),
302 method: Some((*method).to_string()),
303 path,
304 line: 0,
305 });
306 }
307 None
308}
309
310fn extract_rust_routes(text: &str) -> Vec<RouteSite> {
311 let mut routes = Vec::new();
312 let mut pending = Vec::<PendingRoute>::new();
313
314 for (line_idx, line) in text.lines().enumerate() {
315 if let Some(mut attr) = parse_route_attribute(line, "actix") {
316 attr.line = line_idx;
317 if let Some(handler) = parse_rust_fn_name(line) {
318 routes.push(RouteSite {
319 framework: attr.framework,
320 method: attr.method,
321 path: attr.path,
322 handler,
323 line: attr.line,
324 handler_line: Some(line_idx),
325 });
326 } else {
327 pending.push(attr);
328 }
329 } else if !pending.is_empty()
330 && let Some(handler) = parse_rust_fn_name(line)
331 {
332 for attr in pending.drain(..) {
333 routes.push(RouteSite {
334 framework: attr.framework,
335 method: attr.method,
336 path: attr.path,
337 handler: handler.clone(),
338 line: attr.line,
339 handler_line: Some(line_idx),
340 });
341 }
342 }
343
344 if let Some(route_pos) = line.find(".route(") {
345 let route_args = &line[route_pos + ".route(".len()..];
346 if let Some((path, end_offset)) = extract_string_literal(route_args) {
347 let args_after_path = &route_args[end_offset..];
348 let (method, handler) = parse_wrapped_handler(args_after_path);
349 if let Some(handler) = handler {
350 routes.push(RouteSite {
351 framework: "axum".to_string(),
352 method: method.or_else(|| Some("route".to_string())),
353 path,
354 handler,
355 line: line_idx,
356 handler_line: None,
357 });
358 }
359 }
360 }
361 }
362
363 routes
364}
365
366fn parse_python_def_name(line: &str) -> Option<String> {
367 let trimmed = line.trim_start();
368 let rest = trimmed
369 .strip_prefix("async def ")
370 .or_else(|| trimmed.strip_prefix("def "))?;
371 first_identifier(rest)
372}
373
374fn parse_python_route_decorator(line: &str) -> Option<PendingRoute> {
375 let trimmed = line.trim_start();
376 let rest = trimmed.strip_prefix('@')?;
377 let dot = rest.find('.')?;
378 let after_dot = &rest[dot + 1..];
379 for method in route_methods() {
380 let Some(method_rest) = after_dot.strip_prefix(method) else {
381 continue;
382 };
383 if !method_rest.trim_start().starts_with('(') {
384 continue;
385 }
386 let (path, _) = extract_string_literal(method_rest)?;
387 let framework = if *method == "route" {
388 "flask"
389 } else {
390 "fastapi"
391 };
392 return Some(PendingRoute {
393 framework: framework.to_string(),
394 method: Some((*method).to_string()),
395 path,
396 line: 0,
397 });
398 }
399 None
400}
401
402fn extract_python_routes(text: &str) -> Vec<RouteSite> {
403 let mut routes = Vec::new();
404 let mut pending = Vec::<PendingRoute>::new();
405
406 for (line_idx, line) in text.lines().enumerate() {
407 if let Some(mut route) = parse_python_route_decorator(line) {
408 route.line = line_idx;
409 pending.push(route);
410 continue;
411 }
412
413 if !pending.is_empty()
414 && let Some(handler) = parse_python_def_name(line)
415 {
416 for route in pending.drain(..) {
417 routes.push(RouteSite {
418 framework: route.framework,
419 method: route.method,
420 path: route.path,
421 handler: handler.clone(),
422 line: route.line,
423 handler_line: Some(line_idx),
424 });
425 }
426 }
427 }
428
429 routes
430}
431
432fn parse_ts_method_name(line: &str) -> Option<String> {
433 let trimmed = line.trim_start();
434 first_identifier(trimmed)
435}
436
437fn parse_ts_route_decorator(line: &str) -> Option<PendingRoute> {
438 let trimmed = line.trim_start();
439 let rest = trimmed.strip_prefix('@')?;
440 for method in route_methods() {
441 let mut chars = method.chars();
442 let title = match chars.next() {
443 Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
444 None => continue,
445 };
446 let Some(method_rest) = rest.strip_prefix(&title) else {
447 continue;
448 };
449 if !method_rest.trim_start().starts_with('(') {
450 continue;
451 }
452 let (path, _) = extract_string_literal(method_rest)?;
453 return Some(PendingRoute {
454 framework: "nestjs".to_string(),
455 method: Some((*method).to_string()),
456 path,
457 line: 0,
458 });
459 }
460 None
461}
462
463fn parse_ts_router_call(line: &str, line_idx: usize) -> Option<RouteSite> {
464 let trimmed = line.trim_start();
465 if trimmed.starts_with("//")
466 || trimmed.starts_with("/*")
467 || trimmed.starts_with('*')
468 || trimmed.starts_with("*/")
469 {
470 return None;
471 }
472 for method in route_methods() {
473 if *method == "route" {
474 continue;
475 }
476 let needle = format!(".{method}(");
477 let Some(pos) = line.find(&needle) else {
478 continue;
479 };
480 let receiver = line[..pos]
481 .trim_end()
482 .chars()
483 .rev()
484 .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$'))
485 .collect::<String>()
486 .chars()
487 .rev()
488 .collect::<String>();
489 let receiver = receiver.to_ascii_lowercase();
490 if !matches!(receiver.as_str(), "app" | "router" | "server" | "api")
491 && !receiver.ends_with("router")
492 {
493 continue;
494 }
495 let args = &line[pos + needle.len()..];
496 let (path, end_offset) = extract_string_literal(args)?;
497 let handler = args[end_offset..]
498 .split_once(',')
499 .and_then(|(_, rest)| first_identifier(rest))
500 .unwrap_or_else(|| "<inline>".to_string());
501 return Some(RouteSite {
502 framework: "express".to_string(),
503 method: Some((*method).to_string()),
504 path,
505 handler,
506 line: line_idx,
507 handler_line: None,
508 });
509 }
510 None
511}
512
513fn extract_typescript_routes(text: &str) -> Vec<RouteSite> {
514 let mut routes = Vec::new();
515 let mut pending = Vec::<PendingRoute>::new();
516
517 for (line_idx, line) in text.lines().enumerate() {
518 if let Some(mut route) = parse_ts_route_decorator(line) {
519 route.line = line_idx;
520 pending.push(route);
521 continue;
522 }
523
524 if !pending.is_empty()
525 && let Some(handler) = parse_ts_method_name(line)
526 {
527 for route in pending.drain(..) {
528 routes.push(RouteSite {
529 framework: route.framework,
530 method: route.method,
531 path: route.path,
532 handler: handler.clone(),
533 line: route.line,
534 handler_line: Some(line_idx),
535 });
536 }
537 }
538
539 if let Some(route) = parse_ts_router_call(line, line_idx) {
540 routes.push(route);
541 }
542 }
543
544 routes
545}
546
547pub fn resolve_edges(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
548 resolve_edges_uncached(symbols, call_sites)
549}
550
551fn resolve_edges_uncached(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
552 let mut edges = Vec::new();
553 for site in call_sites {
554 let caller = symbols
555 .iter()
556 .filter(|s| s.kind == "function" || s.kind == "class" || s.kind == "mod")
557 .filter(|s| site.line >= s.line && site.line <= s.end_line)
558 .min_by_key(|s| s.end_line - s.line);
559 if let Some(caller) = caller {
560 edges.push(CallEdge {
561 caller: caller.name.clone(),
562 callee: site.callee.clone(),
563 caller_line: caller.line,
564 call_site_line: site.line,
565 });
566 }
567 }
568 edges
569}
570
571pub fn code_symbol_node_id(name: &str) -> String {
572 format!("code.symbol:{name}")
573}
574
575pub fn code_route_node_id(framework: &str, method: Option<&str>, path: &str) -> String {
576 format!(
577 "code.route:{}:{}:{}",
578 framework,
579 method.unwrap_or("any"),
580 path
581 )
582}
583
584pub fn project_call_edges(
585 edges: &[CallEdge],
586 provenance: Option<GraphProvenance>,
587) -> GraphProjection {
588 let mut nodes = BTreeMap::<String, GraphNode>::new();
589 let mut projected_edges = Vec::with_capacity(edges.len());
590
591 for edge in edges {
592 let caller_id = code_symbol_node_id(&edge.caller);
593 let callee_id = code_symbol_node_id(&edge.callee);
594 for (id, label) in [(&caller_id, &edge.caller), (&callee_id, &edge.callee)] {
595 nodes.entry(id.clone()).or_insert_with(|| {
596 let mut node = GraphNode::new(id.clone(), "code_symbol", label.clone());
597 if let Some(provenance) = provenance.clone() {
598 node = node.with_provenance(provenance);
599 }
600 node
601 });
602 }
603
604 let mut projected = GraphEdge::new(caller_id, callee_id, "calls")
605 .with_property("caller_line", edge.caller_line.to_string())
606 .with_property("call_site_line", edge.call_site_line.to_string());
607 if let Some(provenance) = provenance.clone() {
608 projected = projected.with_provenance(provenance);
609 }
610 projected_edges.push(projected);
611 }
612
613 GraphProjection {
614 nodes: nodes.into_values().collect(),
615 edges: projected_edges,
616 }
617}
618
619pub fn project_routes(
620 routes: &[RouteSite],
621 provenance: Option<GraphProvenance>,
622) -> GraphProjection {
623 let mut nodes = BTreeMap::<String, GraphNode>::new();
624 let mut projected_edges = Vec::with_capacity(routes.len());
625
626 for route in routes {
627 let route_id = code_route_node_id(&route.framework, route.method.as_deref(), &route.path);
628 let handler_id = code_symbol_node_id(&route.handler);
629 let mut route_node = GraphNode::new(
630 route_id.clone(),
631 "route",
632 format!(
633 "{} {}",
634 route.method.as_deref().unwrap_or("any").to_uppercase(),
635 route.path
636 ),
637 )
638 .with_property("framework", route.framework.clone())
639 .with_property("path", route.path.clone())
640 .with_property("handler", route.handler.clone())
641 .with_property("line", route.line.to_string());
642 if let Some(method) = &route.method {
643 route_node = route_node.with_property("method", method.clone());
644 }
645 if let Some(provenance) = provenance.clone() {
646 route_node = route_node.with_provenance(provenance);
647 }
648 nodes.entry(route_id.clone()).or_insert(route_node);
649
650 nodes.entry(handler_id.clone()).or_insert_with(|| {
651 let mut node = GraphNode::new(handler_id.clone(), "code_symbol", route.handler.clone());
652 if let Some(provenance) = provenance.clone() {
653 node = node.with_provenance(provenance);
654 }
655 node
656 });
657
658 let mut edge = GraphEdge::new(route_id, handler_id, "handled_by")
659 .with_property("route_path", route.path.clone())
660 .with_property("framework", route.framework.clone());
661 if let Some(method) = &route.method {
662 edge = edge.with_property("method", method.clone());
663 }
664 if let Some(provenance) = provenance.clone() {
665 edge = edge.with_provenance(provenance);
666 }
667 projected_edges.push(edge);
668 }
669
670 GraphProjection {
671 nodes: nodes.into_values().collect(),
672 edges: projected_edges,
673 }
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct CommunityMemberRef {
678 pub file: String,
679 pub line: i64,
680 pub role: String,
681 pub peer: String,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct CommunityMember {
686 pub name: String,
687 #[serde(skip_serializing_if = "Option::is_none", default)]
688 pub file: Option<String>,
689 #[serde(skip_serializing_if = "Option::is_none", default)]
690 pub line: Option<i64>,
691 #[serde(skip_serializing_if = "Vec::is_empty", default)]
692 pub refs: Vec<CommunityMemberRef>,
693 #[serde(skip_serializing_if = "Option::is_none", default)]
694 pub tagpath_handle: Option<String>,
695}
696
697impl CommunityMember {
698 pub fn new(name: impl Into<String>) -> Self {
699 Self {
700 name: name.into(),
701 file: None,
702 line: None,
703 refs: Vec::new(),
704 tagpath_handle: None,
705 }
706 }
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct TerseCommunityMember {
711 pub name: String,
712 #[serde(skip_serializing_if = "Option::is_none", default)]
713 pub tagpath_handle: Option<String>,
714}
715
716impl From<&CommunityMember> for TerseCommunityMember {
717 fn from(m: &CommunityMember) -> Self {
718 Self {
719 name: m.name.clone(),
720 tagpath_handle: m.tagpath_handle.clone(),
721 }
722 }
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct TerseCommunity {
727 pub id: usize,
728 pub members: Vec<TerseCommunityMember>,
729 pub modularity_contribution: f64,
730}
731
732impl TerseCommunity {
733 pub fn from_community(community: &Community, top_n: usize) -> Self {
734 let members: Vec<TerseCommunityMember> = community
735 .members
736 .iter()
737 .take(top_n)
738 .map(TerseCommunityMember::from)
739 .collect();
740 Self {
741 id: community.id,
742 members,
743 modularity_contribution: community.modularity_contribution,
744 }
745 }
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct Community {
750 pub id: usize,
751 pub members: Vec<CommunityMember>,
752 pub modularity_contribution: f64,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct CommunityResult {
757 pub communities: Vec<Community>,
758 pub modularity: f64,
759 pub iterations: usize,
760 pub node_count: usize,
761 pub edge_count: usize,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize)]
765pub struct TerseCommunityResult {
766 pub communities: Vec<TerseCommunity>,
767 pub modularity: f64,
768 pub iterations: usize,
769 pub node_count: usize,
770 pub edge_count: usize,
771}
772
773impl CommunityResult {
774 pub fn to_terse(&self, top_n: usize) -> TerseCommunityResult {
775 TerseCommunityResult {
776 communities: self
777 .communities
778 .iter()
779 .map(|c| TerseCommunity::from_community(c, top_n))
780 .collect(),
781 modularity: self.modularity,
782 iterations: self.iterations,
783 node_count: self.node_count,
784 edge_count: self.edge_count,
785 }
786 }
787}
788
789struct LouvainGraph {
790 n: usize,
791 adj: Vec<HashMap<usize, f64>>,
792 degree: Vec<f64>,
793 m: f64,
794}
795
796impl LouvainGraph {
797 fn from_indexed(n: usize, adj: Vec<HashSet<usize>>) -> Self {
798 let degree: Vec<f64> = adj.iter().map(|nb| nb.len() as f64).collect();
799 let m = degree.iter().sum::<f64>() / 2.0;
800 let weighted: Vec<HashMap<usize, f64>> = adj
801 .iter()
802 .map(|nb| nb.iter().map(|&j| (j, 1.0_f64)).collect())
803 .collect();
804 Self {
805 n,
806 adj: weighted,
807 degree,
808 m,
809 }
810 }
811
812 fn phase1(&self) -> (Vec<usize>, usize, bool) {
813 let n = self.n;
814 let m = self.m;
815 let mut community: Vec<usize> = (0..n).collect();
816 let mut comm_degree = self.degree.clone();
817 let mut ki_in: Vec<HashMap<usize, f64>> = (0..n)
818 .map(|i| {
819 let mut map = HashMap::new();
820 for (&nb, &w) in &self.adj[i] {
821 *map.entry(community[nb]).or_insert(0.0) += w;
822 }
823 map
824 })
825 .collect();
826
827 let mut iterations = 0;
828 let mut any_improved = false;
829 loop {
830 let mut improved = false;
831 iterations += 1;
832
833 for i in 0..n {
834 let cur_c = community[i];
835 let ki = self.degree[i];
836
837 let ki_in_cur = ki_in[i].get(&cur_c).copied().unwrap_or(0.0);
838 let cur_gain = ki_in_cur / m - ki * (comm_degree[cur_c] - ki) / (2.0 * m * m);
839
840 let mut best_delta = 0.0f64;
841 let mut best_c = cur_c;
842
843 for (&c, &ki_in_c) in &ki_in[i] {
844 if c == cur_c {
845 continue;
846 }
847 let target_gain = ki_in_c / m - ki * comm_degree[c] / (2.0 * m * m);
848 let delta = target_gain - cur_gain;
849 if delta > best_delta {
850 best_delta = delta;
851 best_c = c;
852 }
853 }
854
855 if best_c != cur_c {
856 comm_degree[cur_c] -= ki;
857 comm_degree[best_c] += ki;
858 for (&nb, &w) in &self.adj[i] {
859 ki_in[nb].entry(cur_c).and_modify(|v| *v -= w).or_insert(-w);
860 *ki_in[nb].entry(best_c).or_insert(0.0) += w;
861 }
862 community[i] = best_c;
863 improved = true;
864 any_improved = true;
865 }
866 }
867
868 if !improved || iterations >= 100 {
869 break;
870 }
871 }
872 (community, iterations, any_improved)
873 }
874
875 fn coarsen(&self, community: &[usize]) -> LouvainGraph {
876 let mut remap = HashMap::new();
877 for &c in community {
878 if !remap.contains_key(&c) {
879 let idx = remap.len();
880 remap.insert(c, idx);
881 }
882 }
883 let n2 = remap.len();
884 let mut adj2: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n2];
885
886 for i in 0..self.n {
887 let ci = remap[&community[i]];
888 for (&j, &w) in &self.adj[i] {
889 let cj = remap[&community[j]];
890 if ci == cj {
891 *adj2[ci].entry(ci).or_insert(0.0) += w / 2.0;
892 } else {
893 *adj2[ci].entry(cj).or_insert(0.0) += w;
894 }
895 }
896 }
897
898 LouvainGraph::from_weighted(n2, adj2)
899 }
900
901 #[allow(dead_code)]
902 fn from_weighted(n: usize, adj: Vec<HashMap<usize, f64>>) -> Self {
903 let degree: Vec<f64> = (0..n).map(|i| adj[i].values().sum::<f64>()).collect();
904 let m = degree.iter().sum::<f64>() / 2.0;
905 Self { n, adj, degree, m }
906 }
907}
908
909pub fn detect_communities(edges: &[(String, String)]) -> CommunityResult {
910 if edges.is_empty() {
911 return CommunityResult {
912 communities: Vec::new(),
913 modularity: 0.0,
914 iterations: 0,
915 node_count: 0,
916 edge_count: 0,
917 };
918 }
919
920 let mut node_vec: Vec<String> = Vec::new();
921 let mut node_idx: HashMap<String, usize> = HashMap::new();
922 for (a, b) in edges {
923 for name in [a, b] {
924 if !node_idx.contains_key(name) {
925 node_idx.insert(name.clone(), node_vec.len());
926 node_vec.push(name.clone());
927 }
928 }
929 }
930 let n = node_vec.len();
931
932 let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); n];
933 for (a, b) in edges {
934 let ai = node_idx[a];
935 let bi = node_idx[b];
936 if ai != bi {
937 adj[ai].insert(bi);
938 adj[bi].insert(ai);
939 }
940 }
941
942 let m = adj.iter().map(|nb| nb.len() as f64).sum::<f64>() / 2.0;
943
944 if m == 0.0 {
945 let communities = node_vec
946 .iter()
947 .enumerate()
948 .map(|(i, name)| Community {
949 id: i,
950 members: vec![CommunityMember::new(name.clone())],
951 modularity_contribution: 0.0,
952 })
953 .collect();
954 return CommunityResult {
955 communities,
956 modularity: 0.0,
957 iterations: 0,
958 node_count: n,
959 edge_count: 0,
960 };
961 }
962
963 let graph = LouvainGraph::from_indexed(n, adj);
964 let mut total_iterations = 0;
965 let original_degrees: Vec<f64> = graph.degree.clone();
966
967 let (community, iter1, _) = graph.phase1();
968 total_iterations += iter1;
969
970 let mut level_assignment = community;
971 let mut current_graph = graph;
972
973 for _level in 0..10 {
974 let coarse = current_graph.coarsen(&level_assignment);
975 if coarse.n == current_graph.n {
976 break;
977 }
978 let (coarse_community, iters, improved) = coarse.phase1();
979 total_iterations += iters;
980 if !improved {
981 break;
982 }
983
984 let mut remap = HashMap::new();
985 for &c in &level_assignment {
986 if !remap.contains_key(&c) {
987 let idx = remap.len();
988 remap.insert(c, idx);
989 }
990 }
991
992 let mut final_community = vec![0usize; n];
993 for i in 0..n {
994 let coarse_node = remap[&level_assignment[i]];
995 final_community[i] = coarse_community[coarse_node];
996 }
997
998 let mut final_remap = HashMap::new();
999 let mut next_id = 0usize;
1000 for c in &final_community {
1001 if let Some(&_id) = final_remap.get(c) {
1002 continue;
1003 }
1004 final_remap.insert(*c, next_id);
1005 next_id += 1;
1006 }
1007 for i in 0..n {
1008 final_community[i] = final_remap[&final_community[i]];
1009 }
1010
1011 level_assignment = final_community;
1012 current_graph = coarse;
1013 }
1014
1015 let community = level_assignment;
1016
1017 let mut node_to_comm: HashMap<String, usize> = HashMap::new();
1018 for (i, &c) in community.iter().enumerate() {
1019 node_to_comm.insert(node_vec[i].clone(), c);
1020 }
1021
1022 let mut comm_members: HashMap<usize, Vec<String>> = HashMap::new();
1023 let mut comm_internal: HashMap<usize, f64> = HashMap::new();
1024 let mut comm_degree_map: HashMap<usize, f64> = HashMap::new();
1025
1026 for (i, &c) in community.iter().enumerate() {
1027 comm_members.entry(c).or_default().push(node_vec[i].clone());
1028 *comm_degree_map.entry(c).or_insert(0.0) += original_degrees[i];
1029 }
1030 for (a, b) in edges {
1031 let ca = node_to_comm[a];
1032 let cb = node_to_comm[b];
1033 if ca == cb {
1034 *comm_internal.entry(ca).or_insert(0.0) += 1.0;
1035 }
1036 }
1037
1038 let mut total_modularity = 0.0;
1039 let mut communities: Vec<Community> = comm_members
1040 .into_iter()
1041 .map(|(id, mut members)| {
1042 members.sort();
1043 let lc = comm_internal.get(&id).copied().unwrap_or(0.0);
1044 let dc = comm_degree_map[&id];
1045 let mod_contrib = lc / m - (dc / (2.0 * m)).powi(2);
1046 total_modularity += mod_contrib;
1047 Community {
1048 id,
1049 members: members.into_iter().map(CommunityMember::new).collect(),
1050 modularity_contribution: mod_contrib,
1051 }
1052 })
1053 .collect();
1054
1055 communities.sort_by(|a, b| b.members.len().cmp(&a.members.len()).then(a.id.cmp(&b.id)));
1056
1057 CommunityResult {
1058 communities,
1059 modularity: total_modularity,
1060 iterations: total_iterations,
1061 node_count: n,
1062 edge_count: m as usize,
1063 }
1064}
1065
1066#[derive(Debug, Clone, Serialize)]
1067pub struct PathNode {
1068 pub name: String,
1069 #[serde(skip_serializing_if = "Option::is_none", default)]
1070 pub tagpath_handle: Option<String>,
1071}
1072
1073impl PathNode {
1074 pub fn new(name: impl Into<String>) -> Self {
1075 Self {
1076 name: name.into(),
1077 tagpath_handle: None,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone, Serialize)]
1083pub struct PathResult {
1084 pub from: String,
1085 pub to: String,
1086 pub path: Vec<PathNode>,
1087 pub hops: usize,
1088}
1089
1090pub fn shortest_path(edges: &[(String, String)], from: &str, to: &str) -> Option<PathResult> {
1091 if from == to {
1092 return Some(PathResult {
1093 from: from.to_string(),
1094 to: to.to_string(),
1095 path: vec![PathNode::new(from)],
1096 hops: 0,
1097 });
1098 }
1099
1100 let mut adj: HashMap<&str, HashSet<&str>> = HashMap::new();
1101 for (a, b) in edges {
1102 if a == b {
1103 continue;
1104 }
1105 adj.entry(a.as_str()).or_default().insert(b.as_str());
1106 adj.entry(b.as_str()).or_default().insert(a.as_str());
1107 }
1108
1109 if !adj.contains_key(from) || !adj.contains_key(to) {
1110 return None;
1111 }
1112
1113 let mut visited: HashSet<&str> = HashSet::new();
1114 let mut queue: VecDeque<&str> = VecDeque::new();
1115 let mut parent: HashMap<&str, &str> = HashMap::new();
1116
1117 visited.insert(from);
1118 queue.push_back(from);
1119
1120 while let Some(current) = queue.pop_front() {
1121 if let Some(neighbors) = adj.get(current) {
1122 for &neighbor in neighbors {
1123 if visited.insert(neighbor) {
1124 parent.insert(neighbor, current);
1125 if neighbor == to {
1126 let mut path = vec![PathNode::new(to)];
1127 let mut curr = to;
1128 while let Some(&p) = parent.get(curr) {
1129 path.push(PathNode::new(p));
1130 curr = p;
1131 }
1132 path.reverse();
1133 let hops = path.len() - 1;
1134 return Some(PathResult {
1135 from: from.to_string(),
1136 to: to.to_string(),
1137 path,
1138 hops,
1139 });
1140 }
1141 queue.push_back(neighbor);
1142 }
1143 }
1144 }
1145 }
1146
1147 None
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::*;
1153
1154 #[cfg(feature = "lang-rust")]
1155 #[test]
1156 fn rust_direct_call() {
1157 let source = b"fn helper() {}\nfn main() { helper(); }";
1158 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1159 assert!(
1160 sites.iter().any(|s| s.callee == "helper"),
1161 "got: {:?}",
1162 sites
1163 );
1164 }
1165
1166 #[cfg(feature = "lang-rust")]
1167 #[test]
1168 fn rust_method_call() {
1169 let source = b"fn main() { vec.push(1); }";
1170 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1171 assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1172 }
1173
1174 #[cfg(feature = "lang-rust")]
1175 #[test]
1176 fn rust_scoped_call() {
1177 let source = b"fn main() { Vec::new(); }";
1178 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1179 assert!(sites.iter().any(|s| s.callee == "new"), "got: {:?}", sites);
1180 }
1181
1182 #[cfg(feature = "lang-rust")]
1183 #[test]
1184 fn rust_macro_call() {
1185 let source = b"fn main() { println!(\"hi\"); }";
1186 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1187 assert!(
1188 sites.iter().any(|s| s.callee == "println"),
1189 "got: {:?}",
1190 sites
1191 );
1192 }
1193
1194 #[cfg(feature = "lang-rust")]
1195 #[test]
1196 fn rust_axum_route_extracted() {
1197 let source = br#"fn router() {
1198 Router::new().route("/users", get(list_users));
1199}
1200fn list_users() {}
1201"#;
1202 let routes = extract_route_sites(Lang::Rust, source).unwrap();
1203 assert!(routes.iter().any(|route| {
1204 route.framework == "axum"
1205 && route.method.as_deref() == Some("get")
1206 && route.path == "/users"
1207 && route.handler == "list_users"
1208 }));
1209 }
1210
1211 #[cfg(feature = "lang-rust")]
1212 #[test]
1213 fn rust_actix_route_attribute_extracted() {
1214 let source = br#"#[post("/submit")]
1215async fn submit_form() {}
1216"#;
1217 let routes = extract_route_sites(Lang::Rust, source).unwrap();
1218 assert_eq!(routes.len(), 1);
1219 assert_eq!(routes[0].framework, "actix");
1220 assert_eq!(routes[0].method.as_deref(), Some("post"));
1221 assert_eq!(routes[0].handler, "submit_form");
1222 }
1223
1224 #[cfg(feature = "lang-kotlin")]
1225 #[test]
1226 fn kotlin_direct_and_navigation_calls_resolve() {
1227 let source = b"fun main() {\n helper(1)\n obj.method(2)\n}\n";
1232 let sites = extract_call_sites(Lang::Kotlin, source).unwrap();
1233 assert!(
1234 sites.iter().any(|s| s.callee == "helper"),
1235 "missing direct call, got: {sites:?}"
1236 );
1237 assert!(
1238 sites.iter().any(|s| s.callee == "method"),
1239 "missing navigation call, got: {sites:?}"
1240 );
1241 }
1242
1243 #[cfg(feature = "lang-zig")]
1244 #[test]
1245 fn zig_direct_and_field_calls_resolve() {
1246 let source =
1247 b"pub fn main() void {\n helper();\n imported.Container.method();\n}\n";
1248 let sites = extract_call_sites(Lang::Zig, source).unwrap();
1249 assert!(
1250 sites.iter().any(|site| site.callee == "helper"),
1251 "missing direct call, got: {sites:?}"
1252 );
1253 assert!(
1254 sites.iter().any(|site| site.callee == "method"),
1255 "missing field call, got: {sites:?}"
1256 );
1257 }
1258
1259 #[cfg(feature = "lang-gdscript")]
1260 #[test]
1261 fn gdscript_direct_attribute_and_base_calls_resolve() {
1262 let source =
1263 b"func _ready():\n\thelper(1)\n\t$Sprite2D.play(\"walk\")\n\nfunc _init():\n\t.foo()\n";
1264 let sites = extract_call_sites(Lang::GdScript, source).unwrap();
1265 for callee in ["helper", "play", "foo"] {
1266 assert!(
1267 sites.iter().any(|s| s.callee == callee),
1268 "missing {callee} call, got: {sites:?}"
1269 );
1270 }
1271 }
1272
1273 #[cfg(feature = "lang-python")]
1274 #[test]
1275 fn python_direct_call() {
1276 let source = b"def helper(): pass\ndef main(): helper()";
1277 let sites = extract_call_sites(Lang::Python, source).unwrap();
1278 assert!(
1279 sites.iter().any(|s| s.callee == "helper"),
1280 "got: {:?}",
1281 sites
1282 );
1283 }
1284
1285 #[cfg(feature = "lang-python")]
1286 #[test]
1287 fn python_method_call() {
1288 let source = b"def main(): obj.method()";
1289 let sites = extract_call_sites(Lang::Python, source).unwrap();
1290 assert!(
1291 sites.iter().any(|s| s.callee == "method"),
1292 "got: {:?}",
1293 sites
1294 );
1295 }
1296
1297 #[cfg(feature = "lang-python")]
1298 #[test]
1299 fn python_fastapi_route_extracted() {
1300 let source = br#"@router.get("/items/{item_id}")
1301def read_item(item_id: str):
1302 return item_id
1303"#;
1304 let routes = extract_route_sites(Lang::Python, source).unwrap();
1305 assert_eq!(routes.len(), 1);
1306 assert_eq!(routes[0].framework, "fastapi");
1307 assert_eq!(routes[0].method.as_deref(), Some("get"));
1308 assert_eq!(routes[0].path, "/items/{item_id}");
1309 assert_eq!(routes[0].handler, "read_item");
1310 }
1311
1312 #[cfg(feature = "lang-typescript")]
1313 #[test]
1314 fn typescript_direct_call() {
1315 let source = b"function helper() {}\nfunction main() { helper(); }";
1316 let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1317 assert!(
1318 sites.iter().any(|s| s.callee == "helper"),
1319 "got: {:?}",
1320 sites
1321 );
1322 }
1323
1324 #[cfg(feature = "lang-typescript")]
1325 #[test]
1326 fn typescript_method_call() {
1327 let source = b"function main() { arr.push(1); }";
1328 let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1329 assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1330 }
1331
1332 #[cfg(feature = "lang-typescript")]
1333 #[test]
1334 fn typescript_express_route_extracted() {
1335 let source = br#"router.post("/users", createUser);
1336function createUser() {}
1337"#;
1338 let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1339 assert_eq!(routes.len(), 1);
1340 assert_eq!(routes[0].framework, "express");
1341 assert_eq!(routes[0].method.as_deref(), Some("post"));
1342 assert_eq!(routes[0].path, "/users");
1343 assert_eq!(routes[0].handler, "createUser");
1344 }
1345
1346 #[cfg(feature = "lang-typescript")]
1347 #[test]
1348 fn typescript_route_extraction_ignores_search_params_and_comments() {
1349 let source = br#"const visible = searchParams.get("visible");
1350// searchParams.delete("date");
1351/* router.delete("/commented", removeCommented); */
1352"#;
1353 let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1354 assert!(routes.is_empty(), "got: {routes:?}");
1355 }
1356
1357 #[cfg(feature = "lang-javascript")]
1358 #[test]
1359 fn javascript_call() {
1360 let source = b"function main() { helper(); obj.method(); }";
1361 let sites = extract_call_sites(Lang::JavaScript, source).unwrap();
1362 assert!(
1363 sites.iter().any(|s| s.callee == "helper"),
1364 "got: {:?}",
1365 sites
1366 );
1367 assert!(
1368 sites.iter().any(|s| s.callee == "method"),
1369 "got: {:?}",
1370 sites
1371 );
1372 }
1373
1374 #[cfg(feature = "lang-rust")]
1375 fn test_symbol(name: &str, line: usize, end_line: usize) -> Symbol {
1376 Symbol {
1377 name: name.into(),
1378 kind: "function".into(),
1379 line,
1380 end_line,
1381 node_kind: "function_item".into(),
1382 start_byte: line,
1383 end_byte: end_line,
1384 body_start_byte: None,
1385 body_end_byte: None,
1386 }
1387 }
1388
1389 #[cfg(feature = "lang-rust")]
1390 #[test]
1391 fn resolve_edges_basic() {
1392 let symbols = vec![test_symbol("main", 1, 3), test_symbol("helper", 5, 7)];
1393 let sites = vec![
1394 CallSite {
1395 callee: "helper".into(),
1396 line: 2,
1397 },
1398 CallSite {
1399 callee: "println".into(),
1400 line: 6,
1401 },
1402 ];
1403 let edges = resolve_edges(&symbols, &sites);
1404 assert_eq!(edges.len(), 2);
1405 assert_eq!(edges[0].caller, "main");
1406 assert_eq!(edges[0].callee, "helper");
1407 assert_eq!(edges[1].caller, "helper");
1408 assert_eq!(edges[1].callee, "println");
1409 }
1410
1411 #[cfg(feature = "lang-rust")]
1412 #[test]
1413 fn resolve_edges_nested_picks_innermost() {
1414 let symbols = vec![test_symbol("outer", 0, 10), test_symbol("inner", 2, 5)];
1415 let sites = vec![CallSite {
1416 callee: "foo".into(),
1417 line: 3,
1418 }];
1419 let edges = resolve_edges(&symbols, &sites);
1420 assert_eq!(edges.len(), 1);
1421 assert_eq!(edges[0].caller, "inner");
1422 }
1423
1424 #[cfg(feature = "lang-rust")]
1425 #[test]
1426 fn resolve_edges_top_level_call_excluded() {
1427 let symbols = vec![test_symbol("main", 5, 10)];
1428 let sites = vec![CallSite {
1429 callee: "foo".into(),
1430 line: 2,
1431 }];
1432 let edges = resolve_edges(&symbols, &sites);
1433 assert!(edges.is_empty());
1434 }
1435
1436 #[test]
1437 fn resolve_edges_cache_reuses_slots_until_mtime_or_hash_changes() {
1438 let cache = ResolveEdgesCache::new();
1439 let file = std::path::Path::new("src/lib.rs");
1440 let symbols = vec![test_symbol("main", 1, 3)];
1441 let sites = vec![CallSite {
1442 callee: "helper".into(),
1443 line: 2,
1444 }];
1445
1446 let first =
1447 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1448 assert_eq!(first.len(), 1);
1449 assert_eq!(cache.stats(), (0, 1));
1450
1451 let cached =
1452 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1453 assert_eq!(cached, first);
1454 assert_eq!(cache.stats(), (1, 1));
1455
1456 let refreshed =
1457 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(11, 0), &symbols, &sites);
1458 assert_eq!(refreshed, first);
1459 assert_eq!(cache.stats(), (1, 2));
1460
1461 let new_hash =
1462 cache.resolve_edges_for_file(file, "hash-b", FileMtime::new(11, 0), &symbols, &sites);
1463 assert_eq!(new_hash, first);
1464 assert_eq!(cache.stats(), (1, 3));
1465 }
1466
1467 #[test]
1468 fn project_call_edges_to_provider_neutral_substrate() {
1469 let edges = vec![CallEdge {
1470 caller: "main".into(),
1471 callee: "helper".into(),
1472 caller_line: 10,
1473 call_site_line: 12,
1474 }];
1475 let projection = project_call_edges(
1476 &edges,
1477 Some(GraphProvenance::new("tsift.index", "src/main.rs")),
1478 );
1479
1480 assert_eq!(projection.nodes.len(), 2);
1481 assert_eq!(projection.edges.len(), 1);
1482 assert!(
1483 projection
1484 .nodes
1485 .iter()
1486 .any(|node| node.id == code_symbol_node_id("main") && node.kind == "code_symbol")
1487 );
1488 }
1489
1490 #[test]
1491 fn project_routes_to_provider_neutral_substrate() {
1492 let routes = vec![RouteSite {
1493 framework: "fastapi".into(),
1494 method: Some("get".into()),
1495 path: "/items".into(),
1496 handler: "list_items".into(),
1497 line: 3,
1498 handler_line: Some(4),
1499 }];
1500 let projection = project_routes(
1501 &routes,
1502 Some(GraphProvenance::new("tsift.index", "src/api.py")),
1503 );
1504
1505 assert!(
1506 projection
1507 .nodes
1508 .iter()
1509 .any(|node| node.kind == "route" && node.label == "GET /items")
1510 );
1511 assert!(projection.edges.iter().any(|edge| edge.kind == "handled_by"
1512 && edge.properties.get("route_path") == Some(&"/items".to_string())));
1513 }
1514
1515 #[test]
1516 fn no_call_query_returns_empty() {
1517 #[cfg(feature = "lang-markdown")]
1518 {
1519 let sites = extract_call_sites(Lang::Markdown, b"# Hello").unwrap();
1520 assert!(sites.is_empty());
1521 }
1522 }
1523
1524 #[cfg(feature = "lang-rust")]
1525 #[test]
1526 fn full_roundtrip_rust() {
1527 let source = b"fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }";
1528 let symbols = Lang::Rust.extract_symbols(source).unwrap();
1529 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1530 let edges = resolve_edges(&symbols, &sites);
1531 let main_calls: Vec<&str> = edges
1532 .iter()
1533 .filter(|e| e.caller == "main")
1534 .map(|e| e.callee.as_str())
1535 .collect();
1536 assert!(
1537 main_calls.contains(&"helper"),
1538 "main should call helper, got: {:?}",
1539 main_calls
1540 );
1541 assert!(
1542 main_calls.contains(&"new"),
1543 "main should call new, got: {:?}",
1544 main_calls
1545 );
1546 }
1547
1548 fn s(a: &str, b: &str) -> (String, String) {
1549 (a.to_string(), b.to_string())
1550 }
1551
1552 #[test]
1553 fn communities_empty_graph() {
1554 let result = detect_communities(&[]);
1555 assert_eq!(result.node_count, 0);
1556 assert_eq!(result.edge_count, 0);
1557 assert!(result.communities.is_empty());
1558 assert_eq!(result.iterations, 0);
1559 }
1560
1561 #[test]
1562 fn communities_single_edge() {
1563 let edges = vec![s("a", "b")];
1564 let result = detect_communities(&edges);
1565 assert_eq!(result.node_count, 2);
1566 assert_eq!(result.edge_count, 1);
1567 assert_eq!(result.communities.len(), 1);
1568 assert_eq!(result.communities[0].members.len(), 2);
1569 }
1570
1571 #[test]
1572 fn communities_self_loop_ignored() {
1573 let edges = vec![s("a", "a"), s("a", "b")];
1574 let result = detect_communities(&edges);
1575 assert_eq!(result.node_count, 2);
1576 assert_eq!(result.edge_count, 1);
1577 }
1578
1579 #[test]
1580 fn communities_duplicate_edges_deduplicated() {
1581 let edges = vec![
1582 s("main", "helper"),
1583 s("main", "helper"),
1584 s("main", "helper"),
1585 ];
1586 let result = detect_communities(&edges);
1587 assert_eq!(result.node_count, 2);
1588 assert_eq!(result.edge_count, 1);
1589 }
1590
1591 #[test]
1592 fn communities_two_cliques_split() {
1593 let edges = vec![
1594 s("a", "b"),
1595 s("a", "c"),
1596 s("b", "c"),
1597 s("d", "e"),
1598 s("d", "f"),
1599 s("e", "f"),
1600 s("a", "d"),
1601 ];
1602 let result = detect_communities(&edges);
1603 assert_eq!(result.node_count, 6);
1604 assert_eq!(
1605 result.communities.len(),
1606 2,
1607 "expected 2 communities, got: {:?}",
1608 result
1609 .communities
1610 .iter()
1611 .map(|c| &c.members)
1612 .collect::<Vec<_>>()
1613 );
1614 assert_eq!(result.communities[0].members.len(), 3);
1615 assert_eq!(result.communities[1].members.len(), 3);
1616 assert!(result.modularity > 0.0);
1617 }
1618
1619 #[test]
1620 fn communities_disconnected_components() {
1621 let edges = vec![s("a", "b"), s("c", "d")];
1622 let result = detect_communities(&edges);
1623 assert_eq!(result.node_count, 4);
1624 assert_eq!(result.edge_count, 2);
1625 assert!(result.modularity >= 0.0);
1626 }
1627
1628 #[test]
1629 fn communities_modularity_non_negative_for_clustered() {
1630 let edges = vec![
1631 s("a", "b"),
1632 s("a", "c"),
1633 s("b", "c"),
1634 s("d", "e"),
1635 s("d", "f"),
1636 s("e", "f"),
1637 ];
1638 let result = detect_communities(&edges);
1639 assert!(result.modularity >= 0.0, "Q={}", result.modularity);
1640 }
1641
1642 #[test]
1643 fn communities_hierarchical_phase2_improves_modularity() {
1644 let mut edges = Vec::new();
1645 for cluster in 0..4 {
1646 let base = cluster * 6;
1647 for i in 0..6 {
1648 for j in (i + 1)..6 {
1649 edges.push((
1650 format!("c{}n{}", cluster, base + i),
1651 format!("c{}n{}", cluster, base + j),
1652 ));
1653 }
1654 }
1655 }
1656 edges.push(("c0n0".to_string(), "c1n6".to_string()));
1657 edges.push(("c2n12".to_string(), "c3n18".to_string()));
1658 edges.push(("c0n1".to_string(), "c2n12".to_string()));
1659
1660 let result = detect_communities(&edges);
1661 assert!(result.modularity > 0.0, "Q={}", result.modularity);
1662 assert!(
1663 result.communities.len() >= 2,
1664 "expected >= 2 communities for hierarchical structure, got {}",
1665 result.communities.len()
1666 );
1667 assert!(result.iterations >= 1);
1668 }
1669
1670 fn path_names(result: &PathResult) -> Vec<&str> {
1671 result.path.iter().map(|n| n.name.as_str()).collect()
1672 }
1673
1674 #[test]
1675 fn path_direct_neighbors() {
1676 let edges = vec![s("a", "b")];
1677 let result = shortest_path(&edges, "a", "b").unwrap();
1678 assert_eq!(path_names(&result), vec!["a", "b"]);
1679 assert_eq!(result.hops, 1);
1680 assert!(result.path.iter().all(|n| n.tagpath_handle.is_none()));
1681 }
1682
1683 #[test]
1684 fn path_two_hops() {
1685 let edges = vec![s("a", "b"), s("b", "c")];
1686 let result = shortest_path(&edges, "a", "c").unwrap();
1687 assert_eq!(result.hops, 2);
1688 assert_eq!(result.path.first().unwrap().name, "a");
1689 assert_eq!(result.path.last().unwrap().name, "c");
1690 }
1691
1692 #[test]
1693 fn path_same_node() {
1694 let edges = vec![s("a", "b")];
1695 let result = shortest_path(&edges, "a", "a").unwrap();
1696 assert_eq!(path_names(&result), vec!["a"]);
1697 assert_eq!(result.hops, 0);
1698 }
1699
1700 #[test]
1701 fn path_no_connection() {
1702 let edges = vec![s("a", "b"), s("c", "d")];
1703 assert!(shortest_path(&edges, "a", "c").is_none());
1704 }
1705
1706 #[test]
1707 fn path_unknown_node() {
1708 let edges = vec![s("a", "b")];
1709 assert!(shortest_path(&edges, "a", "z").is_none());
1710 }
1711
1712 #[test]
1713 fn path_prefers_shorter() {
1714 let edges = vec![s("a", "b"), s("b", "c"), s("a", "c")];
1715 let result = shortest_path(&edges, "a", "c").unwrap();
1716 assert_eq!(result.hops, 1);
1717 }
1718
1719 #[test]
1720 fn path_self_loop_ignored() {
1721 let edges = vec![s("a", "a"), s("a", "b")];
1722 let result = shortest_path(&edges, "a", "b").unwrap();
1723 assert_eq!(result.hops, 1);
1724 }
1725
1726 #[test]
1727 fn terse_community_drops_optional_fields() {
1728 let member = CommunityMember {
1729 name: "foo".to_string(),
1730 file: Some("src/lib.rs".to_string()),
1731 line: Some(42),
1732 refs: vec![CommunityMemberRef {
1733 file: "src/lib.rs".to_string(),
1734 line: 42,
1735 role: "call".to_string(),
1736 peer: "bar".to_string(),
1737 }],
1738 tagpath_handle: Some("foo::lib".to_string()),
1739 };
1740 let terse = TerseCommunityMember::from(&member);
1741 assert_eq!(terse.name, "foo");
1742 assert_eq!(terse.tagpath_handle, Some("foo::lib".to_string()));
1743 }
1744
1745 #[test]
1746 fn terse_community_top_n_truncates_members() {
1747 let community = Community {
1748 id: 0,
1749 members: vec![
1750 CommunityMember::new("a"),
1751 CommunityMember::new("b"),
1752 CommunityMember::new("c"),
1753 CommunityMember::new("d"),
1754 CommunityMember::new("e"),
1755 ],
1756 modularity_contribution: 0.25,
1757 };
1758 let terse = TerseCommunity::from_community(&community, 3);
1759 assert_eq!(terse.id, 0);
1760 assert_eq!(terse.members.len(), 3);
1761 assert_eq!(terse.members[0].name, "a");
1762 assert_eq!(terse.members[2].name, "c");
1763 assert_eq!(terse.modularity_contribution, 0.25);
1764 }
1765
1766 #[test]
1767 fn terse_community_result_from_detect_communities() {
1768 let edges = vec![s("a", "b"), s("b", "c"), s("c", "d")];
1769 let result = detect_communities(&edges);
1770 let terse = result.to_terse(2);
1771 assert_eq!(terse.node_count, result.node_count);
1772 assert_eq!(terse.edge_count, result.edge_count);
1773 assert_eq!(terse.modularity, result.modularity);
1774 assert_eq!(terse.communities.len(), result.communities.len());
1775 for tc in &terse.communities {
1776 assert!(tc.members.len() <= 2);
1777 }
1778 }
1779
1780 #[test]
1781 fn terse_community_json_smaller_than_full() {
1782 let edges: Vec<(String, String)> = (0..20)
1783 .flat_map(|i| {
1784 let base = i * 5;
1785 vec![
1786 (format!("n{}", base), format!("n{}", base + 1)),
1787 (format!("n{}", base), format!("n{}", base + 2)),
1788 (format!("n{}", base + 1), format!("n{}", base + 2)),
1789 (format!("n{}", base + 2), format!("n{}", base + 3)),
1790 (format!("n{}", base + 3), format!("n{}", base + 4)),
1791 ]
1792 })
1793 .chain(std::iter::once(("n0".to_string(), "n5".to_string())))
1794 .collect();
1795 let result = detect_communities(&edges);
1796 let terse = result.to_terse(2);
1797 let full_member_count: usize = result.communities.iter().map(|c| c.members.len()).sum();
1798 let terse_member_count: usize = terse.communities.iter().map(|c| c.members.len()).sum();
1799 assert!(
1800 terse_member_count < full_member_count,
1801 "terse members ({}) should be fewer than full ({})",
1802 terse_member_count,
1803 full_member_count
1804 );
1805 }
1806}