Skip to main content

harper_core/weir/
mod.rs

1//! Weir is a programming language for finding errors in natural language.
2//! See our [main documentation](https://writewithharper.com/docs/weir) for more details.
3
4mod ast;
5mod error;
6mod optimize;
7mod parsing;
8
9use std::collections::VecDeque;
10use std::str::FromStr;
11use std::sync::Arc;
12
13pub use error::Error;
14use hashbrown::{HashMap, HashSet};
15use is_macro::Is;
16use parsing::{parse_expr_str, parse_str};
17use strum_macros::{AsRefStr, EnumString};
18
19use crate::expr::{Expr, ExprExt};
20use crate::linting::{
21    Chunk, ExprLinter, Lint, LintKind, Linter, MAX_SUGGESTION_TRANSFORMATION_DEPTH, Sentence,
22    Suggestion,
23};
24use crate::parsers::Markdown;
25use crate::spell::FstDictionary;
26use crate::{Document, Lrc, Token, TokenStringExt};
27
28use self::ast::{Ast, AstVariable};
29
30pub(crate) fn weir_expr_to_expr(weir_code: &str) -> Result<Box<dyn Expr>, Error> {
31    let ast = parse_expr_str(weir_code, true)?;
32    ast.to_expr(&HashMap::new())
33}
34
35#[derive(Debug, Is, EnumString, AsRefStr)]
36enum ReplacementStrategy {
37    MatchCase,
38    Exact,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString)]
42enum WeirScope {
43    Chunk,
44    Sentence,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct TestResult {
49    pub expected: String,
50    pub got: String,
51}
52
53pub struct WeirLinter {
54    expr: Lrc<Box<dyn Expr>>,
55    description: String,
56    message: String,
57    strategy: ReplacementStrategy,
58    replacements: Vec<String>,
59    lint_kind: LintKind,
60    scope: WeirScope,
61    ast: Arc<Ast>,
62}
63
64struct ChunkWeirLinter(WeirLinter);
65
66struct SentenceWeirLinter(WeirLinter);
67
68impl WeirLinter {
69    pub fn new(weir_code: &str) -> Result<WeirLinter, Error> {
70        let ast = parse_str(weir_code, true)?;
71
72        let main_expr_name = "main";
73        let description_name = "description";
74        let message_name = "message";
75        let lint_kind_name = "kind";
76        let replacement_name = "becomes";
77        let replacement_strat_name = "strategy";
78        let scope_name = "scope";
79
80        let resolved = resolve_exprs(&ast)?;
81
82        let expr = resolved
83            .get(main_expr_name)
84            .ok_or(Error::ExpectedVariableUndefined)?;
85
86        let description = ast
87            .get_variable_value(description_name)
88            .ok_or(Error::ExpectedVariableUndefined)?
89            .as_string()
90            .ok_or(Error::ExpectedDifferentVariableType)?
91            .to_owned();
92
93        let message = ast
94            .get_variable_value(message_name)
95            .ok_or(Error::ExpectedVariableUndefined)?
96            .as_string()
97            .ok_or(Error::ExpectedDifferentVariableType)?
98            .to_owned();
99
100        let replacement_val = ast
101            .get_variable_value(replacement_name)
102            .ok_or(Error::ExpectedVariableUndefined)?;
103
104        let replacements = match replacement_val {
105            AstVariable::String(s) => vec![s.to_owned()],
106            AstVariable::Array(arr) => {
107                let mut out = Vec::with_capacity(arr.len());
108                for item in arr.iter().map(|v| {
109                    v.as_string()
110                        .cloned()
111                        .ok_or(Error::ExpectedDifferentVariableType)
112                }) {
113                    let item = item?;
114                    out.push(item);
115                }
116                out
117            }
118        };
119
120        let replacement_strat_var = ast.get_variable_value(replacement_strat_name);
121        let replacement_strat = if let Some(replacement_strat) = replacement_strat_var {
122            let str = replacement_strat
123                .as_string()
124                .ok_or(Error::ExpectedDifferentVariableType)?;
125            ReplacementStrategy::from_str(str)
126                .ok()
127                .ok_or(Error::InvalidReplacementStrategy)?
128        } else {
129            ReplacementStrategy::MatchCase
130        };
131
132        let lint_kind_var = ast.get_variable_value(lint_kind_name);
133        let lint_kind = if let Some(lint_kind) = lint_kind_var {
134            let str = lint_kind
135                .as_string()
136                .ok_or(Error::ExpectedDifferentVariableType)?;
137            LintKind::from_string_key(str).ok_or(Error::InvalidLintKind)?
138        } else {
139            LintKind::Miscellaneous
140        };
141
142        let scope_var = ast.get_variable_value(scope_name);
143        let scope = if let Some(scope) = scope_var {
144            let str = scope
145                .as_string()
146                .ok_or(Error::ExpectedDifferentVariableType)?;
147            WeirScope::from_str(str).ok().ok_or(Error::InvalidScope)?
148        } else {
149            WeirScope::Chunk
150        };
151
152        let linter = WeirLinter {
153            strategy: replacement_strat,
154            ast,
155            expr: expr.clone(),
156            lint_kind,
157            scope,
158            description,
159            message,
160            replacements,
161        };
162
163        Ok(linter)
164    }
165
166    pub fn into_chunk_linter(self) -> Result<impl ExprLinter<Unit = Chunk>, Self> {
167        if self.scope == WeirScope::Chunk {
168            Ok(ChunkWeirLinter(self))
169        } else {
170            Err(self)
171        }
172    }
173
174    pub fn into_sentence_linter(self) -> Result<impl ExprLinter<Unit = Sentence>, Self> {
175        if self.scope == WeirScope::Sentence {
176            Ok(SentenceWeirLinter(self))
177        } else {
178            Err(self)
179        }
180    }
181
182    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
183        let span = matched_tokens.span()?;
184        let orig = span.get_content(source);
185
186        let suggestions = match self.strategy {
187            ReplacementStrategy::MatchCase => self
188                .replacements
189                .iter()
190                .map(|s| Suggestion::replace_with_match_case(s.chars().collect(), orig))
191                .collect(),
192            ReplacementStrategy::Exact => self
193                .replacements
194                .iter()
195                .map(|r| Suggestion::ReplaceWith(r.chars().collect()))
196                .collect(),
197        };
198
199        Some(Lint {
200            span,
201            lint_kind: self.lint_kind,
202            suggestions,
203            message: self.message.to_owned(),
204            priority: 31,
205        })
206    }
207
208    /// Counts the total number of tests defined.
209    pub fn count_tests(&self) -> usize {
210        self.ast.iter_tests().count()
211    }
212
213    /// Runs the tests defined in the source code, returning any failing results.
214    pub fn run_tests(&mut self) -> Vec<TestResult> {
215        fn apply_nth_suggestion(text: &str, lint: &Lint, n: usize) -> Option<String> {
216            let suggestion = lint.suggestions.get(n)?;
217            let mut text_chars: Vec<char> = text.chars().collect();
218            suggestion.apply(lint.span, &mut text_chars);
219            Some(text_chars.iter().collect())
220        }
221
222        fn transform_to_expected(
223            text: &str,
224            expected: &str,
225            linter: &mut impl Linter,
226        ) -> Option<String> {
227            let mut queue: VecDeque<(String, usize)> = VecDeque::new();
228            let mut seen: HashSet<String> = HashSet::new();
229
230            queue.push_back((text.to_string(), 0));
231            seen.insert(text.to_string());
232
233            while let Some((current, depth)) = queue.pop_front() {
234                if current == expected {
235                    return Some(current);
236                }
237
238                if depth >= MAX_SUGGESTION_TRANSFORMATION_DEPTH {
239                    continue;
240                }
241
242                let doc = Document::new_from_chars(
243                    current.chars().collect::<Vec<_>>().into(),
244                    &Markdown::default(),
245                    &FstDictionary::curated(),
246                );
247                let lints = linter.lint(&doc);
248
249                if let Some(lint) = lints.first() {
250                    for i in 0..lint.suggestions.len() {
251                        if let Some(next) = apply_nth_suggestion(&current, lint, i)
252                            && seen.insert(next.clone())
253                        {
254                            queue.push_back((next, depth + 1));
255                        }
256                    }
257                }
258            }
259
260            None
261        }
262
263        fn transform_nth_str(text: &str, linter: &mut impl Linter, n: usize) -> String {
264            let mut text_chars: Vec<char> = text.chars().collect();
265            let mut iter_count = 0;
266
267            loop {
268                let test = Document::new_from_chars(
269                    text_chars.clone().into(),
270                    &Markdown::default(),
271                    &FstDictionary::curated(),
272                );
273                let lints = linter.lint(&test);
274
275                if let Some(lint) = lints.first() {
276                    if let Some(suggestion) = lint.suggestions.get(n) {
277                        suggestion.apply(lint.span, &mut text_chars);
278                    } else {
279                        break;
280                    }
281                } else {
282                    break;
283                }
284
285                iter_count += 1;
286                if iter_count == MAX_SUGGESTION_TRANSFORMATION_DEPTH {
287                    break;
288                }
289            }
290
291            text_chars.iter().collect()
292        }
293
294        fn lint_count(text: &str, linter: &mut impl Linter) -> usize {
295            let document = Document::new_from_chars(
296                text.chars().collect::<Vec<_>>().into(),
297                &Markdown::default(),
298                &FstDictionary::curated(),
299            );
300
301            linter.lint(&document).len()
302        }
303
304        let mut results = Vec::new();
305        let tests: Vec<(String, String)> = self
306            .ast
307            .iter_tests()
308            .map(|(text, expected)| (text.to_string(), expected.to_string()))
309            .collect();
310
311        for (text, expected) in tests {
312            let matched = transform_to_expected(&text, &expected, self);
313
314            match matched {
315                Some(result) => {
316                    let remaining_lints = lint_count(&result, self);
317
318                    if remaining_lints != 0 {
319                        results.push(TestResult {
320                            expected: expected.to_string(),
321                            got: result,
322                        });
323                    }
324                }
325                None => results.push(TestResult {
326                    expected: expected.to_string(),
327                    got: transform_nth_str(&text, self, 0),
328                }),
329            }
330        }
331
332        results
333    }
334}
335
336impl Linter for WeirLinter {
337    fn lint(&mut self, document: &Document) -> Vec<Lint> {
338        let source = document.get_source();
339        let mut lints = Vec::new();
340        let units: Box<dyn Iterator<Item = &[Token]> + '_> = match self.scope {
341            WeirScope::Chunk => Box::new(document.iter_chunks()),
342            WeirScope::Sentence => Box::new(document.iter_sentences()),
343        };
344
345        for unit in units {
346            lints.extend(
347                self.expr
348                    .iter_matches(unit, source)
349                    .filter_map(|match_span| {
350                        self.match_to_lint(&unit[match_span.start..match_span.end], source)
351                    }),
352            );
353        }
354
355        lints
356    }
357
358    fn description(&self) -> &str {
359        &self.description
360    }
361}
362
363impl ExprLinter for ChunkWeirLinter {
364    type Unit = Chunk;
365
366    fn expr(&self) -> &dyn Expr {
367        &self.0.expr
368    }
369
370    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
371        self.0.match_to_lint(matched_tokens, source)
372    }
373
374    fn description(&self) -> &str {
375        &self.0.description
376    }
377}
378
379impl ExprLinter for SentenceWeirLinter {
380    type Unit = Sentence;
381
382    fn expr(&self) -> &dyn Expr {
383        &self.0.expr
384    }
385
386    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
387        self.0.match_to_lint(matched_tokens, source)
388    }
389
390    fn description(&self) -> &str {
391        &self.0.description
392    }
393}
394
395fn resolve_exprs(ast: &Ast) -> Result<HashMap<String, Lrc<Box<dyn Expr>>>, Error> {
396    let mut resolved_exprs = HashMap::new();
397
398    for (name, val) in ast.iter_exprs() {
399        let expr = val.to_expr(&resolved_exprs)?;
400        resolved_exprs.insert(name.to_owned(), Lrc::new(expr));
401    }
402
403    Ok(resolved_exprs)
404}
405
406#[cfg(test)]
407pub mod tests {
408    use quickcheck_macros::quickcheck;
409
410    use crate::weir::Error;
411
412    use super::{TestResult, WeirLinter};
413
414    #[track_caller]
415    pub fn assert_passes_all(linter: &mut WeirLinter) {
416        assert_eq!(Vec::<TestResult>::new(), linter.run_tests());
417    }
418
419    #[test]
420    fn simple_right_click_linter() {
421        let source = r#"
422            expr main <([right, middle, left] $click), ( )>
423            let message "Hyphenate this mouse command"
424            let description "Hyphenates right-click style mouse commands."
425            let kind "Punctuation"
426            let becomes "-"
427
428            test "Right click the icon." "Right-click the icon."
429            test "Please right click on the link." "Please right-click on the link."
430            test "They right clicked the submit button." "They right-clicked the submit button."
431            test "Right clicking the item highlights it." "Right-clicking the item highlights it."
432            test "Right clicks are tracked in the log." "Right-clicks are tracked in the log."
433            test "He RIGHT CLICKED the file." "He RIGHT-CLICKED the file."
434            test "Left click the checkbox." "Left-click the checkbox."
435            test "Middle click to open in a new tab." "Middle-click to open in a new tab."
436
437            allows "This test contains the correct version of right-click and therefore shouldn't error."
438            "#;
439
440        let mut linter = WeirLinter::new(source).unwrap();
441        assert_passes_all(&mut linter);
442        assert_eq!(9, linter.count_tests());
443    }
444
445    #[test]
446    fn g_suite() {
447        let source = r#"
448            expr main [(G [Suite, Suit]), (Google Apps for Work)]
449            let message "Use the updated brand."
450            let description "`G Suite` or `Google Apps for Work` is now called `Google Workspace`"
451            let kind "Miscellaneous"
452            let becomes "Google Workspace"
453            let strategy "Exact"
454
455            test "We migrated from G Suite last year." "We migrated from Google Workspace last year."
456            test "This account is still labeled as Google Apps for Work." "This account is still labeled as Google Workspace."
457            test "The pricing page mentions G Suit for legacy plans." "The pricing page mentions Google Workspace for legacy plans."
458            test "New customers sign up for Google Workspace." "New customers sign up for Google Workspace."
459
460            allows "This test contains the correct version of Google Workspace and therefore shouldn't error."
461            "#;
462
463        let mut linter = WeirLinter::new(source).unwrap();
464
465        assert_passes_all(&mut linter);
466        assert_eq!(5, linter.count_tests());
467    }
468
469    #[test]
470    fn array_prefers_longest_match_over_first_match() {
471        for main in [
472            "[(capitalized off of), (capitalized off)]",
473            "[(capitalized off), (capitalized off of)]",
474        ] {
475            let source = format!(
476                r#"
477            expr main {main}
478            let message "Use the replacement."
479            let description "Regression test for overlapping Weir array options."
480            let kind "Miscellaneous"
481            let becomes "replacement"
482            let strategy "Exact"
483
484            test "capitalized off of" "replacement"
485            "#
486            );
487
488            let mut linter = WeirLinter::new(&source).unwrap();
489            assert_passes_all(&mut linter);
490        }
491    }
492
493    #[test]
494    fn g_suite_with_refs() {
495        let source = r#"
496            expr a (G [Suite, Suit])
497            expr b (Google Apps For Work)
498            expr incorrect [@a, @b]
499
500            expr main @incorrect
501            let message "Use the updated brand."
502            let description "`G Suite` or `Google Apps for Work` is now called `Google Workspace`"
503            let kind "Miscellaneous"
504            let becomes "Google Workspace"
505            let strategy "Exact"
506
507            test "We migrated from G Suite last year." "We migrated from Google Workspace last year."
508            test "This account is still labeled as Google Apps for Work." "This account is still labeled as Google Workspace."
509            test "The pricing page mentions G Suit for legacy plans." "The pricing page mentions Google Workspace for legacy plans."
510            test "New customers sign up for Google Workspace." "New customers sign up for Google Workspace."
511            "#;
512
513        let mut linter = WeirLinter::new(source).unwrap();
514
515        assert_passes_all(&mut linter);
516        assert_eq!(4, linter.count_tests());
517    }
518
519    #[test]
520    fn scope_defaults_to_chunk() {
521        let source = r#"
522            expr main one**two
523            let message "Use three."
524            let description "Test chunk-scoped Weir."
525            let kind "Miscellaneous"
526            let becomes "three"
527            let strategy "Exact"
528
529            allows "one, two."
530        "#;
531
532        let mut linter = WeirLinter::new(source).unwrap();
533
534        assert_passes_all(&mut linter);
535
536        let linter = WeirLinter::new(source).unwrap();
537        let linter = match linter.into_sentence_linter() {
538            Ok(_) => panic!("default-scoped Weir rule should not convert to sentence linter"),
539            Err(linter) => linter,
540        };
541        assert!(linter.into_chunk_linter().is_ok());
542    }
543
544    #[test]
545    fn sentence_scope_can_match_across_chunks() {
546        let source = r#"
547            expr main one**two
548            let message "Use three."
549            let description "Test sentence-scoped Weir."
550            let kind "Miscellaneous"
551            let becomes "three"
552            let strategy "Exact"
553            let scope "Sentence"
554
555            test "one, two." "three."
556        "#;
557
558        let mut linter = WeirLinter::new(source).unwrap();
559
560        assert_passes_all(&mut linter);
561
562        assert!(
563            WeirLinter::new(source)
564                .unwrap()
565                .into_sentence_linter()
566                .is_ok()
567        );
568    }
569
570    #[test]
571    fn invalid_scope_errors() {
572        let source = r#"
573            expr main one
574            let message ""
575            let description ""
576            let kind "Miscellaneous"
577            let becomes ""
578            let scope "Paragraph"
579        "#;
580
581        let res = WeirLinter::new(source);
582
583        assert_eq!(res.err(), Some(Error::InvalidScope));
584    }
585
586    #[test]
587    fn fails_on_unresolved_expr() {
588        let source = r#"
589            expr main @missing
590            let message ""
591            let description ""
592            let kind "Miscellaneous"
593            let becomes ""
594            let strategy "Exact"
595        "#;
596
597        let res = WeirLinter::new(source);
598
599        assert_eq!(
600            res.err().unwrap(),
601            Error::UnableToResolveExpr("missing".to_string())
602        )
603    }
604
605    #[test]
606    fn wildcard() {
607        let source = r#"
608            expr main <(NOUN * NOUN), (* NOUN), *>
609            let message ""
610            let description ""
611            let kind "Miscellaneous"
612            let becomes ""
613            let strategy "Exact"
614
615            test "I like trees and plants of all kinds" "I like trees  plants of all kinds"
616            test "homework tempts teachers" "homework  teachers"
617            "#;
618
619        let mut linter = WeirLinter::new(source).unwrap();
620
621        assert_passes_all(&mut linter);
622        assert_eq!(2, linter.count_tests());
623    }
624
625    #[test]
626    fn dashes() {
627        let source = r#"
628            expr main --
629            let message ""
630            let description ""
631            let kind "Miscellaneous"
632            let becomes "-"
633            let strategy "Exact"
634
635            test "This--and--that" "This-and-that"
636
637            allows "this-and-that"
638            "#;
639
640        let mut linter = WeirLinter::new(source).unwrap();
641
642        assert_passes_all(&mut linter);
643        assert_eq!(2, linter.count_tests());
644    }
645
646    #[test]
647    fn fails_on_ignore_test() {
648        let source = r#"
649            expr main test
650            let message ""
651            let description ""
652            let kind "Miscellaneous"
653            let becomes "-"
654            let strategy "Exact"
655
656            allows "test"
657            "#;
658
659        let mut linter = WeirLinter::new(source).unwrap();
660
661        assert_eq!(linter.run_tests().len(), 1)
662    }
663
664    #[test]
665    fn errors_properly_with_missing_expr() {
666        let source = "expr main";
667        let res = WeirLinter::new(source);
668        assert_eq!(res.err(), Some(Error::ExpectedVariableUndefined))
669    }
670
671    #[test]
672    fn becomes_array_with_many_alternatives() {
673        let source = r#"
674 expr main (the fact)
675 let message "Consider alternative phrasing"
676 let description "Test that all 'becomes' alternatives can be reached"
677 let kind "Miscellaneous"
678 let becomes ["the allegation", "the idea", "the claim", "the story", "the rumor"]
679 let strategy "Exact"
680
681 test "There is truth to the fact that people like images." "There is truth to the allegation that people like images."
682 test "There is truth to the fact that people like images." "There is truth to the idea that people like images."
683 test "There is truth to the fact that people like images." "There is truth to the claim that people like images."
684 test "There is truth to the fact that people like images." "There is truth to the story that people like images."
685 test "There is truth to the fact that people like images." "There is truth to the rumor that people like images."
686
687 allows "There is truth to the story that people like images."
688 "#;
689
690        let mut linter = WeirLinter::new(source).unwrap();
691        assert_passes_all(&mut linter);
692        assert_eq!(6, linter.count_tests());
693    }
694
695    #[quickcheck]
696    fn does_not_panic(s: String) {
697        let _ = WeirLinter::new(s.as_str());
698    }
699}