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