1use std::path::{Path, PathBuf};
2
3use serde::Serialize;
4
5use crate::context::AppContext;
6use crate::edit::line_col_to_byte;
7use crate::lsp_hints;
8use crate::parser::{detect_language, FileParser, LangId};
9use crate::protocol::{RawRequest, Response};
10use crate::symbols::Range;
11use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
12
13#[derive(Debug, Clone, Serialize)]
15pub struct CallRef {
16 pub name: String,
17 pub line: u32,
19}
20
21#[derive(Debug, Clone, Serialize)]
23pub struct Annotations {
24 pub calls_out: Vec<CallRef>,
25 pub called_by: Vec<CallRef>,
26}
27
28#[derive(Debug, Clone, Serialize)]
30pub struct ZoomResponse {
31 pub name: String,
32 pub kind: String,
33 pub range: Range,
34 pub content: String,
35 pub context_before: Vec<String>,
36 pub context_after: Vec<String>,
37 pub annotations: Annotations,
38}
39
40struct RawCall {
41 name: String,
42 line: u32,
43 start_byte: usize,
44 end_byte: usize,
45}
46
47fn resolve_file_or_url(
48 req: &RawRequest,
49 ctx: &AppContext,
50 file: &str,
51) -> Result<PathBuf, Response> {
52 if is_http_url(file) {
53 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
54 let allow_private = ctx.config().url_fetch_allow_private
55 || req
56 .params
57 .get("allow_private")
58 .and_then(|value| value.as_bool())
59 .unwrap_or(false);
60 return fetch_url_to_cache(
61 file,
62 &storage_dir,
63 UrlFetchOptions {
64 allow_private,
65 ..UrlFetchOptions::default()
66 },
67 )
68 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
69 }
70
71 ctx.validate_path(&req.id, Path::new(file))
72}
73
74pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
79 let file = match req
80 .params
81 .get("file")
82 .or_else(|| req.params.get("url"))
83 .and_then(|v| v.as_str())
84 {
85 Some(f) => f,
86 None => {
87 return Response::error(
88 &req.id,
89 "invalid_request",
90 "zoom: missing required param 'file'",
91 );
92 }
93 };
94
95 let context_lines = req
96 .params
97 .get("context_lines")
98 .and_then(|v| v.as_u64())
99 .unwrap_or(3) as usize;
100
101 let start_line = req
102 .params
103 .get("start_line")
104 .and_then(|v| v.as_u64())
105 .map(|v| v as usize);
106 let end_line = req
107 .params
108 .get("end_line")
109 .and_then(|v| v.as_u64())
110 .map(|v| v as usize);
111
112 let path = match resolve_file_or_url(req, ctx, file) {
113 Ok(path) => path,
114 Err(resp) => return resp,
115 };
116 if !path.exists() {
117 return Response::error(
118 &req.id,
119 "file_not_found",
120 format!("file not found: {}", file),
121 );
122 }
123
124 let source = match std::fs::read_to_string(&path) {
126 Ok(s) => s,
127 Err(e) => {
128 return Response::error(&req.id, "file_not_found", format!("{}: {}", file, e));
129 }
130 };
131
132 let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
133
134 match (start_line, end_line) {
136 (Some(start), Some(end)) => {
137 if req.params.get("symbol").is_some() {
138 return Response::error(
139 &req.id,
140 "invalid_request",
141 "zoom: provide either 'symbol' OR ('start_line' and 'end_line'), not both",
142 );
143 }
144 if start == 0 || end == 0 {
145 return Response::error(
146 &req.id,
147 "invalid_request",
148 "zoom: 'start_line' and 'end_line' are 1-based and must be >= 1",
149 );
150 }
151 if end < start {
152 return Response::error(
153 &req.id,
154 "invalid_request",
155 format!("zoom: end_line {} must be >= start_line {}", end, start),
156 );
157 }
158 if lines.is_empty() {
159 return Response::error(
160 &req.id,
161 "invalid_request",
162 format!("zoom: {} is empty", file),
163 );
164 }
165
166 let start_idx = start - 1;
167 let clamped_end = end.min(lines.len());
169 let end_idx = clamped_end - 1;
170 if start_idx >= lines.len() {
171 return Response::error(
172 &req.id,
173 "invalid_request",
174 format!(
175 "zoom: start_line {} is past end of {} ({} lines)",
176 start,
177 file,
178 lines.len()
179 ),
180 );
181 }
182
183 let content = lines[start_idx..=end_idx].join("\n");
184 let ctx_start = start_idx.saturating_sub(context_lines);
185 let context_before: Vec<String> = if ctx_start < start_idx {
186 lines[ctx_start..start_idx]
187 .iter()
188 .map(|l| l.to_string())
189 .collect()
190 } else {
191 vec![]
192 };
193 let ctx_end = (end_idx + 1 + context_lines).min(lines.len());
194 let context_after: Vec<String> = if end_idx + 1 < lines.len() {
195 lines[(end_idx + 1)..ctx_end]
196 .iter()
197 .map(|l| l.to_string())
198 .collect()
199 } else {
200 vec![]
201 };
202 let end_col = lines[end_idx].chars().count() as u32;
203
204 return Response::success(
205 &req.id,
206 serde_json::json!({
207 "name": format!("lines {}-{}", start, clamped_end),
208 "kind": "lines",
209 "range": {
210 "start_line": start, "start_col": 1,
212 "end_line": clamped_end,
213 "end_col": end_col + 1,
214 },
215 "content": content,
216 "context_before": context_before,
217 "context_after": context_after,
218 "annotations": {
219 "calls_out": [],
220 "called_by": [],
221 },
222 }),
223 );
224 }
225 (Some(_), None) | (None, Some(_)) => {
226 return Response::error(
227 &req.id,
228 "invalid_request",
229 "zoom: provide both 'start_line' and 'end_line' for line-range mode",
230 );
231 }
232 (None, None) => {}
233 }
234
235 let symbol_name = match req.params.get("symbol").and_then(|v| v.as_str()) {
236 Some(s) => s,
237 None => {
238 return Response::error(
239 &req.id,
240 "invalid_request",
241 "zoom: missing required param 'symbol' (or use 'start_line' and 'end_line')",
242 );
243 }
244 };
245
246 let lookup_name = match detect_language(&path) {
250 Some(LangId::Markdown | LangId::Html) => normalize_heading_query(symbol_name),
251 _ => symbol_name,
252 };
253 let matches = match ctx.provider().resolve_symbol(&path, lookup_name) {
254 Ok(m) => m,
255 Err(e) => {
256 return Response::error(&req.id, e.code(), e.to_string());
257 }
258 };
259
260 let matches = if let Some(hints) = lsp_hints::parse_lsp_hints(req) {
262 lsp_hints::apply_lsp_disambiguation(matches, &hints)
263 } else {
264 matches
265 };
266
267 if matches.len() > 1 {
268 let candidates: Vec<String> = matches
271 .iter()
272 .map(|m| {
273 let sym = &m.symbol;
274 let start = sym.range.start_line + 1;
275 let end = sym.range.end_line + 1;
276 let line_range = if start == end {
277 format!("{}", start)
278 } else {
279 format!("{}-{}", start, end)
280 };
281 if sym.scope_chain.is_empty() {
282 format!("{}:{}", sym.name, line_range)
283 } else {
284 format!(
285 "{}::{}:{}",
286 sym.scope_chain.join("::"),
287 sym.name,
288 line_range
289 )
290 }
291 })
292 .collect();
293 return Response::error(
294 &req.id,
295 "ambiguous_symbol",
296 format!(
297 "symbol '{}' is ambiguous, candidates: [{}]",
298 symbol_name,
299 candidates.join(", ")
300 ),
301 );
302 }
303
304 let target = &matches[0].symbol;
305 let start = target.range.start_line as usize;
306 let end = target.range.end_line as usize;
307
308 let resolved_file_path = std::path::Path::new(&matches[0].file);
310 let resolved_lines: Vec<String>;
311 let effective_lines: &[String] = if resolved_file_path != path {
312 resolved_lines = match std::fs::read_to_string(resolved_file_path) {
313 Ok(src) => src.lines().map(|l| l.to_string()).collect(),
314 Err(_) => lines.clone(),
315 };
316 &resolved_lines
317 } else {
318 &lines
319 };
320
321 let content = if end < effective_lines.len() {
323 effective_lines[start..=end].join("\n")
324 } else {
325 effective_lines[start..].join("\n")
326 };
327
328 let ctx_start = start.saturating_sub(context_lines);
330 let context_before: Vec<String> = if ctx_start < start {
331 effective_lines[ctx_start..start]
332 .iter()
333 .map(|l| l.to_string())
334 .collect()
335 } else {
336 vec![]
337 };
338
339 let ctx_end = (end + 1 + context_lines).min(effective_lines.len());
341 let context_after: Vec<String> = if end + 1 < effective_lines.len() {
342 effective_lines[(end + 1)..ctx_end]
343 .iter()
344 .map(|l| l.to_string())
345 .collect()
346 } else {
347 vec![]
348 };
349
350 let all_symbols = match ctx.provider().list_symbols(resolved_file_path) {
352 Ok(s) => s,
353 Err(e) => {
354 return Response::error(&req.id, e.code(), e.to_string());
355 }
356 };
357
358 let known_names: Vec<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
359
360 let mut parser = FileParser::with_symbol_cache(ctx.symbol_cache());
362 let (tree, lang) = match parser.parse(resolved_file_path) {
363 Ok(r) => r,
364 Err(e) => {
365 return Response::error(&req.id, e.code(), e.to_string());
366 }
367 };
368
369 let resolved_source = if resolved_file_path != path {
371 std::fs::read_to_string(resolved_file_path).unwrap_or_else(|_| source.clone())
372 } else {
373 source.clone()
374 };
375 let target_byte_start = line_col_to_byte(
376 &resolved_source,
377 target.range.start_line,
378 target.range.start_col,
379 );
380 let target_byte_end = line_col_to_byte(
381 &resolved_source,
382 target.range.end_line,
383 target.range.end_col,
384 );
385
386 let all_file_calls = extract_calls_with_ranges(&resolved_source, tree.root_node(), lang);
387
388 let raw_calls = all_file_calls
389 .iter()
390 .filter(|call| call.start_byte >= target_byte_start && call.end_byte <= target_byte_end);
391 let calls_out: Vec<CallRef> = raw_calls
392 .filter(|call| known_names.contains(&call.name.as_str()) && call.name != target.name)
393 .map(|call| CallRef {
394 name: call.name.clone(),
395 line: call.line,
396 })
397 .collect();
398
399 let mut called_by: Vec<CallRef> = Vec::new();
401 for sym in &all_symbols {
402 if sym.name == target.name && sym.range.start_line == target.range.start_line {
403 continue; }
405 let sym_byte_start =
406 line_col_to_byte(&resolved_source, sym.range.start_line, sym.range.start_col);
407 let sym_byte_end =
408 line_col_to_byte(&resolved_source, sym.range.end_line, sym.range.end_col);
409 for call in &all_file_calls {
410 if call.name == target.name
411 && call.start_byte >= sym_byte_start
412 && call.end_byte <= sym_byte_end
413 {
414 called_by.push(CallRef {
415 name: sym.name.clone(),
416 line: call.line,
417 });
418 }
419 }
420 }
421
422 called_by.sort_by(|a, b| a.name.cmp(&b.name).then(a.line.cmp(&b.line)));
424 called_by.dedup_by(|a, b| a.name == b.name && a.line == b.line);
425
426 let kind_str = serde_json::to_value(&target.kind)
427 .ok()
428 .and_then(|v| v.as_str().map(String::from))
429 .unwrap_or_else(|| format!("{:?}", target.kind).to_lowercase());
430
431 let resp = ZoomResponse {
432 name: target.name.clone(),
433 kind: kind_str,
434 range: target.range.clone(),
435 content,
436 context_before,
437 context_after,
438 annotations: Annotations {
439 calls_out,
440 called_by,
441 },
442 };
443
444 match serde_json::to_value(&resp) {
445 Ok(resp_json) => Response::success(&req.id, resp_json),
446 Err(err) => Response::error(
447 &req.id,
448 "internal_error",
449 format!("zoom: failed to serialize response: {err}"),
450 ),
451 }
452}
453
454fn normalize_heading_query(input: &str) -> &str {
455 let trimmed = input.trim_start();
456 let hash_stripped = trimmed.trim_start_matches('#').trim_start();
457
458 if let Some(after_open) = hash_stripped.strip_prefix('<') {
459 let after_slash = after_open.strip_prefix('/').unwrap_or(after_open);
460 let mut chars = after_slash.chars();
461 if matches!(chars.next(), Some('h' | 'H')) && matches!(chars.next(), Some('1'..='6')) {
462 if let Some(end) = hash_stripped.find('>') {
463 return hash_stripped[end + 1..].trim_start();
464 }
465 }
466 }
467
468 hash_stripped
469}
470
471#[cfg(test)]
475fn extract_calls_in_range(
476 source: &str,
477 root: tree_sitter::Node,
478 byte_start: usize,
479 byte_end: usize,
480 lang: LangId,
481) -> Vec<(String, u32)> {
482 crate::calls::extract_calls_in_range(source, root, byte_start, byte_end, lang)
483}
484
485fn extract_calls_with_ranges(source: &str, root: tree_sitter::Node, lang: LangId) -> Vec<RawCall> {
486 let mut results = Vec::new();
487 let call_kinds = crate::calls::call_node_kinds(lang);
488 collect_calls_with_ranges(root, source, &call_kinds, &mut results);
489 results
490}
491
492fn collect_calls_with_ranges(
493 node: tree_sitter::Node,
494 source: &str,
495 call_kinds: &[&str],
496 results: &mut Vec<RawCall>,
497) {
498 if call_kinds.contains(&node.kind()) {
499 if let Some(name) = crate::calls::extract_callee_name(&node, source) {
500 results.push(RawCall {
501 name,
502 line: node.start_position().row as u32 + 1,
503 start_byte: node.start_byte(),
504 end_byte: node.end_byte(),
505 });
506 }
507 }
508
509 let mut cursor = node.walk();
510 if cursor.goto_first_child() {
511 loop {
512 collect_calls_with_ranges(cursor.node(), source, call_kinds, results);
513 if !cursor.goto_next_sibling() {
514 break;
515 }
516 }
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::config::Config;
524 use crate::context::AppContext;
525 use crate::parser::TreeSitterProvider;
526 use std::path::PathBuf;
527
528 fn fixture_path(name: &str) -> PathBuf {
529 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
530 .join("tests")
531 .join("fixtures")
532 .join(name)
533 }
534
535 fn make_ctx() -> AppContext {
536 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
537 }
538
539 #[test]
542 fn extract_calls_finds_direct_calls() {
543 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
544 let mut parser = FileParser::new();
545 let path = fixture_path("calls.ts");
546 let (tree, lang) = parser.parse(&path).unwrap();
547
548 let ctx = make_ctx();
550 let symbols = ctx.provider().list_symbols(&path).unwrap();
551 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
552
553 let byte_start =
554 line_col_to_byte(&source, compute.range.start_line, compute.range.start_col);
555 let byte_end = line_col_to_byte(&source, compute.range.end_line, compute.range.end_col);
556
557 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
558 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
559
560 assert!(
561 names.contains(&"helper"),
562 "compute should call helper, got: {:?}",
563 names
564 );
565 }
566
567 #[test]
568 fn extract_calls_finds_member_calls() {
569 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
570 let mut parser = FileParser::new();
571 let path = fixture_path("calls.ts");
572 let (tree, lang) = parser.parse(&path).unwrap();
573
574 let ctx = make_ctx();
575 let symbols = ctx.provider().list_symbols(&path).unwrap();
576 let run_all = symbols.iter().find(|s| s.name == "runAll").unwrap();
577
578 let byte_start =
579 line_col_to_byte(&source, run_all.range.start_line, run_all.range.start_col);
580 let byte_end = line_col_to_byte(&source, run_all.range.end_line, run_all.range.end_col);
581
582 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
583 let names: Vec<&str> = calls.iter().map(|(n, _)| n.as_str()).collect();
584
585 assert!(
586 names.contains(&"add"),
587 "runAll should call this.add, got: {:?}",
588 names
589 );
590 assert!(
591 names.contains(&"helper"),
592 "runAll should call helper, got: {:?}",
593 names
594 );
595 }
596
597 #[test]
598 fn extract_calls_unused_function_has_no_calls() {
599 let source = std::fs::read_to_string(fixture_path("calls.ts")).unwrap();
600 let mut parser = FileParser::new();
601 let path = fixture_path("calls.ts");
602 let (tree, lang) = parser.parse(&path).unwrap();
603
604 let ctx = make_ctx();
605 let symbols = ctx.provider().list_symbols(&path).unwrap();
606 let unused = symbols.iter().find(|s| s.name == "unused").unwrap();
607
608 let byte_start = line_col_to_byte(&source, unused.range.start_line, unused.range.start_col);
609 let byte_end = line_col_to_byte(&source, unused.range.end_line, unused.range.end_col);
610
611 let calls = extract_calls_in_range(&source, tree.root_node(), byte_start, byte_end, lang);
612 let known_names = [
614 "helper",
615 "compute",
616 "orchestrate",
617 "unused",
618 "format",
619 "display",
620 ];
621 let filtered: Vec<&str> = calls
622 .iter()
623 .map(|(n, _)| n.as_str())
624 .filter(|n| known_names.contains(n))
625 .collect();
626 assert!(
627 filtered.is_empty(),
628 "unused should not call known symbols, got: {:?}",
629 filtered
630 );
631 }
632
633 #[test]
636 fn context_lines_clamp_at_file_start() {
637 let ctx = make_ctx();
639 let path = fixture_path("calls.ts");
640 let symbols = ctx.provider().list_symbols(&path).unwrap();
641 let helper = symbols.iter().find(|s| s.name == "helper").unwrap();
642
643 let source = std::fs::read_to_string(&path).unwrap();
644 let lines: Vec<&str> = source.lines().collect();
645 let start = helper.range.start_line as usize;
646
647 let ctx_start = start.saturating_sub(5);
649 let context_before: Vec<&str> = lines[ctx_start..start].to_vec();
650 assert!(context_before.len() <= start);
652 }
653
654 #[test]
655 fn context_lines_clamp_at_file_end() {
656 let ctx = make_ctx();
657 let path = fixture_path("calls.ts");
658 let symbols = ctx.provider().list_symbols(&path).unwrap();
659 let display = symbols.iter().find(|s| s.name == "display").unwrap();
660
661 let source = std::fs::read_to_string(&path).unwrap();
662 let lines: Vec<&str> = source.lines().collect();
663 let end = display.range.end_line as usize;
664
665 let ctx_end = (end + 1 + 20).min(lines.len());
667 let context_after: Vec<&str> = if end + 1 < lines.len() {
668 lines[(end + 1)..ctx_end].to_vec()
669 } else {
670 vec![]
671 };
672 assert!(context_after.len() <= 20);
674 }
675
676 #[test]
679 fn body_extraction_matches_source() {
680 let ctx = make_ctx();
681 let path = fixture_path("calls.ts");
682 let symbols = ctx.provider().list_symbols(&path).unwrap();
683 let compute = symbols.iter().find(|s| s.name == "compute").unwrap();
684
685 let source = std::fs::read_to_string(&path).unwrap();
686 let lines: Vec<&str> = source.lines().collect();
687 let start = compute.range.start_line as usize;
688 let end = compute.range.end_line as usize;
689 let body = lines[start..=end].join("\n");
690
691 assert!(
692 body.contains("function compute"),
693 "body should contain function declaration"
694 );
695 assert!(
696 body.contains("helper(a)"),
697 "body should contain call to helper"
698 );
699 assert!(
700 body.contains("doubled + b"),
701 "body should contain return expression"
702 );
703 }
704
705 #[test]
708 fn zoom_response_has_calls_out_and_called_by() {
709 let ctx = make_ctx();
710 let path = fixture_path("calls.ts");
711
712 let req = make_zoom_request("z-1", path.to_str().unwrap(), "compute", None);
713 let resp = handle_zoom(&req, &ctx);
714
715 let json = serde_json::to_value(&resp).unwrap();
716 assert_eq!(json["success"], true, "zoom should succeed: {:?}", json);
717
718 let calls_out = json["annotations"]["calls_out"]
719 .as_array()
720 .expect("calls_out array");
721 let out_names: Vec<&str> = calls_out
722 .iter()
723 .map(|c| c["name"].as_str().unwrap())
724 .collect();
725 assert!(
726 out_names.contains(&"helper"),
727 "compute calls helper: {:?}",
728 out_names
729 );
730
731 let called_by = json["annotations"]["called_by"]
732 .as_array()
733 .expect("called_by array");
734 let by_names: Vec<&str> = called_by
735 .iter()
736 .map(|c| c["name"].as_str().unwrap())
737 .collect();
738 assert!(
739 by_names.contains(&"orchestrate"),
740 "orchestrate calls compute: {:?}",
741 by_names
742 );
743 }
744
745 #[test]
746 fn zoom_response_empty_annotations_for_unused() {
747 let ctx = make_ctx();
748 let path = fixture_path("calls.ts");
749
750 let req = make_zoom_request("z-2", path.to_str().unwrap(), "unused", None);
751 let resp = handle_zoom(&req, &ctx);
752
753 let json = serde_json::to_value(&resp).unwrap();
754 assert_eq!(json["success"], true);
755
756 let _calls_out = json["annotations"]["calls_out"].as_array().unwrap();
757 let called_by = json["annotations"]["called_by"].as_array().unwrap();
758
759 assert!(
762 called_by.is_empty(),
763 "unused should not be called by anyone: {:?}",
764 called_by
765 );
766 }
767
768 #[test]
769 fn zoom_symbol_not_found() {
770 let ctx = make_ctx();
771 let path = fixture_path("calls.ts");
772
773 let req = make_zoom_request("z-3", path.to_str().unwrap(), "nonexistent", None);
774 let resp = handle_zoom(&req, &ctx);
775
776 let json = serde_json::to_value(&resp).unwrap();
777 assert_eq!(json["success"], false);
778 assert_eq!(json["code"], "symbol_not_found");
779 }
780
781 #[test]
782 fn zoom_custom_context_lines() {
783 let ctx = make_ctx();
784 let path = fixture_path("calls.ts");
785
786 let req = make_zoom_request("z-4", path.to_str().unwrap(), "compute", Some(1));
787 let resp = handle_zoom(&req, &ctx);
788
789 let json = serde_json::to_value(&resp).unwrap();
790 assert_eq!(json["success"], true);
791
792 let ctx_before = json["context_before"].as_array().unwrap();
793 let ctx_after = json["context_after"].as_array().unwrap();
794 assert!(
796 ctx_before.len() <= 1,
797 "context_before should be ≤1: {:?}",
798 ctx_before
799 );
800 assert!(
801 ctx_after.len() <= 1,
802 "context_after should be ≤1: {:?}",
803 ctx_after
804 );
805 }
806
807 #[test]
808 fn zoom_missing_file_param() {
809 let ctx = make_ctx();
810 let req = make_raw_request("z-5", r#"{"id":"z-5","command":"zoom","symbol":"foo"}"#);
811 let resp = handle_zoom(&req, &ctx);
812
813 let json = serde_json::to_value(&resp).unwrap();
814 assert_eq!(json["success"], false);
815 assert_eq!(json["code"], "invalid_request");
816 }
817
818 #[test]
819 fn zoom_missing_symbol_param() {
820 let ctx = make_ctx();
821 let path = fixture_path("calls.ts");
822 let req_value = serde_json::json!({
826 "id": "z-6",
827 "command": "zoom",
828 "file": path.to_string_lossy(),
829 });
830 let req_str = req_value.to_string();
831 let req: RawRequest = serde_json::from_str(&req_str).unwrap();
832 let resp = handle_zoom(&req, &ctx);
833
834 let json = serde_json::to_value(&resp).unwrap();
835 assert_eq!(json["success"], false);
836 assert_eq!(json["code"], "invalid_request");
837 }
838
839 fn make_zoom_request(
842 id: &str,
843 file: &str,
844 symbol: &str,
845 context_lines: Option<u64>,
846 ) -> RawRequest {
847 let mut json = serde_json::json!({
848 "id": id,
849 "command": "zoom",
850 "file": file,
851 "symbol": symbol,
852 });
853 if let Some(cl) = context_lines {
854 json["context_lines"] = serde_json::json!(cl);
855 }
856 serde_json::from_value(json).unwrap()
857 }
858
859 fn make_raw_request(_id: &str, json_str: &str) -> RawRequest {
860 serde_json::from_str(json_str).unwrap()
861 }
862}