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
101    let start_line = req
102        .params
103        .get("start_line")
104        .and_then(|v| v.as_u64())
105        .map(|v| v as usize);
106    let end_line = req
107        .params
108        .get("end_line")
109        .and_then(|v| v.as_u64())
110        .map(|v| v as usize);
111
112    let path = match resolve_file_or_url(req, ctx, file) {
113        Ok(path) => path,
114        Err(resp) => return resp,
115    };
116    if !path.exists() {
117        return Response::error(
118            &req.id,
119            "file_not_found",
120            format!("file not found: {}", file),
121        );
122    }
123
124    // Read source file early because both symbol mode and line-range mode need it.
125    let source = match std::fs::read_to_string(&path) {
126        Ok(s) => s,
127        Err(e) => {
128            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
129        }
130    };
131
132    let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
133
134    // Line-range mode: read arbitrary lines without requiring a symbol.
135    match (start_line, end_line) {
136        (Some(start), Some(end)) => {
137            if req.params.get("symbol").is_some() {
138                return Response::error(
139                    &req.id,
140                    "invalid_request",
141                    "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
142                );
143            }
144            if start == 0 || end == 0 {
145                return Response::error(
146                    &req.id,
147                    "invalid_request",
148                    "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
149                );
150            }
151            if end < start {
152                return Response::error(
153                    &req.id,
154                    "invalid_request",
155                    format!("zoom: end_line {} must be >= start_line {}", end, start),
156                );
157            }
158            if lines.is_empty() {
159                return Response::error(
160                    &req.id,
161                    "invalid_request",
162                    format!("zoom: {} is empty", file),
163                );
164            }
165
166            let start_idx = start - 1;
167            // Clamp end_line to file length (same as batch edits)
168            let clamped_end = end.min(lines.len());
169            let end_idx = clamped_end - 1;
170            if start_idx >= lines.len() {
171                return Response::error(
172                    &req.id,
173                    "invalid_request",
174                    format!(
175                        "zoom: start_line {} is past end of {} ({} lines)",
176                        start,
177                        file,
178                        lines.len()
179                    ),
180                );
181            }
182
183            let content = lines[start_idx..=end_idx].join("\n");
184            let ctx_start = start_idx.saturating_sub(context_lines);
185            let context_before: Vec<String> = if ctx_start < start_idx {
186                lines[ctx_start..start_idx]
187                    .iter()
188                    .map(|l| l.to_string())
189                    .collect()
190            } else {
191                vec![]
192            };
193            let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
194            let context_after: Vec<String> = if end_idx + 1 < lines.len() {
195                lines[(end_idx + 1)..ctx_end]
196                    .iter()
197                    .map(|l| l.to_string())
198                    .collect()
199            } else {
200                vec![]
201            };
202            let end_col = lines[end_idx].chars().count() as u32;
203
204            return Response::success(
205                &req.id,
206                serde_json::json!({
207                    "name": format!("lines {}-{}", start, clamped_end),
208                    "kind": "lines",
209                    "range": {
210                        "start_line": start,  // already 1-based from user input
211                        "start_col": 1,
212                        "end_line": clamped_end,
213                        "end_col": end_col + 1,
214                    },
215                    "content": content,
216                    "context_before": context_before,
217                    "context_after": context_after,
218                    "annotations": {
219                        "calls_out": [],
220                        "called_by": [],
221                    },
222                }),
223            );
224        }
225        (Some(_), None) | (None, Some(_)) => {
226            return Response::error(
227                &req.id,
228                "invalid_request",
229                "zoom: provide both 'start_line' and 'end_line' for line-range mode",
230            );
231        }
232        (None, None) => {}
233    }
234
235    let symbol_name = match req.params.get("symbol").and_then(|v| v.as_str()) {
236        Some(s) => s,
237        None => {
238            return Response::error(
239                &req.id,
240                "invalid_request",
241                "zoom: missing required param 'symbol' (or use 'start_line' and 'end_line')",
242            );
243        }
244    };
245
246    // Resolve the target symbol. Markdown/HTML headings are often copied from outline output
247    // with a visible level prefix (e.g. "## Basic usage" or "<h2>Features"); normalize only
248    // that heading lookup path so code-symbol resolution keeps exact matching semantics.
249    let lookup_name = match detect_language(&path) {
250        Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
251        _ => symbol_name,
252    };
253    let matches = match ctx.provider().resolve_symbol(&path, lookup_name) {
254        Ok(m) => m,
255        Err(e) => {
256            return Response::error(&req.id, e.code(), e.to_string());
257        }
258    };
259
260    // LSP-enhanced disambiguation (S03)
261    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
262        lsp_hints::apply_lsp_disambiguation(matches, &hints)
263    } else {
264        matches
265    };
266
267    if matches.len() > 1 {
268        // Ambiguous — return qualified candidates with 1-based line ranges.
269        // Internal symbols.rs ranges are 0-based; we add 1 to both start and end.
270        let candidates: Vec<String> = matches
271            .iter()
272            .map(|m| {
273                let sym = &m.symbol;
274                let start = sym.range.start_line + 1;
275                let end = sym.range.end_line + 1;
276                let line_range = if start == end {
277                    format!("{}", start)
278                } else {
279                    format!("{}-{}", start, end)
280                };
281                if sym.scope_chain.is_empty() {
282                    format!("{}:{}", sym.name, line_range)
283                } else {
284                    format!(
285                        "{}::{}:{}",
286                        sym.scope_chain.join("::"),
287                        sym.name,
288                        line_range
289                    )
290                }
291            })
292            .collect();
293        return Response::error(
294            &req.id,
295            "ambiguous_symbol",
296            format!(
297                "symbol '{}' is ambiguous, candidates: [{}]",
298                symbol_name,
299                candidates.join(", ")
300            ),
301        );
302    }
303
304    let target = &matches[0].symbol;
305    let start = target.range.start_line as usize;
306    let end = target.range.end_line as usize;
307
308    // When re-export following resolved to a different file, re-read that file's lines
309    let resolved_file_path = std::path::Path::new(&matches[0].file);
310    let resolved_lines: Vec<String>;
311    let effective_lines: &[String] = if resolved_file_path != path {
312        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
313            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
314            Err(_) => lines.clone(),
315        };
316        &resolved_lines
317    } else {
318        &lines
319    };
320
321    // Extract symbol body (0-based line indices)
322    let content = if end < effective_lines.len() {
323        effective_lines[start..=end].join("\n")
324    } else {
325        effective_lines[start..].join("\n")
326    };
327
328    // Context before
329    let ctx_start = start.saturating_sub(context_lines);
330    let context_before: Vec<String> = if ctx_start < start {
331        effective_lines[ctx_start..start]
332            .iter()
333            .map(|l| l.to_string())
334            .collect()
335    } else {
336        vec![]
337    };
338
339    // Context after
340    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
341    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
342        effective_lines[(end + 1)..ctx_end]
343            .iter()
344            .map(|l| l.to_string())
345            .collect()
346    } else {
347        vec![]
348    };
349
350    // Get all symbols in the resolved file for call matching
351    let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
352        Ok(s) => s,
353        Err(e) => {
354            return Response::error(&req.id, e.code(), e.to_string());
355        }
356    };
357
358    let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
359
360    // Parse AST for call extraction (use resolved file for cross-file re-exports)
361    let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
362    let (tree, lang) = match parser.parse(resolved_file_path) {
363        Ok(r) => r,
364        Err(e) => {
365            return Response::error(&req.id, e.code(), e.to_string());
366        }
367    };
368
369    // calls_out: calls within the target symbol's byte range
370    let resolved_source = if resolved_file_path != path {
371        std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.clone())
372    } else {
373        source.clone()
374    };
375    let target_byte_start = line_col_to_byte(
376        &resolved_source,
377        target.range.start_line,
378        target.range.start_col,
379    );
380    let target_byte_end = line_col_to_byte(
381        &resolved_source,
382        target.range.end_line,
383        target.range.end_col,
384    );
385
386    let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
387
388    let raw_calls = all_file_calls
389        .iter()
390        .filter(|call| call.start_byte >= target_byte_start && call.end_byte <= target_byte_end);
391    let calls_out: Vec<CallRef> = raw_calls
392        .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
393        .map(|call| CallRef {
394            name: call.name.clone(),
395            line: call.line,
396        })
397        .collect();
398
399    // called_by: bucket the single file-wide call extraction by enclosing symbol range
400    let mut called_by: Vec<CallRef> = Vec::new();
401    for sym in &all_symbols {
402        if sym.name == target.name && sym.range.start_line == target.range.start_line {
403            continue; // skip self
404        }
405        let sym_byte_start =
406            line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
407        let sym_byte_end =
408            line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
409        for call in &all_file_calls {
410            if call.name == target.name
411                && call.start_byte >= sym_byte_start
412                && call.end_byte <= sym_byte_end
413            {
414                called_by.push(CallRef {
415                    name: sym.name.clone(),
416                    line: call.line,
417                });
418            }
419        }
420    }
421
422    // Dedup called_by by (name, line)
423    called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
424    called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
425
426    let kind_str = serde_json::to_value(&target.kind)
427        .ok()
428        .and_then(|v| v.as_str().map(String::from))
429        .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
430
431    let resp = ZoomResponse {
432        name: target.name.clone(),
433        kind: kind_str,
434        range: target.range.clone(),
435        content,
436        context_before,
437        context_after,
438        annotations: Annotations {
439            calls_out,
440            called_by,
441        },
442    };
443
444    match serde_json::to_value(&resp) {
445        Ok(resp_json) => Response::success(&req.id, resp_json),
446        Err(err) => Response::error(
447            &req.id,
448            "internal_error",
449            format!("zoom: failed to serialize response: {err}"),
450        ),
451    }
452}
453
454fn normalize_heading_query(input: &str) -> &str {
455    let trimmed = input.trim_start();
456    let hash_stripped = trimmed.trim_start_matches('#').trim_start();
457
458    if let Some(after_open) = hash_stripped.strip_prefix('<') {
459        let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
460        let mut chars = after_slash.chars();
461        if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
462            if let Some(end) = hash_stripped.find('>') {
463                return hash_stripped[end + 1..].trim_start();
464            }
465        }
466    }
467
468    hash_stripped
469}
470
471/// Extract call expression names within a byte range of the AST.
472///
473/// Delegates to `crate::calls::extract_calls_in_range`.
474#[cfg(test)]
475fn extract_calls_in_range(
476    source: &str,
477    root: tree_sitter::Node,
478    byte_start: usize,
479    byte_end: usize,
480    lang: LangId,
481) -> Vec<(String, u32)> {
482    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
483}
484
485fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
486    let mut results = Vec::new();
487    let call_kinds = crate::calls::call_node_kinds(lang);
488    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
489    results
490}
491
492fn collect_calls_with_ranges(
493    node: tree_sitter::Node,
494    source: &str,
495    call_kinds: &[&str],
496    results: &mut Vec<RawCall>,
497) {
498    if call_kinds.contains(&node.kind()) {
499        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
500            results.push(RawCall {
501                name,
502                line: node.start_position().row as u32 + 1,
503                start_byte: node.start_byte(),
504                end_byte: node.end_byte(),
505            });
506        }
507    }
508
509    let mut cursor = node.walk();
510    if cursor.goto_first_child() {
511        loop {
512            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
513            if !cursor.goto_next_sibling() {
514                break;
515            }
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::config::Config;
524    use crate::context::AppContext;
525    use crate::parser::TreeSitterProvider;
526    use std::path::PathBuf;
527
528    fn fixture_path(name: &str) -> PathBuf {
529        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
530            .join("tests")
531            .join("fixtures")
532            .join(name)
533    }
534
535    fn make_ctx() -> AppContext {
536        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
537    }
538
539    // --- Call extraction tests ---
540
541    #[test]
542    fn extract_calls_finds_direct_calls() {
543        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
544        let mut parser = FileParser::new();
545        let path = fixture_path("calls.ts");
546        let (tree, lang) = parser.parse(&path).unwrap();
547
548        // `compute` calls `helper` — find compute's range from symbols
549        let ctx = make_ctx();
550        let symbols = ctx.provider().list_symbols(&path).unwrap();
551        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
552
553        let byte_start =
554            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
555        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
556
557        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
558        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
559
560        assert!(
561            names.contains(&"helper"),
562            "compute should call helper, got: {:?}",
563            names
564        );
565    }
566
567    #[test]
568    fn extract_calls_finds_member_calls() {
569        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
570        let mut parser = FileParser::new();
571        let path = fixture_path("calls.ts");
572        let (tree, lang) = parser.parse(&path).unwrap();
573
574        let ctx = make_ctx();
575        let symbols = ctx.provider().list_symbols(&path).unwrap();
576        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
577
578        let byte_start =
579            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
580        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
581
582        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
583        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
584
585        assert!(
586            names.contains(&"add"),
587            "runAll should call this.add, got: {:?}",
588            names
589        );
590        assert!(
591            names.contains(&"helper"),
592            "runAll should call helper, got: {:?}",
593            names
594        );
595    }
596
597    #[test]
598    fn extract_calls_unused_function_has_no_calls() {
599        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
600        let mut parser = FileParser::new();
601        let path = fixture_path("calls.ts");
602        let (tree, lang) = parser.parse(&path).unwrap();
603
604        let ctx = make_ctx();
605        let symbols = ctx.provider().list_symbols(&path).unwrap();
606        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
607
608        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
609        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
610
611        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
612        // console.log is the only call, but "log" or "console" aren't known symbols
613        let known_names = [
614            "helper",
615            "compute",
616            "orchestrate",
617            "unused",
618            "format",
619            "display",
620        ];
621        let filtered: Vec<&str> = calls
622            .iter()
623            .map(|(n, _)| n.as_str())
624            .filter(|n| known_names.contains(n))
625            .collect();
626        assert!(
627            filtered.is_empty(),
628            "unused should not call known symbols, got: {:?}",
629            filtered
630        );
631    }
632
633    // --- Context line tests ---
634
635    #[test]
636    fn context_lines_clamp_at_file_start() {
637        // helper() is at the top of the file (line 2) — context_before should be clamped
638        let ctx = make_ctx();
639        let path = fixture_path("calls.ts");
640        let symbols = ctx.provider().list_symbols(&path).unwrap();
641        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
642
643        let source = std::fs::read_to_string(&path).unwrap();
644        let lines: Vec<&str> = source.lines().collect();
645        let start = helper.range.start_line as usize;
646
647        // With context_lines=5, ctx_start should clamp to 0
648        let ctx_start = start.saturating_sub(5);
649        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
650        // Should have at most `start` lines (not panic)
651        assert!(context_before.len() <= start);
652    }
653
654    #[test]
655    fn context_lines_clamp_at_file_end() {
656        let ctx = make_ctx();
657        let path = fixture_path("calls.ts");
658        let symbols = ctx.provider().list_symbols(&path).unwrap();
659        let display = symbols.iter().find(|s| s.name == "display").unwrap();
660
661        let source = std::fs::read_to_string(&path).unwrap();
662        let lines: Vec<&str> = source.lines().collect();
663        let end = display.range.end_line as usize;
664
665        // With context_lines=20, should clamp to file length
666        let ctx_end = (end + 1 + 20).min(lines.len());
667        let context_after: Vec<&str> = if end + 1 < lines.len() {
668            lines[(end + 1)..ctx_end].to_vec()
669        } else {
670            vec![]
671        };
672        // Should not panic regardless of context_lines size
673        assert!(context_after.len() <= 20);
674    }
675
676    // --- Body extraction test ---
677
678    #[test]
679    fn body_extraction_matches_source() {
680        let ctx = make_ctx();
681        let path = fixture_path("calls.ts");
682        let symbols = ctx.provider().list_symbols(&path).unwrap();
683        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
684
685        let source = std::fs::read_to_string(&path).unwrap();
686        let lines: Vec<&str> = source.lines().collect();
687        let start = compute.range.start_line as usize;
688        let end = compute.range.end_line as usize;
689        let body = lines[start..=end].join("\n");
690
691        assert!(
692            body.contains("function compute"),
693            "body should contain function declaration"
694        );
695        assert!(
696            body.contains("helper(a)"),
697            "body should contain call to helper"
698        );
699        assert!(
700            body.contains("doubled + b"),
701            "body should contain return expression"
702        );
703    }
704
705    // --- Full zoom response tests ---
706
707    #[test]
708    fn zoom_response_has_calls_out_and_called_by() {
709        let ctx = make_ctx();
710        let path = fixture_path("calls.ts");
711
712        let req = make_zoom_request("z-1", path.to_str().unwrap(), "compute", None);
713        let resp = handle_zoom(&req, &ctx);
714
715        let json = serde_json::to_value(&resp).unwrap();
716        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
717
718        let calls_out = json["annotations"]["calls_out"]
719            .as_array()
720            .expect("calls_out array");
721        let out_names: Vec<&str> = calls_out
722            .iter()
723            .map(|c| c["name"].as_str().unwrap())
724            .collect();
725        assert!(
726            out_names.contains(&"helper"),
727            "compute calls helper: {:?}",
728            out_names
729        );
730
731        let called_by = json["annotations"]["called_by"]
732            .as_array()
733            .expect("called_by array");
734        let by_names: Vec<&str> = called_by
735            .iter()
736            .map(|c| c["name"].as_str().unwrap())
737            .collect();
738        assert!(
739            by_names.contains(&"orchestrate"),
740            "orchestrate calls compute: {:?}",
741            by_names
742        );
743    }
744
745    #[test]
746    fn zoom_response_empty_annotations_for_unused() {
747        let ctx = make_ctx();
748        let path = fixture_path("calls.ts");
749
750        let req = make_zoom_request("z-2", path.to_str().unwrap(), "unused", None);
751        let resp = handle_zoom(&req, &ctx);
752
753        let json = serde_json::to_value(&resp).unwrap();
754        assert_eq!(json["success"], true);
755
756        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
757        let called_by = json["annotations"]["called_by"].as_array().unwrap();
758
759        // calls_out exists (may contain console.log but no known symbols)
760        // called_by should be empty — nobody calls unused
761        assert!(
762            called_by.is_empty(),
763            "unused should not be called by anyone: {:?}",
764            called_by
765        );
766    }
767
768    #[test]
769    fn zoom_symbol_not_found() {
770        let ctx = make_ctx();
771        let path = fixture_path("calls.ts");
772
773        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
774        let resp = handle_zoom(&req, &ctx);
775
776        let json = serde_json::to_value(&resp).unwrap();
777        assert_eq!(json["success"], false);
778        assert_eq!(json["code"], "symbol_not_found");
779    }
780
781    #[test]
782    fn zoom_custom_context_lines() {
783        let ctx = make_ctx();
784        let path = fixture_path("calls.ts");
785
786        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
787        let resp = handle_zoom(&req, &ctx);
788
789        let json = serde_json::to_value(&resp).unwrap();
790        assert_eq!(json["success"], true);
791
792        let ctx_before = json["context_before"].as_array().unwrap();
793        let ctx_after = json["context_after"].as_array().unwrap();
794        // With context_lines=1, we get at most 1 line before and after
795        assert!(
796            ctx_before.len() <= 1,
797            "context_before should be ≤1: {:?}",
798            ctx_before
799        );
800        assert!(
801            ctx_after.len() <= 1,
802            "context_after should be ≤1: {:?}",
803            ctx_after
804        );
805    }
806
807    #[test]
808    fn zoom_missing_file_param() {
809        let ctx = make_ctx();
810        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
811        let resp = handle_zoom(&req, &ctx);
812
813        let json = serde_json::to_value(&resp).unwrap();
814        assert_eq!(json["success"], false);
815        assert_eq!(json["code"], "invalid_request");
816    }
817
818    #[test]
819    fn zoom_missing_symbol_param() {
820        let ctx = make_ctx();
821        let path = fixture_path("calls.ts");
822        // Build the JSON via serde_json so Windows paths (with backslashes)
823        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
824        // backslashes as escape sequences and fail to parse.
825        let req_value = serde_json::json!({
826            "id": "z-6",
827            "command": "zoom",
828            "file": path.to_string_lossy(),
829        });
830        let req_str = req_value.to_string();
831        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
832        let resp = handle_zoom(&req, &ctx);
833
834        let json = serde_json::to_value(&resp).unwrap();
835        assert_eq!(json["success"], false);
836        assert_eq!(json["code"], "invalid_request");
837    }
838
839    // --- Helpers ---
840
841    fn make_zoom_request(
842        id: &str,
843        file: &str,
844        symbol: &str,
845        context_lines: Option<u64>,
846    ) -> RawRequest {
847        let mut json = serde_json::json!({
848            "id": id,
849            "command": "zoom",
850            "file": file,
851            "symbol": symbol,
852        });
853        if let Some(cl) = context_lines {
854            json["context_lines"] = serde_json::json!(cl);
855        }
856        serde_json::from_value(json).unwrap()
857    }
858
859    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
860        serde_json::from_str(json_str).unwrap()
861    }
862}