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, node_text, 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 lang = detect_language(path);
593    let is_heading = is_heading_zoom_language(lang);
594
595    // JSON files resolve dotted paths (`a.b.c`, `servers[0]`) against the parsed
596    // document in addition to literal top-level keys. See `resolve_json_zoom`.
597    if lang == Some(LangId::Json) {
598        return resolve_json_zoom(
599            req,
600            ctx,
601            path,
602            source,
603            lines,
604            symbol_name,
605            context_lines,
606            include_callgraph,
607        );
608    }
609
610    let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
611        Ok(matches) => matches,
612        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
613    };
614
615    // LSP-enhanced disambiguation (S03)
616    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
617        lsp_hints::apply_lsp_disambiguation(matches, &hints)
618    } else {
619        matches
620    };
621
622    if matches.len() > 1 {
623        let content = render_ambiguous_symbol_menu(symbol_name, &matches);
624        let candidates = matches
625            .iter()
626            .map(|candidate| {
627                let sym = &candidate.symbol;
628                serde_json::json!({
629                    "name": sym.name.clone(),
630                    "qualified_name": qualified_symbol_name(sym),
631                    "kind": symbol_kind_string(&sym.kind),
632                    "range": sym.range.clone(),
633                    "signature": sym.signature.clone(),
634                })
635            })
636            .collect::<Vec<_>>();
637
638        return Response::success(
639            &req.id,
640            serde_json::json!({
641                "name": symbol_name,
642                "kind": "ambiguous_symbol",
643                "content": content,
644                "context_before": [],
645                "context_after": [],
646                "annotations": empty_annotations(),
647                "candidates": candidates,
648            }),
649        );
650    }
651
652    if matches.is_empty() {
653        let mut msg = format!("symbol '{}' not found", symbol_name);
654        if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
655            let suggestions = if is_heading {
656                suggest_heading_symbols(symbol_name, &all_symbols, 5)
657            } else {
658                let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
659                suggest_close_symbols(symbol_name, &available, 5)
660            };
661            if !suggestions.is_empty() {
662                msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
663            }
664        }
665        return Response::error(&req.id, "symbol_not_found", msg);
666    }
667
668    let target = &matches[0].symbol;
669    let start = target.range.start_line as usize;
670    let end = target.range.end_line as usize;
671
672    // When re-export following resolved to a different file, re-read that file's lines
673    let resolved_file_path = std::path::Path::new(&matches[0].file);
674    let resolved_lines: Vec<String>;
675    let effective_lines: &[String] = if resolved_file_path != path {
676        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
677            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
678            Err(_) => lines.to_vec(),
679        };
680        &resolved_lines
681    } else {
682        lines
683    };
684
685    // Extract symbol body (0-based line indices)
686    let content = if end < effective_lines.len() {
687        effective_lines[start..=end].join("\n")
688    } else {
689        effective_lines[start..].join("\n")
690    };
691
692    let resolved_lang = detect_language(resolved_file_path);
693    let container_outline = if might_have_container_members(target) {
694        match build_container_outline(ctx, resolved_file_path, target) {
695            Ok(outline) => Some(outline),
696            Err(e) => {
697                return Response::error(&req.id, e.code(), e.to_string());
698            }
699        }
700    } else {
701        None
702    };
703
704    if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
705        let kind_str = symbol_kind_string(&target.kind);
706        let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
707        let resp = ZoomResponse {
708            name: target.name.clone(),
709            kind: kind_str,
710            range: target.range.clone(),
711            content: menu,
712            context_before: Vec::new(),
713            context_after: Vec::new(),
714            annotations: Annotations {
715                calls_out: Vec::new(),
716                called_by: Vec::new(),
717            },
718        };
719        return match serde_json::to_value(&resp) {
720            Ok(resp_json) => Response::success(&req.id, resp_json),
721            Err(err) => Response::error(
722                &req.id,
723                "internal_error",
724                format!("zoom: failed to serialize response: {err}"),
725            ),
726        };
727    }
728
729    // Context before
730    let ctx_start = start.saturating_sub(context_lines);
731    let context_before: Vec<String> = if ctx_start < start {
732        effective_lines[ctx_start..start]
733            .iter()
734            .map(|l| l.to_string())
735            .collect()
736    } else {
737        vec![]
738    };
739
740    // Context after
741    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
742    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
743        effective_lines[(end + 1)..ctx_end]
744            .iter()
745            .map(|l| l.to_string())
746            .collect()
747    } else {
748        vec![]
749    };
750
751    let (calls_out, called_by) = if include_callgraph {
752        // Get all symbols in the resolved file for call matching
753        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
754            Ok(s) => s,
755            Err(e) => {
756                return Response::error(&req.id, e.code(), e.to_string());
757            }
758        };
759
760        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
761
762        // Parse AST for call extraction (use resolved file for cross-file re-exports)
763        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
764        let (tree, lang) = match parser.parse(resolved_file_path) {
765            Ok(r) => r,
766            Err(e) => {
767                return Response::error(&req.id, e.code(), e.to_string());
768            }
769        };
770
771        // calls_out: calls within the target symbol's byte range
772        let resolved_source = if resolved_file_path != path {
773            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
774        } else {
775            source.to_string()
776        };
777        let signature_byte_start = line_col_to_byte(
778            &resolved_source,
779            target.range.start_line,
780            target.range.start_col,
781        );
782        let signature_byte_end = line_col_to_byte(
783            &resolved_source,
784            target.range.end_line,
785            target.range.end_col,
786        );
787        let (target_byte_start, target_byte_end) =
788            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
789                .unwrap_or((signature_byte_start, signature_byte_end));
790
791        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
792
793        let raw_calls = all_file_calls.iter().filter(|call| {
794            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
795        });
796        let calls_out = dedupe_call_refs_by_name(
797            raw_calls
798                .filter(|call| {
799                    known_names.contains(&call.name.as_str()) && call.name != target.name
800                })
801                .map(|call| CallRef {
802                    name: call.name.clone(),
803                    line: call.line,
804                    extra_count: 0,
805                })
806                .collect(),
807        );
808
809        // called_by: bucket the single file-wide call extraction by enclosing symbol range
810        let mut called_by: Vec<CallRef> = Vec::new();
811        for sym in &all_symbols {
812            if sym.name == target.name && sym.range.start_line == target.range.start_line {
813                continue; // skip self
814            }
815            let sym_byte_start =
816                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
817            let sym_byte_end =
818                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
819            for call in &all_file_calls {
820                if call.name == target.name
821                    && call.start_byte >= sym_byte_start
822                    && call.end_byte <= sym_byte_end
823                {
824                    called_by.push(CallRef {
825                        name: sym.name.clone(),
826                        line: call.line,
827                        extra_count: 0,
828                    });
829                }
830            }
831        }
832
833        let called_by = dedupe_call_refs_by_name(called_by);
834
835        (calls_out, called_by)
836    } else {
837        (Vec::new(), Vec::new())
838    };
839
840    let kind_str = symbol_kind_string(&target.kind);
841
842    let resp = ZoomResponse {
843        name: target.name.clone(),
844        kind: kind_str,
845        range: target.range.clone(),
846        content,
847        context_before,
848        context_after,
849        annotations: Annotations {
850            calls_out,
851            called_by,
852        },
853    };
854
855    match serde_json::to_value(&resp) {
856        Ok(resp_json) => Response::success(&req.id, resp_json),
857        Err(err) => Response::error(
858            &req.id,
859            "internal_error",
860            format!("zoom: failed to serialize response: {err}"),
861        ),
862    }
863}
864
865fn empty_annotations() -> serde_json::Value {
866    serde_json::json!({
867        "calls_out": [],
868        "called_by": [],
869    })
870}
871
872fn render_ambiguous_symbol_menu(
873    symbol_name: &str,
874    matches: &[crate::symbols::SymbolMatch],
875) -> String {
876    let mut lines = vec![format!(
877        "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
878        matches.len()
879    )];
880
881    for candidate in matches {
882        let entry = symbol_to_entry(&candidate.symbol);
883        lines.push(format!(
884            "- {}",
885            format_qualified_entry(&entry, Some(&candidate.symbol))
886        ));
887    }
888
889    lines.join("\n")
890}
891
892fn levenshtein_distance(s1: &str, s2: &str) -> usize {
893    let s1_chars: Vec<char> = s1.chars().collect();
894    let s2_chars: Vec<char> = s2.chars().collect();
895    let len1 = s1_chars.len();
896    let len2 = s2_chars.len();
897
898    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
899
900    for i in 0..=len1 {
901        dp[i][0] = i;
902    }
903    for j in 0..=len2 {
904        dp[0][j] = j;
905    }
906
907    for i in 1..=len1 {
908        for j in 1..=len2 {
909            if s1_chars[i - 1] == s2_chars[j - 1] {
910                dp[i][j] = dp[i - 1][j - 1];
911            } else {
912                dp[i][j] =
913                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
914            }
915        }
916    }
917
918    dp[len1][len2]
919}
920
921fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
922    let mut unique: Vec<&String> = available.iter().collect();
923    unique.sort();
924    unique.dedup();
925
926    let query_lower = query.to_lowercase();
927    let query_len = query_lower.chars().count();
928    let max_dist = std::cmp::max(2, query_len / 3);
929
930    let mut scored: Vec<(bool, usize, &String)> = unique
931        .into_iter()
932        .map(|name| {
933            let name_lower = name.to_lowercase();
934            let is_substring =
935                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
936            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
937                (query_lower.find('_'), query_lower.rfind('_'))
938            {
939                let prefix = &query_lower[..=first_idx];
940                let suffix = &query_lower[last_idx..];
941                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
942            } else {
943                false
944            };
945            let is_match = is_substring || is_wildcard;
946            let dist = levenshtein_distance(&query_lower, &name_lower);
947            (is_match, dist, name)
948        })
949        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
950        .collect();
951
952    scored.sort_by(|a, b| {
953        let a_match = a.0;
954        let b_match = b.0;
955        (!a_match)
956            .cmp(&(!b_match))
957            .then_with(|| a.1.cmp(&b.1))
958            .then_with(|| a.2.cmp(b.2))
959    });
960
961    scored
962        .into_iter()
963        .take(k)
964        .map(|(_, _, name)| name.clone())
965        .collect()
966}
967
968fn resolve_zoom_symbol(
969    provider: &dyn LanguageProvider,
970    path: &Path,
971    query: &str,
972    is_heading: bool,
973) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
974    if is_heading {
975        return resolve_heading_symbols(provider, path, query);
976    }
977
978    match provider.resolve_symbol(path, query) {
979        Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
980        result => result,
981    }
982}
983
984/// A resolved JSON node: the tree-sitter node plus the human-readable path
985/// segments used to reach it (for error messages and candidate labels).
986struct JsonNode<'a> {
987    node: tree_sitter::Node<'a>,
988    path: String,
989}
990
991/// Resolve a zoom request against a JSON document.
992///
993/// The query is resolved in two independent ways and the results compared:
994///
995/// 1. **Literal first**: the query is matched as an exact top-level key name
996///    using the provider's normal symbol resolution. JSON keys may legitimately
997///    contain dots, so a literal match always wins outright.
998/// 2. **Path walk**: if there is no literal match, the query is split on `.`
999///    and walked through the document — object steps by key, array steps by
1000///    `name[index]` (0-based) or bare `[index]`.
1001///
1002/// If both a literal key and a successful path walk resolve to DIFFERENT nodes,
1003/// the query is ambiguous and both candidates are reported. If they resolve to
1004/// the same node, the literal match is returned (not an error).
1005fn resolve_json_zoom(
1006    req: &RawRequest,
1007    ctx: &AppContext,
1008    path: &Path,
1009    source: &str,
1010    lines: &[String],
1011    symbol_name: &str,
1012    context_lines: usize,
1013    include_callgraph: bool,
1014) -> Response {
1015    let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1016    let (tree, _) = match parser.parse(path) {
1017        Ok(parsed) => parsed,
1018        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1019    };
1020    let root = tree.root_node();
1021
1022    // Literal resolution: exact top-level key via the provider.
1023    let literal = match ctx.provider().resolve_symbol(path, symbol_name) {
1024        Ok(matches) => matches,
1025        Err(crate::error::AftError::SymbolNotFound { .. }) => Vec::new(),
1026        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1027    };
1028
1029    // Path resolution: walk the document by segments.
1030    let path_result = json_path_resolve(source, &root, symbol_name);
1031
1032    // Both a literal key and a path walk resolved. Compare the literal key's
1033    // VALUE node against the path-walked node. A plain top-level key like
1034    // `servers` resolves to the same value node both ways, so it is not
1035    // ambiguous. A dotted query where a literal key AND a nested path both
1036    // exist (e.g. `literal.dotted.key`) resolves to different nodes → ambiguous.
1037    if !literal.is_empty() {
1038        if let Some(path_node) = path_result.as_ref() {
1039            let literal_value_node = root
1040                .named_child(0)
1041                .and_then(|object| json_object_value(source, object, symbol_name));
1042            let same_node = literal_value_node
1043                .map(|node| {
1044                    node.start_position().row == path_node.node.start_position().row
1045                        && node.start_position().column == path_node.node.start_position().column
1046                        && node.end_position().row == path_node.node.end_position().row
1047                        && node.end_position().column == path_node.node.end_position().column
1048                })
1049                .unwrap_or(false);
1050            if !same_node {
1051                let literal_node = &literal[0].symbol;
1052                let candidates = vec![
1053                    serde_json::json!({
1054                        "name": symbol_name,
1055                        "kind": symbol_kind_string(&literal_node.kind),
1056                        "range": literal_node.range.clone(),
1057                        "signature": literal_node.signature.clone(),
1058                    }),
1059                    serde_json::json!({
1060                        "name": path_node.path.clone(),
1061                        "kind": "json_path",
1062                        "range": node_range(&path_node.node),
1063                        "signature": serde_json::Value::Null,
1064                    }),
1065                ];
1066                return Response::error_with_data(
1067                    &req.id,
1068                    "ambiguous_match",
1069                    format!(
1070                        "symbol '{}' is ambiguous: a literal key and a JSON path both resolve to different nodes",
1071                        symbol_name
1072                    ),
1073                    serde_json::json!({ "candidates": candidates }),
1074                );
1075            }
1076        }
1077    }
1078
1079    // Prefer the literal match when present (literal-first).
1080    if !literal.is_empty() {
1081        return render_json_zoom(
1082            req,
1083            ctx,
1084            path,
1085            source,
1086            lines,
1087            symbol_name,
1088            &literal[0].symbol,
1089            context_lines,
1090            include_callgraph,
1091        );
1092    }
1093
1094    // Otherwise use the path walk result.
1095    if let Some(resolved) = path_result {
1096        return render_json_zoom(
1097            req,
1098            ctx,
1099            path,
1100            source,
1101            lines,
1102            &resolved.path,
1103            &json_node_to_symbol(&resolved.node, &resolved.path),
1104            context_lines,
1105            include_callgraph,
1106        );
1107    }
1108
1109    // Miss: report the deepest resolved prefix and the failing segment, and
1110    // suggest sibling keys within the deepest resolved object.
1111    let (prefix, failing) = json_miss_details(symbol_name);
1112    let mut msg = if prefix.is_empty() {
1113        format!("symbol '{}' not found: no key `{}`", symbol_name, failing)
1114    } else {
1115        format!(
1116            "symbol '{}' not found: resolved `{}`, no key `{}`",
1117            symbol_name, prefix, failing
1118        )
1119    };
1120    let sibling_keys = json_sibling_keys(source, &root, &prefix);
1121    if !sibling_keys.is_empty() {
1122        let suggestions = suggest_close_symbols(&failing, &sibling_keys, 5);
1123        if !suggestions.is_empty() {
1124            msg.push_str(&format!(" — nearest: [{}]", suggestions.join(", ")));
1125        }
1126    }
1127    Response::error(&req.id, "symbol_not_found", msg)
1128}
1129
1130/// Walk a JSON document by a dotted path, returning the deepest resolved node.
1131///
1132/// Object steps match keys literally; array steps use `name[index]` (0-based)
1133/// or bare `[index]`. A segment containing brackets tries the bracket parse
1134/// only if no literal key of that exact spelling exists.
1135fn json_path_resolve<'a>(
1136    source: &str,
1137    root: &tree_sitter::Node<'a>,
1138    query: &str,
1139) -> Option<JsonNode<'a>> {
1140    let segments = split_json_path(query);
1141    if segments.is_empty() {
1142        return None;
1143    }
1144
1145    // The document root must be an object for keyed access.
1146    let mut current = root.named_child(0)?;
1147    if current.kind() != "object" {
1148        return None;
1149    }
1150
1151    let mut resolved_path = String::new();
1152    for (index, segment) in segments.iter().enumerate() {
1153        let (key, array_index) = parse_json_segment(segment);
1154        let next = if let Some(array_index) = array_index {
1155            // A segment like `servers[0]` first resolves the key to the array
1156            // node, then indexes into it. A bare `[0]` indexes the current node.
1157            let array = match key {
1158                Some(key) => json_object_value(source, current, key)?,
1159                None => current,
1160            };
1161            json_array_element(array, array_index)?
1162        } else {
1163            json_object_value(source, current, key?)?
1164        };
1165        if index == 0 {
1166            resolved_path = segment.clone();
1167        } else {
1168            resolved_path.push('.');
1169            resolved_path.push_str(segment);
1170        }
1171        current = next;
1172    }
1173
1174    Some(JsonNode {
1175        node: current,
1176        path: resolved_path,
1177    })
1178}
1179
1180/// Split a JSON path query into segments on `.`, preserving bracket groups.
1181fn split_json_path(query: &str) -> Vec<String> {
1182    let mut segments = Vec::new();
1183    let mut current = String::new();
1184    let mut depth = 0usize;
1185    for character in query.chars() {
1186        match character {
1187            '[' => {
1188                depth += 1;
1189                current.push(character);
1190            }
1191            ']' => {
1192                depth = depth.saturating_sub(1);
1193                current.push(character);
1194            }
1195            '.' if depth == 0 => {
1196                if !current.is_empty() {
1197                    segments.push(std::mem::take(&mut current));
1198                }
1199            }
1200            _ => current.push(character),
1201        }
1202    }
1203    if !current.is_empty() {
1204        segments.push(current);
1205    }
1206    segments
1207}
1208
1209/// Parse a single path segment into an optional object key and optional array index.
1210///
1211/// A segment like `servers[0]` yields key `servers` and index `0`; a bare `[0]`
1212/// yields no key and index `0`; a plain `host` yields key `host` and no index.
1213fn parse_json_segment(segment: &str) -> (Option<&str>, Option<usize>) {
1214    if let Some(open) = segment.find('[') {
1215        if segment.ends_with(']') {
1216            let key = if open == 0 {
1217                None
1218            } else {
1219                Some(&segment[..open])
1220            };
1221            let index_text = &segment[open + 1..segment.len() - 1];
1222            if let Ok(index) = index_text.parse::<usize>() {
1223                return (key, Some(index));
1224            }
1225        }
1226    }
1227    (Some(segment), None)
1228}
1229
1230/// Return the value node for a key in a JSON object, or `None` if absent.
1231fn json_object_value<'a>(
1232    source: &str,
1233    object: tree_sitter::Node<'a>,
1234    key: &str,
1235) -> Option<tree_sitter::Node<'a>> {
1236    if object.kind() != "object" {
1237        return None;
1238    }
1239    let mut cursor = object.walk();
1240    for pair in object.named_children(&mut cursor) {
1241        if pair.kind() != "pair" {
1242            continue;
1243        }
1244        let Some(key_node) = pair.child_by_field_name("key") else {
1245            continue;
1246        };
1247        if node_text(source, &key_node).trim_matches('"') == key {
1248            return pair.child_by_field_name("value");
1249        }
1250    }
1251    None
1252}
1253
1254/// Return the element at a 0-based index in a JSON array, or `None` if out of range.
1255fn json_array_element<'a>(
1256    array: tree_sitter::Node<'a>,
1257    index: usize,
1258) -> Option<tree_sitter::Node<'a>> {
1259    if array.kind() != "array" {
1260        return None;
1261    }
1262    let mut cursor = array.walk();
1263    for (seen, element) in array.named_children(&mut cursor).enumerate() {
1264        if seen == index {
1265            return Some(element);
1266        }
1267    }
1268    None
1269}
1270
1271/// Build a `Symbol` from a resolved JSON node for rendering.
1272fn json_node_to_symbol(node: &tree_sitter::Node, path: &str) -> Symbol {
1273    Symbol {
1274        name: path.to_string(),
1275        kind: SymbolKind::Variable,
1276        range: node_range(node),
1277        signature: None,
1278        scope_chain: vec![],
1279        exported: false,
1280        parent: None,
1281    }
1282}
1283
1284/// Render a resolved JSON node as a zoom response, mirroring a top-level zoom.
1285fn render_json_zoom(
1286    req: &RawRequest,
1287    ctx: &AppContext,
1288    path: &Path,
1289    source: &str,
1290    lines: &[String],
1291    name: &str,
1292    target: &Symbol,
1293    context_lines: usize,
1294    include_callgraph: bool,
1295) -> Response {
1296    let start = target.range.start_line as usize;
1297    let end = target.range.end_line as usize;
1298
1299    let content = if end < lines.len() {
1300        lines[start..=end].join("\n")
1301    } else {
1302        lines[start..].join("\n")
1303    };
1304
1305    let ctx_start = start.saturating_sub(context_lines);
1306    let context_before: Vec<String> = if ctx_start < start {
1307        lines[ctx_start..start].to_vec()
1308    } else {
1309        vec![]
1310    };
1311    let ctx_end = (end + 1 + context_lines).min(lines.len());
1312    let context_after: Vec<String> = if end + 1 < lines.len() {
1313        lines[(end + 1)..ctx_end].to_vec()
1314    } else {
1315        vec![]
1316    };
1317
1318    let (calls_out, called_by) = if include_callgraph {
1319        let all_symbols = match ctx.provider().list_symbols(path) {
1320            Ok(s) => s,
1321            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1322        };
1323        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
1324        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1325        let (tree, lang) = match parser.parse(path) {
1326            Ok(r) => r,
1327            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1328        };
1329        let all_file_calls = extract_calls_with_ranges(source, tree.root_node(), lang);
1330        let signature_byte_start =
1331            line_col_to_byte(source, target.range.start_line, target.range.start_col);
1332        let signature_byte_end =
1333            line_col_to_byte(source, target.range.end_line, target.range.end_col);
1334        let (target_byte_start, target_byte_end) =
1335            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
1336                .unwrap_or((signature_byte_start, signature_byte_end));
1337        let calls_out = dedupe_call_refs_by_name(
1338            all_file_calls
1339                .iter()
1340                .filter(|call| {
1341                    call.start_byte >= target_byte_start
1342                        && call.end_byte <= target_byte_end
1343                        && known_names.contains(&call.name.as_str())
1344                        && call.name != target.name
1345                })
1346                .map(|call| CallRef {
1347                    name: call.name.clone(),
1348                    line: call.line,
1349                    extra_count: 0,
1350                })
1351                .collect(),
1352        );
1353        (calls_out, Vec::new())
1354    } else {
1355        (Vec::new(), Vec::new())
1356    };
1357
1358    let resp = ZoomResponse {
1359        name: name.to_string(),
1360        kind: symbol_kind_string(&target.kind),
1361        range: target.range.clone(),
1362        content,
1363        context_before,
1364        context_after,
1365        annotations: Annotations {
1366            calls_out,
1367            called_by,
1368        },
1369    };
1370
1371    match serde_json::to_value(&resp) {
1372        Ok(resp_json) => Response::success(&req.id, resp_json),
1373        Err(err) => Response::error(
1374            &req.id,
1375            "internal_error",
1376            format!("zoom: failed to serialize response: {err}"),
1377        ),
1378    }
1379}
1380
1381/// Compute the deepest resolved prefix and the failing segment for a miss message.
1382///
1383/// For a single-segment query the prefix is empty (the document root) and the
1384/// failing segment is the whole query.
1385fn json_miss_details(query: &str) -> (String, String) {
1386    let segments = split_json_path(query);
1387    if segments.len() <= 1 {
1388        return (String::new(), query.to_string());
1389    }
1390    let prefix = segments[..segments.len() - 1].join(".");
1391    let failing = segments[segments.len() - 1].clone();
1392    (prefix, failing)
1393}
1394
1395/// Return the sibling keys of the deepest resolved object for a miss message.
1396///
1397/// For a single-segment query the "deepest resolved object" is the document
1398/// root; for a multi-segment query it is the object reached by the prefix.
1399fn json_sibling_keys(source: &str, root: &tree_sitter::Node, prefix: &str) -> Vec<String> {
1400    let object = if prefix.is_empty() {
1401        root.named_child(0)
1402    } else {
1403        json_path_resolve(source, root, prefix).map(|resolved| resolved.node)
1404    };
1405    let Some(object) = object else {
1406        return Vec::new();
1407    };
1408    if object.kind() != "object" {
1409        return Vec::new();
1410    }
1411    let mut keys = Vec::new();
1412    let mut cursor = object.walk();
1413    for pair in object.named_children(&mut cursor) {
1414        if pair.kind() != "pair" {
1415            continue;
1416        }
1417        if let Some(key_node) = pair.child_by_field_name("key") {
1418            let key = node_text(source, &key_node).trim_matches('"').to_string();
1419            if !key.is_empty() {
1420                keys.push(key);
1421            }
1422        }
1423    }
1424    keys
1425}
1426
1427/// Build a `Range` from a tree-sitter node (0-indexed, matching `Symbol::range`).
1428fn node_range(node: &tree_sitter::Node) -> Range {
1429    let start = node.start_position();
1430    let end = node.end_position();
1431    Range {
1432        start_line: start.row as u32,
1433        start_col: start.column as u32,
1434        end_line: end.row as u32,
1435        end_col: end.column as u32,
1436    }
1437}
1438
1439/// Keep document headings' raw labels for outline fidelity while allowing zoom to use
1440/// human-readable labels, section prefixes, or anchors without affecting code symbols.
1441fn resolve_heading_symbols(
1442    provider: &dyn LanguageProvider,
1443    path: &Path,
1444    query: &str,
1445) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1446    let headings: Vec<SymbolMatch> = provider
1447        .list_symbols(path)?
1448        .into_iter()
1449        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1450        .map(|symbol| SymbolMatch {
1451            file: path.display().to_string(),
1452            symbol,
1453        })
1454        .collect();
1455
1456    let anchors = if query.starts_with('#') {
1457        provider.heading_anchors(path)?
1458    } else {
1459        Vec::new()
1460    };
1461
1462    Ok(match_heading_identity(&headings, query, &anchors))
1463}
1464
1465fn match_heading_identity(
1466    headings: &[SymbolMatch],
1467    query: &str,
1468    anchors: &[HeadingAnchor],
1469) -> Vec<SymbolMatch> {
1470    if let Some(anchor_query) = query.strip_prefix('#') {
1471        let mut matches: Vec<_> = headings
1472            .iter()
1473            .filter(|candidate| {
1474                anchors.iter().any(|anchor| {
1475                    anchor.id == anchor_query
1476                        && anchor.start_line == candidate.symbol.range.start_line
1477                        && anchor.start_col == candidate.symbol.range.start_col
1478                })
1479            })
1480            .cloned()
1481            .collect();
1482
1483        let normalized_query = normalize_heading_label(query);
1484        let query_slug = slugify_heading_label(&normalized_query);
1485        if !query_slug.is_empty() {
1486            for candidate in headings
1487                .iter()
1488                .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1489            {
1490                if !matches.iter().any(|existing| {
1491                    existing.file == candidate.file && existing.symbol == candidate.symbol
1492                }) {
1493                    matches.push(candidate.clone());
1494                }
1495            }
1496        }
1497        return matches;
1498    }
1499
1500    let exact: Vec<_> = headings
1501        .iter()
1502        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1503        .cloned()
1504        .collect();
1505    if !exact.is_empty() {
1506        return exact;
1507    }
1508
1509    let normalized_query = normalize_heading_label(query);
1510    if normalized_query.is_empty() {
1511        return Vec::new();
1512    }
1513
1514    let normalized: Vec<_> = headings
1515        .iter()
1516        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1517        .cloned()
1518        .collect();
1519    if !normalized.is_empty() {
1520        return normalized;
1521    }
1522
1523    let folded_query = normalized_query.to_lowercase();
1524    let case_insensitive: Vec<_> = headings
1525        .iter()
1526        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1527        .cloned()
1528        .collect();
1529    if !case_insensitive.is_empty() {
1530        return case_insensitive;
1531    }
1532
1533    let query_slug = slugify_heading_label(&normalized_query);
1534    if query_slug.is_empty() {
1535        return Vec::new();
1536    }
1537
1538    headings
1539        .iter()
1540        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1541        .cloned()
1542        .collect()
1543}
1544
1545fn qualified_heading_name(symbol: &Symbol) -> String {
1546    if symbol.scope_chain.is_empty() {
1547        return symbol.name.clone();
1548    }
1549    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1550}
1551
1552fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1553    symbol.name == query || qualified_heading_name(symbol) == query
1554}
1555
1556fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1557    normalize_heading_label(&symbol.name) == query
1558        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1559}
1560
1561fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1562    normalize_heading_label(&symbol.name).to_lowercase() == query
1563        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1564}
1565
1566fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1567    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1568        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1569            == query_slug
1570}
1571
1572fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1573    let available: Vec<String> = symbols
1574        .iter()
1575        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1576        .map(|symbol| normalize_heading_label(&symbol.name))
1577        .filter(|name| !name.is_empty())
1578        .collect();
1579    let normalized_query = normalize_heading_label(query);
1580    if normalized_query.is_empty() {
1581        return Vec::new();
1582    }
1583    suggest_close_symbols(&normalized_query, &available, k)
1584}
1585
1586fn normalize_heading_label(input: &str) -> String {
1587    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1588
1589    // A label can contain both a document prefix and a decorative symbol cluster.
1590    // Repeat the small cleanup sequence so either order is handled consistently.
1591    for _ in 0..4 {
1592        let mut next = value.as_str();
1593        let without_heading_markers = next.trim_start_matches('#').trim_start();
1594        if without_heading_markers != next {
1595            next = without_heading_markers;
1596        }
1597        if let Some(rest) = strip_heading_html_prefix(next) {
1598            next = rest;
1599        }
1600        if let Some(rest) = strip_leading_section_prefix(next) {
1601            next = rest;
1602        }
1603        if let Some(rest) = strip_leading_symbol_cluster(next) {
1604            next = rest;
1605        }
1606
1607        let collapsed = collapse_heading_whitespace(next);
1608        if collapsed == value {
1609            break;
1610        }
1611        value = collapsed;
1612    }
1613
1614    value
1615}
1616
1617fn collapse_heading_whitespace(input: &str) -> String {
1618    input.split_whitespace().collect::<Vec<_>>().join(" ")
1619}
1620
1621fn strip_markdown_links(input: &str) -> String {
1622    let mut output = String::with_capacity(input.len());
1623    let mut cursor = 0;
1624
1625    while let Some(relative_open) = input[cursor..].find('[') {
1626        let open = cursor + relative_open;
1627        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1628            output.push_str(&input[cursor..]);
1629            return output;
1630        };
1631
1632        if input.as_bytes().get(close + 1) != Some(&b'(') {
1633            output.push_str(&input[cursor..=close]);
1634            cursor = close + 1;
1635            continue;
1636        }
1637
1638        let target_open = close + 1;
1639        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1640            output.push_str(&input[cursor..]);
1641            return output;
1642        };
1643
1644        output.push_str(&input[cursor..open]);
1645        output.push_str(&input[open + 1..close]);
1646        cursor = target_close + 1;
1647    }
1648
1649    output.push_str(&input[cursor..]);
1650    output
1651}
1652
1653fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1654    let mut depth = 0;
1655    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1656        if *byte == open {
1657            depth += 1;
1658        } else if *byte == close {
1659            depth -= 1;
1660            if depth == 0 {
1661                return Some(index);
1662            }
1663        }
1664    }
1665    None
1666}
1667
1668fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1669    let input = input.trim_start();
1670    let bytes = input.as_bytes();
1671    if bytes.first() != Some(&b'<') {
1672        return None;
1673    }
1674
1675    let mut index = 1;
1676    if bytes.get(index) == Some(&b'/') {
1677        index += 1;
1678    }
1679    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1680        return None;
1681    }
1682    index += 1;
1683    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1684        return None;
1685    }
1686
1687    let end = input.find('>')?;
1688    let rest = input[end + 1..].trim_start();
1689    if rest.chars().any(|character| character.is_alphanumeric()) {
1690        Some(rest)
1691    } else {
1692        None
1693    }
1694}
1695
1696fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1697    let bytes = input.as_bytes();
1698    let mut index = 0;
1699    let mut saw_dot = false;
1700
1701    if !bytes
1702        .first()
1703        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1704    {
1705        return None;
1706    }
1707
1708    while index < bytes.len() {
1709        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1710            index += 1;
1711        }
1712        if bytes.get(index) != Some(&b'.') {
1713            break;
1714        }
1715        saw_dot = true;
1716        index += 1;
1717        if bytes
1718            .get(index)
1719            .is_some_and(|byte| byte.is_ascii_whitespace())
1720        {
1721            let rest = input[index..].trim_start();
1722            return if rest.chars().any(|character| character.is_alphanumeric()) {
1723                Some(rest)
1724            } else {
1725                None
1726            };
1727        }
1728        if !bytes
1729            .get(index)
1730            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1731        {
1732            return None;
1733        }
1734    }
1735
1736    if saw_dot
1737        && bytes
1738            .get(index)
1739            .is_some_and(|byte| byte.is_ascii_whitespace())
1740    {
1741        let rest = input[index..].trim_start();
1742        if rest.chars().any(|character| character.is_alphanumeric()) {
1743            return Some(rest);
1744        }
1745    }
1746    None
1747}
1748
1749fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1750    let first_text = input
1751        .char_indices()
1752        .find(|(_, character)| character.is_alphanumeric())
1753        .map(|(index, _)| index)?;
1754    if first_text == 0 {
1755        return None;
1756    }
1757
1758    let rest = &input[first_text..];
1759    if rest.chars().any(|character| character.is_alphanumeric()) {
1760        Some(rest)
1761    } else {
1762        None
1763    }
1764}
1765
1766fn slugify_heading_label(label: &str) -> String {
1767    let mut slug = String::new();
1768    let mut pending_separator = false;
1769
1770    for character in label.chars() {
1771        if character.is_alphanumeric() {
1772            if pending_separator && !slug.is_empty() {
1773                slug.push('-');
1774            }
1775            for lowercase in character.to_lowercase() {
1776                slug.push(lowercase);
1777            }
1778            pending_separator = false;
1779        } else if !slug.is_empty() {
1780            pending_separator = true;
1781        }
1782    }
1783
1784    slug
1785}
1786
1787/// Extract call expression names within a byte range of the AST.
1788///
1789/// Delegates to `crate::calls::extract_calls_in_range`.
1790#[cfg(test)]
1791fn extract_calls_in_range(
1792    source: &str,
1793    root: tree_sitter::Node,
1794    byte_start: usize,
1795    byte_end: usize,
1796    lang: LangId,
1797) -> Vec<(String, u32)> {
1798    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1799}
1800
1801fn symbol_body_byte_range(
1802    root: tree_sitter::Node,
1803    byte_start: usize,
1804    byte_end: usize,
1805) -> Option<(usize, usize)> {
1806    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1807    let mut current = Some(node);
1808    while let Some(node) = current {
1809        if is_symbol_body_node(node.kind()) {
1810            return Some((node.start_byte(), node.end_byte()));
1811        }
1812        current = node.parent();
1813    }
1814    Some((node.start_byte(), node.end_byte()))
1815}
1816
1817fn smallest_node_covering_range<'tree>(
1818    node: tree_sitter::Node<'tree>,
1819    byte_start: usize,
1820    byte_end: usize,
1821) -> Option<tree_sitter::Node<'tree>> {
1822    if node.start_byte() > byte_start || node.end_byte() < byte_end {
1823        return None;
1824    }
1825
1826    let mut cursor = node.walk();
1827    if cursor.goto_first_child() {
1828        loop {
1829            let child = cursor.node();
1830            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1831                return Some(found);
1832            }
1833            if !cursor.goto_next_sibling() {
1834                break;
1835            }
1836        }
1837    }
1838
1839    Some(node)
1840}
1841
1842fn is_symbol_body_node(kind: &str) -> bool {
1843    matches!(
1844        kind,
1845        "function_declaration"
1846            | "generator_function_declaration"
1847            | "function_expression"
1848            | "generator_function"
1849            | "arrow_function"
1850            | "method_definition"
1851            | "class_declaration"
1852            | "abstract_class_declaration"
1853            | "class"
1854            | "lexical_declaration"
1855            | "function_definition"
1856            | "class_definition"
1857            | "decorated_definition"
1858            | "function_item"
1859            | "impl_item"
1860            | "method_declaration"
1861    )
1862}
1863
1864fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1865    let mut results = Vec::new();
1866    let call_kinds = crate::calls::call_node_kinds(lang);
1867    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1868    results
1869}
1870
1871fn collect_calls_with_ranges(
1872    node: tree_sitter::Node,
1873    source: &str,
1874    call_kinds: &[&str],
1875    results: &mut Vec<RawCall>,
1876) {
1877    if call_kinds.contains(&node.kind()) {
1878        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1879            results.push(RawCall {
1880                name,
1881                line: node.start_position().row as u32 + 1,
1882                start_byte: node.start_byte(),
1883                end_byte: node.end_byte(),
1884            });
1885        }
1886    }
1887
1888    let mut cursor = node.walk();
1889    if cursor.goto_first_child() {
1890        loop {
1891            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1892            if !cursor.goto_next_sibling() {
1893                break;
1894            }
1895        }
1896    }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use crate::config::Config;
1903    use crate::context::AppContext;
1904    use crate::parser::TreeSitterProvider;
1905    use std::path::PathBuf;
1906
1907    fn fixture_path(name: &str) -> PathBuf {
1908        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1909            .join("tests")
1910            .join("fixtures")
1911            .join(name)
1912    }
1913
1914    fn make_ctx() -> AppContext {
1915        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1916    }
1917
1918    #[test]
1919    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1920        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1921        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1922        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1923    }
1924
1925    #[test]
1926    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1927        let params = serde_json::json!({ "symbols": "Getting Started" });
1928        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1929        assert_eq!(names, vec!["Getting Started"]);
1930    }
1931
1932    #[test]
1933    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1934        let params = serde_json::json!({ "symbol": "Last Heading" });
1935        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1936        assert_eq!(names, vec!["Last Heading"]);
1937    }
1938
1939    #[test]
1940    fn parse_zoom_symbol_names_single_token_unchanged() {
1941        let params = serde_json::json!({ "symbol": "compute" });
1942        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1943        assert_eq!(names, vec!["compute"]);
1944    }
1945
1946    #[test]
1947    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1948        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1949        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1950        assert_eq!(names, vec!["A", "B", "C"]);
1951    }
1952
1953    #[test]
1954    fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1955        // Models sometimes JSON-stringify the array form; headings keep spaces.
1956        let params =
1957            serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1958        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1959        assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1960    }
1961
1962    #[test]
1963    fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
1964        let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
1965        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1966        assert_eq!(names, vec!["alpha", "beta"]);
1967    }
1968
1969    #[test]
1970    fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
1971        // A real heading that starts with '[' but is not a JSON array must
1972        // stay a single lookup name.
1973        let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
1974        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1975        assert_eq!(names, vec!["[Draft] Rollout plan"]);
1976    }
1977
1978    #[test]
1979    fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
1980        // "[1, 2]" parses as JSON but not as symbol names; fall through to
1981        // ordinary handling rather than returning numbers-as-names.
1982        let params = serde_json::json!({ "symbols": "[1, 2]" });
1983        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1984        assert_eq!(names, vec!["[1,", "2]"]);
1985    }
1986
1987    // --- Call extraction tests ---
1988
1989    #[test]
1990    fn extract_calls_finds_direct_calls() {
1991        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1992        let mut parser = FileParser::new();
1993        let path = fixture_path("calls.ts");
1994        let (tree, lang) = parser.parse(&path).unwrap();
1995
1996        // `compute` calls `helper` — find compute's range from symbols
1997        let ctx = make_ctx();
1998        let symbols = ctx.provider().list_symbols(&path).unwrap();
1999        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2000
2001        let byte_start =
2002            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
2003        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
2004
2005        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2006        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2007
2008        assert!(
2009            names.contains(&"helper"),
2010            "compute should call helper, got: {:?}",
2011            names
2012        );
2013    }
2014
2015    #[test]
2016    fn extract_calls_finds_member_calls() {
2017        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2018        let mut parser = FileParser::new();
2019        let path = fixture_path("calls.ts");
2020        let (tree, lang) = parser.parse(&path).unwrap();
2021
2022        let ctx = make_ctx();
2023        let symbols = ctx.provider().list_symbols(&path).unwrap();
2024        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
2025
2026        let byte_start =
2027            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
2028        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
2029
2030        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2031        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2032
2033        assert!(
2034            names.contains(&"add"),
2035            "runAll should call this.add, got: {:?}",
2036            names
2037        );
2038        assert!(
2039            names.contains(&"helper"),
2040            "runAll should call helper, got: {:?}",
2041            names
2042        );
2043    }
2044
2045    #[test]
2046    fn extract_calls_unused_function_has_no_calls() {
2047        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2048        let mut parser = FileParser::new();
2049        let path = fixture_path("calls.ts");
2050        let (tree, lang) = parser.parse(&path).unwrap();
2051
2052        let ctx = make_ctx();
2053        let symbols = ctx.provider().list_symbols(&path).unwrap();
2054        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
2055
2056        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
2057        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
2058
2059        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2060        // console.log is the only call, but "log" or "console" aren't known symbols
2061        let known_names = [
2062            "helper",
2063            "compute",
2064            "orchestrate",
2065            "unused",
2066            "format",
2067            "display",
2068        ];
2069        let filtered: Vec<&str> = calls
2070            .iter()
2071            .map(|(n, _)| n.as_str())
2072            .filter(|n| known_names.contains(n))
2073            .collect();
2074        assert!(
2075            filtered.is_empty(),
2076            "unused should not call known symbols, got: {:?}",
2077            filtered
2078        );
2079    }
2080
2081    // --- Context line tests ---
2082
2083    #[test]
2084    fn context_lines_clamp_at_file_start() {
2085        // helper() is at the top of the file (line 2) — context_before should be clamped
2086        let ctx = make_ctx();
2087        let path = fixture_path("calls.ts");
2088        let symbols = ctx.provider().list_symbols(&path).unwrap();
2089        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
2090
2091        let source = std::fs::read_to_string(&path).unwrap();
2092        let lines: Vec<&str> = source.lines().collect();
2093        let start = helper.range.start_line as usize;
2094
2095        // With context_lines=5, ctx_start should clamp to 0
2096        let ctx_start = start.saturating_sub(5);
2097        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
2098        // Should have at most `start` lines (not panic)
2099        assert!(context_before.len() <= start);
2100    }
2101
2102    #[test]
2103    fn context_lines_clamp_at_file_end() {
2104        let ctx = make_ctx();
2105        let path = fixture_path("calls.ts");
2106        let symbols = ctx.provider().list_symbols(&path).unwrap();
2107        let display = symbols.iter().find(|s| s.name == "display").unwrap();
2108
2109        let source = std::fs::read_to_string(&path).unwrap();
2110        let lines: Vec<&str> = source.lines().collect();
2111        let end = display.range.end_line as usize;
2112
2113        // With context_lines=20, should clamp to file length
2114        let ctx_end = (end + 1 + 20).min(lines.len());
2115        let context_after: Vec<&str> = if end + 1 < lines.len() {
2116            lines[(end + 1)..ctx_end].to_vec()
2117        } else {
2118            vec![]
2119        };
2120        // Should not panic regardless of context_lines size
2121        assert!(context_after.len() <= 20);
2122    }
2123
2124    // --- Body extraction test ---
2125
2126    #[test]
2127    fn body_extraction_matches_source() {
2128        let ctx = make_ctx();
2129        let path = fixture_path("calls.ts");
2130        let symbols = ctx.provider().list_symbols(&path).unwrap();
2131        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2132
2133        let source = std::fs::read_to_string(&path).unwrap();
2134        let lines: Vec<&str> = source.lines().collect();
2135        let start = compute.range.start_line as usize;
2136        let end = compute.range.end_line as usize;
2137        let body = lines[start..=end].join("\n");
2138
2139        assert!(
2140            body.contains("function compute"),
2141            "body should contain function declaration"
2142        );
2143        assert!(
2144            body.contains("helper(a)"),
2145            "body should contain call to helper"
2146        );
2147        assert!(
2148            body.contains("doubled + b"),
2149            "body should contain return expression"
2150        );
2151    }
2152
2153    // --- Full zoom response tests ---
2154
2155    #[test]
2156    fn body_range_expands_signature_range_to_include_body_calls() {
2157        let source = r#"function compute(
2158  value: number,
2159): number {
2160  return helper(value);
2161}
2162
2163function helper(value: number): number {
2164  return value * 2;
2165}
2166"#;
2167        let grammar = crate::parser::grammar_for(LangId::TypeScript);
2168        let mut parser = tree_sitter::Parser::new();
2169        parser.set_language(&grammar).unwrap();
2170        let tree = parser.parse(source, None).unwrap();
2171        let signature_end = source.find('{').expect("function has body");
2172
2173        let (body_start, body_end) =
2174            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
2175        let calls = extract_calls_in_range(
2176            source,
2177            tree.root_node(),
2178            body_start,
2179            body_end,
2180            LangId::TypeScript,
2181        );
2182        let names = calls
2183            .iter()
2184            .map(|(name, _)| name.as_str())
2185            .collect::<Vec<_>>();
2186
2187        assert!(
2188            names.contains(&"helper"),
2189            "call inside the function body should be included: {names:?}"
2190        );
2191    }
2192
2193    #[test]
2194    fn zoom_leaf_returns_full_body_without_budget_marker() {
2195        let ctx = make_ctx();
2196        let path = fixture_path("calls.ts");
2197        let req = make_zoom_request(
2198            "z-leaf-full",
2199            path.to_str().unwrap(),
2200            "repeatedOutgoing",
2201            None,
2202        );
2203        let resp = handle_zoom(&req, &ctx);
2204        let json = serde_json::to_value(&resp).unwrap();
2205        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2206
2207        let symbols = ctx.provider().list_symbols(&path).unwrap();
2208        let target = symbols
2209            .iter()
2210            .find(|symbol| symbol.name == "repeatedOutgoing")
2211            .unwrap();
2212        let source = std::fs::read_to_string(&path).unwrap();
2213        let lines = source.lines().collect::<Vec<_>>();
2214        let expected =
2215            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
2216
2217        assert_eq!(json["content"].as_str().unwrap(), expected);
2218        assert!(
2219            !json["content"]
2220                .as_str()
2221                .unwrap()
2222                .contains("more lines — zoom"),
2223            "explicit zoom must not budget-cap leaf bodies"
2224        );
2225    }
2226
2227    #[test]
2228    fn zoom_response_has_calls_out_and_called_by() {
2229        let ctx = make_ctx();
2230        let path = fixture_path("calls.ts");
2231
2232        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
2233        let resp = handle_zoom(&req, &ctx);
2234
2235        let json = serde_json::to_value(&resp).unwrap();
2236        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2237
2238        let calls_out = json["annotations"]["calls_out"]
2239            .as_array()
2240            .expect("calls_out array");
2241        let out_names: Vec<&str> = calls_out
2242            .iter()
2243            .map(|c| c["name"].as_str().unwrap())
2244            .collect();
2245        assert!(
2246            out_names.contains(&"helper"),
2247            "compute calls helper: {:?}",
2248            out_names
2249        );
2250
2251        let called_by = json["annotations"]["called_by"]
2252            .as_array()
2253            .expect("called_by array");
2254        let by_names: Vec<&str> = called_by
2255            .iter()
2256            .map(|c| c["name"].as_str().unwrap())
2257            .collect();
2258        assert!(
2259            by_names.contains(&"orchestrate"),
2260            "orchestrate calls compute: {:?}",
2261            by_names
2262        );
2263    }
2264
2265    #[test]
2266    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
2267        let ctx = make_ctx();
2268        let path = fixture_path("calls.ts");
2269
2270        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
2271        let resp = handle_zoom(&req, &ctx);
2272        let json = serde_json::to_value(&resp).unwrap();
2273        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2274
2275        let calls_out = json["annotations"]["calls_out"]
2276            .as_array()
2277            .expect("calls_out array");
2278        let helper_refs = calls_out
2279            .iter()
2280            .filter(|call| call["name"] == "helper")
2281            .collect::<Vec<_>>();
2282        assert_eq!(
2283            helper_refs.len(),
2284            1,
2285            "helper should be folded once: {calls_out:?}"
2286        );
2287        assert_eq!(helper_refs[0]["extra_count"], 1);
2288        assert!(
2289            calls_out.iter().any(|call| call["name"] == "format"),
2290            "distinct callee must not be folded into helper: {calls_out:?}"
2291        );
2292
2293        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
2294        let resp = handle_zoom(&req, &ctx);
2295        let json = serde_json::to_value(&resp).unwrap();
2296        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2297
2298        let called_by = json["annotations"]["called_by"]
2299            .as_array()
2300            .expect("called_by array");
2301        let repeat_refs = called_by
2302            .iter()
2303            .filter(|call| call["name"] == "repeatCompute")
2304            .collect::<Vec<_>>();
2305        assert_eq!(
2306            repeat_refs.len(),
2307            1,
2308            "repeatCompute should be folded once: {called_by:?}"
2309        );
2310        assert_eq!(repeat_refs[0]["extra_count"], 1);
2311        assert!(
2312            called_by.iter().any(|call| call["name"] == "orchestrate"),
2313            "distinct caller must not be folded into repeatCompute: {called_by:?}"
2314        );
2315    }
2316
2317    #[test]
2318    fn zoom_response_empty_annotations_for_unused() {
2319        let ctx = make_ctx();
2320        let path = fixture_path("calls.ts");
2321
2322        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
2323        let resp = handle_zoom(&req, &ctx);
2324
2325        let json = serde_json::to_value(&resp).unwrap();
2326        assert_eq!(json["success"], true);
2327
2328        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
2329        let called_by = json["annotations"]["called_by"].as_array().unwrap();
2330
2331        // calls_out exists (may contain console.log but no known symbols)
2332        // called_by should be empty — nobody calls unused
2333        assert!(
2334            called_by.is_empty(),
2335            "unused should not be called by anyone: {:?}",
2336            called_by
2337        );
2338    }
2339
2340    #[test]
2341    fn zoom_default_omits_callgraph_annotations() {
2342        let ctx = make_ctx();
2343        let path = fixture_path("calls.ts");
2344
2345        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
2346        let resp = handle_zoom(&req, &ctx);
2347
2348        let json = serde_json::to_value(&resp).unwrap();
2349        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2350
2351        let calls_out = json["annotations"]["calls_out"]
2352            .as_array()
2353            .expect("calls_out array");
2354        let called_by = json["annotations"]["called_by"]
2355            .as_array()
2356            .expect("called_by array");
2357        assert!(
2358            calls_out.is_empty(),
2359            "default zoom should omit calls_out: {:?}",
2360            calls_out
2361        );
2362        assert!(
2363            called_by.is_empty(),
2364            "default zoom should omit called_by: {:?}",
2365            called_by
2366        );
2367    }
2368
2369    #[test]
2370    fn zoom_symbol_not_found() {
2371        let ctx = make_ctx();
2372        let path = fixture_path("calls.ts");
2373
2374        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
2375        let resp = handle_zoom(&req, &ctx);
2376
2377        let json = serde_json::to_value(&resp).unwrap();
2378        assert_eq!(json["success"], false);
2379        assert_eq!(json["code"], "symbol_not_found");
2380    }
2381
2382    #[test]
2383    fn zoom_custom_context_lines() {
2384        let ctx = make_ctx();
2385        let path = fixture_path("calls.ts");
2386
2387        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
2388        let resp = handle_zoom(&req, &ctx);
2389
2390        let json = serde_json::to_value(&resp).unwrap();
2391        assert_eq!(json["success"], true);
2392
2393        let ctx_before = json["context_before"].as_array().unwrap();
2394        let ctx_after = json["context_after"].as_array().unwrap();
2395        // With context_lines=1, we get at most 1 line before and after
2396        assert!(
2397            ctx_before.len() <= 1,
2398            "context_before should be ≤1: {:?}",
2399            ctx_before
2400        );
2401        assert!(
2402            ctx_after.len() <= 1,
2403            "context_after should be ≤1: {:?}",
2404            ctx_after
2405        );
2406    }
2407
2408    #[test]
2409    fn zoom_missing_file_param() {
2410        let ctx = make_ctx();
2411        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
2412        let resp = handle_zoom(&req, &ctx);
2413
2414        let json = serde_json::to_value(&resp).unwrap();
2415        assert_eq!(json["success"], false);
2416        assert_eq!(json["code"], "invalid_request");
2417    }
2418
2419    #[test]
2420    fn zoom_missing_symbol_param() {
2421        let ctx = make_ctx();
2422        let path = fixture_path("calls.ts");
2423        // Build the JSON via serde_json so Windows paths (with backslashes)
2424        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
2425        // backslashes as escape sequences and fail to parse.
2426        let req_value = serde_json::json!({
2427            "id": "z-6",
2428            "command": "zoom",
2429            "file": path.to_string_lossy(),
2430        });
2431        let req_str = req_value.to_string();
2432        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
2433        let resp = handle_zoom(&req, &ctx);
2434
2435        let json = serde_json::to_value(&resp).unwrap();
2436        assert_eq!(json["success"], false);
2437        assert_eq!(json["code"], "invalid_request");
2438    }
2439
2440    #[test]
2441    fn test_suggest_close_symbols_unit() {
2442        let available = vec![
2443            "handle_grep_search".to_string(),
2444            "handle_semantic_search".to_string(),
2445            "handle_semantic_or_hybrid_search".to_string(),
2446            "compute_total".to_string(),
2447            "search".to_string(),
2448            "handle_search".to_string(),
2449        ];
2450
2451        let suggestions = suggest_close_symbols("handle_search", &available, 5);
2452        assert!(suggestions.contains(&"handle_grep_search".to_string()));
2453        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
2454        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
2455        assert!(suggestions.contains(&"search".to_string()));
2456        assert!(!suggestions.contains(&"compute_total".to_string()));
2457
2458        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
2459        assert_eq!(suggestions, suggestions_caps);
2460
2461        let available2 = vec![
2462            "total".to_string(),
2463            "compute_total".to_string(),
2464            "unrelated".to_string(),
2465        ];
2466        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
2467        assert_eq!(suggestions2, vec!["total".to_string()]);
2468    }
2469
2470    // --- Helpers ---
2471
2472    fn make_zoom_request(
2473        id: &str,
2474        file: &str,
2475        symbol: &str,
2476        context_lines: Option<u64>,
2477    ) -> RawRequest {
2478        let mut json = serde_json::json!({
2479            "id": id,
2480            "command": "zoom",
2481            "file": file,
2482            "symbol": symbol,
2483        });
2484        if let Some(cl) = context_lines {
2485            json["context_lines"] = serde_json::json!(cl);
2486        }
2487        serde_json::from_value(json).unwrap()
2488    }
2489
2490    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2491        let mut req = make_zoom_request(id, file, symbol, None);
2492        req.params["callgraph"] = serde_json::json!(true);
2493        req
2494    }
2495
2496    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2497        serde_json::from_str(json_str).unwrap()
2498    }
2499
2500    // --- JSON path resolution tests ---
2501
2502    fn json_fixture_tree() -> (String, tree_sitter::Tree) {
2503        let source = std::fs::read_to_string(fixture_path("nested.json")).unwrap();
2504        let mut parser = FileParser::new();
2505        let path = fixture_path("nested.json");
2506        let (tree, _) = parser.parse(&path).unwrap();
2507        (source, tree.clone())
2508    }
2509
2510    #[test]
2511    fn json_path_resolves_nested_object() {
2512        let (source, tree) = json_fixture_tree();
2513        let resolved = json_path_resolve(
2514            &source,
2515            &tree.root_node(),
2516            "registration_profile_manifest.nested.deep",
2517        )
2518        .expect("path should resolve");
2519        assert_eq!(resolved.path, "registration_profile_manifest.nested.deep");
2520        assert_eq!(node_text(&source, &resolved.node).trim(), "\"value\"");
2521    }
2522
2523    #[test]
2524    fn json_path_resolves_array_index() {
2525        let (source, tree) = json_fixture_tree();
2526        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[0]")
2527            .expect("path should resolve");
2528        assert_eq!(resolved.path, "servers[0]");
2529        assert!(node_text(&source, &resolved.node).contains("primary"));
2530    }
2531
2532    #[test]
2533    fn json_path_resolves_chained_array_index() {
2534        let (source, tree) = json_fixture_tree();
2535        let resolved =
2536            json_path_resolve(&source, &tree.root_node(), "a.b[1].c").expect("path should resolve");
2537        assert_eq!(resolved.path, "a.b[1].c");
2538        assert_eq!(node_text(&source, &resolved.node).trim(), "\"second\"");
2539    }
2540
2541    #[test]
2542    fn json_path_resolves_bare_array_index() {
2543        let (source, tree) = json_fixture_tree();
2544        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[1].name")
2545            .expect("path should resolve");
2546        assert_eq!(resolved.path, "servers[1].name");
2547        assert_eq!(node_text(&source, &resolved.node).trim(), "\"backup\"");
2548    }
2549
2550    #[test]
2551    fn json_path_miss_returns_none() {
2552        let (source, tree) = json_fixture_tree();
2553        assert!(json_path_resolve(
2554            &source,
2555            &tree.root_node(),
2556            "registration_profile_manifest.host_only_allowlis"
2557        )
2558        .is_none());
2559        assert!(json_path_resolve(&source, &tree.root_node(), "servers[9]").is_none());
2560        assert!(json_path_resolve(&source, &tree.root_node(), "missing").is_none());
2561    }
2562
2563    #[test]
2564    fn json_path_resolves_dotted_query_as_path() {
2565        // The fixture has BOTH a literal key "literal.dotted.key" and a nested
2566        // path literal.dotted.key. This unit test exercises the path-walk
2567        // function directly, which resolves to the nested path-value node.
2568        let (source, tree) = json_fixture_tree();
2569        let resolved = json_path_resolve(&source, &tree.root_node(), "literal.dotted.key")
2570            .expect("path should resolve");
2571        assert_eq!(node_text(&source, &resolved.node).trim(), "\"path-value\"");
2572    }
2573
2574    #[test]
2575    fn json_miss_details_reports_deepest_prefix() {
2576        let (prefix, failing) =
2577            json_miss_details("registration_profile_manifest.host_only_allowlis");
2578        assert_eq!(prefix, "registration_profile_manifest");
2579        assert_eq!(failing, "host_only_allowlis");
2580    }
2581
2582    #[test]
2583    fn json_miss_details_single_segment() {
2584        let (prefix, failing) = json_miss_details("missing");
2585        assert_eq!(prefix, "");
2586        assert_eq!(failing, "missing");
2587    }
2588
2589    #[test]
2590    fn split_json_path_keeps_bracket_groups() {
2591        assert_eq!(split_json_path("a.b[0].c"), vec!["a", "b[0]", "c"]);
2592        assert_eq!(split_json_path("servers[0]"), vec!["servers[0]"]);
2593        assert_eq!(split_json_path("a.b.c"), vec!["a", "b", "c"]);
2594    }
2595
2596    #[test]
2597    fn parse_json_segment_handles_key_and_index() {
2598        assert_eq!(parse_json_segment("servers[0]"), (Some("servers"), Some(0)));
2599        assert_eq!(parse_json_segment("[0]"), (None, Some(0)));
2600        assert_eq!(parse_json_segment("host"), (Some("host"), None));
2601    }
2602}