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