use crate::lsp::types::{CallDirection, RelationMode};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "rust-analyzer-cli",
author,
version,
about = "Compiler-accurate Rust codebase navigation powered by rust-analyzer LSP",
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."
)]
pub struct Cli {
#[arg(short, long, env = "RUST_ANALYZER_CLI_PORT", default_value_t = 60094)]
pub port: u16,
#[arg(short, long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(
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."
)]
Daemon {
#[arg(short, long, default_value = ".")]
workspace: PathBuf,
},
#[command(
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."
)]
Status,
#[command(
after_help = "Example:\n rust-analyzer-cli refresh\n\nThe daemon must be running. Query status again after refreshing to confirm indexing state."
)]
Refresh,
#[command(
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."
)]
Symbol {
name: String,
#[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")]
kind: String,
#[arg(long)]
exact: bool,
#[arg(short, long)]
body: bool,
#[arg(long, default_value_t = 100)]
max_lines: usize,
},
#[command(
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."
)]
Outline {
#[arg(
short,
long,
required_unless_present = "file_list",
conflicts_with = "file_list"
)]
file: Option<PathBuf>,
#[arg(long, conflicts_with = "file")]
file_list: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
body: bool,
#[arg(long, default_value_t = 100)]
max_lines: usize,
},
#[command(
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."
)]
Definition {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
#[arg(short, long)]
body: bool,
#[arg(long, default_value_t = 100)]
max_lines: usize,
#[arg(long)]
no_line_numbers: bool,
},
#[command(
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."
)]
Body {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
#[arg(long, default_value_t = 100)]
max_lines: usize,
#[arg(long)]
no_line_numbers: bool,
},
#[command(
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."
)]
Hover {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
},
#[command(
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."
)]
References {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
},
#[command(
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."
)]
Calls {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
#[arg(long, default_value_t = CallDirection::Incoming)]
direction: CallDirection,
#[arg(short, long, value_parser = parse_positive_depth, default_value_t = 1)]
depth: u32,
},
#[command(
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."
)]
Relations {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long, value_parser = parse_positive_u32)]
line: u32,
#[arg(short, long, value_parser = parse_positive_u32)]
col: u32,
#[arg(short, long, default_value_t = RelationMode::Implementations)]
mode: RelationMode,
},
#[command(
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."
)]
InitSkill {
#[arg(short, long, default_value = ".")]
workspace: PathBuf,
#[arg(short, long)]
dir: Option<PathBuf>,
},
}
fn parse_positive_u32(value: &str) -> Result<u32, String> {
let parsed = value
.parse::<u32>()
.map_err(|_| format!("{value} must be a positive integer"))?;
if parsed == 0 {
return Err("value must be at least 1; line and column numbers are 1-based".to_string());
}
Ok(parsed)
}
fn parse_positive_depth(value: &str) -> Result<u32, String> {
let parsed = value
.parse::<u32>()
.map_err(|_| format!("{value} must be a positive integer depth"))?;
if parsed == 0 {
return Err("depth must be at least 1".to_string());
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn test_cli_symbol_parsing() {
let args = vec![
"rust-analyzer-cli",
"symbol",
"ExampleStruct",
"struct",
"--exact",
];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.port, 60094);
assert!(!cli.json);
match cli.command {
Commands::Symbol {
name,
kind,
exact,
body,
max_lines,
} => {
assert_eq!(name, "ExampleStruct");
assert_eq!(kind, "struct");
assert!(exact);
assert!(!body);
assert_eq!(max_lines, 100);
}
_ => panic!("Expected Symbol command"),
}
}
#[test]
fn test_cli_outline_parsing() {
let args = vec![
"rust-analyzer-cli",
"--json",
"outline",
"-f",
"tests/code_example.rs",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(cli.json);
match cli.command {
Commands::Outline {
file,
file_list,
output,
body,
max_lines,
} => {
assert_eq!(file, Some(PathBuf::from("tests/code_example.rs")));
assert!(file_list.is_none());
assert!(output.is_none());
assert!(!body);
assert_eq!(max_lines, 100);
}
_ => panic!("Expected Outline command"),
}
}
#[test]
fn test_cli_definition_parsing() {
let args = vec![
"rust-analyzer-cli",
"definition",
"-f",
"tests/code_example.rs",
"-l",
"10",
"-c",
"5",
"--body",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Definition {
file,
line,
col,
body,
max_lines,
no_line_numbers,
} => {
assert_eq!(file, PathBuf::from("tests/code_example.rs"));
assert_eq!(line, 10);
assert_eq!(col, 5);
assert!(body);
assert_eq!(max_lines, 100);
assert!(!no_line_numbers);
}
_ => panic!("Expected Definition command"),
}
}
#[test]
fn test_cli_body_parsing() {
let args = vec![
"rust-analyzer-cli",
"body",
"-f",
"tests/code_example.rs",
"-l",
"10",
"-c",
"5",
"--max-lines",
"50",
"--no-line-numbers",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Body {
file,
line,
col,
max_lines,
no_line_numbers,
} => {
assert_eq!(file, PathBuf::from("tests/code_example.rs"));
assert_eq!(line, 10);
assert_eq!(col, 5);
assert_eq!(max_lines, 50);
assert!(no_line_numbers);
}
_ => panic!("Expected Body command"),
}
}
#[test]
fn test_cli_hover_parsing() {
let args = vec![
"rust-analyzer-cli",
"hover",
"-f",
"tests/code_example.rs",
"-l",
"155",
"-c",
"5",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Hover { file, line, col } => {
assert_eq!(file, PathBuf::from("tests/code_example.rs"));
assert_eq!(line, 155);
assert_eq!(col, 5);
}
_ => panic!("Expected Hover command"),
}
}
#[test]
fn test_cli_init_skill_parsing() {
let args = vec![
"rust-analyzer-cli",
"init-skill",
"-w",
"/my/proj",
"-d",
"custom/SKILL.md",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::InitSkill { workspace, dir } => {
assert_eq!(workspace, PathBuf::from("/my/proj"));
assert_eq!(dir, Some(PathBuf::from("custom/SKILL.md")));
}
_ => panic!("Expected InitSkill command"),
}
}
#[test]
fn test_cli_help_explains_workflow_and_coordinates() {
let mut command = Cli::command();
let top_help = command.render_help().to_string();
assert!(top_help.contains("Start the daemon"));
assert!(top_help.contains("1-based line and column numbers"));
assert!(top_help.contains("UTF-16 code units"));
assert!(top_help.contains("--json"));
let body_help = command
.find_subcommand_mut("body")
.expect("body subcommand should be present")
.render_help()
.to_string();
assert!(body_help.contains("must be greater than zero"));
assert!(body_help.contains("--max-lines 0"));
let relations_help = command
.find_subcommand_mut("relations")
.expect("relations subcommand should be present")
.render_help()
.to_string();
assert!(relations_help.contains("implementations"));
assert!(!top_help.contains("type-hierarchy"));
assert!(top_help.contains("references"));
assert!(top_help.contains("calls"));
assert!(!top_help.contains("cursor"));
assert!(!top_help.contains("check"));
}
#[test]
fn test_cli_rejects_invalid_positions_and_directions() {
let position_error = Cli::try_parse_from([
"rust-analyzer-cli",
"hover",
"--file",
"tests/code_example.rs",
"--line",
"0",
"--col",
"1",
])
.expect_err("zero-based line should be rejected");
assert!(position_error.to_string().contains("at least 1"));
let direction_error = Cli::try_parse_from([
"rust-analyzer-cli",
"calls",
"--file",
"tests/code_example.rs",
"--line",
"1",
"--col",
"1",
"--direction",
"invalid",
])
.expect_err("unsupported call direction should be rejected");
assert!(direction_error.to_string().contains("incoming"));
assert!(direction_error.to_string().contains("outgoing"));
let removed_cursor_error = Cli::try_parse_from([
"rust-analyzer-cli",
"cursor",
"--file",
"tests/code_example.rs",
"--line",
"1",
"--col",
"1",
])
.expect_err("removed cursor command should be rejected");
assert!(
removed_cursor_error
.to_string()
.contains("unrecognized subcommand")
);
let removed_check_error = Cli::try_parse_from(["rust-analyzer-cli", "check"])
.expect_err("removed check command should be rejected");
assert!(
removed_check_error
.to_string()
.contains("unrecognized subcommand")
);
let kind_error =
Cli::try_parse_from(["rust-analyzer-cli", "symbol", "ExampleStruct", "fn"])
.expect_err("legacy symbol kind should be rejected");
assert!(kind_error.to_string().contains("struct"));
let depth_error = Cli::try_parse_from([
"rust-analyzer-cli",
"calls",
"--file",
"tests/code_example.rs",
"--line",
"1",
"--col",
"1",
"--depth",
"0",
])
.expect_err("zero depth should be rejected");
assert!(depth_error.to_string().contains("depth must be at least 1"));
}
#[test]
fn test_cli_calls_parsing() {
let cli = Cli::try_parse_from([
"rust-analyzer-cli",
"calls",
"--file",
"tests/code_example.rs",
"--line",
"167",
"--col",
"8",
"--direction",
"outgoing",
"--depth",
"2",
])
.unwrap();
match cli.command {
Commands::Calls {
file,
line,
col,
direction,
depth,
} => {
assert_eq!(file, PathBuf::from("tests/code_example.rs"));
assert_eq!(line, 167);
assert_eq!(col, 8);
assert_eq!(direction, CallDirection::Outgoing);
assert_eq!(depth, 2);
}
_ => panic!("Expected Calls command"),
}
}
#[test]
fn test_cli_outline_requires_exactly_one_input() {
let missing_error = Cli::try_parse_from(["rust-analyzer-cli", "outline"])
.expect_err("outline should require a file input");
assert!(missing_error.to_string().contains("--file"));
let conflict_error = Cli::try_parse_from([
"rust-analyzer-cli",
"outline",
"--file",
"src/main.rs",
"--file-list",
"src/lib.rs",
])
.expect_err("outline file inputs should be mutually exclusive");
assert!(conflict_error.to_string().contains("cannot be used"));
}
#[test]
fn test_cli_relations_parsing() {
let cli = Cli::try_parse_from([
"rust-analyzer-cli",
"relations",
"--file",
"tests/code_example.rs",
"--line",
"20",
"--col",
"12",
])
.unwrap();
match cli.command {
Commands::Relations {
file,
line,
col,
mode,
} => {
assert_eq!(file, PathBuf::from("tests/code_example.rs"));
assert_eq!(line, 20);
assert_eq!(col, 12);
assert_eq!(mode, RelationMode::Implementations);
}
_ => panic!("Expected Relations command"),
}
}
}