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::{HeadingAnchor, 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    let anchors = if query.starts_with('#') {
956        provider.heading_anchors(path)?
957    } else {
958        Vec::new()
959    };
960
961    Ok(match_heading_identity(&headings, query, &anchors))
962}
963
964fn match_heading_identity(
965    headings: &[SymbolMatch],
966    query: &str,
967    anchors: &[HeadingAnchor],
968) -> Vec<SymbolMatch> {
969    if let Some(anchor_query) = query.strip_prefix('#') {
970        let mut matches: Vec<_> = headings
971            .iter()
972            .filter(|candidate| {
973                anchors.iter().any(|anchor| {
974                    anchor.id == anchor_query
975                        && anchor.start_line == candidate.symbol.range.start_line
976                        && anchor.start_col == candidate.symbol.range.start_col
977                })
978            })
979            .cloned()
980            .collect();
981
982        let normalized_query = normalize_heading_label(query);
983        let query_slug = slugify_heading_label(&normalized_query);
984        if !query_slug.is_empty() {
985            for candidate in headings
986                .iter()
987                .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
988            {
989                if !matches.iter().any(|existing| {
990                    existing.file == candidate.file && existing.symbol == candidate.symbol
991                }) {
992                    matches.push(candidate.clone());
993                }
994            }
995        }
996        return matches;
997    }
998
999    let exact: Vec<_> = headings
1000        .iter()
1001        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1002        .cloned()
1003        .collect();
1004    if !exact.is_empty() {
1005        return exact;
1006    }
1007
1008    let normalized_query = normalize_heading_label(query);
1009    if normalized_query.is_empty() {
1010        return Vec::new();
1011    }
1012
1013    let normalized: Vec<_> = headings
1014        .iter()
1015        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1016        .cloned()
1017        .collect();
1018    if !normalized.is_empty() {
1019        return normalized;
1020    }
1021
1022    let folded_query = normalized_query.to_lowercase();
1023    let case_insensitive: Vec<_> = headings
1024        .iter()
1025        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1026        .cloned()
1027        .collect();
1028    if !case_insensitive.is_empty() {
1029        return case_insensitive;
1030    }
1031
1032    let query_slug = slugify_heading_label(&normalized_query);
1033    if query_slug.is_empty() {
1034        return Vec::new();
1035    }
1036
1037    headings
1038        .iter()
1039        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1040        .cloned()
1041        .collect()
1042}
1043
1044fn qualified_heading_name(symbol: &Symbol) -> String {
1045    if symbol.scope_chain.is_empty() {
1046        return symbol.name.clone();
1047    }
1048    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1049}
1050
1051fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1052    symbol.name == query || qualified_heading_name(symbol) == query
1053}
1054
1055fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1056    normalize_heading_label(&symbol.name) == query
1057        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1058}
1059
1060fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1061    normalize_heading_label(&symbol.name).to_lowercase() == query
1062        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1063}
1064
1065fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1066    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1067        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1068            == query_slug
1069}
1070
1071fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1072    let available: Vec<String> = symbols
1073        .iter()
1074        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1075        .map(|symbol| normalize_heading_label(&symbol.name))
1076        .filter(|name| !name.is_empty())
1077        .collect();
1078    let normalized_query = normalize_heading_label(query);
1079    if normalized_query.is_empty() {
1080        return Vec::new();
1081    }
1082    suggest_close_symbols(&normalized_query, &available, k)
1083}
1084
1085fn normalize_heading_label(input: &str) -> String {
1086    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1087
1088    // A label can contain both a document prefix and a decorative symbol cluster.
1089    // Repeat the small cleanup sequence so either order is handled consistently.
1090    for _ in 0..4 {
1091        let mut next = value.as_str();
1092        let without_heading_markers = next.trim_start_matches('#').trim_start();
1093        if without_heading_markers != next {
1094            next = without_heading_markers;
1095        }
1096        if let Some(rest) = strip_heading_html_prefix(next) {
1097            next = rest;
1098        }
1099        if let Some(rest) = strip_leading_section_prefix(next) {
1100            next = rest;
1101        }
1102        if let Some(rest) = strip_leading_symbol_cluster(next) {
1103            next = rest;
1104        }
1105
1106        let collapsed = collapse_heading_whitespace(next);
1107        if collapsed == value {
1108            break;
1109        }
1110        value = collapsed;
1111    }
1112
1113    value
1114}
1115
1116fn collapse_heading_whitespace(input: &str) -> String {
1117    input.split_whitespace().collect::<Vec<_>>().join(" ")
1118}
1119
1120fn strip_markdown_links(input: &str) -> String {
1121    let mut output = String::with_capacity(input.len());
1122    let mut cursor = 0;
1123
1124    while let Some(relative_open) = input[cursor..].find('[') {
1125        let open = cursor + relative_open;
1126        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1127            output.push_str(&input[cursor..]);
1128            return output;
1129        };
1130
1131        if input.as_bytes().get(close + 1) != Some(&b'(') {
1132            output.push_str(&input[cursor..=close]);
1133            cursor = close + 1;
1134            continue;
1135        }
1136
1137        let target_open = close + 1;
1138        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1139            output.push_str(&input[cursor..]);
1140            return output;
1141        };
1142
1143        output.push_str(&input[cursor..open]);
1144        output.push_str(&input[open + 1..close]);
1145        cursor = target_close + 1;
1146    }
1147
1148    output.push_str(&input[cursor..]);
1149    output
1150}
1151
1152fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1153    let mut depth = 0;
1154    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1155        if *byte == open {
1156            depth += 1;
1157        } else if *byte == close {
1158            depth -= 1;
1159            if depth == 0 {
1160                return Some(index);
1161            }
1162        }
1163    }
1164    None
1165}
1166
1167fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1168    let input = input.trim_start();
1169    let bytes = input.as_bytes();
1170    if bytes.first() != Some(&b'<') {
1171        return None;
1172    }
1173
1174    let mut index = 1;
1175    if bytes.get(index) == Some(&b'/') {
1176        index += 1;
1177    }
1178    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1179        return None;
1180    }
1181    index += 1;
1182    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1183        return None;
1184    }
1185
1186    let end = input.find('>')?;
1187    let rest = input[end + 1..].trim_start();
1188    if rest.chars().any(|character| character.is_alphanumeric()) {
1189        Some(rest)
1190    } else {
1191        None
1192    }
1193}
1194
1195fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1196    let bytes = input.as_bytes();
1197    let mut index = 0;
1198    let mut saw_dot = false;
1199
1200    if !bytes
1201        .first()
1202        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1203    {
1204        return None;
1205    }
1206
1207    while index < bytes.len() {
1208        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1209            index += 1;
1210        }
1211        if bytes.get(index) != Some(&b'.') {
1212            break;
1213        }
1214        saw_dot = true;
1215        index += 1;
1216        if bytes
1217            .get(index)
1218            .is_some_and(|byte| byte.is_ascii_whitespace())
1219        {
1220            let rest = input[index..].trim_start();
1221            return if rest.chars().any(|character| character.is_alphanumeric()) {
1222                Some(rest)
1223            } else {
1224                None
1225            };
1226        }
1227        if !bytes
1228            .get(index)
1229            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1230        {
1231            return None;
1232        }
1233    }
1234
1235    if saw_dot
1236        && bytes
1237            .get(index)
1238            .is_some_and(|byte| byte.is_ascii_whitespace())
1239    {
1240        let rest = input[index..].trim_start();
1241        if rest.chars().any(|character| character.is_alphanumeric()) {
1242            return Some(rest);
1243        }
1244    }
1245    None
1246}
1247
1248fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1249    let first_text = input
1250        .char_indices()
1251        .find(|(_, character)| character.is_alphanumeric())
1252        .map(|(index, _)| index)?;
1253    if first_text == 0 {
1254        return None;
1255    }
1256
1257    let rest = &input[first_text..];
1258    if rest.chars().any(|character| character.is_alphanumeric()) {
1259        Some(rest)
1260    } else {
1261        None
1262    }
1263}
1264
1265fn slugify_heading_label(label: &str) -> String {
1266    let mut slug = String::new();
1267    let mut pending_separator = false;
1268
1269    for character in label.chars() {
1270        if character.is_alphanumeric() {
1271            if pending_separator && !slug.is_empty() {
1272                slug.push('-');
1273            }
1274            for lowercase in character.to_lowercase() {
1275                slug.push(lowercase);
1276            }
1277            pending_separator = false;
1278        } else if !slug.is_empty() {
1279            pending_separator = true;
1280        }
1281    }
1282
1283    slug
1284}
1285
1286/// Extract call expression names within a byte range of the AST.
1287///
1288/// Delegates to `crate::calls::extract_calls_in_range`.
1289#[cfg(test)]
1290fn extract_calls_in_range(
1291    source: &str,
1292    root: tree_sitter::Node,
1293    byte_start: usize,
1294    byte_end: usize,
1295    lang: LangId,
1296) -> Vec<(String, u32)> {
1297    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1298}
1299
1300fn symbol_body_byte_range(
1301    root: tree_sitter::Node,
1302    byte_start: usize,
1303    byte_end: usize,
1304) -> Option<(usize, usize)> {
1305    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1306    let mut current = Some(node);
1307    while let Some(node) = current {
1308        if is_symbol_body_node(node.kind()) {
1309            return Some((node.start_byte(), node.end_byte()));
1310        }
1311        current = node.parent();
1312    }
1313    Some((node.start_byte(), node.end_byte()))
1314}
1315
1316fn smallest_node_covering_range<'tree>(
1317    node: tree_sitter::Node<'tree>,
1318    byte_start: usize,
1319    byte_end: usize,
1320) -> Option<tree_sitter::Node<'tree>> {
1321    if node.start_byte() > byte_start || node.end_byte() < byte_end {
1322        return None;
1323    }
1324
1325    let mut cursor = node.walk();
1326    if cursor.goto_first_child() {
1327        loop {
1328            let child = cursor.node();
1329            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1330                return Some(found);
1331            }
1332            if !cursor.goto_next_sibling() {
1333                break;
1334            }
1335        }
1336    }
1337
1338    Some(node)
1339}
1340
1341fn is_symbol_body_node(kind: &str) -> bool {
1342    matches!(
1343        kind,
1344        "function_declaration"
1345            | "generator_function_declaration"
1346            | "function_expression"
1347            | "generator_function"
1348            | "arrow_function"
1349            | "method_definition"
1350            | "class_declaration"
1351            | "abstract_class_declaration"
1352            | "class"
1353            | "lexical_declaration"
1354            | "function_definition"
1355            | "class_definition"
1356            | "decorated_definition"
1357            | "function_item"
1358            | "impl_item"
1359            | "method_declaration"
1360    )
1361}
1362
1363fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1364    let mut results = Vec::new();
1365    let call_kinds = crate::calls::call_node_kinds(lang);
1366    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1367    results
1368}
1369
1370fn collect_calls_with_ranges(
1371    node: tree_sitter::Node,
1372    source: &str,
1373    call_kinds: &[&str],
1374    results: &mut Vec<RawCall>,
1375) {
1376    if call_kinds.contains(&node.kind()) {
1377        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1378            results.push(RawCall {
1379                name,
1380                line: node.start_position().row as u32 + 1,
1381                start_byte: node.start_byte(),
1382                end_byte: node.end_byte(),
1383            });
1384        }
1385    }
1386
1387    let mut cursor = node.walk();
1388    if cursor.goto_first_child() {
1389        loop {
1390            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1391            if !cursor.goto_next_sibling() {
1392                break;
1393            }
1394        }
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401    use crate::config::Config;
1402    use crate::context::AppContext;
1403    use crate::parser::TreeSitterProvider;
1404    use std::path::PathBuf;
1405
1406    fn fixture_path(name: &str) -> PathBuf {
1407        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1408            .join("tests")
1409            .join("fixtures")
1410            .join(name)
1411    }
1412
1413    fn make_ctx() -> AppContext {
1414        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1415    }
1416
1417    #[test]
1418    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1419        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1420        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1421        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1422    }
1423
1424    #[test]
1425    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1426        let params = serde_json::json!({ "symbols": "Getting Started" });
1427        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1428        assert_eq!(names, vec!["Getting Started"]);
1429    }
1430
1431    #[test]
1432    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1433        let params = serde_json::json!({ "symbol": "Last Heading" });
1434        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1435        assert_eq!(names, vec!["Last Heading"]);
1436    }
1437
1438    #[test]
1439    fn parse_zoom_symbol_names_single_token_unchanged() {
1440        let params = serde_json::json!({ "symbol": "compute" });
1441        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1442        assert_eq!(names, vec!["compute"]);
1443    }
1444
1445    #[test]
1446    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1447        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1448        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1449        assert_eq!(names, vec!["A", "B", "C"]);
1450    }
1451
1452    // --- Call extraction tests ---
1453
1454    #[test]
1455    fn extract_calls_finds_direct_calls() {
1456        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1457        let mut parser = FileParser::new();
1458        let path = fixture_path("calls.ts");
1459        let (tree, lang) = parser.parse(&path).unwrap();
1460
1461        // `compute` calls `helper` — find compute's range from symbols
1462        let ctx = make_ctx();
1463        let symbols = ctx.provider().list_symbols(&path).unwrap();
1464        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1465
1466        let byte_start =
1467            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1468        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1469
1470        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1471        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1472
1473        assert!(
1474            names.contains(&"helper"),
1475            "compute should call helper, got: {:?}",
1476            names
1477        );
1478    }
1479
1480    #[test]
1481    fn extract_calls_finds_member_calls() {
1482        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1483        let mut parser = FileParser::new();
1484        let path = fixture_path("calls.ts");
1485        let (tree, lang) = parser.parse(&path).unwrap();
1486
1487        let ctx = make_ctx();
1488        let symbols = ctx.provider().list_symbols(&path).unwrap();
1489        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1490
1491        let byte_start =
1492            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1493        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1494
1495        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1496        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1497
1498        assert!(
1499            names.contains(&"add"),
1500            "runAll should call this.add, got: {:?}",
1501            names
1502        );
1503        assert!(
1504            names.contains(&"helper"),
1505            "runAll should call helper, got: {:?}",
1506            names
1507        );
1508    }
1509
1510    #[test]
1511    fn extract_calls_unused_function_has_no_calls() {
1512        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1513        let mut parser = FileParser::new();
1514        let path = fixture_path("calls.ts");
1515        let (tree, lang) = parser.parse(&path).unwrap();
1516
1517        let ctx = make_ctx();
1518        let symbols = ctx.provider().list_symbols(&path).unwrap();
1519        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1520
1521        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1522        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1523
1524        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1525        // console.log is the only call, but "log" or "console" aren't known symbols
1526        let known_names = [
1527            "helper",
1528            "compute",
1529            "orchestrate",
1530            "unused",
1531            "format",
1532            "display",
1533        ];
1534        let filtered: Vec<&str> = calls
1535            .iter()
1536            .map(|(n, _)| n.as_str())
1537            .filter(|n| known_names.contains(n))
1538            .collect();
1539        assert!(
1540            filtered.is_empty(),
1541            "unused should not call known symbols, got: {:?}",
1542            filtered
1543        );
1544    }
1545
1546    // --- Context line tests ---
1547
1548    #[test]
1549    fn context_lines_clamp_at_file_start() {
1550        // helper() is at the top of the file (line 2) — context_before should be clamped
1551        let ctx = make_ctx();
1552        let path = fixture_path("calls.ts");
1553        let symbols = ctx.provider().list_symbols(&path).unwrap();
1554        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1555
1556        let source = std::fs::read_to_string(&path).unwrap();
1557        let lines: Vec<&str> = source.lines().collect();
1558        let start = helper.range.start_line as usize;
1559
1560        // With context_lines=5, ctx_start should clamp to 0
1561        let ctx_start = start.saturating_sub(5);
1562        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1563        // Should have at most `start` lines (not panic)
1564        assert!(context_before.len() <= start);
1565    }
1566
1567    #[test]
1568    fn context_lines_clamp_at_file_end() {
1569        let ctx = make_ctx();
1570        let path = fixture_path("calls.ts");
1571        let symbols = ctx.provider().list_symbols(&path).unwrap();
1572        let display = symbols.iter().find(|s| s.name == "display").unwrap();
1573
1574        let source = std::fs::read_to_string(&path).unwrap();
1575        let lines: Vec<&str> = source.lines().collect();
1576        let end = display.range.end_line as usize;
1577
1578        // With context_lines=20, should clamp to file length
1579        let ctx_end = (end + 1 + 20).min(lines.len());
1580        let context_after: Vec<&str> = if end + 1 < lines.len() {
1581            lines[(end + 1)..ctx_end].to_vec()
1582        } else {
1583            vec![]
1584        };
1585        // Should not panic regardless of context_lines size
1586        assert!(context_after.len() <= 20);
1587    }
1588
1589    // --- Body extraction test ---
1590
1591    #[test]
1592    fn body_extraction_matches_source() {
1593        let ctx = make_ctx();
1594        let path = fixture_path("calls.ts");
1595        let symbols = ctx.provider().list_symbols(&path).unwrap();
1596        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1597
1598        let source = std::fs::read_to_string(&path).unwrap();
1599        let lines: Vec<&str> = source.lines().collect();
1600        let start = compute.range.start_line as usize;
1601        let end = compute.range.end_line as usize;
1602        let body = lines[start..=end].join("\n");
1603
1604        assert!(
1605            body.contains("function compute"),
1606            "body should contain function declaration"
1607        );
1608        assert!(
1609            body.contains("helper(a)"),
1610            "body should contain call to helper"
1611        );
1612        assert!(
1613            body.contains("doubled + b"),
1614            "body should contain return expression"
1615        );
1616    }
1617
1618    // --- Full zoom response tests ---
1619
1620    #[test]
1621    fn body_range_expands_signature_range_to_include_body_calls() {
1622        let source = r#"function compute(
1623  value: number,
1624): number {
1625  return helper(value);
1626}
1627
1628function helper(value: number): number {
1629  return value * 2;
1630}
1631"#;
1632        let grammar = crate::parser::grammar_for(LangId::TypeScript);
1633        let mut parser = tree_sitter::Parser::new();
1634        parser.set_language(&grammar).unwrap();
1635        let tree = parser.parse(source, None).unwrap();
1636        let signature_end = source.find('{').expect("function has body");
1637
1638        let (body_start, body_end) =
1639            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1640        let calls = extract_calls_in_range(
1641            source,
1642            tree.root_node(),
1643            body_start,
1644            body_end,
1645            LangId::TypeScript,
1646        );
1647        let names = calls
1648            .iter()
1649            .map(|(name, _)| name.as_str())
1650            .collect::<Vec<_>>();
1651
1652        assert!(
1653            names.contains(&"helper"),
1654            "call inside the function body should be included: {names:?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn zoom_leaf_returns_full_body_without_budget_marker() {
1660        let ctx = make_ctx();
1661        let path = fixture_path("calls.ts");
1662        let req = make_zoom_request(
1663            "z-leaf-full",
1664            path.to_str().unwrap(),
1665            "repeatedOutgoing",
1666            None,
1667        );
1668        let resp = handle_zoom(&req, &ctx);
1669        let json = serde_json::to_value(&resp).unwrap();
1670        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1671
1672        let symbols = ctx.provider().list_symbols(&path).unwrap();
1673        let target = symbols
1674            .iter()
1675            .find(|symbol| symbol.name == "repeatedOutgoing")
1676            .unwrap();
1677        let source = std::fs::read_to_string(&path).unwrap();
1678        let lines = source.lines().collect::<Vec<_>>();
1679        let expected =
1680            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1681
1682        assert_eq!(json["content"].as_str().unwrap(), expected);
1683        assert!(
1684            !json["content"]
1685                .as_str()
1686                .unwrap()
1687                .contains("more lines — zoom"),
1688            "explicit zoom must not budget-cap leaf bodies"
1689        );
1690    }
1691
1692    #[test]
1693    fn zoom_response_has_calls_out_and_called_by() {
1694        let ctx = make_ctx();
1695        let path = fixture_path("calls.ts");
1696
1697        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1698        let resp = handle_zoom(&req, &ctx);
1699
1700        let json = serde_json::to_value(&resp).unwrap();
1701        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1702
1703        let calls_out = json["annotations"]["calls_out"]
1704            .as_array()
1705            .expect("calls_out array");
1706        let out_names: Vec<&str> = calls_out
1707            .iter()
1708            .map(|c| c["name"].as_str().unwrap())
1709            .collect();
1710        assert!(
1711            out_names.contains(&"helper"),
1712            "compute calls helper: {:?}",
1713            out_names
1714        );
1715
1716        let called_by = json["annotations"]["called_by"]
1717            .as_array()
1718            .expect("called_by array");
1719        let by_names: Vec<&str> = called_by
1720            .iter()
1721            .map(|c| c["name"].as_str().unwrap())
1722            .collect();
1723        assert!(
1724            by_names.contains(&"orchestrate"),
1725            "orchestrate calls compute: {:?}",
1726            by_names
1727        );
1728    }
1729
1730    #[test]
1731    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1732        let ctx = make_ctx();
1733        let path = fixture_path("calls.ts");
1734
1735        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1736        let resp = handle_zoom(&req, &ctx);
1737        let json = serde_json::to_value(&resp).unwrap();
1738        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1739
1740        let calls_out = json["annotations"]["calls_out"]
1741            .as_array()
1742            .expect("calls_out array");
1743        let helper_refs = calls_out
1744            .iter()
1745            .filter(|call| call["name"] == "helper")
1746            .collect::<Vec<_>>();
1747        assert_eq!(
1748            helper_refs.len(),
1749            1,
1750            "helper should be folded once: {calls_out:?}"
1751        );
1752        assert_eq!(helper_refs[0]["extra_count"], 1);
1753        assert!(
1754            calls_out.iter().any(|call| call["name"] == "format"),
1755            "distinct callee must not be folded into helper: {calls_out:?}"
1756        );
1757
1758        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1759        let resp = handle_zoom(&req, &ctx);
1760        let json = serde_json::to_value(&resp).unwrap();
1761        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1762
1763        let called_by = json["annotations"]["called_by"]
1764            .as_array()
1765            .expect("called_by array");
1766        let repeat_refs = called_by
1767            .iter()
1768            .filter(|call| call["name"] == "repeatCompute")
1769            .collect::<Vec<_>>();
1770        assert_eq!(
1771            repeat_refs.len(),
1772            1,
1773            "repeatCompute should be folded once: {called_by:?}"
1774        );
1775        assert_eq!(repeat_refs[0]["extra_count"], 1);
1776        assert!(
1777            called_by.iter().any(|call| call["name"] == "orchestrate"),
1778            "distinct caller must not be folded into repeatCompute: {called_by:?}"
1779        );
1780    }
1781
1782    #[test]
1783    fn zoom_response_empty_annotations_for_unused() {
1784        let ctx = make_ctx();
1785        let path = fixture_path("calls.ts");
1786
1787        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1788        let resp = handle_zoom(&req, &ctx);
1789
1790        let json = serde_json::to_value(&resp).unwrap();
1791        assert_eq!(json["success"], true);
1792
1793        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1794        let called_by = json["annotations"]["called_by"].as_array().unwrap();
1795
1796        // calls_out exists (may contain console.log but no known symbols)
1797        // called_by should be empty — nobody calls unused
1798        assert!(
1799            called_by.is_empty(),
1800            "unused should not be called by anyone: {:?}",
1801            called_by
1802        );
1803    }
1804
1805    #[test]
1806    fn zoom_default_omits_callgraph_annotations() {
1807        let ctx = make_ctx();
1808        let path = fixture_path("calls.ts");
1809
1810        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1811        let resp = handle_zoom(&req, &ctx);
1812
1813        let json = serde_json::to_value(&resp).unwrap();
1814        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1815
1816        let calls_out = json["annotations"]["calls_out"]
1817            .as_array()
1818            .expect("calls_out array");
1819        let called_by = json["annotations"]["called_by"]
1820            .as_array()
1821            .expect("called_by array");
1822        assert!(
1823            calls_out.is_empty(),
1824            "default zoom should omit calls_out: {:?}",
1825            calls_out
1826        );
1827        assert!(
1828            called_by.is_empty(),
1829            "default zoom should omit called_by: {:?}",
1830            called_by
1831        );
1832    }
1833
1834    #[test]
1835    fn zoom_symbol_not_found() {
1836        let ctx = make_ctx();
1837        let path = fixture_path("calls.ts");
1838
1839        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1840        let resp = handle_zoom(&req, &ctx);
1841
1842        let json = serde_json::to_value(&resp).unwrap();
1843        assert_eq!(json["success"], false);
1844        assert_eq!(json["code"], "symbol_not_found");
1845    }
1846
1847    #[test]
1848    fn zoom_custom_context_lines() {
1849        let ctx = make_ctx();
1850        let path = fixture_path("calls.ts");
1851
1852        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1853        let resp = handle_zoom(&req, &ctx);
1854
1855        let json = serde_json::to_value(&resp).unwrap();
1856        assert_eq!(json["success"], true);
1857
1858        let ctx_before = json["context_before"].as_array().unwrap();
1859        let ctx_after = json["context_after"].as_array().unwrap();
1860        // With context_lines=1, we get at most 1 line before and after
1861        assert!(
1862            ctx_before.len() <= 1,
1863            "context_before should be ≤1: {:?}",
1864            ctx_before
1865        );
1866        assert!(
1867            ctx_after.len() <= 1,
1868            "context_after should be ≤1: {:?}",
1869            ctx_after
1870        );
1871    }
1872
1873    #[test]
1874    fn zoom_missing_file_param() {
1875        let ctx = make_ctx();
1876        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1877        let resp = handle_zoom(&req, &ctx);
1878
1879        let json = serde_json::to_value(&resp).unwrap();
1880        assert_eq!(json["success"], false);
1881        assert_eq!(json["code"], "invalid_request");
1882    }
1883
1884    #[test]
1885    fn zoom_missing_symbol_param() {
1886        let ctx = make_ctx();
1887        let path = fixture_path("calls.ts");
1888        // Build the JSON via serde_json so Windows paths (with backslashes)
1889        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
1890        // backslashes as escape sequences and fail to parse.
1891        let req_value = serde_json::json!({
1892            "id": "z-6",
1893            "command": "zoom",
1894            "file": path.to_string_lossy(),
1895        });
1896        let req_str = req_value.to_string();
1897        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1898        let resp = handle_zoom(&req, &ctx);
1899
1900        let json = serde_json::to_value(&resp).unwrap();
1901        assert_eq!(json["success"], false);
1902        assert_eq!(json["code"], "invalid_request");
1903    }
1904
1905    #[test]
1906    fn test_suggest_close_symbols_unit() {
1907        let available = vec![
1908            "handle_grep_search".to_string(),
1909            "handle_semantic_search".to_string(),
1910            "handle_semantic_or_hybrid_search".to_string(),
1911            "compute_total".to_string(),
1912            "search".to_string(),
1913            "handle_search".to_string(),
1914        ];
1915
1916        let suggestions = suggest_close_symbols("handle_search", &available, 5);
1917        assert!(suggestions.contains(&"handle_grep_search".to_string()));
1918        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1919        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1920        assert!(suggestions.contains(&"search".to_string()));
1921        assert!(!suggestions.contains(&"compute_total".to_string()));
1922
1923        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1924        assert_eq!(suggestions, suggestions_caps);
1925
1926        let available2 = vec![
1927            "total".to_string(),
1928            "compute_total".to_string(),
1929            "unrelated".to_string(),
1930        ];
1931        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1932        assert_eq!(suggestions2, vec!["total".to_string()]);
1933    }
1934
1935    // --- Helpers ---
1936
1937    fn make_zoom_request(
1938        id: &str,
1939        file: &str,
1940        symbol: &str,
1941        context_lines: Option<u64>,
1942    ) -> RawRequest {
1943        let mut json = serde_json::json!({
1944            "id": id,
1945            "command": "zoom",
1946            "file": file,
1947            "symbol": symbol,
1948        });
1949        if let Some(cl) = context_lines {
1950            json["context_lines"] = serde_json::json!(cl);
1951        }
1952        serde_json::from_value(json).unwrap()
1953    }
1954
1955    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1956        let mut req = make_zoom_request(id, file, symbol, None);
1957        req.params["callgraph"] = serde_json::json!(true);
1958        req
1959    }
1960
1961    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1962        serde_json::from_str(json_str).unwrap()
1963    }
1964}