use std::path::{Path, PathBuf};
use crate::errors::*;
use fs_err as fs;
use itertools::Itertools;
use serde::Deserialize;
use std::str::FromStr;
use crate::Action;
use super::Config;
use clap_complete::Shell;
#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
pub struct ManifestMetadata {
spellcheck: Option<ManifestMetadataSpellcheck>,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
pub struct ManifestMetadataSpellcheck {
config: PathBuf,
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize)]
pub enum CheckerType {
Hunspell,
ZSpell,
Spellbook,
NlpRules,
Reflow,
}
impl FromStr for CheckerType {
type Err = UnknownCheckerTypeVariant;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.to_lowercase();
Ok(match s.as_str() {
"nlprules" => Self::NlpRules,
"zet" | "zspell" => Self::ZSpell,
"spellbook" | "book" => Self::Spellbook,
"hunspell" => Self::Hunspell,
"reflow" => Self::Reflow,
_other => return Err(UnknownCheckerTypeVariant(s)),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MultipleCheckerTypes(pub Vec<CheckerType>);
impl AsRef<[CheckerType]> for MultipleCheckerTypes {
fn as_ref(&self) -> &[CheckerType] {
self.0.as_slice()
}
}
impl std::ops::Deref for MultipleCheckerTypes {
type Target = [CheckerType];
fn deref(&self) -> &Self::Target {
self.0.as_slice()
}
}
impl IntoIterator for MultipleCheckerTypes {
type Item = CheckerType;
type IntoIter = <Vec<Self::Item> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromStr for MultipleCheckerTypes {
type Err = UnknownCheckerTypeVariant;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.split(',')
.map(<CheckerType as FromStr>::from_str)
.collect::<Result<Vec<_>, _>>()
.map(MultipleCheckerTypes)
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("Unknown checker type variant: {0}")]
pub struct UnknownCheckerTypeVariant(String);
#[derive(clap::Parser, Debug)]
#[clap(author, version, about, long_about = None)]
#[clap(rename_all = "kebab-case")]
#[clap(subcommand_negates_reqs(true))]
pub struct Args {
#[clap(short, long, global(true))]
pub cfg: Option<PathBuf>,
#[clap(flatten)]
pub verbosity: clap_verbosity_flag::Verbosity,
#[clap(flatten)]
pub common: Common,
#[clap(short, long)]
pub fix: bool,
#[clap(subcommand)]
pub command: Option<Sub>,
}
#[derive(Debug, PartialEq, Eq, clap::Parser)]
#[clap(rename_all = "kebab-case")]
pub struct Common {
#[clap(short, long)]
pub recursive: bool,
#[clap(long)]
pub checkers: Option<MultipleCheckerTypes>,
#[clap(short, long)]
pub skip_readme: bool,
#[clap(short, long)]
pub dev_comments: bool,
#[clap(short, long)]
pub jobs: Option<usize>,
#[clap(short = 'm', long, default_value_t = 0_u8)]
pub code: u8,
pub paths: Vec<PathBuf>,
}
#[derive(Debug, PartialEq, Eq, clap::Subcommand)]
#[clap(rename_all = "kebab-case")]
pub enum Sub {
Check {
#[clap(flatten)]
common: Common,
},
Fix {
#[clap(flatten)]
common: Common,
},
Reflow {
#[clap(flatten)]
common: Common,
},
Config {
#[clap(short, long)]
user: bool,
#[clap(short, long)]
overwrite: bool,
#[clap(short, long)]
stdout: bool,
#[clap(long)]
#[clap(alias = "checkers")]
filter: Option<MultipleCheckerTypes>,
},
ListFiles {
#[clap(short, long)]
recursive: bool,
#[clap(short, long)]
skip_readme: bool,
paths: Vec<PathBuf>,
},
Completions {
#[clap(long, env="SHELL", value_parser = load_shell_name)]
shell: Shell,
},
}
#[derive(thiserror::Error, Debug, Clone)]
enum ShellErr {
#[error("Unknown shell: {shell:?}")]
UnknownShell { shell: String },
#[error("Missing SHELL argument")]
MissingArg,
}
fn load_shell_name(shell: &str) -> Result<Shell, ShellErr> {
shell
.split('/')
.next_back()
.map(|shell| {
Shell::from_str(shell).map_err(|unknown_shell| ShellErr::UnknownShell {
shell: unknown_shell,
})
})
.unwrap_or_else(|| Err(ShellErr::MissingArg))
}
pub fn generate_completions<G: clap_complete::Generator, W: std::io::Write>(
generator: G,
sink: &mut W,
) {
let mut app = <Args as clap::CommandFactory>::command();
let app = &mut app;
clap_complete::generate(generator, app, app.get_name().to_string(), sink);
}
impl Args {
pub fn common(&self) -> Option<&Common> {
match &self.command {
Some(
Sub::Check { common, .. } | Sub::Fix { common, .. } | Sub::Reflow { common, .. },
) => Some(common),
None => Some(&self.common),
Some(Sub::Completions { .. } | Sub::ListFiles { .. } | Sub::Config { .. }) => None,
}
}
pub fn checkers(&self) -> Option<Vec<CheckerType>> {
self.common()
.and_then(|common| common.checkers.as_ref().map(|checkers| checkers.0.clone()))
}
pub fn job_count(&self) -> usize {
derive_job_count(self.common().and_then(|common| common.jobs))
}
pub fn verbosity(&self) -> log::LevelFilter {
self.verbosity.log_level_filter()
}
pub fn action(&self) -> Action {
let action = if let Some(sub) = &self.command {
match sub {
Sub::Check { .. } => Action::Check,
Sub::Fix { .. } => Action::Fix,
Sub::Reflow { .. } => Action::Reflow,
Sub::ListFiles { .. } => Action::ListFiles,
Sub::Config { .. } => unreachable!(),
Sub::Completions { .. } => unreachable!(),
}
} else if self.fix {
Action::Fix
} else {
Action::Check
};
log::trace!("Derived action {action:?} from flags/args/cmds");
action
}
pub fn parse(argv_iter: impl IntoIterator<Item = String>) -> Result<Self, clap::Error> {
<Args as clap::Parser>::try_parse_from({
let mut argv_iter = argv_iter.into_iter();
if let Some(arg0) = argv_iter.next() {
match PathBuf::from(&arg0).file_name().and_then(|x| x.to_str()) {
Some(file_name) => {
let mut next = vec!["cargo-spellcheck".to_owned()];
match argv_iter.next() {
Some(arg)
if file_name.starts_with("cargo-spellcheck")
&& arg == "spellcheck" =>
{
}
Some(arg) if file_name.starts_with("cargo") && &arg == "spellcheck" => {
}
Some(arg) if arg == "spellcheck" => {
}
Some(arg) => {
next.push(arg.to_owned())
}
None => {}
};
Vec::from_iter(next.into_iter().chain(argv_iter))
}
_ => Vec::from_iter(argv_iter),
}
} else {
Vec::new()
}
})
}
pub fn checker_selection_override(
filter_set: Option<&[CheckerType]>,
config: &mut Config,
) -> Result<()> {
if let Some(checkers) = filter_set {
#[cfg(feature = "hunspell")]
if !checkers.contains(&CheckerType::Hunspell) && config.hunspell.take().is_none() {
log::warn!("Hunspell was never configured.")
}
#[cfg(feature = "nlprules")]
if !checkers.contains(&CheckerType::NlpRules) && config.nlprules.take().is_none() {
log::warn!("Nlprules checker was never configured.")
}
if !checkers.contains(&CheckerType::Reflow) {
log::warn!("Reflow is a separate sub command.")
}
const EXPECTED_COUNT: usize =
1_usize + cfg!(feature = "nlprules") as usize + cfg!(feature = "hunspell") as usize;
if checkers.iter().unique().count() == EXPECTED_COUNT {
bail!("Argument override for checkers disabled all checkers")
}
}
Ok(())
}
fn load_config_inner(&self) -> Result<(Config, Option<PathBuf>)> {
log::debug!("Attempting to load configuration by priority.");
let cwd = crate::traverse::cwd()?;
let explicit_cfg = self.cfg.as_ref().map(|config_path| {
if config_path.is_absolute() {
config_path.to_owned()
} else {
cwd.join(config_path)
}
});
if let Some(config_path) = explicit_cfg {
log::debug!(
"Using configuration file provided by flag (1) {}",
config_path.display()
);
let config =
Config::load_from(&config_path)?.ok_or_else(|| eyre!("File does not exist."))?;
return Ok((config, Some(config_path)));
}
log::debug!("No cfg flag present");
let single_target_path = self.common().and_then(|common| {
common
.paths
.first()
.filter(|_x| common.paths.len() == 1)
.cloned()
});
let manifest_path_in_target_dir = if let Some(ref base) = single_target_path {
look_for_cargo_manifest(base)?
} else {
None
};
if let Some(manifest_path) = &manifest_path_in_target_dir {
if let Some((config, config_path)) = load_from_manifest_metadata(manifest_path)? {
return Ok((config, Some(config_path)));
}
};
if let Some(manifest_path) = look_for_cargo_manifest(&cwd)? {
if let Some((config, config_path)) = load_from_manifest_metadata(&manifest_path)? {
return Ok((config, Some(config_path)));
}
};
let config_path = cwd.join(".config").join("spellcheck.toml");
if let Some(cfg) = Config::load_from(&config_path)? {
log::debug!("Using configuration file (4) {}", config_path.display());
return Ok((cfg, Some(config_path)));
}
let default_config_path = Config::default_path()?;
if let Some(cfg) = Config::load_from(&default_config_path)? {
log::debug!(
"Using configuration file (5) {}",
default_config_path.display()
);
return Ok((cfg, Some(default_config_path)));
}
log::debug!("No user config present {}", default_config_path.display());
log::debug!("Using configuration default, builtin configuration (5)");
Ok((Config::default(), None))
}
fn load_config(&self) -> Result<(Config, Option<PathBuf>)> {
let (mut config, config_path) = self.load_config_inner()?;
let filter_set = self
.checkers()
.unwrap_or_else(|| vec![CheckerType::Hunspell]);
{
if filter_set.contains(&CheckerType::Hunspell) {
if config.hunspell.is_none() {
config.hunspell = Some(crate::config::HunspellConfig::default());
}
} else {
config.hunspell = None;
}
if filter_set.contains(&CheckerType::ZSpell) {
if config.zet.is_none() {
config.zet = Some(crate::config::ZetConfig::default());
}
} else {
config.zet = None;
}
if filter_set.contains(&CheckerType::Spellbook) {
if config.spellbook.is_none() {
config.spellbook = Some(crate::config::SpellbookConfig::default());
}
} else {
config.spellbook = None;
}
if filter_set.contains(&CheckerType::NlpRules) {
if config.nlprules.is_none() {
config.nlprules = Some(crate::config::NlpRulesConfig::default());
}
} else {
config.nlprules = None;
}
}
Ok((config, config_path))
}
pub fn unified(self) -> Result<(UnifiedArgs, Config)> {
let (config, config_path) = self.load_config()?;
let unified = match self.command {
Some(Sub::Config {
stdout,
user,
overwrite,
filter: checkers,
}) => {
let dest_config = match self.cfg {
None if stdout => ConfigWriteDestination::Stdout,
Some(path) => ConfigWriteDestination::File { overwrite, path },
None if user => ConfigWriteDestination::File {
overwrite,
path: Config::default_path()?,
},
_ => bail!("Neither --user or --stdout are given, invalid flags passed."),
};
UnifiedArgs::Config {
dest_config,
checker_filter_set: checkers,
}
}
Some(Sub::ListFiles {
ref paths,
recursive,
skip_readme,
}) => UnifiedArgs::Operate {
action: self.action(),
config_path,
dev_comments: false, skip_readme,
recursive,
paths: paths.clone(),
exit_code_override: 1,
},
None => {
let common = &self.common;
UnifiedArgs::Operate {
action: Action::Check,
config_path,
dev_comments: common.dev_comments || config.dev_comments,
skip_readme: common.skip_readme || config.skip_readme,
recursive: common.recursive,
paths: common.paths.clone(),
exit_code_override: common.code,
}
}
Some(
Sub::Reflow { ref common, .. }
| Sub::Fix { ref common, .. }
| Sub::Check { ref common, .. },
) => UnifiedArgs::Operate {
action: self.action(),
config_path,
dev_comments: common.dev_comments || config.dev_comments,
skip_readme: common.skip_readme || config.skip_readme,
recursive: common.recursive,
paths: common.paths.clone(),
exit_code_override: common.code,
},
Some(Sub::Completions { .. }) => unreachable!("Was handled earlier. qed"),
};
Ok((unified, config))
}
}
#[derive(Debug, Clone)]
pub enum ConfigWriteDestination {
Stdout,
File { overwrite: bool, path: PathBuf },
}
#[derive(Debug, Clone)]
pub enum UnifiedArgs {
Config {
dest_config: ConfigWriteDestination,
checker_filter_set: Option<MultipleCheckerTypes>,
},
Operate {
action: Action,
config_path: Option<PathBuf>,
dev_comments: bool,
skip_readme: bool,
recursive: bool,
paths: Vec<PathBuf>,
exit_code_override: u8,
},
}
impl UnifiedArgs {
pub fn action(&self) -> Action {
match self {
Self::Operate { action, .. } => *action,
_ => unreachable!(),
}
}
}
fn look_for_cargo_manifest(base: &Path) -> Result<Option<PathBuf>> {
Ok(if base.is_dir() {
let base = base.join("Cargo.toml");
if base.is_file() {
let base = base.canonicalize()?;
log::debug!("Using {} manifest as anchor file", base.display());
Some(base)
} else {
log::debug!("Cargo manifest files does not exist: {}", base.display());
None
}
} else if let Some(file_name) = base.file_name() {
if file_name == "Cargo.toml" && base.is_file() {
let base = base.canonicalize()?;
log::debug!("Using {} manifest as anchor file", base.display());
Some(base)
} else {
log::debug!("Cargo manifest files does not exist: {}", base.display());
None
}
} else {
log::debug!(
"Provided parse target is neither file or dir: {}",
base.display()
);
None
})
}
fn extract_config_path_from_metadata(
manifest_path: &Path,
metadata: ManifestMetadata,
ident: &str,
) -> Result<Option<(Config, PathBuf)>> {
if let Some(spellcheck) = metadata.spellcheck {
let config_path = &spellcheck.config;
let config_path = if config_path.is_absolute() {
config_path.to_owned()
} else {
let manifest_dir = manifest_path.parent().expect("File resides in a dir. qed");
manifest_dir.join(config_path)
};
log::debug!(
"Using configuration ({}) file {}",
ident,
config_path.display()
);
return Ok(Config::load_from(&config_path)?.map(|config| (config, config_path)));
}
Ok(None)
}
fn load_from_manifest_metadata(manifest_path: &Path) -> Result<Option<(Config, PathBuf)>> {
let manifest = fs::read_to_string(manifest_path)?;
let manifest =
cargo_toml::Manifest::<ManifestMetadata>::from_slice_with_metadata(manifest.as_bytes())
.wrap_err(format!(
"Failed to parse cargo manifest: {}",
manifest_path.display()
))?;
if let Some(metadata) = manifest.package.and_then(|package| package.metadata) {
if let Some(x) = extract_config_path_from_metadata(manifest_path, metadata, "package")? {
return Ok(Some(x));
}
}
if let Some(metadata) = manifest.workspace.and_then(|workspace| workspace.metadata) {
if let Some(x) = extract_config_path_from_metadata(manifest_path, metadata, "workspace")? {
return Ok(Some(x));
}
}
Ok(None)
}
pub fn derive_job_count(jobs: impl Into<Option<usize>>) -> usize {
let maybe_jobs = jobs.into();
match maybe_jobs {
_ if cfg!(debug_assertions) => {
log::warn!("Debug mode always uses 1 thread!");
1
}
Some(jobs) if jobs == 0 => {
log::warn!(
"Cannot have less than one worker thread ({jobs}). Retaining one worker thread."
);
1
}
Some(jobs) if jobs > 128 => {
log::warn!("Setting threads beyond 128 ({jobs}) is insane. Capping at 128");
128
}
Some(jobs) => {
log::info!("Explicitly set threads to {jobs}");
jobs
}
None => {
let jobs = num_cpus::get_physical();
log::debug!("Using the default physical thread count of {jobs}");
jobs
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
fn commandline_to_iter(s: &'static str) -> impl Iterator<Item = String> {
s.split(' ').map(|s| s.to_owned()).into_iter()
}
lazy_static::lazy_static!(
static ref SAMPLES: std::collections::HashMap<&'static str, Action> = maplit::hashmap!{
"cargo spellcheck" => Action::Check,
"cargo spellcheck -vvvv" => Action::Check,
"cargo-spellcheck" => Action::Check,
"cargo-spellcheck -vvvv" => Action::Check,
"cargo spellcheck check -m 11" => Action::Check,
"cargo-spellcheck check -m 9" => Action::Check,
"cargo spellcheck reflow" => Action::Reflow,
"cargo-spellcheck reflow" => Action::Reflow,
"cargo spellcheck --fix" => Action::Fix,
"cargo-spellcheck --fix" => Action::Fix,
"cargo spellcheck fix" => Action::Fix,
"cargo-spellcheck fix" => Action::Fix,
"cargo-spellcheck fix -r file.rs" => Action::Fix,
"cargo-spellcheck -q fix Cargo.toml" => Action::Fix,
"cargo spellcheck -v fix Cargo.toml" => Action::Fix,
};
);
#[test]
fn args() {
for command in SAMPLES.keys() {
assert_matches!(Args::parse(commandline_to_iter(command)), Ok(_));
}
}
#[test]
fn deserialize_multiple_checkers() {
let args = Args::parse(commandline_to_iter(
"cargo spellcheck check --checkers=nlprules,hunspell",
))
.expect("Parsing works. qed");
assert_eq!(
args.checkers(),
Some(vec![CheckerType::NlpRules, CheckerType::Hunspell])
);
}
#[test]
fn alt_fix_works() {
let args_sub = Args::parse(commandline_to_iter("cargo spellcheck fix")).unwrap();
let args_alt = Args::parse(commandline_to_iter("cargo spellcheck --fix")).unwrap();
assert_eq!(args_sub.common(), args_alt.common());
assert_eq!(args_sub.action(), args_alt.action());
}
#[test]
fn unify_ops_check() {
let args = Args::parse(
&mut [
"cargo",
"spellcheck",
"-vvvvv",
"check",
"--code=77",
"--dev-comments",
"--skip-readme",
]
.iter()
.map(ToOwned::to_owned)
.map(ToOwned::to_owned),
)
.unwrap();
let (unified, _config) = args.unified().unwrap();
assert_matches!(unified,
UnifiedArgs::Operate {
action,
config_path: _,
dev_comments,
skip_readme,
recursive,
paths,
exit_code_override,
} => {
assert_eq!(Action::Check, action);
assert_eq!(exit_code_override, 77);
assert_eq!(dev_comments, true);
assert_eq!(skip_readme, true);
assert_eq!(recursive, false);
assert_eq!(paths, Vec::<PathBuf>::new());
}
);
}
#[test]
fn unify_config() {
let args = Args::parse(
&mut [
"cargo-spellcheck",
"--cfg=.config/spellcheck.toml",
"config",
"--checkers=NlpRules",
"--overwrite",
]
.iter()
.map(ToOwned::to_owned)
.map(ToOwned::to_owned),
)
.unwrap();
let (unified, _config) = dbg!(args).unified().unwrap();
assert_matches!(dbg!(unified),
UnifiedArgs::Config {
dest_config: ConfigWriteDestination::File { overwrite, path },
checker_filter_set,
} => {
assert_eq!(path, PathBuf::from(".config/spellcheck.toml"));
assert_eq!(checker_filter_set, Some(MultipleCheckerTypes(vec![CheckerType::NlpRules])));
assert_eq!(overwrite, true);
}
);
}
#[test]
fn shell_check_env() {
assert_matches!(load_shell_name("/usr/bin/zsh"), Ok(Shell::Zsh));
assert_matches!(load_shell_name("zsh"), Ok(Shell::Zsh));
assert_matches!(load_shell_name("fish"), Ok(Shell::Fish));
static C1: &str = "cargo spellcheck completions --shell zsh";
assert_matches!(Args::parse(commandline_to_iter(C1)), Ok(Args {
command: Some(Sub::Completions { shell }),
..
}) => {
assert_eq!(shell.to_string(), "zsh")
});
static C2: &str = "cargo spellcheck completions";
std::env::set_var("SHELL", "/bin/fish");
assert_matches!(Args::parse(commandline_to_iter(C2)), Ok(Args {
command: Some(Sub::Completions { shell }),
..
}) => {
assert_eq!(shell.to_string(), "fish")
});
}
}