diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7041dd..7173503 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,7 +66,7 @@ jobs: with: targets: ${{ matrix.target }} - - run: cargo build --target=${{ matrix.target }} --release --locked + - run: cargo build --all-features --target=${{ matrix.target }} --release --locked - run: mv target/${{ matrix.target }}/release/supa-mdx-lint supa-mdx-lint-Darwin-${{ matrix.arch }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 #v4.6.0 @@ -114,7 +114,7 @@ jobs: with: targets: ${{ matrix.target }} - - run: cargo build --target=${{ matrix.target }} --release --locked + - run: cargo build --all-features --target=${{ matrix.target }} --release --locked - run: mv target/${{ matrix.target }}/release/supa-mdx-lint.exe supa-mdx-lint-Windows-${{ matrix.arch }}.exe diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a9ab7b..1ba8d80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,8 +32,10 @@ jobs: run: cargo clippy - name: rust tests run: cargo test - - name: release build + - name: release build (default) run: cargo build --release + - name: release build (all features) + run: cargo build --all-features --release test-macos: runs-on: macos-latest @@ -54,8 +56,10 @@ jobs: run: cargo clippy - name: rust tests run: cargo test - - name: release build + - name: release build (default) run: cargo build --release + - name: release build (all features) + run: cargo build --all-features --release test-windows: runs-on: windows-latest @@ -76,5 +80,7 @@ jobs: run: cargo clippy - name: rust tests run: cargo test - - name: release build + - name: release build (default) run: cargo build --release + - name: release build (all features) + run: cargo build --all-features --release diff --git a/Cargo.lock b/Cargo.lock index 5195db1..319d64f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -88,6 +103,30 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -188,6 +227,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "console" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + [[package]] name = "crop" version = "0.4.2" @@ -318,6 +370,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -336,6 +401,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "0.1.2" @@ -423,6 +494,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "glob" version = "0.3.1" @@ -455,14 +532,20 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -517,6 +600,46 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "miette" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a955165f87b37fd1862df2a59547ac542c77ef6d17c666f619d1ad22dd89484" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf45bf44ab49be92fd1227a3be6fc6f617f1a337c06af54981048574d8783147" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -547,12 +670,27 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "powerfmt" version = "0.2.0" @@ -646,6 +784,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + [[package]] name = "rustix" version = "0.38.41" @@ -725,6 +869,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "simplelog" version = "0.12.2" @@ -764,13 +914,17 @@ dependencies = [ "clap", "crop", "ctor", + "dialoguer", "env_logger", "exitcode", "gag", "glob", + "indexmap", "itertools", "log", "markdown", + "miette", + "owo-colors", "predicates", "regex", "regex-syntax", @@ -793,6 +947,27 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "supports-color" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c0a1e5168041f5f3ff68ff7d95dcb9c8749df29f6e7e89ada40dd4c9de404ee" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "symspell" version = "0.4.3" @@ -851,12 +1026,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "termtree" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -976,12 +1171,30 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unidecode" version = "0.3.0" @@ -1190,3 +1403,9 @@ checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 085eaee..e5e50fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,16 @@ anyhow = "1.0.89" bon = "3.3.2" clap = { version = "4.5.20", features = ["derive"] } crop = { version = "0.4.2", features = ["graphemes"] } +dialoguer = { version = "0.11.0", optional = true } exitcode = "1.1.2" gag = "1.0.0" glob = "0.3.1" +indexmap = "2.7.1" itertools = "0.13.0" log = "0.4.22" markdown = "1.0.0-alpha.21" +miette = { version = "7.5.0", optional = true, features = ["fancy"] } +owo-colors = { version = "4.1.0", optional = true } regex = "1.11.0" regex-syntax = { version = "0.8.5", features = ["std", "unicode-perl"] } serde = { version = "1.0.210", features = ["derive"] } @@ -32,3 +36,7 @@ ctor = "0.2.8" env_logger = "0.11.5" predicates = "3.1.2" tempfile = "3.13.0" + +[features] +interactive = ["dep:dialoguer", "dep:owo-colors", "pretty"] +pretty = ["dep:miette"] diff --git a/scripts/build-in-docker.sh b/scripts/build-in-docker.sh index 7ffc463..1ded84c 100755 --- a/scripts/build-in-docker.sh +++ b/scripts/build-in-docker.sh @@ -18,7 +18,7 @@ DOCKER_RUN_OPTS=" docker run \ ${DOCKER_RUN_OPTS} \ - cargo build --release --target=${TARGET} --locked + cargo build --all-features --release --target=${TARGET} --locked # Fix permissions for shared directories USER_ID=$(id -u) diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 4a28bdb..67bac04 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -52,6 +52,6 @@ update_package_json "packages/supa-mdx-lint/package.json" "$NEW_VERSION" update_optional_dependencies "packages/supa-mdx-lint/package.json" "$NEW_VERSION" -cargo build --release +cargo build --all-features --release echo "Version updated to $NEW_VERSION in all manifest files." diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..e859342 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "interactive")] +mod interactive; + +#[cfg(feature = "interactive")] +pub use interactive::InteractiveFixManager; diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs new file mode 100644 index 0000000..18b2164 --- /dev/null +++ b/src/cli/interactive.rs @@ -0,0 +1,392 @@ +use std::{collections::HashSet, fs, ops::Range, path::PathBuf}; + +use anyhow::Result; +use bon::bon; +use dialoguer::{Confirm, Editor, Select}; +use miette::{miette, LabeledSpan, NamedSource, Severity}; +use owo_colors::OwoColorize; +use supa_mdx_lint::{ + errors::LintError, + fix::LintCorrection, + rope::{Rope, RopeSlice}, + utils::Offsets, + LintTarget, Linter, +}; + +enum CorrectionStrategy { + Fix, + Skip, +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +struct ErrorCacheKey(usize, usize, String); + +impl From<&LintError> for ErrorCacheKey { + fn from(error: &LintError) -> Self { + Self(error.start(), error.end(), error.message().to_string()) + } +} + +struct CachedFile { + path: PathBuf, + rope: Rope, + content: String, + has_diagnostics: bool, + edited: bool, + skipped: HashSet, +} + +impl CachedFile { + fn load(path: impl Into) -> Result { + let path = path.into(); + let content = fs::read_to_string(&path)?; + let rope = Rope::from(content.as_str()); + Ok(Self { + path, + rope, + content, + has_diagnostics: false, + edited: false, + skipped: HashSet::new(), + }) + } + + fn sync_staged_contents(&mut self) { + self.content = self.rope.to_string(); + self.edited = true; + } + + fn commit_contents(&self) -> Result<()> { + fs::write(&self.path, self.content.as_str())?; + Ok(()) + } +} + +pub struct InteractiveFixManager<'a, 'b> { + linter: &'a Linter, + targets: Vec>, + curr_file: Option, +} + +#[bon] +impl<'a, 'b> InteractiveFixManager<'a, 'b> { + pub fn new(linter: &'a Linter, targets: Vec>) -> Self { + Self { + linter, + targets, + curr_file: None, + } + } + + pub fn run(&mut self) -> Result<()> { + for idx in 0..self.targets.len() { + #[cfg(debug_assertions)] + log::trace!("Linting target {idx}: {:?}", self.targets.get(idx)); + + let target = self.targets.get(idx).unwrap(); + let LintTarget::FileOrDirectory(path) = target else { + continue; + }; + self.curr_file = Some(CachedFile::load(path)?); + + self.run_relint_loop()?; + + if !self.curr_file.as_ref().unwrap().has_diagnostics { + continue; + } + + let mut new_prompt = if self.curr_file.as_ref().unwrap().edited { + match self.curr_file.as_mut().unwrap().commit_contents() { + Ok(_) => "šŸ’¾ Changes successfully written to file".to_string(), + Err(err) => format!("šŸšØ Error writing changes to file: {err}").to_string(), + } + } else { + "0ļøāƒ£ No edits made to current file".to_string() + }; + + self.curr_file = None; + new_prompt.push_str("\n\nšŸ‘‰ Continue to next file?"); + + match Confirm::new() + .with_prompt(format!("\n\n{new_prompt}")) + .report(false) + .interact()? + { + true => continue, + false => break, + } + } + println!("\n\nšŸŽ‰ Finished!"); + Ok(()) + } + + pub fn run_relint_loop(&mut self) -> Result<()> { + 'relint: loop { + let diagnostics = self.linter.lint(&LintTarget::String( + &self.curr_file.as_ref().unwrap().content.as_str(), + ))?; + match diagnostics.get(0) { + Some(diagnostic) if !diagnostic.errors().is_empty() => { + self.curr_file.as_mut().unwrap().has_diagnostics = true; + for error in diagnostic.errors().iter() { + if self + .curr_file + .as_ref() + .unwrap() + .skipped + .contains(&error.into()) + { + continue; + } + + if let Some(CorrectionStrategy::Fix) = + self.prompt_error().error(error).call()? + { + continue 'relint; + } + } + break 'relint; + } + _ => { + break 'relint; + } + } + } + Ok(()) + } + + #[builder] + fn prompt_error(&mut self, error: &LintError) -> Result> { + let pretty_error = self.pretty_error( + error, + &self + .curr_file + .as_ref() + .unwrap() + .path + .to_string_lossy() + .to_string(), + self.curr_file.as_ref().unwrap().content.clone(), + ); + + let message = format!("\n\nError"); + let suggestions_heading = "Suggestions" + .bold() + .underline() + .bright_magenta() + .to_string(); + let suggestion_number = + |i: usize| -> String { format!("Suggestion {}:", i + 1).bold().to_string() }; + + let combined_suggestions = error.combined_suggestions(); + let suggestions = match combined_suggestions { + Some(suggestions) if !suggestions.is_empty() => self.pretty_suggestions( + &suggestions, + self.curr_file.as_ref().unwrap().rope.byte_slice(..), + ), + _ => todo!(), + }; + + let suggestions_string = suggestions + .iter() + .enumerate() + .map(|(idx, (suggestion_string, _))| { + format!("{} {}", suggestion_number(idx), suggestion_string) + }) + .collect::>() + .join("\n\n"); + + let custom_edit_prompt = Self::custom_edit_prompt(suggestions.len() + 1); + let skip_for_now_prompt = Self::skip_for_now_prompt(suggestions.len() + 2); + + let selection = Select::new() + .with_prompt(format!( + "\n{}\n\n{:?}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}", + message.bold().red().underline(), + pretty_error, + suggestions_heading, + suggestions_string, + custom_edit_prompt, + skip_for_now_prompt, + "Choose an option" + )) + .items( + &(0..suggestions.len() + 2) + .map(|i| format!("Suggestion {}", i + 1)) + .collect::>(), + ) + .interact()?; + + match selection { + n if n == suggestions.len() + 1 => { + self.curr_file + .as_mut() + .unwrap() + .skipped + .insert(error.into()); + Ok(Some(CorrectionStrategy::Skip)) + } + n if n == suggestions.len() => { + self.custom_edit(error)?; + Ok(Some(CorrectionStrategy::Fix)) + } + n => { + let suggestion = suggestions.get(n).unwrap(); + self.apply_suggestion(suggestion.1); + Ok(Some(CorrectionStrategy::Fix)) + } + } + } + + fn pretty_error( + &self, + error: &LintError, + file_name: &str, + content: String, + ) -> impl std::fmt::Debug { + let severity: Severity = error.level().into(); + let message = error.message(); + + miette!( + severity = severity, + labels = vec![LabeledSpan::at(error.offset_range(), "here")], + "{}", + message + ) + .with_source_code(NamedSource::new(file_name, content)) + } + + fn pretty_suggestions( + &self, + suggestions: &[&'a LintCorrection], + rope: RopeSlice<'_>, + ) -> Vec<(String, &'a LintCorrection)> { + let mut suggestions = suggestions + .into_iter() + .map(|suggestion| (self.format_suggestion(suggestion, rope), *suggestion)) + .collect::>(); + suggestions.sort(); + suggestions + } + + fn custom_edit_prompt(number: usize) -> String { + let mut result = format!("Suggestion {number}: ").bold().to_string(); + result.push_str("āœļø Make a custom edit"); + result + } + + fn skip_for_now_prompt(number: usize) -> String { + let mut result = format!("Suggestion {number}: ").bold().to_string(); + result.push_str("ā© Skip for now"); + result + } + + fn format_suggestion(&self, suggestion: &LintCorrection, rope: RopeSlice<'_>) -> String { + match suggestion { + LintCorrection::Insert(insert) => { + let line_offset_range = Self::bytes_from_offsets(insert, rope); + + let mut result = "āž• Insert text before marked character\n\n".to_string(); + result.push_str(&Self::mark_position_string(line_offset_range, insert, rope)); + result + } + LintCorrection::Delete(delete) => { + let line_offset_range = Self::bytes_from_offsets(delete, rope); + + let mut result = "āœ‚ļø Delete underlined text\n\n".to_string(); + result.push_str(&Self::mark_position_string(line_offset_range, delete, rope)); + result + } + LintCorrection::Replace(replace) => { + let line_offset_range = Self::bytes_from_offsets(replace, rope); + + let mut result = format!( + "šŸ”„ Replace underlined text with \"{}\"\n\n", + replace.text() + ); + result.push_str(&Self::mark_position_string( + line_offset_range, + replace, + rope, + )); + result + } + } + } + + fn apply_suggestion(&mut self, suggestion: &LintCorrection) { + let rope = &mut self.curr_file.as_mut().unwrap().rope; + + match suggestion { + LintCorrection::Insert(insert) => rope.insert(insert.start(), insert.text()), + LintCorrection::Delete(delete) => rope.delete(delete.start()..delete.end()), + LintCorrection::Replace(replace) => { + rope.replace(replace.start()..replace.end(), replace.text()) + } + } + + self.curr_file.as_mut().unwrap().sync_staged_contents(); + } + + fn custom_edit(&mut self, error: &LintError) -> Result<()> { + let rope = &mut self.curr_file.as_mut().unwrap().rope; + let edit_range = Self::bytes_from_offsets(error, rope.byte_slice(..)); + + let Some(revised_content) = + Editor::new().edit(&rope.byte_slice(edit_range.clone()).to_string())? + else { + println!("Editing canceled"); + return Ok(()); + }; + + rope.replace(edit_range, &revised_content); + self.curr_file.as_mut().unwrap().sync_staged_contents(); + Ok(()) + } + + fn bytes_from_offsets(offsets: impl Offsets, rope: RopeSlice<'_>) -> Range { + let start_line_byte = rope.byte_of_line(rope.line_of_byte(offsets.start())); + let end_line_byte = { + let line = rope.line_of_byte(offsets.end()); + if line == rope.line_len() - 1 { + rope.byte_len() + } else { + rope.byte_of_line(line + 1) + } + }; + Range { + start: start_line_byte, + end: end_line_byte, + } + } + + fn mark_position_string( + display_range: Range, + marked_range: impl Offsets, + rope: RopeSlice<'_>, + ) -> String { + // Build in a little extra for formatting markers + let mut result = String::with_capacity(display_range.end - display_range.start + 15); + + for char in rope + .byte_slice(display_range.start..marked_range.start()) + .chars() + { + result.push(char); + } + + let marked = rope + .byte_slice(marked_range.start()..marked_range.end()) + .to_string(); + result.push_str(&format!("{}", marked.underline())); + + for char in rope + .byte_slice(marked_range.end()..display_range.end) + .chars() + { + result.push(char); + } + + result + } +} diff --git a/src/config.rs b/src/config.rs index 2c3bd41..43e960c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,7 +72,8 @@ impl Config { let config_content = std::fs::read_to_string(&config_path) .inspect_err(|_| error!("Failed to read config file at {config_path:?}"))?; - let parsed = Self::process_includes(&config_content, config_dir).inspect_err(|_| { + let table: toml::Table = toml::from_str(&config_content)?; + let parsed = Self::process_includes(&table, config_dir).inspect_err(|_| { error!("Failed to parse config"); debug!("Config file content:\n\t{config_content}") })?; @@ -81,8 +82,7 @@ impl Config { Self::from_serializable(parsed, &config_dir) } - fn process_includes(raw_str: &str, base_dir: &Path) -> Result { - let table: toml::Table = toml::from_str(raw_str)?; + fn process_includes(table: &toml::Table, base_dir: &Path) -> Result { let mut processed_table = toml::Table::new(); for (key, value) in table { @@ -99,18 +99,22 @@ impl Config { e ) })?; - toml::from_str(&include_content).map_err(|e| { + let table: toml::Table = toml::from_str(&include_content)?; + toml::Value::Table(Self::process_includes(&table, base_dir).map_err(|e| { anyhow::anyhow!( "Failed to parse include file from path {:?}: {}", include_path, e ) - })? + })?) } - _ => value, + toml::Value::Table(table) => { + toml::Value::Table(Self::process_includes(table, base_dir)?) + } + _ => value.clone(), }; - processed_table.insert(key, processed_value); + processed_table.insert(key.clone(), processed_value); } Ok(processed_table) diff --git a/src/errors.rs b/src/errors.rs index df76515..b5c0f8a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use std::{fmt::Display, ops::Range}; use anyhow::Result; use bon::bon; @@ -9,13 +9,15 @@ use crate::{ fix::LintCorrection, geometry::{AdjustedPoint, AdjustedRange, DenormalizedLocation}, rules::RuleContext, + utils::Offsets, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum LintLevel { - Error, Warning, + #[default] + Error, } impl Display for LintLevel { @@ -42,19 +44,29 @@ impl TryFrom<&str> for LintLevel { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LintError { - pub rule: String, - pub level: LintLevel, - pub message: String, - pub location: DenormalizedLocation, - pub fix: Option>, - pub suggestions: Option>, + pub(crate) rule: String, + pub(crate) level: LintLevel, + pub(crate) message: String, + pub(crate) location: DenormalizedLocation, + pub(crate) fix: Option>, + pub(crate) suggestions: Option>, +} + +impl Offsets for LintError { + fn start(&self) -> usize { + self.location.offset_range.start.into() + } + + fn end(&self) -> usize { + self.location.offset_range.end.into() + } } #[bon] impl LintError { #[builder] #[allow(clippy::needless_lifetimes)] - pub fn new<'ctx>( + pub(crate) fn new<'ctx>( rule: impl AsRef, message: impl Into, level: LintLevel, @@ -81,9 +93,37 @@ impl LintError { } } + pub fn level(&self) -> LintLevel { + self.level + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn offset_range(&self) -> Range { + self.location.offset_range.to_usize_range() + } + + pub fn combined_suggestions(&self) -> Option> { + match (self.fix.as_ref(), self.suggestions.as_ref()) { + (None, None) => None, + (fix, suggestions) => { + let mut combined = Vec::new(); + if let Some(f) = fix { + combined.extend(f.iter()); + } + if let Some(s) = suggestions { + combined.extend(s.iter()); + } + Some(combined) + } + } + } + #[builder] #[allow(clippy::needless_lifetimes)] - pub fn from_node<'ctx>( + pub(crate) fn from_node<'ctx>( /// The AST node to generate the error location from. node: &Node, context: &RuleContext<'ctx>, @@ -113,7 +153,7 @@ impl LintError { } #[builder] - pub fn from_raw_location( + pub(crate) fn from_raw_location( rule: impl AsRef, message: impl Into, level: LintLevel, diff --git a/src/fix.rs b/src/fix.rs index f94e359..e9ff3f3 100644 --- a/src/fix.rs +++ b/src/fix.rs @@ -1,6 +1,7 @@ -use std::{cmp::Ordering, fs}; +use std::{borrow::Cow, cmp::Ordering, fs}; use anyhow::Result; +use bon::bon; use log::{debug, error, trace}; use serde::{Deserialize, Serialize}; @@ -9,7 +10,11 @@ use crate::{ geometry::{AdjustedRange, DenormalizedLocation}, output::LintOutput, rope::Rope, - Linter, + utils::{ + words::{is_sentence_start, WordIterator}, + Offsets, + }, + Linter, RuleContext, }; #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] @@ -22,19 +27,61 @@ pub enum LintCorrection { #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct LintCorrectionInsert { /// Text is inserted in front of the start point. The end point is ignored. - pub location: DenormalizedLocation, - pub text: String, + pub(crate) location: DenormalizedLocation, + pub(crate) text: String, } #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct LintCorrectionDelete { - pub location: DenormalizedLocation, + pub(crate) location: DenormalizedLocation, } #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct LintCorrectionReplace { - pub location: DenormalizedLocation, - pub text: String, + pub(crate) location: DenormalizedLocation, + pub(crate) text: String, +} + +impl Offsets for LintCorrectionInsert { + fn start(&self) -> usize { + self.location.offset_range.start.into() + } + + fn end(&self) -> usize { + self.location.offset_range.end.into() + } +} + +impl LintCorrectionInsert { + pub fn text(&self) -> &str { + &self.text + } +} + +impl Offsets for LintCorrectionDelete { + fn start(&self) -> usize { + self.location.offset_range.start.into() + } + + fn end(&self) -> usize { + self.location.offset_range.end.into() + } +} + +impl Offsets for LintCorrectionReplace { + fn start(&self) -> usize { + self.location.offset_range.start.into() + } + + fn end(&self) -> usize { + self.location.offset_range.end.into() + } +} + +impl LintCorrectionReplace { + pub fn text(&self) -> &str { + &self.text + } } impl PartialOrd for LintCorrection { @@ -143,6 +190,7 @@ impl Ord for LintCorrection { } } +#[bon] impl LintCorrection { /// Given two conflicting fixes, choose one to apply, or create a new fix /// that merges the two. Returns `None` if the's not clear which one to @@ -253,6 +301,83 @@ impl LintCorrection { } } } + + #[builder] + pub(crate) fn create_word_splice_correction( + context: &RuleContext<'_>, + outer_range: &AdjustedRange, + splice_range: &AdjustedRange, + #[builder(default = true)] count_beginning_as_sentence_start: bool, + replace: Option>, + ) -> Self { + let outer_text = context.rope().byte_slice(outer_range.to_usize_range()); + let is_sentence_start = is_sentence_start() + .slice(outer_text) + .query_offset(splice_range.start.into_usize() - outer_range.start.into_usize()) + .count_beginning_as_sentence_start(count_beginning_as_sentence_start) + .call(); + + let location = DenormalizedLocation::from_offset_range(splice_range.clone(), context); + + match replace { + Some(replace) => { + let replace = if is_sentence_start { + replace.chars().next().unwrap().to_uppercase().to_string() + &replace[1..] + } else { + replace.to_string() + }; + + LintCorrection::Replace(LintCorrectionReplace { + location, + text: replace, + }) + } + None => { + let mut iter = WordIterator::new( + context.rope().byte_slice(splice_range.end.into_usize()..), + splice_range.end.into(), + Default::default(), + ); + + if let Some((offset, _, _)) = iter.next() { + let mut between = context + .rope() + .byte_slice(splice_range.end.into()..offset) + .chars(); + if between.all(|c| c.is_whitespace()) { + if is_sentence_start { + let location = DenormalizedLocation::from_offset_range( + AdjustedRange::new(splice_range.start, (offset + 1).into()), + context, + ); + LintCorrection::Replace(LintCorrectionReplace { + location, + text: context + .rope() + .byte_slice(offset..) + .chars() + .next() + .unwrap() + .to_string() + .to_uppercase(), + }) + } else { + LintCorrection::Delete(LintCorrectionDelete { + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(splice_range.start, offset.into()), + context, + ), + }) + } + } else { + LintCorrection::Delete(LintCorrectionDelete { location }) + } + } else { + LintCorrection::Delete(LintCorrectionDelete { location }) + } + } + } + } } impl Linter { @@ -365,3 +490,126 @@ impl Linter { fixes_to_apply } } + +#[cfg(test)] +mod tests { + use crate::parse; + + use super::*; + + #[test] + fn test_create_word_splice_correction_midsentence() { + let parsed = parse("Here is a simple sentence.").unwrap(); + let context = RuleContext::builder().parse_result(parsed).build().unwrap(); + + let outer_range = AdjustedRange::new(0.into(), 26.into()); + let splice_range = AdjustedRange::new(10.into(), 16.into()); + + let expected = LintCorrection::Delete(LintCorrectionDelete { + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(10.into(), 17.into()), + &context, + ), + }); + let actual = LintCorrection::create_word_splice_correction() + .context(&context) + .outer_range(&outer_range) + .splice_range(&splice_range) + .call(); + assert_eq!(expected, actual); + } + + #[test] + fn test_create_word_splice_correction_midsentence_replace() { + let parsed = parse("Here is a simple sentence.").unwrap(); + let context = RuleContext::builder().parse_result(parsed).build().unwrap(); + + let outer_range = AdjustedRange::new(0.into(), 26.into()); + let splice_range = AdjustedRange::new(10.into(), 16.into()); + + let expected = LintCorrection::Replace(LintCorrectionReplace { + text: "lovely".to_string(), + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(10.into(), 16.into()), + &context, + ), + }); + let actual = LintCorrection::create_word_splice_correction() + .context(&context) + .outer_range(&outer_range) + .splice_range(&splice_range) + .replace("lovely".into()) + .call(); + assert_eq!(expected, actual); + } + + #[test] + fn test_create_word_splice_correction_new_sentence() { + let parsed = parse("What a lovely day. Please take a biscuit.").unwrap(); + let context = RuleContext::builder().parse_result(parsed).build().unwrap(); + + let outer_range = AdjustedRange::new(0.into(), 41.into()); + let splice_range = AdjustedRange::new(19.into(), 25.into()); + + let expected = LintCorrection::Replace(LintCorrectionReplace { + text: "T".to_string(), + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(19.into(), 27.into()), + &context, + ), + }); + let actual = LintCorrection::create_word_splice_correction() + .context(&context) + .outer_range(&outer_range) + .splice_range(&splice_range) + .call(); + assert_eq!(expected, actual); + } + + #[test] + fn test_create_word_splice_correction_new_sentence_replace() { + let parsed = parse("What a lovely day. Please take a biscuit.").unwrap(); + let context = RuleContext::builder().parse_result(parsed).build().unwrap(); + + let outer_range = AdjustedRange::new(0.into(), 41.into()); + let splice_range = AdjustedRange::new(19.into(), 25.into()); + + let expected = LintCorrection::Replace(LintCorrectionReplace { + text: "Kindly".to_string(), + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(19.into(), 25.into()), + &context, + ), + }); + let actual = LintCorrection::create_word_splice_correction() + .context(&context) + .outer_range(&outer_range) + .splice_range(&splice_range) + .replace("kindly".into()) + .call(); + assert_eq!(expected, actual); + } + + #[test] + fn test_create_word_splice_correction_start() { + let parsed = parse("Please take a biscuit.").unwrap(); + let context = RuleContext::builder().parse_result(parsed).build().unwrap(); + + let outer_range = AdjustedRange::new(0.into(), 22.into()); + let splice_range = AdjustedRange::new(0.into(), 6.into()); + + let expected = LintCorrection::Replace(LintCorrectionReplace { + text: "T".to_string(), + location: DenormalizedLocation::from_offset_range( + AdjustedRange::new(0.into(), 8.into()), + &context, + ), + }); + let actual = LintCorrection::create_word_splice_correction() + .context(&context) + .outer_range(&outer_range) + .splice_range(&splice_range) + .call(); + assert_eq!(expected, actual); + } +} diff --git a/src/geometry.rs b/src/geometry.rs index 3f31f15..62e9c83 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -78,6 +78,10 @@ impl AdjustedOffset { pub(crate) fn from_unist(point: &markdown::unist::Point, context: &RuleContext) -> Self { Self::from_unadjusted(UnadjustedOffset::from(point), context) } + + pub(crate) fn into_usize(self) -> usize { + Into::::into(self) + } } /// An offset in the source document, not accounting for frontmatter lines. @@ -187,7 +191,7 @@ impl From<&AdjustedRange> for Range { } impl AdjustedRange { - pub fn new(start: AdjustedOffset, end: AdjustedOffset) -> Self { + pub(crate) fn new(start: AdjustedOffset, end: AdjustedOffset) -> Self { Self(Range { start, end }) } @@ -203,19 +207,25 @@ impl AdjustedRange { }) } - pub fn span_between(first: &Self, second: &Self) -> Self { + pub(crate) fn span_between(first: &Self, second: &Self) -> Self { let start = first.start.min(second.start); let end = first.end.max(second.end); Self(Range { start, end }) } - pub fn overlaps_or_abuts(&self, other: &Self) -> bool { + pub(crate) fn overlaps_or_abuts(&self, other: &Self) -> bool { if self.start > other.start { other.overlaps_or_abuts(self) } else { self.end >= other.start } } + + // Helper method to avoid having to call the ridiculous + // `Into::>::into` in many places. + pub fn to_usize_range(&self) -> Range { + Into::>::into(self) + } } #[derive(Debug, Default)] diff --git a/src/lib.rs b/src/lib.rs index dafe359..3d9147a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,12 +8,12 @@ use utils::is_lintable; mod app_error; mod config; -mod errors; -mod fix; +pub mod errors; +pub mod fix; mod geometry; mod output; mod parser; -mod rope; +pub mod rope; pub mod rules; pub mod utils; @@ -30,9 +30,9 @@ pub struct Linter { } #[derive(Debug)] -pub enum LintTarget { +pub enum LintTarget<'a> { FileOrDirectory(PathBuf), - String(String), + String(&'a str), } struct LintSourceReference<'reference>(Option<&'reference Path>); @@ -47,7 +47,7 @@ impl Linter { this.config .rule_registry - .setup(&this.config.rule_specific_settings)?; + .setup(&mut this.config.rule_specific_settings)?; Ok(this) } @@ -159,7 +159,7 @@ mod tests { .deactivate_all_but("Rule001HeadingCase"); let valid_mdx = "# Hello, world!\n\nThis is a valid document."; - let result = linter.lint(&LintTarget::String(valid_mdx.to_string()))?; + let result = linter.lint(&LintTarget::String(&valid_mdx.to_string()))?; assert!( result.get(0).unwrap().errors().is_empty(), @@ -178,7 +178,7 @@ mod tests { .deactivate_all_but("Rule001HeadingCase"); let invalid_mdx = "# Incorrect Heading\n\nThis is an invalid document."; - let result = linter.lint(&LintTarget::String(invalid_mdx.to_string()))?; + let result = linter.lint(&LintTarget::String(&invalid_mdx.to_string()))?; assert!( !result.get(0).unwrap().errors().is_empty(), diff --git a/src/main.rs b/src/main.rs index ff5c5c2..4d896e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,10 @@ use std::{ }; use anyhow::{Context, Result}; +use bon::builder; use clap::{error::ErrorKind, ArgGroup, CommandFactory, Parser}; +#[cfg(feature = "interactive")] +use cli::InteractiveFixManager; use glob::glob; use log::{debug, error}; use simplelog::{ColorChoice, Config as LogConfig, LevelFilter, TermLogger, TerminalMode}; @@ -15,6 +18,8 @@ use supa_mdx_lint::{ utils::is_lintable, Config, LintLevel, LintOutput, LintTarget, Linter, OutputFormatter, }; +mod cli; + const DEFAULT_CONFIG_FILE: &str = "supa-mdx-lint.config.toml"; #[derive(Parser, Debug)] @@ -35,6 +40,10 @@ struct Args { #[arg(short, long)] fix: bool, + #[cfg(feature = "interactive")] + #[arg(short, long, requires_all = ["fix", "enable_experimental"], conflicts_with = "silent")] + interactive: bool, + /// Output format #[arg(long, value_name = "FORMAT", default_value = "simple", value_parser = clap::value_parser!(OutputFormatter))] format: OutputFormatter, @@ -50,6 +59,9 @@ struct Args { #[cfg(debug_assertions)] #[arg(long)] trace: bool, + + #[arg(long)] + enable_experimental: bool, } fn setup_logging(args: &Args) -> Result { @@ -78,7 +90,11 @@ fn setup_logging(args: &Args) -> Result { Ok(log_level) } -fn get_diagnostics(targets: &[String], linter: &Linter) -> Result> { +#[builder] +fn get_targets( + targets: &[String], + #[builder(default = false)] expand_dirs: bool, +) -> Result>> { let mut all_targets = Vec::new(); for target in targets.iter() { @@ -90,6 +106,44 @@ fn get_diagnostics(targets: &[String], linter: &Linter) -> Result Ok(all_targets), + true => { + let mut new_targets = Vec::new(); + + let mut idx = 0; + while idx < all_targets.len() { + let target = all_targets + .get(idx) + .expect("Just checked length of all_targets array"); + match target { + LintTarget::FileOrDirectory(path) if path.is_dir() => { + for entry in std::fs::read_dir(path).context("Failed to read directory")? { + let entry = entry.context("Failed to get directory entry")?; + let path = entry.path(); + if is_lintable(&path) { + all_targets.push(LintTarget::FileOrDirectory(path)); + } + } + + idx += 1; + } + LintTarget::FileOrDirectory(path) => { + new_targets.push(LintTarget::FileOrDirectory(path.clone())); + idx += 1; + } + _ => unreachable!(), + } + } + + Ok(new_targets) + } + } +} + +fn get_diagnostics(targets: &[String], linter: &Linter) -> Result> { + let all_targets = get_targets().targets(targets).call()?; debug!("Lint targets: {targets:#?}"); let mut diagnostics = Vec::new(); @@ -136,12 +190,31 @@ fn execute() -> Result> { let linter = Linter::builder().config(config).build()?; debug!("Linter built: {linter:#?}"); - let mut diagnostics = get_diagnostics(&args.target, &linter)?; - let stdout = std::io::stdout().lock(); let mut stdout = BufWriter::new(stdout); - if args.fix { + #[cfg(feature = "interactive")] + if args.interactive { + return Ok(InteractiveFixManager::new( + &linter, + get_targets() + .targets(&args.target) + .expand_dirs(true) + .call()?, + ) + .run()); + } + + let mut diagnostics = get_diagnostics(&args.target, &linter)?; + + #[allow(unused_mut)] + let mut fix_only = args.fix; + #[cfg(feature = "interactive")] + if args.interactive { + fix_only = false; + } + + if fix_only { let (num_files_fixed, num_errors_fixed) = linter.fix(&diagnostics)?; if !args.silent { writeln!( @@ -178,7 +251,7 @@ fn execute() -> Result> { if diagnostics .iter() - .any(|d| d.errors().iter().any(|e| e.level == LintLevel::Error)) + .any(|d| d.errors().iter().any(|e| e.level() == LintLevel::Error)) { Ok(Err(anyhow::anyhow!("Linting errors found"))) } else { diff --git a/src/output.rs b/src/output.rs index 633a6b1..b45f428 100644 --- a/src/output.rs +++ b/src/output.rs @@ -4,6 +4,7 @@ use anyhow::Result; use crate::{app_error, errors::LintError}; +#[cfg(feature = "pretty")] pub mod pretty; pub mod rdf; pub mod simple; @@ -32,7 +33,9 @@ impl LintOutput { } #[derive(Debug, Clone)] +#[non_exhaustive] pub enum OutputFormatter { + #[cfg(feature = "pretty")] Pretty(pretty::PrettyFormatter), Simple(simple::SimpleFormatter), Rdf(rdf::RdfFormatter), @@ -41,6 +44,7 @@ pub enum OutputFormatter { impl OutputFormatter { pub fn format(&self, output: &[LintOutput], io: &mut Writer) -> Result<()> { match self { + #[cfg(feature = "pretty")] Self::Pretty(formatter) => formatter.format(output, io), Self::Simple(formatter) => formatter.format(output, io), Self::Rdf(formatter) => formatter.format(output, io), @@ -49,6 +53,7 @@ impl OutputFormatter { pub fn should_log_metadata(&self) -> bool { match self { + #[cfg(feature = "pretty")] Self::Pretty(formatter) => formatter.should_log_metadata(), Self::Simple(formatter) => formatter.should_log_metadata(), Self::Rdf(formatter) => formatter.should_log_metadata(), @@ -61,6 +66,7 @@ impl FromStr for OutputFormatter { fn from_str(s: &str) -> Result { match s { + #[cfg(feature = "pretty")] "pretty" => Ok(Self::Pretty(pretty::PrettyFormatter)), "simple" => Ok(Self::Simple(simple::SimpleFormatter)), "rdf" => Ok(Self::Rdf(rdf::RdfFormatter)), diff --git a/src/output/pretty.rs b/src/output/pretty.rs index a0a4e8d..4cbee8d 100644 --- a/src/output/pretty.rs +++ b/src/output/pretty.rs @@ -1,24 +1,23 @@ use std::{collections::HashSet, fs, io::Write}; use anyhow::Result; +use miette::{miette, LabeledSpan, NamedSource, Severity}; -use crate::{errors::LintLevel, rope::Rope}; +use crate::errors::LintLevel; use super::LintOutput; -/// Outputs linter diagnostics in the pretty format, for CLI display, which has -/// the structure: -/// -/// ```text -/// -/// =========== -/// [: ] -/// The line number containing the error -/// ^^^^^ -/// -/// [: ] -/// ... -/// ``` +impl From for Severity { + fn from(level: LintLevel) -> Self { + match level { + LintLevel::Error => Severity::Error, + LintLevel::Warning => Severity::Warning, + } + } +} + +/// Outputs linter diagnostics in the pretty format, for CLI display, using +/// Miette. /// /// The diagnostics are followed by a summary of the number of linted files, /// total errors, and total warnings. @@ -39,92 +38,32 @@ impl PrettyFormatter { if curr.errors.is_empty() { continue; } - - let content = fs::read_to_string(&curr.file_path)?; - let rope = Rope::from(content); - if written { writeln!(io)?; } written |= true; - writeln!(io, "{}", curr.file_path)?; - writeln!(io, "{}", "=".repeat(curr.file_path.len()))?; + let content = fs::read_to_string(&curr.file_path)?; for (idx, error) in curr.errors.iter().enumerate() { if idx > 0 { writeln!(io)?; } - writeln!(io, "[{}: {}] {}", error.level, error.rule, error.message)?; - - let start_line = rope.line_of_byte(error.location.offset_range.start.into()); - let end_line = rope.line_of_byte(error.location.offset_range.end.into()); - - for line_no in start_line..=end_line { - let line = rope.line(line_no); - let number_graphemes = line.graphemes().count(); - - let line_number_display = format!("{}: ", line_no + 1); - let line_number_length = line_number_display.len(); - - if line_no == start_line && line_no == end_line { - writeln!(io, "{}{}", line_number_display, line)?; - - let (_line, start_col) = - rope.line_column_of_byte(error.location.offset_range.start.into()); - let (_line, end_col) = - rope.line_column_of_byte(error.location.offset_range.end.into()); - let graphemes_before = line.byte_slice(..start_col).graphemes().count(); - let graphemes_within = - line.byte_slice(start_col..end_col).graphemes().count(); - - writeln!( - io, - "{}{}{}{}", - " ".repeat(line_number_length), - " ".repeat(graphemes_before), - "^".repeat(graphemes_within), - " ".repeat(number_graphemes - graphemes_before - graphemes_within) - )?; - } else if line_no == start_line { - writeln!(io, "{}{}", line_number_display, line)?; - - let (_line, col) = - rope.line_column_of_byte(error.location.offset_range.start.into()); - let graphemes_before = line.byte_slice(..col).graphemes().count(); - - writeln!( - io, - "{}{}{}", - " ".repeat(line_number_length), - " ".repeat(graphemes_before), - "^".repeat(number_graphemes - graphemes_before) - )?; - } else if line_no == end_line { - writeln!(io, "{}{}", " ".repeat(line_number_length), line)?; - - let (_line, col) = - rope.line_column_of_byte(error.location.offset_range.end.into()); - let graphemes_before = line.byte_slice(..col).graphemes().count(); - - writeln!( - io, - "{}{}{}", - " ".repeat(line_number_length), - "^".repeat(graphemes_before), - " ".repeat(number_graphemes - graphemes_before) - )?; - } else { - writeln!(io, "{}{}", " ".repeat(line_number_length), line)?; - writeln!( - io, - "{}{}", - " ".repeat(line_number_length), - "^".repeat(number_graphemes) - )?; - } - } + let severity: Severity = error.level.into(); + let message = error.message.clone(); + + let error = miette!( + severity = severity, + labels = vec![LabeledSpan::at( + error.location.offset_range.to_usize_range(), + "here" + )], + "{}", + message + ) + .with_source_code(NamedSource::new(&curr.file_path, content.clone())); + writeln!(io, "{:?}", error)?; } } diff --git a/src/rope.rs b/src/rope.rs index be0af2d..ac95827 100644 --- a/src/rope.rs +++ b/src/rope.rs @@ -1,7 +1,17 @@ use std::ops::{Deref, DerefMut}; +/// This is publicly exposed because we need it for the interactive fixing +/// feature, but should _not_ be considered part of the public API. There are +/// no guarantees about the stability of this type and its methods. +#[doc(hidden)] #[derive(Debug, Default, Clone, Eq, PartialEq)] -pub(crate) struct Rope(crop::Rope); +pub struct Rope(crop::Rope); + +/// This is publicly exposed because we need it for the interactive fixing +/// feature, but should _not_ be considered part of the public API. There are +/// no guarantees about the stability of this type and its methods. +#[doc(hidden)] +pub use crop::RopeSlice; impl Deref for Rope { type Target = crop::Rope; @@ -42,10 +52,59 @@ impl From for Rope { } impl Rope { - pub(crate) fn line_column_of_byte(&self, byte_offset: usize) -> (usize, usize) { + pub fn line_column_of_byte(&self, byte_offset: usize) -> (usize, usize) { + self.byte_slice(..).line_column_of_byte(byte_offset) + } +} + +/// This is publicly exposed because we need it for the interactive fixing +/// feature, but should _not_ be considered part of the public API. There are +/// no guarantees about the stability of this type and its methods. +#[doc(hidden)] +pub trait RopeSliceExt { + fn eq_str(&self, s: &str) -> bool; + fn line_column_of_byte(&self, byte_offset: usize) -> (usize, usize); +} + +impl RopeSliceExt for RopeSlice<'_> { + fn eq_str(&self, s: &str) -> bool { + let mut this = self.bytes(); + let mut s = s.as_bytes().iter(); + + loop { + match (this.next(), s.next()) { + (Some(this_byte), Some(s_byte)) => { + if this_byte != *s_byte { + return false; + } + continue; + } + (None, None) => return true, + _ => return false, + } + } + } + + fn line_column_of_byte(&self, byte_offset: usize) -> (usize, usize) { let line = self.line_of_byte(byte_offset); let start_of_line = self.byte_of_line(line); let column = byte_offset - start_of_line; (line, column) } } + +#[cfg(test)] +mod tests { + use crate::rope::{Rope, RopeSliceExt as _}; + + #[test] + fn test_eq_str() { + let rope = Rope::from("hello world"); + assert!(rope.byte_slice(0..5).eq_str("hello")); + assert!(rope.byte_slice(6..11).eq_str("world")); + assert!(rope.byte_slice(..).eq_str("hello world")); + assert!(!rope.byte_slice(0..4).eq_str("hello")); + assert!(!rope.byte_slice(0..5).eq_str("world")); + assert!(!rope.byte_slice(6..11).eq_str("hello worlds")); + } +} diff --git a/src/rules.rs b/src/rules.rs index 092158c..d74fbd9 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -3,8 +3,12 @@ use bon::bon; use log::{debug, error, warn}; use markdown::mdast::Node; use regex::Regex; +use serde::Deserialize; use std::{collections::HashMap, fmt::Debug}; +#[cfg(test)] +use serde::Serialize; + use crate::{ errors::{LintError, LintLevel}, geometry::AdjustedOffset, @@ -15,22 +19,25 @@ use crate::{ mod rule001_heading_case; mod rule002_admonition_types; mod rule003_spelling; +mod rule004_exclude_words; pub use rule001_heading_case::Rule001HeadingCase; pub use rule002_admonition_types::Rule002AdmonitionTypes; pub use rule003_spelling::Rule003Spelling; +pub use rule004_exclude_words::Rule004ExcludeWords; fn get_all_rules() -> Vec> { vec![ Box::new(Rule001HeadingCase::default()), Box::new(Rule002AdmonitionTypes::default()), Box::new(Rule003Spelling::default()), + Box::new(Rule004ExcludeWords::default()), ] } -pub(crate) trait Rule: Debug + RuleName { +pub(crate) trait Rule: Debug + Send + RuleName { fn default_level(&self) -> LintLevel; - fn setup(&mut self, _settings: Option<&RuleSettings>) {} + fn setup(&mut self, _settings: Option<&mut RuleSettings>) {} fn check(&self, ast: &Node, context: &RuleContext, level: LintLevel) -> Option>; } @@ -185,6 +192,23 @@ impl RuleSettings { None } } + + #[cfg(test)] + fn with_serializable(key: &str, value: &T) -> Self { + Self::from_key_value(key, toml::Value::try_from(value).unwrap()) + } + + // TODO: global config should not keep carrying around the rule-level configs after the rules are set up, because the rules could mutate it + fn get_deserializable Deserialize<'de>>(&mut self, key: &str) -> Option { + if let toml::Value::Table(ref mut table) = self.0 { + if let Some(value) = table.remove(key) { + if let Ok(item) = value.try_into() { + return Some(item); + } + } + } + None + } } pub(crate) type RuleFilter<'filter> = Option<&'filter [&'filter str]>; @@ -287,11 +311,11 @@ impl RuleRegistry { self.rules.retain(|rule| rule.name() == rule_name) } - pub fn setup(&mut self, settings: &HashMap) -> Result<()> { + pub fn setup(&mut self, settings: &mut HashMap) -> Result<()> { match self.state { RuleRegistryState::PreSetup => { for rule in &mut self.rules { - let rule_settings = settings.get(rule.name()); + let rule_settings = settings.get_mut(rule.name()); rule.setup(rule_settings); } self.state = RuleRegistryState::Ready; diff --git a/src/rules/rule001_heading_case.rs b/src/rules/rule001_heading_case.rs index 7c45657..159cf23 100644 --- a/src/rules/rule001_heading_case.rs +++ b/src/rules/rule001_heading_case.rs @@ -65,7 +65,7 @@ impl Rule for Rule001HeadingCase { LintLevel::Error } - fn setup(&mut self, settings: Option<&RuleSettings>) { + fn setup(&mut self, settings: Option<&mut RuleSettings>) { if let Some(settings) = settings { let regex_settings = RegexSettings { beginning: Some(RegexBeginning::VeryBeginning), @@ -438,8 +438,8 @@ mod tests { #[test] fn test_rule001_may_uppercase() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); + rule.setup(Some(&mut settings)); let mdx = "# This is an API heading"; let parse_result = parse(mdx).unwrap(); @@ -459,8 +459,8 @@ mod tests { #[test] fn test_rule001_may_lowercase() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_lowercase", vec!["the"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_lowercase", vec!["the"]); + rule.setup(Some(&mut settings)); let mdx = "# the quick brown fox"; let parse_result = parse(mdx).unwrap(); @@ -498,8 +498,9 @@ mod tests { #[test] fn test_rule001_may_uppercase_multi_word() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["New York City"]); - rule.setup(Some(&settings)); + let mut settings = + RuleSettings::with_array_of_strings("may_uppercase", vec!["New York City"]); + rule.setup(Some(&mut settings)); let mdx = "# This is about New York City"; let parse_result = parse(mdx).unwrap(); @@ -519,9 +520,9 @@ mod tests { #[test] fn test_rule001_multiple_exception_matches() { let mut rule = Rule001HeadingCase::default(); - let settings = + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["New York", "New York City"]); - rule.setup(Some(&settings)); + rule.setup(Some(&mut settings)); let mdx = "# This is about New York City"; let parse_result = parse(mdx).unwrap(); @@ -541,8 +542,8 @@ mod tests { #[test] fn test_rule001_may_uppercase_partial_match() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); + rule.setup(Some(&mut settings)); let mdx = "# This is an API-related topic"; let parse_result = parse(mdx).unwrap(); @@ -562,8 +563,8 @@ mod tests { #[test] fn test_rule001_may_lowercase_regex() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_lowercase", vec!["(the|a|an)"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_lowercase", vec!["(the|a|an)"]); + rule.setup(Some(&mut settings)); let mdx = "# the quick brown fox"; let parse_result = parse(mdx).unwrap(); @@ -583,8 +584,8 @@ mod tests { #[test] fn test_rule001_may_uppercase_regex_fails() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["[A-Z]{4,}"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["[A-Z]{4,}"]); + rule.setup(Some(&mut settings)); let mdx = "# This is an API call"; let parse_result = parse(mdx).unwrap(); @@ -625,9 +626,9 @@ mod tests { #[test] fn test_rule001_multi_word_exception_at_start() { let mut rule = Rule001HeadingCase::default(); - let settings = + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["Content Delivery Network"]); - rule.setup(Some(&settings)); + rule.setup(Some(&mut settings)); let mdx = "# Content Delivery Network latency"; let parse_result = parse(mdx).unwrap(); @@ -647,8 +648,8 @@ mod tests { #[test] fn test_rule001_multi_word_exception_in_middle() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["Magic Link"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["Magic Link"]); + rule.setup(Some(&mut settings)); let markdown = "### Enabling Magic Link signins"; let parse_result = parse(markdown).unwrap(); @@ -669,8 +670,9 @@ mod tests { #[test] fn test_rule001_brackets_around_exception() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["Edge Functions"]); - rule.setup(Some(&settings)); + let mut settings = + RuleSettings::with_array_of_strings("may_uppercase", vec!["Edge Functions"]); + rule.setup(Some(&mut settings)); let mdx = "# Deno (Edge Functions)"; let parse_result = parse(mdx).unwrap(); @@ -690,8 +692,9 @@ mod tests { #[test] fn test_rule001_complex_heading() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API", "OAuth"]); - rule.setup(Some(&settings)); + let mut settings = + RuleSettings::with_array_of_strings("may_uppercase", vec!["API", "OAuth"]); + rule.setup(Some(&mut settings)); let mdx = "# The basics of API authentication in OAuth"; let parse_result = parse(mdx).unwrap(); @@ -811,8 +814,8 @@ mod tests { #[test] fn test_rule001_heading_starts_with_may_uppercase_exception() { let mut rule = Rule001HeadingCase::default(); - let settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("may_uppercase", vec!["API"]); + rule.setup(Some(&mut settings)); let markdown = "### API Error codes"; let parse_result = parse(markdown).unwrap(); diff --git a/src/rules/rule002_admonition_types.rs b/src/rules/rule002_admonition_types.rs index e195be0..7a931e2 100644 --- a/src/rules/rule002_admonition_types.rs +++ b/src/rules/rule002_admonition_types.rs @@ -27,7 +27,7 @@ impl Rule for Rule002AdmonitionTypes { LintLevel::Error } - fn setup(&mut self, settings: Option<&RuleSettings>) { + fn setup(&mut self, settings: Option<&mut RuleSettings>) { if let Some(settings) = settings { if let Some(vec) = settings.get_array_of_strings("admonition_types") { self.admonition_types = vec; diff --git a/src/rules/rule003_spelling.rs b/src/rules/rule003_spelling.rs index fb9ebc7..d82cf54 100644 --- a/src/rules/rule003_spelling.rs +++ b/src/rules/rule003_spelling.rs @@ -90,7 +90,7 @@ impl Rule for Rule003Spelling { LintLevel::Error } - fn setup(&mut self, settings: Option<&RuleSettings>) { + fn setup(&mut self, settings: Option<&mut RuleSettings>) { if let Some(settings) = settings { if let Some(vec) = settings.get_array_of_regexes( "allow_list", @@ -513,8 +513,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -543,8 +543,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -573,8 +573,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("allow_list", vec!["[Hh]eloo"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["[Hh]eloo"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -717,8 +717,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -747,8 +747,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("allow_list", vec!["\\S+\\.toml"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["\\S+\\.toml"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -777,8 +777,9 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("allow_list", vec!["\\[#[A-Za-z-]+\\]"]); - rule.setup(Some(&settings)); + let mut settings = + RuleSettings::with_array_of_strings("allow_list", vec!["\\[#[A-Za-z-]+\\]"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context @@ -836,8 +837,8 @@ mod tests { .unwrap(); let mut rule = Rule003Spelling::default(); - let settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre", "post"]); - rule.setup(Some(&settings)); + let mut settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre", "post"]); + rule.setup(Some(&mut settings)); let errors = rule.check( context diff --git a/src/rules/rule004_exclude_words.rs b/src/rules/rule004_exclude_words.rs new file mode 100644 index 0000000..8ea587e --- /dev/null +++ b/src/rules/rule004_exclude_words.rs @@ -0,0 +1,1331 @@ +use std::{borrow::Cow, collections::HashMap, iter::Peekable, sync::LazyLock}; + +use bon::bon; +use crop::RopeSlice; +use indexmap::IndexSet; +use log::{debug, trace}; +use markdown::mdast; +use regex::Regex; +use serde::{ + de::{MapAccess, SeqAccess}, + ser::{SerializeMap, SerializeTuple}, + Deserialize, Serialize, Serializer, +}; +use supa_mdx_macros::RuleName; + +use crate::{ + errors::LintError, + fix::LintCorrection, + geometry::{AdjustedRange, DenormalizedLocation}, + rope::Rope, + utils::words::{ + extras::{WordIteratorExtension, WordIteratorPrefix}, + WordIterator, WordIteratorItem, + }, + LintLevel, +}; + +use super::{Rule, RuleContext, RuleName, RuleSettings}; + +#[derive(Debug, Default, RuleName)] +pub struct Rule004ExcludeWords(WordExclusionIndex); + +/// Provides an index of exclusions to allow for easy lookup and matching based +/// on the first word of the exclusion. +#[derive(Debug, Default)] +struct WordExclusionIndex { + index: WordExclusionIndexInner, + rules: Vec, +} + +#[derive(Debug, Default)] +struct WordExclusionIndexInner(HashMap, WordExclusionMeta>); + +#[derive(Debug, Default, PartialEq, Eq, Hash)] +struct Prefix<'a>(Cow<'a, str>, CaseSensitivity); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +enum CaseSensitivity { + Sensitive, + #[default] + Insensitive, +} + +#[derive(Debug, Default)] +struct WordExclusionMeta { + /// The trailing part of an exclusion, after the first word is stripped. + remainders: IndexSet, + /// The rule indexes and replacements associated with these exclusions, if + /// any. Rule indexes correspond to the position within the rules of the + /// WordExclusionIndex. + /// + /// Invariant: Ordering must correspond to the ordering of `remainders`. + details: Vec<(usize, Option)>, +} + +/// The definition of a user-defined rule. +/// +/// ## Fields +/// * `String` - A human-readable description of the rule +/// * `LintLevel` - The level at which the rule should be linted +#[derive(Debug, Default, Clone)] +struct RuleMeta(String, LintLevel); + +/// A structure to allow for deserialization from an easy-to-write rule config +/// format. +#[derive(Debug, Default)] +struct WordExclusionIndexIntermediate { + rule: HashMap, +} + +impl Serialize for WordExclusionIndexIntermediate { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.rule.len()))?; + for (key, value) in &self.rule { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for WordExclusionIndexIntermediate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = WordExclusionIndexIntermediate; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("A map of rule names to their exclusion details") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut rule = HashMap::new(); + + while let Some((key, value)) = + map.next_entry::()? + { + rule.insert(key, value); + } + + Ok(WordExclusionIndexIntermediate { rule }) + } + } + + deserializer.deserialize_any(Visitor) + } +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct WordExclusionMetaIntermediate { + #[serde(default)] + level: LintLevel, + #[serde(default)] + case_sensitive: bool, + words: Vec, + description: String, +} + +#[derive(Debug)] +enum ExclusionDefinition { + ExcludeOnly(String), + WithReplace(String, String), +} + +impl Serialize for ExclusionDefinition { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + ExclusionDefinition::ExcludeOnly(s) => serializer.serialize_str(s), + ExclusionDefinition::WithReplace(a, b) => { + let mut seq = serializer.serialize_tuple(2)?; + seq.serialize_element(a)?; + seq.serialize_element(b)?; + seq.end() + } + } + } +} + +impl<'de> Deserialize<'de> for ExclusionDefinition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ExclusionDefinition; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("A string (representing an exclusion) or a tuple of two strings (representing an exclusion and its replacement") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(ExclusionDefinition::ExcludeOnly(value.to_string())) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let first: String = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + let second: String = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + Ok(ExclusionDefinition::WithReplace(first, second)) + } + } + + deserializer.deserialize_any(Visitor) + } +} + +#[derive(Debug)] +struct IndexLookupResult<'a> { + case_sensitive_details: Option<&'a WordExclusionMeta>, + case_insensitive_details: Option<&'a WordExclusionMeta>, +} + +impl From for CaseSensitivity { + fn from(case_sensitive: bool) -> CaseSensitivity { + if case_sensitive { + CaseSensitivity::Sensitive + } else { + CaseSensitivity::Insensitive + } + } +} + +impl<'a> From<(Cow<'a, str>, CaseSensitivity)> for Prefix<'a> { + fn from((s, case_sensitivity): (Cow<'a, str>, CaseSensitivity)) -> Self { + let prefix = match case_sensitivity { + CaseSensitivity::Sensitive => s, + CaseSensitivity::Insensitive => s.to_lowercase().into(), + }; + Prefix(prefix, case_sensitivity) + } +} + +impl RuleMeta { + fn description(&self) -> &str { + &self.0 + } + + fn level(&self) -> LintLevel { + self.1 + } +} + +impl ExclusionDefinition { + fn into_parts(self) -> (String, Option) { + match self { + ExclusionDefinition::ExcludeOnly(w) => (w, None), + ExclusionDefinition::WithReplace(w, r) => (w, Some(r)), + } + } +} + +#[bon] +impl WordExclusionIndex { + #[builder] + fn insert_exclusion( + &mut self, + exclusion: ExclusionDefinition, + case_sensitivity: CaseSensitivity, + rule_index: usize, + ) { + let (word, replacement) = exclusion.into_parts(); + + let rope = Rope::from(word.as_ref()); + let mut iter = WordIterator::new(rope.byte_slice(..), 0, Default::default()); + + let prefix = iter.next(); + let remainder = iter.collect_remainder(); + + if let Some(prefix) = prefix { + self.handle_insert_prefix() + .prefix(prefix.1.to_string()) + .maybe_remainder(remainder) + .maybe_replacement(replacement) + .case_sensitivity(case_sensitivity) + .rule_index(rule_index) + .call(); + } + } + + #[builder] + fn handle_insert_prefix( + &mut self, + prefix: String, + remainder: Option, + replacement: Option, + case_sensitivity: CaseSensitivity, + rule_index: usize, + ) { + let prefix = Prefix::from((Cow::from(prefix), case_sensitivity)); + let remainder = remainder.unwrap_or_default(); + + let existing = self.index.0.get_mut(&prefix); + match existing { + Some(existing) => { + let (inserted_idx, is_new) = existing.remainders.insert_full(remainder); + + if is_new { + existing.details.push((rule_index, replacement)) + } else { + let rule_meta = self + .rules + .get(rule_index) + .expect("Rule meta previously inserted into global rule map"); + let new_rule_level = rule_meta.level(); + match self.rules.get_mut(inserted_idx) { + Some(existing_rule) if existing_rule.level() < new_rule_level => { + if let Some(idx) = existing.details.get_mut(inserted_idx) { + *idx = (rule_index, replacement) + } + } + _ => { + // The new rule doesn't outrank the existing one, + // leave it. + } + } + } + } + None => { + let mut remainders = IndexSet::new(); + remainders.insert(remainder); + + self.index.0.insert( + prefix, + WordExclusionMeta { + remainders, + details: vec![(rule_index, replacement)], + }, + ); + } + } + } + + fn get<'a, 'b: 'a>(&'a self, prefix: &'b str) -> IndexLookupResult { + let case_sensitive_key = Prefix::from((Cow::from(prefix), CaseSensitivity::Sensitive)); + let case_insensitive_key = Prefix::from((Cow::from(prefix), CaseSensitivity::Insensitive)); + + let case_sensitive = self.index.0.get(&case_sensitive_key); + let case_insensitive = self.index.0.get(&case_insensitive_key); + + IndexLookupResult { + case_sensitive_details: case_sensitive, + case_insensitive_details: case_insensitive, + } + } +} + +impl From for WordExclusionIndex { + fn from(exclude_words: WordExclusionIndexIntermediate) -> Self { + let mut this = Self { + index: WordExclusionIndexInner::default(), + rules: Vec::with_capacity(exclude_words.rule.len()), + }; + + for (_, rule_details) in exclude_words.rule { + let rule_index = this.rules.len(); + this.rules + .push(RuleMeta(rule_details.description, rule_details.level)); + + let words = rule_details.words; + for word in words { + this.insert_exclusion() + .exclusion(word) + .case_sensitivity(rule_details.case_sensitive.into()) + .rule_index(rule_index) + .call(); + } + } + + this + } +} + +impl Rule for Rule004ExcludeWords { + fn default_level(&self) -> LintLevel { + // An implementation is required for this trait, but this rule defines + // its levels in its own configuration, so this is ignored. + LintLevel::default() + } + + fn setup(&mut self, settings: Option<&mut RuleSettings>) { + trace!("Setting up Rule004ExcludeWords"); + + let Some(settings) = settings else { + return; + }; + + let rules = settings.get_deserializable::("rules"); + if let Some(rules) = rules { + self.0 = rules.into(); + } + + debug!("Rule 004 is set up: {:#?}", self) + } + + fn check( + &self, + ast: &mdast::Node, + context: &super::RuleContext, + _level: LintLevel, + ) -> Option> { + let mdast::Node::Text(text_node) = ast else { + return None; + }; + let Some(position) = &text_node.position else { + return None; + }; + debug!("Checking Rule 004 for node {:#?}", ast); + + let mut errors = None::>; + + let range = AdjustedRange::from_unadjusted_position(position, context); + let text = context + .rope() + .byte_slice(Into::>::into(range.clone())); + let mut word_iterator: WordIteratorExtension<'_, WordIteratorPrefix> = + WordIterator::new(text, range.start.into(), Default::default()).into(); + + loop { + let Some((offset, word, _)) = word_iterator.next() else { + break; + }; + let word = word.to_string(); + + let ExclusionMatch { + new_iterator, + match_, + } = self.match_exclusions(self.0.get(&word), word_iterator); + word_iterator = new_iterator; + + if let Some(MatchDetails { + last_word, + rule, + replacement, + }) = match_ + { + let end_offset = match last_word { + Some(last_word) => last_word.0 + last_word.1.len(), + None => offset + word.len(), + }; + + let error = self + .create_lint_error() + .beginning_offset(offset) + .end_offset(end_offset) + .maybe_replacement(replacement) + .rule(rule) + .range(range.clone()) + .context(context) + .call(); + errors.get_or_insert_with(Vec::new).push(error); + } + } + + errors + } +} + +enum Suffix<'a> { + Finish, + Remaining(&'a str), +} + +impl<'a> From<&'a str> for Suffix<'a> { + fn from(s: &'a str) -> Self { + match s { + "" => Suffix::Finish, + _ => Suffix::Remaining(s), + } + } +} + +struct ExclusionMatch<'a> { + new_iterator: WordIteratorExtension<'a, WordIteratorPrefix<'a>>, + match_: Option, +} + +#[derive(Debug)] +struct MatchDetails { + last_word: Option, + replacement: Option, + rule: RuleMeta, +} + +#[derive(Debug)] +struct MatchDetailsIntermediate<'a> { + match_: MatchDetailsIntermediateInner, + rule: RuleMeta, + replacement: &'a Option, +} + +#[derive(Debug)] +enum MatchDetailsIntermediateInner { + OneWord, + /// The match is multiple words long. The position of the last matching + /// word is tracked to calculate the full match range later. This is the + /// offset not in the text, but in the vector of matches so far. + MultipleWords(usize), +} + +#[derive(Debug)] +struct LastWordMatched(usize, String); + +#[bon] +impl Rule004ExcludeWords { + #[builder] + fn create_lint_error( + &self, + beginning_offset: usize, + end_offset: usize, + range: AdjustedRange, + replacement: Option, + context: &RuleContext<'_>, + rule: RuleMeta, + ) -> LintError { + trace!("Creating lint error for Rule004. Range: {range:#?}; Beginning offset: {beginning_offset}; End offset: {end_offset}"); + let narrowed_range = AdjustedRange::new(beginning_offset.into(), end_offset.into()); + let word = context.rope().byte_slice(narrowed_range.to_usize_range()); + + let suggestion = vec![LintCorrection::create_word_splice_correction() + .context(context) + .outer_range(&range) + .splice_range(&narrowed_range) + .maybe_replace(replacement.clone().map(Cow::from)) + .call()]; + let location = DenormalizedLocation::from_offset_range(narrowed_range, context); + let message = substitute_format_string(rule.description().to_string(), word, replacement); + + LintError::from_raw_location() + .rule(self.name()) + .message(message) + .level(rule.level()) + .location(location) + .suggestions(suggestion) + .call() + } + + fn match_exclusions<'a>( + &self, + IndexLookupResult { + case_sensitive_details, + case_insensitive_details, + }: IndexLookupResult, + words: WordIteratorExtension<'a, WordIteratorPrefix<'a>>, + ) -> ExclusionMatch<'a> { + trace!("Checking for need to match exclusions in Rule 004"); + if case_sensitive_details.is_none() && case_insensitive_details.is_none() { + return ExclusionMatch { + new_iterator: words, + match_: None, + }; + } + debug!("Matching exclusions in Rule 004"); + + let mut result_so_far = None::; + let all = combine_exclusions(case_sensitive_details, case_insensitive_details); + + let mut consumed = vec![]; + let words = self + .match_exclusions_rec() + .remaining(all) + .consumed(&mut consumed) + .words(words) + .result(&mut result_so_far) + .call(); + + let new_iterator = { + match result_so_far { + Some(MatchDetailsIntermediate { + match_: MatchDetailsIntermediateInner::MultipleWords(end_pos_incl), + .. + }) => reattach_unused_words(words, consumed.clone().into_iter(), end_pos_incl + 1), + _ => reattach_unused_words(words, consumed.clone().into_iter(), 0), + } + }; + ExclusionMatch { + new_iterator, + match_: result_so_far.map(|res| MatchDetails { + last_word: match res.match_ { + MatchDetailsIntermediateInner::OneWord => None, + MatchDetailsIntermediateInner::MultipleWords(end_pos_incl) => { + let last_word = consumed.into_iter().nth(end_pos_incl).expect( + "Saved result only points to actual positions in the list of matches", + ); + Some(LastWordMatched(last_word.0, last_word.1.to_string())) + } + }, + rule: res.rule, + replacement: res.replacement.clone(), + }), + } + } + + #[builder] + fn match_exclusions_rec<'a, 'b>( + &self, + /// Words that have been consumed so far. + consumed: &mut Vec>, + /// The remaining candidates that may still be viable matches. Stored + /// alongside their rule index. + mut remaining: Peekable< + impl Iterator, CaseSensitivity, &'a Option)>, + >, + /// The remaining words to match. + mut words: WordIteratorExtension<'b, WordIteratorPrefix<'b>>, + result: &mut Option>, + ) -> WordIteratorExtension<'b, WordIteratorPrefix<'b>> { + #[cfg(debug_assertions)] + trace!("Recursing through the match in Rule004. Consumed: \"{consumed:#?}\"; Current result: {result:#?}"); + + match words.next() { + None => { + // There are no words left in the string to match. If any of + // the prior matches were complete matches, then they are the + // longest matches. Pick an arbitary one. + if let Some((rule_index, _, _, repl)) = + remaining.find(|(_, rem, _, _)| matches!(rem, Suffix::Finish)) + { + self.save_result() + .matched(consumed) + .rule_index(rule_index) + .replacement(repl) + .result(result) + .call() + } + words + } + Some(word_item) => { + let mut next_iteration = None; + for (rule_index, suffix, case_sensitivity, repl) in remaining { + match suffix { + Suffix::Finish => self + .save_result() + .matched(consumed) + .rule_index(rule_index) + .result(result) + .replacement(repl) + .call(), + Suffix::Remaining(s) => { + if let Some(remainder) = + trim_start((s, case_sensitivity), word_item.1.to_string()) + { + // The match could potentially continue. Store the + // candidate to run another iteration. + next_iteration.get_or_insert_with(Vec::new).push(( + rule_index, + Suffix::from(remainder), + case_sensitivity, + repl, + )); + } + } + } + } + + consumed.push(word_item); + if let Some(next_iteration) = next_iteration { + self.match_exclusions_rec() + .remaining(next_iteration.into_iter().peekable()) + .words(words) + .consumed(consumed) + .result(result) + .call() + } else { + words + } + } + } + } + + #[builder] + fn save_result<'a>( + &self, + matched: &[WordIteratorItem<'_>], + rule_index: usize, + replacement: &'a Option, + result: &mut Option>, + ) { + let match_ = if matched.is_empty() { + MatchDetailsIntermediateInner::OneWord + } else { + MatchDetailsIntermediateInner::MultipleWords(matched.len() - 1) + }; + + result.replace(MatchDetailsIntermediate { + match_, + rule: self + .0 + .rules + .get(rule_index) + .expect("Rule meta added when this linter rule was set up") + .clone(), + replacement, + }); + } +} + +fn combine_exclusions<'a>( + case_sensitive: Option<&'a WordExclusionMeta>, + case_insensitive: Option<&'a WordExclusionMeta>, +) -> Peekable, CaseSensitivity, &'a Option)>> { + fn remainders_iter( + details: &WordExclusionMeta, + ) -> impl Iterator)> { + details.remainders.iter().enumerate().map(|(i, rem)| { + let (rule_index, replacement) = details + .details + .get(i) + .expect("Details added when setting up rule"); + (*rule_index, Suffix::from(rem.as_str()), replacement) + }) + } + + let case_sensitive = case_sensitive + .map(remainders_iter) + .into_iter() + .flatten() + .map(|(i, rem, repl)| (i, rem, CaseSensitivity::Sensitive, repl)); + let case_insensitive = case_insensitive + .map(remainders_iter) + .into_iter() + .flatten() + .map(|(i, rem, repl)| (i, rem, CaseSensitivity::Insensitive, repl)); + + case_sensitive.chain(case_insensitive).peekable() +} + +fn trim_start(hay: (&str, CaseSensitivity), prefix: impl AsRef) -> Option<&str> { + let prefix = prefix.as_ref(); + match hay.1 { + CaseSensitivity::Sensitive => { + if hay.0.starts_with(prefix) { + Some(&hay.0[prefix.len()..]) + } else { + None + } + } + CaseSensitivity::Insensitive => { + let hay_lower = hay.0.to_lowercase(); + let prefix_lower = prefix.to_lowercase(); + if hay_lower.starts_with(&prefix_lower) { + Some(&hay.0[prefix.len()..]) + } else { + None + } + } + } +} + +fn reattach_unused_words<'words>( + words: WordIteratorExtension<'words, WordIteratorPrefix<'words>>, + consumed: impl Iterator>, + num_used: usize, +) -> WordIteratorExtension<'words, WordIteratorPrefix<'words>> { + #[cfg(debug_assertions)] + trace!("Reattaching unused words after matching"); + words.extend_on_prefix(WordIteratorPrefix::new(consumed.skip(num_used))) +} + +static FORMAT_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"[^%](?%s|%r)").expect("Hardcoded regex should not fail to compile") +}); + +fn substitute_format_string(s: String, word: RopeSlice<'_>, replacement: Option) -> String { + if FORMAT_REGEX.captures(&s).is_none() { + return s; + } + + let mut result = String::with_capacity(s.len()); + let mut last_index = 0; + for capture in FORMAT_REGEX.captures_iter(&s) { + let placeholder = capture.name("placeholder").unwrap(); + let range = placeholder.range(); + + let substitution = if placeholder.as_str().ends_with('s') { + word.to_string() + } else { + replacement + .clone() + .unwrap_or("".to_string()) + }; + + result.push_str(&s[last_index..range.start]); + result.push_str(&substitution); + last_index = range.end; + } + result.push_str(&s[last_index..]); + result +} + +#[cfg(test)] +mod tests { + use crate::{fix::LintCorrectionReplace, geometry::AdjustedOffset, parser::parse}; + + use super::*; + + fn setup_rule( + rules: Vec<(impl Into, WordExclusionMetaIntermediate)>, + ) -> Rule004ExcludeWords { + let mut rule = Rule004ExcludeWords::default(); + let mut settings = WordExclusionIndexIntermediate { + rule: HashMap::new(), + }; + + for (rule_description, rule_meta) in rules { + settings.rule.insert(rule_description.into(), rule_meta); + } + + let mut settings = + RuleSettings::with_serializable::("rules", &settings); + rule.setup(Some(&mut settings)); + rule + } + + fn get_simple_ast<'a>( + md: impl AsRef, + ) -> ( + RuleContext<'a>, + impl Fn(&'a RuleContext<'a>) -> &'a mdast::Node, + ) { + let parse_result = parse(md.as_ref()).unwrap(); + let context = RuleContext::builder() + .parse_result(parse_result) + .build() + .unwrap(); + + (context, |context| { + context + .ast() + .children() + .unwrap() + .first() + .unwrap() + .children() + .unwrap() + .first() + .unwrap() + }) + } + + #[test] + fn test_rule004_exclude_word() { + let rules = vec![( + "foo".to_string(), + WordExclusionMetaIntermediate { + description: "Don't use 'Foo'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(13)); + } + + #[test] + fn test_rule004_exclude_and_replace_word() { + let rules = vec![( + "foo".to_string(), + WordExclusionMetaIntermediate { + description: "Don't use 'Foo'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::WithReplace( + "Foo".to_string(), + "Bar".to_string(), + )], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(13)); + + assert!(error.suggestions.is_some()); + let suggestions = error.suggestions.as_ref().unwrap(); + assert_eq!(suggestions.len(), 1); + let suggestion = suggestions.get(0).unwrap(); + assert!(matches!( + suggestion, + LintCorrection::Replace(LintCorrectionReplace { .. }) + )); + } + + #[test] + fn test_rule004_exclude_multiple_words() { + let rules = vec![ + ( + "foo", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo".to_string())], + level: LintLevel::Error, + }, + ), + ( + "bar", + WordExclusionMetaIntermediate { + description: "Don't use 'bar'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("bar".to_string())], + level: LintLevel::Error, + }, + ), + ]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo test with bar."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 2); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(13)); + + let error = errors.get(1).unwrap(); + assert_eq!(error.message, "Don't use 'bar'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(24)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(27)); + } + + #[test] + fn test_rule004_multiword_exclusion() { + let rules = vec![( + "foo bar".to_string(), + WordExclusionMetaIntermediate { + description: "Don't use 'Foo bar'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo bar".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo bar test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo bar'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(17)); + } + + #[test] + fn test_rule004_overlapping_exclusions() { + let rules = vec![ + ( + "Foo barbie", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo barbie'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo barbie".to_string())], + level: LintLevel::Error, + }, + ), + ( + "bartender", + WordExclusionMetaIntermediate { + description: "Don't use 'bartender'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("bartender".to_string())], + level: LintLevel::Error, + }, + ), + ]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo bartender."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'bartender'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(14)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(23)); + } + + #[test] + fn test_rule004_use_longest_overlapping() { + let rules = vec![ + ( + "Foo bar", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo bar'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo bar".to_string())], + level: LintLevel::Error, + }, + ), + ( + "Foo bartender", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo bartender'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly( + "Foo bartender".to_string(), + )], + level: LintLevel::Error, + }, + ), + ]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo bartender."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo bartender'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(23)); + } + + #[test] + fn test_rule004_no_exclusions() { + let rules = Vec::<(String, _)>::new(); + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo bar test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_none()); + } + + #[test] + fn test_rule004_recover_false_longer_overlap() { + let rules = vec![ + ( + "Foo bartender", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo bartender'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly( + "Foo bartender".to_string(), + )], + level: LintLevel::Error, + }, + ), + ( + "Foo bartender blah whaaaat", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo bartender blah whaaaat'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly( + "Foo bartender blah whaaaat".to_string(), + )], + level: LintLevel::Error, + }, + ), + ]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo bartender blah."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'Foo bartender'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(23)); + } + + #[test] + fn test_rule004_no_matching_exclusions() { + let rules = vec![( + "Foo", + WordExclusionMetaIntermediate { + description: "Don't use 'Foo'".to_string(), + case_sensitive: true, + words: vec![ExclusionDefinition::ExcludeOnly("Foo".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a passing test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_none()); + } + + #[test] + fn test_rule004_case_insensitive() { + let rules = vec![( + "foo", + WordExclusionMetaIntermediate { + description: "Don't use 'foo'".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("foo".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'foo'"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(13)); + } + + #[test] + fn test_rule004_lint_level() { + let rules = vec![( + "foo", + WordExclusionMetaIntermediate { + description: "Don't use 'foo'".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("foo".to_string())], + level: LintLevel::Warning, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("This is a Foo test."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use 'foo'"); + assert_eq!(error.level, LintLevel::Warning); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(10)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(13)); + } + + #[test] + fn test_rule_004_exclusion_with_apostrophe() { + let rules = vec![( + "blah", + WordExclusionMetaIntermediate { + description: "blah blah blah".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("that's it".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("That's it, Bob's your uncle."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "blah blah blah"); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(9)); + } + + #[test] + fn test_rule_004_exclusion_with_other_punctuation() { + let rules = vec![( + "blah blah", + WordExclusionMetaIntermediate { + description: "This isn't Reddit.".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("tl;dr".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("tl;dr: Just do the thing."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "This isn't Reddit."); + assert_eq!(error.level, LintLevel::Error); + assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0)); + assert_eq!(error.location.offset_range.end, AdjustedOffset::from(5)); + } + + #[test] + fn test_rule_004_formatted_message() { + let rules = vec![( + "something", + WordExclusionMetaIntermediate { + description: "Don't use %s".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("ladeeda".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("Well, ladeeda."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use ladeeda"); + } + + #[test] + fn test_rule_004_formatted_message_with_escape() { + let rules = vec![( + "something", + WordExclusionMetaIntermediate { + description: "Don't use %%s".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("ladeeda".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("Well, ladeeda."); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Don't use %%s"); + } + + #[test] + fn test_rule_004_formatted_message_with_replacement() { + let rules = vec![( + "something", + WordExclusionMetaIntermediate { + description: "Use %r instead of %s".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::WithReplace( + "PostgreSQL".to_string(), + "Postgres".to_string(), + )], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("PostgreSQL is awesome!"); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + assert_eq!(error.message, "Use Postgres instead of PostgreSQL"); + } + + #[test] + fn test_rule_004_delete_at_beginning() { + let rules = vec![( + "yeah", + WordExclusionMetaIntermediate { + description: "Don't use yeah".to_string(), + case_sensitive: false, + words: vec![ExclusionDefinition::ExcludeOnly("Yeah".to_string())], + level: LintLevel::Error, + }, + )]; + let rule = setup_rule(rules); + + let (context, get_ast) = get_simple_ast("Yeah this is awesome!"); + let result = rule.check(get_ast(&context), &context, LintLevel::Error); + assert!(result.is_some()); + + let errors = result.unwrap(); + assert_eq!(errors.len(), 1); + + let error = errors.get(0).unwrap(); + let suggestion = error.suggestions.as_ref().unwrap().get(0).unwrap(); + match suggestion { + LintCorrection::Replace(replace) => { + assert_eq!(replace.location.offset_range.start, AdjustedOffset::from(0)); + assert_eq!(replace.text(), "T".to_string()); + } + other => panic!("Should have been a replacement, got: {other:#?}"), + } + } +} diff --git a/src/utils.rs b/src/utils.rs index c1a1dac..236738c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -10,3 +10,18 @@ pub fn is_lintable(path: impl AsRef) -> bool { let path = path.as_ref(); path.is_dir() || path.extension().map_or(false, |ext| ext == "mdx") } + +pub trait Offsets { + fn start(&self) -> usize; + fn end(&self) -> usize; +} + +impl Offsets for &T { + fn start(&self) -> usize { + (*self).start() + } + + fn end(&self) -> usize { + (*self).end() + } +} diff --git a/src/utils/words.rs b/src/utils/words.rs index d6d9013..9c8283b 100644 --- a/src/utils/words.rs +++ b/src/utils/words.rs @@ -1,3 +1,4 @@ +use bon::builder; use crop::RopeSlice; use log::trace; @@ -78,20 +79,31 @@ impl<'rope> WordIterator<'rope> { None } } + + pub(crate) fn collect_remainder(self) -> Option { + assert!(self.parser.word_start_offset == self.parser.tracking_offset); + if self.parser.word_start_offset == self.rope.byte_len() { + None + } else { + Some( + self.rope + .byte_slice(self.parser.word_start_offset..) + .to_string(), + ) + } + } } +pub(crate) type WordIteratorItem<'r> = (usize, RopeSlice<'r>, Capitalize); + impl<'rope> Iterator for WordIterator<'rope> { - type Item = (usize, RopeSlice<'rope>, Capitalize); + type Item = WordIteratorItem<'rope>; fn next(&mut self) -> Option { let next_word_data = self.parser.parse(self.rope); - if let Some(next_word_data) = next_word_data { - Some(( - next_word_data.0 + self.offset_from_parent, - next_word_data.1, - next_word_data.2, - )) + if let Some((offset, slice, capitalize)) = next_word_data { + Some((offset + self.offset_from_parent, slice, capitalize)) } else { None } @@ -145,10 +157,7 @@ impl WordParser { } } - fn parse<'rope>( - &mut self, - rope: RopeSlice<'rope>, - ) -> Option<(usize, RopeSlice<'rope>, Capitalize)> { + fn parse<'rope>(&mut self, rope: RopeSlice<'rope>) -> Option> { assert!(self.word_start_offset == self.tracking_offset); if self.word_start_offset >= rope.byte_len() { return None; @@ -461,6 +470,250 @@ pub fn is_punctuation(c: &char) -> bool { || *c == ';' } +const SENTENCE_ENDING_PUNCTUATION: &[char] = &['.', '!', '?', 'ā€¦']; + +fn is_sentence_ending_punctuation(c: &char) -> bool { + SENTENCE_ENDING_PUNCTUATION.contains(c) +} + +#[builder] +pub(crate) fn is_sentence_start( + slice: RopeSlice<'_>, + query_offset: usize, + #[builder(default = true)] count_beginning_as_sentence_start: bool, +) -> bool { + #[cfg(debug_assertions)] + log::trace!("Checking if offset {query_offset} is at sentence start"); + + let mut iter = WordIterator::new(slice, 0, Default::default()) + .enumerate() + .peekable(); + + let (preceding_offset, preceding_word, queried_offset, queried_word) = loop { + match (iter.next(), iter.peek()) { + (Some((0, _)), _) if query_offset == 0 && count_beginning_as_sentence_start => { + return count_beginning_as_sentence_start; + } + ( + Some((_, (preceding_offset, preceding_word, _))), + Some((_, (next_word_offset, next_word, _))), + ) => { + if *next_word_offset == query_offset { + break ( + preceding_offset, + preceding_word, + next_word_offset, + next_word, + ); + } + } + _ => { + return false; + } + } + }; + + // A word in the middle of a text is at the start of a sentence if it is + // proceeded by a word immediately followed by punctuation. The punctuation + // _must_ include a sentence-closing punctuation mark, which may be + // surrounded by other punctuation. For example, `".)` would be a valid + // sentence-ending punctuation cluster. + // + // We're also going to check for capitalization to avoid false positives + // from punctuation clusters such as `(T.B.D.)`, though this will give us + // false negatives for some special cases of words that are allowed to + // be lowercase at sentence start. The number of these exceptions is + // relatively small, and for simplicity's sake we will ignore them. + if !(queried_word.is_char_boundary(0) + && queried_word + .chars() + .next() + .map_or(false, |c: char| c.is_uppercase())) + { + return false; + } + + let between = slice + .byte_slice(preceding_offset + preceding_word.byte_len()..*queried_offset) + .chars(); + #[cfg(debug_assertions)] + trace!( + "Parsing the between-sentence text: \"{}\"", + between.clone().collect::() + ); + between_sentence_parser::BetweenSentenceParser::new().parse(between) +} + +mod between_sentence_parser { + #[cfg(debug_assertions)] + use log::trace; + + #[derive(Debug)] + enum BetweenSentenceParserState { + Initial, + PrecedingPunctuation, + SentenceEndingPunctuation(EndingPunctuationType), + FollowingPunctuation, + Whitespace, + SentenceStartPunctuation, + } + + #[derive(Debug)] + enum EndingPunctuationType { + Mixable, + NonMixable(char), + } + + #[derive(Debug)] + pub(super) struct BetweenSentenceParser { + state: BetweenSentenceParserState, + } + + impl BetweenSentenceParser { + pub(super) fn new() -> Self { + Self { + state: BetweenSentenceParserState::Initial, + } + } + + pub(super) fn parse(&mut self, chars: impl Iterator) -> bool { + use BetweenSentenceParserState::*; + + for char in chars { + #[cfg(debug_assertions)] + trace!("Parser state: {:?}", self.state); + + match char { + c if c.is_whitespace() => match self.state { + SentenceEndingPunctuation(_) | FollowingPunctuation => { + self.state = Whitespace; + } + Whitespace => {} + _ => return false, + }, + c if super::is_sentence_ending_punctuation(&c) => { + let r#type = match c { + '.' => EndingPunctuationType::NonMixable(c), + _ => EndingPunctuationType::Mixable, + }; + match self.state { + Initial | PrecedingPunctuation => { + self.state = SentenceEndingPunctuation(r#type); + } + SentenceEndingPunctuation(EndingPunctuationType::Mixable) + if matches!(r#type, EndingPunctuationType::Mixable) => {} + SentenceEndingPunctuation(EndingPunctuationType::NonMixable(old_c)) + if matches!(r#type, EndingPunctuationType::NonMixable(c) if c == old_c) => + {} + _ => return false, + } + } + c if super::is_punctuation(&c) => match self.state { + Initial => { + self.state = PrecedingPunctuation; + } + PrecedingPunctuation | FollowingPunctuation | SentenceStartPunctuation => {} + SentenceEndingPunctuation(_) => { + self.state = FollowingPunctuation; + } + Whitespace => self.state = SentenceStartPunctuation, + }, + _ => return false, + } + } + + matches!(self.state, Whitespace | SentenceStartPunctuation) + } + } +} + +pub(crate) mod extras { + use std::collections::VecDeque; + + use super::*; + + pub(crate) struct WordIteratorExtension<'a, I> { + prefix: Option, + inner: WordIterator<'a>, + } + + impl<'a, I> From> for WordIteratorExtension<'a, I> { + fn from(inner: WordIterator<'a>) -> Self { + Self { + prefix: None, + inner, + } + } + } + + impl<'a, I> WordIteratorExtension<'a, I> + where + I: Iterator>, + { + pub(crate) fn extend_on_prefix(self, prefix: I) -> Self { + Self { + prefix: Some(prefix), + inner: self.into_inner().1, + } + } + + pub(crate) fn into_inner(self) -> (Option, WordIterator<'a>) { + (self.prefix, self.inner) + } + } + + impl<'a, I> Iterator for WordIteratorExtension<'a, I> + where + I: Iterator>, + { + type Item = WordIteratorItem<'a>; + + fn next(&mut self) -> Option { + match self.prefix { + Some(ref mut prefix) => prefix.next().or_else(|| self.inner.next()), + None => self.inner.next(), + } + } + } + + #[cfg(test)] + pub(crate) struct UnitIterator<'a> { + _marker: std::marker::PhantomData<&'a ()>, + } + + #[cfg(test)] + impl<'a> Iterator for UnitIterator<'a> { + type Item = WordIteratorItem<'a>; + + fn next(&mut self) -> Option { + None + } + } + + pub(crate) struct WordIteratorPrefix<'a> { + inner: VecDeque>, + } + + impl<'a> WordIteratorPrefix<'a> { + pub(crate) fn new(inner: I) -> Self + where + I: IntoIterator>, + { + Self { + inner: inner.into_iter().collect(), + } + } + } + + impl<'a> Iterator for WordIteratorPrefix<'a> { + type Item = WordIteratorItem<'a>; + + fn next(&mut self) -> Option { + self.inner.pop_front() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -809,4 +1062,155 @@ mod tests { ); } } + + #[test] + fn test_word_iterator_collect_remainder() { + let rope = Rope::from("hello everybody in the world"); + let slice = rope.byte_slice(..); + let mut iter = WordIterator::new(slice, 0, Default::default()); + + iter.next(); + assert_eq!( + iter.collect_remainder(), + Some("everybody in the world".to_string()) + ); + } + + #[test] + fn test_word_iterator_no_remainder() { + let rope = Rope::from("hello"); + let slice = rope.byte_slice(..); + let mut iter = WordIterator::new(slice, 0, Default::default()); + + iter.next(); + assert!(iter.collect_remainder().is_none()); + } + + #[test] + fn test_word_iterator_wrapper() { + let rope = Rope::from("hello world"); + let slice = rope.byte_slice(..); + let mut iter: extras::WordIteratorExtension<'_, extras::UnitIterator> = + WordIterator::new(slice, 0, Default::default()).into(); + + let (offset, word, cap) = iter.next().unwrap(); + assert_eq!(offset, 0); + assert_eq!(word.to_string(), "hello"); + assert_eq!(cap, Capitalize::False); + + let (offset, word, cap) = iter.next().unwrap(); + assert_eq!(offset, 6); + assert_eq!(word.to_string(), "world"); + assert_eq!(cap, Capitalize::False); + + assert!(iter.next().is_none()); + } + + #[test] + fn test_word_iterator_wrapper_with_prefix() { + let rope = Rope::from("hello world keep going"); + let slice = rope.byte_slice(..); + + let mut orig_iter: extras::WordIteratorExtension<'_, extras::WordIteratorPrefix> = + WordIterator::new(slice, 0, Default::default()).into(); + + let mut consumed = vec![]; + consumed.push(orig_iter.next().unwrap()); + consumed.push(orig_iter.next().unwrap()); + + let mut new_iter = orig_iter.extend_on_prefix(extras::WordIteratorPrefix::new(consumed)); + + let next = new_iter.next().unwrap(); + assert_eq!(next.0, 0); + assert_eq!(next.1.to_string(), "hello"); + let next = new_iter.next().unwrap(); + assert_eq!(next.0, 6); + assert_eq!(next.1.to_string(), "world"); + let next = new_iter.next().unwrap(); + assert_eq!(next.0, 12); + assert_eq!(next.1.to_string(), "keep"); + let next = new_iter.next().unwrap(); + assert_eq!(next.0, 17); + assert_eq!(next.1.to_string(), "going"); + assert!(new_iter.next().is_none()); + } + + #[test] + fn test_is_sentence_start() { + let rope = Rope::from("Hello world! What a wonderful day. What's up?"); + assert!(is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(0) + .call()); + assert!(is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(13) + .call()); + assert!(is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(35) + .call()); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(6) + .call()); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(11) + .call()); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(12) + .call()); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(40) + .call()); + } + + #[test] + fn test_is_sentence_start_handles_ellipsis() { + let rope = Rope::from("Hello... world!"); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(9) + .call()); + + let rope = Rope::from("Hello... World!"); + assert!(is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(9) + .call()); + } + + #[test] + fn test_is_sentence_start_handles_mixed_punctuation() { + let rope = Rope::from("Hello?!?!?! World!"); + assert!(is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(12) + .call()); + + let rope = Rope::from("Hello.!?. What?"); + assert!(!is_sentence_start() + .slice(rope.byte_slice(..)) + .query_offset(10) + .call()); + } + + #[test] + fn test_is_sentence_start_gracefully_fails_on_empty_rope() { + assert!(!is_sentence_start() + .slice(Rope::from("").byte_slice(..)) + .query_offset(0) + .call()); + } + + #[test] + fn test_is_sentence_start_gracefully_fails_on_out_of_bounds() { + assert!(!is_sentence_start() + .slice(Rope::from("Hello").byte_slice(..)) + .query_offset(1000) + .call()); + } } diff --git a/tests/rule002/supa-mdx-lint.config.toml b/tests/rule002/supa-mdx-lint.config.toml index c64d27e..bbb4268 100644 --- a/tests/rule002/supa-mdx-lint.config.toml +++ b/tests/rule002/supa-mdx-lint.config.toml @@ -1,5 +1,6 @@ Rule001HeadingCase = false Rule003Spelling = false +Rule004ExcludeWords = false [Rule002AdmonitionTypes] admonition_types = ["note", "tip", "caution", "deprecation", "danger"] diff --git a/tests/rule003/supa-mdx-lint.config.toml b/tests/rule003/supa-mdx-lint.config.toml index 196f150..106b4af 100644 --- a/tests/rule003/supa-mdx-lint.config.toml +++ b/tests/rule003/supa-mdx-lint.config.toml @@ -1,5 +1,6 @@ Rule001HeadingCase = false Rule002AdmonitionTypes = false +Rule004ExcludeWords = false [Rule003Spelling] allow_list = ["Supabase"] diff --git a/tests/rule004/mod.rs b/tests/rule004/mod.rs new file mode 100644 index 0000000..ebee3c5 --- /dev/null +++ b/tests/rule004/mod.rs @@ -0,0 +1,18 @@ +use std::process::Command; + +use assert_cmd::prelude::*; +use predicates::prelude::*; + +#[test] +fn integration_test_rule004() { + let mut cmd = Command::cargo_bin("supa-mdx-lint").unwrap(); + cmd.arg("tests/rule004/rule004.mdx") + .arg("--config") + .arg("tests/rule004/supa-mdx-lint.config.toml"); + cmd.assert() + .failure() + .stdout(predicate::str::contains("2 errors")) + .stdout(predicate::str::contains( + "Don't use the following filler words", + )); +} diff --git a/tests/rule004/rule004.mdx b/tests/rule004/rule004.mdx new file mode 100644 index 0000000..769f283 --- /dev/null +++ b/tests/rule004/rule004.mdx @@ -0,0 +1,5 @@ +# Rule 004 + +## No filler + +Please, don't use filler words like "simply". diff --git a/tests/rule004/supa-mdx-lint.config.toml b/tests/rule004/supa-mdx-lint.config.toml new file mode 100644 index 0000000..d9c740c --- /dev/null +++ b/tests/rule004/supa-mdx-lint.config.toml @@ -0,0 +1,7 @@ +Rule001HeadingCase = false +Rule002AdmonitionTypes = false +Rule003Spelling = false + +[Rule004ExcludeWords.rules.filler] +description = "Don't use the following filler words." +words = ["please", "that's it", "just", "easily", "simply"] diff --git a/tests/rules.rs b/tests/rules.rs index cb21980..bd9179b 100644 --- a/tests/rules.rs +++ b/tests/rules.rs @@ -1,2 +1,3 @@ mod rule002; mod rule003; +mod rule004; diff --git a/tests/supa-mdx-lint.config.toml b/tests/supa-mdx-lint.config.toml index 0db6f4a..93a94f1 100644 --- a/tests/supa-mdx-lint.config.toml +++ b/tests/supa-mdx-lint.config.toml @@ -2,6 +2,7 @@ ignore_patterns = ["*00x.mdx", "rule*/**"] Rule002AdmonitionTypes = false Rule003Spelling = false +Rule004ExcludeWords = false [Rule001HeadingCase] may_uppercase = ["Supabase"]