Skip to main content

ravenrs_lint/
config.rs

1//! What a package says about the rules, read from `[package.metadata.ravenrs-lint]` and
2//! `[workspace.metadata.ravenrs-lint]`.
3
4use std::collections::HashMap;
5
6use serde_json::Value;
7
8use crate::{Error, Lint};
9
10/// How a broken rule is reported, as in Cargo's `[lints]`.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
12#[non_exhaustive]
13pub enum Level {
14    /// Not reported.
15    Allow,
16    /// Reported as a warning. `assert_clean` still fails.
17    Warn,
18    /// Reported as an error, and `cargo ravenrs-lint` exits with a failure.
19    Deny,
20}
21
22impl Level {
23    fn parse(text: &str) -> Option<Level> {
24        match text {
25            "allow" => Some(Level::Allow),
26            "warn" => Some(Level::Warn),
27            "deny" | "forbid" => Some(Level::Deny),
28            _ => None,
29        }
30    }
31
32    /// `"warning"` or `"error"`, as the compiler prints it.
33    pub fn label(self) -> &'static str {
34        match self {
35            Level::Allow => "allowed",
36            Level::Warn => "warning",
37            Level::Deny => "error",
38        }
39    }
40}
41
42/// The language of the interface's text, for the checks that read it.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum Language {
46    /// English: captions start with an imperative verb from a built-in list.
47    #[default]
48    English,
49    /// Brazilian Portuguese: captions start with an infinitive ("Salvar", "Cadastrar").
50    PortugueseBrazil,
51}
52
53/// The configuration of one package.
54#[derive(Clone, Debug)]
55pub struct Config {
56    levels: HashMap<Lint, Level>,
57    /// The language of the interface.
58    pub language: Language,
59    /// First words of button captions accepted as verbs, besides the built-in ones.
60    pub extra_verbs: Vec<String>,
61    /// The longest a source file may be before `oversized_file`.
62    pub max_file_lines: usize,
63}
64
65impl Default for Config {
66    fn default() -> Self {
67        Config {
68            levels: HashMap::new(),
69            language: Language::default(),
70            extra_verbs: Vec::new(),
71            max_file_lines: 600,
72        }
73    }
74}
75
76impl Config {
77    /// The level of `lint`: `deny` unless the configuration lowered it.
78    pub fn level(&self, lint: Lint) -> Level {
79        self.levels.get(&lint).copied().unwrap_or(Level::Deny)
80    }
81
82    /// Sets the level of `lint`.
83    pub fn set_level(&mut self, lint: Lint, level: Level) {
84        self.levels.insert(lint, level);
85    }
86
87    /// Reads the workspace's table, then the package's over it. Both are the JSON that
88    /// `cargo metadata` gives for `metadata.ravenrs-lint`.
89    pub fn from_metadata(workspace: Option<&Value>, package: Option<&Value>) -> Result<Config, Error> {
90        let mut config = Config::default();
91        for table in [workspace, package].into_iter().flatten() {
92            config.merge(table)?;
93        }
94        Ok(config)
95    }
96
97    fn merge(&mut self, table: &Value) -> Result<(), Error> {
98        let Some(table) = table.as_object() else {
99            return Err(Error::Config("[metadata.ravenrs-lint] must be a table".into()));
100        };
101        for (key, value) in table {
102            match key.as_str() {
103                "language" => {
104                    self.language = match value.as_str() {
105                        Some("en") => Language::English,
106                        Some("pt-BR" | "pt") => Language::PortugueseBrazil,
107                        _ => {
108                            return Err(Error::Config(format!(
109                                "language must be \"en\" or \"pt-BR\", not {value}"
110                            )));
111                        }
112                    }
113                }
114                "extra-verbs" => {
115                    let words = value.as_array().and_then(|items| {
116                        items.iter().map(|w| w.as_str().map(str::to_owned)).collect::<Option<Vec<_>>>()
117                    });
118                    self.extra_verbs =
119                        words.ok_or_else(|| Error::Config("extra-verbs must be a list of words".into()))?;
120                }
121                "max-file-lines" => {
122                    self.max_file_lines = value
123                        .as_u64()
124                        .and_then(|n| usize::try_from(n).ok())
125                        .ok_or_else(|| Error::Config("max-file-lines must be a whole number".into()))?;
126                }
127                "lints" => self.merge_levels(value)?,
128                other => {
129                    return Err(Error::Config(format!(
130                        "unknown key `{other}`; the keys are language, extra-verbs, max-file-lines and lints"
131                    )));
132                }
133            }
134        }
135        Ok(())
136    }
137
138    fn merge_levels(&mut self, value: &Value) -> Result<(), Error> {
139        let Some(levels) = value.as_object() else {
140            return Err(Error::Config("[metadata.ravenrs-lint.lints] must be a table".into()));
141        };
142        for (name, value) in levels {
143            let lint = Lint::from_name(name).ok_or_else(|| {
144                let known: Vec<&str> = Lint::ALL.iter().map(|l| l.name()).collect();
145                Error::Config(format!("unknown rule `{name}`; the rules are {}", known.join(", ")))
146            })?;
147            // `"deny"`, or `{ level = "warn", reason = "..." }`: lowering a rule needs a reason.
148            let (level, reason) = match value {
149                Value::String(level) => (level.as_str(), None),
150                Value::Object(table) => (
151                    table.get("level").and_then(Value::as_str).unwrap_or_default(),
152                    table.get("reason").and_then(Value::as_str).map(str::trim).filter(|r| !r.is_empty()),
153                ),
154                _ => ("", None),
155            };
156            let level = Level::parse(level).ok_or_else(|| {
157                Error::Config(format!(
158                    "`{name}` must be \"deny\" or {{ level = \"warn\", reason = \"...\" }}"
159                ))
160            })?;
161            if level < Level::Deny && reason.is_none() {
162                return Err(Error::Config(format!(
163                    "`{name}` is lowered to {level:?} without a reason. Fix the code instead; when lowering the \
164                     rule is right, write {name} = {{ level = \"{}\", reason = \"why\" }}",
165                    match level {
166                        Level::Allow => "allow",
167                        _ => "warn",
168                    }
169                )));
170            }
171            self.levels.insert(lint, level);
172        }
173        Ok(())
174    }
175}