use serde::Deserialize;
use toml::Spanned;
use crate::{
ClassDeclaration, CodeOrderFix, Config, DeclarationGroup, FileNameCase, IndentStyle, Problem,
};
const DECLARATION_GROUPS: &[&str] = &[
"tools",
"classnames",
"extends",
"docstrings",
"signals",
"enums",
"consts",
"staticvars",
"exports",
"pubvars",
"prvvars",
"onreadypubvars",
"onreadyprvvars",
"others",
];
pub(crate) const KEYS: &[&str] = &[
"format.line-length",
"format.indent",
"format.indent-width",
"format.class-declaration",
"format.safety-checks",
"lint.max-line-length",
"lint.max-file-lines",
"lint.max-public-methods",
"lint.max-returns",
"lint.max-arguments",
"lint.code-order",
"lint.file-name",
"lint.declaration-order",
"lint.disable",
"files.exclude",
"files.respect-gitignore",
];
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct File {
#[serde(default)]
format: FormatTable,
#[serde(default)]
lint: LintTable,
#[serde(default)]
files: FilesTable,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct FormatTable {
#[serde(alias = "line_length")]
line_length: Option<Spanned<u16>>,
indent: Option<Spanned<Indent>>,
#[serde(alias = "indent_width")]
indent_width: Option<Spanned<u8>>,
#[serde(alias = "class_declaration")]
class_declaration: Option<ClassDecl>,
#[serde(alias = "safety_checks")]
safety_checks: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct LintTable {
#[serde(alias = "max_line_length")]
max_line_length: Option<Spanned<u16>>,
#[serde(alias = "max_file_lines")]
max_file_lines: Option<u32>,
#[serde(alias = "max_public_methods")]
max_public_methods: Option<u32>,
#[serde(alias = "max_returns")]
max_returns: Option<u32>,
#[serde(alias = "max_arguments")]
max_arguments: Option<u32>,
#[serde(alias = "code_order")]
code_order: Option<CodeOrder>,
#[serde(alias = "file_name")]
file_name: Option<FileName>,
#[serde(alias = "declaration_order")]
declaration_order: Option<Vec<Spanned<String>>>,
disable: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct FilesTable {
exclude: Option<Vec<String>>,
#[serde(alias = "respect_gitignore")]
respect_gitignore: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Indent {
Tabs,
Spaces,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CodeOrder {
Report,
FixWhenSafe,
Off,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ClassDecl {
MultiLine,
SingleLine,
}
impl From<ClassDecl> for ClassDeclaration {
fn from(shape: ClassDecl) -> Self {
match shape {
ClassDecl::MultiLine => Self::MultiLine,
ClassDecl::SingleLine => Self::SingleLine,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum FileName {
SnakeCase,
PascalCase,
}
impl From<FileName> for FileNameCase {
fn from(case: FileName) -> Self {
match case {
FileName::SnakeCase => Self::SnakeCase,
FileName::PascalCase => Self::PascalCase,
}
}
}
impl From<CodeOrder> for CodeOrderFix {
fn from(order: CodeOrder) -> Self {
match order {
CodeOrder::Report => Self::ReportOnly,
CodeOrder::FixWhenSafe => Self::WholeFileWhenSafe,
CodeOrder::Off => Self::Off,
}
}
}
const DEFAULT_INDENT_WIDTH: u8 = 4;
pub(crate) fn read(text: &str) -> Result<Config, Problem> {
let file: File = toml::from_str(text).map_err(|error| translate(text, &error))?;
let mut config = Config::default();
if let Some(checks) = file.format.safety_checks {
config.format.safety_checks = checks;
}
if let Some(lines) = file.lint.max_file_lines {
config.lint.max_file_lines = lines;
}
if let Some(methods) = file.lint.max_public_methods {
config.lint.max_public_methods = methods;
}
if let Some(returns) = file.lint.max_returns {
config.lint.max_returns = returns;
}
if let Some(arguments) = file.lint.max_arguments {
config.lint.max_function_arguments = arguments;
}
if let Some(order) = file.lint.code_order {
config.lint.code_order = order.into();
}
if let Some(case) = file.lint.file_name {
config.lint.file_name = case.into();
}
if let Some(order) = file.lint.declaration_order {
let mut groups = Vec::with_capacity(order.len());
for name in order {
let Some(group) = DeclarationGroup::from_name(name.get_ref()) else {
return Err(Problem {
line: line_of(text, name.span().start),
message: format!(
"`{}` is not a declaration group; the groups are {}",
name.get_ref(),
DECLARATION_GROUPS.join(", ")
),
});
};
groups.push(group);
}
config.lint.declaration_order = Some(groups);
}
if let Some(shape) = file.format.class_declaration {
config.format.class_declaration = shape.into();
}
if let Some(disable) = file.lint.disable {
config.lint.disabled = disable;
}
if let Some(respect) = file.files.respect_gitignore {
config.respect_gitignore = respect;
}
if let Some(exclude) = file.files.exclude {
config.excluded_dirs = exclude;
}
config.format.indent = indent_of(text, &file.format)?;
if let Some(length) = &file.format.line_length {
config.format.line_length = positive(text, length, "format.line-length")?;
config.lint.max_line_length = config.format.line_length;
}
if let Some(length) = &file.lint.max_line_length {
config.lint.max_line_length = positive(text, length, "lint.max-line-length")?;
}
Ok(config)
}
fn indent_of(text: &str, format: &FormatTable) -> Result<IndentStyle, Problem> {
let width = match &format.indent_width {
Some(width) => {
let value = *width.get_ref();
if !(1..=16).contains(&value) {
return Err(Problem {
line: line_of(text, width.span().start),
message: "`format.indent-width` must be between 1 and 16".to_string(),
});
}
Some(value)
}
None => None,
};
match (
format.indent.as_ref().map(Spanned::get_ref),
&format.indent_width,
) {
(Some(Indent::Spaces), _) => Ok(IndentStyle::Spaces(width.unwrap_or(DEFAULT_INDENT_WIDTH))),
(_, Some(spanned)) => Err(Problem {
line: line_of(text, spanned.span().start),
message: "`format.indent-width` applies only when `format.indent` is \"spaces\""
.to_string(),
}),
_ => Ok(IndentStyle::Tabs),
}
}
fn positive(text: &str, value: &Spanned<u16>, key: &str) -> Result<u16, Problem> {
let width = *value.get_ref();
if width == 0 {
return Err(Problem {
line: line_of(text, value.span().start),
message: format!("`{key}` must be between 1 and {}", u16::MAX),
});
}
Ok(width)
}
fn translate(text: &str, error: &toml::de::Error) -> Problem {
let line = error.span().map_or(1, |span| line_of(text, span.start));
let message = error.message();
if let Some(name) = unknown_field(message) {
return Problem {
line,
message: match nearest(name) {
Some(nearest) => format!("unknown setting `{name}`; did you mean `{nearest}`?"),
None => format!("unknown setting `{name}`"),
},
};
}
Problem {
line,
message: message.to_string(),
}
}
fn unknown_field(message: &str) -> Option<&str> {
let rest = message.strip_prefix("unknown field `")?;
let end = rest.find('`')?;
Some(&rest[..end])
}
fn line_of(text: &str, offset: usize) -> u32 {
let offset = offset.min(text.len());
u32::try_from(text[..offset].bytes().filter(|byte| *byte == b'\n').count() + 1)
.unwrap_or(u32::MAX)
}
fn nearest(name: &str) -> Option<&'static str> {
let name = name.replace('_', "-");
if let Some(moved) = KEYS.iter().find(|key| last_segment(key) == name) {
return Some(moved);
}
KEYS.iter()
.map(|key| (distance(&name, last_segment(key)), *key))
.filter(|(distance, _)| *distance * 3 <= name.len().max(3))
.min_by_key(|(distance, _)| *distance)
.map(|(_, key)| key)
}
fn last_segment(key: &str) -> &str {
key.rsplit('.').next().unwrap_or(key)
}
fn distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0; b.len() + 1];
for (i, from) in a.iter().enumerate() {
current[0] = i + 1;
for (j, to) in b.iter().enumerate() {
let substitute = previous[j] + usize::from(from != to);
current[j + 1] = substitute.min(previous[j + 1] + 1).min(current[j] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b.len()]
}
pub(crate) fn to_toml(config: &Config) -> String {
render(config, Defaults::Write)
}
pub(crate) fn to_starter_toml(config: &Config) -> String {
render(config, Defaults::Comment)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Defaults {
Write,
Comment,
}
fn put(out: &mut String, defaults: Defaults, at_default: bool, setting: &str) {
use std::fmt::Write;
if defaults == Defaults::Comment && at_default {
let _ = writeln!(out, "# {setting}");
} else {
let _ = writeln!(out, "{setting}");
}
}
fn render(config: &Config, defaults: Defaults) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "[format]");
render_format(&mut out, config, defaults);
let _ = writeln!(out, "\n[lint]");
render_lint(&mut out, config, defaults);
let _ = writeln!(out, "\n[files]");
render_files(&mut out, config, defaults);
out
}
fn render_format(out: &mut String, config: &Config, defaults: Defaults) {
let base = Config::default();
let format = &config.format;
put(
out,
defaults,
format.line_length == base.format.line_length,
&format!("line-length = {}", format.line_length),
);
match format.indent {
IndentStyle::Tabs => put(
out,
defaults,
base.format.indent == IndentStyle::Tabs,
"indent = \"tabs\"",
),
IndentStyle::Spaces(width) => {
put(out, defaults, false, "indent = \"spaces\"");
put(out, defaults, false, &format!("indent-width = {width}"));
}
}
let shape = match format.class_declaration {
ClassDeclaration::MultiLine => "multi-line",
ClassDeclaration::SingleLine => "single-line",
};
put(
out,
defaults,
format.class_declaration == base.format.class_declaration,
&format!("class-declaration = {}", quoted(shape)),
);
put(
out,
defaults,
format.safety_checks == base.format.safety_checks,
&format!("safety-checks = {}", format.safety_checks),
);
}
fn render_lint(out: &mut String, config: &Config, defaults: Defaults) {
let base = Config::default();
let lint = &config.lint;
put(
out,
defaults,
lint.max_line_length == base.lint.max_line_length,
&format!("max-line-length = {}", lint.max_line_length),
);
put(
out,
defaults,
lint.max_file_lines == base.lint.max_file_lines,
&format!("max-file-lines = {}", lint.max_file_lines),
);
put(
out,
defaults,
lint.max_public_methods == base.lint.max_public_methods,
&format!("max-public-methods = {}", lint.max_public_methods),
);
put(
out,
defaults,
lint.max_returns == base.lint.max_returns,
&format!("max-returns = {}", lint.max_returns),
);
put(
out,
defaults,
lint.max_function_arguments == base.lint.max_function_arguments,
&format!("max-arguments = {}", lint.max_function_arguments),
);
let order = match lint.code_order {
CodeOrderFix::ReportOnly => "report",
CodeOrderFix::WholeFileWhenSafe => "fix-when-safe",
CodeOrderFix::Off => "off",
};
put(
out,
defaults,
lint.code_order == base.lint.code_order,
&format!("code-order = {}", quoted(order)),
);
let case = match lint.file_name {
FileNameCase::SnakeCase => "snake-case",
FileNameCase::PascalCase => "pascal-case",
};
put(
out,
defaults,
lint.file_name == base.lint.file_name,
&format!("file-name = {}", quoted(case)),
);
if let Some(order) = &lint.declaration_order {
let names: Vec<String> = order.iter().map(|g| g.name().to_string()).collect();
put(
out,
defaults,
false,
&format!("declaration-order = {}", array(&names)),
);
}
put(
out,
defaults,
lint.disabled == base.lint.disabled,
&format!("disable = {}", array(&lint.disabled)),
);
}
fn render_files(out: &mut String, config: &Config, defaults: Defaults) {
let base = Config::default();
let exclusions = effective_exclusions(config);
let untouched = exclusions.iter().eq(crate::DEFAULT_EXCLUDED_DIRS.iter());
put(
out,
defaults,
untouched,
&format!("exclude = {}", array(&exclusions)),
);
put(
out,
defaults,
config.respect_gitignore == base.respect_gitignore,
&format!("respect-gitignore = {}", config.respect_gitignore),
);
}
fn effective_exclusions(config: &Config) -> Vec<String> {
if config.excluded_dirs.is_empty() {
return crate::DEFAULT_EXCLUDED_DIRS
.iter()
.map(|dir| (*dir).to_string())
.collect();
}
config.excluded_dirs.clone()
}
fn array(items: &[String]) -> String {
let items: Vec<String> = items.iter().map(|item| quoted(item)).collect();
format!("[{}]", items.join(", "))
}
fn quoted(text: &str) -> String {
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
fn read_ok(text: &str) -> Config {
read(text).expect("should read")
}
fn message(text: &str) -> String {
read(text).expect_err("should not read").message
}
#[test]
fn an_empty_file_leaves_every_default_alone() {
assert_eq!(read_ok(""), Config::default());
assert_eq!(read_ok("# nothing to say\n"), Config::default());
}
#[test]
fn every_setting_can_be_read() {
let config = read_ok(
"[format]\n\
line-length = 120\n\
indent = \"spaces\"\n\
indent-width = 2\n\
safety-checks = false\n\
\n\
[lint]\n\
max-line-length = 110\n\
max-file-lines = 500\n\
max-public-methods = 12\n\
max-returns = 3\n\
max-arguments = 5\n\
code-order = \"fix-when-safe\"\n\
disable = [\"max-returns\", \"line-too-long\"]\n\
\n\
[files]\n\
exclude = [\"vendor\"]\n",
);
assert_eq!(config.format.line_length, 120);
assert_eq!(config.format.indent, IndentStyle::Spaces(2));
assert!(!config.format.safety_checks);
assert_eq!(config.lint.max_line_length, 110);
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_eq!(config.lint.code_order, CodeOrderFix::WholeFileWhenSafe);
assert_eq!(config.lint.disabled, ["max-returns", "line-too-long"]);
assert_eq!(config.excluded_dirs, ["vendor"]);
}
#[test]
fn exclude_replaces_the_defaults_so_a_project_can_narrow_them() {
let config = read_ok("[files]\nexclude = [\".git\"]\n");
assert_eq!(config.excluded_dirs, [".git"]);
assert!(!config.is_excluded_dir("addons"));
}
#[test]
fn underscores_and_dashes_are_the_same_separator() {
assert_eq!(
read_ok("[format]\nline_length = 120\n").format.line_length,
120
);
assert_eq!(read_ok("[lint]\nmax_returns = 2\n").lint.max_returns, 2);
}
#[test]
fn a_dotted_key_means_the_same_as_a_table() {
assert_eq!(
read_ok("format.line-length = 120\n").format.line_length,
120
);
}
#[test]
fn spaces_default_to_four_of_them() {
assert_eq!(
read_ok("[format]\nindent = \"spaces\"\n").format.indent,
IndentStyle::Spaces(4)
);
}
#[test]
fn tabs_have_no_width_to_set() {
let reported = message("[format]\nindent = \"tabs\"\nindent-width = 2\n");
assert!(reported.contains("only when"), "{reported}");
assert!(message("[format]\nindent-width = 2\nindent = \"tabs\"\n").contains("only when"));
assert!(message("[format]\nindent-width = 2\n").contains("only when"));
}
#[test]
fn widening_the_lines_widens_them_for_the_linter_too() {
let config = read_ok("[format]\nline-length = 120\n");
assert_eq!(config.lint.max_line_length, 120);
let config = read_ok("[format]\nline-length = 120\n\n[lint]\nmax-line-length = 100\n");
assert_eq!(config.format.line_length, 120);
assert_eq!(config.lint.max_line_length, 100);
}
#[test]
fn an_unknown_setting_is_refused_rather_than_ignored() {
assert!(message("[lint]\nmax-recursion = 3\n").contains("unknown setting"));
assert!(message("[linting]\nmax-returns = 3\n").contains("unknown setting"));
}
#[test]
fn a_near_miss_is_named() {
assert_eq!(
message("[format]\nline-lenght = 100\n"),
"unknown setting `line-lenght`; did you mean `format.line-length`?"
);
assert_eq!(
message("[format]\nmax-returns = 3\n"),
"unknown setting `max-returns`; did you mean `lint.max-returns`?"
);
}
#[test]
fn a_wild_guess_is_not_offered_as_a_suggestion() {
let reported = message("[lint]\nfavourite-colour = \"blue\"\n");
assert_eq!(reported, "unknown setting `favourite-colour`");
}
#[test]
fn the_wrong_kind_of_value_says_what_it_wanted() {
assert!(message("[format]\nline-length = \"100\"\n").contains("expected u16"));
assert!(message("[format]\nsafety-checks = 1\n").contains("expected a boolean"));
assert!(message("[lint]\ndisable = \"max-returns\"\n").contains("expected a sequence"));
assert!(message("[lint]\ndisable = [1]\n").contains("expected a string"));
}
#[test]
fn a_value_outside_its_range_is_refused() {
assert!(message("[format]\nline-length = 0\n").contains("must be between 1 and 65535"));
assert!(message("[format]\nline-length = 70000\n").contains("expected u16"));
assert!(message("[lint]\nmax-returns = -1\n").contains("expected u32"));
assert!(message("[format]\nindent-width = 0\n").contains("must be between 1 and 16"));
}
#[test]
fn a_choice_lists_the_choices() {
let reported = message("[lint]\ncode-order = \"sometimes\"\n");
assert!(
reported.contains("unknown variant `sometimes`"),
"{reported}"
);
assert!(reported.contains("fix-when-safe"), "{reported}");
}
#[test]
fn a_project_can_name_the_file_convention_it_keeps() {
let config = read("[lint]\nfile-name = \"pascal-case\"\n").expect("should read");
assert_eq!(config.lint.file_name, FileNameCase::PascalCase);
let config = read("[lint]\nfile_name = \"snake-case\"\n").expect("should read");
assert_eq!(config.lint.file_name, FileNameCase::SnakeCase);
let reported = message("[lint]\nfile-name = \"kebab-case\"\n");
assert!(
reported.contains("unknown variant `kebab-case`"),
"{reported}"
);
assert!(reported.contains("pascal-case"), "{reported}");
}
#[test]
fn a_syntax_error_is_reported_as_one() {
assert!(read("[format\n").is_err());
assert!(read("line-length = = 1\n").is_err());
}
#[test]
fn the_line_number_is_the_settings_own() {
let problem = read("[format]\n\n# a note\nline-length = true\n").expect_err("should fail");
assert_eq!(problem.line, 4);
let problem = read("[format]\n\n\nindent-width = 2\n").expect_err("should fail");
assert_eq!(problem.line, 4);
}
#[test]
fn what_is_written_out_can_be_read_back_in() {
let config = Config {
format: crate::FormatConfig {
line_length: 120,
indent: IndentStyle::Spaces(2),
class_declaration: ClassDeclaration::SingleLine,
safety_checks: false,
},
lint: crate::LintConfig {
max_line_length: 110,
max_file_lines: 500,
max_public_methods: 12,
max_returns: 3,
max_function_arguments: 5,
code_order: CodeOrderFix::Off,
file_name: FileNameCase::PascalCase,
declaration_order: Some(vec![
DeclarationGroup::Tools,
DeclarationGroup::Extends,
DeclarationGroup::Others,
]),
disabled: vec!["max-returns".to_string()],
},
excluded_dirs: vec!["vendor".to_string()],
respect_gitignore: false,
};
assert_eq!(read_ok(&config.to_toml()), config);
}
#[test]
fn the_defaults_round_trip_as_the_defaults() {
let read_back = read_ok(&Config::default().to_toml());
assert_eq!(read_back.format, Config::default().format);
assert_eq!(read_back.lint, Config::default().lint);
assert_eq!(read_back.excluded_dirs, crate::DEFAULT_EXCLUDED_DIRS);
}
#[test]
fn every_key_in_the_catalogue_is_one_read_accepts() {
for key in KEYS {
let reported = message(&format!("{key} = \"probe\"\n"));
assert!(
!reported.contains("unknown setting"),
"`{key}` is listed but not read: {reported}"
);
}
}
}