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::lsp_hints;
15use crate::parser::{detect_language, FileParser, LangId};
16use crate::protocol::{RawRequest, Response};
17use crate::symbols::Range;
18use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
19
20/// A reference to a called/calling function.
21#[derive(Debug, Clone, Serialize)]
22pub struct CallRef {
23    pub name: String,
24    /// 1-based line number of the call reference.
25    pub line: u32,
26    /// Number of later call sites with the same callee or caller name merged into this entry.
27    #[serde(skip_serializing_if = "is_zero")]
28    pub extra_count: u32,
29}
30
31fn is_zero(value: &u32) -> bool {
32    *value == 0
33}
34
35fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
36    let mut index_by_name: HashMap<String, usize> = HashMap::new();
37    let mut deduped: Vec<CallRef> = Vec::new();
38
39    for call in calls {
40        if let Some(index) = index_by_name.get(&call.name).copied() {
41            deduped[index].extra_count = deduped[index]
42                .extra_count
43                .saturating_add(call.extra_count.saturating_add(1));
44        } else {
45            index_by_name.insert(call.name.clone(), deduped.len());
46            deduped.push(call);
47        }
48    }
49
50    deduped
51}
52
53/// Annotations describing file-scoped call relationships.
54#[derive(Debug, Clone, Serialize)]
55pub struct Annotations {
56    pub calls_out: Vec<CallRef>,
57    pub called_by: Vec<CallRef>,
58}
59
60/// Response payload for the zoom command.
61#[derive(Debug, Clone, Serialize)]
62pub struct ZoomResponse {
63    pub name: String,
64    pub kind: String,
65    pub range: Range,
66    pub content: String,
67    pub context_before: Vec<String>,
68    pub context_after: Vec<String>,
69    pub annotations: Annotations,
70}
71
72struct RawCall {
73    name: String,
74    line: u32,
75    start_byte: usize,
76    end_byte: usize,
77}
78
79fn resolve_file_or_url(
80    req: &RawRequest,
81    ctx: &AppContext,
82    file: &str,
83) -> Result<PathBuf, Response> {
84    if is_http_url(file) {
85        let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
86        let allow_private = ctx.config().url_fetch_allow_private
87            || req
88                .params
89                .get("allow_private")
90                .and_then(|value| value.as_bool())
91                .unwrap_or(false);
92        return fetch_url_to_cache(
93            file,
94            &storage_dir,
95            UrlFetchOptions {
96                allow_private,
97                ..UrlFetchOptions::default()
98            },
99        )
100        .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
101    }
102
103    ctx.validate_path(&req.id, Path::new(file))
104}
105
106fn zoom_one_target_response(
107    req: &RawRequest,
108    ctx: &AppContext,
109    file: &str,
110    symbol: &str,
111    context_lines: usize,
112    include_callgraph: bool,
113) -> Response {
114    let path = match resolve_file_or_url(req, ctx, file) {
115        Ok(path) => path,
116        Err(resp) => return resp,
117    };
118    if !path.exists() {
119        return Response::error(
120            &req.id,
121            "file_not_found",
122            format!("file not found: {}", file),
123        );
124    }
125
126    let source = match std::fs::read_to_string(&path) {
127        Ok(source) => source,
128        Err(error) => {
129            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, error));
130        }
131    };
132    let lines: Vec<String> = source.lines().map(|line| line.to_string()).collect();
133
134    zoom_one_symbol(
135        req,
136        ctx,
137        &path,
138        file,
139        &source,
140        &lines,
141        symbol,
142        context_lines,
143        include_callgraph,
144    )
145}
146
147fn serialize_zoom_target_response(req: &RawRequest, response: Response) -> serde_json::Value {
148    serde_json::to_value(&response).unwrap_or_else(|error| {
149        serde_json::to_value(Response::error(
150            &req.id,
151            "internal_error",
152            format!("zoom: failed to serialize target response: {error}"),
153        ))
154        .expect("serializing Response::error should not fail")
155    })
156}
157
158fn handle_zoom_targets(
159    req: &RawRequest,
160    ctx: &AppContext,
161    targets: &[serde_json::Value],
162    context_lines: usize,
163    include_callgraph: bool,
164) -> Response {
165    if targets.is_empty() {
166        return Response::error(
167            &req.id,
168            "invalid_request",
169            "zoom: 'targets' must be a non-empty array",
170        );
171    }
172
173    let mut entries = Vec::with_capacity(targets.len());
174    for (index, target) in targets.iter().enumerate() {
175        let obj = target.as_object();
176        let Some(file) = obj
177            .and_then(|obj| obj.get("file"))
178            .and_then(|value| value.as_str())
179            .filter(|file| !file.is_empty())
180        else {
181            return Response::error(
182                &req.id,
183                "invalid_request",
184                format!("zoom: targets[{index}].file must be a non-empty string"),
185            );
186        };
187        let Some(symbol) = obj
188            .and_then(|obj| obj.get("symbol"))
189            .and_then(|value| value.as_str())
190            .filter(|symbol| !symbol.is_empty())
191        else {
192            return Response::error(
193                &req.id,
194                "invalid_request",
195                format!("zoom: targets[{index}].symbol must be a non-empty string"),
196            );
197        };
198        let target_label = obj
199            .and_then(|obj| obj.get("target_label").or_else(|| obj.get("targetLabel")))
200            .and_then(|value| value.as_str())
201            .filter(|label| !label.is_empty())
202            .unwrap_or(file);
203
204        let response =
205            zoom_one_target_response(req, ctx, file, symbol, context_lines, include_callgraph);
206        entries.push(serde_json::json!({
207            "targetLabel": target_label,
208            "name": symbol,
209            "response": serialize_zoom_target_response(req, response),
210        }));
211    }
212
213    Response::success(
214        &req.id,
215        serde_json::json!({
216            "targets": entries,
217        }),
218    )
219}
220
221/// Handle a `zoom` request.
222///
223/// Expects either `file` plus `symbol`/`symbols`, or a cross-file `targets` array,
224/// with optional `context_lines` (default 3). Resolves the symbol, extracts body +
225/// context, and walks ASTs for call annotations. For code files, a whitespace-separated
226/// top-level `symbol`/`symbols` string is split into multiple same-file lookups.
227pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
228    let context_lines = req
229        .params
230        .get("context_lines")
231        .and_then(|v| v.as_u64())
232        .unwrap_or(3) as usize;
233    let include_callgraph = req
234        .params
235        .get("callgraph")
236        .and_then(|v| v.as_bool())
237        .unwrap_or(false);
238
239    if let Some(targets_value) = req.params.get("targets") {
240        let Some(targets) = targets_value.as_array() else {
241            return Response::error(
242                &req.id,
243                "invalid_request",
244                "zoom: 'targets' must be a non-empty array",
245            );
246        };
247        return handle_zoom_targets(req, ctx, targets, context_lines, include_callgraph);
248    }
249
250    let file = match req
251        .params
252        .get("file")
253        .or_else(|| req.params.get("url"))
254        .and_then(|v| v.as_str())
255    {
256        Some(f) => f,
257        None => {
258            return Response::error(
259                &req.id,
260                "invalid_request",
261                "zoom: missing required param 'file'",
262            );
263        }
264    };
265
266    let start_line = req
267        .params
268        .get("start_line")
269        .and_then(|v| v.as_u64())
270        .map(|v| v as usize);
271    let end_line = req
272        .params
273        .get("end_line")
274        .and_then(|v| v.as_u64())
275        .map(|v| v as usize);
276
277    let path = match resolve_file_or_url(req, ctx, file) {
278        Ok(path) => path,
279        Err(resp) => return resp,
280    };
281    if !path.exists() {
282        return Response::error(
283            &req.id,
284            "file_not_found",
285            format!("file not found: {}", file),
286        );
287    }
288
289    // Read source file early because both symbol mode and line-range mode need it.
290    let source = match std::fs::read_to_string(&path) {
291        Ok(s) => s,
292        Err(e) => {
293            return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
294        }
295    };
296
297    let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
298
299    // Line-range mode: read arbitrary lines without requiring a symbol.
300    match (start_line, end_line) {
301        (Some(start), Some(end)) => {
302            if zoom_symbol_param(&req.params).is_some() {
303                return Response::error(
304                    &req.id,
305                    "invalid_request",
306                    "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
307                );
308            }
309            if start == 0 || end == 0 {
310                return Response::error(
311                    &req.id,
312                    "invalid_request",
313                    "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
314                );
315            }
316            if end < start {
317                return Response::error(
318                    &req.id,
319                    "invalid_request",
320                    format!("zoom: end_line {} must be >= start_line {}", end, start),
321                );
322            }
323            if lines.is_empty() {
324                return Response::error(
325                    &req.id,
326                    "invalid_request",
327                    format!("zoom: {} is empty", file),
328                );
329            }
330
331            let start_idx = start - 1;
332            // Clamp end_line to file length (same as batch edits)
333            let clamped_end = end.min(lines.len());
334            let end_idx = clamped_end - 1;
335            if start_idx >= lines.len() {
336                return Response::error(
337                    &req.id,
338                    "invalid_request",
339                    format!(
340                        "zoom: start_line {} is past end of {} ({} lines)",
341                        start,
342                        file,
343                        lines.len()
344                    ),
345                );
346            }
347
348            let content = lines[start_idx..=end_idx].join("\n");
349            let ctx_start = start_idx.saturating_sub(context_lines);
350            let context_before: Vec<String> = if ctx_start < start_idx {
351                lines[ctx_start..start_idx]
352                    .iter()
353                    .map(|l| l.to_string())
354                    .collect()
355            } else {
356                vec![]
357            };
358            let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
359            let context_after: Vec<String> = if end_idx + 1 < lines.len() {
360                lines[(end_idx + 1)..ctx_end]
361                    .iter()
362                    .map(|l| l.to_string())
363                    .collect()
364            } else {
365                vec![]
366            };
367            let end_col = lines[end_idx].chars().count() as u32;
368
369            return Response::success(
370                &req.id,
371                serde_json::json!({
372                    "name": format!("lines {}-{}", start, clamped_end),
373                    "kind": "lines",
374                    "range": {
375                        "start_line": start,  // already 1-based from user input
376                        "start_col": 1,
377                        "end_line": clamped_end,
378                        "end_col": end_col + 1,
379                    },
380                    "content": content,
381                    "context_before": context_before,
382                    "context_after": context_after,
383                    "annotations": {
384                        "calls_out": [],
385                        "called_by": [],
386                    },
387                }),
388            );
389        }
390        (Some(_), None) | (None, Some(_)) => {
391            return Response::error(
392                &req.id,
393                "invalid_request",
394                "zoom: provide both 'start_line' and 'end_line' for line-range mode",
395            );
396        }
397        (None, None) => {}
398    }
399
400    let lang = detect_language(&path);
401    let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
402        Ok(names) => names,
403        Err(resp) => return resp,
404    };
405
406    if symbol_names.is_empty() {
407        return Response::error(
408            &req.id,
409            "invalid_request",
410            "zoom: missing required param 'symbol'",
411        );
412    }
413
414    if symbol_names.len() == 1 {
415        return zoom_one_symbol(
416            req,
417            ctx,
418            &path,
419            file,
420            &source,
421            &lines,
422            &symbol_names[0],
423            context_lines,
424            include_callgraph,
425        );
426    }
427
428    zoom_batch_symbols(
429        req,
430        ctx,
431        &path,
432        file,
433        &source,
434        &lines,
435        &symbol_names,
436        context_lines,
437        include_callgraph,
438    )
439}
440
441/// Raw `symbol` or `symbols` param before language-aware splitting.
442fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
443    params
444        .get("symbol")
445        .or_else(|| params.get("symbols"))
446        .and_then(|v| v.as_str())
447}
448
449fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
450    matches!(lang, Some(LangId::Markdown | LangId::Html))
451}
452
453/// Normalize `symbol` / `symbols` into one or more lookup names.
454///
455/// For code files, a single string containing internal whitespace is split on `\s+`.
456/// Markdown/HTML headings keep the full string (headings may contain spaces).
457fn parse_zoom_symbol_names(
458    params: &serde_json::Value,
459    lang: Option<LangId>,
460) -> Result<Vec<String>, Response> {
461    if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
462        let names: Vec<String> = arr
463            .iter()
464            .filter_map(|v| v.as_str().map(str::trim))
465            .filter(|s| !s.is_empty())
466            .map(str::to_string)
467            .collect();
468        return Ok(names);
469    }
470
471    let Some(raw) = zoom_symbol_param(params) else {
472        return Ok(Vec::new());
473    };
474
475    if is_heading_zoom_language(lang) {
476        let trimmed = raw.trim();
477        if trimmed.is_empty() {
478            return Ok(Vec::new());
479        }
480        return Ok(vec![trimmed.to_string()]);
481    }
482
483    if raw.split_whitespace().count() <= 1 {
484        let trimmed = raw.trim();
485        if trimmed.is_empty() {
486            return Ok(Vec::new());
487        }
488        return Ok(vec![trimmed.to_string()]);
489    }
490
491    Ok(raw.split_whitespace().map(str::to_string).collect())
492}
493
494fn zoom_batch_symbols(
495    req: &RawRequest,
496    ctx: &AppContext,
497    path: &Path,
498    file: &str,
499    source: &str,
500    lines: &[String],
501    symbol_names: &[String],
502    context_lines: usize,
503    include_callgraph: bool,
504) -> Response {
505    let mut entries = Vec::with_capacity(symbol_names.len());
506    let mut all_ok = true;
507
508    for name in symbol_names {
509        let resp = zoom_one_symbol(
510            req,
511            ctx,
512            path,
513            file,
514            source,
515            lines,
516            name,
517            context_lines,
518            include_callgraph,
519        );
520        let json = match serde_json::to_value(&resp) {
521            Ok(v) => v,
522            Err(err) => {
523                return Response::error(
524                    &req.id,
525                    "internal_error",
526                    format!("zoom: failed to serialize batch entry: {err}"),
527                );
528            }
529        };
530        if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
531            all_ok = false;
532        }
533        entries.push(serde_json::json!({
534            "name": name,
535            "response": json,
536        }));
537    }
538
539    Response::success(
540        &req.id,
541        serde_json::json!({
542            "complete": all_ok,
543            "symbols": entries,
544        }),
545    )
546}
547
548fn zoom_one_symbol(
549    req: &RawRequest,
550    ctx: &AppContext,
551    path: &Path,
552    _file: &str,
553    source: &str,
554    lines: &[String],
555    symbol_name: &str,
556    context_lines: usize,
557    include_callgraph: bool,
558) -> Response {
559    // Resolve the target symbol. Markdown/HTML headings are often copied from outline output
560    // with a visible level prefix (e.g. "## Basic usage" or "<h2>Features"); normalize only
561    // that heading lookup path so code-symbol resolution keeps exact matching semantics.
562    let lookup_name = match detect_language(path) {
563        Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
564        _ => symbol_name,
565    };
566    let matches = match ctx.provider().resolve_symbol(path, lookup_name) {
567        Ok(m) => m,
568        Err(crate::error::AftError::SymbolNotFound { name, .. }) => {
569            let mut msg = format!("symbol '{}' not found", name);
570            if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
571                let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
572                let suggestions = suggest_close_symbols(&name, &available, 5);
573                if !suggestions.is_empty() {
574                    msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
575                }
576            }
577            return Response::error(&req.id, "symbol_not_found", msg);
578        }
579        Err(e) => {
580            return Response::error(&req.id, e.code(), e.to_string());
581        }
582    };
583
584    // LSP-enhanced disambiguation (S03)
585    let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
586        lsp_hints::apply_lsp_disambiguation(matches, &hints)
587    } else {
588        matches
589    };
590
591    if matches.len() > 1 {
592        let content = render_ambiguous_symbol_menu(symbol_name, &matches);
593        let candidates = matches
594            .iter()
595            .map(|candidate| {
596                let sym = &candidate.symbol;
597                serde_json::json!({
598                    "name": sym.name.clone(),
599                    "qualified_name": qualified_symbol_name(sym),
600                    "kind": symbol_kind_string(&sym.kind),
601                    "range": sym.range.clone(),
602                    "signature": sym.signature.clone(),
603                })
604            })
605            .collect::<Vec<_>>();
606
607        return Response::success(
608            &req.id,
609            serde_json::json!({
610                "name": symbol_name,
611                "kind": "ambiguous_symbol",
612                "content": content,
613                "context_before": [],
614                "context_after": [],
615                "annotations": empty_annotations(),
616                "candidates": candidates,
617            }),
618        );
619    }
620
621    if matches.is_empty() {
622        let mut msg = format!("symbol '{}' not found", symbol_name);
623        if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
624            let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
625            let suggestions = suggest_close_symbols(symbol_name, &available, 5);
626            if !suggestions.is_empty() {
627                msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
628            }
629        }
630        return Response::error(&req.id, "symbol_not_found", msg);
631    }
632
633    let target = &matches[0].symbol;
634    let start = target.range.start_line as usize;
635    let end = target.range.end_line as usize;
636
637    // When re-export following resolved to a different file, re-read that file's lines
638    let resolved_file_path = std::path::Path::new(&matches[0].file);
639    let resolved_lines: Vec<String>;
640    let effective_lines: &[String] = if resolved_file_path != path {
641        resolved_lines = match std::fs::read_to_string(resolved_file_path) {
642            Ok(src) => src.lines().map(|l| l.to_string()).collect(),
643            Err(_) => lines.to_vec(),
644        };
645        &resolved_lines
646    } else {
647        lines
648    };
649
650    // Extract symbol body (0-based line indices)
651    let content = if end < effective_lines.len() {
652        effective_lines[start..=end].join("\n")
653    } else {
654        effective_lines[start..].join("\n")
655    };
656
657    let resolved_lang = detect_language(resolved_file_path);
658    let container_outline = if might_have_container_members(target) {
659        match build_container_outline(ctx, resolved_file_path, target) {
660            Ok(outline) => Some(outline),
661            Err(e) => {
662                return Response::error(&req.id, e.code(), e.to_string());
663            }
664        }
665    } else {
666        None
667    };
668
669    if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
670        let kind_str = symbol_kind_string(&target.kind);
671        let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
672        let resp = ZoomResponse {
673            name: target.name.clone(),
674            kind: kind_str,
675            range: target.range.clone(),
676            content: menu,
677            context_before: Vec::new(),
678            context_after: Vec::new(),
679            annotations: Annotations {
680                calls_out: Vec::new(),
681                called_by: Vec::new(),
682            },
683        };
684        return match serde_json::to_value(&resp) {
685            Ok(resp_json) => Response::success(&req.id, resp_json),
686            Err(err) => Response::error(
687                &req.id,
688                "internal_error",
689                format!("zoom: failed to serialize response: {err}"),
690            ),
691        };
692    }
693
694    // Context before
695    let ctx_start = start.saturating_sub(context_lines);
696    let context_before: Vec<String> = if ctx_start < start {
697        effective_lines[ctx_start..start]
698            .iter()
699            .map(|l| l.to_string())
700            .collect()
701    } else {
702        vec![]
703    };
704
705    // Context after
706    let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
707    let context_after: Vec<String> = if end + 1 < effective_lines.len() {
708        effective_lines[(end + 1)..ctx_end]
709            .iter()
710            .map(|l| l.to_string())
711            .collect()
712    } else {
713        vec![]
714    };
715
716    let (calls_out, called_by) = if include_callgraph {
717        // Get all symbols in the resolved file for call matching
718        let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
719            Ok(s) => s,
720            Err(e) => {
721                return Response::error(&req.id, e.code(), e.to_string());
722            }
723        };
724
725        let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
726
727        // Parse AST for call extraction (use resolved file for cross-file re-exports)
728        let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
729        let (tree, lang) = match parser.parse(resolved_file_path) {
730            Ok(r) => r,
731            Err(e) => {
732                return Response::error(&req.id, e.code(), e.to_string());
733            }
734        };
735
736        // calls_out: calls within the target symbol's byte range
737        let resolved_source = if resolved_file_path != path {
738            std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
739        } else {
740            source.to_string()
741        };
742        let signature_byte_start = line_col_to_byte(
743            &resolved_source,
744            target.range.start_line,
745            target.range.start_col,
746        );
747        let signature_byte_end = line_col_to_byte(
748            &resolved_source,
749            target.range.end_line,
750            target.range.end_col,
751        );
752        let (target_byte_start, target_byte_end) =
753            symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
754                .unwrap_or((signature_byte_start, signature_byte_end));
755
756        let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
757
758        let raw_calls = all_file_calls.iter().filter(|call| {
759            call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
760        });
761        let calls_out = dedupe_call_refs_by_name(
762            raw_calls
763                .filter(|call| {
764                    known_names.contains(&call.name.as_str()) && call.name != target.name
765                })
766                .map(|call| CallRef {
767                    name: call.name.clone(),
768                    line: call.line,
769                    extra_count: 0,
770                })
771                .collect(),
772        );
773
774        // called_by: bucket the single file-wide call extraction by enclosing symbol range
775        let mut called_by: Vec<CallRef> = Vec::new();
776        for sym in &all_symbols {
777            if sym.name == target.name && sym.range.start_line == target.range.start_line {
778                continue; // skip self
779            }
780            let sym_byte_start =
781                line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
782            let sym_byte_end =
783                line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
784            for call in &all_file_calls {
785                if call.name == target.name
786                    && call.start_byte >= sym_byte_start
787                    && call.end_byte <= sym_byte_end
788                {
789                    called_by.push(CallRef {
790                        name: sym.name.clone(),
791                        line: call.line,
792                        extra_count: 0,
793                    });
794                }
795            }
796        }
797
798        let called_by = dedupe_call_refs_by_name(called_by);
799
800        (calls_out, called_by)
801    } else {
802        (Vec::new(), Vec::new())
803    };
804
805    let kind_str = symbol_kind_string(&target.kind);
806
807    let resp = ZoomResponse {
808        name: target.name.clone(),
809        kind: kind_str,
810        range: target.range.clone(),
811        content,
812        context_before,
813        context_after,
814        annotations: Annotations {
815            calls_out,
816            called_by,
817        },
818    };
819
820    match serde_json::to_value(&resp) {
821        Ok(resp_json) => Response::success(&req.id, resp_json),
822        Err(err) => Response::error(
823            &req.id,
824            "internal_error",
825            format!("zoom: failed to serialize response: {err}"),
826        ),
827    }
828}
829
830fn empty_annotations() -> serde_json::Value {
831    serde_json::json!({
832        "calls_out": [],
833        "called_by": [],
834    })
835}
836
837fn render_ambiguous_symbol_menu(
838    symbol_name: &str,
839    matches: &[crate::symbols::SymbolMatch],
840) -> String {
841    let mut lines = vec![format!(
842        "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
843        matches.len()
844    )];
845
846    for candidate in matches {
847        let entry = symbol_to_entry(&candidate.symbol);
848        lines.push(format!(
849            "- {}",
850            format_qualified_entry(&entry, Some(&candidate.symbol))
851        ));
852    }
853
854    lines.join("\n")
855}
856
857fn levenshtein_distance(s1: &str, s2: &str) -> usize {
858    let s1_chars: Vec<char> = s1.chars().collect();
859    let s2_chars: Vec<char> = s2.chars().collect();
860    let len1 = s1_chars.len();
861    let len2 = s2_chars.len();
862
863    let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
864
865    for i in 0..=len1 {
866        dp[i][0] = i;
867    }
868    for j in 0..=len2 {
869        dp[0][j] = j;
870    }
871
872    for i in 1..=len1 {
873        for j in 1..=len2 {
874            if s1_chars[i - 1] == s2_chars[j - 1] {
875                dp[i][j] = dp[i - 1][j - 1];
876            } else {
877                dp[i][j] =
878                    1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
879            }
880        }
881    }
882
883    dp[len1][len2]
884}
885
886fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
887    let mut unique: Vec<&String> = available.iter().collect();
888    unique.sort();
889    unique.dedup();
890
891    let query_lower = query.to_lowercase();
892    let query_len = query_lower.chars().count();
893    let max_dist = std::cmp::max(2, query_len / 3);
894
895    let mut scored: Vec<(bool, usize, &String)> = unique
896        .into_iter()
897        .map(|name| {
898            let name_lower = name.to_lowercase();
899            let is_substring =
900                name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
901            let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
902                (query_lower.find('_'), query_lower.rfind('_'))
903            {
904                let prefix = &query_lower[..=first_idx];
905                let suffix = &query_lower[last_idx..];
906                name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
907            } else {
908                false
909            };
910            let is_match = is_substring || is_wildcard;
911            let dist = levenshtein_distance(&query_lower, &name_lower);
912            (is_match, dist, name)
913        })
914        .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
915        .collect();
916
917    scored.sort_by(|a, b| {
918        let a_match = a.0;
919        let b_match = b.0;
920        (!a_match)
921            .cmp(&(!b_match))
922            .then_with(|| a.1.cmp(&b.1))
923            .then_with(|| a.2.cmp(b.2))
924    });
925
926    scored
927        .into_iter()
928        .take(k)
929        .map(|(_, _, name)| name.clone())
930        .collect()
931}
932
933fn normalize_heading_query(input: &str) -> &str {
934    let trimmed = input.trim_start();
935    let hash_stripped = trimmed.trim_start_matches('#').trim_start();
936
937    if let Some(after_open) = hash_stripped.strip_prefix('<') {
938        let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
939        let mut chars = after_slash.chars();
940        if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
941            if let Some(end) = hash_stripped.find('>') {
942                return hash_stripped[end + 1..].trim_start();
943            }
944        }
945    }
946
947    hash_stripped
948}
949
950/// Extract call expression names within a byte range of the AST.
951///
952/// Delegates to `crate::calls::extract_calls_in_range`.
953#[cfg(test)]
954fn extract_calls_in_range(
955    source: &str,
956    root: tree_sitter::Node,
957    byte_start: usize,
958    byte_end: usize,
959    lang: LangId,
960) -> Vec<(String, u32)> {
961    crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
962}
963
964fn symbol_body_byte_range(
965    root: tree_sitter::Node,
966    byte_start: usize,
967    byte_end: usize,
968) -> Option<(usize, usize)> {
969    let node = smallest_node_covering_range(root, byte_start, byte_end)?;
970    let mut current = Some(node);
971    while let Some(node) = current {
972        if is_symbol_body_node(node.kind()) {
973            return Some((node.start_byte(), node.end_byte()));
974        }
975        current = node.parent();
976    }
977    Some((node.start_byte(), node.end_byte()))
978}
979
980fn smallest_node_covering_range<'tree>(
981    node: tree_sitter::Node<'tree>,
982    byte_start: usize,
983    byte_end: usize,
984) -> Option<tree_sitter::Node<'tree>> {
985    if node.start_byte() > byte_start || node.end_byte() < byte_end {
986        return None;
987    }
988
989    let mut cursor = node.walk();
990    if cursor.goto_first_child() {
991        loop {
992            let child = cursor.node();
993            if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
994                return Some(found);
995            }
996            if !cursor.goto_next_sibling() {
997                break;
998            }
999        }
1000    }
1001
1002    Some(node)
1003}
1004
1005fn is_symbol_body_node(kind: &str) -> bool {
1006    matches!(
1007        kind,
1008        "function_declaration"
1009            | "generator_function_declaration"
1010            | "function_expression"
1011            | "generator_function"
1012            | "arrow_function"
1013            | "method_definition"
1014            | "class_declaration"
1015            | "abstract_class_declaration"
1016            | "class"
1017            | "lexical_declaration"
1018            | "function_definition"
1019            | "class_definition"
1020            | "decorated_definition"
1021            | "function_item"
1022            | "impl_item"
1023            | "method_declaration"
1024    )
1025}
1026
1027fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1028    let mut results = Vec::new();
1029    let call_kinds = crate::calls::call_node_kinds(lang);
1030    collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1031    results
1032}
1033
1034fn collect_calls_with_ranges(
1035    node: tree_sitter::Node,
1036    source: &str,
1037    call_kinds: &[&str],
1038    results: &mut Vec<RawCall>,
1039) {
1040    if call_kinds.contains(&node.kind()) {
1041        if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1042            results.push(RawCall {
1043                name,
1044                line: node.start_position().row as u32 + 1,
1045                start_byte: node.start_byte(),
1046                end_byte: node.end_byte(),
1047            });
1048        }
1049    }
1050
1051    let mut cursor = node.walk();
1052    if cursor.goto_first_child() {
1053        loop {
1054            collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1055            if !cursor.goto_next_sibling() {
1056                break;
1057            }
1058        }
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use crate::config::Config;
1066    use crate::context::AppContext;
1067    use crate::parser::TreeSitterProvider;
1068    use std::path::PathBuf;
1069
1070    fn fixture_path(name: &str) -> PathBuf {
1071        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1072            .join("tests")
1073            .join("fixtures")
1074            .join(name)
1075    }
1076
1077    fn make_ctx() -> AppContext {
1078        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1079    }
1080
1081    #[test]
1082    fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1083        let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1084        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1085        assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1086    }
1087
1088    #[test]
1089    fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1090        let params = serde_json::json!({ "symbols": "Getting Started" });
1091        let names = parse_zoom_symbol_names(&params, Some(LangId::Markdown)).expect("parse");
1092        assert_eq!(names, vec!["Getting Started"]);
1093    }
1094
1095    #[test]
1096    fn parse_zoom_symbol_names_does_not_split_html_headings() {
1097        let params = serde_json::json!({ "symbol": "Last Heading" });
1098        let names = parse_zoom_symbol_names(&params, Some(LangId::Html)).expect("parse");
1099        assert_eq!(names, vec!["Last Heading"]);
1100    }
1101
1102    #[test]
1103    fn parse_zoom_symbol_names_single_token_unchanged() {
1104        let params = serde_json::json!({ "symbol": "compute" });
1105        let names = parse_zoom_symbol_names(&params, Some(LangId::TypeScript)).expect("parse");
1106        assert_eq!(names, vec!["compute"]);
1107    }
1108
1109    #[test]
1110    fn parse_zoom_symbol_names_symbols_array_unchanged() {
1111        let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1112        let names = parse_zoom_symbol_names(&params, Some(LangId::Rust)).expect("parse");
1113        assert_eq!(names, vec!["A", "B", "C"]);
1114    }
1115
1116    // --- Call extraction tests ---
1117
1118    #[test]
1119    fn extract_calls_finds_direct_calls() {
1120        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1121        let mut parser = FileParser::new();
1122        let path = fixture_path("calls.ts");
1123        let (tree, lang) = parser.parse(&path).unwrap();
1124
1125        // `compute` calls `helper` — find compute's range from symbols
1126        let ctx = make_ctx();
1127        let symbols = ctx.provider().list_symbols(&path).unwrap();
1128        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1129
1130        let byte_start =
1131            line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1132        let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1133
1134        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1135        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1136
1137        assert!(
1138            names.contains(&"helper"),
1139            "compute should call helper, got: {:?}",
1140            names
1141        );
1142    }
1143
1144    #[test]
1145    fn extract_calls_finds_member_calls() {
1146        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1147        let mut parser = FileParser::new();
1148        let path = fixture_path("calls.ts");
1149        let (tree, lang) = parser.parse(&path).unwrap();
1150
1151        let ctx = make_ctx();
1152        let symbols = ctx.provider().list_symbols(&path).unwrap();
1153        let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1154
1155        let byte_start =
1156            line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1157        let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1158
1159        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1160        let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1161
1162        assert!(
1163            names.contains(&"add"),
1164            "runAll should call this.add, got: {:?}",
1165            names
1166        );
1167        assert!(
1168            names.contains(&"helper"),
1169            "runAll should call helper, got: {:?}",
1170            names
1171        );
1172    }
1173
1174    #[test]
1175    fn extract_calls_unused_function_has_no_calls() {
1176        let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1177        let mut parser = FileParser::new();
1178        let path = fixture_path("calls.ts");
1179        let (tree, lang) = parser.parse(&path).unwrap();
1180
1181        let ctx = make_ctx();
1182        let symbols = ctx.provider().list_symbols(&path).unwrap();
1183        let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1184
1185        let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1186        let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1187
1188        let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1189        // console.log is the only call, but "log" or "console" aren't known symbols
1190        let known_names = [
1191            "helper",
1192            "compute",
1193            "orchestrate",
1194            "unused",
1195            "format",
1196            "display",
1197        ];
1198        let filtered: Vec<&str> = calls
1199            .iter()
1200            .map(|(n, _)| n.as_str())
1201            .filter(|n| known_names.contains(n))
1202            .collect();
1203        assert!(
1204            filtered.is_empty(),
1205            "unused should not call known symbols, got: {:?}",
1206            filtered
1207        );
1208    }
1209
1210    // --- Context line tests ---
1211
1212    #[test]
1213    fn context_lines_clamp_at_file_start() {
1214        // helper() is at the top of the file (line 2) — context_before should be clamped
1215        let ctx = make_ctx();
1216        let path = fixture_path("calls.ts");
1217        let symbols = ctx.provider().list_symbols(&path).unwrap();
1218        let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1219
1220        let source = std::fs::read_to_string(&path).unwrap();
1221        let lines: Vec<&str> = source.lines().collect();
1222        let start = helper.range.start_line as usize;
1223
1224        // With context_lines=5, ctx_start should clamp to 0
1225        let ctx_start = start.saturating_sub(5);
1226        let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1227        // Should have at most `start` lines (not panic)
1228        assert!(context_before.len() <= start);
1229    }
1230
1231    #[test]
1232    fn context_lines_clamp_at_file_end() {
1233        let ctx = make_ctx();
1234        let path = fixture_path("calls.ts");
1235        let symbols = ctx.provider().list_symbols(&path).unwrap();
1236        let display = symbols.iter().find(|s| s.name == "display").unwrap();
1237
1238        let source = std::fs::read_to_string(&path).unwrap();
1239        let lines: Vec<&str> = source.lines().collect();
1240        let end = display.range.end_line as usize;
1241
1242        // With context_lines=20, should clamp to file length
1243        let ctx_end = (end + 1 + 20).min(lines.len());
1244        let context_after: Vec<&str> = if end + 1 < lines.len() {
1245            lines[(end + 1)..ctx_end].to_vec()
1246        } else {
1247            vec![]
1248        };
1249        // Should not panic regardless of context_lines size
1250        assert!(context_after.len() <= 20);
1251    }
1252
1253    // --- Body extraction test ---
1254
1255    #[test]
1256    fn body_extraction_matches_source() {
1257        let ctx = make_ctx();
1258        let path = fixture_path("calls.ts");
1259        let symbols = ctx.provider().list_symbols(&path).unwrap();
1260        let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1261
1262        let source = std::fs::read_to_string(&path).unwrap();
1263        let lines: Vec<&str> = source.lines().collect();
1264        let start = compute.range.start_line as usize;
1265        let end = compute.range.end_line as usize;
1266        let body = lines[start..=end].join("\n");
1267
1268        assert!(
1269            body.contains("function compute"),
1270            "body should contain function declaration"
1271        );
1272        assert!(
1273            body.contains("helper(a)"),
1274            "body should contain call to helper"
1275        );
1276        assert!(
1277            body.contains("doubled + b"),
1278            "body should contain return expression"
1279        );
1280    }
1281
1282    // --- Full zoom response tests ---
1283
1284    #[test]
1285    fn body_range_expands_signature_range_to_include_body_calls() {
1286        let source = r#"function compute(
1287  value: number,
1288): number {
1289  return helper(value);
1290}
1291
1292function helper(value: number): number {
1293  return value * 2;
1294}
1295"#;
1296        let grammar = crate::parser::grammar_for(LangId::TypeScript);
1297        let mut parser = tree_sitter::Parser::new();
1298        parser.set_language(&grammar).unwrap();
1299        let tree = parser.parse(source, None).unwrap();
1300        let signature_end = source.find('{').expect("function has body");
1301
1302        let (body_start, body_end) =
1303            symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1304        let calls = extract_calls_in_range(
1305            source,
1306            tree.root_node(),
1307            body_start,
1308            body_end,
1309            LangId::TypeScript,
1310        );
1311        let names = calls
1312            .iter()
1313            .map(|(name, _)| name.as_str())
1314            .collect::<Vec<_>>();
1315
1316        assert!(
1317            names.contains(&"helper"),
1318            "call inside the function body should be included: {names:?}"
1319        );
1320    }
1321
1322    #[test]
1323    fn zoom_leaf_returns_full_body_without_budget_marker() {
1324        let ctx = make_ctx();
1325        let path = fixture_path("calls.ts");
1326        let req = make_zoom_request(
1327            "z-leaf-full",
1328            path.to_str().unwrap(),
1329            "repeatedOutgoing",
1330            None,
1331        );
1332        let resp = handle_zoom(&req, &ctx);
1333        let json = serde_json::to_value(&resp).unwrap();
1334        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1335
1336        let symbols = ctx.provider().list_symbols(&path).unwrap();
1337        let target = symbols
1338            .iter()
1339            .find(|symbol| symbol.name == "repeatedOutgoing")
1340            .unwrap();
1341        let source = std::fs::read_to_string(&path).unwrap();
1342        let lines = source.lines().collect::<Vec<_>>();
1343        let expected =
1344            lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1345
1346        assert_eq!(json["content"].as_str().unwrap(), expected);
1347        assert!(
1348            !json["content"]
1349                .as_str()
1350                .unwrap()
1351                .contains("more lines — zoom"),
1352            "explicit zoom must not budget-cap leaf bodies"
1353        );
1354    }
1355
1356    #[test]
1357    fn zoom_response_has_calls_out_and_called_by() {
1358        let ctx = make_ctx();
1359        let path = fixture_path("calls.ts");
1360
1361        let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1362        let resp = handle_zoom(&req, &ctx);
1363
1364        let json = serde_json::to_value(&resp).unwrap();
1365        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1366
1367        let calls_out = json["annotations"]["calls_out"]
1368            .as_array()
1369            .expect("calls_out array");
1370        let out_names: Vec<&str> = calls_out
1371            .iter()
1372            .map(|c| c["name"].as_str().unwrap())
1373            .collect();
1374        assert!(
1375            out_names.contains(&"helper"),
1376            "compute calls helper: {:?}",
1377            out_names
1378        );
1379
1380        let called_by = json["annotations"]["called_by"]
1381            .as_array()
1382            .expect("called_by array");
1383        let by_names: Vec<&str> = called_by
1384            .iter()
1385            .map(|c| c["name"].as_str().unwrap())
1386            .collect();
1387        assert!(
1388            by_names.contains(&"orchestrate"),
1389            "orchestrate calls compute: {:?}",
1390            by_names
1391        );
1392    }
1393
1394    #[test]
1395    fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1396        let ctx = make_ctx();
1397        let path = fixture_path("calls.ts");
1398
1399        let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1400        let resp = handle_zoom(&req, &ctx);
1401        let json = serde_json::to_value(&resp).unwrap();
1402        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1403
1404        let calls_out = json["annotations"]["calls_out"]
1405            .as_array()
1406            .expect("calls_out array");
1407        let helper_refs = calls_out
1408            .iter()
1409            .filter(|call| call["name"] == "helper")
1410            .collect::<Vec<_>>();
1411        assert_eq!(
1412            helper_refs.len(),
1413            1,
1414            "helper should be folded once: {calls_out:?}"
1415        );
1416        assert_eq!(helper_refs[0]["extra_count"], 1);
1417        assert!(
1418            calls_out.iter().any(|call| call["name"] == "format"),
1419            "distinct callee must not be folded into helper: {calls_out:?}"
1420        );
1421
1422        let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1423        let resp = handle_zoom(&req, &ctx);
1424        let json = serde_json::to_value(&resp).unwrap();
1425        assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1426
1427        let called_by = json["annotations"]["called_by"]
1428            .as_array()
1429            .expect("called_by array");
1430        let repeat_refs = called_by
1431            .iter()
1432            .filter(|call| call["name"] == "repeatCompute")
1433            .collect::<Vec<_>>();
1434        assert_eq!(
1435            repeat_refs.len(),
1436            1,
1437            "repeatCompute should be folded once: {called_by:?}"
1438        );
1439        assert_eq!(repeat_refs[0]["extra_count"], 1);
1440        assert!(
1441            called_by.iter().any(|call| call["name"] == "orchestrate"),
1442            "distinct caller must not be folded into repeatCompute: {called_by:?}"
1443        );
1444    }
1445
1446    #[test]
1447    fn zoom_response_empty_annotations_for_unused() {
1448        let ctx = make_ctx();
1449        let path = fixture_path("calls.ts");
1450
1451        let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1452        let resp = handle_zoom(&req, &ctx);
1453
1454        let json = serde_json::to_value(&resp).unwrap();
1455        assert_eq!(json["success"], true);
1456
1457        let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1458        let called_by = json["annotations"]["called_by"].as_array().unwrap();
1459
1460        // calls_out exists (may contain console.log but no known symbols)
1461        // called_by should be empty — nobody calls unused
1462        assert!(
1463            called_by.is_empty(),
1464            "unused should not be called by anyone: {:?}",
1465            called_by
1466        );
1467    }
1468
1469    #[test]
1470    fn zoom_default_omits_callgraph_annotations() {
1471        let ctx = make_ctx();
1472        let path = fixture_path("calls.ts");
1473
1474        let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1475        let resp = handle_zoom(&req, &ctx);
1476
1477        let json = serde_json::to_value(&resp).unwrap();
1478        assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1479
1480        let calls_out = json["annotations"]["calls_out"]
1481            .as_array()
1482            .expect("calls_out array");
1483        let called_by = json["annotations"]["called_by"]
1484            .as_array()
1485            .expect("called_by array");
1486        assert!(
1487            calls_out.is_empty(),
1488            "default zoom should omit calls_out: {:?}",
1489            calls_out
1490        );
1491        assert!(
1492            called_by.is_empty(),
1493            "default zoom should omit called_by: {:?}",
1494            called_by
1495        );
1496    }
1497
1498    #[test]
1499    fn zoom_symbol_not_found() {
1500        let ctx = make_ctx();
1501        let path = fixture_path("calls.ts");
1502
1503        let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1504        let resp = handle_zoom(&req, &ctx);
1505
1506        let json = serde_json::to_value(&resp).unwrap();
1507        assert_eq!(json["success"], false);
1508        assert_eq!(json["code"], "symbol_not_found");
1509    }
1510
1511    #[test]
1512    fn zoom_custom_context_lines() {
1513        let ctx = make_ctx();
1514        let path = fixture_path("calls.ts");
1515
1516        let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1517        let resp = handle_zoom(&req, &ctx);
1518
1519        let json = serde_json::to_value(&resp).unwrap();
1520        assert_eq!(json["success"], true);
1521
1522        let ctx_before = json["context_before"].as_array().unwrap();
1523        let ctx_after = json["context_after"].as_array().unwrap();
1524        // With context_lines=1, we get at most 1 line before and after
1525        assert!(
1526            ctx_before.len() <= 1,
1527            "context_before should be ≤1: {:?}",
1528            ctx_before
1529        );
1530        assert!(
1531            ctx_after.len() <= 1,
1532            "context_after should be ≤1: {:?}",
1533            ctx_after
1534        );
1535    }
1536
1537    #[test]
1538    fn zoom_missing_file_param() {
1539        let ctx = make_ctx();
1540        let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1541        let resp = handle_zoom(&req, &ctx);
1542
1543        let json = serde_json::to_value(&resp).unwrap();
1544        assert_eq!(json["success"], false);
1545        assert_eq!(json["code"], "invalid_request");
1546    }
1547
1548    #[test]
1549    fn zoom_missing_symbol_param() {
1550        let ctx = make_ctx();
1551        let path = fixture_path("calls.ts");
1552        // Build the JSON via serde_json so Windows paths (with backslashes)
1553        // are escaped correctly. Hand-formatted JSON would treat `C:\path`
1554        // backslashes as escape sequences and fail to parse.
1555        let req_value = serde_json::json!({
1556            "id": "z-6",
1557            "command": "zoom",
1558            "file": path.to_string_lossy(),
1559        });
1560        let req_str = req_value.to_string();
1561        let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1562        let resp = handle_zoom(&req, &ctx);
1563
1564        let json = serde_json::to_value(&resp).unwrap();
1565        assert_eq!(json["success"], false);
1566        assert_eq!(json["code"], "invalid_request");
1567    }
1568
1569    #[test]
1570    fn test_suggest_close_symbols_unit() {
1571        let available = vec![
1572            "handle_grep_search".to_string(),
1573            "handle_semantic_search".to_string(),
1574            "handle_semantic_or_hybrid_search".to_string(),
1575            "compute_total".to_string(),
1576            "search".to_string(),
1577            "handle_search".to_string(),
1578        ];
1579
1580        let suggestions = suggest_close_symbols("handle_search", &available, 5);
1581        assert!(suggestions.contains(&"handle_grep_search".to_string()));
1582        assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1583        assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1584        assert!(suggestions.contains(&"search".to_string()));
1585        assert!(!suggestions.contains(&"compute_total".to_string()));
1586
1587        let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1588        assert_eq!(suggestions, suggestions_caps);
1589
1590        let available2 = vec![
1591            "total".to_string(),
1592            "compute_total".to_string(),
1593            "unrelated".to_string(),
1594        ];
1595        let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1596        assert_eq!(suggestions2, vec!["total".to_string()]);
1597    }
1598
1599    // --- Helpers ---
1600
1601    fn make_zoom_request(
1602        id: &str,
1603        file: &str,
1604        symbol: &str,
1605        context_lines: Option<u64>,
1606    ) -> RawRequest {
1607        let mut json = serde_json::json!({
1608            "id": id,
1609            "command": "zoom",
1610            "file": file,
1611            "symbol": symbol,
1612        });
1613        if let Some(cl) = context_lines {
1614            json["context_lines"] = serde_json::json!(cl);
1615        }
1616        serde_json::from_value(json).unwrap()
1617    }
1618
1619    fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1620        let mut req = make_zoom_request(id, file, symbol, None);
1621        req.params["callgraph"] = serde_json::json!(true);
1622        req
1623    }
1624
1625    fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1626        serde_json::from_str(json_str).unwrap()
1627    }
1628}