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::{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
214    let matches = match ctx.provider().resolve_symbol(&path, symbol_name) {
215        Ok(m) => m,
216        Err(e) => {
217            return Response::error(&req.id, e.code(), e.to_string());
218        }
219    };
220
221    // LSP-enhanced disambiguation (S03)
222    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
223        lsp_hints::apply_lsp_disambiguation(matches, &hints)
224    } else {
225        matches
226    };
227
228    if matches.len() > 1 {
229        // Ambiguous — return qualified candidates with 1-based line ranges.
230        // Internal symbols.rs ranges are 0-based; we add 1 to both start and end.
231        let candidates: Vec<String> = matches
232            .iter()
233            .map(|m| {
234                let sym = &m.symbol;
235                let start = sym.range.start_line + 1;
236                let end = sym.range.end_line + 1;
237                let line_range = if start == end {
238                    format!("{}", start)
239                } else {
240                    format!("{}-{}", start, end)
241                };
242                if sym.scope_chain.is_empty() {
243                    format!("{}:{}", sym.name, line_range)
244                } else {
245                    format!(
246                        "{}::{}:{}",
247                        sym.scope_chain.join("::"),
248                        sym.name,
249                        line_range
250                    )
251                }
252            })
253            .collect();
254        return Response::error(
255            &req.id,
256            "ambiguous_symbol",
257            format!(
258                "symbol '{}' is ambiguous, candidates: [{}]",
259                symbol_name,
260                candidates.join(", ")
261            ),
262        );
263    }
264
265    let target = &matches[0].symbol;
266    let start = target.range.start_line as usize;
267    let end = target.range.end_line as usize;
268
269    // When re-export following resolved to a different file, re-read that file's lines
270    let resolved_file_path = std::path::Path::new(&matches[0].file);
271    let resolved_lines: Vec<String>;
272    let effective_lines: &[String] = if resolved_file_path != path {
273        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
274            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
275            Err(_) => lines.clone(),
276        };
277        &resolved_lines
278    } else {
279        &lines
280    };
281
282    // Extract symbol body (0-based line indices)
283    let content = if end < effective_lines.len() {
284        effective_lines[start..=end].join("\n")
285    } else {
286        effective_lines[start..].join("\n")
287    };
288
289    // Context before
290    let ctx_start = start.saturating_sub(context_lines);
291    let context_before: Vec<String> = if ctx_start < start {
292        effective_lines[ctx_start..start]
293            .iter()
294            .map(|l| l.to_string())
295            .collect()
296    } else {
297        vec![]
298    };
299
300    // Context after
301    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
302    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
303        effective_lines[(end + 1)..ctx_end]
304            .iter()
305            .map(|l| l.to_string())
306            .collect()
307    } else {
308        vec![]
309    };
310
311    // Get all symbols in the resolved file for call matching
312    let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
313        Ok(s) => s,
314        Err(e) => {
315            return Response::error(&req.id, e.code(), e.to_string());
316        }
317    };
318
319    let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
320
321    // Parse AST for call extraction (use resolved file for cross-file re-exports)
322    let mut parser = FileParser::new();
323    let (tree, lang) = match parser.parse(resolved_file_path) {
324        Ok(r) => r,
325        Err(e) => {
326            return Response::error(&req.id, e.code(), e.to_string());
327        }
328    };
329
330    // calls_out: calls within the target symbol's byte range
331    let resolved_source = if resolved_file_path != path {
332        std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.clone())
333    } else {
334        source.clone()
335    };
336    let target_byte_start = line_col_to_byte(
337        &resolved_source,
338        target.range.start_line,
339        target.range.start_col,
340    );
341    let target_byte_end = line_col_to_byte(
342        &resolved_source,
343        target.range.end_line,
344        target.range.end_col,
345    );
346
347    let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
348
349    let raw_calls = all_file_calls
350        .iter()
351        .filter(|call| call.start_byte >= target_byte_start && call.end_byte <= target_byte_end);
352    let calls_out: Vec<CallRef> = raw_calls
353        .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
354        .map(|call| CallRef {
355            name: call.name.clone(),
356            line: call.line,
357        })
358        .collect();
359
360    // called_by: bucket the single file-wide call extraction by enclosing symbol range
361    let mut called_by: Vec<CallRef> = Vec::new();
362    for sym in &all_symbols {
363        if sym.name == target.name && sym.range.start_line == target.range.start_line {
364            continue; // skip self
365        }
366        let sym_byte_start =
367            line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
368        let sym_byte_end =
369            line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
370        for call in &all_file_calls {
371            if call.name == target.name
372                && call.start_byte >= sym_byte_start
373                && call.end_byte <= sym_byte_end
374            {
375                called_by.push(CallRef {
376                    name: sym.name.clone(),
377                    line: call.line,
378                });
379            }
380        }
381    }
382
383    // Dedup called_by by (name, line)
384    called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
385    called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
386
387    let kind_str = serde_json::to_value(&target.kind)
388        .ok()
389        .and_then(|v| v.as_str().map(String::from))
390        .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
391
392    let resp = ZoomResponse {
393        name: target.name.clone(),
394        kind: kind_str,
395        range: target.range.clone(),
396        content,
397        context_before,
398        context_after,
399        annotations: Annotations {
400            calls_out,
401            called_by,
402        },
403    };
404
405    match serde_json::to_value(&resp) {
406        Ok(resp_json) => Response::success(&req.id, resp_json),
407        Err(err) => Response::error(
408            &req.id,
409            "internal_error",
410            format!("zoom: failed to serialize response: {err}"),
411        ),
412    }
413}
414
415/// Extract call expression names within a byte range of the AST.
416///
417/// Delegates to `crate::calls::extract_calls_in_range`.
418#[cfg(test)]
419fn extract_calls_in_range(
420    source: &str,
421    root: tree_sitter::Node,
422    byte_start: usize,
423    byte_end: usize,
424    lang: LangId,
425) -> Vec<(String, u32)> {
426    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
427}
428
429fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
430    let mut results = Vec::new();
431    let call_kinds = crate::calls::call_node_kinds(lang);
432    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
433    results
434}
435
436fn collect_calls_with_ranges(
437    node: tree_sitter::Node,
438    source: &str,
439    call_kinds: &[&str],
440    results: &mut Vec<RawCall>,
441) {
442    if call_kinds.contains(&node.kind()) {
443        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
444            results.push(RawCall {
445                name,
446                line: node.start_position().row as u32 + 1,
447                start_byte: node.start_byte(),
448                end_byte: node.end_byte(),
449            });
450        }
451    }
452
453    let mut cursor = node.walk();
454    if cursor.goto_first_child() {
455        loop {
456            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
457            if !cursor.goto_next_sibling() {
458                break;
459            }
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::config::Config;
468    use crate::context::AppContext;
469    use crate::parser::TreeSitterProvider;
470    use std::path::PathBuf;
471
472    fn fixture_path(name: &str) -> PathBuf {
473        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
474            .join("tests")
475            .join("fixtures")
476            .join(name)
477    }
478
479    fn make_ctx() -> AppContext {
480        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
481    }
482
483    // --- Call extraction tests ---
484
485    #[test]
486    fn extract_calls_finds_direct_calls() {
487        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
488        let mut parser = FileParser::new();
489        let path = fixture_path("calls.ts");
490        let (tree, lang) = parser.parse(&path).unwrap();
491
492        // `compute` calls `helper` — find compute's range from symbols
493        let ctx = make_ctx();
494        let symbols = ctx.provider().list_symbols(&path).unwrap();
495        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
496
497        let byte_start =
498            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
499        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
500
501        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
502        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
503
504        assert!(
505            names.contains(&"helper"),
506            "compute should call helper, got: {:?}",
507            names
508        );
509    }
510
511    #[test]
512    fn extract_calls_finds_member_calls() {
513        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
514        let mut parser = FileParser::new();
515        let path = fixture_path("calls.ts");
516        let (tree, lang) = parser.parse(&path).unwrap();
517
518        let ctx = make_ctx();
519        let symbols = ctx.provider().list_symbols(&path).unwrap();
520        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
521
522        let byte_start =
523            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
524        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
525
526        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
527        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
528
529        assert!(
530            names.contains(&"add"),
531            "runAll should call this.add, got: {:?}",
532            names
533        );
534        assert!(
535            names.contains(&"helper"),
536            "runAll should call helper, got: {:?}",
537            names
538        );
539    }
540
541    #[test]
542    fn extract_calls_unused_function_has_no_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        let ctx = make_ctx();
549        let symbols = ctx.provider().list_symbols(&path).unwrap();
550        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
551
552        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
553        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
554
555        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
556        // console.log is the only call, but "log" or "console" aren't known symbols
557        let known_names = vec![
558            "helper",
559            "compute",
560            "orchestrate",
561            "unused",
562            "format",
563            "display",
564        ];
565        let filtered: Vec<&str> = calls
566            .iter()
567            .map(|(n, _)| n.as_str())
568            .filter(|n| known_names.contains(n))
569            .collect();
570        assert!(
571            filtered.is_empty(),
572            "unused should not call known symbols, got: {:?}",
573            filtered
574        );
575    }
576
577    // --- Context line tests ---
578
579    #[test]
580    fn context_lines_clamp_at_file_start() {
581        // helper() is at the top of the file (line 2) — context_before should be clamped
582        let ctx = make_ctx();
583        let path = fixture_path("calls.ts");
584        let symbols = ctx.provider().list_symbols(&path).unwrap();
585        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
586
587        let source = std::fs::read_to_string(&path).unwrap();
588        let lines: Vec<&str> = source.lines().collect();
589        let start = helper.range.start_line as usize;
590
591        // With context_lines=5, ctx_start should clamp to 0
592        let ctx_start = start.saturating_sub(5);
593        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
594        // Should have at most `start` lines (not panic)
595        assert!(context_before.len() <= start);
596    }
597
598    #[test]
599    fn context_lines_clamp_at_file_end() {
600        let ctx = make_ctx();
601        let path = fixture_path("calls.ts");
602        let symbols = ctx.provider().list_symbols(&path).unwrap();
603        let display = symbols.iter().find(|s| s.name == "display").unwrap();
604
605        let source = std::fs::read_to_string(&path).unwrap();
606        let lines: Vec<&str> = source.lines().collect();
607        let end = display.range.end_line as usize;
608
609        // With context_lines=20, should clamp to file length
610        let ctx_end = (end + 1 + 20).min(lines.len());
611        let context_after: Vec<&str> = if end + 1 < lines.len() {
612            lines[(end + 1)..ctx_end].to_vec()
613        } else {
614            vec![]
615        };
616        // Should not panic regardless of context_lines size
617        assert!(context_after.len() <= 20);
618    }
619
620    // --- Body extraction test ---
621
622    #[test]
623    fn body_extraction_matches_source() {
624        let ctx = make_ctx();
625        let path = fixture_path("calls.ts");
626        let symbols = ctx.provider().list_symbols(&path).unwrap();
627        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
628
629        let source = std::fs::read_to_string(&path).unwrap();
630        let lines: Vec<&str> = source.lines().collect();
631        let start = compute.range.start_line as usize;
632        let end = compute.range.end_line as usize;
633        let body = lines[start..=end].join("\n");
634
635        assert!(
636            body.contains("function compute"),
637            "body should contain function declaration"
638        );
639        assert!(
640            body.contains("helper(a)"),
641            "body should contain call to helper"
642        );
643        assert!(
644            body.contains("doubled + b"),
645            "body should contain return expression"
646        );
647    }
648
649    // --- Full zoom response tests ---
650
651    #[test]
652    fn zoom_response_has_calls_out_and_called_by() {
653        let ctx = make_ctx();
654        let path = fixture_path("calls.ts");
655
656        let req = make_zoom_request("z-1", path.to_str().unwrap(), "compute", None);
657        let resp = handle_zoom(&req, &ctx);
658
659        let json = serde_json::to_value(&resp).unwrap();
660        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
661
662        let calls_out = json["annotations"]["calls_out"]
663            .as_array()
664            .expect("calls_out array");
665        let out_names: Vec<&str> = calls_out
666            .iter()
667            .map(|c| c["name"].as_str().unwrap())
668            .collect();
669        assert!(
670            out_names.contains(&"helper"),
671            "compute calls helper: {:?}",
672            out_names
673        );
674
675        let called_by = json["annotations"]["called_by"]
676            .as_array()
677            .expect("called_by array");
678        let by_names: Vec<&str> = called_by
679            .iter()
680            .map(|c| c["name"].as_str().unwrap())
681            .collect();
682        assert!(
683            by_names.contains(&"orchestrate"),
684            "orchestrate calls compute: {:?}",
685            by_names
686        );
687    }
688
689    #[test]
690    fn zoom_response_empty_annotations_for_unused() {
691        let ctx = make_ctx();
692        let path = fixture_path("calls.ts");
693
694        let req = make_zoom_request("z-2", path.to_str().unwrap(), "unused", None);
695        let resp = handle_zoom(&req, &ctx);
696
697        let json = serde_json::to_value(&resp).unwrap();
698        assert_eq!(json["success"], true);
699
700        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
701        let called_by = json["annotations"]["called_by"].as_array().unwrap();
702
703        // calls_out exists (may contain console.log but no known symbols)
704        // called_by should be empty — nobody calls unused
705        assert!(
706            called_by.is_empty(),
707            "unused should not be called by anyone: {:?}",
708            called_by
709        );
710    }
711
712    #[test]
713    fn zoom_symbol_not_found() {
714        let ctx = make_ctx();
715        let path = fixture_path("calls.ts");
716
717        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
718        let resp = handle_zoom(&req, &ctx);
719
720        let json = serde_json::to_value(&resp).unwrap();
721        assert_eq!(json["success"], false);
722        assert_eq!(json["code"], "symbol_not_found");
723    }
724
725    #[test]
726    fn zoom_custom_context_lines() {
727        let ctx = make_ctx();
728        let path = fixture_path("calls.ts");
729
730        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
731        let resp = handle_zoom(&req, &ctx);
732
733        let json = serde_json::to_value(&resp).unwrap();
734        assert_eq!(json["success"], true);
735
736        let ctx_before = json["context_before"].as_array().unwrap();
737        let ctx_after = json["context_after"].as_array().unwrap();
738        // With context_lines=1, we get at most 1 line before and after
739        assert!(
740            ctx_before.len() <= 1,
741            "context_before should be ≤1: {:?}",
742            ctx_before
743        );
744        assert!(
745            ctx_after.len() <= 1,
746            "context_after should be ≤1: {:?}",
747            ctx_after
748        );
749    }
750
751    #[test]
752    fn zoom_missing_file_param() {
753        let ctx = make_ctx();
754        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
755        let resp = handle_zoom(&req, &ctx);
756
757        let json = serde_json::to_value(&resp).unwrap();
758        assert_eq!(json["success"], false);
759        assert_eq!(json["code"], "invalid_request");
760    }
761
762    #[test]
763    fn zoom_missing_symbol_param() {
764        let ctx = make_ctx();
765        let path = fixture_path("calls.ts");
766        let req_str = format!(
767            r#"{{"id":"z-6","command":"zoom","file":"{}"}}"#,
768            path.display()
769        );
770        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
771        let resp = handle_zoom(&req, &ctx);
772
773        let json = serde_json::to_value(&resp).unwrap();
774        assert_eq!(json["success"], false);
775        assert_eq!(json["code"], "invalid_request");
776    }
777
778    // --- Helpers ---
779
780    fn make_zoom_request(
781        id: &str,
782        file: &str,
783        symbol: &str,
784        context_lines: Option<u64>,
785    ) -> RawRequest {
786        let mut json = serde_json::json!({
787            "id": id,
788            "command": "zoom",
789            "file": file,
790            "symbol": symbol,
791        });
792        if let Some(cl) = context_lines {
793            json["context_lines"] = serde_json::json!(cl);
794        }
795        serde_json::from_value(json).unwrap()
796    }
797
798    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
799        serde_json::from_str(json_str).unwrap()
800    }
801}