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