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::lsp_hints;
15use crate::parser::{detect_language, FileParser, LangId};
16use crate::protocol::{RawRequest, Response};
17use crate::symbols::Range;
18use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
19
20#[derive(Debug, Clone, Serialize)]
22pub struct CallRef {
23 pub name: String,
24 pub line: u32,
26 #[serde(skip_serializing_if = "is_zero")]
28 pub extra_count: u32,
29}
30
31fn is_zero(value: &u32) -> bool {
32 *value == 0
33}
34
35fn dedupe_call_refs_by_name(calls: Vec<CallRef>) -> Vec<CallRef> {
36 let mut index_by_name: HashMap<String, usize> = HashMap::new();
37 let mut deduped: Vec<CallRef> = Vec::new();
38
39 for call in calls {
40 if let Some(index) = index_by_name.get(&call.name).copied() {
41 deduped[index].extra_count = deduped[index]
42 .extra_count
43 .saturating_add(call.extra_count.saturating_add(1));
44 } else {
45 index_by_name.insert(call.name.clone(), deduped.len());
46 deduped.push(call);
47 }
48 }
49
50 deduped
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct Annotations {
56 pub calls_out: Vec<CallRef>,
57 pub called_by: Vec<CallRef>,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct ZoomResponse {
63 pub name: String,
64 pub kind: String,
65 pub range: Range,
66 pub content: String,
67 pub context_before: Vec<String>,
68 pub context_after: Vec<String>,
69 pub annotations: Annotations,
70}
71
72struct RawCall {
73 name: String,
74 line: u32,
75 start_byte: usize,
76 end_byte: usize,
77}
78
79fn resolve_file_or_url(
80 req: &RawRequest,
81 ctx: &AppContext,
82 file: &str,
83) -> Result<PathBuf, Response> {
84 if is_http_url(file) {
85 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
86 let allow_private = ctx.config().url_fetch_allow_private
87 || req
88 .params
89 .get("allow_private")
90 .and_then(|value| value.as_bool())
91 .unwrap_or(false);
92 return fetch_url_to_cache(
93 file,
94 &storage_dir,
95 UrlFetchOptions {
96 allow_private,
97 ..UrlFetchOptions::default()
98 },
99 )
100 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
101 }
102
103 ctx.validate_path(&req.id, Path::new(file))
104}
105
106fn zoom_one_target_response(
107 req: &RawRequest,
108 ctx: &AppContext,
109 file: &str,
110 symbol: &str,
111 context_lines: usize,
112 include_callgraph: bool,
113) -> Response {
114 let path = match resolve_file_or_url(req, ctx, file) {
115 Ok(path) => path,
116 Err(resp) => return resp,
117 };
118 if !path.exists() {
119 return Response::error(
120 &req.id,
121 "file_not_found",
122 format!("file not found: {}", file),
123 );
124 }
125
126 let source = match std::fs::read_to_string(&path) {
127 Ok(source) => source,
128 Err(error) => {
129 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, error));
130 }
131 };
132 let lines: Vec<String> = source.lines().map(|line| line.to_string()).collect();
133
134 zoom_one_symbol(
135 req,
136 ctx,
137 &path,
138 file,
139 &source,
140 &lines,
141 symbol,
142 context_lines,
143 include_callgraph,
144 )
145}
146
147fn serialize_zoom_target_response(req: &RawRequest, response: Response) -> serde_json::Value {
148 serde_json::to_value(&response).unwrap_or_else(|error| {
149 serde_json::to_value(Response::error(
150 &req.id,
151 "internal_error",
152 format!("zoom: failed to serialize target response: {error}"),
153 ))
154 .expect("serializing Response::error should not fail")
155 })
156}
157
158fn handle_zoom_targets(
159 req: &RawRequest,
160 ctx: &AppContext,
161 targets: &[serde_json::Value],
162 context_lines: usize,
163 include_callgraph: bool,
164) -> Response {
165 if targets.is_empty() {
166 return Response::error(
167 &req.id,
168 "invalid_request",
169 "zoom: 'targets' must be a non-empty array",
170 );
171 }
172
173 let mut entries = Vec::with_capacity(targets.len());
174 for (index, target) in targets.iter().enumerate() {
175 let obj = target.as_object();
176 let Some(file) = obj
177 .and_then(|obj| obj.get("file"))
178 .and_then(|value| value.as_str())
179 .filter(|file| !file.is_empty())
180 else {
181 return Response::error(
182 &req.id,
183 "invalid_request",
184 format!("zoom: targets[{index}].file must be a non-empty string"),
185 );
186 };
187 let Some(symbol) = obj
188 .and_then(|obj| obj.get("symbol"))
189 .and_then(|value| value.as_str())
190 .filter(|symbol| !symbol.is_empty())
191 else {
192 return Response::error(
193 &req.id,
194 "invalid_request",
195 format!("zoom: targets[{index}].symbol must be a non-empty string"),
196 );
197 };
198 let target_label = obj
199 .and_then(|obj| obj.get("target_label").or_else(|| obj.get("targetLabel")))
200 .and_then(|value| value.as_str())
201 .filter(|label| !label.is_empty())
202 .unwrap_or(file);
203
204 let response =
205 zoom_one_target_response(req, ctx, file, symbol, context_lines, include_callgraph);
206 entries.push(serde_json::json!({
207 "targetLabel": target_label,
208 "name": symbol,
209 "response": serialize_zoom_target_response(req, response),
210 }));
211 }
212
213 Response::success(
214 &req.id,
215 serde_json::json!({
216 "targets": entries,
217 }),
218 )
219}
220
221pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
228 let context_lines = req
229 .params
230 .get("context_lines")
231 .and_then(|v| v.as_u64())
232 .unwrap_or(3) as usize;
233 let include_callgraph = req
234 .params
235 .get("callgraph")
236 .and_then(|v| v.as_bool())
237 .unwrap_or(false);
238
239 if let Some(targets_value) = req.params.get("targets") {
240 let Some(targets) = targets_value.as_array() else {
241 return Response::error(
242 &req.id,
243 "invalid_request",
244 "zoom: 'targets' must be a non-empty array",
245 );
246 };
247 return handle_zoom_targets(req, ctx, targets, context_lines, include_callgraph);
248 }
249
250 let file = match req
251 .params
252 .get("file")
253 .or_else(|| req.params.get("url"))
254 .and_then(|v| v.as_str())
255 {
256 Some(f) => f,
257 None => {
258 return Response::error(
259 &req.id,
260 "invalid_request",
261 "zoom: missing required param 'file'",
262 );
263 }
264 };
265
266 let start_line = req
267 .params
268 .get("start_line")
269 .and_then(|v| v.as_u64())
270 .map(|v| v as usize);
271 let end_line = req
272 .params
273 .get("end_line")
274 .and_then(|v| v.as_u64())
275 .map(|v| v as usize);
276
277 let path = match resolve_file_or_url(req, ctx, file) {
278 Ok(path) => path,
279 Err(resp) => return resp,
280 };
281 if !path.exists() {
282 return Response::error(
283 &req.id,
284 "file_not_found",
285 format!("file not found: {}", file),
286 );
287 }
288
289 let source = match std::fs::read_to_string(&path) {
291 Ok(s) => s,
292 Err(e) => {
293 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
294 }
295 };
296
297 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
298
299 match (start_line, end_line) {
301 (Some(start), Some(end)) => {
302 if zoom_symbol_param(&req.params).is_some() {
303 return Response::error(
304 &req.id,
305 "invalid_request",
306 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
307 );
308 }
309 if start == 0 || end == 0 {
310 return Response::error(
311 &req.id,
312 "invalid_request",
313 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
314 );
315 }
316 if end < start {
317 return Response::error(
318 &req.id,
319 "invalid_request",
320 format!("zoom: end_line {} must be >= start_line {}", end, start),
321 );
322 }
323 if lines.is_empty() {
324 return Response::error(
325 &req.id,
326 "invalid_request",
327 format!("zoom: {} is empty", file),
328 );
329 }
330
331 let start_idx = start - 1;
332 let clamped_end = end.min(lines.len());
334 let end_idx = clamped_end - 1;
335 if start_idx >= lines.len() {
336 return Response::error(
337 &req.id,
338 "invalid_request",
339 format!(
340 "zoom: start_line {} is past end of {} ({} lines)",
341 start,
342 file,
343 lines.len()
344 ),
345 );
346 }
347
348 let content = lines[start_idx..=end_idx].join("\n");
349 let ctx_start = start_idx.saturating_sub(context_lines);
350 let context_before: Vec<String> = if ctx_start < start_idx {
351 lines[ctx_start..start_idx]
352 .iter()
353 .map(|l| l.to_string())
354 .collect()
355 } else {
356 vec![]
357 };
358 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
359 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
360 lines[(end_idx + 1)..ctx_end]
361 .iter()
362 .map(|l| l.to_string())
363 .collect()
364 } else {
365 vec![]
366 };
367 let end_col = lines[end_idx].chars().count() as u32;
368
369 return Response::success(
370 &req.id,
371 serde_json::json!({
372 "name": format!("lines {}-{}", start, clamped_end),
373 "kind": "lines",
374 "range": {
375 "start_line": start, "start_col": 1,
377 "end_line": clamped_end,
378 "end_col": end_col + 1,
379 },
380 "content": content,
381 "context_before": context_before,
382 "context_after": context_after,
383 "annotations": {
384 "calls_out": [],
385 "called_by": [],
386 },
387 }),
388 );
389 }
390 (Some(_), None) | (None, Some(_)) => {
391 return Response::error(
392 &req.id,
393 "invalid_request",
394 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
395 );
396 }
397 (None, None) => {}
398 }
399
400 let lang = detect_language(&path);
401 let symbol_names = match parse_zoom_symbol_names(&req.params, lang) {
402 Ok(names) => names,
403 Err(resp) => return resp,
404 };
405
406 if symbol_names.is_empty() {
407 return Response::error(
408 &req.id,
409 "invalid_request",
410 "zoom: missing required param 'symbol'",
411 );
412 }
413
414 if symbol_names.len() == 1 {
415 return zoom_one_symbol(
416 req,
417 ctx,
418 &path,
419 file,
420 &source,
421 &lines,
422 &symbol_names[0],
423 context_lines,
424 include_callgraph,
425 );
426 }
427
428 zoom_batch_symbols(
429 req,
430 ctx,
431 &path,
432 file,
433 &source,
434 &lines,
435 &symbol_names,
436 context_lines,
437 include_callgraph,
438 )
439}
440
441fn zoom_symbol_param(params: &serde_json::Value) -> Option<&str> {
443 params
444 .get("symbol")
445 .or_else(|| params.get("symbols"))
446 .and_then(|v| v.as_str())
447}
448
449fn is_heading_zoom_language(lang: Option<LangId>) -> bool {
450 matches!(lang, Some(LangId::Markdown | LangId::Html))
451}
452
453fn parse_zoom_symbol_names(
458 params: &serde_json::Value,
459 lang: Option<LangId>,
460) -> Result<Vec<String>, Response> {
461 if let Some(arr) = params.get("symbols").and_then(|v| v.as_array()) {
462 let names: Vec<String> = arr
463 .iter()
464 .filter_map(|v| v.as_str().map(str::trim))
465 .filter(|s| !s.is_empty())
466 .map(str::to_string)
467 .collect();
468 return Ok(names);
469 }
470
471 let Some(raw) = zoom_symbol_param(params) else {
472 return Ok(Vec::new());
473 };
474
475 if is_heading_zoom_language(lang) {
476 let trimmed = raw.trim();
477 if trimmed.is_empty() {
478 return Ok(Vec::new());
479 }
480 return Ok(vec![trimmed.to_string()]);
481 }
482
483 if raw.split_whitespace().count() <= 1 {
484 let trimmed = raw.trim();
485 if trimmed.is_empty() {
486 return Ok(Vec::new());
487 }
488 return Ok(vec![trimmed.to_string()]);
489 }
490
491 Ok(raw.split_whitespace().map(str::to_string).collect())
492}
493
494fn zoom_batch_symbols(
495 req: &RawRequest,
496 ctx: &AppContext,
497 path: &Path,
498 file: &str,
499 source: &str,
500 lines: &[String],
501 symbol_names: &[String],
502 context_lines: usize,
503 include_callgraph: bool,
504) -> Response {
505 let mut entries = Vec::with_capacity(symbol_names.len());
506 let mut all_ok = true;
507
508 for name in symbol_names {
509 let resp = zoom_one_symbol(
510 req,
511 ctx,
512 path,
513 file,
514 source,
515 lines,
516 name,
517 context_lines,
518 include_callgraph,
519 );
520 let json = match serde_json::to_value(&resp) {
521 Ok(v) => v,
522 Err(err) => {
523 return Response::error(
524 &req.id,
525 "internal_error",
526 format!("zoom: failed to serialize batch entry: {err}"),
527 );
528 }
529 };
530 if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
531 all_ok = false;
532 }
533 entries.push(serde_json::json!({
534 "name": name,
535 "response": json,
536 }));
537 }
538
539 Response::success(
540 &req.id,
541 serde_json::json!({
542 "complete": all_ok,
543 "symbols": entries,
544 }),
545 )
546}
547
548fn zoom_one_symbol(
549 req: &RawRequest,
550 ctx: &AppContext,
551 path: &Path,
552 _file: &str,
553 source: &str,
554 lines: &[String],
555 symbol_name: &str,
556 context_lines: usize,
557 include_callgraph: bool,
558) -> Response {
559 let lookup_name = match detect_language(path) {
563 Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
564 _ => symbol_name,
565 };
566 let matches = match ctx.provider().resolve_symbol(path, lookup_name) {
567 Ok(m) => m,
568 Err(crate::error::AftError::SymbolNotFound { name, .. }) => {
569 let mut msg = format!("symbol '{}' not found", name);
570 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
571 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
572 let suggestions = suggest_close_symbols(&name, &available, 5);
573 if !suggestions.is_empty() {
574 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
575 }
576 }
577 return Response::error(&req.id, "symbol_not_found", msg);
578 }
579 Err(e) => {
580 return Response::error(&req.id, e.code(), e.to_string());
581 }
582 };
583
584 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
586 lsp_hints::apply_lsp_disambiguation(matches, &hints)
587 } else {
588 matches
589 };
590
591 if matches.len() > 1 {
592 let content = render_ambiguous_symbol_menu(symbol_name, &matches);
593 let candidates = matches
594 .iter()
595 .map(|candidate| {
596 let sym = &candidate.symbol;
597 serde_json::json!({
598 "name": sym.name.clone(),
599 "qualified_name": qualified_symbol_name(sym),
600 "kind": symbol_kind_string(&sym.kind),
601 "range": sym.range.clone(),
602 "signature": sym.signature.clone(),
603 })
604 })
605 .collect::<Vec<_>>();
606
607 return Response::success(
608 &req.id,
609 serde_json::json!({
610 "name": symbol_name,
611 "kind": "ambiguous_symbol",
612 "content": content,
613 "context_before": [],
614 "context_after": [],
615 "annotations": empty_annotations(),
616 "candidates": candidates,
617 }),
618 );
619 }
620
621 if matches.is_empty() {
622 let mut msg = format!("symbol '{}' not found", symbol_name);
623 if let Ok(all_symbols) = ctx.provider().list_symbols(path) {
624 let available: Vec<String> = all_symbols.into_iter().map(|s| s.name).collect();
625 let suggestions = suggest_close_symbols(symbol_name, &available, 5);
626 if !suggestions.is_empty() {
627 msg.push_str(&format!(", did you mean: [{}]", suggestions.join(", ")));
628 }
629 }
630 return Response::error(&req.id, "symbol_not_found", msg);
631 }
632
633 let target = &matches[0].symbol;
634 let start = target.range.start_line as usize;
635 let end = target.range.end_line as usize;
636
637 let resolved_file_path = std::path::Path::new(&matches[0].file);
639 let resolved_lines: Vec<String>;
640 let effective_lines: &[String] = if resolved_file_path != path {
641 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
642 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
643 Err(_) => lines.to_vec(),
644 };
645 &resolved_lines
646 } else {
647 lines
648 };
649
650 let content = if end < effective_lines.len() {
652 effective_lines[start..=end].join("\n")
653 } else {
654 effective_lines[start..].join("\n")
655 };
656
657 let resolved_lang = detect_language(resolved_file_path);
658 let container_outline = if might_have_container_members(target) {
659 match build_container_outline(ctx, resolved_file_path, target) {
660 Ok(outline) => Some(outline),
661 Err(e) => {
662 return Response::error(&req.id, e.code(), e.to_string());
663 }
664 }
665 } else {
666 None
667 };
668
669 if should_return_member_menu(target, resolved_lang, container_outline.as_ref()) {
670 let kind_str = symbol_kind_string(&target.kind);
671 let menu = render_container_member_menu(target, container_outline.as_ref().unwrap());
672 let resp = ZoomResponse {
673 name: target.name.clone(),
674 kind: kind_str,
675 range: target.range.clone(),
676 content: menu,
677 context_before: Vec::new(),
678 context_after: Vec::new(),
679 annotations: Annotations {
680 calls_out: Vec::new(),
681 called_by: Vec::new(),
682 },
683 };
684 return match serde_json::to_value(&resp) {
685 Ok(resp_json) => Response::success(&req.id, resp_json),
686 Err(err) => Response::error(
687 &req.id,
688 "internal_error",
689 format!("zoom: failed to serialize response: {err}"),
690 ),
691 };
692 }
693
694 let ctx_start = start.saturating_sub(context_lines);
696 let context_before: Vec<String> = if ctx_start < start {
697 effective_lines[ctx_start..start]
698 .iter()
699 .map(|l| l.to_string())
700 .collect()
701 } else {
702 vec![]
703 };
704
705 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
707 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
708 effective_lines[(end + 1)..ctx_end]
709 .iter()
710 .map(|l| l.to_string())
711 .collect()
712 } else {
713 vec![]
714 };
715
716 let (calls_out, called_by) = if include_callgraph {
717 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
719 Ok(s) => s,
720 Err(e) => {
721 return Response::error(&req.id, e.code(), e.to_string());
722 }
723 };
724
725 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
726
727 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
729 let (tree, lang) = match parser.parse(resolved_file_path) {
730 Ok(r) => r,
731 Err(e) => {
732 return Response::error(&req.id, e.code(), e.to_string());
733 }
734 };
735
736 let resolved_source = if resolved_file_path != path {
738 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.to_string())
739 } else {
740 source.to_string()
741 };
742 let signature_byte_start = line_col_to_byte(
743 &resolved_source,
744 target.range.start_line,
745 target.range.start_col,
746 );
747 let signature_byte_end = line_col_to_byte(
748 &resolved_source,
749 target.range.end_line,
750 target.range.end_col,
751 );
752 let (target_byte_start, target_byte_end) =
753 symbol_body_byte_range(tree.root_node(), signature_byte_start, signature_byte_end)
754 .unwrap_or((signature_byte_start, signature_byte_end));
755
756 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
757
758 let raw_calls = all_file_calls.iter().filter(|call| {
759 call.start_byte >= target_byte_start && call.end_byte <= target_byte_end
760 });
761 let calls_out = dedupe_call_refs_by_name(
762 raw_calls
763 .filter(|call| {
764 known_names.contains(&call.name.as_str()) && call.name != target.name
765 })
766 .map(|call| CallRef {
767 name: call.name.clone(),
768 line: call.line,
769 extra_count: 0,
770 })
771 .collect(),
772 );
773
774 let mut called_by: Vec<CallRef> = Vec::new();
776 for sym in &all_symbols {
777 if sym.name == target.name && sym.range.start_line == target.range.start_line {
778 continue; }
780 let sym_byte_start =
781 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
782 let sym_byte_end =
783 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
784 for call in &all_file_calls {
785 if call.name == target.name
786 && call.start_byte >= sym_byte_start
787 && call.end_byte <= sym_byte_end
788 {
789 called_by.push(CallRef {
790 name: sym.name.clone(),
791 line: call.line,
792 extra_count: 0,
793 });
794 }
795 }
796 }
797
798 let called_by = dedupe_call_refs_by_name(called_by);
799
800 (calls_out, called_by)
801 } else {
802 (Vec::new(), Vec::new())
803 };
804
805 let kind_str = symbol_kind_string(&target.kind);
806
807 let resp = ZoomResponse {
808 name: target.name.clone(),
809 kind: kind_str,
810 range: target.range.clone(),
811 content,
812 context_before,
813 context_after,
814 annotations: Annotations {
815 calls_out,
816 called_by,
817 },
818 };
819
820 match serde_json::to_value(&resp) {
821 Ok(resp_json) => Response::success(&req.id, resp_json),
822 Err(err) => Response::error(
823 &req.id,
824 "internal_error",
825 format!("zoom: failed to serialize response: {err}"),
826 ),
827 }
828}
829
830fn empty_annotations() -> serde_json::Value {
831 serde_json::json!({
832 "calls_out": [],
833 "called_by": [],
834 })
835}
836
837fn render_ambiguous_symbol_menu(
838 symbol_name: &str,
839 matches: &[crate::symbols::SymbolMatch],
840) -> String {
841 let mut lines = vec![format!(
842 "symbol '{symbol_name}' is ambiguous ({} candidates) — zoom a qualified name for its body",
843 matches.len()
844 )];
845
846 for candidate in matches {
847 let entry = symbol_to_entry(&candidate.symbol);
848 lines.push(format!(
849 "- {}",
850 format_qualified_entry(&entry, Some(&candidate.symbol))
851 ));
852 }
853
854 lines.join("\n")
855}
856
857fn levenshtein_distance(s1: &str, s2: &str) -> usize {
858 let s1_chars: Vec<char> = s1.chars().collect();
859 let s2_chars: Vec<char> = s2.chars().collect();
860 let len1 = s1_chars.len();
861 let len2 = s2_chars.len();
862
863 let mut dp = vec![vec![0; len2 + 1]; len1 + 1];
864
865 for i in 0..=len1 {
866 dp[i][0] = i;
867 }
868 for j in 0..=len2 {
869 dp[0][j] = j;
870 }
871
872 for i in 1..=len1 {
873 for j in 1..=len2 {
874 if s1_chars[i - 1] == s2_chars[j - 1] {
875 dp[i][j] = dp[i - 1][j - 1];
876 } else {
877 dp[i][j] =
878 1 + std::cmp::min(dp[i - 1][j], std::cmp::min(dp[i][j - 1], dp[i - 1][j - 1]));
879 }
880 }
881 }
882
883 dp[len1][len2]
884}
885
886fn suggest_close_symbols(query: &str, available: &[String], k: usize) -> Vec<String> {
887 let mut unique: Vec<&String> = available.iter().collect();
888 unique.sort();
889 unique.dedup();
890
891 let query_lower = query.to_lowercase();
892 let query_len = query_lower.chars().count();
893 let max_dist = std::cmp::max(2, query_len / 3);
894
895 let mut scored: Vec<(bool, usize, &String)> = unique
896 .into_iter()
897 .map(|name| {
898 let name_lower = name.to_lowercase();
899 let is_substring =
900 name_lower.contains(&query_lower) || query_lower.contains(&name_lower);
901 let is_wildcard = if let (Some(first_idx), Some(last_idx)) =
902 (query_lower.find('_'), query_lower.rfind('_'))
903 {
904 let prefix = &query_lower[..=first_idx];
905 let suffix = &query_lower[last_idx..];
906 name_lower.starts_with(prefix) && name_lower.ends_with(suffix)
907 } else {
908 false
909 };
910 let is_match = is_substring || is_wildcard;
911 let dist = levenshtein_distance(&query_lower, &name_lower);
912 (is_match, dist, name)
913 })
914 .filter(|&(is_match, dist, _)| is_match || dist <= max_dist)
915 .collect();
916
917 scored.sort_by(|a, b| {
918 let a_match = a.0;
919 let b_match = b.0;
920 (!a_match)
921 .cmp(&(!b_match))
922 .then_with(|| a.1.cmp(&b.1))
923 .then_with(|| a.2.cmp(b.2))
924 });
925
926 scored
927 .into_iter()
928 .take(k)
929 .map(|(_, _, name)| name.clone())
930 .collect()
931}
932
933fn normalize_heading_query(input: &str) -> &str {
934 let trimmed = input.trim_start();
935 let hash_stripped = trimmed.trim_start_matches('#').trim_start();
936
937 if let Some(after_open) = hash_stripped.strip_prefix('<') {
938 let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
939 let mut chars = after_slash.chars();
940 if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
941 if let Some(end) = hash_stripped.find('>') {
942 return hash_stripped[end + 1..].trim_start();
943 }
944 }
945 }
946
947 hash_stripped
948}
949
950#[cfg(test)]
954fn extract_calls_in_range(
955 source: &str,
956 root: tree_sitter::Node,
957 byte_start: usize,
958 byte_end: usize,
959 lang: LangId,
960) -> Vec<(String, u32)> {
961 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
962}
963
964fn symbol_body_byte_range(
965 root: tree_sitter::Node,
966 byte_start: usize,
967 byte_end: usize,
968) -> Option<(usize, usize)> {
969 let node = smallest_node_covering_range(root, byte_start, byte_end)?;
970 let mut current = Some(node);
971 while let Some(node) = current {
972 if is_symbol_body_node(node.kind()) {
973 return Some((node.start_byte(), node.end_byte()));
974 }
975 current = node.parent();
976 }
977 Some((node.start_byte(), node.end_byte()))
978}
979
980fn smallest_node_covering_range<'tree>(
981 node: tree_sitter::Node<'tree>,
982 byte_start: usize,
983 byte_end: usize,
984) -> Option<tree_sitter::Node<'tree>> {
985 if node.start_byte() > byte_start || node.end_byte() < byte_end {
986 return None;
987 }
988
989 let mut cursor = node.walk();
990 if cursor.goto_first_child() {
991 loop {
992 let child = cursor.node();
993 if let Some(found) = smallest_node_covering_range(child, byte_start, byte_end) {
994 return Some(found);
995 }
996 if !cursor.goto_next_sibling() {
997 break;
998 }
999 }
1000 }
1001
1002 Some(node)
1003}
1004
1005fn is_symbol_body_node(kind: &str) -> bool {
1006 matches!(
1007 kind,
1008 "function_declaration"
1009 | "generator_function_declaration"
1010 | "function_expression"
1011 | "generator_function"
1012 | "arrow_function"
1013 | "method_definition"
1014 | "class_declaration"
1015 | "abstract_class_declaration"
1016 | "class"
1017 | "lexical_declaration"
1018 | "function_definition"
1019 | "class_definition"
1020 | "decorated_definition"
1021 | "function_item"
1022 | "impl_item"
1023 | "method_declaration"
1024 )
1025}
1026
1027fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
1028 let mut results = Vec::new();
1029 let call_kinds = crate::calls::call_node_kinds(lang);
1030 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
1031 results
1032}
1033
1034fn collect_calls_with_ranges(
1035 node: tree_sitter::Node,
1036 source: &str,
1037 call_kinds: &[&str],
1038 results: &mut Vec<RawCall>,
1039) {
1040 if call_kinds.contains(&node.kind()) {
1041 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
1042 results.push(RawCall {
1043 name,
1044 line: node.start_position().row as u32 + 1,
1045 start_byte: node.start_byte(),
1046 end_byte: node.end_byte(),
1047 });
1048 }
1049 }
1050
1051 let mut cursor = node.walk();
1052 if cursor.goto_first_child() {
1053 loop {
1054 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
1055 if !cursor.goto_next_sibling() {
1056 break;
1057 }
1058 }
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use crate::config::Config;
1066 use crate::context::AppContext;
1067 use crate::parser::TreeSitterProvider;
1068 use std::path::PathBuf;
1069
1070 fn fixture_path(name: &str) -> PathBuf {
1071 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1072 .join("tests")
1073 .join("fixtures")
1074 .join(name)
1075 }
1076
1077 fn make_ctx() -> AppContext {
1078 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
1079 }
1080
1081 #[test]
1082 fn parse_zoom_symbol_names_splits_whitespace_for_code() {
1083 let params = serde_json::json!({ "symbol": "InspectCategory active is_active" });
1084 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1085 assert_eq!(names, vec!["InspectCategory", "active", "is_active"]);
1086 }
1087
1088 #[test]
1089 fn parse_zoom_symbol_names_does_not_split_markdown_headings() {
1090 let params = serde_json::json!({ "symbols": "Getting Started" });
1091 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Markdown)).expect("parse");
1092 assert_eq!(names, vec!["Getting Started"]);
1093 }
1094
1095 #[test]
1096 fn parse_zoom_symbol_names_does_not_split_html_headings() {
1097 let params = serde_json::json!({ "symbol": "Last Heading" });
1098 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Html)).expect("parse");
1099 assert_eq!(names, vec!["Last Heading"]);
1100 }
1101
1102 #[test]
1103 fn parse_zoom_symbol_names_single_token_unchanged() {
1104 let params = serde_json::json!({ "symbol": "compute" });
1105 let names = parse_zoom_symbol_names(¶ms, Some(LangId::TypeScript)).expect("parse");
1106 assert_eq!(names, vec!["compute"]);
1107 }
1108
1109 #[test]
1110 fn parse_zoom_symbol_names_symbols_array_unchanged() {
1111 let params = serde_json::json!({ "symbols": ["A", "B", "C"] });
1112 let names = parse_zoom_symbol_names(¶ms, Some(LangId::Rust)).expect("parse");
1113 assert_eq!(names, vec!["A", "B", "C"]);
1114 }
1115
1116 #[test]
1119 fn extract_calls_finds_direct_calls() {
1120 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1121 let mut parser = FileParser::new();
1122 let path = fixture_path("calls.ts");
1123 let (tree, lang) = parser.parse(&path).unwrap();
1124
1125 let ctx = make_ctx();
1127 let symbols = ctx.provider().list_symbols(&path).unwrap();
1128 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1129
1130 let byte_start =
1131 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
1132 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
1133
1134 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1135 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1136
1137 assert!(
1138 names.contains(&"helper"),
1139 "compute should call helper, got: {:?}",
1140 names
1141 );
1142 }
1143
1144 #[test]
1145 fn extract_calls_finds_member_calls() {
1146 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1147 let mut parser = FileParser::new();
1148 let path = fixture_path("calls.ts");
1149 let (tree, lang) = parser.parse(&path).unwrap();
1150
1151 let ctx = make_ctx();
1152 let symbols = ctx.provider().list_symbols(&path).unwrap();
1153 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
1154
1155 let byte_start =
1156 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
1157 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
1158
1159 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1160 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
1161
1162 assert!(
1163 names.contains(&"add"),
1164 "runAll should call this.add, got: {:?}",
1165 names
1166 );
1167 assert!(
1168 names.contains(&"helper"),
1169 "runAll should call helper, got: {:?}",
1170 names
1171 );
1172 }
1173
1174 #[test]
1175 fn extract_calls_unused_function_has_no_calls() {
1176 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
1177 let mut parser = FileParser::new();
1178 let path = fixture_path("calls.ts");
1179 let (tree, lang) = parser.parse(&path).unwrap();
1180
1181 let ctx = make_ctx();
1182 let symbols = ctx.provider().list_symbols(&path).unwrap();
1183 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
1184
1185 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
1186 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
1187
1188 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
1189 let known_names = [
1191 "helper",
1192 "compute",
1193 "orchestrate",
1194 "unused",
1195 "format",
1196 "display",
1197 ];
1198 let filtered: Vec<&str> = calls
1199 .iter()
1200 .map(|(n, _)| n.as_str())
1201 .filter(|n| known_names.contains(n))
1202 .collect();
1203 assert!(
1204 filtered.is_empty(),
1205 "unused should not call known symbols, got: {:?}",
1206 filtered
1207 );
1208 }
1209
1210 #[test]
1213 fn context_lines_clamp_at_file_start() {
1214 let ctx = make_ctx();
1216 let path = fixture_path("calls.ts");
1217 let symbols = ctx.provider().list_symbols(&path).unwrap();
1218 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
1219
1220 let source = std::fs::read_to_string(&path).unwrap();
1221 let lines: Vec<&str> = source.lines().collect();
1222 let start = helper.range.start_line as usize;
1223
1224 let ctx_start = start.saturating_sub(5);
1226 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
1227 assert!(context_before.len() <= start);
1229 }
1230
1231 #[test]
1232 fn context_lines_clamp_at_file_end() {
1233 let ctx = make_ctx();
1234 let path = fixture_path("calls.ts");
1235 let symbols = ctx.provider().list_symbols(&path).unwrap();
1236 let display = symbols.iter().find(|s| s.name == "display").unwrap();
1237
1238 let source = std::fs::read_to_string(&path).unwrap();
1239 let lines: Vec<&str> = source.lines().collect();
1240 let end = display.range.end_line as usize;
1241
1242 let ctx_end = (end + 1 + 20).min(lines.len());
1244 let context_after: Vec<&str> = if end + 1 < lines.len() {
1245 lines[(end + 1)..ctx_end].to_vec()
1246 } else {
1247 vec![]
1248 };
1249 assert!(context_after.len() <= 20);
1251 }
1252
1253 #[test]
1256 fn body_extraction_matches_source() {
1257 let ctx = make_ctx();
1258 let path = fixture_path("calls.ts");
1259 let symbols = ctx.provider().list_symbols(&path).unwrap();
1260 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
1261
1262 let source = std::fs::read_to_string(&path).unwrap();
1263 let lines: Vec<&str> = source.lines().collect();
1264 let start = compute.range.start_line as usize;
1265 let end = compute.range.end_line as usize;
1266 let body = lines[start..=end].join("\n");
1267
1268 assert!(
1269 body.contains("function compute"),
1270 "body should contain function declaration"
1271 );
1272 assert!(
1273 body.contains("helper(a)"),
1274 "body should contain call to helper"
1275 );
1276 assert!(
1277 body.contains("doubled + b"),
1278 "body should contain return expression"
1279 );
1280 }
1281
1282 #[test]
1285 fn body_range_expands_signature_range_to_include_body_calls() {
1286 let source = r#"function compute(
1287 value: number,
1288): number {
1289 return helper(value);
1290}
1291
1292function helper(value: number): number {
1293 return value * 2;
1294}
1295"#;
1296 let grammar = crate::parser::grammar_for(LangId::TypeScript);
1297 let mut parser = tree_sitter::Parser::new();
1298 parser.set_language(&grammar).unwrap();
1299 let tree = parser.parse(source, None).unwrap();
1300 let signature_end = source.find('{').expect("function has body");
1301
1302 let (body_start, body_end) =
1303 symbol_body_byte_range(tree.root_node(), 0, signature_end).expect("body range");
1304 let calls = extract_calls_in_range(
1305 source,
1306 tree.root_node(),
1307 body_start,
1308 body_end,
1309 LangId::TypeScript,
1310 );
1311 let names = calls
1312 .iter()
1313 .map(|(name, _)| name.as_str())
1314 .collect::<Vec<_>>();
1315
1316 assert!(
1317 names.contains(&"helper"),
1318 "call inside the function body should be included: {names:?}"
1319 );
1320 }
1321
1322 #[test]
1323 fn zoom_leaf_returns_full_body_without_budget_marker() {
1324 let ctx = make_ctx();
1325 let path = fixture_path("calls.ts");
1326 let req = make_zoom_request(
1327 "z-leaf-full",
1328 path.to_str().unwrap(),
1329 "repeatedOutgoing",
1330 None,
1331 );
1332 let resp = handle_zoom(&req, &ctx);
1333 let json = serde_json::to_value(&resp).unwrap();
1334 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1335
1336 let symbols = ctx.provider().list_symbols(&path).unwrap();
1337 let target = symbols
1338 .iter()
1339 .find(|symbol| symbol.name == "repeatedOutgoing")
1340 .unwrap();
1341 let source = std::fs::read_to_string(&path).unwrap();
1342 let lines = source.lines().collect::<Vec<_>>();
1343 let expected =
1344 lines[target.range.start_line as usize..=target.range.end_line as usize].join("\n");
1345
1346 assert_eq!(json["content"].as_str().unwrap(), expected);
1347 assert!(
1348 !json["content"]
1349 .as_str()
1350 .unwrap()
1351 .contains("more lines — zoom"),
1352 "explicit zoom must not budget-cap leaf bodies"
1353 );
1354 }
1355
1356 #[test]
1357 fn zoom_response_has_calls_out_and_called_by() {
1358 let ctx = make_ctx();
1359 let path = fixture_path("calls.ts");
1360
1361 let req = make_zoom_request_cg("z-1", path.to_str().unwrap(), "compute");
1362 let resp = handle_zoom(&req, &ctx);
1363
1364 let json = serde_json::to_value(&resp).unwrap();
1365 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1366
1367 let calls_out = json["annotations"]["calls_out"]
1368 .as_array()
1369 .expect("calls_out array");
1370 let out_names: Vec<&str> = calls_out
1371 .iter()
1372 .map(|c| c["name"].as_str().unwrap())
1373 .collect();
1374 assert!(
1375 out_names.contains(&"helper"),
1376 "compute calls helper: {:?}",
1377 out_names
1378 );
1379
1380 let called_by = json["annotations"]["called_by"]
1381 .as_array()
1382 .expect("called_by array");
1383 let by_names: Vec<&str> = called_by
1384 .iter()
1385 .map(|c| c["name"].as_str().unwrap())
1386 .collect();
1387 assert!(
1388 by_names.contains(&"orchestrate"),
1389 "orchestrate calls compute: {:?}",
1390 by_names
1391 );
1392 }
1393
1394 #[test]
1395 fn zoom_callgraph_dedupes_repeated_call_sites_by_name() {
1396 let ctx = make_ctx();
1397 let path = fixture_path("calls.ts");
1398
1399 let req = make_zoom_request_cg("z-dedupe-out", path.to_str().unwrap(), "repeatedOutgoing");
1400 let resp = handle_zoom(&req, &ctx);
1401 let json = serde_json::to_value(&resp).unwrap();
1402 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1403
1404 let calls_out = json["annotations"]["calls_out"]
1405 .as_array()
1406 .expect("calls_out array");
1407 let helper_refs = calls_out
1408 .iter()
1409 .filter(|call| call["name"] == "helper")
1410 .collect::<Vec<_>>();
1411 assert_eq!(
1412 helper_refs.len(),
1413 1,
1414 "helper should be folded once: {calls_out:?}"
1415 );
1416 assert_eq!(helper_refs[0]["extra_count"], 1);
1417 assert!(
1418 calls_out.iter().any(|call| call["name"] == "format"),
1419 "distinct callee must not be folded into helper: {calls_out:?}"
1420 );
1421
1422 let req = make_zoom_request_cg("z-dedupe-by", path.to_str().unwrap(), "compute");
1423 let resp = handle_zoom(&req, &ctx);
1424 let json = serde_json::to_value(&resp).unwrap();
1425 assert_eq!(json["success"], true, "zoom should succeed: {json:?}");
1426
1427 let called_by = json["annotations"]["called_by"]
1428 .as_array()
1429 .expect("called_by array");
1430 let repeat_refs = called_by
1431 .iter()
1432 .filter(|call| call["name"] == "repeatCompute")
1433 .collect::<Vec<_>>();
1434 assert_eq!(
1435 repeat_refs.len(),
1436 1,
1437 "repeatCompute should be folded once: {called_by:?}"
1438 );
1439 assert_eq!(repeat_refs[0]["extra_count"], 1);
1440 assert!(
1441 called_by.iter().any(|call| call["name"] == "orchestrate"),
1442 "distinct caller must not be folded into repeatCompute: {called_by:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn zoom_response_empty_annotations_for_unused() {
1448 let ctx = make_ctx();
1449 let path = fixture_path("calls.ts");
1450
1451 let req = make_zoom_request_cg("z-2", path.to_str().unwrap(), "unused");
1452 let resp = handle_zoom(&req, &ctx);
1453
1454 let json = serde_json::to_value(&resp).unwrap();
1455 assert_eq!(json["success"], true);
1456
1457 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
1458 let called_by = json["annotations"]["called_by"].as_array().unwrap();
1459
1460 assert!(
1463 called_by.is_empty(),
1464 "unused should not be called by anyone: {:?}",
1465 called_by
1466 );
1467 }
1468
1469 #[test]
1470 fn zoom_default_omits_callgraph_annotations() {
1471 let ctx = make_ctx();
1472 let path = fixture_path("calls.ts");
1473
1474 let req = make_zoom_request("z-1-default", path.to_str().unwrap(), "compute", None);
1475 let resp = handle_zoom(&req, &ctx);
1476
1477 let json = serde_json::to_value(&resp).unwrap();
1478 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
1479
1480 let calls_out = json["annotations"]["calls_out"]
1481 .as_array()
1482 .expect("calls_out array");
1483 let called_by = json["annotations"]["called_by"]
1484 .as_array()
1485 .expect("called_by array");
1486 assert!(
1487 calls_out.is_empty(),
1488 "default zoom should omit calls_out: {:?}",
1489 calls_out
1490 );
1491 assert!(
1492 called_by.is_empty(),
1493 "default zoom should omit called_by: {:?}",
1494 called_by
1495 );
1496 }
1497
1498 #[test]
1499 fn zoom_symbol_not_found() {
1500 let ctx = make_ctx();
1501 let path = fixture_path("calls.ts");
1502
1503 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
1504 let resp = handle_zoom(&req, &ctx);
1505
1506 let json = serde_json::to_value(&resp).unwrap();
1507 assert_eq!(json["success"], false);
1508 assert_eq!(json["code"], "symbol_not_found");
1509 }
1510
1511 #[test]
1512 fn zoom_custom_context_lines() {
1513 let ctx = make_ctx();
1514 let path = fixture_path("calls.ts");
1515
1516 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
1517 let resp = handle_zoom(&req, &ctx);
1518
1519 let json = serde_json::to_value(&resp).unwrap();
1520 assert_eq!(json["success"], true);
1521
1522 let ctx_before = json["context_before"].as_array().unwrap();
1523 let ctx_after = json["context_after"].as_array().unwrap();
1524 assert!(
1526 ctx_before.len() <= 1,
1527 "context_before should be ≤1: {:?}",
1528 ctx_before
1529 );
1530 assert!(
1531 ctx_after.len() <= 1,
1532 "context_after should be ≤1: {:?}",
1533 ctx_after
1534 );
1535 }
1536
1537 #[test]
1538 fn zoom_missing_file_param() {
1539 let ctx = make_ctx();
1540 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
1541 let resp = handle_zoom(&req, &ctx);
1542
1543 let json = serde_json::to_value(&resp).unwrap();
1544 assert_eq!(json["success"], false);
1545 assert_eq!(json["code"], "invalid_request");
1546 }
1547
1548 #[test]
1549 fn zoom_missing_symbol_param() {
1550 let ctx = make_ctx();
1551 let path = fixture_path("calls.ts");
1552 let req_value = serde_json::json!({
1556 "id": "z-6",
1557 "command": "zoom",
1558 "file": path.to_string_lossy(),
1559 });
1560 let req_str = req_value.to_string();
1561 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
1562 let resp = handle_zoom(&req, &ctx);
1563
1564 let json = serde_json::to_value(&resp).unwrap();
1565 assert_eq!(json["success"], false);
1566 assert_eq!(json["code"], "invalid_request");
1567 }
1568
1569 #[test]
1570 fn test_suggest_close_symbols_unit() {
1571 let available = vec![
1572 "handle_grep_search".to_string(),
1573 "handle_semantic_search".to_string(),
1574 "handle_semantic_or_hybrid_search".to_string(),
1575 "compute_total".to_string(),
1576 "search".to_string(),
1577 "handle_search".to_string(),
1578 ];
1579
1580 let suggestions = suggest_close_symbols("handle_search", &available, 5);
1581 assert!(suggestions.contains(&"handle_grep_search".to_string()));
1582 assert!(suggestions.contains(&"handle_semantic_search".to_string()));
1583 assert!(suggestions.contains(&"handle_semantic_or_hybrid_search".to_string()));
1584 assert!(suggestions.contains(&"search".to_string()));
1585 assert!(!suggestions.contains(&"compute_total".to_string()));
1586
1587 let suggestions_caps = suggest_close_symbols("HANDLE_SEARCH", &available, 5);
1588 assert_eq!(suggestions, suggestions_caps);
1589
1590 let available2 = vec![
1591 "total".to_string(),
1592 "compute_total".to_string(),
1593 "unrelated".to_string(),
1594 ];
1595 let suggestions2 = suggest_close_symbols("totol", &available2, 5);
1596 assert_eq!(suggestions2, vec!["total".to_string()]);
1597 }
1598
1599 fn make_zoom_request(
1602 id: &str,
1603 file: &str,
1604 symbol: &str,
1605 context_lines: Option<u64>,
1606 ) -> RawRequest {
1607 let mut json = serde_json::json!({
1608 "id": id,
1609 "command": "zoom",
1610 "file": file,
1611 "symbol": symbol,
1612 });
1613 if let Some(cl) = context_lines {
1614 json["context_lines"] = serde_json::json!(cl);
1615 }
1616 serde_json::from_value(json).unwrap()
1617 }
1618
1619 fn make_zoom_request_cg(id: &str, file: &str, symbol: &str) -> RawRequest {
1620 let mut req = make_zoom_request(id, file, symbol, None);
1621 req.params["callgraph"] = serde_json::json!(true);
1622 req
1623 }
1624
1625 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
1626 serde_json::from_str(json_str).unwrap()
1627 }
1628}