Skip to main content

aft/commands/
zoom.rs

1use std::path::{Path, PathBuf};
2
3use serde::Serialize;
4
5use crate::context::AppContext;
6use crate::edit::line_col_to_byte;
7use crate::lsp_hints;
8use crate::parser::{detect_language, FileParser, LangId};
9use crate::protocol::{RawRequest, Response};
10use crate::symbols::Range;
11use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
12
13/// A reference to a called/calling function.
14#[derive(Debug, Clone, Serialize)]
15pub struct CallRef {
16    pub name: String,
17    /// 1-based line number of the call reference.
18    pub line: u32,
19}
20
21/// Annotations describing file-scoped call relationships.
22#[derive(Debug, Clone, Serialize)]
23pub struct Annotations {
24    pub calls_out: Vec<CallRef>,
25    pub called_by: Vec<CallRef>,
26}
27
28/// Response payload for the zoom command.
29#[derive(Debug, Clone, Serialize)]
30pub struct ZoomResponse {
31    pub name: String,
32    pub kind: String,
33    pub range: Range,
34    pub content: String,
35    pub context_before: Vec<String>,
36    pub context_after: Vec<String>,
37    pub annotations: Annotations,
38}
39
40struct RawCall {
41    name: String,
42    line: u32,
43    start_byte: usize,
44    end_byte: usize,
45}
46
47fn resolve_file_or_url(
48    req: &RawRequest,
49    ctx: &AppContext,
50    file: &str,
51) -> Result<PathBuf, Response> {
52    if is_http_url(file) {
53        let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
54        let allow_private = ctx.config().url_fetch_allow_private
55            || req
56                .params
57                .get("allow_private")
58                .and_then(|value| value.as_bool())
59                .unwrap_or(false);
60        return fetch_url_to_cache(
61            file,
62            &storage_dir,
63            UrlFetchOptions {
64                allow_private,
65                ..UrlFetchOptions::default()
66            },
67        )
68        .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
69    }
70
71    ctx.validate_path(&req.id, Path::new(file))
72}
73
74/// Handle a `zoom` request.
75///
76/// Expects `file`, `symbol` (or `symbols`) in request params, optional `context_lines` (default 3).
77/// Resolves the symbol, extracts body + context, walks AST for call annotations.
78/// For code files, a whitespace-separated `symbol`/`symbols` string is split into multiple lookups.
79pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
80    let file = match req
81        .params
82        .get("file")
83        .or_else(|| req.params.get("url"))
84        .and_then(|v| v.as_str())
85    {
86        Some(f) => f,
87        None => {
88            return Response::error(
89                &req.id,
90                "invalid_request",
91                "zoom: missing required param 'file'",
92            );
93        }
94    };
95
96    let context_lines = req
97        .params
98        .get("context_lines")
99        .and_then(|v| v.as_u64())
100        .unwrap_or(3) as usize;
101    let include_callgraph = req
102        .params
103        .get("callgraph")
104        .and_then(|v| v.as_bool())
105        .unwrap_or(false);
106
107    let start_line = req
108        .params
109        .get("start_line")
110        .and_then(|v| v.as_u64())
111        .map(|v| v as usize);
112    let end_line = req
113        .params
114        .get("end_line")
115        .and_then(|v| v.as_u64())
116        .map(|v| v as usize);
117
118    let path = match resolve_file_or_url(req, ctx, file) {
119        Ok(path) => path,
120        Err(resp) => return resp,
121    };
122    if !path.exists() {
123        return Response::error(
124            &req.id,
125            "file_not_found",
126            format!("file not found: {}", file),
127        );
128    }
129
130    // Read source file early because both symbol mode and line-range mode need it.
131    let source = match std::fs::read_to_string(&path) {
132        Ok(s) => s,
133        Err(e) => {
134            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
135        }
136    };
137
138    let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
139
140    // Line-range mode: read arbitrary lines without requiring a symbol.
141    match (start_line, end_line) {
142        (Some(start), Some(end)) => {
143            if zoom_symbol_param(&req.params).is_some() {
144                return Response::error(
145                    &req.id,
146                    "invalid_request",
147                    "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
148                );
149            }
150            if start == 0 || end == 0 {
151                return Response::error(
152                    &req.id,
153                    "invalid_request",
154                    "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
155                );
156            }
157            if end < start {
158                return Response::error(
159                    &req.id,
160                    "invalid_request",
161                    format!("zoom: end_line {} must be >= start_line {}", end, start),
162                );
163            }
164            if lines.is_empty() {
165                return Response::error(
166                    &req.id,
167                    "invalid_request",
168                    format!("zoom: {} is empty", file),
169                );
170            }
171
172            let start_idx = start - 1;
173            // Clamp end_line to file length (same as batch edits)
174            let clamped_end = end.min(lines.len());
175            let end_idx = clamped_end - 1;
176            if start_idx >= lines.len() {
177                return Response::error(
178                    &req.id,
179                    "invalid_request",
180                    format!(
181                        "zoom: start_line {} is past end of {} ({} lines)",
182                        start,
183                        file,
184                        lines.len()
185                    ),
186                );
187            }
188
189            let content = lines[start_idx..=end_idx].join("\n");
190            let ctx_start = start_idx.saturating_sub(context_lines);
191            let context_before: Vec<String> = if ctx_start < start_idx {
192                lines[ctx_start..start_idx]
193                    .iter()
194                    .map(|l| l.to_string())
195                    .collect()
196            } else {
197                vec![]
198            };
199            let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
200            let context_after: Vec<String> = if end_idx + 1 < lines.len() {
201                lines[(end_idx + 1)..ctx_end]
202                    .iter()
203                    .map(|l| l.to_string())
204                    .collect()
205            } else {
206                vec![]
207            };
208            let end_col = lines[end_idx].chars().count() as u32;
209
210            return Response::success(
211                &req.id,
212                serde_json::json!({
213                    "name": format!("lines {}-{}", start, clamped_end),
214                    "kind": "lines",
215                    "range": {
216                        "start_line": start,  // already 1-based from user input
217                        "start_col": 1,
218                        "end_line": clamped_end,
219                        "end_col": end_col + 1,
220                    },
221                    "content": content,
222                    "context_before": context_before,
223                    "context_after": context_after,
224                    "annotations": {
225                        "calls_out": [],
226                        "called_by": [],
227                    },
228                }),
229            );
230        }
231        (Some(_), None) | (None, Some(_)) => {
232            return Response::error(
233                &req.id,
234                "invalid_request",
235                "zoom: provide both 'start_line' and 'end_line' for line-range mode",
236            );
237        }
238        (None, None) => {}
239    }
240
241    let lang = detect_language(&path);
242    let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
243        Ok(names) => names,
244        Err(resp) => return resp,
245    };
246
247    if symbol_names.is_empty() {
248        return Response::error(
249            &req.id,
250            "invalid_request",
251            "zoom: missing required param 'symbol'",
252        );
253    }
254
255    if symbol_names.len() == 1 {
256        return zoom_one_symbol(
257            req,
258            ctx,
259            &path,
260            file,
261            &source,
262            &lines,
263            &symbol_names[0],
264            context_lines,
265            include_callgraph,
266        );
267    }
268
269    zoom_batch_symbols(
270        req,
271        ctx,
272        &path,
273        file,
274        &source,
275        &lines,
276        &symbol_names,
277        context_lines,
278        include_callgraph,
279    )
280}
281
282/// Raw `symbol` or `symbols` param before language-aware splitting.
283fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
284    params
285        .get("symbol")
286        .or_else(|| params.get("symbols"))
287        .and_then(|v| v.as_str())
288}
289
290fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
291    matches!(lang, Some(LangId::Markdown | LangId::Html))
292}
293
294/// Normalize `symbol` / `symbols` into one or more lookup names.
295///
296/// For code files, a single string containing internal whitespace is split on `\s+`.
297/// Markdown/HTML headings keep the full string (headings may contain spaces).
298fn parse_zoom_symbol_names(
299    params: &serde_json::Value,
300    lang: Option<LangId>,
301) -> Result<Vec<String>, Response> {
302    if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
303        let names: Vec<String> = arr
304            .iter()
305            .filter_map(|v| v.as_str().map(str::trim))
306            .filter(|s| !s.is_empty())
307            .map(str::to_string)
308            .collect();
309        return Ok(names);
310    }
311
312    let Some(raw) = zoom_symbol_param(params) else {
313        return Ok(Vec::new());
314    };
315
316    if is_heading_zoom_language(lang) {
317        let trimmed = raw.trim();
318        if trimmed.is_empty() {
319            return Ok(Vec::new());
320        }
321        return Ok(vec![trimmed.to_string()]);
322    }
323
324    if raw.split_whitespace().count() <= 1 {
325        let trimmed = raw.trim();
326        if trimmed.is_empty() {
327            return Ok(Vec::new());
328        }
329        return Ok(vec![trimmed.to_string()]);
330    }
331
332    Ok(raw.split_whitespace().map(str::to_string).collect())
333}
334
335fn zoom_batch_symbols(
336    req: &RawRequest,
337    ctx: &AppContext,
338    path: &Path,
339    file: &str,
340    source: &str,
341    lines: &[String],
342    symbol_names: &[String],
343    context_lines: usize,
344    include_callgraph: bool,
345) -> Response {
346    let mut entries = Vec::with_capacity(symbol_names.len());
347    let mut all_ok = true;
348
349    for name in symbol_names {
350        let resp = zoom_one_symbol(
351            req,
352            ctx,
353            path,
354            file,
355            source,
356            lines,
357            name,
358            context_lines,
359            include_callgraph,
360        );
361        let json = match serde_json::to_value(&resp) {
362            Ok(v) => v,
363            Err(err) => {
364                return Response::error(
365                    &req.id,
366                    "internal_error",
367                    format!("zoom: failed to serialize batch entry: {err}"),
368                );
369            }
370        };
371        if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
372            all_ok = false;
373        }
374        entries.push(serde_json::json!({
375            "name": name,
376            "response": json,
377        }));
378    }
379
380    Response::success(
381        &req.id,
382        serde_json::json!({
383            "complete": all_ok,
384            "symbols": entries,
385        }),
386    )
387}
388
389fn zoom_one_symbol(
390    req: &RawRequest,
391    ctx: &AppContext,
392    path: &Path,
393    _file: &str,
394    source: &str,
395    lines: &[String],
396    symbol_name: &str,
397    context_lines: usize,
398    include_callgraph: bool,
399) -> Response {
400    // Resolve the target symbol. Markdown/HTML headings are often copied from outline output
401    // with a visible level prefix (e.g. "## Basic usage" or "<h2>Features"); normalize only
402    // that heading lookup path so code-symbol resolution keeps exact matching semantics.
403    let lookup_name = match detect_language(path) {
404        Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
405        _ => symbol_name,
406    };
407    let matches = match ctx.provider().resolve_symbol(path, lookup_name) {
408        Ok(m) => m,
409        Err(crate::error::AftError::SymbolNotFound { name, .. }) => {
410            let mut msg = format!("symbol '{}' not found", name);
411            if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
412                let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
413                let suggestions = suggest_close_symbols(&name, &available, 5);
414                if !suggestions.is_empty() {
415                    msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
416                }
417            }
418            return Response::error(&req.id, "symbol_not_found", msg);
419        }
420        Err(e) => {
421            return Response::error(&req.id, e.code(), e.to_string());
422        }
423    };
424
425    // LSP-enhanced disambiguation (S03)
426    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
427        lsp_hints::apply_lsp_disambiguation(matches, &hints)
428    } else {
429        matches
430    };
431
432    if matches.len() > 1 {
433        // Ambiguous — return qualified candidates with 1-based line ranges.
434        // Internal symbols.rs ranges are 0-based; we add 1 to both start and end.
435        let candidates: Vec<String> = matches
436            .iter()
437            .map(|m| {
438                let sym = &m.symbol;
439                let start = sym.range.start_line + 1;
440                let end = sym.range.end_line + 1;
441                let line_range = if start == end {
442                    format!("{}", start)
443                } else {
444                    format!("{}-{}", start, end)
445                };
446                if sym.scope_chain.is_empty() {
447                    format!("{}:{}", sym.name, line_range)
448                } else {
449                    format!(
450                        "{}::{}:{}",
451                        sym.scope_chain.join("::"),
452                        sym.name,
453                        line_range
454                    )
455                }
456            })
457            .collect();
458        return Response::error(
459            &req.id,
460            "ambiguous_symbol",
461            format!(
462                "symbol '{}' is ambiguous, candidates: [{}]",
463                symbol_name,
464                candidates.join(", ")
465            ),
466        );
467    }
468
469    if matches.is_empty() {
470        let mut msg = format!("symbol '{}' not found", symbol_name);
471        if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
472            let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
473            let suggestions = suggest_close_symbols(symbol_name, &available, 5);
474            if !suggestions.is_empty() {
475                msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
476            }
477        }
478        return Response::error(&req.id, "symbol_not_found", msg);
479    }
480
481    let target = &matches[0].symbol;
482    let start = target.range.start_line as usize;
483    let end = target.range.end_line as usize;
484
485    // When re-export following resolved to a different file, re-read that file's lines
486    let resolved_file_path = std::path::Path::new(&matches[0].file);
487    let resolved_lines: Vec<String>;
488    let effective_lines: &[String] = if resolved_file_path != path {
489        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
490            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
491            Err(_) => lines.to_vec(),
492        };
493        &resolved_lines
494    } else {
495        lines
496    };
497
498    // Extract symbol body (0-based line indices)
499    let content = if end < effective_lines.len() {
500        effective_lines[start..=end].join("\n")
501    } else {
502        effective_lines[start..].join("\n")
503    };
504
505    // Context before
506    let ctx_start = start.saturating_sub(context_lines);
507    let context_before: Vec<String> = if ctx_start < start {
508        effective_lines[ctx_start..start]
509            .iter()
510            .map(|l| l.to_string())
511            .collect()
512    } else {
513        vec![]
514    };
515
516    // Context after
517    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
518    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
519        effective_lines[(end + 1)..ctx_end]
520            .iter()
521            .map(|l| l.to_string())
522            .collect()
523    } else {
524        vec![]
525    };
526
527    let (calls_out, called_by) = if include_callgraph {
528        // Get all symbols in the resolved file for call matching
529        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
530            Ok(s) => s,
531            Err(e) => {
532                return Response::error(&req.id, e.code(), e.to_string());
533            }
534        };
535
536        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
537
538        // Parse AST for call extraction (use resolved file for cross-file re-exports)
539        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
540        let (tree, lang) = match parser.parse(resolved_file_path) {
541            Ok(r) => r,
542            Err(e) => {
543                return Response::error(&req.id, e.code(), e.to_string());
544            }
545        };
546
547        // calls_out: calls within the target symbol's byte range
548        let resolved_source = if resolved_file_path != path {
549            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
550        } else {
551            source.to_string()
552        };
553        let signature_byte_start = line_col_to_byte(
554            &resolved_source,
555            target.range.start_line,
556            target.range.start_col,
557        );
558        let signature_byte_end = line_col_to_byte(
559            &resolved_source,
560            target.range.end_line,
561            target.range.end_col,
562        );
563        let (target_byte_start, target_byte_end) =
564            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
565                .unwrap_or((signature_byte_start, signature_byte_end));
566
567        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
568
569        let raw_calls = all_file_calls.iter().filter(|call| {
570            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
571        });
572        let calls_out: Vec<CallRef> = raw_calls
573            .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
574            .map(|call| CallRef {
575                name: call.name.clone(),
576                line: call.line,
577            })
578            .collect();
579
580        // called_by: bucket the single file-wide call extraction by enclosing symbol range
581        let mut called_by: Vec<CallRef> = Vec::new();
582        for sym in &all_symbols {
583            if sym.name == target.name && sym.range.start_line == target.range.start_line {
584                continue; // skip self
585            }
586            let sym_byte_start =
587                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
588            let sym_byte_end =
589                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
590            for call in &all_file_calls {
591                if call.name == target.name
592                    && call.start_byte >= sym_byte_start
593                    && call.end_byte <= sym_byte_end
594                {
595                    called_by.push(CallRef {
596                        name: sym.name.clone(),
597                        line: call.line,
598                    });
599                }
600            }
601        }
602
603        // Dedup called_by by (name, line)
604        called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
605        called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
606
607        (calls_out, called_by)
608    } else {
609        (Vec::new(), Vec::new())
610    };
611
612    let kind_str = serde_json::to_value(&target.kind)
613        .ok()
614        .and_then(|v| v.as_str().map(String::from))
615        .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
616
617    let resp = ZoomResponse {
618        name: target.name.clone(),
619        kind: kind_str,
620        range: target.range.clone(),
621        content,
622        context_before,
623        context_after,
624        annotations: Annotations {
625            calls_out,
626            called_by,
627        },
628    };
629
630    match serde_json::to_value(&resp) {
631        Ok(resp_json) => Response::success(&req.id, resp_json),
632        Err(err) => Response::error(
633            &req.id,
634            "internal_error",
635            format!("zoom: failed to serialize response: {err}"),
636        ),
637    }
638}
639
640fn levenshtein_distance(s1: &str, s2: &str) -> usize {
641    let s1_chars: Vec<char> = s1.chars().collect();
642    let s2_chars: Vec<char> = s2.chars().collect();
643    let len1 = s1_chars.len();
644    let len2 = s2_chars.len();
645
646    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
647
648    for i in 0..=len1 {
649        dp[i][0] = i;
650    }
651    for j in 0..=len2 {
652        dp[0][j] = j;
653    }
654
655    for i in 1..=len1 {
656        for j in 1..=len2 {
657            if s1_chars[i - 1] == s2_chars[j - 1] {
658                dp[i][j] = dp[i - 1][j - 1];
659            } else {
660                dp[i][j] =
661                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
662            }
663        }
664    }
665
666    dp[len1][len2]
667}
668
669fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
670    let mut unique: Vec<&String> = available.iter().collect();
671    unique.sort();
672    unique.dedup();
673
674    let query_lower = query.to_lowercase();
675    let query_len = query_lower.chars().count();
676    let max_dist = std::cmp::max(2, query_len / 3);
677
678    let mut scored: Vec<(bool, usize, &String)> = unique
679        .into_iter()
680        .map(|name| {
681            let name_lower = name.to_lowercase();
682            let is_substring =
683                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
684            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
685                (query_lower.find('_'), query_lower.rfind('_'))
686            {
687                let prefix = &query_lower[..=first_idx];
688                let suffix = &query_lower[last_idx..];
689                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
690            } else {
691                false
692            };
693            let is_match = is_substring || is_wildcard;
694            let dist = levenshtein_distance(&query_lower, &name_lower);
695            (is_match, dist, name)
696        })
697        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
698        .collect();
699
700    scored.sort_by(|a, b| {
701        let a_match = a.0;
702        let b_match = b.0;
703        (!a_match)
704            .cmp(&(!b_match))
705            .then_with(|| a.1.cmp(&b.1))
706            .then_with(|| a.2.cmp(b.2))
707    });
708
709    scored
710        .into_iter()
711        .take(k)
712        .map(|(_, _, name)| name.clone())
713        .collect()
714}
715
716fn normalize_heading_query(input: &str) -> &str {
717    let trimmed = input.trim_start();
718    let hash_stripped = trimmed.trim_start_matches('#').trim_start();
719
720    if let Some(after_open) = hash_stripped.strip_prefix('<') {
721        let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
722        let mut chars = after_slash.chars();
723        if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
724            if let Some(end) = hash_stripped.find('>') {
725                return hash_stripped[end + 1..].trim_start();
726            }
727        }
728    }
729
730    hash_stripped
731}
732
733/// Extract call expression names within a byte range of the AST.
734///
735/// Delegates to `crate::calls::extract_calls_in_range`.
736#[cfg(test)]
737fn extract_calls_in_range(
738    source: &str,
739    root: tree_sitter::Node,
740    byte_start: usize,
741    byte_end: usize,
742    lang: LangId,
743) -> Vec<(String, u32)> {
744    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
745}
746
747fn symbol_body_byte_range(
748    root: tree_sitter::Node,
749    byte_start: usize,
750    byte_end: usize,
751) -> Option<(usize, usize)> {
752    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
753    let mut current = Some(node);
754    while let Some(node) = current {
755        if is_symbol_body_node(node.kind()) {
756            return Some((node.start_byte(), node.end_byte()));
757        }
758        current = node.parent();
759    }
760    Some((node.start_byte(), node.end_byte()))
761}
762
763fn smallest_node_covering_range<'tree>(
764    node: tree_sitter::Node<'tree>,
765    byte_start: usize,
766    byte_end: usize,
767) -> Option<tree_sitter::Node<'tree>> {
768    if node.start_byte() > byte_start || node.end_byte() < byte_end {
769        return None;
770    }
771
772    let mut cursor = node.walk();
773    if cursor.goto_first_child() {
774        loop {
775            let child = cursor.node();
776            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
777                return Some(found);
778            }
779            if !cursor.goto_next_sibling() {
780                break;
781            }
782        }
783    }
784
785    Some(node)
786}
787
788fn is_symbol_body_node(kind: &str) -> bool {
789    matches!(
790        kind,
791        "function_declaration"
792            | "generator_function_declaration"
793            | "function_expression"
794            | "generator_function"
795            | "arrow_function"
796            | "method_definition"
797            | "class_declaration"
798            | "abstract_class_declaration"
799            | "class"
800            | "lexical_declaration"
801            | "function_definition"
802            | "class_definition"
803            | "decorated_definition"
804            | "function_item"
805            | "impl_item"
806            | "method_declaration"
807    )
808}
809
810fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
811    let mut results = Vec::new();
812    let call_kinds = crate::calls::call_node_kinds(lang);
813    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
814    results
815}
816
817fn collect_calls_with_ranges(
818    node: tree_sitter::Node,
819    source: &str,
820    call_kinds: &[&str],
821    results: &mut Vec<RawCall>,
822) {
823    if call_kinds.contains(&node.kind()) {
824        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
825            results.push(RawCall {
826                name,
827                line: node.start_position().row as u32 + 1,
828                start_byte: node.start_byte(),
829                end_byte: node.end_byte(),
830            });
831        }
832    }
833
834    let mut cursor = node.walk();
835    if cursor.goto_first_child() {
836        loop {
837            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
838            if !cursor.goto_next_sibling() {
839                break;
840            }
841        }
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use crate::config::Config;
849    use crate::context::AppContext;
850    use crate::parser::TreeSitterProvider;
851    use std::path::PathBuf;
852
853    fn fixture_path(name: &str) -> PathBuf {
854        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
855            .join("tests")
856            .join("fixtures")
857            .join(name)
858    }
859
860    fn make_ctx() -> AppContext {
861        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
862    }
863
864    #[test]
865    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
866        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
867        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
868        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
869    }
870
871    #[test]
872    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
873        let params = serde_json::json!({ "symbols": "Getting Started" });
874        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
875        assert_eq!(names, vec!["Getting Started"]);
876    }
877
878    #[test]
879    fn parse_zoom_symbol_names_does_not_split_html_headings() {
880        let params = serde_json::json!({ "symbol": "Last Heading" });
881        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
882        assert_eq!(names, vec!["Last Heading"]);
883    }
884
885    #[test]
886    fn parse_zoom_symbol_names_single_token_unchanged() {
887        let params = serde_json::json!({ "symbol": "compute" });
888        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
889        assert_eq!(names, vec!["compute"]);
890    }
891
892    #[test]
893    fn parse_zoom_symbol_names_symbols_array_unchanged() {
894        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
895        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
896        assert_eq!(names, vec!["A", "B", "C"]);
897    }
898
899    // --- Call extraction tests ---
900
901    #[test]
902    fn extract_calls_finds_direct_calls() {
903        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
904        let mut parser = FileParser::new();
905        let path = fixture_path("calls.ts");
906        let (tree, lang) = parser.parse(&path).unwrap();
907
908        // `compute` calls `helper` — find compute's range from symbols
909        let ctx = make_ctx();
910        let symbols = ctx.provider().list_symbols(&path).unwrap();
911        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
912
913        let byte_start =
914            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
915        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
916
917        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
918        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
919
920        assert!(
921            names.contains(&"helper"),
922            "compute should call helper, got: {:?}",
923            names
924        );
925    }
926
927    #[test]
928    fn extract_calls_finds_member_calls() {
929        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
930        let mut parser = FileParser::new();
931        let path = fixture_path("calls.ts");
932        let (tree, lang) = parser.parse(&path).unwrap();
933
934        let ctx = make_ctx();
935        let symbols = ctx.provider().list_symbols(&path).unwrap();
936        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
937
938        let byte_start =
939            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
940        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
941
942        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
943        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
944
945        assert!(
946            names.contains(&"add"),
947            "runAll should call this.add, got: {:?}",
948            names
949        );
950        assert!(
951            names.contains(&"helper"),
952            "runAll should call helper, got: {:?}",
953            names
954        );
955    }
956
957    #[test]
958    fn extract_calls_unused_function_has_no_calls() {
959        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
960        let mut parser = FileParser::new();
961        let path = fixture_path("calls.ts");
962        let (tree, lang) = parser.parse(&path).unwrap();
963
964        let ctx = make_ctx();
965        let symbols = ctx.provider().list_symbols(&path).unwrap();
966        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
967
968        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
969        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
970
971        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
972        // console.log is the only call, but "log" or "console" aren't known symbols
973        let known_names = [
974            "helper",
975            "compute",
976            "orchestrate",
977            "unused",
978            "format",
979            "display",
980        ];
981        let filtered: Vec<&str> = calls
982            .iter()
983            .map(|(n, _)| n.as_str())
984            .filter(|n| known_names.contains(n))
985            .collect();
986        assert!(
987            filtered.is_empty(),
988            "unused should not call known symbols, got: {:?}",
989            filtered
990        );
991    }
992
993    // --- Context line tests ---
994
995    #[test]
996    fn context_lines_clamp_at_file_start() {
997        // helper() is at the top of the file (line 2) — context_before should be clamped
998        let ctx = make_ctx();
999        let path = fixture_path("calls.ts");
1000        let symbols = ctx.provider().list_symbols(&path).unwrap();
1001        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1002
1003        let source = std::fs::read_to_string(&path).unwrap();
1004        let lines: Vec<&str> = source.lines().collect();
1005        let start = helper.range.start_line as usize;
1006
1007        // With context_lines=5, ctx_start should clamp to 0
1008        let ctx_start = start.saturating_sub(5);
1009        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1010        // Should have at most `start` lines (not panic)
1011        assert!(context_before.len() <= start);
1012    }
1013
1014    #[test]
1015    fn context_lines_clamp_at_file_end() {
1016        let ctx = make_ctx();
1017        let path = fixture_path("calls.ts");
1018        let symbols = ctx.provider().list_symbols(&path).unwrap();
1019        let display = symbols.iter().find(|s| s.name == "display").unwrap();
1020
1021        let source = std::fs::read_to_string(&path).unwrap();
1022        let lines: Vec<&str> = source.lines().collect();
1023        let end = display.range.end_line as usize;
1024
1025        // With context_lines=20, should clamp to file length
1026        let ctx_end = (end + 1 + 20).min(lines.len());
1027        let context_after: Vec<&str> = if end + 1 < lines.len() {
1028            lines[(end + 1)..ctx_end].to_vec()
1029        } else {
1030            vec![]
1031        };
1032        // Should not panic regardless of context_lines size
1033        assert!(context_after.len() <= 20);
1034    }
1035
1036    // --- Body extraction test ---
1037
1038    #[test]
1039    fn body_extraction_matches_source() {
1040        let ctx = make_ctx();
1041        let path = fixture_path("calls.ts");
1042        let symbols = ctx.provider().list_symbols(&path).unwrap();
1043        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1044
1045        let source = std::fs::read_to_string(&path).unwrap();
1046        let lines: Vec<&str> = source.lines().collect();
1047        let start = compute.range.start_line as usize;
1048        let end = compute.range.end_line as usize;
1049        let body = lines[start..=end].join("\n");
1050
1051        assert!(
1052            body.contains("function compute"),
1053            "body should contain function declaration"
1054        );
1055        assert!(
1056            body.contains("helper(a)"),
1057            "body should contain call to helper"
1058        );
1059        assert!(
1060            body.contains("doubled + b"),
1061            "body should contain return expression"
1062        );
1063    }
1064
1065    // --- Full zoom response tests ---
1066
1067    #[test]
1068    fn body_range_expands_signature_range_to_include_body_calls() {
1069        let source = r#"function compute(
1070  value: number,
1071): number {
1072  return helper(value);
1073}
1074
1075function helper(value: number): number {
1076  return value * 2;
1077}
1078"#;
1079        let grammar = crate::parser::grammar_for(LangId::TypeScript);
1080        let mut parser = tree_sitter::Parser::new();
1081        parser.set_language(&grammar).unwrap();
1082        let tree = parser.parse(source, None).unwrap();
1083        let signature_end = source.find('{').expect("function has body");
1084
1085        let (body_start, body_end) =
1086            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1087        let calls = extract_calls_in_range(
1088            source,
1089            tree.root_node(),
1090            body_start,
1091            body_end,
1092            LangId::TypeScript,
1093        );
1094        let names = calls
1095            .iter()
1096            .map(|(name, _)| name.as_str())
1097            .collect::<Vec<_>>();
1098
1099        assert!(
1100            names.contains(&"helper"),
1101            "call inside the function body should be included: {names:?}"
1102        );
1103    }
1104
1105    #[test]
1106    fn zoom_response_has_calls_out_and_called_by() {
1107        let ctx = make_ctx();
1108        let path = fixture_path("calls.ts");
1109
1110        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1111        let resp = handle_zoom(&req, &ctx);
1112
1113        let json = serde_json::to_value(&resp).unwrap();
1114        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1115
1116        let calls_out = json["annotations"]["calls_out"]
1117            .as_array()
1118            .expect("calls_out array");
1119        let out_names: Vec<&str> = calls_out
1120            .iter()
1121            .map(|c| c["name"].as_str().unwrap())
1122            .collect();
1123        assert!(
1124            out_names.contains(&"helper"),
1125            "compute calls helper: {:?}",
1126            out_names
1127        );
1128
1129        let called_by = json["annotations"]["called_by"]
1130            .as_array()
1131            .expect("called_by array");
1132        let by_names: Vec<&str> = called_by
1133            .iter()
1134            .map(|c| c["name"].as_str().unwrap())
1135            .collect();
1136        assert!(
1137            by_names.contains(&"orchestrate"),
1138            "orchestrate calls compute: {:?}",
1139            by_names
1140        );
1141    }
1142
1143    #[test]
1144    fn zoom_response_empty_annotations_for_unused() {
1145        let ctx = make_ctx();
1146        let path = fixture_path("calls.ts");
1147
1148        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1149        let resp = handle_zoom(&req, &ctx);
1150
1151        let json = serde_json::to_value(&resp).unwrap();
1152        assert_eq!(json["success"], true);
1153
1154        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1155        let called_by = json["annotations"]["called_by"].as_array().unwrap();
1156
1157        // calls_out exists (may contain console.log but no known symbols)
1158        // called_by should be empty — nobody calls unused
1159        assert!(
1160            called_by.is_empty(),
1161            "unused should not be called by anyone: {:?}",
1162            called_by
1163        );
1164    }
1165
1166    #[test]
1167    fn zoom_default_omits_callgraph_annotations() {
1168        let ctx = make_ctx();
1169        let path = fixture_path("calls.ts");
1170
1171        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1172        let resp = handle_zoom(&req, &ctx);
1173
1174        let json = serde_json::to_value(&resp).unwrap();
1175        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1176
1177        let calls_out = json["annotations"]["calls_out"]
1178            .as_array()
1179            .expect("calls_out array");
1180        let called_by = json["annotations"]["called_by"]
1181            .as_array()
1182            .expect("called_by array");
1183        assert!(
1184            calls_out.is_empty(),
1185            "default zoom should omit calls_out: {:?}",
1186            calls_out
1187        );
1188        assert!(
1189            called_by.is_empty(),
1190            "default zoom should omit called_by: {:?}",
1191            called_by
1192        );
1193    }
1194
1195    #[test]
1196    fn zoom_symbol_not_found() {
1197        let ctx = make_ctx();
1198        let path = fixture_path("calls.ts");
1199
1200        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1201        let resp = handle_zoom(&req, &ctx);
1202
1203        let json = serde_json::to_value(&resp).unwrap();
1204        assert_eq!(json["success"], false);
1205        assert_eq!(json["code"], "symbol_not_found");
1206    }
1207
1208    #[test]
1209    fn zoom_custom_context_lines() {
1210        let ctx = make_ctx();
1211        let path = fixture_path("calls.ts");
1212
1213        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1214        let resp = handle_zoom(&req, &ctx);
1215
1216        let json = serde_json::to_value(&resp).unwrap();
1217        assert_eq!(json["success"], true);
1218
1219        let ctx_before = json["context_before"].as_array().unwrap();
1220        let ctx_after = json["context_after"].as_array().unwrap();
1221        // With context_lines=1, we get at most 1 line before and after
1222        assert!(
1223            ctx_before.len() <= 1,
1224            "context_before should be ≤1: {:?}",
1225            ctx_before
1226        );
1227        assert!(
1228            ctx_after.len() <= 1,
1229            "context_after should be ≤1: {:?}",
1230            ctx_after
1231        );
1232    }
1233
1234    #[test]
1235    fn zoom_missing_file_param() {
1236        let ctx = make_ctx();
1237        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1238        let resp = handle_zoom(&req, &ctx);
1239
1240        let json = serde_json::to_value(&resp).unwrap();
1241        assert_eq!(json["success"], false);
1242        assert_eq!(json["code"], "invalid_request");
1243    }
1244
1245    #[test]
1246    fn zoom_missing_symbol_param() {
1247        let ctx = make_ctx();
1248        let path = fixture_path("calls.ts");
1249        // Build the JSON via serde_json so Windows paths (with backslashes)
1250        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
1251        // backslashes as escape sequences and fail to parse.
1252        let req_value = serde_json::json!({
1253            "id": "z-6",
1254            "command": "zoom",
1255            "file": path.to_string_lossy(),
1256        });
1257        let req_str = req_value.to_string();
1258        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1259        let resp = handle_zoom(&req, &ctx);
1260
1261        let json = serde_json::to_value(&resp).unwrap();
1262        assert_eq!(json["success"], false);
1263        assert_eq!(json["code"], "invalid_request");
1264    }
1265
1266    #[test]
1267    fn test_suggest_close_symbols_unit() {
1268        let available = vec![
1269            "handle_grep_search".to_string(),
1270            "handle_semantic_search".to_string(),
1271            "handle_semantic_or_hybrid_search".to_string(),
1272            "compute_total".to_string(),
1273            "search".to_string(),
1274            "handle_search".to_string(),
1275        ];
1276
1277        let suggestions = suggest_close_symbols("handle_search", &available, 5);
1278        assert!(suggestions.contains(&"handle_grep_search".to_string()));
1279        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1280        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1281        assert!(suggestions.contains(&"search".to_string()));
1282        assert!(!suggestions.contains(&"compute_total".to_string()));
1283
1284        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1285        assert_eq!(suggestions, suggestions_caps);
1286
1287        let available2 = vec![
1288            "total".to_string(),
1289            "compute_total".to_string(),
1290            "unrelated".to_string(),
1291        ];
1292        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1293        assert_eq!(suggestions2, vec!["total".to_string()]);
1294    }
1295
1296    // --- Helpers ---
1297
1298    fn make_zoom_request(
1299        id: &str,
1300        file: &str,
1301        symbol: &str,
1302        context_lines: Option<u64>,
1303    ) -> RawRequest {
1304        let mut json = serde_json::json!({
1305            "id": id,
1306            "command": "zoom",
1307            "file": file,
1308            "symbol": symbol,
1309        });
1310        if let Some(cl) = context_lines {
1311            json["context_lines"] = serde_json::json!(cl);
1312        }
1313        serde_json::from_value(json).unwrap()
1314    }
1315
1316    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1317        let mut req = make_zoom_request(id, file, symbol, None);
1318        req.params["callgraph"] = serde_json::json!(true);
1319        req
1320    }
1321
1322    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1323        serde_json::from_str(json_str).unwrap()
1324    }
1325}