Skip to main content

gdck_config/
lib.rs

1//! Configuration for `gdck`.
2//!
3//! Defaults come from the [GDScript style guide][guide], so a project with no
4//! configuration file at all gets style-guide behaviour.
5//!
6//! [guide]: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html
7//!
8//! # Reading a project's settings
9//!
10//! [`resolve`] walks up from a directory and returns the settings in force
11//! there, along with the files they came from:
12//!
13//! ```no_run
14//! let loaded = gdck_config::resolve(std::path::Path::new("."))?;
15//! println!("{} columns", loaded.config.format.line_length);
16//! # Ok::<(), gdck_config::Error>(())
17//! ```
18//!
19//! The nearest `gdck.toml` wins outright. Failing that, `gdtoolkit`'s own
20//! `gdformatrc` and `gdlintrc` are read, so a project already using `gdformat`
21//! and `gdlint` keeps its line length and its disabled rules without writing
22//! anything new. See `docs/CONFIG.md` for the schema and the precedence.
23//!
24//! # Two file formats, two approaches
25//!
26//! `gdck.toml` is read by the [`toml`] crate, and validated here for the
27//! things a deserialiser cannot know — ranges, and settings that only mean
28//! something alongside another.
29//!
30//! The `gdtoolkit` files are read by [`yaml_serde`] into an untyped mapping
31//! rather than into a struct, because a `gdlintrc` holds dozens of settings
32//! `gdck` has no equivalent for and each should be skipped with a note rather
33//! than failing the file. A file that cannot be parsed at all *is* an error:
34//! none of its settings would apply, and a project formatted by rules it had
35//! written down and rejected is the outcome worse than not running.
36
37mod compat;
38mod schema;
39
40use std::fmt;
41use std::path::{Path, PathBuf};
42
43/// Config file names searched for, in priority order, walking up from the
44/// working directory.
45pub const CONFIG_FILE_NAMES: &[&str] = &["gdck.toml", ".gdck.toml"];
46
47/// `gdformat`'s configuration file, read for compatibility.
48pub const GDFORMAT_FILE_NAMES: &[&str] = &["gdformatrc", ".gdformatrc"];
49
50/// `gdlint`'s configuration file, read for compatibility.
51pub const GDLINT_FILE_NAMES: &[&str] = &["gdlintrc", ".gdlintrc"];
52
53/// Directories skipped when collecting `.gd` files.
54///
55/// `.godot` holds the editor's generated import cache and `addons` is usually
56/// third-party code a project does not want reformatted.
57pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[".git", ".godot", ".import", "addons"];
58
59/// Indentation style. The style guide mandates tabs; spaces are available
60/// because some existing projects have already committed to them.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum IndentStyle {
63    #[default]
64    Tabs,
65    Spaces(u8),
66}
67
68/// Formatting options.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct FormatConfig {
71    /// Hard wrap width. The style guide says keep lines under 100 characters.
72    pub line_length: u16,
73    pub indent: IndentStyle,
74    /// Re-run the formatter on its own output and reject the result if it is
75    /// not stable, and check that no comments were dropped. Cheap insurance
76    /// against a formatter bug silently eating code.
77    pub safety_checks: bool,
78}
79
80impl Default for FormatConfig {
81    fn default() -> Self {
82        Self {
83            line_length: 100,
84            indent: IndentStyle::Tabs,
85            safety_checks: true,
86        }
87    }
88}
89
90/// How aggressively `gdck fix` may reorder class members.
91///
92/// Reordering is not purely cosmetic: class-level initialisers run in
93/// declaration order, so moving a public variable above a private one it reads
94/// changes behaviour. See `docs/DESIGN.md` for the full analysis.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum CodeOrderFix {
97    /// Report violations; only reorder when `--fix-order` is passed.
98    #[default]
99    ReportOnly,
100    /// Reorder a file when every required move is provably safe, and leave the
101    /// file completely untouched otherwise.
102    WholeFileWhenSafe,
103    /// Never reorder, and do not report ordering problems either.
104    Off,
105}
106
107/// Lint options.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct LintConfig {
110    pub max_line_length: u16,
111    pub max_file_lines: u32,
112    pub max_public_methods: u32,
113    pub max_returns: u32,
114    pub max_function_arguments: u32,
115    pub code_order: CodeOrderFix,
116    /// Rule names switched off for this project.
117    pub disabled: Vec<String>,
118}
119
120impl Default for LintConfig {
121    fn default() -> Self {
122        Self {
123            max_line_length: 100,
124            max_file_lines: 1000,
125            max_public_methods: 20,
126            max_returns: 6,
127            max_function_arguments: 10,
128            code_order: CodeOrderFix::default(),
129            disabled: Vec::new(),
130        }
131    }
132}
133
134/// Naming patterns from the style guide's conventions table.
135///
136/// Written out as source strings because that is the least ambiguous way to
137/// state a convention, and because it is the form a project would use to
138/// override one. The linter does not compile them: `is_snake_case` and its
139/// two siblings are a dozen lines each and read better than the equivalent
140/// pattern, so these stand as documentation of what those functions accept.
141/// See [`crate::naming`] and `gdck_lint::names`.
142pub mod naming {
143    pub const PASCAL_CASE: &str = r"([A-Z][a-z0-9]*)+";
144    pub const SNAKE_CASE: &str = r"[a-z][a-z0-9]*(_[a-z0-9]+)*";
145    pub const PRIVATE_SNAKE_CASE: &str = r"_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
146    pub const CONSTANT_CASE: &str = r"[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
147    pub const PRIVATE_CONSTANT_CASE: &str = r"_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
148}
149
150/// The full configuration for a run.
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
152pub struct Config {
153    pub format: FormatConfig,
154    pub lint: LintConfig,
155    pub excluded_dirs: Vec<String>,
156}
157
158impl Config {
159    /// Whether a directory name should be skipped when collecting files.
160    #[must_use]
161    pub fn is_excluded_dir(&self, name: &str) -> bool {
162        if self.excluded_dirs.is_empty() {
163            return DEFAULT_EXCLUDED_DIRS.contains(&name);
164        }
165        self.excluded_dirs.iter().any(|dir| dir == name)
166    }
167
168    /// Write these settings out as a `gdck.toml`.
169    ///
170    /// Every setting is written, including the ones left at their default,
171    /// because the question this answers is what a run is actually using.
172    #[must_use]
173    pub fn to_toml(&self) -> String {
174        schema::to_toml(self)
175    }
176}
177
178// -- errors and notes -------------------------------------------------------
179
180/// Something wrong at a line of a file, before the file is known.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub(crate) struct Problem {
183    pub(crate) line: u32,
184    pub(crate) message: String,
185}
186
187/// A configuration file that could not be used.
188///
189/// A broken configuration file is always fatal rather than a fall back to the
190/// defaults. Settings that quietly do not apply are worse than a run that
191/// stops and says why.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Error {
194    pub path: PathBuf,
195    /// The line at fault, when the file was read but not understood.
196    pub line: Option<u32>,
197    pub message: String,
198}
199
200impl fmt::Display for Error {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self.line {
203            Some(line) => write!(f, "{}:{line}: {}", self.path.display(), self.message),
204            None => write!(f, "{}: {}", self.path.display(), self.message),
205        }
206    }
207}
208
209impl std::error::Error for Error {}
210
211/// Something a configuration file asked for that `gdck` cannot honour.
212///
213/// Only the `gdtoolkit` files produce these. `gdck.toml` refuses what it
214/// cannot do, but a foreign file is allowed to hold settings that mean nothing
215/// here — and saying so is the difference between a setting that does not
216/// apply and one that silently does not apply.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Note {
219    pub path: PathBuf,
220    pub line: u32,
221    pub message: String,
222}
223
224impl fmt::Display for Note {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
227    }
228}
229
230/// Settings, and where they came from.
231#[derive(Debug, Clone, PartialEq, Eq, Default)]
232pub struct Loaded {
233    pub config: Config,
234    /// The files read, in the order they were applied. Empty when nothing was
235    /// found and the defaults are in force.
236    pub files: Vec<PathBuf>,
237    pub notes: Vec<Note>,
238}
239
240// -- discovery and loading --------------------------------------------------
241
242/// Find the nearest `gdck.toml`, searching `start` and then each ancestor.
243///
244/// Returns `None` when the search reaches the filesystem root without a match,
245/// which is the normal case for a project that has not configured anything.
246#[must_use]
247pub fn discover(start: &Path) -> Option<PathBuf> {
248    discover_named(start, CONFIG_FILE_NAMES)
249}
250
251/// Find the nearest file with one of `names`, searching `start` and then each
252/// ancestor.
253#[must_use]
254pub fn discover_named(start: &Path, names: &[&str]) -> Option<PathBuf> {
255    for dir in start.ancestors() {
256        for name in names {
257            let candidate = dir.join(name);
258            if candidate.is_file() {
259                return Some(candidate);
260            }
261        }
262    }
263    None
264}
265
266/// The settings in force in a directory.
267///
268/// The nearest `gdck.toml` wins outright: a project that has written one has
269/// said what it wants, and quietly mixing in a `gdlintrc` from three
270/// directories further up would make the result impossible to predict. Only
271/// when there is no `gdck.toml` are `gdformatrc` and `gdlintrc` read, so a
272/// project already set up for `gdtoolkit` keeps its settings.
273pub fn resolve(start: &Path) -> Result<Loaded, Error> {
274    /// One of the `gdtoolkit` readers: settings in, notes out.
275    type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
276
277    if let Some(path) = discover(start) {
278        return load(&path);
279    }
280
281    let mut loaded = Loaded::default();
282    // Formatting first, so a `gdlintrc` naming its own line length has the
283    // last word on what the linter reports.
284    let readers: [(&[&str], Reader); 2] = [
285        (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
286        (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
287    ];
288    for (names, apply) in readers {
289        let Some(path) = discover_named(start, names) else {
290            continue;
291        };
292        let text = read_to_string(&path)?;
293        let problems = apply(&text, &mut loaded.config).map_err(|problem| at(&path, problem))?;
294        loaded.notes.extend(notes_at(&path, problems));
295        loaded.files.push(path);
296    }
297    Ok(loaded)
298}
299
300/// Read one configuration file, whatever kind it is.
301///
302/// The kind is decided by the file's name, so `--config` can be pointed at a
303/// `gdlintrc` as readily as at a `gdck.toml`. A name matching none of them is
304/// read as a `gdck.toml`.
305pub fn load(path: &Path) -> Result<Loaded, Error> {
306    let text = read_to_string(path)?;
307    let name = path
308        .file_name()
309        .map_or_else(String::new, |name| name.to_string_lossy().into_owned());
310
311    let mut loaded = Loaded {
312        files: vec![path.to_path_buf()],
313        ..Loaded::default()
314    };
315    let problems = if GDLINT_FILE_NAMES.contains(&name.as_str()) {
316        compat::apply_gdlintrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
317    } else if GDFORMAT_FILE_NAMES.contains(&name.as_str()) {
318        compat::apply_gdformatrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
319    } else {
320        loaded.config = schema::read(&text).map_err(|problem| at(path, problem))?;
321        Vec::new()
322    };
323    loaded.notes = notes_at(path, problems);
324    Ok(loaded)
325}
326
327/// Pin a problem to the file it was found in.
328fn at(path: &Path, problem: Problem) -> Error {
329    Error {
330        path: path.to_path_buf(),
331        line: Some(problem.line),
332        message: problem.message,
333    }
334}
335
336fn notes_at(path: &Path, problems: Vec<Problem>) -> Vec<Note> {
337    problems
338        .into_iter()
339        .map(|problem| Note {
340            path: path.to_path_buf(),
341            line: problem.line,
342            message: problem.message,
343        })
344        .collect()
345}
346
347fn read_to_string(path: &Path) -> Result<String, Error> {
348    std::fs::read_to_string(path).map_err(|error| Error {
349        path: path.to_path_buf(),
350        line: None,
351        message: error.to_string(),
352    })
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn defaults_match_the_style_guide() {
361        let config = Config::default();
362        assert_eq!(config.format.line_length, 100);
363        assert_eq!(config.format.indent, IndentStyle::Tabs);
364        assert!(config.format.safety_checks);
365        // Reordering must be opt-in until the dependency analysis is proven.
366        assert_eq!(config.lint.code_order, CodeOrderFix::ReportOnly);
367    }
368
369    #[test]
370    fn excluded_dirs_fall_back_to_defaults() {
371        let config = Config::default();
372        assert!(config.is_excluded_dir(".git"));
373        assert!(config.is_excluded_dir(".godot"));
374        assert!(!config.is_excluded_dir("src"));
375    }
376
377    #[test]
378    fn explicit_excluded_dirs_replace_the_defaults() {
379        let config = Config {
380            excluded_dirs: vec!["vendor".to_string()],
381            ..Config::default()
382        };
383        assert!(config.is_excluded_dir("vendor"));
384        assert!(!config.is_excluded_dir(".git"));
385    }
386
387    #[test]
388    fn discover_returns_none_when_nothing_is_configured() {
389        // A directory that certainly holds no gdck.toml above it is hard to
390        // guarantee, so just check the call is total and does not panic.
391        let _ = discover(Path::new("/"));
392    }
393
394    #[test]
395    fn an_error_reads_as_a_place_and_a_reason() {
396        let error = Error {
397            path: PathBuf::from("gdck.toml"),
398            line: Some(4),
399            message: "unknown setting `foo`".to_string(),
400        };
401        assert_eq!(error.to_string(), "gdck.toml:4: unknown setting `foo`");
402        let error = Error {
403            line: None,
404            ..error
405        };
406        assert_eq!(error.to_string(), "gdck.toml: unknown setting `foo`");
407    }
408}