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, Copy, PartialEq, Eq, Default)]
pub enum ClassDeclaration {
#[default]
MultiLine,
SingleLine,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FormatConfig {
pub line_length: u16,
pub indent: IndentStyle,
pub class_declaration: ClassDeclaration,
pub safety_checks: bool,
}
impl Default for FormatConfig {
fn default() -> Self {
Self {
line_length: 100,
indent: IndentStyle::Tabs,
class_declaration: ClassDeclaration::MultiLine,
safety_checks: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CodeOrderFix {
#[default]
ReportOnly,
WholeFileWhenSafe,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeclarationGroup {
Tools,
ClassNames,
Extends,
Docstrings,
Signals,
Enums,
Consts,
StaticVars,
Exports,
PubVars,
PrvVars,
OnreadyPubVars,
OnreadyPrvVars,
Others,
}
impl DeclarationGroup {
pub const GUIDE_ORDER: [Self; 14] = [
Self::Tools,
Self::ClassNames,
Self::Extends,
Self::Docstrings,
Self::Signals,
Self::Enums,
Self::Consts,
Self::StaticVars,
Self::Exports,
Self::PubVars,
Self::PrvVars,
Self::OnreadyPubVars,
Self::OnreadyPrvVars,
Self::Others,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Tools => "tools",
Self::ClassNames => "classnames",
Self::Extends => "extends",
Self::Docstrings => "docstrings",
Self::Enums => "enums",
Self::Signals => "signals",
Self::Consts => "consts",
Self::Exports => "exports",
Self::PubVars => "pubvars",
Self::PrvVars => "prvvars",
Self::OnreadyPubVars => "onreadypubvars",
Self::OnreadyPrvVars => "onreadyprvvars",
Self::StaticVars => "staticvars",
Self::Others => "others",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
[
Self::Tools,
Self::ClassNames,
Self::Extends,
Self::Docstrings,
Self::Signals,
Self::Enums,
Self::Consts,
Self::Exports,
Self::PubVars,
Self::PrvVars,
Self::OnreadyPubVars,
Self::OnreadyPrvVars,
Self::StaticVars,
Self::Others,
]
.into_iter()
.find(|group| group.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileNameCase {
#[default]
SnakeCase,
PascalCase,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
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 file_name: FileNameCase,
pub declaration_order: Option<Vec<DeclarationGroup>>,
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(),
file_name: FileNameCase::default(),
declaration_order: None,
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)]
#[non_exhaustive]
pub struct Config {
pub format: FormatConfig,
pub lint: LintConfig,
pub excluded_dirs: Vec<String>,
pub respect_gitignore: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
format: FormatConfig::default(),
lint: LintConfig::default(),
excluded_dirs: Vec::new(),
respect_gitignore: true,
}
}
}
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)
}
#[must_use]
pub fn to_starter_toml(&self) -> String {
schema::to_starter_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>,
}
type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
#[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> {
if let Some(path) = discover(start) {
let mut loaded = load(&path)?;
loaded.notes.extend(shadowed_notes(start, &loaded.config));
return Ok(loaded);
}
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)
}
fn shadowed_notes(start: &Path, active: &Config) -> Vec<Note> {
let mut notes = Vec::new();
let readers: [(&[&str], Reader); 2] = [
(GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
(GDLINT_FILE_NAMES, compat::apply_gdlintrc),
];
let mut would_be = Config::default();
let mut found = Vec::new();
for (names, apply) in readers {
let Some(path) = discover_named(start, names) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if apply(&text, &mut would_be).is_err() {
continue;
}
found.push(path);
}
if found.is_empty() {
return notes;
}
let changed = differences(active, &would_be);
if changed.is_empty() {
return notes;
}
for path in found {
notes.push(Note {
path,
line: 1,
message: format!(
"not applied, because the gdck.toml takes precedence. It disagrees \
about {}. Copy those into the gdck.toml, or delete this file",
changed.join(", ")
),
});
}
notes
}
fn differences(active: &Config, other: &Config) -> Vec<&'static str> {
let mut changed = Vec::new();
if active.format.line_length != other.format.line_length {
changed.push("format.line-length");
}
if active.format.indent != other.format.indent {
changed.push("format.indent");
}
if active.format.safety_checks != other.format.safety_checks {
changed.push("format.safety-checks");
}
if active.lint.max_line_length != other.lint.max_line_length {
changed.push("lint.max-line-length");
}
if active.lint.max_file_lines != other.lint.max_file_lines {
changed.push("lint.max-file-lines");
}
if active.lint.max_public_methods != other.lint.max_public_methods {
changed.push("lint.max-public-methods");
}
if active.lint.max_returns != other.lint.max_returns {
changed.push("lint.max-returns");
}
if active.lint.max_function_arguments != other.lint.max_function_arguments {
changed.push("lint.max-arguments");
}
if active.lint.declaration_order != other.lint.declaration_order {
changed.push("lint.declaration-order");
}
if active.lint.disabled != other.lint.disabled {
changed.push("lint.disable");
}
if active.excluded_dirs != other.excluded_dirs {
changed.push("files.exclude");
}
changed
}
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`");
}
}