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