diff --git a/harper-core/src/linting/lint_group.rs b/harper-core/src/linting/lint_group.rs index 95c19679..4db54468 100644 --- a/harper-core/src/linting/lint_group.rs +++ b/harper-core/src/linting/lint_group.rs @@ -110,12 +110,14 @@ use super::quote_spacing::QuoteSpacing; use super::redundant_additive_adverbs::RedundantAdditiveAdverbs; use super::regionalisms::Regionalisms; use super::repeated_words::RepeatedWords; +use super::roller_skated::RollerSkated; use super::save_to_safe::SaveToSafe; use super::semicolon_apostrophe::SemicolonApostrophe; use super::sentence_capitalization::SentenceCapitalization; use super::shoot_oneself_in_the_foot::ShootOneselfInTheFoot; use super::simple_past_to_past_participle::SimplePastToPastParticiple; use super::since_duration::SinceDuration; +use super::some_without_article::SomeWithoutArticle; use super::something_is::SomethingIs; use super::somewhat_something::SomewhatSomething; use super::sought_after::SoughtAfter; @@ -526,6 +528,7 @@ impl LintGroup { insert_struct_rule!(QuoteSpacing, true); insert_expr_rule!(RedundantAdditiveAdverbs, true); insert_struct_rule!(RepeatedWords, true); + insert_expr_rule!(RollerSkated, true); insert_struct_rule!(SaveToSafe, true); insert_expr_rule!(SemicolonApostrophe, true); insert_expr_rule!(ShootOneselfInTheFoot, true); @@ -534,6 +537,7 @@ impl LintGroup { insert_expr_rule!(SomethingIs, true); insert_expr_rule!(SomewhatSomething, true); insert_expr_rule!(SoughtAfter, true); + insert_expr_rule!(SomeWithoutArticle, true); insert_struct_rule!(Spaces, true); insert_struct_rule!(SpelledNumbers, false); insert_expr_rule!(ThatThan, true); diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index e3a47d8e..d893f46b 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -123,12 +123,14 @@ mod quote_spacing; mod redundant_additive_adverbs; mod regionalisms; mod repeated_words; +mod roller_skated; mod save_to_safe; mod semicolon_apostrophe; mod sentence_capitalization; mod shoot_oneself_in_the_foot; mod simple_past_to_past_participle; mod since_duration; +mod some_without_article; mod something_is; mod somewhat_something; mod sought_after; @@ -269,12 +271,14 @@ pub use quote_spacing::QuoteSpacing; pub use redundant_additive_adverbs::RedundantAdditiveAdverbs; pub use regionalisms::Regionalisms; pub use repeated_words::RepeatedWords; +pub use roller_skated::RollerSkated; pub use save_to_safe::SaveToSafe; pub use semicolon_apostrophe::SemicolonApostrophe; pub use sentence_capitalization::SentenceCapitalization; pub use shoot_oneself_in_the_foot::ShootOneselfInTheFoot; pub use simple_past_to_past_participle::SimplePastToPastParticiple; pub use since_duration::SinceDuration; +pub use some_without_article::SomeWithoutArticle; pub use something_is::SomethingIs; pub use somewhat_something::SomewhatSomething; pub use sought_after::SoughtAfter; diff --git a/harper-core/src/linting/phrase_corrections/mod.rs b/harper-core/src/linting/phrase_corrections/mod.rs index 5e07acfa..aa28e9ba 100644 --- a/harper-core/src/linting/phrase_corrections/mod.rs +++ b/harper-core/src/linting/phrase_corrections/mod.rs @@ -1096,6 +1096,13 @@ pub fn lint_group() -> LintGroup { "Corrects `to the manor born` to `to the manner born`, ensuring the intended meaning of being naturally suited to a way of life.", LintKind::Eggcorn ), + "TongueInCheek" => ( + ["tongue and cheek"], + ["tongue in cheek"], + "Use `tongue in cheek` for the idiom.", + "Corrects the idiom when `and` replaces the needed preposition.", + LintKind::WordChoice + ), "Towards" => ( ["to towards"], ["towards"], diff --git a/harper-core/src/linting/phrase_corrections/tests.rs b/harper-core/src/linting/phrase_corrections/tests.rs index f54bdd15..b456edf1 100644 --- a/harper-core/src/linting/phrase_corrections/tests.rs +++ b/harper-core/src/linting/phrase_corrections/tests.rs @@ -1440,6 +1440,93 @@ fn correct_to_a_great_length() { // ToTheMannerBorn // -none- +// TongueInCheek +#[test] +fn tongue_and_cheek_plain() { + assert_suggestion_result( + "The remark was entirely tongue and cheek.", + lint_group(), + "The remark was entirely tongue in cheek.", + ); +} + +#[test] +fn tongue_and_cheek_with_article() { + assert_suggestion_result( + "It was a tongue and cheek response.", + lint_group(), + "It was a tongue in cheek response.", + ); +} + +#[test] +fn tongue_and_cheek_with_comma() { + assert_suggestion_result( + "He delivered it tongue and cheek, expecting a laugh.", + lint_group(), + "He delivered it tongue in cheek, expecting a laugh.", + ); +} + +#[test] +fn tongue_and_cheek_in_quotes() { + assert_suggestion_result( + "\"tongue and cheek\" jokes are tough to read.", + lint_group(), + "\"tongue in cheek\" jokes are tough to read.", + ); +} + +#[test] +fn tongue_and_cheek_all_caps() { + assert_suggestion_result( + "Their tone was TONGUE AND CHEEK all night.", + lint_group(), + "Their tone was TONGUE IN CHEEK all night.", + ); +} + +#[test] +fn tongue_and_cheek_capitalized() { + assert_suggestion_result( + "Tongue and cheek banter kept the meeting light.", + lint_group(), + "Tongue in cheek banter kept the meeting light.", + ); +} + +#[test] +fn tongue_and_cheek_in_parentheses() { + assert_suggestion_result( + "Her note (totally tongue and cheek) made us smile.", + lint_group(), + "Her note (totally tongue in cheek) made us smile.", + ); +} + +#[test] +fn tongue_and_cheek_question() { + assert_suggestion_result( + "Was that tongue and cheek or sincere?", + lint_group(), + "Was that tongue in cheek or sincere?", + ); +} + +#[test] +fn tongue_in_cheek_is_allowed() { + assert_lint_count( + "Their comments were deliberately tongue in cheek.", + lint_group(), + 0, + ); +} + +#[test] +fn tongue_in_cheek_hyphenated_is_allowed() { + assert_lint_count("That was a tongue-in-cheek reply.", lint_group(), 0); +} + // Towards // -none- diff --git a/harper-core/src/linting/roller_skated.rs b/harper-core/src/linting/roller_skated.rs new file mode 100644 index 00000000..02516952 --- /dev/null +++ b/harper-core/src/linting/roller_skated.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use crate::{ + Token, TokenKind, TokenStringExt, + expr::{AnchorStart, Expr, ExprMap, SequenceExpr}, + linting::{ExprLinter, Lint, LintKind, Suggestion}, +}; + +/// Suggests hyphenating the past tense of `roller-skate`. +pub struct RollerSkated { + expr: Box, + map: Arc>, +} + +impl RollerSkated { + fn roller_pair() -> SequenceExpr { + SequenceExpr::default() + .t_aco("roller") + .t_ws() + .t_aco("skated") + } +} + +impl Default for RollerSkated { + fn default() -> Self { + let mut map = ExprMap::default(); + + map.insert( + SequenceExpr::default() + .then_kind_is_but_is_not( + |kind| matches!(kind, TokenKind::Word(_)), + |kind| kind.is_determiner(), + ) + .then_whitespace() + .then_seq(Self::roller_pair()), + 2, + ); + + map.insert( + SequenceExpr::default() + .then_punctuation() + .then_whitespace() + .then_seq(Self::roller_pair()), + 2, + ); + + map.insert( + SequenceExpr::default() + .then_punctuation() + .then_seq(Self::roller_pair()), + 1, + ); + + map.insert( + SequenceExpr::default() + .then(AnchorStart) + .then_seq(Self::roller_pair()), + 0, + ); + + let map = Arc::new(map); + + Self { + expr: Box::new(map.clone()), + map, + } + } +} + +impl ExprLinter for RollerSkated { + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let roller_idx = *self.map.lookup(0, matched_tokens, source)?; + let skated_idx = roller_idx.checked_add(2)?; + let window = matched_tokens.get(roller_idx..=skated_idx)?; + let span = window.span()?; + let original = span.get_content(source); + + Some(Lint { + span, + lint_kind: LintKind::Punctuation, + suggestions: vec![Suggestion::replace_with_match_case( + "roller-skated".chars().collect(), + original, + )], + message: "Hyphenate this verb as `roller-skated`.".to_owned(), + priority: 40, + }) + } + + fn description(&self) -> &'static str { + "Encourages hyphenating the past tense of `roller-skate`." + } +} + +#[cfg(test)] +mod tests { + use super::RollerSkated; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn corrects_basic_sentence() { + assert_suggestion_result( + "He roller skated down the hill.", + RollerSkated::default(), + "He roller-skated down the hill.", + ); + } + + #[test] + fn corrects_with_adverb() { + assert_suggestion_result( + "They roller skated quickly around the rink.", + RollerSkated::default(), + "They roller-skated quickly around the rink.", + ); + } + + #[test] + fn corrects_with_auxiliary() { + assert_suggestion_result( + "She had roller skated there before.", + RollerSkated::default(), + "She had roller-skated there before.", + ); + } + + #[test] + fn corrects_with_contraction() { + assert_suggestion_result( + "They'd roller skated all night.", + RollerSkated::default(), + "They'd roller-skated all night.", + ); + } + + #[test] + fn corrects_caps() { + assert_suggestion_result( + "They ROLLER SKATED yesterday.", + RollerSkated::default(), + "They ROLLER-SKATED yesterday.", + ); + } + + #[test] + fn corrects_in_quotes() { + assert_suggestion_result( + "\"We roller skated together,\" she said.", + RollerSkated::default(), + "\"We roller-skated together,\" she said.", + ); + } + + #[test] + fn corrects_across_line_break() { + assert_suggestion_result( + "We\nroller skated whenever we could.", + RollerSkated::default(), + "We\nroller-skated whenever we could.", + ); + } + + #[test] + fn corrects_with_trailing_punctuation() { + assert_suggestion_result( + "He roller skated, laughed, and waved.", + RollerSkated::default(), + "He roller-skated, laughed, and waved.", + ); + } + + #[test] + fn corrects_without_space_after_punctuation() { + assert_suggestion_result( + "He roller skated,laughed, and waved.", + RollerSkated::default(), + "He roller-skated,laughed, and waved.", + ); + } + + #[test] + fn allows_hyphenated_form() { + assert_lint_count("They roller-skated yesterday.", RollerSkated::default(), 0); + } + + #[test] + fn allows_subject_named_roller() { + assert_lint_count( + "The roller skated across the stage.", + RollerSkated::default(), + 0, + ); + } + + #[test] + fn allows_other_compounds() { + assert_lint_count( + "Their roller skating routine impressed everyone.", + RollerSkated::default(), + 0, + ); + } +} diff --git a/harper-core/src/linting/some_without_article.rs b/harper-core/src/linting/some_without_article.rs new file mode 100644 index 00000000..9d0a9363 --- /dev/null +++ b/harper-core/src/linting/some_without_article.rs @@ -0,0 +1,153 @@ +use crate::{ + Token, TokenStringExt, + expr::{Expr, SequenceExpr}, + linting::{ExprLinter, Lint, LintKind, Suggestion}, +}; + +pub struct SomeWithoutArticle { + expr: Box, +} + +impl Default for SomeWithoutArticle { + fn default() -> Self { + let expr = SequenceExpr::default() + .then_any_capitalization_of("the") + .t_ws() + .then_any_capitalization_of("some"); + + Self { + expr: Box::new(expr), + } + } +} + +impl ExprLinter for SomeWithoutArticle { + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let span = matched_tokens.span()?; + let template = span.get_content(source); + let some_chars = matched_tokens.last()?.span.get_content(source); + + let suggestions = vec![ + Suggestion::ReplaceWith(some_chars.to_vec()), + Suggestion::replace_with_match_case("the same".chars().collect(), template), + ]; + + Some(Lint { + span, + lint_kind: LintKind::WordChoice, + message: + "Use `some` on its own here, or switch to `the same` if that was the intention." + .to_owned(), + suggestions, + ..Default::default() + }) + } + + fn description(&self) -> &'static str { + "Detects the redundant article in front of `some` and suggests more natural phrasing." + } +} + +#[cfg(test)] +mod tests { + use crate::linting::tests::{ + assert_lint_count, assert_nth_suggestion_result, assert_suggestion_result, + }; + + use super::SomeWithoutArticle; + + #[test] + fn fixes_simple_lowercase() { + assert_suggestion_result( + "We interviewed the some candidates today.", + SomeWithoutArticle::default(), + "We interviewed some candidates today.", + ); + } + + #[test] + fn fixes_sentence_case() { + assert_suggestion_result( + "The Some volunteers arrived early.", + SomeWithoutArticle::default(), + "Some volunteers arrived early.", + ); + } + + #[test] + fn preserves_uppercase_block() { + assert_suggestion_result( + "THE SOME OPTIONS WERE LISTED.", + SomeWithoutArticle::default(), + "SOME OPTIONS WERE LISTED.", + ); + } + + #[test] + fn second_suggestion_produces_the_same() { + assert_nth_suggestion_result( + "We kept the some approach from last year.", + SomeWithoutArticle::default(), + "We kept the same approach from last year.", + 1, + ); + } + + #[test] + fn ignores_already_correct_some() { + assert_lint_count( + "We interviewed some candidates today.", + SomeWithoutArticle::default(), + 0, + ); + } + + #[test] + fn ignores_the_same() { + assert_lint_count( + "We kept the same approach from last year.", + SomeWithoutArticle::default(), + 0, + ); + } + + #[test] + fn ignores_the_something() { + assert_lint_count( + "We interviewed the something else entirely.", + SomeWithoutArticle::default(), + 0, + ); + } + + #[test] + fn works_before_comma() { + assert_suggestion_result( + "They reviewed the some, then finalized the list.", + SomeWithoutArticle::default(), + "They reviewed some, then finalized the list.", + ); + } + + #[test] + fn works_before_possessive_noun() { + assert_suggestion_result( + "The report praised the some team's effort.", + SomeWithoutArticle::default(), + "The report praised some team's effort.", + ); + } + + #[test] + fn handles_line_break_spacing() { + assert_suggestion_result( + "We invited the some\nartists to perform.", + SomeWithoutArticle::default(), + "We invited some\nartists to perform.", + ); + } +}