1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::commands::outline::symbol_to_entry;
7use crate::commands::symbol_render::{
8 build_container_outline, format_qualified_entry, might_have_container_members,
9 qualified_symbol_name, render_container_member_menu, should_return_member_menu,
10 symbol_kind_string,
11};
12use crate::context::AppContext;
13use crate::edit::line_col_to_byte;
14use crate::language::{HeadingAnchor, LanguageProvider};
15use crate::lsp_hints;
16use crate::parser::{detect_language, json_document_value, node_text, FileParser, LangId};
17use crate::protocol::{RawRequest, Response};
18use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch};
19use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
20
21#[derive(Debug, Clone, Serialize)]
23pub struct CallRef {
24 pub name: String,
25 pub line: u32,
27 #[serde(skip_serializing_if = "is_zero")]
29 pub extra_count: u32,
30}
31
32fn is_zero(value: &u32) -> bool {
33 *value == 0
34}
35
36fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
37 let mut index_by_name: HashMap<String, usize> = HashMap::new();
38 let mut deduped: Vec<CallRef> = Vec::new();
39
40 for call in calls {
41 if let Some(index) = index_by_name.get(&call.name).copied() {
42 deduped[index].extra_count = deduped[index]
43 .extra_count
44 .saturating_add(call.extra_count.saturating_add(1));
45 } else {
46 index_by_name.insert(call.name.clone(), deduped.len());
47 deduped.push(call);
48 }
49 }
50
51 deduped
52}
53
54#[derive(Debug, Clone, Serialize)]
56pub struct Annotations {
57 pub calls_out: Vec<CallRef>,
58 pub called_by: Vec<CallRef>,
59}
60
61#[derive(Debug, Clone, Serialize)]
63pub struct ZoomResponse {
64 pub name: String,
65 pub kind: String,
66 pub range: Range,
67 pub content: String,
68 pub context_before: Vec<String>,
69 pub context_after: Vec<String>,
70 pub annotations: Annotations,
71}
72
73struct RawCall {
74 name: String,
75 line: u32,
76 start_byte: usize,
77 end_byte: usize,
78}
79
80fn resolve_file_or_url(
81 req: &RawRequest,
82 ctx: &AppContext,
83 file: &str,
84) -> Result<PathBuf, Response> {
85 if is_http_url(file) {
86 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
87 let allow_private = ctx.config().url_fetch_allow_private
88 || req
89 .params
90 .get("allow_private")
91 .and_then(|value| value.as_bool())
92 .unwrap_or(false);
93 return fetch_url_to_cache(
94 file,
95 &storage_dir,
96 UrlFetchOptions {
97 allow_private,
98 ..UrlFetchOptions::default()
99 },
100 )
101 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
102 }
103
104 ctx.validate_path(&req.id, Path::new(file))
105}
106
107fn 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
991struct JsonPathMiss {
992 prefix: String,
993 failing: String,
994}
995
996fn resolve_json_zoom(
1011 req: &RawRequest,
1012 ctx: &AppContext,
1013 path: &Path,
1014 source: &str,
1015 lines: &[String],
1016 symbol_name: &str,
1017 context_lines: usize,
1018 include_callgraph: bool,
1019) -> Response {
1020 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1021 let (tree, _) = match parser.parse(path) {
1022 Ok(parsed) => parsed,
1023 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1024 };
1025 let root = tree.root_node();
1026
1027 let literal = match ctx.provider().resolve_symbol(path, symbol_name) {
1029 Ok(matches) => matches,
1030 Err(crate::error::AftError::SymbolNotFound { .. }) => Vec::new(),
1031 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1032 };
1033
1034 let path_result = json_path_resolve(source, &root, symbol_name);
1036
1037 if !literal.is_empty() {
1043 if let Some(path_node) = path_result.as_ref() {
1044 let literal_value_node = json_document_value(&root)
1045 .and_then(|object| json_object_value(source, object, symbol_name));
1046 let same_node = literal_value_node
1047 .map(|node| {
1048 node.start_position().row == path_node.node.start_position().row
1049 && node.start_position().column == path_node.node.start_position().column
1050 && node.end_position().row == path_node.node.end_position().row
1051 && node.end_position().column == path_node.node.end_position().column
1052 })
1053 .unwrap_or(false);
1054 if !same_node {
1055 let literal_node = &literal[0].symbol;
1056 let candidates = vec![
1057 serde_json::json!({
1058 "name": symbol_name,
1059 "kind": symbol_kind_string(&literal_node.kind),
1060 "range": literal_node.range.clone(),
1061 "signature": literal_node.signature.clone(),
1062 }),
1063 serde_json::json!({
1064 "name": path_node.path.clone(),
1065 "kind": "json_path",
1066 "range": node_range(&path_node.node),
1067 "signature": serde_json::Value::Null,
1068 }),
1069 ];
1070 return Response::error_with_data(
1071 &req.id,
1072 "ambiguous_match",
1073 format!(
1074 "symbol '{}' is ambiguous: a literal key and a JSON path both resolve to different nodes",
1075 symbol_name
1076 ),
1077 serde_json::json!({ "candidates": candidates }),
1078 );
1079 }
1080 }
1081 }
1082
1083 if !literal.is_empty() {
1085 return render_json_zoom(
1086 req,
1087 ctx,
1088 path,
1089 source,
1090 lines,
1091 symbol_name,
1092 &literal[0].symbol,
1093 context_lines,
1094 include_callgraph,
1095 );
1096 }
1097
1098 if let Some(resolved) = path_result {
1100 return render_json_zoom(
1101 req,
1102 ctx,
1103 path,
1104 source,
1105 lines,
1106 &resolved.path,
1107 &json_node_to_symbol(&resolved.node, &resolved.path),
1108 context_lines,
1109 include_callgraph,
1110 );
1111 }
1112
1113 let (prefix, failing) = json_miss_details(source, &root, symbol_name);
1116 let mut msg = if prefix.is_empty() {
1117 format!("symbol '{}' not found: no key `{}`", symbol_name, failing)
1118 } else {
1119 format!(
1120 "symbol '{}' not found: resolved `{}`, no key `{}`",
1121 symbol_name, prefix, failing
1122 )
1123 };
1124 let sibling_keys = json_sibling_keys(source, &root, &prefix);
1125 if !sibling_keys.is_empty() {
1126 let suggestions = suggest_close_symbols(&failing, &sibling_keys, 5);
1127 if !suggestions.is_empty() {
1128 msg.push_str(&format!(" — nearest: [{}]", suggestions.join(", ")));
1129 }
1130 }
1131 Response::error(&req.id, "symbol_not_found", msg)
1132}
1133
1134fn json_path_resolve<'a>(
1140 source: &str,
1141 root: &tree_sitter::Node<'a>,
1142 query: &str,
1143) -> Option<JsonNode<'a>> {
1144 json_path_lookup(source, root, query).ok()
1145}
1146
1147fn json_path_lookup<'a>(
1152 source: &str,
1153 root: &tree_sitter::Node<'a>,
1154 query: &str,
1155) -> Result<JsonNode<'a>, JsonPathMiss> {
1156 let segments = split_json_path(query);
1157 let Some(first_segment) = segments.first() else {
1158 return Err(JsonPathMiss {
1159 prefix: String::new(),
1160 failing: query.to_string(),
1161 });
1162 };
1163
1164 let Some(mut current) = json_document_value(root) else {
1168 return Err(JsonPathMiss {
1169 prefix: String::new(),
1170 failing: first_segment.clone(),
1171 });
1172 };
1173 if current.kind() != "object" {
1174 return Err(JsonPathMiss {
1175 prefix: String::new(),
1176 failing: first_segment.clone(),
1177 });
1178 }
1179
1180 let mut resolved_path = String::new();
1181 for segment in &segments {
1182 let (key, array_index) = parse_json_segment(segment);
1183 let next = if let Some(array_index) = array_index {
1184 let array = match key {
1187 Some(key) => json_object_value(source, current, key),
1188 None => Some(current),
1189 };
1190 array.and_then(|array| json_array_element(array, array_index))
1191 } else {
1192 key.and_then(|key| json_object_value(source, current, key))
1193 };
1194 let Some(next) = next else {
1195 return Err(JsonPathMiss {
1196 prefix: resolved_path,
1197 failing: segment.clone(),
1198 });
1199 };
1200
1201 if resolved_path.is_empty() {
1202 resolved_path = segment.clone();
1203 } else {
1204 resolved_path.push('.');
1205 resolved_path.push_str(segment);
1206 }
1207 current = next;
1208 }
1209
1210 Ok(JsonNode {
1211 node: current,
1212 path: resolved_path,
1213 })
1214}
1215
1216fn split_json_path(query: &str) -> Vec<String> {
1218 let mut segments = Vec::new();
1219 let mut current = String::new();
1220 let mut depth = 0usize;
1221 for character in query.chars() {
1222 match character {
1223 '[' => {
1224 depth += 1;
1225 current.push(character);
1226 }
1227 ']' => {
1228 depth = depth.saturating_sub(1);
1229 current.push(character);
1230 }
1231 '.' if depth == 0 => {
1232 if !current.is_empty() {
1233 segments.push(std::mem::take(&mut current));
1234 }
1235 }
1236 _ => current.push(character),
1237 }
1238 }
1239 if !current.is_empty() {
1240 segments.push(current);
1241 }
1242 segments
1243}
1244
1245fn parse_json_segment(segment: &str) -> (Option<&str>, Option<usize>) {
1250 if let Some(open) = segment.find('[') {
1251 if segment.ends_with(']') {
1252 let key = if open == 0 {
1253 None
1254 } else {
1255 Some(&segment[..open])
1256 };
1257 let index_text = &segment[open + 1..segment.len() - 1];
1258 if let Ok(index) = index_text.parse::<usize>() {
1259 return (key, Some(index));
1260 }
1261 }
1262 }
1263 (Some(segment), None)
1264}
1265
1266fn json_object_value<'a>(
1268 source: &str,
1269 object: tree_sitter::Node<'a>,
1270 key: &str,
1271) -> Option<tree_sitter::Node<'a>> {
1272 if object.kind() != "object" {
1273 return None;
1274 }
1275 let mut cursor = object.walk();
1276 for pair in object.named_children(&mut cursor) {
1277 if pair.kind() != "pair" {
1278 continue;
1279 }
1280 let Some(key_node) = pair.child_by_field_name("key") else {
1281 continue;
1282 };
1283 if node_text(source, &key_node).trim_matches('"') == key {
1284 return pair.child_by_field_name("value");
1285 }
1286 }
1287 None
1288}
1289
1290fn json_array_element<'a>(
1292 array: tree_sitter::Node<'a>,
1293 index: usize,
1294) -> Option<tree_sitter::Node<'a>> {
1295 if array.kind() != "array" {
1296 return None;
1297 }
1298 let mut cursor = array.walk();
1299 for (seen, element) in array.named_children(&mut cursor).enumerate() {
1300 if seen == index {
1301 return Some(element);
1302 }
1303 }
1304 None
1305}
1306
1307fn json_node_to_symbol(node: &tree_sitter::Node, path: &str) -> Symbol {
1309 Symbol {
1310 name: path.to_string(),
1311 kind: SymbolKind::Variable,
1312 range: node_range(node),
1313 signature: None,
1314 scope_chain: vec![],
1315 exported: false,
1316 parent: None,
1317 }
1318}
1319
1320fn render_json_zoom(
1322 req: &RawRequest,
1323 ctx: &AppContext,
1324 path: &Path,
1325 source: &str,
1326 lines: &[String],
1327 name: &str,
1328 target: &Symbol,
1329 context_lines: usize,
1330 include_callgraph: bool,
1331) -> Response {
1332 let start = target.range.start_line as usize;
1333 let end = target.range.end_line as usize;
1334
1335 let content = if end < lines.len() {
1336 lines[start..=end].join("\n")
1337 } else {
1338 lines[start..].join("\n")
1339 };
1340
1341 let ctx_start = start.saturating_sub(context_lines);
1342 let context_before: Vec<String> = if ctx_start < start {
1343 lines[ctx_start..start].to_vec()
1344 } else {
1345 vec![]
1346 };
1347 let ctx_end = (end + 1 + context_lines).min(lines.len());
1348 let context_after: Vec<String> = if end + 1 < lines.len() {
1349 lines[(end + 1)..ctx_end].to_vec()
1350 } else {
1351 vec![]
1352 };
1353
1354 let (calls_out, called_by) = if include_callgraph {
1355 let all_symbols = match ctx.provider().list_symbols(path) {
1356 Ok(s) => s,
1357 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1358 };
1359 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
1360 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1361 let (tree, lang) = match parser.parse(path) {
1362 Ok(r) => r,
1363 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1364 };
1365 let all_file_calls = extract_calls_with_ranges(source, tree.root_node(), lang);
1366 let signature_byte_start =
1367 line_col_to_byte(source, target.range.start_line, target.range.start_col);
1368 let signature_byte_end =
1369 line_col_to_byte(source, target.range.end_line, target.range.end_col);
1370 let (target_byte_start, target_byte_end) =
1371 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
1372 .unwrap_or((signature_byte_start, signature_byte_end));
1373 let calls_out = dedupe_call_refs_by_name(
1374 all_file_calls
1375 .iter()
1376 .filter(|call| {
1377 call.start_byte >= target_byte_start
1378 && call.end_byte <= target_byte_end
1379 && known_names.contains(&call.name.as_str())
1380 && call.name != target.name
1381 })
1382 .map(|call| CallRef {
1383 name: call.name.clone(),
1384 line: call.line,
1385 extra_count: 0,
1386 })
1387 .collect(),
1388 );
1389 (calls_out, Vec::new())
1390 } else {
1391 (Vec::new(), Vec::new())
1392 };
1393
1394 let resp = ZoomResponse {
1395 name: name.to_string(),
1396 kind: symbol_kind_string(&target.kind),
1397 range: target.range.clone(),
1398 content,
1399 context_before,
1400 context_after,
1401 annotations: Annotations {
1402 calls_out,
1403 called_by,
1404 },
1405 };
1406
1407 match serde_json::to_value(&resp) {
1408 Ok(resp_json) => Response::success(&req.id, resp_json),
1409 Err(err) => Response::error(
1410 &req.id,
1411 "internal_error",
1412 format!("zoom: failed to serialize response: {err}"),
1413 ),
1414 }
1415}
1416
1417fn json_miss_details(source: &str, root: &tree_sitter::Node, query: &str) -> (String, String) {
1423 match json_path_lookup(source, root, query) {
1424 Err(miss) => (miss.prefix, miss.failing),
1425 Ok(_) => (String::new(), String::new()),
1426 }
1427}
1428
1429fn json_sibling_keys(source: &str, root: &tree_sitter::Node, prefix: &str) -> Vec<String> {
1434 let object = if prefix.is_empty() {
1435 json_document_value(root)
1436 } else {
1437 json_path_resolve(source, root, prefix).map(|resolved| resolved.node)
1438 };
1439 let Some(object) = object else {
1440 return Vec::new();
1441 };
1442 if object.kind() != "object" {
1443 return Vec::new();
1444 }
1445 let mut keys = Vec::new();
1446 let mut cursor = object.walk();
1447 for pair in object.named_children(&mut cursor) {
1448 if pair.kind() != "pair" {
1449 continue;
1450 }
1451 if let Some(key_node) = pair.child_by_field_name("key") {
1452 let key = node_text(source, &key_node).trim_matches('"').to_string();
1453 if !key.is_empty() {
1454 keys.push(key);
1455 }
1456 }
1457 }
1458 keys
1459}
1460
1461fn node_range(node: &tree_sitter::Node) -> Range {
1463 let start = node.start_position();
1464 let end = node.end_position();
1465 Range {
1466 start_line: start.row as u32,
1467 start_col: start.column as u32,
1468 end_line: end.row as u32,
1469 end_col: end.column as u32,
1470 }
1471}
1472
1473fn resolve_heading_symbols(
1476 provider: &dyn LanguageProvider,
1477 path: &Path,
1478 query: &str,
1479) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1480 let headings: Vec<SymbolMatch> = provider
1481 .list_symbols(path)?
1482 .into_iter()
1483 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1484 .map(|symbol| SymbolMatch {
1485 file: path.display().to_string(),
1486 symbol,
1487 })
1488 .collect();
1489
1490 let anchors = if query.starts_with('#') {
1491 provider.heading_anchors(path)?
1492 } else {
1493 Vec::new()
1494 };
1495
1496 Ok(match_heading_identity(&headings, query, &anchors))
1497}
1498
1499fn match_heading_identity(
1500 headings: &[SymbolMatch],
1501 query: &str,
1502 anchors: &[HeadingAnchor],
1503) -> Vec<SymbolMatch> {
1504 if let Some(anchor_query) = query.strip_prefix('#') {
1505 let mut matches: Vec<_> = headings
1506 .iter()
1507 .filter(|candidate| {
1508 anchors.iter().any(|anchor| {
1509 anchor.id == anchor_query
1510 && anchor.start_line == candidate.symbol.range.start_line
1511 && anchor.start_col == candidate.symbol.range.start_col
1512 })
1513 })
1514 .cloned()
1515 .collect();
1516
1517 let normalized_query = normalize_heading_label(query);
1518 let query_slug = slugify_heading_label(&normalized_query);
1519 if !query_slug.is_empty() {
1520 for candidate in headings
1521 .iter()
1522 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1523 {
1524 if !matches.iter().any(|existing| {
1525 existing.file == candidate.file && existing.symbol == candidate.symbol
1526 }) {
1527 matches.push(candidate.clone());
1528 }
1529 }
1530 }
1531 return matches;
1532 }
1533
1534 let exact: Vec<_> = headings
1535 .iter()
1536 .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1537 .cloned()
1538 .collect();
1539 if !exact.is_empty() {
1540 return exact;
1541 }
1542
1543 let normalized_query = normalize_heading_label(query);
1544 if normalized_query.is_empty() {
1545 return Vec::new();
1546 }
1547
1548 let normalized: Vec<_> = headings
1549 .iter()
1550 .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1551 .cloned()
1552 .collect();
1553 if !normalized.is_empty() {
1554 return normalized;
1555 }
1556
1557 let folded_query = normalized_query.to_lowercase();
1558 let case_insensitive: Vec<_> = headings
1559 .iter()
1560 .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1561 .cloned()
1562 .collect();
1563 if !case_insensitive.is_empty() {
1564 return case_insensitive;
1565 }
1566
1567 let query_slug = slugify_heading_label(&normalized_query);
1568 if query_slug.is_empty() {
1569 return Vec::new();
1570 }
1571
1572 headings
1573 .iter()
1574 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1575 .cloned()
1576 .collect()
1577}
1578
1579fn qualified_heading_name(symbol: &Symbol) -> String {
1580 if symbol.scope_chain.is_empty() {
1581 return symbol.name.clone();
1582 }
1583 format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1584}
1585
1586fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1587 symbol.name == query || qualified_heading_name(symbol) == query
1588}
1589
1590fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1591 normalize_heading_label(&symbol.name) == query
1592 || normalize_heading_label(&qualified_heading_name(symbol)) == query
1593}
1594
1595fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1596 normalize_heading_label(&symbol.name).to_lowercase() == query
1597 || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1598}
1599
1600fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1601 slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1602 || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1603 == query_slug
1604}
1605
1606fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1607 let available: Vec<String> = symbols
1608 .iter()
1609 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1610 .map(|symbol| normalize_heading_label(&symbol.name))
1611 .filter(|name| !name.is_empty())
1612 .collect();
1613 let normalized_query = normalize_heading_label(query);
1614 if normalized_query.is_empty() {
1615 return Vec::new();
1616 }
1617 suggest_close_symbols(&normalized_query, &available, k)
1618}
1619
1620fn normalize_heading_label(input: &str) -> String {
1621 let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1622
1623 for _ in 0..4 {
1626 let mut next = value.as_str();
1627 let without_heading_markers = next.trim_start_matches('#').trim_start();
1628 if without_heading_markers != next {
1629 next = without_heading_markers;
1630 }
1631 if let Some(rest) = strip_heading_html_prefix(next) {
1632 next = rest;
1633 }
1634 if let Some(rest) = strip_leading_section_prefix(next) {
1635 next = rest;
1636 }
1637 if let Some(rest) = strip_leading_symbol_cluster(next) {
1638 next = rest;
1639 }
1640
1641 let collapsed = collapse_heading_whitespace(next);
1642 if collapsed == value {
1643 break;
1644 }
1645 value = collapsed;
1646 }
1647
1648 value
1649}
1650
1651fn collapse_heading_whitespace(input: &str) -> String {
1652 input.split_whitespace().collect::<Vec<_>>().join(" ")
1653}
1654
1655fn strip_markdown_links(input: &str) -> String {
1656 let mut output = String::with_capacity(input.len());
1657 let mut cursor = 0;
1658
1659 while let Some(relative_open) = input[cursor..].find('[') {
1660 let open = cursor + relative_open;
1661 let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1662 output.push_str(&input[cursor..]);
1663 return output;
1664 };
1665
1666 if input.as_bytes().get(close + 1) != Some(&b'(') {
1667 output.push_str(&input[cursor..=close]);
1668 cursor = close + 1;
1669 continue;
1670 }
1671
1672 let target_open = close + 1;
1673 let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1674 output.push_str(&input[cursor..]);
1675 return output;
1676 };
1677
1678 output.push_str(&input[cursor..open]);
1679 output.push_str(&input[open + 1..close]);
1680 cursor = target_close + 1;
1681 }
1682
1683 output.push_str(&input[cursor..]);
1684 output
1685}
1686
1687fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1688 let mut depth = 0;
1689 for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1690 if *byte == open {
1691 depth += 1;
1692 } else if *byte == close {
1693 depth -= 1;
1694 if depth == 0 {
1695 return Some(index);
1696 }
1697 }
1698 }
1699 None
1700}
1701
1702fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1703 let input = input.trim_start();
1704 let bytes = input.as_bytes();
1705 if bytes.first() != Some(&b'<') {
1706 return None;
1707 }
1708
1709 let mut index = 1;
1710 if bytes.get(index) == Some(&b'/') {
1711 index += 1;
1712 }
1713 if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1714 return None;
1715 }
1716 index += 1;
1717 if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1718 return None;
1719 }
1720
1721 let end = input.find('>')?;
1722 let rest = input[end + 1..].trim_start();
1723 if rest.chars().any(|character| character.is_alphanumeric()) {
1724 Some(rest)
1725 } else {
1726 None
1727 }
1728}
1729
1730fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1731 let bytes = input.as_bytes();
1732 let mut index = 0;
1733 let mut saw_dot = false;
1734
1735 if !bytes
1736 .first()
1737 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1738 {
1739 return None;
1740 }
1741
1742 while index < bytes.len() {
1743 while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1744 index += 1;
1745 }
1746 if bytes.get(index) != Some(&b'.') {
1747 break;
1748 }
1749 saw_dot = true;
1750 index += 1;
1751 if bytes
1752 .get(index)
1753 .is_some_and(|byte| byte.is_ascii_whitespace())
1754 {
1755 let rest = input[index..].trim_start();
1756 return if rest.chars().any(|character| character.is_alphanumeric()) {
1757 Some(rest)
1758 } else {
1759 None
1760 };
1761 }
1762 if !bytes
1763 .get(index)
1764 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1765 {
1766 return None;
1767 }
1768 }
1769
1770 if saw_dot
1771 && bytes
1772 .get(index)
1773 .is_some_and(|byte| byte.is_ascii_whitespace())
1774 {
1775 let rest = input[index..].trim_start();
1776 if rest.chars().any(|character| character.is_alphanumeric()) {
1777 return Some(rest);
1778 }
1779 }
1780 None
1781}
1782
1783fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1784 let first_text = input
1785 .char_indices()
1786 .find(|(_, character)| character.is_alphanumeric())
1787 .map(|(index, _)| index)?;
1788 if first_text == 0 {
1789 return None;
1790 }
1791
1792 let rest = &input[first_text..];
1793 if rest.chars().any(|character| character.is_alphanumeric()) {
1794 Some(rest)
1795 } else {
1796 None
1797 }
1798}
1799
1800fn slugify_heading_label(label: &str) -> String {
1801 let mut slug = String::new();
1802 let mut pending_separator = false;
1803
1804 for character in label.chars() {
1805 if character.is_alphanumeric() {
1806 if pending_separator && !slug.is_empty() {
1807 slug.push('-');
1808 }
1809 for lowercase in character.to_lowercase() {
1810 slug.push(lowercase);
1811 }
1812 pending_separator = false;
1813 } else if !slug.is_empty() {
1814 pending_separator = true;
1815 }
1816 }
1817
1818 slug
1819}
1820
1821#[cfg(test)]
1825fn extract_calls_in_range(
1826 source: &str,
1827 root: tree_sitter::Node,
1828 byte_start: usize,
1829 byte_end: usize,
1830 lang: LangId,
1831) -> Vec<(String, u32)> {
1832 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1833}
1834
1835fn symbol_body_byte_range(
1836 root: tree_sitter::Node,
1837 byte_start: usize,
1838 byte_end: usize,
1839) -> Option<(usize, usize)> {
1840 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1841 let mut current = Some(node);
1842 while let Some(node) = current {
1843 if is_symbol_body_node(node.kind()) {
1844 return Some((node.start_byte(), node.end_byte()));
1845 }
1846 current = node.parent();
1847 }
1848 Some((node.start_byte(), node.end_byte()))
1849}
1850
1851fn smallest_node_covering_range<'tree>(
1852 node: tree_sitter::Node<'tree>,
1853 byte_start: usize,
1854 byte_end: usize,
1855) -> Option<tree_sitter::Node<'tree>> {
1856 if node.start_byte() > byte_start || node.end_byte() < byte_end {
1857 return None;
1858 }
1859
1860 let mut cursor = node.walk();
1861 if cursor.goto_first_child() {
1862 loop {
1863 let child = cursor.node();
1864 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1865 return Some(found);
1866 }
1867 if !cursor.goto_next_sibling() {
1868 break;
1869 }
1870 }
1871 }
1872
1873 Some(node)
1874}
1875
1876fn is_symbol_body_node(kind: &str) -> bool {
1877 matches!(
1878 kind,
1879 "function_declaration"
1880 | "generator_function_declaration"
1881 | "function_expression"
1882 | "generator_function"
1883 | "arrow_function"
1884 | "method_definition"
1885 | "class_declaration"
1886 | "abstract_class_declaration"
1887 | "class"
1888 | "lexical_declaration"
1889 | "function_definition"
1890 | "class_definition"
1891 | "decorated_definition"
1892 | "function_item"
1893 | "impl_item"
1894 | "method_declaration"
1895 )
1896}
1897
1898fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1899 let mut results = Vec::new();
1900 let call_kinds = crate::calls::call_node_kinds(lang);
1901 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1902 results
1903}
1904
1905fn collect_calls_with_ranges(
1906 node: tree_sitter::Node,
1907 source: &str,
1908 call_kinds: &[&str],
1909 results: &mut Vec<RawCall>,
1910) {
1911 if call_kinds.contains(&node.kind()) {
1912 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1913 results.push(RawCall {
1914 name,
1915 line: node.start_position().row as u32 + 1,
1916 start_byte: node.start_byte(),
1917 end_byte: node.end_byte(),
1918 });
1919 }
1920 }
1921
1922 let mut cursor = node.walk();
1923 if cursor.goto_first_child() {
1924 loop {
1925 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1926 if !cursor.goto_next_sibling() {
1927 break;
1928 }
1929 }
1930 }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935 use super::*;
1936 use crate::config::Config;
1937 use crate::context::AppContext;
1938 use crate::parser::TreeSitterProvider;
1939 use std::path::PathBuf;
1940
1941 fn fixture_path(name: &str) -> PathBuf {
1942 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1943 .join("tests")
1944 .join("fixtures")
1945 .join(name)
1946 }
1947
1948 fn make_ctx() -> AppContext {
1949 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1950 }
1951
1952 #[test]
1953 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1954 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1955 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1956 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1957 }
1958
1959 #[test]
1960 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1961 let params = serde_json::json!({ "symbols": "Getting Started" });
1962 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1963 assert_eq!(names, vec!["Getting Started"]);
1964 }
1965
1966 #[test]
1967 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1968 let params = serde_json::json!({ "symbol": "Last Heading" });
1969 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1970 assert_eq!(names, vec!["Last Heading"]);
1971 }
1972
1973 #[test]
1974 fn parse_zoom_symbol_names_single_token_unchanged() {
1975 let params = serde_json::json!({ "symbol": "compute" });
1976 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1977 assert_eq!(names, vec!["compute"]);
1978 }
1979
1980 #[test]
1981 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1982 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1983 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1984 assert_eq!(names, vec!["A", "B", "C"]);
1985 }
1986
1987 #[test]
1988 fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1989 let params =
1991 serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1992 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1993 assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1994 }
1995
1996 #[test]
1997 fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
1998 let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
1999 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
2000 assert_eq!(names, vec!["alpha", "beta"]);
2001 }
2002
2003 #[test]
2004 fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
2005 let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
2008 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
2009 assert_eq!(names, vec!["[Draft] Rollout plan"]);
2010 }
2011
2012 #[test]
2013 fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
2014 let params = serde_json::json!({ "symbols": "[1, 2]" });
2017 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
2018 assert_eq!(names, vec!["[1,", "2]"]);
2019 }
2020
2021 #[test]
2024 fn extract_calls_finds_direct_calls() {
2025 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2026 let mut parser = FileParser::new();
2027 let path = fixture_path("calls.ts");
2028 let (tree, lang) = parser.parse(&path).unwrap();
2029
2030 let ctx = make_ctx();
2032 let symbols = ctx.provider().list_symbols(&path).unwrap();
2033 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2034
2035 let byte_start =
2036 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
2037 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
2038
2039 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2040 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2041
2042 assert!(
2043 names.contains(&"helper"),
2044 "compute should call helper, got: {:?}",
2045 names
2046 );
2047 }
2048
2049 #[test]
2050 fn extract_calls_finds_member_calls() {
2051 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2052 let mut parser = FileParser::new();
2053 let path = fixture_path("calls.ts");
2054 let (tree, lang) = parser.parse(&path).unwrap();
2055
2056 let ctx = make_ctx();
2057 let symbols = ctx.provider().list_symbols(&path).unwrap();
2058 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
2059
2060 let byte_start =
2061 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
2062 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
2063
2064 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2065 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2066
2067 assert!(
2068 names.contains(&"add"),
2069 "runAll should call this.add, got: {:?}",
2070 names
2071 );
2072 assert!(
2073 names.contains(&"helper"),
2074 "runAll should call helper, got: {:?}",
2075 names
2076 );
2077 }
2078
2079 #[test]
2080 fn extract_calls_unused_function_has_no_calls() {
2081 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2082 let mut parser = FileParser::new();
2083 let path = fixture_path("calls.ts");
2084 let (tree, lang) = parser.parse(&path).unwrap();
2085
2086 let ctx = make_ctx();
2087 let symbols = ctx.provider().list_symbols(&path).unwrap();
2088 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
2089
2090 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
2091 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
2092
2093 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2094 let known_names = [
2096 "helper",
2097 "compute",
2098 "orchestrate",
2099 "unused",
2100 "format",
2101 "display",
2102 ];
2103 let filtered: Vec<&str> = calls
2104 .iter()
2105 .map(|(n, _)| n.as_str())
2106 .filter(|n| known_names.contains(n))
2107 .collect();
2108 assert!(
2109 filtered.is_empty(),
2110 "unused should not call known symbols, got: {:?}",
2111 filtered
2112 );
2113 }
2114
2115 #[test]
2118 fn context_lines_clamp_at_file_start() {
2119 let ctx = make_ctx();
2121 let path = fixture_path("calls.ts");
2122 let symbols = ctx.provider().list_symbols(&path).unwrap();
2123 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
2124
2125 let source = std::fs::read_to_string(&path).unwrap();
2126 let lines: Vec<&str> = source.lines().collect();
2127 let start = helper.range.start_line as usize;
2128
2129 let ctx_start = start.saturating_sub(5);
2131 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
2132 assert!(context_before.len() <= start);
2134 }
2135
2136 #[test]
2137 fn context_lines_clamp_at_file_end() {
2138 let ctx = make_ctx();
2139 let path = fixture_path("calls.ts");
2140 let symbols = ctx.provider().list_symbols(&path).unwrap();
2141 let display = symbols.iter().find(|s| s.name == "display").unwrap();
2142
2143 let source = std::fs::read_to_string(&path).unwrap();
2144 let lines: Vec<&str> = source.lines().collect();
2145 let end = display.range.end_line as usize;
2146
2147 let ctx_end = (end + 1 + 20).min(lines.len());
2149 let context_after: Vec<&str> = if end + 1 < lines.len() {
2150 lines[(end + 1)..ctx_end].to_vec()
2151 } else {
2152 vec![]
2153 };
2154 assert!(context_after.len() <= 20);
2156 }
2157
2158 #[test]
2161 fn body_extraction_matches_source() {
2162 let ctx = make_ctx();
2163 let path = fixture_path("calls.ts");
2164 let symbols = ctx.provider().list_symbols(&path).unwrap();
2165 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2166
2167 let source = std::fs::read_to_string(&path).unwrap();
2168 let lines: Vec<&str> = source.lines().collect();
2169 let start = compute.range.start_line as usize;
2170 let end = compute.range.end_line as usize;
2171 let body = lines[start..=end].join("\n");
2172
2173 assert!(
2174 body.contains("function compute"),
2175 "body should contain function declaration"
2176 );
2177 assert!(
2178 body.contains("helper(a)"),
2179 "body should contain call to helper"
2180 );
2181 assert!(
2182 body.contains("doubled + b"),
2183 "body should contain return expression"
2184 );
2185 }
2186
2187 #[test]
2190 fn body_range_expands_signature_range_to_include_body_calls() {
2191 let source = r#"function compute(
2192 value: number,
2193): number {
2194 return helper(value);
2195}
2196
2197function helper(value: number): number {
2198 return value * 2;
2199}
2200"#;
2201 let grammar = crate::parser::grammar_for(LangId::TypeScript);
2202 let mut parser = tree_sitter::Parser::new();
2203 parser.set_language(&grammar).unwrap();
2204 let tree = parser.parse(source, None).unwrap();
2205 let signature_end = source.find('{').expect("function has body");
2206
2207 let (body_start, body_end) =
2208 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
2209 let calls = extract_calls_in_range(
2210 source,
2211 tree.root_node(),
2212 body_start,
2213 body_end,
2214 LangId::TypeScript,
2215 );
2216 let names = calls
2217 .iter()
2218 .map(|(name, _)| name.as_str())
2219 .collect::<Vec<_>>();
2220
2221 assert!(
2222 names.contains(&"helper"),
2223 "call inside the function body should be included: {names:?}"
2224 );
2225 }
2226
2227 #[test]
2228 fn zoom_leaf_returns_full_body_without_budget_marker() {
2229 let ctx = make_ctx();
2230 let path = fixture_path("calls.ts");
2231 let req = make_zoom_request(
2232 "z-leaf-full",
2233 path.to_str().unwrap(),
2234 "repeatedOutgoing",
2235 None,
2236 );
2237 let resp = handle_zoom(&req, &ctx);
2238 let json = serde_json::to_value(&resp).unwrap();
2239 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2240
2241 let symbols = ctx.provider().list_symbols(&path).unwrap();
2242 let target = symbols
2243 .iter()
2244 .find(|symbol| symbol.name == "repeatedOutgoing")
2245 .unwrap();
2246 let source = std::fs::read_to_string(&path).unwrap();
2247 let lines = source.lines().collect::<Vec<_>>();
2248 let expected =
2249 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
2250
2251 assert_eq!(json["content"].as_str().unwrap(), expected);
2252 assert!(
2253 !json["content"]
2254 .as_str()
2255 .unwrap()
2256 .contains("more lines — zoom"),
2257 "explicit zoom must not budget-cap leaf bodies"
2258 );
2259 }
2260
2261 #[test]
2262 fn zoom_response_has_calls_out_and_called_by() {
2263 let ctx = make_ctx();
2264 let path = fixture_path("calls.ts");
2265
2266 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
2267 let resp = handle_zoom(&req, &ctx);
2268
2269 let json = serde_json::to_value(&resp).unwrap();
2270 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2271
2272 let calls_out = json["annotations"]["calls_out"]
2273 .as_array()
2274 .expect("calls_out array");
2275 let out_names: Vec<&str> = calls_out
2276 .iter()
2277 .map(|c| c["name"].as_str().unwrap())
2278 .collect();
2279 assert!(
2280 out_names.contains(&"helper"),
2281 "compute calls helper: {:?}",
2282 out_names
2283 );
2284
2285 let called_by = json["annotations"]["called_by"]
2286 .as_array()
2287 .expect("called_by array");
2288 let by_names: Vec<&str> = called_by
2289 .iter()
2290 .map(|c| c["name"].as_str().unwrap())
2291 .collect();
2292 assert!(
2293 by_names.contains(&"orchestrate"),
2294 "orchestrate calls compute: {:?}",
2295 by_names
2296 );
2297 }
2298
2299 #[test]
2300 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
2301 let ctx = make_ctx();
2302 let path = fixture_path("calls.ts");
2303
2304 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
2305 let resp = handle_zoom(&req, &ctx);
2306 let json = serde_json::to_value(&resp).unwrap();
2307 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2308
2309 let calls_out = json["annotations"]["calls_out"]
2310 .as_array()
2311 .expect("calls_out array");
2312 let helper_refs = calls_out
2313 .iter()
2314 .filter(|call| call["name"] == "helper")
2315 .collect::<Vec<_>>();
2316 assert_eq!(
2317 helper_refs.len(),
2318 1,
2319 "helper should be folded once: {calls_out:?}"
2320 );
2321 assert_eq!(helper_refs[0]["extra_count"], 1);
2322 assert!(
2323 calls_out.iter().any(|call| call["name"] == "format"),
2324 "distinct callee must not be folded into helper: {calls_out:?}"
2325 );
2326
2327 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
2328 let resp = handle_zoom(&req, &ctx);
2329 let json = serde_json::to_value(&resp).unwrap();
2330 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2331
2332 let called_by = json["annotations"]["called_by"]
2333 .as_array()
2334 .expect("called_by array");
2335 let repeat_refs = called_by
2336 .iter()
2337 .filter(|call| call["name"] == "repeatCompute")
2338 .collect::<Vec<_>>();
2339 assert_eq!(
2340 repeat_refs.len(),
2341 1,
2342 "repeatCompute should be folded once: {called_by:?}"
2343 );
2344 assert_eq!(repeat_refs[0]["extra_count"], 1);
2345 assert!(
2346 called_by.iter().any(|call| call["name"] == "orchestrate"),
2347 "distinct caller must not be folded into repeatCompute: {called_by:?}"
2348 );
2349 }
2350
2351 #[test]
2352 fn zoom_response_empty_annotations_for_unused() {
2353 let ctx = make_ctx();
2354 let path = fixture_path("calls.ts");
2355
2356 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
2357 let resp = handle_zoom(&req, &ctx);
2358
2359 let json = serde_json::to_value(&resp).unwrap();
2360 assert_eq!(json["success"], true);
2361
2362 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
2363 let called_by = json["annotations"]["called_by"].as_array().unwrap();
2364
2365 assert!(
2368 called_by.is_empty(),
2369 "unused should not be called by anyone: {:?}",
2370 called_by
2371 );
2372 }
2373
2374 #[test]
2375 fn zoom_default_omits_callgraph_annotations() {
2376 let ctx = make_ctx();
2377 let path = fixture_path("calls.ts");
2378
2379 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
2380 let resp = handle_zoom(&req, &ctx);
2381
2382 let json = serde_json::to_value(&resp).unwrap();
2383 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2384
2385 let calls_out = json["annotations"]["calls_out"]
2386 .as_array()
2387 .expect("calls_out array");
2388 let called_by = json["annotations"]["called_by"]
2389 .as_array()
2390 .expect("called_by array");
2391 assert!(
2392 calls_out.is_empty(),
2393 "default zoom should omit calls_out: {:?}",
2394 calls_out
2395 );
2396 assert!(
2397 called_by.is_empty(),
2398 "default zoom should omit called_by: {:?}",
2399 called_by
2400 );
2401 }
2402
2403 #[test]
2404 fn zoom_symbol_not_found() {
2405 let ctx = make_ctx();
2406 let path = fixture_path("calls.ts");
2407
2408 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
2409 let resp = handle_zoom(&req, &ctx);
2410
2411 let json = serde_json::to_value(&resp).unwrap();
2412 assert_eq!(json["success"], false);
2413 assert_eq!(json["code"], "symbol_not_found");
2414 }
2415
2416 #[test]
2417 fn zoom_custom_context_lines() {
2418 let ctx = make_ctx();
2419 let path = fixture_path("calls.ts");
2420
2421 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
2422 let resp = handle_zoom(&req, &ctx);
2423
2424 let json = serde_json::to_value(&resp).unwrap();
2425 assert_eq!(json["success"], true);
2426
2427 let ctx_before = json["context_before"].as_array().unwrap();
2428 let ctx_after = json["context_after"].as_array().unwrap();
2429 assert!(
2431 ctx_before.len() <= 1,
2432 "context_before should be ≤1: {:?}",
2433 ctx_before
2434 );
2435 assert!(
2436 ctx_after.len() <= 1,
2437 "context_after should be ≤1: {:?}",
2438 ctx_after
2439 );
2440 }
2441
2442 #[test]
2443 fn zoom_missing_file_param() {
2444 let ctx = make_ctx();
2445 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
2446 let resp = handle_zoom(&req, &ctx);
2447
2448 let json = serde_json::to_value(&resp).unwrap();
2449 assert_eq!(json["success"], false);
2450 assert_eq!(json["code"], "invalid_request");
2451 }
2452
2453 #[test]
2454 fn zoom_missing_symbol_param() {
2455 let ctx = make_ctx();
2456 let path = fixture_path("calls.ts");
2457 let req_value = serde_json::json!({
2461 "id": "z-6",
2462 "command": "zoom",
2463 "file": path.to_string_lossy(),
2464 });
2465 let req_str = req_value.to_string();
2466 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
2467 let resp = handle_zoom(&req, &ctx);
2468
2469 let json = serde_json::to_value(&resp).unwrap();
2470 assert_eq!(json["success"], false);
2471 assert_eq!(json["code"], "invalid_request");
2472 }
2473
2474 #[test]
2475 fn test_suggest_close_symbols_unit() {
2476 let available = vec![
2477 "handle_grep_search".to_string(),
2478 "handle_semantic_search".to_string(),
2479 "handle_semantic_or_hybrid_search".to_string(),
2480 "compute_total".to_string(),
2481 "search".to_string(),
2482 "handle_search".to_string(),
2483 ];
2484
2485 let suggestions = suggest_close_symbols("handle_search", &available, 5);
2486 assert!(suggestions.contains(&"handle_grep_search".to_string()));
2487 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
2488 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
2489 assert!(suggestions.contains(&"search".to_string()));
2490 assert!(!suggestions.contains(&"compute_total".to_string()));
2491
2492 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
2493 assert_eq!(suggestions, suggestions_caps);
2494
2495 let available2 = vec![
2496 "total".to_string(),
2497 "compute_total".to_string(),
2498 "unrelated".to_string(),
2499 ];
2500 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
2501 assert_eq!(suggestions2, vec!["total".to_string()]);
2502 }
2503
2504 fn make_zoom_request(
2507 id: &str,
2508 file: &str,
2509 symbol: &str,
2510 context_lines: Option<u64>,
2511 ) -> RawRequest {
2512 let mut json = serde_json::json!({
2513 "id": id,
2514 "command": "zoom",
2515 "file": file,
2516 "symbol": symbol,
2517 });
2518 if let Some(cl) = context_lines {
2519 json["context_lines"] = serde_json::json!(cl);
2520 }
2521 serde_json::from_value(json).unwrap()
2522 }
2523
2524 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2525 let mut req = make_zoom_request(id, file, symbol, None);
2526 req.params["callgraph"] = serde_json::json!(true);
2527 req
2528 }
2529
2530 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2531 serde_json::from_str(json_str).unwrap()
2532 }
2533
2534 fn json_fixture_tree() -> (String, tree_sitter::Tree) {
2537 json_fixture_tree_named("nested.json")
2538 }
2539
2540 fn json_fixture_tree_named(name: &str) -> (String, tree_sitter::Tree) {
2541 let source = std::fs::read_to_string(fixture_path(name)).unwrap();
2542 let mut parser = FileParser::new();
2543 let path = fixture_path(name);
2544 let (tree, _) = parser.parse(&path).unwrap();
2545 (source, tree.clone())
2546 }
2547
2548 fn assert_json_zoom_resolves(fixture: &str, query: &str, expected_fragment: &str) {
2549 let ctx = make_ctx();
2550 let path = fixture_path(fixture);
2551 let req = make_zoom_request("json-regression", path.to_str().unwrap(), query, None);
2552 let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2553 assert_eq!(json["success"], true, "JSON zoom should succeed: {json}");
2554 assert_eq!(json["name"], query);
2555 assert!(
2556 json["content"]
2557 .as_str()
2558 .unwrap_or_default()
2559 .contains(expected_fragment),
2560 "JSON zoom content should contain {expected_fragment:?}: {json}"
2561 );
2562 }
2563
2564 #[test]
2565 fn json_path_resolves_nested_object() {
2566 let (source, tree) = json_fixture_tree();
2567 let resolved = json_path_resolve(
2568 &source,
2569 &tree.root_node(),
2570 "registration_profile_manifest.nested.deep",
2571 )
2572 .expect("path should resolve");
2573 assert_eq!(resolved.path, "registration_profile_manifest.nested.deep");
2574 assert_eq!(node_text(&source, &resolved.node).trim(), "\"value\"");
2575 }
2576
2577 #[test]
2578 fn json_zoom_resolves_leading_line_comments() {
2579 assert_json_zoom_resolves(
2580 "zoom_jsonc_leading_line_comments.jsonc",
2581 "chains",
2582 "\"executor\"",
2583 );
2584 }
2585
2586 #[test]
2587 fn json_zoom_resolves_leading_block_comment() {
2588 assert_json_zoom_resolves(
2589 "zoom_jsonc_leading_block_comment.jsonc",
2590 "chains",
2591 "\"executor\"",
2592 );
2593 }
2594
2595 #[test]
2596 fn json_zoom_resolves_blank_lines_before_document() {
2597 assert_json_zoom_resolves("zoom_json_blank_lines.json", "chains", "\"executor\"");
2598 }
2599
2600 #[test]
2601 fn json_zoom_resolves_comments_inside_object() {
2602 assert_json_zoom_resolves("zoom_json_comments_inside.jsonc", "chains", "\"executor\"");
2603 }
2604
2605 #[test]
2606 fn json_zoom_leading_multibyte_comment_keeps_path_and_value_offsets() {
2607 assert_json_zoom_resolves(
2608 "zoom_jsonc_leading_line_comments.jsonc",
2609 "chains.executor.entries[1].model",
2610 "\"large\"",
2611 );
2612 }
2613
2614 #[test]
2615 fn json_zoom_miss_reports_actual_deepest_prefix_and_segment() {
2616 let ctx = make_ctx();
2617 let path = fixture_path("zoom_json_miss_locus.json");
2618 let query = "agent.general.model";
2619 let req = make_zoom_request("json-miss", path.to_str().unwrap(), query, None);
2620 let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2621
2622 assert_eq!(json["success"], false);
2623 assert_eq!(
2624 json["message"],
2625 "symbol 'agent.general.model' not found: resolved `agent`, no key `general` — nearest: [general_settings]"
2626 );
2627 }
2628
2629 #[test]
2630 fn json_path_resolves_array_index() {
2631 let (source, tree) = json_fixture_tree();
2632 let resolved = json_path_resolve(&source, &tree.root_node(), "servers[0]")
2633 .expect("path should resolve");
2634 assert_eq!(resolved.path, "servers[0]");
2635 assert!(node_text(&source, &resolved.node).contains("primary"));
2636 }
2637
2638 #[test]
2639 fn json_path_resolves_chained_array_index() {
2640 let (source, tree) = json_fixture_tree();
2641 let resolved =
2642 json_path_resolve(&source, &tree.root_node(), "a.b[1].c").expect("path should resolve");
2643 assert_eq!(resolved.path, "a.b[1].c");
2644 assert_eq!(node_text(&source, &resolved.node).trim(), "\"second\"");
2645 }
2646
2647 #[test]
2648 fn json_path_resolves_bare_array_index() {
2649 let (source, tree) = json_fixture_tree();
2650 let resolved = json_path_resolve(&source, &tree.root_node(), "servers[1].name")
2651 .expect("path should resolve");
2652 assert_eq!(resolved.path, "servers[1].name");
2653 assert_eq!(node_text(&source, &resolved.node).trim(), "\"backup\"");
2654 }
2655
2656 #[test]
2657 fn json_path_miss_returns_none() {
2658 let (source, tree) = json_fixture_tree();
2659 assert!(json_path_resolve(
2660 &source,
2661 &tree.root_node(),
2662 "registration_profile_manifest.host_only_allowlis"
2663 )
2664 .is_none());
2665 assert!(json_path_resolve(&source, &tree.root_node(), "servers[9]").is_none());
2666 assert!(json_path_resolve(&source, &tree.root_node(), "missing").is_none());
2667 }
2668
2669 #[test]
2670 fn json_path_resolves_dotted_query_as_path() {
2671 let (source, tree) = json_fixture_tree();
2675 let resolved = json_path_resolve(&source, &tree.root_node(), "literal.dotted.key")
2676 .expect("path should resolve");
2677 assert_eq!(node_text(&source, &resolved.node).trim(), "\"path-value\"");
2678 }
2679
2680 #[test]
2681 fn json_miss_details_reports_deepest_prefix() {
2682 let (source, tree) = json_fixture_tree();
2683 let (prefix, failing) = json_miss_details(
2684 &source,
2685 &tree.root_node(),
2686 "registration_profile_manifest.host_only_allowlis",
2687 );
2688 assert_eq!(prefix, "registration_profile_manifest");
2689 assert_eq!(failing, "host_only_allowlis");
2690 }
2691
2692 #[test]
2693 fn json_miss_details_single_segment() {
2694 let (source, tree) = json_fixture_tree();
2695 let (prefix, failing) = json_miss_details(&source, &tree.root_node(), "missing");
2696 assert_eq!(prefix, "");
2697 assert_eq!(failing, "missing");
2698 }
2699
2700 #[test]
2701 fn split_json_path_keeps_bracket_groups() {
2702 assert_eq!(split_json_path("a.b[0].c"), vec!["a", "b[0]", "c"]);
2703 assert_eq!(split_json_path("servers[0]"), vec!["servers[0]"]);
2704 assert_eq!(split_json_path("a.b.c"), vec!["a", "b", "c"]);
2705 }
2706
2707 #[test]
2708 fn parse_json_segment_handles_key_and_index() {
2709 assert_eq!(parse_json_segment("servers[0]"), (Some("servers"), Some(0)));
2710 assert_eq!(parse_json_segment("[0]"), (None, Some(0)));
2711 assert_eq!(parse_json_segment("host"), (Some("host"), None));
2712 }
2713}