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 pub explicit_optional_access: bool,
59 pub max_function_body_statements: usize,
62 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 #[must_use]
90 pub fn default_static() -> &'static Self {
91 static DEFAULT: LazyLock<Config> = LazyLock::new(Config::default);
92 &DEFAULT
93 }
94
95 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 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 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 #[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#[must_use]
187pub fn user_config_path() -> Option<PathBuf> {
188 dirs::config_dir().map(|d| d.join("nu-lint.toml"))
189}
190
191#[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}