diff --git a/harper-core/src/linting/apart_from.rs b/harper-core/src/linting/apart_from.rs new file mode 100644 index 00000000..ca50c6b7 --- /dev/null +++ b/harper-core/src/linting/apart_from.rs @@ -0,0 +1,144 @@ +use crate::Token; +use crate::expr::{Expr, SequenceExpr}; +use crate::linting::expr_linter::Chunk; + +use super::{ExprLinter, Lint, LintKind, Suggestion}; + +pub struct ApartFrom { + expr: Box, +} + +impl Default for ApartFrom { + fn default() -> Self { + let expr = SequenceExpr::any_capitalization_of("apart") + .t_ws() + .then_any_capitalization_of("form"); + + Self { + expr: Box::new(expr), + } + } +} + +impl ExprLinter for ApartFrom { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let span = matched_tokens.last()?.span; + + Some(Lint { + span, + lint_kind: LintKind::WordChoice, + suggestions: vec![Suggestion::replace_with_match_case_str( + "from", + span.get_content(source), + )], + message: "Use `from` to spell `apart from`.".to_owned(), + priority: 50, + }) + } + + fn description(&self) -> &'static str { + "Flags the misspelling `apart form` and suggests `apart from`." + } +} + +#[cfg(test)] +mod tests { + use super::ApartFrom; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn corrects_basic_typo() { + assert_suggestion_result( + "Christianity was set apart form other religions.", + ApartFrom::default(), + "Christianity was set apart from other religions.", + ); + } + + #[test] + fn corrects_title_case() { + assert_suggestion_result( + "Apart Form these files, everything uploaded fine.", + ApartFrom::default(), + "Apart From these files, everything uploaded fine.", + ); + } + + #[test] + fn corrects_all_caps() { + assert_suggestion_result( + "APART FORM THE REST OF THE FIELD.", + ApartFrom::default(), + "APART FROM THE REST OF THE FIELD.", + ); + } + + #[test] + fn corrects_with_comma() { + assert_suggestion_result( + "It was apart form, not apart from, the original plan.", + ApartFrom::default(), + "It was apart from, not apart from, the original plan.", + ); + } + + #[test] + fn corrects_with_newline() { + assert_suggestion_result( + "They stood apart\nform everyone else at the rally.", + ApartFrom::default(), + "They stood apart\nfrom everyone else at the rally.", + ); + } + + #[test] + fn corrects_extra_spacing() { + assert_suggestion_result( + "We keep the archive apart form public assets.", + ApartFrom::default(), + "We keep the archive apart from public assets.", + ); + } + + #[test] + fn allows_correct_phrase() { + assert_lint_count( + "Lebanon's freedoms set it apart from other Arab states.", + ApartFrom::default(), + 0, + ); + } + + #[test] + fn ignores_hyphenated() { + assert_lint_count( + "Their apart-form design wasn’t what we needed.", + ApartFrom::default(), + 0, + ); + } + + #[test] + fn ignores_split_by_comma() { + assert_lint_count( + "They stood apart, form lines when asked.", + ApartFrom::default(), + 0, + ); + } + + #[test] + fn ignores_unrelated_form_usage() { + assert_lint_count( + "The form was kept apart to dry after printing.", + ApartFrom::default(), + 0, + ); + } +} diff --git a/harper-core/src/linting/cure_for.rs b/harper-core/src/linting/cure_for.rs new file mode 100644 index 00000000..82d3265e --- /dev/null +++ b/harper-core/src/linting/cure_for.rs @@ -0,0 +1,147 @@ +use crate::{ + Span, Token, + expr::{Expr, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, + patterns::{DerivedFrom, Word}, +}; + +pub struct CureFor { + expr: Box, +} + +impl Default for CureFor { + fn default() -> Self { + let expr = SequenceExpr::default() + .then(DerivedFrom::new_from_str("cure")) + .t_ws() + .then(Word::new("against")); + + Self { + expr: Box::new(expr), + } + } +} + +impl ExprLinter for CureFor { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let against = matched_tokens.last()?; + + let template: Vec = against.span.get_content(source).to_vec(); + let suggestion = Suggestion::replace_with_match_case_str("for", &template); + + Some(Lint { + span: Span::new(against.span.start, against.span.end), + lint_kind: LintKind::Usage, + suggestions: vec![suggestion], + message: "Prefer `cure for` when describing a treatment target.".to_owned(), + priority: 31, + }) + } + + fn description(&self) -> &str { + "Flags `cure against` and prefers the standard `cure for` pairing." + } +} + +#[cfg(test)] +mod tests { + use super::CureFor; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn corrects_simple_cure_against() { + assert_suggestion_result( + "Researchers sought a cure against the stubborn illness.", + CureFor::default(), + "Researchers sought a cure for the stubborn illness.", + ); + } + + #[test] + fn corrects_plural_cures_against() { + assert_suggestion_result( + "Doctors insist this serum cures against the new variant.", + CureFor::default(), + "Doctors insist this serum cures for the new variant.", + ); + } + + #[test] + fn corrects_past_participle_cured_against() { + assert_suggestion_result( + "The remedy was cured against the infection last spring.", + CureFor::default(), + "The remedy was cured for the infection last spring.", + ); + } + + #[test] + fn corrects_uppercase_against() { + assert_suggestion_result( + "We still trust the cure AGAINST the dreaded plague.", + CureFor::default(), + "We still trust the cure FOR the dreaded plague.", + ); + } + + #[test] + fn corrects_at_sentence_start() { + assert_suggestion_result( + "Cure against that condition became the rallying cry.", + CureFor::default(), + "Cure for that condition became the rallying cry.", + ); + } + + #[test] + fn does_not_flag_cure_for() { + assert_lint_count( + "They finally found a cure for the fever.", + CureFor::default(), + 0, + ); + } + + #[test] + fn does_not_flag_cure_from() { + assert_lint_count( + "A cure from this rare herb is on the horizon.", + CureFor::default(), + 0, + ); + } + + #[test] + fn does_not_flag_with_comma() { + assert_lint_count( + "A cure, against all odds, appeared in the files.", + CureFor::default(), + 0, + ); + } + + #[test] + fn does_not_flag_unrelated_against() { + assert_lint_count( + "Travelers stand against the roaring wind on the cliffs.", + CureFor::default(), + 0, + ); + } + + #[test] + fn does_not_flag_secure_against() { + assert_lint_count( + "The fortress stayed secure against the invaders.", + CureFor::default(), + 0, + ); + } +} diff --git a/harper-core/src/linting/handful.rs b/harper-core/src/linting/handful.rs new file mode 100644 index 00000000..d13d6549 --- /dev/null +++ b/harper-core/src/linting/handful.rs @@ -0,0 +1,160 @@ +use crate::expr::{Expr, SequenceExpr, SpaceOrHyphen}; +use crate::linting::expr_linter::Chunk; +use crate::{Token, TokenStringExt}; + +use super::{ExprLinter, Lint, LintKind, Suggestion}; + +pub struct Handful { + expr: Box, +} + +impl Default for Handful { + fn default() -> Self { + let expr = SequenceExpr::default() + .then_any_capitalization_of("hand") + .then_one_or_more(SpaceOrHyphen) + .then_any_capitalization_of("full") + .then_one_or_more(SpaceOrHyphen) + .then_any_capitalization_of("of"); + + Self { + expr: Box::new(expr), + } + } +} + +impl ExprLinter for Handful { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + if matched_tokens.len() < 2 { + return None; + } + + let mut highlight_end = matched_tokens.len() - 1; + while highlight_end > 0 { + let prev = &matched_tokens[highlight_end - 1]; + if prev.kind.is_whitespace() || prev.kind.is_hyphen() { + highlight_end -= 1; + } else { + break; + } + } + + if highlight_end == 0 { + return None; + } + + let replacement = &matched_tokens[..highlight_end]; + let span = replacement.span()?; + let template = matched_tokens.first()?.span.get_content(source); + + Some(Lint { + span, + lint_kind: LintKind::BoundaryError, + suggestions: vec![Suggestion::replace_with_match_case( + "handful".chars().collect(), + template, + )], + message: "Write this quantity as the single word `handful`.".to_owned(), + priority: 31, + }) + } + + fn description(&self) -> &'static str { + "Keeps the palm-sized quantity expressed by `handful` as one word." + } +} + +#[cfg(test)] +mod tests { + use super::Handful; + use crate::linting::tests::{assert_lint_count, assert_no_lints, assert_suggestion_result}; + + #[test] + fn suggests_plain_spacing() { + assert_suggestion_result( + "Her basket held a hand full of berries.", + Handful::default(), + "Her basket held a handful of berries.", + ); + } + + #[test] + fn suggests_capitalized_form() { + assert_suggestion_result( + "Hand full of tales lined the shelf.", + Handful::default(), + "Handful of tales lined the shelf.", + ); + } + + #[test] + fn suggests_hyphenated_form() { + assert_suggestion_result( + "A hand-full of marbles scattered across the floor.", + Handful::default(), + "A handful of marbles scattered across the floor.", + ); + } + + #[test] + fn suggests_space_hyphen_combo() { + assert_suggestion_result( + "A hand - full of seeds spilled on the workbench.", + Handful::default(), + "A handful of seeds spilled on the workbench.", + ); + } + + #[test] + fn suggests_initial_hyphen_variants() { + assert_suggestion_result( + "Hand-Full of furniture, the cart creaked slowly.", + Handful::default(), + "Handful of furniture, the cart creaked slowly.", + ); + } + + #[test] + fn flags_multiple_instances() { + assert_lint_count( + "She carried a hand full of carrots and a hand full of radishes.", + Handful::default(), + 2, + ); + } + + #[test] + fn allows_correct_handful() { + assert_no_lints( + "A handful of volunteers arrived in time.", + Handful::default(), + ); + } + + #[test] + fn allows_parenthetical_hand() { + assert_no_lints( + "His hand, full of ink, kept writing without pause.", + Handful::default(), + ); + } + + #[test] + fn allows_hand_is_full() { + assert_no_lints("The hand is full of water.", Handful::default()); + } + + #[test] + fn allows_handfull_typo() { + assert_no_lints( + "The word handfull is an incorrect spelling.", + Handful::default(), + ); + } +} diff --git a/harper-core/src/linting/its_contraction/general.rs b/harper-core/src/linting/its_contraction/general.rs new file mode 100644 index 00000000..4621df51 --- /dev/null +++ b/harper-core/src/linting/its_contraction/general.rs @@ -0,0 +1,88 @@ +use harper_brill::UPOS; + +use crate::{ + Document, Token, TokenStringExt, + expr::{All, Expr, ExprExt, OwnedExprExt, SequenceExpr}, + linting::{Lint, LintKind, Linter, Suggestion}, + patterns::{NominalPhrase, Pattern, UPOSSet, WordSet}, +}; + +pub struct General { + expr: Box, +} + +impl Default for General { + fn default() -> Self { + let positive = SequenceExpr::default().t_aco("its").then_whitespace().then( + UPOSSet::new(&[UPOS::VERB, UPOS::AUX, UPOS::DET, UPOS::PRON]) + .or(WordSet::new(&["because"])), + ); + + let exceptions = SequenceExpr::default() + .then_anything() + .then_anything() + .then(WordSet::new(&["own", "intended"])); + + let inverted = SequenceExpr::default().then_unless(exceptions); + + let expr = All::new(vec![Box::new(positive), Box::new(inverted)]).or_longest( + SequenceExpr::aco("its") + .t_ws() + .then(UPOSSet::new(&[UPOS::ADJ])) + .t_ws() + .then(UPOSSet::new(&[UPOS::SCONJ, UPOS::PART])), + ); + + Self { + expr: Box::new(expr), + } + } +} + +impl Linter for General { + fn lint(&mut self, document: &Document) -> Vec { + let mut lints = Vec::new(); + let source = document.get_source(); + + for chunk in document.iter_chunks() { + lints.extend( + self.expr + .iter_matches(chunk, source) + .filter_map(|match_span| { + self.match_to_lint(&chunk[match_span.start..], source) + }), + ); + } + + lints + } + + fn description(&self) -> &str { + "Detects the possessive `its` before `had`, `been`, or `got` and offers `it's` or `it has`." + } +} + +impl General { + fn match_to_lint(&self, toks: &[Token], source: &[char]) -> Option { + let offender = toks.first()?; + let offender_chars = offender.span.get_content(source); + + if toks.get(2)?.kind.is_upos(UPOS::VERB) + && NominalPhrase.matches(&toks[2..], source).is_some() + { + return None; + } + + Some(Lint { + span: offender.span, + lint_kind: LintKind::Punctuation, + suggestions: vec![ + Suggestion::replace_with_match_case_str("it's", offender_chars), + Suggestion::replace_with_match_case_str("it has", offender_chars), + ], + message: "Use `it's` (short for `it has` or `it is`) here, not the possessive `its`." + .to_owned(), + priority: 54, + }) + } +} diff --git a/harper-core/src/linting/its_contraction.rs b/harper-core/src/linting/its_contraction/mod.rs similarity index 61% rename from harper-core/src/linting/its_contraction.rs rename to harper-core/src/linting/its_contraction/mod.rs index 8ffb1a65..50b94616 100644 --- a/harper-core/src/linting/its_contraction.rs +++ b/harper-core/src/linting/its_contraction/mod.rs @@ -1,102 +1,15 @@ -use harper_brill::UPOS; +use super::merge_linters::merge_linters; -use crate::Document; -use crate::TokenStringExt; -use crate::expr::All; -use crate::expr::Expr; -use crate::expr::ExprExt; -use crate::expr::OwnedExprExt; -use crate::expr::SequenceExpr; -use crate::patterns::NominalPhrase; -use crate::patterns::Pattern; -use crate::patterns::UPOSSet; -use crate::patterns::WordSet; -use crate::{ - Token, - linting::{Lint, LintKind, Suggestion}, -}; +mod general; +mod proper_noun; -use super::Linter; +use general::General; +use proper_noun::ProperNoun; -pub struct ItsContraction { - expr: Box, -} - -impl Default for ItsContraction { - fn default() -> Self { - let positive = SequenceExpr::default().t_aco("its").then_whitespace().then( - UPOSSet::new(&[UPOS::VERB, UPOS::AUX, UPOS::DET, UPOS::PRON]) - .or(WordSet::new(&["because"])), - ); - - let exceptions = SequenceExpr::default() - .then_anything() - .then_anything() - .then(WordSet::new(&["own", "intended"])); - - let inverted = SequenceExpr::default().then_unless(exceptions); - - let expr = All::new(vec![Box::new(positive), Box::new(inverted)]).or_longest( - SequenceExpr::aco("its") - .t_ws() - .then(UPOSSet::new(&[UPOS::ADJ])) - .t_ws() - .then(UPOSSet::new(&[UPOS::SCONJ, UPOS::PART])), - ); - - Self { - expr: Box::new(expr), - } - } -} - -impl Linter for ItsContraction { - fn lint(&mut self, document: &Document) -> Vec { - let mut lints = Vec::new(); - let source = document.get_source(); - - for chunk in document.iter_chunks() { - lints.extend( - self.expr - .iter_matches(chunk, source) - .filter_map(|match_span| { - self.match_to_lint(&chunk[match_span.start..], source) - }), - ); - } - - lints - } - - fn description(&self) -> &str { - "Detects the possessive `its` before `had`, `been`, or `got` and offers `it's` or `it has`." - } -} - -impl ItsContraction { - fn match_to_lint(&self, toks: &[Token], source: &[char]) -> Option { - let offender = toks.first()?; - let offender_chars = offender.span.get_content(source); - - if toks.get(2)?.kind.is_upos(UPOS::VERB) - && NominalPhrase.matches(&toks[2..], source).is_some() - { - return None; - } - - Some(Lint { - span: offender.span, - lint_kind: LintKind::WordChoice, - suggestions: vec![ - Suggestion::replace_with_match_case_str("it's", offender_chars), - Suggestion::replace_with_match_case_str("it has", offender_chars), - ], - message: "Use `it's` (short for `it has` or `it is`) here, not the possessive `its`." - .to_owned(), - priority: 54, - }) - } -} +merge_linters!( + ItsContraction => General, ProperNoun => + "Detects places where the possessive `its` should be the contraction `it's`, including before verbs/clauses and before proper nouns after opinion verbs." +); #[cfg(test)] mod tests { @@ -284,4 +197,90 @@ mod tests { ItsContraction::default(), ); } + + #[test] + fn corrects_think_google() { + assert_suggestion_result( + "I think its Google, not Microsoft.", + ItsContraction::default(), + "I think it's Google, not Microsoft.", + ); + } + + #[test] + fn corrects_hope_katie() { + assert_suggestion_result( + "I hope its Katie.", + ItsContraction::default(), + "I hope it's Katie.", + ); + } + + #[test] + fn corrects_guess_date() { + assert_suggestion_result( + "I guess its March 6.", + ItsContraction::default(), + "I guess it's March 6.", + ); + } + + #[test] + fn corrects_assume_john() { + assert_suggestion_result( + "We assume its John.", + ItsContraction::default(), + "We assume it's John.", + ); + } + + #[test] + fn corrects_doubt_tesla() { + assert_suggestion_result( + "They doubt its Tesla this year.", + ItsContraction::default(), + "They doubt it's Tesla this year.", + ); + } + + #[test] + fn handles_two_word_name() { + assert_suggestion_result( + "She thinks its New York.", + ItsContraction::default(), + "She thinks it's New York.", + ); + } + + #[test] + fn ignores_existing_contraction() { + assert_lint_count("I think it's Google.", ItsContraction::default(), 0); + } + + #[test] + fn ignores_possessive_noun_after_name() { + assert_lint_count( + "I think its Google product launch.", + ItsContraction::default(), + 0, + ); + } + + #[test] + fn ignores_without_opinion_verb() { + assert_lint_count( + "Its Google Pixel lineup is impressive.", + ItsContraction::default(), + 0, + ); + } + + #[test] + fn ignores_common_noun_target() { + assert_lint_count( + "We hope its accuracy improves.", + ItsContraction::default(), + 0, + ); + } } diff --git a/harper-core/src/linting/its_contraction/proper_noun.rs b/harper-core/src/linting/its_contraction/proper_noun.rs new file mode 100644 index 00000000..2e5ffded --- /dev/null +++ b/harper-core/src/linting/its_contraction/proper_noun.rs @@ -0,0 +1,121 @@ +use std::ops::Range; +use std::sync::Arc; + +use harper_brill::UPOS; + +use crate::{ + Document, Token, TokenStringExt, + expr::{Expr, ExprExt, ExprMap, OwnedExprExt, SequenceExpr}, + linting::{Lint, LintKind, Linter, Suggestion}, + patterns::{DerivedFrom, UPOSSet}, +}; + +pub struct ProperNoun { + expr: Box, + map: Arc>>, +} + +impl Default for ProperNoun { + fn default() -> Self { + let mut map = ExprMap::default(); + + let opinion_verbs = DerivedFrom::new_from_str("think") + .or(DerivedFrom::new_from_str("hope")) + .or(DerivedFrom::new_from_str("assume")) + .or(DerivedFrom::new_from_str("doubt")) + .or(DerivedFrom::new_from_str("guess")); + + let capitalized_word = |tok: &Token, src: &[char]| { + tok.kind.is_word() + && tok + .span + .get_content(src) + .first() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + }; + + let name_head = UPOSSet::new(&[UPOS::PROPN]).or(capitalized_word); + + let lookahead_word = SequenceExpr::default().t_ws().then_any_word(); + + map.insert( + SequenceExpr::default() + .then(opinion_verbs) + .t_ws() + .t_aco("its") + .t_ws() + .then(name_head) + .then_optional(lookahead_word), + 2..3, + ); + + let map = Arc::new(map); + + Self { + expr: Box::new(map.clone()), + map, + } + } +} + +impl Linter for ProperNoun { + fn lint(&mut self, document: &Document) -> Vec { + let mut lints = Vec::new(); + let source = document.get_source(); + + for chunk in document.iter_chunks() { + lints.extend( + self.expr + .iter_matches(chunk, source) + .filter_map(|match_span| { + let matched = &chunk[match_span.start..match_span.end]; + self.match_to_lint(matched, source) + }), + ); + } + + lints + } + + fn description(&self) -> &str { + "Suggests the contraction `it's` after opinion verbs when it introduces a proper noun." + } +} + +impl ProperNoun { + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + if matched_tokens.len() >= 7 + && let Some(next_word) = matched_tokens.get(6) + { + let is_lowercase = next_word + .span + .get_content(source) + .first() + .map(|c| c.is_lowercase()) + .unwrap_or(false); + + if is_lowercase + && (next_word.kind.is_upos(UPOS::NOUN) || next_word.kind.is_upos(UPOS::ADJ)) + { + return None; + } + } + + let range = self.map.lookup(0, matched_tokens, source)?.clone(); + let offending = matched_tokens.get(range.start)?; + let offender_text = offending.span.get_content(source); + + Some(Lint { + span: offending.span, + lint_kind: LintKind::Punctuation, + suggestions: vec![Suggestion::replace_with_match_case_str( + "it's", + offender_text, + )], + message: "Use `it's` (short for \"it is\") before a proper noun in this construction." + .to_owned(), + priority: 31, + }) + } +} diff --git a/harper-core/src/linting/jealous_of.rs b/harper-core/src/linting/jealous_of.rs new file mode 100644 index 00000000..897eb755 --- /dev/null +++ b/harper-core/src/linting/jealous_of.rs @@ -0,0 +1,164 @@ +use crate::{ + Token, + expr::{Expr, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, +}; + +pub struct JealousOf { + expr: Box, +} + +impl Default for JealousOf { + fn default() -> Self { + let valid_object = |tok: &Token, _source: &[char]| { + (tok.kind.is_nominal() || !tok.kind.is_verb()) + && (tok.kind.is_oov() || tok.kind.is_nominal()) + && !tok.kind.is_preposition() + }; + + let pattern = SequenceExpr::default() + .t_aco("jealous") + .t_ws() + .t_aco("from") + .t_ws() + .then_optional(SequenceExpr::default().then_determiner().t_ws()) + .then(valid_object); + + Self { + expr: Box::new(pattern), + } + } +} + +impl ExprLinter for JealousOf { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, tokens: &[Token], source: &[char]) -> Option { + let from_token = &tokens[2]; + + Some(Lint { + span: from_token.span, + lint_kind: LintKind::WordChoice, + suggestions: vec![Suggestion::replace_with_match_case_str( + "of", + from_token.span.get_content(source), + )], + message: "Use `of` after `jealous`.".to_owned(), + ..Default::default() + }) + } + + fn description(&self) -> &str { + "Encourages the standard preposition after `jealous`." + } +} + +#[cfg(test)] +mod tests { + use super::JealousOf; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn replaces_basic_from() { + assert_suggestion_result( + "She was jealous from her sister's success.", + JealousOf::default(), + "She was jealous of her sister's success.", + ); + } + + #[test] + fn handles_optional_determiner() { + assert_suggestion_result( + "He grew jealous from the attention.", + JealousOf::default(), + "He grew jealous of the attention.", + ); + } + + #[test] + fn fixes_pronoun_object() { + assert_suggestion_result( + "They became jealous from him.", + JealousOf::default(), + "They became jealous of him.", + ); + } + + #[test] + fn allows_oov_target() { + assert_suggestion_result( + "I'm jealous from Zybrix.", + JealousOf::default(), + "I'm jealous of Zybrix.", + ); + } + + #[test] + fn corrects_uppercase_preposition() { + assert_suggestion_result( + "Jealous FROM his fame.", + JealousOf::default(), + "Jealous OF his fame.", + ); + } + + #[test] + fn fixes_longer_phrase() { + assert_suggestion_result( + "They felt jealous from the sudden praise she received.", + JealousOf::default(), + "They felt jealous of the sudden praise she received.", + ); + } + + #[test] + fn fixes_minimal_phrase() { + assert_suggestion_result( + "jealous from success", + JealousOf::default(), + "jealous of success", + ); + } + + #[test] + fn does_not_flag_correct_usage() { + assert_lint_count( + "She was jealous of her sister's success.", + JealousOf::default(), + 0, + ); + } + + #[test] + fn does_not_flag_other_preposition_sequence() { + assert_lint_count( + "They stayed jealous from within the fortress.", + JealousOf::default(), + 0, + ); + } + + #[test] + fn fixes_following_gerund() { + assert_suggestion_result( + "He was jealous from being ignored.", + JealousOf::default(), + "He was jealous of being ignored.", + ); + } + + #[test] + fn ignores_numbers_after_from() { + assert_lint_count( + "She remained jealous from 2010 through 2015.", + JealousOf::default(), + 0, + ); + } +} diff --git a/harper-core/src/linting/johns_hopkins.rs b/harper-core/src/linting/johns_hopkins.rs new file mode 100644 index 00000000..0de2cabd --- /dev/null +++ b/harper-core/src/linting/johns_hopkins.rs @@ -0,0 +1,149 @@ +use crate::{ + CharStringExt, Token, + expr::{Expr, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, +}; + +pub struct JohnsHopkins { + expr: Box, +} + +impl Default for JohnsHopkins { + fn default() -> Self { + let expr = SequenceExpr::default() + .then(|tok: &Token, src: &[char]| { + tok.kind.is_proper_noun() + && tok.span.get_content(src).eq_ignore_ascii_case_str("john") + }) + .t_ws() + .then(|tok: &Token, src: &[char]| { + tok.kind.is_proper_noun() + && tok + .span + .get_content(src) + .eq_ignore_ascii_case_str("hopkins") + }); + + Self { + expr: Box::new(expr), + } + } +} + +impl ExprLinter for JohnsHopkins { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let span = matched_tokens.first()?.span; + let template = span.get_content(source); + + Some(Lint { + span, + lint_kind: LintKind::WordChoice, + suggestions: vec![Suggestion::replace_with_match_case_str("Johns", template)], + message: "Use `Johns Hopkins` for this name.".to_owned(), + priority: 31, + }) + } + + fn description(&self) -> &'static str { + "Recommends the proper spelling `Johns Hopkins`." + } +} + +#[cfg(test)] +mod tests { + use super::JohnsHopkins; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn corrects_university_reference() { + assert_suggestion_result( + "I applied to John Hopkins University last fall.", + JohnsHopkins::default(), + "I applied to Johns Hopkins University last fall.", + ); + } + + #[test] + fn corrects_hospital_reference() { + assert_suggestion_result( + "She works at the John Hopkins hospital.", + JohnsHopkins::default(), + "She works at the Johns Hopkins hospital.", + ); + } + + #[test] + fn corrects_standalone_name() { + assert_suggestion_result( + "We toured John Hopkins yesterday.", + JohnsHopkins::default(), + "We toured Johns Hopkins yesterday.", + ); + } + + #[test] + fn corrects_lowercase_usage() { + assert_suggestion_result( + "I studied at john hopkins online.", + JohnsHopkins::default(), + "I studied at johns hopkins online.", + ); + } + + #[test] + fn corrects_across_newline_whitespace() { + assert_suggestion_result( + "We met at John\nHopkins for lunch.", + JohnsHopkins::default(), + "We met at Johns\nHopkins for lunch.", + ); + } + + #[test] + fn corrects_with_trailing_punctuation() { + assert_suggestion_result( + "I toured John Hopkins, and it was great.", + JohnsHopkins::default(), + "I toured Johns Hopkins, and it was great.", + ); + } + + #[test] + fn corrects_before_hyphenated_unit() { + assert_suggestion_result( + "She joined the John Hopkins-affiliated lab.", + JohnsHopkins::default(), + "She joined the Johns Hopkins-affiliated lab.", + ); + } + + #[test] + fn allows_correct_spelling() { + assert_lint_count( + "Johns Hopkins University has a great program.", + JohnsHopkins::default(), + 0, + ); + } + + #[test] + fn allows_apostrophized_form() { + assert_lint_count( + "John Hopkins's novel won awards.", + JohnsHopkins::default(), + 0, + ); + } + + #[test] + fn allows_reversed_name_order() { + assert_lint_count("Hopkins, John is a contact.", JohnsHopkins::default(), 0); + } +} diff --git a/harper-core/src/linting/lint_group.rs b/harper-core/src/linting/lint_group.rs index d1ee5698..eb52cc4e 100644 --- a/harper-core/src/linting/lint_group.rs +++ b/harper-core/src/linting/lint_group.rs @@ -26,6 +26,7 @@ use super::and_in::AndIn; use super::and_the_like::AndTheLike; use super::another_thing_coming::AnotherThingComing; use super::another_think_coming::AnotherThinkComing; +use super::apart_from::ApartFrom; use super::ask_no_preposition::AskNoPreposition; use super::avoid_curses::AvoidCurses; use super::back_in_the_day::BackInTheDay; @@ -44,6 +45,7 @@ use super::compound_subject_i::CompoundSubjectI; use super::confident::Confident; use super::correct_number_suffix::CorrectNumberSuffix; use super::criteria_phenomena::CriteriaPhenomena; +use super::cure_for::CureFor; use super::currency_placement::CurrencyPlacement; use super::despite_of::DespiteOf; use super::didnt::Didnt; @@ -83,6 +85,8 @@ use super::interested_in::InterestedIn; use super::it_looks_like_that::ItLooksLikeThat; use super::its_contraction::ItsContraction; use super::its_possessive::ItsPossessive; +use super::jealous_of::JealousOf; +use super::johns_hopkins::JohnsHopkins; use super::left_right_hand::LeftRightHand; use super::less_worse::LessWorse; use super::let_to_do::LetToDo; @@ -140,6 +144,8 @@ use super::quote_spacing::QuoteSpacing; use super::redundant_additive_adverbs::RedundantAdditiveAdverbs; use super::regionalisms::Regionalisms; use super::repeated_words::RepeatedWords; +use super::respond::Respond; +use super::right_click::RightClick; use super::roller_skated::RollerSkated; use super::safe_to_save::SafeToSave; use super::save_to_safe::SaveToSafe; @@ -152,12 +158,14 @@ use super::single_be::SingleBe; use super::some_without_article::SomeWithoutArticle; use super::something_is::SomethingIs; use super::somewhat_something::SomewhatSomething; +use super::soon_to_be::SoonToBe; use super::sought_after::SoughtAfter; use super::spaces::Spaces; use super::spell_check::SpellCheck; use super::spelled_numbers::SpelledNumbers; use super::split_words::SplitWords; use super::subject_pronoun::SubjectPronoun; +use super::take_medicine::TakeMedicine; use super::that_than::ThatThan; use super::that_which::ThatWhich; use super::the_how_why::TheHowWhy; @@ -474,6 +482,7 @@ impl LintGroup { // Please maintain alphabetical order. // On *nix you can maintain sort order with `sort -t'(' -k2` insert_expr_rule!(APart, true); + insert_expr_rule!(ApartFrom, true); insert_expr_rule!(AWhile, true); insert_expr_rule!(Addicting, true); insert_expr_rule!(AdjectiveDoubleDegree, true); @@ -506,6 +515,7 @@ impl LintGroup { insert_expr_rule!(Confident, true); insert_struct_rule!(CorrectNumberSuffix, true); insert_expr_rule!(CriteriaPhenomena, true); + insert_expr_rule!(CureFor, true); insert_struct_rule!(CurrencyPlacement, true); insert_expr_rule!(Dashes, true); insert_expr_rule!(DespiteOf, true); @@ -539,6 +549,8 @@ impl LintGroup { insert_expr_rule!(IAmAgreement, true); insert_expr_rule!(IfWouldve, true); insert_expr_rule!(InterestedIn, true); + insert_expr_rule!(JealousOf, true); + insert_expr_rule!(JohnsHopkins, true); insert_expr_rule!(ItLooksLikeThat, true); insert_struct_rule!(ItsContraction, true); insert_expr_rule!(ItsPossessive, true); @@ -596,6 +608,8 @@ impl LintGroup { insert_struct_rule!(QuoteSpacing, true); insert_expr_rule!(RedundantAdditiveAdverbs, true); insert_struct_rule!(RepeatedWords, true); + insert_expr_rule!(Respond, true); + insert_expr_rule!(RightClick, true); insert_expr_rule!(RollerSkated, true); insert_expr_rule!(SafeToSave, true); insert_expr_rule!(SaveToSafe, true); @@ -603,6 +617,7 @@ impl LintGroup { insert_expr_rule!(ShootOneselfInTheFoot, true); insert_expr_rule!(SimplePastToPastParticiple, true); insert_expr_rule!(SinceDuration, true); + insert_expr_rule!(SoonToBe, true); insert_expr_rule!(SingleBe, true); insert_expr_rule!(SomeWithoutArticle, true); insert_expr_rule!(SomethingIs, true); @@ -611,6 +626,7 @@ impl LintGroup { insert_struct_rule!(Spaces, true); insert_struct_rule!(SpelledNumbers, false); insert_expr_rule!(SplitWords, true); + insert_expr_rule!(TakeMedicine, true); insert_struct_rule!(SubjectPronoun, true); insert_expr_rule!(ThatThan, true); insert_expr_rule!(ThatWhich, true); diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index 47e6fed3..8cdb5438 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -17,6 +17,7 @@ mod and_in; mod and_the_like; mod another_thing_coming; mod another_think_coming; +mod apart_from; mod ask_no_preposition; mod avoid_curses; mod back_in_the_day; @@ -37,6 +38,7 @@ mod compound_subject_i; mod confident; mod correct_number_suffix; mod criteria_phenomena; +mod cure_for; mod currency_placement; mod dashes; mod despite_of; @@ -62,6 +64,7 @@ mod for_noun; mod free_predicate; mod friend_of_me; mod go_so_far_as_to; +mod handful; mod have_pronoun; mod have_take_a_look; mod hedging; @@ -83,6 +86,8 @@ mod it_looks_like_that; mod it_would_be; mod its_contraction; mod its_possessive; +mod jealous_of; +mod johns_hopkins; mod left_right_hand; mod less_worse; mod let_to_do; @@ -150,6 +155,8 @@ mod quote_spacing; mod redundant_additive_adverbs; mod regionalisms; mod repeated_words; +mod respond; +mod right_click; mod roller_skated; mod safe_to_save; mod save_to_safe; @@ -162,6 +169,7 @@ mod single_be; mod some_without_article; mod something_is; mod somewhat_something; +mod soon_to_be; mod sought_after; mod spaces; mod spell_check; @@ -169,6 +177,7 @@ mod spelled_numbers; mod split_words; mod subject_pronoun; mod suggestion; +mod take_medicine; mod take_serious; mod that_than; mod that_which; diff --git a/harper-core/src/linting/phrase_corrections/mod.rs b/harper-core/src/linting/phrase_corrections/mod.rs index 6469f698..1cc14031 100644 --- a/harper-core/src/linting/phrase_corrections/mod.rs +++ b/harper-core/src/linting/phrase_corrections/mod.rs @@ -725,6 +725,13 @@ pub fn lint_group() -> LintGroup { "Corrects wrong variations of the idiomatic adjective `last-ditch`.", LintKind::Usage ), + "LastNight" => ( + ["yesterday night"], + ["last night"], + "The idiomatic phrase is `last night`.", + "Flags `yesterday night` and suggests the standard phrasing `last night`.", + LintKind::WordChoice + ), "LetAlone" => ( ["let along"], ["let alone"], diff --git a/harper-core/src/linting/phrase_corrections/tests.rs b/harper-core/src/linting/phrase_corrections/tests.rs index 00901ac7..ce715d8e 100644 --- a/harper-core/src/linting/phrase_corrections/tests.rs +++ b/harper-core/src/linting/phrase_corrections/tests.rs @@ -1102,6 +1102,48 @@ fn correct_last_ditch_space() { ); } +// LastNight +#[test] +fn corrects_yesterday_night_basic() { + assert_suggestion_result( + "I was there yesterday night.", + lint_group(), + "I was there last night.", + ); +} + +#[test] +fn corrects_yesterday_night_capitalized() { + assert_suggestion_result( + "Yesterday night was fun.", + lint_group(), + "Last night was fun.", + ); +} + +#[test] +fn corrects_yesterday_night_with_comma() { + assert_suggestion_result( + "Yesterday night, we watched a movie.", + lint_group(), + "Last night, we watched a movie.", + ); +} + +#[test] +fn corrects_yesterday_night_across_newline() { + assert_suggestion_result( + "They left yesterday\nnight after the show.", + lint_group(), + "They left last night after the show.", + ); +} + +#[test] +fn no_lint_for_last_night_phrase() { + assert_lint_count("I remember last night clearly.", lint_group(), 0); +} + // LetAlone #[test] fn let_along() { diff --git a/harper-core/src/linting/respond.rs b/harper-core/src/linting/respond.rs new file mode 100644 index 00000000..75354342 --- /dev/null +++ b/harper-core/src/linting/respond.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use crate::Token; +use crate::expr::{Expr, ExprMap, SequenceExpr}; +use crate::linting::expr_linter::Chunk; +use crate::linting::{ExprLinter, Lint, LintKind, Suggestion}; +use crate::patterns::Word; + +pub struct Respond { + expr: Box, + map: Arc>, +} + +impl Default for Respond { + fn default() -> Self { + let mut map = ExprMap::default(); + + let helper_verb = |tok: &Token, src: &[char]| { + if tok.kind.is_auxiliary_verb() { + return true; + } + + if !tok.kind.is_verb() { + return false; + } + + let lower = tok.span.get_content_string(src).to_lowercase(); + matches!( + lower.as_str(), + "do" | "did" | "does" | "won't" | "don't" | "didn't" | "doesn't" + ) + }; + + map.insert( + SequenceExpr::default() + .then_nominal() + .t_ws() + .then(helper_verb) + .t_ws() + .then(Word::new("response")), + 4, + ); + + map.insert( + SequenceExpr::default() + .then_nominal() + .t_ws() + .then(helper_verb) + .t_ws() + .then_adverb() + .t_ws() + .then(Word::new("response")), + 6, + ); + + let map = Arc::new(map); + + Self { + expr: Box::new(map.clone()), + map, + } + } +} + +impl ExprLinter for Respond { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let response_index = *self.map.lookup(0, matched_tokens, source)?; + let response_token = matched_tokens.get(response_index)?; + + Some(Lint { + span: response_token.span, + lint_kind: LintKind::WordChoice, + suggestions: vec![Suggestion::replace_with_match_case_str( + "respond", + response_token.span.get_content(source), + )], + message: "Use the verb `respond` here.".to_owned(), + priority: 40, + }) + } + + fn description(&self) -> &'static str { + "Flags uses of the noun `response` where the verb `respond` is needed after an auxiliary." + } +} + +#[cfg(test)] +mod tests { + use super::Respond; + use crate::linting::tests::{assert_lint_count, assert_no_lints, assert_suggestion_result}; + + #[test] + fn fixes_will_response() { + assert_suggestion_result( + "He will response soon.", + Respond::default(), + "He will respond soon.", + ); + } + + #[test] + fn fixes_can_response() { + assert_suggestion_result( + "They can response to the survey.", + Respond::default(), + "They can respond to the survey.", + ); + } + + #[test] + fn fixes_did_not_response() { + assert_suggestion_result( + "I did not response yesterday.", + Respond::default(), + "I did not respond yesterday.", + ); + } + + #[test] + fn fixes_might_quickly_response() { + assert_suggestion_result( + "She might quickly response to feedback.", + Respond::default(), + "She might quickly respond to feedback.", + ); + } + + #[test] + fn fixes_wont_response() { + assert_suggestion_result( + "They won't response in time.", + Respond::default(), + "They won't respond in time.", + ); + } + + #[test] + fn fixes_would_response() { + assert_suggestion_result( + "We would response if we could.", + Respond::default(), + "We would respond if we could.", + ); + } + + #[test] + fn fixes_should_response() { + assert_suggestion_result( + "You should response politely.", + Respond::default(), + "You should respond politely.", + ); + } + + #[test] + fn does_not_flag_correct_respond() { + assert_no_lints("Please respond when you can.", Respond::default()); + } + + #[test] + fn does_not_flag_noun_use() { + assert_no_lints("The response time was great.", Respond::default()); + } + + #[test] + fn does_not_flag_question_subject() { + assert_lint_count("Should response times be logged?", Respond::default(), 0); + } + + #[test] + fn does_not_flag_response_as_object() { + assert_no_lints("I have no response for that.", Respond::default()); + } +} diff --git a/harper-core/src/linting/right_click.rs b/harper-core/src/linting/right_click.rs new file mode 100644 index 00000000..07c899e8 --- /dev/null +++ b/harper-core/src/linting/right_click.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use crate::{ + Token, TokenStringExt, + expr::{Expr, ExprMap, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, + patterns::DerivedFrom, +}; + +pub struct RightClick { + expr: Box, + map: Arc>, +} + +impl Default for RightClick { + fn default() -> Self { + let mut map = ExprMap::default(); + + map.insert( + SequenceExpr::default() + .then_word_set(&["right", "left", "middle"]) + .t_ws() + .then(DerivedFrom::new_from_str("click")), + 0, + ); + + let map = Arc::new(map); + + Self { + expr: Box::new(map.clone()), + map, + } + } +} + +impl ExprLinter for RightClick { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let start_idx = *self.map.lookup(0, matched_tokens, source)?; + let click_idx = matched_tokens.len().checked_sub(1)?; + let span = matched_tokens.get(start_idx..=click_idx)?.span()?; + let template = span.get_content(source); + + let direction = matched_tokens.get(start_idx)?.span.get_content(source); + let click = matched_tokens.get(click_idx)?.span.get_content(source); + + let replacement: Vec = direction + .iter() + .copied() + .chain(['-']) + .chain(click.iter().copied()) + .collect(); + + Some(Lint { + span, + lint_kind: LintKind::Punctuation, + suggestions: vec![Suggestion::replace_with_match_case(replacement, template)], + message: "Hyphenate this mouse command.".to_owned(), + priority: 40, + }) + } + + fn description(&self) -> &'static str { + "Hyphenates right-click style mouse commands." + } +} + +#[cfg(test)] +mod tests { + use super::RightClick; + use crate::linting::tests::{assert_lint_count, assert_suggestion_result}; + + #[test] + fn hyphenates_basic_command() { + assert_suggestion_result( + "Right click the icon.", + RightClick::default(), + "Right-click the icon.", + ); + } + + #[test] + fn hyphenates_with_preposition() { + assert_suggestion_result( + "Please right click on the link.", + RightClick::default(), + "Please right-click on the link.", + ); + } + + #[test] + fn hyphenates_past_tense() { + assert_suggestion_result( + "They right clicked the submit button.", + RightClick::default(), + "They right-clicked the submit button.", + ); + } + + #[test] + fn hyphenates_gerund() { + assert_suggestion_result( + "Right clicking the item highlights it.", + RightClick::default(), + "Right-clicking the item highlights it.", + ); + } + + #[test] + fn hyphenates_plural_noun() { + assert_suggestion_result( + "Right clicks are tracked in the log.", + RightClick::default(), + "Right-clicks are tracked in the log.", + ); + } + + #[test] + fn hyphenates_all_caps() { + assert_suggestion_result( + "He RIGHT CLICKED the file.", + RightClick::default(), + "He RIGHT-CLICKED the file.", + ); + } + + #[test] + fn hyphenates_left_click() { + assert_suggestion_result( + "Left click the checkbox.", + RightClick::default(), + "Left-click the checkbox.", + ); + } + + #[test] + fn hyphenates_middle_click() { + assert_suggestion_result( + "Middle click to open in a new tab.", + RightClick::default(), + "Middle-click to open in a new tab.", + ); + } + + #[test] + fn allows_hyphenated_form() { + assert_lint_count("Right-click the icon.", RightClick::default(), 0); + } + + #[test] + fn ignores_unrelated_right_and_click() { + assert_lint_count( + "Click the right button to continue.", + RightClick::default(), + 0, + ); + } +} diff --git a/harper-core/src/linting/soon_to_be.rs b/harper-core/src/linting/soon_to_be.rs new file mode 100644 index 00000000..48eb2c1e --- /dev/null +++ b/harper-core/src/linting/soon_to_be.rs @@ -0,0 +1,240 @@ +use std::{ops::Range, sync::Arc}; + +use crate::{ + Token, TokenKind, TokenStringExt, + expr::{Expr, ExprMap, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, + patterns::NominalPhrase, +}; + +pub struct SoonToBe { + expr: Box, + map: Arc>>, +} + +impl Default for SoonToBe { + fn default() -> Self { + let mut map = ExprMap::default(); + + let soon_to_be = || { + SequenceExpr::default() + .t_aco("soon") + .t_ws() + .t_aco("to") + .t_ws() + .t_aco("be") + }; + + let nominal_tail = || { + SequenceExpr::default() + .then_optional(SequenceExpr::default().then_one_or_more_adverbs().t_ws()) + .then(NominalPhrase) + }; + + let hyphenated_number_modifier = || { + SequenceExpr::default() + .then_number() + .then_hyphen() + .then_nominal() + .then_optional(SequenceExpr::default().then_hyphen().then_adjective()) + .t_ws() + .then_nominal() + }; + + let hyphenated_compound = || { + SequenceExpr::default() + .then_kind_any(&[TokenKind::is_word_like as fn(&TokenKind) -> bool]) + .then_hyphen() + .then_nominal() + }; + + let trailing_phrase = || { + SequenceExpr::default().then_any_of(vec![ + Box::new(hyphenated_number_modifier()), + Box::new(hyphenated_compound()), + Box::new(nominal_tail()), + ]) + }; + + map.insert( + SequenceExpr::default() + .then_determiner() + .t_ws() + .then_seq(soon_to_be()) + .t_ws() + .then_seq(trailing_phrase()), + 2..7, + ); + + map.insert( + SequenceExpr::default() + .then_seq(soon_to_be()) + .t_ws() + .then_seq(trailing_phrase()), + 0..5, + ); + + let map = Arc::new(map); + + Self { + expr: Box::new(map.clone()), + map, + } + } +} + +impl ExprLinter for SoonToBe { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let range = self.map.lookup(0, matched_tokens, source)?; + let span = matched_tokens.get(range.start..range.end)?.span()?; + let template = span.get_content(source); + + let mut nominal_found = false; + for tok in matched_tokens.iter().skip(range.end) { + if tok.kind.is_whitespace() || tok.kind.is_hyphen() { + continue; + } + + if tok.kind.is_punctuation() { + break; + } + + if tok.kind.is_nominal() { + if tok.kind.is_preposition() { + continue; + } else { + nominal_found = true; + break; + } + } + } + + if !nominal_found { + return None; + } + + Some(Lint { + span, + lint_kind: LintKind::Miscellaneous, + suggestions: vec![Suggestion::replace_with_match_case_str( + "soon-to-be", + template, + )], + message: "Use hyphens when `soon to be` modifies a noun.".to_owned(), + priority: 31, + }) + } + + fn description(&self) -> &'static str { + "Hyphenates `soon-to-be` when it appears before a noun." + } +} + +#[cfg(test)] +mod tests { + use super::SoonToBe; + use crate::linting::tests::{assert_lint_count, assert_no_lints, assert_suggestion_result}; + + #[test] + fn hyphenates_possessive_phrase() { + assert_suggestion_result( + "We met his soon to be boss at lunch.", + SoonToBe::default(), + "We met his soon-to-be boss at lunch.", + ); + } + + #[test] + fn hyphenates_article_phrase() { + assert_suggestion_result( + "They toasted the soon to be couple.", + SoonToBe::default(), + "They toasted the soon-to-be couple.", + ); + } + + #[test] + fn hyphenates_sentence_start() { + assert_suggestion_result( + "Soon to be parents filled the classroom.", + SoonToBe::default(), + "Soon-to-be parents filled the classroom.", + ); + } + + #[test] + fn allows_existing_hyphens() { + assert_no_lints("We met his soon-to-be boss yesterday.", SoonToBe::default()); + } + + #[test] + fn keeps_non_adjectival_use() { + assert_no_lints("The concert is soon to be over.", SoonToBe::default()); + } + + #[test] + fn hyphenates_with_adverb() { + assert_suggestion_result( + "Our soon to be newly married friends visited.", + SoonToBe::default(), + "Our soon-to-be newly married friends visited.", + ); + } + + #[test] + fn hyphenates_hyphenated_number_phrase() { + assert_suggestion_result( + "Our soon to be 5-year-old son starts school.", + SoonToBe::default(), + "Our soon-to-be 5-year-old son starts school.", + ); + } + + #[test] + fn hyphenates_in_law_phrase() { + assert_suggestion_result( + "She thanked her soon to be in-laws for hosting.", + SoonToBe::default(), + "She thanked her soon-to-be in-laws for hosting.", + ); + } + + #[test] + fn hyphenates_future_event() { + assert_suggestion_result( + "We reserved space for our soon to be celebration.", + SoonToBe::default(), + "We reserved space for our soon-to-be celebration.", + ); + } + + #[test] + fn ignores_misaligned_verb_chain() { + assert_lint_count( + "They will soon to be moving overseas.", + SoonToBe::default(), + 0, + ); + } + + #[test] + fn hyphenates_guest_example() { + assert_suggestion_result( + "I cooked for my soon to be guests.", + SoonToBe::default(), + "I cooked for my soon-to-be guests.", + ); + } + + #[test] + fn ignores_rearranged_phrase() { + assert_no_lints("We hope to soon be home.", SoonToBe::default()); + } +} diff --git a/harper-core/src/linting/take_medicine.rs b/harper-core/src/linting/take_medicine.rs new file mode 100644 index 00000000..aef01c99 --- /dev/null +++ b/harper-core/src/linting/take_medicine.rs @@ -0,0 +1,242 @@ +use crate::{ + Token, + expr::{Expr, OwnedExprExt, SequenceExpr}, + linting::expr_linter::Chunk, + linting::{ExprLinter, Lint, LintKind, Suggestion}, + patterns::DerivedFrom, +}; + +pub struct TakeMedicine { + expr: Box, +} + +impl Default for TakeMedicine { + fn default() -> Self { + let eat_verb = DerivedFrom::new_from_str("eat") + .or(DerivedFrom::new_from_str("eats")) + .or(DerivedFrom::new_from_str("ate")) + .or(DerivedFrom::new_from_str("eating")) + .or(DerivedFrom::new_from_str("eaten")); + + let medication = DerivedFrom::new_from_str("antibiotic") + .or(DerivedFrom::new_from_str("medicine")) + .or(DerivedFrom::new_from_str("medication")) + .or(DerivedFrom::new_from_str("pill")) + .or(DerivedFrom::new_from_str("tablet")) + .or(DerivedFrom::new_from_str("aspirin")) + .or(DerivedFrom::new_from_str("paracetamol")); + + let modifiers = SequenceExpr::default() + .then_any_of(vec![ + Box::new(SequenceExpr::default().then_determiner()), + Box::new(SequenceExpr::default().then_possessive_determiner()), + Box::new(SequenceExpr::default().then_quantifier()), + ]) + .t_ws(); + + let adjectives = SequenceExpr::default().then_one_or_more_adjectives().t_ws(); + + let pattern = SequenceExpr::default() + .then(eat_verb) + .t_ws() + .then_optional(modifiers) + .then_optional(adjectives) + .then(medication); + + Self { + expr: Box::new(pattern), + } + } +} + +fn replacement_for( + verb: &Token, + source: &[char], + base: &str, + third_person: &str, + past: &str, + past_participle: &str, + progressive: &str, +) -> Suggestion { + let replacement = if verb.kind.is_verb_progressive_form() { + progressive + } else if verb.kind.is_verb_third_person_singular_present_form() { + third_person + } else if verb.kind.is_verb_past_participle_form() && !verb.kind.is_verb_simple_past_form() { + past_participle + } else if verb.kind.is_verb_simple_past_form() { + past + } else { + base + }; + + Suggestion::replace_with_match_case( + replacement.chars().collect(), + verb.span.get_content(source), + ) +} + +impl ExprLinter for TakeMedicine { + type Unit = Chunk; + + fn expr(&self) -> &dyn Expr { + self.expr.as_ref() + } + + fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option { + let verb = matched_tokens.first()?; + let span = verb.span; + + let suggestions = vec![ + replacement_for(verb, source, "take", "takes", "took", "taken", "taking"), + replacement_for( + verb, + source, + "swallow", + "swallows", + "swallowed", + "swallowed", + "swallowing", + ), + ]; + + Some(Lint { + span, + lint_kind: LintKind::Usage, + suggestions, + message: "Use a verb like `take` or `swallow` with medicine instead of `eat`." + .to_string(), + priority: 63, + }) + } + + fn description(&self) -> &'static str { + "Encourages pairing medicine-related nouns with verbs like `take` or `swallow` instead of `eat`." + } +} + +#[cfg(test)] +mod tests { + use super::TakeMedicine; + use crate::linting::tests::{ + assert_lint_count, assert_nth_suggestion_result, assert_suggestion_result, + }; + + #[test] + fn swaps_ate_antibiotics() { + assert_suggestion_result( + "I ate antibiotics for a week.", + TakeMedicine::default(), + "I took antibiotics for a week.", + ); + } + + #[test] + fn swaps_eat_medicine() { + assert_suggestion_result( + "You should eat the medicine now.", + TakeMedicine::default(), + "You should take the medicine now.", + ); + } + + #[test] + fn swaps_eats_medication() { + assert_suggestion_result( + "She eats medication daily.", + TakeMedicine::default(), + "She takes medication daily.", + ); + } + + #[test] + fn swaps_eating_medicines() { + assert_suggestion_result( + "Are you eating medicines for that illness?", + TakeMedicine::default(), + "Are you taking medicines for that illness?", + ); + } + + #[test] + fn swaps_eaten_medication() { + assert_suggestion_result( + "He has eaten medication already.", + TakeMedicine::default(), + "He has taken medication already.", + ); + } + + #[test] + fn swaps_eat_pills() { + assert_suggestion_result( + "He ate the pills without water.", + TakeMedicine::default(), + "He took the pills without water.", + ); + } + + #[test] + fn swaps_eating_paracetamol() { + assert_suggestion_result( + "She is eating paracetamol for her headache.", + TakeMedicine::default(), + "She is taking paracetamol for her headache.", + ); + } + + #[test] + fn handles_possessive_modifier() { + assert_suggestion_result( + "Please eat my antibiotics.", + TakeMedicine::default(), + "Please take my antibiotics.", + ); + } + + #[test] + fn handles_adjectives() { + assert_suggestion_result( + "They ate the prescribed antibiotics.", + TakeMedicine::default(), + "They took the prescribed antibiotics.", + ); + } + + #[test] + fn supports_uppercase() { + assert_suggestion_result( + "Eat antibiotics with water.", + TakeMedicine::default(), + "Take antibiotics with water.", + ); + } + + #[test] + fn offers_swallow_alternative() { + assert_nth_suggestion_result( + "He ate the medication without water.", + TakeMedicine::default(), + "He swallowed the medication without water.", + 1, + ); + } + + #[test] + fn ignores_correct_usage() { + assert_lint_count( + "She took antibiotics last winter.", + TakeMedicine::default(), + 0, + ); + } + + #[test] + fn ignores_unrelated_eat() { + assert_lint_count( + "They ate dinner after taking medicine.", + TakeMedicine::default(), + 0, + ); + } +} 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 2c3ca071..5b45782a 100644 --- a/harper-core/tests/text/linters/The Great Gatsby.snap.yml +++ b/harper-core/tests/text/linters/The Great Gatsby.snap.yml @@ -4910,7 +4910,7 @@ Suggest: -Lint: WordChoice (54 priority) +Lint: Punctuation (54 priority) Message: | 3495 | It was when curiosity about Gatsby was at its highest that the lights in his | ^~~ Use `it's` (short for `it has` or `it is`) here, not the possessive `its`.