use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::Error;
use crate::error::CustomFailure;
pub(crate) const FILE_NAME: &str = ".refquiem.toml";
const DEFAULT_EXCLUDES: [&str; 8] = [
"CHANGELOG*",
"changelog/",
"RELEASES*",
"RELEASE-NOTES*",
"RELEASE_NOTES*",
"NEWS*",
"HISTORY*",
"CHANGES*",
];
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct Config {
#[serde(default)]
pub(crate) exclude: Vec<String>,
#[serde(default)]
pub(crate) allow: Vec<String>,
#[serde(default)]
pub(crate) generated_markers: Vec<String>,
#[serde(default)]
pub(crate) scan_generated: bool,
#[serde(default)]
pub(crate) scan_commented_code: bool,
pub(crate) since: Option<String>,
vocabularies: Option<Vocabularies>,
}
impl Config {
pub(crate) fn selects(&self, builtin: Builtin) -> bool {
match &self.vocabularies {
Some(vocabularies) => vocabularies.builtins.contains(&builtin),
None => builtin == Builtin::English,
}
}
pub(crate) fn custom_files(&self) -> &[String] {
match &self.vocabularies {
Some(vocabularies) => &vocabularies.files,
None => &[],
}
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Vocabularies {
#[serde(default)]
builtins: Vec<Builtin>,
#[serde(default)]
files: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
pub(crate) enum Builtin {
#[serde(rename = "english")]
English,
#[serde(rename = "sql@2023")]
Sql2023,
#[serde(rename = "wasi@preview1")]
WasiPreview1,
#[serde(rename = "wasm@3.0")]
Wasm3,
#[serde(rename = "android@36")]
Android36,
#[serde(rename = "uikit@27.1")]
Uikit27_1,
#[serde(rename = "posix@2017")]
Posix2017,
#[serde(rename = "web-api@2026-10")]
WebApi2026_10,
#[serde(rename = "python@3.15")]
Python3_15,
#[serde(rename = "javascript@2026-10")]
Javascript2026_10,
#[serde(rename = "svg@2026-10")]
Svg2026_10,
#[serde(rename = "postgres@18")]
Postgres18,
#[serde(rename = "sqlite@3.54")]
Sqlite3_54,
}
pub(crate) fn load(path: &Path, required: bool) -> Result<Config, Error> {
if !required && !path.exists() {
return Ok(Config::default());
}
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&text).map_err(|source| Error::Config {
path: path.to_path_buf(),
source: Box::new(source),
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Category {
NaturalLanguage,
Technical,
}
impl Category {
pub(crate) fn as_str(self) -> &'static str {
match self {
Category::NaturalLanguage => "natural-language",
Category::Technical => "technical",
}
}
}
#[derive(Clone, Copy, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Case {
Sensitive,
AsciiInsensitive,
UnicodeLowercase,
}
impl Case {
pub(crate) fn as_str(self) -> &'static str {
match self {
Case::Sensitive => "sensitive",
Case::AsciiInsensitive => "ascii-insensitive",
Case::UnicodeLowercase => "unicode-lowercase",
}
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct CustomFile {
format_version: u32,
kind: Category,
case: Case,
words: Vec<String>,
}
pub(crate) struct Custom {
pub(crate) path: PathBuf,
pub(crate) kind: Category,
pub(crate) case: Case,
pub(crate) format_version: u32,
pub(crate) words: Vec<String>,
}
pub(crate) fn load_custom(config_path: &Path, config: &Config) -> Result<Vec<Custom>, Error> {
let base = config_path.parent().unwrap_or(Path::new("."));
let mut paths = BTreeSet::new();
for entry in config.custom_files() {
paths.insert(canonical(&base.join(entry))?);
}
let mut files = Vec::with_capacity(paths.len());
for path in paths {
files.push(load_custom_file(path)?);
}
Ok(files)
}
fn load_custom_file(path: PathBuf) -> Result<Custom, Error> {
let bytes =
std::fs::read(&path).map_err(|source| custom_error(&path, CustomFailure::Io(source)))?;
let text = std::str::from_utf8(&bytes).map_err(|_| custom_error(&path, CustomFailure::Utf8))?;
let mut file: CustomFile = toml::from_str(text)
.map_err(|source| custom_error(&path, CustomFailure::Toml(Box::new(source))))?;
if file.format_version != 1 {
return Err(custom_error(
&path,
CustomFailure::Version(file.format_version),
));
}
if let Some(at) = file.words.iter().position(|word| {
word.is_empty() || word.chars().any(|c| c.is_whitespace() || c.is_control())
}) {
let word = file.words.swap_remove(at);
return Err(custom_error(&path, CustomFailure::Word(word)));
}
Ok(Custom {
path,
kind: file.kind,
case: file.case,
format_version: file.format_version,
words: file.words,
})
}
fn custom_error(path: &Path, source: CustomFailure) -> Error {
Error::Custom {
path: path.to_path_buf(),
source: Box::new(source),
}
}
fn canonical(path: &Path) -> Result<PathBuf, Error> {
path.canonicalize()
.map_err(|source| custom_error(path, CustomFailure::Io(source)))
}
pub(crate) struct Filter {
excludes: Gitignore,
workspace: String,
generated_markers: Vec<String>,
scan_generated: bool,
pub(crate) scan_commented_code: bool,
package_dir: Option<String>,
}
impl Filter {
pub(crate) fn new(
config: &Config,
workspace_dir: &Path,
workspace: &str,
package_dir: Option<String>,
) -> Result<Self, Error> {
let mut builder = GitignoreBuilder::new(workspace_dir);
for glob in DEFAULT_EXCLUDES
.iter()
.copied()
.chain(config.exclude.iter().map(String::as_str))
{
builder.add_line(None, glob).map_err(Error::Exclude)?;
}
let excludes = builder.build().map_err(Error::Exclude)?;
Ok(Self {
excludes,
workspace: workspace.to_owned(),
generated_markers: config.generated_markers.clone(),
scan_generated: config.scan_generated,
scan_commented_code: config.scan_commented_code,
package_dir,
})
}
pub(crate) fn skips_path(&self, file: &str) -> bool {
if let Some(dir) = &self.package_dir
&& !below(dir, file)
{
return true;
}
let relative = if self.workspace.is_empty() {
Some(file)
} else {
file.strip_prefix(&self.workspace)
.and_then(|rest| rest.strip_prefix('/'))
};
relative.is_some_and(|relative| {
self.excludes
.matched_path_or_any_parents(relative, false)
.is_ignore()
})
}
pub(crate) fn skips_text(&self, text: &str) -> bool {
!self.scan_generated && is_generated(text, &self.generated_markers)
}
}
fn below(dir: &str, file: &str) -> bool {
dir.is_empty()
|| file
.strip_prefix(dir)
.is_some_and(|rest| rest.starts_with('/'))
}
fn is_generated(text: &str, markers: &[String]) -> bool {
text.lines().take(5).any(|line| {
line.contains("@generated")
|| (line.starts_with("// Code generated ") && line.ends_with(" DO NOT EDIT."))
|| markers.iter().any(|marker| line.contains(marker.as_str()))
})
}