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