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, json_document_value, 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
991struct JsonPathMiss {
992    prefix: String,
993    failing: String,
994}
995
996/// Resolve a zoom request against a JSON document.
997///
998/// The query is resolved in two independent ways and the results compared:
999///
1000/// 1. **Literal first**: the query is matched as an exact top-level key name
1001///    using the provider's normal symbol resolution. JSON keys may legitimately
1002///    contain dots, so a literal match always wins outright.
1003/// 2. **Path walk**: if there is no literal match, the query is split on `.`
1004///    and walked through the document — object steps by key, array steps by
1005///    `name[index]` (0-based) or bare `[index]`.
1006///
1007/// If both a literal key and a successful path walk resolve to DIFFERENT nodes,
1008/// the query is ambiguous and both candidates are reported. If they resolve to
1009/// the same node, the literal match is returned (not an error).
1010fn resolve_json_zoom(
1011    req: &RawRequest,
1012    ctx: &AppContext,
1013    path: &Path,
1014    source: &str,
1015    lines: &[String],
1016    symbol_name: &str,
1017    context_lines: usize,
1018    include_callgraph: bool,
1019) -> Response {
1020    let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1021    let (tree, _) = match parser.parse(path) {
1022        Ok(parsed) => parsed,
1023        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1024    };
1025    let root = tree.root_node();
1026
1027    // Literal resolution: exact top-level key via the provider.
1028    let literal = match ctx.provider().resolve_symbol(path, symbol_name) {
1029        Ok(matches) => matches,
1030        Err(crate::error::AftError::SymbolNotFound { .. }) => Vec::new(),
1031        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1032    };
1033
1034    // Path resolution: walk the document by segments.
1035    let path_result = json_path_resolve(source, &root, symbol_name);
1036
1037    // Both a literal key and a path walk resolved. Compare the literal key's
1038    // VALUE node against the path-walked node. A plain top-level key like
1039    // `servers` resolves to the same value node both ways, so it is not
1040    // ambiguous. A dotted query where a literal key AND a nested path both
1041    // exist (e.g. `literal.dotted.key`) resolves to different nodes → ambiguous.
1042    if !literal.is_empty() {
1043        if let Some(path_node) = path_result.as_ref() {
1044            let literal_value_node = json_document_value(&root)
1045                .and_then(|object| json_object_value(source, object, symbol_name));
1046            let same_node = literal_value_node
1047                .map(|node| {
1048                    node.start_position().row == path_node.node.start_position().row
1049                        && node.start_position().column == path_node.node.start_position().column
1050                        && node.end_position().row == path_node.node.end_position().row
1051                        && node.end_position().column == path_node.node.end_position().column
1052                })
1053                .unwrap_or(false);
1054            if !same_node {
1055                let literal_node = &literal[0].symbol;
1056                let candidates = vec![
1057                    serde_json::json!({
1058                        "name": symbol_name,
1059                        "kind": symbol_kind_string(&literal_node.kind),
1060                        "range": literal_node.range.clone(),
1061                        "signature": literal_node.signature.clone(),
1062                    }),
1063                    serde_json::json!({
1064                        "name": path_node.path.clone(),
1065                        "kind": "json_path",
1066                        "range": node_range(&path_node.node),
1067                        "signature": serde_json::Value::Null,
1068                    }),
1069                ];
1070                return Response::error_with_data(
1071                    &req.id,
1072                    "ambiguous_match",
1073                    format!(
1074                        "symbol '{}' is ambiguous: a literal key and a JSON path both resolve to different nodes",
1075                        symbol_name
1076                    ),
1077                    serde_json::json!({ "candidates": candidates }),
1078                );
1079            }
1080        }
1081    }
1082
1083    // Prefer the literal match when present (literal-first).
1084    if !literal.is_empty() {
1085        return render_json_zoom(
1086            req,
1087            ctx,
1088            path,
1089            source,
1090            lines,
1091            symbol_name,
1092            &literal[0].symbol,
1093            context_lines,
1094            include_callgraph,
1095        );
1096    }
1097
1098    // Otherwise use the path walk result.
1099    if let Some(resolved) = path_result {
1100        return render_json_zoom(
1101            req,
1102            ctx,
1103            path,
1104            source,
1105            lines,
1106            &resolved.path,
1107            &json_node_to_symbol(&resolved.node, &resolved.path),
1108            context_lines,
1109            include_callgraph,
1110        );
1111    }
1112
1113    // Miss: report the deepest resolved prefix and the failing segment, and
1114    // suggest sibling keys within the deepest resolved object.
1115    let (prefix, failing) = json_miss_details(source, &root, symbol_name);
1116    let mut msg = if prefix.is_empty() {
1117        format!("symbol '{}' not found: no key `{}`", symbol_name, failing)
1118    } else {
1119        format!(
1120            "symbol '{}' not found: resolved `{}`, no key `{}`",
1121            symbol_name, prefix, failing
1122        )
1123    };
1124    let sibling_keys = json_sibling_keys(source, &root, &prefix);
1125    if !sibling_keys.is_empty() {
1126        let suggestions = suggest_close_symbols(&failing, &sibling_keys, 5);
1127        if !suggestions.is_empty() {
1128            msg.push_str(&format!(" — nearest: [{}]", suggestions.join(", ")));
1129        }
1130    }
1131    Response::error(&req.id, "symbol_not_found", msg)
1132}
1133
1134/// Walk a JSON document by a dotted path, returning the deepest resolved node.
1135///
1136/// Object steps match keys literally; array steps use `name[index]` (0-based)
1137/// or bare `[index]`. A segment containing brackets tries the bracket parse
1138/// only if no literal key of that exact spelling exists.
1139fn json_path_resolve<'a>(
1140    source: &str,
1141    root: &tree_sitter::Node<'a>,
1142    query: &str,
1143) -> Option<JsonNode<'a>> {
1144    json_path_lookup(source, root, query).ok()
1145}
1146
1147/// Walk a JSON path while preserving the exact segment at which resolution fails.
1148///
1149/// Keeping the miss at the point of failure lets the caller report the actual
1150/// deepest object rather than guessing that only the final query segment failed.
1151fn json_path_lookup<'a>(
1152    source: &str,
1153    root: &tree_sitter::Node<'a>,
1154    query: &str,
1155) -> Result<JsonNode<'a>, JsonPathMiss> {
1156    let segments = split_json_path(query);
1157    let Some(first_segment) = segments.first() else {
1158        return Err(JsonPathMiss {
1159            prefix: String::new(),
1160            failing: query.to_string(),
1161        });
1162    };
1163
1164    // The document root must be an object for keyed access. JSONC comments can
1165    // precede this value, so select the first non-comment child rather than the
1166    // first named child unconditionally.
1167    let Some(mut current) = json_document_value(root) else {
1168        return Err(JsonPathMiss {
1169            prefix: String::new(),
1170            failing: first_segment.clone(),
1171        });
1172    };
1173    if current.kind() != "object" {
1174        return Err(JsonPathMiss {
1175            prefix: String::new(),
1176            failing: first_segment.clone(),
1177        });
1178    }
1179
1180    let mut resolved_path = String::new();
1181    for segment in &segments {
1182        let (key, array_index) = parse_json_segment(segment);
1183        let next = if let Some(array_index) = array_index {
1184            // A segment like `servers[0]` first resolves the key to the array
1185            // node, then indexes into it. A bare `[0]` indexes the current node.
1186            let array = match key {
1187                Some(key) => json_object_value(source, current, key),
1188                None => Some(current),
1189            };
1190            array.and_then(|array| json_array_element(array, array_index))
1191        } else {
1192            key.and_then(|key| json_object_value(source, current, key))
1193        };
1194        let Some(next) = next else {
1195            return Err(JsonPathMiss {
1196                prefix: resolved_path,
1197                failing: segment.clone(),
1198            });
1199        };
1200
1201        if resolved_path.is_empty() {
1202            resolved_path = segment.clone();
1203        } else {
1204            resolved_path.push('.');
1205            resolved_path.push_str(segment);
1206        }
1207        current = next;
1208    }
1209
1210    Ok(JsonNode {
1211        node: current,
1212        path: resolved_path,
1213    })
1214}
1215
1216/// Split a JSON path query into segments on `.`, preserving bracket groups.
1217fn split_json_path(query: &str) -> Vec<String> {
1218    let mut segments = Vec::new();
1219    let mut current = String::new();
1220    let mut depth = 0usize;
1221    for character in query.chars() {
1222        match character {
1223            '[' => {
1224                depth += 1;
1225                current.push(character);
1226            }
1227            ']' => {
1228                depth = depth.saturating_sub(1);
1229                current.push(character);
1230            }
1231            '.' if depth == 0 => {
1232                if !current.is_empty() {
1233                    segments.push(std::mem::take(&mut current));
1234                }
1235            }
1236            _ => current.push(character),
1237        }
1238    }
1239    if !current.is_empty() {
1240        segments.push(current);
1241    }
1242    segments
1243}
1244
1245/// Parse a single path segment into an optional object key and optional array index.
1246///
1247/// A segment like `servers[0]` yields key `servers` and index `0`; a bare `[0]`
1248/// yields no key and index `0`; a plain `host` yields key `host` and no index.
1249fn parse_json_segment(segment: &str) -> (Option<&str>, Option<usize>) {
1250    if let Some(open) = segment.find('[') {
1251        if segment.ends_with(']') {
1252            let key = if open == 0 {
1253                None
1254            } else {
1255                Some(&segment[..open])
1256            };
1257            let index_text = &segment[open + 1..segment.len() - 1];
1258            if let Ok(index) = index_text.parse::<usize>() {
1259                return (key, Some(index));
1260            }
1261        }
1262    }
1263    (Some(segment), None)
1264}
1265
1266/// Return the value node for a key in a JSON object, or `None` if absent.
1267fn json_object_value<'a>(
1268    source: &str,
1269    object: tree_sitter::Node<'a>,
1270    key: &str,
1271) -> Option<tree_sitter::Node<'a>> {
1272    if object.kind() != "object" {
1273        return None;
1274    }
1275    let mut cursor = object.walk();
1276    for pair in object.named_children(&mut cursor) {
1277        if pair.kind() != "pair" {
1278            continue;
1279        }
1280        let Some(key_node) = pair.child_by_field_name("key") else {
1281            continue;
1282        };
1283        if node_text(source, &key_node).trim_matches('"') == key {
1284            return pair.child_by_field_name("value");
1285        }
1286    }
1287    None
1288}
1289
1290/// Return the element at a 0-based index in a JSON array, or `None` if out of range.
1291fn json_array_element<'a>(
1292    array: tree_sitter::Node<'a>,
1293    index: usize,
1294) -> Option<tree_sitter::Node<'a>> {
1295    if array.kind() != "array" {
1296        return None;
1297    }
1298    let mut cursor = array.walk();
1299    for (seen, element) in array.named_children(&mut cursor).enumerate() {
1300        if seen == index {
1301            return Some(element);
1302        }
1303    }
1304    None
1305}
1306
1307/// Build a `Symbol` from a resolved JSON node for rendering.
1308fn json_node_to_symbol(node: &tree_sitter::Node, path: &str) -> Symbol {
1309    Symbol {
1310        name: path.to_string(),
1311        kind: SymbolKind::Variable,
1312        range: node_range(node),
1313        signature: None,
1314        scope_chain: vec![],
1315        exported: false,
1316        parent: None,
1317    }
1318}
1319
1320/// Render a resolved JSON node as a zoom response, mirroring a top-level zoom.
1321fn render_json_zoom(
1322    req: &RawRequest,
1323    ctx: &AppContext,
1324    path: &Path,
1325    source: &str,
1326    lines: &[String],
1327    name: &str,
1328    target: &Symbol,
1329    context_lines: usize,
1330    include_callgraph: bool,
1331) -> Response {
1332    let start = target.range.start_line as usize;
1333    let end = target.range.end_line as usize;
1334
1335    let content = if end < lines.len() {
1336        lines[start..=end].join("\n")
1337    } else {
1338        lines[start..].join("\n")
1339    };
1340
1341    let ctx_start = start.saturating_sub(context_lines);
1342    let context_before: Vec<String> = if ctx_start < start {
1343        lines[ctx_start..start].to_vec()
1344    } else {
1345        vec![]
1346    };
1347    let ctx_end = (end + 1 + context_lines).min(lines.len());
1348    let context_after: Vec<String> = if end + 1 < lines.len() {
1349        lines[(end + 1)..ctx_end].to_vec()
1350    } else {
1351        vec![]
1352    };
1353
1354    let (calls_out, called_by) = if include_callgraph {
1355        let all_symbols = match ctx.provider().list_symbols(path) {
1356            Ok(s) => s,
1357            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1358        };
1359        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
1360        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1361        let (tree, lang) = match parser.parse(path) {
1362            Ok(r) => r,
1363            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1364        };
1365        let all_file_calls = extract_calls_with_ranges(source, tree.root_node(), lang);
1366        let signature_byte_start =
1367            line_col_to_byte(source, target.range.start_line, target.range.start_col);
1368        let signature_byte_end =
1369            line_col_to_byte(source, target.range.end_line, target.range.end_col);
1370        let (target_byte_start, target_byte_end) =
1371            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
1372                .unwrap_or((signature_byte_start, signature_byte_end));
1373        let calls_out = dedupe_call_refs_by_name(
1374            all_file_calls
1375                .iter()
1376                .filter(|call| {
1377                    call.start_byte >= target_byte_start
1378                        && call.end_byte <= target_byte_end
1379                        && known_names.contains(&call.name.as_str())
1380                        && call.name != target.name
1381                })
1382                .map(|call| CallRef {
1383                    name: call.name.clone(),
1384                    line: call.line,
1385                    extra_count: 0,
1386                })
1387                .collect(),
1388        );
1389        (calls_out, Vec::new())
1390    } else {
1391        (Vec::new(), Vec::new())
1392    };
1393
1394    let resp = ZoomResponse {
1395        name: name.to_string(),
1396        kind: symbol_kind_string(&target.kind),
1397        range: target.range.clone(),
1398        content,
1399        context_before,
1400        context_after,
1401        annotations: Annotations {
1402            calls_out,
1403            called_by,
1404        },
1405    };
1406
1407    match serde_json::to_value(&resp) {
1408        Ok(resp_json) => Response::success(&req.id, resp_json),
1409        Err(err) => Response::error(
1410            &req.id,
1411            "internal_error",
1412            format!("zoom: failed to serialize response: {err}"),
1413        ),
1414    }
1415}
1416
1417/// Compute the deepest resolved prefix and the failing segment for a miss message.
1418///
1419/// The path walker records the first segment that cannot be resolved, so a
1420/// multi-segment miss names the real failing segment and its nearest resolved
1421/// object.
1422fn json_miss_details(source: &str, root: &tree_sitter::Node, query: &str) -> (String, String) {
1423    match json_path_lookup(source, root, query) {
1424        Err(miss) => (miss.prefix, miss.failing),
1425        Ok(_) => (String::new(), String::new()),
1426    }
1427}
1428
1429/// Return the sibling keys of the deepest resolved object for a miss message.
1430///
1431/// For a single-segment query the "deepest resolved object" is the document
1432/// root; for a multi-segment query it is the object reached by the prefix.
1433fn json_sibling_keys(source: &str, root: &tree_sitter::Node, prefix: &str) -> Vec<String> {
1434    let object = if prefix.is_empty() {
1435        json_document_value(root)
1436    } else {
1437        json_path_resolve(source, root, prefix).map(|resolved| resolved.node)
1438    };
1439    let Some(object) = object else {
1440        return Vec::new();
1441    };
1442    if object.kind() != "object" {
1443        return Vec::new();
1444    }
1445    let mut keys = Vec::new();
1446    let mut cursor = object.walk();
1447    for pair in object.named_children(&mut cursor) {
1448        if pair.kind() != "pair" {
1449            continue;
1450        }
1451        if let Some(key_node) = pair.child_by_field_name("key") {
1452            let key = node_text(source, &key_node).trim_matches('"').to_string();
1453            if !key.is_empty() {
1454                keys.push(key);
1455            }
1456        }
1457    }
1458    keys
1459}
1460
1461/// Build a `Range` from a tree-sitter node (0-indexed, matching `Symbol::range`).
1462fn node_range(node: &tree_sitter::Node) -> Range {
1463    let start = node.start_position();
1464    let end = node.end_position();
1465    Range {
1466        start_line: start.row as u32,
1467        start_col: start.column as u32,
1468        end_line: end.row as u32,
1469        end_col: end.column as u32,
1470    }
1471}
1472
1473/// Keep document headings' raw labels for outline fidelity while allowing zoom to use
1474/// human-readable labels, section prefixes, or anchors without affecting code symbols.
1475fn resolve_heading_symbols(
1476    provider: &dyn LanguageProvider,
1477    path: &Path,
1478    query: &str,
1479) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1480    let headings: Vec<SymbolMatch> = provider
1481        .list_symbols(path)?
1482        .into_iter()
1483        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1484        .map(|symbol| SymbolMatch {
1485            file: path.display().to_string(),
1486            symbol,
1487        })
1488        .collect();
1489
1490    let anchors = if query.starts_with('#') {
1491        provider.heading_anchors(path)?
1492    } else {
1493        Vec::new()
1494    };
1495
1496    Ok(match_heading_identity(&headings, query, &anchors))
1497}
1498
1499fn match_heading_identity(
1500    headings: &[SymbolMatch],
1501    query: &str,
1502    anchors: &[HeadingAnchor],
1503) -> Vec<SymbolMatch> {
1504    if let Some(anchor_query) = query.strip_prefix('#') {
1505        let mut matches: Vec<_> = headings
1506            .iter()
1507            .filter(|candidate| {
1508                anchors.iter().any(|anchor| {
1509                    anchor.id == anchor_query
1510                        && anchor.start_line == candidate.symbol.range.start_line
1511                        && anchor.start_col == candidate.symbol.range.start_col
1512                })
1513            })
1514            .cloned()
1515            .collect();
1516
1517        let normalized_query = normalize_heading_label(query);
1518        let query_slug = slugify_heading_label(&normalized_query);
1519        if !query_slug.is_empty() {
1520            for candidate in headings
1521                .iter()
1522                .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1523            {
1524                if !matches.iter().any(|existing| {
1525                    existing.file == candidate.file && existing.symbol == candidate.symbol
1526                }) {
1527                    matches.push(candidate.clone());
1528                }
1529            }
1530        }
1531        return matches;
1532    }
1533
1534    let exact: Vec<_> = headings
1535        .iter()
1536        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1537        .cloned()
1538        .collect();
1539    if !exact.is_empty() {
1540        return exact;
1541    }
1542
1543    let normalized_query = normalize_heading_label(query);
1544    if normalized_query.is_empty() {
1545        return Vec::new();
1546    }
1547
1548    let normalized: Vec<_> = headings
1549        .iter()
1550        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1551        .cloned()
1552        .collect();
1553    if !normalized.is_empty() {
1554        return normalized;
1555    }
1556
1557    let folded_query = normalized_query.to_lowercase();
1558    let case_insensitive: Vec<_> = headings
1559        .iter()
1560        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1561        .cloned()
1562        .collect();
1563    if !case_insensitive.is_empty() {
1564        return case_insensitive;
1565    }
1566
1567    let query_slug = slugify_heading_label(&normalized_query);
1568    if query_slug.is_empty() {
1569        return Vec::new();
1570    }
1571
1572    headings
1573        .iter()
1574        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1575        .cloned()
1576        .collect()
1577}
1578
1579fn qualified_heading_name(symbol: &Symbol) -> String {
1580    if symbol.scope_chain.is_empty() {
1581        return symbol.name.clone();
1582    }
1583    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1584}
1585
1586fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1587    symbol.name == query || qualified_heading_name(symbol) == query
1588}
1589
1590fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1591    normalize_heading_label(&symbol.name) == query
1592        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1593}
1594
1595fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1596    normalize_heading_label(&symbol.name).to_lowercase() == query
1597        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1598}
1599
1600fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1601    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1602        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1603            == query_slug
1604}
1605
1606fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1607    let available: Vec<String> = symbols
1608        .iter()
1609        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1610        .map(|symbol| normalize_heading_label(&symbol.name))
1611        .filter(|name| !name.is_empty())
1612        .collect();
1613    let normalized_query = normalize_heading_label(query);
1614    if normalized_query.is_empty() {
1615        return Vec::new();
1616    }
1617    suggest_close_symbols(&normalized_query, &available, k)
1618}
1619
1620fn normalize_heading_label(input: &str) -> String {
1621    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1622
1623    // A label can contain both a document prefix and a decorative symbol cluster.
1624    // Repeat the small cleanup sequence so either order is handled consistently.
1625    for _ in 0..4 {
1626        let mut next = value.as_str();
1627        let without_heading_markers = next.trim_start_matches('#').trim_start();
1628        if without_heading_markers != next {
1629            next = without_heading_markers;
1630        }
1631        if let Some(rest) = strip_heading_html_prefix(next) {
1632            next = rest;
1633        }
1634        if let Some(rest) = strip_leading_section_prefix(next) {
1635            next = rest;
1636        }
1637        if let Some(rest) = strip_leading_symbol_cluster(next) {
1638            next = rest;
1639        }
1640
1641        let collapsed = collapse_heading_whitespace(next);
1642        if collapsed == value {
1643            break;
1644        }
1645        value = collapsed;
1646    }
1647
1648    value
1649}
1650
1651fn collapse_heading_whitespace(input: &str) -> String {
1652    input.split_whitespace().collect::<Vec<_>>().join(" ")
1653}
1654
1655fn strip_markdown_links(input: &str) -> String {
1656    let mut output = String::with_capacity(input.len());
1657    let mut cursor = 0;
1658
1659    while let Some(relative_open) = input[cursor..].find('[') {
1660        let open = cursor + relative_open;
1661        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1662            output.push_str(&input[cursor..]);
1663            return output;
1664        };
1665
1666        if input.as_bytes().get(close + 1) != Some(&b'(') {
1667            output.push_str(&input[cursor..=close]);
1668            cursor = close + 1;
1669            continue;
1670        }
1671
1672        let target_open = close + 1;
1673        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1674            output.push_str(&input[cursor..]);
1675            return output;
1676        };
1677
1678        output.push_str(&input[cursor..open]);
1679        output.push_str(&input[open + 1..close]);
1680        cursor = target_close + 1;
1681    }
1682
1683    output.push_str(&input[cursor..]);
1684    output
1685}
1686
1687fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1688    let mut depth = 0;
1689    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1690        if *byte == open {
1691            depth += 1;
1692        } else if *byte == close {
1693            depth -= 1;
1694            if depth == 0 {
1695                return Some(index);
1696            }
1697        }
1698    }
1699    None
1700}
1701
1702fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1703    let input = input.trim_start();
1704    let bytes = input.as_bytes();
1705    if bytes.first() != Some(&b'<') {
1706        return None;
1707    }
1708
1709    let mut index = 1;
1710    if bytes.get(index) == Some(&b'/') {
1711        index += 1;
1712    }
1713    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1714        return None;
1715    }
1716    index += 1;
1717    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1718        return None;
1719    }
1720
1721    let end = input.find('>')?;
1722    let rest = input[end + 1..].trim_start();
1723    if rest.chars().any(|character| character.is_alphanumeric()) {
1724        Some(rest)
1725    } else {
1726        None
1727    }
1728}
1729
1730fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1731    let bytes = input.as_bytes();
1732    let mut index = 0;
1733    let mut saw_dot = false;
1734
1735    if !bytes
1736        .first()
1737        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1738    {
1739        return None;
1740    }
1741
1742    while index < bytes.len() {
1743        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1744            index += 1;
1745        }
1746        if bytes.get(index) != Some(&b'.') {
1747            break;
1748        }
1749        saw_dot = true;
1750        index += 1;
1751        if bytes
1752            .get(index)
1753            .is_some_and(|byte| byte.is_ascii_whitespace())
1754        {
1755            let rest = input[index..].trim_start();
1756            return if rest.chars().any(|character| character.is_alphanumeric()) {
1757                Some(rest)
1758            } else {
1759                None
1760            };
1761        }
1762        if !bytes
1763            .get(index)
1764            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1765        {
1766            return None;
1767        }
1768    }
1769
1770    if saw_dot
1771        && bytes
1772            .get(index)
1773            .is_some_and(|byte| byte.is_ascii_whitespace())
1774    {
1775        let rest = input[index..].trim_start();
1776        if rest.chars().any(|character| character.is_alphanumeric()) {
1777            return Some(rest);
1778        }
1779    }
1780    None
1781}
1782
1783fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1784    let first_text = input
1785        .char_indices()
1786        .find(|(_, character)| character.is_alphanumeric())
1787        .map(|(index, _)| index)?;
1788    if first_text == 0 {
1789        return None;
1790    }
1791
1792    let rest = &input[first_text..];
1793    if rest.chars().any(|character| character.is_alphanumeric()) {
1794        Some(rest)
1795    } else {
1796        None
1797    }
1798}
1799
1800fn slugify_heading_label(label: &str) -> String {
1801    let mut slug = String::new();
1802    let mut pending_separator = false;
1803
1804    for character in label.chars() {
1805        if character.is_alphanumeric() {
1806            if pending_separator && !slug.is_empty() {
1807                slug.push('-');
1808            }
1809            for lowercase in character.to_lowercase() {
1810                slug.push(lowercase);
1811            }
1812            pending_separator = false;
1813        } else if !slug.is_empty() {
1814            pending_separator = true;
1815        }
1816    }
1817
1818    slug
1819}
1820
1821/// Extract call expression names within a byte range of the AST.
1822///
1823/// Delegates to `crate::calls::extract_calls_in_range`.
1824#[cfg(test)]
1825fn extract_calls_in_range(
1826    source: &str,
1827    root: tree_sitter::Node,
1828    byte_start: usize,
1829    byte_end: usize,
1830    lang: LangId,
1831) -> Vec<(String, u32)> {
1832    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1833}
1834
1835fn symbol_body_byte_range(
1836    root: tree_sitter::Node,
1837    byte_start: usize,
1838    byte_end: usize,
1839) -> Option<(usize, usize)> {
1840    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1841    let mut current = Some(node);
1842    while let Some(node) = current {
1843        if is_symbol_body_node(node.kind()) {
1844            return Some((node.start_byte(), node.end_byte()));
1845        }
1846        current = node.parent();
1847    }
1848    Some((node.start_byte(), node.end_byte()))
1849}
1850
1851fn smallest_node_covering_range<'tree>(
1852    node: tree_sitter::Node<'tree>,
1853    byte_start: usize,
1854    byte_end: usize,
1855) -> Option<tree_sitter::Node<'tree>> {
1856    if node.start_byte() > byte_start || node.end_byte() < byte_end {
1857        return None;
1858    }
1859
1860    let mut cursor = node.walk();
1861    if cursor.goto_first_child() {
1862        loop {
1863            let child = cursor.node();
1864            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1865                return Some(found);
1866            }
1867            if !cursor.goto_next_sibling() {
1868                break;
1869            }
1870        }
1871    }
1872
1873    Some(node)
1874}
1875
1876fn is_symbol_body_node(kind: &str) -> bool {
1877    matches!(
1878        kind,
1879        "function_declaration"
1880            | "generator_function_declaration"
1881            | "function_expression"
1882            | "generator_function"
1883            | "arrow_function"
1884            | "method_definition"
1885            | "class_declaration"
1886            | "abstract_class_declaration"
1887            | "class"
1888            | "lexical_declaration"
1889            | "function_definition"
1890            | "class_definition"
1891            | "decorated_definition"
1892            | "function_item"
1893            | "impl_item"
1894            | "method_declaration"
1895    )
1896}
1897
1898fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1899    let mut results = Vec::new();
1900    let call_kinds = crate::calls::call_node_kinds(lang);
1901    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1902    results
1903}
1904
1905fn collect_calls_with_ranges(
1906    node: tree_sitter::Node,
1907    source: &str,
1908    call_kinds: &[&str],
1909    results: &mut Vec<RawCall>,
1910) {
1911    if call_kinds.contains(&node.kind()) {
1912        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1913            results.push(RawCall {
1914                name,
1915                line: node.start_position().row as u32 + 1,
1916                start_byte: node.start_byte(),
1917                end_byte: node.end_byte(),
1918            });
1919        }
1920    }
1921
1922    let mut cursor = node.walk();
1923    if cursor.goto_first_child() {
1924        loop {
1925            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1926            if !cursor.goto_next_sibling() {
1927                break;
1928            }
1929        }
1930    }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936    use crate::config::Config;
1937    use crate::context::AppContext;
1938    use crate::parser::TreeSitterProvider;
1939    use std::path::PathBuf;
1940
1941    fn fixture_path(name: &str) -> PathBuf {
1942        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1943            .join("tests")
1944            .join("fixtures")
1945            .join(name)
1946    }
1947
1948    fn make_ctx() -> AppContext {
1949        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1950    }
1951
1952    #[test]
1953    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1954        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1955        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1956        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1957    }
1958
1959    #[test]
1960    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1961        let params = serde_json::json!({ "symbols": "Getting Started" });
1962        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1963        assert_eq!(names, vec!["Getting Started"]);
1964    }
1965
1966    #[test]
1967    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1968        let params = serde_json::json!({ "symbol": "Last Heading" });
1969        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1970        assert_eq!(names, vec!["Last Heading"]);
1971    }
1972
1973    #[test]
1974    fn parse_zoom_symbol_names_single_token_unchanged() {
1975        let params = serde_json::json!({ "symbol": "compute" });
1976        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1977        assert_eq!(names, vec!["compute"]);
1978    }
1979
1980    #[test]
1981    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1982        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1983        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1984        assert_eq!(names, vec!["A", "B", "C"]);
1985    }
1986
1987    #[test]
1988    fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1989        // Models sometimes JSON-stringify the array form; headings keep spaces.
1990        let params =
1991            serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1992        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1993        assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1994    }
1995
1996    #[test]
1997    fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
1998        let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
1999        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2000        assert_eq!(names, vec!["alpha", "beta"]);
2001    }
2002
2003    #[test]
2004    fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
2005        // A real heading that starts with '[' but is not a JSON array must
2006        // stay a single lookup name.
2007        let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
2008        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
2009        assert_eq!(names, vec!["[Draft] Rollout plan"]);
2010    }
2011
2012    #[test]
2013    fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
2014        // "[1, 2]" parses as JSON but not as symbol names; fall through to
2015        // ordinary handling rather than returning numbers-as-names.
2016        let params = serde_json::json!({ "symbols": "[1, 2]" });
2017        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2018        assert_eq!(names, vec!["[1,", "2]"]);
2019    }
2020
2021    // --- Call extraction tests ---
2022
2023    #[test]
2024    fn extract_calls_finds_direct_calls() {
2025        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2026        let mut parser = FileParser::new();
2027        let path = fixture_path("calls.ts");
2028        let (tree, lang) = parser.parse(&path).unwrap();
2029
2030        // `compute` calls `helper` — find compute's range from symbols
2031        let ctx = make_ctx();
2032        let symbols = ctx.provider().list_symbols(&path).unwrap();
2033        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2034
2035        let byte_start =
2036            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
2037        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
2038
2039        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2040        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2041
2042        assert!(
2043            names.contains(&"helper"),
2044            "compute should call helper, got: {:?}",
2045            names
2046        );
2047    }
2048
2049    #[test]
2050    fn extract_calls_finds_member_calls() {
2051        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2052        let mut parser = FileParser::new();
2053        let path = fixture_path("calls.ts");
2054        let (tree, lang) = parser.parse(&path).unwrap();
2055
2056        let ctx = make_ctx();
2057        let symbols = ctx.provider().list_symbols(&path).unwrap();
2058        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
2059
2060        let byte_start =
2061            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
2062        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
2063
2064        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2065        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2066
2067        assert!(
2068            names.contains(&"add"),
2069            "runAll should call this.add, got: {:?}",
2070            names
2071        );
2072        assert!(
2073            names.contains(&"helper"),
2074            "runAll should call helper, got: {:?}",
2075            names
2076        );
2077    }
2078
2079    #[test]
2080    fn extract_calls_unused_function_has_no_calls() {
2081        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2082        let mut parser = FileParser::new();
2083        let path = fixture_path("calls.ts");
2084        let (tree, lang) = parser.parse(&path).unwrap();
2085
2086        let ctx = make_ctx();
2087        let symbols = ctx.provider().list_symbols(&path).unwrap();
2088        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
2089
2090        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
2091        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
2092
2093        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2094        // console.log is the only call, but "log" or "console" aren't known symbols
2095        let known_names = [
2096            "helper",
2097            "compute",
2098            "orchestrate",
2099            "unused",
2100            "format",
2101            "display",
2102        ];
2103        let filtered: Vec<&str> = calls
2104            .iter()
2105            .map(|(n, _)| n.as_str())
2106            .filter(|n| known_names.contains(n))
2107            .collect();
2108        assert!(
2109            filtered.is_empty(),
2110            "unused should not call known symbols, got: {:?}",
2111            filtered
2112        );
2113    }
2114
2115    // --- Context line tests ---
2116
2117    #[test]
2118    fn context_lines_clamp_at_file_start() {
2119        // helper() is at the top of the file (line 2) — context_before should be clamped
2120        let ctx = make_ctx();
2121        let path = fixture_path("calls.ts");
2122        let symbols = ctx.provider().list_symbols(&path).unwrap();
2123        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
2124
2125        let source = std::fs::read_to_string(&path).unwrap();
2126        let lines: Vec<&str> = source.lines().collect();
2127        let start = helper.range.start_line as usize;
2128
2129        // With context_lines=5, ctx_start should clamp to 0
2130        let ctx_start = start.saturating_sub(5);
2131        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
2132        // Should have at most `start` lines (not panic)
2133        assert!(context_before.len() <= start);
2134    }
2135
2136    #[test]
2137    fn context_lines_clamp_at_file_end() {
2138        let ctx = make_ctx();
2139        let path = fixture_path("calls.ts");
2140        let symbols = ctx.provider().list_symbols(&path).unwrap();
2141        let display = symbols.iter().find(|s| s.name == "display").unwrap();
2142
2143        let source = std::fs::read_to_string(&path).unwrap();
2144        let lines: Vec<&str> = source.lines().collect();
2145        let end = display.range.end_line as usize;
2146
2147        // With context_lines=20, should clamp to file length
2148        let ctx_end = (end + 1 + 20).min(lines.len());
2149        let context_after: Vec<&str> = if end + 1 < lines.len() {
2150            lines[(end + 1)..ctx_end].to_vec()
2151        } else {
2152            vec![]
2153        };
2154        // Should not panic regardless of context_lines size
2155        assert!(context_after.len() <= 20);
2156    }
2157
2158    // --- Body extraction test ---
2159
2160    #[test]
2161    fn body_extraction_matches_source() {
2162        let ctx = make_ctx();
2163        let path = fixture_path("calls.ts");
2164        let symbols = ctx.provider().list_symbols(&path).unwrap();
2165        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2166
2167        let source = std::fs::read_to_string(&path).unwrap();
2168        let lines: Vec<&str> = source.lines().collect();
2169        let start = compute.range.start_line as usize;
2170        let end = compute.range.end_line as usize;
2171        let body = lines[start..=end].join("\n");
2172
2173        assert!(
2174            body.contains("function compute"),
2175            "body should contain function declaration"
2176        );
2177        assert!(
2178            body.contains("helper(a)"),
2179            "body should contain call to helper"
2180        );
2181        assert!(
2182            body.contains("doubled + b"),
2183            "body should contain return expression"
2184        );
2185    }
2186
2187    // --- Full zoom response tests ---
2188
2189    #[test]
2190    fn body_range_expands_signature_range_to_include_body_calls() {
2191        let source = r#"function compute(
2192  value: number,
2193): number {
2194  return helper(value);
2195}
2196
2197function helper(value: number): number {
2198  return value * 2;
2199}
2200"#;
2201        let grammar = crate::parser::grammar_for(LangId::TypeScript);
2202        let mut parser = tree_sitter::Parser::new();
2203        parser.set_language(&grammar).unwrap();
2204        let tree = parser.parse(source, None).unwrap();
2205        let signature_end = source.find('{').expect("function has body");
2206
2207        let (body_start, body_end) =
2208            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
2209        let calls = extract_calls_in_range(
2210            source,
2211            tree.root_node(),
2212            body_start,
2213            body_end,
2214            LangId::TypeScript,
2215        );
2216        let names = calls
2217            .iter()
2218            .map(|(name, _)| name.as_str())
2219            .collect::<Vec<_>>();
2220
2221        assert!(
2222            names.contains(&"helper"),
2223            "call inside the function body should be included: {names:?}"
2224        );
2225    }
2226
2227    #[test]
2228    fn zoom_leaf_returns_full_body_without_budget_marker() {
2229        let ctx = make_ctx();
2230        let path = fixture_path("calls.ts");
2231        let req = make_zoom_request(
2232            "z-leaf-full",
2233            path.to_str().unwrap(),
2234            "repeatedOutgoing",
2235            None,
2236        );
2237        let resp = handle_zoom(&req, &ctx);
2238        let json = serde_json::to_value(&resp).unwrap();
2239        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2240
2241        let symbols = ctx.provider().list_symbols(&path).unwrap();
2242        let target = symbols
2243            .iter()
2244            .find(|symbol| symbol.name == "repeatedOutgoing")
2245            .unwrap();
2246        let source = std::fs::read_to_string(&path).unwrap();
2247        let lines = source.lines().collect::<Vec<_>>();
2248        let expected =
2249            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
2250
2251        assert_eq!(json["content"].as_str().unwrap(), expected);
2252        assert!(
2253            !json["content"]
2254                .as_str()
2255                .unwrap()
2256                .contains("more lines — zoom"),
2257            "explicit zoom must not budget-cap leaf bodies"
2258        );
2259    }
2260
2261    #[test]
2262    fn zoom_response_has_calls_out_and_called_by() {
2263        let ctx = make_ctx();
2264        let path = fixture_path("calls.ts");
2265
2266        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
2267        let resp = handle_zoom(&req, &ctx);
2268
2269        let json = serde_json::to_value(&resp).unwrap();
2270        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2271
2272        let calls_out = json["annotations"]["calls_out"]
2273            .as_array()
2274            .expect("calls_out array");
2275        let out_names: Vec<&str> = calls_out
2276            .iter()
2277            .map(|c| c["name"].as_str().unwrap())
2278            .collect();
2279        assert!(
2280            out_names.contains(&"helper"),
2281            "compute calls helper: {:?}",
2282            out_names
2283        );
2284
2285        let called_by = json["annotations"]["called_by"]
2286            .as_array()
2287            .expect("called_by array");
2288        let by_names: Vec<&str> = called_by
2289            .iter()
2290            .map(|c| c["name"].as_str().unwrap())
2291            .collect();
2292        assert!(
2293            by_names.contains(&"orchestrate"),
2294            "orchestrate calls compute: {:?}",
2295            by_names
2296        );
2297    }
2298
2299    #[test]
2300    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
2301        let ctx = make_ctx();
2302        let path = fixture_path("calls.ts");
2303
2304        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
2305        let resp = handle_zoom(&req, &ctx);
2306        let json = serde_json::to_value(&resp).unwrap();
2307        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2308
2309        let calls_out = json["annotations"]["calls_out"]
2310            .as_array()
2311            .expect("calls_out array");
2312        let helper_refs = calls_out
2313            .iter()
2314            .filter(|call| call["name"] == "helper")
2315            .collect::<Vec<_>>();
2316        assert_eq!(
2317            helper_refs.len(),
2318            1,
2319            "helper should be folded once: {calls_out:?}"
2320        );
2321        assert_eq!(helper_refs[0]["extra_count"], 1);
2322        assert!(
2323            calls_out.iter().any(|call| call["name"] == "format"),
2324            "distinct callee must not be folded into helper: {calls_out:?}"
2325        );
2326
2327        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
2328        let resp = handle_zoom(&req, &ctx);
2329        let json = serde_json::to_value(&resp).unwrap();
2330        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2331
2332        let called_by = json["annotations"]["called_by"]
2333            .as_array()
2334            .expect("called_by array");
2335        let repeat_refs = called_by
2336            .iter()
2337            .filter(|call| call["name"] == "repeatCompute")
2338            .collect::<Vec<_>>();
2339        assert_eq!(
2340            repeat_refs.len(),
2341            1,
2342            "repeatCompute should be folded once: {called_by:?}"
2343        );
2344        assert_eq!(repeat_refs[0]["extra_count"], 1);
2345        assert!(
2346            called_by.iter().any(|call| call["name"] == "orchestrate"),
2347            "distinct caller must not be folded into repeatCompute: {called_by:?}"
2348        );
2349    }
2350
2351    #[test]
2352    fn zoom_response_empty_annotations_for_unused() {
2353        let ctx = make_ctx();
2354        let path = fixture_path("calls.ts");
2355
2356        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
2357        let resp = handle_zoom(&req, &ctx);
2358
2359        let json = serde_json::to_value(&resp).unwrap();
2360        assert_eq!(json["success"], true);
2361
2362        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
2363        let called_by = json["annotations"]["called_by"].as_array().unwrap();
2364
2365        // calls_out exists (may contain console.log but no known symbols)
2366        // called_by should be empty — nobody calls unused
2367        assert!(
2368            called_by.is_empty(),
2369            "unused should not be called by anyone: {:?}",
2370            called_by
2371        );
2372    }
2373
2374    #[test]
2375    fn zoom_default_omits_callgraph_annotations() {
2376        let ctx = make_ctx();
2377        let path = fixture_path("calls.ts");
2378
2379        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
2380        let resp = handle_zoom(&req, &ctx);
2381
2382        let json = serde_json::to_value(&resp).unwrap();
2383        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2384
2385        let calls_out = json["annotations"]["calls_out"]
2386            .as_array()
2387            .expect("calls_out array");
2388        let called_by = json["annotations"]["called_by"]
2389            .as_array()
2390            .expect("called_by array");
2391        assert!(
2392            calls_out.is_empty(),
2393            "default zoom should omit calls_out: {:?}",
2394            calls_out
2395        );
2396        assert!(
2397            called_by.is_empty(),
2398            "default zoom should omit called_by: {:?}",
2399            called_by
2400        );
2401    }
2402
2403    #[test]
2404    fn zoom_symbol_not_found() {
2405        let ctx = make_ctx();
2406        let path = fixture_path("calls.ts");
2407
2408        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
2409        let resp = handle_zoom(&req, &ctx);
2410
2411        let json = serde_json::to_value(&resp).unwrap();
2412        assert_eq!(json["success"], false);
2413        assert_eq!(json["code"], "symbol_not_found");
2414    }
2415
2416    #[test]
2417    fn zoom_custom_context_lines() {
2418        let ctx = make_ctx();
2419        let path = fixture_path("calls.ts");
2420
2421        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
2422        let resp = handle_zoom(&req, &ctx);
2423
2424        let json = serde_json::to_value(&resp).unwrap();
2425        assert_eq!(json["success"], true);
2426
2427        let ctx_before = json["context_before"].as_array().unwrap();
2428        let ctx_after = json["context_after"].as_array().unwrap();
2429        // With context_lines=1, we get at most 1 line before and after
2430        assert!(
2431            ctx_before.len() <= 1,
2432            "context_before should be ≤1: {:?}",
2433            ctx_before
2434        );
2435        assert!(
2436            ctx_after.len() <= 1,
2437            "context_after should be ≤1: {:?}",
2438            ctx_after
2439        );
2440    }
2441
2442    #[test]
2443    fn zoom_missing_file_param() {
2444        let ctx = make_ctx();
2445        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
2446        let resp = handle_zoom(&req, &ctx);
2447
2448        let json = serde_json::to_value(&resp).unwrap();
2449        assert_eq!(json["success"], false);
2450        assert_eq!(json["code"], "invalid_request");
2451    }
2452
2453    #[test]
2454    fn zoom_missing_symbol_param() {
2455        let ctx = make_ctx();
2456        let path = fixture_path("calls.ts");
2457        // Build the JSON via serde_json so Windows paths (with backslashes)
2458        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
2459        // backslashes as escape sequences and fail to parse.
2460        let req_value = serde_json::json!({
2461            "id": "z-6",
2462            "command": "zoom",
2463            "file": path.to_string_lossy(),
2464        });
2465        let req_str = req_value.to_string();
2466        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
2467        let resp = handle_zoom(&req, &ctx);
2468
2469        let json = serde_json::to_value(&resp).unwrap();
2470        assert_eq!(json["success"], false);
2471        assert_eq!(json["code"], "invalid_request");
2472    }
2473
2474    #[test]
2475    fn test_suggest_close_symbols_unit() {
2476        let available = vec![
2477            "handle_grep_search".to_string(),
2478            "handle_semantic_search".to_string(),
2479            "handle_semantic_or_hybrid_search".to_string(),
2480            "compute_total".to_string(),
2481            "search".to_string(),
2482            "handle_search".to_string(),
2483        ];
2484
2485        let suggestions = suggest_close_symbols("handle_search", &available, 5);
2486        assert!(suggestions.contains(&"handle_grep_search".to_string()));
2487        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
2488        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
2489        assert!(suggestions.contains(&"search".to_string()));
2490        assert!(!suggestions.contains(&"compute_total".to_string()));
2491
2492        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
2493        assert_eq!(suggestions, suggestions_caps);
2494
2495        let available2 = vec![
2496            "total".to_string(),
2497            "compute_total".to_string(),
2498            "unrelated".to_string(),
2499        ];
2500        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
2501        assert_eq!(suggestions2, vec!["total".to_string()]);
2502    }
2503
2504    // --- Helpers ---
2505
2506    fn make_zoom_request(
2507        id: &str,
2508        file: &str,
2509        symbol: &str,
2510        context_lines: Option<u64>,
2511    ) -> RawRequest {
2512        let mut json = serde_json::json!({
2513            "id": id,
2514            "command": "zoom",
2515            "file": file,
2516            "symbol": symbol,
2517        });
2518        if let Some(cl) = context_lines {
2519            json["context_lines"] = serde_json::json!(cl);
2520        }
2521        serde_json::from_value(json).unwrap()
2522    }
2523
2524    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2525        let mut req = make_zoom_request(id, file, symbol, None);
2526        req.params["callgraph"] = serde_json::json!(true);
2527        req
2528    }
2529
2530    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2531        serde_json::from_str(json_str).unwrap()
2532    }
2533
2534    // --- JSON path resolution tests ---
2535
2536    fn json_fixture_tree() -> (String, tree_sitter::Tree) {
2537        json_fixture_tree_named("nested.json")
2538    }
2539
2540    fn json_fixture_tree_named(name: &str) -> (String, tree_sitter::Tree) {
2541        let source = std::fs::read_to_string(fixture_path(name)).unwrap();
2542        let mut parser = FileParser::new();
2543        let path = fixture_path(name);
2544        let (tree, _) = parser.parse(&path).unwrap();
2545        (source, tree.clone())
2546    }
2547
2548    fn assert_json_zoom_resolves(fixture: &str, query: &str, expected_fragment: &str) {
2549        let ctx = make_ctx();
2550        let path = fixture_path(fixture);
2551        let req = make_zoom_request("json-regression", path.to_str().unwrap(), query, None);
2552        let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2553        assert_eq!(json["success"], true, "JSON zoom should succeed: {json}");
2554        assert_eq!(json["name"], query);
2555        assert!(
2556            json["content"]
2557                .as_str()
2558                .unwrap_or_default()
2559                .contains(expected_fragment),
2560            "JSON zoom content should contain {expected_fragment:?}: {json}"
2561        );
2562    }
2563
2564    #[test]
2565    fn json_path_resolves_nested_object() {
2566        let (source, tree) = json_fixture_tree();
2567        let resolved = json_path_resolve(
2568            &source,
2569            &tree.root_node(),
2570            "registration_profile_manifest.nested.deep",
2571        )
2572        .expect("path should resolve");
2573        assert_eq!(resolved.path, "registration_profile_manifest.nested.deep");
2574        assert_eq!(node_text(&source, &resolved.node).trim(), "\"value\"");
2575    }
2576
2577    #[test]
2578    fn json_zoom_resolves_leading_line_comments() {
2579        assert_json_zoom_resolves(
2580            "zoom_jsonc_leading_line_comments.jsonc",
2581            "chains",
2582            "\"executor\"",
2583        );
2584    }
2585
2586    #[test]
2587    fn json_zoom_resolves_leading_block_comment() {
2588        assert_json_zoom_resolves(
2589            "zoom_jsonc_leading_block_comment.jsonc",
2590            "chains",
2591            "\"executor\"",
2592        );
2593    }
2594
2595    #[test]
2596    fn json_zoom_resolves_blank_lines_before_document() {
2597        assert_json_zoom_resolves("zoom_json_blank_lines.json", "chains", "\"executor\"");
2598    }
2599
2600    #[test]
2601    fn json_zoom_resolves_comments_inside_object() {
2602        assert_json_zoom_resolves("zoom_json_comments_inside.jsonc", "chains", "\"executor\"");
2603    }
2604
2605    #[test]
2606    fn json_zoom_leading_multibyte_comment_keeps_path_and_value_offsets() {
2607        assert_json_zoom_resolves(
2608            "zoom_jsonc_leading_line_comments.jsonc",
2609            "chains.executor.entries[1].model",
2610            "\"large\"",
2611        );
2612    }
2613
2614    #[test]
2615    fn json_zoom_miss_reports_actual_deepest_prefix_and_segment() {
2616        let ctx = make_ctx();
2617        let path = fixture_path("zoom_json_miss_locus.json");
2618        let query = "agent.general.model";
2619        let req = make_zoom_request("json-miss", path.to_str().unwrap(), query, None);
2620        let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2621
2622        assert_eq!(json["success"], false);
2623        assert_eq!(
2624            json["message"],
2625            "symbol 'agent.general.model' not found: resolved `agent`, no key `general` — nearest: [general_settings]"
2626        );
2627    }
2628
2629    #[test]
2630    fn json_path_resolves_array_index() {
2631        let (source, tree) = json_fixture_tree();
2632        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[0]")
2633            .expect("path should resolve");
2634        assert_eq!(resolved.path, "servers[0]");
2635        assert!(node_text(&source, &resolved.node).contains("primary"));
2636    }
2637
2638    #[test]
2639    fn json_path_resolves_chained_array_index() {
2640        let (source, tree) = json_fixture_tree();
2641        let resolved =
2642            json_path_resolve(&source, &tree.root_node(), "a.b[1].c").expect("path should resolve");
2643        assert_eq!(resolved.path, "a.b[1].c");
2644        assert_eq!(node_text(&source, &resolved.node).trim(), "\"second\"");
2645    }
2646
2647    #[test]
2648    fn json_path_resolves_bare_array_index() {
2649        let (source, tree) = json_fixture_tree();
2650        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[1].name")
2651            .expect("path should resolve");
2652        assert_eq!(resolved.path, "servers[1].name");
2653        assert_eq!(node_text(&source, &resolved.node).trim(), "\"backup\"");
2654    }
2655
2656    #[test]
2657    fn json_path_miss_returns_none() {
2658        let (source, tree) = json_fixture_tree();
2659        assert!(json_path_resolve(
2660            &source,
2661            &tree.root_node(),
2662            "registration_profile_manifest.host_only_allowlis"
2663        )
2664        .is_none());
2665        assert!(json_path_resolve(&source, &tree.root_node(), "servers[9]").is_none());
2666        assert!(json_path_resolve(&source, &tree.root_node(), "missing").is_none());
2667    }
2668
2669    #[test]
2670    fn json_path_resolves_dotted_query_as_path() {
2671        // The fixture has BOTH a literal key "literal.dotted.key" and a nested
2672        // path literal.dotted.key. This unit test exercises the path-walk
2673        // function directly, which resolves to the nested path-value node.
2674        let (source, tree) = json_fixture_tree();
2675        let resolved = json_path_resolve(&source, &tree.root_node(), "literal.dotted.key")
2676            .expect("path should resolve");
2677        assert_eq!(node_text(&source, &resolved.node).trim(), "\"path-value\"");
2678    }
2679
2680    #[test]
2681    fn json_miss_details_reports_deepest_prefix() {
2682        let (source, tree) = json_fixture_tree();
2683        let (prefix, failing) = json_miss_details(
2684            &source,
2685            &tree.root_node(),
2686            "registration_profile_manifest.host_only_allowlis",
2687        );
2688        assert_eq!(prefix, "registration_profile_manifest");
2689        assert_eq!(failing, "host_only_allowlis");
2690    }
2691
2692    #[test]
2693    fn json_miss_details_single_segment() {
2694        let (source, tree) = json_fixture_tree();
2695        let (prefix, failing) = json_miss_details(&source, &tree.root_node(), "missing");
2696        assert_eq!(prefix, "");
2697        assert_eq!(failing, "missing");
2698    }
2699
2700    #[test]
2701    fn split_json_path_keeps_bracket_groups() {
2702        assert_eq!(split_json_path("a.b[0].c"), vec!["a", "b[0]", "c"]);
2703        assert_eq!(split_json_path("servers[0]"), vec!["servers[0]"]);
2704        assert_eq!(split_json_path("a.b.c"), vec!["a", "b", "c"]);
2705    }
2706
2707    #[test]
2708    fn parse_json_segment_handles_key_and_index() {
2709        assert_eq!(parse_json_segment("servers[0]"), (Some("servers"), Some(0)));
2710        assert_eq!(parse_json_segment("[0]"), (None, Some(0)));
2711        assert_eq!(parse_json_segment("host"), (Some("host"), None));
2712    }
2713}