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(¤t_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 rustup_toolchain::install(public_api::MINIMUM_NIGHTLY_RUST_VERSION).unwrap();
179
180 let rustdoc_json = rustdoc_json::Builder::default()
182 .toolchain(public_api::MINIMUM_NIGHTLY_RUST_VERSION)
183 .build()
184 .unwrap();
185
186 let public_api = public_api::Builder::from_rustdoc_json(rustdoc_json)
188 .build()
189 .unwrap();
190
191 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}