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::lsp_hints;
15use crate::parser::{detect_language, FileParser, LangId};
16use crate::protocol::{RawRequest, Response};
17use crate::symbols::Range;
18use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
19
20#[derive(Debug, Clone, Serialize)]
22pub struct CallRef {
23 pub name: String,
24 pub line: u32,
26 #[serde(skip_serializing_if = "is_zero")]
28 pub extra_count: u32,
29}
30
31fn is_zero(value: &u32) -> bool {
32 *value == 0
33}
34
35fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
36 let mut index_by_name: HashMap<String, usize> = HashMap::new();
37 let mut deduped: Vec<CallRef> = Vec::new();
38
39 for call in calls {
40 if let Some(index) = index_by_name.get(&call.name).copied() {
41 deduped[index].extra_count = deduped[index]
42 .extra_count
43 .saturating_add(call.extra_count.saturating_add(1));
44 } else {
45 index_by_name.insert(call.name.clone(), deduped.len());
46 deduped.push(call);
47 }
48 }
49
50 deduped
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct Annotations {
56 pub calls_out: Vec<CallRef>,
57 pub called_by: Vec<CallRef>,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct ZoomResponse {
63 pub name: String,
64 pub kind: String,
65 pub range: Range,
66 pub content: String,
67 pub context_before: Vec<String>,
68 pub context_after: Vec<String>,
69 pub annotations: Annotations,
70}
71
72struct RawCall {
73 name: String,
74 line: u32,
75 start_byte: usize,
76 end_byte: usize,
77}
78
79fn resolve_file_or_url(
80 req: &RawRequest,
81 ctx: &AppContext,
82 file: &str,
83) -> Result<PathBuf, Response> {
84 if is_http_url(file) {
85 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
86 let allow_private = ctx.config().url_fetch_allow_private
87 || req
88 .params
89 .get("allow_private")
90 .and_then(|value| value.as_bool())
91 .unwrap_or(false);
92 return fetch_url_to_cache(
93 file,
94 &storage_dir,
95 UrlFetchOptions {
96 allow_private,
97 ..UrlFetchOptions::default()
98 },
99 )
100 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
101 }
102
103 ctx.validate_path(&req.id, Path::new(file))
104}
105
106pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
112 let file = match req
113 .params
114 .get("file")
115 .or_else(|| req.params.get("url"))
116 .and_then(|v| v.as_str())
117 {
118 Some(f) => f,
119 None => {
120 return Response::error(
121 &req.id,
122 "invalid_request",
123 "zoom: missing required param 'file'",
124 );
125 }
126 };
127
128 let context_lines = req
129 .params
130 .get("context_lines")
131 .and_then(|v| v.as_u64())
132 .unwrap_or(3) as usize;
133 let include_callgraph = req
134 .params
135 .get("callgraph")
136 .and_then(|v| v.as_bool())
137 .unwrap_or(false);
138
139 let start_line = req
140 .params
141 .get("start_line")
142 .and_then(|v| v.as_u64())
143 .map(|v| v as usize);
144 let end_line = req
145 .params
146 .get("end_line")
147 .and_then(|v| v.as_u64())
148 .map(|v| v as usize);
149
150 let path = match resolve_file_or_url(req, ctx, file) {
151 Ok(path) => path,
152 Err(resp) => return resp,
153 };
154 if !path.exists() {
155 return Response::error(
156 &req.id,
157 "file_not_found",
158 format!("file not found: {}", file),
159 );
160 }
161
162 let source = match std::fs::read_to_string(&path) {
164 Ok(s) => s,
165 Err(e) => {
166 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
167 }
168 };
169
170 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
171
172 match (start_line, end_line) {
174 (Some(start), Some(end)) => {
175 if zoom_symbol_param(&req.params).is_some() {
176 return Response::error(
177 &req.id,
178 "invalid_request",
179 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
180 );
181 }
182 if start == 0 || end == 0 {
183 return Response::error(
184 &req.id,
185 "invalid_request",
186 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
187 );
188 }
189 if end < start {
190 return Response::error(
191 &req.id,
192 "invalid_request",
193 format!("zoom: end_line {} must be >= start_line {}", end, start),
194 );
195 }
196 if lines.is_empty() {
197 return Response::error(
198 &req.id,
199 "invalid_request",
200 format!("zoom: {} is empty", file),
201 );
202 }
203
204 let start_idx = start - 1;
205 let clamped_end = end.min(lines.len());
207 let end_idx = clamped_end - 1;
208 if start_idx >= lines.len() {
209 return Response::error(
210 &req.id,
211 "invalid_request",
212 format!(
213 "zoom: start_line {} is past end of {} ({} lines)",
214 start,
215 file,
216 lines.len()
217 ),
218 );
219 }
220
221 let content = lines[start_idx..=end_idx].join("\n");
222 let ctx_start = start_idx.saturating_sub(context_lines);
223 let context_before: Vec<String> = if ctx_start < start_idx {
224 lines[ctx_start..start_idx]
225 .iter()
226 .map(|l| l.to_string())
227 .collect()
228 } else {
229 vec![]
230 };
231 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
232 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
233 lines[(end_idx + 1)..ctx_end]
234 .iter()
235 .map(|l| l.to_string())
236 .collect()
237 } else {
238 vec![]
239 };
240 let end_col = lines[end_idx].chars().count() as u32;
241
242 return Response::success(
243 &req.id,
244 serde_json::json!({
245 "name": format!("lines {}-{}", start, clamped_end),
246 "kind": "lines",
247 "range": {
248 "start_line": start, "start_col": 1,
250 "end_line": clamped_end,
251 "end_col": end_col + 1,
252 },
253 "content": content,
254 "context_before": context_before,
255 "context_after": context_after,
256 "annotations": {
257 "calls_out": [],
258 "called_by": [],
259 },
260 }),
261 );
262 }
263 (Some(_), None) | (None, Some(_)) => {
264 return Response::error(
265 &req.id,
266 "invalid_request",
267 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
268 );
269 }
270 (None, None) => {}
271 }
272
273 let lang = detect_language(&path);
274 let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
275 Ok(names) => names,
276 Err(resp) => return resp,
277 };
278
279 if symbol_names.is_empty() {
280 return Response::error(
281 &req.id,
282 "invalid_request",
283 "zoom: missing required param 'symbol'",
284 );
285 }
286
287 if symbol_names.len() == 1 {
288 return zoom_one_symbol(
289 req,
290 ctx,
291 &path,
292 file,
293 &source,
294 &lines,
295 &symbol_names[0],
296 context_lines,
297 include_callgraph,
298 );
299 }
300
301 zoom_batch_symbols(
302 req,
303 ctx,
304 &path,
305 file,
306 &source,
307 &lines,
308 &symbol_names,
309 context_lines,
310 include_callgraph,
311 )
312}
313
314fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
316 params
317 .get("symbol")
318 .or_else(|| params.get("symbols"))
319 .and_then(|v| v.as_str())
320}
321
322fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
323 matches!(lang, Some(LangId::Markdown | LangId::Html))
324}
325
326fn parse_zoom_symbol_names(
331 params: &serde_json::Value,
332 lang: Option<LangId>,
333) -> Result<Vec<String>, Response> {
334 if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
335 let names: Vec<String> = arr
336 .iter()
337 .filter_map(|v| v.as_str().map(str::trim))
338 .filter(|s| !s.is_empty())
339 .map(str::to_string)
340 .collect();
341 return Ok(names);
342 }
343
344 let Some(raw) = zoom_symbol_param(params) else {
345 return Ok(Vec::new());
346 };
347
348 if is_heading_zoom_language(lang) {
349 let trimmed = raw.trim();
350 if trimmed.is_empty() {
351 return Ok(Vec::new());
352 }
353 return Ok(vec![trimmed.to_string()]);
354 }
355
356 if raw.split_whitespace().count() <= 1 {
357 let trimmed = raw.trim();
358 if trimmed.is_empty() {
359 return Ok(Vec::new());
360 }
361 return Ok(vec![trimmed.to_string()]);
362 }
363
364 Ok(raw.split_whitespace().map(str::to_string).collect())
365}
366
367fn zoom_batch_symbols(
368 req: &RawRequest,
369 ctx: &AppContext,
370 path: &Path,
371 file: &str,
372 source: &str,
373 lines: &[String],
374 symbol_names: &[String],
375 context_lines: usize,
376 include_callgraph: bool,
377) -> Response {
378 let mut entries = Vec::with_capacity(symbol_names.len());
379 let mut all_ok = true;
380
381 for name in symbol_names {
382 let resp = zoom_one_symbol(
383 req,
384 ctx,
385 path,
386 file,
387 source,
388 lines,
389 name,
390 context_lines,
391 include_callgraph,
392 );
393 let json = match serde_json::to_value(&resp) {
394 Ok(v) => v,
395 Err(err) => {
396 return Response::error(
397 &req.id,
398 "internal_error",
399 format!("zoom: failed to serialize batch entry: {err}"),
400 );
401 }
402 };
403 if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
404 all_ok = false;
405 }
406 entries.push(serde_json::json!({
407 "name": name,
408 "response": json,
409 }));
410 }
411
412 Response::success(
413 &req.id,
414 serde_json::json!({
415 "complete": all_ok,
416 "symbols": entries,
417 }),
418 )
419}
420
421fn zoom_one_symbol(
422 req: &RawRequest,
423 ctx: &AppContext,
424 path: &Path,
425 _file: &str,
426 source: &str,
427 lines: &[String],
428 symbol_name: &str,
429 context_lines: usize,
430 include_callgraph: bool,
431) -> Response {
432 let lookup_name = match detect_language(path) {
436 Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
437 _ => symbol_name,
438 };
439 let matches = match ctx.provider().resolve_symbol(path, lookup_name) {
440 Ok(m) => m,
441 Err(crate::error::AftError::SymbolNotFound { name, .. }) => {
442 let mut msg = format!("symbol '{}' not found", name);
443 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
444 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
445 let suggestions = suggest_close_symbols(&name, &available, 5);
446 if !suggestions.is_empty() {
447 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
448 }
449 }
450 return Response::error(&req.id, "symbol_not_found", msg);
451 }
452 Err(e) => {
453 return Response::error(&req.id, e.code(), e.to_string());
454 }
455 };
456
457 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
459 lsp_hints::apply_lsp_disambiguation(matches, &hints)
460 } else {
461 matches
462 };
463
464 if matches.len() > 1 {
465 let content = render_ambiguous_symbol_menu(symbol_name, &matches);
466 let candidates = matches
467 .iter()
468 .map(|candidate| {
469 let sym = &candidate.symbol;
470 serde_json::json!({
471 "name": sym.name.clone(),
472 "qualified_name": qualified_symbol_name(sym),
473 "kind": symbol_kind_string(&sym.kind),
474 "range": sym.range.clone(),
475 "signature": sym.signature.clone(),
476 })
477 })
478 .collect::<Vec<_>>();
479
480 return Response::success(
481 &req.id,
482 serde_json::json!({
483 "name": symbol_name,
484 "kind": "ambiguous_symbol",
485 "content": content,
486 "context_before": [],
487 "context_after": [],
488 "annotations": empty_annotations(),
489 "candidates": candidates,
490 }),
491 );
492 }
493
494 if matches.is_empty() {
495 let mut msg = format!("symbol '{}' not found", symbol_name);
496 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
497 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
498 let suggestions = suggest_close_symbols(symbol_name, &available, 5);
499 if !suggestions.is_empty() {
500 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
501 }
502 }
503 return Response::error(&req.id, "symbol_not_found", msg);
504 }
505
506 let target = &matches[0].symbol;
507 let start = target.range.start_line as usize;
508 let end = target.range.end_line as usize;
509
510 let resolved_file_path = std::path::Path::new(&matches[0].file);
512 let resolved_lines: Vec<String>;
513 let effective_lines: &[String] = if resolved_file_path != path {
514 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
515 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
516 Err(_) => lines.to_vec(),
517 };
518 &resolved_lines
519 } else {
520 lines
521 };
522
523 let content = if end < effective_lines.len() {
525 effective_lines[start..=end].join("\n")
526 } else {
527 effective_lines[start..].join("\n")
528 };
529
530 let resolved_lang = detect_language(resolved_file_path);
531 let container_outline = if might_have_container_members(target) {
532 match build_container_outline(ctx, resolved_file_path, target) {
533 Ok(outline) => Some(outline),
534 Err(e) => {
535 return Response::error(&req.id, e.code(), e.to_string());
536 }
537 }
538 } else {
539 None
540 };
541
542 if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
543 let kind_str = symbol_kind_string(&target.kind);
544 let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
545 let resp = ZoomResponse {
546 name: target.name.clone(),
547 kind: kind_str,
548 range: target.range.clone(),
549 content: menu,
550 context_before: Vec::new(),
551 context_after: Vec::new(),
552 annotations: Annotations {
553 calls_out: Vec::new(),
554 called_by: Vec::new(),
555 },
556 };
557 return match serde_json::to_value(&resp) {
558 Ok(resp_json) => Response::success(&req.id, resp_json),
559 Err(err) => Response::error(
560 &req.id,
561 "internal_error",
562 format!("zoom: failed to serialize response: {err}"),
563 ),
564 };
565 }
566
567 let ctx_start = start.saturating_sub(context_lines);
569 let context_before: Vec<String> = if ctx_start < start {
570 effective_lines[ctx_start..start]
571 .iter()
572 .map(|l| l.to_string())
573 .collect()
574 } else {
575 vec![]
576 };
577
578 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
580 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
581 effective_lines[(end + 1)..ctx_end]
582 .iter()
583 .map(|l| l.to_string())
584 .collect()
585 } else {
586 vec![]
587 };
588
589 let (calls_out, called_by) = if include_callgraph {
590 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
592 Ok(s) => s,
593 Err(e) => {
594 return Response::error(&req.id, e.code(), e.to_string());
595 }
596 };
597
598 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
599
600 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
602 let (tree, lang) = match parser.parse(resolved_file_path) {
603 Ok(r) => r,
604 Err(e) => {
605 return Response::error(&req.id, e.code(), e.to_string());
606 }
607 };
608
609 let resolved_source = if resolved_file_path != path {
611 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
612 } else {
613 source.to_string()
614 };
615 let signature_byte_start = line_col_to_byte(
616 &resolved_source,
617 target.range.start_line,
618 target.range.start_col,
619 );
620 let signature_byte_end = line_col_to_byte(
621 &resolved_source,
622 target.range.end_line,
623 target.range.end_col,
624 );
625 let (target_byte_start, target_byte_end) =
626 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
627 .unwrap_or((signature_byte_start, signature_byte_end));
628
629 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
630
631 let raw_calls = all_file_calls.iter().filter(|call| {
632 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
633 });
634 let calls_out = dedupe_call_refs_by_name(
635 raw_calls
636 .filter(|call| {
637 known_names.contains(&call.name.as_str()) && call.name != target.name
638 })
639 .map(|call| CallRef {
640 name: call.name.clone(),
641 line: call.line,
642 extra_count: 0,
643 })
644 .collect(),
645 );
646
647 let mut called_by: Vec<CallRef> = Vec::new();
649 for sym in &all_symbols {
650 if sym.name == target.name && sym.range.start_line == target.range.start_line {
651 continue; }
653 let sym_byte_start =
654 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
655 let sym_byte_end =
656 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
657 for call in &all_file_calls {
658 if call.name == target.name
659 && call.start_byte >= sym_byte_start
660 && call.end_byte <= sym_byte_end
661 {
662 called_by.push(CallRef {
663 name: sym.name.clone(),
664 line: call.line,
665 extra_count: 0,
666 });
667 }
668 }
669 }
670
671 let called_by = dedupe_call_refs_by_name(called_by);
672
673 (calls_out, called_by)
674 } else {
675 (Vec::new(), Vec::new())
676 };
677
678 let kind_str = symbol_kind_string(&target.kind);
679
680 let resp = ZoomResponse {
681 name: target.name.clone(),
682 kind: kind_str,
683 range: target.range.clone(),
684 content,
685 context_before,
686 context_after,
687 annotations: Annotations {
688 calls_out,
689 called_by,
690 },
691 };
692
693 match serde_json::to_value(&resp) {
694 Ok(resp_json) => Response::success(&req.id, resp_json),
695 Err(err) => Response::error(
696 &req.id,
697 "internal_error",
698 format!("zoom: failed to serialize response: {err}"),
699 ),
700 }
701}
702
703fn empty_annotations() -> serde_json::Value {
704 serde_json::json!({
705 "calls_out": [],
706 "called_by": [],
707 })
708}
709
710fn render_ambiguous_symbol_menu(
711 symbol_name: &str,
712 matches: &[crate::symbols::SymbolMatch],
713) -> String {
714 let mut lines = vec![format!(
715 "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
716 matches.len()
717 )];
718
719 for candidate in matches {
720 let entry = symbol_to_entry(&candidate.symbol);
721 lines.push(format!(
722 "- {}",
723 format_qualified_entry(&entry, Some(&candidate.symbol))
724 ));
725 }
726
727 lines.join("\n")
728}
729
730fn levenshtein_distance(s1: &str, s2: &str) -> usize {
731 let s1_chars: Vec<char> = s1.chars().collect();
732 let s2_chars: Vec<char> = s2.chars().collect();
733 let len1 = s1_chars.len();
734 let len2 = s2_chars.len();
735
736 let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
737
738 for i in 0..=len1 {
739 dp[i][0] = i;
740 }
741 for j in 0..=len2 {
742 dp[0][j] = j;
743 }
744
745 for i in 1..=len1 {
746 for j in 1..=len2 {
747 if s1_chars[i - 1] == s2_chars[j - 1] {
748 dp[i][j] = dp[i - 1][j - 1];
749 } else {
750 dp[i][j] =
751 1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
752 }
753 }
754 }
755
756 dp[len1][len2]
757}
758
759fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
760 let mut unique: Vec<&String> = available.iter().collect();
761 unique.sort();
762 unique.dedup();
763
764 let query_lower = query.to_lowercase();
765 let query_len = query_lower.chars().count();
766 let max_dist = std::cmp::max(2, query_len / 3);
767
768 let mut scored: Vec<(bool, usize, &String)> = unique
769 .into_iter()
770 .map(|name| {
771 let name_lower = name.to_lowercase();
772 let is_substring =
773 name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
774 let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
775 (query_lower.find('_'), query_lower.rfind('_'))
776 {
777 let prefix = &query_lower[..=first_idx];
778 let suffix = &query_lower[last_idx..];
779 name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
780 } else {
781 false
782 };
783 let is_match = is_substring || is_wildcard;
784 let dist = levenshtein_distance(&query_lower, &name_lower);
785 (is_match, dist, name)
786 })
787 .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
788 .collect();
789
790 scored.sort_by(|a, b| {
791 let a_match = a.0;
792 let b_match = b.0;
793 (!a_match)
794 .cmp(&(!b_match))
795 .then_with(|| a.1.cmp(&b.1))
796 .then_with(|| a.2.cmp(b.2))
797 });
798
799 scored
800 .into_iter()
801 .take(k)
802 .map(|(_, _, name)| name.clone())
803 .collect()
804}
805
806fn normalize_heading_query(input: &str) -> &str {
807 let trimmed = input.trim_start();
808 let hash_stripped = trimmed.trim_start_matches('#').trim_start();
809
810 if let Some(after_open) = hash_stripped.strip_prefix('<') {
811 let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
812 let mut chars = after_slash.chars();
813 if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
814 if let Some(end) = hash_stripped.find('>') {
815 return hash_stripped[end + 1..].trim_start();
816 }
817 }
818 }
819
820 hash_stripped
821}
822
823#[cfg(test)]
827fn extract_calls_in_range(
828 source: &str,
829 root: tree_sitter::Node,
830 byte_start: usize,
831 byte_end: usize,
832 lang: LangId,
833) -> Vec<(String, u32)> {
834 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
835}
836
837fn symbol_body_byte_range(
838 root: tree_sitter::Node,
839 byte_start: usize,
840 byte_end: usize,
841) -> Option<(usize, usize)> {
842 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
843 let mut current = Some(node);
844 while let Some(node) = current {
845 if is_symbol_body_node(node.kind()) {
846 return Some((node.start_byte(), node.end_byte()));
847 }
848 current = node.parent();
849 }
850 Some((node.start_byte(), node.end_byte()))
851}
852
853fn smallest_node_covering_range<'tree>(
854 node: tree_sitter::Node<'tree>,
855 byte_start: usize,
856 byte_end: usize,
857) -> Option<tree_sitter::Node<'tree>> {
858 if node.start_byte() > byte_start || node.end_byte() < byte_end {
859 return None;
860 }
861
862 let mut cursor = node.walk();
863 if cursor.goto_first_child() {
864 loop {
865 let child = cursor.node();
866 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
867 return Some(found);
868 }
869 if !cursor.goto_next_sibling() {
870 break;
871 }
872 }
873 }
874
875 Some(node)
876}
877
878fn is_symbol_body_node(kind: &str) -> bool {
879 matches!(
880 kind,
881 "function_declaration"
882 | "generator_function_declaration"
883 | "function_expression"
884 | "generator_function"
885 | "arrow_function"
886 | "method_definition"
887 | "class_declaration"
888 | "abstract_class_declaration"
889 | "class"
890 | "lexical_declaration"
891 | "function_definition"
892 | "class_definition"
893 | "decorated_definition"
894 | "function_item"
895 | "impl_item"
896 | "method_declaration"
897 )
898}
899
900fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
901 let mut results = Vec::new();
902 let call_kinds = crate::calls::call_node_kinds(lang);
903 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
904 results
905}
906
907fn collect_calls_with_ranges(
908 node: tree_sitter::Node,
909 source: &str,
910 call_kinds: &[&str],
911 results: &mut Vec<RawCall>,
912) {
913 if call_kinds.contains(&node.kind()) {
914 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
915 results.push(RawCall {
916 name,
917 line: node.start_position().row as u32 + 1,
918 start_byte: node.start_byte(),
919 end_byte: node.end_byte(),
920 });
921 }
922 }
923
924 let mut cursor = node.walk();
925 if cursor.goto_first_child() {
926 loop {
927 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
928 if !cursor.goto_next_sibling() {
929 break;
930 }
931 }
932 }
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938 use crate::config::Config;
939 use crate::context::AppContext;
940 use crate::parser::TreeSitterProvider;
941 use std::path::PathBuf;
942
943 fn fixture_path(name: &str) -> PathBuf {
944 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
945 .join("tests")
946 .join("fixtures")
947 .join(name)
948 }
949
950 fn make_ctx() -> AppContext {
951 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
952 }
953
954 #[test]
955 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
956 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
957 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
958 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
959 }
960
961 #[test]
962 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
963 let params = serde_json::json!({ "symbols": "Getting Started" });
964 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
965 assert_eq!(names, vec!["Getting Started"]);
966 }
967
968 #[test]
969 fn parse_zoom_symbol_names_does_not_split_html_headings() {
970 let params = serde_json::json!({ "symbol": "Last Heading" });
971 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
972 assert_eq!(names, vec!["Last Heading"]);
973 }
974
975 #[test]
976 fn parse_zoom_symbol_names_single_token_unchanged() {
977 let params = serde_json::json!({ "symbol": "compute" });
978 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
979 assert_eq!(names, vec!["compute"]);
980 }
981
982 #[test]
983 fn parse_zoom_symbol_names_symbols_array_unchanged() {
984 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
985 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
986 assert_eq!(names, vec!["A", "B", "C"]);
987 }
988
989 #[test]
992 fn extract_calls_finds_direct_calls() {
993 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
994 let mut parser = FileParser::new();
995 let path = fixture_path("calls.ts");
996 let (tree, lang) = parser.parse(&path).unwrap();
997
998 let ctx = make_ctx();
1000 let symbols = ctx.provider().list_symbols(&path).unwrap();
1001 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1002
1003 let byte_start =
1004 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1005 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1006
1007 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1008 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1009
1010 assert!(
1011 names.contains(&"helper"),
1012 "compute should call helper, got: {:?}",
1013 names
1014 );
1015 }
1016
1017 #[test]
1018 fn extract_calls_finds_member_calls() {
1019 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1020 let mut parser = FileParser::new();
1021 let path = fixture_path("calls.ts");
1022 let (tree, lang) = parser.parse(&path).unwrap();
1023
1024 let ctx = make_ctx();
1025 let symbols = ctx.provider().list_symbols(&path).unwrap();
1026 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1027
1028 let byte_start =
1029 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1030 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1031
1032 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1033 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1034
1035 assert!(
1036 names.contains(&"add"),
1037 "runAll should call this.add, got: {:?}",
1038 names
1039 );
1040 assert!(
1041 names.contains(&"helper"),
1042 "runAll should call helper, got: {:?}",
1043 names
1044 );
1045 }
1046
1047 #[test]
1048 fn extract_calls_unused_function_has_no_calls() {
1049 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1050 let mut parser = FileParser::new();
1051 let path = fixture_path("calls.ts");
1052 let (tree, lang) = parser.parse(&path).unwrap();
1053
1054 let ctx = make_ctx();
1055 let symbols = ctx.provider().list_symbols(&path).unwrap();
1056 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1057
1058 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1059 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1060
1061 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1062 let known_names = [
1064 "helper",
1065 "compute",
1066 "orchestrate",
1067 "unused",
1068 "format",
1069 "display",
1070 ];
1071 let filtered: Vec<&str> = calls
1072 .iter()
1073 .map(|(n, _)| n.as_str())
1074 .filter(|n| known_names.contains(n))
1075 .collect();
1076 assert!(
1077 filtered.is_empty(),
1078 "unused should not call known symbols, got: {:?}",
1079 filtered
1080 );
1081 }
1082
1083 #[test]
1086 fn context_lines_clamp_at_file_start() {
1087 let ctx = make_ctx();
1089 let path = fixture_path("calls.ts");
1090 let symbols = ctx.provider().list_symbols(&path).unwrap();
1091 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1092
1093 let source = std::fs::read_to_string(&path).unwrap();
1094 let lines: Vec<&str> = source.lines().collect();
1095 let start = helper.range.start_line as usize;
1096
1097 let ctx_start = start.saturating_sub(5);
1099 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1100 assert!(context_before.len() <= start);
1102 }
1103
1104 #[test]
1105 fn context_lines_clamp_at_file_end() {
1106 let ctx = make_ctx();
1107 let path = fixture_path("calls.ts");
1108 let symbols = ctx.provider().list_symbols(&path).unwrap();
1109 let display = symbols.iter().find(|s| s.name == "display").unwrap();
1110
1111 let source = std::fs::read_to_string(&path).unwrap();
1112 let lines: Vec<&str> = source.lines().collect();
1113 let end = display.range.end_line as usize;
1114
1115 let ctx_end = (end + 1 + 20).min(lines.len());
1117 let context_after: Vec<&str> = if end + 1 < lines.len() {
1118 lines[(end + 1)..ctx_end].to_vec()
1119 } else {
1120 vec![]
1121 };
1122 assert!(context_after.len() <= 20);
1124 }
1125
1126 #[test]
1129 fn body_extraction_matches_source() {
1130 let ctx = make_ctx();
1131 let path = fixture_path("calls.ts");
1132 let symbols = ctx.provider().list_symbols(&path).unwrap();
1133 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1134
1135 let source = std::fs::read_to_string(&path).unwrap();
1136 let lines: Vec<&str> = source.lines().collect();
1137 let start = compute.range.start_line as usize;
1138 let end = compute.range.end_line as usize;
1139 let body = lines[start..=end].join("\n");
1140
1141 assert!(
1142 body.contains("function compute"),
1143 "body should contain function declaration"
1144 );
1145 assert!(
1146 body.contains("helper(a)"),
1147 "body should contain call to helper"
1148 );
1149 assert!(
1150 body.contains("doubled + b"),
1151 "body should contain return expression"
1152 );
1153 }
1154
1155 #[test]
1158 fn body_range_expands_signature_range_to_include_body_calls() {
1159 let source = r#"function compute(
1160 value: number,
1161): number {
1162 return helper(value);
1163}
1164
1165function helper(value: number): number {
1166 return value * 2;
1167}
1168"#;
1169 let grammar = crate::parser::grammar_for(LangId::TypeScript);
1170 let mut parser = tree_sitter::Parser::new();
1171 parser.set_language(&grammar).unwrap();
1172 let tree = parser.parse(source, None).unwrap();
1173 let signature_end = source.find('{').expect("function has body");
1174
1175 let (body_start, body_end) =
1176 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1177 let calls = extract_calls_in_range(
1178 source,
1179 tree.root_node(),
1180 body_start,
1181 body_end,
1182 LangId::TypeScript,
1183 );
1184 let names = calls
1185 .iter()
1186 .map(|(name, _)| name.as_str())
1187 .collect::<Vec<_>>();
1188
1189 assert!(
1190 names.contains(&"helper"),
1191 "call inside the function body should be included: {names:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn zoom_leaf_returns_full_body_without_budget_marker() {
1197 let ctx = make_ctx();
1198 let path = fixture_path("calls.ts");
1199 let req = make_zoom_request(
1200 "z-leaf-full",
1201 path.to_str().unwrap(),
1202 "repeatedOutgoing",
1203 None,
1204 );
1205 let resp = handle_zoom(&req, &ctx);
1206 let json = serde_json::to_value(&resp).unwrap();
1207 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1208
1209 let symbols = ctx.provider().list_symbols(&path).unwrap();
1210 let target = symbols
1211 .iter()
1212 .find(|symbol| symbol.name == "repeatedOutgoing")
1213 .unwrap();
1214 let source = std::fs::read_to_string(&path).unwrap();
1215 let lines = source.lines().collect::<Vec<_>>();
1216 let expected =
1217 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1218
1219 assert_eq!(json["content"].as_str().unwrap(), expected);
1220 assert!(
1221 !json["content"]
1222 .as_str()
1223 .unwrap()
1224 .contains("more lines — zoom"),
1225 "explicit zoom must not budget-cap leaf bodies"
1226 );
1227 }
1228
1229 #[test]
1230 fn zoom_response_has_calls_out_and_called_by() {
1231 let ctx = make_ctx();
1232 let path = fixture_path("calls.ts");
1233
1234 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1235 let resp = handle_zoom(&req, &ctx);
1236
1237 let json = serde_json::to_value(&resp).unwrap();
1238 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1239
1240 let calls_out = json["annotations"]["calls_out"]
1241 .as_array()
1242 .expect("calls_out array");
1243 let out_names: Vec<&str> = calls_out
1244 .iter()
1245 .map(|c| c["name"].as_str().unwrap())
1246 .collect();
1247 assert!(
1248 out_names.contains(&"helper"),
1249 "compute calls helper: {:?}",
1250 out_names
1251 );
1252
1253 let called_by = json["annotations"]["called_by"]
1254 .as_array()
1255 .expect("called_by array");
1256 let by_names: Vec<&str> = called_by
1257 .iter()
1258 .map(|c| c["name"].as_str().unwrap())
1259 .collect();
1260 assert!(
1261 by_names.contains(&"orchestrate"),
1262 "orchestrate calls compute: {:?}",
1263 by_names
1264 );
1265 }
1266
1267 #[test]
1268 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1269 let ctx = make_ctx();
1270 let path = fixture_path("calls.ts");
1271
1272 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1273 let resp = handle_zoom(&req, &ctx);
1274 let json = serde_json::to_value(&resp).unwrap();
1275 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1276
1277 let calls_out = json["annotations"]["calls_out"]
1278 .as_array()
1279 .expect("calls_out array");
1280 let helper_refs = calls_out
1281 .iter()
1282 .filter(|call| call["name"] == "helper")
1283 .collect::<Vec<_>>();
1284 assert_eq!(
1285 helper_refs.len(),
1286 1,
1287 "helper should be folded once: {calls_out:?}"
1288 );
1289 assert_eq!(helper_refs[0]["extra_count"], 1);
1290 assert!(
1291 calls_out.iter().any(|call| call["name"] == "format"),
1292 "distinct callee must not be folded into helper: {calls_out:?}"
1293 );
1294
1295 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1296 let resp = handle_zoom(&req, &ctx);
1297 let json = serde_json::to_value(&resp).unwrap();
1298 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1299
1300 let called_by = json["annotations"]["called_by"]
1301 .as_array()
1302 .expect("called_by array");
1303 let repeat_refs = called_by
1304 .iter()
1305 .filter(|call| call["name"] == "repeatCompute")
1306 .collect::<Vec<_>>();
1307 assert_eq!(
1308 repeat_refs.len(),
1309 1,
1310 "repeatCompute should be folded once: {called_by:?}"
1311 );
1312 assert_eq!(repeat_refs[0]["extra_count"], 1);
1313 assert!(
1314 called_by.iter().any(|call| call["name"] == "orchestrate"),
1315 "distinct caller must not be folded into repeatCompute: {called_by:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn zoom_response_empty_annotations_for_unused() {
1321 let ctx = make_ctx();
1322 let path = fixture_path("calls.ts");
1323
1324 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1325 let resp = handle_zoom(&req, &ctx);
1326
1327 let json = serde_json::to_value(&resp).unwrap();
1328 assert_eq!(json["success"], true);
1329
1330 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1331 let called_by = json["annotations"]["called_by"].as_array().unwrap();
1332
1333 assert!(
1336 called_by.is_empty(),
1337 "unused should not be called by anyone: {:?}",
1338 called_by
1339 );
1340 }
1341
1342 #[test]
1343 fn zoom_default_omits_callgraph_annotations() {
1344 let ctx = make_ctx();
1345 let path = fixture_path("calls.ts");
1346
1347 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1348 let resp = handle_zoom(&req, &ctx);
1349
1350 let json = serde_json::to_value(&resp).unwrap();
1351 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1352
1353 let calls_out = json["annotations"]["calls_out"]
1354 .as_array()
1355 .expect("calls_out array");
1356 let called_by = json["annotations"]["called_by"]
1357 .as_array()
1358 .expect("called_by array");
1359 assert!(
1360 calls_out.is_empty(),
1361 "default zoom should omit calls_out: {:?}",
1362 calls_out
1363 );
1364 assert!(
1365 called_by.is_empty(),
1366 "default zoom should omit called_by: {:?}",
1367 called_by
1368 );
1369 }
1370
1371 #[test]
1372 fn zoom_symbol_not_found() {
1373 let ctx = make_ctx();
1374 let path = fixture_path("calls.ts");
1375
1376 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1377 let resp = handle_zoom(&req, &ctx);
1378
1379 let json = serde_json::to_value(&resp).unwrap();
1380 assert_eq!(json["success"], false);
1381 assert_eq!(json["code"], "symbol_not_found");
1382 }
1383
1384 #[test]
1385 fn zoom_custom_context_lines() {
1386 let ctx = make_ctx();
1387 let path = fixture_path("calls.ts");
1388
1389 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1390 let resp = handle_zoom(&req, &ctx);
1391
1392 let json = serde_json::to_value(&resp).unwrap();
1393 assert_eq!(json["success"], true);
1394
1395 let ctx_before = json["context_before"].as_array().unwrap();
1396 let ctx_after = json["context_after"].as_array().unwrap();
1397 assert!(
1399 ctx_before.len() <= 1,
1400 "context_before should be ≤1: {:?}",
1401 ctx_before
1402 );
1403 assert!(
1404 ctx_after.len() <= 1,
1405 "context_after should be ≤1: {:?}",
1406 ctx_after
1407 );
1408 }
1409
1410 #[test]
1411 fn zoom_missing_file_param() {
1412 let ctx = make_ctx();
1413 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1414 let resp = handle_zoom(&req, &ctx);
1415
1416 let json = serde_json::to_value(&resp).unwrap();
1417 assert_eq!(json["success"], false);
1418 assert_eq!(json["code"], "invalid_request");
1419 }
1420
1421 #[test]
1422 fn zoom_missing_symbol_param() {
1423 let ctx = make_ctx();
1424 let path = fixture_path("calls.ts");
1425 let req_value = serde_json::json!({
1429 "id": "z-6",
1430 "command": "zoom",
1431 "file": path.to_string_lossy(),
1432 });
1433 let req_str = req_value.to_string();
1434 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1435 let resp = handle_zoom(&req, &ctx);
1436
1437 let json = serde_json::to_value(&resp).unwrap();
1438 assert_eq!(json["success"], false);
1439 assert_eq!(json["code"], "invalid_request");
1440 }
1441
1442 #[test]
1443 fn test_suggest_close_symbols_unit() {
1444 let available = vec![
1445 "handle_grep_search".to_string(),
1446 "handle_semantic_search".to_string(),
1447 "handle_semantic_or_hybrid_search".to_string(),
1448 "compute_total".to_string(),
1449 "search".to_string(),
1450 "handle_search".to_string(),
1451 ];
1452
1453 let suggestions = suggest_close_symbols("handle_search", &available, 5);
1454 assert!(suggestions.contains(&"handle_grep_search".to_string()));
1455 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1456 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1457 assert!(suggestions.contains(&"search".to_string()));
1458 assert!(!suggestions.contains(&"compute_total".to_string()));
1459
1460 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1461 assert_eq!(suggestions, suggestions_caps);
1462
1463 let available2 = vec![
1464 "total".to_string(),
1465 "compute_total".to_string(),
1466 "unrelated".to_string(),
1467 ];
1468 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1469 assert_eq!(suggestions2, vec!["total".to_string()]);
1470 }
1471
1472 fn make_zoom_request(
1475 id: &str,
1476 file: &str,
1477 symbol: &str,
1478 context_lines: Option<u64>,
1479 ) -> RawRequest {
1480 let mut json = serde_json::json!({
1481 "id": id,
1482 "command": "zoom",
1483 "file": file,
1484 "symbol": symbol,
1485 });
1486 if let Some(cl) = context_lines {
1487 json["context_lines"] = serde_json::json!(cl);
1488 }
1489 serde_json::from_value(json).unwrap()
1490 }
1491
1492 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1493 let mut req = make_zoom_request(id, file, symbol, None);
1494 req.params["callgraph"] = serde_json::json!(true);
1495 req
1496 }
1497
1498 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1499 serde_json::from_str(json_str).unwrap()
1500 }
1501}