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/// Parse a JSON-stringified array of symbol names (`"[\"A\", \"B\"]"`).
455///
456/// Returns `None` unless the string is a well-formed JSON array whose elements
457/// are all strings, so ordinary symbol text (including bracketed code like
458/// `[derive]` or heading text starting with `[`) is never misparsed.
459fn parse_stringified_symbol_array(raw: &str) -> Option<Vec<String>> {
460    let trimmed = raw.trim();
461    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
462        return None;
463    }
464    let values: Vec<serde_json::Value> = serde_json::from_str(trimmed).ok()?;
465    let mut names = Vec::with_capacity(values.len());
466    for value in values {
467        let name = value.as_str()?.trim();
468        if !name.is_empty() {
469            names.push(name.to_string());
470        }
471    }
472    Some(names)
473}
474
475/// Normalize `symbol` / `symbols` into one or more lookup names.
476///
477/// For code files, a single string containing internal whitespace is split on `\s+`.
478/// Markdown/HTML headings keep the full string (headings may contain spaces).
479fn parse_zoom_symbol_names(
480    params: &serde_json::Value,
481    lang: Option<LangId>,
482) -> Result<Vec<String>, Response> {
483    if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
484        let names: Vec<String> = arr
485            .iter()
486            .filter_map(|v| v.as_str().map(str::trim))
487            .filter(|s| !s.is_empty())
488            .map(str::to_string)
489            .collect();
490        return Ok(names);
491    }
492
493    let Some(raw) = zoom_symbol_param(params) else {
494        return Ok(Vec::new());
495    };
496
497    // Some models JSON-stringify the array form ("[\"A\", \"B\"]"). Absorb it
498    // instead of treating the serialized text as one symbol name; without this
499    // the lookup fails with a not-found whose suggestions are the very names
500    // the caller sent.
501    if let Some(names) = parse_stringified_symbol_array(raw) {
502        return Ok(names);
503    }
504
505    if is_heading_zoom_language(lang) {
506        let trimmed = raw.trim();
507        if trimmed.is_empty() {
508            return Ok(Vec::new());
509        }
510        return Ok(vec![trimmed.to_string()]);
511    }
512
513    if raw.split_whitespace().count() <= 1 {
514        let trimmed = raw.trim();
515        if trimmed.is_empty() {
516            return Ok(Vec::new());
517        }
518        return Ok(vec![trimmed.to_string()]);
519    }
520
521    Ok(raw.split_whitespace().map(str::to_string).collect())
522}
523
524fn zoom_batch_symbols(
525    req: &RawRequest,
526    ctx: &AppContext,
527    path: &Path,
528    file: &str,
529    source: &str,
530    lines: &[String],
531    symbol_names: &[String],
532    context_lines: usize,
533    include_callgraph: bool,
534) -> Response {
535    let mut entries = Vec::with_capacity(symbol_names.len());
536    let mut all_ok = true;
537
538    for name in symbol_names {
539        let resp = zoom_one_symbol(
540            req,
541            ctx,
542            path,
543            file,
544            source,
545            lines,
546            name,
547            context_lines,
548            include_callgraph,
549        );
550        let json = match serde_json::to_value(&resp) {
551            Ok(v) => v,
552            Err(err) => {
553                return Response::error(
554                    &req.id,
555                    "internal_error",
556                    format!("zoom: failed to serialize batch entry: {err}"),
557                );
558            }
559        };
560        if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
561            all_ok = false;
562        }
563        entries.push(serde_json::json!({
564            "name": name,
565            "response": json,
566        }));
567    }
568
569    Response::success(
570        &req.id,
571        serde_json::json!({
572            "complete": all_ok,
573            "symbols": entries,
574        }),
575    )
576}
577
578fn zoom_one_symbol(
579    req: &RawRequest,
580    ctx: &AppContext,
581    path: &Path,
582    _file: &str,
583    source: &str,
584    lines: &[String],
585    symbol_name: &str,
586    context_lines: usize,
587    include_callgraph: bool,
588) -> Response {
589    // Keep raw heading labels for outline display. Zoom resolves heading names in tiers:
590    // exact raw text, normalized text, case-insensitive normalized text, then anchor slugs.
591    // Code symbols continue through the provider's exact resolver.
592    let is_heading = is_heading_zoom_language(detect_language(path));
593    let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
594        Ok(matches) => matches,
595        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
596    };
597
598    // LSP-enhanced disambiguation (S03)
599    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
600        lsp_hints::apply_lsp_disambiguation(matches, &hints)
601    } else {
602        matches
603    };
604
605    if matches.len() > 1 {
606        let content = render_ambiguous_symbol_menu(symbol_name, &matches);
607        let candidates = matches
608            .iter()
609            .map(|candidate| {
610                let sym = &candidate.symbol;
611                serde_json::json!({
612                    "name": sym.name.clone(),
613                    "qualified_name": qualified_symbol_name(sym),
614                    "kind": symbol_kind_string(&sym.kind),
615                    "range": sym.range.clone(),
616                    "signature": sym.signature.clone(),
617                })
618            })
619            .collect::<Vec<_>>();
620
621        return Response::success(
622            &req.id,
623            serde_json::json!({
624                "name": symbol_name,
625                "kind": "ambiguous_symbol",
626                "content": content,
627                "context_before": [],
628                "context_after": [],
629                "annotations": empty_annotations(),
630                "candidates": candidates,
631            }),
632        );
633    }
634
635    if matches.is_empty() {
636        let mut msg = format!("symbol '{}' not found", symbol_name);
637        if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
638            let suggestions = if is_heading {
639                suggest_heading_symbols(symbol_name, &all_symbols, 5)
640            } else {
641                let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
642                suggest_close_symbols(symbol_name, &available, 5)
643            };
644            if !suggestions.is_empty() {
645                msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
646            }
647        }
648        return Response::error(&req.id, "symbol_not_found", msg);
649    }
650
651    let target = &matches[0].symbol;
652    let start = target.range.start_line as usize;
653    let end = target.range.end_line as usize;
654
655    // When re-export following resolved to a different file, re-read that file's lines
656    let resolved_file_path = std::path::Path::new(&matches[0].file);
657    let resolved_lines: Vec<String>;
658    let effective_lines: &[String] = if resolved_file_path != path {
659        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
660            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
661            Err(_) => lines.to_vec(),
662        };
663        &resolved_lines
664    } else {
665        lines
666    };
667
668    // Extract symbol body (0-based line indices)
669    let content = if end < effective_lines.len() {
670        effective_lines[start..=end].join("\n")
671    } else {
672        effective_lines[start..].join("\n")
673    };
674
675    let resolved_lang = detect_language(resolved_file_path);
676    let container_outline = if might_have_container_members(target) {
677        match build_container_outline(ctx, resolved_file_path, target) {
678            Ok(outline) => Some(outline),
679            Err(e) => {
680                return Response::error(&req.id, e.code(), e.to_string());
681            }
682        }
683    } else {
684        None
685    };
686
687    if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
688        let kind_str = symbol_kind_string(&target.kind);
689        let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
690        let resp = ZoomResponse {
691            name: target.name.clone(),
692            kind: kind_str,
693            range: target.range.clone(),
694            content: menu,
695            context_before: Vec::new(),
696            context_after: Vec::new(),
697            annotations: Annotations {
698                calls_out: Vec::new(),
699                called_by: Vec::new(),
700            },
701        };
702        return match serde_json::to_value(&resp) {
703            Ok(resp_json) => Response::success(&req.id, resp_json),
704            Err(err) => Response::error(
705                &req.id,
706                "internal_error",
707                format!("zoom: failed to serialize response: {err}"),
708            ),
709        };
710    }
711
712    // Context before
713    let ctx_start = start.saturating_sub(context_lines);
714    let context_before: Vec<String> = if ctx_start < start {
715        effective_lines[ctx_start..start]
716            .iter()
717            .map(|l| l.to_string())
718            .collect()
719    } else {
720        vec![]
721    };
722
723    // Context after
724    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
725    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
726        effective_lines[(end + 1)..ctx_end]
727            .iter()
728            .map(|l| l.to_string())
729            .collect()
730    } else {
731        vec![]
732    };
733
734    let (calls_out, called_by) = if include_callgraph {
735        // Get all symbols in the resolved file for call matching
736        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
737            Ok(s) => s,
738            Err(e) => {
739                return Response::error(&req.id, e.code(), e.to_string());
740            }
741        };
742
743        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
744
745        // Parse AST for call extraction (use resolved file for cross-file re-exports)
746        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
747        let (tree, lang) = match parser.parse(resolved_file_path) {
748            Ok(r) => r,
749            Err(e) => {
750                return Response::error(&req.id, e.code(), e.to_string());
751            }
752        };
753
754        // calls_out: calls within the target symbol's byte range
755        let resolved_source = if resolved_file_path != path {
756            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
757        } else {
758            source.to_string()
759        };
760        let signature_byte_start = line_col_to_byte(
761            &resolved_source,
762            target.range.start_line,
763            target.range.start_col,
764        );
765        let signature_byte_end = line_col_to_byte(
766            &resolved_source,
767            target.range.end_line,
768            target.range.end_col,
769        );
770        let (target_byte_start, target_byte_end) =
771            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
772                .unwrap_or((signature_byte_start, signature_byte_end));
773
774        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
775
776        let raw_calls = all_file_calls.iter().filter(|call| {
777            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
778        });
779        let calls_out = dedupe_call_refs_by_name(
780            raw_calls
781                .filter(|call| {
782                    known_names.contains(&call.name.as_str()) && call.name != target.name
783                })
784                .map(|call| CallRef {
785                    name: call.name.clone(),
786                    line: call.line,
787                    extra_count: 0,
788                })
789                .collect(),
790        );
791
792        // called_by: bucket the single file-wide call extraction by enclosing symbol range
793        let mut called_by: Vec<CallRef> = Vec::new();
794        for sym in &all_symbols {
795            if sym.name == target.name && sym.range.start_line == target.range.start_line {
796                continue; // skip self
797            }
798            let sym_byte_start =
799                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
800            let sym_byte_end =
801                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
802            for call in &all_file_calls {
803                if call.name == target.name
804                    && call.start_byte >= sym_byte_start
805                    && call.end_byte <= sym_byte_end
806                {
807                    called_by.push(CallRef {
808                        name: sym.name.clone(),
809                        line: call.line,
810                        extra_count: 0,
811                    });
812                }
813            }
814        }
815
816        let called_by = dedupe_call_refs_by_name(called_by);
817
818        (calls_out, called_by)
819    } else {
820        (Vec::new(), Vec::new())
821    };
822
823    let kind_str = symbol_kind_string(&target.kind);
824
825    let resp = ZoomResponse {
826        name: target.name.clone(),
827        kind: kind_str,
828        range: target.range.clone(),
829        content,
830        context_before,
831        context_after,
832        annotations: Annotations {
833            calls_out,
834            called_by,
835        },
836    };
837
838    match serde_json::to_value(&resp) {
839        Ok(resp_json) => Response::success(&req.id, resp_json),
840        Err(err) => Response::error(
841            &req.id,
842            "internal_error",
843            format!("zoom: failed to serialize response: {err}"),
844        ),
845    }
846}
847
848fn empty_annotations() -> serde_json::Value {
849    serde_json::json!({
850        "calls_out": [],
851        "called_by": [],
852    })
853}
854
855fn render_ambiguous_symbol_menu(
856    symbol_name: &str,
857    matches: &[crate::symbols::SymbolMatch],
858) -> String {
859    let mut lines = vec![format!(
860        "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
861        matches.len()
862    )];
863
864    for candidate in matches {
865        let entry = symbol_to_entry(&candidate.symbol);
866        lines.push(format!(
867            "- {}",
868            format_qualified_entry(&entry, Some(&candidate.symbol))
869        ));
870    }
871
872    lines.join("\n")
873}
874
875fn levenshtein_distance(s1: &str, s2: &str) -> usize {
876    let s1_chars: Vec<char> = s1.chars().collect();
877    let s2_chars: Vec<char> = s2.chars().collect();
878    let len1 = s1_chars.len();
879    let len2 = s2_chars.len();
880
881    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
882
883    for i in 0..=len1 {
884        dp[i][0] = i;
885    }
886    for j in 0..=len2 {
887        dp[0][j] = j;
888    }
889
890    for i in 1..=len1 {
891        for j in 1..=len2 {
892            if s1_chars[i - 1] == s2_chars[j - 1] {
893                dp[i][j] = dp[i - 1][j - 1];
894            } else {
895                dp[i][j] =
896                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
897            }
898        }
899    }
900
901    dp[len1][len2]
902}
903
904fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
905    let mut unique: Vec<&String> = available.iter().collect();
906    unique.sort();
907    unique.dedup();
908
909    let query_lower = query.to_lowercase();
910    let query_len = query_lower.chars().count();
911    let max_dist = std::cmp::max(2, query_len / 3);
912
913    let mut scored: Vec<(bool, usize, &String)> = unique
914        .into_iter()
915        .map(|name| {
916            let name_lower = name.to_lowercase();
917            let is_substring =
918                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
919            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
920                (query_lower.find('_'), query_lower.rfind('_'))
921            {
922                let prefix = &query_lower[..=first_idx];
923                let suffix = &query_lower[last_idx..];
924                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
925            } else {
926                false
927            };
928            let is_match = is_substring || is_wildcard;
929            let dist = levenshtein_distance(&query_lower, &name_lower);
930            (is_match, dist, name)
931        })
932        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
933        .collect();
934
935    scored.sort_by(|a, b| {
936        let a_match = a.0;
937        let b_match = b.0;
938        (!a_match)
939            .cmp(&(!b_match))
940            .then_with(|| a.1.cmp(&b.1))
941            .then_with(|| a.2.cmp(b.2))
942    });
943
944    scored
945        .into_iter()
946        .take(k)
947        .map(|(_, _, name)| name.clone())
948        .collect()
949}
950
951fn resolve_zoom_symbol(
952    provider: &dyn LanguageProvider,
953    path: &Path,
954    query: &str,
955    is_heading: bool,
956) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
957    if is_heading {
958        return resolve_heading_symbols(provider, path, query);
959    }
960
961    match provider.resolve_symbol(path, query) {
962        Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
963        result => result,
964    }
965}
966
967/// Keep document headings' raw labels for outline fidelity while allowing zoom to use
968/// human-readable labels, section prefixes, or anchors without affecting code symbols.
969fn resolve_heading_symbols(
970    provider: &dyn LanguageProvider,
971    path: &Path,
972    query: &str,
973) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
974    let headings: Vec<SymbolMatch> = provider
975        .list_symbols(path)?
976        .into_iter()
977        .filter(|symbol| symbol.kind == SymbolKind::Heading)
978        .map(|symbol| SymbolMatch {
979            file: path.display().to_string(),
980            symbol,
981        })
982        .collect();
983
984    let anchors = if query.starts_with('#') {
985        provider.heading_anchors(path)?
986    } else {
987        Vec::new()
988    };
989
990    Ok(match_heading_identity(&headings, query, &anchors))
991}
992
993fn match_heading_identity(
994    headings: &[SymbolMatch],
995    query: &str,
996    anchors: &[HeadingAnchor],
997) -> Vec<SymbolMatch> {
998    if let Some(anchor_query) = query.strip_prefix('#') {
999        let mut matches: Vec<_> = headings
1000            .iter()
1001            .filter(|candidate| {
1002                anchors.iter().any(|anchor| {
1003                    anchor.id == anchor_query
1004                        && anchor.start_line == candidate.symbol.range.start_line
1005                        && anchor.start_col == candidate.symbol.range.start_col
1006                })
1007            })
1008            .cloned()
1009            .collect();
1010
1011        let normalized_query = normalize_heading_label(query);
1012        let query_slug = slugify_heading_label(&normalized_query);
1013        if !query_slug.is_empty() {
1014            for candidate in headings
1015                .iter()
1016                .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1017            {
1018                if !matches.iter().any(|existing| {
1019                    existing.file == candidate.file && existing.symbol == candidate.symbol
1020                }) {
1021                    matches.push(candidate.clone());
1022                }
1023            }
1024        }
1025        return matches;
1026    }
1027
1028    let exact: Vec<_> = headings
1029        .iter()
1030        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1031        .cloned()
1032        .collect();
1033    if !exact.is_empty() {
1034        return exact;
1035    }
1036
1037    let normalized_query = normalize_heading_label(query);
1038    if normalized_query.is_empty() {
1039        return Vec::new();
1040    }
1041
1042    let normalized: Vec<_> = headings
1043        .iter()
1044        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1045        .cloned()
1046        .collect();
1047    if !normalized.is_empty() {
1048        return normalized;
1049    }
1050
1051    let folded_query = normalized_query.to_lowercase();
1052    let case_insensitive: Vec<_> = headings
1053        .iter()
1054        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1055        .cloned()
1056        .collect();
1057    if !case_insensitive.is_empty() {
1058        return case_insensitive;
1059    }
1060
1061    let query_slug = slugify_heading_label(&normalized_query);
1062    if query_slug.is_empty() {
1063        return Vec::new();
1064    }
1065
1066    headings
1067        .iter()
1068        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1069        .cloned()
1070        .collect()
1071}
1072
1073fn qualified_heading_name(symbol: &Symbol) -> String {
1074    if symbol.scope_chain.is_empty() {
1075        return symbol.name.clone();
1076    }
1077    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1078}
1079
1080fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1081    symbol.name == query || qualified_heading_name(symbol) == query
1082}
1083
1084fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1085    normalize_heading_label(&symbol.name) == query
1086        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1087}
1088
1089fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1090    normalize_heading_label(&symbol.name).to_lowercase() == query
1091        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1092}
1093
1094fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1095    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1096        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1097            == query_slug
1098}
1099
1100fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1101    let available: Vec<String> = symbols
1102        .iter()
1103        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1104        .map(|symbol| normalize_heading_label(&symbol.name))
1105        .filter(|name| !name.is_empty())
1106        .collect();
1107    let normalized_query = normalize_heading_label(query);
1108    if normalized_query.is_empty() {
1109        return Vec::new();
1110    }
1111    suggest_close_symbols(&normalized_query, &available, k)
1112}
1113
1114fn normalize_heading_label(input: &str) -> String {
1115    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1116
1117    // A label can contain both a document prefix and a decorative symbol cluster.
1118    // Repeat the small cleanup sequence so either order is handled consistently.
1119    for _ in 0..4 {
1120        let mut next = value.as_str();
1121        let without_heading_markers = next.trim_start_matches('#').trim_start();
1122        if without_heading_markers != next {
1123            next = without_heading_markers;
1124        }
1125        if let Some(rest) = strip_heading_html_prefix(next) {
1126            next = rest;
1127        }
1128        if let Some(rest) = strip_leading_section_prefix(next) {
1129            next = rest;
1130        }
1131        if let Some(rest) = strip_leading_symbol_cluster(next) {
1132            next = rest;
1133        }
1134
1135        let collapsed = collapse_heading_whitespace(next);
1136        if collapsed == value {
1137            break;
1138        }
1139        value = collapsed;
1140    }
1141
1142    value
1143}
1144
1145fn collapse_heading_whitespace(input: &str) -> String {
1146    input.split_whitespace().collect::<Vec<_>>().join(" ")
1147}
1148
1149fn strip_markdown_links(input: &str) -> String {
1150    let mut output = String::with_capacity(input.len());
1151    let mut cursor = 0;
1152
1153    while let Some(relative_open) = input[cursor..].find('[') {
1154        let open = cursor + relative_open;
1155        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1156            output.push_str(&input[cursor..]);
1157            return output;
1158        };
1159
1160        if input.as_bytes().get(close + 1) != Some(&b'(') {
1161            output.push_str(&input[cursor..=close]);
1162            cursor = close + 1;
1163            continue;
1164        }
1165
1166        let target_open = close + 1;
1167        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1168            output.push_str(&input[cursor..]);
1169            return output;
1170        };
1171
1172        output.push_str(&input[cursor..open]);
1173        output.push_str(&input[open + 1..close]);
1174        cursor = target_close + 1;
1175    }
1176
1177    output.push_str(&input[cursor..]);
1178    output
1179}
1180
1181fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1182    let mut depth = 0;
1183    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1184        if *byte == open {
1185            depth += 1;
1186        } else if *byte == close {
1187            depth -= 1;
1188            if depth == 0 {
1189                return Some(index);
1190            }
1191        }
1192    }
1193    None
1194}
1195
1196fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1197    let input = input.trim_start();
1198    let bytes = input.as_bytes();
1199    if bytes.first() != Some(&b'<') {
1200        return None;
1201    }
1202
1203    let mut index = 1;
1204    if bytes.get(index) == Some(&b'/') {
1205        index += 1;
1206    }
1207    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1208        return None;
1209    }
1210    index += 1;
1211    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1212        return None;
1213    }
1214
1215    let end = input.find('>')?;
1216    let rest = input[end + 1..].trim_start();
1217    if rest.chars().any(|character| character.is_alphanumeric()) {
1218        Some(rest)
1219    } else {
1220        None
1221    }
1222}
1223
1224fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1225    let bytes = input.as_bytes();
1226    let mut index = 0;
1227    let mut saw_dot = false;
1228
1229    if !bytes
1230        .first()
1231        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1232    {
1233        return None;
1234    }
1235
1236    while index < bytes.len() {
1237        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1238            index += 1;
1239        }
1240        if bytes.get(index) != Some(&b'.') {
1241            break;
1242        }
1243        saw_dot = true;
1244        index += 1;
1245        if bytes
1246            .get(index)
1247            .is_some_and(|byte| byte.is_ascii_whitespace())
1248        {
1249            let rest = input[index..].trim_start();
1250            return if rest.chars().any(|character| character.is_alphanumeric()) {
1251                Some(rest)
1252            } else {
1253                None
1254            };
1255        }
1256        if !bytes
1257            .get(index)
1258            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1259        {
1260            return None;
1261        }
1262    }
1263
1264    if saw_dot
1265        && bytes
1266            .get(index)
1267            .is_some_and(|byte| byte.is_ascii_whitespace())
1268    {
1269        let rest = input[index..].trim_start();
1270        if rest.chars().any(|character| character.is_alphanumeric()) {
1271            return Some(rest);
1272        }
1273    }
1274    None
1275}
1276
1277fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1278    let first_text = input
1279        .char_indices()
1280        .find(|(_, character)| character.is_alphanumeric())
1281        .map(|(index, _)| index)?;
1282    if first_text == 0 {
1283        return None;
1284    }
1285
1286    let rest = &input[first_text..];
1287    if rest.chars().any(|character| character.is_alphanumeric()) {
1288        Some(rest)
1289    } else {
1290        None
1291    }
1292}
1293
1294fn slugify_heading_label(label: &str) -> String {
1295    let mut slug = String::new();
1296    let mut pending_separator = false;
1297
1298    for character in label.chars() {
1299        if character.is_alphanumeric() {
1300            if pending_separator && !slug.is_empty() {
1301                slug.push('-');
1302            }
1303            for lowercase in character.to_lowercase() {
1304                slug.push(lowercase);
1305            }
1306            pending_separator = false;
1307        } else if !slug.is_empty() {
1308            pending_separator = true;
1309        }
1310    }
1311
1312    slug
1313}
1314
1315/// Extract call expression names within a byte range of the AST.
1316///
1317/// Delegates to `crate::calls::extract_calls_in_range`.
1318#[cfg(test)]
1319fn extract_calls_in_range(
1320    source: &str,
1321    root: tree_sitter::Node,
1322    byte_start: usize,
1323    byte_end: usize,
1324    lang: LangId,
1325) -> Vec<(String, u32)> {
1326    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1327}
1328
1329fn symbol_body_byte_range(
1330    root: tree_sitter::Node,
1331    byte_start: usize,
1332    byte_end: usize,
1333) -> Option<(usize, usize)> {
1334    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1335    let mut current = Some(node);
1336    while let Some(node) = current {
1337        if is_symbol_body_node(node.kind()) {
1338            return Some((node.start_byte(), node.end_byte()));
1339        }
1340        current = node.parent();
1341    }
1342    Some((node.start_byte(), node.end_byte()))
1343}
1344
1345fn smallest_node_covering_range<'tree>(
1346    node: tree_sitter::Node<'tree>,
1347    byte_start: usize,
1348    byte_end: usize,
1349) -> Option<tree_sitter::Node<'tree>> {
1350    if node.start_byte() > byte_start || node.end_byte() < byte_end {
1351        return None;
1352    }
1353
1354    let mut cursor = node.walk();
1355    if cursor.goto_first_child() {
1356        loop {
1357            let child = cursor.node();
1358            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1359                return Some(found);
1360            }
1361            if !cursor.goto_next_sibling() {
1362                break;
1363            }
1364        }
1365    }
1366
1367    Some(node)
1368}
1369
1370fn is_symbol_body_node(kind: &str) -> bool {
1371    matches!(
1372        kind,
1373        "function_declaration"
1374            | "generator_function_declaration"
1375            | "function_expression"
1376            | "generator_function"
1377            | "arrow_function"
1378            | "method_definition"
1379            | "class_declaration"
1380            | "abstract_class_declaration"
1381            | "class"
1382            | "lexical_declaration"
1383            | "function_definition"
1384            | "class_definition"
1385            | "decorated_definition"
1386            | "function_item"
1387            | "impl_item"
1388            | "method_declaration"
1389    )
1390}
1391
1392fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1393    let mut results = Vec::new();
1394    let call_kinds = crate::calls::call_node_kinds(lang);
1395    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1396    results
1397}
1398
1399fn collect_calls_with_ranges(
1400    node: tree_sitter::Node,
1401    source: &str,
1402    call_kinds: &[&str],
1403    results: &mut Vec<RawCall>,
1404) {
1405    if call_kinds.contains(&node.kind()) {
1406        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1407            results.push(RawCall {
1408                name,
1409                line: node.start_position().row as u32 + 1,
1410                start_byte: node.start_byte(),
1411                end_byte: node.end_byte(),
1412            });
1413        }
1414    }
1415
1416    let mut cursor = node.walk();
1417    if cursor.goto_first_child() {
1418        loop {
1419            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1420            if !cursor.goto_next_sibling() {
1421                break;
1422            }
1423        }
1424    }
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use super::*;
1430    use crate::config::Config;
1431    use crate::context::AppContext;
1432    use crate::parser::TreeSitterProvider;
1433    use std::path::PathBuf;
1434
1435    fn fixture_path(name: &str) -> PathBuf {
1436        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1437            .join("tests")
1438            .join("fixtures")
1439            .join(name)
1440    }
1441
1442    fn make_ctx() -> AppContext {
1443        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1444    }
1445
1446    #[test]
1447    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1448        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1449        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1450        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1451    }
1452
1453    #[test]
1454    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1455        let params = serde_json::json!({ "symbols": "Getting Started" });
1456        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1457        assert_eq!(names, vec!["Getting Started"]);
1458    }
1459
1460    #[test]
1461    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1462        let params = serde_json::json!({ "symbol": "Last Heading" });
1463        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1464        assert_eq!(names, vec!["Last Heading"]);
1465    }
1466
1467    #[test]
1468    fn parse_zoom_symbol_names_single_token_unchanged() {
1469        let params = serde_json::json!({ "symbol": "compute" });
1470        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1471        assert_eq!(names, vec!["compute"]);
1472    }
1473
1474    #[test]
1475    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1476        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1477        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1478        assert_eq!(names, vec!["A", "B", "C"]);
1479    }
1480
1481    #[test]
1482    fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1483        // Models sometimes JSON-stringify the array form; headings keep spaces.
1484        let params =
1485            serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1486        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1487        assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1488    }
1489
1490    #[test]
1491    fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
1492        let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
1493        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1494        assert_eq!(names, vec!["alpha", "beta"]);
1495    }
1496
1497    #[test]
1498    fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
1499        // A real heading that starts with '[' but is not a JSON array must
1500        // stay a single lookup name.
1501        let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
1502        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1503        assert_eq!(names, vec!["[Draft] Rollout plan"]);
1504    }
1505
1506    #[test]
1507    fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
1508        // "[1, 2]" parses as JSON but not as symbol names; fall through to
1509        // ordinary handling rather than returning numbers-as-names.
1510        let params = serde_json::json!({ "symbols": "[1, 2]" });
1511        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1512        assert_eq!(names, vec!["[1,", "2]"]);
1513    }
1514
1515    // --- Call extraction tests ---
1516
1517    #[test]
1518    fn extract_calls_finds_direct_calls() {
1519        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1520        let mut parser = FileParser::new();
1521        let path = fixture_path("calls.ts");
1522        let (tree, lang) = parser.parse(&path).unwrap();
1523
1524        // `compute` calls `helper` — find compute's range from symbols
1525        let ctx = make_ctx();
1526        let symbols = ctx.provider().list_symbols(&path).unwrap();
1527        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1528
1529        let byte_start =
1530            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1531        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1532
1533        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1534        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1535
1536        assert!(
1537            names.contains(&"helper"),
1538            "compute should call helper, got: {:?}",
1539            names
1540        );
1541    }
1542
1543    #[test]
1544    fn extract_calls_finds_member_calls() {
1545        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1546        let mut parser = FileParser::new();
1547        let path = fixture_path("calls.ts");
1548        let (tree, lang) = parser.parse(&path).unwrap();
1549
1550        let ctx = make_ctx();
1551        let symbols = ctx.provider().list_symbols(&path).unwrap();
1552        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1553
1554        let byte_start =
1555            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1556        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1557
1558        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1559        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1560
1561        assert!(
1562            names.contains(&"add"),
1563            "runAll should call this.add, got: {:?}",
1564            names
1565        );
1566        assert!(
1567            names.contains(&"helper"),
1568            "runAll should call helper, got: {:?}",
1569            names
1570        );
1571    }
1572
1573    #[test]
1574    fn extract_calls_unused_function_has_no_calls() {
1575        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1576        let mut parser = FileParser::new();
1577        let path = fixture_path("calls.ts");
1578        let (tree, lang) = parser.parse(&path).unwrap();
1579
1580        let ctx = make_ctx();
1581        let symbols = ctx.provider().list_symbols(&path).unwrap();
1582        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1583
1584        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1585        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1586
1587        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1588        // console.log is the only call, but "log" or "console" aren't known symbols
1589        let known_names = [
1590            "helper",
1591            "compute",
1592            "orchestrate",
1593            "unused",
1594            "format",
1595            "display",
1596        ];
1597        let filtered: Vec<&str> = calls
1598            .iter()
1599            .map(|(n, _)| n.as_str())
1600            .filter(|n| known_names.contains(n))
1601            .collect();
1602        assert!(
1603            filtered.is_empty(),
1604            "unused should not call known symbols, got: {:?}",
1605            filtered
1606        );
1607    }
1608
1609    // --- Context line tests ---
1610
1611    #[test]
1612    fn context_lines_clamp_at_file_start() {
1613        // helper() is at the top of the file (line 2) — context_before should be clamped
1614        let ctx = make_ctx();
1615        let path = fixture_path("calls.ts");
1616        let symbols = ctx.provider().list_symbols(&path).unwrap();
1617        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1618
1619        let source = std::fs::read_to_string(&path).unwrap();
1620        let lines: Vec<&str> = source.lines().collect();
1621        let start = helper.range.start_line as usize;
1622
1623        // With context_lines=5, ctx_start should clamp to 0
1624        let ctx_start = start.saturating_sub(5);
1625        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1626        // Should have at most `start` lines (not panic)
1627        assert!(context_before.len() <= start);
1628    }
1629
1630    #[test]
1631    fn context_lines_clamp_at_file_end() {
1632        let ctx = make_ctx();
1633        let path = fixture_path("calls.ts");
1634        let symbols = ctx.provider().list_symbols(&path).unwrap();
1635        let display = symbols.iter().find(|s| s.name == "display").unwrap();
1636
1637        let source = std::fs::read_to_string(&path).unwrap();
1638        let lines: Vec<&str> = source.lines().collect();
1639        let end = display.range.end_line as usize;
1640
1641        // With context_lines=20, should clamp to file length
1642        let ctx_end = (end + 1 + 20).min(lines.len());
1643        let context_after: Vec<&str> = if end + 1 < lines.len() {
1644            lines[(end + 1)..ctx_end].to_vec()
1645        } else {
1646            vec![]
1647        };
1648        // Should not panic regardless of context_lines size
1649        assert!(context_after.len() <= 20);
1650    }
1651
1652    // --- Body extraction test ---
1653
1654    #[test]
1655    fn body_extraction_matches_source() {
1656        let ctx = make_ctx();
1657        let path = fixture_path("calls.ts");
1658        let symbols = ctx.provider().list_symbols(&path).unwrap();
1659        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1660
1661        let source = std::fs::read_to_string(&path).unwrap();
1662        let lines: Vec<&str> = source.lines().collect();
1663        let start = compute.range.start_line as usize;
1664        let end = compute.range.end_line as usize;
1665        let body = lines[start..=end].join("\n");
1666
1667        assert!(
1668            body.contains("function compute"),
1669            "body should contain function declaration"
1670        );
1671        assert!(
1672            body.contains("helper(a)"),
1673            "body should contain call to helper"
1674        );
1675        assert!(
1676            body.contains("doubled + b"),
1677            "body should contain return expression"
1678        );
1679    }
1680
1681    // --- Full zoom response tests ---
1682
1683    #[test]
1684    fn body_range_expands_signature_range_to_include_body_calls() {
1685        let source = r#"function compute(
1686  value: number,
1687): number {
1688  return helper(value);
1689}
1690
1691function helper(value: number): number {
1692  return value * 2;
1693}
1694"#;
1695        let grammar = crate::parser::grammar_for(LangId::TypeScript);
1696        let mut parser = tree_sitter::Parser::new();
1697        parser.set_language(&grammar).unwrap();
1698        let tree = parser.parse(source, None).unwrap();
1699        let signature_end = source.find('{').expect("function has body");
1700
1701        let (body_start, body_end) =
1702            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1703        let calls = extract_calls_in_range(
1704            source,
1705            tree.root_node(),
1706            body_start,
1707            body_end,
1708            LangId::TypeScript,
1709        );
1710        let names = calls
1711            .iter()
1712            .map(|(name, _)| name.as_str())
1713            .collect::<Vec<_>>();
1714
1715        assert!(
1716            names.contains(&"helper"),
1717            "call inside the function body should be included: {names:?}"
1718        );
1719    }
1720
1721    #[test]
1722    fn zoom_leaf_returns_full_body_without_budget_marker() {
1723        let ctx = make_ctx();
1724        let path = fixture_path("calls.ts");
1725        let req = make_zoom_request(
1726            "z-leaf-full",
1727            path.to_str().unwrap(),
1728            "repeatedOutgoing",
1729            None,
1730        );
1731        let resp = handle_zoom(&req, &ctx);
1732        let json = serde_json::to_value(&resp).unwrap();
1733        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1734
1735        let symbols = ctx.provider().list_symbols(&path).unwrap();
1736        let target = symbols
1737            .iter()
1738            .find(|symbol| symbol.name == "repeatedOutgoing")
1739            .unwrap();
1740        let source = std::fs::read_to_string(&path).unwrap();
1741        let lines = source.lines().collect::<Vec<_>>();
1742        let expected =
1743            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1744
1745        assert_eq!(json["content"].as_str().unwrap(), expected);
1746        assert!(
1747            !json["content"]
1748                .as_str()
1749                .unwrap()
1750                .contains("more lines — zoom"),
1751            "explicit zoom must not budget-cap leaf bodies"
1752        );
1753    }
1754
1755    #[test]
1756    fn zoom_response_has_calls_out_and_called_by() {
1757        let ctx = make_ctx();
1758        let path = fixture_path("calls.ts");
1759
1760        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1761        let resp = handle_zoom(&req, &ctx);
1762
1763        let json = serde_json::to_value(&resp).unwrap();
1764        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1765
1766        let calls_out = json["annotations"]["calls_out"]
1767            .as_array()
1768            .expect("calls_out array");
1769        let out_names: Vec<&str> = calls_out
1770            .iter()
1771            .map(|c| c["name"].as_str().unwrap())
1772            .collect();
1773        assert!(
1774            out_names.contains(&"helper"),
1775            "compute calls helper: {:?}",
1776            out_names
1777        );
1778
1779        let called_by = json["annotations"]["called_by"]
1780            .as_array()
1781            .expect("called_by array");
1782        let by_names: Vec<&str> = called_by
1783            .iter()
1784            .map(|c| c["name"].as_str().unwrap())
1785            .collect();
1786        assert!(
1787            by_names.contains(&"orchestrate"),
1788            "orchestrate calls compute: {:?}",
1789            by_names
1790        );
1791    }
1792
1793    #[test]
1794    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1795        let ctx = make_ctx();
1796        let path = fixture_path("calls.ts");
1797
1798        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1799        let resp = handle_zoom(&req, &ctx);
1800        let json = serde_json::to_value(&resp).unwrap();
1801        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1802
1803        let calls_out = json["annotations"]["calls_out"]
1804            .as_array()
1805            .expect("calls_out array");
1806        let helper_refs = calls_out
1807            .iter()
1808            .filter(|call| call["name"] == "helper")
1809            .collect::<Vec<_>>();
1810        assert_eq!(
1811            helper_refs.len(),
1812            1,
1813            "helper should be folded once: {calls_out:?}"
1814        );
1815        assert_eq!(helper_refs[0]["extra_count"], 1);
1816        assert!(
1817            calls_out.iter().any(|call| call["name"] == "format"),
1818            "distinct callee must not be folded into helper: {calls_out:?}"
1819        );
1820
1821        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1822        let resp = handle_zoom(&req, &ctx);
1823        let json = serde_json::to_value(&resp).unwrap();
1824        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1825
1826        let called_by = json["annotations"]["called_by"]
1827            .as_array()
1828            .expect("called_by array");
1829        let repeat_refs = called_by
1830            .iter()
1831            .filter(|call| call["name"] == "repeatCompute")
1832            .collect::<Vec<_>>();
1833        assert_eq!(
1834            repeat_refs.len(),
1835            1,
1836            "repeatCompute should be folded once: {called_by:?}"
1837        );
1838        assert_eq!(repeat_refs[0]["extra_count"], 1);
1839        assert!(
1840            called_by.iter().any(|call| call["name"] == "orchestrate"),
1841            "distinct caller must not be folded into repeatCompute: {called_by:?}"
1842        );
1843    }
1844
1845    #[test]
1846    fn zoom_response_empty_annotations_for_unused() {
1847        let ctx = make_ctx();
1848        let path = fixture_path("calls.ts");
1849
1850        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1851        let resp = handle_zoom(&req, &ctx);
1852
1853        let json = serde_json::to_value(&resp).unwrap();
1854        assert_eq!(json["success"], true);
1855
1856        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1857        let called_by = json["annotations"]["called_by"].as_array().unwrap();
1858
1859        // calls_out exists (may contain console.log but no known symbols)
1860        // called_by should be empty — nobody calls unused
1861        assert!(
1862            called_by.is_empty(),
1863            "unused should not be called by anyone: {:?}",
1864            called_by
1865        );
1866    }
1867
1868    #[test]
1869    fn zoom_default_omits_callgraph_annotations() {
1870        let ctx = make_ctx();
1871        let path = fixture_path("calls.ts");
1872
1873        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1874        let resp = handle_zoom(&req, &ctx);
1875
1876        let json = serde_json::to_value(&resp).unwrap();
1877        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1878
1879        let calls_out = json["annotations"]["calls_out"]
1880            .as_array()
1881            .expect("calls_out array");
1882        let called_by = json["annotations"]["called_by"]
1883            .as_array()
1884            .expect("called_by array");
1885        assert!(
1886            calls_out.is_empty(),
1887            "default zoom should omit calls_out: {:?}",
1888            calls_out
1889        );
1890        assert!(
1891            called_by.is_empty(),
1892            "default zoom should omit called_by: {:?}",
1893            called_by
1894        );
1895    }
1896
1897    #[test]
1898    fn zoom_symbol_not_found() {
1899        let ctx = make_ctx();
1900        let path = fixture_path("calls.ts");
1901
1902        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1903        let resp = handle_zoom(&req, &ctx);
1904
1905        let json = serde_json::to_value(&resp).unwrap();
1906        assert_eq!(json["success"], false);
1907        assert_eq!(json["code"], "symbol_not_found");
1908    }
1909
1910    #[test]
1911    fn zoom_custom_context_lines() {
1912        let ctx = make_ctx();
1913        let path = fixture_path("calls.ts");
1914
1915        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1916        let resp = handle_zoom(&req, &ctx);
1917
1918        let json = serde_json::to_value(&resp).unwrap();
1919        assert_eq!(json["success"], true);
1920
1921        let ctx_before = json["context_before"].as_array().unwrap();
1922        let ctx_after = json["context_after"].as_array().unwrap();
1923        // With context_lines=1, we get at most 1 line before and after
1924        assert!(
1925            ctx_before.len() <= 1,
1926            "context_before should be ≤1: {:?}",
1927            ctx_before
1928        );
1929        assert!(
1930            ctx_after.len() <= 1,
1931            "context_after should be ≤1: {:?}",
1932            ctx_after
1933        );
1934    }
1935
1936    #[test]
1937    fn zoom_missing_file_param() {
1938        let ctx = make_ctx();
1939        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1940        let resp = handle_zoom(&req, &ctx);
1941
1942        let json = serde_json::to_value(&resp).unwrap();
1943        assert_eq!(json["success"], false);
1944        assert_eq!(json["code"], "invalid_request");
1945    }
1946
1947    #[test]
1948    fn zoom_missing_symbol_param() {
1949        let ctx = make_ctx();
1950        let path = fixture_path("calls.ts");
1951        // Build the JSON via serde_json so Windows paths (with backslashes)
1952        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
1953        // backslashes as escape sequences and fail to parse.
1954        let req_value = serde_json::json!({
1955            "id": "z-6",
1956            "command": "zoom",
1957            "file": path.to_string_lossy(),
1958        });
1959        let req_str = req_value.to_string();
1960        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1961        let resp = handle_zoom(&req, &ctx);
1962
1963        let json = serde_json::to_value(&resp).unwrap();
1964        assert_eq!(json["success"], false);
1965        assert_eq!(json["code"], "invalid_request");
1966    }
1967
1968    #[test]
1969    fn test_suggest_close_symbols_unit() {
1970        let available = vec![
1971            "handle_grep_search".to_string(),
1972            "handle_semantic_search".to_string(),
1973            "handle_semantic_or_hybrid_search".to_string(),
1974            "compute_total".to_string(),
1975            "search".to_string(),
1976            "handle_search".to_string(),
1977        ];
1978
1979        let suggestions = suggest_close_symbols("handle_search", &available, 5);
1980        assert!(suggestions.contains(&"handle_grep_search".to_string()));
1981        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1982        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1983        assert!(suggestions.contains(&"search".to_string()));
1984        assert!(!suggestions.contains(&"compute_total".to_string()));
1985
1986        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1987        assert_eq!(suggestions, suggestions_caps);
1988
1989        let available2 = vec![
1990            "total".to_string(),
1991            "compute_total".to_string(),
1992            "unrelated".to_string(),
1993        ];
1994        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1995        assert_eq!(suggestions2, vec!["total".to_string()]);
1996    }
1997
1998    // --- Helpers ---
1999
2000    fn make_zoom_request(
2001        id: &str,
2002        file: &str,
2003        symbol: &str,
2004        context_lines: Option<u64>,
2005    ) -> RawRequest {
2006        let mut json = serde_json::json!({
2007            "id": id,
2008            "command": "zoom",
2009            "file": file,
2010            "symbol": symbol,
2011        });
2012        if let Some(cl) = context_lines {
2013            json["context_lines"] = serde_json::json!(cl);
2014        }
2015        serde_json::from_value(json).unwrap()
2016    }
2017
2018    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2019        let mut req = make_zoom_request(id, file, symbol, None);
2020        req.params["callgraph"] = serde_json::json!(true);
2021        req
2022    }
2023
2024    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2025        serde_json::from_str(json_str).unwrap()
2026    }
2027}