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