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}