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