1use std::path::Path;
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;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct CallRef {
15 pub name: String,
16 pub line: u32,
18}
19
20#[derive(Debug, Clone, Serialize)]
22pub struct Annotations {
23 pub calls_out: Vec<CallRef>,
24 pub called_by: Vec<CallRef>,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct ZoomResponse {
30 pub name: String,
31 pub kind: String,
32 pub range: Range,
33 pub content: String,
34 pub context_before: Vec<String>,
35 pub context_after: Vec<String>,
36 pub annotations: Annotations,
37}
38
39struct RawCall {
40 name: String,
41 line: u32,
42 start_byte: usize,
43 end_byte: usize,
44}
45
46pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
51 let file = match req.params.get("file").and_then(|v| v.as_str()) {
52 Some(f) => f,
53 None => {
54 return Response::error(
55 &req.id,
56 "invalid_request",
57 "zoom: missing required param 'file'",
58 );
59 }
60 };
61
62 let context_lines = req
63 .params
64 .get("context_lines")
65 .and_then(|v| v.as_u64())
66 .unwrap_or(3) as usize;
67
68 let start_line = req
69 .params
70 .get("start_line")
71 .and_then(|v| v.as_u64())
72 .map(|v| v as usize);
73 let end_line = req
74 .params
75 .get("end_line")
76 .and_then(|v| v.as_u64())
77 .map(|v| v as usize);
78
79 let path = match ctx.validate_path(&req.id, Path::new(file)) {
80 Ok(path) => path,
81 Err(resp) => return resp,
82 };
83 if !path.exists() {
84 return Response::error(
85 &req.id,
86 "file_not_found",
87 format!("file not found: {}", file),
88 );
89 }
90
91 let source = match std::fs::read_to_string(&path) {
93 Ok(s) => s,
94 Err(e) => {
95 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
96 }
97 };
98
99 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
100
101 match (start_line, end_line) {
103 (Some(start), Some(end)) => {
104 if req.params.get("symbol").is_some() {
105 return Response::error(
106 &req.id,
107 "invalid_request",
108 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
109 );
110 }
111 if start == 0 || end == 0 {
112 return Response::error(
113 &req.id,
114 "invalid_request",
115 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
116 );
117 }
118 if end < start {
119 return Response::error(
120 &req.id,
121 "invalid_request",
122 format!("zoom: end_line {} must be >= start_line {}", end, start),
123 );
124 }
125 if lines.is_empty() {
126 return Response::error(
127 &req.id,
128 "invalid_request",
129 format!("zoom: {} is empty", file),
130 );
131 }
132
133 let start_idx = start - 1;
134 let clamped_end = end.min(lines.len());
136 let end_idx = clamped_end - 1;
137 if start_idx >= lines.len() {
138 return Response::error(
139 &req.id,
140 "invalid_request",
141 format!(
142 "zoom: start_line {} is past end of {} ({} lines)",
143 start,
144 file,
145 lines.len()
146 ),
147 );
148 }
149
150 let content = lines[start_idx..=end_idx].join("\n");
151 let ctx_start = start_idx.saturating_sub(context_lines);
152 let context_before: Vec<String> = if ctx_start < start_idx {
153 lines[ctx_start..start_idx]
154 .iter()
155 .map(|l| l.to_string())
156 .collect()
157 } else {
158 vec![]
159 };
160 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
161 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
162 lines[(end_idx + 1)..ctx_end]
163 .iter()
164 .map(|l| l.to_string())
165 .collect()
166 } else {
167 vec![]
168 };
169 let end_col = lines[end_idx].chars().count() as u32;
170
171 return Response::success(
172 &req.id,
173 serde_json::json!({
174 "name": format!("lines {}-{}", start, clamped_end),
175 "kind": "lines",
176 "range": {
177 "start_line": start, "start_col": 1,
179 "end_line": clamped_end,
180 "end_col": end_col + 1,
181 },
182 "content": content,
183 "context_before": context_before,
184 "context_after": context_after,
185 "annotations": {
186 "calls_out": [],
187 "called_by": [],
188 },
189 }),
190 );
191 }
192 (Some(_), None) | (None, Some(_)) => {
193 return Response::error(
194 &req.id,
195 "invalid_request",
196 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
197 );
198 }
199 (None, None) => {}
200 }
201
202 let symbol_name = match req.params.get("symbol").and_then(|v| v.as_str()) {
203 Some(s) => s,
204 None => {
205 return Response::error(
206 &req.id,
207 "invalid_request",
208 "zoom: missing required param 'symbol' (or use 'start_line' and 'end_line')",
209 );
210 }
211 };
212
213 let lookup_name = match detect_language(&path) {
217 Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
218 _ => symbol_name,
219 };
220 let matches = match ctx.provider().resolve_symbol(&path, lookup_name) {
221 Ok(m) => m,
222 Err(e) => {
223 return Response::error(&req.id, e.code(), e.to_string());
224 }
225 };
226
227 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
229 lsp_hints::apply_lsp_disambiguation(matches, &hints)
230 } else {
231 matches
232 };
233
234 if matches.len() > 1 {
235 let candidates: Vec<String> = matches
238 .iter()
239 .map(|m| {
240 let sym = &m.symbol;
241 let start = sym.range.start_line + 1;
242 let end = sym.range.end_line + 1;
243 let line_range = if start == end {
244 format!("{}", start)
245 } else {
246 format!("{}-{}", start, end)
247 };
248 if sym.scope_chain.is_empty() {
249 format!("{}:{}", sym.name, line_range)
250 } else {
251 format!(
252 "{}::{}:{}",
253 sym.scope_chain.join("::"),
254 sym.name,
255 line_range
256 )
257 }
258 })
259 .collect();
260 return Response::error(
261 &req.id,
262 "ambiguous_symbol",
263 format!(
264 "symbol '{}' is ambiguous, candidates: [{}]",
265 symbol_name,
266 candidates.join(", ")
267 ),
268 );
269 }
270
271 let target = &matches[0].symbol;
272 let start = target.range.start_line as usize;
273 let end = target.range.end_line as usize;
274
275 let resolved_file_path = std::path::Path::new(&matches[0].file);
277 let resolved_lines: Vec<String>;
278 let effective_lines: &[String] = if resolved_file_path != path {
279 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
280 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
281 Err(_) => lines.clone(),
282 };
283 &resolved_lines
284 } else {
285 &lines
286 };
287
288 let content = if end < effective_lines.len() {
290 effective_lines[start..=end].join("\n")
291 } else {
292 effective_lines[start..].join("\n")
293 };
294
295 let ctx_start = start.saturating_sub(context_lines);
297 let context_before: Vec<String> = if ctx_start < start {
298 effective_lines[ctx_start..start]
299 .iter()
300 .map(|l| l.to_string())
301 .collect()
302 } else {
303 vec![]
304 };
305
306 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
308 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
309 effective_lines[(end + 1)..ctx_end]
310 .iter()
311 .map(|l| l.to_string())
312 .collect()
313 } else {
314 vec![]
315 };
316
317 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
319 Ok(s) => s,
320 Err(e) => {
321 return Response::error(&req.id, e.code(), e.to_string());
322 }
323 };
324
325 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
326
327 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
329 let (tree, lang) = match parser.parse(resolved_file_path) {
330 Ok(r) => r,
331 Err(e) => {
332 return Response::error(&req.id, e.code(), e.to_string());
333 }
334 };
335
336 let resolved_source = if resolved_file_path != path {
338 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.clone())
339 } else {
340 source.clone()
341 };
342 let target_byte_start = line_col_to_byte(
343 &resolved_source,
344 target.range.start_line,
345 target.range.start_col,
346 );
347 let target_byte_end = line_col_to_byte(
348 &resolved_source,
349 target.range.end_line,
350 target.range.end_col,
351 );
352
353 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
354
355 let raw_calls = all_file_calls
356 .iter()
357 .filter(|call| call.start_byte >= target_byte_start && call.end_byte <= target_byte_end);
358 let calls_out: Vec<CallRef> = raw_calls
359 .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
360 .map(|call| CallRef {
361 name: call.name.clone(),
362 line: call.line,
363 })
364 .collect();
365
366 let mut called_by: Vec<CallRef> = Vec::new();
368 for sym in &all_symbols {
369 if sym.name == target.name && sym.range.start_line == target.range.start_line {
370 continue; }
372 let sym_byte_start =
373 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
374 let sym_byte_end =
375 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
376 for call in &all_file_calls {
377 if call.name == target.name
378 && call.start_byte >= sym_byte_start
379 && call.end_byte <= sym_byte_end
380 {
381 called_by.push(CallRef {
382 name: sym.name.clone(),
383 line: call.line,
384 });
385 }
386 }
387 }
388
389 called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
391 called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
392
393 let kind_str = serde_json::to_value(&target.kind)
394 .ok()
395 .and_then(|v| v.as_str().map(String::from))
396 .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
397
398 let resp = ZoomResponse {
399 name: target.name.clone(),
400 kind: kind_str,
401 range: target.range.clone(),
402 content,
403 context_before,
404 context_after,
405 annotations: Annotations {
406 calls_out,
407 called_by,
408 },
409 };
410
411 match serde_json::to_value(&resp) {
412 Ok(resp_json) => Response::success(&req.id, resp_json),
413 Err(err) => Response::error(
414 &req.id,
415 "internal_error",
416 format!("zoom: failed to serialize response: {err}"),
417 ),
418 }
419}
420
421fn normalize_heading_query(input: &str) -> &str {
422 let trimmed = input.trim_start();
423 let hash_stripped = trimmed.trim_start_matches('#').trim_start();
424
425 if let Some(after_open) = hash_stripped.strip_prefix('<') {
426 let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
427 let mut chars = after_slash.chars();
428 if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
429 if let Some(end) = hash_stripped.find('>') {
430 return hash_stripped[end + 1..].trim_start();
431 }
432 }
433 }
434
435 hash_stripped
436}
437
438#[cfg(test)]
442fn extract_calls_in_range(
443 source: &str,
444 root: tree_sitter::Node,
445 byte_start: usize,
446 byte_end: usize,
447 lang: LangId,
448) -> Vec<(String, u32)> {
449 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
450}
451
452fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
453 let mut results = Vec::new();
454 let call_kinds = crate::calls::call_node_kinds(lang);
455 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
456 results
457}
458
459fn collect_calls_with_ranges(
460 node: tree_sitter::Node,
461 source: &str,
462 call_kinds: &[&str],
463 results: &mut Vec<RawCall>,
464) {
465 if call_kinds.contains(&node.kind()) {
466 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
467 results.push(RawCall {
468 name,
469 line: node.start_position().row as u32 + 1,
470 start_byte: node.start_byte(),
471 end_byte: node.end_byte(),
472 });
473 }
474 }
475
476 let mut cursor = node.walk();
477 if cursor.goto_first_child() {
478 loop {
479 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
480 if !cursor.goto_next_sibling() {
481 break;
482 }
483 }
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::config::Config;
491 use crate::context::AppContext;
492 use crate::parser::TreeSitterProvider;
493 use std::path::PathBuf;
494
495 fn fixture_path(name: &str) -> PathBuf {
496 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
497 .join("tests")
498 .join("fixtures")
499 .join(name)
500 }
501
502 fn make_ctx() -> AppContext {
503 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
504 }
505
506 #[test]
509 fn extract_calls_finds_direct_calls() {
510 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
511 let mut parser = FileParser::new();
512 let path = fixture_path("calls.ts");
513 let (tree, lang) = parser.parse(&path).unwrap();
514
515 let ctx = make_ctx();
517 let symbols = ctx.provider().list_symbols(&path).unwrap();
518 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
519
520 let byte_start =
521 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
522 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
523
524 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
525 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
526
527 assert!(
528 names.contains(&"helper"),
529 "compute should call helper, got: {:?}",
530 names
531 );
532 }
533
534 #[test]
535 fn extract_calls_finds_member_calls() {
536 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
537 let mut parser = FileParser::new();
538 let path = fixture_path("calls.ts");
539 let (tree, lang) = parser.parse(&path).unwrap();
540
541 let ctx = make_ctx();
542 let symbols = ctx.provider().list_symbols(&path).unwrap();
543 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
544
545 let byte_start =
546 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
547 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
548
549 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
550 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
551
552 assert!(
553 names.contains(&"add"),
554 "runAll should call this.add, got: {:?}",
555 names
556 );
557 assert!(
558 names.contains(&"helper"),
559 "runAll should call helper, got: {:?}",
560 names
561 );
562 }
563
564 #[test]
565 fn extract_calls_unused_function_has_no_calls() {
566 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
567 let mut parser = FileParser::new();
568 let path = fixture_path("calls.ts");
569 let (tree, lang) = parser.parse(&path).unwrap();
570
571 let ctx = make_ctx();
572 let symbols = ctx.provider().list_symbols(&path).unwrap();
573 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
574
575 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
576 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
577
578 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
579 let known_names = [
581 "helper",
582 "compute",
583 "orchestrate",
584 "unused",
585 "format",
586 "display",
587 ];
588 let filtered: Vec<&str> = calls
589 .iter()
590 .map(|(n, _)| n.as_str())
591 .filter(|n| known_names.contains(n))
592 .collect();
593 assert!(
594 filtered.is_empty(),
595 "unused should not call known symbols, got: {:?}",
596 filtered
597 );
598 }
599
600 #[test]
603 fn context_lines_clamp_at_file_start() {
604 let ctx = make_ctx();
606 let path = fixture_path("calls.ts");
607 let symbols = ctx.provider().list_symbols(&path).unwrap();
608 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
609
610 let source = std::fs::read_to_string(&path).unwrap();
611 let lines: Vec<&str> = source.lines().collect();
612 let start = helper.range.start_line as usize;
613
614 let ctx_start = start.saturating_sub(5);
616 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
617 assert!(context_before.len() <= start);
619 }
620
621 #[test]
622 fn context_lines_clamp_at_file_end() {
623 let ctx = make_ctx();
624 let path = fixture_path("calls.ts");
625 let symbols = ctx.provider().list_symbols(&path).unwrap();
626 let display = symbols.iter().find(|s| s.name == "display").unwrap();
627
628 let source = std::fs::read_to_string(&path).unwrap();
629 let lines: Vec<&str> = source.lines().collect();
630 let end = display.range.end_line as usize;
631
632 let ctx_end = (end + 1 + 20).min(lines.len());
634 let context_after: Vec<&str> = if end + 1 < lines.len() {
635 lines[(end + 1)..ctx_end].to_vec()
636 } else {
637 vec![]
638 };
639 assert!(context_after.len() <= 20);
641 }
642
643 #[test]
646 fn body_extraction_matches_source() {
647 let ctx = make_ctx();
648 let path = fixture_path("calls.ts");
649 let symbols = ctx.provider().list_symbols(&path).unwrap();
650 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
651
652 let source = std::fs::read_to_string(&path).unwrap();
653 let lines: Vec<&str> = source.lines().collect();
654 let start = compute.range.start_line as usize;
655 let end = compute.range.end_line as usize;
656 let body = lines[start..=end].join("\n");
657
658 assert!(
659 body.contains("function compute"),
660 "body should contain function declaration"
661 );
662 assert!(
663 body.contains("helper(a)"),
664 "body should contain call to helper"
665 );
666 assert!(
667 body.contains("doubled + b"),
668 "body should contain return expression"
669 );
670 }
671
672 #[test]
675 fn zoom_response_has_calls_out_and_called_by() {
676 let ctx = make_ctx();
677 let path = fixture_path("calls.ts");
678
679 let req = make_zoom_request("z-1", path.to_str().unwrap(), "compute", None);
680 let resp = handle_zoom(&req, &ctx);
681
682 let json = serde_json::to_value(&resp).unwrap();
683 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
684
685 let calls_out = json["annotations"]["calls_out"]
686 .as_array()
687 .expect("calls_out array");
688 let out_names: Vec<&str> = calls_out
689 .iter()
690 .map(|c| c["name"].as_str().unwrap())
691 .collect();
692 assert!(
693 out_names.contains(&"helper"),
694 "compute calls helper: {:?}",
695 out_names
696 );
697
698 let called_by = json["annotations"]["called_by"]
699 .as_array()
700 .expect("called_by array");
701 let by_names: Vec<&str> = called_by
702 .iter()
703 .map(|c| c["name"].as_str().unwrap())
704 .collect();
705 assert!(
706 by_names.contains(&"orchestrate"),
707 "orchestrate calls compute: {:?}",
708 by_names
709 );
710 }
711
712 #[test]
713 fn zoom_response_empty_annotations_for_unused() {
714 let ctx = make_ctx();
715 let path = fixture_path("calls.ts");
716
717 let req = make_zoom_request("z-2", path.to_str().unwrap(), "unused", None);
718 let resp = handle_zoom(&req, &ctx);
719
720 let json = serde_json::to_value(&resp).unwrap();
721 assert_eq!(json["success"], true);
722
723 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
724 let called_by = json["annotations"]["called_by"].as_array().unwrap();
725
726 assert!(
729 called_by.is_empty(),
730 "unused should not be called by anyone: {:?}",
731 called_by
732 );
733 }
734
735 #[test]
736 fn zoom_symbol_not_found() {
737 let ctx = make_ctx();
738 let path = fixture_path("calls.ts");
739
740 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
741 let resp = handle_zoom(&req, &ctx);
742
743 let json = serde_json::to_value(&resp).unwrap();
744 assert_eq!(json["success"], false);
745 assert_eq!(json["code"], "symbol_not_found");
746 }
747
748 #[test]
749 fn zoom_custom_context_lines() {
750 let ctx = make_ctx();
751 let path = fixture_path("calls.ts");
752
753 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
754 let resp = handle_zoom(&req, &ctx);
755
756 let json = serde_json::to_value(&resp).unwrap();
757 assert_eq!(json["success"], true);
758
759 let ctx_before = json["context_before"].as_array().unwrap();
760 let ctx_after = json["context_after"].as_array().unwrap();
761 assert!(
763 ctx_before.len() <= 1,
764 "context_before should be ≤1: {:?}",
765 ctx_before
766 );
767 assert!(
768 ctx_after.len() <= 1,
769 "context_after should be ≤1: {:?}",
770 ctx_after
771 );
772 }
773
774 #[test]
775 fn zoom_missing_file_param() {
776 let ctx = make_ctx();
777 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
778 let resp = handle_zoom(&req, &ctx);
779
780 let json = serde_json::to_value(&resp).unwrap();
781 assert_eq!(json["success"], false);
782 assert_eq!(json["code"], "invalid_request");
783 }
784
785 #[test]
786 fn zoom_missing_symbol_param() {
787 let ctx = make_ctx();
788 let path = fixture_path("calls.ts");
789 let req_value = serde_json::json!({
793 "id": "z-6",
794 "command": "zoom",
795 "file": path.to_string_lossy(),
796 });
797 let req_str = req_value.to_string();
798 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
799 let resp = handle_zoom(&req, &ctx);
800
801 let json = serde_json::to_value(&resp).unwrap();
802 assert_eq!(json["success"], false);
803 assert_eq!(json["code"], "invalid_request");
804 }
805
806 fn make_zoom_request(
809 id: &str,
810 file: &str,
811 symbol: &str,
812 context_lines: Option<u64>,
813 ) -> RawRequest {
814 let mut json = serde_json::json!({
815 "id": id,
816 "command": "zoom",
817 "file": file,
818 "symbol": symbol,
819 });
820 if let Some(cl) = context_lines {
821 json["context_lines"] = serde_json::json!(cl);
822 }
823 serde_json::from_value(json).unwrap()
824 }
825
826 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
827 serde_json::from_str(json_str).unwrap()
828 }
829}