1use anyhow::{bail, Result};
4use std::{ffi::OsString, path::PathBuf};
5
6use crate::settings::Settings;
7
8pub const USAGE: &str = "\
9MCP server for rust-analyzer integration.
10
11Usage: rust-analyzer-mcp [OPTIONS] [--] [WORKSPACE]
12
13Arguments:
14 [WORKSPACE] Path to the workspace root [default: current directory]
15
16Options:
17 --all-features Analyse and check with every cargo feature enabled
18 --features <LIST> Cargo features to enable, comma- or space-separated. May be given
19 more than once
20 --no-default-features Leave the manifest's default features out
21 --config <KEY=VALUE> Set any rust-analyzer setting, such as --config check.command=clippy.
22 VALUE is read as JSON, or taken for a string if it is not JSON. May
23 be given more than once
24 -h, --help Print help
25 -V, --version Print version
26";
27
28#[derive(Debug, PartialEq, Eq)]
30pub enum Action {
31 Help,
32 Version,
33 Serve {
34 workspace: Option<PathBuf>,
36 settings: Settings,
37 },
38}
39
40pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Action> {
45 let mut args = args.into_iter();
46 let mut workspace = None;
47 let mut settings = Settings::default();
48 let mut options_ended = false;
49
50 while let Some(arg) = args.next() {
51 if !options_ended && arg == "--" {
54 options_ended = true;
55 continue;
56 }
57
58 let option = (!options_ended)
60 .then(|| arg.to_str())
61 .flatten()
62 .filter(|arg| arg.starts_with('-'));
63 if let Some(option) = option {
64 let (name, inline) = match option.split_once('=') {
66 Some((name, value)) => (name, Some(value.to_string())),
67 None => (option, None),
68 };
69
70 match name {
71 "-h" | "--help" => return Ok(Action::Help),
72 "-V" | "--version" => return Ok(Action::Version),
73 "--all-features" => settings.enable_all_features()?,
74 "--no-default-features" => settings.disable_default_features()?,
75 "--features" => settings.enable_features(&value(name, inline, &mut args)?)?,
76 "--config" => settings.set(&value(name, inline, &mut args)?)?,
77 _ => bail!("unknown option '{option}'\n\n{USAGE}"),
78 }
79 continue;
80 }
81
82 if workspace.replace(PathBuf::from(&arg)).is_some() {
83 bail!(
84 "unexpected extra argument '{}'\n\n{USAGE}",
85 arg.to_string_lossy()
86 );
87 }
88 }
89
90 Ok(Action::Serve {
91 workspace,
92 settings,
93 })
94}
95
96fn value(
98 name: &str,
99 inline: Option<String>,
100 args: &mut impl Iterator<Item = OsString>,
101) -> Result<String> {
102 if let Some(value) = inline {
103 return Ok(value);
104 }
105
106 let Some(value) = args.next() else {
107 bail!("{name} needs a value\n\n{USAGE}");
108 };
109 let Some(value) = value.to_str() else {
110 bail!("{name}'s value is not valid UTF-8");
111 };
112
113 Ok(value.to_string())
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use serde_json::json;
120
121 #[test]
122 fn nothing_at_all_serves_the_current_directory() {
123 assert_eq!(
124 parse([]).unwrap(),
125 Action::Serve {
126 workspace: None,
127 settings: Settings::default()
128 }
129 );
130 }
131
132 #[test]
133 fn help_and_version_win_over_everything_else() {
134 for asked in ["-h", "--help"] {
135 assert_eq!(parse(args(&[asked, "/ws"])).unwrap(), Action::Help);
136 }
137 for asked in ["-V", "--version"] {
138 assert_eq!(parse(args(&[asked])).unwrap(), Action::Version);
139 }
140 }
141
142 #[test]
143 fn the_workspace_is_whichever_argument_is_not_an_option() {
144 let Action::Serve { workspace, .. } = parse(args(&["--all-features", "/ws"])).unwrap()
145 else {
146 panic!("expected a workspace");
147 };
148
149 assert_eq!(workspace, Some(PathBuf::from("/ws")));
150 }
151
152 #[test]
153 fn a_workspace_can_be_named_after_the_options_end() {
154 let Action::Serve { workspace, .. } = parse(args(&["--", "-weird-name"])).unwrap() else {
155 panic!("expected a workspace");
156 };
157
158 assert_eq!(workspace, Some(PathBuf::from("-weird-name")));
159 }
160
161 #[test]
162 fn features_are_taken_either_way_round() {
163 for spelling in [
164 &["--features", "serde,tokio"][..],
165 &["--features=serde,tokio"][..],
166 &["--features", "serde", "--features", "tokio"][..],
167 &["--features", "serde tokio"][..],
168 ] {
169 assert_eq!(
170 settings_of(parse(args(spelling)).unwrap())["cargo"]["features"],
171 json!(["serde", "tokio"]),
172 "{spelling:?}"
173 );
174 }
175 }
176
177 #[test]
178 fn every_feature_can_be_asked_for() {
179 let settings = settings_of(parse(args(&["--all-features"])).unwrap());
180
181 assert_eq!(settings["cargo"]["features"], json!("all"));
182 }
183
184 #[test]
185 fn defaults_can_be_left_out() {
186 let settings = settings_of(parse(args(&["--no-default-features"])).unwrap());
187
188 assert_eq!(settings["cargo"]["noDefaultFeatures"], json!(true));
189 }
190
191 #[test]
192 fn any_setting_can_be_named() {
193 let settings = settings_of(
194 parse(args(&[
195 "--config",
196 "check.command=clippy",
197 "--config=cargo.allTargets=false",
198 ]))
199 .unwrap(),
200 );
201
202 assert_eq!(settings["check"]["command"], json!("clippy"));
203 assert_eq!(settings["cargo"]["allTargets"], json!(false));
204 }
205
206 #[test]
207 fn a_command_line_that_makes_no_sense_says_so() {
208 for nonsense in [
209 &["--all-features", "--features", "serde"][..],
210 &["--features"][..],
211 &["--config"][..],
212 &["--config", "nonsense"][..],
213 &["--nope"][..],
214 &["/ws", "/other"][..],
215 ] {
216 assert!(parse(args(nonsense)).is_err(), "{nonsense:?}");
217 }
218 }
219
220 #[test]
221 fn an_option_after_the_options_ended_is_a_path() {
222 let Action::Serve { workspace, .. } = parse(args(&["--", "--all-features"])).unwrap()
223 else {
224 panic!("expected a workspace");
225 };
226
227 assert_eq!(workspace, Some(PathBuf::from("--all-features")));
228 }
229
230 fn args(args: &[&str]) -> Vec<OsString> {
231 args.iter().map(OsString::from).collect()
232 }
233
234 fn settings_of(action: Action) -> serde_json::Value {
235 let Action::Serve { settings, .. } = action else {
236 panic!("expected a command line asking to serve");
237 };
238
239 settings.to_json()
240 }
241}