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