Skip to main content

nu_lint/
config.rs

1use std::{
2    collections::HashMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::IntoDeserializer};
8
9use crate::{
10    LintError,
11    rule::Rule,
12    rules::{USED_RULES, groups::ALL_GROUPS},
13};
14
15#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
16#[serde(rename_all = "lowercase")]
17pub enum LintLevel {
18    Hint,
19    #[default]
20    Warning,
21    Error,
22}
23
24/// Wrapper for `Option<LintLevel>` that serializes `None` as `"off"`.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ToggledLevel(pub Option<LintLevel>);
27
28impl Serialize for ToggledLevel {
29    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
30        match self.0 {
31            Some(level) => level.serialize(serializer),
32            None => serializer.serialize_str("off"),
33        }
34    }
35}
36
37impl<'de> Deserialize<'de> for ToggledLevel {
38    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
39        let s = String::deserialize(deserializer)?;
40        match s.as_str() {
41            "off" => Ok(Self(None)),
42            _ => LintLevel::deserialize(s.into_deserializer()).map(|l| Self(Some(l))),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
48#[serde(rename_all = "lowercase")]
49pub enum PipelinePlacement {
50    #[default]
51    Start,
52    End,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(default)]
57pub struct Config {
58    pub groups: HashMap<String, LintLevel>,
59    pub rules: HashMap<String, ToggledLevel>,
60    pub sequential: bool,
61    pub pipeline_placement: PipelinePlacement,
62    pub max_pipeline_length: usize,
63    pub skip_external_parse_errors: bool,
64    /// When true, rules recommend `get --optional` instead of `$list.0?` for
65    /// safe access. Default is false (prefer `?` syntax).
66    pub explicit_optional_access: bool,
67}
68
69impl Default for Config {
70    fn default() -> Self {
71        Self {
72            groups: HashMap::new(),
73            rules: HashMap::new(),
74            sequential: false,
75            pipeline_placement: PipelinePlacement::default(),
76            max_pipeline_length: 80,
77            skip_external_parse_errors: true,
78            explicit_optional_access: false,
79        }
80    }
81}
82
83impl Config {
84    /// Load configuration from a TOML string.
85    ///
86    /// # Errors
87    ///
88    /// Errors when TOML string is not a valid TOML string.
89    pub(crate) fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
90        toml::from_str(toml_str).map_err(|source| LintError::Config { source })
91    }
92    /// Load configuration from a TOML file.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the file cannot be read or if the TOML content is
97    /// invalid.
98    pub(crate) fn load_from_file(path: &Path) -> Result<Self, LintError> {
99        log::debug!("Loading configuration file at {}", path.display());
100        let content = fs::read_to_string(path).map_err(|source| LintError::Io {
101            path: path.to_path_buf(),
102            source,
103        })?;
104        Self::load_from_str(&content)
105    }
106
107    /// Validate that no conflicting rules are both enabled
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if two conflicting rules are both enabled.
112    pub fn validate(&self) -> Result<(), LintError> {
113        log::debug!("Validating loaded configuration.");
114
115        for rule_id_in_config_file in self.rules.keys() {
116            if USED_RULES
117                .iter()
118                .find(|rule| rule.id() == rule_id_in_config_file)
119                .is_none()
120            {
121                return Err(LintError::RuleDoesNotExist {
122                    non_existing_id: rule_id_in_config_file.clone(),
123                });
124            }
125        }
126
127        for rule in USED_RULES {
128            if self.get_lint_level(*rule).is_none() {
129                continue;
130            }
131
132            for conflicting_rule in rule.conflicts_with() {
133                if self.get_lint_level(*conflicting_rule).is_some() {
134                    return Err(LintError::RuleConflict {
135                        rule_a: rule.id(),
136                        rule_b: conflicting_rule.id(),
137                    });
138                }
139            }
140        }
141        Ok(())
142    }
143
144    /// Get the effective lint level for a specific rule
145    #[must_use]
146    pub fn get_lint_level(&self, rule: &dyn Rule) -> Option<LintLevel> {
147        let rule_id = rule.id();
148
149        if let Some(ToggledLevel(level)) = self.rules.get(rule_id) {
150            log::debug!(
151                "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
152                 levels"
153            );
154            return *level;
155        }
156
157        for (set_name, level) in &self.groups {
158            let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
159                continue;
160            };
161
162            if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
163                continue;
164            }
165
166            log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
167            return Some(*level);
168        }
169
170        rule.level()
171    }
172}
173
174/// Search for .nu-lint.toml starting from the given directory and walking up to
175/// parent directories
176#[must_use]
177pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
178    let mut current_dir = start_dir.to_path_buf();
179
180    loop {
181        let config_path = current_dir.join(".nu-lint.toml");
182        if config_path.exists() && config_path.is_file() {
183            return Some(config_path);
184        }
185
186        if !current_dir.pop() {
187            break;
188        }
189    }
190
191    None
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_load_config_simple_str() {
200        let toml_str = r#"
201        [rules]
202        snake_case_variables = "error"
203        other_rule = "off"
204    "#;
205
206        let config = Config::load_from_str(toml_str).unwrap();
207        assert_eq!(
208            config.rules["snake_case_variables"],
209            ToggledLevel(Some(LintLevel::Error))
210        );
211
212        assert_eq!(config.rules["other_rule"], ToggledLevel(None));
213    }
214
215    #[test]
216    fn test_validate_passes_with_default_config() {
217        let result = Config::default().validate();
218        assert!(result.is_ok());
219    }
220}