Repository: Automattic/harper (2026-04-10T15:27:33Z)

Issue:
`SplitWords` linter doesn't engage
I've been noticing that words joined without spaces between don't trigger the `SplitWords` linter.

Only the `SpellCheck` linter engages in such cases
Chrome extension:
<img width="367" height="279" alt="Image" src="https://github.com/user-attachments/assets/e7c20b21-c899-4e64-8500-7d0972a901c4" />

<img width="612" height="230" alt="Image" src="https://github.com/user-attachments/assets/556b6a90-658f-4a74-91ab-418807c2a1ef" />

<img width="723" height="229" alt="Image" src="https://github.com/user-attachments/assets/c066e648-6bca-45c9-b605-07d6915a5174" />

It's the same if I leave the option on "Default" or turn it "on": 

<img width="1007" height="62" alt="Image" src="https://github.com/user-attachments/assets/5218949a-a2e8-4b3f-8b28-c0c334ca52c7" />

Harper CLI: 

<img width="1007" height="62" alt="Image" src="https://github.com/user-attachments/assets/109e9334-6ff8-4324-a3dd-46076979778e" />

Using the CLI with `--only SplitWords` does seem to work:

<img width="915" height="241" alt="Image" src="https://github.com/user-attachments/assets/57989649-8d4e-4f4e-96b2-fac67a8fd493" />

Using the CLI with `--only SplitWords,SpellCheck` shows that `SpellCheck` is triggered and has a priority of `63` where `SplitWords` had a priority of `31`

<img width="986" height="287" alt="Image" src="https://github.com/user-attachments/assets/3e15b3ec-3515-467b-9b16-e21331b89164" />


Discussion:
I've done some experimentation with the priorities, and making `SplitWords` priority 65, one higher than `SpellCheck` ***does not*** solve the problem.
I threw an AI coding agent at this problem and it suggested changing the `remove_overlaps` functions in `harper-core/src/lib.rs`:
```
    lints.sort_by_key(|l| (l.span.start, !0 - l.span.end));
```
becomes:
```
    lints.sort_by_key(|l| (l.span.start, Reverse(l.span.end), Reverse(l.priority)));
```
and this:
```
    spans.sort_by_key(|span| (span.start, usize::MAX - span.end));
```
becomes this:
```
    spans.sort_by_key(|span| (span.start, Reverse(span.end), Reverse(span.priority)));
```

I'm not sure if this is the right fix / the best fix?
I wonder if this will be resolved by #2685.
> I wonder if this will be resolved by [#2685](https://github.com/Automattic/harper/pull/2685).

Behaviour is the same in v1.9.0



Patch:
diff --git a/harper-core/default_config.json b/harper-core/default_config.json
index 16ac29404..87dd793bf 100644
--- a/harper-core/default_config.json
+++ b/harper-core/default_config.json
@@ -2245,6 +2245,13 @@
 								"label": "Sneaked Snuck"
 							}
 						},
+						{
+							"Bool": {
+								"name": "OkToOkay",
+								"state": true,
+								"label": "Ok To Okay"
+							}
+						},
 						{
 							"Bool": {
 								"name": "SpellCheck",
diff --git a/harper-core/src/linting/capitalize_personal_pronouns.rs b/harper-core/src/linting/capitalize_personal_pronouns.rs
index 71b115c29..7f8ed6b92 100644
--- a/harper-core/src/linting/capitalize_personal_pronouns.rs
+++ b/harper-core/src/linting/capitalize_personal_pronouns.rs
@@ -21,6 +21,7 @@ impl Linter for CapitalizePersonalPronouns {
                         | ['i', '\'', 'l', 'l']
                         | ['i', '\'', 'm']
                         | ['i', '\'', 'v', 'e']
+                        | ['i', 'v', 'e']
                 ) {
                     let mut replacement = span_content.to_vec();
                     replacement[0] = 'I';
diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs
index 55d83c8f9..bbfb6136c 100644
--- a/harper-core/src/linting/lint_group/mod.rs
+++ b/harper-core/src/linting/lint_group/mod.rs
@@ -279,7 +279,7 @@ pub struct LintGroup {
     /// We use a binary map here so the ordering is stable.
     chunk_expr_linters: BTreeMap<String, Box<dyn ExprLinter<Unit = Chunk>>>,
     /// Since [`ExprLinter`]s operate on a chunk-basis, we can store a
-    /// mapping of `Chunk -> Lint` and only re-run the expr linters
+    /// mapping of `Chunk -> Lint` and only rerun the expr linters
     /// when a chunk changes.
     ///
     /// Since the expr linter results also depend on the config, we hash it and pass it as part
@@ -335,7 +335,6 @@ impl LintGroup {
     pub fn add_chunk_expr_linter(
         &mut self,
         name: impl AsRef<str>,
-        // linter: impl ExprLinter + 'static,
         linter: impl ExprLinter<Unit = Chunk> + 'static,
     ) -> bool {
         if self.contains_key(&name) {
@@ -865,7 +864,7 @@ mod tests {
 
     use super::{FlatConfig, LintGroup};
     use crate::linting::LintKind;
-    use crate::linting::tests::assert_no_lints;
+    use crate::linting::tests::{assert_no_lints, assert_suggestion_result};
     use crate::spell::{FstDictionary, MutableDictionary};
     use crate::{Dialect, Document, linting::Linter};
 
@@ -886,6 +885,54 @@ mod tests {
         assert_no_lints("But there is less consensus on this.", test_group());
     }
 
+    #[test]
+    fn ive_corrects_to_single_word() {
+        assert_suggestion_result(
+            "ive never seen that before",
+            test_group(),
+            "I've never seen that before",
+        );
+    }
+
+    #[test]
+    fn worthchecking_is_split() {
+        assert_suggestion_result("It is worthchecking", test_group(), "It is worth checking");
+    }
+
+    #[test]
+    fn its_not_perfect_keeps_apostrophe() {
+        assert_no_lints("It's not perfect", test_group());
+    }
+
+    #[test]
+    fn corrects_extention() {
+        let mut group = test_group();
+        let document = Document::new_plain_english_curated("I love this extention!");
+        let organized = group.organized_lints(&document);
+
+        let spellcheck_lints = organized
+            .get("SpellCheck")
+            .expect("SpellCheck should produce a lint for extention");
+        assert_eq!(spellcheck_lints.len(), 1);
+        assert!(
+            spellcheck_lints[0]
+                .suggestions
+                .iter()
+                .any(|suggestion| suggestion.to_string() == "Replace with: “extension”")
+        );
+
+        assert!(
+            organized.get("SplitWords").is_none_or(Vec::is_empty),
+            "expected no lints from SplitWords, but found {:?}",
+            organized.get("SplitWords")
+        );
+    }
+
+    #[test]
+    fn ok_becomes_okay() {
+        assert_suggestion_result("This is ok.", test_group(), "This is okay.");
+    }
+
     #[test]
     fn can_get_all_descriptions() {
         let group =
diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs
index 33850602d..08bfd72ef 100644
--- a/harper-core/src/linting/mod.rs
+++ b/harper-core/src/linting/mod.rs
@@ -615,7 +615,8 @@ pub mod tests {
         // Lint current text and try each suggestion branch
         let chars: Vec<char> = text.chars().collect();
         let document = create_document(&chars, doc_type);
-        let lints = linter.lint(&document);
+        let mut lints = linter.lint(&document);
+        lints.sort_by_key(|l| l.priority);
 
         if let Some(lint) = lints.first() {
             for sug in lint.suggestions.iter() {
diff --git a/harper-core/src/linting/split_words.rs b/harper-core/src/linting/split_words.rs
index df69afa49..f1913b800 100644
--- a/harper-core/src/linting/split_words.rs
+++ b/harper-core/src/linting/split_words.rs
@@ -3,7 +3,10 @@ use std::sync::Arc;
 use hashbrown::HashSet;
 
 use crate::expr::Expr;
-use crate::linting::{ExprLinter, LintKind, Suggestion, expr_linter::Chunk};
+use crate::linting::{
+    ExprLinter, LintKind, Suggestion,
+    expr_linter::{Chunk, at_start_of_sentence, preceded_by_word},
+};
 use crate::spell::{Dictionary, FstDictionary, TrieDictionary};
 use crate::{Lint, Token};
 
@@ -38,7 +41,12 @@ impl ExprLinter for SplitWords {
         self.expr.as_ref()
     }
 
-    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
+    fn match_to_lint_with_context(
+        &self,
+        matched_tokens: &[Token],
+        source: &[char],
+        context: Option<(&[Token], &[Token])>,
+    ) -> Option<Lint> {
         let word = &matched_tokens[0];
 
         // If it's a recognized word, we don't care about it.
@@ -76,6 +84,7 @@ impl ExprLinter for SplitWords {
         }
 
         let mut suggestions = Vec::new();
+        let mut has_anchor_split = false;
         let mut message: Option<String> = None;
 
         // Check positions in middle-outward order
@@ -88,22 +97,24 @@ impl ExprLinter for SplitWords {
             let remainder = &chars[split_pos..];
 
             // Both parts must be valid common words
-            if let Some(cand_meta) = self.dict.get_word_metadata(candidate) {
-                if !cand_meta.common {
-                    continue;
-                }
-            } else {
+            let Some(cand_meta) = self.dict.get_word_metadata(candidate) else {
+                continue;
+            };
+            if !cand_meta.common {
                 continue;
             }
 
-            if let Some(rem_meta) = self.dict.get_word_metadata(remainder) {
-                if !rem_meta.common {
-                    continue;
-                }
-            } else {
+            let Some(rem_meta) = self.dict.get_word_metadata(remainder) else {
+                continue;
+            };
+            if !rem_meta.common {
                 continue;
             }
 
+            if is_anchor_split(&cand_meta, candidate) || is_anchor_split(&rem_meta, remainder) {
+                has_anchor_split = true;
+            }
+
             // Valid split found
             let mut suggestion = Vec::new();
             suggestion.extend(candidate.iter());
@@ -124,6 +135,10 @@ impl ExprLinter for SplitWords {
         if !suggestions.is_empty() {
             let original_word: String = chars.iter().collect();
 
+            if should_defer_to_spellcheck(&self.dict, chars, has_anchor_split, context) {
+                return None;
+            }
+
             if suggestions.len() != 1 {
                 message = Some(format!(
                     "`{original_word}` has a missing space between words."
@@ -143,6 +158,45 @@ impl ExprLinter for SplitWords {
     }
 }
 
+fn is_anchor_split(meta: &crate::DictWordMetadata, word: &[char]) -> bool {
+    meta.preposition
+        || meta.is_determiner()
+        || meta.is_conjunction()
+        || meta.is_pronoun()
+        || meta.is_adverb()
+        || word.len() <= 2
+}
+
+fn should_defer_to_spellcheck(
+    dict: &TrieDictionary<Arc<FstDictionary>>,
+    chars: &[char],
+    has_anchor_split: bool,
+    context: Option<(&[Token], &[Token])>,
+) -> bool {
+    if has_anchor_split {
+        return false;
+    }
+
+    let nounish_context = context.is_some_and(|_| {
+        at_start_of_sentence(context)
+            || preceded_by_word(context, |tok| {
+                tok.kind.is_determiner()
+                    || tok.kind.is_pronoun()
+                    || tok.kind.is_adjective()
+                    || tok.kind.is_possessive_determiner()
+            })
+    });
+
+    if !nounish_context {
+        return false;
+    }
+
+    // If the whole word has a strong one-word correction, prefer that over a content-word split.
+    dict.fuzzy_match(chars, 1, 1)
+        .first()
+        .is_some_and(|suggestion| suggestion.edit_distance == 1)
+}
+
 #[cfg(test)]
 mod tests {
     use crate::linting::tests::{
@@ -216,6 +270,21 @@ mod tests {
         assert_no_lints("The monkeyxyz escaped unnoticed.", SplitWords::default());
     }
 
+    #[test]
+    fn ignores_single_word_misspelling_with_split_like_halves() {
+        assert_no_lints("I love this extention!", SplitWords::default());
+    }
+
+    #[test]
+    fn corrects_doesthe() {
+        assert_suggestion_result("doesthe", SplitWords::default(), "does the");
+    }
+
+    #[test]
+    fn corrects_splitwords() {
+        assert_suggestion_result("splitwords", SplitWords::default(), "split words");
+    }
+
     #[test]
     fn test_atall_to_at_all() {
         assert_suggestion_result(
diff --git a/harper-core/src/linting/weir_rules/OkToOkay.weir b/harper-core/src/linting/weir_rules/OkToOkay.weir
new file mode 100644
index 000000000..46f174aa0
--- /dev/null
+++ b/harper-core/src/linting/weir_rules/OkToOkay.weir
@@ -0,0 +1,22 @@
+expr main (ok)
+
+let message "Use `okay` instead of `ok`."
+let description "Corrects `ok` to `okay`."
+let kind "Spelling"
+let becomes "okay"
+
+test "ok" "okay"
+test "ok." "okay."
+test "ok," "okay,"
+test "ok!" "okay!"
+test "ok?" "okay?"
+test "Please say ok." "Please say okay."
+test "It is ok." "It is okay."
+test "We are ok today." "We are okay today."
+test "ok then" "okay then"
+test "ok, then let's go." "okay, then let's go."
+
+allows "The word okay is already correct."
+allows "Oklahoma is a state."
+allows "An okapi is an animal."
+allows "The code looks okay to me."


Test patch:
diff --git a/harper-core/tests/text/linters/Alice's Adventures in Wonderland.snap.yml b/harper-core/tests/text/linters/Alice's Adventures in Wonderland.snap.yml
index 777c4026a..dda68ede0 100644
--- a/harper-core/tests/text/linters/Alice's Adventures in Wonderland.snap.yml
+++ b/harper-core/tests/text/linters/Alice's Adventures in Wonderland.snap.yml
@@ -1395,16 +1395,6 @@ Suggest:
 
 
 
-Lint:    Typo (31 priority)
-Message: |
-     961 | her foot, that there was hardly room to open her mouth; but she did it at last,
-     962 | and managed to swallow a morsel of the lefthand bit.
-         |                                        ^~~~~~~~ `lefthand` should probably be written as `left hand`.
-Suggest:
-  - Replace with: “left hand”
-
-
-
 Lint:    Readability (127 priority)
 Message: |
      964 | “Come, my head’s free at last!” said Alice in a tone of delight, which changed
@@ -1513,16 +1503,6 @@ Suggest:
 
 
 
-Lint:    Typo (31 priority)
-Message: |
-    1055 | this size: why, I should frighten them out of their wits!” So she began nibbling
-    1056 | at the righthand bit again, and did not venture to go near the house till she
-         |        ^~~~~~~~~ `righthand` should probably be written as `right hand`.
-Suggest:
-  - Replace with: “right hand”
-
-
-
 Lint:    Capitalization (127 priority)
 Message: |
     1059 | ## CHAPTER VI: Pig and Pepper
@@ -1740,16 +1720,6 @@ Suggest:
 
 
 
-Lint:    Typo (31 priority)
-Message: |
-    1349 | did not like to go nearer till she had nibbled some more of the lefthand bit of
-         |                                                                 ^~~~~~~~ `lefthand` should probably be written as `left hand`.
-    1350 | mushroom, and raised herself to about two feet high: even then she walked up
-Suggest:
-  - Replace with: “left hand”
-
-
-
 Lint:    Readability (127 priority)
 Message: |
     1356 | There was a table set out under a tree in front of the house, and the March Hare
diff --git a/harper-core/tests/text/linters/The Great Gatsby.snap.yml b/harper-core/tests/text/linters/The Great Gatsby.snap.yml
index 10db0e2fc..1dbbc2b0a 100644
--- a/harper-core/tests/text/linters/The Great Gatsby.snap.yml
+++ b/harper-core/tests/text/linters/The Great Gatsby.snap.yml
@@ -213,16 +213,6 @@ Suggest:
 
 
 
-Lint:    Typo (31 priority)
-Message: |
-      72 | sounded like a great idea. He found the house, a weatherbeaten cardboard
-         |                                                  ^~~~~~~~~~~~~ `weatherbeaten` should probably be written as `weather beaten`.
-      73 | bungalow at eighty a month, but at the last minute the firm ordered him to
-Suggest:
-  - Replace with: “weather beaten”
-
-
-
 Lint:    Readability (127 priority)
 Message: |
       74 | Washington, and I went out to the country alone. I had a dog—at least I had him
@@ -638,16 +628,6 @@ Suggest:
 
 
 
-Lint:    Typo (31 priority)
-Message: |
-     529 | the whole evening had been a trick of some sort to exact a contributary emotion
-         |                                                            ^~~~~~~~~~~~ `contributary` should probably be written as `con tributary`.
-     530 | from me. I waited, and sure enough, in a moment she looked at me with an
-Suggest:
-  - Replace with: “con tributary”
-
-
-
 Lint:    Spelling (63 priority)
 Message: |
      551 | “Jordan’s going to play in the tournament tomorrow,” explained Daisy, “over at