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| {
557 matches!(s.kind.as_str(), "function" | "method" | "class" | "mod")
558 })
559 .filter(|s| site.line >= s.line && site.line <= s.end_line)
560 .min_by_key(|s| s.end_line - s.line);
561 if let Some(caller) = caller {
562 edges.push(CallEdge {
563 caller: caller.name.clone(),
564 callee: site.callee.clone(),
565 caller_line: caller.line,
566 call_site_line: site.line,
567 });
568 }
569 }
570 edges
571}
572
573pub fn code_symbol_node_id(name: &str) -> String {
574 format!("code.symbol:{name}")
575}
576
577pub fn code_route_node_id(framework: &str, method: Option<&str>, path: &str) -> String {
578 format!(
579 "code.route:{}:{}:{}",
580 framework,
581 method.unwrap_or("any"),
582 path
583 )
584}
585
586pub fn project_call_edges(
587 edges: &[CallEdge],
588 provenance: Option<GraphProvenance>,
589) -> GraphProjection {
590 let mut nodes = BTreeMap::<String, GraphNode>::new();
591 let mut projected_edges = Vec::with_capacity(edges.len());
592
593 for edge in edges {
594 let caller_id = code_symbol_node_id(&edge.caller);
595 let callee_id = code_symbol_node_id(&edge.callee);
596 for (id, label) in [(&caller_id, &edge.caller), (&callee_id, &edge.callee)] {
597 nodes.entry(id.clone()).or_insert_with(|| {
598 let mut node = GraphNode::new(id.clone(), "code_symbol", label.clone());
599 if let Some(provenance) = provenance.clone() {
600 node = node.with_provenance(provenance);
601 }
602 node
603 });
604 }
605
606 let mut projected = GraphEdge::new(caller_id, callee_id, "calls")
607 .with_property("caller_line", edge.caller_line.to_string())
608 .with_property("call_site_line", edge.call_site_line.to_string());
609 if let Some(provenance) = provenance.clone() {
610 projected = projected.with_provenance(provenance);
611 }
612 projected_edges.push(projected);
613 }
614
615 GraphProjection {
616 nodes: nodes.into_values().collect(),
617 edges: projected_edges,
618 }
619}
620
621pub fn project_routes(
622 routes: &[RouteSite],
623 provenance: Option<GraphProvenance>,
624) -> GraphProjection {
625 let mut nodes = BTreeMap::<String, GraphNode>::new();
626 let mut projected_edges = Vec::with_capacity(routes.len());
627
628 for route in routes {
629 let route_id = code_route_node_id(&route.framework, route.method.as_deref(), &route.path);
630 let handler_id = code_symbol_node_id(&route.handler);
631 let mut route_node = GraphNode::new(
632 route_id.clone(),
633 "route",
634 format!(
635 "{} {}",
636 route.method.as_deref().unwrap_or("any").to_uppercase(),
637 route.path
638 ),
639 )
640 .with_property("framework", route.framework.clone())
641 .with_property("path", route.path.clone())
642 .with_property("handler", route.handler.clone())
643 .with_property("line", route.line.to_string());
644 if let Some(method) = &route.method {
645 route_node = route_node.with_property("method", method.clone());
646 }
647 if let Some(provenance) = provenance.clone() {
648 route_node = route_node.with_provenance(provenance);
649 }
650 nodes.entry(route_id.clone()).or_insert(route_node);
651
652 nodes.entry(handler_id.clone()).or_insert_with(|| {
653 let mut node = GraphNode::new(handler_id.clone(), "code_symbol", route.handler.clone());
654 if let Some(provenance) = provenance.clone() {
655 node = node.with_provenance(provenance);
656 }
657 node
658 });
659
660 let mut edge = GraphEdge::new(route_id, handler_id, "handled_by")
661 .with_property("route_path", route.path.clone())
662 .with_property("framework", route.framework.clone());
663 if let Some(method) = &route.method {
664 edge = edge.with_property("method", method.clone());
665 }
666 if let Some(provenance) = provenance.clone() {
667 edge = edge.with_provenance(provenance);
668 }
669 projected_edges.push(edge);
670 }
671
672 GraphProjection {
673 nodes: nodes.into_values().collect(),
674 edges: projected_edges,
675 }
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
679pub struct CommunityMemberRef {
680 pub file: String,
681 pub line: i64,
682 pub role: String,
683 pub peer: String,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct CommunityMember {
688 pub name: String,
689 #[serde(skip_serializing_if = "Option::is_none", default)]
690 pub file: Option<String>,
691 #[serde(skip_serializing_if = "Option::is_none", default)]
692 pub line: Option<i64>,
693 #[serde(skip_serializing_if = "Vec::is_empty", default)]
694 pub refs: Vec<CommunityMemberRef>,
695 #[serde(skip_serializing_if = "Option::is_none", default)]
696 pub tagpath_handle: Option<String>,
697}
698
699impl CommunityMember {
700 pub fn new(name: impl Into<String>) -> Self {
701 Self {
702 name: name.into(),
703 file: None,
704 line: None,
705 refs: Vec::new(),
706 tagpath_handle: None,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct TerseCommunityMember {
713 pub name: String,
714 #[serde(skip_serializing_if = "Option::is_none", default)]
715 pub tagpath_handle: Option<String>,
716}
717
718impl From<&CommunityMember> for TerseCommunityMember {
719 fn from(m: &CommunityMember) -> Self {
720 Self {
721 name: m.name.clone(),
722 tagpath_handle: m.tagpath_handle.clone(),
723 }
724 }
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct TerseCommunity {
729 pub id: usize,
730 pub members: Vec<TerseCommunityMember>,
731 pub modularity_contribution: f64,
732}
733
734impl TerseCommunity {
735 pub fn from_community(community: &Community, top_n: usize) -> Self {
736 let members: Vec<TerseCommunityMember> = community
737 .members
738 .iter()
739 .take(top_n)
740 .map(TerseCommunityMember::from)
741 .collect();
742 Self {
743 id: community.id,
744 members,
745 modularity_contribution: community.modularity_contribution,
746 }
747 }
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct Community {
752 pub id: usize,
753 pub members: Vec<CommunityMember>,
754 pub modularity_contribution: f64,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
758pub struct CommunityResult {
759 pub communities: Vec<Community>,
760 pub modularity: f64,
761 pub iterations: usize,
762 pub node_count: usize,
763 pub edge_count: usize,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct TerseCommunityResult {
768 pub communities: Vec<TerseCommunity>,
769 pub modularity: f64,
770 pub iterations: usize,
771 pub node_count: usize,
772 pub edge_count: usize,
773}
774
775impl CommunityResult {
776 pub fn to_terse(&self, top_n: usize) -> TerseCommunityResult {
777 TerseCommunityResult {
778 communities: self
779 .communities
780 .iter()
781 .map(|c| TerseCommunity::from_community(c, top_n))
782 .collect(),
783 modularity: self.modularity,
784 iterations: self.iterations,
785 node_count: self.node_count,
786 edge_count: self.edge_count,
787 }
788 }
789}
790
791struct LouvainGraph {
792 n: usize,
793 adj: Vec<HashMap<usize, f64>>,
794 degree: Vec<f64>,
795 m: f64,
796}
797
798impl LouvainGraph {
799 fn from_indexed(n: usize, adj: Vec<HashSet<usize>>) -> Self {
800 let degree: Vec<f64> = adj.iter().map(|nb| nb.len() as f64).collect();
801 let m = degree.iter().sum::<f64>() / 2.0;
802 let weighted: Vec<HashMap<usize, f64>> = adj
803 .iter()
804 .map(|nb| nb.iter().map(|&j| (j, 1.0_f64)).collect())
805 .collect();
806 Self {
807 n,
808 adj: weighted,
809 degree,
810 m,
811 }
812 }
813
814 fn phase1(&self) -> (Vec<usize>, usize, bool) {
815 let n = self.n;
816 let m = self.m;
817 let mut community: Vec<usize> = (0..n).collect();
818 let mut comm_degree = self.degree.clone();
819 let mut ki_in: Vec<HashMap<usize, f64>> = (0..n)
820 .map(|i| {
821 let mut map = HashMap::new();
822 for (&nb, &w) in &self.adj[i] {
823 *map.entry(community[nb]).or_insert(0.0) += w;
824 }
825 map
826 })
827 .collect();
828
829 let mut iterations = 0;
830 let mut any_improved = false;
831 loop {
832 let mut improved = false;
833 iterations += 1;
834
835 for i in 0..n {
836 let cur_c = community[i];
837 let ki = self.degree[i];
838
839 let ki_in_cur = ki_in[i].get(&cur_c).copied().unwrap_or(0.0);
840 let cur_gain = ki_in_cur / m - ki * (comm_degree[cur_c] - ki) / (2.0 * m * m);
841
842 let mut best_delta = 0.0f64;
843 let mut best_c = cur_c;
844
845 for (&c, &ki_in_c) in &ki_in[i] {
846 if c == cur_c {
847 continue;
848 }
849 let target_gain = ki_in_c / m - ki * comm_degree[c] / (2.0 * m * m);
850 let delta = target_gain - cur_gain;
851 if delta > best_delta {
852 best_delta = delta;
853 best_c = c;
854 }
855 }
856
857 if best_c != cur_c {
858 comm_degree[cur_c] -= ki;
859 comm_degree[best_c] += ki;
860 for (&nb, &w) in &self.adj[i] {
861 ki_in[nb].entry(cur_c).and_modify(|v| *v -= w).or_insert(-w);
862 *ki_in[nb].entry(best_c).or_insert(0.0) += w;
863 }
864 community[i] = best_c;
865 improved = true;
866 any_improved = true;
867 }
868 }
869
870 if !improved || iterations >= 100 {
871 break;
872 }
873 }
874 (community, iterations, any_improved)
875 }
876
877 fn coarsen(&self, community: &[usize]) -> LouvainGraph {
878 let mut remap = HashMap::new();
879 for &c in community {
880 if !remap.contains_key(&c) {
881 let idx = remap.len();
882 remap.insert(c, idx);
883 }
884 }
885 let n2 = remap.len();
886 let mut adj2: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n2];
887
888 for i in 0..self.n {
889 let ci = remap[&community[i]];
890 for (&j, &w) in &self.adj[i] {
891 let cj = remap[&community[j]];
892 if ci == cj {
893 *adj2[ci].entry(ci).or_insert(0.0) += w / 2.0;
894 } else {
895 *adj2[ci].entry(cj).or_insert(0.0) += w;
896 }
897 }
898 }
899
900 LouvainGraph::from_weighted(n2, adj2)
901 }
902
903 #[allow(dead_code)]
904 fn from_weighted(n: usize, adj: Vec<HashMap<usize, f64>>) -> Self {
905 let degree: Vec<f64> = (0..n).map(|i| adj[i].values().sum::<f64>()).collect();
906 let m = degree.iter().sum::<f64>() / 2.0;
907 Self { n, adj, degree, m }
908 }
909}
910
911pub fn detect_communities(edges: &[(String, String)]) -> CommunityResult {
912 if edges.is_empty() {
913 return CommunityResult {
914 communities: Vec::new(),
915 modularity: 0.0,
916 iterations: 0,
917 node_count: 0,
918 edge_count: 0,
919 };
920 }
921
922 let mut node_vec: Vec<String> = Vec::new();
923 let mut node_idx: HashMap<String, usize> = HashMap::new();
924 for (a, b) in edges {
925 for name in [a, b] {
926 if !node_idx.contains_key(name) {
927 node_idx.insert(name.clone(), node_vec.len());
928 node_vec.push(name.clone());
929 }
930 }
931 }
932 let n = node_vec.len();
933
934 let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); n];
935 for (a, b) in edges {
936 let ai = node_idx[a];
937 let bi = node_idx[b];
938 if ai != bi {
939 adj[ai].insert(bi);
940 adj[bi].insert(ai);
941 }
942 }
943
944 let m = adj.iter().map(|nb| nb.len() as f64).sum::<f64>() / 2.0;
945
946 if m == 0.0 {
947 let communities = node_vec
948 .iter()
949 .enumerate()
950 .map(|(i, name)| Community {
951 id: i,
952 members: vec![CommunityMember::new(name.clone())],
953 modularity_contribution: 0.0,
954 })
955 .collect();
956 return CommunityResult {
957 communities,
958 modularity: 0.0,
959 iterations: 0,
960 node_count: n,
961 edge_count: 0,
962 };
963 }
964
965 let graph = LouvainGraph::from_indexed(n, adj);
966 let mut total_iterations = 0;
967 let original_degrees: Vec<f64> = graph.degree.clone();
968
969 let (community, iter1, _) = graph.phase1();
970 total_iterations += iter1;
971
972 let mut level_assignment = community;
973 let mut current_graph = graph;
974
975 for _level in 0..10 {
976 let coarse = current_graph.coarsen(&level_assignment);
977 if coarse.n == current_graph.n {
978 break;
979 }
980 let (coarse_community, iters, improved) = coarse.phase1();
981 total_iterations += iters;
982 if !improved {
983 break;
984 }
985
986 let mut remap = HashMap::new();
987 for &c in &level_assignment {
988 if !remap.contains_key(&c) {
989 let idx = remap.len();
990 remap.insert(c, idx);
991 }
992 }
993
994 let mut final_community = vec![0usize; n];
995 for i in 0..n {
996 let coarse_node = remap[&level_assignment[i]];
997 final_community[i] = coarse_community[coarse_node];
998 }
999
1000 let mut final_remap = HashMap::new();
1001 let mut next_id = 0usize;
1002 for c in &final_community {
1003 if let Some(&_id) = final_remap.get(c) {
1004 continue;
1005 }
1006 final_remap.insert(*c, next_id);
1007 next_id += 1;
1008 }
1009 for i in 0..n {
1010 final_community[i] = final_remap[&final_community[i]];
1011 }
1012
1013 level_assignment = final_community;
1014 current_graph = coarse;
1015 }
1016
1017 let community = level_assignment;
1018
1019 let mut node_to_comm: HashMap<String, usize> = HashMap::new();
1020 for (i, &c) in community.iter().enumerate() {
1021 node_to_comm.insert(node_vec[i].clone(), c);
1022 }
1023
1024 let mut comm_members: HashMap<usize, Vec<String>> = HashMap::new();
1025 let mut comm_internal: HashMap<usize, f64> = HashMap::new();
1026 let mut comm_degree_map: HashMap<usize, f64> = HashMap::new();
1027
1028 for (i, &c) in community.iter().enumerate() {
1029 comm_members.entry(c).or_default().push(node_vec[i].clone());
1030 *comm_degree_map.entry(c).or_insert(0.0) += original_degrees[i];
1031 }
1032 for (a, b) in edges {
1033 let ca = node_to_comm[a];
1034 let cb = node_to_comm[b];
1035 if ca == cb {
1036 *comm_internal.entry(ca).or_insert(0.0) += 1.0;
1037 }
1038 }
1039
1040 let mut total_modularity = 0.0;
1041 let mut communities: Vec<Community> = comm_members
1042 .into_iter()
1043 .map(|(id, mut members)| {
1044 members.sort();
1045 let lc = comm_internal.get(&id).copied().unwrap_or(0.0);
1046 let dc = comm_degree_map[&id];
1047 let mod_contrib = lc / m - (dc / (2.0 * m)).powi(2);
1048 total_modularity += mod_contrib;
1049 Community {
1050 id,
1051 members: members.into_iter().map(CommunityMember::new).collect(),
1052 modularity_contribution: mod_contrib,
1053 }
1054 })
1055 .collect();
1056
1057 communities.sort_by(|a, b| b.members.len().cmp(&a.members.len()).then(a.id.cmp(&b.id)));
1058
1059 CommunityResult {
1060 communities,
1061 modularity: total_modularity,
1062 iterations: total_iterations,
1063 node_count: n,
1064 edge_count: m as usize,
1065 }
1066}
1067
1068#[derive(Debug, Clone, Serialize)]
1069pub struct PathNode {
1070 pub name: String,
1071 #[serde(skip_serializing_if = "Option::is_none", default)]
1072 pub tagpath_handle: Option<String>,
1073}
1074
1075impl PathNode {
1076 pub fn new(name: impl Into<String>) -> Self {
1077 Self {
1078 name: name.into(),
1079 tagpath_handle: None,
1080 }
1081 }
1082}
1083
1084#[derive(Debug, Clone, Serialize)]
1085pub struct PathResult {
1086 pub from: String,
1087 pub to: String,
1088 pub path: Vec<PathNode>,
1089 pub hops: usize,
1090}
1091
1092pub fn shortest_path(edges: &[(String, String)], from: &str, to: &str) -> Option<PathResult> {
1093 if from == to {
1094 return Some(PathResult {
1095 from: from.to_string(),
1096 to: to.to_string(),
1097 path: vec![PathNode::new(from)],
1098 hops: 0,
1099 });
1100 }
1101
1102 let mut adj: HashMap<&str, HashSet<&str>> = HashMap::new();
1103 for (a, b) in edges {
1104 if a == b {
1105 continue;
1106 }
1107 adj.entry(a.as_str()).or_default().insert(b.as_str());
1108 adj.entry(b.as_str()).or_default().insert(a.as_str());
1109 }
1110
1111 if !adj.contains_key(from) || !adj.contains_key(to) {
1112 return None;
1113 }
1114
1115 let mut visited: HashSet<&str> = HashSet::new();
1116 let mut queue: VecDeque<&str> = VecDeque::new();
1117 let mut parent: HashMap<&str, &str> = HashMap::new();
1118
1119 visited.insert(from);
1120 queue.push_back(from);
1121
1122 while let Some(current) = queue.pop_front() {
1123 if let Some(neighbors) = adj.get(current) {
1124 for &neighbor in neighbors {
1125 if visited.insert(neighbor) {
1126 parent.insert(neighbor, current);
1127 if neighbor == to {
1128 let mut path = vec![PathNode::new(to)];
1129 let mut curr = to;
1130 while let Some(&p) = parent.get(curr) {
1131 path.push(PathNode::new(p));
1132 curr = p;
1133 }
1134 path.reverse();
1135 let hops = path.len() - 1;
1136 return Some(PathResult {
1137 from: from.to_string(),
1138 to: to.to_string(),
1139 path,
1140 hops,
1141 });
1142 }
1143 queue.push_back(neighbor);
1144 }
1145 }
1146 }
1147 }
1148
1149 None
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155
1156 #[cfg(feature = "lang-rust")]
1157 #[test]
1158 fn rust_direct_call() {
1159 let source = b"fn helper() {}\nfn main() { helper(); }";
1160 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1161 assert!(
1162 sites.iter().any(|s| s.callee == "helper"),
1163 "got: {:?}",
1164 sites
1165 );
1166 }
1167
1168 #[cfg(feature = "lang-rust")]
1169 #[test]
1170 fn rust_method_call() {
1171 let source = b"fn main() { vec.push(1); }";
1172 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1173 assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1174 }
1175
1176 #[cfg(feature = "lang-rust")]
1177 #[test]
1178 fn rust_scoped_call() {
1179 let source = b"fn main() { Vec::new(); }";
1180 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1181 assert!(sites.iter().any(|s| s.callee == "new"), "got: {:?}", sites);
1182 }
1183
1184 #[cfg(feature = "lang-rust")]
1185 #[test]
1186 fn rust_macro_call() {
1187 let source = b"fn main() { println!(\"hi\"); }";
1188 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1189 assert!(
1190 sites.iter().any(|s| s.callee == "println"),
1191 "got: {:?}",
1192 sites
1193 );
1194 }
1195
1196 #[cfg(feature = "lang-rust")]
1197 #[test]
1198 fn rust_axum_route_extracted() {
1199 let source = br#"fn router() {
1200 Router::new().route("/users", get(list_users));
1201}
1202fn list_users() {}
1203"#;
1204 let routes = extract_route_sites(Lang::Rust, source).unwrap();
1205 assert!(routes.iter().any(|route| {
1206 route.framework == "axum"
1207 && route.method.as_deref() == Some("get")
1208 && route.path == "/users"
1209 && route.handler == "list_users"
1210 }));
1211 }
1212
1213 #[cfg(feature = "lang-rust")]
1214 #[test]
1215 fn rust_actix_route_attribute_extracted() {
1216 let source = br#"#[post("/submit")]
1217async fn submit_form() {}
1218"#;
1219 let routes = extract_route_sites(Lang::Rust, source).unwrap();
1220 assert_eq!(routes.len(), 1);
1221 assert_eq!(routes[0].framework, "actix");
1222 assert_eq!(routes[0].method.as_deref(), Some("post"));
1223 assert_eq!(routes[0].handler, "submit_form");
1224 }
1225
1226 #[cfg(feature = "lang-kotlin")]
1227 #[test]
1228 fn kotlin_direct_and_navigation_calls_resolve() {
1229 let source = b"fun main() {\n helper(1)\n obj.method(2)\n}\n";
1234 let sites = extract_call_sites(Lang::Kotlin, source).unwrap();
1235 assert!(
1236 sites.iter().any(|s| s.callee == "helper"),
1237 "missing direct call, got: {sites:?}"
1238 );
1239 assert!(
1240 sites.iter().any(|s| s.callee == "method"),
1241 "missing navigation call, got: {sites:?}"
1242 );
1243 }
1244
1245 #[cfg(feature = "lang-zig")]
1246 #[test]
1247 fn zig_direct_and_field_calls_resolve() {
1248 let source =
1249 b"pub fn main() void {\n helper();\n imported.Container.method();\n}\n";
1250 let sites = extract_call_sites(Lang::Zig, source).unwrap();
1251 assert!(
1252 sites.iter().any(|site| site.callee == "helper"),
1253 "missing direct call, got: {sites:?}"
1254 );
1255 assert!(
1256 sites.iter().any(|site| site.callee == "method"),
1257 "missing field call, got: {sites:?}"
1258 );
1259 }
1260
1261 #[cfg(feature = "lang-gdscript")]
1262 #[test]
1263 fn gdscript_direct_attribute_and_base_calls_resolve() {
1264 let source =
1265 b"func _ready():\n\thelper(1)\n\t$Sprite2D.play(\"walk\")\n\nfunc _init():\n\t.foo()\n";
1266 let sites = extract_call_sites(Lang::GdScript, source).unwrap();
1267 for callee in ["helper", "play", "foo"] {
1268 assert!(
1269 sites.iter().any(|s| s.callee == callee),
1270 "missing {callee} call, got: {sites:?}"
1271 );
1272 }
1273 }
1274
1275 #[cfg(feature = "lang-python")]
1276 #[test]
1277 fn python_direct_call() {
1278 let source = b"def helper(): pass\ndef main(): helper()";
1279 let sites = extract_call_sites(Lang::Python, source).unwrap();
1280 assert!(
1281 sites.iter().any(|s| s.callee == "helper"),
1282 "got: {:?}",
1283 sites
1284 );
1285 }
1286
1287 #[cfg(feature = "lang-python")]
1288 #[test]
1289 fn python_method_call() {
1290 let source = b"def main(): obj.method()";
1291 let sites = extract_call_sites(Lang::Python, source).unwrap();
1292 assert!(
1293 sites.iter().any(|s| s.callee == "method"),
1294 "got: {:?}",
1295 sites
1296 );
1297 }
1298
1299 #[cfg(feature = "lang-python")]
1300 #[test]
1301 fn python_fastapi_route_extracted() {
1302 let source = br#"@router.get("/items/{item_id}")
1303def read_item(item_id: str):
1304 return item_id
1305"#;
1306 let routes = extract_route_sites(Lang::Python, source).unwrap();
1307 assert_eq!(routes.len(), 1);
1308 assert_eq!(routes[0].framework, "fastapi");
1309 assert_eq!(routes[0].method.as_deref(), Some("get"));
1310 assert_eq!(routes[0].path, "/items/{item_id}");
1311 assert_eq!(routes[0].handler, "read_item");
1312 }
1313
1314 #[cfg(feature = "lang-typescript")]
1315 #[test]
1316 fn typescript_direct_call() {
1317 let source = b"function helper() {}\nfunction main() { helper(); }";
1318 let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1319 assert!(
1320 sites.iter().any(|s| s.callee == "helper"),
1321 "got: {:?}",
1322 sites
1323 );
1324 }
1325
1326 #[cfg(feature = "lang-typescript")]
1327 #[test]
1328 fn typescript_method_call() {
1329 let source = b"function main() { arr.push(1); }";
1330 let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1331 assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1332 }
1333
1334 #[cfg(feature = "lang-typescript")]
1335 #[test]
1336 fn typescript_express_route_extracted() {
1337 let source = br#"router.post("/users", createUser);
1338function createUser() {}
1339"#;
1340 let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1341 assert_eq!(routes.len(), 1);
1342 assert_eq!(routes[0].framework, "express");
1343 assert_eq!(routes[0].method.as_deref(), Some("post"));
1344 assert_eq!(routes[0].path, "/users");
1345 assert_eq!(routes[0].handler, "createUser");
1346 }
1347
1348 #[cfg(feature = "lang-typescript")]
1349 #[test]
1350 fn typescript_route_extraction_ignores_search_params_and_comments() {
1351 let source = br#"const visible = searchParams.get("visible");
1352// searchParams.delete("date");
1353/* router.delete("/commented", removeCommented); */
1354"#;
1355 let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1356 assert!(routes.is_empty(), "got: {routes:?}");
1357 }
1358
1359 #[cfg(feature = "lang-javascript")]
1360 #[test]
1361 fn javascript_call() {
1362 let source = b"function main() { helper(); obj.method(); }";
1363 let sites = extract_call_sites(Lang::JavaScript, source).unwrap();
1364 assert!(
1365 sites.iter().any(|s| s.callee == "helper"),
1366 "got: {:?}",
1367 sites
1368 );
1369 assert!(
1370 sites.iter().any(|s| s.callee == "method"),
1371 "got: {:?}",
1372 sites
1373 );
1374 }
1375
1376 #[cfg(feature = "lang-rust")]
1377 fn test_symbol(name: &str, line: usize, end_line: usize) -> Symbol {
1378 Symbol {
1379 name: name.into(),
1380 kind: "function".into(),
1381 line,
1382 end_line,
1383 node_kind: "function_item".into(),
1384 start_byte: line,
1385 end_byte: end_line,
1386 body_start_byte: None,
1387 body_end_byte: None,
1388 }
1389 }
1390
1391 #[cfg(feature = "lang-rust")]
1392 #[test]
1393 fn resolve_edges_basic() {
1394 let symbols = vec![test_symbol("main", 1, 3), test_symbol("helper", 5, 7)];
1395 let sites = vec![
1396 CallSite {
1397 callee: "helper".into(),
1398 line: 2,
1399 },
1400 CallSite {
1401 callee: "println".into(),
1402 line: 6,
1403 },
1404 ];
1405 let edges = resolve_edges(&symbols, &sites);
1406 assert_eq!(edges.len(), 2);
1407 assert_eq!(edges[0].caller, "main");
1408 assert_eq!(edges[0].callee, "helper");
1409 assert_eq!(edges[1].caller, "helper");
1410 assert_eq!(edges[1].callee, "println");
1411 }
1412
1413 #[cfg(feature = "lang-rust")]
1414 #[test]
1415 fn resolve_edges_nested_picks_innermost() {
1416 let symbols = vec![test_symbol("outer", 0, 10), test_symbol("inner", 2, 5)];
1417 let sites = vec![CallSite {
1418 callee: "foo".into(),
1419 line: 3,
1420 }];
1421 let edges = resolve_edges(&symbols, &sites);
1422 assert_eq!(edges.len(), 1);
1423 assert_eq!(edges[0].caller, "inner");
1424 }
1425
1426 #[cfg(feature = "lang-rust")]
1427 #[test]
1428 fn resolve_edges_top_level_call_excluded() {
1429 let symbols = vec![test_symbol("main", 5, 10)];
1430 let sites = vec![CallSite {
1431 callee: "foo".into(),
1432 line: 2,
1433 }];
1434 let edges = resolve_edges(&symbols, &sites);
1435 assert!(edges.is_empty());
1436 }
1437
1438 #[test]
1439 fn resolve_edges_cache_reuses_slots_until_mtime_or_hash_changes() {
1440 let cache = ResolveEdgesCache::new();
1441 let file = std::path::Path::new("src/lib.rs");
1442 let symbols = vec![test_symbol("main", 1, 3)];
1443 let sites = vec![CallSite {
1444 callee: "helper".into(),
1445 line: 2,
1446 }];
1447
1448 let first =
1449 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1450 assert_eq!(first.len(), 1);
1451 assert_eq!(cache.stats(), (0, 1));
1452
1453 let cached =
1454 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1455 assert_eq!(cached, first);
1456 assert_eq!(cache.stats(), (1, 1));
1457
1458 let refreshed =
1459 cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(11, 0), &symbols, &sites);
1460 assert_eq!(refreshed, first);
1461 assert_eq!(cache.stats(), (1, 2));
1462
1463 let new_hash =
1464 cache.resolve_edges_for_file(file, "hash-b", FileMtime::new(11, 0), &symbols, &sites);
1465 assert_eq!(new_hash, first);
1466 assert_eq!(cache.stats(), (1, 3));
1467 }
1468
1469 #[test]
1470 fn project_call_edges_to_provider_neutral_substrate() {
1471 let edges = vec![CallEdge {
1472 caller: "main".into(),
1473 callee: "helper".into(),
1474 caller_line: 10,
1475 call_site_line: 12,
1476 }];
1477 let projection = project_call_edges(
1478 &edges,
1479 Some(GraphProvenance::new("tsift.index", "src/main.rs")),
1480 );
1481
1482 assert_eq!(projection.nodes.len(), 2);
1483 assert_eq!(projection.edges.len(), 1);
1484 assert!(
1485 projection
1486 .nodes
1487 .iter()
1488 .any(|node| node.id == code_symbol_node_id("main") && node.kind == "code_symbol")
1489 );
1490 }
1491
1492 #[test]
1493 fn project_routes_to_provider_neutral_substrate() {
1494 let routes = vec![RouteSite {
1495 framework: "fastapi".into(),
1496 method: Some("get".into()),
1497 path: "/items".into(),
1498 handler: "list_items".into(),
1499 line: 3,
1500 handler_line: Some(4),
1501 }];
1502 let projection = project_routes(
1503 &routes,
1504 Some(GraphProvenance::new("tsift.index", "src/api.py")),
1505 );
1506
1507 assert!(
1508 projection
1509 .nodes
1510 .iter()
1511 .any(|node| node.kind == "route" && node.label == "GET /items")
1512 );
1513 assert!(projection.edges.iter().any(|edge| edge.kind == "handled_by"
1514 && edge.properties.get("route_path") == Some(&"/items".to_string())));
1515 }
1516
1517 #[test]
1518 fn no_call_query_returns_empty() {
1519 #[cfg(feature = "lang-markdown")]
1520 {
1521 let sites = extract_call_sites(Lang::Markdown, b"# Hello").unwrap();
1522 assert!(sites.is_empty());
1523 }
1524 }
1525
1526 #[cfg(feature = "lang-rust")]
1527 #[test]
1528 fn full_roundtrip_rust() {
1529 let source = b"fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }";
1530 let symbols = Lang::Rust.extract_symbols(source).unwrap();
1531 let sites = extract_call_sites(Lang::Rust, source).unwrap();
1532 let edges = resolve_edges(&symbols, &sites);
1533 let main_calls: Vec<&str> = edges
1534 .iter()
1535 .filter(|e| e.caller == "main")
1536 .map(|e| e.callee.as_str())
1537 .collect();
1538 assert!(
1539 main_calls.contains(&"helper"),
1540 "main should call helper, got: {:?}",
1541 main_calls
1542 );
1543 assert!(
1544 main_calls.contains(&"new"),
1545 "main should call new, got: {:?}",
1546 main_calls
1547 );
1548 }
1549
1550 fn s(a: &str, b: &str) -> (String, String) {
1551 (a.to_string(), b.to_string())
1552 }
1553
1554 #[test]
1555 fn communities_empty_graph() {
1556 let result = detect_communities(&[]);
1557 assert_eq!(result.node_count, 0);
1558 assert_eq!(result.edge_count, 0);
1559 assert!(result.communities.is_empty());
1560 assert_eq!(result.iterations, 0);
1561 }
1562
1563 #[test]
1564 fn communities_single_edge() {
1565 let edges = vec![s("a", "b")];
1566 let result = detect_communities(&edges);
1567 assert_eq!(result.node_count, 2);
1568 assert_eq!(result.edge_count, 1);
1569 assert_eq!(result.communities.len(), 1);
1570 assert_eq!(result.communities[0].members.len(), 2);
1571 }
1572
1573 #[test]
1574 fn communities_self_loop_ignored() {
1575 let edges = vec![s("a", "a"), s("a", "b")];
1576 let result = detect_communities(&edges);
1577 assert_eq!(result.node_count, 2);
1578 assert_eq!(result.edge_count, 1);
1579 }
1580
1581 #[test]
1582 fn communities_duplicate_edges_deduplicated() {
1583 let edges = vec![
1584 s("main", "helper"),
1585 s("main", "helper"),
1586 s("main", "helper"),
1587 ];
1588 let result = detect_communities(&edges);
1589 assert_eq!(result.node_count, 2);
1590 assert_eq!(result.edge_count, 1);
1591 }
1592
1593 #[test]
1594 fn communities_two_cliques_split() {
1595 let edges = vec![
1596 s("a", "b"),
1597 s("a", "c"),
1598 s("b", "c"),
1599 s("d", "e"),
1600 s("d", "f"),
1601 s("e", "f"),
1602 s("a", "d"),
1603 ];
1604 let result = detect_communities(&edges);
1605 assert_eq!(result.node_count, 6);
1606 assert_eq!(
1607 result.communities.len(),
1608 2,
1609 "expected 2 communities, got: {:?}",
1610 result
1611 .communities
1612 .iter()
1613 .map(|c| &c.members)
1614 .collect::<Vec<_>>()
1615 );
1616 assert_eq!(result.communities[0].members.len(), 3);
1617 assert_eq!(result.communities[1].members.len(), 3);
1618 assert!(result.modularity > 0.0);
1619 }
1620
1621 #[test]
1622 fn communities_disconnected_components() {
1623 let edges = vec![s("a", "b"), s("c", "d")];
1624 let result = detect_communities(&edges);
1625 assert_eq!(result.node_count, 4);
1626 assert_eq!(result.edge_count, 2);
1627 assert!(result.modularity >= 0.0);
1628 }
1629
1630 #[test]
1631 fn communities_modularity_non_negative_for_clustered() {
1632 let edges = vec![
1633 s("a", "b"),
1634 s("a", "c"),
1635 s("b", "c"),
1636 s("d", "e"),
1637 s("d", "f"),
1638 s("e", "f"),
1639 ];
1640 let result = detect_communities(&edges);
1641 assert!(result.modularity >= 0.0, "Q={}", result.modularity);
1642 }
1643
1644 #[test]
1645 fn communities_hierarchical_phase2_improves_modularity() {
1646 let mut edges = Vec::new();
1647 for cluster in 0..4 {
1648 let base = cluster * 6;
1649 for i in 0..6 {
1650 for j in (i + 1)..6 {
1651 edges.push((
1652 format!("c{}n{}", cluster, base + i),
1653 format!("c{}n{}", cluster, base + j),
1654 ));
1655 }
1656 }
1657 }
1658 edges.push(("c0n0".to_string(), "c1n6".to_string()));
1659 edges.push(("c2n12".to_string(), "c3n18".to_string()));
1660 edges.push(("c0n1".to_string(), "c2n12".to_string()));
1661
1662 let result = detect_communities(&edges);
1663 assert!(result.modularity > 0.0, "Q={}", result.modularity);
1664 assert!(
1665 result.communities.len() >= 2,
1666 "expected >= 2 communities for hierarchical structure, got {}",
1667 result.communities.len()
1668 );
1669 assert!(result.iterations >= 1);
1670 }
1671
1672 fn path_names(result: &PathResult) -> Vec<&str> {
1673 result.path.iter().map(|n| n.name.as_str()).collect()
1674 }
1675
1676 #[test]
1677 fn path_direct_neighbors() {
1678 let edges = vec![s("a", "b")];
1679 let result = shortest_path(&edges, "a", "b").unwrap();
1680 assert_eq!(path_names(&result), vec!["a", "b"]);
1681 assert_eq!(result.hops, 1);
1682 assert!(result.path.iter().all(|n| n.tagpath_handle.is_none()));
1683 }
1684
1685 #[test]
1686 fn path_two_hops() {
1687 let edges = vec![s("a", "b"), s("b", "c")];
1688 let result = shortest_path(&edges, "a", "c").unwrap();
1689 assert_eq!(result.hops, 2);
1690 assert_eq!(result.path.first().unwrap().name, "a");
1691 assert_eq!(result.path.last().unwrap().name, "c");
1692 }
1693
1694 #[test]
1695 fn path_same_node() {
1696 let edges = vec![s("a", "b")];
1697 let result = shortest_path(&edges, "a", "a").unwrap();
1698 assert_eq!(path_names(&result), vec!["a"]);
1699 assert_eq!(result.hops, 0);
1700 }
1701
1702 #[test]
1703 fn path_no_connection() {
1704 let edges = vec![s("a", "b"), s("c", "d")];
1705 assert!(shortest_path(&edges, "a", "c").is_none());
1706 }
1707
1708 #[test]
1709 fn path_unknown_node() {
1710 let edges = vec![s("a", "b")];
1711 assert!(shortest_path(&edges, "a", "z").is_none());
1712 }
1713
1714 #[test]
1715 fn path_prefers_shorter() {
1716 let edges = vec![s("a", "b"), s("b", "c"), s("a", "c")];
1717 let result = shortest_path(&edges, "a", "c").unwrap();
1718 assert_eq!(result.hops, 1);
1719 }
1720
1721 #[test]
1722 fn path_self_loop_ignored() {
1723 let edges = vec![s("a", "a"), s("a", "b")];
1724 let result = shortest_path(&edges, "a", "b").unwrap();
1725 assert_eq!(result.hops, 1);
1726 }
1727
1728 #[test]
1729 fn terse_community_drops_optional_fields() {
1730 let member = CommunityMember {
1731 name: "foo".to_string(),
1732 file: Some("src/lib.rs".to_string()),
1733 line: Some(42),
1734 refs: vec![CommunityMemberRef {
1735 file: "src/lib.rs".to_string(),
1736 line: 42,
1737 role: "call".to_string(),
1738 peer: "bar".to_string(),
1739 }],
1740 tagpath_handle: Some("foo::lib".to_string()),
1741 };
1742 let terse = TerseCommunityMember::from(&member);
1743 assert_eq!(terse.name, "foo");
1744 assert_eq!(terse.tagpath_handle, Some("foo::lib".to_string()));
1745 }
1746
1747 #[test]
1748 fn terse_community_top_n_truncates_members() {
1749 let community = Community {
1750 id: 0,
1751 members: vec![
1752 CommunityMember::new("a"),
1753 CommunityMember::new("b"),
1754 CommunityMember::new("c"),
1755 CommunityMember::new("d"),
1756 CommunityMember::new("e"),
1757 ],
1758 modularity_contribution: 0.25,
1759 };
1760 let terse = TerseCommunity::from_community(&community, 3);
1761 assert_eq!(terse.id, 0);
1762 assert_eq!(terse.members.len(), 3);
1763 assert_eq!(terse.members[0].name, "a");
1764 assert_eq!(terse.members[2].name, "c");
1765 assert_eq!(terse.modularity_contribution, 0.25);
1766 }
1767
1768 #[test]
1769 fn terse_community_result_from_detect_communities() {
1770 let edges = vec![s("a", "b"), s("b", "c"), s("c", "d")];
1771 let result = detect_communities(&edges);
1772 let terse = result.to_terse(2);
1773 assert_eq!(terse.node_count, result.node_count);
1774 assert_eq!(terse.edge_count, result.edge_count);
1775 assert_eq!(terse.modularity, result.modularity);
1776 assert_eq!(terse.communities.len(), result.communities.len());
1777 for tc in &terse.communities {
1778 assert!(tc.members.len() <= 2);
1779 }
1780 }
1781
1782 #[test]
1783 fn terse_community_json_smaller_than_full() {
1784 let edges: Vec<(String, String)> = (0..20)
1785 .flat_map(|i| {
1786 let base = i * 5;
1787 vec![
1788 (format!("n{}", base), format!("n{}", base + 1)),
1789 (format!("n{}", base), format!("n{}", base + 2)),
1790 (format!("n{}", base + 1), format!("n{}", base + 2)),
1791 (format!("n{}", base + 2), format!("n{}", base + 3)),
1792 (format!("n{}", base + 3), format!("n{}", base + 4)),
1793 ]
1794 })
1795 .chain(std::iter::once(("n0".to_string(), "n5".to_string())))
1796 .collect();
1797 let result = detect_communities(&edges);
1798 let terse = result.to_terse(2);
1799 let full_member_count: usize = result.communities.iter().map(|c| c.members.len()).sum();
1800 let terse_member_count: usize = terse.communities.iter().map(|c| c.members.len()).sum();
1801 assert!(
1802 terse_member_count < full_member_count,
1803 "terse members ({}) should be fewer than full ({})",
1804 terse_member_count,
1805 full_member_count
1806 );
1807 }
1808}