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/// The groups a project may put in an order of its own.
108///
109/// This is `gdtoolkit`'s vocabulary rather than `gdck`'s, deliberately: it
110/// exists so a project that pinned `class-definitions-order` in a `gdlintrc`
111/// keeps the order it chose. `gdck` sorts more finely than these fourteen
112/// names can express — it knows `_ready()` from `_process()` from a static
113/// function, where `gdtoolkit` has only `others` — and that finer order is
114/// kept *within* whichever position `Others` is given.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum DeclarationGroup {
117    Tools,
118    ClassNames,
119    Extends,
120    Docstrings,
121    Signals,
122    Enums,
123    Consts,
124    Exports,
125    PubVars,
126    PrvVars,
127    OnreadyPubVars,
128    OnreadyPrvVars,
129    StaticVars,
130    Others,
131}
132
133impl DeclarationGroup {
134    /// The `gdtoolkit` spelling, which is also `gdck`'s.
135    #[must_use]
136    pub fn name(self) -> &'static str {
137        match self {
138            Self::Tools => "tools",
139            Self::ClassNames => "classnames",
140            Self::Extends => "extends",
141            Self::Docstrings => "docstrings",
142            Self::Enums => "enums",
143            Self::Signals => "signals",
144            Self::Consts => "consts",
145            Self::Exports => "exports",
146            Self::PubVars => "pubvars",
147            Self::PrvVars => "prvvars",
148            Self::OnreadyPubVars => "onreadypubvars",
149            Self::OnreadyPrvVars => "onreadyprvvars",
150            Self::StaticVars => "staticvars",
151            Self::Others => "others",
152        }
153    }
154
155    /// Read one from the name `gdtoolkit` uses for it.
156    #[must_use]
157    pub fn from_name(name: &str) -> Option<Self> {
158        [
159            Self::Tools,
160            Self::ClassNames,
161            Self::Extends,
162            Self::Docstrings,
163            Self::Signals,
164            Self::Enums,
165            Self::Consts,
166            Self::Exports,
167            Self::PubVars,
168            Self::PrvVars,
169            Self::OnreadyPubVars,
170            Self::OnreadyPrvVars,
171            Self::StaticVars,
172            Self::Others,
173        ]
174        .into_iter()
175        .find(|group| group.name() == name)
176    }
177}
178
179/// Which convention the `file-name` rule holds a file to.
180///
181/// The style guide says snake_case and that is the default. It is also the one
182/// naming rule where a project's own answer is worth honouring rather than
183/// suppressing: a file name is not an identifier the language sees, so a
184/// project that names files after the classes in them is following a
185/// convention rather than breaking one. Saying which is being followed keeps
186/// the rule working, where disabling it stops it noticing anything at all.
187///
188/// Both settings are a convention by name, not a pattern to match. The
189/// alternative — a regular expression, as `gdtoolkit` takes — makes every
190/// project's spelling of "snake_case" subtly its own. See `docs/DESIGN.md`.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
192pub enum FileNameCase {
193    /// `player_controller.gd`, what the guide asks for.
194    #[default]
195    SnakeCase,
196    /// `PlayerController.gd`, matching the class the file holds.
197    PascalCase,
198}
199
200/// Lint options.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct LintConfig {
203    pub max_line_length: u16,
204    pub max_file_lines: u32,
205    pub max_public_methods: u32,
206    pub max_returns: u32,
207    pub max_function_arguments: u32,
208    pub code_order: CodeOrderFix,
209    pub file_name: FileNameCase,
210    /// A declaration order of the project's own, if it stated one.
211    ///
212    /// `None` means the style guide's, which is what `gdck` sorts by and what
213    /// its own buckets are named after. A project that pinned
214    /// `class-definitions-order` in a `gdlintrc` gets that order instead.
215    pub declaration_order: Option<Vec<DeclarationGroup>>,
216    /// Rule names switched off for this project.
217    pub disabled: Vec<String>,
218}
219
220impl Default for LintConfig {
221    fn default() -> Self {
222        Self {
223            max_line_length: 100,
224            max_file_lines: 1000,
225            max_public_methods: 20,
226            max_returns: 6,
227            max_function_arguments: 10,
228            code_order: CodeOrderFix::default(),
229            file_name: FileNameCase::default(),
230            declaration_order: None,
231            disabled: Vec::new(),
232        }
233    }
234}
235
236/// Naming patterns from the style guide's conventions table.
237///
238/// Written out as source strings because that is the least ambiguous way to
239/// state a convention, and because it is the form a project would use to
240/// override one. The linter does not compile them: `is_snake_case` and its
241/// two siblings are a dozen lines each and read better than the equivalent
242/// pattern, so these stand as documentation of what those functions accept.
243/// See [`crate::naming`] and `gdck_lint::names`.
244pub mod naming {
245    pub const PASCAL_CASE: &str = r"([A-Z][a-z0-9]*)+";
246    pub const SNAKE_CASE: &str = r"[a-z][a-z0-9]*(_[a-z0-9]+)*";
247    pub const PRIVATE_SNAKE_CASE: &str = r"_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
248    pub const CONSTANT_CASE: &str = r"[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
249    pub const PRIVATE_CONSTANT_CASE: &str = r"_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
250}
251
252/// The full configuration for a run.
253#[derive(Debug, Clone, PartialEq, Eq, Default)]
254pub struct Config {
255    pub format: FormatConfig,
256    pub lint: LintConfig,
257    pub excluded_dirs: Vec<String>,
258}
259
260impl Config {
261    /// Whether a directory name should be skipped when collecting files.
262    #[must_use]
263    pub fn is_excluded_dir(&self, name: &str) -> bool {
264        if self.excluded_dirs.is_empty() {
265            return DEFAULT_EXCLUDED_DIRS.contains(&name);
266        }
267        self.excluded_dirs.iter().any(|dir| dir == name)
268    }
269
270    /// Write these settings out as a `gdck.toml`.
271    ///
272    /// Every setting is written, including the ones left at their default,
273    /// because the question this answers is what a run is actually using.
274    #[must_use]
275    pub fn to_toml(&self) -> String {
276        schema::to_toml(self)
277    }
278}
279
280// -- errors and notes -------------------------------------------------------
281
282/// Something wrong at a line of a file, before the file is known.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub(crate) struct Problem {
285    pub(crate) line: u32,
286    pub(crate) message: String,
287}
288
289/// A configuration file that could not be used.
290///
291/// A broken configuration file is always fatal rather than a fall back to the
292/// defaults. Settings that quietly do not apply are worse than a run that
293/// stops and says why.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Error {
296    pub path: PathBuf,
297    /// The line at fault, when the file was read but not understood.
298    pub line: Option<u32>,
299    pub message: String,
300}
301
302impl fmt::Display for Error {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self.line {
305            Some(line) => write!(f, "{}:{line}: {}", self.path.display(), self.message),
306            None => write!(f, "{}: {}", self.path.display(), self.message),
307        }
308    }
309}
310
311impl std::error::Error for Error {}
312
313/// Something a configuration file asked for that `gdck` cannot honour.
314///
315/// Only the `gdtoolkit` files produce these. `gdck.toml` refuses what it
316/// cannot do, but a foreign file is allowed to hold settings that mean nothing
317/// here — and saying so is the difference between a setting that does not
318/// apply and one that silently does not apply.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct Note {
321    pub path: PathBuf,
322    pub line: u32,
323    pub message: String,
324}
325
326impl fmt::Display for Note {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
329    }
330}
331
332/// Settings, and where they came from.
333#[derive(Debug, Clone, PartialEq, Eq, Default)]
334pub struct Loaded {
335    pub config: Config,
336    /// The files read, in the order they were applied. Empty when nothing was
337    /// found and the defaults are in force.
338    pub files: Vec<PathBuf>,
339    pub notes: Vec<Note>,
340}
341
342// -- discovery and loading --------------------------------------------------
343
344/// Find the nearest `gdck.toml`, searching `start` and then each ancestor.
345///
346/// Returns `None` when the search reaches the filesystem root without a match,
347/// which is the normal case for a project that has not configured anything.
348#[must_use]
349pub fn discover(start: &Path) -> Option<PathBuf> {
350    discover_named(start, CONFIG_FILE_NAMES)
351}
352
353/// Find the nearest file with one of `names`, searching `start` and then each
354/// ancestor.
355#[must_use]
356pub fn discover_named(start: &Path, names: &[&str]) -> Option<PathBuf> {
357    for dir in start.ancestors() {
358        for name in names {
359            let candidate = dir.join(name);
360            if candidate.is_file() {
361                return Some(candidate);
362            }
363        }
364    }
365    None
366}
367
368/// The settings in force in a directory.
369///
370/// The nearest `gdck.toml` wins outright: a project that has written one has
371/// said what it wants, and quietly mixing in a `gdlintrc` from three
372/// directories further up would make the result impossible to predict. Only
373/// when there is no `gdck.toml` are `gdformatrc` and `gdlintrc` read, so a
374/// project already set up for `gdtoolkit` keeps its settings.
375pub fn resolve(start: &Path) -> Result<Loaded, Error> {
376    /// One of the `gdtoolkit` readers: settings in, notes out.
377    type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
378
379    if let Some(path) = discover(start) {
380        return load(&path);
381    }
382
383    let mut loaded = Loaded::default();
384    // Formatting first, so a `gdlintrc` naming its own line length has the
385    // last word on what the linter reports.
386    let readers: [(&[&str], Reader); 2] = [
387        (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
388        (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
389    ];
390    for (names, apply) in readers {
391        let Some(path) = discover_named(start, names) else {
392            continue;
393        };
394        let text = read_to_string(&path)?;
395        let problems = apply(&text, &mut loaded.config).map_err(|problem| at(&path, problem))?;
396        loaded.notes.extend(notes_at(&path, problems));
397        loaded.files.push(path);
398    }
399    Ok(loaded)
400}
401
402/// Read one configuration file, whatever kind it is.
403///
404/// The kind is decided by the file's name, so `--config` can be pointed at a
405/// `gdlintrc` as readily as at a `gdck.toml`. A name matching none of them is
406/// read as a `gdck.toml`.
407pub fn load(path: &Path) -> Result<Loaded, Error> {
408    let text = read_to_string(path)?;
409    let name = path
410        .file_name()
411        .map_or_else(String::new, |name| name.to_string_lossy().into_owned());
412
413    let mut loaded = Loaded {
414        files: vec![path.to_path_buf()],
415        ..Loaded::default()
416    };
417    let problems = if GDLINT_FILE_NAMES.contains(&name.as_str()) {
418        compat::apply_gdlintrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
419    } else if GDFORMAT_FILE_NAMES.contains(&name.as_str()) {
420        compat::apply_gdformatrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
421    } else {
422        loaded.config = schema::read(&text).map_err(|problem| at(path, problem))?;
423        Vec::new()
424    };
425    loaded.notes = notes_at(path, problems);
426    Ok(loaded)
427}
428
429/// Pin a problem to the file it was found in.
430fn at(path: &Path, problem: Problem) -> Error {
431    Error {
432        path: path.to_path_buf(),
433        line: Some(problem.line),
434        message: problem.message,
435    }
436}
437
438fn notes_at(path: &Path, problems: Vec<Problem>) -> Vec<Note> {
439    problems
440        .into_iter()
441        .map(|problem| Note {
442            path: path.to_path_buf(),
443            line: problem.line,
444            message: problem.message,
445        })
446        .collect()
447}
448
449fn read_to_string(path: &Path) -> Result<String, Error> {
450    std::fs::read_to_string(path).map_err(|error| Error {
451        path: path.to_path_buf(),
452        line: None,
453        message: error.to_string(),
454    })
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn defaults_match_the_style_guide() {
463        let config = Config::default();
464        assert_eq!(config.format.line_length, 100);
465        assert_eq!(config.format.indent, IndentStyle::Tabs);
466        assert!(config.format.safety_checks);
467        // Reordering must be opt-in until the dependency analysis is proven.
468        assert_eq!(config.lint.code_order, CodeOrderFix::ReportOnly);
469    }
470
471    #[test]
472    fn excluded_dirs_fall_back_to_defaults() {
473        let config = Config::default();
474        assert!(config.is_excluded_dir(".git"));
475        assert!(config.is_excluded_dir(".godot"));
476        assert!(!config.is_excluded_dir("src"));
477    }
478
479    #[test]
480    fn explicit_excluded_dirs_replace_the_defaults() {
481        let config = Config {
482            excluded_dirs: vec!["vendor".to_string()],
483            ..Config::default()
484        };
485        assert!(config.is_excluded_dir("vendor"));
486        assert!(!config.is_excluded_dir(".git"));
487    }
488
489    #[test]
490    fn discover_returns_none_when_nothing_is_configured() {
491        // A directory that certainly holds no gdck.toml above it is hard to
492        // guarantee, so just check the call is total and does not panic.
493        let _ = discover(Path::new("/"));
494    }
495
496    #[test]
497    fn an_error_reads_as_a_place_and_a_reason() {
498        let error = Error {
499            path: PathBuf::from("gdck.toml"),
500            line: Some(4),
501            message: "unknown setting `foo`".to_string(),
502        };
503        assert_eq!(error.to_string(), "gdck.toml:4: unknown setting `foo`");
504        let error = Error {
505            line: None,
506            ..error
507        };
508        assert_eq!(error.to_string(), "gdck.toml: unknown setting `foo`");
509    }
510}