Skip to main content

nu_lint/
config.rs

1use std::{
2    collections::HashMap,
3    fs,
4    path::{Path, PathBuf},
5    sync::LazyLock,
6};
7
8use miette::Severity;
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    LintError,
13    rule::Rule,
14    rules::{USED_RULES, groups::ALL_GROUPS},
15};
16
17#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
18#[serde(rename_all = "lowercase")]
19pub enum LintLevel {
20    Off,
21    Hint,
22    #[default]
23    Warning,
24    Error,
25}
26
27impl TryFrom<LintLevel> for Severity {
28    type Error = ();
29    fn try_from(value: LintLevel) -> Result<Self, ()> {
30        match value {
31            LintLevel::Off => Err(()),
32            LintLevel::Hint => Ok(Self::Advice),
33            LintLevel::Warning => Ok(Self::Warning),
34            LintLevel::Error => Ok(Self::Error),
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
40#[serde(rename_all = "lowercase")]
41pub enum PipelinePlacement {
42    #[default]
43    Start,
44    End,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(default)]
49pub struct Config {
50    pub groups: HashMap<String, LintLevel>,
51    pub rules: HashMap<String, LintLevel>,
52    pub sequential: bool,
53    pub pipeline_placement: PipelinePlacement,
54    pub max_pipeline_length: usize,
55    pub skip_external_parse_errors: bool,
56    /// When true, rules recommend `get --optional` instead of `$list.0?` for
57    /// safe access. Default is false (prefer `?` syntax).
58    pub explicit_optional_access: bool,
59    /// Maximum number of top-level statements allowed in a function body before
60    /// `max_function_body_length` fires. Default: 40.
61    pub max_function_body_statements: usize,
62    /// Maximum number of pipeline elements (pipe-separated steps) a
63    /// single-call function body may have before `single_call_command` stops
64    /// suggesting it can be inlined. Default: 2 (at most one `|`).
65    pub max_inlinable_pipeline_elements: usize,
66}
67
68impl Default for Config {
69    fn default() -> Self {
70        Self {
71            groups: HashMap::new(),
72            rules: HashMap::new(),
73            sequential: false,
74            pipeline_placement: PipelinePlacement::default(),
75            max_pipeline_length: 80,
76            skip_external_parse_errors: true,
77            explicit_optional_access: false,
78            max_function_body_statements: 40,
79            max_inlinable_pipeline_elements: 2,
80        }
81    }
82}
83
84impl Config {
85    /// Return a `&'static` reference to the default configuration.
86    ///
87    /// Useful when a `&'static Config` is needed without searching
88    /// the file system (e.g. in tests).
89    #[must_use]
90    pub fn default_static() -> &'static Self {
91        static DEFAULT: LazyLock<Config> = LazyLock::new(Config::default);
92        &DEFAULT
93    }
94
95    /// Load configuration from a TOML string.
96    ///
97    /// # Errors
98    ///
99    /// Errors when TOML string is not a valid TOML string.
100    pub(crate) fn load_from_str(toml_str: &str) -> Result<Self, LintError> {
101        toml::from_str(toml_str).map_err(|source| LintError::Config { source })
102    }
103    /// Load configuration from a TOML file.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the file cannot be read or if the TOML content is
108    /// invalid.
109    pub(crate) fn load_from_file(path: &Path) -> Result<Self, LintError> {
110        log::debug!("Loading configuration file at {}", path.display());
111        let content = fs::read_to_string(path).map_err(|source| LintError::Io {
112            path: path.to_path_buf(),
113            source,
114        })?;
115        Self::load_from_str(&content)
116    }
117
118    /// Validate that no conflicting rules are both enabled
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if two conflicting rules are both enabled.
123    pub fn validate(&self) -> Result<(), LintError> {
124        log::debug!("Validating loaded configuration.");
125
126        for rule_id_in_config_file in self.rules.keys() {
127            if USED_RULES
128                .iter()
129                .find(|rule| rule.id() == rule_id_in_config_file)
130                .is_none()
131            {
132                return Err(LintError::RuleDoesNotExist {
133                    non_existing_id: rule_id_in_config_file.clone(),
134                });
135            }
136        }
137
138        for rule in USED_RULES {
139            if self.get_lint_level(*rule) == LintLevel::Off {
140                continue;
141            }
142
143            for conflicting_rule in rule.conflicts_with() {
144                if self.get_lint_level(*conflicting_rule) > LintLevel::Off {
145                    return Err(LintError::RuleConflict {
146                        rule_a: rule.id(),
147                        rule_b: conflicting_rule.id(),
148                    });
149                }
150            }
151        }
152        Ok(())
153    }
154
155    /// Get the effective lint level for a specific rule
156    #[must_use]
157    pub fn get_lint_level(&self, rule: &dyn Rule) -> LintLevel {
158        let rule_id = rule.id();
159
160        if let Some(level) = self.rules.get(rule_id) {
161            log::trace!(
162                "Rule '{rule_id}' has individual level '{level:?}' in config, overriding set \
163                 levels"
164            );
165            return *level;
166        }
167
168        for (set_name, level) in &self.groups {
169            let Some(lint_set) = ALL_GROUPS.iter().find(|set| set.name == set_name.as_str()) else {
170                continue;
171            };
172
173            if !lint_set.rules.iter().any(|r| r.id() == rule_id) {
174                continue;
175            }
176
177            log::trace!("Rule '{rule_id}' found in set '{set_name}' with level {level:?}");
178            return *level;
179        }
180
181        rule.level()
182    }
183}
184
185/// Path to the user-wide config file (XDG: `~/.config/nu-lint.toml` on Linux).
186#[must_use]
187pub fn user_config_path() -> Option<PathBuf> {
188    dirs::config_dir().map(|d| d.join("nu-lint.toml"))
189}
190
191/// Load the user-wide XDG config, falling back to defaults if missing.
192#[must_use]
193pub fn load_user_config() -> Config {
194    user_config_path()
195        .filter(|p| p.is_file())
196        .and_then(|p| Config::load_from_file(&p).ok())
197        .unwrap_or_default()
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_load_config_simple_str() {
206        let toml_str = r#"
207        [rules]
208        snake_case_variables = "error"
209        other_rule = "off"
210    "#;
211
212        let config = Config::load_from_str(toml_str).unwrap();
213        assert_eq!(config.rules["snake_case_variables"], LintLevel::Error);
214
215        assert_eq!(config.rules["other_rule"], LintLevel::Off);
216    }
217
218    #[test]
219    fn test_validate_passes_with_default_config() {
220        let result = Config::default().validate();
221        assert!(result.is_ok());
222    }
223}