From 214e3bc4b675b08ce4df76b1373f4548949e67ee Mon Sep 17 00:00:00 2001 From: Akshay Date: Tue, 19 Oct 2021 15:58:46 +0530 Subject: fully flesh out CLI --- bin/src/config.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ bin/src/err.rs | 28 +++++++++ bin/src/main.rs | 112 +++++++++++++++-------------------- bin/src/traits.rs | 83 ++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 66 deletions(-) create mode 100644 bin/src/config.rs create mode 100644 bin/src/err.rs create mode 100644 bin/src/traits.rs (limited to 'bin/src') 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 @@ +use std::{default::Default, fs, path::PathBuf, str::FromStr}; + +use clap::Clap; +use globset::{GlobBuilder, GlobSetBuilder}; +use vfs::ReadOnlyVfs; + +use crate::err::ConfigErr; + +/// Static analysis and linting for the nix programming language +#[derive(Clap, Debug)] +#[clap(version = "0.1.0", author = "Akshay ")] +pub struct Opts { + /// File or directory to run statix on + #[clap(default_value = ".")] + target: String, + + // /// Path to statix config + // #[clap(short, long, default_value = ".statix.toml")] + // config: String, + /// Regex of file patterns to not lint + #[clap(short, long)] + ignore: Vec, + + /// Output format. Supported values: json, errfmt + #[clap(short = 'o', long)] + format: Option, + + #[clap(subcommand)] + pub subcmd: Option, +} + +#[derive(Clap, Debug)] +#[clap(version = "0.1.0", author = "Akshay ")] +pub enum SubCommand { + /// Find and fix issues raised by statix + Fix(Fix), +} + +#[derive(Clap, Debug)] +pub struct Fix { + /// Do not write to files, display a diff instead + #[clap(short = 'd', long = "dry-run")] + diff_only: bool, +} + +#[derive(Debug, Copy, Clone)] +pub enum OutFormat { + Json, + Errfmt, + StdErr, +} + +impl Default for OutFormat { + fn default() -> Self { + OutFormat::StdErr + } +} + +impl FromStr for OutFormat { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "json" => Ok(Self::Json), + "errfmt" => Ok(Self::Errfmt), + "stderr" => Ok(Self::StdErr), + _ => Err("unknown output format, try: json, errfmt"), + } + } +} + +#[derive(Debug)] +pub struct LintConfig { + pub files: Vec, + pub format: OutFormat, +} + +impl LintConfig { + pub fn from_opts(opts: Opts) -> Result { + let ignores = { + let mut set = GlobSetBuilder::new(); + for pattern in opts.ignore { + let glob = GlobBuilder::new(&pattern).build().map_err(|err| { + ConfigErr::InvalidGlob(err.glob().map(|i| i.to_owned()), err.kind().clone()) + })?; + set.add(glob); + } + set.build().map_err(|err| { + ConfigErr::InvalidGlob(err.glob().map(|i| i.to_owned()), err.kind().clone()) + }) + }?; + + let walker = dirs::Walker::new(opts.target).map_err(ConfigErr::InvalidPath)?; + + let files = walker + .filter(|path| matches!(path.extension(), Some(e) if e == "nix")) + .filter(|path| !ignores.is_match(path)) + .collect(); + Ok(Self { + files, + format: opts.format.unwrap_or_default(), + }) + } + + pub fn vfs(&self) -> Result { + let mut vfs = ReadOnlyVfs::default(); + for file in self.files.iter() { + let _id = vfs.alloc_file_id(&file); + let data = fs::read_to_string(&file).map_err(ConfigErr::InvalidPath)?; + vfs.set_file_contents(&file, data.as_bytes()); + } + Ok(vfs) + } +} + +mod dirs { + use std::{ + fs, + io::{self, Error, ErrorKind}, + path::{Path, PathBuf}, + }; + + #[derive(Default, Debug)] + pub struct Walker { + dirs: Vec, + files: Vec, + } + + impl Walker { + pub fn new>(target: P) -> io::Result { + let target = target.as_ref().to_path_buf(); + if !target.exists() { + Err(Error::new( + ErrorKind::NotFound, + format!("file not found: {}", target.display()), + )) + } else if target.is_dir() { + Ok(Self { + dirs: vec![target], + ..Default::default() + }) + } else { + Ok(Self { + files: vec![target], + ..Default::default() + }) + } + } + } + + impl Iterator for Walker { + type Item = PathBuf; + fn next(&mut self) -> Option { + if let Some(dir) = self.dirs.pop() { + if dir.is_dir() { + for entry in fs::read_dir(dir).ok()? { + let entry = entry.ok()?; + let path = entry.path(); + if path.is_dir() { + self.dirs.push(path); + } else if path.is_file() { + self.files.push(path); + } + } + } + } + self.files.pop() + } + } +} 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 @@ +use std::{io, path::PathBuf}; + +use globset::ErrorKind; +use rnix::parser::ParseError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigErr { + #[error("error parsing glob `{0:?}`: {1}")] + InvalidGlob(Option, ErrorKind), + + #[error("path error: {0}")] + InvalidPath(#[from] io::Error), +} + +#[derive(Error, Debug)] +pub enum LintErr { + #[error("[{0}] syntax error: {1}")] + Parse(PathBuf, ParseError), +} + +#[derive(Error, Debug)] +pub enum StatixErr { + #[error("linter error: {0}")] + Lint(#[from] LintErr), + #[error("config error: {0}")] + Config(#[from] ConfigErr), +} 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 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; +#![feature(path_try_exists)] + +mod config; +mod err; +mod traits; -use anyhow::{Context, Result}; -use ariadne::{ - CharSet, Color, Config as CliConfig, Label, LabelAttach, Report as CliReport, - ReportKind as CliReportKind, Source, +use std::io; + +use crate::{ + err::{LintErr, StatixErr}, + traits::{LintResult, WriteDiagnostic}, }; -use lib::{Report, LINTS}; -use rnix::{TextRange, WalkEvent}; -fn analyze(source: &str) -> Result> { - let parsed = rnix::parse(source).as_result()?; +use clap::Clap; +use config::{LintConfig, Opts, SubCommand}; +use lib::LINTS; +use rnix::WalkEvent; +use vfs::VfsEntry; - Ok(parsed +fn analyze<'ρ>(vfs_entry: VfsEntry<'ρ>) -> Result { + let source = vfs_entry.contents; + let parsed = rnix::parse(source) + .as_result() + .map_err(|e| LintErr::Parse(vfs_entry.file_path.to_path_buf(), e))?; + let reports = parsed .node() .preorder_with_tokens() .filter_map(|event| match event { @@ -27,61 +35,33 @@ fn analyze(source: &str) -> Result> { _ => None, }) .flatten() - .collect()) + .collect(); + Ok(LintResult { + file_id: vfs_entry.file_id, + reports, + }) } -fn print_report(report: Report, file_src: &str, file_path: &Path) -> Result<()> { - let range = |at: TextRange| at.start().into()..at.end().into(); - let src_id = file_path.to_str().unwrap_or(""); - let offset = report - .diagnostics - .iter() - .map(|d| d.at.start().into()) - .min() - .unwrap_or(0usize); - report - .diagnostics - .iter() - .fold( - CliReport::build(CliReportKind::Warning, src_id, offset) - .with_config( - CliConfig::default() - .with_cross_gap(true) - .with_multiline_arrows(false) - .with_label_attach(LabelAttach::Middle) - .with_char_set(CharSet::Unicode), - ) - .with_message(report.note) - .with_code(report.code), - |cli_report, diagnostic| { - cli_report.with_label( - Label::new((src_id, range(diagnostic.at))) - .with_message(&diagnostic.message) - .with_color(Color::Magenta), - ) - }, - ) - .finish() - .eprint((src_id, Source::from(file_src))) - .context("failed to print report to stdout") -} - -fn _main() -> Result<()> { +fn _main() -> Result<(), StatixErr> { // TODO: accept cli args, construct a CLI config with a list of files to analyze - let args = env::args(); - for (file_src, file_path, reports) in args - .skip(1) - .map(|s| PathBuf::from(&s)) - .filter(|p| p.is_file()) - .filter_map(|path| { - let s = fs::read_to_string(&path).ok()?; - analyze(&s) - .map(|analysis_result| (s, path, analysis_result)) - .ok() - }) - { - for r in reports { - print_report(r, &file_src, &file_path)? + let opts = Opts::parse(); + match opts.subcmd { + Some(SubCommand::Fix(_)) => {} + None => { + let lint_config = LintConfig::from_opts(opts)?; + let vfs = lint_config.vfs()?; + let (reports, errors): (Vec<_>, Vec<_>) = + vfs.iter().map(analyze).partition(Result::is_ok); + let lint_results: Vec<_> = reports.into_iter().map(Result::unwrap).collect(); + let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect(); + + let mut stderr = io::stderr(); + lint_results.into_iter().for_each(|r| { + stderr.write(&r, &vfs).unwrap(); + }); + errors.into_iter().for_each(|e| { + eprintln!("{}", e); + }); } } Ok(()) @@ -90,6 +70,6 @@ fn _main() -> Result<()> { fn main() { match _main() { Err(e) => eprintln!("{}", e), - _ => {} + _ => (), } } 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 @@ +use std::{ + io::{self, Write}, + str, +}; + +use ariadne::{ + CharSet, Color, Config as CliConfig, Label, LabelAttach, Report as CliReport, + ReportKind as CliReportKind, Source, Fmt +}; +use lib::Report; +use rnix::TextRange; +use vfs::{FileId, ReadOnlyVfs}; + +#[derive(Debug)] +pub struct LintResult { + pub file_id: FileId, + pub reports: Vec, +} + +pub trait WriteDiagnostic { + fn write(&mut self, report: &LintResult, vfs: &ReadOnlyVfs) -> io::Result<()>; +} + +impl WriteDiagnostic for T +where + T: Write, +{ + fn write(&mut self, lint_result: &LintResult, vfs: &ReadOnlyVfs) -> io::Result<()> { + let file_id = lint_result.file_id; + let src = str::from_utf8(vfs.get(file_id)).unwrap(); + let path = vfs.file_path(file_id); + let range = |at: TextRange| at.start().into()..at.end().into(); + let src_id = path.to_str().unwrap_or(""); + for report in lint_result.reports.iter() { + let offset = report + .diagnostics + .iter() + .map(|d| d.at.start().into()) + .min() + .unwrap_or(0usize); + report + .diagnostics + .iter() + .fold( + CliReport::build(CliReportKind::Warning, src_id, offset) + .with_config( + CliConfig::default() + .with_cross_gap(true) + .with_multiline_arrows(false) + .with_label_attach(LabelAttach::Middle) + .with_char_set(CharSet::Unicode), + ) + .with_message(report.note) + .with_code(report.code), + |cli_report, diagnostic| { + cli_report.with_label( + Label::new((src_id, range(diagnostic.at))) + .with_message(&colorize(&diagnostic.message)) + .with_color(Color::Magenta), + ) + }, + ) + .finish() + .write((src_id, Source::from(src)), &mut *self)?; + } + Ok(()) + } +} + +// everything within backticks is colorized, backticks are removed +fn colorize(message: &str) -> String { + message.split('`') + .enumerate() + .map(|(idx, part)| { + if idx % 2 == 1 { + part.fg(Color::Cyan).to_string() + } else { + part.to_string() + } + }) + .collect::>() + .join("") +} -- cgit v1.2.3