use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigSource {
CliPlugin,
ProjectPlugin,
GlobalPlugin,
}
impl ConfigSource {
pub fn priority(&self) -> u8 {
match self {
ConfigSource::CliPlugin => 2,
ConfigSource::ProjectPlugin => 3,
ConfigSource::GlobalPlugin => 4,
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
pub language: String,
pub tool: String,
pub config_path: PathBuf,
pub source: ConfigSource,
pub plugin_name: String,
}
impl ResolvedConfig {
pub fn new(
language: impl Into<String>,
tool: impl Into<String>,
config_path: PathBuf,
source: ConfigSource,
plugin_name: impl Into<String>,
) -> Self {
Self {
language: language.into(),
tool: tool.into(),
config_path,
source,
plugin_name: plugin_name.into(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigResolver {
configs: Vec<ResolvedConfig>,
}
impl ConfigResolver {
pub fn new() -> Self {
Self::default()
}
pub fn with_configs(mut configs: Vec<ResolvedConfig>) -> Self {
configs.sort_by_key(|c| c.source.priority());
Self { configs }
}
pub fn add_config(&mut self, config: ResolvedConfig) {
self.configs.push(config);
self.configs.sort_by_key(|c| c.source.priority());
}
pub fn add_configs(&mut self, configs: impl IntoIterator<Item = ResolvedConfig>) {
self.configs.extend(configs);
self.configs.sort_by_key(|c| c.source.priority());
}
pub fn get_config(&self, lang: &str, tool: &str, file: &Path) -> Option<PathBuf> {
if let Some(local) = find_local_config(lang, tool, file) {
return Some(local);
}
self.configs
.iter()
.find(|c| c.language == lang && c.tool == tool)
.map(|c| c.config_path.clone())
}
pub fn get_plugin_config(&self, lang: &str, tool: &str) -> Option<PathBuf> {
if let Some(config) = self
.configs
.iter()
.find(|c| c.language == lang && c.tool == tool)
{
return Some(config.config_path.clone());
}
let aliases = get_tool_aliases(tool);
for alias in aliases {
if let Some(config) = self
.configs
.iter()
.find(|c| c.language == lang && c.tool == alias)
{
return Some(config.config_path.clone());
}
}
None
}
pub fn get_configs_for_language(&self, lang: &str) -> Vec<&ResolvedConfig> {
self.configs.iter().filter(|c| c.language == lang).collect()
}
pub fn get_configs_for_tool(&self, tool: &str) -> Vec<&ResolvedConfig> {
self.configs.iter().filter(|c| c.tool == tool).collect()
}
pub fn len(&self) -> usize {
self.configs.len()
}
pub fn is_empty(&self) -> bool {
self.configs.is_empty()
}
pub fn configs(&self) -> &[ResolvedConfig] {
&self.configs
}
}
pub fn find_local_config(lang: &str, tool: &str, file: &Path) -> Option<PathBuf> {
let config_names = get_config_names(lang, tool);
if config_names.is_empty() {
return None;
}
let mut current = if file.is_file() {
file.parent()?.to_path_buf()
} else {
file.to_path_buf()
};
loop {
for config_name in &config_names {
let config_path = current.join(config_name);
if config_path.exists() {
if !is_plugin_config_path(&config_path) {
return Some(config_path);
}
}
}
if !current.pop() {
break;
}
}
None
}
fn is_plugin_config_path(path: &Path) -> bool {
let path_str = path.to_string_lossy();
path_str.contains(".linthis/configs/") || path_str.contains(".linthis\\configs\\")
}
fn get_tool_aliases(tool: &str) -> Vec<&'static str> {
match tool {
"ruff" => vec!["ruff-from-flake8"],
"eslint" => vec!["eslint-from-tslint"],
"clang-format" => vec!["clang-format-from-idea"],
_ => vec![],
}
}
fn get_config_names(lang: &str, tool: &str) -> Vec<&'static str> {
match (lang, tool) {
("python", "ruff") => vec!["ruff.toml", ".ruff.toml", "pyproject.toml"],
("typescript" | "javascript", "eslint") => vec![
".eslintrc.js",
".eslintrc.cjs",
".eslintrc.json",
".eslintrc.yml",
".eslintrc.yaml",
"eslint.config.js",
"eslint.config.mjs",
"eslint.config.cjs",
],
("typescript" | "javascript", "prettier") => vec![
".prettierrc",
".prettierrc.json",
".prettierrc.yml",
".prettierrc.yaml",
".prettierrc.js",
".prettierrc.cjs",
"prettier.config.js",
"prettier.config.cjs",
],
("go", "golangci-lint") => vec![
".golangci.yml",
".golangci.yaml",
".golangci.toml",
".golangci.json",
],
("cpp" | "oc", "clang-tidy") => vec![".clang-tidy"],
("cpp" | "oc", "clang-format") => vec![".clang-format", "_clang-format"],
("rust", "clippy") => vec!["clippy.toml", ".clippy.toml"],
("rust", "rustfmt") => vec!["rustfmt.toml", ".rustfmt.toml"],
("java", "checkstyle") => vec!["checkstyle.xml"],
("kotlin", "ktlint") => vec![".editorconfig"],
("swift", "swiftlint") => vec![".swiftlint.yml", ".swiftlint.yaml"],
("dart", "analysis_options") => vec!["analysis_options.yaml"],
("lua", "luacheck") => vec![".luacheckrc"],
("lua", "stylua") => vec!["stylua.toml", ".stylua.toml"],
("shell", "shellcheck") => vec![".shellcheckrc"],
("ruby", "rubocop") => vec![".rubocop.yml"],
("php", "phpcs") => vec![
"phpcs.xml",
"phpcs.xml.dist",
".phpcs.xml",
".phpcs.xml.dist",
],
("scala", "scalafmt") => vec![".scalafmt.conf"],
("*", "linthis-secrets") => vec!["secrets.toml", ".secrets.toml"],
_ => vec![],
}
}
#[derive(Debug, Default)]
pub struct ConfigResolverBuilder {
configs: Vec<ResolvedConfig>,
}
impl ConfigResolverBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_cli_plugins(
mut self,
plugin_configs: Vec<(String, String, PathBuf, String)>,
) -> Self {
for (lang, tool, path, plugin_name) in plugin_configs {
self.configs.push(ResolvedConfig::new(
lang,
tool,
path,
ConfigSource::CliPlugin,
plugin_name,
));
}
self
}
pub fn with_project_plugins(
mut self,
plugin_configs: Vec<(String, String, PathBuf, String)>,
) -> Self {
for (lang, tool, path, plugin_name) in plugin_configs {
self.configs.push(ResolvedConfig::new(
lang,
tool,
path,
ConfigSource::ProjectPlugin,
plugin_name,
));
}
self
}
pub fn with_global_plugins(
mut self,
plugin_configs: Vec<(String, String, PathBuf, String)>,
) -> Self {
for (lang, tool, path, plugin_name) in plugin_configs {
self.configs.push(ResolvedConfig::new(
lang,
tool,
path,
ConfigSource::GlobalPlugin,
plugin_name,
));
}
self
}
pub fn build(self) -> ConfigResolver {
ConfigResolver::with_configs(self.configs)
}
}
pub type SharedConfigResolver = Arc<ConfigResolver>;
pub fn shared_resolver(resolver: ConfigResolver) -> SharedConfigResolver {
Arc::new(resolver)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_config_source_priority() {
assert!(ConfigSource::CliPlugin.priority() < ConfigSource::ProjectPlugin.priority());
assert!(ConfigSource::ProjectPlugin.priority() < ConfigSource::GlobalPlugin.priority());
}
#[test]
fn test_config_resolver_sorting() {
let resolver = ConfigResolver::with_configs(vec![
ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/global/ruff.toml"),
ConfigSource::GlobalPlugin,
"global",
),
ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/cli/ruff.toml"),
ConfigSource::CliPlugin,
"cli",
),
ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/project/ruff.toml"),
ConfigSource::ProjectPlugin,
"project",
),
]);
let configs = resolver.configs();
assert_eq!(configs[0].source, ConfigSource::CliPlugin);
assert_eq!(configs[1].source, ConfigSource::ProjectPlugin);
assert_eq!(configs[2].source, ConfigSource::GlobalPlugin);
}
#[test]
fn test_get_plugin_config() {
let resolver = ConfigResolver::with_configs(vec![
ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/cli/ruff.toml"),
ConfigSource::CliPlugin,
"cli",
),
ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/project/ruff.toml"),
ConfigSource::ProjectPlugin,
"project",
),
]);
let config = resolver.get_plugin_config("python", "ruff");
assert_eq!(config, Some(PathBuf::from("/cli/ruff.toml")));
}
#[test]
fn test_get_plugin_config_not_found() {
let resolver = ConfigResolver::new();
let config = resolver.get_plugin_config("python", "ruff");
assert_eq!(config, None);
}
#[test]
fn test_local_config_has_priority() {
let temp = tempdir().unwrap();
let local_config = temp.path().join("ruff.toml");
fs::write(&local_config, "# local config").unwrap();
let test_file = temp.path().join("test.py");
fs::write(&test_file, "# test").unwrap();
let resolver = ConfigResolver::with_configs(vec![ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/plugin/ruff.toml"),
ConfigSource::CliPlugin,
"plugin",
)]);
let config = resolver.get_config("python", "ruff", &test_file);
assert_eq!(config, Some(local_config));
}
#[test]
fn test_plugin_config_used_when_no_local() {
let temp = tempdir().unwrap();
let test_file = temp.path().join("test.py");
fs::write(&test_file, "# test").unwrap();
let plugin_path = PathBuf::from("/plugin/ruff.toml");
let resolver = ConfigResolver::with_configs(vec![ResolvedConfig::new(
"python",
"ruff",
plugin_path.clone(),
ConfigSource::CliPlugin,
"plugin",
)]);
let config = resolver.get_config("python", "ruff", &test_file);
assert_eq!(config, Some(plugin_path));
}
#[test]
fn test_is_plugin_config_path() {
assert!(is_plugin_config_path(Path::new(
"/project/.linthis/configs/python/ruff.toml"
)));
assert!(is_plugin_config_path(Path::new(
"C:\\project\\.linthis\\configs\\python\\ruff.toml"
)));
assert!(!is_plugin_config_path(Path::new("/project/ruff.toml")));
assert!(!is_plugin_config_path(Path::new(
"/project/.linthis/config.toml"
)));
}
#[test]
fn test_get_config_names_python() {
let names = get_config_names("python", "ruff");
assert!(names.contains(&"ruff.toml"));
assert!(names.contains(&".ruff.toml"));
assert!(names.contains(&"pyproject.toml"));
}
#[test]
fn test_get_config_names_typescript() {
let names = get_config_names("typescript", "eslint");
assert!(names.contains(&".eslintrc.js"));
assert!(names.contains(&"eslint.config.js"));
}
#[test]
fn test_builder() {
let resolver = ConfigResolverBuilder::new()
.with_cli_plugins(vec![(
"python".to_string(),
"ruff".to_string(),
PathBuf::from("/cli/ruff.toml"),
"cli-plugin".to_string(),
)])
.with_project_plugins(vec![(
"python".to_string(),
"ruff".to_string(),
PathBuf::from("/project/ruff.toml"),
"project-plugin".to_string(),
)])
.build();
assert_eq!(resolver.len(), 2);
let configs = resolver.configs();
assert_eq!(configs[0].source, ConfigSource::CliPlugin);
assert_eq!(configs[1].source, ConfigSource::ProjectPlugin);
}
#[test]
fn test_resolver_len_and_is_empty() {
let resolver = ConfigResolver::new();
assert!(resolver.is_empty());
assert_eq!(resolver.len(), 0);
let resolver = ConfigResolver::with_configs(vec![ResolvedConfig::new(
"python",
"ruff",
PathBuf::from("/ruff.toml"),
ConfigSource::CliPlugin,
"test",
)]);
assert!(!resolver.is_empty());
assert_eq!(resolver.len(), 1);
}
}