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 resolve_zoom_file(
108    req: &RawRequest,
109    ctx: &AppContext,
110    file: &str,
111) -> Result<(PathBuf, String), Response> {
112    let path = resolve_file_or_url(req, ctx, file)?;
113    if !path.exists() {
114        return Err(Response::error(
115            &req.id,
116            "file_not_found",
117            deterministic_zoom_refusal(
118                format!("file not found: {file}"),
119                "Set `file` to an existing path or use a reachable `url`.",
120            ),
121        ));
122    }
123
124    let source = std::fs::read_to_string(&path).map_err(|error| {
125        Response::error(
126            &req.id,
127            "file_not_found",
128            deterministic_zoom_refusal(
129                format!("cannot read {file}: {error}"),
130                "Choose a readable `file` path.",
131            ),
132        )
133    })?;
134    Ok((path, source))
135}
136
137fn zoom_one_target_response(
138    req: &RawRequest,
139    ctx: &AppContext,
140    file: &str,
141    symbol: &str,
142    context_lines: usize,
143    include_callgraph: bool,
144) -> Response {
145    let (path, source) = match resolve_zoom_file(req, ctx, file) {
146        Ok(file) => file,
147        Err(resp) => return resp,
148    };
149    let lines: Vec<&str> = source.lines().collect();
150
151    zoom_one_symbol(
152        req,
153        ctx,
154        &path,
155        file,
156        &source,
157        &lines,
158        symbol,
159        context_lines,
160        include_callgraph,
161    )
162}
163
164fn serialize_zoom_target_response(req: &RawRequest, response: Response) -> serde_json::Value {
165    serde_json::to_value(&response).unwrap_or_else(|error| {
166        serde_json::to_value(Response::error(
167            &req.id,
168            "internal_error",
169            format!("zoom: failed to serialize target response: {error}"),
170        ))
171        .expect("serializing Response::error should not fail")
172    })
173}
174
175fn handle_zoom_targets(
176    req: &RawRequest,
177    ctx: &AppContext,
178    targets: &[serde_json::Value],
179    context_lines: usize,
180    include_callgraph: bool,
181) -> Response {
182    if targets.is_empty() {
183        return Response::error(
184            &req.id,
185            "invalid_request",
186            deterministic_zoom_refusal(
187                "zoom: 'targets' must be a non-empty array",
188                "Pass at least one `{ file, symbol }` target.",
189            ),
190        );
191    }
192
193    let mut entries = Vec::with_capacity(targets.len());
194    for (index, target) in targets.iter().enumerate() {
195        let obj = target.as_object();
196        let Some(file) = obj
197            .and_then(|obj| obj.get("file"))
198            .and_then(|value| value.as_str())
199            .filter(|file| !file.is_empty())
200        else {
201            return Response::error(
202                &req.id,
203                "invalid_request",
204                deterministic_zoom_refusal(
205                    format!("zoom: targets[{index}].file must be a non-empty string"),
206                    "Provide a file path for every target.",
207                ),
208            );
209        };
210        let Some(symbol) = obj
211            .and_then(|obj| obj.get("symbol"))
212            .and_then(|value| value.as_str())
213            .filter(|symbol| !symbol.is_empty())
214        else {
215            return Response::error(
216                &req.id,
217                "invalid_request",
218                deterministic_zoom_refusal(
219                    format!("zoom: targets[{index}].symbol must be a non-empty string"),
220                    "Provide a symbol name for every target.",
221                ),
222            );
223        };
224        let target_label = obj
225            .and_then(|obj| obj.get("target_label").or_else(|| obj.get("targetLabel")))
226            .and_then(|value| value.as_str())
227            .filter(|label| !label.is_empty())
228            .unwrap_or(file);
229
230        let response =
231            zoom_one_target_response(req, ctx, file, symbol, context_lines, include_callgraph);
232        entries.push(serde_json::json!({
233            "targetLabel": target_label,
234            "name": symbol,
235            "response": serialize_zoom_target_response(req, response),
236        }));
237    }
238
239    Response::success(
240        &req.id,
241        serde_json::json!({
242            "targets": entries,
243        }),
244    )
245}
246
247/// Handle a `zoom` request.
248///
249/// Expects either `file` plus `symbol`/`symbols`, or a cross-file `targets` array,
250/// with optional `context_lines` (default 3). Resolves the symbol, extracts body +
251/// context, and walks ASTs for call annotations. For code files, a whitespace-separated
252/// top-level `symbol`/`symbols` string is split into multiple same-file lookups.
253pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
254    let context_lines = req
255        .params
256        .get("context_lines")
257        .and_then(|v| v.as_u64())
258        .unwrap_or(3) as usize;
259    let include_callgraph = req
260        .params
261        .get("callgraph")
262        .and_then(|v| v.as_bool())
263        .unwrap_or(false);
264
265    if let Some(targets_value) = req.params.get("targets") {
266        let Some(targets) = targets_value.as_array() else {
267            return Response::error(
268                &req.id,
269                "invalid_request",
270                deterministic_zoom_refusal(
271                    "zoom: 'targets' must be a non-empty array",
272                    "Pass a non-empty `targets` array or use `file` with `symbol`.",
273                ),
274            );
275        };
276        return handle_zoom_targets(req, ctx, targets, context_lines, include_callgraph);
277    }
278
279    let file = match req
280        .params
281        .get("file")
282        .or_else(|| req.params.get("url"))
283        .and_then(|v| v.as_str())
284    {
285        Some(f) => f,
286        None => {
287            return Response::error(
288                &req.id,
289                "invalid_request",
290                deterministic_zoom_refusal(
291                    "zoom: missing required param 'file'",
292                    "Provide `file` or `url` with the symbol to inspect.",
293                ),
294            );
295        }
296    };
297
298    let start_line = req
299        .params
300        .get("start_line")
301        .and_then(|v| v.as_u64())
302        .map(|v| v as usize);
303    let end_line = req
304        .params
305        .get("end_line")
306        .and_then(|v| v.as_u64())
307        .map(|v| v as usize);
308
309    // Read source file early because both symbol mode and line-range mode need it.
310    let (path, source) = match resolve_zoom_file(req, ctx, file) {
311        Ok(file) => file,
312        Err(resp) => return resp,
313    };
314
315    let lines: Vec<&str> = source.lines().collect();
316
317    // Line-range mode: read arbitrary lines without requiring a symbol.
318    match (start_line, end_line) {
319        (Some(start), Some(end)) => {
320            if zoom_symbol_param(&req.params).is_some() {
321                return Response::error(
322                    &req.id,
323                    "invalid_request",
324                    deterministic_zoom_refusal(
325                        "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
326                        "Remove one mode and keep only the arguments it requires.",
327                    ),
328                );
329            }
330            if start == 0 || end == 0 {
331                return Response::error(
332                    &req.id,
333                    "invalid_request",
334                    deterministic_zoom_refusal(
335                        "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
336                        "Use positive 1-based line numbers.",
337                    ),
338                );
339            }
340            if end < start {
341                return Response::error(
342                    &req.id,
343                    "invalid_request",
344                    deterministic_zoom_refusal(
345                        format!("zoom: end_line {end} must be >= start_line {start}"),
346                        "Use an end line at or after the start line.",
347                    ),
348                );
349            }
350            if lines.is_empty() {
351                return Response::error(
352                    &req.id,
353                    "invalid_request",
354                    deterministic_zoom_refusal(
355                        format!("zoom: {file} is empty"),
356                        "Choose a non-empty file or inspect a different path.",
357                    ),
358                );
359            }
360
361            let start_idx = start - 1;
362            // Clamp end_line to file length (same as batch edits)
363            let clamped_end = end.min(lines.len());
364            let end_idx = clamped_end - 1;
365            if start_idx >= lines.len() {
366                return Response::error(
367                    &req.id,
368                    "invalid_request",
369                    deterministic_zoom_refusal(
370                        format!(
371                            "zoom: start_line {start} is past end of {file} ({} lines)",
372                            lines.len()
373                        ),
374                        "Choose a start line inside the file.",
375                    ),
376                );
377            }
378
379            let content = lines[start_idx..=end_idx].join("\n");
380            let ctx_start = start_idx.saturating_sub(context_lines);
381            let context_before: Vec<String> = if ctx_start < start_idx {
382                lines[ctx_start..start_idx]
383                    .iter()
384                    .map(|l| l.to_string())
385                    .collect()
386            } else {
387                vec![]
388            };
389            let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
390            let context_after: Vec<String> = if end_idx + 1 < lines.len() {
391                lines[(end_idx + 1)..ctx_end]
392                    .iter()
393                    .map(|l| l.to_string())
394                    .collect()
395            } else {
396                vec![]
397            };
398            let end_col = lines[end_idx].chars().count() as u32;
399
400            return Response::success(
401                &req.id,
402                serde_json::json!({
403                    "name": format!("lines {}-{}", start, clamped_end),
404                    "kind": "lines",
405                    "range": {
406                        "start_line": start,  // already 1-based from user input
407                        "start_col": 1,
408                        "end_line": clamped_end,
409                        "end_col": end_col + 1,
410                    },
411                    "content": content,
412                    "context_before": context_before,
413                    "context_after": context_after,
414                    "annotations": {
415                        "calls_out": [],
416                        "called_by": [],
417                    },
418                }),
419            );
420        }
421        (Some(_), None) | (None, Some(_)) => {
422            return Response::error(
423                &req.id,
424                "invalid_request",
425                deterministic_zoom_refusal(
426                    "zoom: provide both 'start_line' and 'end_line' for line-range mode",
427                    "Provide both 1-based line bounds or use `symbol` instead.",
428                ),
429            );
430        }
431        (None, None) => {}
432    }
433
434    let lang = detect_language(&path);
435    let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
436        Ok(names) => names,
437        Err(resp) => return resp,
438    };
439
440    if symbol_names.is_empty() {
441        return Response::error(
442            &req.id,
443            "invalid_request",
444            deterministic_zoom_refusal(
445                "zoom: missing required param 'symbol'",
446                "Provide `symbol`, `symbols`, or both `start_line` and `end_line`.",
447            ),
448        );
449    }
450
451    if symbol_names.len() == 1 {
452        return zoom_one_symbol(
453            req,
454            ctx,
455            &path,
456            file,
457            &source,
458            &lines,
459            &symbol_names[0],
460            context_lines,
461            include_callgraph,
462        );
463    }
464
465    zoom_batch_symbols(
466        req,
467        ctx,
468        &path,
469        file,
470        &source,
471        &lines,
472        &symbol_names,
473        context_lines,
474        include_callgraph,
475    )
476}
477
478/// Raw `symbol` or `symbols` param before language-aware splitting.
479fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
480    params
481        .get("symbol")
482        .or_else(|| params.get("symbols"))
483        .and_then(|v| v.as_str())
484}
485
486fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
487    matches!(lang, Some(LangId::Markdown | LangId::Html))
488}
489
490const RETRY_UNCHANGED_ZOOM_MESSAGE: &str = "Retrying this exact zoom call will fail again.";
491const MAX_ZOOM_SYMBOL_SUGGESTIONS: usize = 5;
492
493fn deterministic_zoom_refusal(reason: impl AsRef<str>, action: &str) -> String {
494    format!(
495        "{}. {} {}",
496        reason.as_ref().trim_end_matches('.'),
497        RETRY_UNCHANGED_ZOOM_MESSAGE,
498        action
499    )
500}
501
502fn outline_symbol_name(symbol: &Symbol, is_heading: bool) -> String {
503    if is_heading {
504        normalize_heading_label(&symbol.name)
505    } else {
506        symbol.name.clone()
507    }
508}
509
510/// Heading suggestions render the DE-LINKED label (`[Label](url)` -> `Label`):
511/// zoom accepts plain-text heading queries via normalization, so the URL adds
512/// tokens without adding addressability - the long-standing suggestion
513/// contract strips it.
514fn format_outline_symbol_labeled(symbol: &Symbol, is_heading: bool) -> String {
515    let start = symbol.range.start_line.saturating_add(1);
516    let end = symbol.range.end_line.saturating_add(1).max(start);
517    let name = if is_heading {
518        normalize_heading_label(&symbol.name)
519    } else {
520        symbol.name.clone()
521    };
522    format!("`{name}` (lines {start}-{end})")
523}
524
525fn nearest_outline_symbols(
526    query: &str,
527    all_symbols: &[Symbol],
528    is_heading: bool,
529    k: usize,
530) -> Vec<String> {
531    let normalized_query = if is_heading {
532        normalize_heading_label(query)
533    } else {
534        query.to_string()
535    };
536    if normalized_query.is_empty() {
537        return Vec::new();
538    }
539
540    let candidates: Vec<(&Symbol, String)> = all_symbols
541        .iter()
542        .filter(|symbol| !is_heading || symbol.kind == SymbolKind::Heading)
543        .map(|symbol| (symbol, outline_symbol_name(symbol, is_heading)))
544        .filter(|(_, name)| !name.is_empty())
545        .collect();
546    let available: Vec<String> = candidates.iter().map(|(_, name)| name.clone()).collect();
547    let names = suggest_close_symbols(&normalized_query, &available, k);
548    let mut suggestions = Vec::with_capacity(names.len());
549
550    for name in names {
551        for (symbol, candidate_name) in &candidates {
552            if candidate_name == &name {
553                let rendered = format_outline_symbol_labeled(symbol, is_heading);
554                if !suggestions.contains(&rendered) {
555                    suggestions.push(rendered);
556                }
557                if suggestions.len() == k {
558                    return suggestions;
559                }
560            }
561        }
562    }
563    suggestions
564}
565
566fn closest_outline_symbol<'a>(
567    query: &str,
568    all_symbols: &'a [Symbol],
569    is_heading: bool,
570) -> Option<&'a Symbol> {
571    let normalized_query = if is_heading {
572        normalize_heading_label(query)
573    } else {
574        query.to_string()
575    };
576    if normalized_query.is_empty() {
577        return None;
578    }
579    let query_lower = normalized_query.to_lowercase();
580
581    all_symbols
582        .iter()
583        .filter(|symbol| !is_heading || symbol.kind == SymbolKind::Heading)
584        .filter(|symbol| !outline_symbol_name(symbol, is_heading).is_empty())
585        .min_by(|left, right| {
586            let left_name = outline_symbol_name(left, is_heading).to_lowercase();
587            let right_name = outline_symbol_name(right, is_heading).to_lowercase();
588            let left_is_substring =
589                left_name.contains(&query_lower) || query_lower.contains(&left_name);
590            let right_is_substring =
591                right_name.contains(&query_lower) || query_lower.contains(&right_name);
592            (!left_is_substring)
593                .cmp(&(!right_is_substring))
594                .then_with(|| {
595                    levenshtein_distance(&query_lower, &left_name)
596                        .cmp(&levenshtein_distance(&query_lower, &right_name))
597                })
598                .then_with(|| left_name.cmp(&right_name))
599        })
600}
601
602fn symbol_not_found_message(symbol_name: &str, all_symbols: &[Symbol], is_heading: bool) -> String {
603    let item_label = if is_heading { "heading" } else { "symbol" };
604    let suggestions = nearest_outline_symbols(
605        symbol_name,
606        all_symbols,
607        is_heading,
608        MAX_ZOOM_SYMBOL_SUGGESTIONS,
609    );
610    if !suggestions.is_empty() {
611        let outline_label = if is_heading {
612            "document outline"
613        } else {
614            "file outline"
615        };
616        return deterministic_zoom_refusal(
617            format!("{item_label} '{symbol_name}' not found"),
618            &format!(
619                "Choose one of these names from the {outline_label}: {}.",
620                suggestions.join(", ")
621            ),
622        );
623    }
624
625    let symbol_count = all_symbols
626        .iter()
627        .filter(|symbol| !is_heading || symbol.kind == SymbolKind::Heading)
628        .count();
629    if let Some(closest) = closest_outline_symbol(symbol_name, all_symbols, is_heading) {
630        let container_label = if is_heading { "document" } else { "file" };
631        let item_plural = if is_heading { "headings" } else { "symbols" };
632        return deterministic_zoom_refusal(
633            format!("{item_label} '{symbol_name}' not found"),
634            &format!(
635                "This {container_label} has {symbol_count} {item_plural}; closest is {}. The requested {item_label} may be in another file, so change `file` or `symbol`.",
636                format_outline_symbol_labeled(closest, is_heading)
637            ),
638        );
639    }
640
641    deterministic_zoom_refusal(
642        format!("{item_label} '{symbol_name}' not found"),
643        "Use line-range mode for a symbol-less file or choose a different `file` and `symbol`.",
644    )
645}
646
647/// Parse a JSON-stringified array of symbol names (`"[\"A\", \"B\"]"`).
648///
649/// Returns `None` unless the string is a well-formed JSON array whose elements
650/// are all strings, so ordinary symbol text (including bracketed code like
651/// `[derive]` or heading text starting with `[`) is never misparsed.
652fn parse_stringified_symbol_array(raw: &str) -> Option<Vec<String>> {
653    let trimmed = raw.trim();
654    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
655        return None;
656    }
657    let values: Vec<serde_json::Value> = serde_json::from_str(trimmed).ok()?;
658    let mut names = Vec::with_capacity(values.len());
659    for value in values {
660        let name = value.as_str()?.trim();
661        if !name.is_empty() {
662            names.push(name.to_string());
663        }
664    }
665    Some(names)
666}
667
668/// Normalize `symbol` / `symbols` into one or more lookup names.
669///
670/// For code files, a single string containing internal whitespace is split on `\s+`.
671/// Markdown/HTML headings keep the full string (headings may contain spaces).
672fn parse_zoom_symbol_names(
673    params: &serde_json::Value,
674    lang: Option<LangId>,
675) -> Result<Vec<String>, Response> {
676    if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
677        let names: Vec<String> = arr
678            .iter()
679            .filter_map(|v| v.as_str().map(str::trim))
680            .filter(|s| !s.is_empty())
681            .map(str::to_string)
682            .collect();
683        return Ok(names);
684    }
685
686    let Some(raw) = zoom_symbol_param(params) else {
687        return Ok(Vec::new());
688    };
689
690    // Some models JSON-stringify the array form ("[\"A\", \"B\"]"). Absorb it
691    // instead of treating the serialized text as one symbol name; without this
692    // the lookup fails with a not-found whose suggestions are the very names
693    // the caller sent.
694    if let Some(names) = parse_stringified_symbol_array(raw) {
695        return Ok(names);
696    }
697
698    if is_heading_zoom_language(lang) {
699        let trimmed = raw.trim();
700        if trimmed.is_empty() {
701            return Ok(Vec::new());
702        }
703        return Ok(vec![trimmed.to_string()]);
704    }
705
706    if raw.split_whitespace().count() <= 1 {
707        let trimmed = raw.trim();
708        if trimmed.is_empty() {
709            return Ok(Vec::new());
710        }
711        return Ok(vec![trimmed.to_string()]);
712    }
713
714    Ok(raw.split_whitespace().map(str::to_string).collect())
715}
716
717fn zoom_batch_symbols(
718    req: &RawRequest,
719    ctx: &AppContext,
720    path: &Path,
721    file: &str,
722    source: &str,
723    lines: &[&str],
724    symbol_names: &[String],
725    context_lines: usize,
726    include_callgraph: bool,
727) -> Response {
728    let mut entries = Vec::with_capacity(symbol_names.len());
729    let mut all_ok = true;
730
731    for name in symbol_names {
732        let resp = zoom_one_symbol(
733            req,
734            ctx,
735            path,
736            file,
737            source,
738            lines,
739            name,
740            context_lines,
741            include_callgraph,
742        );
743        let json = match serde_json::to_value(&resp) {
744            Ok(v) => v,
745            Err(err) => {
746                return Response::error(
747                    &req.id,
748                    "internal_error",
749                    format!("zoom: failed to serialize batch entry: {err}"),
750                );
751            }
752        };
753        if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
754            all_ok = false;
755        }
756        entries.push(serde_json::json!({
757            "name": name,
758            "response": json,
759        }));
760    }
761
762    Response::success(
763        &req.id,
764        serde_json::json!({
765            "complete": all_ok,
766            "symbols": entries,
767        }),
768    )
769}
770
771fn zoom_one_symbol(
772    req: &RawRequest,
773    ctx: &AppContext,
774    path: &Path,
775    _file: &str,
776    source: &str,
777    lines: &[&str],
778    symbol_name: &str,
779    context_lines: usize,
780    include_callgraph: bool,
781) -> Response {
782    // Keep raw heading labels for outline display. Zoom resolves heading names in tiers:
783    // exact raw text, normalized text, case-insensitive normalized text, then anchor slugs.
784    // Code symbols continue through the provider's exact resolver.
785    let lang = detect_language(path);
786    let is_heading = is_heading_zoom_language(lang);
787
788    // JSON files resolve dotted paths (`a.b.c`, `servers[0]`) against the parsed
789    // document in addition to literal top-level keys. See `resolve_json_zoom`.
790    if lang == Some(LangId::Json) {
791        return resolve_json_zoom(
792            req,
793            ctx,
794            path,
795            source,
796            lines,
797            symbol_name,
798            context_lines,
799            include_callgraph,
800        );
801    }
802
803    let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
804        Ok(matches) => matches,
805        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
806    };
807
808    // LSP-enhanced disambiguation (S03)
809    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
810        lsp_hints::apply_lsp_disambiguation(matches, &hints)
811    } else {
812        matches
813    };
814
815    if matches.len() > 1 {
816        let content = render_ambiguous_symbol_menu(symbol_name, &matches);
817        let candidates = matches
818            .iter()
819            .map(|candidate| {
820                let sym = &candidate.symbol;
821                serde_json::json!({
822                    "name": sym.name.clone(),
823                    "qualified_name": qualified_symbol_name(sym),
824                    "kind": symbol_kind_string(&sym.kind),
825                    "range": sym.range.clone(),
826                    "signature": sym.signature.clone(),
827                })
828            })
829            .collect::<Vec<_>>();
830
831        return Response::success(
832            &req.id,
833            serde_json::json!({
834                "name": symbol_name,
835                "kind": "ambiguous_symbol",
836                "content": content,
837                "context_before": [],
838                "context_after": [],
839                "annotations": empty_annotations(),
840                "candidates": candidates,
841            }),
842        );
843    }
844
845    if matches.is_empty() {
846        let msg = match ctx.provider().list_symbols(path) {
847            Ok(all_symbols) => symbol_not_found_message(symbol_name, &all_symbols, is_heading),
848            Err(_) => deterministic_zoom_refusal(
849                format!("symbol '{symbol_name}' not found"),
850                "List the file outline, then change `file` or `symbol`.",
851            ),
852        };
853        return Response::error(&req.id, "symbol_not_found", msg);
854    }
855
856    let target = &matches[0].symbol;
857    let start = target.range.start_line as usize;
858    let end = target.range.end_line as usize;
859
860    // When re-export following resolved to a different file, re-read that file's lines.
861    let resolved_file_path = std::path::Path::new(&matches[0].file);
862    let resolved_source = if resolved_file_path != path {
863        std::fs::read_to_string(resolved_file_path).ok()
864    } else {
865        None
866    };
867    let resolved_lines = resolved_source
868        .as_deref()
869        .map(|source| source.lines().collect::<Vec<_>>());
870    let effective_lines = resolved_lines.as_deref().unwrap_or(lines);
871
872    // Extract symbol body (0-based line indices)
873    let content = if end < effective_lines.len() {
874        effective_lines[start..=end].join("\n")
875    } else {
876        effective_lines[start..].join("\n")
877    };
878
879    let resolved_lang = detect_language(resolved_file_path);
880    let container_outline = if might_have_container_members(target) {
881        match build_container_outline(ctx, resolved_file_path, target) {
882            Ok(outline) => Some(outline),
883            Err(e) => {
884                return Response::error(&req.id, e.code(), e.to_string());
885            }
886        }
887    } else {
888        None
889    };
890
891    if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
892        let kind_str = symbol_kind_string(&target.kind);
893        let menu = format!(
894            "{}. {} Pick one of the listed member names and zoom it for its body.",
895            render_container_member_menu(target, container_outline.as_ref().unwrap()),
896            RETRY_UNCHANGED_ZOOM_MESSAGE,
897        );
898        let resp = ZoomResponse {
899            name: target.name.clone(),
900            kind: kind_str,
901            range: target.range.clone(),
902            content: menu,
903            context_before: Vec::new(),
904            context_after: Vec::new(),
905            annotations: Annotations {
906                calls_out: Vec::new(),
907                called_by: Vec::new(),
908            },
909        };
910        return match serde_json::to_value(&resp) {
911            Ok(resp_json) => Response::success(&req.id, resp_json),
912            Err(err) => Response::error(
913                &req.id,
914                "internal_error",
915                format!("zoom: failed to serialize response: {err}"),
916            ),
917        };
918    }
919
920    // Context before
921    let ctx_start = start.saturating_sub(context_lines);
922    let context_before: Vec<String> = if ctx_start < start {
923        effective_lines[ctx_start..start]
924            .iter()
925            .map(|l| l.to_string())
926            .collect()
927    } else {
928        vec![]
929    };
930
931    // Context after
932    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
933    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
934        effective_lines[(end + 1)..ctx_end]
935            .iter()
936            .map(|l| l.to_string())
937            .collect()
938    } else {
939        vec![]
940    };
941
942    let (calls_out, called_by) = if include_callgraph {
943        // Get all symbols in the resolved file for call matching
944        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
945            Ok(s) => s,
946            Err(e) => {
947                return Response::error(&req.id, e.code(), e.to_string());
948            }
949        };
950
951        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
952
953        // Parse AST for call extraction (use resolved file for cross-file re-exports)
954        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
955        let (tree, lang) = match parser.parse(resolved_file_path) {
956            Ok(r) => r,
957            Err(e) => {
958                return Response::error(&req.id, e.code(), e.to_string());
959            }
960        };
961
962        // calls_out: calls within the target symbol's byte range
963        let resolved_source = if resolved_file_path != path {
964            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
965        } else {
966            source.to_string()
967        };
968        let signature_byte_start = line_col_to_byte(
969            &resolved_source,
970            target.range.start_line,
971            target.range.start_col,
972        );
973        let signature_byte_end = line_col_to_byte(
974            &resolved_source,
975            target.range.end_line,
976            target.range.end_col,
977        );
978        let (target_byte_start, target_byte_end) =
979            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
980                .unwrap_or((signature_byte_start, signature_byte_end));
981
982        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
983
984        let raw_calls = all_file_calls.iter().filter(|call| {
985            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
986        });
987        let calls_out = dedupe_call_refs_by_name(
988            raw_calls
989                .filter(|call| {
990                    known_names.contains(&call.name.as_str()) && call.name != target.name
991                })
992                .map(|call| CallRef {
993                    name: call.name.clone(),
994                    line: call.line,
995                    extra_count: 0,
996                })
997                .collect(),
998        );
999
1000        // called_by: bucket the single file-wide call extraction by enclosing symbol range
1001        let mut called_by: Vec<CallRef> = Vec::new();
1002        for sym in &all_symbols {
1003            if sym.name == target.name && sym.range.start_line == target.range.start_line {
1004                continue; // skip self
1005            }
1006            let sym_byte_start =
1007                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
1008            let sym_byte_end =
1009                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
1010            for call in &all_file_calls {
1011                if call.name == target.name
1012                    && call.start_byte >= sym_byte_start
1013                    && call.end_byte <= sym_byte_end
1014                {
1015                    called_by.push(CallRef {
1016                        name: sym.name.clone(),
1017                        line: call.line,
1018                        extra_count: 0,
1019                    });
1020                }
1021            }
1022        }
1023
1024        let called_by = dedupe_call_refs_by_name(called_by);
1025
1026        (calls_out, called_by)
1027    } else {
1028        (Vec::new(), Vec::new())
1029    };
1030
1031    let kind_str = symbol_kind_string(&target.kind);
1032
1033    let resp = ZoomResponse {
1034        name: target.name.clone(),
1035        kind: kind_str,
1036        range: target.range.clone(),
1037        content,
1038        context_before,
1039        context_after,
1040        annotations: Annotations {
1041            calls_out,
1042            called_by,
1043        },
1044    };
1045
1046    match serde_json::to_value(&resp) {
1047        Ok(resp_json) => Response::success(&req.id, resp_json),
1048        Err(err) => Response::error(
1049            &req.id,
1050            "internal_error",
1051            format!("zoom: failed to serialize response: {err}"),
1052        ),
1053    }
1054}
1055
1056fn empty_annotations() -> serde_json::Value {
1057    serde_json::json!({
1058        "calls_out": [],
1059        "called_by": [],
1060    })
1061}
1062
1063fn render_ambiguous_symbol_menu(
1064    symbol_name: &str,
1065    matches: &[crate::symbols::SymbolMatch],
1066) -> String {
1067    let mut lines = vec![format!(
1068        "symbol '{symbol_name}' is ambiguous ({} candidates) and cannot choose a body. {} Pick one of these qualified names for `symbol`:",
1069        matches.len(),
1070        RETRY_UNCHANGED_ZOOM_MESSAGE,
1071    )];
1072
1073    for candidate in matches {
1074        let entry = symbol_to_entry(&candidate.symbol);
1075        lines.push(format!(
1076            "- {}",
1077            format_qualified_entry(&entry, Some(&candidate.symbol))
1078        ));
1079    }
1080
1081    lines.join("\n")
1082}
1083
1084fn levenshtein_distance(s1: &str, s2: &str) -> usize {
1085    let s1_chars: Vec<char> = s1.chars().collect();
1086    let s2_chars: Vec<char> = s2.chars().collect();
1087    let len1 = s1_chars.len();
1088    let len2 = s2_chars.len();
1089
1090    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
1091
1092    for i in 0..=len1 {
1093        dp[i][0] = i;
1094    }
1095    for j in 0..=len2 {
1096        dp[0][j] = j;
1097    }
1098
1099    for i in 1..=len1 {
1100        for j in 1..=len2 {
1101            if s1_chars[i - 1] == s2_chars[j - 1] {
1102                dp[i][j] = dp[i - 1][j - 1];
1103            } else {
1104                dp[i][j] =
1105                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
1106            }
1107        }
1108    }
1109
1110    dp[len1][len2]
1111}
1112
1113fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
1114    let mut unique: Vec<&String> = available.iter().collect();
1115    unique.sort();
1116    unique.dedup();
1117
1118    let query_lower = query.to_lowercase();
1119    let query_len = query_lower.chars().count();
1120    let max_dist = std::cmp::max(2, query_len / 3);
1121
1122    let mut scored: Vec<(bool, usize, &String)> = unique
1123        .into_iter()
1124        .map(|name| {
1125            let name_lower = name.to_lowercase();
1126            let is_substring =
1127                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
1128            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
1129                (query_lower.find('_'), query_lower.rfind('_'))
1130            {
1131                let prefix = &query_lower[..=first_idx];
1132                let suffix = &query_lower[last_idx..];
1133                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
1134            } else {
1135                false
1136            };
1137            let is_match = is_substring || is_wildcard;
1138            let dist = levenshtein_distance(&query_lower, &name_lower);
1139            (is_match, dist, name)
1140        })
1141        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
1142        .collect();
1143
1144    scored.sort_by(|a, b| {
1145        let a_match = a.0;
1146        let b_match = b.0;
1147        (!a_match)
1148            .cmp(&(!b_match))
1149            .then_with(|| a.1.cmp(&b.1))
1150            .then_with(|| a.2.cmp(b.2))
1151    });
1152
1153    scored
1154        .into_iter()
1155        .take(k)
1156        .map(|(_, _, name)| name.clone())
1157        .collect()
1158}
1159
1160fn resolve_zoom_symbol(
1161    provider: &dyn LanguageProvider,
1162    path: &Path,
1163    query: &str,
1164    is_heading: bool,
1165) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1166    if is_heading {
1167        return resolve_heading_symbols(provider, path, query);
1168    }
1169
1170    match provider.resolve_symbol(path, query) {
1171        Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
1172        result => result,
1173    }
1174}
1175
1176/// A resolved JSON node: the tree-sitter node plus the human-readable path
1177/// segments used to reach it (for error messages and candidate labels).
1178struct JsonNode<'a> {
1179    node: tree_sitter::Node<'a>,
1180    path: String,
1181}
1182
1183struct JsonPathMiss {
1184    prefix: String,
1185    failing: String,
1186}
1187
1188/// Resolve a zoom request against a JSON document.
1189///
1190/// The query is resolved in two independent ways and the results compared:
1191///
1192/// 1. **Literal first**: the query is matched as an exact top-level key name
1193///    using the provider's normal symbol resolution. JSON keys may legitimately
1194///    contain dots, so a literal match always wins outright.
1195/// 2. **Path walk**: if there is no literal match, the query is split on `.`
1196///    and walked through the document — object steps by key, array steps by
1197///    `name[index]` (0-based) or bare `[index]`.
1198///
1199/// If both a literal key and a successful path walk resolve to DIFFERENT nodes,
1200/// the query is ambiguous and both candidates are reported. If they resolve to
1201/// the same node, the literal match is returned (not an error).
1202fn resolve_json_zoom(
1203    req: &RawRequest,
1204    ctx: &AppContext,
1205    path: &Path,
1206    source: &str,
1207    lines: &[&str],
1208    symbol_name: &str,
1209    context_lines: usize,
1210    include_callgraph: bool,
1211) -> Response {
1212    let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1213    let (tree, _) = match parser.parse(path) {
1214        Ok(parsed) => parsed,
1215        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1216    };
1217    let root = tree.root_node();
1218
1219    // Literal resolution: exact top-level key via the provider.
1220    let literal = match ctx.provider().resolve_symbol(path, symbol_name) {
1221        Ok(matches) => matches,
1222        Err(crate::error::AftError::SymbolNotFound { .. }) => Vec::new(),
1223        Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1224    };
1225
1226    // Path resolution: walk the document by segments.
1227    let path_result = json_path_resolve(source, &root, symbol_name);
1228
1229    // Both a literal key and a path walk resolved. Compare the literal key's
1230    // VALUE node against the path-walked node. A plain top-level key like
1231    // `servers` resolves to the same value node both ways, so it is not
1232    // ambiguous. A dotted query where a literal key AND a nested path both
1233    // exist (e.g. `literal.dotted.key`) resolves to different nodes → ambiguous.
1234    if !literal.is_empty() {
1235        if let Some(path_node) = path_result.as_ref() {
1236            let literal_value_node = json_document_value(&root)
1237                .and_then(|object| json_object_value(source, object, symbol_name));
1238            let same_node = literal_value_node
1239                .map(|node| {
1240                    node.start_position().row == path_node.node.start_position().row
1241                        && node.start_position().column == path_node.node.start_position().column
1242                        && node.end_position().row == path_node.node.end_position().row
1243                        && node.end_position().column == path_node.node.end_position().column
1244                })
1245                .unwrap_or(false);
1246            if !same_node {
1247                let literal_node = &literal[0].symbol;
1248                let candidates = vec![
1249                    serde_json::json!({
1250                        "name": symbol_name,
1251                        "kind": symbol_kind_string(&literal_node.kind),
1252                        "range": literal_node.range.clone(),
1253                        "signature": literal_node.signature.clone(),
1254                    }),
1255                    serde_json::json!({
1256                        "name": path_node.path.clone(),
1257                        "kind": "json_path",
1258                        "range": node_range(&path_node.node),
1259                        "signature": serde_json::Value::Null,
1260                    }),
1261                ];
1262                return Response::error_with_data(
1263                    &req.id,
1264                    "ambiguous_match",
1265                    deterministic_zoom_refusal(
1266                        format!(
1267                            "symbol '{symbol_name}' is ambiguous: a literal key and a JSON path both resolve to different nodes"
1268                        ),
1269                        "Choose either the literal key or the dotted JSON path from the listed candidates.",
1270                    ),
1271                    serde_json::json!({ "candidates": candidates }),
1272                );
1273            }
1274        }
1275    }
1276
1277    // Prefer the literal match when present (literal-first).
1278    if !literal.is_empty() {
1279        return render_json_zoom(
1280            req,
1281            ctx,
1282            path,
1283            source,
1284            lines,
1285            symbol_name,
1286            &literal[0].symbol,
1287            context_lines,
1288            include_callgraph,
1289        );
1290    }
1291
1292    // Otherwise use the path walk result.
1293    if let Some(resolved) = path_result {
1294        return render_json_zoom(
1295            req,
1296            ctx,
1297            path,
1298            source,
1299            lines,
1300            &resolved.path,
1301            &json_node_to_symbol(&resolved.node, &resolved.path),
1302            context_lines,
1303            include_callgraph,
1304        );
1305    }
1306
1307    // Miss: report the deepest resolved prefix and the failing segment, and
1308    // suggest sibling keys within the deepest resolved object.
1309    let (prefix, failing) = json_miss_details(source, &root, symbol_name);
1310    let mut msg = if prefix.is_empty() {
1311        format!("symbol '{}' not found: no key `{}`", symbol_name, failing)
1312    } else {
1313        format!(
1314            "symbol '{}' not found: resolved `{}`, no key `{}`",
1315            symbol_name, prefix, failing
1316        )
1317    };
1318    let sibling_keys = json_sibling_keys(source, &root, &prefix);
1319    if !sibling_keys.is_empty() {
1320        let suggestions = suggest_close_symbols(&failing, &sibling_keys, 5);
1321        if !suggestions.is_empty() {
1322            msg.push_str(&format!(" — nearest: [{}]", suggestions.join(", ")));
1323        }
1324    }
1325    Response::error(
1326        &req.id,
1327        "symbol_not_found",
1328        deterministic_zoom_refusal(
1329            msg,
1330            "Choose a listed sibling key or change the JSON path segment that missed.",
1331        ),
1332    )
1333}
1334
1335/// Walk a JSON document by a dotted path, returning the deepest resolved node.
1336///
1337/// Object steps match keys literally; array steps use `name[index]` (0-based)
1338/// or bare `[index]`. A segment containing brackets tries the bracket parse
1339/// only if no literal key of that exact spelling exists.
1340fn json_path_resolve<'a>(
1341    source: &str,
1342    root: &tree_sitter::Node<'a>,
1343    query: &str,
1344) -> Option<JsonNode<'a>> {
1345    json_path_lookup(source, root, query).ok()
1346}
1347
1348/// Walk a JSON path while preserving the exact segment at which resolution fails.
1349///
1350/// Keeping the miss at the point of failure lets the caller report the actual
1351/// deepest object rather than guessing that only the final query segment failed.
1352fn json_path_lookup<'a>(
1353    source: &str,
1354    root: &tree_sitter::Node<'a>,
1355    query: &str,
1356) -> Result<JsonNode<'a>, JsonPathMiss> {
1357    let segments = split_json_path(query);
1358    let Some(first_segment) = segments.first() else {
1359        return Err(JsonPathMiss {
1360            prefix: String::new(),
1361            failing: query.to_string(),
1362        });
1363    };
1364
1365    // The document root must be an object for keyed access. JSONC comments can
1366    // precede this value, so select the first non-comment child rather than the
1367    // first named child unconditionally.
1368    let Some(mut current) = json_document_value(root) else {
1369        return Err(JsonPathMiss {
1370            prefix: String::new(),
1371            failing: first_segment.clone(),
1372        });
1373    };
1374    if current.kind() != "object" {
1375        return Err(JsonPathMiss {
1376            prefix: String::new(),
1377            failing: first_segment.clone(),
1378        });
1379    }
1380
1381    let mut resolved_path = String::new();
1382    for segment in &segments {
1383        let (key, array_index) = parse_json_segment(segment);
1384        let next = if let Some(array_index) = array_index {
1385            // A segment like `servers[0]` first resolves the key to the array
1386            // node, then indexes into it. A bare `[0]` indexes the current node.
1387            let array = match key {
1388                Some(key) => json_object_value(source, current, key),
1389                None => Some(current),
1390            };
1391            array.and_then(|array| json_array_element(array, array_index))
1392        } else {
1393            key.and_then(|key| json_object_value(source, current, key))
1394        };
1395        let Some(next) = next else {
1396            return Err(JsonPathMiss {
1397                prefix: resolved_path,
1398                failing: segment.clone(),
1399            });
1400        };
1401
1402        if resolved_path.is_empty() {
1403            resolved_path = segment.clone();
1404        } else {
1405            resolved_path.push('.');
1406            resolved_path.push_str(segment);
1407        }
1408        current = next;
1409    }
1410
1411    Ok(JsonNode {
1412        node: current,
1413        path: resolved_path,
1414    })
1415}
1416
1417/// Split a JSON path query into segments on `.`, preserving bracket groups.
1418fn split_json_path(query: &str) -> Vec<String> {
1419    let mut segments = Vec::new();
1420    let mut current = String::new();
1421    let mut depth = 0usize;
1422    for character in query.chars() {
1423        match character {
1424            '[' => {
1425                depth += 1;
1426                current.push(character);
1427            }
1428            ']' => {
1429                depth = depth.saturating_sub(1);
1430                current.push(character);
1431            }
1432            '.' if depth == 0 => {
1433                if !current.is_empty() {
1434                    segments.push(std::mem::take(&mut current));
1435                }
1436            }
1437            _ => current.push(character),
1438        }
1439    }
1440    if !current.is_empty() {
1441        segments.push(current);
1442    }
1443    segments
1444}
1445
1446/// Parse a single path segment into an optional object key and optional array index.
1447///
1448/// A segment like `servers[0]` yields key `servers` and index `0`; a bare `[0]`
1449/// yields no key and index `0`; a plain `host` yields key `host` and no index.
1450fn parse_json_segment(segment: &str) -> (Option<&str>, Option<usize>) {
1451    if let Some(open) = segment.find('[') {
1452        if segment.ends_with(']') {
1453            let key = if open == 0 {
1454                None
1455            } else {
1456                Some(&segment[..open])
1457            };
1458            let index_text = &segment[open + 1..segment.len() - 1];
1459            if let Ok(index) = index_text.parse::<usize>() {
1460                return (key, Some(index));
1461            }
1462        }
1463    }
1464    (Some(segment), None)
1465}
1466
1467/// Return the value node for a key in a JSON object, or `None` if absent.
1468fn json_object_value<'a>(
1469    source: &str,
1470    object: tree_sitter::Node<'a>,
1471    key: &str,
1472) -> Option<tree_sitter::Node<'a>> {
1473    if object.kind() != "object" {
1474        return None;
1475    }
1476    let mut cursor = object.walk();
1477    for pair in object.named_children(&mut cursor) {
1478        if pair.kind() != "pair" {
1479            continue;
1480        }
1481        let Some(key_node) = pair.child_by_field_name("key") else {
1482            continue;
1483        };
1484        if node_text(source, &key_node).trim_matches('"') == key {
1485            return pair.child_by_field_name("value");
1486        }
1487    }
1488    None
1489}
1490
1491/// Return the element at a 0-based index in a JSON array, or `None` if out of range.
1492fn json_array_element<'a>(
1493    array: tree_sitter::Node<'a>,
1494    index: usize,
1495) -> Option<tree_sitter::Node<'a>> {
1496    if array.kind() != "array" {
1497        return None;
1498    }
1499    let mut cursor = array.walk();
1500    for (seen, element) in array.named_children(&mut cursor).enumerate() {
1501        if seen == index {
1502            return Some(element);
1503        }
1504    }
1505    None
1506}
1507
1508/// Build a `Symbol` from a resolved JSON node for rendering.
1509fn json_node_to_symbol(node: &tree_sitter::Node, path: &str) -> Symbol {
1510    Symbol {
1511        name: path.to_string(),
1512        kind: SymbolKind::Variable,
1513        range: node_range(node),
1514        signature: None,
1515        scope_chain: vec![],
1516        exported: false,
1517        parent: None,
1518    }
1519}
1520
1521/// Render a resolved JSON node as a zoom response, mirroring a top-level zoom.
1522fn render_json_zoom(
1523    req: &RawRequest,
1524    ctx: &AppContext,
1525    path: &Path,
1526    source: &str,
1527    lines: &[&str],
1528    name: &str,
1529    target: &Symbol,
1530    context_lines: usize,
1531    include_callgraph: bool,
1532) -> Response {
1533    let start = target.range.start_line as usize;
1534    let end = target.range.end_line as usize;
1535
1536    let content = if end < lines.len() {
1537        lines[start..=end].join("\n")
1538    } else {
1539        lines[start..].join("\n")
1540    };
1541
1542    let ctx_start = start.saturating_sub(context_lines);
1543    let context_before: Vec<String> = if ctx_start < start {
1544        lines[ctx_start..start]
1545            .iter()
1546            .map(|line| (*line).to_string())
1547            .collect()
1548    } else {
1549        vec![]
1550    };
1551    let ctx_end = (end + 1 + context_lines).min(lines.len());
1552    let context_after: Vec<String> = if end + 1 < lines.len() {
1553        lines[(end + 1)..ctx_end]
1554            .iter()
1555            .map(|line| (*line).to_string())
1556            .collect()
1557    } else {
1558        vec![]
1559    };
1560
1561    let (calls_out, called_by) = if include_callgraph {
1562        let all_symbols = match ctx.provider().list_symbols(path) {
1563            Ok(s) => s,
1564            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1565        };
1566        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
1567        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1568        let (tree, lang) = match parser.parse(path) {
1569            Ok(r) => r,
1570            Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1571        };
1572        let all_file_calls = extract_calls_with_ranges(source, tree.root_node(), lang);
1573        let signature_byte_start =
1574            line_col_to_byte(source, target.range.start_line, target.range.start_col);
1575        let signature_byte_end =
1576            line_col_to_byte(source, target.range.end_line, target.range.end_col);
1577        let (target_byte_start, target_byte_end) =
1578            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
1579                .unwrap_or((signature_byte_start, signature_byte_end));
1580        let calls_out = dedupe_call_refs_by_name(
1581            all_file_calls
1582                .iter()
1583                .filter(|call| {
1584                    call.start_byte >= target_byte_start
1585                        && call.end_byte <= target_byte_end
1586                        && known_names.contains(&call.name.as_str())
1587                        && call.name != target.name
1588                })
1589                .map(|call| CallRef {
1590                    name: call.name.clone(),
1591                    line: call.line,
1592                    extra_count: 0,
1593                })
1594                .collect(),
1595        );
1596        (calls_out, Vec::new())
1597    } else {
1598        (Vec::new(), Vec::new())
1599    };
1600
1601    let resp = ZoomResponse {
1602        name: name.to_string(),
1603        kind: symbol_kind_string(&target.kind),
1604        range: target.range.clone(),
1605        content,
1606        context_before,
1607        context_after,
1608        annotations: Annotations {
1609            calls_out,
1610            called_by,
1611        },
1612    };
1613
1614    match serde_json::to_value(&resp) {
1615        Ok(resp_json) => Response::success(&req.id, resp_json),
1616        Err(err) => Response::error(
1617            &req.id,
1618            "internal_error",
1619            format!("zoom: failed to serialize response: {err}"),
1620        ),
1621    }
1622}
1623
1624/// Compute the deepest resolved prefix and the failing segment for a miss message.
1625///
1626/// The path walker records the first segment that cannot be resolved, so a
1627/// multi-segment miss names the real failing segment and its nearest resolved
1628/// object.
1629fn json_miss_details(source: &str, root: &tree_sitter::Node, query: &str) -> (String, String) {
1630    match json_path_lookup(source, root, query) {
1631        Err(miss) => (miss.prefix, miss.failing),
1632        Ok(_) => (String::new(), String::new()),
1633    }
1634}
1635
1636/// Return the sibling keys of the deepest resolved object for a miss message.
1637///
1638/// For a single-segment query the "deepest resolved object" is the document
1639/// root; for a multi-segment query it is the object reached by the prefix.
1640fn json_sibling_keys(source: &str, root: &tree_sitter::Node, prefix: &str) -> Vec<String> {
1641    let object = if prefix.is_empty() {
1642        json_document_value(root)
1643    } else {
1644        json_path_resolve(source, root, prefix).map(|resolved| resolved.node)
1645    };
1646    let Some(object) = object else {
1647        return Vec::new();
1648    };
1649    if object.kind() != "object" {
1650        return Vec::new();
1651    }
1652    let mut keys = Vec::new();
1653    let mut cursor = object.walk();
1654    for pair in object.named_children(&mut cursor) {
1655        if pair.kind() != "pair" {
1656            continue;
1657        }
1658        if let Some(key_node) = pair.child_by_field_name("key") {
1659            let key = node_text(source, &key_node).trim_matches('"').to_string();
1660            if !key.is_empty() {
1661                keys.push(key);
1662            }
1663        }
1664    }
1665    keys
1666}
1667
1668/// Build a `Range` from a tree-sitter node (0-indexed, matching `Symbol::range`).
1669fn node_range(node: &tree_sitter::Node) -> Range {
1670    let start = node.start_position();
1671    let end = node.end_position();
1672    Range {
1673        start_line: start.row as u32,
1674        start_col: start.column as u32,
1675        end_line: end.row as u32,
1676        end_col: end.column as u32,
1677    }
1678}
1679
1680/// Keep document headings' raw labels for outline fidelity while allowing zoom to use
1681/// human-readable labels, section prefixes, or anchors without affecting code symbols.
1682fn resolve_heading_symbols(
1683    provider: &dyn LanguageProvider,
1684    path: &Path,
1685    query: &str,
1686) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1687    let headings: Vec<SymbolMatch> = provider
1688        .list_symbols(path)?
1689        .into_iter()
1690        .filter(|symbol| symbol.kind == SymbolKind::Heading)
1691        .map(|symbol| SymbolMatch {
1692            file: path.display().to_string(),
1693            symbol,
1694        })
1695        .collect();
1696
1697    let anchors = if query.starts_with('#') {
1698        provider.heading_anchors(path)?
1699    } else {
1700        Vec::new()
1701    };
1702
1703    Ok(match_heading_identity(&headings, query, &anchors))
1704}
1705
1706fn match_heading_identity(
1707    headings: &[SymbolMatch],
1708    query: &str,
1709    anchors: &[HeadingAnchor],
1710) -> Vec<SymbolMatch> {
1711    if let Some(anchor_query) = query.strip_prefix('#') {
1712        let mut matches: Vec<_> = headings
1713            .iter()
1714            .filter(|candidate| {
1715                anchors.iter().any(|anchor| {
1716                    anchor.id == anchor_query
1717                        && anchor.start_line == candidate.symbol.range.start_line
1718                        && anchor.start_col == candidate.symbol.range.start_col
1719                })
1720            })
1721            .cloned()
1722            .collect();
1723
1724        let normalized_query = normalize_heading_label(query);
1725        let query_slug = slugify_heading_label(&normalized_query);
1726        if !query_slug.is_empty() {
1727            for candidate in headings
1728                .iter()
1729                .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1730            {
1731                if !matches.iter().any(|existing| {
1732                    existing.file == candidate.file && existing.symbol == candidate.symbol
1733                }) {
1734                    matches.push(candidate.clone());
1735                }
1736            }
1737        }
1738        return matches;
1739    }
1740
1741    let exact: Vec<_> = headings
1742        .iter()
1743        .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1744        .cloned()
1745        .collect();
1746    if !exact.is_empty() {
1747        return exact;
1748    }
1749
1750    let normalized_query = normalize_heading_label(query);
1751    if normalized_query.is_empty() {
1752        return Vec::new();
1753    }
1754
1755    let normalized: Vec<_> = headings
1756        .iter()
1757        .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1758        .cloned()
1759        .collect();
1760    if !normalized.is_empty() {
1761        return normalized;
1762    }
1763
1764    let folded_query = normalized_query.to_lowercase();
1765    let case_insensitive: Vec<_> = headings
1766        .iter()
1767        .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1768        .cloned()
1769        .collect();
1770    if !case_insensitive.is_empty() {
1771        return case_insensitive;
1772    }
1773
1774    let query_slug = slugify_heading_label(&normalized_query);
1775    if query_slug.is_empty() {
1776        return Vec::new();
1777    }
1778
1779    headings
1780        .iter()
1781        .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1782        .cloned()
1783        .collect()
1784}
1785
1786fn qualified_heading_name(symbol: &Symbol) -> String {
1787    if symbol.scope_chain.is_empty() {
1788        return symbol.name.clone();
1789    }
1790    format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1791}
1792
1793fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1794    symbol.name == query || qualified_heading_name(symbol) == query
1795}
1796
1797fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1798    normalize_heading_label(&symbol.name) == query
1799        || normalize_heading_label(&qualified_heading_name(symbol)) == query
1800}
1801
1802fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1803    normalize_heading_label(&symbol.name).to_lowercase() == query
1804        || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1805}
1806
1807fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1808    slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1809        || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1810            == query_slug
1811}
1812
1813fn normalize_heading_label(input: &str) -> String {
1814    let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1815
1816    // A label can contain both a document prefix and a decorative symbol cluster.
1817    // Repeat the small cleanup sequence so either order is handled consistently.
1818    for _ in 0..4 {
1819        let mut next = value.as_str();
1820        let without_heading_markers = next.trim_start_matches('#').trim_start();
1821        if without_heading_markers != next {
1822            next = without_heading_markers;
1823        }
1824        if let Some(rest) = strip_heading_html_prefix(next) {
1825            next = rest;
1826        }
1827        if let Some(rest) = strip_leading_section_prefix(next) {
1828            next = rest;
1829        }
1830        if let Some(rest) = strip_leading_symbol_cluster(next) {
1831            next = rest;
1832        }
1833
1834        let collapsed = collapse_heading_whitespace(next);
1835        if collapsed == value {
1836            break;
1837        }
1838        value = collapsed;
1839    }
1840
1841    value
1842}
1843
1844fn collapse_heading_whitespace(input: &str) -> String {
1845    input.split_whitespace().collect::<Vec<_>>().join(" ")
1846}
1847
1848fn strip_markdown_links(input: &str) -> String {
1849    let mut output = String::with_capacity(input.len());
1850    let mut cursor = 0;
1851
1852    while let Some(relative_open) = input[cursor..].find('[') {
1853        let open = cursor + relative_open;
1854        let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1855            output.push_str(&input[cursor..]);
1856            return output;
1857        };
1858
1859        if input.as_bytes().get(close + 1) != Some(&b'(') {
1860            output.push_str(&input[cursor..=close]);
1861            cursor = close + 1;
1862            continue;
1863        }
1864
1865        let target_open = close + 1;
1866        let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1867            output.push_str(&input[cursor..]);
1868            return output;
1869        };
1870
1871        output.push_str(&input[cursor..open]);
1872        output.push_str(&input[open + 1..close]);
1873        cursor = target_close + 1;
1874    }
1875
1876    output.push_str(&input[cursor..]);
1877    output
1878}
1879
1880fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1881    let mut depth = 0;
1882    for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1883        if *byte == open {
1884            depth += 1;
1885        } else if *byte == close {
1886            depth -= 1;
1887            if depth == 0 {
1888                return Some(index);
1889            }
1890        }
1891    }
1892    None
1893}
1894
1895fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1896    let input = input.trim_start();
1897    let bytes = input.as_bytes();
1898    if bytes.first() != Some(&b'<') {
1899        return None;
1900    }
1901
1902    let mut index = 1;
1903    if bytes.get(index) == Some(&b'/') {
1904        index += 1;
1905    }
1906    if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1907        return None;
1908    }
1909    index += 1;
1910    if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1911        return None;
1912    }
1913
1914    let end = input.find('>')?;
1915    let rest = input[end + 1..].trim_start();
1916    if rest.chars().any(|character| character.is_alphanumeric()) {
1917        Some(rest)
1918    } else {
1919        None
1920    }
1921}
1922
1923fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1924    let bytes = input.as_bytes();
1925    let mut index = 0;
1926    let mut saw_dot = false;
1927
1928    if !bytes
1929        .first()
1930        .is_some_and(|byte| byte.is_ascii_alphanumeric())
1931    {
1932        return None;
1933    }
1934
1935    while index < bytes.len() {
1936        while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1937            index += 1;
1938        }
1939        if bytes.get(index) != Some(&b'.') {
1940            break;
1941        }
1942        saw_dot = true;
1943        index += 1;
1944        if bytes
1945            .get(index)
1946            .is_some_and(|byte| byte.is_ascii_whitespace())
1947        {
1948            let rest = input[index..].trim_start();
1949            return if rest.chars().any(|character| character.is_alphanumeric()) {
1950                Some(rest)
1951            } else {
1952                None
1953            };
1954        }
1955        if !bytes
1956            .get(index)
1957            .is_some_and(|byte| byte.is_ascii_alphanumeric())
1958        {
1959            return None;
1960        }
1961    }
1962
1963    if saw_dot
1964        && bytes
1965            .get(index)
1966            .is_some_and(|byte| byte.is_ascii_whitespace())
1967    {
1968        let rest = input[index..].trim_start();
1969        if rest.chars().any(|character| character.is_alphanumeric()) {
1970            return Some(rest);
1971        }
1972    }
1973    None
1974}
1975
1976fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1977    let first_text = input
1978        .char_indices()
1979        .find(|(_, character)| character.is_alphanumeric())
1980        .map(|(index, _)| index)?;
1981    if first_text == 0 {
1982        return None;
1983    }
1984
1985    let rest = &input[first_text..];
1986    if rest.chars().any(|character| character.is_alphanumeric()) {
1987        Some(rest)
1988    } else {
1989        None
1990    }
1991}
1992
1993fn slugify_heading_label(label: &str) -> String {
1994    let mut slug = String::new();
1995    let mut pending_separator = false;
1996
1997    for character in label.chars() {
1998        if character.is_alphanumeric() {
1999            if pending_separator && !slug.is_empty() {
2000                slug.push('-');
2001            }
2002            for lowercase in character.to_lowercase() {
2003                slug.push(lowercase);
2004            }
2005            pending_separator = false;
2006        } else if !slug.is_empty() {
2007            pending_separator = true;
2008        }
2009    }
2010
2011    slug
2012}
2013
2014/// Extract call expression names within a byte range of the AST.
2015///
2016/// Delegates to `crate::calls::extract_calls_in_range`.
2017#[cfg(test)]
2018fn extract_calls_in_range(
2019    source: &str,
2020    root: tree_sitter::Node,
2021    byte_start: usize,
2022    byte_end: usize,
2023    lang: LangId,
2024) -> Vec<(String, u32)> {
2025    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
2026}
2027
2028fn symbol_body_byte_range(
2029    root: tree_sitter::Node,
2030    byte_start: usize,
2031    byte_end: usize,
2032) -> Option<(usize, usize)> {
2033    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
2034    let mut current = Some(node);
2035    while let Some(node) = current {
2036        if is_symbol_body_node(node.kind()) {
2037            return Some((node.start_byte(), node.end_byte()));
2038        }
2039        current = node.parent();
2040    }
2041    Some((node.start_byte(), node.end_byte()))
2042}
2043
2044fn smallest_node_covering_range<'tree>(
2045    node: tree_sitter::Node<'tree>,
2046    byte_start: usize,
2047    byte_end: usize,
2048) -> Option<tree_sitter::Node<'tree>> {
2049    if node.start_byte() > byte_start || node.end_byte() < byte_end {
2050        return None;
2051    }
2052
2053    let mut cursor = node.walk();
2054    if cursor.goto_first_child() {
2055        loop {
2056            let child = cursor.node();
2057            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
2058                return Some(found);
2059            }
2060            if !cursor.goto_next_sibling() {
2061                break;
2062            }
2063        }
2064    }
2065
2066    Some(node)
2067}
2068
2069fn is_symbol_body_node(kind: &str) -> bool {
2070    matches!(
2071        kind,
2072        "function_declaration"
2073            | "generator_function_declaration"
2074            | "function_expression"
2075            | "generator_function"
2076            | "arrow_function"
2077            | "method_definition"
2078            | "class_declaration"
2079            | "abstract_class_declaration"
2080            | "class"
2081            | "lexical_declaration"
2082            | "function_definition"
2083            | "class_definition"
2084            | "decorated_definition"
2085            | "function_item"
2086            | "impl_item"
2087            | "method_declaration"
2088    )
2089}
2090
2091fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
2092    let mut results = Vec::new();
2093    let call_kinds = crate::calls::call_node_kinds(lang);
2094    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
2095    results
2096}
2097
2098fn collect_calls_with_ranges(
2099    node: tree_sitter::Node,
2100    source: &str,
2101    call_kinds: &[&str],
2102    results: &mut Vec<RawCall>,
2103) {
2104    if call_kinds.contains(&node.kind()) {
2105        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
2106            results.push(RawCall {
2107                name,
2108                line: node.start_position().row as u32 + 1,
2109                start_byte: node.start_byte(),
2110                end_byte: node.end_byte(),
2111            });
2112        }
2113    }
2114
2115    let mut cursor = node.walk();
2116    if cursor.goto_first_child() {
2117        loop {
2118            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
2119            if !cursor.goto_next_sibling() {
2120                break;
2121            }
2122        }
2123    }
2124}
2125
2126#[cfg(test)]
2127mod tests {
2128    use super::*;
2129    use crate::config::Config;
2130    use crate::context::AppContext;
2131    use crate::parser::TreeSitterProvider;
2132    use std::path::PathBuf;
2133
2134    fn fixture_path(name: &str) -> PathBuf {
2135        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2136            .join("tests")
2137            .join("fixtures")
2138            .join(name)
2139    }
2140
2141    fn make_ctx() -> AppContext {
2142        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
2143    }
2144
2145    #[test]
2146    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
2147        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
2148        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2149        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
2150    }
2151
2152    #[test]
2153    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
2154        let params = serde_json::json!({ "symbols": "Getting Started" });
2155        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
2156        assert_eq!(names, vec!["Getting Started"]);
2157    }
2158
2159    #[test]
2160    fn parse_zoom_symbol_names_does_not_split_html_headings() {
2161        let params = serde_json::json!({ "symbol": "Last Heading" });
2162        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
2163        assert_eq!(names, vec!["Last Heading"]);
2164    }
2165
2166    #[test]
2167    fn parse_zoom_symbol_names_single_token_unchanged() {
2168        let params = serde_json::json!({ "symbol": "compute" });
2169        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
2170        assert_eq!(names, vec!["compute"]);
2171    }
2172
2173    #[test]
2174    fn parse_zoom_symbol_names_symbols_array_unchanged() {
2175        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
2176        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2177        assert_eq!(names, vec!["A", "B", "C"]);
2178    }
2179
2180    #[test]
2181    fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
2182        // Models sometimes JSON-stringify the array form; headings keep spaces.
2183        let params =
2184            serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
2185        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
2186        assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
2187    }
2188
2189    #[test]
2190    fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
2191        let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
2192        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2193        assert_eq!(names, vec!["alpha", "beta"]);
2194    }
2195
2196    #[test]
2197    fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
2198        // A real heading that starts with '[' but is not a JSON array must
2199        // stay a single lookup name.
2200        let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
2201        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
2202        assert_eq!(names, vec!["[Draft] Rollout plan"]);
2203    }
2204
2205    #[test]
2206    fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
2207        // "[1, 2]" parses as JSON but not as symbol names; fall through to
2208        // ordinary handling rather than returning numbers-as-names.
2209        let params = serde_json::json!({ "symbols": "[1, 2]" });
2210        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
2211        assert_eq!(names, vec!["[1,", "2]"]);
2212    }
2213
2214    // --- Call extraction tests ---
2215
2216    #[test]
2217    fn extract_calls_finds_direct_calls() {
2218        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2219        let mut parser = FileParser::new();
2220        let path = fixture_path("calls.ts");
2221        let (tree, lang) = parser.parse(&path).unwrap();
2222
2223        // `compute` calls `helper` — find compute's range from symbols
2224        let ctx = make_ctx();
2225        let symbols = ctx.provider().list_symbols(&path).unwrap();
2226        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2227
2228        let byte_start =
2229            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
2230        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
2231
2232        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2233        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2234
2235        assert!(
2236            names.contains(&"helper"),
2237            "compute should call helper, got: {:?}",
2238            names
2239        );
2240    }
2241
2242    #[test]
2243    fn extract_calls_finds_member_calls() {
2244        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2245        let mut parser = FileParser::new();
2246        let path = fixture_path("calls.ts");
2247        let (tree, lang) = parser.parse(&path).unwrap();
2248
2249        let ctx = make_ctx();
2250        let symbols = ctx.provider().list_symbols(&path).unwrap();
2251        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
2252
2253        let byte_start =
2254            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
2255        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
2256
2257        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2258        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2259
2260        assert!(
2261            names.contains(&"add"),
2262            "runAll should call this.add, got: {:?}",
2263            names
2264        );
2265        assert!(
2266            names.contains(&"helper"),
2267            "runAll should call helper, got: {:?}",
2268            names
2269        );
2270    }
2271
2272    #[test]
2273    fn extract_calls_unused_function_has_no_calls() {
2274        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2275        let mut parser = FileParser::new();
2276        let path = fixture_path("calls.ts");
2277        let (tree, lang) = parser.parse(&path).unwrap();
2278
2279        let ctx = make_ctx();
2280        let symbols = ctx.provider().list_symbols(&path).unwrap();
2281        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
2282
2283        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
2284        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
2285
2286        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2287        // console.log is the only call, but "log" or "console" aren't known symbols
2288        let known_names = [
2289            "helper",
2290            "compute",
2291            "orchestrate",
2292            "unused",
2293            "format",
2294            "display",
2295        ];
2296        let filtered: Vec<&str> = calls
2297            .iter()
2298            .map(|(n, _)| n.as_str())
2299            .filter(|n| known_names.contains(n))
2300            .collect();
2301        assert!(
2302            filtered.is_empty(),
2303            "unused should not call known symbols, got: {:?}",
2304            filtered
2305        );
2306    }
2307
2308    // --- Context line tests ---
2309
2310    #[test]
2311    fn context_lines_clamp_at_file_start() {
2312        // helper() is at the top of the file (line 2) — context_before should be clamped
2313        let ctx = make_ctx();
2314        let path = fixture_path("calls.ts");
2315        let symbols = ctx.provider().list_symbols(&path).unwrap();
2316        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
2317
2318        let source = std::fs::read_to_string(&path).unwrap();
2319        let lines: Vec<&str> = source.lines().collect();
2320        let start = helper.range.start_line as usize;
2321
2322        // With context_lines=5, ctx_start should clamp to 0
2323        let ctx_start = start.saturating_sub(5);
2324        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
2325        // Should have at most `start` lines (not panic)
2326        assert!(context_before.len() <= start);
2327    }
2328
2329    #[test]
2330    fn context_lines_clamp_at_file_end() {
2331        let ctx = make_ctx();
2332        let path = fixture_path("calls.ts");
2333        let symbols = ctx.provider().list_symbols(&path).unwrap();
2334        let display = symbols.iter().find(|s| s.name == "display").unwrap();
2335
2336        let source = std::fs::read_to_string(&path).unwrap();
2337        let lines: Vec<&str> = source.lines().collect();
2338        let end = display.range.end_line as usize;
2339
2340        // With context_lines=20, should clamp to file length
2341        let ctx_end = (end + 1 + 20).min(lines.len());
2342        let context_after: Vec<&str> = if end + 1 < lines.len() {
2343            lines[(end + 1)..ctx_end].to_vec()
2344        } else {
2345            vec![]
2346        };
2347        // Should not panic regardless of context_lines size
2348        assert!(context_after.len() <= 20);
2349    }
2350
2351    // --- Body extraction test ---
2352
2353    #[test]
2354    fn body_extraction_matches_source() {
2355        let ctx = make_ctx();
2356        let path = fixture_path("calls.ts");
2357        let symbols = ctx.provider().list_symbols(&path).unwrap();
2358        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2359
2360        let source = std::fs::read_to_string(&path).unwrap();
2361        let lines: Vec<&str> = source.lines().collect();
2362        let start = compute.range.start_line as usize;
2363        let end = compute.range.end_line as usize;
2364        let body = lines[start..=end].join("\n");
2365
2366        assert!(
2367            body.contains("function compute"),
2368            "body should contain function declaration"
2369        );
2370        assert!(
2371            body.contains("helper(a)"),
2372            "body should contain call to helper"
2373        );
2374        assert!(
2375            body.contains("doubled + b"),
2376            "body should contain return expression"
2377        );
2378    }
2379
2380    // --- Full zoom response tests ---
2381
2382    #[test]
2383    fn body_range_expands_signature_range_to_include_body_calls() {
2384        let source = r#"function compute(
2385  value: number,
2386): number {
2387  return helper(value);
2388}
2389
2390function helper(value: number): number {
2391  return value * 2;
2392}
2393"#;
2394        let grammar = crate::parser::grammar_for(LangId::TypeScript);
2395        let mut parser = tree_sitter::Parser::new();
2396        parser.set_language(&grammar).unwrap();
2397        let tree = parser.parse(source, None).unwrap();
2398        let signature_end = source.find('{').expect("function has body");
2399
2400        let (body_start, body_end) =
2401            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
2402        let calls = extract_calls_in_range(
2403            source,
2404            tree.root_node(),
2405            body_start,
2406            body_end,
2407            LangId::TypeScript,
2408        );
2409        let names = calls
2410            .iter()
2411            .map(|(name, _)| name.as_str())
2412            .collect::<Vec<_>>();
2413
2414        assert!(
2415            names.contains(&"helper"),
2416            "call inside the function body should be included: {names:?}"
2417        );
2418    }
2419
2420    #[test]
2421    fn zoom_leaf_returns_full_body_without_budget_marker() {
2422        let ctx = make_ctx();
2423        let path = fixture_path("calls.ts");
2424        let req = make_zoom_request(
2425            "z-leaf-full",
2426            path.to_str().unwrap(),
2427            "repeatedOutgoing",
2428            None,
2429        );
2430        let resp = handle_zoom(&req, &ctx);
2431        let json = serde_json::to_value(&resp).unwrap();
2432        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2433
2434        let symbols = ctx.provider().list_symbols(&path).unwrap();
2435        let target = symbols
2436            .iter()
2437            .find(|symbol| symbol.name == "repeatedOutgoing")
2438            .unwrap();
2439        let source = std::fs::read_to_string(&path).unwrap();
2440        let lines = source.lines().collect::<Vec<_>>();
2441        let expected =
2442            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
2443
2444        assert_eq!(json["content"].as_str().unwrap(), expected);
2445        assert!(
2446            !json["content"]
2447                .as_str()
2448                .unwrap()
2449                .contains("more lines — zoom"),
2450            "explicit zoom must not budget-cap leaf bodies"
2451        );
2452    }
2453
2454    #[test]
2455    fn zoom_response_has_calls_out_and_called_by() {
2456        let ctx = make_ctx();
2457        let path = fixture_path("calls.ts");
2458
2459        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
2460        let resp = handle_zoom(&req, &ctx);
2461
2462        let json = serde_json::to_value(&resp).unwrap();
2463        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2464
2465        let calls_out = json["annotations"]["calls_out"]
2466            .as_array()
2467            .expect("calls_out array");
2468        let out_names: Vec<&str> = calls_out
2469            .iter()
2470            .map(|c| c["name"].as_str().unwrap())
2471            .collect();
2472        assert!(
2473            out_names.contains(&"helper"),
2474            "compute calls helper: {:?}",
2475            out_names
2476        );
2477
2478        let called_by = json["annotations"]["called_by"]
2479            .as_array()
2480            .expect("called_by array");
2481        let by_names: Vec<&str> = called_by
2482            .iter()
2483            .map(|c| c["name"].as_str().unwrap())
2484            .collect();
2485        assert!(
2486            by_names.contains(&"orchestrate"),
2487            "orchestrate calls compute: {:?}",
2488            by_names
2489        );
2490    }
2491
2492    #[test]
2493    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
2494        let ctx = make_ctx();
2495        let path = fixture_path("calls.ts");
2496
2497        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
2498        let resp = handle_zoom(&req, &ctx);
2499        let json = serde_json::to_value(&resp).unwrap();
2500        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2501
2502        let calls_out = json["annotations"]["calls_out"]
2503            .as_array()
2504            .expect("calls_out array");
2505        let helper_refs = calls_out
2506            .iter()
2507            .filter(|call| call["name"] == "helper")
2508            .collect::<Vec<_>>();
2509        assert_eq!(
2510            helper_refs.len(),
2511            1,
2512            "helper should be folded once: {calls_out:?}"
2513        );
2514        assert_eq!(helper_refs[0]["extra_count"], 1);
2515        assert!(
2516            calls_out.iter().any(|call| call["name"] == "format"),
2517            "distinct callee must not be folded into helper: {calls_out:?}"
2518        );
2519
2520        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
2521        let resp = handle_zoom(&req, &ctx);
2522        let json = serde_json::to_value(&resp).unwrap();
2523        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2524
2525        let called_by = json["annotations"]["called_by"]
2526            .as_array()
2527            .expect("called_by array");
2528        let repeat_refs = called_by
2529            .iter()
2530            .filter(|call| call["name"] == "repeatCompute")
2531            .collect::<Vec<_>>();
2532        assert_eq!(
2533            repeat_refs.len(),
2534            1,
2535            "repeatCompute should be folded once: {called_by:?}"
2536        );
2537        assert_eq!(repeat_refs[0]["extra_count"], 1);
2538        assert!(
2539            called_by.iter().any(|call| call["name"] == "orchestrate"),
2540            "distinct caller must not be folded into repeatCompute: {called_by:?}"
2541        );
2542    }
2543
2544    #[test]
2545    fn zoom_response_empty_annotations_for_unused() {
2546        let ctx = make_ctx();
2547        let path = fixture_path("calls.ts");
2548
2549        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
2550        let resp = handle_zoom(&req, &ctx);
2551
2552        let json = serde_json::to_value(&resp).unwrap();
2553        assert_eq!(json["success"], true);
2554
2555        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
2556        let called_by = json["annotations"]["called_by"].as_array().unwrap();
2557
2558        // calls_out exists (may contain console.log but no known symbols)
2559        // called_by should be empty — nobody calls unused
2560        assert!(
2561            called_by.is_empty(),
2562            "unused should not be called by anyone: {:?}",
2563            called_by
2564        );
2565    }
2566
2567    #[test]
2568    fn zoom_default_omits_callgraph_annotations() {
2569        let ctx = make_ctx();
2570        let path = fixture_path("calls.ts");
2571
2572        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
2573        let resp = handle_zoom(&req, &ctx);
2574
2575        let json = serde_json::to_value(&resp).unwrap();
2576        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2577
2578        let calls_out = json["annotations"]["calls_out"]
2579            .as_array()
2580            .expect("calls_out array");
2581        let called_by = json["annotations"]["called_by"]
2582            .as_array()
2583            .expect("called_by array");
2584        assert!(
2585            calls_out.is_empty(),
2586            "default zoom should omit calls_out: {:?}",
2587            calls_out
2588        );
2589        assert!(
2590            called_by.is_empty(),
2591            "default zoom should omit called_by: {:?}",
2592            called_by
2593        );
2594    }
2595
2596    #[test]
2597    fn zoom_symbol_not_found() {
2598        let ctx = make_ctx();
2599        let path = fixture_path("calls.ts");
2600
2601        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
2602        let resp = handle_zoom(&req, &ctx);
2603
2604        let json = serde_json::to_value(&resp).unwrap();
2605        assert_eq!(json["success"], false);
2606        assert_eq!(json["code"], "symbol_not_found");
2607    }
2608
2609    #[test]
2610    fn zoom_custom_context_lines() {
2611        let ctx = make_ctx();
2612        let path = fixture_path("calls.ts");
2613
2614        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
2615        let resp = handle_zoom(&req, &ctx);
2616
2617        let json = serde_json::to_value(&resp).unwrap();
2618        assert_eq!(json["success"], true);
2619
2620        let ctx_before = json["context_before"].as_array().unwrap();
2621        let ctx_after = json["context_after"].as_array().unwrap();
2622        // With context_lines=1, we get at most 1 line before and after
2623        assert!(
2624            ctx_before.len() <= 1,
2625            "context_before should be ≤1: {:?}",
2626            ctx_before
2627        );
2628        assert!(
2629            ctx_after.len() <= 1,
2630            "context_after should be ≤1: {:?}",
2631            ctx_after
2632        );
2633    }
2634
2635    #[test]
2636    fn zoom_missing_file_param() {
2637        let ctx = make_ctx();
2638        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
2639        let resp = handle_zoom(&req, &ctx);
2640
2641        let json = serde_json::to_value(&resp).unwrap();
2642        assert_eq!(json["success"], false);
2643        assert_eq!(json["code"], "invalid_request");
2644    }
2645
2646    #[test]
2647    fn zoom_missing_symbol_param() {
2648        let ctx = make_ctx();
2649        let path = fixture_path("calls.ts");
2650        // Build the JSON via serde_json so Windows paths (with backslashes)
2651        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
2652        // backslashes as escape sequences and fail to parse.
2653        let req_value = serde_json::json!({
2654            "id": "z-6",
2655            "command": "zoom",
2656            "file": path.to_string_lossy(),
2657        });
2658        let req_str = req_value.to_string();
2659        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
2660        let resp = handle_zoom(&req, &ctx);
2661
2662        let json = serde_json::to_value(&resp).unwrap();
2663        assert_eq!(json["success"], false);
2664        assert_eq!(json["code"], "invalid_request");
2665    }
2666
2667    #[test]
2668    fn test_suggest_close_symbols_unit() {
2669        let available = vec![
2670            "handle_grep_search".to_string(),
2671            "handle_semantic_search".to_string(),
2672            "handle_semantic_or_hybrid_search".to_string(),
2673            "compute_total".to_string(),
2674            "search".to_string(),
2675            "handle_search".to_string(),
2676        ];
2677        let original_available = available.clone();
2678
2679        let suggestions = suggest_close_symbols("handle_search", &available, 5);
2680        assert_eq!(
2681            available, original_available,
2682            "nearest-name matching must not mutate the file outline candidates"
2683        );
2684        assert!(suggestions.contains(&"handle_grep_search".to_string()));
2685        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
2686        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
2687        assert!(suggestions.contains(&"search".to_string()));
2688        assert!(!suggestions.contains(&"compute_total".to_string()));
2689
2690        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
2691        assert_eq!(suggestions, suggestions_caps);
2692
2693        let available2 = vec![
2694            "total".to_string(),
2695            "compute_total".to_string(),
2696            "unrelated".to_string(),
2697        ];
2698        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
2699        assert_eq!(suggestions2, vec!["total".to_string()]);
2700    }
2701
2702    #[test]
2703    fn zoom_symbol_miss_steers_to_ranged_outline_names() {
2704        let ctx = make_ctx();
2705        let path = fixture_path("calls.ts");
2706        let req = make_zoom_request("steer-symbol", path.to_str().unwrap(), "comput", None);
2707
2708        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2709        let message = response["message"].as_str().unwrap();
2710        assert_eq!(response["code"], "symbol_not_found");
2711        assert!(message.contains(RETRY_UNCHANGED_ZOOM_MESSAGE));
2712        assert!(message.contains("Choose one of these names from the file outline"));
2713        assert!(message.contains("`compute` (lines"));
2714    }
2715
2716    #[test]
2717    fn zoom_symbol_miss_steers_when_the_file_is_likely_wrong() {
2718        let ctx = make_ctx();
2719        let path = fixture_path("calls.ts");
2720        let req = make_zoom_request(
2721            "steer-wrong-file",
2722            path.to_str().unwrap(),
2723            "entirely_unrelated_lookup",
2724            None,
2725        );
2726
2727        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2728        let message = response["message"].as_str().unwrap();
2729        assert_eq!(response["code"], "symbol_not_found");
2730        assert!(message.contains(RETRY_UNCHANGED_ZOOM_MESSAGE));
2731        assert!(message.contains("This file has"));
2732        assert!(message.contains("closest is `"));
2733        assert!(message.contains("may be in another file"));
2734    }
2735
2736    #[test]
2737    fn zoom_markdown_heading_miss_steers_to_ranged_headings() {
2738        assert_heading_miss_steering("zoom_steering.md");
2739    }
2740
2741    #[test]
2742    fn zoom_html_heading_miss_steers_to_ranged_headings() {
2743        assert_heading_miss_steering("zoom_steering.html");
2744    }
2745
2746    #[test]
2747    fn zoom_missing_file_steers_to_a_replacement_path() {
2748        let ctx = make_ctx();
2749        let path = fixture_path("does-not-exist.ts");
2750        let req = make_zoom_request(
2751            "steer-missing-file",
2752            path.to_str().unwrap(),
2753            "compute",
2754            None,
2755        );
2756
2757        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2758        let message = response["message"].as_str().unwrap();
2759        assert_eq!(response["code"], "file_not_found");
2760        assert!(message.contains(RETRY_UNCHANGED_ZOOM_MESSAGE));
2761        assert!(message.contains("Set `file` to an existing path"));
2762    }
2763
2764    #[test]
2765    fn zoom_ambiguous_menu_says_to_pick_a_listed_name() {
2766        let ctx = make_ctx();
2767        let path = fixture_path("zoom_steering.ts");
2768        let req = make_zoom_request("steer-ambiguous", path.to_str().unwrap(), "run", None);
2769
2770        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2771        let content = response["content"].as_str().unwrap();
2772        assert_eq!(response["kind"], "ambiguous_symbol");
2773        assert!(
2774            content.contains(RETRY_UNCHANGED_ZOOM_MESSAGE),
2775            "expected ambiguous-name menu, got: {content}"
2776        );
2777        assert!(content.contains("Pick one of these qualified names"));
2778    }
2779
2780    #[test]
2781    fn zoom_container_menu_says_to_pick_a_listed_member() {
2782        let temp_dir = tempfile::tempdir().unwrap();
2783        let path = temp_dir.path().join("large-container.ts");
2784        std::fs::write(
2785            &path,
2786            format!(
2787                "class LargeContainer {{\n  member(): void {{}}{}\n}}\n",
2788                "\n".repeat(151)
2789            ),
2790        )
2791        .unwrap();
2792        let ctx = make_ctx();
2793        let req = make_zoom_request(
2794            "steer-container",
2795            path.to_str().unwrap(),
2796            "LargeContainer",
2797            None,
2798        );
2799
2800        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2801        let content = response["content"].as_str().unwrap();
2802        assert!(response["success"].as_bool().unwrap());
2803        assert!(
2804            content.contains(RETRY_UNCHANGED_ZOOM_MESSAGE),
2805            "expected member menu, got: {content}"
2806        );
2807        assert!(content.contains("Pick one of the listed member names"));
2808    }
2809
2810    fn assert_heading_miss_steering(fixture: &str) {
2811        let ctx = make_ctx();
2812        let path = fixture_path(fixture);
2813        let req = make_zoom_request(
2814            "steer-heading",
2815            path.to_str().unwrap(),
2816            "Installation Gude",
2817            None,
2818        );
2819
2820        let response = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2821        let message = response["message"].as_str().unwrap();
2822        assert_eq!(response["code"], "symbol_not_found");
2823        assert!(message.contains(RETRY_UNCHANGED_ZOOM_MESSAGE));
2824        assert!(message.contains("Choose one of these names from the document outline"));
2825        assert!(message.contains("`Installation Guide` (lines"));
2826    }
2827
2828    // --- Helpers ---
2829
2830    fn make_zoom_request(
2831        id: &str,
2832        file: &str,
2833        symbol: &str,
2834        context_lines: Option<u64>,
2835    ) -> RawRequest {
2836        let mut json = serde_json::json!({
2837            "id": id,
2838            "command": "zoom",
2839            "file": file,
2840            "symbol": symbol,
2841        });
2842        if let Some(cl) = context_lines {
2843            json["context_lines"] = serde_json::json!(cl);
2844        }
2845        serde_json::from_value(json).unwrap()
2846    }
2847
2848    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2849        let mut req = make_zoom_request(id, file, symbol, None);
2850        req.params["callgraph"] = serde_json::json!(true);
2851        req
2852    }
2853
2854    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2855        serde_json::from_str(json_str).unwrap()
2856    }
2857
2858    // --- JSON path resolution tests ---
2859
2860    fn json_fixture_tree() -> (String, tree_sitter::Tree) {
2861        json_fixture_tree_named("nested.json")
2862    }
2863
2864    fn json_fixture_tree_named(name: &str) -> (String, tree_sitter::Tree) {
2865        let source = std::fs::read_to_string(fixture_path(name)).unwrap();
2866        let mut parser = FileParser::new();
2867        let path = fixture_path(name);
2868        let (tree, _) = parser.parse(&path).unwrap();
2869        (source, tree.clone())
2870    }
2871
2872    fn assert_json_zoom_resolves(fixture: &str, query: &str, expected_fragment: &str) {
2873        let ctx = make_ctx();
2874        let path = fixture_path(fixture);
2875        let req = make_zoom_request("json-regression", path.to_str().unwrap(), query, None);
2876        let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2877        assert_eq!(json["success"], true, "JSON zoom should succeed: {json}");
2878        assert_eq!(json["name"], query);
2879        assert!(
2880            json["content"]
2881                .as_str()
2882                .unwrap_or_default()
2883                .contains(expected_fragment),
2884            "JSON zoom content should contain {expected_fragment:?}: {json}"
2885        );
2886    }
2887
2888    #[test]
2889    fn json_path_resolves_nested_object() {
2890        let (source, tree) = json_fixture_tree();
2891        let resolved = json_path_resolve(
2892            &source,
2893            &tree.root_node(),
2894            "registration_profile_manifest.nested.deep",
2895        )
2896        .expect("path should resolve");
2897        assert_eq!(resolved.path, "registration_profile_manifest.nested.deep");
2898        assert_eq!(node_text(&source, &resolved.node).trim(), "\"value\"");
2899    }
2900
2901    #[test]
2902    fn json_zoom_resolves_leading_line_comments() {
2903        assert_json_zoom_resolves(
2904            "zoom_jsonc_leading_line_comments.jsonc",
2905            "chains",
2906            "\"executor\"",
2907        );
2908    }
2909
2910    #[test]
2911    fn json_zoom_resolves_leading_block_comment() {
2912        assert_json_zoom_resolves(
2913            "zoom_jsonc_leading_block_comment.jsonc",
2914            "chains",
2915            "\"executor\"",
2916        );
2917    }
2918
2919    #[test]
2920    fn json_zoom_resolves_blank_lines_before_document() {
2921        assert_json_zoom_resolves("zoom_json_blank_lines.json", "chains", "\"executor\"");
2922    }
2923
2924    #[test]
2925    fn json_zoom_resolves_comments_inside_object() {
2926        assert_json_zoom_resolves("zoom_json_comments_inside.jsonc", "chains", "\"executor\"");
2927    }
2928
2929    #[test]
2930    fn json_zoom_leading_multibyte_comment_keeps_path_and_value_offsets() {
2931        assert_json_zoom_resolves(
2932            "zoom_jsonc_leading_line_comments.jsonc",
2933            "chains.executor.entries[1].model",
2934            "\"large\"",
2935        );
2936    }
2937
2938    #[test]
2939    fn json_zoom_miss_reports_actual_deepest_prefix_and_segment() {
2940        let ctx = make_ctx();
2941        let path = fixture_path("zoom_json_miss_locus.json");
2942        let query = "agent.general.model";
2943        let req = make_zoom_request("json-miss", path.to_str().unwrap(), query, None);
2944        let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2945
2946        assert_eq!(json["success"], false);
2947        let message = json["message"].as_str().unwrap();
2948        assert!(message.starts_with(
2949            "symbol 'agent.general.model' not found: resolved `agent`, no key `general` — nearest: [general_settings]"
2950        ));
2951        assert!(message.contains(RETRY_UNCHANGED_ZOOM_MESSAGE));
2952    }
2953
2954    #[test]
2955    fn json_path_resolves_array_index() {
2956        let (source, tree) = json_fixture_tree();
2957        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[0]")
2958            .expect("path should resolve");
2959        assert_eq!(resolved.path, "servers[0]");
2960        assert!(node_text(&source, &resolved.node).contains("primary"));
2961    }
2962
2963    #[test]
2964    fn json_path_resolves_chained_array_index() {
2965        let (source, tree) = json_fixture_tree();
2966        let resolved =
2967            json_path_resolve(&source, &tree.root_node(), "a.b[1].c").expect("path should resolve");
2968        assert_eq!(resolved.path, "a.b[1].c");
2969        assert_eq!(node_text(&source, &resolved.node).trim(), "\"second\"");
2970    }
2971
2972    #[test]
2973    fn json_path_resolves_bare_array_index() {
2974        let (source, tree) = json_fixture_tree();
2975        let resolved = json_path_resolve(&source, &tree.root_node(), "servers[1].name")
2976            .expect("path should resolve");
2977        assert_eq!(resolved.path, "servers[1].name");
2978        assert_eq!(node_text(&source, &resolved.node).trim(), "\"backup\"");
2979    }
2980
2981    #[test]
2982    fn json_path_miss_returns_none() {
2983        let (source, tree) = json_fixture_tree();
2984        assert!(json_path_resolve(
2985            &source,
2986            &tree.root_node(),
2987            "registration_profile_manifest.host_only_allowlis"
2988        )
2989        .is_none());
2990        assert!(json_path_resolve(&source, &tree.root_node(), "servers[9]").is_none());
2991        assert!(json_path_resolve(&source, &tree.root_node(), "missing").is_none());
2992    }
2993
2994    #[test]
2995    fn json_path_resolves_dotted_query_as_path() {
2996        // The fixture has BOTH a literal key "literal.dotted.key" and a nested
2997        // path literal.dotted.key. This unit test exercises the path-walk
2998        // function directly, which resolves to the nested path-value node.
2999        let (source, tree) = json_fixture_tree();
3000        let resolved = json_path_resolve(&source, &tree.root_node(), "literal.dotted.key")
3001            .expect("path should resolve");
3002        assert_eq!(node_text(&source, &resolved.node).trim(), "\"path-value\"");
3003    }
3004
3005    #[test]
3006    fn json_miss_details_reports_deepest_prefix() {
3007        let (source, tree) = json_fixture_tree();
3008        let (prefix, failing) = json_miss_details(
3009            &source,
3010            &tree.root_node(),
3011            "registration_profile_manifest.host_only_allowlis",
3012        );
3013        assert_eq!(prefix, "registration_profile_manifest");
3014        assert_eq!(failing, "host_only_allowlis");
3015    }
3016
3017    #[test]
3018    fn json_miss_details_single_segment() {
3019        let (source, tree) = json_fixture_tree();
3020        let (prefix, failing) = json_miss_details(&source, &tree.root_node(), "missing");
3021        assert_eq!(prefix, "");
3022        assert_eq!(failing, "missing");
3023    }
3024
3025    #[test]
3026    fn split_json_path_keeps_bracket_groups() {
3027        assert_eq!(split_json_path("a.b[0].c"), vec!["a", "b[0]", "c"]);
3028        assert_eq!(split_json_path("servers[0]"), vec!["servers[0]"]);
3029        assert_eq!(split_json_path("a.b.c"), vec!["a", "b", "c"]);
3030    }
3031
3032    #[test]
3033    fn parse_json_segment_handles_key_and_index() {
3034        assert_eq!(parse_json_segment("servers[0]"), (Some("servers"), Some(0)));
3035        assert_eq!(parse_json_segment("[0]"), (None, Some(0)));
3036        assert_eq!(parse_json_segment("host"), (Some("host"), None));
3037    }
3038}