supa_mdx_lint/
lib.rs

1use anyhow::{Context as _, Result};
2use bon::bon;
3use context::Context;
4use rules::RuleFilter;
5use std::env;
6use std::path::{Path, PathBuf};
7use std::{fs, io::Read};
8
9use crate::output::LintOutput;
10use crate::parser::parse;
11
12mod app_error;
13mod comments;
14mod config;
15mod context;
16mod errors;
17pub mod location;
18pub(crate) mod parser;
19mod utils;
20
21pub mod fix;
22pub mod output;
23#[doc(hidden)]
24pub mod rope;
25pub mod rules;
26
27#[doc(inline)]
28pub use crate::config::{Config, ConfigDir, ConfigMetadata};
29#[doc(inline)]
30pub use crate::errors::{LintError, LintLevel};
31
32#[derive(Debug)]
33pub struct PhaseSetup;
34
35#[derive(Debug)]
36pub struct PhaseReady;
37
38#[derive(Debug)]
39pub struct Linter {
40    config: Config<PhaseReady>,
41}
42
43#[derive(Debug)]
44pub enum LintTarget<'a> {
45    FileOrDirectory(PathBuf),
46    String(&'a str),
47}
48
49struct LintSourceReference<'reference>(Option<&'reference Path>);
50
51#[bon]
52impl Linter {
53    #[builder]
54    pub fn new(config: Option<Config<PhaseSetup>>) -> Result<Self> {
55        Ok(Self {
56            config: config.unwrap_or_default().try_into()?,
57        })
58    }
59
60    pub fn config_metadata(&self) -> ConfigMetadata {
61        (&self.config).into()
62    }
63
64    pub fn is_lintable(&self, path: impl AsRef<Path>) -> bool {
65        self.config.is_lintable(path)
66    }
67
68    pub fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
69        self.config.is_ignored(path)
70    }
71
72    pub fn lint(&self, input: &LintTarget) -> Result<Vec<LintOutput>> {
73        self.lint_internal(input, None)
74    }
75
76    pub fn lint_only_rule(&self, rule_id: &str, input: &LintTarget) -> Result<Vec<LintOutput>> {
77        self.lint_internal(input, Some(&[rule_id]))
78    }
79
80    fn lint_internal(
81        &self,
82        input: &LintTarget,
83        check_only_rules: RuleFilter,
84    ) -> Result<Vec<LintOutput>> {
85        match input {
86            LintTarget::FileOrDirectory(path) => {
87                self.lint_file_or_directory(path, check_only_rules)
88            }
89            LintTarget::String(string) => {
90                self.lint_string(string, LintSourceReference(None), check_only_rules)
91            }
92        }
93    }
94
95    fn lint_file_or_directory(
96        &self,
97        path: &PathBuf,
98        check_only_rules: RuleFilter,
99    ) -> Result<Vec<LintOutput>> {
100        if path.is_file() {
101            if self.config.is_ignored(path) {
102                return Ok(Vec::new());
103            }
104
105            let mut file = fs::File::open(path)?;
106            let mut contents = String::new();
107            file.read_to_string(&mut contents)?;
108            self.lint_string(&contents, LintSourceReference(Some(path)), check_only_rules)
109        } else if path.is_dir() {
110            let collected_vec = fs::read_dir(path)?
111                .filter_map(Result::ok)
112                .filter(|dir_entry| self.is_lintable(dir_entry.path()))
113                .flat_map(|entry| {
114                    self.lint_file_or_directory(&entry.path(), check_only_rules)
115                        .unwrap_or_default()
116                })
117                .collect::<Vec<_>>();
118            Ok(collected_vec)
119        } else {
120            Err(anyhow::anyhow!(
121                "Path is neither a file nor a directory: {:?}",
122                path
123            ))
124        }
125    }
126
127    fn lint_string(
128        &self,
129        string: &str,
130        source: LintSourceReference,
131        check_only_rules: RuleFilter,
132    ) -> Result<Vec<LintOutput>> {
133        let parse_result = parse(string)?;
134        let rule_context = Context::builder()
135            .parse_result(&parse_result)
136            .maybe_check_only_rules(check_only_rules)
137            .build()?;
138        match self.config.rule_registry.run(&rule_context) {
139            Ok(diagnostics) => {
140                let source = match source.0 {
141                    Some(path) => {
142                        let current_dir =
143                            env::current_dir().context("Failed to get current directory")?;
144                        let relative_path = match path.strip_prefix(&current_dir) {
145                            Ok(relative_path) => relative_path,
146                            Err(_) => path,
147                        };
148                        &relative_path.to_string_lossy()
149                    }
150                    None => "[direct input]",
151                };
152                Ok(vec![LintOutput::new(source, diagnostics)])
153            }
154            Err(err) => Err(err),
155        }
156    }
157}
158
159mod private {
160    pub trait Sealed {}
161    impl<T: Sealed> Sealed for &T {}
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    use ctor::ctor;
169
170    #[ctor]
171    fn init_test_logger() {
172        env_logger::builder().is_test(true).try_init().unwrap();
173    }
174
175    #[test]
176    fn public_api() {
177        // Install a compatible nightly toolchain if it is missing
178        rustup_toolchain::install(public_api::MINIMUM_NIGHTLY_RUST_VERSION).unwrap();
179
180        // Build rustdoc JSON
181        let rustdoc_json = rustdoc_json::Builder::default()
182            .toolchain(public_api::MINIMUM_NIGHTLY_RUST_VERSION)
183            .build()
184            .unwrap();
185
186        // Derive the public API from the rustdoc JSON
187        let public_api = public_api::Builder::from_rustdoc_json(rustdoc_json)
188            .build()
189            .unwrap();
190
191        // Assert that the public API looks correct
192        insta::assert_snapshot!(public_api);
193    }
194
195    #[test]
196    fn test_lint_valid_string() -> Result<()> {
197        let mut linter = Linter::builder().build()?;
198        linter
199            .config
200            .rule_registry
201            .deactivate_all_but("Rule001HeadingCase");
202
203        let valid_mdx = "# Hello, world!\n\nThis is a valid document.";
204        let result = linter.lint(&LintTarget::String(&valid_mdx.to_string()))?;
205
206        assert!(
207            result.get(0).unwrap().errors().is_empty(),
208            "Expected no lint errors for valid MDX, got {:?}",
209            result
210        );
211        Ok(())
212    }
213
214    #[test]
215    fn test_lint_invalid_string() -> Result<()> {
216        let mut linter = Linter::builder().build()?;
217        linter
218            .config
219            .rule_registry
220            .deactivate_all_but("Rule001HeadingCase");
221
222        let invalid_mdx = "# Incorrect Heading\n\nThis is an invalid document.";
223        let result = linter.lint(&LintTarget::String(&invalid_mdx.to_string()))?;
224
225        assert!(
226            !result.get(0).unwrap().errors().is_empty(),
227            "Expected lint errors for invalid MDX"
228        );
229        Ok(())
230    }
231}