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<&str> = source.lines().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<&str> = source.lines().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: &[&str],
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: &[&str],
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_source = if resolved_file_path != path {
675 std::fs::read_to_string(resolved_file_path).ok()
676 } else {
677 None
678 };
679 let resolved_lines = resolved_source
680 .as_deref()
681 .map(|source| source.lines().collect::<Vec<_>>());
682 let effective_lines = resolved_lines.as_deref().unwrap_or(lines);
683
684 let content = if end < effective_lines.len() {
686 effective_lines[start..=end].join("\n")
687 } else {
688 effective_lines[start..].join("\n")
689 };
690
691 let resolved_lang = detect_language(resolved_file_path);
692 let container_outline = if might_have_container_members(target) {
693 match build_container_outline(ctx, resolved_file_path, target) {
694 Ok(outline) => Some(outline),
695 Err(e) => {
696 return Response::error(&req.id, e.code(), e.to_string());
697 }
698 }
699 } else {
700 None
701 };
702
703 if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
704 let kind_str = symbol_kind_string(&target.kind);
705 let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
706 let resp = ZoomResponse {
707 name: target.name.clone(),
708 kind: kind_str,
709 range: target.range.clone(),
710 content: menu,
711 context_before: Vec::new(),
712 context_after: Vec::new(),
713 annotations: Annotations {
714 calls_out: Vec::new(),
715 called_by: Vec::new(),
716 },
717 };
718 return match serde_json::to_value(&resp) {
719 Ok(resp_json) => Response::success(&req.id, resp_json),
720 Err(err) => Response::error(
721 &req.id,
722 "internal_error",
723 format!("zoom: failed to serialize response: {err}"),
724 ),
725 };
726 }
727
728 let ctx_start = start.saturating_sub(context_lines);
730 let context_before: Vec<String> = if ctx_start < start {
731 effective_lines[ctx_start..start]
732 .iter()
733 .map(|l| l.to_string())
734 .collect()
735 } else {
736 vec![]
737 };
738
739 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
741 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
742 effective_lines[(end + 1)..ctx_end]
743 .iter()
744 .map(|l| l.to_string())
745 .collect()
746 } else {
747 vec![]
748 };
749
750 let (calls_out, called_by) = if include_callgraph {
751 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
753 Ok(s) => s,
754 Err(e) => {
755 return Response::error(&req.id, e.code(), e.to_string());
756 }
757 };
758
759 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
760
761 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
763 let (tree, lang) = match parser.parse(resolved_file_path) {
764 Ok(r) => r,
765 Err(e) => {
766 return Response::error(&req.id, e.code(), e.to_string());
767 }
768 };
769
770 let resolved_source = if resolved_file_path != path {
772 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
773 } else {
774 source.to_string()
775 };
776 let signature_byte_start = line_col_to_byte(
777 &resolved_source,
778 target.range.start_line,
779 target.range.start_col,
780 );
781 let signature_byte_end = line_col_to_byte(
782 &resolved_source,
783 target.range.end_line,
784 target.range.end_col,
785 );
786 let (target_byte_start, target_byte_end) =
787 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
788 .unwrap_or((signature_byte_start, signature_byte_end));
789
790 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
791
792 let raw_calls = all_file_calls.iter().filter(|call| {
793 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
794 });
795 let calls_out = dedupe_call_refs_by_name(
796 raw_calls
797 .filter(|call| {
798 known_names.contains(&call.name.as_str()) && call.name != target.name
799 })
800 .map(|call| CallRef {
801 name: call.name.clone(),
802 line: call.line,
803 extra_count: 0,
804 })
805 .collect(),
806 );
807
808 let mut called_by: Vec<CallRef> = Vec::new();
810 for sym in &all_symbols {
811 if sym.name == target.name && sym.range.start_line == target.range.start_line {
812 continue; }
814 let sym_byte_start =
815 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
816 let sym_byte_end =
817 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
818 for call in &all_file_calls {
819 if call.name == target.name
820 && call.start_byte >= sym_byte_start
821 && call.end_byte <= sym_byte_end
822 {
823 called_by.push(CallRef {
824 name: sym.name.clone(),
825 line: call.line,
826 extra_count: 0,
827 });
828 }
829 }
830 }
831
832 let called_by = dedupe_call_refs_by_name(called_by);
833
834 (calls_out, called_by)
835 } else {
836 (Vec::new(), Vec::new())
837 };
838
839 let kind_str = symbol_kind_string(&target.kind);
840
841 let resp = ZoomResponse {
842 name: target.name.clone(),
843 kind: kind_str,
844 range: target.range.clone(),
845 content,
846 context_before,
847 context_after,
848 annotations: Annotations {
849 calls_out,
850 called_by,
851 },
852 };
853
854 match serde_json::to_value(&resp) {
855 Ok(resp_json) => Response::success(&req.id, resp_json),
856 Err(err) => Response::error(
857 &req.id,
858 "internal_error",
859 format!("zoom: failed to serialize response: {err}"),
860 ),
861 }
862}
863
864fn empty_annotations() -> serde_json::Value {
865 serde_json::json!({
866 "calls_out": [],
867 "called_by": [],
868 })
869}
870
871fn render_ambiguous_symbol_menu(
872 symbol_name: &str,
873 matches: &[crate::symbols::SymbolMatch],
874) -> String {
875 let mut lines = vec![format!(
876 "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
877 matches.len()
878 )];
879
880 for candidate in matches {
881 let entry = symbol_to_entry(&candidate.symbol);
882 lines.push(format!(
883 "- {}",
884 format_qualified_entry(&entry, Some(&candidate.symbol))
885 ));
886 }
887
888 lines.join("\n")
889}
890
891fn levenshtein_distance(s1: &str, s2: &str) -> usize {
892 let s1_chars: Vec<char> = s1.chars().collect();
893 let s2_chars: Vec<char> = s2.chars().collect();
894 let len1 = s1_chars.len();
895 let len2 = s2_chars.len();
896
897 let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
898
899 for i in 0..=len1 {
900 dp[i][0] = i;
901 }
902 for j in 0..=len2 {
903 dp[0][j] = j;
904 }
905
906 for i in 1..=len1 {
907 for j in 1..=len2 {
908 if s1_chars[i - 1] == s2_chars[j - 1] {
909 dp[i][j] = dp[i - 1][j - 1];
910 } else {
911 dp[i][j] =
912 1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
913 }
914 }
915 }
916
917 dp[len1][len2]
918}
919
920fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
921 let mut unique: Vec<&String> = available.iter().collect();
922 unique.sort();
923 unique.dedup();
924
925 let query_lower = query.to_lowercase();
926 let query_len = query_lower.chars().count();
927 let max_dist = std::cmp::max(2, query_len / 3);
928
929 let mut scored: Vec<(bool, usize, &String)> = unique
930 .into_iter()
931 .map(|name| {
932 let name_lower = name.to_lowercase();
933 let is_substring =
934 name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
935 let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
936 (query_lower.find('_'), query_lower.rfind('_'))
937 {
938 let prefix = &query_lower[..=first_idx];
939 let suffix = &query_lower[last_idx..];
940 name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
941 } else {
942 false
943 };
944 let is_match = is_substring || is_wildcard;
945 let dist = levenshtein_distance(&query_lower, &name_lower);
946 (is_match, dist, name)
947 })
948 .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
949 .collect();
950
951 scored.sort_by(|a, b| {
952 let a_match = a.0;
953 let b_match = b.0;
954 (!a_match)
955 .cmp(&(!b_match))
956 .then_with(|| a.1.cmp(&b.1))
957 .then_with(|| a.2.cmp(b.2))
958 });
959
960 scored
961 .into_iter()
962 .take(k)
963 .map(|(_, _, name)| name.clone())
964 .collect()
965}
966
967fn resolve_zoom_symbol(
968 provider: &dyn LanguageProvider,
969 path: &Path,
970 query: &str,
971 is_heading: bool,
972) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
973 if is_heading {
974 return resolve_heading_symbols(provider, path, query);
975 }
976
977 match provider.resolve_symbol(path, query) {
978 Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
979 result => result,
980 }
981}
982
983struct JsonNode<'a> {
986 node: tree_sitter::Node<'a>,
987 path: String,
988}
989
990struct JsonPathMiss {
991 prefix: String,
992 failing: String,
993}
994
995fn resolve_json_zoom(
1010 req: &RawRequest,
1011 ctx: &AppContext,
1012 path: &Path,
1013 source: &str,
1014 lines: &[&str],
1015 symbol_name: &str,
1016 context_lines: usize,
1017 include_callgraph: bool,
1018) -> Response {
1019 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1020 let (tree, _) = match parser.parse(path) {
1021 Ok(parsed) => parsed,
1022 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1023 };
1024 let root = tree.root_node();
1025
1026 let literal = match ctx.provider().resolve_symbol(path, symbol_name) {
1028 Ok(matches) => matches,
1029 Err(crate::error::AftError::SymbolNotFound { .. }) => Vec::new(),
1030 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1031 };
1032
1033 let path_result = json_path_resolve(source, &root, symbol_name);
1035
1036 if !literal.is_empty() {
1042 if let Some(path_node) = path_result.as_ref() {
1043 let literal_value_node = json_document_value(&root)
1044 .and_then(|object| json_object_value(source, object, symbol_name));
1045 let same_node = literal_value_node
1046 .map(|node| {
1047 node.start_position().row == path_node.node.start_position().row
1048 && node.start_position().column == path_node.node.start_position().column
1049 && node.end_position().row == path_node.node.end_position().row
1050 && node.end_position().column == path_node.node.end_position().column
1051 })
1052 .unwrap_or(false);
1053 if !same_node {
1054 let literal_node = &literal[0].symbol;
1055 let candidates = vec![
1056 serde_json::json!({
1057 "name": symbol_name,
1058 "kind": symbol_kind_string(&literal_node.kind),
1059 "range": literal_node.range.clone(),
1060 "signature": literal_node.signature.clone(),
1061 }),
1062 serde_json::json!({
1063 "name": path_node.path.clone(),
1064 "kind": "json_path",
1065 "range": node_range(&path_node.node),
1066 "signature": serde_json::Value::Null,
1067 }),
1068 ];
1069 return Response::error_with_data(
1070 &req.id,
1071 "ambiguous_match",
1072 format!(
1073 "symbol '{}' is ambiguous: a literal key and a JSON path both resolve to different nodes",
1074 symbol_name
1075 ),
1076 serde_json::json!({ "candidates": candidates }),
1077 );
1078 }
1079 }
1080 }
1081
1082 if !literal.is_empty() {
1084 return render_json_zoom(
1085 req,
1086 ctx,
1087 path,
1088 source,
1089 lines,
1090 symbol_name,
1091 &literal[0].symbol,
1092 context_lines,
1093 include_callgraph,
1094 );
1095 }
1096
1097 if let Some(resolved) = path_result {
1099 return render_json_zoom(
1100 req,
1101 ctx,
1102 path,
1103 source,
1104 lines,
1105 &resolved.path,
1106 &json_node_to_symbol(&resolved.node, &resolved.path),
1107 context_lines,
1108 include_callgraph,
1109 );
1110 }
1111
1112 let (prefix, failing) = json_miss_details(source, &root, symbol_name);
1115 let mut msg = if prefix.is_empty() {
1116 format!("symbol '{}' not found: no key `{}`", symbol_name, failing)
1117 } else {
1118 format!(
1119 "symbol '{}' not found: resolved `{}`, no key `{}`",
1120 symbol_name, prefix, failing
1121 )
1122 };
1123 let sibling_keys = json_sibling_keys(source, &root, &prefix);
1124 if !sibling_keys.is_empty() {
1125 let suggestions = suggest_close_symbols(&failing, &sibling_keys, 5);
1126 if !suggestions.is_empty() {
1127 msg.push_str(&format!(" — nearest: [{}]", suggestions.join(", ")));
1128 }
1129 }
1130 Response::error(&req.id, "symbol_not_found", msg)
1131}
1132
1133fn json_path_resolve<'a>(
1139 source: &str,
1140 root: &tree_sitter::Node<'a>,
1141 query: &str,
1142) -> Option<JsonNode<'a>> {
1143 json_path_lookup(source, root, query).ok()
1144}
1145
1146fn json_path_lookup<'a>(
1151 source: &str,
1152 root: &tree_sitter::Node<'a>,
1153 query: &str,
1154) -> Result<JsonNode<'a>, JsonPathMiss> {
1155 let segments = split_json_path(query);
1156 let Some(first_segment) = segments.first() else {
1157 return Err(JsonPathMiss {
1158 prefix: String::new(),
1159 failing: query.to_string(),
1160 });
1161 };
1162
1163 let Some(mut current) = json_document_value(root) else {
1167 return Err(JsonPathMiss {
1168 prefix: String::new(),
1169 failing: first_segment.clone(),
1170 });
1171 };
1172 if current.kind() != "object" {
1173 return Err(JsonPathMiss {
1174 prefix: String::new(),
1175 failing: first_segment.clone(),
1176 });
1177 }
1178
1179 let mut resolved_path = String::new();
1180 for segment in &segments {
1181 let (key, array_index) = parse_json_segment(segment);
1182 let next = if let Some(array_index) = array_index {
1183 let array = match key {
1186 Some(key) => json_object_value(source, current, key),
1187 None => Some(current),
1188 };
1189 array.and_then(|array| json_array_element(array, array_index))
1190 } else {
1191 key.and_then(|key| json_object_value(source, current, key))
1192 };
1193 let Some(next) = next else {
1194 return Err(JsonPathMiss {
1195 prefix: resolved_path,
1196 failing: segment.clone(),
1197 });
1198 };
1199
1200 if resolved_path.is_empty() {
1201 resolved_path = segment.clone();
1202 } else {
1203 resolved_path.push('.');
1204 resolved_path.push_str(segment);
1205 }
1206 current = next;
1207 }
1208
1209 Ok(JsonNode {
1210 node: current,
1211 path: resolved_path,
1212 })
1213}
1214
1215fn split_json_path(query: &str) -> Vec<String> {
1217 let mut segments = Vec::new();
1218 let mut current = String::new();
1219 let mut depth = 0usize;
1220 for character in query.chars() {
1221 match character {
1222 '[' => {
1223 depth += 1;
1224 current.push(character);
1225 }
1226 ']' => {
1227 depth = depth.saturating_sub(1);
1228 current.push(character);
1229 }
1230 '.' if depth == 0 => {
1231 if !current.is_empty() {
1232 segments.push(std::mem::take(&mut current));
1233 }
1234 }
1235 _ => current.push(character),
1236 }
1237 }
1238 if !current.is_empty() {
1239 segments.push(current);
1240 }
1241 segments
1242}
1243
1244fn parse_json_segment(segment: &str) -> (Option<&str>, Option<usize>) {
1249 if let Some(open) = segment.find('[') {
1250 if segment.ends_with(']') {
1251 let key = if open == 0 {
1252 None
1253 } else {
1254 Some(&segment[..open])
1255 };
1256 let index_text = &segment[open + 1..segment.len() - 1];
1257 if let Ok(index) = index_text.parse::<usize>() {
1258 return (key, Some(index));
1259 }
1260 }
1261 }
1262 (Some(segment), None)
1263}
1264
1265fn json_object_value<'a>(
1267 source: &str,
1268 object: tree_sitter::Node<'a>,
1269 key: &str,
1270) -> Option<tree_sitter::Node<'a>> {
1271 if object.kind() != "object" {
1272 return None;
1273 }
1274 let mut cursor = object.walk();
1275 for pair in object.named_children(&mut cursor) {
1276 if pair.kind() != "pair" {
1277 continue;
1278 }
1279 let Some(key_node) = pair.child_by_field_name("key") else {
1280 continue;
1281 };
1282 if node_text(source, &key_node).trim_matches('"') == key {
1283 return pair.child_by_field_name("value");
1284 }
1285 }
1286 None
1287}
1288
1289fn json_array_element<'a>(
1291 array: tree_sitter::Node<'a>,
1292 index: usize,
1293) -> Option<tree_sitter::Node<'a>> {
1294 if array.kind() != "array" {
1295 return None;
1296 }
1297 let mut cursor = array.walk();
1298 for (seen, element) in array.named_children(&mut cursor).enumerate() {
1299 if seen == index {
1300 return Some(element);
1301 }
1302 }
1303 None
1304}
1305
1306fn json_node_to_symbol(node: &tree_sitter::Node, path: &str) -> Symbol {
1308 Symbol {
1309 name: path.to_string(),
1310 kind: SymbolKind::Variable,
1311 range: node_range(node),
1312 signature: None,
1313 scope_chain: vec![],
1314 exported: false,
1315 parent: None,
1316 }
1317}
1318
1319fn render_json_zoom(
1321 req: &RawRequest,
1322 ctx: &AppContext,
1323 path: &Path,
1324 source: &str,
1325 lines: &[&str],
1326 name: &str,
1327 target: &Symbol,
1328 context_lines: usize,
1329 include_callgraph: bool,
1330) -> Response {
1331 let start = target.range.start_line as usize;
1332 let end = target.range.end_line as usize;
1333
1334 let content = if end < lines.len() {
1335 lines[start..=end].join("\n")
1336 } else {
1337 lines[start..].join("\n")
1338 };
1339
1340 let ctx_start = start.saturating_sub(context_lines);
1341 let context_before: Vec<String> = if ctx_start < start {
1342 lines[ctx_start..start]
1343 .iter()
1344 .map(|line| (*line).to_string())
1345 .collect()
1346 } else {
1347 vec![]
1348 };
1349 let ctx_end = (end + 1 + context_lines).min(lines.len());
1350 let context_after: Vec<String> = if end + 1 < lines.len() {
1351 lines[(end + 1)..ctx_end]
1352 .iter()
1353 .map(|line| (*line).to_string())
1354 .collect()
1355 } else {
1356 vec![]
1357 };
1358
1359 let (calls_out, called_by) = if include_callgraph {
1360 let all_symbols = match ctx.provider().list_symbols(path) {
1361 Ok(s) => s,
1362 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1363 };
1364 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
1365 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
1366 let (tree, lang) = match parser.parse(path) {
1367 Ok(r) => r,
1368 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
1369 };
1370 let all_file_calls = extract_calls_with_ranges(source, tree.root_node(), lang);
1371 let signature_byte_start =
1372 line_col_to_byte(source, target.range.start_line, target.range.start_col);
1373 let signature_byte_end =
1374 line_col_to_byte(source, target.range.end_line, target.range.end_col);
1375 let (target_byte_start, target_byte_end) =
1376 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
1377 .unwrap_or((signature_byte_start, signature_byte_end));
1378 let calls_out = dedupe_call_refs_by_name(
1379 all_file_calls
1380 .iter()
1381 .filter(|call| {
1382 call.start_byte >= target_byte_start
1383 && call.end_byte <= target_byte_end
1384 && known_names.contains(&call.name.as_str())
1385 && call.name != target.name
1386 })
1387 .map(|call| CallRef {
1388 name: call.name.clone(),
1389 line: call.line,
1390 extra_count: 0,
1391 })
1392 .collect(),
1393 );
1394 (calls_out, Vec::new())
1395 } else {
1396 (Vec::new(), Vec::new())
1397 };
1398
1399 let resp = ZoomResponse {
1400 name: name.to_string(),
1401 kind: symbol_kind_string(&target.kind),
1402 range: target.range.clone(),
1403 content,
1404 context_before,
1405 context_after,
1406 annotations: Annotations {
1407 calls_out,
1408 called_by,
1409 },
1410 };
1411
1412 match serde_json::to_value(&resp) {
1413 Ok(resp_json) => Response::success(&req.id, resp_json),
1414 Err(err) => Response::error(
1415 &req.id,
1416 "internal_error",
1417 format!("zoom: failed to serialize response: {err}"),
1418 ),
1419 }
1420}
1421
1422fn json_miss_details(source: &str, root: &tree_sitter::Node, query: &str) -> (String, String) {
1428 match json_path_lookup(source, root, query) {
1429 Err(miss) => (miss.prefix, miss.failing),
1430 Ok(_) => (String::new(), String::new()),
1431 }
1432}
1433
1434fn json_sibling_keys(source: &str, root: &tree_sitter::Node, prefix: &str) -> Vec<String> {
1439 let object = if prefix.is_empty() {
1440 json_document_value(root)
1441 } else {
1442 json_path_resolve(source, root, prefix).map(|resolved| resolved.node)
1443 };
1444 let Some(object) = object else {
1445 return Vec::new();
1446 };
1447 if object.kind() != "object" {
1448 return Vec::new();
1449 }
1450 let mut keys = Vec::new();
1451 let mut cursor = object.walk();
1452 for pair in object.named_children(&mut cursor) {
1453 if pair.kind() != "pair" {
1454 continue;
1455 }
1456 if let Some(key_node) = pair.child_by_field_name("key") {
1457 let key = node_text(source, &key_node).trim_matches('"').to_string();
1458 if !key.is_empty() {
1459 keys.push(key);
1460 }
1461 }
1462 }
1463 keys
1464}
1465
1466fn node_range(node: &tree_sitter::Node) -> Range {
1468 let start = node.start_position();
1469 let end = node.end_position();
1470 Range {
1471 start_line: start.row as u32,
1472 start_col: start.column as u32,
1473 end_line: end.row as u32,
1474 end_col: end.column as u32,
1475 }
1476}
1477
1478fn resolve_heading_symbols(
1481 provider: &dyn LanguageProvider,
1482 path: &Path,
1483 query: &str,
1484) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
1485 let headings: Vec<SymbolMatch> = provider
1486 .list_symbols(path)?
1487 .into_iter()
1488 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1489 .map(|symbol| SymbolMatch {
1490 file: path.display().to_string(),
1491 symbol,
1492 })
1493 .collect();
1494
1495 let anchors = if query.starts_with('#') {
1496 provider.heading_anchors(path)?
1497 } else {
1498 Vec::new()
1499 };
1500
1501 Ok(match_heading_identity(&headings, query, &anchors))
1502}
1503
1504fn match_heading_identity(
1505 headings: &[SymbolMatch],
1506 query: &str,
1507 anchors: &[HeadingAnchor],
1508) -> Vec<SymbolMatch> {
1509 if let Some(anchor_query) = query.strip_prefix('#') {
1510 let mut matches: Vec<_> = headings
1511 .iter()
1512 .filter(|candidate| {
1513 anchors.iter().any(|anchor| {
1514 anchor.id == anchor_query
1515 && anchor.start_line == candidate.symbol.range.start_line
1516 && anchor.start_col == candidate.symbol.range.start_col
1517 })
1518 })
1519 .cloned()
1520 .collect();
1521
1522 let normalized_query = normalize_heading_label(query);
1523 let query_slug = slugify_heading_label(&normalized_query);
1524 if !query_slug.is_empty() {
1525 for candidate in headings
1526 .iter()
1527 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1528 {
1529 if !matches.iter().any(|existing| {
1530 existing.file == candidate.file && existing.symbol == candidate.symbol
1531 }) {
1532 matches.push(candidate.clone());
1533 }
1534 }
1535 }
1536 return matches;
1537 }
1538
1539 let exact: Vec<_> = headings
1540 .iter()
1541 .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1542 .cloned()
1543 .collect();
1544 if !exact.is_empty() {
1545 return exact;
1546 }
1547
1548 let normalized_query = normalize_heading_label(query);
1549 if normalized_query.is_empty() {
1550 return Vec::new();
1551 }
1552
1553 let normalized: Vec<_> = headings
1554 .iter()
1555 .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1556 .cloned()
1557 .collect();
1558 if !normalized.is_empty() {
1559 return normalized;
1560 }
1561
1562 let folded_query = normalized_query.to_lowercase();
1563 let case_insensitive: Vec<_> = headings
1564 .iter()
1565 .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1566 .cloned()
1567 .collect();
1568 if !case_insensitive.is_empty() {
1569 return case_insensitive;
1570 }
1571
1572 let query_slug = slugify_heading_label(&normalized_query);
1573 if query_slug.is_empty() {
1574 return Vec::new();
1575 }
1576
1577 headings
1578 .iter()
1579 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1580 .cloned()
1581 .collect()
1582}
1583
1584fn qualified_heading_name(symbol: &Symbol) -> String {
1585 if symbol.scope_chain.is_empty() {
1586 return symbol.name.clone();
1587 }
1588 format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1589}
1590
1591fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1592 symbol.name == query || qualified_heading_name(symbol) == query
1593}
1594
1595fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1596 normalize_heading_label(&symbol.name) == query
1597 || normalize_heading_label(&qualified_heading_name(symbol)) == query
1598}
1599
1600fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1601 normalize_heading_label(&symbol.name).to_lowercase() == query
1602 || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1603}
1604
1605fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1606 slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1607 || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1608 == query_slug
1609}
1610
1611fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1612 let available: Vec<String> = symbols
1613 .iter()
1614 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1615 .map(|symbol| normalize_heading_label(&symbol.name))
1616 .filter(|name| !name.is_empty())
1617 .collect();
1618 let normalized_query = normalize_heading_label(query);
1619 if normalized_query.is_empty() {
1620 return Vec::new();
1621 }
1622 suggest_close_symbols(&normalized_query, &available, k)
1623}
1624
1625fn normalize_heading_label(input: &str) -> String {
1626 let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1627
1628 for _ in 0..4 {
1631 let mut next = value.as_str();
1632 let without_heading_markers = next.trim_start_matches('#').trim_start();
1633 if without_heading_markers != next {
1634 next = without_heading_markers;
1635 }
1636 if let Some(rest) = strip_heading_html_prefix(next) {
1637 next = rest;
1638 }
1639 if let Some(rest) = strip_leading_section_prefix(next) {
1640 next = rest;
1641 }
1642 if let Some(rest) = strip_leading_symbol_cluster(next) {
1643 next = rest;
1644 }
1645
1646 let collapsed = collapse_heading_whitespace(next);
1647 if collapsed == value {
1648 break;
1649 }
1650 value = collapsed;
1651 }
1652
1653 value
1654}
1655
1656fn collapse_heading_whitespace(input: &str) -> String {
1657 input.split_whitespace().collect::<Vec<_>>().join(" ")
1658}
1659
1660fn strip_markdown_links(input: &str) -> String {
1661 let mut output = String::with_capacity(input.len());
1662 let mut cursor = 0;
1663
1664 while let Some(relative_open) = input[cursor..].find('[') {
1665 let open = cursor + relative_open;
1666 let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1667 output.push_str(&input[cursor..]);
1668 return output;
1669 };
1670
1671 if input.as_bytes().get(close + 1) != Some(&b'(') {
1672 output.push_str(&input[cursor..=close]);
1673 cursor = close + 1;
1674 continue;
1675 }
1676
1677 let target_open = close + 1;
1678 let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1679 output.push_str(&input[cursor..]);
1680 return output;
1681 };
1682
1683 output.push_str(&input[cursor..open]);
1684 output.push_str(&input[open + 1..close]);
1685 cursor = target_close + 1;
1686 }
1687
1688 output.push_str(&input[cursor..]);
1689 output
1690}
1691
1692fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1693 let mut depth = 0;
1694 for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1695 if *byte == open {
1696 depth += 1;
1697 } else if *byte == close {
1698 depth -= 1;
1699 if depth == 0 {
1700 return Some(index);
1701 }
1702 }
1703 }
1704 None
1705}
1706
1707fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1708 let input = input.trim_start();
1709 let bytes = input.as_bytes();
1710 if bytes.first() != Some(&b'<') {
1711 return None;
1712 }
1713
1714 let mut index = 1;
1715 if bytes.get(index) == Some(&b'/') {
1716 index += 1;
1717 }
1718 if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1719 return None;
1720 }
1721 index += 1;
1722 if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1723 return None;
1724 }
1725
1726 let end = input.find('>')?;
1727 let rest = input[end + 1..].trim_start();
1728 if rest.chars().any(|character| character.is_alphanumeric()) {
1729 Some(rest)
1730 } else {
1731 None
1732 }
1733}
1734
1735fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1736 let bytes = input.as_bytes();
1737 let mut index = 0;
1738 let mut saw_dot = false;
1739
1740 if !bytes
1741 .first()
1742 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1743 {
1744 return None;
1745 }
1746
1747 while index < bytes.len() {
1748 while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1749 index += 1;
1750 }
1751 if bytes.get(index) != Some(&b'.') {
1752 break;
1753 }
1754 saw_dot = true;
1755 index += 1;
1756 if bytes
1757 .get(index)
1758 .is_some_and(|byte| byte.is_ascii_whitespace())
1759 {
1760 let rest = input[index..].trim_start();
1761 return if rest.chars().any(|character| character.is_alphanumeric()) {
1762 Some(rest)
1763 } else {
1764 None
1765 };
1766 }
1767 if !bytes
1768 .get(index)
1769 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1770 {
1771 return None;
1772 }
1773 }
1774
1775 if saw_dot
1776 && bytes
1777 .get(index)
1778 .is_some_and(|byte| byte.is_ascii_whitespace())
1779 {
1780 let rest = input[index..].trim_start();
1781 if rest.chars().any(|character| character.is_alphanumeric()) {
1782 return Some(rest);
1783 }
1784 }
1785 None
1786}
1787
1788fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1789 let first_text = input
1790 .char_indices()
1791 .find(|(_, character)| character.is_alphanumeric())
1792 .map(|(index, _)| index)?;
1793 if first_text == 0 {
1794 return None;
1795 }
1796
1797 let rest = &input[first_text..];
1798 if rest.chars().any(|character| character.is_alphanumeric()) {
1799 Some(rest)
1800 } else {
1801 None
1802 }
1803}
1804
1805fn slugify_heading_label(label: &str) -> String {
1806 let mut slug = String::new();
1807 let mut pending_separator = false;
1808
1809 for character in label.chars() {
1810 if character.is_alphanumeric() {
1811 if pending_separator && !slug.is_empty() {
1812 slug.push('-');
1813 }
1814 for lowercase in character.to_lowercase() {
1815 slug.push(lowercase);
1816 }
1817 pending_separator = false;
1818 } else if !slug.is_empty() {
1819 pending_separator = true;
1820 }
1821 }
1822
1823 slug
1824}
1825
1826#[cfg(test)]
1830fn extract_calls_in_range(
1831 source: &str,
1832 root: tree_sitter::Node,
1833 byte_start: usize,
1834 byte_end: usize,
1835 lang: LangId,
1836) -> Vec<(String, u32)> {
1837 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1838}
1839
1840fn symbol_body_byte_range(
1841 root: tree_sitter::Node,
1842 byte_start: usize,
1843 byte_end: usize,
1844) -> Option<(usize, usize)> {
1845 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1846 let mut current = Some(node);
1847 while let Some(node) = current {
1848 if is_symbol_body_node(node.kind()) {
1849 return Some((node.start_byte(), node.end_byte()));
1850 }
1851 current = node.parent();
1852 }
1853 Some((node.start_byte(), node.end_byte()))
1854}
1855
1856fn smallest_node_covering_range<'tree>(
1857 node: tree_sitter::Node<'tree>,
1858 byte_start: usize,
1859 byte_end: usize,
1860) -> Option<tree_sitter::Node<'tree>> {
1861 if node.start_byte() > byte_start || node.end_byte() < byte_end {
1862 return None;
1863 }
1864
1865 let mut cursor = node.walk();
1866 if cursor.goto_first_child() {
1867 loop {
1868 let child = cursor.node();
1869 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1870 return Some(found);
1871 }
1872 if !cursor.goto_next_sibling() {
1873 break;
1874 }
1875 }
1876 }
1877
1878 Some(node)
1879}
1880
1881fn is_symbol_body_node(kind: &str) -> bool {
1882 matches!(
1883 kind,
1884 "function_declaration"
1885 | "generator_function_declaration"
1886 | "function_expression"
1887 | "generator_function"
1888 | "arrow_function"
1889 | "method_definition"
1890 | "class_declaration"
1891 | "abstract_class_declaration"
1892 | "class"
1893 | "lexical_declaration"
1894 | "function_definition"
1895 | "class_definition"
1896 | "decorated_definition"
1897 | "function_item"
1898 | "impl_item"
1899 | "method_declaration"
1900 )
1901}
1902
1903fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1904 let mut results = Vec::new();
1905 let call_kinds = crate::calls::call_node_kinds(lang);
1906 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1907 results
1908}
1909
1910fn collect_calls_with_ranges(
1911 node: tree_sitter::Node,
1912 source: &str,
1913 call_kinds: &[&str],
1914 results: &mut Vec<RawCall>,
1915) {
1916 if call_kinds.contains(&node.kind()) {
1917 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1918 results.push(RawCall {
1919 name,
1920 line: node.start_position().row as u32 + 1,
1921 start_byte: node.start_byte(),
1922 end_byte: node.end_byte(),
1923 });
1924 }
1925 }
1926
1927 let mut cursor = node.walk();
1928 if cursor.goto_first_child() {
1929 loop {
1930 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1931 if !cursor.goto_next_sibling() {
1932 break;
1933 }
1934 }
1935 }
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940 use super::*;
1941 use crate::config::Config;
1942 use crate::context::AppContext;
1943 use crate::parser::TreeSitterProvider;
1944 use std::path::PathBuf;
1945
1946 fn fixture_path(name: &str) -> PathBuf {
1947 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1948 .join("tests")
1949 .join("fixtures")
1950 .join(name)
1951 }
1952
1953 fn make_ctx() -> AppContext {
1954 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1955 }
1956
1957 #[test]
1958 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1959 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1960 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1961 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1962 }
1963
1964 #[test]
1965 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1966 let params = serde_json::json!({ "symbols": "Getting Started" });
1967 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1968 assert_eq!(names, vec!["Getting Started"]);
1969 }
1970
1971 #[test]
1972 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1973 let params = serde_json::json!({ "symbol": "Last Heading" });
1974 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1975 assert_eq!(names, vec!["Last Heading"]);
1976 }
1977
1978 #[test]
1979 fn parse_zoom_symbol_names_single_token_unchanged() {
1980 let params = serde_json::json!({ "symbol": "compute" });
1981 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1982 assert_eq!(names, vec!["compute"]);
1983 }
1984
1985 #[test]
1986 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1987 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1988 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1989 assert_eq!(names, vec!["A", "B", "C"]);
1990 }
1991
1992 #[test]
1993 fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1994 let params =
1996 serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1997 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1998 assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1999 }
2000
2001 #[test]
2002 fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
2003 let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
2004 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
2005 assert_eq!(names, vec!["alpha", "beta"]);
2006 }
2007
2008 #[test]
2009 fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
2010 let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
2013 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
2014 assert_eq!(names, vec!["[Draft] Rollout plan"]);
2015 }
2016
2017 #[test]
2018 fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
2019 let params = serde_json::json!({ "symbols": "[1, 2]" });
2022 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
2023 assert_eq!(names, vec!["[1,", "2]"]);
2024 }
2025
2026 #[test]
2029 fn extract_calls_finds_direct_calls() {
2030 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2031 let mut parser = FileParser::new();
2032 let path = fixture_path("calls.ts");
2033 let (tree, lang) = parser.parse(&path).unwrap();
2034
2035 let ctx = make_ctx();
2037 let symbols = ctx.provider().list_symbols(&path).unwrap();
2038 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2039
2040 let byte_start =
2041 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
2042 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
2043
2044 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2045 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2046
2047 assert!(
2048 names.contains(&"helper"),
2049 "compute should call helper, got: {:?}",
2050 names
2051 );
2052 }
2053
2054 #[test]
2055 fn extract_calls_finds_member_calls() {
2056 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2057 let mut parser = FileParser::new();
2058 let path = fixture_path("calls.ts");
2059 let (tree, lang) = parser.parse(&path).unwrap();
2060
2061 let ctx = make_ctx();
2062 let symbols = ctx.provider().list_symbols(&path).unwrap();
2063 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
2064
2065 let byte_start =
2066 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
2067 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
2068
2069 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2070 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
2071
2072 assert!(
2073 names.contains(&"add"),
2074 "runAll should call this.add, got: {:?}",
2075 names
2076 );
2077 assert!(
2078 names.contains(&"helper"),
2079 "runAll should call helper, got: {:?}",
2080 names
2081 );
2082 }
2083
2084 #[test]
2085 fn extract_calls_unused_function_has_no_calls() {
2086 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
2087 let mut parser = FileParser::new();
2088 let path = fixture_path("calls.ts");
2089 let (tree, lang) = parser.parse(&path).unwrap();
2090
2091 let ctx = make_ctx();
2092 let symbols = ctx.provider().list_symbols(&path).unwrap();
2093 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
2094
2095 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
2096 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
2097
2098 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
2099 let known_names = [
2101 "helper",
2102 "compute",
2103 "orchestrate",
2104 "unused",
2105 "format",
2106 "display",
2107 ];
2108 let filtered: Vec<&str> = calls
2109 .iter()
2110 .map(|(n, _)| n.as_str())
2111 .filter(|n| known_names.contains(n))
2112 .collect();
2113 assert!(
2114 filtered.is_empty(),
2115 "unused should not call known symbols, got: {:?}",
2116 filtered
2117 );
2118 }
2119
2120 #[test]
2123 fn context_lines_clamp_at_file_start() {
2124 let ctx = make_ctx();
2126 let path = fixture_path("calls.ts");
2127 let symbols = ctx.provider().list_symbols(&path).unwrap();
2128 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
2129
2130 let source = std::fs::read_to_string(&path).unwrap();
2131 let lines: Vec<&str> = source.lines().collect();
2132 let start = helper.range.start_line as usize;
2133
2134 let ctx_start = start.saturating_sub(5);
2136 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
2137 assert!(context_before.len() <= start);
2139 }
2140
2141 #[test]
2142 fn context_lines_clamp_at_file_end() {
2143 let ctx = make_ctx();
2144 let path = fixture_path("calls.ts");
2145 let symbols = ctx.provider().list_symbols(&path).unwrap();
2146 let display = symbols.iter().find(|s| s.name == "display").unwrap();
2147
2148 let source = std::fs::read_to_string(&path).unwrap();
2149 let lines: Vec<&str> = source.lines().collect();
2150 let end = display.range.end_line as usize;
2151
2152 let ctx_end = (end + 1 + 20).min(lines.len());
2154 let context_after: Vec<&str> = if end + 1 < lines.len() {
2155 lines[(end + 1)..ctx_end].to_vec()
2156 } else {
2157 vec![]
2158 };
2159 assert!(context_after.len() <= 20);
2161 }
2162
2163 #[test]
2166 fn body_extraction_matches_source() {
2167 let ctx = make_ctx();
2168 let path = fixture_path("calls.ts");
2169 let symbols = ctx.provider().list_symbols(&path).unwrap();
2170 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
2171
2172 let source = std::fs::read_to_string(&path).unwrap();
2173 let lines: Vec<&str> = source.lines().collect();
2174 let start = compute.range.start_line as usize;
2175 let end = compute.range.end_line as usize;
2176 let body = lines[start..=end].join("\n");
2177
2178 assert!(
2179 body.contains("function compute"),
2180 "body should contain function declaration"
2181 );
2182 assert!(
2183 body.contains("helper(a)"),
2184 "body should contain call to helper"
2185 );
2186 assert!(
2187 body.contains("doubled + b"),
2188 "body should contain return expression"
2189 );
2190 }
2191
2192 #[test]
2195 fn body_range_expands_signature_range_to_include_body_calls() {
2196 let source = r#"function compute(
2197 value: number,
2198): number {
2199 return helper(value);
2200}
2201
2202function helper(value: number): number {
2203 return value * 2;
2204}
2205"#;
2206 let grammar = crate::parser::grammar_for(LangId::TypeScript);
2207 let mut parser = tree_sitter::Parser::new();
2208 parser.set_language(&grammar).unwrap();
2209 let tree = parser.parse(source, None).unwrap();
2210 let signature_end = source.find('{').expect("function has body");
2211
2212 let (body_start, body_end) =
2213 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
2214 let calls = extract_calls_in_range(
2215 source,
2216 tree.root_node(),
2217 body_start,
2218 body_end,
2219 LangId::TypeScript,
2220 );
2221 let names = calls
2222 .iter()
2223 .map(|(name, _)| name.as_str())
2224 .collect::<Vec<_>>();
2225
2226 assert!(
2227 names.contains(&"helper"),
2228 "call inside the function body should be included: {names:?}"
2229 );
2230 }
2231
2232 #[test]
2233 fn zoom_leaf_returns_full_body_without_budget_marker() {
2234 let ctx = make_ctx();
2235 let path = fixture_path("calls.ts");
2236 let req = make_zoom_request(
2237 "z-leaf-full",
2238 path.to_str().unwrap(),
2239 "repeatedOutgoing",
2240 None,
2241 );
2242 let resp = handle_zoom(&req, &ctx);
2243 let json = serde_json::to_value(&resp).unwrap();
2244 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2245
2246 let symbols = ctx.provider().list_symbols(&path).unwrap();
2247 let target = symbols
2248 .iter()
2249 .find(|symbol| symbol.name == "repeatedOutgoing")
2250 .unwrap();
2251 let source = std::fs::read_to_string(&path).unwrap();
2252 let lines = source.lines().collect::<Vec<_>>();
2253 let expected =
2254 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
2255
2256 assert_eq!(json["content"].as_str().unwrap(), expected);
2257 assert!(
2258 !json["content"]
2259 .as_str()
2260 .unwrap()
2261 .contains("more lines — zoom"),
2262 "explicit zoom must not budget-cap leaf bodies"
2263 );
2264 }
2265
2266 #[test]
2267 fn zoom_response_has_calls_out_and_called_by() {
2268 let ctx = make_ctx();
2269 let path = fixture_path("calls.ts");
2270
2271 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
2272 let resp = handle_zoom(&req, &ctx);
2273
2274 let json = serde_json::to_value(&resp).unwrap();
2275 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2276
2277 let calls_out = json["annotations"]["calls_out"]
2278 .as_array()
2279 .expect("calls_out array");
2280 let out_names: Vec<&str> = calls_out
2281 .iter()
2282 .map(|c| c["name"].as_str().unwrap())
2283 .collect();
2284 assert!(
2285 out_names.contains(&"helper"),
2286 "compute calls helper: {:?}",
2287 out_names
2288 );
2289
2290 let called_by = json["annotations"]["called_by"]
2291 .as_array()
2292 .expect("called_by array");
2293 let by_names: Vec<&str> = called_by
2294 .iter()
2295 .map(|c| c["name"].as_str().unwrap())
2296 .collect();
2297 assert!(
2298 by_names.contains(&"orchestrate"),
2299 "orchestrate calls compute: {:?}",
2300 by_names
2301 );
2302 }
2303
2304 #[test]
2305 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
2306 let ctx = make_ctx();
2307 let path = fixture_path("calls.ts");
2308
2309 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
2310 let resp = handle_zoom(&req, &ctx);
2311 let json = serde_json::to_value(&resp).unwrap();
2312 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2313
2314 let calls_out = json["annotations"]["calls_out"]
2315 .as_array()
2316 .expect("calls_out array");
2317 let helper_refs = calls_out
2318 .iter()
2319 .filter(|call| call["name"] == "helper")
2320 .collect::<Vec<_>>();
2321 assert_eq!(
2322 helper_refs.len(),
2323 1,
2324 "helper should be folded once: {calls_out:?}"
2325 );
2326 assert_eq!(helper_refs[0]["extra_count"], 1);
2327 assert!(
2328 calls_out.iter().any(|call| call["name"] == "format"),
2329 "distinct callee must not be folded into helper: {calls_out:?}"
2330 );
2331
2332 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
2333 let resp = handle_zoom(&req, &ctx);
2334 let json = serde_json::to_value(&resp).unwrap();
2335 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
2336
2337 let called_by = json["annotations"]["called_by"]
2338 .as_array()
2339 .expect("called_by array");
2340 let repeat_refs = called_by
2341 .iter()
2342 .filter(|call| call["name"] == "repeatCompute")
2343 .collect::<Vec<_>>();
2344 assert_eq!(
2345 repeat_refs.len(),
2346 1,
2347 "repeatCompute should be folded once: {called_by:?}"
2348 );
2349 assert_eq!(repeat_refs[0]["extra_count"], 1);
2350 assert!(
2351 called_by.iter().any(|call| call["name"] == "orchestrate"),
2352 "distinct caller must not be folded into repeatCompute: {called_by:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn zoom_response_empty_annotations_for_unused() {
2358 let ctx = make_ctx();
2359 let path = fixture_path("calls.ts");
2360
2361 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
2362 let resp = handle_zoom(&req, &ctx);
2363
2364 let json = serde_json::to_value(&resp).unwrap();
2365 assert_eq!(json["success"], true);
2366
2367 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
2368 let called_by = json["annotations"]["called_by"].as_array().unwrap();
2369
2370 assert!(
2373 called_by.is_empty(),
2374 "unused should not be called by anyone: {:?}",
2375 called_by
2376 );
2377 }
2378
2379 #[test]
2380 fn zoom_default_omits_callgraph_annotations() {
2381 let ctx = make_ctx();
2382 let path = fixture_path("calls.ts");
2383
2384 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
2385 let resp = handle_zoom(&req, &ctx);
2386
2387 let json = serde_json::to_value(&resp).unwrap();
2388 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
2389
2390 let calls_out = json["annotations"]["calls_out"]
2391 .as_array()
2392 .expect("calls_out array");
2393 let called_by = json["annotations"]["called_by"]
2394 .as_array()
2395 .expect("called_by array");
2396 assert!(
2397 calls_out.is_empty(),
2398 "default zoom should omit calls_out: {:?}",
2399 calls_out
2400 );
2401 assert!(
2402 called_by.is_empty(),
2403 "default zoom should omit called_by: {:?}",
2404 called_by
2405 );
2406 }
2407
2408 #[test]
2409 fn zoom_symbol_not_found() {
2410 let ctx = make_ctx();
2411 let path = fixture_path("calls.ts");
2412
2413 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
2414 let resp = handle_zoom(&req, &ctx);
2415
2416 let json = serde_json::to_value(&resp).unwrap();
2417 assert_eq!(json["success"], false);
2418 assert_eq!(json["code"], "symbol_not_found");
2419 }
2420
2421 #[test]
2422 fn zoom_custom_context_lines() {
2423 let ctx = make_ctx();
2424 let path = fixture_path("calls.ts");
2425
2426 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
2427 let resp = handle_zoom(&req, &ctx);
2428
2429 let json = serde_json::to_value(&resp).unwrap();
2430 assert_eq!(json["success"], true);
2431
2432 let ctx_before = json["context_before"].as_array().unwrap();
2433 let ctx_after = json["context_after"].as_array().unwrap();
2434 assert!(
2436 ctx_before.len() <= 1,
2437 "context_before should be ≤1: {:?}",
2438 ctx_before
2439 );
2440 assert!(
2441 ctx_after.len() <= 1,
2442 "context_after should be ≤1: {:?}",
2443 ctx_after
2444 );
2445 }
2446
2447 #[test]
2448 fn zoom_missing_file_param() {
2449 let ctx = make_ctx();
2450 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
2451 let resp = handle_zoom(&req, &ctx);
2452
2453 let json = serde_json::to_value(&resp).unwrap();
2454 assert_eq!(json["success"], false);
2455 assert_eq!(json["code"], "invalid_request");
2456 }
2457
2458 #[test]
2459 fn zoom_missing_symbol_param() {
2460 let ctx = make_ctx();
2461 let path = fixture_path("calls.ts");
2462 let req_value = serde_json::json!({
2466 "id": "z-6",
2467 "command": "zoom",
2468 "file": path.to_string_lossy(),
2469 });
2470 let req_str = req_value.to_string();
2471 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
2472 let resp = handle_zoom(&req, &ctx);
2473
2474 let json = serde_json::to_value(&resp).unwrap();
2475 assert_eq!(json["success"], false);
2476 assert_eq!(json["code"], "invalid_request");
2477 }
2478
2479 #[test]
2480 fn test_suggest_close_symbols_unit() {
2481 let available = vec![
2482 "handle_grep_search".to_string(),
2483 "handle_semantic_search".to_string(),
2484 "handle_semantic_or_hybrid_search".to_string(),
2485 "compute_total".to_string(),
2486 "search".to_string(),
2487 "handle_search".to_string(),
2488 ];
2489
2490 let suggestions = suggest_close_symbols("handle_search", &available, 5);
2491 assert!(suggestions.contains(&"handle_grep_search".to_string()));
2492 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
2493 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
2494 assert!(suggestions.contains(&"search".to_string()));
2495 assert!(!suggestions.contains(&"compute_total".to_string()));
2496
2497 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
2498 assert_eq!(suggestions, suggestions_caps);
2499
2500 let available2 = vec![
2501 "total".to_string(),
2502 "compute_total".to_string(),
2503 "unrelated".to_string(),
2504 ];
2505 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
2506 assert_eq!(suggestions2, vec!["total".to_string()]);
2507 }
2508
2509 fn make_zoom_request(
2512 id: &str,
2513 file: &str,
2514 symbol: &str,
2515 context_lines: Option<u64>,
2516 ) -> RawRequest {
2517 let mut json = serde_json::json!({
2518 "id": id,
2519 "command": "zoom",
2520 "file": file,
2521 "symbol": symbol,
2522 });
2523 if let Some(cl) = context_lines {
2524 json["context_lines"] = serde_json::json!(cl);
2525 }
2526 serde_json::from_value(json).unwrap()
2527 }
2528
2529 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2530 let mut req = make_zoom_request(id, file, symbol, None);
2531 req.params["callgraph"] = serde_json::json!(true);
2532 req
2533 }
2534
2535 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2536 serde_json::from_str(json_str).unwrap()
2537 }
2538
2539 fn json_fixture_tree() -> (String, tree_sitter::Tree) {
2542 json_fixture_tree_named("nested.json")
2543 }
2544
2545 fn json_fixture_tree_named(name: &str) -> (String, tree_sitter::Tree) {
2546 let source = std::fs::read_to_string(fixture_path(name)).unwrap();
2547 let mut parser = FileParser::new();
2548 let path = fixture_path(name);
2549 let (tree, _) = parser.parse(&path).unwrap();
2550 (source, tree.clone())
2551 }
2552
2553 fn assert_json_zoom_resolves(fixture: &str, query: &str, expected_fragment: &str) {
2554 let ctx = make_ctx();
2555 let path = fixture_path(fixture);
2556 let req = make_zoom_request("json-regression", path.to_str().unwrap(), query, None);
2557 let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2558 assert_eq!(json["success"], true, "JSON zoom should succeed: {json}");
2559 assert_eq!(json["name"], query);
2560 assert!(
2561 json["content"]
2562 .as_str()
2563 .unwrap_or_default()
2564 .contains(expected_fragment),
2565 "JSON zoom content should contain {expected_fragment:?}: {json}"
2566 );
2567 }
2568
2569 #[test]
2570 fn json_path_resolves_nested_object() {
2571 let (source, tree) = json_fixture_tree();
2572 let resolved = json_path_resolve(
2573 &source,
2574 &tree.root_node(),
2575 "registration_profile_manifest.nested.deep",
2576 )
2577 .expect("path should resolve");
2578 assert_eq!(resolved.path, "registration_profile_manifest.nested.deep");
2579 assert_eq!(node_text(&source, &resolved.node).trim(), "\"value\"");
2580 }
2581
2582 #[test]
2583 fn json_zoom_resolves_leading_line_comments() {
2584 assert_json_zoom_resolves(
2585 "zoom_jsonc_leading_line_comments.jsonc",
2586 "chains",
2587 "\"executor\"",
2588 );
2589 }
2590
2591 #[test]
2592 fn json_zoom_resolves_leading_block_comment() {
2593 assert_json_zoom_resolves(
2594 "zoom_jsonc_leading_block_comment.jsonc",
2595 "chains",
2596 "\"executor\"",
2597 );
2598 }
2599
2600 #[test]
2601 fn json_zoom_resolves_blank_lines_before_document() {
2602 assert_json_zoom_resolves("zoom_json_blank_lines.json", "chains", "\"executor\"");
2603 }
2604
2605 #[test]
2606 fn json_zoom_resolves_comments_inside_object() {
2607 assert_json_zoom_resolves("zoom_json_comments_inside.jsonc", "chains", "\"executor\"");
2608 }
2609
2610 #[test]
2611 fn json_zoom_leading_multibyte_comment_keeps_path_and_value_offsets() {
2612 assert_json_zoom_resolves(
2613 "zoom_jsonc_leading_line_comments.jsonc",
2614 "chains.executor.entries[1].model",
2615 "\"large\"",
2616 );
2617 }
2618
2619 #[test]
2620 fn json_zoom_miss_reports_actual_deepest_prefix_and_segment() {
2621 let ctx = make_ctx();
2622 let path = fixture_path("zoom_json_miss_locus.json");
2623 let query = "agent.general.model";
2624 let req = make_zoom_request("json-miss", path.to_str().unwrap(), query, None);
2625 let json = serde_json::to_value(handle_zoom(&req, &ctx)).unwrap();
2626
2627 assert_eq!(json["success"], false);
2628 assert_eq!(
2629 json["message"],
2630 "symbol 'agent.general.model' not found: resolved `agent`, no key `general` — nearest: [general_settings]"
2631 );
2632 }
2633
2634 #[test]
2635 fn json_path_resolves_array_index() {
2636 let (source, tree) = json_fixture_tree();
2637 let resolved = json_path_resolve(&source, &tree.root_node(), "servers[0]")
2638 .expect("path should resolve");
2639 assert_eq!(resolved.path, "servers[0]");
2640 assert!(node_text(&source, &resolved.node).contains("primary"));
2641 }
2642
2643 #[test]
2644 fn json_path_resolves_chained_array_index() {
2645 let (source, tree) = json_fixture_tree();
2646 let resolved =
2647 json_path_resolve(&source, &tree.root_node(), "a.b[1].c").expect("path should resolve");
2648 assert_eq!(resolved.path, "a.b[1].c");
2649 assert_eq!(node_text(&source, &resolved.node).trim(), "\"second\"");
2650 }
2651
2652 #[test]
2653 fn json_path_resolves_bare_array_index() {
2654 let (source, tree) = json_fixture_tree();
2655 let resolved = json_path_resolve(&source, &tree.root_node(), "servers[1].name")
2656 .expect("path should resolve");
2657 assert_eq!(resolved.path, "servers[1].name");
2658 assert_eq!(node_text(&source, &resolved.node).trim(), "\"backup\"");
2659 }
2660
2661 #[test]
2662 fn json_path_miss_returns_none() {
2663 let (source, tree) = json_fixture_tree();
2664 assert!(json_path_resolve(
2665 &source,
2666 &tree.root_node(),
2667 "registration_profile_manifest.host_only_allowlis"
2668 )
2669 .is_none());
2670 assert!(json_path_resolve(&source, &tree.root_node(), "servers[9]").is_none());
2671 assert!(json_path_resolve(&source, &tree.root_node(), "missing").is_none());
2672 }
2673
2674 #[test]
2675 fn json_path_resolves_dotted_query_as_path() {
2676 let (source, tree) = json_fixture_tree();
2680 let resolved = json_path_resolve(&source, &tree.root_node(), "literal.dotted.key")
2681 .expect("path should resolve");
2682 assert_eq!(node_text(&source, &resolved.node).trim(), "\"path-value\"");
2683 }
2684
2685 #[test]
2686 fn json_miss_details_reports_deepest_prefix() {
2687 let (source, tree) = json_fixture_tree();
2688 let (prefix, failing) = json_miss_details(
2689 &source,
2690 &tree.root_node(),
2691 "registration_profile_manifest.host_only_allowlis",
2692 );
2693 assert_eq!(prefix, "registration_profile_manifest");
2694 assert_eq!(failing, "host_only_allowlis");
2695 }
2696
2697 #[test]
2698 fn json_miss_details_single_segment() {
2699 let (source, tree) = json_fixture_tree();
2700 let (prefix, failing) = json_miss_details(&source, &tree.root_node(), "missing");
2701 assert_eq!(prefix, "");
2702 assert_eq!(failing, "missing");
2703 }
2704
2705 #[test]
2706 fn split_json_path_keeps_bracket_groups() {
2707 assert_eq!(split_json_path("a.b[0].c"), vec!["a", "b[0]", "c"]);
2708 assert_eq!(split_json_path("servers[0]"), vec!["servers[0]"]);
2709 assert_eq!(split_json_path("a.b.c"), vec!["a", "b", "c"]);
2710 }
2711
2712 #[test]
2713 fn parse_json_segment_handles_key_and_index() {
2714 assert_eq!(parse_json_segment("servers[0]"), (Some("servers"), Some(0)));
2715 assert_eq!(parse_json_segment("[0]"), (None, Some(0)));
2716 assert_eq!(parse_json_segment("host"), (Some("host"), None));
2717 }
2718}