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}