Skip to main content

aft/commands/
zoom.rs

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