nu_lint/
config.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    LintError,
11    rule::Rule,
12    rules::{self, 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#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
25#[serde(rename_all = "lowercase")]
26pub enum PipelinePlacement {
27    #[default]
28    Start,
29    End,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(default)]
34pub struct Config {
35    pub groups: HashMap<String, LintLevel>,
36    pub rules: HashMap<String, LintLevel>,
37    pub ignored: HashSet<String>,
38    pub additional: HashSet<String>,
39    pub sequential: bool,
40    pub pipeline_placement: PipelinePlacement,
41    pub max_pipeline_length: usize,
42    pub skip_external_parse_errors: bool,
43}
44
45impl Default for Config {
46    fn default() -> Self {
47        Self {
48            groups: HashMap::new(),
49            rules: HashMap::new(),
50            ignored: HashSet::from([
51                rules::always_annotate_ext_hat::RULE.id().into(),
52                rules::upstream::nu_parse_error::RULE.id().into(),
53                rules::error_make::add_url::RULE.id().into(),
54                rules::error_make::add_label::RULE.id().into(),
55            ]),
56            additional: HashSet::new(),
57            sequential: false,
58            pipeline_placement: PipelinePlacement::default(),
59            max_pipeline_length: 80,
60            skip_external_parse_errors: true,
61        }
62    }
63}
64
65impl Config {
66    /// Load configuration from a TOML string.
67    ///
68    /// # Errors
69    ///
70    /// Errors when TOML string is not a valid TOML string.
71    pub(crate) fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
72        toml::from_str(toml_str).map_err(|source| LintError::Config { source })
73    }
74    /// Load configuration from a TOML file.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the file cannot be read or if the TOML content is
79    /// invalid.
80    pub(crate) fn load_from_file(path: &Path) -> Result<Self, LintError> {
81        let content = fs::read_to_string(path).map_err(|source| LintError::Io {
82            path: path.to_path_buf(),
83            source,
84        })?;
85        Self::load_from_str(&content)
86    }
87
88    /// Get the effective lint level for a specific rule
89    #[must_use]
90    pub fn get_lint_level(&self, rule: &dyn Rule) -> Option<LintLevel> {
91        let rule_id = rule.id();
92
93        if self.ignored.contains(rule_id) {
94            return None;
95        }
96
97        if let Some(level) = self.rules.get(rule_id) {
98            log::debug!(
99                "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
100                 levels"
101            );
102            return Some(*level);
103        }
104
105        for (set_name, level) in &self.groups {
106            let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
107                continue;
108            };
109
110            if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
111                continue;
112            }
113
114            log::debug!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
115            return Some(*level);
116        }
117
118        Some(rule.level())
119    }
120}
121
122/// Search for .nu-lint.toml starting from the given directory and walking up to
123/// parent directories
124#[must_use]
125pub fn find_config_file_from(start_dir: &Path) -> Option<PathBuf> {
126    let mut current_dir = start_dir.to_path_buf();
127
128    loop {
129        let config_path = current_dir.join(".nu-lint.toml");
130        if config_path.exists() && config_path.is_file() {
131            return Some(config_path);
132        }
133
134        if !current_dir.pop() {
135            break;
136        }
137    }
138
139    None
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::rules::USED_RULES;
146
147    #[test]
148    fn test_load_config_simple_str() {
149        let toml_str = r#"
150        [rules]
151        snake_case_variables = "error"
152    "#;
153
154        let config = Config::load_from_str(toml_str).unwrap();
155        assert_eq!(
156            config.rules.get("snake_case_variables"),
157            Some(&LintLevel::Error)
158        );
159    }
160
161    #[test]
162    fn test_load_config_simple_str_set() {
163        let toml_str = r#"
164        ignored = [ "snake_case_variables" ]
165        [groups]
166        naming = "error"
167    "#;
168
169        let config = Config::load_from_str(toml_str).unwrap();
170        let found_set_level = config.groups.iter().find(|(k, _)| **k == "naming");
171        assert!(matches!(found_set_level, Some((_, LintLevel::Error))));
172        let ignored_rule = USED_RULES
173            .iter()
174            .find(|r| r.id() == "snake_case_variables")
175            .unwrap();
176        assert_eq!(config.get_lint_level(*ignored_rule), None);
177    }
178}