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::{HeadingAnchor, 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 let anchors = if query.starts_with('#') {
956 provider.heading_anchors(path)?
957 } else {
958 Vec::new()
959 };
960
961 Ok(match_heading_identity(&headings, query, &anchors))
962}
963
964fn match_heading_identity(
965 headings: &[SymbolMatch],
966 query: &str,
967 anchors: &[HeadingAnchor],
968) -> Vec<SymbolMatch> {
969 if let Some(anchor_query) = query.strip_prefix('#') {
970 let mut matches: Vec<_> = headings
971 .iter()
972 .filter(|candidate| {
973 anchors.iter().any(|anchor| {
974 anchor.id == anchor_query
975 && anchor.start_line == candidate.symbol.range.start_line
976 && anchor.start_col == candidate.symbol.range.start_col
977 })
978 })
979 .cloned()
980 .collect();
981
982 let normalized_query = normalize_heading_label(query);
983 let query_slug = slugify_heading_label(&normalized_query);
984 if !query_slug.is_empty() {
985 for candidate in headings
986 .iter()
987 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
988 {
989 if !matches.iter().any(|existing| {
990 existing.file == candidate.file && existing.symbol == candidate.symbol
991 }) {
992 matches.push(candidate.clone());
993 }
994 }
995 }
996 return matches;
997 }
998
999 let exact: Vec<_> = headings
1000 .iter()
1001 .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1002 .cloned()
1003 .collect();
1004 if !exact.is_empty() {
1005 return exact;
1006 }
1007
1008 let normalized_query = normalize_heading_label(query);
1009 if normalized_query.is_empty() {
1010 return Vec::new();
1011 }
1012
1013 let normalized: Vec<_> = headings
1014 .iter()
1015 .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1016 .cloned()
1017 .collect();
1018 if !normalized.is_empty() {
1019 return normalized;
1020 }
1021
1022 let folded_query = normalized_query.to_lowercase();
1023 let case_insensitive: Vec<_> = headings
1024 .iter()
1025 .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1026 .cloned()
1027 .collect();
1028 if !case_insensitive.is_empty() {
1029 return case_insensitive;
1030 }
1031
1032 let query_slug = slugify_heading_label(&normalized_query);
1033 if query_slug.is_empty() {
1034 return Vec::new();
1035 }
1036
1037 headings
1038 .iter()
1039 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1040 .cloned()
1041 .collect()
1042}
1043
1044fn qualified_heading_name(symbol: &Symbol) -> String {
1045 if symbol.scope_chain.is_empty() {
1046 return symbol.name.clone();
1047 }
1048 format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1049}
1050
1051fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1052 symbol.name == query || qualified_heading_name(symbol) == query
1053}
1054
1055fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1056 normalize_heading_label(&symbol.name) == query
1057 || normalize_heading_label(&qualified_heading_name(symbol)) == query
1058}
1059
1060fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1061 normalize_heading_label(&symbol.name).to_lowercase() == query
1062 || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1063}
1064
1065fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1066 slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1067 || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1068 == query_slug
1069}
1070
1071fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1072 let available: Vec<String> = symbols
1073 .iter()
1074 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1075 .map(|symbol| normalize_heading_label(&symbol.name))
1076 .filter(|name| !name.is_empty())
1077 .collect();
1078 let normalized_query = normalize_heading_label(query);
1079 if normalized_query.is_empty() {
1080 return Vec::new();
1081 }
1082 suggest_close_symbols(&normalized_query, &available, k)
1083}
1084
1085fn normalize_heading_label(input: &str) -> String {
1086 let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1087
1088 for _ in 0..4 {
1091 let mut next = value.as_str();
1092 let without_heading_markers = next.trim_start_matches('#').trim_start();
1093 if without_heading_markers != next {
1094 next = without_heading_markers;
1095 }
1096 if let Some(rest) = strip_heading_html_prefix(next) {
1097 next = rest;
1098 }
1099 if let Some(rest) = strip_leading_section_prefix(next) {
1100 next = rest;
1101 }
1102 if let Some(rest) = strip_leading_symbol_cluster(next) {
1103 next = rest;
1104 }
1105
1106 let collapsed = collapse_heading_whitespace(next);
1107 if collapsed == value {
1108 break;
1109 }
1110 value = collapsed;
1111 }
1112
1113 value
1114}
1115
1116fn collapse_heading_whitespace(input: &str) -> String {
1117 input.split_whitespace().collect::<Vec<_>>().join(" ")
1118}
1119
1120fn strip_markdown_links(input: &str) -> String {
1121 let mut output = String::with_capacity(input.len());
1122 let mut cursor = 0;
1123
1124 while let Some(relative_open) = input[cursor..].find('[') {
1125 let open = cursor + relative_open;
1126 let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1127 output.push_str(&input[cursor..]);
1128 return output;
1129 };
1130
1131 if input.as_bytes().get(close + 1) != Some(&b'(') {
1132 output.push_str(&input[cursor..=close]);
1133 cursor = close + 1;
1134 continue;
1135 }
1136
1137 let target_open = close + 1;
1138 let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1139 output.push_str(&input[cursor..]);
1140 return output;
1141 };
1142
1143 output.push_str(&input[cursor..open]);
1144 output.push_str(&input[open + 1..close]);
1145 cursor = target_close + 1;
1146 }
1147
1148 output.push_str(&input[cursor..]);
1149 output
1150}
1151
1152fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1153 let mut depth = 0;
1154 for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1155 if *byte == open {
1156 depth += 1;
1157 } else if *byte == close {
1158 depth -= 1;
1159 if depth == 0 {
1160 return Some(index);
1161 }
1162 }
1163 }
1164 None
1165}
1166
1167fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1168 let input = input.trim_start();
1169 let bytes = input.as_bytes();
1170 if bytes.first() != Some(&b'<') {
1171 return None;
1172 }
1173
1174 let mut index = 1;
1175 if bytes.get(index) == Some(&b'/') {
1176 index += 1;
1177 }
1178 if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1179 return None;
1180 }
1181 index += 1;
1182 if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1183 return None;
1184 }
1185
1186 let end = input.find('>')?;
1187 let rest = input[end + 1..].trim_start();
1188 if rest.chars().any(|character| character.is_alphanumeric()) {
1189 Some(rest)
1190 } else {
1191 None
1192 }
1193}
1194
1195fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1196 let bytes = input.as_bytes();
1197 let mut index = 0;
1198 let mut saw_dot = false;
1199
1200 if !bytes
1201 .first()
1202 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1203 {
1204 return None;
1205 }
1206
1207 while index < bytes.len() {
1208 while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1209 index += 1;
1210 }
1211 if bytes.get(index) != Some(&b'.') {
1212 break;
1213 }
1214 saw_dot = true;
1215 index += 1;
1216 if bytes
1217 .get(index)
1218 .is_some_and(|byte| byte.is_ascii_whitespace())
1219 {
1220 let rest = input[index..].trim_start();
1221 return if rest.chars().any(|character| character.is_alphanumeric()) {
1222 Some(rest)
1223 } else {
1224 None
1225 };
1226 }
1227 if !bytes
1228 .get(index)
1229 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1230 {
1231 return None;
1232 }
1233 }
1234
1235 if saw_dot
1236 && bytes
1237 .get(index)
1238 .is_some_and(|byte| byte.is_ascii_whitespace())
1239 {
1240 let rest = input[index..].trim_start();
1241 if rest.chars().any(|character| character.is_alphanumeric()) {
1242 return Some(rest);
1243 }
1244 }
1245 None
1246}
1247
1248fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1249 let first_text = input
1250 .char_indices()
1251 .find(|(_, character)| character.is_alphanumeric())
1252 .map(|(index, _)| index)?;
1253 if first_text == 0 {
1254 return None;
1255 }
1256
1257 let rest = &input[first_text..];
1258 if rest.chars().any(|character| character.is_alphanumeric()) {
1259 Some(rest)
1260 } else {
1261 None
1262 }
1263}
1264
1265fn slugify_heading_label(label: &str) -> String {
1266 let mut slug = String::new();
1267 let mut pending_separator = false;
1268
1269 for character in label.chars() {
1270 if character.is_alphanumeric() {
1271 if pending_separator && !slug.is_empty() {
1272 slug.push('-');
1273 }
1274 for lowercase in character.to_lowercase() {
1275 slug.push(lowercase);
1276 }
1277 pending_separator = false;
1278 } else if !slug.is_empty() {
1279 pending_separator = true;
1280 }
1281 }
1282
1283 slug
1284}
1285
1286#[cfg(test)]
1290fn extract_calls_in_range(
1291 source: &str,
1292 root: tree_sitter::Node,
1293 byte_start: usize,
1294 byte_end: usize,
1295 lang: LangId,
1296) -> Vec<(String, u32)> {
1297 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1298}
1299
1300fn symbol_body_byte_range(
1301 root: tree_sitter::Node,
1302 byte_start: usize,
1303 byte_end: usize,
1304) -> Option<(usize, usize)> {
1305 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1306 let mut current = Some(node);
1307 while let Some(node) = current {
1308 if is_symbol_body_node(node.kind()) {
1309 return Some((node.start_byte(), node.end_byte()));
1310 }
1311 current = node.parent();
1312 }
1313 Some((node.start_byte(), node.end_byte()))
1314}
1315
1316fn smallest_node_covering_range<'tree>(
1317 node: tree_sitter::Node<'tree>,
1318 byte_start: usize,
1319 byte_end: usize,
1320) -> Option<tree_sitter::Node<'tree>> {
1321 if node.start_byte() > byte_start || node.end_byte() < byte_end {
1322 return None;
1323 }
1324
1325 let mut cursor = node.walk();
1326 if cursor.goto_first_child() {
1327 loop {
1328 let child = cursor.node();
1329 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1330 return Some(found);
1331 }
1332 if !cursor.goto_next_sibling() {
1333 break;
1334 }
1335 }
1336 }
1337
1338 Some(node)
1339}
1340
1341fn is_symbol_body_node(kind: &str) -> bool {
1342 matches!(
1343 kind,
1344 "function_declaration"
1345 | "generator_function_declaration"
1346 | "function_expression"
1347 | "generator_function"
1348 | "arrow_function"
1349 | "method_definition"
1350 | "class_declaration"
1351 | "abstract_class_declaration"
1352 | "class"
1353 | "lexical_declaration"
1354 | "function_definition"
1355 | "class_definition"
1356 | "decorated_definition"
1357 | "function_item"
1358 | "impl_item"
1359 | "method_declaration"
1360 )
1361}
1362
1363fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1364 let mut results = Vec::new();
1365 let call_kinds = crate::calls::call_node_kinds(lang);
1366 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1367 results
1368}
1369
1370fn collect_calls_with_ranges(
1371 node: tree_sitter::Node,
1372 source: &str,
1373 call_kinds: &[&str],
1374 results: &mut Vec<RawCall>,
1375) {
1376 if call_kinds.contains(&node.kind()) {
1377 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1378 results.push(RawCall {
1379 name,
1380 line: node.start_position().row as u32 + 1,
1381 start_byte: node.start_byte(),
1382 end_byte: node.end_byte(),
1383 });
1384 }
1385 }
1386
1387 let mut cursor = node.walk();
1388 if cursor.goto_first_child() {
1389 loop {
1390 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1391 if !cursor.goto_next_sibling() {
1392 break;
1393 }
1394 }
1395 }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400 use super::*;
1401 use crate::config::Config;
1402 use crate::context::AppContext;
1403 use crate::parser::TreeSitterProvider;
1404 use std::path::PathBuf;
1405
1406 fn fixture_path(name: &str) -> PathBuf {
1407 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1408 .join("tests")
1409 .join("fixtures")
1410 .join(name)
1411 }
1412
1413 fn make_ctx() -> AppContext {
1414 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1415 }
1416
1417 #[test]
1418 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1419 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1420 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1421 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1422 }
1423
1424 #[test]
1425 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1426 let params = serde_json::json!({ "symbols": "Getting Started" });
1427 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1428 assert_eq!(names, vec!["Getting Started"]);
1429 }
1430
1431 #[test]
1432 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1433 let params = serde_json::json!({ "symbol": "Last Heading" });
1434 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1435 assert_eq!(names, vec!["Last Heading"]);
1436 }
1437
1438 #[test]
1439 fn parse_zoom_symbol_names_single_token_unchanged() {
1440 let params = serde_json::json!({ "symbol": "compute" });
1441 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1442 assert_eq!(names, vec!["compute"]);
1443 }
1444
1445 #[test]
1446 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1447 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1448 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1449 assert_eq!(names, vec!["A", "B", "C"]);
1450 }
1451
1452 #[test]
1455 fn extract_calls_finds_direct_calls() {
1456 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1457 let mut parser = FileParser::new();
1458 let path = fixture_path("calls.ts");
1459 let (tree, lang) = parser.parse(&path).unwrap();
1460
1461 let ctx = make_ctx();
1463 let symbols = ctx.provider().list_symbols(&path).unwrap();
1464 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1465
1466 let byte_start =
1467 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1468 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1469
1470 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1471 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1472
1473 assert!(
1474 names.contains(&"helper"),
1475 "compute should call helper, got: {:?}",
1476 names
1477 );
1478 }
1479
1480 #[test]
1481 fn extract_calls_finds_member_calls() {
1482 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1483 let mut parser = FileParser::new();
1484 let path = fixture_path("calls.ts");
1485 let (tree, lang) = parser.parse(&path).unwrap();
1486
1487 let ctx = make_ctx();
1488 let symbols = ctx.provider().list_symbols(&path).unwrap();
1489 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1490
1491 let byte_start =
1492 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1493 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1494
1495 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1496 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1497
1498 assert!(
1499 names.contains(&"add"),
1500 "runAll should call this.add, got: {:?}",
1501 names
1502 );
1503 assert!(
1504 names.contains(&"helper"),
1505 "runAll should call helper, got: {:?}",
1506 names
1507 );
1508 }
1509
1510 #[test]
1511 fn extract_calls_unused_function_has_no_calls() {
1512 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1513 let mut parser = FileParser::new();
1514 let path = fixture_path("calls.ts");
1515 let (tree, lang) = parser.parse(&path).unwrap();
1516
1517 let ctx = make_ctx();
1518 let symbols = ctx.provider().list_symbols(&path).unwrap();
1519 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1520
1521 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1522 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1523
1524 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1525 let known_names = [
1527 "helper",
1528 "compute",
1529 "orchestrate",
1530 "unused",
1531 "format",
1532 "display",
1533 ];
1534 let filtered: Vec<&str> = calls
1535 .iter()
1536 .map(|(n, _)| n.as_str())
1537 .filter(|n| known_names.contains(n))
1538 .collect();
1539 assert!(
1540 filtered.is_empty(),
1541 "unused should not call known symbols, got: {:?}",
1542 filtered
1543 );
1544 }
1545
1546 #[test]
1549 fn context_lines_clamp_at_file_start() {
1550 let ctx = make_ctx();
1552 let path = fixture_path("calls.ts");
1553 let symbols = ctx.provider().list_symbols(&path).unwrap();
1554 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1555
1556 let source = std::fs::read_to_string(&path).unwrap();
1557 let lines: Vec<&str> = source.lines().collect();
1558 let start = helper.range.start_line as usize;
1559
1560 let ctx_start = start.saturating_sub(5);
1562 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1563 assert!(context_before.len() <= start);
1565 }
1566
1567 #[test]
1568 fn context_lines_clamp_at_file_end() {
1569 let ctx = make_ctx();
1570 let path = fixture_path("calls.ts");
1571 let symbols = ctx.provider().list_symbols(&path).unwrap();
1572 let display = symbols.iter().find(|s| s.name == "display").unwrap();
1573
1574 let source = std::fs::read_to_string(&path).unwrap();
1575 let lines: Vec<&str> = source.lines().collect();
1576 let end = display.range.end_line as usize;
1577
1578 let ctx_end = (end + 1 + 20).min(lines.len());
1580 let context_after: Vec<&str> = if end + 1 < lines.len() {
1581 lines[(end + 1)..ctx_end].to_vec()
1582 } else {
1583 vec![]
1584 };
1585 assert!(context_after.len() <= 20);
1587 }
1588
1589 #[test]
1592 fn body_extraction_matches_source() {
1593 let ctx = make_ctx();
1594 let path = fixture_path("calls.ts");
1595 let symbols = ctx.provider().list_symbols(&path).unwrap();
1596 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1597
1598 let source = std::fs::read_to_string(&path).unwrap();
1599 let lines: Vec<&str> = source.lines().collect();
1600 let start = compute.range.start_line as usize;
1601 let end = compute.range.end_line as usize;
1602 let body = lines[start..=end].join("\n");
1603
1604 assert!(
1605 body.contains("function compute"),
1606 "body should contain function declaration"
1607 );
1608 assert!(
1609 body.contains("helper(a)"),
1610 "body should contain call to helper"
1611 );
1612 assert!(
1613 body.contains("doubled + b"),
1614 "body should contain return expression"
1615 );
1616 }
1617
1618 #[test]
1621 fn body_range_expands_signature_range_to_include_body_calls() {
1622 let source = r#"function compute(
1623 value: number,
1624): number {
1625 return helper(value);
1626}
1627
1628function helper(value: number): number {
1629 return value * 2;
1630}
1631"#;
1632 let grammar = crate::parser::grammar_for(LangId::TypeScript);
1633 let mut parser = tree_sitter::Parser::new();
1634 parser.set_language(&grammar).unwrap();
1635 let tree = parser.parse(source, None).unwrap();
1636 let signature_end = source.find('{').expect("function has body");
1637
1638 let (body_start, body_end) =
1639 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1640 let calls = extract_calls_in_range(
1641 source,
1642 tree.root_node(),
1643 body_start,
1644 body_end,
1645 LangId::TypeScript,
1646 );
1647 let names = calls
1648 .iter()
1649 .map(|(name, _)| name.as_str())
1650 .collect::<Vec<_>>();
1651
1652 assert!(
1653 names.contains(&"helper"),
1654 "call inside the function body should be included: {names:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn zoom_leaf_returns_full_body_without_budget_marker() {
1660 let ctx = make_ctx();
1661 let path = fixture_path("calls.ts");
1662 let req = make_zoom_request(
1663 "z-leaf-full",
1664 path.to_str().unwrap(),
1665 "repeatedOutgoing",
1666 None,
1667 );
1668 let resp = handle_zoom(&req, &ctx);
1669 let json = serde_json::to_value(&resp).unwrap();
1670 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1671
1672 let symbols = ctx.provider().list_symbols(&path).unwrap();
1673 let target = symbols
1674 .iter()
1675 .find(|symbol| symbol.name == "repeatedOutgoing")
1676 .unwrap();
1677 let source = std::fs::read_to_string(&path).unwrap();
1678 let lines = source.lines().collect::<Vec<_>>();
1679 let expected =
1680 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1681
1682 assert_eq!(json["content"].as_str().unwrap(), expected);
1683 assert!(
1684 !json["content"]
1685 .as_str()
1686 .unwrap()
1687 .contains("more lines — zoom"),
1688 "explicit zoom must not budget-cap leaf bodies"
1689 );
1690 }
1691
1692 #[test]
1693 fn zoom_response_has_calls_out_and_called_by() {
1694 let ctx = make_ctx();
1695 let path = fixture_path("calls.ts");
1696
1697 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1698 let resp = handle_zoom(&req, &ctx);
1699
1700 let json = serde_json::to_value(&resp).unwrap();
1701 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1702
1703 let calls_out = json["annotations"]["calls_out"]
1704 .as_array()
1705 .expect("calls_out array");
1706 let out_names: Vec<&str> = calls_out
1707 .iter()
1708 .map(|c| c["name"].as_str().unwrap())
1709 .collect();
1710 assert!(
1711 out_names.contains(&"helper"),
1712 "compute calls helper: {:?}",
1713 out_names
1714 );
1715
1716 let called_by = json["annotations"]["called_by"]
1717 .as_array()
1718 .expect("called_by array");
1719 let by_names: Vec<&str> = called_by
1720 .iter()
1721 .map(|c| c["name"].as_str().unwrap())
1722 .collect();
1723 assert!(
1724 by_names.contains(&"orchestrate"),
1725 "orchestrate calls compute: {:?}",
1726 by_names
1727 );
1728 }
1729
1730 #[test]
1731 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1732 let ctx = make_ctx();
1733 let path = fixture_path("calls.ts");
1734
1735 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1736 let resp = handle_zoom(&req, &ctx);
1737 let json = serde_json::to_value(&resp).unwrap();
1738 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1739
1740 let calls_out = json["annotations"]["calls_out"]
1741 .as_array()
1742 .expect("calls_out array");
1743 let helper_refs = calls_out
1744 .iter()
1745 .filter(|call| call["name"] == "helper")
1746 .collect::<Vec<_>>();
1747 assert_eq!(
1748 helper_refs.len(),
1749 1,
1750 "helper should be folded once: {calls_out:?}"
1751 );
1752 assert_eq!(helper_refs[0]["extra_count"], 1);
1753 assert!(
1754 calls_out.iter().any(|call| call["name"] == "format"),
1755 "distinct callee must not be folded into helper: {calls_out:?}"
1756 );
1757
1758 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1759 let resp = handle_zoom(&req, &ctx);
1760 let json = serde_json::to_value(&resp).unwrap();
1761 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1762
1763 let called_by = json["annotations"]["called_by"]
1764 .as_array()
1765 .expect("called_by array");
1766 let repeat_refs = called_by
1767 .iter()
1768 .filter(|call| call["name"] == "repeatCompute")
1769 .collect::<Vec<_>>();
1770 assert_eq!(
1771 repeat_refs.len(),
1772 1,
1773 "repeatCompute should be folded once: {called_by:?}"
1774 );
1775 assert_eq!(repeat_refs[0]["extra_count"], 1);
1776 assert!(
1777 called_by.iter().any(|call| call["name"] == "orchestrate"),
1778 "distinct caller must not be folded into repeatCompute: {called_by:?}"
1779 );
1780 }
1781
1782 #[test]
1783 fn zoom_response_empty_annotations_for_unused() {
1784 let ctx = make_ctx();
1785 let path = fixture_path("calls.ts");
1786
1787 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1788 let resp = handle_zoom(&req, &ctx);
1789
1790 let json = serde_json::to_value(&resp).unwrap();
1791 assert_eq!(json["success"], true);
1792
1793 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1794 let called_by = json["annotations"]["called_by"].as_array().unwrap();
1795
1796 assert!(
1799 called_by.is_empty(),
1800 "unused should not be called by anyone: {:?}",
1801 called_by
1802 );
1803 }
1804
1805 #[test]
1806 fn zoom_default_omits_callgraph_annotations() {
1807 let ctx = make_ctx();
1808 let path = fixture_path("calls.ts");
1809
1810 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1811 let resp = handle_zoom(&req, &ctx);
1812
1813 let json = serde_json::to_value(&resp).unwrap();
1814 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1815
1816 let calls_out = json["annotations"]["calls_out"]
1817 .as_array()
1818 .expect("calls_out array");
1819 let called_by = json["annotations"]["called_by"]
1820 .as_array()
1821 .expect("called_by array");
1822 assert!(
1823 calls_out.is_empty(),
1824 "default zoom should omit calls_out: {:?}",
1825 calls_out
1826 );
1827 assert!(
1828 called_by.is_empty(),
1829 "default zoom should omit called_by: {:?}",
1830 called_by
1831 );
1832 }
1833
1834 #[test]
1835 fn zoom_symbol_not_found() {
1836 let ctx = make_ctx();
1837 let path = fixture_path("calls.ts");
1838
1839 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1840 let resp = handle_zoom(&req, &ctx);
1841
1842 let json = serde_json::to_value(&resp).unwrap();
1843 assert_eq!(json["success"], false);
1844 assert_eq!(json["code"], "symbol_not_found");
1845 }
1846
1847 #[test]
1848 fn zoom_custom_context_lines() {
1849 let ctx = make_ctx();
1850 let path = fixture_path("calls.ts");
1851
1852 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1853 let resp = handle_zoom(&req, &ctx);
1854
1855 let json = serde_json::to_value(&resp).unwrap();
1856 assert_eq!(json["success"], true);
1857
1858 let ctx_before = json["context_before"].as_array().unwrap();
1859 let ctx_after = json["context_after"].as_array().unwrap();
1860 assert!(
1862 ctx_before.len() <= 1,
1863 "context_before should be ≤1: {:?}",
1864 ctx_before
1865 );
1866 assert!(
1867 ctx_after.len() <= 1,
1868 "context_after should be ≤1: {:?}",
1869 ctx_after
1870 );
1871 }
1872
1873 #[test]
1874 fn zoom_missing_file_param() {
1875 let ctx = make_ctx();
1876 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1877 let resp = handle_zoom(&req, &ctx);
1878
1879 let json = serde_json::to_value(&resp).unwrap();
1880 assert_eq!(json["success"], false);
1881 assert_eq!(json["code"], "invalid_request");
1882 }
1883
1884 #[test]
1885 fn zoom_missing_symbol_param() {
1886 let ctx = make_ctx();
1887 let path = fixture_path("calls.ts");
1888 let req_value = serde_json::json!({
1892 "id": "z-6",
1893 "command": "zoom",
1894 "file": path.to_string_lossy(),
1895 });
1896 let req_str = req_value.to_string();
1897 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1898 let resp = handle_zoom(&req, &ctx);
1899
1900 let json = serde_json::to_value(&resp).unwrap();
1901 assert_eq!(json["success"], false);
1902 assert_eq!(json["code"], "invalid_request");
1903 }
1904
1905 #[test]
1906 fn test_suggest_close_symbols_unit() {
1907 let available = vec![
1908 "handle_grep_search".to_string(),
1909 "handle_semantic_search".to_string(),
1910 "handle_semantic_or_hybrid_search".to_string(),
1911 "compute_total".to_string(),
1912 "search".to_string(),
1913 "handle_search".to_string(),
1914 ];
1915
1916 let suggestions = suggest_close_symbols("handle_search", &available, 5);
1917 assert!(suggestions.contains(&"handle_grep_search".to_string()));
1918 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1919 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1920 assert!(suggestions.contains(&"search".to_string()));
1921 assert!(!suggestions.contains(&"compute_total".to_string()));
1922
1923 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1924 assert_eq!(suggestions, suggestions_caps);
1925
1926 let available2 = vec![
1927 "total".to_string(),
1928 "compute_total".to_string(),
1929 "unrelated".to_string(),
1930 ];
1931 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1932 assert_eq!(suggestions2, vec!["total".to_string()]);
1933 }
1934
1935 fn make_zoom_request(
1938 id: &str,
1939 file: &str,
1940 symbol: &str,
1941 context_lines: Option<u64>,
1942 ) -> RawRequest {
1943 let mut json = serde_json::json!({
1944 "id": id,
1945 "command": "zoom",
1946 "file": file,
1947 "symbol": symbol,
1948 });
1949 if let Some(cl) = context_lines {
1950 json["context_lines"] = serde_json::json!(cl);
1951 }
1952 serde_json::from_value(json).unwrap()
1953 }
1954
1955 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1956 let mut req = make_zoom_request(id, file, symbol, None);
1957 req.params["callgraph"] = serde_json::json!(true);
1958 req
1959 }
1960
1961 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1962 serde_json::from_str(json_str).unwrap()
1963 }
1964}