supa_mdx_lint/
config.rs

1use anyhow::Result;
2use bon::bon;
3use glob::{MatchOptions, Pattern};
4use log::{debug, error, warn};
5use std::{
6    collections::{hash_map, HashMap, HashSet},
7    env,
8    path::{Path, PathBuf},
9};
10
11use crate::{
12    errors::LintLevel,
13    rules::{RuleRegistry, RuleSettings},
14    utils::{
15        path::{normalize_path, IsGlob},
16        path_relative_from,
17    },
18    PhaseReady, PhaseSetup,
19};
20
21const IGNORE_GLOBS_KEY: &str = "ignore_patterns";
22
23#[derive(Debug, Clone)]
24pub struct ConfigDir(pub Option<PathBuf>);
25
26impl ConfigDir {
27    pub fn none() -> Self {
28        Self(None)
29    }
30
31    pub fn new(path: PathBuf) -> Self {
32        Self(Some(path))
33    }
34}
35
36#[derive(Debug, Default)]
37pub struct ConfigFileLocations(Option<HashMap<String, String>>);
38
39impl ConfigFileLocations {
40    fn insert(&mut self, key: &str, value: &Path) {
41        let map = self.0.get_or_insert_with(HashMap::new);
42        if !map.contains_key(key) {
43            map.insert(
44                key.to_string(),
45                std::fs::canonicalize(value)
46                    .map(|path| path.to_string_lossy().into_owned())
47                    .unwrap_or(value.to_string_lossy().into_owned()),
48            );
49        }
50    }
51
52    fn iter(&self) -> ConfigFileLocationsIterator {
53        ConfigFileLocationsIterator {
54            inner: self.0.as_ref().map(|map| map.iter()),
55        }
56    }
57}
58
59struct ConfigFileLocationsIterator<'a> {
60    inner: Option<hash_map::Iter<'a, String, String>>,
61}
62
63impl<'a> Iterator for ConfigFileLocationsIterator<'a> {
64    type Item = (&'a String, &'a String);
65
66    fn next(&mut self) -> Option<Self::Item> {
67        self.inner.as_mut().and_then(|iter| iter.next())
68    }
69}
70
71#[derive(Debug)]
72pub struct Config<Phase> {
73    pub(crate) rule_registry: RuleRegistry<Phase>,
74    pub(crate) rule_specific_settings: HashMap<String, RuleSettings>,
75    /// A list of globs to ignore.
76    ignore_globs: HashSet<Pattern>,
77    config_file_locations: ConfigFileLocations,
78}
79
80impl Default for Config<PhaseSetup> {
81    fn default() -> Self {
82        Self {
83            rule_registry: RuleRegistry::<PhaseSetup>::new(),
84            rule_specific_settings: HashMap::new(),
85            ignore_globs: HashSet::new(),
86            config_file_locations: ConfigFileLocations(None),
87        }
88    }
89}
90
91#[bon]
92impl Config<PhaseSetup> {
93    /// Read the rule configuration from a TOML file.
94    ///
95    /// The configuration file is a TOML file that contains a table of rule
96    /// settings. Each rule has a unique name, and each rule can have a set of
97    /// settings that are specific to that rule.
98    ///
99    /// The setting named `level` is reserved for setting the rule's severity level.
100    ///
101    /// Rules can be turned off by setting the rule to `false`.
102    ///
103    /// The configuration file can also include other files using the `include()`
104    /// function. This allows for modular configuration, where each rule can be
105    /// defined in a separate file, and then included into the main configuration
106    /// file.
107    ///
108    /// Example:
109    ///
110    /// ```toml
111    /// [Rule001SomeRule]
112    /// level = "error"
113    /// option1 = true
114    /// option2 = "value"
115    ///
116    /// Rule002SomeOtherRule = "include('some_other_rule.toml')"
117    ///
118    /// Rule003NotApplied = false
119    /// ```
120    pub fn from_config_file<P: AsRef<Path>>(config_file: P) -> Result<Self> {
121        let config_file = config_file.as_ref();
122        let config_path = config_file.to_path_buf();
123        let config_dir = config_path.parent().ok_or_else(|| {
124            anyhow::anyhow!("Unable to determine parent directory of config file: {config_path:?}")
125        })?;
126
127        let config_content = std::fs::read_to_string(&config_path)
128            .inspect_err(|_| error!("Failed to read config file at {config_path:?}"))?;
129        let table: toml::Table = toml::from_str(&config_content)?;
130
131        let mut file_locations = ConfigFileLocations::default();
132
133        let parsed = Self::process_includes()
134            .table(&table)
135            .file_locations(&mut file_locations)
136            .base_dir(config_dir)
137            .current_file(config_file)
138            .is_top_level(true)
139            .call()
140            .inspect_err(|_| {
141                error!("Failed to parse config");
142                debug!("Config file content:\n\t{config_content}")
143            })?;
144
145        let config_dir = ConfigDir(Some(config_dir.to_path_buf()));
146        Self::from_serializable()
147            .config(parsed)
148            .config_dir(&config_dir)
149            .config_file_locations(file_locations)
150            .call()
151    }
152
153    #[builder]
154    fn process_includes(
155        table: &toml::Table,
156        file_locations: &mut ConfigFileLocations,
157        base_dir: &Path,
158        current_file: &Path,
159        #[builder(default)] is_top_level: bool,
160    ) -> Result<toml::Table> {
161        let mut processed_table = toml::Table::new();
162
163        for (key, value) in table {
164            let processed_value = match value {
165                toml::Value::String(s) if s.starts_with("include('") && s.ends_with("')") => {
166                    // Extract the path from include('path')
167                    let path_str = s[9..s.len() - 2].to_string();
168                    let include_path = base_dir.join(path_str);
169
170                    let include_content = std::fs::read_to_string(&include_path).map_err(|e| {
171                        anyhow::anyhow!(
172                            "Failed to read include file at path {:?}: {}",
173                            include_path,
174                            e
175                        )
176                    })?;
177
178                    file_locations.insert(key, include_path.as_path());
179
180                    let table: toml::Table = toml::from_str(&include_content)?;
181                    toml::Value::Table(
182                        Self::process_includes()
183                            .table(&table)
184                            .file_locations(file_locations)
185                            .base_dir(base_dir)
186                            .current_file(include_path.as_path())
187                            .call()
188                            .map_err(|e| {
189                                anyhow::anyhow!(
190                                    "Failed to parse include file from path {:?}: {}",
191                                    include_path,
192                                    e
193                                )
194                            })?,
195                    )
196                }
197                toml::Value::Table(table) => {
198                    if is_top_level {
199                        file_locations.insert(key, current_file);
200                    }
201                    toml::Value::Table(
202                        Self::process_includes()
203                            .table(table)
204                            .file_locations(file_locations)
205                            .base_dir(base_dir)
206                            .current_file(current_file)
207                            .call()?,
208                    )
209                }
210                _ => {
211                    if is_top_level {
212                        file_locations.insert(key, current_file);
213                    }
214                    value.clone()
215                }
216            };
217
218            processed_table.insert(key.clone(), processed_value);
219        }
220
221        Ok(processed_table)
222    }
223
224    #[builder]
225    pub fn from_serializable<T: serde::Serialize>(
226        config: T,
227        config_dir: &ConfigDir,
228        #[builder(default = ConfigFileLocations::default())]
229        config_file_locations: ConfigFileLocations,
230    ) -> Result<Self> {
231        let registry = RuleRegistry::new();
232        let value = toml::Value::try_from(config)?;
233        let table = Self::validate_config_structure(value)?;
234
235        let (registry, rule_settings, ignore_globs) =
236            Self::process_config_table(registry, table, config_dir)?;
237
238        Ok(Self {
239            rule_registry: registry,
240            rule_specific_settings: rule_settings,
241            ignore_globs,
242            config_file_locations,
243        })
244    }
245
246    fn validate_config_structure(value: toml::Value) -> Result<toml::Table> {
247        match value {
248            toml::Value::Table(table) => Ok(table),
249            _ => Err(anyhow::anyhow!(
250                "Invalid configuration. Must be serializable to an object."
251            )),
252        }
253    }
254
255    #[allow(clippy::type_complexity)]
256    fn process_config_table(
257        mut registry: RuleRegistry<PhaseSetup>,
258        table: toml::Table,
259        config_dir: &ConfigDir,
260    ) -> Result<(
261        RuleRegistry<PhaseSetup>,
262        HashMap<String, RuleSettings>,
263        HashSet<Pattern>,
264    )> {
265        let mut filtered_rules: HashSet<String> = HashSet::new();
266        let mut rule_specific_settings = HashMap::new();
267        let mut ignore_globs = HashSet::<Pattern>::new();
268
269        for (key, value) in table {
270            match value {
271                toml::Value::Array(arr) if key == IGNORE_GLOBS_KEY => {
272                    arr.into_iter().for_each(|glob| {
273                        if let toml::Value::String(glob) = glob {
274                            let root_dir = match config_dir.0 {
275                                Some(ref dir) => dir,
276                                None => &std::env::current_dir().unwrap(),
277                            };
278                            let glob = root_dir.join(glob);
279                            let glob_str = normalize_path(&glob, IsGlob(true));
280                            match Pattern::new(&glob_str) {
281                                Ok(glob) => {
282                                    ignore_globs.insert(glob);
283                                }
284                                Err(err) => {
285                                    warn!("Failed to parse ignore pattern {glob_str}: {err:?}");
286                                }
287                            }
288                        }
289                    });
290                }
291                toml::Value::Boolean(false) if registry.is_valid_rule(&key) => {
292                    filtered_rules.insert(key.clone());
293                }
294                toml::Value::Table(table) if registry.is_valid_rule(&key) => {
295                    let level = table.get("level");
296                    if let Some(toml::Value::String(level)) = level.as_ref() {
297                        match TryInto::<LintLevel>::try_into(level.as_str()) {
298                            Ok(level) => {
299                                registry.save_configured_level(&key, level);
300                            }
301                            Err(err) => {
302                                warn!("{err}")
303                            }
304                        }
305                    }
306
307                    rule_specific_settings.insert(key.clone(), RuleSettings::new(table.clone()));
308                }
309                _ => {}
310            }
311        }
312
313        filtered_rules.iter().for_each(|rule_name| {
314            registry.deactivate_rule(rule_name);
315        });
316
317        Ok((registry, rule_specific_settings, ignore_globs))
318    }
319}
320
321impl TryFrom<Config<PhaseSetup>> for Config<PhaseReady> {
322    type Error = anyhow::Error;
323
324    fn try_from(mut old_config: Config<PhaseSetup>) -> Result<Self> {
325        let ready_registry = old_config
326            .rule_registry
327            .setup(&mut old_config.rule_specific_settings)?;
328        Ok(Self {
329            rule_registry: ready_registry,
330            rule_specific_settings: old_config.rule_specific_settings,
331            ignore_globs: old_config.ignore_globs,
332            config_file_locations: old_config.config_file_locations,
333        })
334    }
335}
336
337impl<RuleRegistryState> Config<RuleRegistryState> {
338    pub(crate) fn is_lintable(&self, path: impl AsRef<Path>) -> bool {
339        let path = path.as_ref();
340        path.is_dir() || path.extension().is_some_and(|ext| ext == "mdx")
341    }
342
343    pub(crate) fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
344        let path = path.as_ref();
345        let path = if path.is_relative() {
346            let current_dir = env::current_dir().unwrap();
347            &current_dir.join(path)
348        } else {
349            path
350        };
351        let path_str = normalize_path(path, IsGlob(false));
352        debug!("Checking if {path_str} is ignored");
353
354        let is_ignored = self.ignore_globs.iter().any(|pattern| {
355            pattern.matches_with(
356                &path_str,
357                MatchOptions {
358                    case_sensitive: true,
359                    require_literal_separator: true,
360                    require_literal_leading_dot: false,
361                },
362            )
363        });
364        debug!(
365            "Path {path_str} is {}ignored",
366            if is_ignored { "" } else { "not " }
367        );
368        is_ignored
369    }
370}
371
372#[derive(Debug, Default)]
373pub struct ConfigMetadata {
374    pub config_file_locations: Option<HashMap<String, String>>,
375}
376
377impl From<&Config<PhaseReady>> for ConfigMetadata {
378    fn from(config: &Config<PhaseReady>) -> Self {
379        let current_directory = std::env::current_dir().unwrap();
380
381        let locations = &config.config_file_locations;
382        let mut map: Option<HashMap<String, String>> = None;
383
384        locations.iter().for_each(|(key, value)| {
385            let normalized_path = PathBuf::from(value);
386            let normalized_path =
387                path_relative_from(normalized_path.as_path(), current_directory.as_path())
388                    .unwrap_or(normalized_path);
389            map.get_or_insert_with(HashMap::new)
390                .insert(key.clone(), normalized_path.to_string_lossy().to_string());
391        });
392
393        Self {
394            config_file_locations: map,
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use std::fs;
403
404    use serde_json::json;
405
406    use tempfile::NamedTempFile;
407
408    const VALID_RULE_NAME: &str = "Rule001HeadingCase";
409    const VALID_RULE_NAME_2: &str = "Rule003Spelling";
410
411    fn create_temp_config_file(content: &str) -> NamedTempFile {
412        let file = NamedTempFile::new().unwrap();
413        std::fs::write(&file, content).unwrap();
414        file
415    }
416
417    #[test]
418    fn test_from_config_file_valid() {
419        let content = format!(
420            r#"
421[{VALID_RULE_NAME}]
422option1 = true
423option2 = "value"
424"#
425        );
426        let file = create_temp_config_file(&content);
427        let config = Config::from_config_file(file.path()).unwrap();
428        assert!(config.rule_specific_settings.contains_key(VALID_RULE_NAME));
429        assert!(config.rule_registry.is_rule_active(VALID_RULE_NAME));
430    }
431
432    #[test]
433    fn test_config_with_includes() -> Result<()> {
434        let temp_dir = tempfile::tempdir()?;
435
436        let included_content = r#"
437option1 = true
438option2 = "value"
439"#;
440        let included_path = temp_dir.path().join("heading_sentence_case.toml");
441        fs::write(&included_path, included_content)?;
442
443        let main_content = format!(
444            r#"
445{VALID_RULE_NAME} = "include('heading_sentence_case.toml')"
446"#
447        );
448        let main_config_path = temp_dir.path().join("config.toml");
449        fs::write(&main_config_path, main_content)?;
450
451        let config = Config::from_config_file(main_config_path)?;
452
453        assert!(config.rule_specific_settings.contains_key(VALID_RULE_NAME));
454        let rule_settings = config.rule_specific_settings.get(VALID_RULE_NAME).unwrap();
455        assert!(rule_settings.has_key("option1"));
456        assert!(rule_settings.has_key("option2"));
457
458        Ok(())
459    }
460
461    #[test]
462    fn test_ignores_invalid_rule_name() {
463        let content = r#"
464[RuleInvalidlyNamed]
465option1 = true
466option2 = "value"
467"#;
468        let file = create_temp_config_file(content);
469        let config = Config::from_config_file(file.path()).unwrap();
470        assert!(!config
471            .rule_specific_settings
472            .contains_key("RuleInvalidlyNamed"));
473        assert!(config.rule_registry.is_rule_active(VALID_RULE_NAME));
474    }
475
476    #[test]
477    fn test_from_config_file_invalid() {
478        let content = "invalid toml content";
479        let file = create_temp_config_file(content);
480        assert!(Config::from_config_file(file.path()).is_err());
481    }
482
483    #[test]
484    fn test_from_serializable_valid() {
485        let config_json = json!({
486            VALID_RULE_NAME: {
487                "option1": true,
488                "option2": "value"
489            },
490        });
491        let config = Config::from_serializable()
492            .config(config_json)
493            .config_dir(&ConfigDir(None))
494            .call()
495            .unwrap();
496        assert!(config.rule_specific_settings.contains_key(VALID_RULE_NAME));
497        assert!(config.rule_registry.is_rule_active(VALID_RULE_NAME));
498    }
499
500    #[test]
501    fn test_config_deactivate_rule() {
502        let config_json = json!({
503            VALID_RULE_NAME: false
504        });
505        let config = Config::from_serializable()
506            .config(config_json)
507            .config_dir(&ConfigDir(None))
508            .call()
509            .unwrap();
510        assert!(!config.rule_registry.is_rule_active(VALID_RULE_NAME));
511    }
512
513    #[test]
514    fn test_from_serializable_invalid() {
515        let invalid_config = vec![1, 2, 3]; // Not a table/object
516        assert!(Config::from_serializable()
517            .config(invalid_config)
518            .config_dir(&ConfigDir(None))
519            .call()
520            .is_err());
521    }
522
523    #[test]
524    fn test_config_tracks_file_locations_single_file() {
525        let content = format!(
526            r#"
527    [{VALID_RULE_NAME}]
528    option1 = true
529    option2 = "value"
530    "#
531        );
532        let file = create_temp_config_file(&content);
533        let config = Config::from_config_file(file.path()).unwrap();
534
535        let metadata = ConfigMetadata::from(&Config::try_from(config).unwrap());
536        let locations = metadata.config_file_locations.unwrap();
537
538        assert!(locations.len() == 1);
539        assert!(locations.get(VALID_RULE_NAME).is_some());
540    }
541
542    #[test]
543    fn test_config_tracks_file_locations_with_includes() {
544        let temp_dir = tempfile::tempdir().unwrap();
545
546        // Create include file
547        let included_content = r#"
548    option1 = true
549    option2 = "value"
550    "#;
551        let included_path = temp_dir.path().join("rule_settings.toml");
552        fs::write(&included_path, included_content).unwrap();
553
554        // Create main config that includes the above file
555        let main_content = format!(
556            r#"
557    {VALID_RULE_NAME} = "include('rule_settings.toml')"
558
559    [{VALID_RULE_NAME_2}]
560    option3 = false
561    "#
562        );
563        let main_config_path = temp_dir.path().join("config.toml");
564        fs::write(&main_config_path, &main_content).unwrap();
565
566        let config = Config::from_config_file(&main_config_path).unwrap();
567        let metadata = ConfigMetadata::from(&Config::try_from(config).unwrap());
568        let locations = metadata.config_file_locations.unwrap();
569
570        assert!(locations.len() == 2);
571        assert!(locations
572            .get(VALID_RULE_NAME)
573            .unwrap()
574            .contains("rule_settings.toml"));
575        assert!(locations
576            .get(VALID_RULE_NAME_2)
577            .unwrap()
578            .contains("config.toml"));
579    }
580
581    #[test]
582    // Known bug where the relative path calculation doesn't work on Windows
583    #[cfg(not(target_os = "windows"))]
584    fn test_config_locations_normalized() {
585        let temp_dir = tempfile::tempdir().unwrap();
586        let original_dir = env::current_dir().unwrap();
587
588        // Create a nested directory structure
589        let project_dir = temp_dir.path().join("project");
590        let config_dir = project_dir.join("configs");
591        let rules_dir = project_dir.join("rules");
592        fs::create_dir_all(&config_dir).unwrap();
593        fs::create_dir_all(&rules_dir).unwrap();
594
595        // Create rule config in rules directory
596        let rule_content = r#"
597    option1 = true
598    option2 = "value"
599    "#;
600        let rule_path = rules_dir.join("rule_config.toml");
601        fs::write(&rule_path, rule_content).unwrap();
602
603        // Create main config that includes the rule file
604        let main_content = format!(
605            r#"
606    {VALID_RULE_NAME} = "include('../rules/rule_config.toml')"
607
608    [{VALID_RULE_NAME_2}]
609    option3 = false
610    "#
611        );
612        let main_config_path = config_dir.join("main.toml");
613        fs::write(&main_config_path, &main_content).unwrap();
614
615        // Change current directory to the project root
616        env::set_current_dir(&project_dir).unwrap();
617
618        // Parse config
619        let config = Config::from_config_file(&main_config_path).unwrap();
620        let metadata = ConfigMetadata::from(&Config::try_from(config).unwrap());
621        let locations = metadata.config_file_locations.unwrap();
622
623        assert!(locations.len() == 2);
624        assert!(locations.get(VALID_RULE_NAME).unwrap() == "rules/rule_config.toml");
625        assert!(locations.get(VALID_RULE_NAME_2).unwrap() == "configs/main.toml");
626
627        // Restore original directory
628        env::set_current_dir(original_dir).unwrap();
629    }
630}