Skip to main content

voltaic_driver/
classify.rs

1use std::collections::BTreeMap;
2
3use std::fmt::Display;
4
5use std::fmt::Formatter;
6
7use std::fmt::Result;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub enum Class {
11  Auto,
12  Review,
13  Manual,
14}
15#[allow(unused_imports)]
16use Class::*;
17
18impl Display for Class {
19  fn fmt(&self, f: &mut Formatter<'_>) -> Result {
20    match self {
21      Class::Auto => write!(f, "auto"),
22      Class::Review => write!(f, "review"),
23      Class::Manual => write!(f, "manual"),
24    }
25  }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Position {
30  Anywhere,
31  AtEnd,
32  AtStart,
33}
34#[allow(unused_imports)]
35use Position::*;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Marker {
39  pub needle: String,
40  pub construct: String,
41  pub kind: Class,
42  pub position: Position,
43}
44
45pub fn markers() -> Vec<Marker> {
46  let mut found = Vec::new();
47  at_end(&mut found, "do", "a `do` block", Class::Manual);
48  add(
49    &mut found,
50    "Option<Option<",
51    "a nested optional",
52    Class::Manual,
53  );
54  add(
55    &mut found,
56    "FieldNames",
57    "`FieldNames` in a derive template",
58    Class::Manual,
59  );
60  add(
61    &mut found,
62    "Fields",
63    "`Fields` in a derive template",
64    Class::Manual,
65  );
66  add(
67    &mut found,
68    "__vlt_",
69    "a generated identifier named by hand",
70    Class::Manual,
71  );
72  add(
73    &mut found,
74    ".and_then",
75    "`.and_then` over a std shape",
76    Class::Review,
77  );
78  add(&mut found, "where", "a `where` group", Class::Review);
79  add(&mut found, "|>", "the `|>` pipeline", Class::Auto);
80  add(&mut found, ">>>", "forward composition", Class::Auto);
81  add(&mut found, "<<<", "reverse composition", Class::Auto);
82  add(&mut found, "otherwise", "`otherwise`", Class::Auto);
83  add(&mut found, "newtype ", "`newtype`", Class::Auto);
84  add(&mut found, "errors ", "`errors`", Class::Auto);
85  add(&mut found, "deriving ", "`deriving`", Class::Auto);
86  add(&mut found, "instance ", "an `instance` header", Class::Auto);
87  add(&mut found, "import ", "`import`", Class::Auto);
88  add(&mut found, "export ", "`export`", Class::Auto);
89  add(&mut found, "module ", "`module`", Class::Auto);
90  add(&mut found, "async ", "`async`", Class::Auto);
91  add(&mut found, ".await", "`await`", Class::Auto);
92  add(&mut found, "catch ", "`catch`", Class::Auto);
93  add(&mut found, "&mut ", "an explicit `&mut`", Class::Auto);
94  at_start(&mut found, "if", "an `if` statement", Class::Review);
95  at_start(&mut found, "while", "a `while` loop", Class::Review);
96  at_start(&mut found, "for", "a `for` loop", Class::Review);
97  add(&mut found, "case", "a `case` expression", Class::Review);
98  at_start(&mut found, "guard", "a `guard`", Class::Auto);
99  found
100}
101
102fn add(into: &mut Vec<Marker>, needle: &str, construct: &str, kind: Class) {
103  at(into, needle, construct, kind, Position::Anywhere);
104}
105
106fn at_end(into: &mut Vec<Marker>, needle: &str, construct: &str, kind: Class) {
107  at(into, needle, construct, kind, Position::AtEnd);
108}
109
110fn at_start(into: &mut Vec<Marker>, needle: &str, construct: &str, kind: Class) {
111  at(into, needle, construct, kind, Position::AtStart);
112}
113
114fn at(into: &mut Vec<Marker>, needle: &str, construct: &str, kind: Class, position: Position) {
115  into.push(Marker {
116    needle: needle.to_string(),
117    construct: construct.to_string(),
118    kind,
119    position,
120  });
121  ();
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Finding {
126  pub line: usize,
127  pub construct: String,
128  pub kind: Class,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Verdict {
133  pub kind: Class,
134  pub findings: Vec<Finding>,
135}
136
137impl Verdict {
138  pub fn of_class(&self, wanted: Class) -> Vec<&Finding> {
139    self.findings.iter().filter(|f| f.kind == wanted).collect()
140  }
141}
142
143pub fn classify(source: &str) -> Verdict {
144  let table = markers();
145  let mut findings = Vec::new();
146  let mut worst = Class::Auto;
147  for pair in source.lines().enumerate() {
148    let text = pair.1;
149    if is_comment(text) {
150      continue;
151    }
152    for marker in table.iter() {
153      if hits(text, marker) {
154        if marker.kind > worst {
155          worst = marker.kind;
156        }
157        findings.push(Finding {
158          line: pair.0 + 1,
159          construct: marker.construct.clone(),
160          kind: marker.kind,
161        });
162      }
163    }
164  }
165  Verdict {
166    kind: worst,
167    findings,
168  }
169}
170
171pub fn hits(text: &str, marker: &Marker) -> bool {
172  let needle = marker.needle.as_str();
173  match marker.position {
174    Position::AtEnd => text.trim_end().ends_with(needle) && mentions(text, needle),
175    Position::AtStart => opens_a_statement(text, needle),
176    Position::Anywhere => mentions(text, needle),
177  }
178}
179
180pub fn opens_a_statement(text: &str, raw: &str) -> bool {
181  let needle = raw.trim();
182  let body = text.trim_start();
183  if !body.starts_with(needle) {
184    return false;
185  }
186  let start = needle.len();
187  let end = body.len();
188  start >= end || !is_word_byte(&body[start..start + 1])
189}
190
191pub fn mentions(text: &str, raw: &str) -> bool {
192  if !is_word(raw) {
193    return text.contains(raw);
194  }
195  let needle = raw.trim();
196  for hit in text.match_indices(needle) {
197    let start = hit.0;
198    let end = start + needle.len();
199    let before_ok = start == 0 || !is_word_byte(&text[start - 1..start]);
200    let after_ok = end >= text.len() || !is_word_byte(&text[end..end + 1]);
201    if before_ok && after_ok {
202      return true;
203    }
204  }
205  false
206}
207
208fn is_word(needle: &str) -> bool {
209  needle
210    .trim()
211    .chars()
212    .all(|c| c == '_' || char::is_alphanumeric(c) || c == ' ')
213}
214
215fn is_word_byte(held: &str) -> bool {
216  held.chars().any(|c| c == '_' || char::is_alphanumeric(c))
217}
218
219fn is_comment(text: &str) -> bool {
220  text.trim_start().starts_with("--")
221}
222
223#[derive(Debug, Clone, Default)]
224pub struct Tally {
225  pub files: usize,
226  pub by_class: BTreeMap<Class, usize>,
227}
228
229impl Tally {
230  pub fn new() -> Self {
231    Tally {
232      files: 0,
233      by_class: BTreeMap::new(),
234    }
235  }
236
237  pub fn record(&mut self, kind: Class) {
238    self.files += 1;
239    let counter = self.by_class.entry(kind).or_insert(0);
240    *counter += 1;
241    ();
242  }
243
244  pub fn count(&self, kind: Class) -> usize {
245    self.by_class.get(&kind).copied().unwrap_or(0)
246  }
247
248  pub fn blocking(&self) -> usize {
249    self.count(Class::Manual)
250  }
251}
252
253#[cfg(test)]
254mod __vlt_tests {
255  use super::*;
256
257  #[test]
258  fn a_tail_needle_must_end_the_line_because_do_introduces_a_block() {
259    assert_eq!(classify("held = do\n").kind, Class::Manual);
260    assert_eq!(
261      classify("test \"two names do not conflict\"\n").kind,
262      Class::Auto,
263      "the English word is not the construct"
264    );
265    assert_eq!(
266      classify("    \"do\" -> Some Do\n").kind,
267      Class::Auto,
268      "a keyword table names `do` without using it"
269    );
270  }
271
272  #[test]
273  fn a_tail_needle_still_needs_its_word_boundary() {
274    assert_eq!(
275      classify("shown = tornado\n").kind,
276      Class::Auto,
277      "`tornado` ends in `do` and is not a block"
278    );
279  }
280
281  #[test]
282  fn a_word_needle_needs_a_word_boundary_on_both_sides() {
283    assert!(mentions("data Fields = Fields", "Fields"));
284    assert!(
285      !mentions("ConstructorFields::Record", "Fields"),
286      "a longer identifier is not the construct"
287    );
288    assert!(
289      !mentions("elsewhere = 1", "where"),
290      "`elsewhere` is not a `where` group"
291    );
292  }
293
294  #[test]
295  fn a_punctuation_needle_cannot_have_a_word_boundary_so_it_matches_as_a_substring() {
296    assert!(mentions("shown = value |> .to_string", "|>"));
297    assert!(mentions("held.await", ".await"));
298  }
299
300  #[test]
301  fn a_needle_at_the_very_start_or_end_of_a_line_still_matches() {
302    assert!(mentions("Fields", "Fields"));
303    assert!(mentions("newtype Meters = Meters f64", "newtype "));
304  }
305
306  #[test]
307  fn the_classifier_does_not_match_its_own_marker_table() {
308    let found = classify("  at_end (&mut found) \"do\" \"a block\" Class::Manual\n");
309    assert_eq!(
310      found.kind,
311      Class::Auto,
312      "a source file that describes source is the hardest case for text matching, and the tail anchor is what answers it"
313    );
314  }
315
316  #[test]
317  fn a_file_with_nothing_notable_is_auto() {
318    let found = classify("twice : u64 -> u64\ntwice n = n + n\n");
319    assert_eq!(found.kind, Class::Auto);
320  }
321
322  #[test]
323  fn a_pipeline_is_auto_because_the_rewrite_is_mechanical() {
324    let found = classify("shown = value |> .to_string\n");
325    assert_eq!(found.kind, Class::Auto);
326    assert_eq!(found.of_class(Class::Auto).len(), 1);
327  }
328
329  #[test]
330  fn a_nested_optional_is_manual_and_doc_86_5_6_is_why() {
331    let found = classify("held : Option<Option<u64>>\n");
332    assert_eq!(found.kind, Class::Manual);
333  }
334
335  #[test]
336  fn the_worst_class_in_a_file_is_the_file_s_class() {
337    let found = classify("shown = value |> .to_string\nheld : Option<Option<u64>>\n");
338    assert_eq!(
339      found.kind,
340      Class::Manual,
341      "one manual construct makes the file manual"
342    );
343    assert_eq!(
344      found.of_class(Class::Auto).len(),
345      1,
346      "the auto finding is still reported"
347    );
348  }
349
350  #[test]
351  fn review_sits_between_auto_and_manual() {
352    assert!(Class::Auto < Class::Review);
353    assert!(Class::Review < Class::Manual);
354  }
355
356  #[test]
357  fn a_construct_inside_a_comment_is_not_a_finding() {
358    let found = classify(
359      "-- this names |> and Option<Option<u64>> and neither counts\ntwice : u64 -> u64\ntwice n = n + n\n",
360    );
361    assert_eq!(found.kind, Class::Auto, "a comment is prose, not code");
362    assert!(found.findings.is_empty());
363  }
364
365  #[test]
366  fn a_finding_carries_its_line_counting_from_one() {
367    let found = classify("twice : u64 -> u64\ntwice n = n |> .count\n");
368    assert_eq!(found.findings[0].line, 2);
369  }
370
371  #[test]
372  fn a_tally_counts_files_per_class_and_names_the_blocking_number() {
373    let mut tally = Tally::new();
374    tally.record(Class::Auto);
375    tally.record(Class::Auto);
376    tally.record(Class::Manual);
377    assert_eq!(tally.files, 3);
378    assert_eq!(tally.count(Class::Auto), 2);
379    assert_eq!(tally.count(Class::Review), 0);
380    assert_eq!(
381      tally.blocking(),
382      1,
383      "doc 89 milestone 17 asks this to reach zero"
384    );
385  }
386
387  #[test]
388  fn every_marker_names_a_construct_so_a_report_can_be_read() {
389    for marker in markers().iter() {
390      assert!(!marker.needle.is_empty());
391      assert!(!marker.construct.is_empty());
392    }
393  }
394
395  #[test]
396  fn the_marker_table_covers_all_three_classes() {
397    let mut seen = Vec::new();
398    for marker in markers().iter() {
399      if !seen.contains(&marker.kind) {
400        seen.push(marker.kind);
401      }
402    }
403    assert_eq!(
404      seen.len(),
405      3,
406      "a table missing a class would report that class as absent"
407    );
408  }
409}