1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use crate::models::{
5 BlastRadiusResult, ContextSlice, ImpactedSymbol, ReferenceSite, SearchExplain, Symbol,
6 SymbolSearchResult, TestTarget,
7};
8
9pub fn format_file_skeleton(
11 file_path: &str,
12 symbols: &[Symbol],
13 line_count: Option<usize>,
14 parse_errors: usize,
15) -> String {
16 let mut out = String::new();
17 let lines_str = match line_count {
18 Some(c) => format!(" (Lines 1-{c})"),
19 None => String::new(),
20 };
21 out.push_str(&format!("// File: {file_path}{lines_str}\n\n"));
22
23 if parse_errors > 0 {
24 let noun = if parse_errors == 1 { "error" } else { "errors" };
25 out.push_str(&format!(
26 "// {parse_errors} parse {noun}: symbols may be incomplete\n\n"
27 ));
28 }
29
30 if symbols.is_empty() {
31 out.push_str("// No exported symbols indexed.\n");
32 return out;
33 }
34
35 let mut children_map: HashMap<Option<String>, Vec<&Symbol>> = HashMap::new();
37 for s in symbols {
38 children_map
39 .entry(s.parent_symbol_id.clone())
40 .or_default()
41 .push(s);
42 }
43
44 if let Some(roots) = children_map.get(&None) {
46 for root in roots {
47 render_symbol_skeleton(&mut out, root, None, &children_map, 0);
48 }
49 } else {
50 for s in symbols {
52 render_symbol_skeleton(&mut out, s, None, &children_map, 0);
53 }
54 }
55
56 out
57}
58
59fn is_container_kind(kind: &str) -> bool {
60 let hides_its_body = matches!(
61 kind,
62 "function" | "method" | "constructor" | "destructor" | "operator"
63 );
64 !is_skippable_kind(kind) && !hides_its_body
65}
66
67pub(crate) fn display_kind(sym: &Symbol) -> &str {
71 match (sym.language.as_str(), sym.kind.as_str()) {
72 ("html", "class") => "element",
73 ("sql", "class") => "table",
74 ("markdown", "module") => "section",
75 ("markdown", "import") => "link",
76 (language, "property")
77 if language != "fsharp"
78 && sym.signature.as_deref().is_some_and(|sig| {
79 ["self.", "this.", "cls."]
80 .iter()
81 .any(|p| sig.starts_with(p))
82 }) =>
83 {
84 "attribute"
85 }
86 (_, kind) => kind,
87 }
88}
89
90fn is_skippable_kind(kind: &str) -> bool {
91 matches!(kind, "variable" | "parameter" | "import")
92}
93
94fn value_row_signature(sig: &str) -> std::borrow::Cow<'_, str> {
97 let sig = sig.trim_end().trim_end_matches(';').trim_end();
98 if !sig.contains('\n') && sig.chars().count() <= 120 {
99 return sig.into();
100 }
101 let joined = one_line(sig);
102 let Some(equals) = value_equals(&joined) else {
103 if joined.chars().count() <= 120 {
104 return joined.into();
105 }
106 let cut: String = joined.chars().take(119).collect();
107 return format!("{}…", cut.trim_end()).into();
108 };
109 let (declaration, value) = joined.split_at(equals);
110 let budget = 120usize.saturating_sub(declaration.chars().count()).max(40);
111 if value.chars().count() <= budget {
112 return joined.into();
113 }
114 let cut: String = value.chars().take(budget - 1).collect();
115 format!("{declaration}{}…", cut.trim_end()).into()
116}
117
118fn one_line(sig: &str) -> String {
120 let mut joined = sig
121 .lines()
122 .map(|line| {
123 let code = [" # ", " // "]
124 .iter()
125 .filter_map(|marker| line.find(marker))
126 .min()
127 .map_or(line, |at| &line[..at]);
128 code.trim().trim_end_matches('\\').trim_end()
129 })
130 .filter(|line| !line.is_empty())
131 .collect::<Vec<_>>()
132 .join(" ");
133 for (from, to) in [
134 ("( ", "("),
135 ("[ ", "["),
136 ("{ ", "{"),
137 (", )", ")"),
138 (", ]", "]"),
139 (", }", "}"),
140 (" )", ")"),
141 (" ]", "]"),
142 (" }", "}"),
143 ] {
144 joined = joined.replace(from, to);
145 }
146 joined
147}
148
149fn assigns_a_value(sig: &str) -> bool {
152 value_equals(sig).is_some()
153}
154
155fn value_equals(sig: &str) -> Option<usize> {
157 let bytes = sig.as_bytes();
158 let mut depth = 0usize;
159 for (i, &byte) in bytes.iter().enumerate() {
160 match byte {
161 b'(' | b'[' => depth += 1,
162 b')' | b']' => depth = depth.saturating_sub(1),
163 b'{' if depth == 0 => return None,
164 b'=' if depth == 0 => {
165 let before = i.checked_sub(1).map(|j| bytes[j]);
166 let after = bytes.get(i + 1).copied();
167 let operator_before = before.is_some_and(|b| b"<>!=^*~|$+-/%&:?".contains(&b));
168 let operator_after = after.is_some_and(|b| b == b'=' || b == b'>');
169 if !operator_before && !operator_after {
170 return Some(i);
171 }
172 }
173 _ => {}
174 }
175 }
176 None
177}
178
179fn sanitize_skeleton_sig<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
180 if matches!(
181 sym.kind.as_str(),
182 "variable" | "constant" | "property" | "field" | "enum_member"
183 ) && assigns_a_value(sig)
184 {
185 return value_row_signature(sig);
186 }
187 container_signature(sig, sym)
188}
189
190fn container_signature<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
192 let expression_arrow = if sym.language == "csharp"
193 && matches!(
194 sym.kind.as_str(),
195 "method" | "function" | "constructor" | "operator"
196 ) {
197 sig.match_indices("=>")
198 .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
199 } else {
200 None
201 };
202 let cutoff = sig
203 .find('{')
204 .into_iter()
205 .chain(expression_arrow)
206 .min()
207 .unwrap_or(sig.len());
208 let clean = sig[..cutoff].trim_end();
209 let trimmed = clean.trim_end_matches(';').trim_end();
210 if trimmed.is_empty() {
211 sym.name.as_str().into()
212 } else {
213 trimmed.into()
214 }
215}
216
217fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
218 let body = body.trim();
219 if body.is_empty() {
220 return signature;
221 }
222
223 let trimmed_signature = signature.trim_end();
224 trimmed_signature
225 .strip_suffix(body)
226 .map(str::trim_end)
227 .unwrap_or(signature)
228}
229
230fn unspelled_kind(sym: &Symbol) -> &'static str {
234 if sym.kind != "event" {
235 return "";
236 }
237 let spelled = sym.signature.as_deref().is_some_and(|sig| {
238 sig.split_whitespace()
239 .any(|word| word == "signal" || word == "event")
240 });
241 if spelled { "" } else { "event " }
242}
243
244fn assigning_method(
247 sym: &Symbol,
248 parent: Option<&Symbol>,
249 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
250) -> String {
251 if !sym
252 .signature
253 .as_deref()
254 .is_some_and(|sig| sig.starts_with("self."))
255 {
256 return String::new();
257 }
258 parent
259 .and_then(|parent| children_map.get(&Some(parent.symbol_id.clone())))
260 .and_then(|siblings| {
261 siblings.iter().find(|method| {
262 matches!(method.kind.as_str(), "method" | "function" | "constructor")
263 && method.start_line <= sym.start_line
264 && sym.end_line <= method.end_line
265 })
266 })
267 .map_or_else(String::new, |method| format!("in {}(), ", method.name))
268}
269
270fn is_shown(sym: &Symbol, parent: Option<&Symbol>) -> bool {
273 let python_declaration = sym.kind == "variable"
274 && sym.language == "python"
275 && parent.is_none_or(|parent| parent.kind == "class");
276 !is_skippable_kind(&sym.kind) || python_declaration
277}
278
279fn is_misparsed_c_function(child: &Symbol) -> bool {
284 matches!(child.language.as_str(), "c" | "cpp")
285 && matches!(child.kind.as_str(), "function" | "method")
286 && !child.is_test
287 && !child.test_container
288}
289
290fn nested_definitions(children: &[&Symbol]) -> String {
291 let names: Vec<String> = children
292 .iter()
293 .filter(|child| {
294 matches!(
295 child.kind.as_str(),
296 "function" | "method" | "class" | "struct"
297 )
298 })
299 .filter(|child| {
300 !child
301 .signature
302 .as_deref()
303 .is_some_and(|sig| sig.starts_with("lambda"))
304 })
305 .filter(|child| !is_misparsed_c_function(child))
306 .map(|child| format!("`{}`", child.name))
307 .fold(Vec::new(), |mut names, name| {
308 if !names.contains(&name) {
309 names.push(name);
310 }
311 names
312 });
313 match names.len() {
314 0 => String::new(),
315 1..=5 => format!("; defines {}", names.join(", ")),
316 n => format!("; defines {}, +{} more", names[..5].join(", "), n - 5),
317 }
318}
319
320fn render_symbol_skeleton(
321 out: &mut String,
322 sym: &Symbol,
323 parent: Option<&Symbol>,
324 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
325 indent_level: usize,
326) {
327 if !is_shown(sym, parent) {
328 return;
329 }
330
331 let indent = " ".repeat(indent_level);
332
333 if let Some(ref doc) = sym.doc_comment {
335 let lines: Vec<_> = doc.lines().collect();
336 let cap = 3;
337 for line in lines.iter().take(cap) {
338 out.push_str(&format!("{indent}/// {line}\n"));
339 }
340 if lines.len() > cap {
341 out.push_str(&format!(
342 "{indent}/// ... ({} more lines)\n",
343 lines.len() - cap
344 ));
345 }
346 }
347
348 let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
349 let leaf_note = format!(
350 "{}{}{span_str}",
351 unspelled_kind(sym),
352 assigning_method(sym, parent, children_map)
353 );
354
355 let children = children_map.get(&Some(sym.symbol_id.clone()));
356
357 let spans_multiple_lines = sym.end_line > sym.start_line;
358
359 if is_container_kind(&sym.kind)
360 && children.is_some_and(|list| list.iter().any(|child| is_shown(child, Some(sym))))
361 && (spans_multiple_lines || sym.kind == "enum")
362 {
363 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
364 let sig = container_signature(raw_sig, sym);
365 out.push_str(&format!("{indent}{sig} {{\n"));
366 if let Some(child_list) = children {
367 for child in child_list {
368 render_symbol_skeleton(out, child, Some(sym), children_map, indent_level + 1);
369 }
370 }
371 out.push_str(&format!("{indent}}} // {span_str}\n\n"));
372 } else {
373 if let Some(count) = sym.hidden_body_line_count() {
374 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
375 let sig = if count > 1 {
376 container_signature(raw_sig, sym)
377 } else {
378 sanitize_skeleton_sig(raw_sig, sym)
379 };
380 let b_start = sym.body_start_line.unwrap_or(sym.start_line);
381 let b_end = sym.body_end_line.unwrap_or(sym.end_line);
382
383 if count > 1 {
384 let defines = nested_definitions(children.map(Vec::as_slice).unwrap_or_default());
385 out.push_str(&format!(
386 "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end}{defines} */ }}\n"
387 ));
388 } else {
389 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
390 }
391 } else if let Some(ref raw_sig) = sym.signature {
392 let sig = sanitize_skeleton_sig(raw_sig, sym);
393 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
394 } else {
395 out.push_str(&format!(
396 "{indent}{} {sym_name}; // {span_str}\n",
397 display_kind(sym),
398 sym_name = sym.name
399 ));
400 }
401 if sym.kind == "namespace"
402 && let Some(child_list) = children
403 {
404 for child in child_list {
405 render_symbol_skeleton(out, child, Some(sym), children_map, indent_level);
406 }
407 }
408 }
409}
410
411#[derive(Default)]
413pub struct OutlineNode {
414 pub files: BTreeMap<String, Vec<String>>, pub subdirs: BTreeMap<String, OutlineNode>,
416 pub hidden_files: usize,
418 pub plain_files: Vec<String>,
420 pub unsupported_files: usize,
422}
423
424fn outline_components(file_path: &str, norm_filter: &str) -> Option<Vec<String>> {
427 let normalized = file_path.replace('\\', "/");
428 let rel_path_str = if norm_filter.is_empty() {
429 normalized.as_str()
430 } else if normalized.eq_ignore_ascii_case(norm_filter) {
431 Path::new(&normalized)
432 .file_name()
433 .and_then(|n| n.to_str())
434 .unwrap_or(&normalized)
435 } else if normalized.len() > norm_filter.len()
436 && normalized.as_bytes()[norm_filter.len()] == b'/'
437 && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
438 {
439 &normalized[norm_filter.len() + 1..]
440 } else {
441 return None;
442 };
443
444 let path = Path::new(rel_path_str);
445 let components: Vec<String> = path
446 .components()
447 .map(|c| c.as_os_str().to_str().unwrap_or(""))
448 .filter(|s| !s.is_empty())
449 .map(str::to_string)
450 .collect();
451
452 if components.is_empty() {
453 return None;
454 }
455
456 Some(components)
457}
458
459pub fn add_unsupported_to_outline(
462 root_node: &mut OutlineNode,
463 file_path: &str,
464 max_depth: usize,
465 norm_filter: &str,
466) {
467 let Some(components) = outline_components(file_path, norm_filter) else {
468 return;
469 };
470 let mut curr = root_node;
471 for (i, comp) in components
472 .iter()
473 .take(components.len() - 1)
474 .take(max_depth)
475 .enumerate()
476 {
477 if i > 0 && !curr.subdirs.contains_key(comp) {
478 break;
479 }
480 curr = curr.subdirs.entry(comp.clone()).or_default();
481 }
482 curr.unsupported_files += 1;
483}
484
485pub fn add_path_to_outline(
487 root_node: &mut OutlineNode,
488 file_path: &str,
489 symbols_by_file: &HashMap<String, Vec<Symbol>>,
490 counts: &HashMap<String, crate::queries::OutlineCounts>,
491 max_depth: usize,
492 norm_filter: &str,
493) {
494 let Some(components) = outline_components(file_path, norm_filter) else {
495 return;
496 };
497 let normalized = file_path.replace('\\', "/");
498
499 let mut curr = root_node;
500 let depth = components.len();
501
502 for (i, comp) in components.iter().enumerate() {
503 if i == depth - 1 {
504 if depth > max_depth {
505 curr.hidden_files += 1;
506 } else {
507 let mut sym_tags: Vec<String> = symbols_by_file
508 .get(&normalized)
509 .into_iter()
510 .flatten()
511 .filter(|s| !s.is_test && !s.test_container)
512 .take(5)
513 .map(|s| format!("{} {}", display_kind(s), s.name))
514 .collect();
515 let count = counts.get(&normalized).copied().unwrap_or_default();
516 if count.definitions > sym_tags.len() {
517 sym_tags.push(format!("+{} more", count.definitions - sym_tags.len()));
518 }
519 if count.tests > 0 {
520 sym_tags.push(format!("{} {}", count.tests, plural(count.tests, "test")));
521 }
522 if count.fixtures > 0 {
523 sym_tags.push(format!(
524 "{} {}",
525 count.fixtures,
526 plural(count.fixtures, "fixture")
527 ));
528 }
529 if sym_tags.is_empty() {
530 curr.plain_files.push(comp.to_string());
531 } else {
532 curr.files.insert(comp.to_string(), sym_tags);
533 }
534 }
535 } else if i < max_depth {
536 curr = curr.subdirs.entry(comp.to_string()).or_default();
537 } else {
538 curr.hidden_files += 1;
539 break;
540 }
541 }
542}
543
544const FILES_LISTED_PER_FOLDER: usize = 40;
546
547pub fn render_outline_tree(
548 out: &mut String,
549 node: &OutlineNode,
550 prefix: &str,
551 depth: usize,
552 max_depth: usize,
553) {
554 if depth >= max_depth {
555 return;
556 }
557
558 let listed = if depth == 0 {
559 node.files.len()
560 } else {
561 node.files.len().min(FILES_LISTED_PER_FOLDER)
562 };
563 let more_files = node.files.len() - listed;
564 let total_items = node.subdirs.len()
565 + listed
566 + usize::from(more_files > 0)
567 + usize::from(!node.plain_files.is_empty());
568 let mut index = 0;
569
570 for (name, sub) in &node.subdirs {
572 index += 1;
573 let is_last = index == total_items;
574 let branch = if is_last { "└── " } else { "├── " };
575 let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
576
577 let hidden = match (sub.hidden_files, sub.unsupported_files) {
578 (0, 0) => String::new(),
579 (0, m) => format!(" ({m} unsupported {})", plural(m, "file")),
580 (n, 0) => format!(" ({n} indexed {})", plural(n, "file")),
581 (n, m) => format!(" ({n} indexed {}, {m} unsupported)", plural(n, "file")),
582 };
583 out.push_str(&format!("{prefix}{branch}{name}/{hidden}\n"));
584 render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
585 }
586
587 for (file_name, syms) in node.files.iter().take(listed) {
589 index += 1;
590 let is_last = index == total_items;
591 let branch = if is_last { "└── " } else { "├── " };
592
593 let sym_suffix = if !syms.is_empty() {
594 format!(" [{}]", syms.join(", "))
595 } else {
596 String::new()
597 };
598
599 out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
600 }
601
602 if more_files > 0 {
603 index += 1;
604 let branch = if index == total_items {
605 "└── "
606 } else {
607 "├── "
608 };
609 out.push_str(&format!(
610 "{prefix}{branch}(+{more_files} more {} with functions or classes; pass this folder as the path to list them)\n",
611 plural(more_files, "file")
612 ));
613 }
614
615 if !node.plain_files.is_empty() {
616 let count = node.plain_files.len();
617 let names = if count <= 5 {
618 node.plain_files.join(", ")
619 } else {
620 format!("{}, +{} more", node.plain_files[..3].join(", "), count - 3)
621 };
622 out.push_str(&format!(
623 "{prefix}└── ({count} {} without functions or classes: {names})\n",
624 plural(count, "file")
625 ));
626 }
627}
628
629pub fn format_symbol_body(symbol: &Symbol, source: &str) -> String {
632 let mut out = format!(
633 "// {}:{}-{} ({})\n{source}",
634 symbol.path, symbol.start_line, symbol.end_line, symbol.name
635 );
636 if !source.ends_with('\n') {
637 out.push('\n');
638 }
639 out
640}
641
642pub fn format_context_slice(slice: &ContextSlice) -> String {
644 let sym = &slice.target_symbol;
645 let mut out = String::new();
646 out.push_str(&format!(
647 "### Target: `{}` ({}:{}-{})\n\n",
648 sym.name, sym.path, sym.start_line, sym.end_line
649 ));
650
651 if let Some(ref sig) = sym.signature {
652 let sig = signature_without_duplicate_body(sig, &slice.target_body);
653 if !sig.trim().is_empty() {
654 out.push_str(&format!("Signature: `{sig}`\n\n"));
655 }
656 }
657
658 out.push_str(&format!("```{}\n", sym.language));
659 out.push_str(&slice.target_body);
660 if !slice.target_body.ends_with('\n') {
661 out.push('\n');
662 }
663 out.push_str("```\n\n");
664
665 if !slice.callee_signatures.is_empty() {
666 out.push_str("### Dependencies (Signatures):\n");
667 for callee in &slice.callee_signatures {
668 out.push_str(&format!("- {callee}\n"));
669 }
670 if slice.callee_signatures.len() >= 10 {
671 out.push_str("[Showing 10 dependencies (limit reached)]\n");
672 }
673 out.push('\n');
674 }
675
676 if !slice.related_types.is_empty() {
677 out.push_str("### Types:\n");
678 for t in &slice.related_types {
679 out.push_str(&format!("- {t}\n"));
680 }
681 out.push('\n');
682 }
683
684 if !slice.related_tests.is_empty() {
685 out.push_str("### Related Tests:\n");
686 for test in &slice.related_tests {
687 out.push_str(&format!(
688 "- `{}` ({}:{})\n",
689 test.name, test.path, test.start_line
690 ));
691 }
692 if slice.related_tests.len() >= 5 {
693 out.push_str("[Showing 5 tests (limit reached)]\n");
694 }
695 out.push_str("These tests call, use, or name this symbol; blast_radius also lists tests that can reach it through callers.\n\n");
696 } else {
697 out.push_str(
698 "### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n",
699 );
700 }
701
702 out
703}
704
705fn cap_notice(shown: usize, limit: usize) -> String {
706 let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
707 "narrow the query to see more"
708 } else {
709 "increase limit to see more"
710 };
711 format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
712}
713
714fn match_line(snippet: &str, signature: &str) -> Option<String> {
717 let line = snippet
718 .lines()
719 .enumerate()
720 .map(|(i, text)| match i {
721 0 => text.trim(),
722 _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
723 })
724 .flat_map(str::split_whitespace)
725 .collect::<Vec<_>>()
726 .join(" ");
727 let plain = |text: &str| {
728 text.split_whitespace()
729 .collect::<String>()
730 .replace(['[', ']'], "")
731 };
732 let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
733 (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
734}
735
736fn callee_name(name: &str) -> String {
739 let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
740 if line.chars().count() <= 60 || !line.contains('(') {
741 return line;
742 }
743 match line.rsplit_once('.') {
744 Some((_, last)) if !last.is_empty() => format!("….{last}"),
745 _ => line,
746 }
747}
748
749pub fn format_import_summary(sites: &[(String, usize)]) -> String {
752 if sites.is_empty() {
753 return String::new();
754 }
755 let files: std::collections::BTreeSet<&str> =
756 sites.iter().map(|(path, _)| path.as_str()).collect();
757 let first: Vec<String> = sites
758 .iter()
759 .take(3)
760 .map(|(path, line)| format!("{path}:{line}"))
761 .collect();
762 let more = if sites.len() > 3 { ", …" } else { "" };
763 format!(
764 "Imported {} {} in {} {}: {}{more} (lookup_symbol with kind=\"import\" lists them)\n",
765 sites.len(),
766 plural(sites.len(), "time"),
767 files.len(),
768 plural(files.len(), "file"),
769 first.join(", ")
770 )
771}
772
773pub fn format_references(
774 target_name: &str,
775 refs: &[ReferenceSite],
776 direction: &str,
777 limit: usize,
778) -> String {
779 let mut out = String::new();
780 let dir_label = if direction == "callers" {
781 "Callers of"
782 } else {
783 "Callees called by"
784 };
785 out.push_str(&format!(
786 "{dir_label} `{target_name}` ({} found):\n",
787 refs.len()
788 ));
789
790 if refs.is_empty() {
791 out.push_str(" (none)\n");
792 return out;
793 }
794
795 for r in refs {
796 let line_info = match r.start_line {
797 Some(l) => format!(":{l}"),
798 None => String::new(),
799 };
800 let other = if direction == "callers" {
801 r.from_symbol_name.clone()
802 } else {
803 callee_name(&r.to_symbol_name)
804 };
805 let in_file = match r.occurrences {
806 Some(n) => format!(", {n} in file"),
807 None => String::new(),
808 };
809 let target = match &r.target {
810 Some(target) if direction != "callers" => format!(" → {target}"),
811 _ => String::new(),
812 };
813 out.push_str(&format!(
814 "- `{other}` [{}{line_info}] (kind: {}{in_file}){target}\n",
815 r.path, r.kind
816 ));
817 }
818
819 if refs.len() >= limit {
820 out.push_str(&cap_notice(refs.len(), limit));
821 }
822
823 out
824}
825
826fn other_names_line(query: &str, others: &[&Symbol]) -> String {
830 let mut groups: Vec<(String, &str, usize)> = Vec::new();
831 for s in others {
832 let kind = display_kind(s);
833 match groups
834 .iter_mut()
835 .find(|(name, k, _)| *name == s.name && *k == kind)
836 {
837 Some(group) => group.2 += 1,
838 None => groups.push((s.name.clone(), kind, 1)),
839 }
840 }
841 let listed: Vec<String> = groups
842 .iter()
843 .take(8)
844 .map(|(name, kind, count)| match count {
845 1 => format!("`{name}` ({kind})"),
846 n => format!("`{name}` ({kind}, {n}×)"),
847 })
848 .collect();
849 let more = match groups.len().saturating_sub(8) {
850 0 => String::new(),
851 n => format!(", +{n} more"),
852 };
853 format!(
854 "- {} other {} `{query}`, ignoring case: {}{more} (lookup_symbol with one of these names lists its rows)\n",
855 others.len(),
856 if others.len() == 1 {
857 "row starts with or contains"
858 } else {
859 "rows start with or contain"
860 },
861 listed.join(", ")
862 )
863}
864
865pub fn format_find_symbol_results(
866 query: &str,
867 exact_matches: &[Symbol],
868 fts_matches: &[SymbolSearchResult],
869 limit: usize,
870) -> String {
871 if !exact_matches.is_empty() {
872 let named_exactly = exact_matches
873 .iter()
874 .filter(|s| s.name == query || s.name.ends_with(&format!(".{query}")))
875 .count();
876 let exact_note = if named_exactly < exact_matches.len() {
877 format!(" ({named_exactly} named exactly `{query}`; the rest start with or contain it)")
878 } else {
879 String::new()
880 };
881 let owner = query
882 .strip_suffix("::")
883 .or_else(|| query.strip_suffix('.'))
884 .filter(|owner| !owner.is_empty());
885 let mut out = match owner {
886 Some(owner) => format!("Found {} members of `{owner}`:\n\n", exact_matches.len()),
887 None => format!(
888 "Found {} symbols matching \"{query}\"{exact_note}:\n\n",
889 exact_matches.len()
890 ),
891 };
892 let has_definition = exact_matches.iter().any(|s| s.kind != "import");
893 let folds = |s: &Symbol| crate::queries::folds_into_import_line(query, s, has_definition);
894 let imports: Vec<&Symbol> = exact_matches.iter().filter(|s| folds(s)).collect();
895 let is_exact = |s: &Symbol| s.name == query || s.name.ends_with(&format!(".{query}"));
896 let (shown, others): (Vec<&Symbol>, Vec<&Symbol>) = exact_matches
897 .iter()
898 .filter(|s| !folds(s))
899 .partition(|s| named_exactly == 0 || is_exact(s));
900 for s in shown {
901 let sig = s.signature.as_deref().unwrap_or(&s.name);
902 out.push_str(&format!(
903 "- {} `{}` [{}:{}-{}] id={}\n",
904 display_kind(s),
905 s.name,
906 s.path,
907 s.start_line,
908 s.end_line,
909 s.symbol_id
910 ));
911 out.push_str(&format!(" Signature: {sig}\n"));
912 if let Some(doc) = &s.doc_comment {
913 let first = doc.lines().next().unwrap_or("").trim();
914 if !first.is_empty() {
915 out.push_str(&format!(" Doc: {first}\n"));
916 }
917 }
918 }
919 if !imports.is_empty() {
920 let shown = imports
921 .iter()
922 .take(3)
923 .map(|s| format!("{}:{}", s.path, s.start_line))
924 .collect::<Vec<_>>()
925 .join(", ");
926 let more = if imports.len() > 3 { ", …" } else { "" };
927 let cut_inside_exact_names = exact_matches
928 .last()
929 .is_some_and(|s| s.name.eq_ignore_ascii_case(query) || folds(s));
930 let capped = if exact_matches.len() - imports.len() >= limit && cut_inside_exact_names {
931 "at least "
932 } else {
933 ""
934 };
935 out.push_str(&format!(
936 "- {capped}{} {} of `{query}`: {shown}{more} (lookup_symbol with kind=\"import\" lists them)\n",
937 imports.len(),
938 plural(imports.len(), "import")
939 ));
940 }
941 if !others.is_empty() {
942 out.push_str(&other_names_line(query, &others));
943 }
944 if exact_matches.len() - imports.len() >= limit {
945 out.push_str(&cap_notice(exact_matches.len() - imports.len(), limit));
946 }
947 out
948 } else if !fts_matches.is_empty() {
949 let mut out = format!(
950 "No exact name match; {} full-text matches for \"{query}\":\n\n",
951 fts_matches.len()
952 );
953 for r in fts_matches {
954 let s = &r.symbol;
955 let sig = s.signature.as_deref().unwrap_or(&s.name);
956 out.push_str(&format!(
957 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
958 display_kind(s),
959 s.name,
960 s.path,
961 s.start_line,
962 s.end_line,
963 r.score,
964 s.symbol_id
965 ));
966 out.push_str(&format!(" Signature: {sig}\n"));
967 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
968 out.push_str(&format!(" Match: {line}\n"));
969 } else if let Some(doc) = &s.doc_comment {
970 let first = doc.lines().next().unwrap_or("").trim();
971 if !first.is_empty() {
972 out.push_str(&format!(" Doc: {first}\n"));
973 }
974 }
975 }
976 if fts_matches.len() >= limit {
977 out.push_str(&cap_notice(fts_matches.len(), limit));
978 }
979 out
980 } else {
981 format!("No symbols found matching \"{query}\".\n")
982 }
983}
984
985pub fn no_facts_heading(category: &str, path_filter: Option<&str>) -> String {
988 match path_filter {
989 Some(path) => format!("No facts match '{category}' under `{path}`."),
990 None => format!("No facts match '{category}' in this repository."),
991 }
992}
993
994pub fn format_no_facts(
998 category: &str,
999 path_filter: Option<&str>,
1000 categories: &[(String, usize)],
1001) -> String {
1002 let mut out = format!("{}\n\n", no_facts_heading(category, path_filter));
1003 if !crate::queries::is_category_alias(category) {
1004 out.push_str(&format_fact_categories(categories));
1005 return out;
1006 }
1007 if matches!(category.to_ascii_lowercase().as_str(), "import" | "imports") {
1008 out.push_str("Most languages record imports as symbols: lookup_symbol with kind=\"import\" lists them.\n\n");
1009 }
1010 out.push_str(&alias_summary(categories));
1011 out
1012}
1013
1014fn alias_summary(categories: &[(String, usize)]) -> String {
1015 let aliases = crate::queries::alias_fact_counts(categories);
1016 if aliases.is_empty() {
1017 return String::new();
1018 }
1019 let parts: Vec<String> = aliases
1020 .iter()
1021 .map(|(alias, patterns, facts)| {
1022 format!(
1023 "{alias} ({patterns} {}, {facts} {})",
1024 plural(*patterns, "pattern"),
1025 plural(*facts, "fact")
1026 )
1027 })
1028 .collect();
1029 format!("Aliases: {}\n\n", parts.join(", "))
1030}
1031
1032pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
1033 if categories.is_empty() {
1034 return "No structural facts or literals indexed in this repository.".to_string();
1035 }
1036 let mut out = String::new();
1037 out.push_str(&alias_summary(categories));
1038 let (patterns, literal_kinds): (Vec<_>, Vec<_>) = categories
1040 .iter()
1041 .filter(|(name, _)| !name.ends_with(".decorated_definition.v1"))
1042 .partition(|(name, _)| name.contains('.'));
1043 out.push_str(&format!(
1044 "Available structural fact categories ({} found):\n\n",
1045 patterns.len()
1046 ));
1047 for (name, count) in &patterns {
1048 out.push_str(&format!(
1049 "- `{name}` ({count} {})\n",
1050 plural(*count, "fact")
1051 ));
1052 }
1053 if !literal_kinds.is_empty() {
1054 out.push_str("\nString literal kinds (the same command lists the literals of a kind):\n\n");
1055 for (name, count) in &literal_kinds {
1056 out.push_str(&format!(
1057 "- `{name}` ({count} {})\n",
1058 plural(*count, "literal")
1059 ));
1060 }
1061 }
1062 out
1063}
1064
1065fn plural(count: usize, word: &str) -> String {
1066 if count == 1 {
1067 word.to_string()
1068 } else {
1069 format!("{word}s")
1070 }
1071}
1072
1073pub fn format_structural_facts(
1075 facts: &[crate::models::StructuralFact],
1076 literals: &[crate::models::LiteralFact],
1077 category: &str,
1078 limit: usize,
1079) -> String {
1080 let mut out = format!(
1081 "Structural facts for '{category}' ({} found):\n",
1082 facts.len()
1083 );
1084 for f in facts {
1085 let label = f.key.as_deref().unwrap_or(&f.capture_name);
1086 let details = qt_property_details(f);
1087 let api_style = f
1088 .metadata
1089 .as_ref()
1090 .and_then(|m| m.get("api_style"))
1091 .and_then(|v| v.as_str());
1092 let place = match api_style {
1093 Some("decorator_routing") => "handler",
1094 Some("call_routing") => "registered in",
1095 _ => "in",
1096 };
1097 let parent = f
1098 .containing_symbol_name
1099 .as_deref()
1100 .map(|p| format!(", {place}: {p}"))
1101 .unwrap_or_default();
1102 out.push_str(&format!(
1103 "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
1104 f.path, f.start_line, f.pattern_id
1105 ));
1106 }
1107 if limit > 0 && facts.len() >= limit {
1108 out.push_str(&cap_notice(facts.len(), limit));
1109 }
1110 if facts
1111 .iter()
1112 .any(|f| f.pattern_id.starts_with("flask.route"))
1113 {
1114 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");
1115 }
1116 if !literals.is_empty() {
1117 out.push_str(&format!(
1118 "\nMatching literals ({} found):\n",
1119 literals.len()
1120 ));
1121 for l in literals {
1122 out.push_str(&format!(
1123 "- \"{}\" [{}:{}] (kind: {})\n",
1124 l.literal_text, l.path, l.start_line, l.kind
1125 ));
1126 }
1127 if limit > 0 && literals.len() >= limit {
1128 out.push_str(&cap_notice(literals.len(), limit));
1129 }
1130 }
1131 out
1132}
1133
1134fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
1135 if fact.pattern_id != "cpp.qt_property.v1" {
1136 return String::new();
1137 }
1138 let Some(metadata) = fact.metadata.as_ref() else {
1139 return String::new();
1140 };
1141 [
1142 "property_type",
1143 "read",
1144 "write",
1145 "notify",
1146 "designable",
1147 "scriptable",
1148 "stored",
1149 "user",
1150 "revision",
1151 ]
1152 .into_iter()
1153 .filter_map(|key| metadata.get(key).map(|value| (key, value)))
1154 .map(|(key, value)| {
1155 let value = value
1156 .as_str()
1157 .map(str::to_owned)
1158 .unwrap_or_else(|| value.to_string());
1159 format!(", {key}: {value}")
1160 })
1161 .collect()
1162}
1163
1164pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
1166 if results.is_empty() {
1167 return format!("No symbols found matching concept \"{query}\".");
1168 }
1169
1170 let mut out = format!(
1171 "Found {} symbols matching concept \"{query}\":\n",
1172 results.len()
1173 );
1174 if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
1175 out.push_str(&format!(
1176 "rerank: {} candidates in {} µs",
1177 explain.candidates, explain.rerank_us
1178 ));
1179 if !explain.word_weights.is_empty() {
1180 let words: Vec<String> = explain
1181 .word_weights
1182 .iter()
1183 .map(|(word, weight)| format!("{word} {weight:.2}"))
1184 .collect();
1185 out.push_str(&format!("; words {}", words.join(", ")));
1186 }
1187 out.push('\n');
1188 }
1189 out.push('\n');
1190 for r in results {
1191 let s = &r.symbol;
1192 let sig = s.signature.as_deref().unwrap_or(&s.name);
1193 out.push_str(&format!(
1194 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1195 display_kind(s),
1196 s.name,
1197 s.path,
1198 s.start_line,
1199 s.end_line,
1200 r.score,
1201 s.symbol_id
1202 ));
1203 out.push_str(&format!(" Signature: {sig}\n"));
1204 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1205 out.push_str(&format!(" Match: {line}\n"));
1206 } else if let Some(doc) = &s.doc_comment {
1207 let first_line = doc.lines().next().unwrap_or("").trim();
1208 if !first_line.is_empty() {
1209 out.push_str(&format!(" Doc: {first_line}\n"));
1210 }
1211 }
1212 if let Some(explain) = &r.explain {
1213 out.push_str(&format!(" explain: {}\n", explain_line(r.score, explain)));
1214 }
1215 }
1216
1217 if results.len() >= limit {
1218 out.push_str(&cap_notice(results.len(), limit));
1219 }
1220
1221 out
1222}
1223
1224fn explain_line(score: f64, e: &SearchExplain) -> String {
1225 let mut line = format!(
1226 "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
1227 e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
1228 );
1229 if e.documentation != 0.0 {
1230 line.push_str(&format!(" + doc {:.1}", e.documentation));
1231 }
1232 if e.test_intent != 0.0 {
1233 line.push_str(&format!(" + test {:.1}", e.test_intent));
1234 }
1235 if e.nested != 0.0 {
1236 line.push_str(&format!(" + nested {:.1}", e.nested));
1237 }
1238 line.push_str(&format!(" [{}]", e.branches.join(",")));
1239 if let Some(bm25) = e.bm25 {
1240 line.push_str(&format!(" bm25 {bm25:.2}"));
1241 }
1242 if !e.terms.is_empty() {
1243 let terms: Vec<String> = e
1244 .terms
1245 .iter()
1246 .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
1247 .collect();
1248 line.push_str(&format!(" terms {}", terms.join(" ")));
1249 }
1250 line
1251}
1252
1253pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
1255 if result.seed_type == "none" {
1256 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
1257 }
1258
1259 let mut out = String::new();
1260 let seed_label = if result.seed_type == "file" {
1261 format!("Files: {}", result.seeds.join(", "))
1262 } else if result.seed_type == "symbol" {
1263 format!("Symbol: {}", result.seeds.join(", "))
1264 } else {
1265 format!("Seeds: {}", result.seeds.join(", "))
1266 };
1267
1268 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
1269
1270 let more = if result.limit_at_maximum {
1271 "limit is at its maximum, so narrow the target to a symbol or fewer files"
1272 } else {
1273 "raise limit to see the rest"
1274 };
1275 let shown_of = |shown: usize, found: usize, noun: &str| {
1276 if found > shown {
1277 format!("Showing {shown} of {found} {noun}; {more}.\n\n")
1278 } else {
1279 format!("Requested limit hid additional {noun}; {more}.\n\n")
1280 }
1281 };
1282 if result.likely_tests_truncated {
1283 out.push_str(&shown_of(
1284 result.likely_tests.len(),
1285 result.likely_tests_found,
1286 "likely tests",
1287 ));
1288 }
1289 if result.impacted_symbols_truncated {
1290 out.push_str(&shown_of(
1291 result.impacted_symbols.len(),
1292 result.impacted_symbols_found,
1293 "impacted symbols",
1294 ));
1295 }
1296 if result.traversal_ceiling_reached {
1297 out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
1298 }
1299 if result.test_file_ceiling_reached {
1300 out.push_str("Name-matched test discovery hit its fixed ceiling; narrow the target because increasing limit cannot raise it.\n\n");
1301 }
1302
1303 if !result.likely_tests.is_empty() {
1304 let total = result.likely_tests.len();
1305 out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
1306
1307 let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
1308 std::collections::BTreeMap::new();
1309 let mut file_order = Vec::new();
1310 for t in &result.likely_tests {
1311 if !tests_by_file.contains_key(t.path.as_str()) {
1312 file_order.push(t.path.as_str());
1313 }
1314 tests_by_file.entry(t.path.as_str()).or_default().push(t);
1315 }
1316
1317 for path in file_order {
1318 out.push_str(&format!("{path}:\n"));
1319 if let Some(tests) = tests_by_file.get(path) {
1320 for t in tests {
1321 out.push_str(&format!(
1322 " - `{}` [line {}] ({})\n",
1323 t.name, t.line, t.reason
1324 ));
1325 }
1326 }
1327 }
1328
1329 if result
1330 .likely_tests
1331 .iter()
1332 .any(|t| t.reason.starts_with("possible:"))
1333 {
1334 out.push_str("`possible` rows build the class and use a test client, so they reach the target only through a runtime call to `__call__`. The index picks them by shared name words and cannot see whether they send a call that reaches the target; run the whole suite for full coverage.\n");
1335 }
1336 out.push('\n');
1337 } else if result.likely_tests_truncated
1338 || result.traversal_ceiling_reached
1339 || result.test_file_ceiling_reached
1340 {
1341 out.push_str("### Likely Tests to Run (0 returned)\n\n");
1342 } else {
1343 out.push_str(
1344 "### Likely Tests to Run (0 returned)\nNo direct or name-matched tests found.\n\n",
1345 );
1346 }
1347
1348 if !result.impacted_symbols.is_empty() {
1349 let total = result.impacted_symbols.len();
1350 let mut visible = Vec::new();
1351 let mut low_signal_count = 0;
1352 for s in &result.impacted_symbols {
1353 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
1354 low_signal_count += 1;
1355 } else {
1356 visible.push(s);
1357 }
1358 }
1359
1360 out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
1361
1362 if visible.is_empty() && low_signal_count > 0 {
1363 let row_word = if low_signal_count == 1 {
1364 "row (import/module)"
1365 } else {
1366 "rows (imports/modules)"
1367 };
1368 out.push_str(&format!(
1369 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
1370 ));
1371 } else {
1372 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
1373 std::collections::BTreeMap::new();
1374 let mut file_order = Vec::new();
1375 for s in &visible {
1376 if !syms_by_file.contains_key(s.path.as_str()) {
1377 file_order.push(s.path.as_str());
1378 }
1379 syms_by_file.entry(s.path.as_str()).or_default().push(s);
1380 }
1381
1382 for path in file_order {
1383 out.push_str(&format!("{path}:\n"));
1384 if let Some(syms) = syms_by_file.get(path) {
1385 for s in syms {
1386 out.push_str(&format!(
1387 " - [depth {}] {} `{}` [line {}]\n",
1388 s.depth, s.kind, s.name, s.line
1389 ));
1390 }
1391 }
1392 }
1393
1394 if low_signal_count > 0 {
1395 let row_word = if low_signal_count == 1 {
1396 "row (import/module)"
1397 } else {
1398 "rows (imports/modules)"
1399 };
1400 out.push_str(&format!(
1401 "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
1402 ));
1403 }
1404 }
1405 } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
1406 out.push_str("### Downstream Impact (0 returned)\n");
1407 } else {
1408 out.push_str(
1409 "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
1410 );
1411 }
1412
1413 out
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419 use crate::models::{ImpactedSymbol, TestTarget};
1420
1421 fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
1422 crate::models::StructuralFact {
1423 structural_fact_id: "sf1".into(),
1424 path: ".codex/config.toml".into(),
1425 language: "toml".into(),
1426 pattern_id: "toml.key_value.v1".into(),
1427 capture_name: "key_value".into(),
1428 node_kind: "table".into(),
1429 key: key.map(str::to_string),
1430 metadata: None,
1431 containing_symbol_name: None,
1432 start_line: 2,
1433 end_line: 2,
1434 confidence: 1.0,
1435 }
1436 }
1437
1438 #[test]
1439 fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
1440 let with_key = format_structural_facts(
1441 &[structural_fact(Some("mcp_servers.code-kb.command"))],
1442 &[],
1443 "config",
1444 30,
1445 );
1446 assert!(with_key.contains(
1447 "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
1448 ));
1449
1450 let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
1451 assert!(
1452 without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
1453 );
1454 }
1455
1456 #[test]
1457 fn format_structural_facts_renders_selected_qt_property_metadata() {
1458 let mut fact = structural_fact(Some("index"));
1459 fact.pattern_id = "cpp.qt_property.v1".into();
1460 fact.metadata = Some(serde_json::json!({
1461 "property_type": "int",
1462 "designable": false,
1463 "scriptable": true,
1464 "stored": false,
1465 "user": true,
1466 "revision": 2,
1467 }));
1468
1469 let output = format_structural_facts(&[fact], &[], "property", 30);
1470
1471 assert!(output.contains(
1472 "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
1473 ));
1474 }
1475
1476 #[test]
1477 fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
1478 let site = |occurrences| ReferenceSite {
1479 from_symbol_name: "Button".into(),
1480 from_symbol_id: "s1".into(),
1481 to_symbol_name: "background".into(),
1482 kind: "member_access".into(),
1483 path: "Ui/Button.qml".into(),
1484 start_line: Some(5),
1485 start_column: Some(4),
1486 occurrences,
1487 target: None,
1488 };
1489
1490 let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
1491 assert!(
1492 grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
1493 "{grouped}"
1494 );
1495
1496 let single = format_references("Color", &[site(None)], "callers", 30);
1497 assert!(
1498 single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
1499 "{single}"
1500 );
1501 }
1502
1503 #[test]
1504 fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
1505 assert_eq!(
1506 match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
1507 Some("...path up to the [root] (the [root] excluded)".into())
1508 );
1509 assert_eq!(
1510 match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
1511 Some("// [HelpFunc] returns the [function] set".into())
1512 );
1513 assert_eq!(
1514 match_line(
1515 "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
1516 "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
1517 ),
1518 None
1519 );
1520 assert_eq!(
1521 match_line(
1522 "...[root]: &[PathBuf]) -> bool",
1523 "fn f(root: &[PathBuf]) -> bool"
1524 ),
1525 None
1526 );
1527 }
1528
1529 #[test]
1530 fn a_long_unresolved_call_chain_shows_only_its_last_call() {
1531 assert_eq!(
1532 callee_name(
1533 "[\"a\", \"b\"]\n .into_iter()\n .find_map(|key| args.get(key)).ok_or_else"
1534 ),
1535 "….ok_or_else"
1536 );
1537 assert_eq!(
1538 callee_name("workspace.resolve_path"),
1539 "workspace.resolve_path"
1540 );
1541 let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
1542 assert_eq!(callee_name(selector), selector);
1543 }
1544
1545 #[test]
1546 fn file_skeleton_reports_parse_errors() {
1547 let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
1548 assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
1549
1550 let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
1551 assert!(one.contains("// 1 parse error: symbols may be incomplete"));
1552
1553 let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
1554 assert!(!none.contains("parse error"));
1555 }
1556
1557 #[test]
1558 fn test_format_file_skeleton() {
1559 let syms = vec![Symbol {
1560 symbol_id: "s1".into(),
1561 file_id: "f1".into(),
1562 path: "src/lib.rs".into(),
1563 language: "rust".into(),
1564 name: "do_work".into(),
1565 kind: "function".into(),
1566 signature: Some("pub fn do_work() -> Result<()>".into()),
1567 doc_comment: Some("Performs core work.".into()),
1568 visibility: Some("pub".into()),
1569 parent_symbol_id: None,
1570 start_line: 10,
1571 start_column: 0,
1572 end_line: 30,
1573 end_column: 1,
1574 start_byte: 100,
1575 end_byte: 300,
1576 body_start_line: Some(11),
1577 body_start_column: Some(0),
1578 body_end_line: Some(29),
1579 body_end_column: Some(1),
1580 body_start_byte: Some(130),
1581 body_end_byte: Some(298),
1582 body_hash: None,
1583 semantic_group: None,
1584 is_test: false,
1585 test_container: false,
1586 }];
1587
1588 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
1589 assert!(skeleton.contains("/// Performs core work."));
1590 assert!(skeleton.contains("19 lines hidden: L11-L29"));
1591 }
1592
1593 #[test]
1594 fn format_symbol_body_prints_the_source_under_its_location() {
1595 let mut symbol = sample_symbol("plain");
1596 symbol.signature = Some("pub fn plain()".into());
1597
1598 assert_eq!(
1599 format_symbol_body(&symbol, "pub fn plain() {\n 1\n}"),
1600 "// src/lib.rs:1-10 (plain)\npub fn plain() {\n 1\n}\n"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_format_search_results() {
1606 let results = vec![SymbolSearchResult {
1607 symbol: Symbol {
1608 symbol_id: "s1".into(),
1609 file_id: "f1".into(),
1610 path: "src/parser.rs".into(),
1611 language: "rust".into(),
1612 name: "parse_tokens".into(),
1613 kind: "function".into(),
1614 signature: Some("pub fn parse_tokens()".into()),
1615 doc_comment: Some("Parses tokens from stream.".into()),
1616 visibility: Some("pub".into()),
1617 parent_symbol_id: None,
1618 start_line: 15,
1619 start_column: 0,
1620 end_line: 25,
1621 end_column: 1,
1622 start_byte: 100,
1623 end_byte: 250,
1624 body_start_line: None,
1625 body_start_column: None,
1626 body_end_line: None,
1627 body_end_column: None,
1628 body_start_byte: None,
1629 body_end_byte: None,
1630 body_hash: None,
1631 semantic_group: None,
1632 is_test: false,
1633 test_container: false,
1634 },
1635 score: -1.85,
1636 snippet: Some("Parses [tokens] from stream.".into()),
1637 explain: None,
1638 }];
1639
1640 let formatted = format_search_results("tokens", &results, 20);
1641 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
1642 assert!(
1643 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
1644 );
1645 assert!(formatted.contains("Match: Parses [tokens] from stream."));
1646 assert!(!formatted.contains("explain"));
1647 assert!(!formatted.contains("rerank"));
1648 }
1649
1650 #[test]
1651 fn exact_lookup_folds_import_rows_into_one_line_when_a_definition_exists() {
1652 let class = Symbol {
1653 kind: "class".into(),
1654 path: "src/flask/app.py".into(),
1655 ..sample_symbol("Flask")
1656 };
1657 let import = |path: &str, line: usize| Symbol {
1658 kind: "import".into(),
1659 path: path.into(),
1660 start_line: line,
1661 symbol_id: format!("id_{path}"),
1662 ..sample_symbol("Flask")
1663 };
1664 let rows = vec![
1665 class,
1666 import("src/flask/cli.py", 34),
1667 import("src/flask/ctx.py", 21),
1668 import("tests/conftest.py", 6),
1669 import("examples/app.py", 1),
1670 ];
1671
1672 let folded = format_find_symbol_results("Flask", &rows, &[], 20);
1673
1674 assert!(
1675 folded.contains("- class `Flask` [src/flask/app.py:"),
1676 "{folded}"
1677 );
1678 assert!(
1679 folded.contains(
1680 "- 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)"
1681 ),
1682 "{folded}"
1683 );
1684 assert!(!folded.contains("- import `Flask`"), "{folded}");
1685
1686 let capped = format_find_symbol_results("Flask", &rows, &[], 1);
1687 assert!(
1688 capped.contains("- at least 4 imports of `Flask`"),
1689 "{capped}"
1690 );
1691
1692 let mut with_prefix_rows = rows.clone();
1693 with_prefix_rows.push(Symbol {
1694 kind: "class".into(),
1695 ..sample_symbol("FlaskGroup")
1696 });
1697 let cut_after_exact = format_find_symbol_results("Flask", &with_prefix_rows, &[], 6);
1698 assert!(
1699 cut_after_exact.contains("- 4 imports of `Flask`"),
1700 "{cut_after_exact}"
1701 );
1702
1703 let imports_only = format_find_symbol_results("Flask", &rows[1..], &[], 20);
1704 assert_eq!(imports_only.matches("- import `Flask`").count(), 4);
1705 }
1706
1707 #[test]
1708 fn skeleton_shows_python_class_and_module_variables_with_one_line_values() {
1709 let leaf = |name: &str, kind: &str, line: usize, sig: &str| Symbol {
1710 kind: kind.into(),
1711 language: "python".into(),
1712 path: "app.py".into(),
1713 parent_symbol_id: Some("id_App".into()),
1714 start_line: line,
1715 end_line: line,
1716 body_start_line: None,
1717 body_end_line: None,
1718 signature: Some(sig.into()),
1719 ..sample_symbol(name)
1720 };
1721 let class = Symbol {
1722 kind: "class".into(),
1723 language: "python".into(),
1724 path: "app.py".into(),
1725 start_line: 1,
1726 end_line: 20,
1727 body_start_line: None,
1728 body_end_line: None,
1729 signature: Some("class App".into()),
1730 ..sample_symbol("App")
1731 };
1732 let module_variable = Symbol {
1733 kind: "variable".into(),
1734 language: "python".into(),
1735 path: "app.py".into(),
1736 start_line: 30,
1737 end_line: 30,
1738 body_start_line: None,
1739 body_end_line: None,
1740 signature: Some("app = App()".into()),
1741 ..sample_symbol("app")
1742 };
1743 let symbols = vec![
1744 class,
1745 leaf(
1746 "request_class",
1747 "variable",
1748 2,
1749 "request_class: type[Request] = Request",
1750 ),
1751 leaf(
1752 "default_config",
1753 "variable",
1754 3,
1755 "default_config = ImmutableDict(\n {\n \"DEBUG\": None,\n }\n)",
1756 ),
1757 leaf(
1758 "blueprints",
1759 "property",
1760 5,
1761 "self.blueprints: dict[str, Blueprint] = {}",
1762 ),
1763 leaf(
1764 "error_handler_spec",
1765 "property",
1766 7,
1767 "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",
1768 ),
1769 module_variable,
1770 ];
1771
1772 let out = format_file_skeleton("app.py", &symbols, None, 0);
1773
1774 assert!(
1775 out.contains(" request_class: type[Request] = Request; // L2-2"),
1776 "{out}"
1777 );
1778 assert!(
1779 out.contains(" default_config = ImmutableDict({\"DEBUG\": None}); // L3-3"),
1780 "{out}"
1781 );
1782 assert!(
1783 out.contains(" self.error_handler_spec: dict[ft.AppOrBlueprintKey, dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]]] = defaultdict(lambda: defaultdict(dict)…; // L7-7"),
1784 "{out}"
1785 );
1786 assert!(
1787 out.contains(" self.blueprints: dict[str, Blueprint] = {}; // L5-5"),
1788 "{out}"
1789 );
1790 assert!(out.contains("app = App(); // L30-30"), "{out}");
1791 }
1792
1793 #[test]
1794 fn skeleton_names_the_method_that_assigns_an_attribute() {
1795 let member = |name: &str, kind: &str, start: usize, end: usize, sig: &str| Symbol {
1796 kind: kind.into(),
1797 language: "python".into(),
1798 parent_symbol_id: Some("id_Flask".into()),
1799 start_line: start,
1800 end_line: end,
1801 body_start_line: None,
1802 body_end_line: None,
1803 signature: Some(sig.into()),
1804 ..sample_symbol(name)
1805 };
1806 let class = Symbol {
1807 kind: "class".into(),
1808 language: "python".into(),
1809 start_line: 1,
1810 end_line: 30,
1811 body_start_line: None,
1812 body_end_line: None,
1813 signature: Some("class Flask(App)".into()),
1814 ..sample_symbol("Flask")
1815 };
1816 let symbols = vec![
1817 class,
1818 member("__init__", "method", 2, 10, "def __init__(self)"),
1819 member("cli", "property", 3, 3, "self.cli = cli.AppGroup()"),
1820 member("run", "method", 12, 20, "def run(self)"),
1821 member("debug", "property", 14, 14, "self.debug = get_debug_flag()"),
1822 ];
1823
1824 let out = format_file_skeleton("app.py", &symbols, None, 0);
1825
1826 assert!(
1827 out.contains("self.cli = cli.AppGroup(); // in __init__(), L3-3"),
1828 "{out}"
1829 );
1830 assert!(
1831 out.contains("self.debug = get_debug_flag(); // in run(), L14-14"),
1832 "{out}"
1833 );
1834 }
1835
1836 #[test]
1837 fn skeleton_hides_variables_that_are_not_python_class_attributes() {
1838 let parent = |name: &str, kind: &str, language: &str| Symbol {
1839 kind: kind.into(),
1840 language: language.into(),
1841 start_line: 1,
1842 end_line: 20,
1843 body_start_line: None,
1844 body_end_line: None,
1845 signature: Some(format!("{kind} {name}")),
1846 ..sample_symbol(name)
1847 };
1848 let child = |name: &str, parent: &str, language: &str, sig: &str| Symbol {
1849 kind: "variable".into(),
1850 language: language.into(),
1851 parent_symbol_id: Some(format!("id_{parent}")),
1852 start_line: 2,
1853 end_line: 2,
1854 body_start_line: None,
1855 body_end_line: None,
1856 signature: Some(sig.into()),
1857 ..sample_symbol(name)
1858 };
1859 let symbols = vec![
1860 parent("Adapters", "class", "java"),
1861 child(
1862 "accessible",
1863 "Adapters",
1864 "java",
1865 "boolean accessible = false",
1866 ),
1867 parent("body", "class", "html"),
1868 child(
1869 "img",
1870 "body",
1871 "html",
1872 "<img class=\"carat\" src=\"carat.png\" alt>",
1873 ),
1874 parent("section", "module", "yaml"),
1875 child("title", "section", "yaml", "title: Invoking jq"),
1876 ];
1877
1878 let out = format_file_skeleton("mixed", &symbols, None, 0);
1879
1880 for hidden in ["accessible", "<img", "title:"] {
1881 assert!(!out.contains(hidden), "{out}");
1882 }
1883 }
1884
1885 #[test]
1886 fn a_multi_line_macro_row_drops_its_line_continuations_and_is_cut_to_one_line() {
1887 let short = "#define PAIR(a, b) \\\n a, \\\n b";
1888 assert_eq!(value_row_signature(short), "#define PAIR(a, b) a, b");
1889
1890 let long = format!(
1891 "#define WIDE(x) \\\n{}",
1892 " x + x + x + x + x + x + x + x \\\n".repeat(8)
1893 );
1894 let row = value_row_signature(&long);
1895 assert!(row.starts_with("#define WIDE(x) x + x"), "{row}");
1896 assert!(row.ends_with('…'), "{row}");
1897 assert!(!row.contains('\\'), "{row}");
1898 assert!(row.chars().count() <= 120, "{row}");
1899 }
1900
1901 #[test]
1902 fn skeleton_keeps_values_only_for_real_assignments() {
1903 let row =
1904 |name: &str, kind: &str, line: usize, sig: &str, body: Option<(usize, usize)>| Symbol {
1905 kind: kind.into(),
1906 start_line: line,
1907 end_line: body.map_or(line, |(_, end)| end),
1908 body_start_line: body.map(|(start, _)| start),
1909 body_end_line: body.map(|(_, end)| end),
1910 signature: Some(sig.into()),
1911 ..sample_symbol(name)
1912 };
1913 let symbols = vec![
1914 row(
1915 "img",
1916 "property",
1917 1,
1918 "[class^=rz-] img,[class^=rz-] svg { vertical-align:middle }",
1919 None,
1920 ),
1921 row(
1922 "Coordinates",
1923 "property",
1924 2,
1925 "[JsonProperty(ItemConverterType = typeof(IntToFloatConverter))] public int[,,] Coordinates { get; set; }",
1926 None,
1927 ),
1928 row(
1929 "VERSION_CHECK",
1930 "constant",
1931 3,
1932 "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor)))\n",
1933 None,
1934 ),
1935 row(
1936 "TABLE",
1937 "constant",
1938 4,
1939 "TABLE = {\n '\"' => '%22',\n '\\r' => '%0D',\n}.freeze",
1940 Some((4, 8)),
1941 ),
1942 row(
1943 "LIMIT",
1944 "field",
1945 10,
1946 "private static final int LIMIT = 5;",
1947 None,
1948 ),
1949 ];
1950
1951 let out = format_file_skeleton("mixed", &symbols, None, 0);
1952
1953 assert!(
1954 out.contains("[class^=rz-] img,[class^=rz-] svg; // L1-1"),
1955 "{out}"
1956 );
1957 assert!(out.contains("public int[,,] Coordinates; // L2-2"), "{out}");
1958 assert!(
1959 out.contains(
1960 "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor))); // L3-3\n"
1961 ),
1962 "{out}"
1963 );
1964 assert!(
1965 out.contains("TABLE = { /* 5 lines hidden: L4-L8 */ }"),
1966 "{out}"
1967 );
1968 assert!(
1969 out.contains("private static final int LIMIT = 5; // L10-10"),
1970 "{out}"
1971 );
1972 }
1973
1974 #[test]
1975 fn skeleton_cuts_a_field_with_members_at_its_brace() {
1976 let field = Symbol {
1977 kind: "field".into(),
1978 language: "java".into(),
1979 start_line: 1,
1980 end_line: 9,
1981 body_start_line: None,
1982 body_end_line: None,
1983 signature: Some(
1984 "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n @Override\n}"
1985 .into(),
1986 ),
1987 ..sample_symbol("CLASS")
1988 };
1989 let member = Symbol {
1990 kind: "method".into(),
1991 language: "java".into(),
1992 parent_symbol_id: Some("id_CLASS".into()),
1993 start_line: 2,
1994 end_line: 4,
1995 signature: Some("public void write(JsonWriter out, Class value)".into()),
1996 ..sample_symbol("write")
1997 };
1998
1999 let out = format_file_skeleton("TypeAdapters.java", &[field, member], None, 0);
2000
2001 assert!(
2002 out.contains(
2003 "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n"
2004 ),
2005 "{out}"
2006 );
2007 assert!(!out.contains('…'), "{out}");
2008 }
2009
2010 #[test]
2011 fn import_summary_counts_sites_and_files_and_lists_three() {
2012 let sites = vec![
2013 ("src/flask/__init__.py".to_string(), 2),
2014 ("src/flask/cli.py".to_string(), 34),
2015 ("src/flask/cli.py".to_string(), 45),
2016 ("tests/conftest.py".to_string(), 6),
2017 ];
2018
2019 assert_eq!(
2020 format_import_summary(&sites),
2021 "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"
2022 );
2023 assert_eq!(format_import_summary(&[]), "");
2024 }
2025
2026 #[test]
2027 fn no_facts_for_an_alias_points_imports_at_lookup_and_skips_the_raw_list() {
2028 let categories = vec![("flask.route.v1".to_string(), 3)];
2029
2030 let imports = format_no_facts("import", None, &categories);
2031 let unknown = format_no_facts("widgets", None, &categories);
2032
2033 assert!(
2034 imports.contains("lookup_symbol with kind=\"import\""),
2035 "{imports}"
2036 );
2037 assert!(!imports.contains("`flask.route.v1`"), "{imports}");
2038 assert!(unknown.contains("`flask.route.v1` (3 facts)"), "{unknown}");
2039 }
2040
2041 #[test]
2042 fn markup_and_data_rows_show_their_own_kind_words() {
2043 let row = |language: &str, kind: &str| Symbol {
2044 language: language.into(),
2045 kind: kind.into(),
2046 ..sample_symbol("x")
2047 };
2048 assert_eq!(display_kind(&row("html", "class")), "element");
2049 assert_eq!(display_kind(&row("sql", "class")), "table");
2050 assert_eq!(display_kind(&row("markdown", "module")), "section");
2051 assert_eq!(display_kind(&row("markdown", "import")), "link");
2052 let attribute = Symbol {
2053 kind: "property".into(),
2054 signature: Some("self.extensions = {}".into()),
2055 ..sample_symbol("extensions")
2056 };
2057 assert_eq!(display_kind(&attribute), "attribute");
2058 let fsharp_member = Symbol {
2059 language: "fsharp".into(),
2060 signature: Some("this.Total = decimal this.Qty * this.Price".into()),
2061 ..attribute
2062 };
2063 assert_eq!(display_kind(&fsharp_member), "property");
2064 assert_eq!(display_kind(&row("python", "class")), "class");
2065 }
2066
2067 #[test]
2068 fn no_facts_heading_names_the_path_filter() {
2069 assert_eq!(
2070 no_facts_heading("config", Some("src/flask")),
2071 "No facts match 'config' under `src/flask`."
2072 );
2073 assert_eq!(
2074 no_facts_heading("config", None),
2075 "No facts match 'config' in this repository."
2076 );
2077 }
2078
2079 #[test]
2080 fn outline_says_how_many_definitions_it_left_out_and_counts_tests() {
2081 let mut root = OutlineNode::default();
2082 let symbol = |name: &str, is_test: bool| Symbol {
2083 kind: "function".into(),
2084 path: "tests/test_basic.py".into(),
2085 is_test,
2086 ..sample_symbol(name)
2087 };
2088 let symbols = HashMap::from([(
2089 "tests/test_basic.py".to_string(),
2090 vec![symbol("helper", false), symbol("test_one", true)],
2091 )]);
2092 let counts = HashMap::from([(
2093 "tests/test_basic.py".to_string(),
2094 crate::queries::OutlineCounts {
2095 definitions: 3,
2096 tests: 90,
2097 fixtures: 2,
2098 },
2099 )]);
2100 add_path_to_outline(&mut root, "tests/test_basic.py", &symbols, &counts, 3, "");
2101
2102 let mut out = String::new();
2103 render_outline_tree(&mut out, &root, "", 0, 3);
2104
2105 assert!(
2106 out.contains("test_basic.py [function helper, +2 more, 90 tests, 2 fixtures]"),
2107 "{out}"
2108 );
2109 }
2110
2111 #[test]
2112 fn outline_counts_the_files_below_the_depth_limit() {
2113 let mut root = OutlineNode::default();
2114 let no_symbols = HashMap::new();
2115 for path in [
2116 "src/flask/app.py",
2117 "src/flask/json/tag.py",
2118 "src/flask/cli.py",
2119 "tests/conftest.py",
2120 ] {
2121 add_path_to_outline(&mut root, path, &no_symbols, &HashMap::new(), 2, "");
2122 }
2123
2124 let mut out = String::new();
2125 render_outline_tree(&mut out, &root, "", 0, 2);
2126
2127 assert!(out.contains("└── flask/ (3 indexed files)"), "{out}");
2128 assert!(!out.contains("tests/ ("), "{out}");
2129 }
2130
2131 #[test]
2132 fn exact_lookup_folds_only_imports_named_exactly_like_the_query() {
2133 let rows = vec![
2134 Symbol {
2135 kind: "function".into(),
2136 ..sample_symbol("read")
2137 },
2138 Symbol {
2139 kind: "import".into(),
2140 path: "src/flask/cli.py".into(),
2141 start_line: 1034,
2142 ..sample_symbol("readline")
2143 },
2144 Symbol {
2145 kind: "import".into(),
2146 path: "src/Types.kt".into(),
2147 start_line: 18,
2148 ..sample_symbol("com.example.internal.read")
2149 },
2150 ];
2151
2152 let out = format_find_symbol_results("read", &rows, &[], 20);
2153
2154 assert!(
2155 out.contains(
2156 "- 1 other row starts with or contains `read`, ignoring case: `readline` (import) (lookup_symbol"
2157 ),
2158 "{out}"
2159 );
2160 assert!(
2161 out.contains(
2162 "- 1 import of `read`: src/Types.kt:18 (lookup_symbol with kind=\"import\" lists them)"
2163 ),
2164 "{out}"
2165 );
2166 }
2167
2168 #[test]
2169 fn discovery_formatters_append_ids_to_metadata_rows() {
2170 let exact = vec![sample_symbol("exact")];
2171 let fallback = vec![SymbolSearchResult {
2172 symbol: sample_symbol("fallback"),
2173 score: 1.0,
2174 snippet: None,
2175 explain: None,
2176 }];
2177
2178 let outputs = [
2179 (
2180 format_find_symbol_results("exact", &exact, &[], 20),
2181 "id_exact",
2182 ),
2183 (
2184 format_find_symbol_results("fallback query", &[], &fallback, 20),
2185 "id_fallback",
2186 ),
2187 (
2188 format_search_results("fallback", &fallback, 20),
2189 "id_fallback",
2190 ),
2191 ];
2192
2193 for (formatted, id) in outputs {
2194 assert!(
2195 formatted
2196 .lines()
2197 .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
2198 "{formatted}"
2199 );
2200 assert!(!formatted.contains("\n id="), "{formatted}");
2201 }
2202 }
2203
2204 #[test]
2205 fn every_printed_part_of_the_explain_line_sums_to_the_score() {
2206 let e = SearchExplain {
2207 bm25: Some(-3.21),
2208 branches: vec!["word".into()],
2209 name_tier: "all".into(),
2210 name_strength: 6,
2211 term_score: 24.5,
2212 name_bonus: 60.0,
2213 kind_prior: 4.0,
2214 path_role: -10.0,
2215 documentation: -200.0,
2216 test_intent: 5.0,
2217 nested: 0.0,
2218 terms: vec![("sha".into(), "name".into(), 3.0)],
2219 word_weights: vec![("sha".into(), 2.6)],
2220 candidates: 1,
2221 rerank_us: 1,
2222 };
2223 let score = e.term_score
2224 + e.name_bonus
2225 + e.kind_prior
2226 + e.path_role
2227 + e.documentation
2228 + e.test_intent;
2229
2230 let line = explain_line(score, &e);
2231 let parts: f64 = line
2232 .split(" = ")
2233 .nth(1)
2234 .unwrap()
2235 .split(" [")
2236 .next()
2237 .unwrap()
2238 .split(" + ")
2239 .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
2240 .sum();
2241
2242 assert!(line.starts_with(&format!(
2243 "score {score:.1} = terms 24.5 + name all(6) 60.0 "
2244 )));
2245 assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
2246 }
2247
2248 #[test]
2249 fn test_format_search_results_prints_the_explain_breakdown_when_present() {
2250 let mut result = SymbolSearchResult {
2251 symbol: sample_symbol("parseSha256Sidecar"),
2252 score: 71.6,
2253 snippet: Some("parse[Sha256]Sidecar".into()),
2254 explain: Some(SearchExplain {
2255 bm25: Some(-3.21),
2256 branches: vec!["word".into(), "name".into()],
2257 name_tier: "all".into(),
2258 name_strength: 6,
2259 terms: vec![
2260 ("sha".into(), "name".into(), 3.0),
2261 ("256".into(), "name".into(), 3.0),
2262 ],
2263 term_score: 12.6,
2264 name_bonus: 60.0,
2265 kind_prior: 4.0,
2266 path_role: -10.0,
2267 documentation: 0.0,
2268 test_intent: 5.0,
2269 nested: 0.0,
2270 word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
2271 candidates: 37,
2272 rerank_us: 180,
2273 }),
2274 };
2275
2276 let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
2277 assert!(formatted.contains(
2278 "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
2279 ));
2280 assert!(formatted.contains(
2281 " 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"
2282 ));
2283
2284 result.explain = None;
2285 let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
2286 assert!(!silent.contains("explain"));
2287 assert!(!silent.contains("rerank"));
2288 }
2289
2290 #[test]
2291 fn test_format_search_results_discloses_a_reached_limit() {
2292 let results = vec![SymbolSearchResult {
2293 symbol: sample_symbol("parse_tokens"),
2294 score: 0.0,
2295 snippet: None,
2296 explain: None,
2297 }];
2298
2299 let formatted = format_search_results("tokens", &results, 1);
2300 assert!(
2301 formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
2302 );
2303
2304 let at_ceiling =
2305 format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
2306 assert!(!at_ceiling.contains("limit reached"));
2307
2308 let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
2309 .map(|_| SymbolSearchResult {
2310 symbol: sample_symbol("parse_tokens"),
2311 score: 0.0,
2312 snippet: None,
2313 explain: None,
2314 })
2315 .collect();
2316 let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
2317 assert!(capped.contains("(limit reached); narrow the query to see more.]"));
2318 }
2319
2320 #[test]
2321 fn test_format_blast_radius() {
2322 let res = BlastRadiusResult {
2323 seed_type: "symbol".into(),
2324 seeds: vec!["do_work".into()],
2325 likely_tests: vec![TestTarget {
2326 name: "test_do_work".into(),
2327 path: "tests/work_test.rs".into(),
2328 line: 15,
2329 reason: "direct caller".into(),
2330 }],
2331 impacted_symbols: vec![ImpactedSymbol {
2332 name: "caller_fn".into(),
2333 kind: "function".into(),
2334 path: "src/caller.rs".into(),
2335 line: 42,
2336 depth: 1,
2337 }],
2338 traversal_ceiling_reached: false,
2339 likely_tests_truncated: false,
2340 impacted_symbols_truncated: false,
2341 test_file_ceiling_reached: false,
2342 limit_at_maximum: false,
2343 likely_tests_found: 0,
2344 impacted_symbols_found: 0,
2345 };
2346
2347 let formatted = format_blast_radius(&res);
2348 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
2349 assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
2350 assert!(
2351 formatted.contains("tests/work_test.rs:\n - `test_do_work` [line 15] (direct caller)")
2352 );
2353 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
2354 }
2355
2356 #[test]
2357 fn format_blast_radius_groups_every_returned_test_by_file() {
2358 let mut likely_tests = Vec::new();
2359 for i in 1..=25 {
2360 likely_tests.push(TestTarget {
2361 name: format!("test_{i}"),
2362 path: format!("tests/test_{}.rs", (i % 3) + 1),
2363 line: i * 10,
2364 reason: "direct caller".into(),
2365 });
2366 }
2367
2368 let impacted_symbols = vec![
2369 ImpactedSymbol {
2370 name: "use_foo".into(),
2371 kind: "import".into(),
2372 path: "src/service.rs".into(),
2373 line: 1,
2374 depth: 1,
2375 },
2376 ImpactedSymbol {
2377 name: "service_fn".into(),
2378 kind: "function".into(),
2379 path: "src/service.rs".into(),
2380 line: 20,
2381 depth: 1,
2382 },
2383 ImpactedSymbol {
2384 name: "api_handler".into(),
2385 kind: "function".into(),
2386 path: "src/api.rs".into(),
2387 line: 45,
2388 depth: 2,
2389 },
2390 ];
2391
2392 let res = BlastRadiusResult {
2393 seed_type: "file".into(),
2394 seeds: vec!["src/lib.rs".into()],
2395 likely_tests,
2396 impacted_symbols,
2397 traversal_ceiling_reached: false,
2398 likely_tests_truncated: false,
2399 impacted_symbols_truncated: false,
2400 test_file_ceiling_reached: false,
2401 limit_at_maximum: false,
2402 likely_tests_found: 0,
2403 impacted_symbols_found: 0,
2404 };
2405
2406 let formatted = format_blast_radius(&res);
2407
2408 assert!(formatted.contains("### Likely Tests to Run (25 returned)"));
2409 assert!(
2410 formatted.contains(" - `test_25` [line 250]"),
2411 "{formatted}"
2412 );
2413 assert!(!formatted.contains("hidden by the compact"));
2414
2415 assert!(formatted.contains("tests/test_1.rs:\n"));
2416 assert!(formatted.contains(" - `test_"));
2417
2418 assert!(!formatted.contains("use_foo"));
2419 assert!(
2420 formatted
2421 .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
2422 );
2423 assert!(formatted.contains("src/service.rs:\n"));
2424 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
2425 }
2426
2427 fn sample_symbol(name: &str) -> Symbol {
2428 Symbol {
2429 symbol_id: format!("id_{name}"),
2430 file_id: "f1".into(),
2431 path: "src/lib.rs".into(),
2432 language: "rust".into(),
2433 name: name.into(),
2434 kind: "function".into(),
2435 signature: Some(format!("pub fn {name}()")),
2436 doc_comment: None,
2437 visibility: Some("pub".into()),
2438 parent_symbol_id: None,
2439 start_line: 1,
2440 start_column: 0,
2441 end_line: 10,
2442 end_column: 1,
2443 start_byte: 0,
2444 end_byte: 100,
2445 body_start_line: Some(2),
2446 body_start_column: Some(0),
2447 body_end_line: Some(9),
2448 body_end_column: Some(1),
2449 body_start_byte: Some(10),
2450 body_end_byte: Some(99),
2451 body_hash: None,
2452 semantic_group: None,
2453 is_test: false,
2454 test_container: false,
2455 }
2456 }
2457
2458 fn sample_context_slice() -> ContextSlice {
2459 ContextSlice {
2460 target_symbol: sample_symbol("target_fn"),
2461 target_body: " println!(\"hello\");\n".into(),
2462 callee_signatures: Vec::new(),
2463 related_types: Vec::new(),
2464 related_tests: Vec::new(),
2465 }
2466 }
2467
2468 #[test]
2469 fn format_context_slice_does_not_repeat_expression_already_in_signature() {
2470 let body = "=>\n transport.SendAsync<Response>(\n HttpMethod.Get,\n $\"items/{id}\",\n cancellationToken: cancellationToken)";
2471 let mut slice = sample_context_slice();
2472 slice.target_symbol.language = "csharp".into();
2473 slice.target_symbol.signature = Some(format!(
2474 "public static Task<Response?> GetByIdAsync(\n int id,\n CancellationToken cancellationToken = default){body}"
2475 ));
2476 slice.target_body = body.into();
2477
2478 let formatted = format_context_slice(&slice);
2479
2480 assert_eq!(
2481 formatted.matches("transport.SendAsync").count(),
2482 1,
2483 "{formatted}"
2484 );
2485 }
2486
2487 #[test]
2488 fn context_slice_leaves_out_a_signature_that_the_body_already_holds() {
2489 let mut slice = sample_context_slice();
2490 slice.target_symbol.signature = Some("type Person = { Name: string; Age: int }".into());
2491 slice.target_body = "type Person = { Name: string; Age: int }\n".into();
2492
2493 let text = format_context_slice(&slice);
2494
2495 assert!(!text.contains("Signature:"), "{text}");
2496 assert_eq!(text.matches("type Person").count(), 1, "{text}");
2497 }
2498
2499 #[test]
2500 fn context_slice_says_when_no_test_was_found() {
2501 let text = format_context_slice(&sample_context_slice());
2502
2503 assert!(
2504 text.ends_with("### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n"),
2505 "{text}"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
2511 let mut slice = sample_context_slice();
2512 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
2513 let text = format_context_slice(&slice);
2514 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
2515
2516 let mut slice_tests = sample_context_slice();
2517 slice_tests.related_tests = (1..=5)
2518 .map(|i| {
2519 let mut sym = sample_symbol(&format!("test_fn_{i}"));
2520 sym.path = format!("tests/test_{i}.rs");
2521 sym.is_test = true;
2522 sym
2523 })
2524 .collect();
2525 let text_tests = format_context_slice(&slice_tests);
2526 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
2527 }
2528
2529 #[test]
2530 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
2531 let impacted_symbols = (1..=200)
2532 .map(|i| ImpactedSymbol {
2533 name: format!("sym_{i}"),
2534 kind: "function".into(),
2535 path: format!("src/mod_{}.rs", i % 10),
2536 line: i,
2537 depth: 1,
2538 })
2539 .collect();
2540
2541 let res = BlastRadiusResult {
2542 seed_type: "symbol".into(),
2543 seeds: vec!["root_fn".into()],
2544 likely_tests: Vec::new(),
2545 impacted_symbols,
2546 traversal_ceiling_reached: true,
2547 likely_tests_truncated: false,
2548 impacted_symbols_truncated: false,
2549 test_file_ceiling_reached: false,
2550 limit_at_maximum: false,
2551 likely_tests_found: 0,
2552 impacted_symbols_found: 0,
2553 };
2554
2555 let formatted = format_blast_radius(&res);
2556 assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
2557 }
2558
2559 #[test]
2560 fn blast_radius_does_not_claim_no_results_after_discovery_ceiling() {
2561 let res = BlastRadiusResult {
2562 seed_type: "file".into(),
2563 seeds: vec!["src/widget.rs".into()],
2564 likely_tests: Vec::new(),
2565 impacted_symbols: Vec::new(),
2566 likely_tests_truncated: false,
2567 impacted_symbols_truncated: false,
2568 traversal_ceiling_reached: true,
2569 test_file_ceiling_reached: true,
2570 limit_at_maximum: false,
2571 likely_tests_found: 0,
2572 impacted_symbols_found: 0,
2573 };
2574
2575 let formatted = format_blast_radius(&res);
2576 assert!(formatted.contains("Likely Tests to Run (0 returned)"));
2577 assert!(formatted.contains("Downstream Impact (0 returned)"));
2578 assert!(formatted.contains("Name-matched test discovery hit its fixed ceiling"));
2579 assert!(!formatted.contains("No direct or name-matched tests found"));
2580 assert!(!formatted.contains("No downstream callers found within depth"));
2581
2582 let no_ceiling = BlastRadiusResult {
2583 traversal_ceiling_reached: false,
2584 test_file_ceiling_reached: false,
2585 limit_at_maximum: false,
2586 ..res.clone()
2587 };
2588 assert!(format_blast_radius(&no_ceiling).contains("No direct or name-matched tests found"));
2589
2590 let traversal_only = BlastRadiusResult {
2591 traversal_ceiling_reached: true,
2592 test_file_ceiling_reached: false,
2593 limit_at_maximum: false,
2594 ..res
2595 };
2596 let traversal_only_text = format_blast_radius(&traversal_only);
2597 assert!(!traversal_only_text.contains("No direct or name-matched tests found"));
2598 }
2599
2600 fn skeleton_row(
2601 id: &str,
2602 parent: Option<&str>,
2603 kind: &str,
2604 name: &str,
2605 signature: &str,
2606 lines: (usize, usize),
2607 body: Option<(usize, usize)>,
2608 ) -> Symbol {
2609 Symbol {
2610 symbol_id: id.into(),
2611 file_id: "f1".into(),
2612 path: "src/lib.rs".into(),
2613 language: "rust".into(),
2614 name: name.into(),
2615 kind: kind.into(),
2616 signature: Some(signature.into()),
2617 doc_comment: None,
2618 visibility: None,
2619 parent_symbol_id: parent.map(str::to_string),
2620 start_line: lines.0,
2621 start_column: 0,
2622 end_line: lines.1,
2623 end_column: 1,
2624 start_byte: 0,
2625 end_byte: 0,
2626 body_start_line: body.map(|b| b.0),
2627 body_start_column: None,
2628 body_end_line: body.map(|b| b.1),
2629 body_end_column: None,
2630 body_start_byte: None,
2631 body_end_byte: None,
2632 body_hash: None,
2633 semantic_group: None,
2634 is_test: false,
2635 test_container: false,
2636 }
2637 }
2638
2639 #[test]
2640 fn skeleton_nests_an_object_under_the_field_that_declares_it() {
2641 let syms = vec![
2642 skeleton_row(
2643 "root",
2644 None,
2645 "class",
2646 "shell",
2647 "extends ShellRoot",
2648 (1, 8),
2649 None,
2650 ),
2651 skeleton_row(
2652 "timer",
2653 Some("root"),
2654 "field",
2655 "localPluginReloadTimer",
2656 "localPluginReloadTimer: Timer",
2657 (2, 7),
2658 Some((2, 7)),
2659 ),
2660 skeleton_row(
2661 "interval",
2662 Some("timer"),
2663 "property",
2664 "interval",
2665 "interval: 150",
2666 (3, 3),
2667 None,
2668 ),
2669 skeleton_row(
2670 "fire",
2671 Some("timer"),
2672 "function",
2673 "fire",
2674 "function fire()",
2675 (5, 7),
2676 Some((6, 7)),
2677 ),
2678 ];
2679
2680 assert_eq!(
2681 format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
2682 "// File: shell/shell.qml (Lines 1-8)\n\
2683 \n\
2684 extends ShellRoot {\n\
2685 \x20 localPluginReloadTimer: Timer {\n\
2686 \x20 interval: 150; // L3-3\n\
2687 \x20 function fire() { /* 2 lines hidden: L6-L7 */ }\n\
2688 \x20 } // L2-7\n\
2689 \n\
2690 } // L1-8\n\
2691 \n"
2692 );
2693 }
2694
2695 #[test]
2696 fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
2697 let syms = vec![
2698 skeleton_row(
2699 "rusqlite",
2700 None,
2701 "field",
2702 "rusqlite",
2703 "rusqlite = { workspace = true }",
2704 (15, 15),
2705 None,
2706 ),
2707 skeleton_row(
2708 "workspace",
2709 Some("rusqlite"),
2710 "property",
2711 "workspace",
2712 "workspace = true",
2713 (15, 15),
2714 None,
2715 ),
2716 ];
2717
2718 assert_eq!(
2719 format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
2720 "// File: Cargo.toml (Lines 1-15)\n\
2721 \n\
2722 rusqlite = { workspace = true }; // L15-15\n"
2723 );
2724 }
2725
2726 #[test]
2727 fn skeleton_renders_members_of_a_single_line_enum() {
2728 let mut syms = vec![
2729 skeleton_row(
2730 "mode",
2731 None,
2732 "enum",
2733 "DispatchMode",
2734 "public enum DispatchMode",
2735 (21, 21),
2736 None,
2737 ),
2738 skeleton_row(
2739 "provision",
2740 Some("mode"),
2741 "enum_member",
2742 "Provision",
2743 "Provision",
2744 (21, 21),
2745 None,
2746 ),
2747 skeleton_row(
2748 "deprovision",
2749 Some("mode"),
2750 "enum_member",
2751 "Deprovision",
2752 "Deprovision",
2753 (21, 21),
2754 None,
2755 ),
2756 skeleton_row(
2757 "extension",
2758 Some("mode"),
2759 "enum_member",
2760 "Extension",
2761 "Extension",
2762 (21, 21),
2763 None,
2764 ),
2765 ];
2766 for symbol in &mut syms {
2767 symbol.language = "csharp".into();
2768 }
2769
2770 let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
2771
2772 for member in ["Provision", "Deprovision", "Extension"] {
2773 assert!(skeleton.contains(member), "{skeleton}");
2774 }
2775 }
2776
2777 #[test]
2778 fn a_hidden_body_names_the_functions_defined_inside_it_but_not_lambdas() {
2779 let mut syms = vec![
2780 skeleton_row(
2781 "factory",
2782 None,
2783 "function",
2784 "create_app",
2785 "def create_app()",
2786 (1, 9),
2787 Some((2, 9)),
2788 ),
2789 skeleton_row(
2790 "hello",
2791 Some("factory"),
2792 "function",
2793 "hello",
2794 "def hello()",
2795 (4, 5),
2796 Some((5, 5)),
2797 ),
2798 skeleton_row(
2799 "lambda",
2800 Some("factory"),
2801 "function",
2802 "lambda_7",
2803 "lambda v: v",
2804 (7, 7),
2805 None,
2806 ),
2807 ];
2808 for symbol in &mut syms {
2809 symbol.language = "python".into();
2810 }
2811
2812 let skeleton = format_file_skeleton("app.py", &syms, Some(9), 0);
2813
2814 assert!(
2815 skeleton.contains("def create_app() { /* 8 lines hidden: L2-L9; defines `hello` */ }"),
2816 "{skeleton}"
2817 );
2818
2819 for symbol in &mut syms {
2820 symbol.language = "cpp".into();
2821 }
2822 let cpp = format_file_skeleton("app.cpp", &syms, Some(9), 0);
2823 assert!(!cpp.contains("defines"), "{cpp}");
2824 syms[1].test_container = true;
2825 let cpp_section = format_file_skeleton("app.cpp", &syms, Some(9), 0);
2826 assert!(cpp_section.contains("defines `hello`"), "{cpp_section}");
2827 }
2828
2829 #[test]
2830 fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
2831 let syms = vec![
2832 skeleton_row(
2833 "cfg",
2834 None,
2835 "struct",
2836 "Config",
2837 "pub struct Config",
2838 (1, 3),
2839 None,
2840 ),
2841 skeleton_row(
2842 "retries",
2843 Some("cfg"),
2844 "field",
2845 "retries",
2846 "pub retries: u32",
2847 (2, 2),
2848 None,
2849 ),
2850 skeleton_row(
2851 "run",
2852 None,
2853 "function",
2854 "run",
2855 "pub fn run()",
2856 (5, 9),
2857 Some((6, 8)),
2858 ),
2859 skeleton_row(
2860 "tmp",
2861 Some("run"),
2862 "variable",
2863 "tmp",
2864 "let tmp",
2865 (7, 7),
2866 None,
2867 ),
2868 ];
2869
2870 assert_eq!(
2871 format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
2872 "// File: src/lib.rs (Lines 1-9)\n\
2873 \n\
2874 pub struct Config {\n\
2875 \x20 pub retries: u32; // L2-2\n\
2876 } // L1-3\n\
2877 \n\
2878 pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
2879 );
2880 }
2881
2882 #[test]
2883 fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
2884 let syms = vec![
2885 skeleton_row(
2886 "cls",
2887 None,
2888 "class",
2889 "ColumnViewAttached",
2890 "class ColumnViewAttached : public QObject",
2891 (11, 52),
2892 None,
2893 ),
2894 skeleton_row(
2895 "sig",
2896 Some("cls"),
2897 "event",
2898 "indexChanged",
2899 "void indexChanged()",
2900 (47, 47),
2901 None,
2902 ),
2903 skeleton_row(
2904 "qml",
2905 None,
2906 "event",
2907 "clicked",
2908 "signal clicked()",
2909 (60, 60),
2910 None,
2911 ),
2912 ];
2913
2914 assert_eq!(
2915 format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
2916 "// File: src/columnview.h (Lines 1-60)\n\
2917 \n\
2918 class ColumnViewAttached : public QObject {\n\
2919 \x20 void indexChanged(); // event L47-47\n\
2920 } // L11-52\n\
2921 \n\
2922 signal clicked(); // L60-60\n"
2923 );
2924 }
2925}