diff options
author | Akshay <[email protected]> | 2021-10-19 11:28:46 +0100 |
---|---|---|
committer | Akshay <[email protected]> | 2021-10-19 11:28:46 +0100 |
commit | 214e3bc4b675b08ce4df76b1373f4548949e67ee (patch) | |
tree | db6584a360d69bc9759e2b8059a013ba9e8db5e1 /bin | |
parent | 0076b3a37dcca0e11afd05dc98174f646cdb8fa9 (diff) |
fully flesh out CLI
Diffstat (limited to 'bin')
-rw-r--r-- | bin/Cargo.toml | 5 | ||||
-rw-r--r-- | bin/src/config.rs | 170 | ||||
-rw-r--r-- | bin/src/err.rs | 28 | ||||
-rw-r--r-- | bin/src/main.rs | 112 | ||||
-rw-r--r-- | bin/src/traits.rs | 83 |
5 files changed, 331 insertions, 67 deletions
diff --git a/bin/Cargo.toml b/bin/Cargo.toml index 9452c2b..0d6f970 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml | |||
@@ -8,5 +8,8 @@ edition = "2018" | |||
8 | [dependencies] | 8 | [dependencies] |
9 | lib = { path = "../lib" } | 9 | lib = { path = "../lib" } |
10 | ariadne = "0.1.3" | 10 | ariadne = "0.1.3" |
11 | anyhow = "1.0" | ||
12 | rnix = "0.9.0" | 11 | rnix = "0.9.0" |
12 | clap = "3.0.0-beta.4" | ||
13 | globset = "0.4.8" | ||
14 | thiserror = "1.0.30" | ||
15 | vfs = { path = "../vfs" } | ||
diff --git a/bin/src/config.rs b/bin/src/config.rs new file mode 100644 index 0000000..f2cf29d --- /dev/null +++ b/bin/src/config.rs | |||
@@ -0,0 +1,170 @@ | |||
1 | use std::{default::Default, fs, path::PathBuf, str::FromStr}; | ||
2 | |||
3 | use clap::Clap; | ||
4 | use globset::{GlobBuilder, GlobSetBuilder}; | ||
5 | use vfs::ReadOnlyVfs; | ||
6 | |||
7 | use crate::err::ConfigErr; | ||
8 | |||
9 | /// Static analysis and linting for the nix programming language | ||
10 | #[derive(Clap, Debug)] | ||
11 | #[clap(version = "0.1.0", author = "Akshay <[email protected]>")] | ||
12 | pub struct Opts { | ||
13 | /// File or directory to run statix on | ||
14 | #[clap(default_value = ".")] | ||
15 | target: String, | ||
16 | |||
17 | // /// Path to statix config | ||
18 | // #[clap(short, long, default_value = ".statix.toml")] | ||
19 | // config: String, | ||
20 | /// Regex of file patterns to not lint | ||
21 | #[clap(short, long)] | ||
22 | ignore: Vec<String>, | ||
23 | |||
24 | /// Output format. Supported values: json, errfmt | ||
25 | #[clap(short = 'o', long)] | ||
26 | format: Option<OutFormat>, | ||
27 | |||
28 | #[clap(subcommand)] | ||
29 | pub subcmd: Option<SubCommand>, | ||
30 | } | ||
31 | |||
32 | #[derive(Clap, Debug)] | ||
33 | #[clap(version = "0.1.0", author = "Akshay <[email protected]>")] | ||
34 | pub enum SubCommand { | ||
35 | /// Find and fix issues raised by statix | ||
36 | Fix(Fix), | ||
37 | } | ||
38 | |||
39 | #[derive(Clap, Debug)] | ||
40 | pub struct Fix { | ||
41 | /// Do not write to files, display a diff instead | ||
42 | #[clap(short = 'd', long = "dry-run")] | ||
43 | diff_only: bool, | ||
44 | } | ||
45 | |||
46 | #[derive(Debug, Copy, Clone)] | ||
47 | pub enum OutFormat { | ||
48 | Json, | ||
49 | Errfmt, | ||
50 | StdErr, | ||
51 | } | ||
52 | |||
53 | impl Default for OutFormat { | ||
54 | fn default() -> Self { | ||
55 | OutFormat::StdErr | ||
56 | } | ||
57 | } | ||
58 | |||
59 | impl FromStr for OutFormat { | ||
60 | type Err = &'static str; | ||
61 | |||
62 | fn from_str(value: &str) -> Result<Self, Self::Err> { | ||
63 | match value.to_ascii_lowercase().as_str() { | ||
64 | "json" => Ok(Self::Json), | ||
65 | "errfmt" => Ok(Self::Errfmt), | ||
66 | "stderr" => Ok(Self::StdErr), | ||
67 | _ => Err("unknown output format, try: json, errfmt"), | ||
68 | } | ||
69 | } | ||
70 | } | ||
71 | |||
72 | #[derive(Debug)] | ||
73 | pub struct LintConfig { | ||
74 | pub files: Vec<PathBuf>, | ||
75 | pub format: OutFormat, | ||
76 | } | ||
77 | |||
78 | impl LintConfig { | ||
79 | pub fn from_opts(opts: Opts) -> Result<Self, ConfigErr> { | ||
80 | let ignores = { | ||
81 | let mut set = GlobSetBuilder::new(); | ||
82 | for pattern in opts.ignore { | ||
83 | let glob = GlobBuilder::new(&pattern).build().map_err(|err| { | ||
84 | ConfigErr::InvalidGlob(err.glob().map(|i| i.to_owned()), err.kind().clone()) | ||
85 | })?; | ||
86 | set.add(glob); | ||
87 | } | ||
88 | set.build().map_err(|err| { | ||
89 | ConfigErr::InvalidGlob(err.glob().map(|i| i.to_owned()), err.kind().clone()) | ||
90 | }) | ||
91 | }?; | ||
92 | |||
93 | let walker = dirs::Walker::new(opts.target).map_err(ConfigErr::InvalidPath)?; | ||
94 | |||
95 | let files = walker | ||
96 | .filter(|path| matches!(path.extension(), Some(e) if e == "nix")) | ||
97 | .filter(|path| !ignores.is_match(path)) | ||
98 | .collect(); | ||
99 | Ok(Self { | ||
100 | files, | ||
101 | format: opts.format.unwrap_or_default(), | ||
102 | }) | ||
103 | } | ||
104 | |||
105 | pub fn vfs(&self) -> Result<ReadOnlyVfs, ConfigErr> { | ||
106 | let mut vfs = ReadOnlyVfs::default(); | ||
107 | for file in self.files.iter() { | ||
108 | let _id = vfs.alloc_file_id(&file); | ||
109 | let data = fs::read_to_string(&file).map_err(ConfigErr::InvalidPath)?; | ||
110 | vfs.set_file_contents(&file, data.as_bytes()); | ||
111 | } | ||
112 | Ok(vfs) | ||
113 | } | ||
114 | } | ||
115 | |||
116 | mod dirs { | ||
117 | use std::{ | ||
118 | fs, | ||
119 | io::{self, Error, ErrorKind}, | ||
120 | path::{Path, PathBuf}, | ||
121 | }; | ||
122 | |||
123 | #[derive(Default, Debug)] | ||
124 | pub struct Walker { | ||
125 | dirs: Vec<PathBuf>, | ||
126 | files: Vec<PathBuf>, | ||
127 | } | ||
128 | |||
129 | impl Walker { | ||
130 | pub fn new<P: AsRef<Path>>(target: P) -> io::Result<Self> { | ||
131 | let target = target.as_ref().to_path_buf(); | ||
132 | if !target.exists() { | ||
133 | Err(Error::new( | ||
134 | ErrorKind::NotFound, | ||
135 | format!("file not found: {}", target.display()), | ||
136 | )) | ||
137 | } else if target.is_dir() { | ||
138 | Ok(Self { | ||
139 | dirs: vec![target], | ||
140 | ..Default::default() | ||
141 | }) | ||
142 | } else { | ||
143 | Ok(Self { | ||
144 | files: vec![target], | ||
145 | ..Default::default() | ||
146 | }) | ||
147 | } | ||
148 | } | ||
149 | } | ||
150 | |||
151 | impl Iterator for Walker { | ||
152 | type Item = PathBuf; | ||
153 | fn next(&mut self) -> Option<Self::Item> { | ||
154 | if let Some(dir) = self.dirs.pop() { | ||
155 | if dir.is_dir() { | ||
156 | for entry in fs::read_dir(dir).ok()? { | ||
157 | let entry = entry.ok()?; | ||
158 | let path = entry.path(); | ||
159 | if path.is_dir() { | ||
160 | self.dirs.push(path); | ||
161 | } else if path.is_file() { | ||
162 | self.files.push(path); | ||
163 | } | ||
164 | } | ||
165 | } | ||
166 | } | ||
167 | self.files.pop() | ||
168 | } | ||
169 | } | ||
170 | } | ||
diff --git a/bin/src/err.rs b/bin/src/err.rs new file mode 100644 index 0000000..b3a79c2 --- /dev/null +++ b/bin/src/err.rs | |||
@@ -0,0 +1,28 @@ | |||
1 | use std::{io, path::PathBuf}; | ||
2 | |||
3 | use globset::ErrorKind; | ||
4 | use rnix::parser::ParseError; | ||
5 | use thiserror::Error; | ||
6 | |||
7 | #[derive(Error, Debug)] | ||
8 | pub enum ConfigErr { | ||
9 | #[error("error parsing glob `{0:?}`: {1}")] | ||
10 | InvalidGlob(Option<String>, ErrorKind), | ||
11 | |||
12 | #[error("path error: {0}")] | ||
13 | InvalidPath(#[from] io::Error), | ||
14 | } | ||
15 | |||
16 | #[derive(Error, Debug)] | ||
17 | pub enum LintErr { | ||
18 | #[error("[{0}] syntax error: {1}")] | ||
19 | Parse(PathBuf, ParseError), | ||
20 | } | ||
21 | |||
22 | #[derive(Error, Debug)] | ||
23 | pub enum StatixErr { | ||
24 | #[error("linter error: {0}")] | ||
25 | Lint(#[from] LintErr), | ||
26 | #[error("config error: {0}")] | ||
27 | Config(#[from] ConfigErr), | ||
28 | } | ||
diff --git a/bin/src/main.rs b/bin/src/main.rs index ab99aee..b26151d 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs | |||
@@ -1,20 +1,28 @@ | |||
1 | use std::{ | 1 | #![feature(path_try_exists)] |
2 | env, fs, | 2 | |
3 | path::{Path, PathBuf}, | 3 | mod config; |
4 | }; | 4 | mod err; |
5 | mod traits; | ||
5 | 6 | ||
6 | use anyhow::{Context, Result}; | 7 | use std::io; |
7 | use ariadne::{ | 8 | |
8 | CharSet, Color, Config as CliConfig, Label, LabelAttach, Report as CliReport, | 9 | use crate::{ |
9 | ReportKind as CliReportKind, Source, | 10 | err::{LintErr, StatixErr}, |
11 | traits::{LintResult, WriteDiagnostic}, | ||
10 | }; | 12 | }; |
11 | use lib::{Report, LINTS}; | ||
12 | use rnix::{TextRange, WalkEvent}; | ||
13 | 13 | ||
14 | fn analyze(source: &str) -> Result<Vec<Report>> { | 14 | use clap::Clap; |
15 | let parsed = rnix::parse(source).as_result()?; | 15 | use config::{LintConfig, Opts, SubCommand}; |
16 | use lib::LINTS; | ||
17 | use rnix::WalkEvent; | ||
18 | use vfs::VfsEntry; | ||
16 | 19 | ||
17 | Ok(parsed | 20 | fn analyze<'ρ>(vfs_entry: VfsEntry<'ρ>) -> Result<LintResult, LintErr> { |
21 | let source = vfs_entry.contents; | ||
22 | let parsed = rnix::parse(source) | ||
23 | .as_result() | ||
24 | .map_err(|e| LintErr::Parse(vfs_entry.file_path.to_path_buf(), e))?; | ||
25 | let reports = parsed | ||
18 | .node() | 26 | .node() |
19 | .preorder_with_tokens() | 27 | .preorder_with_tokens() |
20 | .filter_map(|event| match event { | 28 | .filter_map(|event| match event { |
@@ -27,61 +35,33 @@ fn analyze(source: &str) -> Result<Vec<Report>> { | |||
27 | _ => None, | 35 | _ => None, |
28 | }) | 36 | }) |
29 | .flatten() | 37 | .flatten() |
30 | .collect()) | 38 | .collect(); |
39 | Ok(LintResult { | ||
40 | file_id: vfs_entry.file_id, | ||
41 | reports, | ||
42 | }) | ||
31 | } | 43 | } |
32 | 44 | ||
33 | fn print_report(report: Report, file_src: &str, file_path: &Path) -> Result<()> { | 45 | fn _main() -> Result<(), StatixErr> { |
34 | let range = |at: TextRange| at.start().into()..at.end().into(); | ||
35 | let src_id = file_path.to_str().unwrap_or("<unknown>"); | ||
36 | let offset = report | ||
37 | .diagnostics | ||
38 | .iter() | ||
39 | .map(|d| d.at.start().into()) | ||
40 | .min() | ||
41 | .unwrap_or(0usize); | ||
42 | report | ||
43 | .diagnostics | ||
44 | .iter() | ||
45 | .fold( | ||
46 | CliReport::build(CliReportKind::Warning, src_id, offset) | ||
47 | .with_config( | ||
48 | CliConfig::default() | ||
49 | .with_cross_gap(true) | ||
50 | .with_multiline_arrows(false) | ||
51 | .with_label_attach(LabelAttach::Middle) | ||
52 | .with_char_set(CharSet::Unicode), | ||
53 | ) | ||
54 | .with_message(report.note) | ||
55 | .with_code(report.code), | ||
56 | |cli_report, diagnostic| { | ||
57 | cli_report.with_label( | ||
58 | Label::new((src_id, range(diagnostic.at))) | ||
59 | .with_message(&diagnostic.message) | ||
60 | .with_color(Color::Magenta), | ||
61 | ) | ||
62 | }, | ||
63 | ) | ||
64 | .finish() | ||
65 | .eprint((src_id, Source::from(file_src))) | ||
66 | .context("failed to print report to stdout") | ||
67 | } | ||
68 | |||
69 | fn _main() -> Result<()> { | ||
70 | // TODO: accept cli args, construct a CLI config with a list of files to analyze | 46 | // TODO: accept cli args, construct a CLI config with a list of files to analyze |
71 | let args = env::args(); | 47 | let opts = Opts::parse(); |
72 | for (file_src, file_path, reports) in args | 48 | match opts.subcmd { |
73 | .skip(1) | 49 | Some(SubCommand::Fix(_)) => {} |
74 | .map(|s| PathBuf::from(&s)) | 50 | None => { |
75 | .filter(|p| p.is_file()) | 51 | let lint_config = LintConfig::from_opts(opts)?; |
76 | .filter_map(|path| { | 52 | let vfs = lint_config.vfs()?; |
77 | let s = fs::read_to_string(&path).ok()?; | 53 | let (reports, errors): (Vec<_>, Vec<_>) = |
78 | analyze(&s) | 54 | vfs.iter().map(analyze).partition(Result::is_ok); |
79 | .map(|analysis_result| (s, path, analysis_result)) | 55 | let lint_results: Vec<_> = reports.into_iter().map(Result::unwrap).collect(); |
80 | .ok() | 56 | let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect(); |
81 | }) | 57 | |
82 | { | 58 | let mut stderr = io::stderr(); |
83 | for r in reports { | 59 | lint_results.into_iter().for_each(|r| { |
84 | print_report(r, &file_src, &file_path)? | 60 | stderr.write(&r, &vfs).unwrap(); |
61 | }); | ||
62 | errors.into_iter().for_each(|e| { | ||
63 | eprintln!("{}", e); | ||
64 | }); | ||
85 | } | 65 | } |
86 | } | 66 | } |
87 | Ok(()) | 67 | Ok(()) |
@@ -90,6 +70,6 @@ fn _main() -> Result<()> { | |||
90 | fn main() { | 70 | fn main() { |
91 | match _main() { | 71 | match _main() { |
92 | Err(e) => eprintln!("{}", e), | 72 | Err(e) => eprintln!("{}", e), |
93 | _ => {} | 73 | _ => (), |
94 | } | 74 | } |
95 | } | 75 | } |
diff --git a/bin/src/traits.rs b/bin/src/traits.rs new file mode 100644 index 0000000..1807ad0 --- /dev/null +++ b/bin/src/traits.rs | |||
@@ -0,0 +1,83 @@ | |||
1 | use std::{ | ||
2 | io::{self, Write}, | ||
3 | str, | ||
4 | }; | ||
5 | |||
6 | use ariadne::{ | ||
7 | CharSet, Color, Config as CliConfig, Label, LabelAttach, Report as CliReport, | ||
8 | ReportKind as CliReportKind, Source, Fmt | ||
9 | }; | ||
10 | use lib::Report; | ||
11 | use rnix::TextRange; | ||
12 | use vfs::{FileId, ReadOnlyVfs}; | ||
13 | |||
14 | #[derive(Debug)] | ||
15 | pub struct LintResult { | ||
16 | pub file_id: FileId, | ||
17 | pub reports: Vec<Report>, | ||
18 | } | ||
19 | |||
20 | pub trait WriteDiagnostic { | ||
21 | fn write(&mut self, report: &LintResult, vfs: &ReadOnlyVfs) -> io::Result<()>; | ||
22 | } | ||
23 | |||
24 | impl<T> WriteDiagnostic for T | ||
25 | where | ||
26 | T: Write, | ||
27 | { | ||
28 | fn write(&mut self, lint_result: &LintResult, vfs: &ReadOnlyVfs) -> io::Result<()> { | ||
29 | let file_id = lint_result.file_id; | ||
30 | let src = str::from_utf8(vfs.get(file_id)).unwrap(); | ||
31 | let path = vfs.file_path(file_id); | ||
32 | let range = |at: TextRange| at.start().into()..at.end().into(); | ||
33 | let src_id = path.to_str().unwrap_or("<unknown>"); | ||
34 | for report in lint_result.reports.iter() { | ||
35 | let offset = report | ||
36 | .diagnostics | ||
37 | .iter() | ||
38 | .map(|d| d.at.start().into()) | ||
39 | .min() | ||
40 | .unwrap_or(0usize); | ||
41 | report | ||
42 | .diagnostics | ||
43 | .iter() | ||
44 | .fold( | ||
45 | CliReport::build(CliReportKind::Warning, src_id, offset) | ||
46 | .with_config( | ||
47 | CliConfig::default() | ||
48 | .with_cross_gap(true) | ||
49 | .with_multiline_arrows(false) | ||
50 | .with_label_attach(LabelAttach::Middle) | ||
51 | .with_char_set(CharSet::Unicode), | ||
52 | ) | ||
53 | .with_message(report.note) | ||
54 | .with_code(report.code), | ||
55 | |cli_report, diagnostic| { | ||
56 | cli_report.with_label( | ||
57 | Label::new((src_id, range(diagnostic.at))) | ||
58 | .with_message(&colorize(&diagnostic.message)) | ||
59 | .with_color(Color::Magenta), | ||
60 | ) | ||
61 | }, | ||
62 | ) | ||
63 | .finish() | ||
64 | .write((src_id, Source::from(src)), &mut *self)?; | ||
65 | } | ||
66 | Ok(()) | ||
67 | } | ||
68 | } | ||
69 | |||
70 | // everything within backticks is colorized, backticks are removed | ||
71 | fn colorize(message: &str) -> String { | ||
72 | message.split('`') | ||
73 | .enumerate() | ||
74 | .map(|(idx, part)| { | ||
75 | if idx % 2 == 1 { | ||
76 | part.fg(Color::Cyan).to_string() | ||
77 | } else { | ||
78 | part.to_string() | ||
79 | } | ||
80 | }) | ||
81 | .collect::<Vec<_>>() | ||
82 | .join("") | ||
83 | } | ||