Skip to main content

aft/commands/
zoom.rs

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