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 {
79 let file = match req
80 .params
81 .get("file")
82 .or_else(|| req.params.get("url"))
83 .and_then(|v| v.as_str())
84 {
85 Some(f) => f,
86 None => {
87 return Response::error(
88 &req.id,
89 "invalid_request",
90 "zoom: missing required param 'file'",
91 );
92 }
93 };
94
95 let context_lines = req
96 .params
97 .get("context_lines")
98 .and_then(|v| v.as_u64())
99 .unwrap_or(3) as usize;
100 let include_callgraph = req
101 .params
102 .get("callgraph")
103 .and_then(|v| v.as_bool())
104 .unwrap_or(false);
105
106 let start_line = req
107 .params
108 .get("start_line")
109 .and_then(|v| v.as_u64())
110 .map(|v| v as usize);
111 let end_line = req
112 .params
113 .get("end_line")
114 .and_then(|v| v.as_u64())
115 .map(|v| v as usize);
116
117 let path = match resolve_file_or_url(req, ctx, file) {
118 Ok(path) => path,
119 Err(resp) => return resp,
120 };
121 if !path.exists() {
122 return Response::error(
123 &req.id,
124 "file_not_found",
125 format!("file not found: {}", file),
126 );
127 }
128
129 let source = match std::fs::read_to_string(&path) {
131 Ok(s) => s,
132 Err(e) => {
133 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
134 }
135 };
136
137 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
138
139 match (start_line, end_line) {
141 (Some(start), Some(end)) => {
142 if req.params.get("symbol").is_some() {
143 return Response::error(
144 &req.id,
145 "invalid_request",
146 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
147 );
148 }
149 if start == 0 || end == 0 {
150 return Response::error(
151 &req.id,
152 "invalid_request",
153 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
154 );
155 }
156 if end < start {
157 return Response::error(
158 &req.id,
159 "invalid_request",
160 format!("zoom: end_line {} must be >= start_line {}", end, start),
161 );
162 }
163 if lines.is_empty() {
164 return Response::error(
165 &req.id,
166 "invalid_request",
167 format!("zoom: {} is empty", file),
168 );
169 }
170
171 let start_idx = start - 1;
172 let clamped_end = end.min(lines.len());
174 let end_idx = clamped_end - 1;
175 if start_idx >= lines.len() {
176 return Response::error(
177 &req.id,
178 "invalid_request",
179 format!(
180 "zoom: start_line {} is past end of {} ({} lines)",
181 start,
182 file,
183 lines.len()
184 ),
185 );
186 }
187
188 let content = lines[start_idx..=end_idx].join("\n");
189 let ctx_start = start_idx.saturating_sub(context_lines);
190 let context_before: Vec<String> = if ctx_start < start_idx {
191 lines[ctx_start..start_idx]
192 .iter()
193 .map(|l| l.to_string())
194 .collect()
195 } else {
196 vec![]
197 };
198 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
199 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
200 lines[(end_idx + 1)..ctx_end]
201 .iter()
202 .map(|l| l.to_string())
203 .collect()
204 } else {
205 vec![]
206 };
207 let end_col = lines[end_idx].chars().count() as u32;
208
209 return Response::success(
210 &req.id,
211 serde_json::json!({
212 "name": format!("lines {}-{}", start, clamped_end),
213 "kind": "lines",
214 "range": {
215 "start_line": start, "start_col": 1,
217 "end_line": clamped_end,
218 "end_col": end_col + 1,
219 },
220 "content": content,
221 "context_before": context_before,
222 "context_after": context_after,
223 "annotations": {
224 "calls_out": [],
225 "called_by": [],
226 },
227 }),
228 );
229 }
230 (Some(_), None) | (None, Some(_)) => {
231 return Response::error(
232 &req.id,
233 "invalid_request",
234 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
235 );
236 }
237 (None, None) => {}
238 }
239
240 let symbol_name = match req.params.get("symbol").and_then(|v| v.as_str()) {
241 Some(s) => s,
242 None => {
243 return Response::error(
244 &req.id,
245 "invalid_request",
246 "zoom: missing required param 'symbol' (or use 'start_line' and 'end_line')",
247 );
248 }
249 };
250
251 let lookup_name = match detect_language(&path) {
255 Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
256 _ => symbol_name,
257 };
258 let matches = match ctx.provider().resolve_symbol(&path, lookup_name) {
259 Ok(m) => m,
260 Err(e) => {
261 return Response::error(&req.id, e.code(), e.to_string());
262 }
263 };
264
265 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
267 lsp_hints::apply_lsp_disambiguation(matches, &hints)
268 } else {
269 matches
270 };
271
272 if matches.len() > 1 {
273 let candidates: Vec<String> = matches
276 .iter()
277 .map(|m| {
278 let sym = &m.symbol;
279 let start = sym.range.start_line + 1;
280 let end = sym.range.end_line + 1;
281 let line_range = if start == end {
282 format!("{}", start)
283 } else {
284 format!("{}-{}", start, end)
285 };
286 if sym.scope_chain.is_empty() {
287 format!("{}:{}", sym.name, line_range)
288 } else {
289 format!(
290 "{}::{}:{}",
291 sym.scope_chain.join("::"),
292 sym.name,
293 line_range
294 )
295 }
296 })
297 .collect();
298 return Response::error(
299 &req.id,
300 "ambiguous_symbol",
301 format!(
302 "symbol '{}' is ambiguous, candidates: [{}]",
303 symbol_name,
304 candidates.join(", ")
305 ),
306 );
307 }
308
309 let target = &matches[0].symbol;
310 let start = target.range.start_line as usize;
311 let end = target.range.end_line as usize;
312
313 let resolved_file_path = std::path::Path::new(&matches[0].file);
315 let resolved_lines: Vec<String>;
316 let effective_lines: &[String] = if resolved_file_path != path {
317 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
318 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
319 Err(_) => lines.clone(),
320 };
321 &resolved_lines
322 } else {
323 &lines
324 };
325
326 let content = if end < effective_lines.len() {
328 effective_lines[start..=end].join("\n")
329 } else {
330 effective_lines[start..].join("\n")
331 };
332
333 let ctx_start = start.saturating_sub(context_lines);
335 let context_before: Vec<String> = if ctx_start < start {
336 effective_lines[ctx_start..start]
337 .iter()
338 .map(|l| l.to_string())
339 .collect()
340 } else {
341 vec![]
342 };
343
344 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
346 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
347 effective_lines[(end + 1)..ctx_end]
348 .iter()
349 .map(|l| l.to_string())
350 .collect()
351 } else {
352 vec![]
353 };
354
355 let (calls_out, called_by) = if include_callgraph {
356 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
358 Ok(s) => s,
359 Err(e) => {
360 return Response::error(&req.id, e.code(), e.to_string());
361 }
362 };
363
364 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
365
366 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
368 let (tree, lang) = match parser.parse(resolved_file_path) {
369 Ok(r) => r,
370 Err(e) => {
371 return Response::error(&req.id, e.code(), e.to_string());
372 }
373 };
374
375 let resolved_source = if resolved_file_path != path {
377 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.clone())
378 } else {
379 source.clone()
380 };
381 let signature_byte_start = line_col_to_byte(
382 &resolved_source,
383 target.range.start_line,
384 target.range.start_col,
385 );
386 let signature_byte_end = line_col_to_byte(
387 &resolved_source,
388 target.range.end_line,
389 target.range.end_col,
390 );
391 let (target_byte_start, target_byte_end) =
392 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
393 .unwrap_or((signature_byte_start, signature_byte_end));
394
395 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
396
397 let raw_calls = all_file_calls.iter().filter(|call| {
398 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
399 });
400 let calls_out: Vec<CallRef> = raw_calls
401 .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
402 .map(|call| CallRef {
403 name: call.name.clone(),
404 line: call.line,
405 })
406 .collect();
407
408 let mut called_by: Vec<CallRef> = Vec::new();
410 for sym in &all_symbols {
411 if sym.name == target.name && sym.range.start_line == target.range.start_line {
412 continue; }
414 let sym_byte_start =
415 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
416 let sym_byte_end =
417 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
418 for call in &all_file_calls {
419 if call.name == target.name
420 && call.start_byte >= sym_byte_start
421 && call.end_byte <= sym_byte_end
422 {
423 called_by.push(CallRef {
424 name: sym.name.clone(),
425 line: call.line,
426 });
427 }
428 }
429 }
430
431 called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
433 called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
434
435 (calls_out, called_by)
436 } else {
437 (Vec::new(), Vec::new())
438 };
439
440 let kind_str = serde_json::to_value(&target.kind)
441 .ok()
442 .and_then(|v| v.as_str().map(String::from))
443 .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
444
445 let resp = ZoomResponse {
446 name: target.name.clone(),
447 kind: kind_str,
448 range: target.range.clone(),
449 content,
450 context_before,
451 context_after,
452 annotations: Annotations {
453 calls_out,
454 called_by,
455 },
456 };
457
458 match serde_json::to_value(&resp) {
459 Ok(resp_json) => Response::success(&req.id, resp_json),
460 Err(err) => Response::error(
461 &req.id,
462 "internal_error",
463 format!("zoom: failed to serialize response: {err}"),
464 ),
465 }
466}
467
468fn normalize_heading_query(input: &str) -> &str {
469 let trimmed = input.trim_start();
470 let hash_stripped = trimmed.trim_start_matches('#').trim_start();
471
472 if let Some(after_open) = hash_stripped.strip_prefix('<') {
473 let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
474 let mut chars = after_slash.chars();
475 if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
476 if let Some(end) = hash_stripped.find('>') {
477 return hash_stripped[end + 1..].trim_start();
478 }
479 }
480 }
481
482 hash_stripped
483}
484
485#[cfg(test)]
489fn extract_calls_in_range(
490 source: &str,
491 root: tree_sitter::Node,
492 byte_start: usize,
493 byte_end: usize,
494 lang: LangId,
495) -> Vec<(String, u32)> {
496 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
497}
498
499fn symbol_body_byte_range(
500 root: tree_sitter::Node,
501 byte_start: usize,
502 byte_end: usize,
503) -> Option<(usize, usize)> {
504 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
505 let mut current = Some(node);
506 while let Some(node) = current {
507 if is_symbol_body_node(node.kind()) {
508 return Some((node.start_byte(), node.end_byte()));
509 }
510 current = node.parent();
511 }
512 Some((node.start_byte(), node.end_byte()))
513}
514
515fn smallest_node_covering_range<'tree>(
516 node: tree_sitter::Node<'tree>,
517 byte_start: usize,
518 byte_end: usize,
519) -> Option<tree_sitter::Node<'tree>> {
520 if node.start_byte() > byte_start || node.end_byte() < byte_end {
521 return None;
522 }
523
524 let mut cursor = node.walk();
525 if cursor.goto_first_child() {
526 loop {
527 let child = cursor.node();
528 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
529 return Some(found);
530 }
531 if !cursor.goto_next_sibling() {
532 break;
533 }
534 }
535 }
536
537 Some(node)
538}
539
540fn is_symbol_body_node(kind: &str) -> bool {
541 matches!(
542 kind,
543 "function_declaration"
544 | "generator_function_declaration"
545 | "function_expression"
546 | "generator_function"
547 | "arrow_function"
548 | "method_definition"
549 | "class_declaration"
550 | "abstract_class_declaration"
551 | "class"
552 | "lexical_declaration"
553 | "function_definition"
554 | "class_definition"
555 | "decorated_definition"
556 | "function_item"
557 | "impl_item"
558 | "method_declaration"
559 )
560}
561
562fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
563 let mut results = Vec::new();
564 let call_kinds = crate::calls::call_node_kinds(lang);
565 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
566 results
567}
568
569fn collect_calls_with_ranges(
570 node: tree_sitter::Node,
571 source: &str,
572 call_kinds: &[&str],
573 results: &mut Vec<RawCall>,
574) {
575 if call_kinds.contains(&node.kind()) {
576 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
577 results.push(RawCall {
578 name,
579 line: node.start_position().row as u32 + 1,
580 start_byte: node.start_byte(),
581 end_byte: node.end_byte(),
582 });
583 }
584 }
585
586 let mut cursor = node.walk();
587 if cursor.goto_first_child() {
588 loop {
589 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
590 if !cursor.goto_next_sibling() {
591 break;
592 }
593 }
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::config::Config;
601 use crate::context::AppContext;
602 use crate::parser::TreeSitterProvider;
603 use std::path::PathBuf;
604
605 fn fixture_path(name: &str) -> PathBuf {
606 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
607 .join("tests")
608 .join("fixtures")
609 .join(name)
610 }
611
612 fn make_ctx() -> AppContext {
613 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
614 }
615
616 #[test]
619 fn extract_calls_finds_direct_calls() {
620 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
621 let mut parser = FileParser::new();
622 let path = fixture_path("calls.ts");
623 let (tree, lang) = parser.parse(&path).unwrap();
624
625 let ctx = make_ctx();
627 let symbols = ctx.provider().list_symbols(&path).unwrap();
628 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
629
630 let byte_start =
631 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
632 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
633
634 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
635 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
636
637 assert!(
638 names.contains(&"helper"),
639 "compute should call helper, got: {:?}",
640 names
641 );
642 }
643
644 #[test]
645 fn extract_calls_finds_member_calls() {
646 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
647 let mut parser = FileParser::new();
648 let path = fixture_path("calls.ts");
649 let (tree, lang) = parser.parse(&path).unwrap();
650
651 let ctx = make_ctx();
652 let symbols = ctx.provider().list_symbols(&path).unwrap();
653 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
654
655 let byte_start =
656 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
657 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
658
659 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
660 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
661
662 assert!(
663 names.contains(&"add"),
664 "runAll should call this.add, got: {:?}",
665 names
666 );
667 assert!(
668 names.contains(&"helper"),
669 "runAll should call helper, got: {:?}",
670 names
671 );
672 }
673
674 #[test]
675 fn extract_calls_unused_function_has_no_calls() {
676 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
677 let mut parser = FileParser::new();
678 let path = fixture_path("calls.ts");
679 let (tree, lang) = parser.parse(&path).unwrap();
680
681 let ctx = make_ctx();
682 let symbols = ctx.provider().list_symbols(&path).unwrap();
683 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
684
685 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
686 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
687
688 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
689 let known_names = [
691 "helper",
692 "compute",
693 "orchestrate",
694 "unused",
695 "format",
696 "display",
697 ];
698 let filtered: Vec<&str> = calls
699 .iter()
700 .map(|(n, _)| n.as_str())
701 .filter(|n| known_names.contains(n))
702 .collect();
703 assert!(
704 filtered.is_empty(),
705 "unused should not call known symbols, got: {:?}",
706 filtered
707 );
708 }
709
710 #[test]
713 fn context_lines_clamp_at_file_start() {
714 let ctx = make_ctx();
716 let path = fixture_path("calls.ts");
717 let symbols = ctx.provider().list_symbols(&path).unwrap();
718 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
719
720 let source = std::fs::read_to_string(&path).unwrap();
721 let lines: Vec<&str> = source.lines().collect();
722 let start = helper.range.start_line as usize;
723
724 let ctx_start = start.saturating_sub(5);
726 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
727 assert!(context_before.len() <= start);
729 }
730
731 #[test]
732 fn context_lines_clamp_at_file_end() {
733 let ctx = make_ctx();
734 let path = fixture_path("calls.ts");
735 let symbols = ctx.provider().list_symbols(&path).unwrap();
736 let display = symbols.iter().find(|s| s.name == "display").unwrap();
737
738 let source = std::fs::read_to_string(&path).unwrap();
739 let lines: Vec<&str> = source.lines().collect();
740 let end = display.range.end_line as usize;
741
742 let ctx_end = (end + 1 + 20).min(lines.len());
744 let context_after: Vec<&str> = if end + 1 < lines.len() {
745 lines[(end + 1)..ctx_end].to_vec()
746 } else {
747 vec![]
748 };
749 assert!(context_after.len() <= 20);
751 }
752
753 #[test]
756 fn body_extraction_matches_source() {
757 let ctx = make_ctx();
758 let path = fixture_path("calls.ts");
759 let symbols = ctx.provider().list_symbols(&path).unwrap();
760 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
761
762 let source = std::fs::read_to_string(&path).unwrap();
763 let lines: Vec<&str> = source.lines().collect();
764 let start = compute.range.start_line as usize;
765 let end = compute.range.end_line as usize;
766 let body = lines[start..=end].join("\n");
767
768 assert!(
769 body.contains("function compute"),
770 "body should contain function declaration"
771 );
772 assert!(
773 body.contains("helper(a)"),
774 "body should contain call to helper"
775 );
776 assert!(
777 body.contains("doubled + b"),
778 "body should contain return expression"
779 );
780 }
781
782 #[test]
785 fn body_range_expands_signature_range_to_include_body_calls() {
786 let source = r#"function compute(
787 value: number,
788): number {
789 return helper(value);
790}
791
792function helper(value: number): number {
793 return value * 2;
794}
795"#;
796 let grammar = crate::parser::grammar_for(LangId::TypeScript);
797 let mut parser = tree_sitter::Parser::new();
798 parser.set_language(&grammar).unwrap();
799 let tree = parser.parse(source, None).unwrap();
800 let signature_end = source.find('{').expect("function has body");
801
802 let (body_start, body_end) =
803 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
804 let calls = extract_calls_in_range(
805 source,
806 tree.root_node(),
807 body_start,
808 body_end,
809 LangId::TypeScript,
810 );
811 let names = calls
812 .iter()
813 .map(|(name, _)| name.as_str())
814 .collect::<Vec<_>>();
815
816 assert!(
817 names.contains(&"helper"),
818 "call inside the function body should be included: {names:?}"
819 );
820 }
821
822 #[test]
823 fn zoom_response_has_calls_out_and_called_by() {
824 let ctx = make_ctx();
825 let path = fixture_path("calls.ts");
826
827 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
828 let resp = handle_zoom(&req, &ctx);
829
830 let json = serde_json::to_value(&resp).unwrap();
831 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
832
833 let calls_out = json["annotations"]["calls_out"]
834 .as_array()
835 .expect("calls_out array");
836 let out_names: Vec<&str> = calls_out
837 .iter()
838 .map(|c| c["name"].as_str().unwrap())
839 .collect();
840 assert!(
841 out_names.contains(&"helper"),
842 "compute calls helper: {:?}",
843 out_names
844 );
845
846 let called_by = json["annotations"]["called_by"]
847 .as_array()
848 .expect("called_by array");
849 let by_names: Vec<&str> = called_by
850 .iter()
851 .map(|c| c["name"].as_str().unwrap())
852 .collect();
853 assert!(
854 by_names.contains(&"orchestrate"),
855 "orchestrate calls compute: {:?}",
856 by_names
857 );
858 }
859
860 #[test]
861 fn zoom_response_empty_annotations_for_unused() {
862 let ctx = make_ctx();
863 let path = fixture_path("calls.ts");
864
865 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
866 let resp = handle_zoom(&req, &ctx);
867
868 let json = serde_json::to_value(&resp).unwrap();
869 assert_eq!(json["success"], true);
870
871 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
872 let called_by = json["annotations"]["called_by"].as_array().unwrap();
873
874 assert!(
877 called_by.is_empty(),
878 "unused should not be called by anyone: {:?}",
879 called_by
880 );
881 }
882
883 #[test]
884 fn zoom_default_omits_callgraph_annotations() {
885 let ctx = make_ctx();
886 let path = fixture_path("calls.ts");
887
888 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
889 let resp = handle_zoom(&req, &ctx);
890
891 let json = serde_json::to_value(&resp).unwrap();
892 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
893
894 let calls_out = json["annotations"]["calls_out"]
895 .as_array()
896 .expect("calls_out array");
897 let called_by = json["annotations"]["called_by"]
898 .as_array()
899 .expect("called_by array");
900 assert!(
901 calls_out.is_empty(),
902 "default zoom should omit calls_out: {:?}",
903 calls_out
904 );
905 assert!(
906 called_by.is_empty(),
907 "default zoom should omit called_by: {:?}",
908 called_by
909 );
910 }
911
912 #[test]
913 fn zoom_symbol_not_found() {
914 let ctx = make_ctx();
915 let path = fixture_path("calls.ts");
916
917 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
918 let resp = handle_zoom(&req, &ctx);
919
920 let json = serde_json::to_value(&resp).unwrap();
921 assert_eq!(json["success"], false);
922 assert_eq!(json["code"], "symbol_not_found");
923 }
924
925 #[test]
926 fn zoom_custom_context_lines() {
927 let ctx = make_ctx();
928 let path = fixture_path("calls.ts");
929
930 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
931 let resp = handle_zoom(&req, &ctx);
932
933 let json = serde_json::to_value(&resp).unwrap();
934 assert_eq!(json["success"], true);
935
936 let ctx_before = json["context_before"].as_array().unwrap();
937 let ctx_after = json["context_after"].as_array().unwrap();
938 assert!(
940 ctx_before.len() <= 1,
941 "context_before should be ≤1: {:?}",
942 ctx_before
943 );
944 assert!(
945 ctx_after.len() <= 1,
946 "context_after should be ≤1: {:?}",
947 ctx_after
948 );
949 }
950
951 #[test]
952 fn zoom_missing_file_param() {
953 let ctx = make_ctx();
954 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
955 let resp = handle_zoom(&req, &ctx);
956
957 let json = serde_json::to_value(&resp).unwrap();
958 assert_eq!(json["success"], false);
959 assert_eq!(json["code"], "invalid_request");
960 }
961
962 #[test]
963 fn zoom_missing_symbol_param() {
964 let ctx = make_ctx();
965 let path = fixture_path("calls.ts");
966 let req_value = serde_json::json!({
970 "id": "z-6",
971 "command": "zoom",
972 "file": path.to_string_lossy(),
973 });
974 let req_str = req_value.to_string();
975 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
976 let resp = handle_zoom(&req, &ctx);
977
978 let json = serde_json::to_value(&resp).unwrap();
979 assert_eq!(json["success"], false);
980 assert_eq!(json["code"], "invalid_request");
981 }
982
983 fn make_zoom_request(
986 id: &str,
987 file: &str,
988 symbol: &str,
989 context_lines: Option<u64>,
990 ) -> RawRequest {
991 let mut json = serde_json::json!({
992 "id": id,
993 "command": "zoom",
994 "file": file,
995 "symbol": symbol,
996 });
997 if let Some(cl) = context_lines {
998 json["context_lines"] = serde_json::json!(cl);
999 }
1000 serde_json::from_value(json).unwrap()
1001 }
1002
1003 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1004 let mut req = make_zoom_request(id, file, symbol, None);
1005 req.params["callgraph"] = serde_json::json!(true);
1006 req
1007 }
1008
1009 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1010 serde_json::from_str(json_str).unwrap()
1011 }
1012}