feat(core): many new rules (#2292)

* feat(core): `Handful`

* feat(core): add several additional rules

* refactor(core): combine `ItsContraction` duplicates

* fix(core): `ItsContraction` should emit `LintKind::Punctuation`

In accordance to part of #2220

* chore(core): update snapshots

* feat(core): many more rules

* feat(core): many more

* feat(core): `CureFor`

* fix(core): merge fallout

* feat(core): expand according to feedback

* fix(core): appease clippy
This commit is contained in:
Elijah Potter
2025-12-03 15:14:49 -07:00
committed by GitHub
parent 94a83d7e78
commit c246654906
17 changed files with 1969 additions and 97 deletions

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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 wasnt 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,
);
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
let against = matched_tokens.last()?;
let template: Vec<char> = 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,
);
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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(),
);
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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<Lint> {
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,
})
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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<Lint> {
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,
);
}
}

View File

@@ -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<dyn Expr>,
map: Arc<ExprMap<Range<usize>>>,
}
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<Lint> {
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<Lint> {
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,
})
}
}

View File

@@ -0,0 +1,164 @@
use crate::{
Token,
expr::{Expr, SequenceExpr},
linting::expr_linter::Chunk,
linting::{ExprLinter, Lint, LintKind, Suggestion},
};
pub struct JealousOf {
expr: Box<dyn Expr>,
}
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<Lint> {
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,
);
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"],

View File

@@ -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() {

View File

@@ -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<dyn Expr>,
map: Arc<ExprMap<usize>>,
}
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<Lint> {
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());
}
}

View File

@@ -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<dyn Expr>,
map: Arc<ExprMap<usize>>,
}
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<Lint> {
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<char> = 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,
);
}
}

View File

@@ -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<dyn Expr>,
map: Arc<ExprMap<Range<usize>>>,
}
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<Lint> {
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());
}
}

View File

@@ -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<dyn Expr>,
}
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<Lint> {
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,
);
}
}

View File

@@ -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`.