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, 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 is_heading = is_heading_zoom_language(detect_language(path));
593 let matches = match resolve_zoom_symbol(ctx.provider(), path, symbol_name, is_heading) {
594 Ok(matches) => matches,
595 Err(e) => return Response::error(&req.id, e.code(), e.to_string()),
596 };
597
598 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
600 lsp_hints::apply_lsp_disambiguation(matches, &hints)
601 } else {
602 matches
603 };
604
605 if matches.len() > 1 {
606 let content = render_ambiguous_symbol_menu(symbol_name, &matches);
607 let candidates = matches
608 .iter()
609 .map(|candidate| {
610 let sym = &candidate.symbol;
611 serde_json::json!({
612 "name": sym.name.clone(),
613 "qualified_name": qualified_symbol_name(sym),
614 "kind": symbol_kind_string(&sym.kind),
615 "range": sym.range.clone(),
616 "signature": sym.signature.clone(),
617 })
618 })
619 .collect::<Vec<_>>();
620
621 return Response::success(
622 &req.id,
623 serde_json::json!({
624 "name": symbol_name,
625 "kind": "ambiguous_symbol",
626 "content": content,
627 "context_before": [],
628 "context_after": [],
629 "annotations": empty_annotations(),
630 "candidates": candidates,
631 }),
632 );
633 }
634
635 if matches.is_empty() {
636 let mut msg = format!("symbol '{}' not found", symbol_name);
637 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
638 let suggestions = if is_heading {
639 suggest_heading_symbols(symbol_name, &all_symbols, 5)
640 } else {
641 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
642 suggest_close_symbols(symbol_name, &available, 5)
643 };
644 if !suggestions.is_empty() {
645 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
646 }
647 }
648 return Response::error(&req.id, "symbol_not_found", msg);
649 }
650
651 let target = &matches[0].symbol;
652 let start = target.range.start_line as usize;
653 let end = target.range.end_line as usize;
654
655 let resolved_file_path = std::path::Path::new(&matches[0].file);
657 let resolved_lines: Vec<String>;
658 let effective_lines: &[String] = if resolved_file_path != path {
659 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
660 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
661 Err(_) => lines.to_vec(),
662 };
663 &resolved_lines
664 } else {
665 lines
666 };
667
668 let content = if end < effective_lines.len() {
670 effective_lines[start..=end].join("\n")
671 } else {
672 effective_lines[start..].join("\n")
673 };
674
675 let resolved_lang = detect_language(resolved_file_path);
676 let container_outline = if might_have_container_members(target) {
677 match build_container_outline(ctx, resolved_file_path, target) {
678 Ok(outline) => Some(outline),
679 Err(e) => {
680 return Response::error(&req.id, e.code(), e.to_string());
681 }
682 }
683 } else {
684 None
685 };
686
687 if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
688 let kind_str = symbol_kind_string(&target.kind);
689 let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
690 let resp = ZoomResponse {
691 name: target.name.clone(),
692 kind: kind_str,
693 range: target.range.clone(),
694 content: menu,
695 context_before: Vec::new(),
696 context_after: Vec::new(),
697 annotations: Annotations {
698 calls_out: Vec::new(),
699 called_by: Vec::new(),
700 },
701 };
702 return match serde_json::to_value(&resp) {
703 Ok(resp_json) => Response::success(&req.id, resp_json),
704 Err(err) => Response::error(
705 &req.id,
706 "internal_error",
707 format!("zoom: failed to serialize response: {err}"),
708 ),
709 };
710 }
711
712 let ctx_start = start.saturating_sub(context_lines);
714 let context_before: Vec<String> = if ctx_start < start {
715 effective_lines[ctx_start..start]
716 .iter()
717 .map(|l| l.to_string())
718 .collect()
719 } else {
720 vec![]
721 };
722
723 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
725 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
726 effective_lines[(end + 1)..ctx_end]
727 .iter()
728 .map(|l| l.to_string())
729 .collect()
730 } else {
731 vec![]
732 };
733
734 let (calls_out, called_by) = if include_callgraph {
735 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
737 Ok(s) => s,
738 Err(e) => {
739 return Response::error(&req.id, e.code(), e.to_string());
740 }
741 };
742
743 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
744
745 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
747 let (tree, lang) = match parser.parse(resolved_file_path) {
748 Ok(r) => r,
749 Err(e) => {
750 return Response::error(&req.id, e.code(), e.to_string());
751 }
752 };
753
754 let resolved_source = if resolved_file_path != path {
756 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
757 } else {
758 source.to_string()
759 };
760 let signature_byte_start = line_col_to_byte(
761 &resolved_source,
762 target.range.start_line,
763 target.range.start_col,
764 );
765 let signature_byte_end = line_col_to_byte(
766 &resolved_source,
767 target.range.end_line,
768 target.range.end_col,
769 );
770 let (target_byte_start, target_byte_end) =
771 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
772 .unwrap_or((signature_byte_start, signature_byte_end));
773
774 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
775
776 let raw_calls = all_file_calls.iter().filter(|call| {
777 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
778 });
779 let calls_out = dedupe_call_refs_by_name(
780 raw_calls
781 .filter(|call| {
782 known_names.contains(&call.name.as_str()) && call.name != target.name
783 })
784 .map(|call| CallRef {
785 name: call.name.clone(),
786 line: call.line,
787 extra_count: 0,
788 })
789 .collect(),
790 );
791
792 let mut called_by: Vec<CallRef> = Vec::new();
794 for sym in &all_symbols {
795 if sym.name == target.name && sym.range.start_line == target.range.start_line {
796 continue; }
798 let sym_byte_start =
799 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
800 let sym_byte_end =
801 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
802 for call in &all_file_calls {
803 if call.name == target.name
804 && call.start_byte >= sym_byte_start
805 && call.end_byte <= sym_byte_end
806 {
807 called_by.push(CallRef {
808 name: sym.name.clone(),
809 line: call.line,
810 extra_count: 0,
811 });
812 }
813 }
814 }
815
816 let called_by = dedupe_call_refs_by_name(called_by);
817
818 (calls_out, called_by)
819 } else {
820 (Vec::new(), Vec::new())
821 };
822
823 let kind_str = symbol_kind_string(&target.kind);
824
825 let resp = ZoomResponse {
826 name: target.name.clone(),
827 kind: kind_str,
828 range: target.range.clone(),
829 content,
830 context_before,
831 context_after,
832 annotations: Annotations {
833 calls_out,
834 called_by,
835 },
836 };
837
838 match serde_json::to_value(&resp) {
839 Ok(resp_json) => Response::success(&req.id, resp_json),
840 Err(err) => Response::error(
841 &req.id,
842 "internal_error",
843 format!("zoom: failed to serialize response: {err}"),
844 ),
845 }
846}
847
848fn empty_annotations() -> serde_json::Value {
849 serde_json::json!({
850 "calls_out": [],
851 "called_by": [],
852 })
853}
854
855fn render_ambiguous_symbol_menu(
856 symbol_name: &str,
857 matches: &[crate::symbols::SymbolMatch],
858) -> String {
859 let mut lines = vec![format!(
860 "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
861 matches.len()
862 )];
863
864 for candidate in matches {
865 let entry = symbol_to_entry(&candidate.symbol);
866 lines.push(format!(
867 "- {}",
868 format_qualified_entry(&entry, Some(&candidate.symbol))
869 ));
870 }
871
872 lines.join("\n")
873}
874
875fn levenshtein_distance(s1: &str, s2: &str) -> usize {
876 let s1_chars: Vec<char> = s1.chars().collect();
877 let s2_chars: Vec<char> = s2.chars().collect();
878 let len1 = s1_chars.len();
879 let len2 = s2_chars.len();
880
881 let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
882
883 for i in 0..=len1 {
884 dp[i][0] = i;
885 }
886 for j in 0..=len2 {
887 dp[0][j] = j;
888 }
889
890 for i in 1..=len1 {
891 for j in 1..=len2 {
892 if s1_chars[i - 1] == s2_chars[j - 1] {
893 dp[i][j] = dp[i - 1][j - 1];
894 } else {
895 dp[i][j] =
896 1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
897 }
898 }
899 }
900
901 dp[len1][len2]
902}
903
904fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
905 let mut unique: Vec<&String> = available.iter().collect();
906 unique.sort();
907 unique.dedup();
908
909 let query_lower = query.to_lowercase();
910 let query_len = query_lower.chars().count();
911 let max_dist = std::cmp::max(2, query_len / 3);
912
913 let mut scored: Vec<(bool, usize, &String)> = unique
914 .into_iter()
915 .map(|name| {
916 let name_lower = name.to_lowercase();
917 let is_substring =
918 name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
919 let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
920 (query_lower.find('_'), query_lower.rfind('_'))
921 {
922 let prefix = &query_lower[..=first_idx];
923 let suffix = &query_lower[last_idx..];
924 name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
925 } else {
926 false
927 };
928 let is_match = is_substring || is_wildcard;
929 let dist = levenshtein_distance(&query_lower, &name_lower);
930 (is_match, dist, name)
931 })
932 .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
933 .collect();
934
935 scored.sort_by(|a, b| {
936 let a_match = a.0;
937 let b_match = b.0;
938 (!a_match)
939 .cmp(&(!b_match))
940 .then_with(|| a.1.cmp(&b.1))
941 .then_with(|| a.2.cmp(b.2))
942 });
943
944 scored
945 .into_iter()
946 .take(k)
947 .map(|(_, _, name)| name.clone())
948 .collect()
949}
950
951fn resolve_zoom_symbol(
952 provider: &dyn LanguageProvider,
953 path: &Path,
954 query: &str,
955 is_heading: bool,
956) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
957 if is_heading {
958 return resolve_heading_symbols(provider, path, query);
959 }
960
961 match provider.resolve_symbol(path, query) {
962 Err(crate::error::AftError::SymbolNotFound { .. }) => Ok(Vec::new()),
963 result => result,
964 }
965}
966
967fn resolve_heading_symbols(
970 provider: &dyn LanguageProvider,
971 path: &Path,
972 query: &str,
973) -> Result<Vec<SymbolMatch>, crate::error::AftError> {
974 let headings: Vec<SymbolMatch> = provider
975 .list_symbols(path)?
976 .into_iter()
977 .filter(|symbol| symbol.kind == SymbolKind::Heading)
978 .map(|symbol| SymbolMatch {
979 file: path.display().to_string(),
980 symbol,
981 })
982 .collect();
983
984 let anchors = if query.starts_with('#') {
985 provider.heading_anchors(path)?
986 } else {
987 Vec::new()
988 };
989
990 Ok(match_heading_identity(&headings, query, &anchors))
991}
992
993fn match_heading_identity(
994 headings: &[SymbolMatch],
995 query: &str,
996 anchors: &[HeadingAnchor],
997) -> Vec<SymbolMatch> {
998 if let Some(anchor_query) = query.strip_prefix('#') {
999 let mut matches: Vec<_> = headings
1000 .iter()
1001 .filter(|candidate| {
1002 anchors.iter().any(|anchor| {
1003 anchor.id == anchor_query
1004 && anchor.start_line == candidate.symbol.range.start_line
1005 && anchor.start_col == candidate.symbol.range.start_col
1006 })
1007 })
1008 .cloned()
1009 .collect();
1010
1011 let normalized_query = normalize_heading_label(query);
1012 let query_slug = slugify_heading_label(&normalized_query);
1013 if !query_slug.is_empty() {
1014 for candidate in headings
1015 .iter()
1016 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1017 {
1018 if !matches.iter().any(|existing| {
1019 existing.file == candidate.file && existing.symbol == candidate.symbol
1020 }) {
1021 matches.push(candidate.clone());
1022 }
1023 }
1024 }
1025 return matches;
1026 }
1027
1028 let exact: Vec<_> = headings
1029 .iter()
1030 .filter(|candidate| heading_identity_is_exact(&candidate.symbol, query))
1031 .cloned()
1032 .collect();
1033 if !exact.is_empty() {
1034 return exact;
1035 }
1036
1037 let normalized_query = normalize_heading_label(query);
1038 if normalized_query.is_empty() {
1039 return Vec::new();
1040 }
1041
1042 let normalized: Vec<_> = headings
1043 .iter()
1044 .filter(|candidate| heading_identity_is_normalized(&candidate.symbol, &normalized_query))
1045 .cloned()
1046 .collect();
1047 if !normalized.is_empty() {
1048 return normalized;
1049 }
1050
1051 let folded_query = normalized_query.to_lowercase();
1052 let case_insensitive: Vec<_> = headings
1053 .iter()
1054 .filter(|candidate| heading_identity_is_case_insensitive(&candidate.symbol, &folded_query))
1055 .cloned()
1056 .collect();
1057 if !case_insensitive.is_empty() {
1058 return case_insensitive;
1059 }
1060
1061 let query_slug = slugify_heading_label(&normalized_query);
1062 if query_slug.is_empty() {
1063 return Vec::new();
1064 }
1065
1066 headings
1067 .iter()
1068 .filter(|candidate| heading_identity_has_slug(&candidate.symbol, &query_slug))
1069 .cloned()
1070 .collect()
1071}
1072
1073fn qualified_heading_name(symbol: &Symbol) -> String {
1074 if symbol.scope_chain.is_empty() {
1075 return symbol.name.clone();
1076 }
1077 format!("{}.{}", symbol.scope_chain.join("."), symbol.name)
1078}
1079
1080fn heading_identity_is_exact(symbol: &Symbol, query: &str) -> bool {
1081 symbol.name == query || qualified_heading_name(symbol) == query
1082}
1083
1084fn heading_identity_is_normalized(symbol: &Symbol, query: &str) -> bool {
1085 normalize_heading_label(&symbol.name) == query
1086 || normalize_heading_label(&qualified_heading_name(symbol)) == query
1087}
1088
1089fn heading_identity_is_case_insensitive(symbol: &Symbol, query: &str) -> bool {
1090 normalize_heading_label(&symbol.name).to_lowercase() == query
1091 || normalize_heading_label(&qualified_heading_name(symbol)).to_lowercase() == query
1092}
1093
1094fn heading_identity_has_slug(symbol: &Symbol, query_slug: &str) -> bool {
1095 slugify_heading_label(&normalize_heading_label(&symbol.name)) == query_slug
1096 || slugify_heading_label(&normalize_heading_label(&qualified_heading_name(symbol)))
1097 == query_slug
1098}
1099
1100fn suggest_heading_symbols(query: &str, symbols: &[Symbol], k: usize) -> Vec<String> {
1101 let available: Vec<String> = symbols
1102 .iter()
1103 .filter(|symbol| symbol.kind == SymbolKind::Heading)
1104 .map(|symbol| normalize_heading_label(&symbol.name))
1105 .filter(|name| !name.is_empty())
1106 .collect();
1107 let normalized_query = normalize_heading_label(query);
1108 if normalized_query.is_empty() {
1109 return Vec::new();
1110 }
1111 suggest_close_symbols(&normalized_query, &available, k)
1112}
1113
1114fn normalize_heading_label(input: &str) -> String {
1115 let mut value = collapse_heading_whitespace(&strip_markdown_links(input));
1116
1117 for _ in 0..4 {
1120 let mut next = value.as_str();
1121 let without_heading_markers = next.trim_start_matches('#').trim_start();
1122 if without_heading_markers != next {
1123 next = without_heading_markers;
1124 }
1125 if let Some(rest) = strip_heading_html_prefix(next) {
1126 next = rest;
1127 }
1128 if let Some(rest) = strip_leading_section_prefix(next) {
1129 next = rest;
1130 }
1131 if let Some(rest) = strip_leading_symbol_cluster(next) {
1132 next = rest;
1133 }
1134
1135 let collapsed = collapse_heading_whitespace(next);
1136 if collapsed == value {
1137 break;
1138 }
1139 value = collapsed;
1140 }
1141
1142 value
1143}
1144
1145fn collapse_heading_whitespace(input: &str) -> String {
1146 input.split_whitespace().collect::<Vec<_>>().join(" ")
1147}
1148
1149fn strip_markdown_links(input: &str) -> String {
1150 let mut output = String::with_capacity(input.len());
1151 let mut cursor = 0;
1152
1153 while let Some(relative_open) = input[cursor..].find('[') {
1154 let open = cursor + relative_open;
1155 let Some(close) = find_matching_delimiter(input, open, b'[', b']') else {
1156 output.push_str(&input[cursor..]);
1157 return output;
1158 };
1159
1160 if input.as_bytes().get(close + 1) != Some(&b'(') {
1161 output.push_str(&input[cursor..=close]);
1162 cursor = close + 1;
1163 continue;
1164 }
1165
1166 let target_open = close + 1;
1167 let Some(target_close) = find_matching_delimiter(input, target_open, b'(', b')') else {
1168 output.push_str(&input[cursor..]);
1169 return output;
1170 };
1171
1172 output.push_str(&input[cursor..open]);
1173 output.push_str(&input[open + 1..close]);
1174 cursor = target_close + 1;
1175 }
1176
1177 output.push_str(&input[cursor..]);
1178 output
1179}
1180
1181fn find_matching_delimiter(input: &str, start: usize, open: u8, close: u8) -> Option<usize> {
1182 let mut depth = 0;
1183 for (index, byte) in input.as_bytes().iter().enumerate().skip(start) {
1184 if *byte == open {
1185 depth += 1;
1186 } else if *byte == close {
1187 depth -= 1;
1188 if depth == 0 {
1189 return Some(index);
1190 }
1191 }
1192 }
1193 None
1194}
1195
1196fn strip_heading_html_prefix(input: &str) -> Option<&str> {
1197 let input = input.trim_start();
1198 let bytes = input.as_bytes();
1199 if bytes.first() != Some(&b'<') {
1200 return None;
1201 }
1202
1203 let mut index = 1;
1204 if bytes.get(index) == Some(&b'/') {
1205 index += 1;
1206 }
1207 if !matches!(bytes.get(index), Some(b'h' | b'H')) {
1208 return None;
1209 }
1210 index += 1;
1211 if !matches!(bytes.get(index), Some(b'1'..=b'6')) {
1212 return None;
1213 }
1214
1215 let end = input.find('>')?;
1216 let rest = input[end + 1..].trim_start();
1217 if rest.chars().any(|character| character.is_alphanumeric()) {
1218 Some(rest)
1219 } else {
1220 None
1221 }
1222}
1223
1224fn strip_leading_section_prefix(input: &str) -> Option<&str> {
1225 let bytes = input.as_bytes();
1226 let mut index = 0;
1227 let mut saw_dot = false;
1228
1229 if !bytes
1230 .first()
1231 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1232 {
1233 return None;
1234 }
1235
1236 while index < bytes.len() {
1237 while index < bytes.len() && bytes[index].is_ascii_alphanumeric() {
1238 index += 1;
1239 }
1240 if bytes.get(index) != Some(&b'.') {
1241 break;
1242 }
1243 saw_dot = true;
1244 index += 1;
1245 if bytes
1246 .get(index)
1247 .is_some_and(|byte| byte.is_ascii_whitespace())
1248 {
1249 let rest = input[index..].trim_start();
1250 return if rest.chars().any(|character| character.is_alphanumeric()) {
1251 Some(rest)
1252 } else {
1253 None
1254 };
1255 }
1256 if !bytes
1257 .get(index)
1258 .is_some_and(|byte| byte.is_ascii_alphanumeric())
1259 {
1260 return None;
1261 }
1262 }
1263
1264 if saw_dot
1265 && bytes
1266 .get(index)
1267 .is_some_and(|byte| byte.is_ascii_whitespace())
1268 {
1269 let rest = input[index..].trim_start();
1270 if rest.chars().any(|character| character.is_alphanumeric()) {
1271 return Some(rest);
1272 }
1273 }
1274 None
1275}
1276
1277fn strip_leading_symbol_cluster(input: &str) -> Option<&str> {
1278 let first_text = input
1279 .char_indices()
1280 .find(|(_, character)| character.is_alphanumeric())
1281 .map(|(index, _)| index)?;
1282 if first_text == 0 {
1283 return None;
1284 }
1285
1286 let rest = &input[first_text..];
1287 if rest.chars().any(|character| character.is_alphanumeric()) {
1288 Some(rest)
1289 } else {
1290 None
1291 }
1292}
1293
1294fn slugify_heading_label(label: &str) -> String {
1295 let mut slug = String::new();
1296 let mut pending_separator = false;
1297
1298 for character in label.chars() {
1299 if character.is_alphanumeric() {
1300 if pending_separator && !slug.is_empty() {
1301 slug.push('-');
1302 }
1303 for lowercase in character.to_lowercase() {
1304 slug.push(lowercase);
1305 }
1306 pending_separator = false;
1307 } else if !slug.is_empty() {
1308 pending_separator = true;
1309 }
1310 }
1311
1312 slug
1313}
1314
1315#[cfg(test)]
1319fn extract_calls_in_range(
1320 source: &str,
1321 root: tree_sitter::Node,
1322 byte_start: usize,
1323 byte_end: usize,
1324 lang: LangId,
1325) -> Vec<(String, u32)> {
1326 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
1327}
1328
1329fn symbol_body_byte_range(
1330 root: tree_sitter::Node,
1331 byte_start: usize,
1332 byte_end: usize,
1333) -> Option<(usize, usize)> {
1334 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
1335 let mut current = Some(node);
1336 while let Some(node) = current {
1337 if is_symbol_body_node(node.kind()) {
1338 return Some((node.start_byte(), node.end_byte()));
1339 }
1340 current = node.parent();
1341 }
1342 Some((node.start_byte(), node.end_byte()))
1343}
1344
1345fn smallest_node_covering_range<'tree>(
1346 node: tree_sitter::Node<'tree>,
1347 byte_start: usize,
1348 byte_end: usize,
1349) -> Option<tree_sitter::Node<'tree>> {
1350 if node.start_byte() > byte_start || node.end_byte() < byte_end {
1351 return None;
1352 }
1353
1354 let mut cursor = node.walk();
1355 if cursor.goto_first_child() {
1356 loop {
1357 let child = cursor.node();
1358 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
1359 return Some(found);
1360 }
1361 if !cursor.goto_next_sibling() {
1362 break;
1363 }
1364 }
1365 }
1366
1367 Some(node)
1368}
1369
1370fn is_symbol_body_node(kind: &str) -> bool {
1371 matches!(
1372 kind,
1373 "function_declaration"
1374 | "generator_function_declaration"
1375 | "function_expression"
1376 | "generator_function"
1377 | "arrow_function"
1378 | "method_definition"
1379 | "class_declaration"
1380 | "abstract_class_declaration"
1381 | "class"
1382 | "lexical_declaration"
1383 | "function_definition"
1384 | "class_definition"
1385 | "decorated_definition"
1386 | "function_item"
1387 | "impl_item"
1388 | "method_declaration"
1389 )
1390}
1391
1392fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1393 let mut results = Vec::new();
1394 let call_kinds = crate::calls::call_node_kinds(lang);
1395 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1396 results
1397}
1398
1399fn collect_calls_with_ranges(
1400 node: tree_sitter::Node,
1401 source: &str,
1402 call_kinds: &[&str],
1403 results: &mut Vec<RawCall>,
1404) {
1405 if call_kinds.contains(&node.kind()) {
1406 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1407 results.push(RawCall {
1408 name,
1409 line: node.start_position().row as u32 + 1,
1410 start_byte: node.start_byte(),
1411 end_byte: node.end_byte(),
1412 });
1413 }
1414 }
1415
1416 let mut cursor = node.walk();
1417 if cursor.goto_first_child() {
1418 loop {
1419 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1420 if !cursor.goto_next_sibling() {
1421 break;
1422 }
1423 }
1424 }
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430 use crate::config::Config;
1431 use crate::context::AppContext;
1432 use crate::parser::TreeSitterProvider;
1433 use std::path::PathBuf;
1434
1435 fn fixture_path(name: &str) -> PathBuf {
1436 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1437 .join("tests")
1438 .join("fixtures")
1439 .join(name)
1440 }
1441
1442 fn make_ctx() -> AppContext {
1443 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1444 }
1445
1446 #[test]
1447 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1448 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1449 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1450 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1451 }
1452
1453 #[test]
1454 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1455 let params = serde_json::json!({ "symbols": "Getting Started" });
1456 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1457 assert_eq!(names, vec!["Getting Started"]);
1458 }
1459
1460 #[test]
1461 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1462 let params = serde_json::json!({ "symbol": "Last Heading" });
1463 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1464 assert_eq!(names, vec!["Last Heading"]);
1465 }
1466
1467 #[test]
1468 fn parse_zoom_symbol_names_single_token_unchanged() {
1469 let params = serde_json::json!({ "symbol": "compute" });
1470 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1471 assert_eq!(names, vec!["compute"]);
1472 }
1473
1474 #[test]
1475 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1476 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1477 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1478 assert_eq!(names, vec!["A", "B", "C"]);
1479 }
1480
1481 #[test]
1482 fn parse_zoom_symbol_names_absorbs_stringified_array_for_headings() {
1483 let params =
1485 serde_json::json!({ "symbols": "[\"2. Identity material\", \"3. Enrollment\"]" });
1486 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1487 assert_eq!(names, vec!["2. Identity material", "3. Enrollment"]);
1488 }
1489
1490 #[test]
1491 fn parse_zoom_symbol_names_absorbs_stringified_array_for_code() {
1492 let params = serde_json::json!({ "symbol": "[\"alpha\", \"beta\"]" });
1493 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1494 assert_eq!(names, vec!["alpha", "beta"]);
1495 }
1496
1497 #[test]
1498 fn parse_zoom_symbol_names_bracketed_heading_not_misparsed() {
1499 let params = serde_json::json!({ "symbols": "[Draft] Rollout plan" });
1502 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1503 assert_eq!(names, vec!["[Draft] Rollout plan"]);
1504 }
1505
1506 #[test]
1507 fn parse_zoom_symbol_names_non_string_json_array_not_absorbed() {
1508 let params = serde_json::json!({ "symbols": "[1, 2]" });
1511 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1512 assert_eq!(names, vec!["[1,", "2]"]);
1513 }
1514
1515 #[test]
1518 fn extract_calls_finds_direct_calls() {
1519 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1520 let mut parser = FileParser::new();
1521 let path = fixture_path("calls.ts");
1522 let (tree, lang) = parser.parse(&path).unwrap();
1523
1524 let ctx = make_ctx();
1526 let symbols = ctx.provider().list_symbols(&path).unwrap();
1527 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1528
1529 let byte_start =
1530 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1531 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1532
1533 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1534 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1535
1536 assert!(
1537 names.contains(&"helper"),
1538 "compute should call helper, got: {:?}",
1539 names
1540 );
1541 }
1542
1543 #[test]
1544 fn extract_calls_finds_member_calls() {
1545 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1546 let mut parser = FileParser::new();
1547 let path = fixture_path("calls.ts");
1548 let (tree, lang) = parser.parse(&path).unwrap();
1549
1550 let ctx = make_ctx();
1551 let symbols = ctx.provider().list_symbols(&path).unwrap();
1552 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1553
1554 let byte_start =
1555 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1556 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1557
1558 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1559 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1560
1561 assert!(
1562 names.contains(&"add"),
1563 "runAll should call this.add, got: {:?}",
1564 names
1565 );
1566 assert!(
1567 names.contains(&"helper"),
1568 "runAll should call helper, got: {:?}",
1569 names
1570 );
1571 }
1572
1573 #[test]
1574 fn extract_calls_unused_function_has_no_calls() {
1575 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1576 let mut parser = FileParser::new();
1577 let path = fixture_path("calls.ts");
1578 let (tree, lang) = parser.parse(&path).unwrap();
1579
1580 let ctx = make_ctx();
1581 let symbols = ctx.provider().list_symbols(&path).unwrap();
1582 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1583
1584 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1585 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1586
1587 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1588 let known_names = [
1590 "helper",
1591 "compute",
1592 "orchestrate",
1593 "unused",
1594 "format",
1595 "display",
1596 ];
1597 let filtered: Vec<&str> = calls
1598 .iter()
1599 .map(|(n, _)| n.as_str())
1600 .filter(|n| known_names.contains(n))
1601 .collect();
1602 assert!(
1603 filtered.is_empty(),
1604 "unused should not call known symbols, got: {:?}",
1605 filtered
1606 );
1607 }
1608
1609 #[test]
1612 fn context_lines_clamp_at_file_start() {
1613 let ctx = make_ctx();
1615 let path = fixture_path("calls.ts");
1616 let symbols = ctx.provider().list_symbols(&path).unwrap();
1617 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1618
1619 let source = std::fs::read_to_string(&path).unwrap();
1620 let lines: Vec<&str> = source.lines().collect();
1621 let start = helper.range.start_line as usize;
1622
1623 let ctx_start = start.saturating_sub(5);
1625 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1626 assert!(context_before.len() <= start);
1628 }
1629
1630 #[test]
1631 fn context_lines_clamp_at_file_end() {
1632 let ctx = make_ctx();
1633 let path = fixture_path("calls.ts");
1634 let symbols = ctx.provider().list_symbols(&path).unwrap();
1635 let display = symbols.iter().find(|s| s.name == "display").unwrap();
1636
1637 let source = std::fs::read_to_string(&path).unwrap();
1638 let lines: Vec<&str> = source.lines().collect();
1639 let end = display.range.end_line as usize;
1640
1641 let ctx_end = (end + 1 + 20).min(lines.len());
1643 let context_after: Vec<&str> = if end + 1 < lines.len() {
1644 lines[(end + 1)..ctx_end].to_vec()
1645 } else {
1646 vec![]
1647 };
1648 assert!(context_after.len() <= 20);
1650 }
1651
1652 #[test]
1655 fn body_extraction_matches_source() {
1656 let ctx = make_ctx();
1657 let path = fixture_path("calls.ts");
1658 let symbols = ctx.provider().list_symbols(&path).unwrap();
1659 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1660
1661 let source = std::fs::read_to_string(&path).unwrap();
1662 let lines: Vec<&str> = source.lines().collect();
1663 let start = compute.range.start_line as usize;
1664 let end = compute.range.end_line as usize;
1665 let body = lines[start..=end].join("\n");
1666
1667 assert!(
1668 body.contains("function compute"),
1669 "body should contain function declaration"
1670 );
1671 assert!(
1672 body.contains("helper(a)"),
1673 "body should contain call to helper"
1674 );
1675 assert!(
1676 body.contains("doubled + b"),
1677 "body should contain return expression"
1678 );
1679 }
1680
1681 #[test]
1684 fn body_range_expands_signature_range_to_include_body_calls() {
1685 let source = r#"function compute(
1686 value: number,
1687): number {
1688 return helper(value);
1689}
1690
1691function helper(value: number): number {
1692 return value * 2;
1693}
1694"#;
1695 let grammar = crate::parser::grammar_for(LangId::TypeScript);
1696 let mut parser = tree_sitter::Parser::new();
1697 parser.set_language(&grammar).unwrap();
1698 let tree = parser.parse(source, None).unwrap();
1699 let signature_end = source.find('{').expect("function has body");
1700
1701 let (body_start, body_end) =
1702 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1703 let calls = extract_calls_in_range(
1704 source,
1705 tree.root_node(),
1706 body_start,
1707 body_end,
1708 LangId::TypeScript,
1709 );
1710 let names = calls
1711 .iter()
1712 .map(|(name, _)| name.as_str())
1713 .collect::<Vec<_>>();
1714
1715 assert!(
1716 names.contains(&"helper"),
1717 "call inside the function body should be included: {names:?}"
1718 );
1719 }
1720
1721 #[test]
1722 fn zoom_leaf_returns_full_body_without_budget_marker() {
1723 let ctx = make_ctx();
1724 let path = fixture_path("calls.ts");
1725 let req = make_zoom_request(
1726 "z-leaf-full",
1727 path.to_str().unwrap(),
1728 "repeatedOutgoing",
1729 None,
1730 );
1731 let resp = handle_zoom(&req, &ctx);
1732 let json = serde_json::to_value(&resp).unwrap();
1733 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1734
1735 let symbols = ctx.provider().list_symbols(&path).unwrap();
1736 let target = symbols
1737 .iter()
1738 .find(|symbol| symbol.name == "repeatedOutgoing")
1739 .unwrap();
1740 let source = std::fs::read_to_string(&path).unwrap();
1741 let lines = source.lines().collect::<Vec<_>>();
1742 let expected =
1743 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1744
1745 assert_eq!(json["content"].as_str().unwrap(), expected);
1746 assert!(
1747 !json["content"]
1748 .as_str()
1749 .unwrap()
1750 .contains("more lines — zoom"),
1751 "explicit zoom must not budget-cap leaf bodies"
1752 );
1753 }
1754
1755 #[test]
1756 fn zoom_response_has_calls_out_and_called_by() {
1757 let ctx = make_ctx();
1758 let path = fixture_path("calls.ts");
1759
1760 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1761 let resp = handle_zoom(&req, &ctx);
1762
1763 let json = serde_json::to_value(&resp).unwrap();
1764 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1765
1766 let calls_out = json["annotations"]["calls_out"]
1767 .as_array()
1768 .expect("calls_out array");
1769 let out_names: Vec<&str> = calls_out
1770 .iter()
1771 .map(|c| c["name"].as_str().unwrap())
1772 .collect();
1773 assert!(
1774 out_names.contains(&"helper"),
1775 "compute calls helper: {:?}",
1776 out_names
1777 );
1778
1779 let called_by = json["annotations"]["called_by"]
1780 .as_array()
1781 .expect("called_by array");
1782 let by_names: Vec<&str> = called_by
1783 .iter()
1784 .map(|c| c["name"].as_str().unwrap())
1785 .collect();
1786 assert!(
1787 by_names.contains(&"orchestrate"),
1788 "orchestrate calls compute: {:?}",
1789 by_names
1790 );
1791 }
1792
1793 #[test]
1794 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1795 let ctx = make_ctx();
1796 let path = fixture_path("calls.ts");
1797
1798 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1799 let resp = handle_zoom(&req, &ctx);
1800 let json = serde_json::to_value(&resp).unwrap();
1801 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1802
1803 let calls_out = json["annotations"]["calls_out"]
1804 .as_array()
1805 .expect("calls_out array");
1806 let helper_refs = calls_out
1807 .iter()
1808 .filter(|call| call["name"] == "helper")
1809 .collect::<Vec<_>>();
1810 assert_eq!(
1811 helper_refs.len(),
1812 1,
1813 "helper should be folded once: {calls_out:?}"
1814 );
1815 assert_eq!(helper_refs[0]["extra_count"], 1);
1816 assert!(
1817 calls_out.iter().any(|call| call["name"] == "format"),
1818 "distinct callee must not be folded into helper: {calls_out:?}"
1819 );
1820
1821 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1822 let resp = handle_zoom(&req, &ctx);
1823 let json = serde_json::to_value(&resp).unwrap();
1824 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1825
1826 let called_by = json["annotations"]["called_by"]
1827 .as_array()
1828 .expect("called_by array");
1829 let repeat_refs = called_by
1830 .iter()
1831 .filter(|call| call["name"] == "repeatCompute")
1832 .collect::<Vec<_>>();
1833 assert_eq!(
1834 repeat_refs.len(),
1835 1,
1836 "repeatCompute should be folded once: {called_by:?}"
1837 );
1838 assert_eq!(repeat_refs[0]["extra_count"], 1);
1839 assert!(
1840 called_by.iter().any(|call| call["name"] == "orchestrate"),
1841 "distinct caller must not be folded into repeatCompute: {called_by:?}"
1842 );
1843 }
1844
1845 #[test]
1846 fn zoom_response_empty_annotations_for_unused() {
1847 let ctx = make_ctx();
1848 let path = fixture_path("calls.ts");
1849
1850 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1851 let resp = handle_zoom(&req, &ctx);
1852
1853 let json = serde_json::to_value(&resp).unwrap();
1854 assert_eq!(json["success"], true);
1855
1856 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1857 let called_by = json["annotations"]["called_by"].as_array().unwrap();
1858
1859 assert!(
1862 called_by.is_empty(),
1863 "unused should not be called by anyone: {:?}",
1864 called_by
1865 );
1866 }
1867
1868 #[test]
1869 fn zoom_default_omits_callgraph_annotations() {
1870 let ctx = make_ctx();
1871 let path = fixture_path("calls.ts");
1872
1873 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1874 let resp = handle_zoom(&req, &ctx);
1875
1876 let json = serde_json::to_value(&resp).unwrap();
1877 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1878
1879 let calls_out = json["annotations"]["calls_out"]
1880 .as_array()
1881 .expect("calls_out array");
1882 let called_by = json["annotations"]["called_by"]
1883 .as_array()
1884 .expect("called_by array");
1885 assert!(
1886 calls_out.is_empty(),
1887 "default zoom should omit calls_out: {:?}",
1888 calls_out
1889 );
1890 assert!(
1891 called_by.is_empty(),
1892 "default zoom should omit called_by: {:?}",
1893 called_by
1894 );
1895 }
1896
1897 #[test]
1898 fn zoom_symbol_not_found() {
1899 let ctx = make_ctx();
1900 let path = fixture_path("calls.ts");
1901
1902 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1903 let resp = handle_zoom(&req, &ctx);
1904
1905 let json = serde_json::to_value(&resp).unwrap();
1906 assert_eq!(json["success"], false);
1907 assert_eq!(json["code"], "symbol_not_found");
1908 }
1909
1910 #[test]
1911 fn zoom_custom_context_lines() {
1912 let ctx = make_ctx();
1913 let path = fixture_path("calls.ts");
1914
1915 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1916 let resp = handle_zoom(&req, &ctx);
1917
1918 let json = serde_json::to_value(&resp).unwrap();
1919 assert_eq!(json["success"], true);
1920
1921 let ctx_before = json["context_before"].as_array().unwrap();
1922 let ctx_after = json["context_after"].as_array().unwrap();
1923 assert!(
1925 ctx_before.len() <= 1,
1926 "context_before should be ≤1: {:?}",
1927 ctx_before
1928 );
1929 assert!(
1930 ctx_after.len() <= 1,
1931 "context_after should be ≤1: {:?}",
1932 ctx_after
1933 );
1934 }
1935
1936 #[test]
1937 fn zoom_missing_file_param() {
1938 let ctx = make_ctx();
1939 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1940 let resp = handle_zoom(&req, &ctx);
1941
1942 let json = serde_json::to_value(&resp).unwrap();
1943 assert_eq!(json["success"], false);
1944 assert_eq!(json["code"], "invalid_request");
1945 }
1946
1947 #[test]
1948 fn zoom_missing_symbol_param() {
1949 let ctx = make_ctx();
1950 let path = fixture_path("calls.ts");
1951 let req_value = serde_json::json!({
1955 "id": "z-6",
1956 "command": "zoom",
1957 "file": path.to_string_lossy(),
1958 });
1959 let req_str = req_value.to_string();
1960 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1961 let resp = handle_zoom(&req, &ctx);
1962
1963 let json = serde_json::to_value(&resp).unwrap();
1964 assert_eq!(json["success"], false);
1965 assert_eq!(json["code"], "invalid_request");
1966 }
1967
1968 #[test]
1969 fn test_suggest_close_symbols_unit() {
1970 let available = vec![
1971 "handle_grep_search".to_string(),
1972 "handle_semantic_search".to_string(),
1973 "handle_semantic_or_hybrid_search".to_string(),
1974 "compute_total".to_string(),
1975 "search".to_string(),
1976 "handle_search".to_string(),
1977 ];
1978
1979 let suggestions = suggest_close_symbols("handle_search", &available, 5);
1980 assert!(suggestions.contains(&"handle_grep_search".to_string()));
1981 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1982 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1983 assert!(suggestions.contains(&"search".to_string()));
1984 assert!(!suggestions.contains(&"compute_total".to_string()));
1985
1986 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1987 assert_eq!(suggestions, suggestions_caps);
1988
1989 let available2 = vec![
1990 "total".to_string(),
1991 "compute_total".to_string(),
1992 "unrelated".to_string(),
1993 ];
1994 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1995 assert_eq!(suggestions2, vec!["total".to_string()]);
1996 }
1997
1998 fn make_zoom_request(
2001 id: &str,
2002 file: &str,
2003 symbol: &str,
2004 context_lines: Option<u64>,
2005 ) -> RawRequest {
2006 let mut json = serde_json::json!({
2007 "id": id,
2008 "command": "zoom",
2009 "file": file,
2010 "symbol": symbol,
2011 });
2012 if let Some(cl) = context_lines {
2013 json["context_lines"] = serde_json::json!(cl);
2014 }
2015 serde_json::from_value(json).unwrap()
2016 }
2017
2018 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
2019 let mut req = make_zoom_request(id, file, symbol, None);
2020 req.params["callgraph"] = serde_json::json!(true);
2021 req
2022 }
2023
2024 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
2025 serde_json::from_str(json_str).unwrap()
2026 }
2027}