use std::path::{Path, PathBuf};
use globset::{GlobBuilder, GlobMatcher};
use serde::{Deserialize, Deserializer};
use thiserror::Error;
#[derive(Debug, Clone, Deserialize)]
pub struct Rule {
#[serde(deserialize_with = "deserialize_string_or_vec")]
pub path: Vec<String>,
pub max_lines: usize,
}
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
String(String),
Vec(Vec<String>),
}
match StringOrVec::deserialize(deserializer)? {
StringOrVec::String(s) => Ok(vec![s]),
StringOrVec::Vec(v) => Ok(v),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoqConfig {
pub default_max_lines: Option<usize>,
#[serde(default = "default_respect_gitignore")]
pub respect_gitignore: bool,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(default)]
pub fix_guidance: Option<String>,
}
impl Default for LoqConfig {
fn default() -> Self {
Self {
default_max_lines: Some(500),
respect_gitignore: true,
exclude: Vec::new(),
rules: Vec::new(),
fix_guidance: None,
}
}
}
impl LoqConfig {
#[must_use]
pub fn built_in_defaults() -> Self {
Self::default()
}
#[must_use]
pub fn init_template() -> Self {
Self {
rules: vec![
Rule {
path: vec!["**/*.tsx".to_string()],
max_lines: 300,
},
Rule {
path: vec!["tests/**/*".to_string()],
max_lines: 500,
},
],
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub enum ConfigOrigin {
BuiltIn,
File(PathBuf),
}
#[derive(Debug, Clone)]
pub struct CompiledConfig {
pub origin: ConfigOrigin,
pub root_dir: PathBuf,
pub default_max_lines: Option<usize>,
pub respect_gitignore: bool,
pub fix_guidance: Option<String>,
exclude: PatternList,
rules: Vec<CompiledRule>,
}
impl CompiledConfig {
#[must_use]
pub const fn exclude_patterns(&self) -> &PatternList {
&self.exclude
}
#[must_use]
pub fn rules(&self) -> &[CompiledRule] {
&self.rules
}
}
#[derive(Debug, Clone)]
pub struct CompiledRule {
pub patterns: Vec<String>,
pub max_lines: usize,
matchers: Vec<GlobMatcher>,
}
impl CompiledRule {
#[must_use]
pub fn matches(&self, path: &str) -> Option<&str> {
for (matcher, pattern) in self.matchers.iter().zip(&self.patterns) {
if matcher.is_match(path) {
return Some(pattern);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct PatternList {
patterns: Vec<PatternMatcher>,
}
impl PatternList {
pub(crate) const fn new(patterns: Vec<PatternMatcher>) -> Self {
Self { patterns }
}
#[must_use]
pub fn matches(&self, path: &str) -> Option<&str> {
for pattern in &self.patterns {
if pattern.matcher.is_match(path) {
return Some(pattern.pattern.as_str());
}
}
None
}
pub fn patterns(&self) -> impl Iterator<Item = &str> {
self.patterns.iter().map(|p| p.pattern.as_str())
}
}
#[derive(Debug, Clone)]
pub(crate) struct PatternMatcher {
pattern: String,
matcher: GlobMatcher,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("{}", format_toml_error(path, line_col, message))]
Toml {
path: PathBuf,
message: String,
line_col: Option<(usize, usize)>,
},
#[error("{}", format_unknown_key_error(path, key, line_col, suggestion))]
UnknownKey {
path: PathBuf,
key: String,
line_col: Option<(usize, usize)>,
suggestion: Option<String>,
},
#[error("{} - invalid glob '{}': {}", path.display(), pattern, message)]
Glob {
path: PathBuf,
pattern: String,
message: String,
},
}
#[allow(clippy::ref_option)]
fn format_toml_error(path: &Path, line_col: &Option<(usize, usize)>, message: &str) -> String {
if let Some((line, col)) = line_col {
format!("{}:{}:{} - {}", path.display(), line, col, message)
} else {
format!("{} - {}", path.display(), message)
}
}
#[allow(clippy::ref_option)]
fn format_unknown_key_error(
path: &Path,
key: &str,
line_col: &Option<(usize, usize)>,
suggestion: &Option<String>,
) -> String {
let base = format_toml_error(path, line_col, &format!("unknown key '{key}'"));
if let Some(suggestion) = suggestion {
format!("{base}\n did you mean '{suggestion}'?")
} else {
base
}
}
pub fn compile_config(
origin: ConfigOrigin,
root_dir: PathBuf,
config: LoqConfig,
source_path: Option<&Path>,
) -> Result<CompiledConfig, ConfigError> {
let path_for_errors =
source_path.map_or_else(|| PathBuf::from("<built-in defaults>"), Path::to_path_buf);
let exclude = compile_patterns(&config.exclude, &path_for_errors)?;
let mut rules = Vec::new();
for rule in config.rules {
let mut matchers = Vec::new();
for pattern in &rule.path {
matchers.push(compile_glob(pattern, &path_for_errors)?);
}
rules.push(CompiledRule {
patterns: rule.path,
max_lines: rule.max_lines,
matchers,
});
}
Ok(CompiledConfig {
origin,
root_dir,
default_max_lines: config.default_max_lines,
respect_gitignore: config.respect_gitignore,
fix_guidance: config.fix_guidance,
exclude,
rules,
})
}
fn compile_patterns(patterns: &[String], source_path: &Path) -> Result<PatternList, ConfigError> {
let mut compiled = Vec::new();
for pattern in patterns {
let matcher = compile_glob(pattern, source_path)?;
compiled.push(PatternMatcher {
pattern: pattern.clone(),
matcher,
});
}
Ok(PatternList::new(compiled))
}
fn compile_glob(pattern: &str, source_path: &Path) -> Result<GlobMatcher, ConfigError> {
#[cfg(windows)]
let builder = {
let mut builder = GlobBuilder::new(pattern);
builder.case_insensitive(true);
builder
};
#[cfg(not(windows))]
let builder = GlobBuilder::new(pattern);
let glob = builder.build().map_err(|err| ConfigError::Glob {
path: source_path.to_path_buf(),
pattern: pattern.to_string(),
message: err.to_string(),
})?;
Ok(glob.compile_matcher())
}
const fn default_respect_gitignore() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn default_config_has_expected_values() {
let config = LoqConfig::default();
assert_eq!(config.default_max_lines, Some(500));
assert!(config.respect_gitignore);
assert!(config.exclude.is_empty());
assert!(config.rules.is_empty());
}
#[test]
fn built_in_defaults_matches_default() {
let default = LoqConfig::default();
let built_in = LoqConfig::built_in_defaults();
assert_eq!(default.default_max_lines, built_in.default_max_lines);
assert_eq!(default.respect_gitignore, built_in.respect_gitignore);
}
#[test]
fn init_template_has_rules() {
let template = LoqConfig::init_template();
assert_eq!(template.default_max_lines, Some(500));
assert_eq!(template.rules.len(), 2);
assert_eq!(template.rules[0].path, vec!["**/*.tsx"]);
assert_eq!(template.rules[1].path, vec!["tests/**/*"]);
}
#[test]
fn invalid_glob_reports_error() {
let config = LoqConfig {
default_max_lines: Some(1),
respect_gitignore: true,
exclude: vec![],
rules: vec![Rule {
path: vec!["[[".to_string()],
max_lines: 1,
}],
fix_guidance: None,
};
let err =
compile_config(ConfigOrigin::BuiltIn, PathBuf::from("."), config, None).unwrap_err();
match err {
ConfigError::Glob { .. } => {}
_ => panic!("expected glob error"),
}
}
#[test]
fn glob_error_display_is_stable() {
let config = LoqConfig {
default_max_lines: Some(1),
respect_gitignore: true,
exclude: vec!["[[".to_string()],
rules: vec![],
fix_guidance: None,
};
let err =
compile_config(ConfigOrigin::BuiltIn, PathBuf::from("."), config, None).unwrap_err();
assert!(err.to_string().contains("invalid glob"));
}
#[test]
fn pattern_list_no_match_returns_none() {
let patterns = vec![PatternMatcher {
pattern: "*.rs".to_string(),
matcher: globset::GlobBuilder::new("*.rs")
.literal_separator(true)
.build()
.unwrap()
.compile_matcher(),
}];
let list = PatternList::new(patterns);
assert!(list.matches("foo.txt").is_none());
}
#[test]
fn format_toml_error_without_location() {
let msg = format_toml_error(Path::new("test.toml"), &None, "parse error");
assert_eq!(msg, "test.toml - parse error");
}
#[test]
fn format_unknown_key_error_without_suggestion() {
let msg = format_unknown_key_error(Path::new("test.toml"), "xyz", &Some((1, 1)), &None);
assert!(msg.contains("unknown key 'xyz'"));
assert!(!msg.contains("did you mean"));
}
}