Skip to main content

aft/commands/
zoom.rs

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