Skip to main content

frs/cmds/
rsl.rs

1//! The `frs rsl` command and its command-line interface.
2
3use std::ffi::OsString;
4use std::path::PathBuf;
5
6use rootcause::report;
7use ytil_sys::pico_args::Arguments;
8
9pub use self::output::RslOutput;
10use self::output::ViolationOutputFormat;
11
12mod ast;
13mod engine;
14mod output;
15mod rules;
16
17/// Runs `frs rsl`.
18///
19/// Returns the lint violations for the supplied files.
20///
21/// # Errors
22///
23/// Returns an error when the arguments are invalid or a source file cannot be read or parsed.
24pub fn run(mut cli_args: Arguments) -> rootcause::Result<RslOutput> {
25    if cli_args.contains("--help") {
26        print!("{}", crate::cmds::Help::Rsl.text());
27        return Ok(RslOutput::new(Vec::new(), ViolationOutputFormat::Compact));
28    }
29
30    let opts = match RslOpts::try_from(cli_args.finish()) {
31        Ok(opts) => opts,
32        Err(error) => {
33            eprintln!("{}", crate::cmds::Help::Rsl.text());
34            return Err(error);
35        }
36    };
37    let violations = crate::cmds::rsl::engine::check_paths(&opts.paths)?;
38    let format = if opts.debug {
39        ViolationOutputFormat::Debug
40    } else {
41        ViolationOutputFormat::Compact
42    };
43
44    Ok(RslOutput::new(violations, format))
45}
46
47#[derive(Debug)]
48struct RslOpts {
49    debug: bool,
50    paths: Vec<PathBuf>,
51}
52
53impl TryFrom<Vec<OsString>> for RslOpts {
54    type Error = rootcause::Report;
55
56    fn try_from(raw: Vec<OsString>) -> Result<Self, Self::Error> {
57        let mut before_separator = Vec::new();
58        let mut after_separator = Vec::new();
59        let mut separator_seen = false;
60
61        for argument in raw {
62            if separator_seen {
63                after_separator.push(argument);
64            } else if argument == "--" {
65                separator_seen = true;
66            } else {
67                before_separator.push(argument);
68            }
69        }
70
71        let mut cli_args = Arguments::from_vec(before_separator);
72        let debug = cli_args.contains("--debug");
73        let mut paths = cli_args.finish();
74        if let Some(option) = paths.iter().find(|path| path.to_string_lossy().starts_with('-')) {
75            return Err(report!("unknown rsl option").attach(format!("option={}", option.to_string_lossy())));
76        }
77        paths.extend(after_separator);
78
79        if paths.is_empty() {
80            return Err(report!("expected one or more Rust source files"));
81        }
82
83        Ok(Self {
84            debug,
85            paths: paths.into_iter().map(PathBuf::from).collect(),
86        })
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use std::ffi::OsString;
93    use std::fmt::Display;
94    use std::path::PathBuf;
95
96    use tempfile::TempDir;
97    use test_that::prelude::*;
98
99    use super::*;
100
101    #[test]
102    fn test_rsl_when_multiple_files_are_clean_returns_no_output() {
103        let directory = require(tempfile::tempdir());
104        let first = require(write_source(
105            &directory,
106            "first.rs",
107            r"
108            use std::fmt;
109            fn first() {}
110            ",
111        ));
112        let second = require(write_source(
113            &directory,
114            "second.rs",
115            r"
116            mod external;
117            mod inline {
118                const VALUE: usize = 1;
119            }
120            ",
121        ));
122
123        assert_that!(
124            run_rsl(vec![first.into_os_string(), second.into_os_string()]),
125            ok(eq(""))
126        );
127    }
128
129    #[test]
130    fn test_rsl_when_multiple_files_have_violations_preserves_input_file_order() {
131        let directory = require(tempfile::tempdir());
132        let first = require(write_source(
133            &directory,
134            "first.rs",
135            r"
136            fn first() {}
137            const FIRST: usize = 1;
138            ",
139        ));
140        let second = require(write_source(
141            &directory,
142            "second.rs",
143            r"
144            fn second() {}
145            const SECOND: usize = 2;
146            ",
147        ));
148
149        let output = require(run_rsl(vec![
150            first.clone().into_os_string(),
151            second.clone().into_os_string(),
152        ]));
153
154        assert_that!(
155            output,
156            eq(format!(
157                "{}:3:13,move `const` after `items`\n{}:3:13,move `const` after `items`\n",
158                first.display(),
159                second.display(),
160            ))
161        );
162    }
163
164    #[test]
165    fn test_rsl_when_file_has_violation_returns_compact_violations() {
166        let directory = require(tempfile::tempdir());
167        let source = require(write_source(
168            &directory,
169            "sample.rs",
170            r"
171            fn run() {}
172            const VALUE: usize = 1;
173            ",
174        ));
175
176        let output = require(run_rsl(vec![source.clone().into_os_string()]));
177        let expected_file = source.to_string_lossy().into_owned();
178
179        assert_that!(output, eq(format!("{expected_file}:3:13,move `const` after `items`\n")));
180    }
181
182    #[test]
183    fn test_rsl_when_function_qualification_is_invalid_omits_rule_codes_by_default() {
184        let directory = require(tempfile::tempdir());
185        let source = require(write_source(
186            &directory,
187            "sample.rs",
188            r#"
189            use tempfile::tempdir;
190            fn main() {
191                tempdir();
192                std::fs::read_to_string("foo");
193            }
194            "#,
195        ));
196
197        let expected_file = source.to_string_lossy().into_owned();
198        let output = require(run_rsl(vec![source.into_os_string()]));
199
200        assert_that!(
201            output,
202            eq(format!(
203                "{expected_file}:4:17,replace `tempdir` with `tempfile::tempdir`\n{expected_file}:5:17,replace `std::fs::read_to_string` with `fs::read_to_string`; add `use std::fs;`\n"
204            ))
205        );
206    }
207
208    #[test]
209    fn test_rsl_when_debug_flag_is_passed_includes_rule_codes() {
210        let directory = require(tempfile::tempdir());
211        let source = require(write_source(
212            &directory,
213            "sample.rs",
214            r#"
215            use tempfile::tempdir;
216            fn main() {
217                tempdir();
218                std::fs::read_to_string("foo");
219            }
220            "#,
221        ));
222
223        let expected_file = source.to_string_lossy().into_owned();
224        let output = require(run_rsl(vec![OsString::from("--debug"), source.into_os_string()]));
225
226        assert_that!(
227            output,
228            eq(format!(
229                "{expected_file}:4:17,unqualified_call,replace `tempdir` with `tempfile::tempdir`\n{expected_file}:5:17,overqualified_call,replace `std::fs::read_to_string` with `fs::read_to_string`; add `use std::fs;`\n"
230            ))
231        );
232    }
233
234    #[test]
235    fn test_rsl_when_relative_call_uses_super_reports_relative_path() {
236        let directory = require(tempfile::tempdir());
237        let source = require(write_source(
238            &directory,
239            "sample.rs",
240            r"
241            mod parent {
242                mod child {
243                    fn run() {
244                        super::helper();
245                    }
246                }
247                fn helper() {}
248            }
249            ",
250        ));
251
252        let expected_file = source.to_string_lossy().into_owned();
253        let output = require(run_rsl(vec![source.into_os_string()]));
254
255        assert_that!(output, eq(format!("{expected_file}:5:25,use a crate-absolute path\n")));
256    }
257
258    #[test]
259    fn test_rsl_when_qualified_result_paths_are_allowed_returns_no_output() {
260        let directory = require(tempfile::tempdir());
261        let source = require(write_source(
262            &directory,
263            "sample.rs",
264            r"
265            fn inspect(_: std::fmt::Result) -> rootcause::Result<()> {
266                panic!()
267            }
268            ",
269        ));
270
271        assert_that!(run_rsl(vec![source.into_os_string()]), ok(eq(String::new())));
272    }
273
274    #[test]
275    fn test_rsl_when_json_option_is_supplied_returns_usage_error() {
276        let directory = require(tempfile::tempdir());
277        let source = require(write_source(&directory, "sample.rs", "fn main() {}"));
278
279        assert_that!(
280            run_rsl(vec![OsString::from("--json"), source.into_os_string()]),
281            err(anything())
282        );
283    }
284
285    #[test]
286    fn test_rsl_when_source_is_malformed_returns_parse_error() {
287        let directory = require(tempfile::tempdir());
288        let source = require(write_source(
289            &directory,
290            "broken.rs",
291            r"
292            fn missing( {
293            ",
294        ));
295
296        assert_that!(
297            run_rsl(vec![source.into_os_string()]),
298            err(displays_as(contains_substring("could not parse Rust source")))
299        );
300    }
301
302    #[test]
303    fn test_rsl_when_file_is_missing_returns_read_error() {
304        let directory = require(tempfile::tempdir());
305        let missing = directory.path().join("missing.rs");
306
307        assert_that!(
308            run_rsl(vec![missing.into_os_string()]),
309            err(displays_as(contains_substring("could not read Rust source")))
310        );
311    }
312
313    #[test]
314    fn test_rsl_when_no_file_is_supplied_returns_usage_error() {
315        assert_that!(
316            run_rsl(Vec::new()),
317            err(displays_as(contains_substring(
318                "expected one or more Rust source files"
319            )))
320        );
321    }
322
323    fn write_source(directory: &TempDir, name: &str, source: &str) -> std::io::Result<PathBuf> {
324        let path = directory.path().join(name);
325        std::fs::write(&path, source).map(|()| path)
326    }
327
328    fn run_rsl(arguments: impl IntoIterator<Item = OsString>) -> rootcause::Result<String> {
329        let output = crate::cmds::rsl::run(Arguments::from_vec(arguments.into_iter().collect()))?;
330        if output.is_empty() {
331            return Ok(String::new());
332        }
333
334        Ok(format!("{}\n", output.render()))
335    }
336
337    fn require<T, E: Display>(result: Result<T, E>) -> T {
338        match result {
339            Ok(value) => value,
340            Err(error) => {
341                panic!("test setup failed: {error}");
342            }
343        }
344    }
345}