Skip to main content

aft/commands/
zoom.rs

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/// A reference to a called/calling function.
14#[derive(Debug, Clone, Serialize)]
15pub struct CallRef {
16    pub name: String,
17    /// 1-based line number of the call reference.
18    pub line: u32,
19}
20
21/// Annotations describing file-scoped call relationships.
22#[derive(Debug, Clone, Serialize)]
23pub struct Annotations {
24    pub calls_out: Vec<CallRef>,
25    pub called_by: Vec<CallRef>,
26}
27
28/// Response payload for the zoom command.
29#[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
74/// Handle a `zoom` request.
75///
76/// Expects `file`, `symbol` in request params, optional `context_lines` (default 3).
77/// Resolves the symbol, extracts body + context, walks AST for call annotations.
78pub 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    // Read source file early because both symbol mode and line-range mode need it.
130    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    // Line-range mode: read arbitrary lines without requiring a symbol.
140    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            // Clamp end_line to file length (same as batch edits)
173            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,  // already 1-based from user input
216                        "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    // Resolve the target symbol. Markdown/HTML headings are often copied from outline output
252    // with a visible level prefix (e.g. "## Basic usage" or "<h2>Features"); normalize only
253    // that heading lookup path so code-symbol resolution keeps exact matching semantics.
254    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    // LSP-enhanced disambiguation (S03)
266    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        // Ambiguous — return qualified candidates with 1-based line ranges.
274        // Internal symbols.rs ranges are 0-based; we add 1 to both start and end.
275        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    // When re-export following resolved to a different file, re-read that file's lines
314    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    // Extract symbol body (0-based line indices)
327    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    // Context before
334    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    // Context after
345    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        // Get all symbols in the resolved file for call matching
357        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        // Parse AST for call extraction (use resolved file for cross-file re-exports)
367        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        // calls_out: calls within the target symbol's byte range
376        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        // called_by: bucket the single file-wide call extraction by enclosing symbol range
409        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; // skip self
413            }
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        // Dedup called_by by (name, line)
432        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/// Extract call expression names within a byte range of the AST.
486///
487/// Delegates to `crate::calls::extract_calls_in_range`.
488#[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    // --- Call extraction tests ---
617
618    #[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        // `compute` calls `helper` — find compute's range from symbols
626        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        // console.log is the only call, but "log" or "console" aren't known symbols
690        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    // --- Context line tests ---
711
712    #[test]
713    fn context_lines_clamp_at_file_start() {
714        // helper() is at the top of the file (line 2) — context_before should be clamped
715        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        // With context_lines=5, ctx_start should clamp to 0
725        let ctx_start = start.saturating_sub(5);
726        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
727        // Should have at most `start` lines (not panic)
728        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        // With context_lines=20, should clamp to file length
743        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        // Should not panic regardless of context_lines size
750        assert!(context_after.len() <= 20);
751    }
752
753    // --- Body extraction test ---
754
755    #[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    // --- Full zoom response tests ---
783
784    #[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        // calls_out exists (may contain console.log but no known symbols)
875        // called_by should be empty — nobody calls unused
876        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        // With context_lines=1, we get at most 1 line before and after
939        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        // Build the JSON via serde_json so Windows paths (with backslashes)
967        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
968        // backslashes as escape sequences and fail to parse.
969        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    // --- Helpers ---
984
985    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}