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 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 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 #[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#[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}