Skip to content

Commit 6ef5e59

Browse files
Merge pull request #1085 from hippietrail/let_lets_let_us_improvements
feat: handle some false positives in let/lets/let's/let us
2 parents 84fc9d8 + 0097bb0 commit 6ef5e59

File tree

7 files changed

+210
-21
lines changed

7 files changed

+210
-21
lines changed

harper-core/dictionary.dict

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30577,7 +30577,7 @@ juryman/1M
3057730577
jurymen/9
3057830578
jurywoman/1M
3057930579
jurywomen/9
30580-
just/~514RYPT
30580+
just/~54RYPT # removed `1` noun. archaic and interferes with linter heuristics
3058130581
justice/~1IMS
3058230582
justifiable/~5U
3058330583
justifiably/Uj
@@ -42132,7 +42132,7 @@ rumourmonger/14SM!@
4213242132
rump/~14MYS
4213342133
rumple/41DSMG
4213442134
rumpus/1MS
42135-
run/~415AM
42135+
run/~41AMS # removed `5` adj. a bit marginal and interferes with linter heuristics
4213642136
runabout/1MS
4213742137
runaround/1SM
4213842138
runaway/~15MS
@@ -42147,7 +42147,6 @@ runner/~1SM
4214742147
running/~451+M
4214842148
runny/5RT
4214942149
runoff/~1SM
42150-
runs/~4
4215142150
runt/1MS
4215242151
runtime/~51
4215342152
runty/5RT

harper-core/src/linting/compound_nouns/general_compound_nouns.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@ use crate::{
1111
patterns::{Pattern, SequencePattern},
1212
};
1313

14-
/// Covers the general cases of accidentally split compound nouns.
14+
/// Two adjacent words separated by whitespace that if joined would be a valid noun.
1515
pub struct GeneralCompoundNouns {
1616
pattern: Box<dyn Pattern>,
1717
split_pattern: Lrc<SplitCompoundWord>,
1818
}
1919

20+
// This heuristic identifies potential compound nouns by:
21+
// 1. Looking for a determiner or adjective (e.g., "a", "big", "red")
22+
// 2. Followed by two content words (not determiners, adverbs, or prepositions)
23+
// 3. Finally, checking if the combination forms a noun in the dictionary
24+
// that is not also an adjective
2025
impl Default for GeneralCompoundNouns {
2126
fn default() -> Self {
22-
let exceptions_pattern = SequencePattern::default()
27+
let context_pattern = SequencePattern::default()
2328
.then(|tok: &Token, _: &[char]| {
2429
let Some(Some(meta)) = tok.kind.as_word() else {
2530
return false;
@@ -44,22 +49,22 @@ impl Default for GeneralCompoundNouns {
4449
tok.span.len() > 1 && !meta.determiner && !meta.is_adverb() && !meta.preposition
4550
});
4651

47-
let split_pattern = Lrc::new(SplitCompoundWord::new(|meta| {
48-
meta.is_nominal() && !meta.is_adjective()
52+
let compound_pattern = Lrc::new(SplitCompoundWord::new(|meta| {
53+
meta.is_noun() && !meta.is_adjective()
4954
}));
5055

5156
let mut pattern = All::default();
52-
pattern.add(Box::new(exceptions_pattern));
57+
pattern.add(Box::new(context_pattern));
5358
pattern.add(Box::new(
5459
SequencePattern::default()
5560
.then_anything()
5661
.then_anything()
57-
.then(split_pattern.clone()),
62+
.then(compound_pattern.clone()),
5863
));
5964

6065
Self {
6166
pattern: Box::new(pattern),
62-
split_pattern,
67+
split_pattern: compound_pattern,
6368
}
6469
}
6570
}

harper-core/src/linting/compound_nouns/implied_ownership_compound_nouns.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ use crate::{
1111

1212
/// Looks for closed compound nouns which can be condensed due to their position after a
1313
/// possessive noun (which implies ownership).
14+
/// See also:
15+
/// harper-core/src/linting/lets_confusion/mod.rs
16+
/// harper-core/src/linting/lets_confusion/let_us_redundancy.rs
17+
/// harper-core/src/linting/lets_confusion/no_contraction_with_verb.rs
18+
/// harper-core/src/linting/pronoun_contraction/should_contract.rs
1419
pub struct ImpliedOwnershipCompoundNouns {
1520
pattern: Box<dyn Pattern>,
1621
split_pattern: Lrc<SplitCompoundWord>,
@@ -38,9 +43,14 @@ impl PatternLinter for ImpliedOwnershipCompoundNouns {
3843

3944
fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
4045
// "Let's" can technically be a possessive noun (of a lease, or a let in tennis, etc.)
41-
// but in practice it's almost always a contraction of "let us" before a verb.
42-
let possessive = matched_tokens[0].span.get_content(source);
43-
if possessive == ['l', 'e', 't', '\'', 's'] || possessive == ['L', 'e', 't', '\'', 's'] {
46+
// but in practice it's almost always a contraction of "let us" before a verb
47+
// or a mistake for "lets", the 3rd person singular present form of "to let".
48+
let word_apostrophe_s = matched_tokens[0].span.get_content(source);
49+
if word_apostrophe_s
50+
.iter()
51+
.map(|&c| c.to_ascii_lowercase())
52+
.eq(['l', 'e', 't', '\'', 's'].iter().copied())
53+
{
4454
return None;
4555
}
4656
let span = matched_tokens[2..].span()?;

harper-core/src/linting/lets_confusion/let_us_redundancy.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ use crate::{
55

66
use crate::linting::{Lint, LintKind, PatternLinter, Suggestion};
77

8+
/// See also:
9+
/// harper-core/src/linting/compound_nouns/implied_ownership_compound_nouns.rs
10+
/// harper-core/src/linting/lets_confusion/mod.rs
11+
/// harper-core/src/linting/lets_confusion/no_contraction_with_verb.rs
12+
/// harper-core/src/linting/pronoun_contraction/should_contract.rs
813
pub struct LetUsRedundancy {
914
pattern: Box<dyn Pattern>,
1015
}

harper-core/src/linting/lets_confusion/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ use super::merge_linters::merge_linters;
55
use let_us_redundancy::LetUsRedundancy;
66
use no_contraction_with_verb::NoContractionWithVerb;
77

8+
// See also:
9+
// harper-core/src/linting/compound_nouns/implied_ownership_compound_nouns.rs
10+
// harper-core/src/linting/lets_confusion/let_us_redundancy.rs
11+
// harper-core/src/linting/lets_confusion/no_contraction_with_verb.rs
12+
// harper-core/src/linting/pronoun_contraction/should_contract.rs
813
merge_linters!(LetsConfusion => LetUsRedundancy, NoContractionWithVerb => "It's often hard to determine where the subject should go with the word `let`. This rule attempts to find common errors with redundancy and contractions that may lead to confusion for readers.");
914

1015
#[cfg(test)]
@@ -41,14 +46,25 @@ mod tests {
4146
);
4247
}
4348

49+
// "play" is also a noun so in a context like "Sometimes the umpire lets play continue"
50+
// #[test]
51+
// fn issue_470_missing_apostrophe() {
52+
// assert_suggestion_result("lets play", LetsConfusion::default(), "let's play");
53+
// }
54+
55+
// #[test]
56+
// fn issue_470_missing_subject() {
57+
// assert_suggestion_result("let play", LetsConfusion::default(), "let's play");
58+
// }
59+
4460
#[test]
4561
fn issue_470_missing_apostrophe() {
46-
assert_suggestion_result("lets play", LetsConfusion::default(), "let's play");
62+
assert_suggestion_result("lets proceed", LetsConfusion::default(), "let's proceed");
4763
}
4864

4965
#[test]
5066
fn issue_470_missing_subject() {
51-
assert_suggestion_result("let play", LetsConfusion::default(), "let's play");
67+
assert_suggestion_result("let proceed", LetsConfusion::default(), "let's proceed");
5268
}
5369

5470
#[test]

harper-core/src/linting/lets_confusion/no_contraction_with_verb.rs

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,69 @@
11
use crate::{
22
Token,
33
linting::{Lint, LintKind, Suggestion},
4-
patterns::{Pattern, SequencePattern, WordSet},
4+
patterns::{EitherPattern, Pattern, SequencePattern, WordSet},
55
};
66

77
use crate::linting::PatternLinter;
88

9+
/// See also:
10+
/// harper-core/src/linting/compound_nouns/implied_ownership_compound_nouns.rs
11+
/// harper-core/src/linting/lets_confusion/mod.rs
12+
/// harper-core/src/linting/lets_confusion/let_us_redundancy.rs
13+
/// harper-core/src/linting/pronoun_contraction/should_contract.rs
914
pub struct NoContractionWithVerb {
1015
pattern: Box<dyn Pattern>,
1116
}
1217

1318
impl Default for NoContractionWithVerb {
1419
fn default() -> Self {
15-
let pattern = SequencePattern::default()
20+
let let_ws = SequencePattern::default()
1621
.then(WordSet::new(&["lets", "let"]))
22+
.then_whitespace();
23+
24+
// Word is only a verb, and not the gerund/present participle form.
25+
let non_ing_verb = SequencePattern::default().then(|tok: &Token, source: &[char]| {
26+
let Some(Some(meta)) = tok.kind.as_word() else {
27+
return false;
28+
};
29+
30+
if !meta.is_verb() || meta.is_noun() || meta.is_adjective() {
31+
return false;
32+
}
33+
34+
let tok_chars = tok.span.get_content(source);
35+
36+
// If it ends with 'ing' and is at least 5 chars long, it could be a gerund or past participle
37+
// TODO: replace with metadata check when affix system supports verb forms
38+
if tok_chars.len() < 5 {
39+
return true;
40+
}
41+
42+
let is_ing_form = tok_chars
43+
.iter()
44+
.skip(tok_chars.len() - 3)
45+
.map(|&c| c.to_ascii_lowercase())
46+
.collect::<Vec<_>>()
47+
.ends_with(&['i', 'n', 'g']);
48+
49+
!is_ing_form
50+
});
51+
52+
// Ambiguous word is a verb determined by heuristic of following word's part of speech
53+
let verb_due_to_following_pos = SequencePattern::default()
54+
.then(|tok: &Token, _source: &[char]| tok.kind.is_verb() || tok.kind.is_noun())
1755
.then_whitespace()
18-
.then_verb();
56+
.then(|tok: &Token, _source: &[char]| {
57+
tok.kind.is_determiner() || tok.kind.is_pronoun() || tok.kind.is_conjunction()
58+
});
59+
60+
let let_then_verb = let_ws.then(EitherPattern::new(vec![
61+
Box::new(non_ing_verb),
62+
Box::new(verb_due_to_following_pos),
63+
]));
1964

2065
Self {
21-
pattern: Box::new(pattern),
66+
pattern: Box::new(let_then_verb),
2267
}
2368
}
2469
}
@@ -39,12 +84,116 @@ impl PatternLinter for NoContractionWithVerb {
3984
Suggestion::replace_with_match_case_str("let's", template),
4085
Suggestion::replace_with_match_case_str("let us", template),
4186
],
42-
message: "It seems you forgot to include a subject here.".to_owned(),
87+
message: "To suggest an action, use 'let's' or 'let us'.".to_owned(),
4388
priority: 31,
4489
})
4590
}
4691

4792
fn description(&self) -> &'static str {
48-
"Make sure you include a subject when giving permission to it."
93+
"Checks for `lets` meaning `permits` when the context is about suggesting an action."
94+
}
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::NoContractionWithVerb;
100+
use crate::linting::tests::{assert_lint_count, assert_suggestion_result};
101+
102+
// Corrections
103+
104+
#[test]
105+
fn fix_lets_inspect() {
106+
assert_suggestion_result(
107+
"In the end lets inspect with git-blame the results.",
108+
NoContractionWithVerb::default(),
109+
"In the end let's inspect with git-blame the results.",
110+
);
111+
}
112+
113+
// False positives where verb is also a noun
114+
115+
#[test]
116+
fn dont_flag_let_chance() {
117+
assert_lint_count("Let chance decide", NoContractionWithVerb::default(), 0);
118+
}
119+
120+
#[test]
121+
fn dont_flag_let_time() {
122+
assert_lint_count(
123+
"Let time granularity be parametrized",
124+
NoContractionWithVerb::default(),
125+
0,
126+
);
127+
}
128+
129+
#[test]
130+
fn dont_flag_lets_staff() {
131+
assert_lint_count(
132+
"A plugin that backs up player's inventories and lets staff restore them or export it as a shulker.",
133+
NoContractionWithVerb::default(),
134+
0,
135+
);
136+
}
137+
138+
#[test]
139+
fn dont_flag_lets_time() {
140+
assert_lint_count(
141+
"This is very different than demo recording, which just simulates a network level connection and lets time move at its own rate.",
142+
NoContractionWithVerb::default(),
143+
0,
144+
);
145+
}
146+
147+
#[test]
148+
fn dont_flag_lets_play() {
149+
assert_lint_count(
150+
"Sometimes the umpire lets play continue",
151+
NoContractionWithVerb::default(),
152+
0,
153+
);
154+
}
155+
156+
// False positives where verb is a gerund/past participle
157+
158+
#[test]
159+
fn dont_flag_let_sleeping() {
160+
assert_lint_count(
161+
"Let sleeping logs lie.",
162+
NoContractionWithVerb::default(),
163+
0,
164+
);
165+
}
166+
167+
// False positives where verb is also an adjective
168+
169+
#[test]
170+
fn dont_flag_let_processed() {
171+
assert_lint_count(
172+
"Let processed response be a new structure analogous to server auction response.",
173+
NoContractionWithVerb::default(),
174+
0,
175+
);
176+
}
177+
178+
// Disambiguated noun/verb by following determiner
179+
180+
#[test]
181+
fn corrects_lets_make_this() {
182+
assert_suggestion_result(
183+
"Lets make this joke repo into one of the best.",
184+
NoContractionWithVerb::default(),
185+
"Let's make this joke repo into one of the best.",
186+
);
187+
}
188+
189+
// Disambiguated verb by following pronoun
190+
191+
#[test]
192+
fn corrects_lets_mock_them() {
193+
assert_suggestion_result(
194+
"Then lets mock them using Module._load based mocker.",
195+
NoContractionWithVerb::default(),
196+
"Then let's mock them using Module._load based mocker.",
197+
);
49198
}
50199
}

harper-core/src/linting/pronoun_contraction/should_contract.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ use crate::{
66
use crate::Lint;
77
use crate::linting::{LintKind, PatternLinter, Suggestion};
88

9+
/// See also:
10+
/// harper-core/src/linting/compound_nouns/implied_ownership_compound_nouns.rs
11+
/// harper-core/src/linting/lets_confusion/mod.rs
12+
/// harper-core/src/linting/lets_confusion/let_us_redundancy.rs
13+
/// harper-core/src/linting/lets_confusion/no_contraction_with_verb.rs
914
pub struct ShouldContract {
1015
pattern: Box<dyn Pattern>,
1116
}

0 commit comments

Comments
 (0)