Skip to main content

rust_analyzer_cli/
cli.rs

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    /// Port for the background daemon HTTP server (default: 60094; env: RUST_ANALYZER_CLI_PORT)
14    #[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
15    pub port: u16,
16
17    /// Print only machine-readable JSON to stdout; errors are reported on stderr
18    #[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    /// Start the background LSP daemon and HTTP server
28    #[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        /// Workspace root directory (default: current directory)
33        #[arg(short, long, default_value = ".")]
34        workspace: PathBuf,
35    },
36    /// Query daemon and rust-analyzer status
37    #[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    /// Refresh / restart the rust-analyzer session
42    #[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    /// Search workspace symbols
47    #[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        /// Symbol name to search for
52        name: String,
53        /// Optional symbol filter: any, struct, enum, fn, trait, type, const, var
54        #[arg(value_parser = ["any", "struct", "enum", "fn", "trait", "type", "const", "var"], default_value = "any")]
55        kind: String,
56        /// Match exact symbol name
57        #[arg(long)]
58        exact: bool,
59        /// Include source code body/snippet; output is limited by --max-lines
60        #[arg(short, long)]
61        body: bool,
62        /// Maximum body lines (default: 100; 0 means unlimited)
63        #[arg(long, default_value_t = 100)]
64        max_lines: usize,
65    },
66    /// Extract file document symbols outline
67    #[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        /// Target file path; mutually exclusive with --file-list
72        #[arg(
73            short,
74            long,
75            required_unless_present = "file_list",
76            conflicts_with = "file_list"
77        )]
78        file: Option<PathBuf>,
79        /// Comma-separated target file paths; mutually exclusive with --file
80        #[arg(long, conflicts_with = "file")]
81        file_list: Option<String>,
82        /// Write outline output to a file instead of stdout
83        #[arg(short, long)]
84        output: Option<PathBuf>,
85        /// Include source code body/snippet for symbols; output is limited by --max-lines
86        #[arg(short, long)]
87        body: bool,
88        /// Maximum body lines (default: 100; 0 means unlimited)
89        #[arg(long, default_value_t = 100)]
90        max_lines: usize,
91    },
92    /// Go to definition at file line and column
93    #[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        /// File path
98        #[arg(short, long)]
99        file: PathBuf,
100        /// Line number (1-based and must be greater than zero)
101        #[arg(short, long, value_parser = parse_positive_u32)]
102        line: u32,
103        /// Column number (1-based and must be greater than zero)
104        #[arg(short, long, value_parser = parse_positive_u32)]
105        col: u32,
106        /// Include source code body/snippet of the definition target
107        #[arg(short, long)]
108        body: bool,
109        /// Maximum body lines (default: 100; 0 means unlimited)
110        #[arg(long, default_value_t = 100)]
111        max_lines: usize,
112        /// Omit line numbers in terminal output
113        #[arg(long)]
114        no_line_numbers: bool,
115    },
116    /// Inspect source code body of struct, function, enum, impl, trait, or module
117    #[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        /// File path
122        #[arg(short, long)]
123        file: PathBuf,
124        /// Line number (1-based and must be greater than zero)
125        #[arg(short, long, value_parser = parse_positive_u32)]
126        line: u32,
127        /// Column number (1-based and must be greater than zero)
128        #[arg(short, long, value_parser = parse_positive_u32)]
129        col: u32,
130        /// Maximum body lines (default: 100; 0 means unlimited)
131        #[arg(long, default_value_t = 100)]
132        max_lines: usize,
133        /// Omit line numbers in terminal output
134        #[arg(long)]
135        no_line_numbers: bool,
136    },
137    /// Show documentation and type information at a file position
138    #[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        /// File path
143        #[arg(short, long)]
144        file: PathBuf,
145        /// Line number (1-based and must be greater than zero)
146        #[arg(short, long, value_parser = parse_positive_u32)]
147        line: u32,
148        /// Column number (1-based and must be greater than zero)
149        #[arg(short, long, value_parser = parse_positive_u32)]
150        col: u32,
151    },
152    /// Find references or call hierarchy at file line and column
153    #[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        /// File path
158        #[arg(short, long)]
159        file: PathBuf,
160        /// Line number (1-based and must be greater than zero)
161        #[arg(short, long, value_parser = parse_positive_u32)]
162        line: u32,
163        /// Column number (1-based and must be greater than zero)
164        #[arg(short, long, value_parser = parse_positive_u32)]
165        col: u32,
166        /// Mode: incoming, outgoing, or references (default: incoming)
167        #[arg(short, long, value_parser = ["incoming", "outgoing", "references"], default_value = "incoming")]
168        mode: String,
169        /// Call hierarchy traversal depth (default: 1; must be greater than zero)
170        #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
171        depth: u32,
172    },
173    /// Query type hierarchy (supertypes / subtypes)
174    #[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        /// File path
179        #[arg(short, long)]
180        file: PathBuf,
181        /// Line number (1-based and must be greater than zero)
182        #[arg(short, long, value_parser = parse_positive_u32)]
183        line: u32,
184        /// Column number (1-based and must be greater than zero)
185        #[arg(short, long, value_parser = parse_positive_u32)]
186        col: u32,
187        /// Mode: supertypes or subtypes (default: supertypes)
188        #[arg(short, long, value_parser = ["supertypes", "subtypes"], default_value = "supertypes")]
189        mode: String,
190        /// Hierarchy traversal depth (default: 1; must be greater than zero)
191        #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
192        depth: u32,
193    },
194    /// Run cargo check compilation verification
195    #[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        /// Optional cargo build target
200        #[arg(short, long)]
201        target: Option<String>,
202    },
203    /// Initialize/install the rust-codebase-navigation SKILL.md in a workspace
204    #[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        /// Target workspace directory (default: current directory)
209        #[arg(short, long, default_value = ".")]
210        workspace: PathBuf,
211        /// Custom project-relative skill path (default: .agents/skills/rust-codebase-navigation/SKILL.md)
212        #[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}