1use crate::lsp::types::{CallDirection, RelationMode};
2use clap::{Parser, Subcommand};
3use std::path::PathBuf;
4
5#[derive(Parser, Debug)]
6#[command(
7 name = "rust-analyzer-cli",
8 author,
9 version,
10 about = "Compiler-accurate Rust codebase navigation powered by rust-analyzer LSP",
11 after_help = "Workflow:\n 1. Start the daemon: rust-analyzer-cli daemon --workspace .\n 2. Confirm the LSP session: rust-analyzer-cli status\n 3. Run a query, such as: rust-analyzer-cli hover --file src/main.rs --line 15 --col 10\n\nCoordinates use 1-based line and column numbers. Columns count UTF-16 code units to match LSP. Use --json for machine-readable stdout.\nMost query commands require the daemon to be running first."
12)]
13pub struct Cli {
14 #[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
16 pub port: u16,
17
18 #[arg(short, long, global = true)]
20 pub json: bool,
21
22 #[command(subcommand)]
23 pub command: Commands,
24}
25
26#[derive(Subcommand, Debug)]
27pub enum Commands {
28 #[command(
30 after_help = "Example:\n rust-analyzer-cli daemon --workspace .\n\nThe daemon must stay running while query commands are used. Use --port to choose a different local port."
31 )]
32 Daemon {
33 #[arg(short, long, default_value = ".")]
35 workspace: PathBuf,
36 },
37 #[command(
39 after_help = "Example:\n rust-analyzer-cli status\n rust-analyzer-cli --json status\n\nREADY means the rust-analyzer LSP session initialized. It does not claim that every workspace index task has completed."
40 )]
41 Status,
42 #[command(
44 after_help = "Example:\n rust-analyzer-cli refresh\n\nThe daemon must be running. Query status again after refreshing to confirm indexing state."
45 )]
46 Refresh,
47 #[command(
49 after_help = "Examples:\n rust-analyzer-cli symbol LspClient struct --exact\n rust-analyzer-cli symbol long_example function --body --max-lines 50\n\nThe optional kind must be one of: any, module, struct, enum, enum_variant, trait, impl, function, method, associated_function, field, type_alias, const, static, macro, union, type_parameter, variable."
50 )]
51 Symbol {
52 name: String,
54 #[arg(value_parser = ["any", "module", "struct", "enum", "enum_variant", "trait", "impl", "function", "method", "associated_function", "field", "type_alias", "const", "static", "macro", "union", "type_parameter", "variable"], default_value = "any")]
56 kind: String,
57 #[arg(long)]
59 exact: bool,
60 #[arg(short, long)]
62 body: bool,
63 #[arg(long, default_value_t = 100)]
65 max_lines: usize,
66 },
67 #[command(
69 after_help = "Examples:\n rust-analyzer-cli outline --file src/main.rs\n rust-analyzer-cli outline --file-list src/main.rs,src/lib.rs --body --max-lines 50\n\nProvide exactly one of --file or --file-list. Paths are interpreted from the current workspace."
70 )]
71 Outline {
72 #[arg(
74 short,
75 long,
76 required_unless_present = "file_list",
77 conflicts_with = "file_list"
78 )]
79 file: Option<PathBuf>,
80 #[arg(long, conflicts_with = "file")]
82 file_list: Option<String>,
83 #[arg(short, long)]
85 output: Option<PathBuf>,
86 #[arg(short, long)]
88 body: bool,
89 #[arg(long, default_value_t = 100)]
91 max_lines: usize,
92 },
93 #[command(
95 after_help = "Example:\n rust-analyzer-cli definition --file src/main.rs --line 13 --col 10 --body\n\nLine and column numbers are 1-based. Start the daemon before querying."
96 )]
97 Definition {
98 #[arg(short, long)]
100 file: PathBuf,
101 #[arg(short, long, value_parser = parse_positive_u32)]
103 line: u32,
104 #[arg(short, long, value_parser = parse_positive_u32)]
106 col: u32,
107 #[arg(short, long)]
109 body: bool,
110 #[arg(long, default_value_t = 100)]
112 max_lines: usize,
113 #[arg(long)]
115 no_line_numbers: bool,
116 },
117 #[command(
119 after_help = "Example:\n rust-analyzer-cli body --file src/main.rs --line 15 --col 10 --max-lines 100\n\nLine and column numbers are 1-based. If the body exceeds --max-lines, the human-readable output includes a truncation warning. Use --max-lines 0 for unlimited output."
120 )]
121 Body {
122 #[arg(short, long)]
124 file: PathBuf,
125 #[arg(short, long, value_parser = parse_positive_u32)]
127 line: u32,
128 #[arg(short, long, value_parser = parse_positive_u32)]
130 col: u32,
131 #[arg(long, default_value_t = 100)]
133 max_lines: usize,
134 #[arg(long)]
136 no_line_numbers: bool,
137 },
138 #[command(
140 after_help = "Example:\n rust-analyzer-cli hover --file src/main.rs --line 15 --col 10\n\nThe output preserves Markdown documentation and Rust code blocks. A valid query with no hover information returns null in JSON mode or an explicit message in human-readable mode."
141 )]
142 Hover {
143 #[arg(short, long)]
145 file: PathBuf,
146 #[arg(short, long, value_parser = parse_positive_u32)]
148 line: u32,
149 #[arg(short, long, value_parser = parse_positive_u32)]
151 col: u32,
152 },
153 #[command(
155 after_help = "Example:\n rust-analyzer-cli references --file src/main.rs --line 25 --col 8\n\nA valid query with no matches returns an empty array. Paths use the daemon workspace and columns count UTF-16 code units."
156 )]
157 References {
158 #[arg(short, long)]
160 file: PathBuf,
161 #[arg(short, long, value_parser = parse_positive_u32)]
163 line: u32,
164 #[arg(short, long, value_parser = parse_positive_u32)]
166 col: u32,
167 },
168 #[command(
170 after_help = "Examples:\n rust-analyzer-cli calls --file src/main.rs --line 25 --col 8 --direction incoming\n rust-analyzer-cli calls --file src/main.rs --line 25 --col 8 --direction outgoing --depth 2\n\nDirection must be incoming or outgoing. Depth defaults to 1 and is the maximum hierarchy level. A valid query with no matches returns an empty array."
171 )]
172 Calls {
173 #[arg(short, long)]
175 file: PathBuf,
176 #[arg(short, long, value_parser = parse_positive_u32)]
178 line: u32,
179 #[arg(short, long, value_parser = parse_positive_u32)]
181 col: u32,
182 #[arg(long, default_value_t = CallDirection::Incoming)]
184 direction: CallDirection,
185 #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
187 depth: u32,
188 },
189 #[command(
191 after_help = "Example:\n rust-analyzer-cli relations --file tests/code_example.rs --line 20 --col 12 --mode implementations\n\nThe first Rust relation mode is implementations, which finds implementation locations for the symbol at the position. A valid query with no result returns an empty array."
192 )]
193 Relations {
194 #[arg(short, long)]
196 file: PathBuf,
197 #[arg(short, long, value_parser = parse_positive_u32)]
199 line: u32,
200 #[arg(short, long, value_parser = parse_positive_u32)]
202 col: u32,
203 #[arg(short, long, default_value_t = RelationMode::Implementations)]
205 mode: RelationMode,
206 },
207 #[command(
209 after_help = "Examples:\n rust-analyzer-cli init-skill --workspace .\n rust-analyzer-cli init-skill --workspace . --dir .agents/skills/rust-codebase-navigation/SKILL.md\n\nThe default destination is .agents/skills/rust-codebase-navigation/SKILL.md. The custom path is relative to --workspace."
210 )]
211 InitSkill {
212 #[arg(short, long, default_value = ".")]
214 workspace: PathBuf,
215 #[arg(short, long)]
217 dir: Option<PathBuf>,
218 },
219}
220
221fn parse_positive_u32(value: &str) -> Result<u32, String> {
222 let parsed = value
223 .parse::<u32>()
224 .map_err(|_| format!("{value} must be a positive integer"))?;
225 if parsed == 0 {
226 return Err("value must be at least 1; line and column numbers are 1-based".to_string());
227 }
228 Ok(parsed)
229}
230
231fn parse_positive_depth(value: &str) -> Result<u32, String> {
232 let parsed = value
233 .parse::<u32>()
234 .map_err(|_| format!("{value} must be a positive integer depth"))?;
235 if parsed == 0 {
236 return Err("depth must be at least 1".to_string());
237 }
238 Ok(parsed)
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use clap::CommandFactory;
245
246 #[test]
247 fn test_cli_symbol_parsing() {
248 let args = vec![
249 "rust-analyzer-cli",
250 "symbol",
251 "ExampleStruct",
252 "struct",
253 "--exact",
254 ];
255 let cli = Cli::try_parse_from(args).unwrap();
256 assert_eq!(cli.port, 60094);
257 assert!(!cli.json);
258 match cli.command {
259 Commands::Symbol {
260 name,
261 kind,
262 exact,
263 body,
264 max_lines,
265 } => {
266 assert_eq!(name, "ExampleStruct");
267 assert_eq!(kind, "struct");
268 assert!(exact);
269 assert!(!body);
270 assert_eq!(max_lines, 100);
271 }
272 _ => panic!("Expected Symbol command"),
273 }
274 }
275
276 #[test]
277 fn test_cli_outline_parsing() {
278 let args = vec![
279 "rust-analyzer-cli",
280 "--json",
281 "outline",
282 "-f",
283 "tests/code_example.rs",
284 ];
285 let cli = Cli::try_parse_from(args).unwrap();
286 assert!(cli.json);
287 match cli.command {
288 Commands::Outline {
289 file,
290 file_list,
291 output,
292 body,
293 max_lines,
294 } => {
295 assert_eq!(file, Some(PathBuf::from("tests/code_example.rs")));
296 assert!(file_list.is_none());
297 assert!(output.is_none());
298 assert!(!body);
299 assert_eq!(max_lines, 100);
300 }
301 _ => panic!("Expected Outline command"),
302 }
303 }
304
305 #[test]
306 fn test_cli_definition_parsing() {
307 let args = vec![
308 "rust-analyzer-cli",
309 "definition",
310 "-f",
311 "tests/code_example.rs",
312 "-l",
313 "10",
314 "-c",
315 "5",
316 "--body",
317 ];
318 let cli = Cli::try_parse_from(args).unwrap();
319 match cli.command {
320 Commands::Definition {
321 file,
322 line,
323 col,
324 body,
325 max_lines,
326 no_line_numbers,
327 } => {
328 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
329 assert_eq!(line, 10);
330 assert_eq!(col, 5);
331 assert!(body);
332 assert_eq!(max_lines, 100);
333 assert!(!no_line_numbers);
334 }
335 _ => panic!("Expected Definition command"),
336 }
337 }
338
339 #[test]
340 fn test_cli_body_parsing() {
341 let args = vec![
342 "rust-analyzer-cli",
343 "body",
344 "-f",
345 "tests/code_example.rs",
346 "-l",
347 "10",
348 "-c",
349 "5",
350 "--max-lines",
351 "50",
352 "--no-line-numbers",
353 ];
354 let cli = Cli::try_parse_from(args).unwrap();
355 match cli.command {
356 Commands::Body {
357 file,
358 line,
359 col,
360 max_lines,
361 no_line_numbers,
362 } => {
363 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
364 assert_eq!(line, 10);
365 assert_eq!(col, 5);
366 assert_eq!(max_lines, 50);
367 assert!(no_line_numbers);
368 }
369 _ => panic!("Expected Body command"),
370 }
371 }
372
373 #[test]
374 fn test_cli_hover_parsing() {
375 let args = vec![
376 "rust-analyzer-cli",
377 "hover",
378 "-f",
379 "tests/code_example.rs",
380 "-l",
381 "155",
382 "-c",
383 "5",
384 ];
385 let cli = Cli::try_parse_from(args).unwrap();
386 match cli.command {
387 Commands::Hover { file, line, col } => {
388 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
389 assert_eq!(line, 155);
390 assert_eq!(col, 5);
391 }
392 _ => panic!("Expected Hover command"),
393 }
394 }
395
396 #[test]
397 fn test_cli_init_skill_parsing() {
398 let args = vec![
399 "rust-analyzer-cli",
400 "init-skill",
401 "-w",
402 "/my/proj",
403 "-d",
404 "custom/SKILL.md",
405 ];
406 let cli = Cli::try_parse_from(args).unwrap();
407 match cli.command {
408 Commands::InitSkill { workspace, dir } => {
409 assert_eq!(workspace, PathBuf::from("/my/proj"));
410 assert_eq!(dir, Some(PathBuf::from("custom/SKILL.md")));
411 }
412 _ => panic!("Expected InitSkill command"),
413 }
414 }
415
416 #[test]
417 fn test_cli_help_explains_workflow_and_coordinates() {
418 let mut command = Cli::command();
419 let top_help = command.render_help().to_string();
420 assert!(top_help.contains("Start the daemon"));
421 assert!(top_help.contains("1-based line and column numbers"));
422 assert!(top_help.contains("UTF-16 code units"));
423 assert!(top_help.contains("--json"));
424
425 let body_help = command
426 .find_subcommand_mut("body")
427 .expect("body subcommand should be present")
428 .render_help()
429 .to_string();
430 assert!(body_help.contains("must be greater than zero"));
431 assert!(body_help.contains("--max-lines 0"));
432
433 let relations_help = command
434 .find_subcommand_mut("relations")
435 .expect("relations subcommand should be present")
436 .render_help()
437 .to_string();
438 assert!(relations_help.contains("implementations"));
439 assert!(!top_help.contains("type-hierarchy"));
440 assert!(top_help.contains("references"));
441 assert!(top_help.contains("calls"));
442 assert!(!top_help.contains("cursor"));
443 assert!(!top_help.contains("check"));
444 }
445
446 #[test]
447 fn test_cli_rejects_invalid_positions_and_directions() {
448 let position_error = Cli::try_parse_from([
449 "rust-analyzer-cli",
450 "hover",
451 "--file",
452 "tests/code_example.rs",
453 "--line",
454 "0",
455 "--col",
456 "1",
457 ])
458 .expect_err("zero-based line should be rejected");
459 assert!(position_error.to_string().contains("at least 1"));
460
461 let direction_error = Cli::try_parse_from([
462 "rust-analyzer-cli",
463 "calls",
464 "--file",
465 "tests/code_example.rs",
466 "--line",
467 "1",
468 "--col",
469 "1",
470 "--direction",
471 "invalid",
472 ])
473 .expect_err("unsupported call direction should be rejected");
474 assert!(direction_error.to_string().contains("incoming"));
475 assert!(direction_error.to_string().contains("outgoing"));
476
477 let removed_cursor_error = Cli::try_parse_from([
478 "rust-analyzer-cli",
479 "cursor",
480 "--file",
481 "tests/code_example.rs",
482 "--line",
483 "1",
484 "--col",
485 "1",
486 ])
487 .expect_err("removed cursor command should be rejected");
488 assert!(
489 removed_cursor_error
490 .to_string()
491 .contains("unrecognized subcommand")
492 );
493
494 let removed_check_error = Cli::try_parse_from(["rust-analyzer-cli", "check"])
495 .expect_err("removed check command should be rejected");
496 assert!(
497 removed_check_error
498 .to_string()
499 .contains("unrecognized subcommand")
500 );
501
502 let kind_error =
503 Cli::try_parse_from(["rust-analyzer-cli", "symbol", "ExampleStruct", "fn"])
504 .expect_err("legacy symbol kind should be rejected");
505 assert!(kind_error.to_string().contains("struct"));
506
507 let depth_error = Cli::try_parse_from([
508 "rust-analyzer-cli",
509 "calls",
510 "--file",
511 "tests/code_example.rs",
512 "--line",
513 "1",
514 "--col",
515 "1",
516 "--depth",
517 "0",
518 ])
519 .expect_err("zero depth should be rejected");
520 assert!(depth_error.to_string().contains("depth must be at least 1"));
521 }
522
523 #[test]
524 fn test_cli_calls_parsing() {
525 let cli = Cli::try_parse_from([
526 "rust-analyzer-cli",
527 "calls",
528 "--file",
529 "tests/code_example.rs",
530 "--line",
531 "167",
532 "--col",
533 "8",
534 "--direction",
535 "outgoing",
536 "--depth",
537 "2",
538 ])
539 .unwrap();
540
541 match cli.command {
542 Commands::Calls {
543 file,
544 line,
545 col,
546 direction,
547 depth,
548 } => {
549 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
550 assert_eq!(line, 167);
551 assert_eq!(col, 8);
552 assert_eq!(direction, CallDirection::Outgoing);
553 assert_eq!(depth, 2);
554 }
555 _ => panic!("Expected Calls command"),
556 }
557 }
558
559 #[test]
560 fn test_cli_outline_requires_exactly_one_input() {
561 let missing_error = Cli::try_parse_from(["rust-analyzer-cli", "outline"])
562 .expect_err("outline should require a file input");
563 assert!(missing_error.to_string().contains("--file"));
564
565 let conflict_error = Cli::try_parse_from([
566 "rust-analyzer-cli",
567 "outline",
568 "--file",
569 "src/main.rs",
570 "--file-list",
571 "src/lib.rs",
572 ])
573 .expect_err("outline file inputs should be mutually exclusive");
574 assert!(conflict_error.to_string().contains("cannot be used"));
575 }
576
577 #[test]
578 fn test_cli_relations_parsing() {
579 let cli = Cli::try_parse_from([
580 "rust-analyzer-cli",
581 "relations",
582 "--file",
583 "tests/code_example.rs",
584 "--line",
585 "20",
586 "--col",
587 "12",
588 ])
589 .unwrap();
590
591 match cli.command {
592 Commands::Relations {
593 file,
594 line,
595 col,
596 mode,
597 } => {
598 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
599 assert_eq!(line, 20);
600 assert_eq!(col, 12);
601 assert_eq!(mode, RelationMode::Implementations);
602 }
603 _ => panic!("Expected Relations command"),
604 }
605 }
606}