Skip to main content

rust_analyzer_mcp/
cli.rs

1//! The command line this server is started with.
2
3use 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/// What a command line asks for.
29#[derive(Debug, PartialEq, Eq)]
30pub enum Action {
31    Help,
32    Version,
33    Serve {
34        /// The workspace to analyse, if the command line named one.
35        workspace: Option<PathBuf>,
36        settings: Settings,
37    },
38}
39
40/// Reads `args`, which are the arguments after the program's own name.
41///
42/// Takes them as [`OsString`]s so that a workspace path that is not valid UTF-8 is passed
43/// through rather than rejected out of hand.
44pub 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        // A lone "--" ends the options, which is how a workspace path that starts with '-' gets
52        // through.
53        if !options_ended && arg == "--" {
54            options_ended = true;
55            continue;
56        }
57
58        // Only an option can be required to be UTF-8; a path is whatever the filesystem says.
59        let option = (!options_ended)
60            .then(|| arg.to_str())
61            .flatten()
62            .filter(|arg| arg.starts_with('-'));
63        if let Some(option) = option {
64            // Both `--features a,b` and `--features=a,b` are how people write these.
65            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
96/// The value of an option, whether it was written after an `=` or as the next argument.
97fn 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}