1mod 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 pub fn count_tests(&self) -> usize {
210 self.ast.iter_tests().count()
211 }
212
213 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(¤t, 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}