Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion harper-core/src/linting/expr_linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ where

impl<L> Linter for L
where
L: ExprLinter,
L: ExprLinter + 'static,
{
fn lint(&mut self, document: &Document) -> Vec<Lint> {
let mut lints = Vec::new();
Expand Down
152 changes: 152 additions & 0 deletions harper-core/src/linting/lint_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ use crate::linting::{
use crate::spell::{Dictionary, MutableDictionary};
use crate::{CharString, Dialect, Document, TokenStringExt};

/// A modifier linter that configures SpellCheck to ignore ALL CAPS words.
/// This linter produces no lints itself - it only modifies SpellCheck behavior.
pub struct SpellCheckIgnoreAllCaps;

impl Default for SpellCheckIgnoreAllCaps {
fn default() -> Self {
Self
}
}

impl Linter for SpellCheckIgnoreAllCaps {
fn lint(&mut self, _document: &Document) -> Vec<Lint> {
Vec::new() // Produces no lints
}

fn description(&self) -> &'static str {
"When enabled, configures spell check to ignore ALL CAPS words."
}
}

fn ser_ordered<S>(map: &HashMap<String, Option<bool>>, ser: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
Expand Down Expand Up @@ -529,6 +549,10 @@ impl LintGroup {
out.add("SpellCheck", SpellCheck::new(dictionary.clone(), dialect));
out.config.set_rule_enabled("SpellCheck", true);

out.add("SpellCheckIgnoreAllCaps", SpellCheckIgnoreAllCaps);
out.config
.set_rule_enabled("SpellCheckIgnoreAllCaps", false);

out.add(
"InflectedVerbAfterTo",
InflectedVerbAfterTo::new(dictionary.clone()),
Expand Down Expand Up @@ -580,9 +604,17 @@ impl Linter for LintGroup {
fn lint(&mut self, document: &Document) -> Vec<Lint> {
let mut results = Vec::new();

// Check for spell check flags before running linters
let ignore_all_caps_enabled = self.config.is_rule_enabled("SpellCheckIgnoreAllCaps");

// Normal linters
for (key, linter) in &mut self.linters {
if self.config.is_rule_enabled(key) {
// Configure SpellCheck before running
if key == "SpellCheck" {
linter.configure_spell_check(ignore_all_caps_enabled);
}

results.extend(linter.lint(document));
}
}
Expand Down Expand Up @@ -708,4 +740,124 @@ mod tests {
}
}
}

#[test]
fn spell_check_ignore_all_caps_integration() {
let mut group = LintGroup::new_curated(FstDictionary::curated(), Dialect::American);

// Test with the modifier disabled (default)
group.config.set_rule_enabled("SpellCheck", true);
group
.config
.set_rule_enabled("SpellCheckIgnoreAllCaps", false);

// Use made-up ALL CAPS words that we know aren't in the dictionary
let doc = Document::new_markdown_default_curated(
"The WRONGAPI is broken. We need WRONGAPIS and WRONGCPU's.",
);
let lints = group.lint(&doc);

// Should find lints for ALL CAPS misspellings when modifier is disabled
let spell_lints: Vec<_> = lints
.iter()
.filter(|lint| lint.lint_kind == crate::linting::LintKind::Spelling)
.collect();
assert!(
spell_lints.len() > 0,
"Expected spelling lints for made-up ALL CAPS words when modifier is disabled"
);

// Test with the modifier enabled
group
.config
.set_rule_enabled("SpellCheckIgnoreAllCaps", true);

let lints = group.lint(&doc);
let spell_lints: Vec<_> = lints
.iter()
.filter(|lint| lint.lint_kind == crate::linting::LintKind::Spelling)
.collect();

// Should find no spelling lints for ALL CAPS words when modifier is enabled
let all_caps_flagged = spell_lints.iter().any(|lint| {
let content = doc.get_span_content_str(&lint.span);
content == "WRONGAPI" || content == "WRONGAPIS" || content == "WRONGCPU's"
});

assert!(
!all_caps_flagged,
"ALL CAPS words should not be flagged when modifier is enabled"
);
}

#[test]
fn spell_check_ignore_all_caps_still_flags_mixed_case() {
let mut group = LintGroup::new_curated(FstDictionary::curated(), Dialect::American);

// Enable both SpellCheck and the modifier
group.config.set_rule_enabled("SpellCheck", true);
group
.config
.set_rule_enabled("SpellCheckIgnoreAllCaps", true);

let doc = Document::new_markdown_default_curated("The API works but speling is wrong.");
let lints = group.lint(&doc);

let spell_lints: Vec<_> = lints
.iter()
.filter(|lint| lint.lint_kind == crate::linting::LintKind::Spelling)
.collect();

// Should still flag "speling" (mixed case misspelling)
let mixed_case_flagged = spell_lints.iter().any(|lint| {
let content = doc.get_span_content_str(&lint.span);
content == "speling"
});

assert!(
mixed_case_flagged,
"Mixed case misspellings should still be flagged"
);

// Should not flag "API" (all caps)
let all_caps_flagged = spell_lints.iter().any(|lint| {
let content = doc.get_span_content_str(&lint.span);
content == "API"
});

assert!(
!all_caps_flagged,
"ALL CAPS words should not be flagged when modifier is enabled"
);
}

#[test]
fn spell_check_ignore_all_caps_suffix_combinations() {
let mut group = LintGroup::new_curated(FstDictionary::curated(), Dialect::American);

// Enable both SpellCheck and the modifier
group.config.set_rule_enabled("SpellCheck", true);
group
.config
.set_rule_enabled("SpellCheckIgnoreAllCaps", true);

let doc = Document::new_markdown_default_curated("The CPUsed approach is wrong.");
let lints = group.lint(&doc);

let spell_lints: Vec<_> = lints
.iter()
.filter(|lint| lint.lint_kind == crate::linting::LintKind::Spelling)
.collect();

// Should still flag "CPUsed" (suffix combination)
let combination_flagged = spell_lints.iter().any(|lint| {
let content = doc.get_span_content_str(&lint.span);
content == "CPUsed"
});

assert!(
combination_flagged,
"Suffix combinations should still be flagged"
);
}
}
4 changes: 4 additions & 0 deletions harper-core/src/linting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ pub trait Linter: LSend {
/// A user-facing description of what kinds of grammatical errors this rule looks for.
/// It is usually shown in settings menus.
fn description(&self) -> &str;
/// Configure spell check behavior. Default implementation does nothing.
fn configure_spell_check(&mut self, _ignore_all_caps: bool) {
// Default: do nothing
}
}

/// A blanket-implemented trait that renders the Markdown description field of a linter to HTML.
Expand Down
95 changes: 95 additions & 0 deletions harper-core/src/linting/spell_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ where
dictionary: T,
word_cache: LruCache<CharString, Vec<CharString>>,
dialect: Dialect,
pub(crate) ignore_all_caps: bool,
}

impl<T: Dictionary> SpellCheck<T> {
Expand All @@ -24,11 +25,48 @@ impl<T: Dictionary> SpellCheck<T> {
dictionary,
word_cache: LruCache::new(NonZero::new(10000).unwrap()),
dialect,
ignore_all_caps: false,
}
}

const MAX_SUGGESTIONS: usize = 3;

fn is_all_caps(&self, word: &[char]) -> bool {
if word.len() <= 1 {
return false;
}

let word_str: String = word.iter().collect();
let word_str = word_str.as_str();

// Check for allowed single suffixes only: 's, 'd, ed, s
let suffixes = ["'s", "'d", "ed", "s", "es"];

for suffix in &suffixes {
if let Some(stem) = word_str.strip_suffix(suffix)
&& !stem.is_empty()
{
let stem_chars: Vec<char> = stem.chars().collect();
// Check if stem is all caps (ignoring non-alphabetic characters)
if stem_chars
.iter()
.all(|c| c.is_uppercase() || !c.is_alphabetic())
{
// Make sure the stem doesn't end with another suffix
for other_suffix in &suffixes {
if stem.ends_with(other_suffix) {
return false;
}
}
return true;
}
}
}

// If no suffix matches, check the whole word
word.iter().all(|c| c.is_uppercase() || !c.is_alphabetic())
}

fn suggest_correct_spelling(&mut self, word: &[char]) -> Vec<CharString> {
if let Some(hit) = self.word_cache.get(word) {
hit.clone()
Expand Down Expand Up @@ -73,6 +111,11 @@ impl<T: Dictionary> Linter for SpellCheck<T> {
for word in document.iter_words() {
let word_chars = document.get_span_content(&word.span);

// Skip all-caps words if flag is set
if self.ignore_all_caps && self.is_all_caps(word_chars) {
continue;
}

if let Some(metadata) = word.kind.as_word().unwrap()
&& metadata.dialects.is_dialect_enabled(self.dialect)
&& (self.dictionary.contains_exact_word(word_chars)
Expand Down Expand Up @@ -124,6 +167,10 @@ impl<T: Dictionary> Linter for SpellCheck<T> {
fn description(&self) -> &'static str {
"Looks and provides corrections for misspelled words."
}

fn configure_spell_check(&mut self, ignore_all_caps: bool) {
self.ignore_all_caps = ignore_all_caps;
}
}

#[cfg(test)]
Expand Down Expand Up @@ -387,6 +434,54 @@ mod tests {
);
}

#[test]
fn ignore_all_caps_with_suffixes() {
let mut spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

// These should be ignored (all caps + allowed suffixes)
assert_lint_count("APIs", spell_check, 0);
spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

assert_lint_count("CPU's", spell_check, 0);
spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

assert_lint_count("API'd", spell_check, 0);
}

#[test]
fn dont_ignore_suffix_combinations() {
let mut spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

// Should NOT be ignored (combination of suffixes)
assert_lint_count("CPUsed", spell_check, 1);
}

#[test]
fn ignore_all_caps_basic() {
let mut spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

// Should be ignored (all caps)
assert_lint_count("API", spell_check, 0);
spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

assert_lint_count("CPU", spell_check, 0);
}

#[test]
fn dont_ignore_mixed_case() {
let mut spell_check = SpellCheck::new(FstDictionary::curated(), Dialect::American);
spell_check.ignore_all_caps = true;

// Should still lint mixed case misspellings
assert_lint_count("speling", spell_check, 1);
}

#[test]
fn corrects_hes() {
assert_suggestion_result(
Expand Down
Loading