Skip to main content

aft/commands/
zoom.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::commands::outline::symbol_to_entry;
7use crate::commands::symbol_render::{
8    build_container_outline, format_qualified_entry, might_have_container_members,
9    qualified_symbol_name, render_container_member_menu, should_return_member_menu,
10    symbol_kind_string,
11};
12use crate::context::AppContext;
13use crate::edit::line_col_to_byte;
14use crate::language::LanguageProvider;
15use crate::lsp_hints;
16use crate::parser::{detect_language, FileParser, LangId};
17use crate::protocol::{RawRequest, Response};
18use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch};
19use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
20
21/// A reference to a called/calling function.
22#[derive(Debug, Clone, Serialize)]
23pub struct CallRef {
24    pub name: String,
25    /// 1-based line number of the call reference.
26    pub line: u32,
27    /// Number of later call sites with the same callee or caller name merged into this entry.
28    #[serde(skip_serializing_if = "is_zero")]
29    pub extra_count: u32,
30}
31
32fn is_zero(value: &u32) -> bool {
33    *value == 0
34}
35
36fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
37    let mut index_by_name: HashMap<String, usize> = HashMap::new();
38    let mut deduped: Vec<CallRef> = Vec::new();
39
40    for call in calls {
41        if let Some(index) = index_by_name.get(&call.name).copied() {
42            deduped[index].extra_count = deduped[index]
43                .extra_count
44                .saturating_add(call.extra_count.saturating_add(1));
45        } else {
46            index_by_name.insert(call.name.clone(), deduped.len());
47            deduped.push(call);
48        }
49    }
50
51    deduped
52}
53
54/// Annotations describing file-scoped call relationships.
55#[derive(Debug, Clone, Serialize)]
56pub struct Annotations {
57    pub calls_out: Vec<CallRef>,
58    pub called_by: Vec<CallRef>,
59}
60
61/// Response payload for the zoom command.
62#[derive(Debug, Clone, Serialize)]
63pub struct ZoomResponse {
64    pub name: String,
65    pub kind: String,
66    pub range: Range,
67    pub content: String,
68    pub context_before: Vec<String>,
69    pub context_after: Vec<String>,
70    pub annotations: Annotations,
71}
72
73struct RawCall {
74    name: String,
75    line: u32,
76    start_byte: usize,
77    end_byte: usize,
78}
79
80fn resolve_file_or_url(
81    req: &RawRequest,
82    ctx: &AppContext,
83    file: &str,
84) -> Result<PathBuf, Response> {
85    if is_http_url(file) {
86        let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
87        let allow_private = ctx.config().url_fetch_allow_private
88            || req
89                .params
90                .get("allow_private")
91                .and_then(|value| value.as_bool())
92                .unwrap_or(false);
93        return fetch_url_to_cache(
94            file,
95            &storage_dir,
96            UrlFetchOptions {
97                allow_private,
98                ..UrlFetchOptions::default()
99            },
100        )
101        .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
102    }
103
104    ctx.validate_path(&req.id, Path::new(file))
105}
106
107fn zoom_one_target_response(
108    req: &RawRequest,
109    ctx: &AppContext,
110    file: &str,
111    symbol: &str,
112    context_lines: usize,
113    include_callgraph: bool,
114) -> Response {
115    let path = match resolve_file_or_url(req, ctx, file) {
116        Ok(path) => path,
117        Err(resp) => return resp,
118    };
119    if !path.exists() {
120        return Response::error(
121            &req.id,
122            "file_not_found",
123            format!("file not found: {}", file),
124        );
125    }
126
127    let source = match std::fs::read_to_string(&path) {
128        Ok(source) => source,
129        Err(error) => {
130            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, error));
131        }
132    };
133    let lines: Vec<String> = source.lines().map(|line| line.to_string()).collect();
134
135    zoom_one_symbol(
136        req,
137        ctx,
138        &path,
139        file,
140        &source,
141        &lines,
142        symbol,
143        context_lines,
144        include_callgraph,
145    )
146}
147
148fn serialize_zoom_target_response(req: &RawRequest, response: Response) -> serde_json::Value {
149    serde_json::to_value(&response).unwrap_or_else(|error| {
150        serde_json::to_value(Response::error(
151            &req.id,
152            "internal_error",
153            format!("zoom: failed to serialize target response: {error}"),
154        ))
155        .expect("serializing Response::error should not fail")
156    })
157}
158
159fn handle_zoom_targets(
160    req: &RawRequest,
161    ctx: &AppContext,
162    targets: &[serde_json::Value],
163    context_lines: usize,
164    include_callgraph: bool,
165) -> Response {
166    if targets.is_empty() {
167        return Response::error(
168            &req.id,
169            "invalid_request",
170            "zoom: 'targets' must be a non-empty array",
171        );
172    }
173
174    let mut entries = Vec::with_capacity(targets.len());
175    for (index, target) in targets.iter().enumerate() {
176        let obj = target.as_object();
177        let Some(file) = obj
178            .and_then(|obj| obj.get("file"))
179            .and_then(|value| value.as_str())
180            .filter(|file| !file.is_empty())
181        else {
182            return Response::error(
183                &req.id,
184                "invalid_request",
185                format!("zoom: targets[{index}].file must be a non-empty string"),
186            );
187        };
188        let Some(symbol) = obj
189            .and_then(|obj| obj.get("symbol"))
190            .and_then(|value| value.as_str())
191            .filter(|symbol| !symbol.is_empty())
192        else {
193            return Response::error(
194                &req.id,
195                "invalid_request",
196                format!("zoom: targets[{index}].symbol must be a non-empty string"),
197            );
198        };
199        let target_label = obj
200            .and_then(|obj| obj.get("target_label").or_else(|| obj.get("targetLabel")))
201            .and_then(|value| value.as_str())
202            .filter(|label| !label.is_empty())
203            .unwrap_or(file);
204
205        let response =
206            zoom_one_target_response(req, ctx, file, symbol, context_lines, include_callgraph);
207        entries.push(serde_json::json!({
208            "targetLabel": target_label,
209            "name": symbol,
210            "response": serialize_zoom_target_response(req, response),
211        }));
212    }
213
214    Response::success(
215        &req.id,
216        serde_json::json!({
217            "targets": entries,
218        }),
219    )
220}
221
222/// Handle a `zoom` request.
223///
224/// Expects either `file` plus `symbol`/`symbols`, or a cross-file `targets` array,
225/// with optional `context_lines` (default 3). Resolves the symbol, extracts body +
226/// context, and walks ASTs for call annotations. For code files, a whitespace-separated
227/// top-level `symbol`/`symbols` string is split into multiple same-file lookups.
228pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
229    let context_lines = req
230        .params
231        .get("context_lines")
232        .and_then(|v| v.as_u64())
233        .unwrap_or(3) as usize;
234    let include_callgraph = req
235        .params
236        .get("callgraph")
237        .and_then(|v| v.as_bool())
238        .unwrap_or(false);
239
240    if let Some(targets_value) = req.params.get("targets") {
241        let Some(targets) = targets_value.as_array() else {
242            return Response::error(
243                &req.id,
244                "invalid_request",
245                "zoom: 'targets' must be a non-empty array",
246            );
247        };
248        return handle_zoom_targets(req, ctx, targets, context_lines, include_callgraph);
249    }
250
251    let file = match req
252        .params
253        .get("file")
254        .or_else(|| req.params.get("url"))
255        .and_then(|v| v.as_str())
256    {
257        Some(f) => f,
258        None => {
259            return Response::error(
260                &req.id,
261                "invalid_request",
262                "zoom: missing required param 'file'",
263            );
264        }
265    };
266
267    let start_line = req
268        .params
269        .get("start_line")
270        .and_then(|v| v.as_u64())
271        .map(|v| v as usize);
272    let end_line = req
273        .params
274        .get("end_line")
275        .and_then(|v| v.as_u64())
276        .map(|v| v as usize);
277
278    let path = match resolve_file_or_url(req, ctx, file) {
279        Ok(path) => path,
280        Err(resp) => return resp,
281    };
282    if !path.exists() {
283        return Response::error(
284            &req.id,
285            "file_not_found",
286            format!("file not found: {}", file),
287        );
288    }
289
290    // Read source file early because both symbol mode and line-range mode need it.
291    let source = match std::fs::read_to_string(&path) {
292        Ok(s) => s,
293        Err(e) => {
294            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
295        }
296    };
297
298    let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
299
300    // Line-range mode: read arbitrary lines without requiring a symbol.
301    match (start_line, end_line) {
302        (Some(start), Some(end)) => {
303            if zoom_symbol_param(&req.params).is_some() {
304                return Response::error(
305                    &req.id,
306                    "invalid_request",
307                    "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
308                );
309            }
310            if start == 0 || end == 0 {
311                return Response::error(
312                    &req.id,
313                    "invalid_request",
314                    "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
315                );
316            }
317            if end < start {
318                return Response::error(
319                    &req.id,
320                    "invalid_request",
321                    format!("zoom: end_line {} must be >= start_line {}", end, start),
322                );
323            }
324            if lines.is_empty() {
325                return Response::error(
326                    &req.id,
327                    "invalid_request",
328                    format!("zoom: {} is empty", file),
329                );
330            }
331
332            let start_idx = start - 1;
333            // Clamp end_line to file length (same as batch edits)
334            let clamped_end = end.min(lines.len());
335            let end_idx = clamped_end - 1;
336            if start_idx >= lines.len() {
337                return Response::error(
338                    &req.id,
339                    "invalid_request",
340                    format!(
341                        "zoom: start_line {} is past end of {} ({} lines)",
342                        start,
343                        file,
344                        lines.len()
345                    ),
346                );
347            }
348
349            let content = lines[start_idx..=end_idx].join("\n");
350            let ctx_start = start_idx.saturating_sub(context_lines);
351            let context_before: Vec<String> = if ctx_start < start_idx {
352                lines[ctx_start..start_idx]
353                    .iter()
354                    .map(|l| l.to_string())
355                    .collect()
356            } else {
357                vec![]
358            };
359            let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
360            let context_after: Vec<String> = if end_idx + 1 < lines.len() {
361                lines[(end_idx + 1)..ctx_end]
362                    .iter()
363                    .map(|l| l.to_string())
364                    .collect()
365            } else {
366                vec![]
367            };
368            let end_col = lines[end_idx].chars().count() as u32;
369
370            return Response::success(
371                &req.id,
372                serde_json::json!({
373                    "name": format!("lines {}-{}", start, clamped_end),
374                    "kind": "lines",
375                    "range": {
376                        "start_line": start,  // already 1-based from user input
377                        "start_col": 1,
378                        "end_line": clamped_end,
379                        "end_col": end_col + 1,
380                    },
381                    "content": content,
382                    "context_before": context_before,
383                    "context_after": context_after,
384                    "annotations": {
385                        "calls_out": [],
386                        "called_by": [],
387                    },
388                }),
389            );
390        }
391        (Some(_), None) | (None, Some(_)) => {
392            return Response::error(
393                &req.id,
394                "invalid_request",
395                "zoom: provide both 'start_line' and 'end_line' for line-range mode",
396            );
397        }
398        (None, None) => {}
399    }
400
401    let lang = detect_language(&path);
402    let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
403        Ok(names) => names,
404        Err(resp) => return resp,
405    };
406
407    if symbol_names.is_empty() {
408        return Response::error(
409            &req.id,
410            "invalid_request",
411            "zoom: missing required param 'symbol'",
412        );
413    }
414
415    if symbol_names.len() == 1 {
416        return zoom_one_symbol(
417            req,
418            ctx,
419            &path,
420            file,
421            &source,
422            &lines,
423            &symbol_names[0],
424            context_lines,
425            include_callgraph,
426        );
427    }
428
429    zoom_batch_symbols(
430        req,
431        ctx,
432        &path,
433        file,
434        &source,
435        &lines,
436        &symbol_names,
437        context_lines,
438        include_callgraph,
439    )
440}
441
442/// Raw `symbol` or `symbols` param before language-aware splitting.
443fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
444    params
445        .get("symbol")
446        .or_else(|| params.get("symbols"))
447        .and_then(|v| v.as_str())
448}
449
450fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
451    matches!(lang, Some(LangId::Markdown | LangId::Html))
452}
453
454/// Normalize `symbol` / `symbols` into one or more lookup names.
455///
456/// For code files, a single string containing internal whitespace is split on `\s+`.
457/// Markdown/HTML headings keep the full string (headings may contain spaces).
458fn parse_zoom_symbol_names(
459    params: &serde_json::Value,
460    lang: Option<LangId>,
461) -> Result<Vec<String>, Response> {
462    if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
463        let names: Vec<String> = arr
464            .iter()
465            .filter_map(|v| v.as_str().map(str::trim))
466            .filter(|s| !s.is_empty())
467            .map(str::to_string)
468            .collect();
469        return Ok(names);
470    }
471
472    let Some(raw) = zoom_symbol_param(params) else {
473        return Ok(Vec::new());
474    };
475
476    if is_heading_zoom_language(lang) {
477        let trimmed = raw.trim();
478        if trimmed.is_empty() {
479            return Ok(Vec::new());
480        }
481        return Ok(vec![trimmed.to_string()]);
482    }
483
484    if raw.split_whitespace().count() <= 1 {
485        let trimmed = raw.trim();
486        if trimmed.is_empty() {
487            return Ok(Vec::new());
488        }
489        return Ok(vec![trimmed.to_string()]);
490    }
491
492    Ok(raw.split_whitespace().map(str::to_string).collect())
493}
494
495fn zoom_batch_symbols(
496    req: &RawRequest,
497    ctx: &AppContext,
498    path: &Path,
499    file: &str,
500    source: &str,
501    lines: &[String],
502    symbol_names: &[String],
503    context_lines: usize,
504    include_callgraph: bool,
505) -> Response {
506    let mut entries = Vec::with_capacity(symbol_names.len());
507    let mut all_ok = true;
508
509    for name in symbol_names {
510        let resp = zoom_one_symbol(
511            req,
512            ctx,
513            path,
514            file,
515            source,
516            lines,
517            name,
518            context_lines,
519            include_callgraph,
520        );
521        let json = match serde_json::to_value(&resp) {
522            Ok(v) => v,
523            Err(err) => {
524                return Response::error(
525                    &req.id,
526                    "internal_error",
527                    format!("zoom: failed to serialize batch entry: {err}"),
528                );
529            }
530        };
531        if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
532            all_ok = false;
533        }
534        entries.push(serde_json::json!({
535            "name": name,
536            "response": json,
537        }));
538    }
539
540    Response::success(
541        &req.id,
542        serde_json::json!({
543            "complete": all_ok,
544            "symbols": entries,
545        }),
546    )
547}
548
549fn zoom_one_symbol(
550    req: &RawRequest,
551    ctx: &AppContext,
552    path: &Path,
553    _file: &str,
554    source: &str,
555    lines: &[String],
556    symbol_name: &str,
557    context_lines: usize,
558    include_callgraph: bool,
559) -> Response {
560    // Keep raw heading labels for outline display. Zoom resolves heading names in tiers:
561    // exact raw text, normalized text, case-insensitive normalized text, then anchor slugs.
562    // Code symbols continue through the provider's exact resolver.
563    let is_heading = is_heading_zoom_language(detect_language(path));
564    let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
565        Ok(matches) => matches,
566        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
567    };
568
569    // LSP-enhanced disambiguation (S03)
570    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
571        lsp_hints::apply_lsp_disambiguation(matches, &hints)
572    } else {
573        matches
574    };
575
576    if matches.len() > 1 {
577        let content = render_ambiguous_symbol_menu(symbol_name, &matches);
578        let candidates = matches
579            .iter()
580            .map(|candidate| {
581                let sym = &candidate.symbol;
582                serde_json::json!({
583                    "name": sym.name.clone(),
584                    "qualified_name": qualified_symbol_name(sym),
585                    "kind": symbol_kind_string(&sym.kind),
586                    "range": sym.range.clone(),
587                    "signature": sym.signature.clone(),
588                })
589            })
590            .collect::<Vec<_>>();
591
592        return Response::success(
593            &req.id,
594            serde_json::json!({
595                "name": symbol_name,
596                "kind": "ambiguous_symbol",
597                "content": content,
598                "context_before": [],
599                "context_after": [],
600                "annotations": empty_annotations(),
601                "candidates": candidates,
602            }),
603        );
604    }
605
606    if matches.is_empty() {
607        let mut msg = format!("symbol '{}' not found", symbol_name);
608        if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
609            let suggestions = if is_heading {
610                suggest_heading_symbols(symbol_name, &all_symbols, 5)
611            } else {
612                let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
613                suggest_close_symbols(symbol_name, &available, 5)
614            };
615            if !suggestions.is_empty() {
616                msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
617            }
618        }
619        return Response::error(&req.id, "symbol_not_found", msg);
620    }
621
622    let target = &matches[0].symbol;
623    let start = target.range.start_line as usize;
624    let end = target.range.end_line as usize;
625
626    // When re-export following resolved to a different file, re-read that file's lines
627    let resolved_file_path = std::path::Path::new(&matches[0].file);
628    let resolved_lines: Vec<String>;
629    let effective_lines: &[String] = if resolved_file_path != path {
630        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
631            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
632            Err(_) => lines.to_vec(),
633        };
634        &resolved_lines
635    } else {
636        lines
637    };
638
639    // Extract symbol body (0-based line indices)
640    let content = if end < effective_lines.len() {
641        effective_lines[start..=end].join("\n")
642    } else {
643        effective_lines[start..].join("\n")
644    };
645
646    let resolved_lang = detect_language(resolved_file_path);
647    let container_outline = if might_have_container_members(target) {
648        match build_container_outline(ctx, resolved_file_path, target) {
649            Ok(outline) => Some(outline),
650            Err(e) => {
651                return Response::error(&req.id, e.code(), e.to_string());
652            }
653        }
654    } else {
655        None
656    };
657
658    if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
659        let kind_str = symbol_kind_string(&target.kind);
660        let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
661        let resp = ZoomResponse {
662            name: target.name.clone(),
663            kind: kind_str,
664            range: target.range.clone(),
665            content: menu,
666            context_before: Vec::new(),
667            context_after: Vec::new(),
668            annotations: Annotations {
669                calls_out: Vec::new(),
670                called_by: Vec::new(),
671            },
672        };
673        return match serde_json::to_value(&resp) {
674            Ok(resp_json) => Response::success(&req.id, resp_json),
675            Err(err) => Response::error(
676                &req.id,
677                "internal_error",
678                format!("zoom: failed to serialize response: {err}"),
679            ),
680        };
681    }
682
683    // Context before
684    let ctx_start = start.saturating_sub(context_lines);
685    let context_before: Vec<String> = if ctx_start < start {
686        effective_lines[ctx_start..start]
687            .iter()
688            .map(|l| l.to_string())
689            .collect()
690    } else {
691        vec![]
692    };
693
694    // Context after
695    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
696    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
697        effective_lines[(end + 1)..ctx_end]
698            .iter()
699            .map(|l| l.to_string())
700            .collect()
701    } else {
702        vec![]
703    };
704
705    let (calls_out, called_by) = if include_callgraph {
706        // Get all symbols in the resolved file for call matching
707        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
708            Ok(s) => s,
709            Err(e) => {
710                return Response::error(&req.id, e.code(), e.to_string());
711            }
712        };
713
714        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
715
716        // Parse AST for call extraction (use resolved file for cross-file re-exports)
717        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
718        let (tree, lang) = match parser.parse(resolved_file_path) {
719            Ok(r) => r,
720            Err(e) => {
721                return Response::error(&req.id, e.code(), e.to_string());
722            }
723        };
724
725        // calls_out: calls within the target symbol's byte range
726        let resolved_source = if resolved_file_path != path {
727            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
728        } else {
729            source.to_string()
730        };
731        let signature_byte_start = line_col_to_byte(
732            &resolved_source,
733            target.range.start_line,
734            target.range.start_col,
735        );
736        let signature_byte_end = line_col_to_byte(
737            &resolved_source,
738            target.range.end_line,
739            target.range.end_col,
740        );
741        let (target_byte_start, target_byte_end) =
742            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
743                .unwrap_or((signature_byte_start, signature_byte_end));
744
745        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
746
747        let raw_calls = all_file_calls.iter().filter(|call| {
748            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
749        });
750        let calls_out = dedupe_call_refs_by_name(
751            raw_calls
752                .filter(|call| {
753                    known_names.contains(&call.name.as_str()) && call.name != target.name
754                })
755                .map(|call| CallRef {
756                    name: call.name.clone(),
757                    line: call.line,
758                    extra_count: 0,
759                })
760                .collect(),
761        );
762
763        // called_by: bucket the single file-wide call extraction by enclosing symbol range
764        let mut called_by: Vec<CallRef> = Vec::new();
765        for sym in &all_symbols {
766            if sym.name == target.name && sym.range.start_line == target.range.start_line {
767                continue; // skip self
768            }
769            let sym_byte_start =
770                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
771            let sym_byte_end =
772                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
773            for call in &all_file_calls {
774                if call.name == target.name
775                    && call.start_byte >= sym_byte_start
776                    && call.end_byte <= sym_byte_end
777                {
778                    called_by.push(CallRef {
779                        name: sym.name.clone(),
780                        line: call.line,
781                        extra_count: 0,
782                    });
783                }
784            }
785        }
786
787        let called_by = dedupe_call_refs_by_name(called_by);
788
789        (calls_out, called_by)
790    } else {
791        (Vec::new(), Vec::new())
792    };
793
794    let kind_str = symbol_kind_string(&target.kind);
795
796    let resp = ZoomResponse {
797        name: target.name.clone(),
798        kind: kind_str,
799        range: target.range.clone(),
800        content,
801        context_before,
802        context_after,
803        annotations: Annotations {
804            calls_out,
805            called_by,
806        },
807    };
808
809    match serde_json::to_value(&resp) {
810        Ok(resp_json) => Response::success(&req.id, resp_json),
811        Err(err) => Response::error(
812            &req.id,
813            "internal_error",
814            format!("zoom: failed to serialize response: {err}"),
815        ),
816    }
817}
818
819fn empty_annotations() -> serde_json::Value {
820    serde_json::json!({
821        "calls_out": [],
822        "called_by": [],
823    })
824}
825
826fn render_ambiguous_symbol_menu(
827    symbol_name: &str,
828    matches: &[crate::symbols::SymbolMatch],
829) -> String {
830    let mut lines = vec![format!(
831        "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
832        matches.len()
833    )];
834
835    for candidate in matches {
836        let entry = symbol_to_entry(&candidate.symbol);
837        lines.push(format!(
838            "- {}",
839            format_qualified_entry(&entry, Some(&candidate.symbol))
840        ));
841    }
842
843    lines.join("\n")
844}
845
846fn levenshtein_distance(s1: &str, s2: &str) -> usize {
847    let s1_chars: Vec<char> = s1.chars().collect();
848    let s2_chars: Vec<char> = s2.chars().collect();
849    let len1 = s1_chars.len();
850    let len2 = s2_chars.len();
851
852    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
853
854    for i in 0..=len1 {
855        dp[i][0] = i;
856    }
857    for j in 0..=len2 {
858        dp[0][j] = j;
859    }
860
861    for i in 1..=len1 {
862        for j in 1..=len2 {
863            if s1_chars[i - 1] == s2_chars[j - 1] {
864                dp[i][j] = dp[i - 1][j - 1];
865            } else {
866                dp[i][j] =
867                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
868            }
869        }
870    }
871
872    dp[len1][len2]
873}
874
875fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
876    let mut unique: Vec<&String> = available.iter().collect();
877    unique.sort();
878    unique.dedup();
879
880    let query_lower = query.to_lowercase();
881    let query_len = query_lower.chars().count();
882    let max_dist = std::cmp::max(2, query_len / 3);
883
884    let mut scored: Vec<(bool, usize, &String)> = unique
885        .into_iter()
886        .map(|name| {
887            let name_lower = name.to_lowercase();
888            let is_substring =
889                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
890            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
891                (query_lower.find('_'), query_lower.rfind('_'))
892            {
893                let prefix = &query_lower[..=first_idx];
894                let suffix = &query_lower[last_idx..];
895                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
896            } else {
897                false
898            };
899            let is_match = is_substring || is_wildcard;
900            let dist = levenshtein_distance(&query_lower, &name_lower);
901            (is_match, dist, name)
902        })
903        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
904        .collect();
905
906    scored.sort_by(|a, b| {
907        let a_match = a.0;
908        let b_match = b.0;
909        (!a_match)
910            .cmp(&(!b_match))
911            .then_with(|| a.1.cmp(&b.1))
912            .then_with(|| a.2.cmp(b.2))
913    });
914
915    scored
916        .into_iter()
917        .take(k)
918        .map(|(_, _, name)| name.clone())
919        .collect()
920}
921
922fn resolve_zoom_symbol(
923    provider: &dyn LanguageProvider,
924    path: &Path,
925    query: &str,
926    is_heading: bool,
927) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
928    if is_heading {
929        return resolve_heading_symbols(provider, path, query);
930    }
931
932    match provider.resolve_symbol(path, query) {
933        Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
934        result => result,
935    }
936}
937
938/// Keep document headings' raw labels for outline fidelity while allowing zoom to use
939/// human-readable labels, section prefixes, or anchors without affecting code symbols.
940fn resolve_heading_symbols(
941    provider: &dyn LanguageProvider,
942    path: &Path,
943    query: &str,
944) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
945    let headings: Vec<SymbolMatch> = provider
946        .list_symbols(path)?
947        .into_iter()
948        .filter(|symbol| symbol.kind == SymbolKind::Heading)
949        .map(|symbol| SymbolMatch {
950            file: path.display().to_string(),
951            symbol,
952        })
953        .collect();
954
955    Ok(match_heading_identity(&headings, query))
956}
957
958fn match_heading_identity(headings: &[SymbolMatch], query: &str) -> Vec<SymbolMatch> {
959    let exact: Vec<_> = headings
960        .iter()
961        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
962        .cloned()
963        .collect();
964    if !exact.is_empty() {
965        return exact;
966    }
967
968    let normalized_query = normalize_heading_label(query);
969    if normalized_query.is_empty() {
970        return Vec::new();
971    }
972
973    let normalized: Vec<_> = headings
974        .iter()
975        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
976        .cloned()
977        .collect();
978    if !normalized.is_empty() {
979        return normalized;
980    }
981
982    let folded_query = normalized_query.to_lowercase();
983    let case_insensitive: Vec<_> = headings
984        .iter()
985        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
986        .cloned()
987        .collect();
988    if !case_insensitive.is_empty() {
989        return case_insensitive;
990    }
991
992    let query_slug = slugify_heading_label(&normalized_query);
993    if query_slug.is_empty() {
994        return Vec::new();
995    }
996
997    headings
998        .iter()
999        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1000        .cloned()
1001        .collect()
1002}
1003
1004fn qualified_heading_name(symbol: &Symbol) -> String {
1005    if symbol.scope_chain.is_empty() {
1006        return symbol.name.clone();
1007    }
1008    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1009}
1010
1011fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1012    symbol.name == query || qualified_heading_name(symbol) == query
1013}
1014
1015fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1016    normalize_heading_label(&symbol.name) == query
1017        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1018}
1019
1020fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1021    normalize_heading_label(&symbol.name).to_lowercase() == query
1022        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1023}
1024
1025fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1026    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1027        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1028            == query_slug
1029}
1030
1031fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1032    let available: Vec<String> = symbols
1033        .iter()
1034        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1035        .map(|symbol| normalize_heading_label(&symbol.name))
1036        .filter(|name| !name.is_empty())
1037        .collect();
1038    let normalized_query = normalize_heading_label(query);
1039    if normalized_query.is_empty() {
1040        return Vec::new();
1041    }
1042    suggest_close_symbols(&normalized_query, &available, k)
1043}
1044
1045fn normalize_heading_label(input: &str) -> String {
1046    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1047
1048    // A label can contain both a document prefix and a decorative symbol cluster.
1049    // Repeat the small cleanup sequence so either order is handled consistently.
1050    for _ in 0..4 {
1051        let mut next = value.as_str();
1052        let without_heading_markers = next.trim_start_matches('#').trim_start();
1053        if without_heading_markers != next {
1054            next = without_heading_markers;
1055        }
1056        if let Some(rest) = strip_heading_html_prefix(next) {
1057            next = rest;
1058        }
1059        if let Some(rest) = strip_leading_section_prefix(next) {
1060            next = rest;
1061        }
1062        if let Some(rest) = strip_leading_symbol_cluster(next) {
1063            next = rest;
1064        }
1065
1066        let collapsed = collapse_heading_whitespace(next);
1067        if collapsed == value {
1068            break;
1069        }
1070        value = collapsed;
1071    }
1072
1073    value
1074}
1075
1076fn collapse_heading_whitespace(input: &str) -> String {
1077    input.split_whitespace().collect::<Vec<_>>().join(" ")
1078}
1079
1080fn strip_markdown_links(input: &str) -> String {
1081    let mut output = String::with_capacity(input.len());
1082    let mut cursor = 0;
1083
1084    while let Some(relative_open) = input[cursor..].find('[') {
1085        let open = cursor + relative_open;
1086        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1087            output.push_str(&input[cursor..]);
1088            return output;
1089        };
1090
1091        if input.as_bytes().get(close + 1) != Some(&b'(') {
1092            output.push_str(&input[cursor..=close]);
1093            cursor = close + 1;
1094            continue;
1095        }
1096
1097        let target_open = close + 1;
1098        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1099            output.push_str(&input[cursor..]);
1100            return output;
1101        };
1102
1103        output.push_str(&input[cursor..open]);
1104        output.push_str(&input[open + 1..close]);
1105        cursor = target_close + 1;
1106    }
1107
1108    output.push_str(&input[cursor..]);
1109    output
1110}
1111
1112fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1113    let mut depth = 0;
1114    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1115        if *byte == open {
1116            depth += 1;
1117        } else if *byte == close {
1118            depth -= 1;
1119            if depth == 0 {
1120                return Some(index);
1121            }
1122        }
1123    }
1124    None
1125}
1126
1127fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1128    let input = input.trim_start();
1129    let bytes = input.as_bytes();
1130    if bytes.first() != Some(&b'<') {
1131        return None;
1132    }
1133
1134    let mut index = 1;
1135    if bytes.get(index) == Some(&b'/') {
1136        index += 1;
1137    }
1138    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1139        return None;
1140    }
1141    index += 1;
1142    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1143        return None;
1144    }
1145
1146    let end = input.find('>')?;
1147    let rest = input[end + 1..].trim_start();
1148    if rest.chars().any(|character| character.is_alphanumeric()) {
1149        Some(rest)
1150    } else {
1151        None
1152    }
1153}
1154
1155fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1156    let bytes = input.as_bytes();
1157    let mut index = 0;
1158    let mut saw_dot = false;
1159
1160    if !bytes
1161        .first()
1162        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1163    {
1164        return None;
1165    }
1166
1167    while index < bytes.len() {
1168        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1169            index += 1;
1170        }
1171        if bytes.get(index) != Some(&b'.') {
1172            break;
1173        }
1174        saw_dot = true;
1175        index += 1;
1176        if bytes
1177            .get(index)
1178            .is_some_and(|byte| byte.is_ascii_whitespace())
1179        {
1180            let rest = input[index..].trim_start();
1181            return if rest.chars().any(|character| character.is_alphanumeric()) {
1182                Some(rest)
1183            } else {
1184                None
1185            };
1186        }
1187        if !bytes
1188            .get(index)
1189            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1190        {
1191            return None;
1192        }
1193    }
1194
1195    if saw_dot
1196        && bytes
1197            .get(index)
1198            .is_some_and(|byte| byte.is_ascii_whitespace())
1199    {
1200        let rest = input[index..].trim_start();
1201        if rest.chars().any(|character| character.is_alphanumeric()) {
1202            return Some(rest);
1203        }
1204    }
1205    None
1206}
1207
1208fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1209    let first_text = input
1210        .char_indices()
1211        .find(|(_, character)| character.is_alphanumeric())
1212        .map(|(index, _)| index)?;
1213    if first_text == 0 {
1214        return None;
1215    }
1216
1217    let rest = &input[first_text..];
1218    if rest.chars().any(|character| character.is_alphanumeric()) {
1219        Some(rest)
1220    } else {
1221        None
1222    }
1223}
1224
1225fn slugify_heading_label(label: &str) -> String {
1226    let mut slug = String::new();
1227    let mut pending_separator = false;
1228
1229    for character in label.chars() {
1230        if character.is_alphanumeric() {
1231            if pending_separator && !slug.is_empty() {
1232                slug.push('-');
1233            }
1234            for lowercase in character.to_lowercase() {
1235                slug.push(lowercase);
1236            }
1237            pending_separator = false;
1238        } else if !slug.is_empty() {
1239            pending_separator = true;
1240        }
1241    }
1242
1243    slug
1244}
1245
1246/// Extract call expression names within a byte range of the AST.
1247///
1248/// Delegates to `crate::calls::extract_calls_in_range`.
1249#[cfg(test)]
1250fn extract_calls_in_range(
1251    source: &str,
1252    root: tree_sitter::Node,
1253    byte_start: usize,
1254    byte_end: usize,
1255    lang: LangId,
1256) -> Vec<(String, u32)> {
1257    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1258}
1259
1260fn symbol_body_byte_range(
1261    root: tree_sitter::Node,
1262    byte_start: usize,
1263    byte_end: usize,
1264) -> Option<(usize, usize)> {
1265    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1266    let mut current = Some(node);
1267    while let Some(node) = current {
1268        if is_symbol_body_node(node.kind()) {
1269            return Some((node.start_byte(), node.end_byte()));
1270        }
1271        current = node.parent();
1272    }
1273    Some((node.start_byte(), node.end_byte()))
1274}
1275
1276fn smallest_node_covering_range<'tree>(
1277    node: tree_sitter::Node<'tree>,
1278    byte_start: usize,
1279    byte_end: usize,
1280) -> Option<tree_sitter::Node<'tree>> {
1281    if node.start_byte() > byte_start || node.end_byte() < byte_end {
1282        return None;
1283    }
1284
1285    let mut cursor = node.walk();
1286    if cursor.goto_first_child() {
1287        loop {
1288            let child = cursor.node();
1289            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1290                return Some(found);
1291            }
1292            if !cursor.goto_next_sibling() {
1293                break;
1294            }
1295        }
1296    }
1297
1298    Some(node)
1299}
1300
1301fn is_symbol_body_node(kind: &str) -> bool {
1302    matches!(
1303        kind,
1304        "function_declaration"
1305            | "generator_function_declaration"
1306            | "function_expression"
1307            | "generator_function"
1308            | "arrow_function"
1309            | "method_definition"
1310            | "class_declaration"
1311            | "abstract_class_declaration"
1312            | "class"
1313            | "lexical_declaration"
1314            | "function_definition"
1315            | "class_definition"
1316            | "decorated_definition"
1317            | "function_item"
1318            | "impl_item"
1319            | "method_declaration"
1320    )
1321}
1322
1323fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1324    let mut results = Vec::new();
1325    let call_kinds = crate::calls::call_node_kinds(lang);
1326    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1327    results
1328}
1329
1330fn collect_calls_with_ranges(
1331    node: tree_sitter::Node,
1332    source: &str,
1333    call_kinds: &[&str],
1334    results: &mut Vec<RawCall>,
1335) {
1336    if call_kinds.contains(&node.kind()) {
1337        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1338            results.push(RawCall {
1339                name,
1340                line: node.start_position().row as u32 + 1,
1341                start_byte: node.start_byte(),
1342                end_byte: node.end_byte(),
1343            });
1344        }
1345    }
1346
1347    let mut cursor = node.walk();
1348    if cursor.goto_first_child() {
1349        loop {
1350            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1351            if !cursor.goto_next_sibling() {
1352                break;
1353            }
1354        }
1355    }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360    use super::*;
1361    use crate::config::Config;
1362    use crate::context::AppContext;
1363    use crate::parser::TreeSitterProvider;
1364    use std::path::PathBuf;
1365
1366    fn fixture_path(name: &str) -> PathBuf {
1367        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1368            .join("tests")
1369            .join("fixtures")
1370            .join(name)
1371    }
1372
1373    fn make_ctx() -> AppContext {
1374        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1375    }
1376
1377    #[test]
1378    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1379        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1380        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1381        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1382    }
1383
1384    #[test]
1385    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1386        let params = serde_json::json!({ "symbols": "Getting Started" });
1387        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1388        assert_eq!(names, vec!["Getting Started"]);
1389    }
1390
1391    #[test]
1392    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1393        let params = serde_json::json!({ "symbol": "Last Heading" });
1394        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1395        assert_eq!(names, vec!["Last Heading"]);
1396    }
1397
1398    #[test]
1399    fn parse_zoom_symbol_names_single_token_unchanged() {
1400        let params = serde_json::json!({ "symbol": "compute" });
1401        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1402        assert_eq!(names, vec!["compute"]);
1403    }
1404
1405    #[test]
1406    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1407        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1408        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1409        assert_eq!(names, vec!["A", "B", "C"]);
1410    }
1411
1412    // --- Call extraction tests ---
1413
1414    #[test]
1415    fn extract_calls_finds_direct_calls() {
1416        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1417        let mut parser = FileParser::new();
1418        let path = fixture_path("calls.ts");
1419        let (tree, lang) = parser.parse(&path).unwrap();
1420
1421        // `compute` calls `helper` — find compute's range from symbols
1422        let ctx = make_ctx();
1423        let symbols = ctx.provider().list_symbols(&path).unwrap();
1424        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1425
1426        let byte_start =
1427            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1428        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1429
1430        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1431        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1432
1433        assert!(
1434            names.contains(&"helper"),
1435            "compute should call helper, got: {:?}",
1436            names
1437        );
1438    }
1439
1440    #[test]
1441    fn extract_calls_finds_member_calls() {
1442        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1443        let mut parser = FileParser::new();
1444        let path = fixture_path("calls.ts");
1445        let (tree, lang) = parser.parse(&path).unwrap();
1446
1447        let ctx = make_ctx();
1448        let symbols = ctx.provider().list_symbols(&path).unwrap();
1449        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1450
1451        let byte_start =
1452            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1453        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1454
1455        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1456        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1457
1458        assert!(
1459            names.contains(&"add"),
1460            "runAll should call this.add, got: {:?}",
1461            names
1462        );
1463        assert!(
1464            names.contains(&"helper"),
1465            "runAll should call helper, got: {:?}",
1466            names
1467        );
1468    }
1469
1470    #[test]
1471    fn extract_calls_unused_function_has_no_calls() {
1472        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1473        let mut parser = FileParser::new();
1474        let path = fixture_path("calls.ts");
1475        let (tree, lang) = parser.parse(&path).unwrap();
1476
1477        let ctx = make_ctx();
1478        let symbols = ctx.provider().list_symbols(&path).unwrap();
1479        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1480
1481        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1482        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1483
1484        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1485        // console.log is the only call, but "log" or "console" aren't known symbols
1486        let known_names = [
1487            "helper",
1488            "compute",
1489            "orchestrate",
1490            "unused",
1491            "format",
1492            "display",
1493        ];
1494        let filtered: Vec<&str> = calls
1495            .iter()
1496            .map(|(n, _)| n.as_str())
1497            .filter(|n| known_names.contains(n))
1498            .collect();
1499        assert!(
1500            filtered.is_empty(),
1501            "unused should not call known symbols, got: {:?}",
1502            filtered
1503        );
1504    }
1505
1506    // --- Context line tests ---
1507
1508    #[test]
1509    fn context_lines_clamp_at_file_start() {
1510        // helper() is at the top of the file (line 2) — context_before should be clamped
1511        let ctx = make_ctx();
1512        let path = fixture_path("calls.ts");
1513        let symbols = ctx.provider().list_symbols(&path).unwrap();
1514        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1515
1516        let source = std::fs::read_to_string(&path).unwrap();
1517        let lines: Vec<&str> = source.lines().collect();
1518        let start = helper.range.start_line as usize;
1519
1520        // With context_lines=5, ctx_start should clamp to 0
1521        let ctx_start = start.saturating_sub(5);
1522        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1523        // Should have at most `start` lines (not panic)
1524        assert!(context_before.len() <= start);
1525    }
1526
1527    #[test]
1528    fn context_lines_clamp_at_file_end() {
1529        let ctx = make_ctx();
1530        let path = fixture_path("calls.ts");
1531        let symbols = ctx.provider().list_symbols(&path).unwrap();
1532        let display = symbols.iter().find(|s| s.name == "display").unwrap();
1533
1534        let source = std::fs::read_to_string(&path).unwrap();
1535        let lines: Vec<&str> = source.lines().collect();
1536        let end = display.range.end_line as usize;
1537
1538        // With context_lines=20, should clamp to file length
1539        let ctx_end = (end + 1 + 20).min(lines.len());
1540        let context_after: Vec<&str> = if end + 1 < lines.len() {
1541            lines[(end + 1)..ctx_end].to_vec()
1542        } else {
1543            vec![]
1544        };
1545        // Should not panic regardless of context_lines size
1546        assert!(context_after.len() <= 20);
1547    }
1548
1549    // --- Body extraction test ---
1550
1551    #[test]
1552    fn body_extraction_matches_source() {
1553        let ctx = make_ctx();
1554        let path = fixture_path("calls.ts");
1555        let symbols = ctx.provider().list_symbols(&path).unwrap();
1556        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1557
1558        let source = std::fs::read_to_string(&path).unwrap();
1559        let lines: Vec<&str> = source.lines().collect();
1560        let start = compute.range.start_line as usize;
1561        let end = compute.range.end_line as usize;
1562        let body = lines[start..=end].join("\n");
1563
1564        assert!(
1565            body.contains("function compute"),
1566            "body should contain function declaration"
1567        );
1568        assert!(
1569            body.contains("helper(a)"),
1570            "body should contain call to helper"
1571        );
1572        assert!(
1573            body.contains("doubled + b"),
1574            "body should contain return expression"
1575        );
1576    }
1577
1578    // --- Full zoom response tests ---
1579
1580    #[test]
1581    fn body_range_expands_signature_range_to_include_body_calls() {
1582        let source = r#"function compute(
1583  value: number,
1584): number {
1585  return helper(value);
1586}
1587
1588function helper(value: number): number {
1589  return value * 2;
1590}
1591"#;
1592        let grammar = crate::parser::grammar_for(LangId::TypeScript);
1593        let mut parser = tree_sitter::Parser::new();
1594        parser.set_language(&grammar).unwrap();
1595        let tree = parser.parse(source, None).unwrap();
1596        let signature_end = source.find('{').expect("function has body");
1597
1598        let (body_start, body_end) =
1599            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1600        let calls = extract_calls_in_range(
1601            source,
1602            tree.root_node(),
1603            body_start,
1604            body_end,
1605            LangId::TypeScript,
1606        );
1607        let names = calls
1608            .iter()
1609            .map(|(name, _)| name.as_str())
1610            .collect::<Vec<_>>();
1611
1612        assert!(
1613            names.contains(&"helper"),
1614            "call inside the function body should be included: {names:?}"
1615        );
1616    }
1617
1618    #[test]
1619    fn zoom_leaf_returns_full_body_without_budget_marker() {
1620        let ctx = make_ctx();
1621        let path = fixture_path("calls.ts");
1622        let req = make_zoom_request(
1623            "z-leaf-full",
1624            path.to_str().unwrap(),
1625            "repeatedOutgoing",
1626            None,
1627        );
1628        let resp = handle_zoom(&req, &ctx);
1629        let json = serde_json::to_value(&resp).unwrap();
1630        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1631
1632        let symbols = ctx.provider().list_symbols(&path).unwrap();
1633        let target = symbols
1634            .iter()
1635            .find(|symbol| symbol.name == "repeatedOutgoing")
1636            .unwrap();
1637        let source = std::fs::read_to_string(&path).unwrap();
1638        let lines = source.lines().collect::<Vec<_>>();
1639        let expected =
1640            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1641
1642        assert_eq!(json["content"].as_str().unwrap(), expected);
1643        assert!(
1644            !json["content"]
1645                .as_str()
1646                .unwrap()
1647                .contains("more lines — zoom"),
1648            "explicit zoom must not budget-cap leaf bodies"
1649        );
1650    }
1651
1652    #[test]
1653    fn zoom_response_has_calls_out_and_called_by() {
1654        let ctx = make_ctx();
1655        let path = fixture_path("calls.ts");
1656
1657        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1658        let resp = handle_zoom(&req, &ctx);
1659
1660        let json = serde_json::to_value(&resp).unwrap();
1661        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1662
1663        let calls_out = json["annotations"]["calls_out"]
1664            .as_array()
1665            .expect("calls_out array");
1666        let out_names: Vec<&str> = calls_out
1667            .iter()
1668            .map(|c| c["name"].as_str().unwrap())
1669            .collect();
1670        assert!(
1671            out_names.contains(&"helper"),
1672            "compute calls helper: {:?}",
1673            out_names
1674        );
1675
1676        let called_by = json["annotations"]["called_by"]
1677            .as_array()
1678            .expect("called_by array");
1679        let by_names: Vec<&str> = called_by
1680            .iter()
1681            .map(|c| c["name"].as_str().unwrap())
1682            .collect();
1683        assert!(
1684            by_names.contains(&"orchestrate"),
1685            "orchestrate calls compute: {:?}",
1686            by_names
1687        );
1688    }
1689
1690    #[test]
1691    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1692        let ctx = make_ctx();
1693        let path = fixture_path("calls.ts");
1694
1695        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1696        let resp = handle_zoom(&req, &ctx);
1697        let json = serde_json::to_value(&resp).unwrap();
1698        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1699
1700        let calls_out = json["annotations"]["calls_out"]
1701            .as_array()
1702            .expect("calls_out array");
1703        let helper_refs = calls_out
1704            .iter()
1705            .filter(|call| call["name"] == "helper")
1706            .collect::<Vec<_>>();
1707        assert_eq!(
1708            helper_refs.len(),
1709            1,
1710            "helper should be folded once: {calls_out:?}"
1711        );
1712        assert_eq!(helper_refs[0]["extra_count"], 1);
1713        assert!(
1714            calls_out.iter().any(|call| call["name"] == "format"),
1715            "distinct callee must not be folded into helper: {calls_out:?}"
1716        );
1717
1718        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1719        let resp = handle_zoom(&req, &ctx);
1720        let json = serde_json::to_value(&resp).unwrap();
1721        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1722
1723        let called_by = json["annotations"]["called_by"]
1724            .as_array()
1725            .expect("called_by array");
1726        let repeat_refs = called_by
1727            .iter()
1728            .filter(|call| call["name"] == "repeatCompute")
1729            .collect::<Vec<_>>();
1730        assert_eq!(
1731            repeat_refs.len(),
1732            1,
1733            "repeatCompute should be folded once: {called_by:?}"
1734        );
1735        assert_eq!(repeat_refs[0]["extra_count"], 1);
1736        assert!(
1737            called_by.iter().any(|call| call["name"] == "orchestrate"),
1738            "distinct caller must not be folded into repeatCompute: {called_by:?}"
1739        );
1740    }
1741
1742    #[test]
1743    fn zoom_response_empty_annotations_for_unused() {
1744        let ctx = make_ctx();
1745        let path = fixture_path("calls.ts");
1746
1747        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1748        let resp = handle_zoom(&req, &ctx);
1749
1750        let json = serde_json::to_value(&resp).unwrap();
1751        assert_eq!(json["success"], true);
1752
1753        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1754        let called_by = json["annotations"]["called_by"].as_array().unwrap();
1755
1756        // calls_out exists (may contain console.log but no known symbols)
1757        // called_by should be empty — nobody calls unused
1758        assert!(
1759            called_by.is_empty(),
1760            "unused should not be called by anyone: {:?}",
1761            called_by
1762        );
1763    }
1764
1765    #[test]
1766    fn zoom_default_omits_callgraph_annotations() {
1767        let ctx = make_ctx();
1768        let path = fixture_path("calls.ts");
1769
1770        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1771        let resp = handle_zoom(&req, &ctx);
1772
1773        let json = serde_json::to_value(&resp).unwrap();
1774        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1775
1776        let calls_out = json["annotations"]["calls_out"]
1777            .as_array()
1778            .expect("calls_out array");
1779        let called_by = json["annotations"]["called_by"]
1780            .as_array()
1781            .expect("called_by array");
1782        assert!(
1783            calls_out.is_empty(),
1784            "default zoom should omit calls_out: {:?}",
1785            calls_out
1786        );
1787        assert!(
1788            called_by.is_empty(),
1789            "default zoom should omit called_by: {:?}",
1790            called_by
1791        );
1792    }
1793
1794    #[test]
1795    fn zoom_symbol_not_found() {
1796        let ctx = make_ctx();
1797        let path = fixture_path("calls.ts");
1798
1799        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1800        let resp = handle_zoom(&req, &ctx);
1801
1802        let json = serde_json::to_value(&resp).unwrap();
1803        assert_eq!(json["success"], false);
1804        assert_eq!(json["code"], "symbol_not_found");
1805    }
1806
1807    #[test]
1808    fn zoom_custom_context_lines() {
1809        let ctx = make_ctx();
1810        let path = fixture_path("calls.ts");
1811
1812        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1813        let resp = handle_zoom(&req, &ctx);
1814
1815        let json = serde_json::to_value(&resp).unwrap();
1816        assert_eq!(json["success"], true);
1817
1818        let ctx_before = json["context_before"].as_array().unwrap();
1819        let ctx_after = json["context_after"].as_array().unwrap();
1820        // With context_lines=1, we get at most 1 line before and after
1821        assert!(
1822            ctx_before.len() <= 1,
1823            "context_before should be ≤1: {:?}",
1824            ctx_before
1825        );
1826        assert!(
1827            ctx_after.len() <= 1,
1828            "context_after should be ≤1: {:?}",
1829            ctx_after
1830        );
1831    }
1832
1833    #[test]
1834    fn zoom_missing_file_param() {
1835        let ctx = make_ctx();
1836        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1837        let resp = handle_zoom(&req, &ctx);
1838
1839        let json = serde_json::to_value(&resp).unwrap();
1840        assert_eq!(json["success"], false);
1841        assert_eq!(json["code"], "invalid_request");
1842    }
1843
1844    #[test]
1845    fn zoom_missing_symbol_param() {
1846        let ctx = make_ctx();
1847        let path = fixture_path("calls.ts");
1848        // Build the JSON via serde_json so Windows paths (with backslashes)
1849        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
1850        // backslashes as escape sequences and fail to parse.
1851        let req_value = serde_json::json!({
1852            "id": "z-6",
1853            "command": "zoom",
1854            "file": path.to_string_lossy(),
1855        });
1856        let req_str = req_value.to_string();
1857        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1858        let resp = handle_zoom(&req, &ctx);
1859
1860        let json = serde_json::to_value(&resp).unwrap();
1861        assert_eq!(json["success"], false);
1862        assert_eq!(json["code"], "invalid_request");
1863    }
1864
1865    #[test]
1866    fn test_suggest_close_symbols_unit() {
1867        let available = vec![
1868            "handle_grep_search".to_string(),
1869            "handle_semantic_search".to_string(),
1870            "handle_semantic_or_hybrid_search".to_string(),
1871            "compute_total".to_string(),
1872            "search".to_string(),
1873            "handle_search".to_string(),
1874        ];
1875
1876        let suggestions = suggest_close_symbols("handle_search", &available, 5);
1877        assert!(suggestions.contains(&"handle_grep_search".to_string()));
1878        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1879        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1880        assert!(suggestions.contains(&"search".to_string()));
1881        assert!(!suggestions.contains(&"compute_total".to_string()));
1882
1883        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1884        assert_eq!(suggestions, suggestions_caps);
1885
1886        let available2 = vec![
1887            "total".to_string(),
1888            "compute_total".to_string(),
1889            "unrelated".to_string(),
1890        ];
1891        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1892        assert_eq!(suggestions2, vec!["total".to_string()]);
1893    }
1894
1895    // --- Helpers ---
1896
1897    fn make_zoom_request(
1898        id: &str,
1899        file: &str,
1900        symbol: &str,
1901        context_lines: Option<u64>,
1902    ) -> RawRequest {
1903        let mut json = serde_json::json!({
1904            "id": id,
1905            "command": "zoom",
1906            "file": file,
1907            "symbol": symbol,
1908        });
1909        if let Some(cl) = context_lines {
1910            json["context_lines"] = serde_json::json!(cl);
1911        }
1912        serde_json::from_value(json).unwrap()
1913    }
1914
1915    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1916        let mut req = make_zoom_request(id, file, symbol, None);
1917        req.params["callgraph"] = serde_json::json!(true);
1918        req
1919    }
1920
1921    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1922        serde_json::from_str(json_str).unwrap()
1923    }
1924}