mod compat;
mod schema;
use std::fmt;
use std::path::{Path, PathBuf};
pub const CONFIG_FILE_NAMES: &[&str] = &["gdck.toml", ".gdck.toml"];
pub const GDFORMAT_FILE_NAMES: &[&str] = &["gdformatrc", ".gdformatrc"];
pub const GDLINT_FILE_NAMES: &[&str] = &["gdlintrc", ".gdlintrc"];
pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[".git", ".godot", ".import", "addons"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IndentStyle {
#[default]
Tabs,
Spaces(u8),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatConfig {
pub line_length: u16,
pub indent: IndentStyle,
pub safety_checks: bool,
}
impl Default for FormatConfig {
fn default() -> Self {
Self {
line_length: 100,
indent: IndentStyle::Tabs,
safety_checks: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CodeOrderFix {
#[default]
ReportOnly,
WholeFileWhenSafe,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintConfig {
pub max_line_length: u16,
pub max_file_lines: u32,
pub max_public_methods: u32,
pub max_returns: u32,
pub max_function_arguments: u32,
pub code_order: CodeOrderFix,
pub disabled: Vec<String>,
}
impl Default for LintConfig {
fn default() -> Self {
Self {
max_line_length: 100,
max_file_lines: 1000,
max_public_methods: 20,
max_returns: 6,
max_function_arguments: 10,
code_order: CodeOrderFix::default(),
disabled: Vec::new(),
}
}
}
pub mod naming {
pub const PASCAL_CASE: &str = r"([A-Z][a-z0-9]*)+";
pub const SNAKE_CASE: &str = r"[a-z][a-z0-9]*(_[a-z0-9]+)*";
pub const PRIVATE_SNAKE_CASE: &str = r"_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
pub const CONSTANT_CASE: &str = r"[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
pub const PRIVATE_CONSTANT_CASE: &str = r"_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Config {
pub format: FormatConfig,
pub lint: LintConfig,
pub excluded_dirs: Vec<String>,
}
impl Config {
#[must_use]
pub fn is_excluded_dir(&self, name: &str) -> bool {
if self.excluded_dirs.is_empty() {
return DEFAULT_EXCLUDED_DIRS.contains(&name);
}
self.excluded_dirs.iter().any(|dir| dir == name)
}
#[must_use]
pub fn to_toml(&self) -> String {
schema::to_toml(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Problem {
pub(crate) line: u32,
pub(crate) message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub path: PathBuf,
pub line: Option<u32>,
pub message: String,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.line {
Some(line) => write!(f, "{}:{line}: {}", self.path.display(), self.message),
None => write!(f, "{}: {}", self.path.display(), self.message),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub path: PathBuf,
pub line: u32,
pub message: String,
}
impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Loaded {
pub config: Config,
pub files: Vec<PathBuf>,
pub notes: Vec<Note>,
}
#[must_use]
pub fn discover(start: &Path) -> Option<PathBuf> {
discover_named(start, CONFIG_FILE_NAMES)
}
#[must_use]
pub fn discover_named(start: &Path, names: &[&str]) -> Option<PathBuf> {
for dir in start.ancestors() {
for name in names {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
pub fn resolve(start: &Path) -> Result<Loaded, Error> {
type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
if let Some(path) = discover(start) {
return load(&path);
}
let mut loaded = Loaded::default();
let readers: [(&[&str], Reader); 2] = [
(GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
(GDLINT_FILE_NAMES, compat::apply_gdlintrc),
];
for (names, apply) in readers {
let Some(path) = discover_named(start, names) else {
continue;
};
let text = read_to_string(&path)?;
let problems = apply(&text, &mut loaded.config).map_err(|problem| at(&path, problem))?;
loaded.notes.extend(notes_at(&path, problems));
loaded.files.push(path);
}
Ok(loaded)
}
pub fn load(path: &Path) -> Result<Loaded, Error> {
let text = read_to_string(path)?;
let name = path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
let mut loaded = Loaded {
files: vec![path.to_path_buf()],
..Loaded::default()
};
let problems = if GDLINT_FILE_NAMES.contains(&name.as_str()) {
compat::apply_gdlintrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
} else if GDFORMAT_FILE_NAMES.contains(&name.as_str()) {
compat::apply_gdformatrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
} else {
loaded.config = schema::read(&text).map_err(|problem| at(path, problem))?;
Vec::new()
};
loaded.notes = notes_at(path, problems);
Ok(loaded)
}
fn at(path: &Path, problem: Problem) -> Error {
Error {
path: path.to_path_buf(),
line: Some(problem.line),
message: problem.message,
}
}
fn notes_at(path: &Path, problems: Vec<Problem>) -> Vec<Note> {
problems
.into_iter()
.map(|problem| Note {
path: path.to_path_buf(),
line: problem.line,
message: problem.message,
})
.collect()
}
fn read_to_string(path: &Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|error| Error {
path: path.to_path_buf(),
line: None,
message: error.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_style_guide() {
let config = Config::default();
assert_eq!(config.format.line_length, 100);
assert_eq!(config.format.indent, IndentStyle::Tabs);
assert!(config.format.safety_checks);
assert_eq!(config.lint.code_order, CodeOrderFix::ReportOnly);
}
#[test]
fn excluded_dirs_fall_back_to_defaults() {
let config = Config::default();
assert!(config.is_excluded_dir(".git"));
assert!(config.is_excluded_dir(".godot"));
assert!(!config.is_excluded_dir("src"));
}
#[test]
fn explicit_excluded_dirs_replace_the_defaults() {
let config = Config {
excluded_dirs: vec!["vendor".to_string()],
..Config::default()
};
assert!(config.is_excluded_dir("vendor"));
assert!(!config.is_excluded_dir(".git"));
}
#[test]
fn discover_returns_none_when_nothing_is_configured() {
let _ = discover(Path::new("/"));
}
#[test]
fn an_error_reads_as_a_place_and_a_reason() {
let error = Error {
path: PathBuf::from("gdck.toml"),
line: Some(4),
message: "unknown setting `foo`".to_string(),
};
assert_eq!(error.to_string(), "gdck.toml:4: unknown setting `foo`");
let error = Error {
line: None,
..error
};
assert_eq!(error.to_string(), "gdck.toml: unknown setting `foo`");
}
}