1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::commands::outline::symbol_to_entry;
7use crate::commands::symbol_render::{
8 build_container_outline, format_qualified_entry, might_have_container_members,
9 qualified_symbol_name, render_container_member_menu, should_return_member_menu,
10 symbol_kind_string,
11};
12use crate::context::AppContext;
13use crate::edit::line_col_to_byte;
14use crate::language::LanguageProvider;
15use crate::lsp_hints;
16use crate::parser::{detect_language, FileParser, LangId};
17use crate::protocol::{RawRequest, Response};
18use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch};
19use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
20
21#[derive(Debug, Clone, Serialize)]
23pub struct CallRef {
24 pub name: String,
25 pub line: u32,
27 #[serde(skip_serializing_if = "is_zero")]
29 pub extra_count: u32,
30}
31
32fn is_zero(value: &u32) -> bool {
33 *value == 0
34}
35
36fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
37 let mut index_by_name: HashMap<String, usize> = HashMap::new();
38 let mut deduped: Vec<CallRef> = Vec::new();
39
40 for call in calls {
41 if let Some(index) = index_by_name.get(&call.name).copied() {
42 deduped[index].extra_count = deduped[index]
43 .extra_count
44 .saturating_add(call.extra_count.saturating_add(1));
45 } else {
46 index_by_name.insert(call.name.clone(), deduped.len());
47 deduped.push(call);
48 }
49 }
50
51 deduped
52}
53
54#[derive(Debug, Clone, Serialize)]
56pub struct Annotations {
57 pub calls_out: Vec<CallRef>,
58 pub called_by: Vec<CallRef>,
59}
60
61#[derive(Debug, Clone, Serialize)]
63pub struct ZoomResponse {
64 pub name: String,
65 pub kind: String,
66 pub range: Range,
67 pub content: String,
68 pub context_before: Vec<String>,
69 pub context_after: Vec<String>,
70 pub annotations: Annotations,
71}
72
73struct RawCall {
74 name: String,
75 line: u32,
76 start_byte: usize,
77 end_byte: usize,
78}
79
80fn resolve_file_or_url(
81 req: &RawRequest,
82 ctx: &AppContext,
83 file: &str,
84) -> Result<PathBuf, Response> {
85 if is_http_url(file) {
86 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
87 let allow_private = ctx.config().url_fetch_allow_private
88 || req
89 .params
90 .get("allow_private")
91 .and_then(|value| value.as_bool())
92 .unwrap_or(false);
93 return fetch_url_to_cache(
94 file,
95 &storage_dir,
96 UrlFetchOptions {
97 allow_private,
98 ..UrlFetchOptions::default()
99 },
100 )
101 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
102 }
103
104 ctx.validate_path(&req.id, Path::new(file))
105}
106
107fn zoom_one_target_response(
108 req: &RawRequest,
109 ctx: &AppContext,
110 file: &str,
111 symbol: &str,
112 context_lines: usize,
113 include_callgraph: bool,
114) -> Response {
115 let path = match resolve_file_or_url(req, ctx, file) {
116 Ok(path) => path,
117 Err(resp) => return resp,
118 };
119 if !path.exists() {
120 return Response::error(
121 &req.id,
122 "file_not_found",
123 format!("file not found: {}", file),
124 );
125 }
126
127 let source = match std::fs::read_to_string(&path) {
128 Ok(source) => source,
129 Err(error) => {
130 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, error));
131 }
132 };
133 let lines: Vec<String> = source.lines().map(|line| line.to_string()).collect();
134
135 zoom_one_symbol(
136 req,
137 ctx,
138 &path,
139 file,
140 &source,
141 &lines,
142 symbol,
143 context_lines,
144 include_callgraph,
145 )
146}
147
148fn serialize_zoom_target_response(req: &RawRequest, response: Response) -> serde_json::Value {
149 serde_json::to_value(&response).unwrap_or_else(|error| {
150 serde_json::to_value(Response::error(
151 &req.id,
152 "internal_error",
153 format!("zoom: failed to serialize target response: {error}"),
154 ))
155 .expect("serializing Response::error should not fail")
156 })
157}
158
159fn handle_zoom_targets(
160 req: &RawRequest,
161 ctx: &AppContext,
162 targets: &[serde_json::Value],
163 context_lines: usize,
164 include_callgraph: bool,
165) -> Response {
166 if targets.is_empty() {
167 return Response::error(
168 &req.id,
169 "invalid_request",
170 "zoom: 'targets' must be a non-empty array",
171 );
172 }
173
174 let mut entries = Vec::with_capacity(targets.len());
175 for (index, target) in targets.iter().enumerate() {
176 let obj = target.as_object();
177 let Some(file) = obj
178 .and_then(|obj| obj.get("file"))
179 .and_then(|value| value.as_str())
180 .filter(|file| !file.is_empty())
181 else {
182 return Response::error(
183 &req.id,
184 "invalid_request",
185 format!("zoom: targets[{index}].file must be a non-empty string"),
186 );
187 };
188 let Some(symbol) = obj
189 .and_then(|obj| obj.get("symbol"))
190 .and_then(|value| value.as_str())
191 .filter(|symbol| !symbol.is_empty())
192 else {
193 return Response::error(
194 &req.id,
195 "invalid_request",
196 format!("zoom: targets[{index}].symbol must be a non-empty string"),
197 );
198 };
199 let target_label = obj
200 .and_then(|obj| obj.get("target_label").or_else(|| obj.get("targetLabel")))
201 .and_then(|value| value.as_str())
202 .filter(|label| !label.is_empty())
203 .unwrap_or(file);
204
205 let response =
206 zoom_one_target_response(req, ctx, file, symbol, context_lines, include_callgraph);
207 entries.push(serde_json::json!({
208 "targetLabel": target_label,
209 "name": symbol,
210 "response": serialize_zoom_target_response(req, response),
211 }));
212 }
213
214 Response::success(
215 &req.id,
216 serde_json::json!({
217 "targets": entries,
218 }),
219 )
220}
221
222pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
229 let context_lines = req
230 .params
231 .get("context_lines")
232 .and_then(|v| v.as_u64())
233 .unwrap_or(3) as usize;
234 let include_callgraph = req
235 .params
236 .get("callgraph")
237 .and_then(|v| v.as_bool())
238 .unwrap_or(false);
239
240 if let Some(targets_value) = req.params.get("targets") {
241 let Some(targets) = targets_value.as_array() else {
242 return Response::error(
243 &req.id,
244 "invalid_request",
245 "zoom: 'targets' must be a non-empty array",
246 );
247 };
248 return handle_zoom_targets(req, ctx, targets, context_lines, include_callgraph);
249 }
250
251 let file = match req
252 .params
253 .get("file")
254 .or_else(|| req.params.get("url"))
255 .and_then(|v| v.as_str())
256 {
257 Some(f) => f,
258 None => {
259 return Response::error(
260 &req.id,
261 "invalid_request",
262 "zoom: missing required param 'file'",
263 );
264 }
265 };
266
267 let start_line = req
268 .params
269 .get("start_line")
270 .and_then(|v| v.as_u64())
271 .map(|v| v as usize);
272 let end_line = req
273 .params
274 .get("end_line")
275 .and_then(|v| v.as_u64())
276 .map(|v| v as usize);
277
278 let path = match resolve_file_or_url(req, ctx, file) {
279 Ok(path) => path,
280 Err(resp) => return resp,
281 };
282 if !path.exists() {
283 return Response::error(
284 &req.id,
285 "file_not_found",
286 format!("file not found: {}", file),
287 );
288 }
289
290 let source = match std::fs::read_to_string(&path) {
292 Ok(s) => s,
293 Err(e) => {
294 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
295 }
296 };
297
298 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
299
300 match (start_line, end_line) {
302 (Some(start), Some(end)) => {
303 if zoom_symbol_param(&req.params).is_some() {
304 return Response::error(
305 &req.id,
306 "invalid_request",
307 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
308 );
309 }
310 if start == 0 || end == 0 {
311 return Response::error(
312 &req.id,
313 "invalid_request",
314 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
315 );
316 }
317 if end < start {
318 return Response::error(
319 &req.id,
320 "invalid_request",
321 format!("zoom: end_line {} must be >= start_line {}", end, start),
322 );
323 }
324 if lines.is_empty() {
325 return Response::error(
326 &req.id,
327 "invalid_request",
328 format!("zoom: {} is empty", file),
329 );
330 }
331
332 let start_idx = start - 1;
333 let clamped_end = end.min(lines.len());
335 let end_idx = clamped_end - 1;
336 if start_idx >= lines.len() {
337 return Response::error(
338 &req.id,
339 "invalid_request",
340 format!(
341 "zoom: start_line {} is past end of {} ({} lines)",
342 start,
343 file,
344 lines.len()
345 ),
346 );
347 }
348
349 let content = lines[start_idx..=end_idx].join("\n");
350 let ctx_start = start_idx.saturating_sub(context_lines);
351 let context_before: Vec<String> = if ctx_start < start_idx {
352 lines[ctx_start..start_idx]
353 .iter()
354 .map(|l| l.to_string())
355 .collect()
356 } else {
357 vec![]
358 };
359 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
360 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
361 lines[(end_idx + 1)..ctx_end]
362 .iter()
363 .map(|l| l.to_string())
364 .collect()
365 } else {
366 vec![]
367 };
368 let end_col = lines[end_idx].chars().count() as u32;
369
370 return Response::success(
371 &req.id,
372 serde_json::json!({
373 "name": format!("lines {}-{}", start, clamped_end),
374 "kind": "lines",
375 "range": {
376 "start_line": start, "start_col": 1,
378 "end_line": clamped_end,
379 "end_col": end_col + 1,
380 },
381 "content": content,
382 "context_before": context_before,
383 "context_after": context_after,
384 "annotations": {
385 "calls_out": [],
386 "called_by": [],
387 },
388 }),
389 );
390 }
391 (Some(_), None) | (None, Some(_)) => {
392 return Response::error(
393 &req.id,
394 "invalid_request",
395 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
396 );
397 }
398 (None, None) => {}
399 }
400
401 let lang = detect_language(&path);
402 let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
403 Ok(names) => names,
404 Err(resp) => return resp,
405 };
406
407 if symbol_names.is_empty() {
408 return Response::error(
409 &req.id,
410 "invalid_request",
411 "zoom: missing required param 'symbol'",
412 );
413 }
414
415 if symbol_names.len() == 1 {
416 return zoom_one_symbol(
417 req,
418 ctx,
419 &path,
420 file,
421 &source,
422 &lines,
423 &symbol_names[0],
424 context_lines,
425 include_callgraph,
426 );
427 }
428
429 zoom_batch_symbols(
430 req,
431 ctx,
432 &path,
433 file,
434 &source,
435 &lines,
436 &symbol_names,
437 context_lines,
438 include_callgraph,
439 )
440}
441
442fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
444 params
445 .get("symbol")
446 .or_else(|| params.get("symbols"))
447 .and_then(|v| v.as_str())
448}
449
450fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
451 matches!(lang, Some(LangId::Markdown | LangId::Html))
452}
453
454fn parse_zoom_symbol_names(
459 params: &serde_json::Value,
460 lang: Option<LangId>,
461) -> Result<Vec<String>, Response> {
462 if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
463 let names: Vec<String> = arr
464 .iter()
465 .filter_map(|v| v.as_str().map(str::trim))
466 .filter(|s| !s.is_empty())
467 .map(str::to_string)
468 .collect();
469 return Ok(names);
470 }
471
472 let Some(raw) = zoom_symbol_param(params) else {
473 return Ok(Vec::new());
474 };
475
476 if is_heading_zoom_language(lang) {
477 let trimmed = raw.trim();
478 if trimmed.is_empty() {
479 return Ok(Vec::new());
480 }
481 return Ok(vec![trimmed.to_string()]);
482 }
483
484 if raw.split_whitespace().count() <= 1 {
485 let trimmed = raw.trim();
486 if trimmed.is_empty() {
487 return Ok(Vec::new());
488 }
489 return Ok(vec![trimmed.to_string()]);
490 }
491
492 Ok(raw.split_whitespace().map(str::to_string).collect())
493}
494
495fn zoom_batch_symbols(
496 req: &RawRequest,
497 ctx: &AppContext,
498 path: &Path,
499 file: &str,
500 source: &str,
501 lines: &[String],
502 symbol_names: &[String],
503 context_lines: usize,
504 include_callgraph: bool,
505) -> Response {
506 let mut entries = Vec::with_capacity(symbol_names.len());
507 let mut all_ok = true;
508
509 for name in symbol_names {
510 let resp = zoom_one_symbol(
511 req,
512 ctx,
513 path,
514 file,
515 source,
516 lines,
517 name,
518 context_lines,
519 include_callgraph,
520 );
521 let json = match serde_json::to_value(&resp) {
522 Ok(v) => v,
523 Err(err) => {
524 return Response::error(
525 &req.id,
526 "internal_error",
527 format!("zoom: failed to serialize batch entry: {err}"),
528 );
529 }
530 };
531 if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
532 all_ok = false;
533 }
534 entries.push(serde_json::json!({
535 "name": name,
536 "response": json,
537 }));
538 }
539
540 Response::success(
541 &req.id,
542 serde_json::json!({
543 "complete": all_ok,
544 "symbols": entries,
545 }),
546 )
547}
548
549fn zoom_one_symbol(
550 req: &RawRequest,
551 ctx: &AppContext,
552 path: &Path,
553 _file: &str,
554 source: &str,
555 lines: &[String],
556 symbol_name: &str,
557 context_lines: usize,
558 include_callgraph: bool,
559) -> Response {
560 let is_heading = is_heading_zoom_language(detect_language(path));
564 let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
565 Ok(matches) => matches,
566 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
567 };
568
569 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
571 lsp_hints::apply_lsp_disambiguation(matches, &hints)
572 } else {
573 matches
574 };
575
576 if matches.len() > 1 {
577 let content = render_ambiguous_symbol_menu(symbol_name, &matches);
578 let candidates = matches
579 .iter()
580 .map(|candidate| {
581 let sym = &candidate.symbol;
582 serde_json::json!({
583 "name": sym.name.clone(),
584 "qualified_name": qualified_symbol_name(sym),
585 "kind": symbol_kind_string(&sym.kind),
586 "range": sym.range.clone(),
587 "signature": sym.signature.clone(),
588 })
589 })
590 .collect::<Vec<_>>();
591
592 return Response::success(
593 &req.id,
594 serde_json::json!({
595 "name": symbol_name,
596 "kind": "ambiguous_symbol",
597 "content": content,
598 "context_before": [],
599 "context_after": [],
600 "annotations": empty_annotations(),
601 "candidates": candidates,
602 }),
603 );
604 }
605
606 if matches.is_empty() {
607 let mut msg = format!("symbol '{}' not found", symbol_name);
608 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
609 let suggestions = if is_heading {
610 suggest_heading_symbols(symbol_name, &all_symbols, 5)
611 } else {
612 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
613 suggest_close_symbols(symbol_name, &available, 5)
614 };
615 if !suggestions.is_empty() {
616 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
617 }
618 }
619 return Response::error(&req.id, "symbol_not_found", msg);
620 }
621
622 let target = &matches[0].symbol;
623 let start = target.range.start_line as usize;
624 let end = target.range.end_line as usize;
625
626 let resolved_file_path = std::path::Path::new(&matches[0].file);
628 let resolved_lines: Vec<String>;
629 let effective_lines: &[String] = if resolved_file_path != path {
630 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
631 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
632 Err(_) => lines.to_vec(),
633 };
634 &resolved_lines
635 } else {
636 lines
637 };
638
639 let content = if end < effective_lines.len() {
641 effective_lines[start..=end].join("\n")
642 } else {
643 effective_lines[start..].join("\n")
644 };
645
646 let resolved_lang = detect_language(resolved_file_path);
647 let container_outline = if might_have_container_members(target) {
648 match build_container_outline(ctx, resolved_file_path, target) {
649 Ok(outline) => Some(outline),
650 Err(e) => {
651 return Response::error(&req.id, e.code(), e.to_string());
652 }
653 }
654 } else {
655 None
656 };
657
658 if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
659 let kind_str = symbol_kind_string(&target.kind);
660 let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
661 let resp = ZoomResponse {
662 name: target.name.clone(),
663 kind: kind_str,
664 range: target.range.clone(),
665 content: menu,
666 context_before: Vec::new(),
667 context_after: Vec::new(),
668 annotations: Annotations {
669 calls_out: Vec::new(),
670 called_by: Vec::new(),
671 },
672 };
673 return match serde_json::to_value(&resp) {
674 Ok(resp_json) => Response::success(&req.id, resp_json),
675 Err(err) => Response::error(
676 &req.id,
677 "internal_error",
678 format!("zoom: failed to serialize response: {err}"),
679 ),
680 };
681 }
682
683 let ctx_start = start.saturating_sub(context_lines);
685 let context_before: Vec<String> = if ctx_start < start {
686 effective_lines[ctx_start..start]
687 .iter()
688 .map(|l| l.to_string())
689 .collect()
690 } else {
691 vec![]
692 };
693
694 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
696 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
697 effective_lines[(end + 1)..ctx_end]
698 .iter()
699 .map(|l| l.to_string())
700 .collect()
701 } else {
702 vec![]
703 };
704
705 let (calls_out, called_by) = if include_callgraph {
706 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
708 Ok(s) => s,
709 Err(e) => {
710 return Response::error(&req.id, e.code(), e.to_string());
711 }
712 };
713
714 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
715
716 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
718 let (tree, lang) = match parser.parse(resolved_file_path) {
719 Ok(r) => r,
720 Err(e) => {
721 return Response::error(&req.id, e.code(), e.to_string());
722 }
723 };
724
725 let resolved_source = if resolved_file_path != path {
727 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
728 } else {
729 source.to_string()
730 };
731 let signature_byte_start = line_col_to_byte(
732 &resolved_source,
733 target.range.start_line,
734 target.range.start_col,
735 );
736 let signature_byte_end = line_col_to_byte(
737 &resolved_source,
738 target.range.end_line,
739 target.range.end_col,
740 );
741 let (target_byte_start, target_byte_end) =
742 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
743 .unwrap_or((signature_byte_start, signature_byte_end));
744
745 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
746
747 let raw_calls = all_file_calls.iter().filter(|call| {
748 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
749 });
750 let calls_out = dedupe_call_refs_by_name(
751 raw_calls
752 .filter(|call| {
753 known_names.contains(&call.name.as_str()) && call.name != target.name
754 })
755 .map(|call| CallRef {
756 name: call.name.clone(),
757 line: call.line,
758 extra_count: 0,
759 })
760 .collect(),
761 );
762
763 let mut called_by: Vec<CallRef> = Vec::new();
765 for sym in &all_symbols {
766 if sym.name == target.name && sym.range.start_line == target.range.start_line {
767 continue; }
769 let sym_byte_start =
770 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
771 let sym_byte_end =
772 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
773 for call in &all_file_calls {
774 if call.name == target.name
775 && call.start_byte >= sym_byte_start
776 && call.end_byte <= sym_byte_end
777 {
778 called_by.push(CallRef {
779 name: sym.name.clone(),
780 line: call.line,
781 extra_count: 0,
782 });
783 }
784 }
785 }
786
787 let called_by = dedupe_call_refs_by_name(called_by);
788
789 (calls_out, called_by)
790 } else {
791 (Vec::new(), Vec::new())
792 };
793
794 let kind_str = symbol_kind_string(&target.kind);
795
796 let resp = ZoomResponse {
797 name: target.name.clone(),
798 kind: kind_str,
799 range: target.range.clone(),
800 content,
801 context_before,
802 context_after,
803 annotations: Annotations {
804 calls_out,
805 called_by,
806 },
807 };
808
809 match serde_json::to_value(&resp) {
810 Ok(resp_json) => Response::success(&req.id, resp_json),
811 Err(err) => Response::error(
812 &req.id,
813 "internal_error",
814 format!("zoom: failed to serialize response: {err}"),
815 ),
816 }
817}
818
819fn empty_annotations() -> serde_json::Value {
820 serde_json::json!({
821 "calls_out": [],
822 "called_by": [],
823 })
824}
825
826fn render_ambiguous_symbol_menu(
827 symbol_name: &str,
828 matches: &[crate::symbols::SymbolMatch],
829) -> String {
830 let mut lines = vec![format!(
831 "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
832 matches.len()
833 )];
834
835 for candidate in matches {
836 let entry = symbol_to_entry(&candidate.symbol);
837 lines.push(format!(
838 "- {}",
839 format_qualified_entry(&entry, Some(&candidate.symbol))
840 ));
841 }
842
843 lines.join("\n")
844}
845
846fn levenshtein_distance(s1: &str, s2: &str) -> usize {
847 let s1_chars: Vec<char> = s1.chars().collect();
848 let s2_chars: Vec<char> = s2.chars().collect();
849 let len1 = s1_chars.len();
850 let len2 = s2_chars.len();
851
852 let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
853
854 for i in 0..=len1 {
855 dp[i][0] = i;
856 }
857 for j in 0..=len2 {
858 dp[0][j] = j;
859 }
860
861 for i in 1..=len1 {
862 for j in 1..=len2 {
863 if s1_chars[i - 1] == s2_chars[j - 1] {
864 dp[i][j] = dp[i - 1][j - 1];
865 } else {
866 dp[i][j] =
867 1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
868 }
869 }
870 }
871
872 dp[len1][len2]
873}
874
875fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
876 let mut unique: Vec<&String> = available.iter().collect();
877 unique.sort();
878 unique.dedup();
879
880 let query_lower = query.to_lowercase();
881 let query_len = query_lower.chars().count();
882 let max_dist = std::cmp::max(2, query_len / 3);
883
884 let mut scored: Vec<(bool, usize, &String)> = unique
885 .into_iter()
886 .map(|name| {
887 let name_lower = name.to_lowercase();
888 let is_substring =
889 name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
890 let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
891 (query_lower.find('_'), query_lower.rfind('_'))
892 {
893 let prefix = &query_lower[..=first_idx];
894 let suffix = &query_lower[last_idx..];
895 name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
896 } else {
897 false
898 };
899 let is_match = is_substring || is_wildcard;
900 let dist = levenshtein_distance(&query_lower, &name_lower);
901 (is_match, dist, name)
902 })
903 .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
904 .collect();
905
906 scored.sort_by(|a, b| {
907 let a_match = a.0;
908 let b_match = b.0;
909 (!a_match)
910 .cmp(&(!b_match))
911 .then_with(|| a.1.cmp(&b.1))
912 .then_with(|| a.2.cmp(b.2))
913 });
914
915 scored
916 .into_iter()
917 .take(k)
918 .map(|(_, _, name)| name.clone())
919 .collect()
920}
921
922fn resolve_zoom_symbol(
923 provider: &dyn LanguageProvider,
924 path: &Path,
925 query: &str,
926 is_heading: bool,
927) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
928 if is_heading {
929 return resolve_heading_symbols(provider, path, query);
930 }
931
932 match provider.resolve_symbol(path, query) {
933 Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
934 result => result,
935 }
936}
937
938fn resolve_heading_symbols(
941 provider: &dyn LanguageProvider,
942 path: &Path,
943 query: &str,
944) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
945 let headings: Vec<SymbolMatch> = provider
946 .list_symbols(path)?
947 .into_iter()
948 .filter(|symbol| symbol.kind == SymbolKind::Heading)
949 .map(|symbol| SymbolMatch {
950 file: path.display().to_string(),
951 symbol,
952 })
953 .collect();
954
955 Ok(match_heading_identity(&headings, query))
956}
957
958fn match_heading_identity(headings: &[SymbolMatch], query: &str) -> Vec<SymbolMatch> {
959 let exact: Vec<_> = headings
960 .iter()
961 .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
962 .cloned()
963 .collect();
964 if !exact.is_empty() {
965 return exact;
966 }
967
968 let normalized_query = normalize_heading_label(query);
969 if normalized_query.is_empty() {
970 return Vec::new();
971 }
972
973 let normalized: Vec<_> = headings
974 .iter()
975 .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
976 .cloned()
977 .collect();
978 if !normalized.is_empty() {
979 return normalized;
980 }
981
982 let folded_query = normalized_query.to_lowercase();
983 let case_insensitive: Vec<_> = headings
984 .iter()
985 .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
986 .cloned()
987 .collect();
988 if !case_insensitive.is_empty() {
989 return case_insensitive;
990 }
991
992 let query_slug = slugify_heading_label(&normalized_query);
993 if query_slug.is_empty() {
994 return Vec::new();
995 }
996
997 headings
998 .iter()
999 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1000 .cloned()
1001 .collect()
1002}
1003
1004fn qualified_heading_name(symbol: &Symbol) -> String {
1005 if symbol.scope_chain.is_empty() {
1006 return symbol.name.clone();
1007 }
1008 format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1009}
1010
1011fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1012 symbol.name == query || qualified_heading_name(symbol) == query
1013}
1014
1015fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1016 normalize_heading_label(&symbol.name) == query
1017 || normalize_heading_label(&qualified_heading_name(symbol)) == query
1018}
1019
1020fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1021 normalize_heading_label(&symbol.name).to_lowercase() == query
1022 || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1023}
1024
1025fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1026 slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1027 || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1028 == query_slug
1029}
1030
1031fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1032 let available: Vec<String> = symbols
1033 .iter()
1034 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1035 .map(|symbol| normalize_heading_label(&symbol.name))
1036 .filter(|name| !name.is_empty())
1037 .collect();
1038 let normalized_query = normalize_heading_label(query);
1039 if normalized_query.is_empty() {
1040 return Vec::new();
1041 }
1042 suggest_close_symbols(&normalized_query, &available, k)
1043}
1044
1045fn normalize_heading_label(input: &str) -> String {
1046 let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1047
1048 for _ in 0..4 {
1051 let mut next = value.as_str();
1052 let without_heading_markers = next.trim_start_matches('#').trim_start();
1053 if without_heading_markers != next {
1054 next = without_heading_markers;
1055 }
1056 if let Some(rest) = strip_heading_html_prefix(next) {
1057 next = rest;
1058 }
1059 if let Some(rest) = strip_leading_section_prefix(next) {
1060 next = rest;
1061 }
1062 if let Some(rest) = strip_leading_symbol_cluster(next) {
1063 next = rest;
1064 }
1065
1066 let collapsed = collapse_heading_whitespace(next);
1067 if collapsed == value {
1068 break;
1069 }
1070 value = collapsed;
1071 }
1072
1073 value
1074}
1075
1076fn collapse_heading_whitespace(input: &str) -> String {
1077 input.split_whitespace().collect::<Vec<_>>().join(" ")
1078}
1079
1080fn strip_markdown_links(input: &str) -> String {
1081 let mut output = String::with_capacity(input.len());
1082 let mut cursor = 0;
1083
1084 while let Some(relative_open) = input[cursor..].find('[') {
1085 let open = cursor + relative_open;
1086 let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1087 output.push_str(&input[cursor..]);
1088 return output;
1089 };
1090
1091 if input.as_bytes().get(close + 1) != Some(&b'(') {
1092 output.push_str(&input[cursor..=close]);
1093 cursor = close + 1;
1094 continue;
1095 }
1096
1097 let target_open = close + 1;
1098 let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1099 output.push_str(&input[cursor..]);
1100 return output;
1101 };
1102
1103 output.push_str(&input[cursor..open]);
1104 output.push_str(&input[open + 1..close]);
1105 cursor = target_close + 1;
1106 }
1107
1108 output.push_str(&input[cursor..]);
1109 output
1110}
1111
1112fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1113 let mut depth = 0;
1114 for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1115 if *byte == open {
1116 depth += 1;
1117 } else if *byte == close {
1118 depth -= 1;
1119 if depth == 0 {
1120 return Some(index);
1121 }
1122 }
1123 }
1124 None
1125}
1126
1127fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1128 let input = input.trim_start();
1129 let bytes = input.as_bytes();
1130 if bytes.first() != Some(&b'<') {
1131 return None;
1132 }
1133
1134 let mut index = 1;
1135 if bytes.get(index) == Some(&b'/') {
1136 index += 1;
1137 }
1138 if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1139 return None;
1140 }
1141 index += 1;
1142 if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1143 return None;
1144 }
1145
1146 let end = input.find('>')?;
1147 let rest = input[end + 1..].trim_start();
1148 if rest.chars().any(|character| character.is_alphanumeric()) {
1149 Some(rest)
1150 } else {
1151 None
1152 }
1153}
1154
1155fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1156 let bytes = input.as_bytes();
1157 let mut index = 0;
1158 let mut saw_dot = false;
1159
1160 if !bytes
1161 .first()
1162 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1163 {
1164 return None;
1165 }
1166
1167 while index < bytes.len() {
1168 while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1169 index += 1;
1170 }
1171 if bytes.get(index) != Some(&b'.') {
1172 break;
1173 }
1174 saw_dot = true;
1175 index += 1;
1176 if bytes
1177 .get(index)
1178 .is_some_and(|byte| byte.is_ascii_whitespace())
1179 {
1180 let rest = input[index..].trim_start();
1181 return if rest.chars().any(|character| character.is_alphanumeric()) {
1182 Some(rest)
1183 } else {
1184 None
1185 };
1186 }
1187 if !bytes
1188 .get(index)
1189 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1190 {
1191 return None;
1192 }
1193 }
1194
1195 if saw_dot
1196 && bytes
1197 .get(index)
1198 .is_some_and(|byte| byte.is_ascii_whitespace())
1199 {
1200 let rest = input[index..].trim_start();
1201 if rest.chars().any(|character| character.is_alphanumeric()) {
1202 return Some(rest);
1203 }
1204 }
1205 None
1206}
1207
1208fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1209 let first_text = input
1210 .char_indices()
1211 .find(|(_, character)| character.is_alphanumeric())
1212 .map(|(index, _)| index)?;
1213 if first_text == 0 {
1214 return None;
1215 }
1216
1217 let rest = &input[first_text..];
1218 if rest.chars().any(|character| character.is_alphanumeric()) {
1219 Some(rest)
1220 } else {
1221 None
1222 }
1223}
1224
1225fn slugify_heading_label(label: &str) -> String {
1226 let mut slug = String::new();
1227 let mut pending_separator = false;
1228
1229 for character in label.chars() {
1230 if character.is_alphanumeric() {
1231 if pending_separator && !slug.is_empty() {
1232 slug.push('-');
1233 }
1234 for lowercase in character.to_lowercase() {
1235 slug.push(lowercase);
1236 }
1237 pending_separator = false;
1238 } else if !slug.is_empty() {
1239 pending_separator = true;
1240 }
1241 }
1242
1243 slug
1244}
1245
1246#[cfg(test)]
1250fn extract_calls_in_range(
1251 source: &str,
1252 root: tree_sitter::Node,
1253 byte_start: usize,
1254 byte_end: usize,
1255 lang: LangId,
1256) -> Vec<(String, u32)> {
1257 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1258}
1259
1260fn symbol_body_byte_range(
1261 root: tree_sitter::Node,
1262 byte_start: usize,
1263 byte_end: usize,
1264) -> Option<(usize, usize)> {
1265 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1266 let mut current = Some(node);
1267 while let Some(node) = current {
1268 if is_symbol_body_node(node.kind()) {
1269 return Some((node.start_byte(), node.end_byte()));
1270 }
1271 current = node.parent();
1272 }
1273 Some((node.start_byte(), node.end_byte()))
1274}
1275
1276fn smallest_node_covering_range<'tree>(
1277 node: tree_sitter::Node<'tree>,
1278 byte_start: usize,
1279 byte_end: usize,
1280) -> Option<tree_sitter::Node<'tree>> {
1281 if node.start_byte() > byte_start || node.end_byte() < byte_end {
1282 return None;
1283 }
1284
1285 let mut cursor = node.walk();
1286 if cursor.goto_first_child() {
1287 loop {
1288 let child = cursor.node();
1289 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1290 return Some(found);
1291 }
1292 if !cursor.goto_next_sibling() {
1293 break;
1294 }
1295 }
1296 }
1297
1298 Some(node)
1299}
1300
1301fn is_symbol_body_node(kind: &str) -> bool {
1302 matches!(
1303 kind,
1304 "function_declaration"
1305 | "generator_function_declaration"
1306 | "function_expression"
1307 | "generator_function"
1308 | "arrow_function"
1309 | "method_definition"
1310 | "class_declaration"
1311 | "abstract_class_declaration"
1312 | "class"
1313 | "lexical_declaration"
1314 | "function_definition"
1315 | "class_definition"
1316 | "decorated_definition"
1317 | "function_item"
1318 | "impl_item"
1319 | "method_declaration"
1320 )
1321}
1322
1323fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1324 let mut results = Vec::new();
1325 let call_kinds = crate::calls::call_node_kinds(lang);
1326 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1327 results
1328}
1329
1330fn collect_calls_with_ranges(
1331 node: tree_sitter::Node,
1332 source: &str,
1333 call_kinds: &[&str],
1334 results: &mut Vec<RawCall>,
1335) {
1336 if call_kinds.contains(&node.kind()) {
1337 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1338 results.push(RawCall {
1339 name,
1340 line: node.start_position().row as u32 + 1,
1341 start_byte: node.start_byte(),
1342 end_byte: node.end_byte(),
1343 });
1344 }
1345 }
1346
1347 let mut cursor = node.walk();
1348 if cursor.goto_first_child() {
1349 loop {
1350 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1351 if !cursor.goto_next_sibling() {
1352 break;
1353 }
1354 }
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361 use crate::config::Config;
1362 use crate::context::AppContext;
1363 use crate::parser::TreeSitterProvider;
1364 use std::path::PathBuf;
1365
1366 fn fixture_path(name: &str) -> PathBuf {
1367 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1368 .join("tests")
1369 .join("fixtures")
1370 .join(name)
1371 }
1372
1373 fn make_ctx() -> AppContext {
1374 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1375 }
1376
1377 #[test]
1378 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1379 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1380 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1381 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1382 }
1383
1384 #[test]
1385 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1386 let params = serde_json::json!({ "symbols": "Getting Started" });
1387 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1388 assert_eq!(names, vec!["Getting Started"]);
1389 }
1390
1391 #[test]
1392 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1393 let params = serde_json::json!({ "symbol": "Last Heading" });
1394 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1395 assert_eq!(names, vec!["Last Heading"]);
1396 }
1397
1398 #[test]
1399 fn parse_zoom_symbol_names_single_token_unchanged() {
1400 let params = serde_json::json!({ "symbol": "compute" });
1401 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1402 assert_eq!(names, vec!["compute"]);
1403 }
1404
1405 #[test]
1406 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1407 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1408 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1409 assert_eq!(names, vec!["A", "B", "C"]);
1410 }
1411
1412 #[test]
1415 fn extract_calls_finds_direct_calls() {
1416 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1417 let mut parser = FileParser::new();
1418 let path = fixture_path("calls.ts");
1419 let (tree, lang) = parser.parse(&path).unwrap();
1420
1421 let ctx = make_ctx();
1423 let symbols = ctx.provider().list_symbols(&path).unwrap();
1424 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1425
1426 let byte_start =
1427 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1428 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1429
1430 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1431 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1432
1433 assert!(
1434 names.contains(&"helper"),
1435 "compute should call helper, got: {:?}",
1436 names
1437 );
1438 }
1439
1440 #[test]
1441 fn extract_calls_finds_member_calls() {
1442 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1443 let mut parser = FileParser::new();
1444 let path = fixture_path("calls.ts");
1445 let (tree, lang) = parser.parse(&path).unwrap();
1446
1447 let ctx = make_ctx();
1448 let symbols = ctx.provider().list_symbols(&path).unwrap();
1449 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1450
1451 let byte_start =
1452 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1453 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1454
1455 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1456 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1457
1458 assert!(
1459 names.contains(&"add"),
1460 "runAll should call this.add, got: {:?}",
1461 names
1462 );
1463 assert!(
1464 names.contains(&"helper"),
1465 "runAll should call helper, got: {:?}",
1466 names
1467 );
1468 }
1469
1470 #[test]
1471 fn extract_calls_unused_function_has_no_calls() {
1472 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1473 let mut parser = FileParser::new();
1474 let path = fixture_path("calls.ts");
1475 let (tree, lang) = parser.parse(&path).unwrap();
1476
1477 let ctx = make_ctx();
1478 let symbols = ctx.provider().list_symbols(&path).unwrap();
1479 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1480
1481 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1482 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1483
1484 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1485 let known_names = [
1487 "helper",
1488 "compute",
1489 "orchestrate",
1490 "unused",
1491 "format",
1492 "display",
1493 ];
1494 let filtered: Vec<&str> = calls
1495 .iter()
1496 .map(|(n, _)| n.as_str())
1497 .filter(|n| known_names.contains(n))
1498 .collect();
1499 assert!(
1500 filtered.is_empty(),
1501 "unused should not call known symbols, got: {:?}",
1502 filtered
1503 );
1504 }
1505
1506 #[test]
1509 fn context_lines_clamp_at_file_start() {
1510 let ctx = make_ctx();
1512 let path = fixture_path("calls.ts");
1513 let symbols = ctx.provider().list_symbols(&path).unwrap();
1514 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1515
1516 let source = std::fs::read_to_string(&path).unwrap();
1517 let lines: Vec<&str> = source.lines().collect();
1518 let start = helper.range.start_line as usize;
1519
1520 let ctx_start = start.saturating_sub(5);
1522 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1523 assert!(context_before.len() <= start);
1525 }
1526
1527 #[test]
1528 fn context_lines_clamp_at_file_end() {
1529 let ctx = make_ctx();
1530 let path = fixture_path("calls.ts");
1531 let symbols = ctx.provider().list_symbols(&path).unwrap();
1532 let display = symbols.iter().find(|s| s.name == "display").unwrap();
1533
1534 let source = std::fs::read_to_string(&path).unwrap();
1535 let lines: Vec<&str> = source.lines().collect();
1536 let end = display.range.end_line as usize;
1537
1538 let ctx_end = (end + 1 + 20).min(lines.len());
1540 let context_after: Vec<&str> = if end + 1 < lines.len() {
1541 lines[(end + 1)..ctx_end].to_vec()
1542 } else {
1543 vec![]
1544 };
1545 assert!(context_after.len() <= 20);
1547 }
1548
1549 #[test]
1552 fn body_extraction_matches_source() {
1553 let ctx = make_ctx();
1554 let path = fixture_path("calls.ts");
1555 let symbols = ctx.provider().list_symbols(&path).unwrap();
1556 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1557
1558 let source = std::fs::read_to_string(&path).unwrap();
1559 let lines: Vec<&str> = source.lines().collect();
1560 let start = compute.range.start_line as usize;
1561 let end = compute.range.end_line as usize;
1562 let body = lines[start..=end].join("\n");
1563
1564 assert!(
1565 body.contains("function compute"),
1566 "body should contain function declaration"
1567 );
1568 assert!(
1569 body.contains("helper(a)"),
1570 "body should contain call to helper"
1571 );
1572 assert!(
1573 body.contains("doubled + b"),
1574 "body should contain return expression"
1575 );
1576 }
1577
1578 #[test]
1581 fn body_range_expands_signature_range_to_include_body_calls() {
1582 let source = r#"function compute(
1583 value: number,
1584): number {
1585 return helper(value);
1586}
1587
1588function helper(value: number): number {
1589 return value * 2;
1590}
1591"#;
1592 let grammar = crate::parser::grammar_for(LangId::TypeScript);
1593 let mut parser = tree_sitter::Parser::new();
1594 parser.set_language(&grammar).unwrap();
1595 let tree = parser.parse(source, None).unwrap();
1596 let signature_end = source.find('{').expect("function has body");
1597
1598 let (body_start, body_end) =
1599 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1600 let calls = extract_calls_in_range(
1601 source,
1602 tree.root_node(),
1603 body_start,
1604 body_end,
1605 LangId::TypeScript,
1606 );
1607 let names = calls
1608 .iter()
1609 .map(|(name, _)| name.as_str())
1610 .collect::<Vec<_>>();
1611
1612 assert!(
1613 names.contains(&"helper"),
1614 "call inside the function body should be included: {names:?}"
1615 );
1616 }
1617
1618 #[test]
1619 fn zoom_leaf_returns_full_body_without_budget_marker() {
1620 let ctx = make_ctx();
1621 let path = fixture_path("calls.ts");
1622 let req = make_zoom_request(
1623 "z-leaf-full",
1624 path.to_str().unwrap(),
1625 "repeatedOutgoing",
1626 None,
1627 );
1628 let resp = handle_zoom(&req, &ctx);
1629 let json = serde_json::to_value(&resp).unwrap();
1630 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1631
1632 let symbols = ctx.provider().list_symbols(&path).unwrap();
1633 let target = symbols
1634 .iter()
1635 .find(|symbol| symbol.name == "repeatedOutgoing")
1636 .unwrap();
1637 let source = std::fs::read_to_string(&path).unwrap();
1638 let lines = source.lines().collect::<Vec<_>>();
1639 let expected =
1640 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1641
1642 assert_eq!(json["content"].as_str().unwrap(), expected);
1643 assert!(
1644 !json["content"]
1645 .as_str()
1646 .unwrap()
1647 .contains("more lines — zoom"),
1648 "explicit zoom must not budget-cap leaf bodies"
1649 );
1650 }
1651
1652 #[test]
1653 fn zoom_response_has_calls_out_and_called_by() {
1654 let ctx = make_ctx();
1655 let path = fixture_path("calls.ts");
1656
1657 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1658 let resp = handle_zoom(&req, &ctx);
1659
1660 let json = serde_json::to_value(&resp).unwrap();
1661 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1662
1663 let calls_out = json["annotations"]["calls_out"]
1664 .as_array()
1665 .expect("calls_out array");
1666 let out_names: Vec<&str> = calls_out
1667 .iter()
1668 .map(|c| c["name"].as_str().unwrap())
1669 .collect();
1670 assert!(
1671 out_names.contains(&"helper"),
1672 "compute calls helper: {:?}",
1673 out_names
1674 );
1675
1676 let called_by = json["annotations"]["called_by"]
1677 .as_array()
1678 .expect("called_by array");
1679 let by_names: Vec<&str> = called_by
1680 .iter()
1681 .map(|c| c["name"].as_str().unwrap())
1682 .collect();
1683 assert!(
1684 by_names.contains(&"orchestrate"),
1685 "orchestrate calls compute: {:?}",
1686 by_names
1687 );
1688 }
1689
1690 #[test]
1691 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1692 let ctx = make_ctx();
1693 let path = fixture_path("calls.ts");
1694
1695 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1696 let resp = handle_zoom(&req, &ctx);
1697 let json = serde_json::to_value(&resp).unwrap();
1698 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1699
1700 let calls_out = json["annotations"]["calls_out"]
1701 .as_array()
1702 .expect("calls_out array");
1703 let helper_refs = calls_out
1704 .iter()
1705 .filter(|call| call["name"] == "helper")
1706 .collect::<Vec<_>>();
1707 assert_eq!(
1708 helper_refs.len(),
1709 1,
1710 "helper should be folded once: {calls_out:?}"
1711 );
1712 assert_eq!(helper_refs[0]["extra_count"], 1);
1713 assert!(
1714 calls_out.iter().any(|call| call["name"] == "format"),
1715 "distinct callee must not be folded into helper: {calls_out:?}"
1716 );
1717
1718 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1719 let resp = handle_zoom(&req, &ctx);
1720 let json = serde_json::to_value(&resp).unwrap();
1721 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1722
1723 let called_by = json["annotations"]["called_by"]
1724 .as_array()
1725 .expect("called_by array");
1726 let repeat_refs = called_by
1727 .iter()
1728 .filter(|call| call["name"] == "repeatCompute")
1729 .collect::<Vec<_>>();
1730 assert_eq!(
1731 repeat_refs.len(),
1732 1,
1733 "repeatCompute should be folded once: {called_by:?}"
1734 );
1735 assert_eq!(repeat_refs[0]["extra_count"], 1);
1736 assert!(
1737 called_by.iter().any(|call| call["name"] == "orchestrate"),
1738 "distinct caller must not be folded into repeatCompute: {called_by:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn zoom_response_empty_annotations_for_unused() {
1744 let ctx = make_ctx();
1745 let path = fixture_path("calls.ts");
1746
1747 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1748 let resp = handle_zoom(&req, &ctx);
1749
1750 let json = serde_json::to_value(&resp).unwrap();
1751 assert_eq!(json["success"], true);
1752
1753 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1754 let called_by = json["annotations"]["called_by"].as_array().unwrap();
1755
1756 assert!(
1759 called_by.is_empty(),
1760 "unused should not be called by anyone: {:?}",
1761 called_by
1762 );
1763 }
1764
1765 #[test]
1766 fn zoom_default_omits_callgraph_annotations() {
1767 let ctx = make_ctx();
1768 let path = fixture_path("calls.ts");
1769
1770 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1771 let resp = handle_zoom(&req, &ctx);
1772
1773 let json = serde_json::to_value(&resp).unwrap();
1774 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1775
1776 let calls_out = json["annotations"]["calls_out"]
1777 .as_array()
1778 .expect("calls_out array");
1779 let called_by = json["annotations"]["called_by"]
1780 .as_array()
1781 .expect("called_by array");
1782 assert!(
1783 calls_out.is_empty(),
1784 "default zoom should omit calls_out: {:?}",
1785 calls_out
1786 );
1787 assert!(
1788 called_by.is_empty(),
1789 "default zoom should omit called_by: {:?}",
1790 called_by
1791 );
1792 }
1793
1794 #[test]
1795 fn zoom_symbol_not_found() {
1796 let ctx = make_ctx();
1797 let path = fixture_path("calls.ts");
1798
1799 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1800 let resp = handle_zoom(&req, &ctx);
1801
1802 let json = serde_json::to_value(&resp).unwrap();
1803 assert_eq!(json["success"], false);
1804 assert_eq!(json["code"], "symbol_not_found");
1805 }
1806
1807 #[test]
1808 fn zoom_custom_context_lines() {
1809 let ctx = make_ctx();
1810 let path = fixture_path("calls.ts");
1811
1812 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1813 let resp = handle_zoom(&req, &ctx);
1814
1815 let json = serde_json::to_value(&resp).unwrap();
1816 assert_eq!(json["success"], true);
1817
1818 let ctx_before = json["context_before"].as_array().unwrap();
1819 let ctx_after = json["context_after"].as_array().unwrap();
1820 assert!(
1822 ctx_before.len() <= 1,
1823 "context_before should be ≤1: {:?}",
1824 ctx_before
1825 );
1826 assert!(
1827 ctx_after.len() <= 1,
1828 "context_after should be ≤1: {:?}",
1829 ctx_after
1830 );
1831 }
1832
1833 #[test]
1834 fn zoom_missing_file_param() {
1835 let ctx = make_ctx();
1836 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1837 let resp = handle_zoom(&req, &ctx);
1838
1839 let json = serde_json::to_value(&resp).unwrap();
1840 assert_eq!(json["success"], false);
1841 assert_eq!(json["code"], "invalid_request");
1842 }
1843
1844 #[test]
1845 fn zoom_missing_symbol_param() {
1846 let ctx = make_ctx();
1847 let path = fixture_path("calls.ts");
1848 let req_value = serde_json::json!({
1852 "id": "z-6",
1853 "command": "zoom",
1854 "file": path.to_string_lossy(),
1855 });
1856 let req_str = req_value.to_string();
1857 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1858 let resp = handle_zoom(&req, &ctx);
1859
1860 let json = serde_json::to_value(&resp).unwrap();
1861 assert_eq!(json["success"], false);
1862 assert_eq!(json["code"], "invalid_request");
1863 }
1864
1865 #[test]
1866 fn test_suggest_close_symbols_unit() {
1867 let available = vec![
1868 "handle_grep_search".to_string(),
1869 "handle_semantic_search".to_string(),
1870 "handle_semantic_or_hybrid_search".to_string(),
1871 "compute_total".to_string(),
1872 "search".to_string(),
1873 "handle_search".to_string(),
1874 ];
1875
1876 let suggestions = suggest_close_symbols("handle_search", &available, 5);
1877 assert!(suggestions.contains(&"handle_grep_search".to_string()));
1878 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1879 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1880 assert!(suggestions.contains(&"search".to_string()));
1881 assert!(!suggestions.contains(&"compute_total".to_string()));
1882
1883 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1884 assert_eq!(suggestions, suggestions_caps);
1885
1886 let available2 = vec![
1887 "total".to_string(),
1888 "compute_total".to_string(),
1889 "unrelated".to_string(),
1890 ];
1891 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1892 assert_eq!(suggestions2, vec!["total".to_string()]);
1893 }
1894
1895 fn make_zoom_request(
1898 id: &str,
1899 file: &str,
1900 symbol: &str,
1901 context_lines: Option<u64>,
1902 ) -> RawRequest {
1903 let mut json = serde_json::json!({
1904 "id": id,
1905 "command": "zoom",
1906 "file": file,
1907 "symbol": symbol,
1908 });
1909 if let Some(cl) = context_lines {
1910 json["context_lines"] = serde_json::json!(cl);
1911 }
1912 serde_json::from_value(json).unwrap()
1913 }
1914
1915 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1916 let mut req = make_zoom_request(id, file, symbol, None);
1917 req.params["callgraph"] = serde_json::json!(true);
1918 req
1919 }
1920
1921 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1922 serde_json::from_str(json_str).unwrap()
1923 }
1924}