diff --git a/harper-cli/src/lint.rs b/harper-cli/src/lint.rs index e0cc731ba6..83f8190803 100644 --- a/harper-cli/src/lint.rs +++ b/harper-cli/src/lint.rs @@ -93,6 +93,7 @@ pub struct LintOptions { pub weirpack_inputs: Vec, pub color: bool, pub format: OutputFormat, + pub quiet: bool, } enum ReportStyle { @@ -407,6 +408,7 @@ fn lint_one_input( weirpack_inputs: _, color: _, format: _, + quiet: _, } = lint_options; let mut lint_kinds: HashMap = HashMap::new(); @@ -526,7 +528,7 @@ fn lint_one_input( &lint_rules, // Reporting arguments batch_mode, - report_mode, + (report_mode, lint_options.quiet), ); } } @@ -629,8 +631,10 @@ fn single_input_report( lint_rules: &HashMap, // Reporting parameters batch_mode: bool, // If true, we are processing multiple files, which affects how we report - report_mode: &ReportStyle, + report_info: (&ReportStyle, bool), ) { + let (report_mode, quiet) = report_info; + // JSON mode: all output is handled by the caller after collecting results if matches!(report_mode, ReportStyle::Json) { return; @@ -667,23 +671,30 @@ fn single_input_report( if batch_mode && longest > MAX_LINE_LEN && matches!(report_mode, ReportStyle::FullAriadne) { report_mode = &ReportStyle::BriefCountsOnly; - println!( - "{}: Longest line: {longest} exceeds max line length: {MAX_LINE_LEN}", - input.format_path() - ); + if !quiet { + println!( + "{}: Longest line: {longest} exceeds max line length: {MAX_LINE_LEN}", + input.format_path() + ); + } } // Report the number of lints no matter what report mode we are in - println!( - "{}: {}", - input.format_path(), - match (lint_count_before, lint_count_after) { - (0, _) => "No lints found".to_string(), - (before, after) if before != after => - format!("{before} lints before overlap removal, {after} after"), - (before, _) => format!("{before} lints"), + if lint_count_before == 0 { + if !quiet { + println!("{}: No lints found", input.format_path()); } - ); + } else { + println!( + "{}: {}", + input.format_path(), + match (lint_count_before, lint_count_after) { + (before, after) if before != after => + format!("{before} lints before overlap removal, {after} after"), + (before, _) => format!("{before} lints"), + } + ); + } // If we are in Ariadne mode, print the report if matches!(report_mode, ReportStyle::FullAriadne) { diff --git a/harper-cli/src/main.rs b/harper-cli/src/main.rs index 097afc58a7..44e4a2d75e 100644 --- a/harper-cli/src/main.rs +++ b/harper-cli/src/main.rs @@ -89,6 +89,9 @@ enum Args { /// Output format for lint results. #[arg(long, value_enum, default_value_t = OutputFormat::Default)] format: OutputFormat, + /// Suppress informational status messages and only output actual lint errors. + #[arg(long)] + quiet: bool, }, /// Parse a provided document and print the detected symbols. Parse { @@ -251,6 +254,7 @@ fn main() -> anyhow::Result<()> { file_dict_path, weirpacks, format, + quiet, } => { let dialect = parse_dialect(&dialect_str) .map_err(|e| anyhow!("Invalid dialect '{}': {}", dialect_str, e))?; @@ -268,6 +272,7 @@ fn main() -> anyhow::Result<()> { weirpack_inputs: weirpacks, color, format, + quiet, }, user_dict_path, // TODO workspace_dict_path? diff --git a/harper-core/default_config.json b/harper-core/default_config.json index aa283c0c2d..4c5381862c 100644 --- a/harper-core/default_config.json +++ b/harper-core/default_config.json @@ -5033,6 +5033,12 @@ "label": "Pay For Price" } }, + { + "Bool": { + "name": "NumericModifier", + "state": true, + "label": "Numeric Modifier" + }, { "Bool": { "name": "IncidentReport", diff --git a/harper-core/src/linting/lint_group/mod.rs b/harper-core/src/linting/lint_group/mod.rs index c1157b4dcc..691fb286f6 100644 --- a/harper-core/src/linting/lint_group/mod.rs +++ b/harper-core/src/linting/lint_group/mod.rs @@ -159,6 +159,7 @@ use super::nor_modal_pronoun::NorModalPronoun; use super::not_only_inversion::NotOnlyInversion; use super::noun_verb_confusion::NounVerbConfusion; use super::number_suffix_capitalization::NumberSuffixCapitalization; +use super::numeric_modifier::NumericModifier; use super::numeric_range_en_dash::NumericRangeEnDash; use super::obsess_preposition::ObsessPreposition; use super::of_course::OfCourse; @@ -718,6 +719,7 @@ impl LintGroup { insert_expr_rule!(NotOnlyInversion, true); insert_struct_rule!(NounVerbConfusion, true); insert_struct_rule!(NumberSuffixCapitalization, true); + insert_struct_rule!(NumericModifier, true); insert_expr_rule!(NumericRangeEnDash, true); insert_expr_rule!(ObsessPreposition, true); insert_expr_rule!(OfCourse, true); diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index 763c79104f..857a6fa814 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -168,6 +168,7 @@ mod nor_modal_pronoun; mod not_only_inversion; mod noun_verb_confusion; mod number_suffix_capitalization; +mod numeric_modifier; mod numeric_range_en_dash; mod obsess_preposition; mod of_course; diff --git a/harper-core/src/linting/numeric_modifier.rs b/harper-core/src/linting/numeric_modifier.rs new file mode 100644 index 0000000000..7b8c630d7e --- /dev/null +++ b/harper-core/src/linting/numeric_modifier.rs @@ -0,0 +1,223 @@ +use crate::{ + Lint, Token, TokenKind, TokenStringExt, + expr::{All, Expr, SequenceExpr, SpelledNumberExpr}, + linting::{ExprLinter, LintKind, debug::format_lint_match, expr_linter::Chunk}, + patterns::WordSet, +}; + +pub struct NumericModifier { + expr: SequenceExpr, +} + +const PLURAL_UNITS: &str = "acres|amperes|amps|bars|bits|bowls|bucks|bytes|\ + cents|cms|centimeters|centmetres|centuries|colors|\ + colours|columns|cups|days|decades|decibels|\ + deciliters|decilitres|decimeters|decimetres|degs|\ + degrees|dollars|drops|ems|euros|feet|frames|gals|\ + gallons|gigabits|gigabytes|gigs|glasses|handfuls|\ + hectares|hrs|hours|inches|joules|keys|kilobits|\ + kilobytes|kgs|kilograms|kilojoules|kilometers|\ + kilometres|kilopascals|lightyears|lbs|megs|lines|\ + liters|megabits|megabytes|meters|metres|mis|miles|\ + mins|minutes|milligrams|mls|milliliters|\ + millilitres|millimeters|millimetres|mos|months|\ + megapascals|nanometers|nanometres|ohms|ounces|\ + pages|parsecs|paragraphs|pascals|pence|petabits|\ + petabytes|pixels|pounds|quarters|qts|quarts|rads|\ + radians|rows|seasons|secs|seconds|slices|stages|\ + strings|terabits|terabytes|ticks|tonnes|tons|volts|\ + watts|wks|weeks|words|yds|yards|yrs|years"; +impl Default for NumericModifier { + fn default() -> Self { + let number = SequenceExpr::any_of(vec![ + Box::new(SpelledNumberExpr), + Box::new(SequenceExpr::default().then_cardinal_number()), + ]); + + // e.g. "10 ‹YEARS› old laptop" + let unit = All::new(vec![ + Box::new(WordSet::new(PLURAL_UNITS.split('|'))), + Box::new(|t: &Token, s: &[char]| t.kind.is_plural_noun() && s.len() > 1), + ]); + + // e.g. "10 years ‹OLD› laptop" + let dimensional_adjective = SequenceExpr::default().then_kind_is_but_isnt_any_of_except( + TokenKind::is_adjective, + &[ + TokenKind::is_superlative_adjective, + TokenKind::is_preposition, + TokenKind::is_verb_progressive_form, + ], + &["it", "left", "max", "now", "paid", "per", "spent"], + ); + + // e.g. "10 years old ‹LAPTOP›" + let noun = SequenceExpr::default().then_kind_is_but_isnt_any_of_except( + TokenKind::is_noun, + &[TokenKind::is_conjunction, TokenKind::is_preposition], + &["uh"], + ); + + Self { + expr: number + .t_ws_h() + .then(unit) + .then_optional(SequenceExpr::default().t_ws_h().then(dimensional_adjective)) + .t_ws() + .then(noun), + } + } +} + +impl ExprLinter for NumericModifier { + type Unit = Chunk; + + fn match_to_lint_with_context( + &self, + matched_tokens: &[Token], + source: &[char], + context: Option<(&[Token], &[Token])>, + ) -> Option { + eprintln!("🚨 {}", format_lint_match(matched_tokens, context, source)); + let span = matched_tokens.span()?; + Some(Lint { + span, + lint_kind: LintKind::Miscellaneous, + suggestions: vec![], + message: "👎".to_string(), + ..Default::default() + }) + } + + fn expr(&self) -> &dyn Expr { + &self.expr + } + + fn description(&self) -> &str { + "Flags plural units used in modifiers." + } +} + +#[cfg(test)] +mod tests { + use crate::linting::tests::assert_suggestion_result; + + use super::NumericModifier; + + #[test] + fn arguments_function() { + assert_suggestion_result( + "The Ackerman function is a two arguments function.", + NumericModifier::default(), + "The Ackerman function is a two argument function.", + ); + } + + #[test] + fn bytes_sequence() { + assert_suggestion_result( + "My laptop is a six years old Lenovo Ideapad L340-17IRH Gaming.", + NumericModifier::default(), + "My laptop is a six year old Lenovo Ideapad L340-17IRH Gaming.", + ); + } + + #[test] + fn columns_dataframe() { + assert_suggestion_result( + "Building a tree from a two columns data frame", + NumericModifier::default(), + "Building a tree from a two column data frame", + ); + } + + #[test] + fn days_event() { + assert_suggestion_result( + "That is, is 1/1/2018-2/1/2018 a two days event, or a 1 day event like in ical", + NumericModifier::default(), + "That is, is 1/1/2018-2/1/2018 a two day event, or a 1 day event like in ical", + ); + } + + #[test] + fn days_window() { + assert_suggestion_result( + "how much the customer paid in a seven days window", + NumericModifier::default(), + "how much the customer paid in a seven day window", + ); + } + + #[test] + fn dollars_tip() { + assert_suggestion_result( + "A two-dollars tip each day!", + NumericModifier::default(), + "A two-dollar tip each day!", + ); + } + + #[test] + fn feet_pole() { + assert_suggestion_result( + "I know someone who won't even touch six words with a ten-feet pole", + NumericModifier::default(), + "I know someone who won't even touch six words with a ten-foot pole", + ); + } + + #[test] + fn lines_program() { + assert_suggestion_result( + "Adding a two lines program that triggers the issue here", + NumericModifier::default(), + "Adding a two line program that triggers the issue here", + ); + } + + #[test] + fn meters_distance() { + assert_suggestion_result( + "in front of the sensor (at a three meters distance)", + NumericModifier::default(), + "in front of the sensor (at a three meter distance)", + ); + } + + #[test] + fn minutes_walk() { + assert_suggestion_result( + "Take a Ten Minutes Walk.", + NumericModifier::default(), + "Take a Ten Minute Walk.", + ); + } + + #[test] + fn parameters_function() { + assert_suggestion_result( + "I need to implement a two parameters function with python.", + NumericModifier::default(), + "I need to implement a two parameter function with python.", + ); + } + + #[test] + fn params_function() { + assert_suggestion_result( + "This is an alias over zip and then apply a two params function.", + NumericModifier::default(), + "This is an alias over zip and then apply a two param function.", + ); + } + + #[test] + fn years_old() { + assert_suggestion_result( + "My laptop is a six years old Lenovo Ideapad L340-17IRH Gaming.", + NumericModifier::default(), + "My laptop is a six year old Lenovo Ideapad L340-17IRH Gaming.", + ); + } +} diff --git a/harper-core/src/linting/web_scraping.rs b/harper-core/src/linting/web_scraping.rs index aca119aaf1..ac538394b8 100644 --- a/harper-core/src/linting/web_scraping.rs +++ b/harper-core/src/linting/web_scraping.rs @@ -14,7 +14,7 @@ impl Default for WebScraping { let scrap_verbs = &["scrap", "scrapped", "scraps", "scrapping"][..]; let scrap_nouns = &["scrapper", "scrappers"][..]; - let mut closed_compounds = WordSet::new(&[]); + let mut closed_compounds = WordSet::new(std::iter::empty::<&str>()); let mut open_and_hyphenated_compounds = vec![]; scrap_verbs.iter().chain(scrap_nouns).for_each(|scrap| { diff --git a/harper-core/src/patterns/word_set.rs b/harper-core/src/patterns/word_set.rs index e4c341046f..65666d967a 100644 --- a/harper-core/src/patterns/word_set.rs +++ b/harper-core/src/patterns/word_set.rs @@ -32,11 +32,11 @@ impl WordSet { } /// Create a new word set that matches against any word in the provided list. - pub fn new(words: &[&'static str]) -> Self { + pub fn new(words: impl IntoIterator>) -> Self { let mut set = Self::default(); for str in words { - set.add(str); + set.add(str.as_ref()); } set