Skip to main content

rust_analyzer_cli/
cli.rs

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    /// Port for the background daemon HTTP server (default: 60094; env: RUST_ANALYZER_CLI_PORT)
15    #[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
16    pub port: u16,
17
18    /// Print only machine-readable JSON to stdout; errors are reported on stderr
19    #[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    /// Start the background LSP daemon and HTTP server
29    #[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        /// Workspace root directory (default: current directory)
34        #[arg(short, long, default_value = ".")]
35        workspace: PathBuf,
36    },
37    /// Query daemon and rust-analyzer status
38    #[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    /// Refresh / restart the rust-analyzer session
43    #[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    /// Search workspace symbols
48    #[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        /// Symbol name to search for
53        name: String,
54        /// Optional Rust symbol filter; use any for all canonical Rust symbol kinds
55        #[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        /// Match exact symbol name
58        #[arg(long)]
59        exact: bool,
60        /// Include source code body/snippet; output is limited by --max-lines
61        #[arg(short, long)]
62        body: bool,
63        /// Maximum body lines (default: 100; 0 means unlimited)
64        #[arg(long, default_value_t = 100)]
65        max_lines: usize,
66    },
67    /// Extract file document symbols outline
68    #[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        /// Target file path; mutually exclusive with --file-list
73        #[arg(
74            short,
75            long,
76            required_unless_present = "file_list",
77            conflicts_with = "file_list"
78        )]
79        file: Option<PathBuf>,
80        /// Comma-separated target file paths; mutually exclusive with --file
81        #[arg(long, conflicts_with = "file")]
82        file_list: Option<String>,
83        /// Write outline output to a file instead of stdout
84        #[arg(short, long)]
85        output: Option<PathBuf>,
86        /// Include source code body/snippet for symbols; output is limited by --max-lines
87        #[arg(short, long)]
88        body: bool,
89        /// Maximum body lines (default: 100; 0 means unlimited)
90        #[arg(long, default_value_t = 100)]
91        max_lines: usize,
92    },
93    /// Go to definition at file line and column
94    #[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        /// File path
99        #[arg(short, long)]
100        file: PathBuf,
101        /// Line number (1-based and must be greater than zero)
102        #[arg(short, long, value_parser = parse_positive_u32)]
103        line: u32,
104        /// Column number (1-based and must be greater than zero)
105        #[arg(short, long, value_parser = parse_positive_u32)]
106        col: u32,
107        /// Include source code body/snippet of the definition target
108        #[arg(short, long)]
109        body: bool,
110        /// Maximum body lines (default: 100; 0 means unlimited)
111        #[arg(long, default_value_t = 100)]
112        max_lines: usize,
113        /// Omit line numbers in terminal output
114        #[arg(long)]
115        no_line_numbers: bool,
116    },
117    /// Inspect source code body of struct, function, enum, impl, trait, or module
118    #[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        /// File path
123        #[arg(short, long)]
124        file: PathBuf,
125        /// Line number (1-based and must be greater than zero)
126        #[arg(short, long, value_parser = parse_positive_u32)]
127        line: u32,
128        /// Column number (1-based and must be greater than zero)
129        #[arg(short, long, value_parser = parse_positive_u32)]
130        col: u32,
131        /// Maximum body lines (default: 100; 0 means unlimited)
132        #[arg(long, default_value_t = 100)]
133        max_lines: usize,
134        /// Omit line numbers in terminal output
135        #[arg(long)]
136        no_line_numbers: bool,
137    },
138    /// Show documentation and type information at a file position
139    #[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        /// File path
144        #[arg(short, long)]
145        file: PathBuf,
146        /// Line number (1-based and must be greater than zero)
147        #[arg(short, long, value_parser = parse_positive_u32)]
148        line: u32,
149        /// Column number (1-based and must be greater than zero)
150        #[arg(short, long, value_parser = parse_positive_u32)]
151        col: u32,
152    },
153    /// Find references at a file position
154    #[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        /// File path
159        #[arg(short, long)]
160        file: PathBuf,
161        /// Line number (1-based and must be greater than zero)
162        #[arg(short, long, value_parser = parse_positive_u32)]
163        line: u32,
164        /// Column number (1-based and must be greater than zero)
165        #[arg(short, long, value_parser = parse_positive_u32)]
166        col: u32,
167    },
168    /// Find incoming or outgoing calls at a file position
169    #[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        /// File path
174        #[arg(short, long)]
175        file: PathBuf,
176        /// Line number (1-based and must be greater than zero)
177        #[arg(short, long, value_parser = parse_positive_u32)]
178        line: u32,
179        /// Column number (1-based and must be greater than zero)
180        #[arg(short, long, value_parser = parse_positive_u32)]
181        col: u32,
182        /// Call direction: incoming or outgoing (default: incoming)
183        #[arg(long, default_value_t = CallDirection::Incoming)]
184        direction: CallDirection,
185        /// Call hierarchy traversal depth (default: 1; must be greater than zero)
186        #[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
187        depth: u32,
188    },
189    /// Query Rust symbol relations
190    #[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        /// File path
195        #[arg(short, long)]
196        file: PathBuf,
197        /// Line number (1-based and must be greater than zero)
198        #[arg(short, long, value_parser = parse_positive_u32)]
199        line: u32,
200        /// Column number (1-based and must be greater than zero)
201        #[arg(short, long, value_parser = parse_positive_u32)]
202        col: u32,
203        /// Relation mode (currently only implementations)
204        #[arg(short, long, default_value_t = RelationMode::Implementations)]
205        mode: RelationMode,
206    },
207    /// Initialize/install the rust-codebase-navigation SKILL.md in a workspace
208    #[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        /// Target workspace directory (default: current directory)
213        #[arg(short, long, default_value = ".")]
214        workspace: PathBuf,
215        /// Custom project-relative skill path (default: .agents/skills/rust-codebase-navigation/SKILL.md)
216        #[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}