1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::path::Path;
3
4use crate::edit_check::{BaseSource, EditCheck, EditSite, ShapeChange};
5use crate::models::{
6 BlastRadiusResult, CallingTest, ConstructionSite, ContextSlice, ImpactedSymbol, LabeledSite,
7 OverrideRow, ReferenceReport, ReferenceSite, Registration, SearchExplain, SiteLabel, Symbol,
8 SymbolSearchResult, VariantUse,
9};
10
11pub fn format_file_skeleton(
13 file_path: &str,
14 symbols: &[Symbol],
15 line_count: Option<usize>,
16 parse_errors: usize,
17) -> String {
18 let mut out = String::new();
19 let lines_str = match line_count {
20 Some(c) => format!(" (Lines 1-{c})"),
21 None => String::new(),
22 };
23 out.push_str(&format!("// File: {file_path}{lines_str}\n\n"));
24
25 if parse_errors > 0 {
26 let noun = if parse_errors == 1 { "error" } else { "errors" };
27 out.push_str(&format!(
28 "// {parse_errors} parse {noun}: symbols may be incomplete\n\n"
29 ));
30 }
31
32 if symbols.is_empty() {
33 out.push_str("// No exported symbols indexed.\n");
34 return out;
35 }
36
37 let in_file: HashSet<&str> = symbols.iter().map(|s| s.symbol_id.as_str()).collect();
39 let mut children_map: HashMap<Option<String>, Vec<&Symbol>> = HashMap::new();
40 for s in symbols {
41 let parent = s
42 .parent_symbol_id
43 .clone()
44 .filter(|parent| in_file.contains(parent.as_str()));
45 children_map.entry(parent).or_default().push(s);
46 }
47
48 if let Some(roots) = children_map.get(&None) {
50 for root in roots {
51 render_symbol_skeleton(&mut out, root, None, &children_map, 0);
52 }
53 } else {
54 for s in symbols {
56 render_symbol_skeleton(&mut out, s, None, &children_map, 0);
57 }
58 }
59
60 out
61}
62
63fn is_container_kind(kind: &str) -> bool {
64 let hides_its_body = matches!(
65 kind,
66 "function" | "method" | "constructor" | "destructor" | "operator"
67 );
68 !is_skippable_kind(kind) && !hides_its_body
69}
70
71pub(crate) fn display_kind(sym: &Symbol) -> &str {
75 match (sym.language.as_str(), sym.kind.as_str()) {
76 ("html", "class") => "element",
77 ("sql", "class") => "table",
78 ("markdown", "module") => "section",
79 ("markdown", "import") => "link",
80 (language, "property")
81 if language != "fsharp"
82 && sym.signature.as_deref().is_some_and(|sig| {
83 ["self.", "this.", "cls."]
84 .iter()
85 .any(|p| sig.starts_with(p))
86 }) =>
87 {
88 "attribute"
89 }
90 (_, kind) => kind,
91 }
92}
93
94fn is_skippable_kind(kind: &str) -> bool {
95 matches!(kind, "variable" | "parameter" | "import")
96}
97
98fn value_row_signature(sig: &str) -> std::borrow::Cow<'_, str> {
101 let sig = sig.trim_end().trim_end_matches(';').trim_end();
102 if !sig.contains('\n') && sig.chars().count() <= 120 {
103 return sig.into();
104 }
105 let joined = one_line(sig);
106 let Some(equals) = value_equals(&joined) else {
107 if joined.chars().count() <= 120 {
108 return joined.into();
109 }
110 let cut: String = joined.chars().take(119).collect();
111 return format!("{}…", cut.trim_end()).into();
112 };
113 let (declaration, value) = joined.split_at(equals);
114 let budget = 120usize.saturating_sub(declaration.chars().count()).max(40);
115 if value.chars().count() <= budget {
116 return joined.into();
117 }
118 let cut: String = value.chars().take(budget - 1).collect();
119 format!("{declaration}{}…", cut.trim_end()).into()
120}
121
122fn one_line(sig: &str) -> String {
124 let mut joined = sig
125 .lines()
126 .map(|line| {
127 let code = [" # ", " // "]
128 .iter()
129 .filter_map(|marker| line.find(marker))
130 .min()
131 .map_or(line, |at| &line[..at]);
132 code.trim().trim_end_matches('\\').trim_end()
133 })
134 .filter(|line| !line.is_empty())
135 .collect::<Vec<_>>()
136 .join(" ");
137 for (from, to) in [
138 ("( ", "("),
139 ("[ ", "["),
140 ("{ ", "{"),
141 (", )", ")"),
142 (", ]", "]"),
143 (", }", "}"),
144 (" )", ")"),
145 (" ]", "]"),
146 (" }", "}"),
147 ] {
148 joined = joined.replace(from, to);
149 }
150 joined
151}
152
153fn assigns_a_value(sig: &str) -> bool {
156 value_equals(sig).is_some()
157}
158
159fn value_equals(sig: &str) -> Option<usize> {
161 let bytes = sig.as_bytes();
162 let mut depth = 0usize;
163 for (i, &byte) in bytes.iter().enumerate() {
164 match byte {
165 b'(' | b'[' => depth += 1,
166 b')' | b']' => depth = depth.saturating_sub(1),
167 b'{' if depth == 0 => return None,
168 b'=' if depth == 0 => {
169 let before = i.checked_sub(1).map(|j| bytes[j]);
170 let after = bytes.get(i + 1).copied();
171 let operator_before = before.is_some_and(|b| b"<>!=^*~|$+-/%&:?".contains(&b));
172 let operator_after = after.is_some_and(|b| b == b'=' || b == b'>');
173 if !operator_before && !operator_after {
174 return Some(i);
175 }
176 }
177 _ => {}
178 }
179 }
180 None
181}
182
183fn sanitize_skeleton_sig<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
184 if matches!(
185 sym.kind.as_str(),
186 "variable" | "constant" | "property" | "field" | "enum_member"
187 ) && assigns_a_value(sig)
188 {
189 return value_row_signature(sig);
190 }
191 container_signature(sig, sym)
192}
193
194fn container_signature<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
196 let expression_arrow = if sym.language == "csharp"
197 && matches!(
198 sym.kind.as_str(),
199 "method" | "function" | "constructor" | "operator"
200 ) {
201 sig.match_indices("=>")
202 .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
203 } else {
204 None
205 };
206 let cutoff = sig
207 .find('{')
208 .into_iter()
209 .chain(expression_arrow)
210 .min()
211 .unwrap_or(sig.len());
212 let clean = sig[..cutoff].trim_end();
213 let trimmed = clean.trim_end_matches(';').trim_end();
214 if trimmed.is_empty() {
215 sym.name.as_str().into()
216 } else {
217 trimmed.into()
218 }
219}
220
221fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
222 let body = body.trim();
223 if body.is_empty() {
224 return signature;
225 }
226
227 let trimmed_signature = signature.trim_end();
228 trimmed_signature
229 .strip_suffix(body)
230 .map(str::trim_end)
231 .unwrap_or(signature)
232}
233
234fn unspelled_kind(sym: &Symbol) -> &'static str {
238 if sym.kind != "event" {
239 return "";
240 }
241 let spelled = sym.signature.as_deref().is_some_and(|sig| {
242 sig.split_whitespace()
243 .any(|word| word == "signal" || word == "event")
244 });
245 if spelled { "" } else { "event " }
246}
247
248fn assigning_method(
251 sym: &Symbol,
252 parent: Option<&Symbol>,
253 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
254) -> String {
255 if !sym
256 .signature
257 .as_deref()
258 .is_some_and(|sig| sig.starts_with("self."))
259 {
260 return String::new();
261 }
262 parent
263 .and_then(|parent| children_map.get(&Some(parent.symbol_id.clone())))
264 .and_then(|siblings| {
265 siblings.iter().find(|method| {
266 matches!(method.kind.as_str(), "method" | "function" | "constructor")
267 && method.start_line <= sym.start_line
268 && sym.end_line <= method.end_line
269 })
270 })
271 .map_or_else(String::new, |method| format!("in {}(), ", method.name))
272}
273
274fn is_shown(sym: &Symbol, parent: Option<&Symbol>) -> bool {
277 let python_declaration = sym.kind == "variable"
278 && sym.language == "python"
279 && parent.is_none_or(|parent| parent.kind == "class");
280 !is_skippable_kind(&sym.kind) || python_declaration
281}
282
283fn is_misparsed_c_function(child: &Symbol) -> bool {
288 matches!(child.language.as_str(), "c" | "cpp")
289 && matches!(child.kind.as_str(), "function" | "method")
290 && !child.is_test
291 && !child.test_container
292}
293
294fn nested_definitions(children: &[&Symbol]) -> String {
295 let names: Vec<String> = children
296 .iter()
297 .filter(|child| {
298 matches!(
299 child.kind.as_str(),
300 "function" | "method" | "class" | "struct"
301 )
302 })
303 .filter(|child| {
304 !child
305 .signature
306 .as_deref()
307 .is_some_and(|sig| sig.starts_with("lambda"))
308 })
309 .filter(|child| !is_misparsed_c_function(child))
310 .map(|child| format!("`{}`", child.name))
311 .fold(Vec::new(), |mut names, name| {
312 if !names.contains(&name) {
313 names.push(name);
314 }
315 names
316 });
317 match names.len() {
318 0 => String::new(),
319 1..=5 => format!("; defines {}", names.join(", ")),
320 n => format!("; defines {}, +{} more", names[..5].join(", "), n - 5),
321 }
322}
323
324fn render_symbol_skeleton(
325 out: &mut String,
326 sym: &Symbol,
327 parent: Option<&Symbol>,
328 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
329 indent_level: usize,
330) {
331 if !is_shown(sym, parent) {
332 return;
333 }
334
335 let indent = " ".repeat(indent_level);
336
337 if let Some(ref doc) = sym.doc_comment {
338 let document = crate::queries::DOCUMENTATION_LANGUAGES.contains(&sym.language.as_str());
339 let lines: Vec<_> = if document {
340 doc.lines().collect()
341 } else {
342 doc.lines()
343 .map(doc_line_text)
344 .filter(|line| !line.is_empty())
345 .collect()
346 };
347 let cap = 3;
348 for line in lines.iter().take(cap) {
349 out.push_str(&format!("{indent}/// {line}\n"));
350 }
351 if lines.len() > cap {
352 out.push_str(&format!(
353 "{indent}/// ... ({} more lines)\n",
354 lines.len() - cap
355 ));
356 }
357 }
358
359 let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
360 let leaf_note = format!(
361 "{}{}{span_str}",
362 unspelled_kind(sym),
363 assigning_method(sym, parent, children_map)
364 );
365
366 let children = children_map.get(&Some(sym.symbol_id.clone()));
367
368 let spans_multiple_lines = sym.end_line > sym.start_line;
369
370 if is_container_kind(&sym.kind)
371 && children.is_some_and(|list| list.iter().any(|child| is_shown(child, Some(sym))))
372 && (spans_multiple_lines || sym.kind == "enum")
373 {
374 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
375 let sig = container_signature(raw_sig, sym);
376 out.push_str(&format!("{indent}{sig} {{\n"));
377 if let Some(child_list) = children {
378 for child in child_list {
379 render_symbol_skeleton(out, child, Some(sym), children_map, indent_level + 1);
380 }
381 }
382 out.push_str(&format!("{indent}}} // {span_str}\n\n"));
383 } else {
384 if let Some(count) = sym.hidden_body_line_count() {
385 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
386 let sig = if count > 1 {
387 container_signature(raw_sig, sym)
388 } else {
389 sanitize_skeleton_sig(raw_sig, sym)
390 };
391 let b_start = sym.body_start_line.unwrap_or(sym.start_line);
392 let b_end = sym.body_end_line.unwrap_or(sym.end_line);
393
394 if count > 1 {
395 let defines = nested_definitions(children.map(Vec::as_slice).unwrap_or_default());
396 out.push_str(&format!(
397 "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end}{defines} */ }}\n"
398 ));
399 } else {
400 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
401 }
402 } else if let Some(ref raw_sig) = sym.signature {
403 let sig = sanitize_skeleton_sig(raw_sig, sym);
404 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
405 } else {
406 out.push_str(&format!(
407 "{indent}{} {sym_name}; // {span_str}\n",
408 display_kind(sym),
409 sym_name = sym.name
410 ));
411 }
412 if sym.kind == "namespace"
413 && let Some(child_list) = children
414 {
415 for child in child_list {
416 render_symbol_skeleton(out, child, Some(sym), children_map, indent_level);
417 }
418 }
419 }
420}
421
422#[derive(Default)]
424pub struct OutlineNode {
425 pub files: BTreeMap<String, Vec<String>>, pub subdirs: BTreeMap<String, OutlineNode>,
427 pub hidden_files: usize,
429 pub plain_files: Vec<String>,
431 pub unsupported_files: usize,
433}
434
435fn outline_components(file_path: &str, norm_filter: &str) -> Option<Vec<String>> {
438 let normalized = file_path.replace('\\', "/");
439 let rel_path_str = if norm_filter.is_empty() {
440 normalized.as_str()
441 } else if normalized.eq_ignore_ascii_case(norm_filter) {
442 Path::new(&normalized)
443 .file_name()
444 .and_then(|n| n.to_str())
445 .unwrap_or(&normalized)
446 } else if normalized.len() > norm_filter.len()
447 && normalized.as_bytes()[norm_filter.len()] == b'/'
448 && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
449 {
450 &normalized[norm_filter.len() + 1..]
451 } else {
452 return None;
453 };
454
455 let path = Path::new(rel_path_str);
456 let components: Vec<String> = path
457 .components()
458 .map(|c| c.as_os_str().to_str().unwrap_or(""))
459 .filter(|s| !s.is_empty())
460 .map(str::to_string)
461 .collect();
462
463 if components.is_empty() {
464 return None;
465 }
466
467 Some(components)
468}
469
470pub fn add_unsupported_to_outline(
473 root_node: &mut OutlineNode,
474 file_path: &str,
475 max_depth: usize,
476 norm_filter: &str,
477) {
478 let Some(components) = outline_components(file_path, norm_filter) else {
479 return;
480 };
481 let mut curr = root_node;
482 for (i, comp) in components
483 .iter()
484 .take(components.len() - 1)
485 .take(max_depth)
486 .enumerate()
487 {
488 if i > 0 && !curr.subdirs.contains_key(comp) {
489 break;
490 }
491 curr = curr.subdirs.entry(comp.clone()).or_default();
492 }
493 curr.unsupported_files += 1;
494}
495
496pub fn add_path_to_outline(
498 root_node: &mut OutlineNode,
499 file_path: &str,
500 symbols_by_file: &HashMap<String, Vec<Symbol>>,
501 counts: &HashMap<String, crate::queries::OutlineCounts>,
502 max_depth: usize,
503 norm_filter: &str,
504) {
505 let Some(components) = outline_components(file_path, norm_filter) else {
506 return;
507 };
508 let normalized = file_path.replace('\\', "/");
509
510 let mut curr = root_node;
511 let depth = components.len();
512
513 for (i, comp) in components.iter().enumerate() {
514 if i == depth - 1 {
515 if depth > max_depth {
516 curr.hidden_files += 1;
517 } else {
518 let mut sym_tags: Vec<String> = symbols_by_file
519 .get(&normalized)
520 .into_iter()
521 .flatten()
522 .filter(|s| !s.is_test && !s.test_container)
523 .take(5)
524 .map(|s| format!("{} {}", display_kind(s), s.name))
525 .collect();
526 let count = counts.get(&normalized).copied().unwrap_or_default();
527 if count.definitions > sym_tags.len() {
528 sym_tags.push(format!("+{} more", count.definitions - sym_tags.len()));
529 }
530 if count.tests > 0 {
531 sym_tags.push(format!("{} {}", count.tests, plural(count.tests, "test")));
532 }
533 if count.fixtures > 0 {
534 sym_tags.push(format!(
535 "{} {}",
536 count.fixtures,
537 plural(count.fixtures, "fixture")
538 ));
539 }
540 if sym_tags.is_empty() {
541 curr.plain_files.push(comp.to_string());
542 } else {
543 curr.files.insert(comp.to_string(), sym_tags);
544 }
545 }
546 } else if i < max_depth {
547 curr = curr.subdirs.entry(comp.to_string()).or_default();
548 } else {
549 curr.hidden_files += 1;
550 break;
551 }
552 }
553}
554
555const FILES_LISTED_PER_FOLDER: usize = 40;
557
558pub fn render_outline_tree(
559 out: &mut String,
560 node: &OutlineNode,
561 prefix: &str,
562 depth: usize,
563 max_depth: usize,
564) {
565 if depth >= max_depth {
566 return;
567 }
568
569 let listed = if depth == 0 {
570 node.files.len()
571 } else {
572 node.files.len().min(FILES_LISTED_PER_FOLDER)
573 };
574 let more_files = node.files.len() - listed;
575 let total_items = node.subdirs.len()
576 + listed
577 + usize::from(more_files > 0)
578 + usize::from(!node.plain_files.is_empty());
579 let mut index = 0;
580
581 for (name, sub) in &node.subdirs {
583 index += 1;
584 let is_last = index == total_items;
585 let branch = if is_last { "└── " } else { "├── " };
586 let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
587
588 let hidden = match (sub.hidden_files, sub.unsupported_files) {
589 (0, 0) => String::new(),
590 (0, m) => format!(" ({m} unsupported {})", plural(m, "file")),
591 (n, 0) => format!(" ({n} indexed {})", plural(n, "file")),
592 (n, m) => format!(" ({n} indexed {}, {m} unsupported)", plural(n, "file")),
593 };
594 out.push_str(&format!("{prefix}{branch}{name}/{hidden}\n"));
595 render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
596 }
597
598 for (file_name, syms) in node.files.iter().take(listed) {
600 index += 1;
601 let is_last = index == total_items;
602 let branch = if is_last { "└── " } else { "├── " };
603
604 let sym_suffix = if !syms.is_empty() {
605 format!(" [{}]", syms.join(", "))
606 } else {
607 String::new()
608 };
609
610 out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
611 }
612
613 if more_files > 0 {
614 index += 1;
615 let branch = if index == total_items {
616 "└── "
617 } else {
618 "├── "
619 };
620 out.push_str(&format!(
621 "{prefix}{branch}(+{more_files} more {} with functions or classes; pass this folder as the path to list them)\n",
622 plural(more_files, "file")
623 ));
624 }
625
626 if !node.plain_files.is_empty() {
627 let count = node.plain_files.len();
628 let names = if count <= 5 {
629 node.plain_files.join(", ")
630 } else {
631 format!("{}, +{} more", node.plain_files[..3].join(", "), count - 3)
632 };
633 out.push_str(&format!(
634 "{prefix}└── ({count} {} without functions or classes: {names})\n",
635 plural(count, "file")
636 ));
637 }
638}
639
640pub fn format_symbol_body(symbol: &Symbol, source: &str) -> String {
643 let mut out = format!(
644 "// {}:{}-{} ({})\n{source}",
645 symbol.path, symbol.start_line, symbol.end_line, symbol.name
646 );
647 if !source.ends_with('\n') {
648 out.push('\n');
649 }
650 out
651}
652
653pub fn format_symbol_bodies(bodies: &[(Symbol, String)]) -> String {
655 let mut out = overloads_header(bodies.iter().map(|(symbol, _)| symbol));
656 let parts: Vec<String> = bodies
657 .iter()
658 .map(|(symbol, source)| format_symbol_body(symbol, source))
659 .collect();
660 out.push_str(&parts.join("\n"));
661 out
662}
663
664pub fn format_context_slices(slices: &[ContextSlice]) -> String {
666 let mut out = overloads_header(slices.iter().map(|slice| &slice.target_symbol));
667 let parts: Vec<String> = slices.iter().map(format_context_slice).collect();
668 out.push_str(&parts.join("\n"));
669 out
670}
671
672fn overloads_header<'a>(symbols: impl ExactSizeIterator<Item = &'a Symbol>) -> String {
673 let count = symbols.len();
674 let mut symbols = symbols;
675 match symbols.next() {
676 Some(first) if count > 1 => format!(
677 "`{}` has {count} overloads in {}; each one follows.\n\n",
678 first.name, first.path
679 ),
680 _ => String::new(),
681 }
682}
683
684pub fn format_context_slice(slice: &ContextSlice) -> String {
686 let sym = &slice.target_symbol;
687 let mut out = String::new();
688 out.push_str(&format!(
689 "### Target: `{}` ({}:{}-{})\n\n",
690 sym.name, sym.path, sym.start_line, sym.end_line
691 ));
692
693 if let Some(ref sig) = sym.signature {
694 let sig = signature_without_duplicate_body(sig, &slice.target_body);
695 if !sig.trim().is_empty() {
696 out.push_str(&format!("Signature: `{sig}`\n\n"));
697 }
698 }
699
700 out.push_str(&format!("```{}\n", sym.language));
701 out.push_str(&slice.target_body);
702 if !slice.target_body.ends_with('\n') {
703 out.push('\n');
704 }
705 out.push_str("```\n\n");
706
707 if !slice.callee_signatures.is_empty() {
708 out.push_str("### Dependencies (Signatures):\n");
709 for callee in &slice.callee_signatures {
710 out.push_str(&format!("- {callee}\n"));
711 }
712 if slice.callee_signatures.len() >= 10 {
713 out.push_str("[Showing 10 dependencies (limit reached)]\n");
714 }
715 out.push('\n');
716 }
717
718 if !slice.related_types.is_empty() {
719 out.push_str("### Types:\n");
720 for t in &slice.related_types {
721 out.push_str(&format!("- {t}\n"));
722 }
723 out.push('\n');
724 }
725
726 if !slice.related_tests.is_empty() {
727 out.push_str("### Related Tests:\n");
728 for test in &slice.related_tests {
729 out.push_str(&format!(
730 "- `{}` ({}:{})\n",
731 test.name, test.path, test.start_line
732 ));
733 }
734 if slice.related_tests.len() >= 5 {
735 out.push_str("[Showing 5 tests (limit reached)]\n");
736 }
737 out.push_str("These tests call, use, or name this symbol; blast_radius also lists tests that can reach it through callers.\n\n");
738 } else {
739 out.push_str(
740 "### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n",
741 );
742 }
743
744 out
745}
746
747fn cap_notice(shown: usize, limit: usize) -> String {
748 let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
749 "narrow the query to see more"
750 } else {
751 "increase limit to see more"
752 };
753 format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
754}
755
756fn match_line(snippet: &str, signature: &str) -> Option<String> {
759 let line = snippet
760 .lines()
761 .enumerate()
762 .map(|(i, text)| match i {
763 0 => text.trim(),
764 _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
765 })
766 .flat_map(str::split_whitespace)
767 .collect::<Vec<_>>()
768 .join(" ");
769 let plain = |text: &str| {
770 text.split_whitespace()
771 .collect::<String>()
772 .replace(['[', ']'], "")
773 };
774 let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
775 (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
776}
777
778fn callee_name(name: &str) -> String {
781 let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
782 if line.chars().count() <= 60 || !line.contains('(') {
783 return line;
784 }
785 match line.rsplit_once('.') {
786 Some((_, last)) if !last.is_empty() => format!("….{last}"),
787 _ => line,
788 }
789}
790
791pub fn format_import_summary(sites: &[(String, usize)]) -> String {
794 if sites.is_empty() {
795 return String::new();
796 }
797 let files: std::collections::BTreeSet<&str> =
798 sites.iter().map(|(path, _)| path.as_str()).collect();
799 let first: Vec<String> = sites
800 .iter()
801 .take(3)
802 .map(|(path, line)| format!("{path}:{line}"))
803 .collect();
804 let more = if sites.len() > 3 { ", …" } else { "" };
805 format!(
806 "Imported {} {} in {} {}: {}{more} (lookup_symbol with kind=\"import\" lists them)\n",
807 sites.len(),
808 plural(sites.len(), "time"),
809 files.len(),
810 plural(files.len(), "file"),
811 first.join(", ")
812 )
813}
814
815pub fn format_references(
816 target_name: &str,
817 refs: &[ReferenceSite],
818 direction: &str,
819 limit: usize,
820) -> String {
821 let mut out = String::new();
822 let dir_label = if direction == "callers" {
823 "Callers of"
824 } else {
825 "Callees called by"
826 };
827 out.push_str(&format!(
828 "{dir_label} `{target_name}` ({} found):\n",
829 refs.len()
830 ));
831
832 if refs.is_empty() {
833 out.push_str(" (none)\n");
834 return out;
835 }
836
837 for r in refs {
838 let line_info = match r.start_line {
839 Some(l) => format!(":{l}"),
840 None => String::new(),
841 };
842 let other = if direction == "callers" {
843 caller_text(r)
844 } else {
845 format!("`{}`", callee_name(&r.to_symbol_name))
846 };
847 let in_file = match r.occurrences {
848 Some(n) => format!(", {n} in file"),
849 None => String::new(),
850 };
851 let target = match &r.target {
852 Some(target) if direction != "callers" => format!(" → {target}"),
853 _ => String::new(),
854 };
855 out.push_str(&format!(
856 "- {other} [{}{line_info}] (kind: {}{in_file}){target}\n",
857 r.path, r.kind
858 ));
859 }
860
861 if refs.len() >= limit {
862 out.push_str(&cap_notice(refs.len(), limit));
863 }
864
865 out
866}
867
868fn caller_text(site: &ReferenceSite) -> String {
869 if site.from_symbol_name.is_empty() && site.enclosing_symbol_name.is_none() {
870 "(no enclosing symbol)".to_string()
871 } else {
872 format!(
873 "`{}`",
874 displayed_symbol_name(
875 &site.from_symbol_name,
876 site.enclosing_symbol_name.as_deref()
877 )
878 )
879 }
880}
881
882const FOLD_FILES_NAMED: usize = 5;
883const TEXT_ONLY_FILES_LISTED: usize = 10;
884const TEXT_ONLY_LINES_PER_FILE: usize = 12;
885
886pub fn format_reference_report(report: &ReferenceReport, limit: usize) -> String {
889 let target = &report.target;
890 let counts = &report.counts;
891 let mut out = format!(
892 "References to {} `{}` ({}:{})\nSites: {} · proven {} · likely {} · unresolved {} · excluded {}\n",
893 target.kind,
894 target.display_name,
895 target.path,
896 target.line,
897 counts.sites,
898 counts.proven,
899 counts.likely,
900 counts.unresolved,
901 counts.excluded,
902 );
903 out.push_str(&text_count_line(report));
904 let scan = &report.text_scan;
905 if !scan.complete && scan.skipped.is_none() {
906 out.push_str(&format!(
907 "Text scan stopped after {} s: {} of {} files read; counts cover those files.\n",
908 (scan.elapsed_ms + 500) / 1000,
909 scan.files_scanned,
910 scan.files_total
911 ));
912 }
913
914 let mut body = site_groups(&report.sites, limit);
915 body.push_str(&override_sections(report, limit));
916 body.push_str(&construction_section(&report.constructions, limit));
917 body.push_str(&variant_section(&report.variant_uses, limit));
918 body.push_str(®istration_section(&report.registrations, limit));
919 body.push_str(&test_section(&report.tests, limit));
920 body.push_str(&text_only_section(report));
921 if !report.cannot_see.is_empty() {
922 body.push_str(&format!(
923 "Not visible to this index: {}.\n",
924 report.cannot_see.join("; ")
925 ));
926 }
927 body.push_str(&format_import_summary(&report.imports));
928 if !body.is_empty() {
929 out.push('\n');
930 out.push_str(&body);
931 }
932 out
933}
934
935fn text_count_line(report: &ReferenceReport) -> String {
936 if let Some(class) = &report.text_scan.skipped {
937 return format!(
938 "Text: skipped for a constructor; its call sites spell the class name. Use find_references on `{class}`.\n"
939 );
940 }
941 let counts = &report.counts;
942 format!(
943 "Text: `{}` is on {} {}: {} {}, {} {}, {} {}, {} text only.\n",
944 report.target.name,
945 counts.text_lines,
946 plural(counts.text_lines, "line"),
947 counts.site_lines,
948 plural(counts.site_lines, "site"),
949 counts.definition_lines,
950 plural(counts.definition_lines, "definition"),
951 counts.import_lines,
952 plural(counts.import_lines, "import"),
953 counts.text_only_lines,
954 )
955}
956
957fn site_groups(sites: &[LabeledSite], limit: usize) -> String {
958 let mut out = String::new();
959 let mut listed = 0;
960 for label in SiteLabel::ALL {
961 let group: Vec<&LabeledSite> = sites.iter().filter(|site| site.label == label).collect();
962 if group.is_empty() {
963 continue;
964 }
965 let title = match label {
966 SiteLabel::Proven => "Proven",
967 SiteLabel::Likely => "Likely",
968 SiteLabel::Unresolved => "Unresolved",
969 SiteLabel::Excluded => "Excluded",
970 };
971 out.push_str(&format!("{title} ({}):\n", group.len()));
972 let shown = limit.saturating_sub(listed).min(group.len());
973 for labeled in &group[..shown] {
974 let site = &labeled.site;
975 let line = site.start_line.map(|l| format!(":{l}")).unwrap_or_default();
976 out.push_str(&format!(
977 "- {} [{}{line}] (kind: {}; {}{})\n",
978 caller_text(site),
979 site.path,
980 site.kind,
981 labeled.basis,
982 arg_count_text(labeled)
983 ));
984 }
985 listed += shown;
986 if shown < group.len() {
987 out.push_str(&fold_line(label, &group[shown..]));
988 }
989 }
990 out
991}
992
993fn arg_count_text(labeled: &LabeledSite) -> String {
995 match labeled.arg_count {
996 Some(count) => {
997 let noun = if count == 1 { "arg" } else { "args" };
998 let text = format!("{count} {noun}");
999 if labeled.basis.starts_with(&text) {
1000 String::new()
1001 } else {
1002 format!("; {text}")
1003 }
1004 }
1005 None => String::new(),
1006 }
1007}
1008
1009fn fold_line(label: SiteLabel, hidden: &[&LabeledSite]) -> String {
1010 let (files, named) = files_by_count(hidden.iter().map(|labeled| labeled.site.path.as_str()));
1011 format!(
1012 "… {} more {} {} in {files} {}: {named}\n",
1013 hidden.len(),
1014 label.as_str(),
1015 plural(hidden.len(), "site"),
1016 plural(files, "file"),
1017 )
1018}
1019
1020fn files_by_count<'a>(paths: impl Iterator<Item = &'a str>) -> (usize, String) {
1023 let mut per_file: BTreeMap<&str, usize> = BTreeMap::new();
1024 for path in paths {
1025 *per_file.entry(path).or_default() += 1;
1026 }
1027 let mut files: Vec<(&str, usize)> = per_file.into_iter().collect();
1028 files.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1029 let named: Vec<String> = files
1030 .iter()
1031 .take(FOLD_FILES_NAMED)
1032 .map(|(path, count)| format!("{path} ({count})"))
1033 .collect();
1034 let more = if files.len() > FOLD_FILES_NAMED {
1035 ", …"
1036 } else {
1037 ""
1038 };
1039 (files.len(), format!("{}{more}", named.join(", ")))
1040}
1041
1042fn override_sections(report: &ReferenceReport, limit: usize) -> String {
1043 let name = &report.target.display_name;
1044 let mut out = method_section(
1045 &format!(
1046 "Overrides ({}): calls through `{name}` can reach these",
1047 report.overrides.len()
1048 ),
1049 &report.overrides,
1050 limit,
1051 );
1052 out.push_str(&method_section(
1053 &format!(
1054 "Overrides of ({}): calls to these can reach `{name}`",
1055 report.overridden.len()
1056 ),
1057 &report.overridden,
1058 limit,
1059 ));
1060 out
1061}
1062
1063fn construction_section(rows: &[ConstructionSite], limit: usize) -> String {
1064 if rows.is_empty() {
1065 return String::new();
1066 }
1067 let shown = limit.min(rows.len());
1068 let mut per_file: Vec<(&str, Vec<String>)> = Vec::new();
1069 for row in &rows[..shown] {
1070 match per_file.last_mut() {
1071 Some((path, lines)) if *path == row.path => lines.push(row.line.to_string()),
1072 _ => per_file.push((&row.path, vec![row.line.to_string()])),
1073 }
1074 }
1075 let listed: Vec<String> = per_file
1076 .iter()
1077 .map(|(path, lines)| format!("{path}: {}", lines.join(", ")))
1078 .collect();
1079 let mut out = format!("Constructions ({}):", rows.len());
1080 if !listed.is_empty() {
1081 out.push_str(&format!(" {}", listed.join(" · ")));
1082 }
1083 out.push('\n');
1084 if shown < rows.len() {
1085 out.push_str(§ion_fold(
1086 rows[shown..].iter().map(|row| row.path.as_str()),
1087 "construction",
1088 ));
1089 }
1090 out
1091}
1092
1093fn variant_section(rows: &[VariantUse], limit: usize) -> String {
1094 if rows.is_empty() {
1095 return String::new();
1096 }
1097 let mut out = format!(
1098 "Variant uses ({} {}): a match in them may need a new arm\n",
1099 rows.len(),
1100 plural(rows.len(), "function")
1101 );
1102 let shown = limit.min(rows.len());
1103 for row in &rows[..shown] {
1104 let caller = if row.caller.is_empty() {
1105 "(no enclosing symbol)".to_string()
1106 } else {
1107 format!("`{}`", row.caller)
1108 };
1109 out.push_str(&format!(
1110 "- {caller} [{}:{}] names {} of {} {}\n",
1111 row.path,
1112 row.line,
1113 row.variants_used,
1114 row.variants_total,
1115 plural(row.variants_total, "variant"),
1116 ));
1117 }
1118 if shown < rows.len() {
1119 out.push_str(§ion_fold(
1120 rows[shown..].iter().map(|row| row.path.as_str()),
1121 "function",
1122 ));
1123 }
1124 out
1125}
1126
1127fn registration_section(rows: &[Registration], limit: usize) -> String {
1128 if rows.is_empty() {
1129 return String::new();
1130 }
1131 let mut out = format!("Registrations ({}):\n", rows.len());
1132 let shown = limit.min(rows.len());
1133 for row in &rows[..shown] {
1134 out.push_str(&format!(
1135 "- {} [{}:{}]{}\n",
1136 registration_label(row),
1137 row.path,
1138 row.line,
1139 if row.by_name { " (by name)" } else { "" }
1140 ));
1141 }
1142 if shown < rows.len() {
1143 out.push_str(§ion_fold(
1144 rows[shown..].iter().map(|row| row.path.as_str()),
1145 "registration",
1146 ));
1147 }
1148 out
1149}
1150
1151fn registration_label(row: &Registration) -> String {
1152 if !row.pattern_id.contains(".route.") {
1153 return row.pattern_id.clone();
1154 }
1155 let template = row.template.as_deref().unwrap_or(&row.pattern_id);
1156 match row.verb.as_deref() {
1157 Some(verb) => format!("route {verb} {template}"),
1158 None => format!("route {template}"),
1159 }
1160}
1161
1162fn section_fold<'a>(hidden: impl ExactSizeIterator<Item = &'a str>, word: &str) -> String {
1163 let count = hidden.len();
1164 let (files, named) = files_by_count(hidden);
1165 format!(
1166 "… {count} more {} in {files} {}: {named}\n",
1167 plural(count, word),
1168 plural(files, "file"),
1169 )
1170}
1171
1172fn test_section(rows: &[CallingTest], limit: usize) -> String {
1173 if rows.is_empty() {
1174 return String::new();
1175 }
1176 let mut out = format!("Tests that call it ({}):\n", rows.len());
1177 let shown = limit.min(rows.len());
1178 for row in &rows[..shown] {
1179 let run = row
1180 .command
1181 .as_ref()
1182 .map(|command| format!(" run: {command}"))
1183 .unwrap_or_default();
1184 let name_only = if row.name_only { " (name only)" } else { "" };
1185 out.push_str(&format!(
1186 "- `{}` [{}:{}]{name_only}{run}\n",
1187 on_one_line(&row.name),
1188 row.path,
1189 row.line
1190 ));
1191 }
1192 if shown < rows.len() {
1193 out.push_str(§ion_fold(
1194 rows[shown..].iter().map(|row| row.path.as_str()),
1195 "test",
1196 ));
1197 }
1198 out
1199}
1200
1201fn on_one_line(text: &str) -> String {
1204 text.chars()
1205 .map(|c| {
1206 if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') {
1207 c.escape_default().to_string()
1208 } else {
1209 c.to_string()
1210 }
1211 })
1212 .collect()
1213}
1214
1215fn method_section(title: &str, rows: &[OverrideRow], limit: usize) -> String {
1216 if rows.is_empty() {
1217 return String::new();
1218 }
1219 let mut out = format!("{title}\n");
1220 let shown = limit.min(rows.len());
1221 for row in &rows[..shown] {
1222 out.push_str(&format!(
1223 "- `{}.{}` ({}:{})\n",
1224 row.owner, row.name, row.path, row.line
1225 ));
1226 }
1227 if shown < rows.len() {
1228 out.push_str(§ion_fold(
1229 rows[shown..].iter().map(|row| row.path.as_str()),
1230 "method",
1231 ));
1232 }
1233 out
1234}
1235
1236fn text_only_section(report: &ReferenceReport) -> String {
1237 if report.text_only.is_empty() {
1238 return String::new();
1239 }
1240 let mut out = format!(
1241 "Text only ({}): docs, comments, strings, or code the index did not record\n",
1242 report.counts.text_only_lines
1243 );
1244 let mut hidden_lines = 0;
1245 let mut hidden_files = 0;
1246 for (index, file) in report.text_only.iter().enumerate() {
1247 let listed = if index < TEXT_ONLY_FILES_LISTED {
1248 file.lines.len().min(TEXT_ONLY_LINES_PER_FILE)
1249 } else {
1250 0
1251 };
1252 if listed > 0 {
1253 let lines: Vec<String> = file.lines[..listed].iter().map(usize::to_string).collect();
1254 out.push_str(&format!(" {}: {}\n", file.path, lines.join(", ")));
1255 }
1256 if listed < file.lines.len() {
1257 hidden_lines += file.lines.len() - listed;
1258 hidden_files += 1;
1259 }
1260 }
1261 if hidden_lines > 0 {
1262 out.push_str(&format!(
1263 " … {hidden_lines} more {} in {hidden_files} {}\n",
1264 plural(hidden_lines, "line"),
1265 plural(hidden_files, "file")
1266 ));
1267 }
1268 out
1269}
1270
1271pub(crate) fn displayed_symbol_name(name: &str, enclosing_symbol_name: Option<&str>) -> String {
1272 if let Some(enclosing_symbol_name) = enclosing_symbol_name {
1273 format!("<lambda> in {enclosing_symbol_name}")
1274 } else {
1275 name.to_string()
1276 }
1277}
1278
1279fn other_names_line(query: &str, others: &[&Symbol]) -> String {
1283 let mut groups: Vec<(String, &str, usize)> = Vec::new();
1284 for s in others {
1285 let kind = display_kind(s);
1286 match groups
1287 .iter_mut()
1288 .find(|(name, k, _)| *name == s.name && *k == kind)
1289 {
1290 Some(group) => group.2 += 1,
1291 None => groups.push((s.name.clone(), kind, 1)),
1292 }
1293 }
1294 let listed: Vec<String> = groups
1295 .iter()
1296 .take(8)
1297 .map(|(name, kind, count)| match count {
1298 1 => format!("`{name}` ({kind})"),
1299 n => format!("`{name}` ({kind}, {n}×)"),
1300 })
1301 .collect();
1302 let more = match groups.len().saturating_sub(8) {
1303 0 => String::new(),
1304 n => format!(", +{n} more"),
1305 };
1306 format!(
1307 "- {} other {} `{query}`, ignoring case: {}{more} (lookup_symbol with one of these names lists its rows)\n",
1308 others.len(),
1309 if others.len() == 1 {
1310 "row starts with or contains"
1311 } else {
1312 "rows start with or contain"
1313 },
1314 listed.join(", ")
1315 )
1316}
1317
1318fn doc_line_text(line: &str) -> &str {
1321 let mut text = line.trim();
1322 for marker in [
1323 "///", "//!", "//", "/**", "/*!", "/*", "##", "#'", "#", "--", "*", "'''", "'",
1324 ] {
1325 if let Some(rest) = text.strip_prefix(marker) {
1326 text = rest.trim_start();
1327 break;
1328 }
1329 }
1330 for tag in ["<summary>", "</summary>"] {
1331 text = text.strip_prefix(tag).unwrap_or(text).trim_start();
1332 }
1333 for suffix in ["*/", "</summary>"] {
1334 text = text.strip_suffix(suffix).unwrap_or(text).trim_end();
1335 }
1336 text
1337}
1338
1339fn sentence_end(text: &str) -> Option<usize> {
1342 let mut depth = 0usize;
1343 for (index, c) in text.char_indices() {
1344 match c {
1345 '(' => depth += 1,
1346 ')' => depth = depth.saturating_sub(1),
1347 '.' if depth == 0 => {
1348 let rest = &text[index + 1..];
1349 if rest.starts_with(' ') && !rest[1..].starts_with(|next: char| next.is_lowercase())
1350 {
1351 return Some(index);
1352 }
1353 }
1354 _ => {}
1355 }
1356 }
1357 None
1358}
1359
1360fn doc_summary(doc: &str) -> Option<String> {
1363 let paragraph = doc
1364 .lines()
1365 .map(doc_line_text)
1366 .skip_while(|line| line.is_empty())
1367 .take_while(|line| !line.is_empty())
1368 .collect::<Vec<_>>()
1369 .join(" ");
1370 let summary = match sentence_end(¶graph) {
1371 Some(end) => paragraph[..=end].to_string(),
1372 None if paragraph.chars().count() > 200 => {
1373 format!("{}…", paragraph.chars().take(200).collect::<String>())
1374 }
1375 None => paragraph,
1376 };
1377 (!summary.is_empty()).then_some(summary)
1378}
1379
1380pub fn format_find_symbol_results(
1381 query: &str,
1382 name_rows: &[Symbol],
1383 ranked_rows: &[SymbolSearchResult],
1384 limit: usize,
1385) -> String {
1386 let mut out = String::new();
1387 if !name_rows.is_empty() {
1388 out.push_str(&format_name_rows(query, name_rows, limit));
1389 }
1390 if !ranked_rows.is_empty() {
1391 if !out.is_empty() {
1392 out.push('\n');
1393 }
1394 out.push_str(&format_ranked_rows(query, ranked_rows, limit));
1395 }
1396 if out.is_empty() {
1397 out.push_str(&format!("No symbols found matching \"{query}\".\n"));
1398 }
1399 out
1400}
1401
1402fn format_name_rows(query: &str, exact_matches: &[Symbol], limit: usize) -> String {
1403 let dotted = query.replace("::", ".");
1404 let is_exact = |s: &Symbol| s.name == dotted || s.name.ends_with(&format!(".{dotted}"));
1405 let named_exactly = exact_matches.iter().filter(|s| is_exact(s)).count();
1406 let exact_note = if named_exactly < exact_matches.len() {
1407 format!(" ({named_exactly} named exactly `{query}`; the rest start with or contain it)")
1408 } else {
1409 String::new()
1410 };
1411 let owner = query
1412 .strip_suffix("::")
1413 .or_else(|| query.strip_suffix('.'))
1414 .filter(|owner| !owner.is_empty());
1415 let mut out = match owner {
1416 Some(owner) => format!("Found {} members of `{owner}`:\n\n", exact_matches.len()),
1417 None => format!(
1418 "Found {} symbols matching \"{query}\"{exact_note}:\n\n",
1419 exact_matches.len()
1420 ),
1421 };
1422 let has_definition = exact_matches.iter().any(|s| s.kind != "import");
1423 let folds = |s: &Symbol| crate::queries::folds_into_import_line(query, s, has_definition);
1424 let imports: Vec<&Symbol> = exact_matches.iter().filter(|s| folds(s)).collect();
1425 let (shown, others): (Vec<&Symbol>, Vec<&Symbol>) = exact_matches
1426 .iter()
1427 .filter(|s| !folds(s))
1428 .partition(|s| named_exactly == 0 || is_exact(s));
1429 for s in shown {
1430 let sig = s.signature.as_deref().unwrap_or(&s.name);
1431 out.push_str(&format!(
1432 "- {} `{}` [{}:{}-{}] id={}\n",
1433 display_kind(s),
1434 s.name,
1435 s.path,
1436 s.start_line,
1437 s.end_line,
1438 s.symbol_id
1439 ));
1440 out.push_str(&format!(" Signature: {sig}\n"));
1441 if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1442 out.push_str(&format!(" Doc: {doc}\n"));
1443 }
1444 }
1445 if !imports.is_empty() {
1446 let shown = imports
1447 .iter()
1448 .take(3)
1449 .map(|s| format!("{}:{}", s.path, s.start_line))
1450 .collect::<Vec<_>>()
1451 .join(", ");
1452 let more = if imports.len() > 3 { ", …" } else { "" };
1453 let cut_inside_exact_names = exact_matches
1454 .last()
1455 .is_some_and(|s| s.name.eq_ignore_ascii_case(query) || folds(s));
1456 let capped = if exact_matches.len() - imports.len() >= limit && cut_inside_exact_names {
1457 "at least "
1458 } else {
1459 ""
1460 };
1461 out.push_str(&format!(
1462 "- {capped}{} {} of `{query}`: {shown}{more} (lookup_symbol with kind=\"import\" lists them)\n",
1463 imports.len(),
1464 plural(imports.len(), "import")
1465 ));
1466 }
1467 if !others.is_empty() {
1468 out.push_str(&other_names_line(query, &others));
1469 }
1470 if exact_matches.len() - imports.len() >= limit {
1471 out.push_str(&cap_notice(exact_matches.len() - imports.len(), limit));
1472 }
1473 out
1474}
1475
1476fn format_ranked_rows(query: &str, fts_matches: &[SymbolSearchResult], limit: usize) -> String {
1477 let mut out = format!(
1478 "No exact name match; {} full-text matches for \"{query}\":\n\n",
1479 fts_matches.len()
1480 );
1481 for r in fts_matches {
1482 let s = &r.symbol;
1483 let sig = s.signature.as_deref().unwrap_or(&s.name);
1484 out.push_str(&format!(
1485 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1486 display_kind(s),
1487 s.name,
1488 s.path,
1489 s.start_line,
1490 s.end_line,
1491 r.score,
1492 s.symbol_id
1493 ));
1494 out.push_str(&format!(" Signature: {sig}\n"));
1495 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1496 out.push_str(&format!(" Match: {line}\n"));
1497 } else if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1498 out.push_str(&format!(" Doc: {doc}\n"));
1499 }
1500 }
1501 if fts_matches.len() >= limit {
1502 out.push_str(&cap_notice(fts_matches.len(), limit));
1503 }
1504 out
1505}
1506
1507pub fn no_facts_heading(category: &str, path_filter: Option<&str>) -> String {
1510 match path_filter {
1511 Some(path) => format!("No facts match '{category}' under `{path}`."),
1512 None => format!("No facts match '{category}' in this repository."),
1513 }
1514}
1515
1516pub fn format_no_facts(
1520 category: &str,
1521 path_filter: Option<&str>,
1522 categories: &[(String, usize)],
1523) -> String {
1524 let mut out = format!("{}\n\n", no_facts_heading(category, path_filter));
1525 if !crate::queries::is_category_alias(category) {
1526 out.push_str(&format_fact_categories(categories));
1527 return out;
1528 }
1529 if matches!(category.to_ascii_lowercase().as_str(), "import" | "imports") {
1530 out.push_str("Most languages record imports as symbols: lookup_symbol with kind=\"import\" lists them.\n\n");
1531 }
1532 out.push_str(&alias_summary(categories));
1533 out
1534}
1535
1536fn alias_summary(categories: &[(String, usize)]) -> String {
1537 let aliases = crate::queries::alias_fact_counts(categories);
1538 if aliases.is_empty() {
1539 return String::new();
1540 }
1541 let parts: Vec<String> = aliases
1542 .iter()
1543 .map(|(alias, patterns, facts)| {
1544 format!(
1545 "{alias} ({patterns} {}, {facts} {})",
1546 plural(*patterns, "pattern"),
1547 plural(*facts, "fact")
1548 )
1549 })
1550 .collect();
1551 format!("Aliases: {}\n\n", parts.join(", "))
1552}
1553
1554pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
1555 if categories.is_empty() {
1556 return "No structural facts or literals indexed in this repository.".to_string();
1557 }
1558 let mut out = String::new();
1559 out.push_str(&alias_summary(categories));
1560 let (patterns, literal_kinds): (Vec<_>, Vec<_>) = categories
1562 .iter()
1563 .filter(|(name, _)| !name.ends_with(".decorated_definition.v1"))
1564 .partition(|(name, _)| name.contains('.'));
1565 out.push_str(&format!(
1566 "Available structural fact categories ({} found):\n\n",
1567 patterns.len()
1568 ));
1569 for (name, count) in &patterns {
1570 out.push_str(&format!(
1571 "- `{name}` ({count} {})\n",
1572 plural(*count, "fact")
1573 ));
1574 }
1575 if !literal_kinds.is_empty() {
1576 out.push_str("\nString literal kinds (the same command lists the literals of a kind):\n\n");
1577 for (name, count) in &literal_kinds {
1578 out.push_str(&format!(
1579 "- `{name}` ({count} {})\n",
1580 plural(*count, "literal")
1581 ));
1582 }
1583 }
1584 out
1585}
1586
1587fn plural(count: usize, word: &str) -> String {
1588 if count == 1 {
1589 word.to_string()
1590 } else {
1591 format!("{word}s")
1592 }
1593}
1594
1595pub fn format_structural_facts(
1597 facts: &[crate::models::StructuralFact],
1598 literals: &[crate::models::LiteralFact],
1599 category: &str,
1600 limit: usize,
1601) -> String {
1602 let mut out = format!(
1603 "Structural facts for '{category}' ({} found):\n",
1604 facts.len()
1605 );
1606 for f in facts {
1607 let label = f.key.as_deref().unwrap_or(&f.capture_name);
1608 let details = format!("{}{}", qt_property_details(f), flask_route_details(f));
1609 let api_style = f
1610 .metadata
1611 .as_ref()
1612 .and_then(|m| m.get("api_style"))
1613 .and_then(|v| v.as_str());
1614 let place = match api_style {
1615 Some("decorator_routing") => "handler",
1616 Some("call_routing") => "registered in",
1617 _ => "in",
1618 };
1619 let parent = f
1620 .containing_symbol_name
1621 .as_deref()
1622 .map(|p| format!(", {place}: {p}"))
1623 .unwrap_or_default();
1624 out.push_str(&format!(
1625 "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
1626 f.path, f.start_line, f.pattern_id
1627 ));
1628 }
1629 if limit > 0 && facts.len() >= limit {
1630 out.push_str(&cap_notice(facts.len(), limit));
1631 }
1632 if facts
1633 .iter()
1634 .any(|f| f.pattern_id.starts_with("flask.route"))
1635 {
1636 out.push_str("Routes as declared in the source. By default Flask also answers HEAD for GET and OPTIONS for every rule, and serves `/static/<path:filename>`; the app or a rule can turn these off.\n");
1637 }
1638 if !literals.is_empty() {
1639 out.push_str(&format!(
1640 "\nMatching literals ({} found):\n",
1641 literals.len()
1642 ));
1643 for l in literals {
1644 out.push_str(&format!(
1645 "- \"{}\" [{}:{}] (kind: {})\n",
1646 l.literal_text, l.path, l.start_line, l.kind
1647 ));
1648 }
1649 if limit > 0 && literals.len() >= limit {
1650 out.push_str(&cap_notice(literals.len(), limit));
1651 }
1652 }
1653 out
1654}
1655
1656fn flask_route_details(fact: &crate::models::StructuralFact) -> String {
1659 if !fact.pattern_id.starts_with("flask.route") {
1660 return String::new();
1661 }
1662 let Some(metadata) = fact.metadata.as_ref() else {
1663 return String::new();
1664 };
1665 let text = |key: &str| metadata.get(key).and_then(|v| v.as_str());
1666 let mut out = String::new();
1667 if let Some(endpoint) = text("endpoint") {
1668 out.push_str(&format!(", endpoint: {endpoint}"));
1669 }
1670 if text("api_style") == Some("call_routing") {
1671 match text("view_target") {
1672 Some(view) => out.push_str(&format!(", view: {view}")),
1673 None => out.push_str(", no view function"),
1674 }
1675 }
1676 out
1677}
1678
1679fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
1680 if fact.pattern_id != "cpp.qt_property.v1" {
1681 return String::new();
1682 }
1683 let Some(metadata) = fact.metadata.as_ref() else {
1684 return String::new();
1685 };
1686 [
1687 "property_type",
1688 "read",
1689 "write",
1690 "notify",
1691 "designable",
1692 "scriptable",
1693 "stored",
1694 "user",
1695 "revision",
1696 ]
1697 .into_iter()
1698 .filter_map(|key| metadata.get(key).map(|value| (key, value)))
1699 .map(|(key, value)| {
1700 let value = value
1701 .as_str()
1702 .map(str::to_owned)
1703 .unwrap_or_else(|| value.to_string());
1704 format!(", {key}: {value}")
1705 })
1706 .collect()
1707}
1708
1709pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
1711 if results.is_empty() {
1712 return format!("No symbols found matching concept \"{query}\".");
1713 }
1714
1715 let mut out = format!(
1716 "Found {} symbols matching concept \"{query}\":\n",
1717 results.len()
1718 );
1719 if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
1720 out.push_str(&format!(
1721 "rerank: {} candidates in {} µs",
1722 explain.candidates, explain.rerank_us
1723 ));
1724 if !explain.word_weights.is_empty() {
1725 let words: Vec<String> = explain
1726 .word_weights
1727 .iter()
1728 .map(|(word, weight)| format!("{word} {weight:.2}"))
1729 .collect();
1730 out.push_str(&format!("; words {}", words.join(", ")));
1731 }
1732 out.push('\n');
1733 }
1734 out.push('\n');
1735 for r in results {
1736 let s = &r.symbol;
1737 let sig = s.signature.as_deref().unwrap_or(&s.name);
1738 out.push_str(&format!(
1739 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1740 display_kind(s),
1741 s.name,
1742 s.path,
1743 s.start_line,
1744 s.end_line,
1745 r.score,
1746 s.symbol_id
1747 ));
1748 out.push_str(&format!(" Signature: {sig}\n"));
1749 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1750 out.push_str(&format!(" Match: {line}\n"));
1751 } else if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1752 out.push_str(&format!(" Doc: {doc}\n"));
1753 }
1754 if let Some(explain) = &r.explain {
1755 out.push_str(&format!(" explain: {}\n", explain_line(r.score, explain)));
1756 }
1757 }
1758
1759 if results.len() >= limit {
1760 out.push_str(&cap_notice(results.len(), limit));
1761 }
1762
1763 out
1764}
1765
1766fn explain_line(score: f64, e: &SearchExplain) -> String {
1767 let mut line = format!(
1768 "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
1769 e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
1770 );
1771 if e.field_diversity_bonus != 0.0 {
1772 line.push_str(&format!(" + field coverage {:.1}", e.field_diversity_bonus));
1773 }
1774 if e.documentation != 0.0 {
1775 line.push_str(&format!(" + doc {:.1}", e.documentation));
1776 }
1777 if e.test_intent != 0.0 {
1778 line.push_str(&format!(" + test {:.1}", e.test_intent));
1779 }
1780 if e.nested != 0.0 {
1781 line.push_str(&format!(" + nested {:.1}", e.nested));
1782 }
1783 line.push_str(&format!(" [{}]", e.branches.join(",")));
1784 if let Some(bm25) = e.bm25 {
1785 line.push_str(&format!(" bm25 {bm25:.2}"));
1786 }
1787 if !e.terms.is_empty() {
1788 let terms: Vec<String> = e
1789 .terms
1790 .iter()
1791 .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
1792 .collect();
1793 line.push_str(&format!(" terms {}", terms.join(" ")));
1794 }
1795 line
1796}
1797
1798const EDIT_SITES_LISTED: usize = 12;
1799const EDIT_TESTS_LISTED: usize = 8;
1800pub const STOP_SITES_LISTED: usize = 15;
1801
1802pub fn format_edit_check(check: &EditCheck) -> String {
1805 let source = match check.source {
1806 BaseSource::Given => "given",
1807 BaseSource::Session => "session base",
1808 BaseSource::Head => "HEAD",
1809 };
1810 let mut out = String::new();
1811 if check.changes.is_empty() {
1812 out.push_str(&format!(
1813 "check_edit: working tree against {} ({source}): no symbol changed shape.\n",
1814 check.rev
1815 ));
1816 push_edit_cuts(&mut out, check);
1817 return out;
1818 }
1819 out.push_str(&format!(
1820 "check_edit: working tree against {} ({source}), {} {} changed\n{} shape {}:\n",
1821 check.rev,
1822 check.files_changed,
1823 plural(check.files_changed, "file"),
1824 check.changes.len(),
1825 plural(check.changes.len(), "change"),
1826 ));
1827 for change in &check.changes {
1828 push_shape_change(&mut out, change);
1829 }
1830 push_edit_cuts(&mut out, check);
1831 if !check.tests.is_empty() {
1832 let shown = check.tests.len().min(EDIT_TESTS_LISTED);
1833 out.push_str(&format!(
1834 "Tests to run: {}",
1835 check.tests[..shown].join("; ")
1836 ));
1837 if shown < check.tests.len() {
1838 out.push_str(&format!("; … {} more", check.tests.len() - shown));
1839 }
1840 out.push('\n');
1841 }
1842 out.push_str("Not checked: calls inside macro arguments, functions passed as values.\n");
1843 out
1844}
1845
1846pub fn format_stop_reason(check: &EditCheck) -> Option<String> {
1850 let rows = stop_rows(check);
1851 if rows.is_empty() {
1852 return None;
1853 }
1854 let mut names: Vec<String> = Vec::new();
1855 for (_, change) in &rows {
1856 let name = format!("`{}`", change.display);
1857 if !names.contains(&name) {
1858 names.push(name);
1859 }
1860 }
1861 let mut out = format!(
1862 "Before you finish: {} sites still use the old shape of {}.\n",
1863 rows.len(),
1864 names.join(", ")
1865 );
1866 for (site, change) in rows.iter().take(STOP_SITES_LISTED) {
1867 out.push_str(&format!(
1868 "{}:{} {}: {}\n",
1869 site.path, site.line, change.display, change.change
1870 ));
1871 }
1872 out.push_str("Edit them or say why they need no change.");
1873 Some(out)
1874}
1875
1876fn stop_rows(check: &EditCheck) -> Vec<(&EditSite, &ShapeChange)> {
1877 let mut rows: Vec<(&EditSite, &ShapeChange)> = Vec::new();
1878 for change in check.changes.iter().filter(|change| blocks_a_stop(change)) {
1879 rows.extend(change.proven.iter().map(|site| (site, change)));
1880 }
1881 rows.sort_by(|a, b| {
1882 (&a.0.path, a.0.line, &a.1.display).cmp(&(&b.0.path, b.0.line, &b.1.display))
1883 });
1884 rows
1885}
1886
1887pub fn stop_sites(check: &EditCheck) -> Vec<&EditSite> {
1889 stop_rows(check).into_iter().map(|(site, _)| site).collect()
1890}
1891
1892fn blocks_a_stop(change: &ShapeChange) -> bool {
1896 let javascript = [".js", ".jsx", ".mjs", ".cjs"]
1897 .iter()
1898 .any(|extension| change.path.ends_with(extension));
1899 !change.change.starts_with("signature changed")
1900 && !(javascript && change.change.starts_with("parameters"))
1901}
1902
1903fn push_edit_cuts(out: &mut String, check: &EditCheck) {
1904 for cut in &check.cuts {
1905 out.push_str(&format!("Cut: {cut}\n"));
1906 }
1907}
1908
1909fn push_shape_change(out: &mut String, change: &ShapeChange) {
1910 out.push_str(&format!(
1911 " {} `{}` ({}): {}\n",
1912 change.kind, change.display, change.path, change.change
1913 ));
1914 if !change.in_diff.is_empty() {
1915 let shown = change.in_diff.len().min(EDIT_SITES_LISTED);
1916 let places: Vec<String> = change.in_diff[..shown]
1917 .iter()
1918 .map(|site| format!("{}:{}", site.path, site.line))
1919 .collect();
1920 out.push_str(&format!(
1921 " In your diff ({}): {}",
1922 change.in_diff.len(),
1923 places.join(", ")
1924 ));
1925 if shown < change.in_diff.len() {
1926 out.push_str(&format!(", … {} more", change.in_diff.len() - shown));
1927 }
1928 out.push('\n');
1929 }
1930 for (label, sites) in [("proven", &change.proven), ("likely", &change.likely)] {
1931 if sites.is_empty() {
1932 continue;
1933 }
1934 out.push_str(&format!(
1935 " Not in your diff, {label} ({}):\n",
1936 sites.len()
1937 ));
1938 for site in &sites[..sites.len().min(EDIT_SITES_LISTED)] {
1939 out.push_str(&edit_site_row(site));
1940 }
1941 if sites.len() > EDIT_SITES_LISTED {
1942 out.push_str(&format!(
1943 " … {} more {label}\n",
1944 sites.len() - EDIT_SITES_LISTED
1945 ));
1946 }
1947 }
1948 if change.unresolved > 0 {
1949 out.push_str(&format!(
1950 " Unresolved outside the diff: {}\n",
1951 change.unresolved
1952 ));
1953 }
1954}
1955
1956fn edit_site_row(site: &EditSite) -> String {
1957 let caller = if site.caller.is_empty() {
1958 "(no enclosing symbol)".to_string()
1959 } else {
1960 format!("`{}`", site.caller)
1961 };
1962 let note = site
1963 .note
1964 .as_deref()
1965 .map(|note| format!(" {note}"))
1966 .unwrap_or_default();
1967 format!(" {}:{} {caller}{note}\n", site.path, site.line)
1968}
1969
1970pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
1972 if result.seed_type == "none" {
1973 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
1974 }
1975
1976 let mut out = String::new();
1977 let seed_label = if result.seed_type == "file" {
1978 format!("Files: {}", result.seeds.join(", "))
1979 } else if result.seed_type == "symbol" {
1980 format!("Symbol: {}", result.seeds.join(", "))
1981 } else {
1982 format!("Seeds: {}", result.seeds.join(", "))
1983 };
1984
1985 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
1986
1987 let more = if result.limit_at_maximum {
1988 "limit is at its maximum, so narrow the target to a symbol or fewer files"
1989 } else {
1990 "raise limit to see the rest"
1991 };
1992 let shown_of = |shown: usize, found: usize, noun: &str| {
1993 if found > shown {
1994 format!("Showing {shown} of {found} {noun}; {more}.\n\n")
1995 } else {
1996 format!("Requested limit hid additional {noun}; {more}.\n\n")
1997 }
1998 };
1999 if result.likely_tests_truncated {
2000 out.push_str(&shown_of(
2001 result.likely_tests.len(),
2002 result.likely_tests_found,
2003 "likely tests",
2004 ));
2005 }
2006 if result.impacted_symbols_truncated {
2007 out.push_str(&shown_of(
2008 result.impacted_symbols.len(),
2009 result.impacted_symbols_found,
2010 "impacted symbols",
2011 ));
2012 }
2013 if result.traversal_ceiling_reached {
2014 out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
2015 }
2016
2017 if !result.likely_tests.is_empty() {
2018 let total = result.likely_tests.len();
2019 out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
2020
2021 let mut previous_path = None;
2023 for t in &result.likely_tests {
2024 if previous_path != Some(t.path.as_str()) {
2025 out.push_str(&format!("{}:\n", t.path));
2026 previous_path = Some(t.path.as_str());
2027 }
2028 out.push_str(&format!(
2029 " - `{}` [line {}] ({})\n",
2030 t.name, t.line, t.reason
2031 ));
2032 }
2033
2034 out.push('\n');
2035 } else if result.likely_tests_truncated || result.traversal_ceiling_reached {
2036 out.push_str("### Likely Tests to Run (0 returned)\n\n");
2037 } else if let Some(depth) = result.stopped_at_depth {
2038 out.push_str(&format!(
2039 "### Likely Tests to Run (0 returned)\nNo test reaches it within depth {depth}; a larger `depth` may find some. Otherwise run the full test suite.\n\n",
2040 ));
2041 } else {
2042 out.push_str(
2043 "### Likely Tests to Run (0 returned)\nNo test reaches it through the index. Run the full test suite.\n\n",
2044 );
2045 }
2046
2047 if !result.impacted_symbols.is_empty() {
2048 let total = result.impacted_symbols.len();
2049 let mut visible = Vec::new();
2050 let mut low_signal_count = 0;
2051 for s in &result.impacted_symbols {
2052 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
2053 low_signal_count += 1;
2054 } else {
2055 visible.push(s);
2056 }
2057 }
2058
2059 out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
2060
2061 if visible.is_empty() && low_signal_count > 0 {
2062 let row_word = if low_signal_count == 1 {
2063 "row (import/module)"
2064 } else {
2065 "rows (imports/modules)"
2066 };
2067 out.push_str(&format!(
2068 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
2069 ));
2070 } else {
2071 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
2072 std::collections::BTreeMap::new();
2073 let mut file_order = Vec::new();
2074 for s in &visible {
2075 if !syms_by_file.contains_key(s.path.as_str()) {
2076 file_order.push(s.path.as_str());
2077 }
2078 syms_by_file.entry(s.path.as_str()).or_default().push(s);
2079 }
2080
2081 for path in file_order {
2082 out.push_str(&format!("{path}:\n"));
2083 if let Some(syms) = syms_by_file.get(path) {
2084 for s in syms {
2085 let name =
2086 displayed_symbol_name(&s.name, s.enclosing_symbol_name.as_deref());
2087 out.push_str(&format!(
2088 " - [depth {}] {} `{}` [line {}]\n",
2089 s.depth, s.kind, name, s.line
2090 ));
2091 }
2092 }
2093 }
2094
2095 if low_signal_count > 0 {
2096 let row_word = if low_signal_count == 1 {
2097 "row (import/module)"
2098 } else {
2099 "rows (imports/modules)"
2100 };
2101 out.push_str(&format!(
2102 "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
2103 ));
2104 }
2105 }
2106 } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
2107 out.push_str("### Downstream Impact (0 returned)\n");
2108 } else {
2109 out.push_str(
2110 "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
2111 );
2112 }
2113
2114 out
2115}
2116
2117#[cfg(test)]
2118mod tests {
2119 use super::*;
2120 use crate::models::{
2121 ImpactedSymbol, LabeledSite, ReferenceCounts, ReferenceReport, ReferenceTarget, SiteLabel,
2122 TestTarget, TextOnlyLines, TextScanSummary,
2123 };
2124
2125 fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
2126 crate::models::StructuralFact {
2127 structural_fact_id: "sf1".into(),
2128 path: ".codex/config.toml".into(),
2129 language: "toml".into(),
2130 pattern_id: "toml.key_value.v1".into(),
2131 capture_name: "key_value".into(),
2132 node_kind: "table".into(),
2133 key: key.map(str::to_string),
2134 metadata: None,
2135 containing_symbol_name: None,
2136 start_line: 2,
2137 end_line: 2,
2138 confidence: 1.0,
2139 }
2140 }
2141
2142 #[test]
2143 fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
2144 let with_key = format_structural_facts(
2145 &[structural_fact(Some("mcp_servers.code-kb.command"))],
2146 &[],
2147 "config",
2148 30,
2149 );
2150 assert!(with_key.contains(
2151 "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
2152 ));
2153
2154 let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
2155 assert!(
2156 without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
2157 );
2158 }
2159
2160 #[test]
2161 fn format_structural_facts_renders_selected_qt_property_metadata() {
2162 let mut fact = structural_fact(Some("index"));
2163 fact.pattern_id = "cpp.qt_property.v1".into();
2164 fact.metadata = Some(serde_json::json!({
2165 "property_type": "int",
2166 "designable": false,
2167 "scriptable": true,
2168 "stored": false,
2169 "user": true,
2170 "revision": 2,
2171 }));
2172
2173 let output = format_structural_facts(&[fact], &[], "property", 30);
2174
2175 assert!(output.contains(
2176 "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
2177 ));
2178 }
2179
2180 #[test]
2181 fn format_structural_facts_names_the_endpoint_and_view_of_a_flask_route() {
2182 let route = |metadata| crate::models::StructuralFact {
2183 pattern_id: "flask.route.v1".into(),
2184 metadata: Some(metadata),
2185 ..structural_fact(Some("GET /"))
2186 };
2187 let alias = route(serde_json::json!({"api_style": "call_routing", "endpoint": "index"}));
2188 let rule = route(serde_json::json!({"api_style": "call_routing", "view_target": "ping"}));
2189 let decorated = route(serde_json::json!({"api_style": "decorator_routing"}));
2190
2191 let output = format_structural_facts(&[alias, rule, decorated], &[], "route", 30);
2192
2193 assert!(
2194 output.contains("(pattern: flask.route.v1, endpoint: index, no view function)"),
2195 "{output}"
2196 );
2197 assert!(
2198 output.contains("(pattern: flask.route.v1, view: ping)"),
2199 "{output}"
2200 );
2201 assert!(output.contains("(pattern: flask.route.v1)"), "{output}");
2202 }
2203
2204 #[test]
2205 fn doc_summary_ends_at_the_first_sentence_of_the_first_paragraph() {
2206 assert_eq!(
2207 doc_summary(
2208 "The actual WSGI application. This is not implemented in\n:meth:`__call__`."
2209 )
2210 .as_deref(),
2211 Some("The actual WSGI application.")
2212 );
2213 assert_eq!(
2214 doc_summary("\nCreate a runner\nfor tests\n\nMore.").as_deref(),
2215 Some("Create a runner for tests")
2216 );
2217 assert_eq!(
2218 doc_summary("Find facts by category (e.g. route, query). More.").as_deref(),
2219 Some("Find facts by category (e.g. route, query).")
2220 );
2221 assert_eq!(
2222 doc_summary("Use a name, e.g. foo. Then more.").as_deref(),
2223 Some("Use a name, e.g. foo.")
2224 );
2225 assert_eq!(
2226 doc_summary("/// <summary>\n/// Goes to previous page.\n/// </summary>").as_deref(),
2227 Some("Goes to previous page.")
2228 );
2229 assert_eq!(
2230 doc_summary("/**\n * Render the view.\n *\n * @param x\n */").as_deref(),
2231 Some("Render the view.")
2232 );
2233 assert_eq!(
2234 doc_summary("# Given a +hash+ returns the settings\n# for the environment.").as_deref(),
2235 Some("Given a +hash+ returns the settings for the environment.")
2236 );
2237 assert_eq!(
2238 doc_summary("#' A person record.").as_deref(),
2239 Some("A person record.")
2240 );
2241 assert_eq!(doc_summary(" \n"), None);
2242 assert!(doc_summary(&"word ".repeat(60)).unwrap().ends_with('…'));
2243 }
2244
2245 #[test]
2246 fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
2247 let site = |occurrences| ReferenceSite {
2248 from_symbol_name: "Button".into(),
2249 from_symbol_id: "s1".into(),
2250 to_symbol_name: "background".into(),
2251 kind: "member_access".into(),
2252 path: "Ui/Button.qml".into(),
2253 start_line: Some(5),
2254 start_column: Some(4),
2255 occurrences,
2256 target: None,
2257 enclosing_symbol_name: None,
2258 };
2259
2260 let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
2261 assert!(
2262 grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
2263 "{grouped}"
2264 );
2265
2266 let single = format_references("Color", &[site(None)], "callers", 30);
2267 assert!(
2268 single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
2269 "{single}"
2270 );
2271 }
2272
2273 #[test]
2274 fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
2275 assert_eq!(
2276 match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
2277 Some("...path up to the [root] (the [root] excluded)".into())
2278 );
2279 assert_eq!(
2280 match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
2281 Some("// [HelpFunc] returns the [function] set".into())
2282 );
2283 assert_eq!(
2284 match_line(
2285 "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
2286 "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
2287 ),
2288 None
2289 );
2290 assert_eq!(
2291 match_line(
2292 "...[root]: &[PathBuf]) -> bool",
2293 "fn f(root: &[PathBuf]) -> bool"
2294 ),
2295 None
2296 );
2297 }
2298
2299 #[test]
2300 fn a_long_unresolved_call_chain_shows_only_its_last_call() {
2301 assert_eq!(
2302 callee_name(
2303 "[\"a\", \"b\"]\n .into_iter()\n .find_map(|key| args.get(key)).ok_or_else"
2304 ),
2305 "….ok_or_else"
2306 );
2307 assert_eq!(
2308 callee_name("workspace.resolve_path"),
2309 "workspace.resolve_path"
2310 );
2311 let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
2312 assert_eq!(callee_name(selector), selector);
2313 }
2314
2315 #[test]
2316 fn file_skeleton_reports_parse_errors() {
2317 let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
2318 assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
2319
2320 let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
2321 assert!(one.contains("// 1 parse error: symbols may be incomplete"));
2322
2323 let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
2324 assert!(!none.contains("parse error"));
2325 }
2326
2327 #[test]
2328 fn test_format_file_skeleton() {
2329 let syms = vec![Symbol {
2330 symbol_id: "s1".into(),
2331 file_id: "f1".into(),
2332 path: "src/lib.rs".into(),
2333 language: "rust".into(),
2334 name: "do_work".into(),
2335 kind: "function".into(),
2336 signature: Some("pub fn do_work() -> Result<()>".into()),
2337 doc_comment: Some("Performs core work.".into()),
2338 visibility: Some("pub".into()),
2339 parent_symbol_id: None,
2340 start_line: 10,
2341 start_column: 0,
2342 end_line: 30,
2343 end_column: 1,
2344 start_byte: 100,
2345 end_byte: 300,
2346 body_start_line: Some(11),
2347 body_start_column: Some(0),
2348 body_end_line: Some(29),
2349 body_end_column: Some(1),
2350 body_start_byte: Some(130),
2351 body_end_byte: Some(298),
2352 body_hash: None,
2353 semantic_group: None,
2354 is_test: false,
2355 test_container: false,
2356 }];
2357
2358 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
2359 assert!(skeleton.contains("/// Performs core work."));
2360
2361 let mut commented = syms.clone();
2362 commented[0].doc_comment = Some("/**\n * Performs core work.\n */".into());
2363 let skeleton = format_file_skeleton("src/lib.rs", &commented, Some(35), 0);
2364 assert!(skeleton.contains("/// Performs core work.\n"), "{skeleton}");
2365 assert!(!skeleton.contains("/// /**"), "{skeleton}");
2366 assert!(skeleton.contains("19 lines hidden: L11-L29"));
2367 }
2368
2369 #[test]
2370 fn format_symbol_body_prints_the_source_under_its_location() {
2371 let mut symbol = sample_symbol("plain");
2372 symbol.signature = Some("pub fn plain()".into());
2373
2374 assert_eq!(
2375 format_symbol_body(&symbol, "pub fn plain() {\n 1\n}"),
2376 "// src/lib.rs:1-10 (plain)\npub fn plain() {\n 1\n}\n"
2377 );
2378 }
2379
2380 #[test]
2381 fn test_format_search_results() {
2382 let results = vec![SymbolSearchResult {
2383 symbol: Symbol {
2384 symbol_id: "s1".into(),
2385 file_id: "f1".into(),
2386 path: "src/parser.rs".into(),
2387 language: "rust".into(),
2388 name: "parse_tokens".into(),
2389 kind: "function".into(),
2390 signature: Some("pub fn parse_tokens()".into()),
2391 doc_comment: Some("Parses tokens from stream.".into()),
2392 visibility: Some("pub".into()),
2393 parent_symbol_id: None,
2394 start_line: 15,
2395 start_column: 0,
2396 end_line: 25,
2397 end_column: 1,
2398 start_byte: 100,
2399 end_byte: 250,
2400 body_start_line: None,
2401 body_start_column: None,
2402 body_end_line: None,
2403 body_end_column: None,
2404 body_start_byte: None,
2405 body_end_byte: None,
2406 body_hash: None,
2407 semantic_group: None,
2408 is_test: false,
2409 test_container: false,
2410 },
2411 score: -1.85,
2412 snippet: Some("Parses [tokens] from stream.".into()),
2413 explain: None,
2414 }];
2415
2416 let formatted = format_search_results("tokens", &results, 20);
2417 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
2418 assert!(
2419 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
2420 );
2421 assert!(formatted.contains("Match: Parses [tokens] from stream."));
2422 assert!(!formatted.contains("explain"));
2423 assert!(!formatted.contains("rerank"));
2424 }
2425
2426 #[test]
2427 fn exact_lookup_folds_import_rows_into_one_line_when_a_definition_exists() {
2428 let class = Symbol {
2429 kind: "class".into(),
2430 path: "src/flask/app.py".into(),
2431 ..sample_symbol("Flask")
2432 };
2433 let import = |path: &str, line: usize| Symbol {
2434 kind: "import".into(),
2435 path: path.into(),
2436 start_line: line,
2437 symbol_id: format!("id_{path}"),
2438 ..sample_symbol("Flask")
2439 };
2440 let rows = vec![
2441 class,
2442 import("src/flask/cli.py", 34),
2443 import("src/flask/ctx.py", 21),
2444 import("tests/conftest.py", 6),
2445 import("examples/app.py", 1),
2446 ];
2447
2448 let folded = format_find_symbol_results("Flask", &rows, &[], 20);
2449
2450 assert!(
2451 folded.contains("- class `Flask` [src/flask/app.py:"),
2452 "{folded}"
2453 );
2454 assert!(
2455 folded.contains(
2456 "- 4 imports of `Flask`: src/flask/cli.py:34, src/flask/ctx.py:21, tests/conftest.py:6, … (lookup_symbol with kind=\"import\" lists them)"
2457 ),
2458 "{folded}"
2459 );
2460 assert!(!folded.contains("- import `Flask`"), "{folded}");
2461
2462 let capped = format_find_symbol_results("Flask", &rows, &[], 1);
2463 assert!(
2464 capped.contains("- at least 4 imports of `Flask`"),
2465 "{capped}"
2466 );
2467
2468 let mut with_prefix_rows = rows.clone();
2469 with_prefix_rows.push(Symbol {
2470 kind: "class".into(),
2471 ..sample_symbol("FlaskGroup")
2472 });
2473 let cut_after_exact = format_find_symbol_results("Flask", &with_prefix_rows, &[], 6);
2474 assert!(
2475 cut_after_exact.contains("- 4 imports of `Flask`"),
2476 "{cut_after_exact}"
2477 );
2478
2479 let imports_only = format_find_symbol_results("Flask", &rows[1..], &[], 20);
2480 assert_eq!(imports_only.matches("- import `Flask`").count(), 4);
2481 }
2482
2483 #[test]
2484 fn skeleton_shows_python_class_and_module_variables_with_one_line_values() {
2485 let leaf = |name: &str, kind: &str, line: usize, sig: &str| Symbol {
2486 kind: kind.into(),
2487 language: "python".into(),
2488 path: "app.py".into(),
2489 parent_symbol_id: Some("id_App".into()),
2490 start_line: line,
2491 end_line: line,
2492 body_start_line: None,
2493 body_end_line: None,
2494 signature: Some(sig.into()),
2495 ..sample_symbol(name)
2496 };
2497 let class = Symbol {
2498 kind: "class".into(),
2499 language: "python".into(),
2500 path: "app.py".into(),
2501 start_line: 1,
2502 end_line: 20,
2503 body_start_line: None,
2504 body_end_line: None,
2505 signature: Some("class App".into()),
2506 ..sample_symbol("App")
2507 };
2508 let module_variable = Symbol {
2509 kind: "variable".into(),
2510 language: "python".into(),
2511 path: "app.py".into(),
2512 start_line: 30,
2513 end_line: 30,
2514 body_start_line: None,
2515 body_end_line: None,
2516 signature: Some("app = App()".into()),
2517 ..sample_symbol("app")
2518 };
2519 let symbols = vec![
2520 class,
2521 leaf(
2522 "request_class",
2523 "variable",
2524 2,
2525 "request_class: type[Request] = Request",
2526 ),
2527 leaf(
2528 "default_config",
2529 "variable",
2530 3,
2531 "default_config = ImmutableDict(\n {\n \"DEBUG\": None,\n }\n)",
2532 ),
2533 leaf(
2534 "blueprints",
2535 "property",
2536 5,
2537 "self.blueprints: dict[str, Blueprint] = {}",
2538 ),
2539 leaf(
2540 "error_handler_spec",
2541 "property",
2542 7,
2543 "self.error_handler_spec: dict[\n ft.AppOrBlueprintKey,\n dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]],\n] = defaultdict(lambda: defaultdict(dict), default_factory_argument_that_is_long) # type: ignore",
2544 ),
2545 module_variable,
2546 ];
2547
2548 let out = format_file_skeleton("app.py", &symbols, None, 0);
2549
2550 assert!(
2551 out.contains(" request_class: type[Request] = Request; // L2-2"),
2552 "{out}"
2553 );
2554 assert!(
2555 out.contains(" default_config = ImmutableDict({\"DEBUG\": None}); // L3-3"),
2556 "{out}"
2557 );
2558 assert!(
2559 out.contains(" self.error_handler_spec: dict[ft.AppOrBlueprintKey, dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]]] = defaultdict(lambda: defaultdict(dict)…; // L7-7"),
2560 "{out}"
2561 );
2562 assert!(
2563 out.contains(" self.blueprints: dict[str, Blueprint] = {}; // L5-5"),
2564 "{out}"
2565 );
2566 assert!(out.contains("app = App(); // L30-30"), "{out}");
2567 }
2568
2569 #[test]
2570 fn skeleton_names_the_method_that_assigns_an_attribute() {
2571 let member = |name: &str, kind: &str, start: usize, end: usize, sig: &str| Symbol {
2572 kind: kind.into(),
2573 language: "python".into(),
2574 parent_symbol_id: Some("id_Flask".into()),
2575 start_line: start,
2576 end_line: end,
2577 body_start_line: None,
2578 body_end_line: None,
2579 signature: Some(sig.into()),
2580 ..sample_symbol(name)
2581 };
2582 let class = Symbol {
2583 kind: "class".into(),
2584 language: "python".into(),
2585 start_line: 1,
2586 end_line: 30,
2587 body_start_line: None,
2588 body_end_line: None,
2589 signature: Some("class Flask(App)".into()),
2590 ..sample_symbol("Flask")
2591 };
2592 let symbols = vec![
2593 class,
2594 member("__init__", "method", 2, 10, "def __init__(self)"),
2595 member("cli", "property", 3, 3, "self.cli = cli.AppGroup()"),
2596 member("run", "method", 12, 20, "def run(self)"),
2597 member("debug", "property", 14, 14, "self.debug = get_debug_flag()"),
2598 ];
2599
2600 let out = format_file_skeleton("app.py", &symbols, None, 0);
2601
2602 assert!(
2603 out.contains("self.cli = cli.AppGroup(); // in __init__(), L3-3"),
2604 "{out}"
2605 );
2606 assert!(
2607 out.contains("self.debug = get_debug_flag(); // in run(), L14-14"),
2608 "{out}"
2609 );
2610 }
2611
2612 #[test]
2613 fn skeleton_hides_variables_that_are_not_python_class_attributes() {
2614 let parent = |name: &str, kind: &str, language: &str| Symbol {
2615 kind: kind.into(),
2616 language: language.into(),
2617 start_line: 1,
2618 end_line: 20,
2619 body_start_line: None,
2620 body_end_line: None,
2621 signature: Some(format!("{kind} {name}")),
2622 ..sample_symbol(name)
2623 };
2624 let child = |name: &str, parent: &str, language: &str, sig: &str| Symbol {
2625 kind: "variable".into(),
2626 language: language.into(),
2627 parent_symbol_id: Some(format!("id_{parent}")),
2628 start_line: 2,
2629 end_line: 2,
2630 body_start_line: None,
2631 body_end_line: None,
2632 signature: Some(sig.into()),
2633 ..sample_symbol(name)
2634 };
2635 let symbols = vec![
2636 parent("Adapters", "class", "java"),
2637 child(
2638 "accessible",
2639 "Adapters",
2640 "java",
2641 "boolean accessible = false",
2642 ),
2643 parent("body", "class", "html"),
2644 child(
2645 "img",
2646 "body",
2647 "html",
2648 "<img class=\"carat\" src=\"carat.png\" alt>",
2649 ),
2650 parent("section", "module", "yaml"),
2651 child("title", "section", "yaml", "title: Invoking jq"),
2652 ];
2653
2654 let out = format_file_skeleton("mixed", &symbols, None, 0);
2655
2656 for hidden in ["accessible", "<img", "title:"] {
2657 assert!(!out.contains(hidden), "{out}");
2658 }
2659 }
2660
2661 #[test]
2662 fn a_multi_line_macro_row_drops_its_line_continuations_and_is_cut_to_one_line() {
2663 let short = "#define PAIR(a, b) \\\n a, \\\n b";
2664 assert_eq!(value_row_signature(short), "#define PAIR(a, b) a, b");
2665
2666 let long = format!(
2667 "#define WIDE(x) \\\n{}",
2668 " x + x + x + x + x + x + x + x \\\n".repeat(8)
2669 );
2670 let row = value_row_signature(&long);
2671 assert!(row.starts_with("#define WIDE(x) x + x"), "{row}");
2672 assert!(row.ends_with('…'), "{row}");
2673 assert!(!row.contains('\\'), "{row}");
2674 assert!(row.chars().count() <= 120, "{row}");
2675 }
2676
2677 #[test]
2678 fn skeleton_keeps_values_only_for_real_assignments() {
2679 let row =
2680 |name: &str, kind: &str, line: usize, sig: &str, body: Option<(usize, usize)>| Symbol {
2681 kind: kind.into(),
2682 start_line: line,
2683 end_line: body.map_or(line, |(_, end)| end),
2684 body_start_line: body.map(|(start, _)| start),
2685 body_end_line: body.map(|(_, end)| end),
2686 signature: Some(sig.into()),
2687 ..sample_symbol(name)
2688 };
2689 let symbols = vec![
2690 row(
2691 "img",
2692 "property",
2693 1,
2694 "[class^=rz-] img,[class^=rz-] svg { vertical-align:middle }",
2695 None,
2696 ),
2697 row(
2698 "Coordinates",
2699 "property",
2700 2,
2701 "[JsonProperty(ItemConverterType = typeof(IntToFloatConverter))] public int[,,] Coordinates { get; set; }",
2702 None,
2703 ),
2704 row(
2705 "VERSION_CHECK",
2706 "constant",
2707 3,
2708 "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor)))\n",
2709 None,
2710 ),
2711 row(
2712 "TABLE",
2713 "constant",
2714 4,
2715 "TABLE = {\n '\"' => '%22',\n '\\r' => '%0D',\n}.freeze",
2716 Some((4, 8)),
2717 ),
2718 row(
2719 "LIMIT",
2720 "field",
2721 10,
2722 "private static final int LIMIT = 5;",
2723 None,
2724 ),
2725 ];
2726
2727 let out = format_file_skeleton("mixed", &symbols, None, 0);
2728
2729 assert!(
2730 out.contains("[class^=rz-] img,[class^=rz-] svg; // L1-1"),
2731 "{out}"
2732 );
2733 assert!(out.contains("public int[,,] Coordinates; // L2-2"), "{out}");
2734 assert!(
2735 out.contains(
2736 "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor))); // L3-3\n"
2737 ),
2738 "{out}"
2739 );
2740 assert!(
2741 out.contains("TABLE = { /* 5 lines hidden: L4-L8 */ }"),
2742 "{out}"
2743 );
2744 assert!(
2745 out.contains("private static final int LIMIT = 5; // L10-10"),
2746 "{out}"
2747 );
2748 }
2749
2750 #[test]
2751 fn skeleton_cuts_a_field_with_members_at_its_brace() {
2752 let field = Symbol {
2753 kind: "field".into(),
2754 language: "java".into(),
2755 start_line: 1,
2756 end_line: 9,
2757 body_start_line: None,
2758 body_end_line: None,
2759 signature: Some(
2760 "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n @Override\n}"
2761 .into(),
2762 ),
2763 ..sample_symbol("CLASS")
2764 };
2765 let member = Symbol {
2766 kind: "method".into(),
2767 language: "java".into(),
2768 parent_symbol_id: Some("id_CLASS".into()),
2769 start_line: 2,
2770 end_line: 4,
2771 signature: Some("public void write(JsonWriter out, Class value)".into()),
2772 ..sample_symbol("write")
2773 };
2774
2775 let out = format_file_skeleton("TypeAdapters.java", &[field, member], None, 0);
2776
2777 assert!(
2778 out.contains(
2779 "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n"
2780 ),
2781 "{out}"
2782 );
2783 assert!(!out.contains('…'), "{out}");
2784 }
2785
2786 #[test]
2787 fn import_summary_counts_sites_and_files_and_lists_three() {
2788 let sites = vec![
2789 ("src/flask/__init__.py".to_string(), 2),
2790 ("src/flask/cli.py".to_string(), 34),
2791 ("src/flask/cli.py".to_string(), 45),
2792 ("tests/conftest.py".to_string(), 6),
2793 ];
2794
2795 assert_eq!(
2796 format_import_summary(&sites),
2797 "Imported 4 times in 3 files: src/flask/__init__.py:2, src/flask/cli.py:34, src/flask/cli.py:45, … (lookup_symbol with kind=\"import\" lists them)\n"
2798 );
2799 assert_eq!(format_import_summary(&[]), "");
2800 }
2801
2802 #[test]
2803 fn no_facts_for_an_alias_points_imports_at_lookup_and_skips_the_raw_list() {
2804 let categories = vec![("flask.route.v1".to_string(), 3)];
2805
2806 let imports = format_no_facts("import", None, &categories);
2807 let unknown = format_no_facts("widgets", None, &categories);
2808
2809 assert!(
2810 imports.contains("lookup_symbol with kind=\"import\""),
2811 "{imports}"
2812 );
2813 assert!(!imports.contains("`flask.route.v1`"), "{imports}");
2814 assert!(unknown.contains("`flask.route.v1` (3 facts)"), "{unknown}");
2815 }
2816
2817 #[test]
2818 fn markup_and_data_rows_show_their_own_kind_words() {
2819 let row = |language: &str, kind: &str| Symbol {
2820 language: language.into(),
2821 kind: kind.into(),
2822 ..sample_symbol("x")
2823 };
2824 assert_eq!(display_kind(&row("html", "class")), "element");
2825 assert_eq!(display_kind(&row("sql", "class")), "table");
2826 assert_eq!(display_kind(&row("markdown", "module")), "section");
2827 assert_eq!(display_kind(&row("markdown", "import")), "link");
2828 let attribute = Symbol {
2829 kind: "property".into(),
2830 signature: Some("self.extensions = {}".into()),
2831 ..sample_symbol("extensions")
2832 };
2833 assert_eq!(display_kind(&attribute), "attribute");
2834 let fsharp_member = Symbol {
2835 language: "fsharp".into(),
2836 signature: Some("this.Total = decimal this.Qty * this.Price".into()),
2837 ..attribute
2838 };
2839 assert_eq!(display_kind(&fsharp_member), "property");
2840 assert_eq!(display_kind(&row("python", "class")), "class");
2841 }
2842
2843 #[test]
2844 fn no_facts_heading_names_the_path_filter() {
2845 assert_eq!(
2846 no_facts_heading("config", Some("src/flask")),
2847 "No facts match 'config' under `src/flask`."
2848 );
2849 assert_eq!(
2850 no_facts_heading("config", None),
2851 "No facts match 'config' in this repository."
2852 );
2853 }
2854
2855 #[test]
2856 fn outline_says_how_many_definitions_it_left_out_and_counts_tests() {
2857 let mut root = OutlineNode::default();
2858 let symbol = |name: &str, is_test: bool| Symbol {
2859 kind: "function".into(),
2860 path: "tests/test_basic.py".into(),
2861 is_test,
2862 ..sample_symbol(name)
2863 };
2864 let symbols = HashMap::from([(
2865 "tests/test_basic.py".to_string(),
2866 vec![symbol("helper", false), symbol("test_one", true)],
2867 )]);
2868 let counts = HashMap::from([(
2869 "tests/test_basic.py".to_string(),
2870 crate::queries::OutlineCounts {
2871 definitions: 3,
2872 tests: 90,
2873 fixtures: 2,
2874 },
2875 )]);
2876 add_path_to_outline(&mut root, "tests/test_basic.py", &symbols, &counts, 3, "");
2877
2878 let mut out = String::new();
2879 render_outline_tree(&mut out, &root, "", 0, 3);
2880
2881 assert!(
2882 out.contains("test_basic.py [function helper, +2 more, 90 tests, 2 fixtures]"),
2883 "{out}"
2884 );
2885 }
2886
2887 #[test]
2888 fn outline_counts_the_files_below_the_depth_limit() {
2889 let mut root = OutlineNode::default();
2890 let no_symbols = HashMap::new();
2891 for path in [
2892 "src/flask/app.py",
2893 "src/flask/json/tag.py",
2894 "src/flask/cli.py",
2895 "tests/conftest.py",
2896 ] {
2897 add_path_to_outline(&mut root, path, &no_symbols, &HashMap::new(), 2, "");
2898 }
2899
2900 let mut out = String::new();
2901 render_outline_tree(&mut out, &root, "", 0, 2);
2902
2903 assert!(out.contains("└── flask/ (3 indexed files)"), "{out}");
2904 assert!(!out.contains("tests/ ("), "{out}");
2905 }
2906
2907 #[test]
2908 fn a_lookup_qualified_with_double_colons_names_the_member_exactly() {
2909 let rows = vec![sample_symbol("Flask.handle_user_exception")];
2910
2911 let out = format_find_symbol_results("Flask::handle_user_exception", &rows, &[], 20);
2912
2913 assert!(!out.contains("named exactly"), "{out}");
2914 }
2915
2916 #[test]
2917 fn exact_lookup_folds_only_imports_named_exactly_like_the_query() {
2918 let rows = vec![
2919 Symbol {
2920 kind: "function".into(),
2921 ..sample_symbol("read")
2922 },
2923 Symbol {
2924 kind: "import".into(),
2925 path: "src/flask/cli.py".into(),
2926 start_line: 1034,
2927 ..sample_symbol("readline")
2928 },
2929 Symbol {
2930 kind: "import".into(),
2931 path: "src/Types.kt".into(),
2932 start_line: 18,
2933 ..sample_symbol("com.example.internal.read")
2934 },
2935 ];
2936
2937 let out = format_find_symbol_results("read", &rows, &[], 20);
2938
2939 assert!(
2940 out.contains(
2941 "- 1 other row starts with or contains `read`, ignoring case: `readline` (import) (lookup_symbol"
2942 ),
2943 "{out}"
2944 );
2945 assert!(
2946 out.contains(
2947 "- 1 import of `read`: src/Types.kt:18 (lookup_symbol with kind=\"import\" lists them)"
2948 ),
2949 "{out}"
2950 );
2951 }
2952
2953 #[test]
2954 fn discovery_formatters_append_ids_to_metadata_rows() {
2955 let exact = vec![sample_symbol("exact")];
2956 let fallback = vec![SymbolSearchResult {
2957 symbol: sample_symbol("fallback"),
2958 score: 1.0,
2959 snippet: None,
2960 explain: None,
2961 }];
2962
2963 let outputs = [
2964 (
2965 format_find_symbol_results("exact", &exact, &[], 20),
2966 "id_exact",
2967 ),
2968 (
2969 format_find_symbol_results("fallback query", &[], &fallback, 20),
2970 "id_fallback",
2971 ),
2972 (
2973 format_search_results("fallback", &fallback, 20),
2974 "id_fallback",
2975 ),
2976 ];
2977
2978 for (formatted, id) in outputs {
2979 assert!(
2980 formatted
2981 .lines()
2982 .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
2983 "{formatted}"
2984 );
2985 assert!(!formatted.contains("\n id="), "{formatted}");
2986 }
2987 }
2988
2989 #[test]
2990 fn lookup_lists_name_rows_before_the_ranked_rows_under_their_own_header() {
2991 let names = vec![sample_symbol("retry_count")];
2992 let ranked = vec![SymbolSearchResult {
2993 symbol: sample_symbol("send_again"),
2994 score: 7.5,
2995 snippet: None,
2996 explain: None,
2997 }];
2998
2999 let out = format_find_symbol_results("retry", &names, &ranked, 20);
3000
3001 let name_header = out.find("Found 1 symbols matching \"retry\"").unwrap();
3002 let ranked_header = out
3003 .find("\nNo exact name match; 1 full-text matches for \"retry\":\n\n")
3004 .unwrap();
3005 assert!(name_header < out.find("`retry_count`").unwrap(), "{out}");
3006 assert!(out.find("`retry_count`").unwrap() < ranked_header, "{out}");
3007 assert!(ranked_header < out.find("`send_again`").unwrap(), "{out}");
3008 }
3009
3010 #[test]
3011 fn every_printed_part_of_the_explain_line_sums_to_the_score() {
3012 let e = SearchExplain {
3013 bm25: Some(-3.21),
3014 branches: vec!["word".into()],
3015 name_tier: "all".into(),
3016 name_strength: 6,
3017 term_score: 24.5,
3018 field_diversity_bonus: 6.0,
3019 name_bonus: 60.0,
3020 kind_prior: 4.0,
3021 path_role: -10.0,
3022 documentation: -200.0,
3023 test_intent: 5.0,
3024 nested: 0.0,
3025 terms: vec![("sha".into(), "name".into(), 3.0)],
3026 word_weights: vec![("sha".into(), 2.6)],
3027 candidates: 1,
3028 rerank_us: 1,
3029 };
3030 let score = e.term_score
3031 + e.field_diversity_bonus
3032 + e.name_bonus
3033 + e.kind_prior
3034 + e.path_role
3035 + e.documentation
3036 + e.test_intent;
3037
3038 let line = explain_line(score, &e);
3039 let parts: f64 = line
3040 .split(" = ")
3041 .nth(1)
3042 .unwrap()
3043 .split(" [")
3044 .next()
3045 .unwrap()
3046 .split(" + ")
3047 .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
3048 .sum();
3049
3050 assert!(line.starts_with(&format!(
3051 "score {score:.1} = terms 24.5 + name all(6) 60.0 "
3052 )));
3053 assert!(line.contains("+ field coverage 6.0"));
3054 assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
3055 }
3056
3057 #[test]
3058 fn test_format_search_results_prints_the_explain_breakdown_when_present() {
3059 let mut result = SymbolSearchResult {
3060 symbol: sample_symbol("parseSha256Sidecar"),
3061 score: 71.6,
3062 snippet: Some("parse[Sha256]Sidecar".into()),
3063 explain: Some(SearchExplain {
3064 bm25: Some(-3.21),
3065 branches: vec!["word".into(), "name".into()],
3066 name_tier: "all".into(),
3067 name_strength: 6,
3068 terms: vec![
3069 ("sha".into(), "name".into(), 3.0),
3070 ("256".into(), "name".into(), 3.0),
3071 ],
3072 term_score: 12.6,
3073 field_diversity_bonus: 0.0,
3074 name_bonus: 60.0,
3075 kind_prior: 4.0,
3076 path_role: -10.0,
3077 documentation: 0.0,
3078 test_intent: 5.0,
3079 nested: 0.0,
3080 word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
3081 candidates: 37,
3082 rerank_us: 180,
3083 }),
3084 };
3085
3086 let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
3087 assert!(formatted.contains(
3088 "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
3089 ));
3090 assert!(formatted.contains(
3091 " explain: score 71.6 = terms 12.6 + name all(6) 60.0 + kind 4.0 + path -10.0 + test 5.0 [word,name] bm25 -3.21 terms sha=name:3 256=name:3\n"
3092 ));
3093
3094 result.explain = None;
3095 let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
3096 assert!(!silent.contains("explain"));
3097 assert!(!silent.contains("rerank"));
3098 }
3099
3100 #[test]
3101 fn test_format_search_results_discloses_a_reached_limit() {
3102 let results = vec![SymbolSearchResult {
3103 symbol: sample_symbol("parse_tokens"),
3104 score: 0.0,
3105 snippet: None,
3106 explain: None,
3107 }];
3108
3109 let formatted = format_search_results("tokens", &results, 1);
3110 assert!(
3111 formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
3112 );
3113
3114 let at_ceiling =
3115 format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
3116 assert!(!at_ceiling.contains("limit reached"));
3117
3118 let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
3119 .map(|_| SymbolSearchResult {
3120 symbol: sample_symbol("parse_tokens"),
3121 score: 0.0,
3122 snippet: None,
3123 explain: None,
3124 })
3125 .collect();
3126 let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
3127 assert!(capped.contains("(limit reached); narrow the query to see more.]"));
3128 }
3129
3130 #[test]
3131 fn test_format_blast_radius() {
3132 let res = BlastRadiusResult {
3133 seed_type: "symbol".into(),
3134 seeds: vec!["do_work".into()],
3135 likely_tests: vec![TestTarget {
3136 name: "test_do_work".into(),
3137 path: "tests/work_test.rs".into(),
3138 line: 15,
3139 reason: "direct caller".into(),
3140 }],
3141 impacted_symbols: vec![ImpactedSymbol {
3142 name: "caller_fn".into(),
3143 kind: "function".into(),
3144 path: "src/caller.rs".into(),
3145 line: 42,
3146 depth: 1,
3147 enclosing_symbol_name: None,
3148 }],
3149 traversal_ceiling_reached: false,
3150 likely_tests_truncated: false,
3151 impacted_symbols_truncated: false,
3152 limit_at_maximum: false,
3153 likely_tests_found: 0,
3154 impacted_symbols_found: 0,
3155 stopped_at_depth: None,
3156 };
3157
3158 let formatted = format_blast_radius(&res);
3159 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
3160 assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
3161 assert!(
3162 formatted.contains("tests/work_test.rs:\n - `test_do_work` [line 15] (direct caller)")
3163 );
3164 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
3165 }
3166
3167 #[test]
3168 fn format_blast_radius_groups_every_returned_test_by_file() {
3169 let mut likely_tests = Vec::new();
3170 for i in 1..=25 {
3171 likely_tests.push(TestTarget {
3172 name: format!("test_{i}"),
3173 path: format!("tests/test_{}.rs", (i % 3) + 1),
3174 line: i * 10,
3175 reason: "direct caller".into(),
3176 });
3177 }
3178
3179 let impacted_symbols = vec![
3180 ImpactedSymbol {
3181 name: "use_foo".into(),
3182 kind: "import".into(),
3183 path: "src/service.rs".into(),
3184 line: 1,
3185 depth: 1,
3186 enclosing_symbol_name: None,
3187 },
3188 ImpactedSymbol {
3189 name: "service_fn".into(),
3190 kind: "function".into(),
3191 path: "src/service.rs".into(),
3192 line: 20,
3193 depth: 1,
3194 enclosing_symbol_name: None,
3195 },
3196 ImpactedSymbol {
3197 name: "api_handler".into(),
3198 kind: "function".into(),
3199 path: "src/api.rs".into(),
3200 line: 45,
3201 depth: 2,
3202 enclosing_symbol_name: None,
3203 },
3204 ];
3205
3206 let res = BlastRadiusResult {
3207 seed_type: "file".into(),
3208 seeds: vec!["src/lib.rs".into()],
3209 likely_tests,
3210 impacted_symbols,
3211 traversal_ceiling_reached: false,
3212 likely_tests_truncated: false,
3213 impacted_symbols_truncated: false,
3214 limit_at_maximum: false,
3215 likely_tests_found: 0,
3216 impacted_symbols_found: 0,
3217 stopped_at_depth: None,
3218 };
3219
3220 let formatted = format_blast_radius(&res);
3221
3222 assert!(formatted.contains("### Likely Tests to Run (25 returned)"));
3223 assert!(
3224 formatted.contains(" - `test_25` [line 250]"),
3225 "{formatted}"
3226 );
3227 assert!(!formatted.contains("hidden by the compact"));
3228
3229 assert!(formatted.contains("tests/test_1.rs:\n"));
3230 assert!(formatted.contains(" - `test_"));
3231
3232 assert!(!formatted.contains("use_foo"));
3233 assert!(
3234 formatted
3235 .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
3236 );
3237 assert!(formatted.contains("src/service.rs:\n"));
3238 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
3239 }
3240
3241 fn sample_symbol(name: &str) -> Symbol {
3242 Symbol {
3243 symbol_id: format!("id_{name}"),
3244 file_id: "f1".into(),
3245 path: "src/lib.rs".into(),
3246 language: "rust".into(),
3247 name: name.into(),
3248 kind: "function".into(),
3249 signature: Some(format!("pub fn {name}()")),
3250 doc_comment: None,
3251 visibility: Some("pub".into()),
3252 parent_symbol_id: None,
3253 start_line: 1,
3254 start_column: 0,
3255 end_line: 10,
3256 end_column: 1,
3257 start_byte: 0,
3258 end_byte: 100,
3259 body_start_line: Some(2),
3260 body_start_column: Some(0),
3261 body_end_line: Some(9),
3262 body_end_column: Some(1),
3263 body_start_byte: Some(10),
3264 body_end_byte: Some(99),
3265 body_hash: None,
3266 semantic_group: None,
3267 is_test: false,
3268 test_container: false,
3269 }
3270 }
3271
3272 fn sample_context_slice() -> ContextSlice {
3273 ContextSlice {
3274 target_symbol: sample_symbol("target_fn"),
3275 target_body: " println!(\"hello\");\n".into(),
3276 callee_signatures: Vec::new(),
3277 related_types: Vec::new(),
3278 related_tests: Vec::new(),
3279 }
3280 }
3281
3282 #[test]
3283 fn format_context_slice_does_not_repeat_expression_already_in_signature() {
3284 let body = "=>\n transport.SendAsync<Response>(\n HttpMethod.Get,\n $\"items/{id}\",\n cancellationToken: cancellationToken)";
3285 let mut slice = sample_context_slice();
3286 slice.target_symbol.language = "csharp".into();
3287 slice.target_symbol.signature = Some(format!(
3288 "public static Task<Response?> GetByIdAsync(\n int id,\n CancellationToken cancellationToken = default){body}"
3289 ));
3290 slice.target_body = body.into();
3291
3292 let formatted = format_context_slice(&slice);
3293
3294 assert_eq!(
3295 formatted.matches("transport.SendAsync").count(),
3296 1,
3297 "{formatted}"
3298 );
3299 }
3300
3301 #[test]
3302 fn context_slice_leaves_out_a_signature_that_the_body_already_holds() {
3303 let mut slice = sample_context_slice();
3304 slice.target_symbol.signature = Some("type Person = { Name: string; Age: int }".into());
3305 slice.target_body = "type Person = { Name: string; Age: int }\n".into();
3306
3307 let text = format_context_slice(&slice);
3308
3309 assert!(!text.contains("Signature:"), "{text}");
3310 assert_eq!(text.matches("type Person").count(), 1, "{text}");
3311 }
3312
3313 #[test]
3314 fn context_slice_says_when_no_test_was_found() {
3315 let text = format_context_slice(&sample_context_slice());
3316
3317 assert!(
3318 text.ends_with("### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n"),
3319 "{text}"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
3325 let mut slice = sample_context_slice();
3326 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
3327 let text = format_context_slice(&slice);
3328 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
3329
3330 let mut slice_tests = sample_context_slice();
3331 slice_tests.related_tests = (1..=5)
3332 .map(|i| {
3333 let mut sym = sample_symbol(&format!("test_fn_{i}"));
3334 sym.path = format!("tests/test_{i}.rs");
3335 sym.is_test = true;
3336 sym
3337 })
3338 .collect();
3339 let text_tests = format_context_slice(&slice_tests);
3340 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
3341 }
3342
3343 #[test]
3344 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
3345 let impacted_symbols = (1..=200)
3346 .map(|i| ImpactedSymbol {
3347 name: format!("sym_{i}"),
3348 kind: "function".into(),
3349 path: format!("src/mod_{}.rs", i % 10),
3350 line: i,
3351 depth: 1,
3352 enclosing_symbol_name: None,
3353 })
3354 .collect();
3355
3356 let res = BlastRadiusResult {
3357 seed_type: "symbol".into(),
3358 seeds: vec!["root_fn".into()],
3359 likely_tests: Vec::new(),
3360 impacted_symbols,
3361 traversal_ceiling_reached: true,
3362 likely_tests_truncated: false,
3363 impacted_symbols_truncated: false,
3364 limit_at_maximum: false,
3365 likely_tests_found: 0,
3366 impacted_symbols_found: 0,
3367 stopped_at_depth: None,
3368 };
3369
3370 let formatted = format_blast_radius(&res);
3371 assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
3372 }
3373
3374 #[test]
3375 fn blast_radius_does_not_claim_no_results_after_the_traversal_ceiling() {
3376 let res = BlastRadiusResult {
3377 seed_type: "file".into(),
3378 seeds: vec!["src/widget.rs".into()],
3379 likely_tests: Vec::new(),
3380 impacted_symbols: Vec::new(),
3381 likely_tests_truncated: false,
3382 impacted_symbols_truncated: false,
3383 traversal_ceiling_reached: true,
3384 limit_at_maximum: false,
3385 likely_tests_found: 0,
3386 impacted_symbols_found: 0,
3387 stopped_at_depth: None,
3388 };
3389
3390 let formatted = format_blast_radius(&res);
3391 assert!(formatted.contains("Likely Tests to Run (0 returned)"));
3392 assert!(formatted.contains("Downstream Impact (0 returned)"));
3393 assert!(!formatted.contains("No test reaches it through the index"));
3394 assert!(!formatted.contains("No downstream callers found within depth"));
3395
3396 let no_ceiling = BlastRadiusResult {
3397 traversal_ceiling_reached: false,
3398 ..res
3399 };
3400 assert!(
3401 format_blast_radius(&no_ceiling)
3402 .contains("No test reaches it through the index. Run the full test suite.\n")
3403 );
3404
3405 let cut_by_depth = BlastRadiusResult {
3406 stopped_at_depth: Some(2),
3407 ..no_ceiling
3408 };
3409 let formatted = format_blast_radius(&cut_by_depth);
3410 assert!(formatted.contains(
3411 "No test reaches it within depth 2; a larger `depth` may find some. Otherwise run the full test suite.\n"
3412 ));
3413 assert!(!formatted.contains("through the index"));
3414 }
3415
3416 fn skeleton_row(
3417 id: &str,
3418 parent: Option<&str>,
3419 kind: &str,
3420 name: &str,
3421 signature: &str,
3422 lines: (usize, usize),
3423 body: Option<(usize, usize)>,
3424 ) -> Symbol {
3425 Symbol {
3426 symbol_id: id.into(),
3427 file_id: "f1".into(),
3428 path: "src/lib.rs".into(),
3429 language: "rust".into(),
3430 name: name.into(),
3431 kind: kind.into(),
3432 signature: Some(signature.into()),
3433 doc_comment: None,
3434 visibility: None,
3435 parent_symbol_id: parent.map(str::to_string),
3436 start_line: lines.0,
3437 start_column: 0,
3438 end_line: lines.1,
3439 end_column: 1,
3440 start_byte: 0,
3441 end_byte: 0,
3442 body_start_line: body.map(|b| b.0),
3443 body_start_column: None,
3444 body_end_line: body.map(|b| b.1),
3445 body_end_column: None,
3446 body_start_byte: None,
3447 body_end_byte: None,
3448 body_hash: None,
3449 semantic_group: None,
3450 is_test: false,
3451 test_container: false,
3452 }
3453 }
3454
3455 #[test]
3456 fn skeleton_nests_an_object_under_the_field_that_declares_it() {
3457 let syms = vec![
3458 skeleton_row(
3459 "root",
3460 None,
3461 "class",
3462 "shell",
3463 "extends ShellRoot",
3464 (1, 8),
3465 None,
3466 ),
3467 skeleton_row(
3468 "timer",
3469 Some("root"),
3470 "field",
3471 "localPluginReloadTimer",
3472 "localPluginReloadTimer: Timer",
3473 (2, 7),
3474 Some((2, 7)),
3475 ),
3476 skeleton_row(
3477 "interval",
3478 Some("timer"),
3479 "property",
3480 "interval",
3481 "interval: 150",
3482 (3, 3),
3483 None,
3484 ),
3485 skeleton_row(
3486 "fire",
3487 Some("timer"),
3488 "function",
3489 "fire",
3490 "function fire()",
3491 (5, 7),
3492 Some((6, 7)),
3493 ),
3494 ];
3495
3496 assert_eq!(
3497 format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
3498 "// File: shell/shell.qml (Lines 1-8)\n\
3499 \n\
3500 extends ShellRoot {\n\
3501 \x20 localPluginReloadTimer: Timer {\n\
3502 \x20 interval: 150; // L3-3\n\
3503 \x20 function fire() { /* 2 lines hidden: L6-L7 */ }\n\
3504 \x20 } // L2-7\n\
3505 \n\
3506 } // L1-8\n\
3507 \n"
3508 );
3509 }
3510
3511 #[test]
3512 fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
3513 let syms = vec![
3514 skeleton_row(
3515 "rusqlite",
3516 None,
3517 "field",
3518 "rusqlite",
3519 "rusqlite = { workspace = true }",
3520 (15, 15),
3521 None,
3522 ),
3523 skeleton_row(
3524 "workspace",
3525 Some("rusqlite"),
3526 "property",
3527 "workspace",
3528 "workspace = true",
3529 (15, 15),
3530 None,
3531 ),
3532 ];
3533
3534 assert_eq!(
3535 format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
3536 "// File: Cargo.toml (Lines 1-15)\n\
3537 \n\
3538 rusqlite = { workspace = true }; // L15-15\n"
3539 );
3540 }
3541
3542 #[test]
3543 fn skeleton_renders_members_of_a_single_line_enum() {
3544 let mut syms = vec![
3545 skeleton_row(
3546 "mode",
3547 None,
3548 "enum",
3549 "DispatchMode",
3550 "public enum DispatchMode",
3551 (21, 21),
3552 None,
3553 ),
3554 skeleton_row(
3555 "provision",
3556 Some("mode"),
3557 "enum_member",
3558 "Provision",
3559 "Provision",
3560 (21, 21),
3561 None,
3562 ),
3563 skeleton_row(
3564 "deprovision",
3565 Some("mode"),
3566 "enum_member",
3567 "Deprovision",
3568 "Deprovision",
3569 (21, 21),
3570 None,
3571 ),
3572 skeleton_row(
3573 "extension",
3574 Some("mode"),
3575 "enum_member",
3576 "Extension",
3577 "Extension",
3578 (21, 21),
3579 None,
3580 ),
3581 ];
3582 for symbol in &mut syms {
3583 symbol.language = "csharp".into();
3584 }
3585
3586 let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
3587
3588 for member in ["Provision", "Deprovision", "Extension"] {
3589 assert!(skeleton.contains(member), "{skeleton}");
3590 }
3591 }
3592
3593 #[test]
3594 fn a_hidden_body_names_the_functions_defined_inside_it_but_not_lambdas() {
3595 let mut syms = vec![
3596 skeleton_row(
3597 "factory",
3598 None,
3599 "function",
3600 "create_app",
3601 "def create_app()",
3602 (1, 9),
3603 Some((2, 9)),
3604 ),
3605 skeleton_row(
3606 "hello",
3607 Some("factory"),
3608 "function",
3609 "hello",
3610 "def hello()",
3611 (4, 5),
3612 Some((5, 5)),
3613 ),
3614 skeleton_row(
3615 "lambda",
3616 Some("factory"),
3617 "function",
3618 "lambda_7",
3619 "lambda v: v",
3620 (7, 7),
3621 None,
3622 ),
3623 ];
3624 for symbol in &mut syms {
3625 symbol.language = "python".into();
3626 }
3627
3628 let skeleton = format_file_skeleton("app.py", &syms, Some(9), 0);
3629
3630 assert!(
3631 skeleton.contains("def create_app() { /* 8 lines hidden: L2-L9; defines `hello` */ }"),
3632 "{skeleton}"
3633 );
3634
3635 for symbol in &mut syms {
3636 symbol.language = "cpp".into();
3637 }
3638 let cpp = format_file_skeleton("app.cpp", &syms, Some(9), 0);
3639 assert!(!cpp.contains("defines"), "{cpp}");
3640 syms[1].test_container = true;
3641 let cpp_section = format_file_skeleton("app.cpp", &syms, Some(9), 0);
3642 assert!(cpp_section.contains("defines `hello`"), "{cpp_section}");
3643 }
3644
3645 #[test]
3646 fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
3647 let syms = vec![
3648 skeleton_row(
3649 "cfg",
3650 None,
3651 "struct",
3652 "Config",
3653 "pub struct Config",
3654 (1, 3),
3655 None,
3656 ),
3657 skeleton_row(
3658 "retries",
3659 Some("cfg"),
3660 "field",
3661 "retries",
3662 "pub retries: u32",
3663 (2, 2),
3664 None,
3665 ),
3666 skeleton_row(
3667 "run",
3668 None,
3669 "function",
3670 "run",
3671 "pub fn run()",
3672 (5, 9),
3673 Some((6, 8)),
3674 ),
3675 skeleton_row(
3676 "tmp",
3677 Some("run"),
3678 "variable",
3679 "tmp",
3680 "let tmp",
3681 (7, 7),
3682 None,
3683 ),
3684 ];
3685
3686 assert_eq!(
3687 format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
3688 "// File: src/lib.rs (Lines 1-9)\n\
3689 \n\
3690 pub struct Config {\n\
3691 \x20 pub retries: u32; // L2-2\n\
3692 } // L1-3\n\
3693 \n\
3694 pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
3695 );
3696 }
3697
3698 #[test]
3699 fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
3700 let syms = vec![
3701 skeleton_row(
3702 "cls",
3703 None,
3704 "class",
3705 "ColumnViewAttached",
3706 "class ColumnViewAttached : public QObject",
3707 (11, 52),
3708 None,
3709 ),
3710 skeleton_row(
3711 "sig",
3712 Some("cls"),
3713 "event",
3714 "indexChanged",
3715 "void indexChanged()",
3716 (47, 47),
3717 None,
3718 ),
3719 skeleton_row(
3720 "qml",
3721 None,
3722 "event",
3723 "clicked",
3724 "signal clicked()",
3725 (60, 60),
3726 None,
3727 ),
3728 ];
3729
3730 assert_eq!(
3731 format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
3732 "// File: src/columnview.h (Lines 1-60)\n\
3733 \n\
3734 class ColumnViewAttached : public QObject {\n\
3735 \x20 void indexChanged(); // event L47-47\n\
3736 } // L11-52\n\
3737 \n\
3738 signal clicked(); // L60-60\n"
3739 );
3740 }
3741
3742 fn labeled_site(
3743 label: SiteLabel,
3744 path: &str,
3745 line: usize,
3746 caller: &str,
3747 basis: &str,
3748 ) -> LabeledSite {
3749 LabeledSite {
3750 site: ReferenceSite {
3751 from_symbol_name: caller.into(),
3752 from_symbol_id: "c1".into(),
3753 to_symbol_name: "target".into(),
3754 kind: "calls".into(),
3755 path: path.into(),
3756 start_line: Some(line),
3757 start_column: Some(4),
3758 occurrences: None,
3759 target: None,
3760 enclosing_symbol_name: None,
3761 },
3762 label,
3763 basis: basis.into(),
3764 end_line: None,
3765 arg_count: None,
3766 }
3767 }
3768
3769 fn report_of(sites: Vec<LabeledSite>) -> ReferenceReport {
3770 let count = |label| sites.iter().filter(|site| site.label == label).count();
3771 ReferenceReport {
3772 target: ReferenceTarget {
3773 symbol_id: "t1".into(),
3774 name: "from_mapping".into(),
3775 display_name: "Config.from_mapping".into(),
3776 kind: "method".into(),
3777 path: "src/flask/config.py".into(),
3778 line: 304,
3779 definitions_with_name: 1,
3780 },
3781 counts: ReferenceCounts {
3782 sites: sites.len(),
3783 proven: count(SiteLabel::Proven),
3784 likely: count(SiteLabel::Likely),
3785 unresolved: count(SiteLabel::Unresolved),
3786 excluded: count(SiteLabel::Excluded),
3787 text_lines: sites.len(),
3788 site_lines: sites.len(),
3789 ..ReferenceCounts::default()
3790 },
3791 sites,
3792 text_only: Vec::new(),
3793 text_scan: TextScanSummary {
3794 files_scanned: 12,
3795 files_total: 12,
3796 complete: true,
3797 elapsed_ms: 40,
3798 skipped: None,
3799 },
3800 cannot_see: Vec::new(),
3801 imports: Vec::new(),
3802 overrides: Vec::new(),
3803 overridden: Vec::new(),
3804 constructions: Vec::new(),
3805 variant_uses: Vec::new(),
3806 registrations: Vec::new(),
3807 tests: Vec::new(),
3808 }
3809 }
3810
3811 #[test]
3812 fn the_report_header_has_three_lines_with_plural_counts() {
3813 let mut report = report_of(vec![
3814 labeled_site(
3815 SiteLabel::Proven,
3816 "src/flask/config.py",
3817 302,
3818 "Config.from_file",
3819 "same file",
3820 ),
3821 labeled_site(
3822 SiteLabel::Likely,
3823 "tests/test_config.py",
3824 112,
3825 "test_config",
3826 "name only",
3827 ),
3828 ]);
3829 report.counts.text_lines = 17;
3830 report.counts.site_lines = 2;
3831 report.counts.definition_lines = 2;
3832 report.counts.import_lines = 0;
3833 report.counts.text_only_lines = 13;
3834
3835 let text = format_reference_report(&report, 20);
3836
3837 assert_eq!(
3838 text.lines().take(3).collect::<Vec<_>>(),
3839 [
3840 "References to method `Config.from_mapping` (src/flask/config.py:304)",
3841 "Sites: 2 · proven 1 · likely 1 · unresolved 0 · excluded 0",
3842 "Text: `from_mapping` is on 17 lines: 2 sites, 2 definitions, 0 imports, 13 text only.",
3843 ]
3844 );
3845 }
3846
3847 #[test]
3848 fn the_report_header_uses_singular_forms_for_a_count_of_one() {
3849 let mut report = report_of(vec![labeled_site(
3850 SiteLabel::Proven,
3851 "a.py",
3852 3,
3853 "caller",
3854 "same file",
3855 )]);
3856 report.counts.text_lines = 1;
3857 report.counts.site_lines = 1;
3858 report.counts.definition_lines = 1;
3859 report.counts.import_lines = 1;
3860 report.counts.text_only_lines = 0;
3861
3862 let text = format_reference_report(&report, 20);
3863
3864 assert_eq!(
3865 text.lines().nth(2),
3866 Some("Text: `from_mapping` is on 1 line: 1 site, 1 definition, 1 import, 0 text only.")
3867 );
3868 }
3869
3870 #[test]
3871 fn the_report_lists_each_group_in_label_order_with_its_basis() {
3872 let report = report_of(vec![
3873 labeled_site(SiteLabel::Proven, "a.py", 2, "first", "same file"),
3874 labeled_site(SiteLabel::Likely, "l.py", 6, "second", "name only"),
3875 labeled_site(SiteLabel::Unresolved, "u.py", 4, "third", "3 definitions"),
3876 labeled_site(SiteLabel::Excluded, "x.py", 9, "other", "other language"),
3877 ]);
3878
3879 let text = format_reference_report(&report, 20);
3880
3881 let body = text.split_once("\n\n").unwrap().1;
3882 assert_eq!(
3883 body,
3884 "Proven (1):\n\
3885 - `first` [a.py:2] (kind: calls; same file)\n\
3886 Likely (1):\n\
3887 - `second` [l.py:6] (kind: calls; name only)\n\
3888 Unresolved (1):\n\
3889 - `third` [u.py:4] (kind: calls; 3 definitions)\n\
3890 Excluded (1):\n\
3891 - `other` [x.py:9] (kind: calls; other language)\n"
3892 );
3893 }
3894
3895 #[test]
3896 fn a_group_with_no_sites_is_left_out() {
3897 let report = report_of(vec![labeled_site(
3898 SiteLabel::Likely,
3899 "l.py",
3900 6,
3901 "second",
3902 "name only",
3903 )]);
3904
3905 let text = format_reference_report(&report, 20);
3906
3907 assert!(!text.contains("Proven ("), "{text}");
3908 assert!(!text.contains("Excluded ("), "{text}");
3909 assert!(text.contains("Likely (1):\n"), "{text}");
3910 }
3911
3912 #[test]
3913 fn a_report_without_sites_has_only_the_header() {
3914 let report = report_of(Vec::new());
3915
3916 let text = format_reference_report(&report, 20);
3917
3918 assert_eq!(
3919 text,
3920 "References to method `Config.from_mapping` (src/flask/config.py:304)\n\
3921 Sites: 0 · proven 0 · likely 0 · unresolved 0 · excluded 0\n\
3922 Text: `from_mapping` is on 0 lines: 0 sites, 0 definitions, 0 imports, 0 text only.\n"
3923 );
3924 }
3925
3926 #[test]
3927 fn caller_names_follow_the_lambda_and_no_enclosing_symbol_rules() {
3928 let mut lambda = labeled_site(SiteLabel::Proven, "a.py", 1, "lambda_7", "same file");
3929 lambda.site.enclosing_symbol_name = Some("outer".into());
3930 let callerless = labeled_site(SiteLabel::Proven, "a.py", 2, "", "same file");
3931 let report = report_of(vec![lambda, callerless]);
3932
3933 let text = format_reference_report(&report, 20);
3934
3935 assert!(
3936 text.contains("- `<lambda> in outer` [a.py:1] (kind: calls; same file)\n"),
3937 "{text}"
3938 );
3939 assert!(
3940 text.contains("- (no enclosing symbol) [a.py:2] (kind: calls; same file)\n"),
3941 "{text}"
3942 );
3943 }
3944
3945 #[test]
3946 fn rows_past_the_limit_fold_into_one_line_per_label_with_per_file_counts() {
3947 let mut sites = vec![labeled_site(
3948 SiteLabel::Proven,
3949 "a.py",
3950 1,
3951 "p1",
3952 "same file",
3953 )];
3954 for (index, path) in ["b.py", "b.py", "b.py", "c.py", "c.py", "d.py"]
3955 .iter()
3956 .enumerate()
3957 {
3958 sites.push(labeled_site(
3959 SiteLabel::Likely,
3960 path,
3961 10 + index,
3962 "caller",
3963 "name only",
3964 ));
3965 }
3966 sites.push(labeled_site(
3967 SiteLabel::Excluded,
3968 "z.py",
3969 1,
3970 "other",
3971 "other language",
3972 ));
3973 let report = report_of(sites);
3974
3975 let text = format_reference_report(&report, 3);
3976
3977 let body = text.split_once("\n\n").unwrap().1;
3978 assert_eq!(
3979 body,
3980 "Proven (1):\n\
3981 - `p1` [a.py:1] (kind: calls; same file)\n\
3982 Likely (6):\n\
3983 - `caller` [b.py:10] (kind: calls; name only)\n\
3984 - `caller` [b.py:11] (kind: calls; name only)\n\
3985 … 4 more likely sites in 3 files: c.py (2), b.py (1), d.py (1)\n\
3986 Excluded (1):\n\
3987 … 1 more excluded site in 1 file: z.py (1)\n"
3988 );
3989 }
3990
3991 #[test]
3992 fn a_fold_line_names_at_most_five_files() {
3993 let sites = ["a", "b", "c", "d", "e", "f", "g"]
3994 .iter()
3995 .map(|stem| {
3996 labeled_site(
3997 SiteLabel::Likely,
3998 &format!("{stem}.py"),
3999 1,
4000 "caller",
4001 "name only",
4002 )
4003 })
4004 .collect();
4005 let report = report_of(sites);
4006
4007 let text = format_reference_report(&report, 1);
4008
4009 assert!(
4010 text.contains(
4011 "… 6 more likely sites in 6 files: b.py (1), c.py (1), d.py (1), e.py (1), f.py (1), …\n"
4012 ),
4013 "{text}"
4014 );
4015 }
4016
4017 #[test]
4018 fn text_only_lines_list_per_file_and_fold_past_ten_files_and_twelve_lines() {
4019 let mut report = report_of(Vec::new());
4020 report.text_only = (0..12)
4021 .map(|index| TextOnlyLines {
4022 path: format!("docs/f{index:02}.rst"),
4023 lines: if index == 0 {
4024 (1..=15).collect()
4025 } else {
4026 vec![index + 1, index + 2]
4027 },
4028 })
4029 .collect();
4030 report.counts.text_only_lines = report.text_only.iter().map(|f| f.lines.len()).sum();
4031
4032 let text = format_reference_report(&report, 20);
4033
4034 let section = text.split_once("Text only (").unwrap().1;
4035 let mut lines = section.lines();
4036 assert_eq!(
4037 lines.next(),
4038 Some("37): docs, comments, strings, or code the index did not record")
4039 );
4040 assert_eq!(
4041 lines.next(),
4042 Some(" docs/f00.rst: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12")
4043 );
4044 assert_eq!(lines.next(), Some(" docs/f01.rst: 2, 3"));
4045 assert_eq!(lines.nth(7), Some(" docs/f09.rst: 10, 11"));
4046 assert_eq!(lines.next(), Some(" … 7 more lines in 3 files"));
4047 assert_eq!(lines.next(), None);
4048 }
4049
4050 #[test]
4051 fn a_text_scan_cut_by_its_budget_adds_the_stopped_line() {
4052 let mut report = report_of(Vec::new());
4053 report.text_scan = TextScanSummary {
4054 files_scanned: 410,
4055 files_total: 17334,
4056 complete: false,
4057 elapsed_ms: 3012,
4058 skipped: None,
4059 };
4060
4061 let text = format_reference_report(&report, 20);
4062
4063 assert_eq!(
4064 text.lines().nth(3),
4065 Some("Text scan stopped after 3 s: 410 of 17334 files read; counts cover those files.")
4066 );
4067 }
4068
4069 #[test]
4070 fn a_registration_without_a_verb_or_template_or_route_pattern_prints_what_it_has() {
4071 let registration =
4072 |pattern_id: &str, verb: Option<&str>, template: Option<&str>| Registration {
4073 pattern_id: pattern_id.into(),
4074 verb: verb.map(Into::into),
4075 template: template.map(Into::into),
4076 path: "app.py".into(),
4077 line: 3,
4078 by_name: false,
4079 };
4080 let mut report = report_of(Vec::new());
4081 report.registrations = vec![
4082 registration("flask.route.v1", Some("GET"), Some("/a")),
4083 registration("axum.route.v1", None, Some("/b")),
4084 registration("hapi.route.v1", Some("PUT"), None),
4085 registration("rocket.mount.v1", Some("GET"), Some("/c")),
4086 ];
4087
4088 let text = format_reference_report(&report, 20);
4089
4090 assert!(
4091 text.contains(
4092 "Registrations (4):\n\
4093 - route GET /a [app.py:3]\n\
4094 - route /b [app.py:3]\n\
4095 - route PUT hapi.route.v1 [app.py:3]\n\
4096 - rocket.mount.v1 [app.py:3]\n"
4097 ),
4098 "{text}"
4099 );
4100 }
4101
4102 #[test]
4103 fn a_constructor_report_replaces_the_text_line_with_the_skip_line() {
4104 let mut report = report_of(Vec::new());
4105 report.text_scan.skipped = Some("Flask".into());
4106
4107 let text = format_reference_report(&report, 20);
4108
4109 assert_eq!(
4110 text.lines().nth(2),
4111 Some(
4112 "Text: skipped for a constructor; its call sites spell the class name. Use find_references on `Flask`."
4113 )
4114 );
4115 assert!(!text.contains("is on"), "{text}");
4116 assert!(!text.contains("Text scan stopped"), "{text}");
4117 }
4118
4119 #[test]
4120 fn the_report_ends_with_the_blind_spots_and_the_import_summary() {
4121 let mut report = report_of(vec![labeled_site(
4122 SiteLabel::Proven,
4123 "a.py",
4124 2,
4125 "first",
4126 "same file",
4127 )]);
4128 report.cannot_see = vec![
4129 "calls through getattr() or names built at runtime".into(),
4130 "calls inside macro bodies".into(),
4131 ];
4132 report.imports = vec![("b.py".into(), 1), ("c.py".into(), 4)];
4133
4134 let text = format_reference_report(&report, 20);
4135
4136 assert!(
4137 text.ends_with(
4138 "Not visible to this index: calls through getattr() or names built at runtime; calls inside macro bodies.\n\
4139 Imported 2 times in 2 files: b.py:1, c.py:4 (lookup_symbol with kind=\"import\" lists them)\n"
4140 ),
4141 "{text}"
4142 );
4143 }
4144}