use yaml_serde::Value;
use crate::{Config, DeclarationGroup, IndentStyle, Problem};
pub(crate) fn apply_gdlintrc(text: &str, config: &mut Config) -> Result<Vec<Problem>, Problem> {
let mut notes = Vec::new();
for item in read(text)? {
let note = match item.key.as_str() {
"disable" => match item.strings() {
Some(rules) => {
config.lint.disabled = rules;
None
}
None => Some(wanted(&item, "a list of rule names")),
},
"max-line-length" => set_u16(&item, &mut config.lint.max_line_length),
"max-file-lines" => set_u32(&item, &mut config.lint.max_file_lines),
"max-public-methods" => set_u32(&item, &mut config.lint.max_public_methods),
"max-returns" => set_u32(&item, &mut config.lint.max_returns),
"function-arguments-number" => set_u32(&item, &mut config.lint.max_function_arguments),
"excluded_directories" => match item.strings() {
Some(dirs) => {
config.excluded_dirs = dirs;
None
}
None => Some(wanted(&item, "a list of directory names")),
},
"class-definitions-order" => match item.strings() {
Some(order) if order == DEFAULT_DEFINITIONS_ORDER => None,
Some(order) => {
let groups: Option<Vec<_>> = order
.iter()
.map(|name| DeclarationGroup::from_name(name))
.collect();
if let Some(groups) = groups {
config.lint.declaration_order = Some(groups);
None
} else {
let unknown: Vec<&str> = order
.iter()
.filter(|name| DeclarationGroup::from_name(name).is_none())
.map(String::as_str)
.collect();
Some(note(
&item,
format!(
"`class-definitions-order` names `{}`, which gdck has no \
group for; the style guide's order is used instead",
unknown.join("`, `")
),
))
}
}
None => Some(wanted(&item, "a list of declaration group names")),
},
"tab-characters" => (item.integer() != Some(1)).then(|| {
note(
&item,
"gdck indents one tab per level; `tab-characters` is not applied",
)
}),
key => match naming_default(key) {
Some(default) => item.text().filter(|text| *text != default).map(|_| {
note(
&item,
format!(
"gdck checks `{key}` against the style guide's convention \
rather than a pattern; this one is not applied"
),
)
}),
None => unrecognised(&item),
},
};
notes.extend(note);
}
Ok(notes)
}
pub(crate) fn apply_gdformatrc(text: &str, config: &mut Config) -> Result<Vec<Problem>, Problem> {
let mut notes = Vec::new();
for item in read(text)? {
let note = match item.key.as_str() {
"line_length" => {
let note = set_u16(&item, &mut config.format.line_length);
config.lint.max_line_length = config.format.line_length;
note
}
"use_spaces" => match (item.integer(), item.is_null()) {
(Some(width @ 1..=16), _) => {
let width = u8::try_from(width).expect("the range was just checked");
config.format.indent = IndentStyle::Spaces(width);
None
}
(None, true) => None,
_ => Some(wanted(&item, "a number of spaces between 1 and 16")),
},
"safety_checks" => match (item.boolean(), item.is_null()) {
(Some(value), _) => {
config.format.safety_checks = value;
None
}
(None, true) => None,
_ => Some(wanted(&item, "true or false")),
},
"excluded_directories" => match item.strings() {
Some(dirs) => {
config.excluded_dirs = dirs;
None
}
None => Some(wanted(&item, "a list of directory names")),
},
_ => unrecognised(&item),
};
notes.extend(note);
}
Ok(notes)
}
fn set_u16(item: &Item, field: &mut u16) -> Option<Problem> {
match item.integer().and_then(|value| u16::try_from(value).ok()) {
Some(value) if value > 0 => {
*field = value;
None
}
_ => Some(wanted(item, "a positive number")),
}
}
fn set_u32(item: &Item, field: &mut u32) -> Option<Problem> {
match item.integer().and_then(|value| u32::try_from(value).ok()) {
Some(value) => {
*field = value;
None
}
None => Some(wanted(item, "a positive number")),
}
}
fn note(item: &Item, message: impl Into<String>) -> Problem {
Problem {
line: item.line,
message: message.into(),
}
}
fn wanted(item: &Item, wanted: &str) -> Problem {
note(
item,
format!("`{}` should be {wanted}; it is ignored", item.key),
)
}
fn unrecognised(item: &Item) -> Option<Problem> {
(!item.is_null()).then(|| {
note(
item,
format!("gdck has no setting matching `{}`; it is ignored", item.key),
)
})
}
const PASCAL_CASE: &str = "([A-Z][a-z0-9]*)+";
const SNAKE_CASE: &str = "[a-z][a-z0-9]*(_[a-z0-9]+)*";
const PRIVATE_SNAKE_CASE: &str = "_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
const UPPER_SNAKE_CASE: &str = "[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
const PRIVATE_UPPER_SNAKE_CASE: &str = "_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
fn naming_default(key: &str) -> Option<String> {
let pattern = match key {
"function-name" => {
return Some(format!(
"(_on_{PASCAL_CASE}(_[a-z0-9]+)*|{PRIVATE_SNAKE_CASE})"
));
}
"class-name" | "enum-name" | "function-preload-variable-name" => PASCAL_CASE,
"sub-class-name" => return Some(format!("_?{PASCAL_CASE}")),
"class-load-variable-name" => {
return Some(format!("({PASCAL_CASE}|{PRIVATE_SNAKE_CASE})"));
}
"load-constant-name" => {
return Some(format!("({PASCAL_CASE}|{PRIVATE_UPPER_SNAKE_CASE})"));
}
"signal-name" | "function-variable-name" => SNAKE_CASE,
"class-variable-name" | "function-argument-name" | "loop-variable-name" => {
PRIVATE_SNAKE_CASE
}
"enum-element-name" => UPPER_SNAKE_CASE,
"constant-name" => PRIVATE_UPPER_SNAKE_CASE,
_ => return None,
};
Some(pattern.to_string())
}
const DEFAULT_DEFINITIONS_ORDER: &[&str] = &[
"tools",
"classnames",
"extends",
"docstrings",
"signals",
"enums",
"consts",
"staticvars",
"exports",
"pubvars",
"prvvars",
"onreadypubvars",
"onreadyprvvars",
"others",
];
#[derive(Debug, Clone)]
struct Item {
key: String,
value: Value,
line: u32,
}
impl Item {
fn is_null(&self) -> bool {
self.value.is_null()
}
fn integer(&self) -> Option<i64> {
self.value.as_i64()
}
fn boolean(&self) -> Option<bool> {
self.value.as_bool()
}
fn text(&self) -> Option<&str> {
self.value.as_str()
}
fn strings(&self) -> Option<Vec<String>> {
match &self.value {
Value::Sequence(items) => items
.iter()
.map(|item| item.as_str().map(str::to_string))
.collect(),
Value::Mapping(members) => members
.iter()
.map(|(key, value)| {
value
.is_null()
.then(|| key.as_str().map(str::to_string))
.flatten()
})
.collect(),
_ => None,
}
}
}
fn read(text: &str) -> Result<Vec<Item>, Problem> {
let document: Value = yaml_serde::from_str(text).map_err(|error| Problem {
line: error.location().map_or(1, |at| at.line() as u32),
message: error.to_string(),
})?;
let mapping = match document {
Value::Null => return Ok(Vec::new()),
Value::Mapping(mapping) => mapping,
other => {
return Err(Problem {
line: 1,
message: format!("expected a mapping of settings, found {}", describe(&other)),
});
}
};
Ok(mapping
.into_iter()
.filter_map(|(key, value)| {
let key = key.as_str()?.to_string();
let line = line_of(text, &key);
Some(Item { key, value, line })
})
.collect())
}
fn describe(value: &Value) -> &'static str {
match value {
Value::Null => "nothing",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Sequence(_) => "a list",
Value::Mapping(_) => "a mapping",
Value::Tagged(_) => "a tagged value",
}
}
fn line_of(text: &str, key: &str) -> u32 {
text.lines()
.position(|line| opens_with_key(line, key))
.map_or(1, |index| index as u32 + 1)
}
fn opens_with_key(line: &str, key: &str) -> bool {
if line.starts_with([' ', '\t']) {
return false;
}
let rest = line
.strip_prefix(key)
.or_else(|| line.strip_prefix(&format!("\"{key}\"")))
.or_else(|| line.strip_prefix(&format!("'{key}'")))
.map(str::trim_start);
rest.is_some_and(|rest| rest.starts_with(':'))
}
#[cfg(test)]
mod tests {
use super::*;
fn written_order() -> String {
use std::fmt::Write;
DEFAULT_DEFINITIONS_ORDER
.iter()
.fold(String::new(), |mut out, item| {
let _ = writeln!(out, " - {item}");
out
})
}
fn lint(text: &str) -> (Config, Vec<String>) {
let mut config = Config::default();
let notes = apply_gdlintrc(text, &mut config).expect("should read");
(config, notes.into_iter().map(|note| note.message).collect())
}
fn format(text: &str) -> (Config, Vec<String>) {
let mut config = Config::default();
let notes = apply_gdformatrc(text, &mut config).expect("should read");
(config, notes.into_iter().map(|note| note.message).collect())
}
fn refused(text: &str) -> Problem {
apply_gdlintrc(text, &mut Config::default()).expect_err("should not read")
}
#[test]
fn the_thresholds_a_gdlintrc_sets_are_applied() {
let (config, notes) = lint(
"max-line-length: 120\n\
max-file-lines: 500\n\
max-public-methods: 12\n\
max-returns: 3\n\
function-arguments-number: 5\n",
);
assert_eq!(config.lint.max_line_length, 120);
assert_eq!(config.lint.max_file_lines, 500);
assert_eq!(config.lint.max_public_methods, 12);
assert_eq!(config.lint.max_returns, 3);
assert_eq!(config.lint.max_function_arguments, 5);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_disable_list_carries_over_in_gdtoolkits_own_names() {
let (config, notes) = lint("disable:\n - max-public-methods\n - class-variable-name\n");
assert_eq!(
config.lint.disabled,
["max-public-methods", "class-variable-name"]
);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_disable_list_can_be_written_inline() {
let (config, _) = lint("disable: [max-returns, unused-argument]\n");
assert_eq!(config.lint.disabled, ["max-returns", "unused-argument"]);
let (config, _) = lint("disable: []\n");
assert!(config.lint.disabled.is_empty());
}
#[test]
fn the_set_that_yaml_dump_writes_is_read_as_a_list() {
let (config, notes) = lint("excluded_directories: !!set\n .git: null\n addons: null\n");
assert_eq!(config.excluded_dirs, [".git", "addons"]);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_gdformatrc_sets_the_width_and_the_indent() {
let (config, notes) = format("line_length: 120\nuse_spaces: 4\nsafety_checks: false\n");
assert_eq!(config.format.line_length, 120);
assert_eq!(config.lint.max_line_length, 120);
assert_eq!(config.format.indent, IndentStyle::Spaces(4));
assert!(!config.format.safety_checks);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn the_nulls_gdtoolkit_writes_for_a_default_are_left_alone() {
let (config, notes) = format("use_spaces: null\nsafety_checks: null\nline_length: 100\n");
assert_eq!(config, Config::default());
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_rule_switched_on_by_naming_it_needs_no_comment() {
let (_, notes) = lint("duplicated-load: null\nunnecessary-pass: null\n");
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_default_naming_pattern_passes_without_comment() {
let (_, notes) = lint(&format!(
"class-name: {PASCAL_CASE}\nsignal-name: '{SNAKE_CASE}'\n"
));
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_customised_naming_pattern_is_reported_as_not_applied() {
let (_, notes) = lint("function-name: '_on_.*'\n");
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("function-name"), "{notes:?}");
assert!(notes[0].contains("not applied"), "{notes:?}");
}
#[test]
fn a_setting_with_no_equivalent_is_reported_rather_than_dropped() {
let (_, notes) = lint("max-locals: 15\n");
assert_eq!(notes.len(), 1);
assert!(
notes[0].contains("no setting matching `max-locals`"),
"{notes:?}"
);
}
#[test]
fn a_reordered_definitions_order_is_kept() {
let (config, notes) = lint("class-definitions-order:\n - enums\n - signals\n");
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(
config.lint.declaration_order,
Some(vec![DeclarationGroup::Enums, DeclarationGroup::Signals])
);
let default = written_order();
let (config, notes) = lint(&format!("class-definitions-order:\n{default}"));
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(config.lint.declaration_order, None);
}
#[test]
fn an_unknown_declaration_group_is_reported_and_not_guessed_at() {
let (config, notes) = lint("class-definitions-order:\n - enums\n - widgets\n");
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("widgets"), "{notes:?}");
assert_eq!(config.lint.declaration_order, None);
}
#[test]
fn comments_and_blank_lines_are_ignored() {
let (config, notes) = lint("# a note\n\nmax-returns: 3 # inline\n");
assert_eq!(config.lint.max_returns, 3);
assert!(notes.is_empty(), "{notes:?}");
}
#[test]
fn a_file_that_is_not_a_mapping_is_refused() {
assert!(
refused("this is not a mapping\n")
.message
.contains("expected a mapping")
);
assert!(refused("- a\n- b\n").message.contains("expected a mapping"));
}
#[test]
fn a_file_that_is_not_yaml_is_refused_at_the_line_it_broke_on() {
let problem = refused("max-returns: 3\ndisable: [unclosed\n");
assert_eq!(problem.line, 3);
assert!(!problem.message.is_empty());
}
#[test]
fn an_empty_file_is_read_as_saying_nothing() {
let (config, notes) = lint("");
assert_eq!(config, Config::default());
assert!(notes.is_empty());
assert!(lint("# only a comment\n").1.is_empty());
}
#[test]
fn a_value_of_the_wrong_shape_is_reported() {
let (config, notes) = lint("max-returns: lots\n");
assert_eq!(config.lint.max_returns, 6, "the default should survive");
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("positive number"), "{notes:?}");
}
#[test]
fn the_note_carries_the_line_it_is_about() {
let mut config = Config::default();
let notes =
apply_gdlintrc("max-returns: 3\n\nmax-locals: 15\n", &mut config).expect("should read");
assert_eq!(notes[0].line, 3);
}
}