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 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 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 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 ¤t_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]; 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 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 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 #[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 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 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 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 env::set_current_dir(&project_dir).unwrap();
617
618 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 env::set_current_dir(original_dir).unwrap();
629 }
630}