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