1use clap::{Parser, Subcommand};
2use std::path::PathBuf;
3
4#[derive(Parser, Debug)]
5#[command(
6 name = "rust-analyzer-cli",
7 author,
8 version,
9 about = "Compiler-accurate Rust codebase navigation powered by rust-analyzer LSP",
10 after_help = "Workflow:\n 1. Start the daemon: rust-analyzer-cli daemon --workspace .\n 2. Wait for indexing: 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. Use --json for machine-readable stdout.\nMost query commands require the daemon to be running first."
11)]
12pub struct Cli {
13 #[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
15 pub port: u16,
16
17 #[arg(short, long, global = true)]
19 pub json: bool,
20
21 #[command(subcommand)]
22 pub command: Commands,
23}
24
25#[derive(Subcommand, Debug)]
26pub enum Commands {
27 #[command(
29 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."
30 )]
31 Daemon {
32 #[arg(short, long, default_value = ".")]
34 workspace: PathBuf,
35 },
36 #[command(
38 after_help = "Example:\n rust-analyzer-cli status\n rust-analyzer-cli --json status\n\nA status of INITIALIZING means rust-analyzer is still indexing the workspace."
39 )]
40 Status,
41 #[command(
43 after_help = "Example:\n rust-analyzer-cli refresh\n\nThe daemon must be running. Query status again after refreshing to confirm indexing state."
44 )]
45 Refresh,
46 #[command(
48 after_help = "Examples:\n rust-analyzer-cli symbol LspClient struct --exact\n rust-analyzer-cli symbol LspClient --body --max-lines 50\n\nThe optional kind must be one of: any, struct, enum, fn, trait, type, const, var."
49 )]
50 Symbol {
51 name: String,
53 #[arg(value_parser = ["any", "struct", "enum", "fn", "trait", "type", "const", "var"], default_value = "any")]
55 kind: String,
56 #[arg(long)]
58 exact: bool,
59 #[arg(short, long)]
61 body: bool,
62 #[arg(long, default_value_t = 100)]
64 max_lines: usize,
65 },
66 #[command(
68 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."
69 )]
70 Outline {
71 #[arg(
73 short,
74 long,
75 required_unless_present = "file_list",
76 conflicts_with = "file_list"
77 )]
78 file: Option<PathBuf>,
79 #[arg(long, conflicts_with = "file")]
81 file_list: Option<String>,
82 #[arg(short, long)]
84 output: Option<PathBuf>,
85 #[arg(short, long)]
87 body: bool,
88 #[arg(long, default_value_t = 100)]
90 max_lines: usize,
91 },
92 #[command(
94 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."
95 )]
96 Definition {
97 #[arg(short, long)]
99 file: PathBuf,
100 #[arg(short, long, value_parser = parse_positive_u32)]
102 line: u32,
103 #[arg(short, long, value_parser = parse_positive_u32)]
105 col: u32,
106 #[arg(short, long)]
108 body: bool,
109 #[arg(long, default_value_t = 100)]
111 max_lines: usize,
112 #[arg(long)]
114 no_line_numbers: bool,
115 },
116 #[command(
118 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."
119 )]
120 Body {
121 #[arg(short, long)]
123 file: PathBuf,
124 #[arg(short, long, value_parser = parse_positive_u32)]
126 line: u32,
127 #[arg(short, long, value_parser = parse_positive_u32)]
129 col: u32,
130 #[arg(long, default_value_t = 100)]
132 max_lines: usize,
133 #[arg(long)]
135 no_line_numbers: bool,
136 },
137 #[command(
139 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."
140 )]
141 Hover {
142 #[arg(short, long)]
144 file: PathBuf,
145 #[arg(short, long, value_parser = parse_positive_u32)]
147 line: u32,
148 #[arg(short, long, value_parser = parse_positive_u32)]
150 col: u32,
151 },
152 #[command(
154 after_help = "Examples:\n rust-analyzer-cli cursor --file src/main.rs --line 25 --col 8 --mode incoming\n rust-analyzer-cli cursor --file src/main.rs --line 25 --col 8 --mode outgoing --depth 2\n\nModes: incoming, outgoing, references. Depth applies to call hierarchy modes; references always returns the direct reference query."
155 )]
156 Cursor {
157 #[arg(short, long)]
159 file: PathBuf,
160 #[arg(short, long, value_parser = parse_positive_u32)]
162 line: u32,
163 #[arg(short, long, value_parser = parse_positive_u32)]
165 col: u32,
166 #[arg(short, long, value_parser = ["incoming", "outgoing", "references"], default_value = "incoming")]
168 mode: String,
169 #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
171 depth: u32,
172 },
173 #[command(
175 after_help = "Examples:\n rust-analyzer-cli type-hierarchy --file src/main.rs --line 25 --col 8\n rust-analyzer-cli type-hierarchy --file src/main.rs --line 25 --col 8 --mode subtypes --depth 2\n\nModes: supertypes and subtypes. Depth controls how many hierarchy levels are traversed."
176 )]
177 TypeHierarchy {
178 #[arg(short, long)]
180 file: PathBuf,
181 #[arg(short, long, value_parser = parse_positive_u32)]
183 line: u32,
184 #[arg(short, long, value_parser = parse_positive_u32)]
186 col: u32,
187 #[arg(short, long, value_parser = ["supertypes", "subtypes"], default_value = "supertypes")]
189 mode: String,
190 #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
192 depth: u32,
193 },
194 #[command(
196 after_help = "Examples:\n rust-analyzer-cli check\n rust-analyzer-cli check --target x86_64-pc-windows-msvc\n\nCompilation failures are returned with Cargo's diagnostic text and source locations when available."
197 )]
198 Check {
199 #[arg(short, long)]
201 target: Option<String>,
202 },
203 #[command(
205 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."
206 )]
207 InitSkill {
208 #[arg(short, long, default_value = ".")]
210 workspace: PathBuf,
211 #[arg(short, long)]
213 dir: Option<PathBuf>,
214 },
215}
216
217fn parse_positive_u32(value: &str) -> Result<u32, String> {
218 let parsed = value
219 .parse::<u32>()
220 .map_err(|_| format!("{value} must be a positive integer"))?;
221 if parsed == 0 {
222 return Err("value must be at least 1; line and column numbers are 1-based".to_string());
223 }
224 Ok(parsed)
225}
226
227fn parse_positive_depth(value: &str) -> Result<u32, String> {
228 let parsed = value
229 .parse::<u32>()
230 .map_err(|_| format!("{value} must be a positive integer depth"))?;
231 if parsed == 0 {
232 return Err("depth must be at least 1".to_string());
233 }
234 Ok(parsed)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use clap::CommandFactory;
241
242 #[test]
243 fn test_cli_symbol_parsing() {
244 let args = vec![
245 "rust-analyzer-cli",
246 "symbol",
247 "ExampleStruct",
248 "struct",
249 "--exact",
250 ];
251 let cli = Cli::try_parse_from(args).unwrap();
252 assert_eq!(cli.port, 60094);
253 assert!(!cli.json);
254 match cli.command {
255 Commands::Symbol {
256 name,
257 kind,
258 exact,
259 body,
260 max_lines,
261 } => {
262 assert_eq!(name, "ExampleStruct");
263 assert_eq!(kind, "struct");
264 assert!(exact);
265 assert!(!body);
266 assert_eq!(max_lines, 100);
267 }
268 _ => panic!("Expected Symbol command"),
269 }
270 }
271
272 #[test]
273 fn test_cli_outline_parsing() {
274 let args = vec![
275 "rust-analyzer-cli",
276 "--json",
277 "outline",
278 "-f",
279 "tests/code_example.rs",
280 ];
281 let cli = Cli::try_parse_from(args).unwrap();
282 assert!(cli.json);
283 match cli.command {
284 Commands::Outline {
285 file,
286 file_list,
287 output,
288 body,
289 max_lines,
290 } => {
291 assert_eq!(file, Some(PathBuf::from("tests/code_example.rs")));
292 assert!(file_list.is_none());
293 assert!(output.is_none());
294 assert!(!body);
295 assert_eq!(max_lines, 100);
296 }
297 _ => panic!("Expected Outline command"),
298 }
299 }
300
301 #[test]
302 fn test_cli_definition_parsing() {
303 let args = vec![
304 "rust-analyzer-cli",
305 "definition",
306 "-f",
307 "tests/code_example.rs",
308 "-l",
309 "10",
310 "-c",
311 "5",
312 "--body",
313 ];
314 let cli = Cli::try_parse_from(args).unwrap();
315 match cli.command {
316 Commands::Definition {
317 file,
318 line,
319 col,
320 body,
321 max_lines,
322 no_line_numbers,
323 } => {
324 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
325 assert_eq!(line, 10);
326 assert_eq!(col, 5);
327 assert!(body);
328 assert_eq!(max_lines, 100);
329 assert!(!no_line_numbers);
330 }
331 _ => panic!("Expected Definition command"),
332 }
333 }
334
335 #[test]
336 fn test_cli_body_parsing() {
337 let args = vec![
338 "rust-analyzer-cli",
339 "body",
340 "-f",
341 "tests/code_example.rs",
342 "-l",
343 "10",
344 "-c",
345 "5",
346 "--max-lines",
347 "50",
348 "--no-line-numbers",
349 ];
350 let cli = Cli::try_parse_from(args).unwrap();
351 match cli.command {
352 Commands::Body {
353 file,
354 line,
355 col,
356 max_lines,
357 no_line_numbers,
358 } => {
359 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
360 assert_eq!(line, 10);
361 assert_eq!(col, 5);
362 assert_eq!(max_lines, 50);
363 assert!(no_line_numbers);
364 }
365 _ => panic!("Expected Body command"),
366 }
367 }
368
369 #[test]
370 fn test_cli_hover_parsing() {
371 let args = vec![
372 "rust-analyzer-cli",
373 "hover",
374 "-f",
375 "tests/code_example.rs",
376 "-l",
377 "155",
378 "-c",
379 "5",
380 ];
381 let cli = Cli::try_parse_from(args).unwrap();
382 match cli.command {
383 Commands::Hover { file, line, col } => {
384 assert_eq!(file, PathBuf::from("tests/code_example.rs"));
385 assert_eq!(line, 155);
386 assert_eq!(col, 5);
387 }
388 _ => panic!("Expected Hover command"),
389 }
390 }
391
392 #[test]
393 fn test_cli_init_skill_parsing() {
394 let args = vec![
395 "rust-analyzer-cli",
396 "init-skill",
397 "-w",
398 "/my/proj",
399 "-d",
400 "custom/SKILL.md",
401 ];
402 let cli = Cli::try_parse_from(args).unwrap();
403 match cli.command {
404 Commands::InitSkill { workspace, dir } => {
405 assert_eq!(workspace, PathBuf::from("/my/proj"));
406 assert_eq!(dir, Some(PathBuf::from("custom/SKILL.md")));
407 }
408 _ => panic!("Expected InitSkill command"),
409 }
410 }
411
412 #[test]
413 fn test_cli_help_explains_workflow_and_coordinates() {
414 let mut command = Cli::command();
415 let top_help = command.render_help().to_string();
416 assert!(top_help.contains("Start the daemon"));
417 assert!(top_help.contains("1-based line and column numbers"));
418 assert!(top_help.contains("--json"));
419
420 let body_help = command
421 .find_subcommand_mut("body")
422 .expect("body subcommand should be present")
423 .render_help()
424 .to_string();
425 assert!(body_help.contains("must be greater than zero"));
426 assert!(body_help.contains("--max-lines 0"));
427 }
428
429 #[test]
430 fn test_cli_rejects_invalid_positions_and_modes() {
431 let position_error = Cli::try_parse_from([
432 "rust-analyzer-cli",
433 "hover",
434 "--file",
435 "tests/code_example.rs",
436 "--line",
437 "0",
438 "--col",
439 "1",
440 ])
441 .expect_err("zero-based line should be rejected");
442 assert!(position_error.to_string().contains("at least 1"));
443
444 let mode_error = Cli::try_parse_from([
445 "rust-analyzer-cli",
446 "cursor",
447 "--file",
448 "tests/code_example.rs",
449 "--line",
450 "1",
451 "--col",
452 "1",
453 "--mode",
454 "invalid",
455 ])
456 .expect_err("unsupported cursor mode should be rejected");
457 assert!(mode_error.to_string().contains("incoming"));
458 assert!(mode_error.to_string().contains("references"));
459
460 let kind_error =
461 Cli::try_parse_from(["rust-analyzer-cli", "symbol", "ExampleStruct", "invalid"])
462 .expect_err("unsupported symbol kind should be rejected");
463 assert!(kind_error.to_string().contains("struct"));
464
465 let depth_error = Cli::try_parse_from([
466 "rust-analyzer-cli",
467 "cursor",
468 "--file",
469 "tests/code_example.rs",
470 "--line",
471 "1",
472 "--col",
473 "1",
474 "--depth",
475 "0",
476 ])
477 .expect_err("zero depth should be rejected");
478 assert!(depth_error.to_string().contains("depth must be at least 1"));
479 }
480
481 #[test]
482 fn test_cli_outline_requires_exactly_one_input() {
483 let missing_error = Cli::try_parse_from(["rust-analyzer-cli", "outline"])
484 .expect_err("outline should require a file input");
485 assert!(missing_error.to_string().contains("--file"));
486
487 let conflict_error = Cli::try_parse_from([
488 "rust-analyzer-cli",
489 "outline",
490 "--file",
491 "src/main.rs",
492 "--file-list",
493 "src/lib.rs",
494 ])
495 .expect_err("outline file inputs should be mutually exclusive");
496 assert!(conflict_error.to_string().contains("cannot be used"));
497 }
498}