thd75_repl/
lint.rs

1//! Accessibility lint checker for REPL output.
2//!
3//! Implements fourteen hard rules (R1 through R14) that together
4//! establish conformance to nine published accessibility standards:
5//! WCAG 2.1 Level AA, Section 508 of the US Rehabilitation Act, the
6//! CHI 2021 CLI accessibility paper, EN 301 549 version 3.2.1,
7//! ISO 9241-171, ITU-T Recommendation F.790, BRLTTY compatibility,
8//! the Handi-Ham Program recommendations, and the ARRL accessibility
9//! resources. Each `Rule` variant carries its own doc comment
10//! describing the specific constraint.
11
12use std::fmt;
13
14/// An identifier for one of the 14 hard accessibility rules. Each
15/// variant carries its own doc comment describing the specific
16/// constraint the rule enforces.
17#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Rule {
19    /// R1: Every byte is ASCII printable (0x20–0x7E) or `\n`.
20    AsciiOnly,
21    /// R2: Every line is ≤ 80 characters.
22    LineLength,
23    /// R3: No ANSI escape sequences.
24    NoAnsi,
25    /// R4: Error lines begin with `Error:`.
26    ErrorPrefix,
27    /// R5: Warning lines begin with `Warning:`.
28    WarningPrefix,
29    /// R6: Mutating commands confirm success. (Covered by per-command unit tests; not a line-level rule.)
30    ConfirmOnSet,
31    /// R7: Lists end with a count summary. (Covered by per-command unit tests.)
32    ListCountSummary,
33    /// R8: Labels end with a colon before their values.
34    LabelColonValue,
35    /// R9: Booleans render as `on`/`off`, never `true`/`false` or `1`/`0`.
36    BooleanWords,
37    /// R10: No `print!` without a trailing newline. (Static grep rule.)
38    NoNakedPrint,
39    /// R11: No cursor-move sequences or spinners. (Static grep rule.)
40    NoCursorMoves,
41    /// R12: Numeric units spelled out (megahertz, watts, etc.).
42    UnitsSpelledOut,
43    /// R13: No ad-hoc `[HH:MM:SS]` timestamps. (Static grep rule.)
44    NoAdHocTimestamps,
45    /// R14: User output to stdout, diagnostics to stderr. (Static grep rule.)
46    StdoutStderrSeparation,
47}
48
49impl Rule {
50    /// Short identifier used in error messages and the compliance
51    /// report: `"R1"`, `"R2"`, etc.
52    #[must_use]
53    pub const fn id(self) -> &'static str {
54        match self {
55            Self::AsciiOnly => "R1",
56            Self::LineLength => "R2",
57            Self::NoAnsi => "R3",
58            Self::ErrorPrefix => "R4",
59            Self::WarningPrefix => "R5",
60            Self::ConfirmOnSet => "R6",
61            Self::ListCountSummary => "R7",
62            Self::LabelColonValue => "R8",
63            Self::BooleanWords => "R9",
64            Self::NoNakedPrint => "R10",
65            Self::NoCursorMoves => "R11",
66            Self::UnitsSpelledOut => "R12",
67            Self::NoAdHocTimestamps => "R13",
68            Self::StdoutStderrSeparation => "R14",
69        }
70    }
71
72    /// Human-readable summary of the rule, suitable for the report
73    /// printed by the `check` subcommand.
74    #[must_use]
75    pub const fn description(self) -> &'static str {
76        match self {
77            Self::AsciiOnly => "ASCII only",
78            Self::LineLength => "line under 80 chars",
79            Self::NoAnsi => "no ANSI escapes",
80            Self::ErrorPrefix => "error prefix",
81            Self::WarningPrefix => "warning prefix",
82            Self::ConfirmOnSet => "confirm after set",
83            Self::ListCountSummary => "list count summary",
84            Self::LabelColonValue => "label colon value",
85            Self::BooleanWords => "booleans as words",
86            Self::NoNakedPrint => "no naked print",
87            Self::NoCursorMoves => "no cursor or spinners",
88            Self::UnitsSpelledOut => "units spelled out",
89            Self::NoAdHocTimestamps => "timestamps only via aprintln",
90            Self::StdoutStderrSeparation => "stdout and stderr separated",
91        }
92    }
93}
94
95/// One accessibility rule violation discovered during linting.
96///
97/// Contains the rule that was violated, the 0-based line number in
98/// the scanned input where the violation occurred, and a short
99/// human-readable explanation of what was wrong.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct Violation {
102    /// Which rule was violated.
103    pub rule: Rule,
104    /// 0-based line number within the scanned input.
105    pub line: usize,
106    /// Human-readable explanation of the violation.
107    pub message: String,
108}
109
110impl fmt::Display for Violation {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "{} {}: line {}: {}",
115            self.rule.id(),
116            self.rule.description(),
117            self.line,
118            self.message,
119        )
120    }
121}
122
123/// Shorthand unit tokens rejected by R12. Longer units (`MHz`,
124/// `kHz`, `GHz`) are listed before the short `Hz` so the per-unit
125/// `break` inside [`check_r12_units`] takes the longer match first.
126const BAD_UNITS: &[&str] = &["MHz", "kHz", "GHz", "Hz", "mW", "kW", "dB"];
127
128/// R3: reject any ANSI control sequence, identified by ESC (0x1B).
129/// Returns `true` if an ESC byte was found and a violation pushed.
130fn check_r3_no_ansi(line: &str, violations: &mut Vec<Violation>) -> bool {
131    line.as_bytes()
132        .iter()
133        .position(|&b| b == 0x1B)
134        .is_some_and(|offset| {
135            violations.push(Violation {
136                rule: Rule::NoAnsi,
137                line: 0,
138                message: format!("ESC byte at offset {offset}"),
139            });
140            true
141        })
142}
143
144/// R1: reject any byte outside printable ASCII (0x20-0x7E).
145fn check_r1_ascii(line: &str, violations: &mut Vec<Violation>) {
146    for (offset, byte) in line.as_bytes().iter().enumerate() {
147        if !(0x20..=0x7E).contains(byte) {
148            violations.push(Violation {
149                rule: Rule::AsciiOnly,
150                line: 0,
151                message: format!("byte 0x{byte:02X} at offset {offset}"),
152            });
153            // One R1 violation per line is enough to signal the problem;
154            // reporting every non-ASCII byte on a line with many would
155            // drown the output.
156            break;
157        }
158    }
159}
160
161/// R2: reject lines longer than 80 characters.
162fn check_r2_line_length(line: &str, violations: &mut Vec<Violation>) {
163    let char_count = line.chars().count();
164    if char_count > 80 {
165        violations.push(Violation {
166            rule: Rule::LineLength,
167            line: 0,
168            message: format!("{char_count} characters (max 80)"),
169        });
170    }
171}
172
173/// R4: lines whose first word looks error-like must start with
174/// `Error: `. Heuristic: case-insensitive first word equals `error`,
175/// `error:`, or starts with `error` and is at most 7 characters.
176fn check_r4_error_prefix(line: &str, violations: &mut Vec<Violation>) {
177    if let Some(first_word) = line.split_whitespace().next() {
178        let lower = first_word.to_lowercase();
179        let looks_like_error = lower == "error"
180            || lower == "error:"
181            || (lower.starts_with("error") && lower.len() <= 7);
182        if looks_like_error && !line.starts_with("Error: ") {
183            violations.push(Violation {
184                rule: Rule::ErrorPrefix,
185                line: 0,
186                message: format!(
187                    "line looks like an error report but does not start with \"Error: \" (first word: {first_word:?})"
188                ),
189            });
190        }
191    }
192}
193
194/// R5: same shape as R4, for warning lines. First word looking like
195/// `warning` must start with `Warning: `.
196fn check_r5_warning_prefix(line: &str, violations: &mut Vec<Violation>) {
197    if let Some(first_word) = line.split_whitespace().next() {
198        let lower = first_word.to_lowercase();
199        let looks_like_warning = lower == "warning"
200            || lower == "warning:"
201            || (lower.starts_with("warning") && lower.len() <= 9);
202        if looks_like_warning && !line.starts_with("Warning: ") {
203            violations.push(Violation {
204                rule: Rule::WarningPrefix,
205                line: 0,
206                message: format!(
207                    "line looks like a warning but does not start with \"Warning: \" (first word: {first_word:?})"
208                ),
209            });
210        }
211    }
212}
213
214/// R9: reject `: true` and `: false` as standalone tokens. This is
215/// the common `{bool}` format mistake.
216fn check_r9_boolean_words(line: &str, violations: &mut Vec<Violation>) {
217    for bad in [": true", ": false"] {
218        if let Some(offset) = line.find(bad) {
219            let next = line.as_bytes().get(offset + bad.len());
220            let is_standalone = next.is_none_or(|&b| !b.is_ascii_alphanumeric());
221            if is_standalone {
222                violations.push(Violation {
223                    rule: Rule::BooleanWords,
224                    line: 0,
225                    message: format!(
226                        "boolean literal {bad:?} at offset {offset} - use on/off instead"
227                    ),
228                });
229            }
230        }
231    }
232}
233
234/// R12: reject shorthand unit tokens (MHz, Hz, dB, ...) when they
235/// appear as standalone tokens bounded by non-alphanumerics. `Hz` as
236/// a substring of `hertz` is not flagged because the neighbouring
237/// characters are alphabetic.
238fn check_r12_units(line: &str, violations: &mut Vec<Violation>) {
239    let bytes = line.as_bytes();
240    for unit in BAD_UNITS {
241        let mut search_start = 0;
242        while let Some(rel_offset) = line[search_start..].find(unit) {
243            let offset = search_start + rel_offset;
244            search_start = offset + unit.len();
245
246            let prev = if offset == 0 { b' ' } else { bytes[offset - 1] };
247            let next = bytes.get(offset + unit.len()).copied().unwrap_or(b' ');
248
249            let prev_ok = !prev.is_ascii_alphanumeric();
250            let next_ok = !next.is_ascii_alphanumeric();
251
252            if prev_ok && next_ok {
253                violations.push(Violation {
254                    rule: Rule::UnitsSpelledOut,
255                    line: 0,
256                    message: format!("shorthand unit {unit:?} at offset {offset} - spell it out"),
257                });
258                break;
259            }
260        }
261    }
262}
263
264/// R8: lines starting with `Band A ` or `Band B ` must contain `: `.
265///
266/// The rule is narrowed so natural verb-phrase sentences (e.g.
267/// `Band A tuned to 146.52 megahertz`, `Band A mode set to FM`,
268/// `Band A recalled channel 5`) are not flagged. R8 only fires on
269/// lines that look like `Band X <noun> <value>` - the label-shape
270/// pattern - without any intervening verb.
271fn check_r8_label_colon(line: &str, violations: &mut Vec<Violation>) {
272    /// Past-tense verbs that can appear directly after `Band X `.
273    /// `Band A tuned to X`, `Band A stepped up to X`, `Band A recalled
274    /// channel N` - these are sentences, not labels.
275    const DIRECT_VERBS: &[&str] = &["tuned", "stepped", "recalled"];
276    /// Phrases that follow a label noun to form a verb phrase:
277    /// `Band A mode set to FM`, `Band A squelch set to 5`. If the
278    /// substring after the prefix contains " set to " we treat the
279    /// whole line as a verb phrase.
280    const VERB_PHRASES: &[&str] = &[" set to "];
281    for prefix in ["Band A ", "Band B "] {
282        if !line.starts_with(prefix) || line.contains(": ") {
283            continue;
284        }
285        let rest = &line[prefix.len()..];
286        // Check for direct past-tense verbs (`tuned`, `stepped`).
287        let first = rest.split_whitespace().next().unwrap_or("");
288        if DIRECT_VERBS.contains(&first) {
289            continue;
290        }
291        // Check for `... set to ...` verb phrases.
292        if VERB_PHRASES.iter().any(|p| rest.contains(p)) {
293            continue;
294        }
295        violations.push(Violation {
296            rule: Rule::LabelColonValue,
297            line: 0,
298            message: format!("line starts with {prefix:?} but has no colon separator"),
299        });
300        break;
301    }
302}
303
304/// Check a single line against all applicable line-level rules.
305///
306/// Static rules (R10, R11, R13, R14) are structural and cannot be
307/// checked from a single line; they are enforced by a static grep
308/// test that scans `src/**/*.rs`.
309///
310/// # Errors
311///
312/// Returns a `Vec` of all violations found on the line. A fully-
313/// conformant line returns `Ok(())`.
314pub fn check_line(line: &str) -> Result<(), Vec<Violation>> {
315    let mut violations = Vec::new();
316
317    // R3 must run first and short-circuit: ESC would otherwise also
318    // trip R1, and the duplicate pair would be confusing.
319    if check_r3_no_ansi(line, &mut violations) {
320        return Err(violations);
321    }
322
323    check_r1_ascii(line, &mut violations);
324    check_r2_line_length(line, &mut violations);
325    check_r4_error_prefix(line, &mut violations);
326    check_r5_warning_prefix(line, &mut violations);
327    check_r9_boolean_words(line, &mut violations);
328    check_r12_units(line, &mut violations);
329    check_r8_label_colon(line, &mut violations);
330
331    if violations.is_empty() {
332        Ok(())
333    } else {
334        Err(violations)
335    }
336}
337
338/// Check multi-line output against all applicable rules.
339///
340/// Splits on `\n` and runs [`check_line`] on each line. Also runs
341/// cross-line rules that need the full output (e.g. error-prefix
342/// classification requires looking at the line as a whole).
343///
344/// # Errors
345///
346/// Returns a `Vec` of all violations found across all lines. A
347/// fully-conformant output returns `Ok(())`.
348pub fn check_output(output: &str) -> Result<(), Vec<Violation>> {
349    let mut violations = Vec::new();
350    // Strip a single trailing `\n` so we don't see the phantom empty
351    // string that `split` produces for newline-terminated inputs.
352    // Intentional blank lines in the middle of the output are still
353    // checked normally.
354    let trimmed = output.strip_suffix('\n').unwrap_or(output);
355    for (line_no, line) in trimmed.split('\n').enumerate() {
356        if let Err(mut v) = check_line(line) {
357            for violation in &mut v {
358                violation.line = line_no;
359            }
360            violations.append(&mut v);
361        }
362    }
363    if violations.is_empty() {
364        Ok(())
365    } else {
366        Err(violations)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn stub_check_line_accepts_empty() {
376        assert!(check_line("").is_ok());
377    }
378
379    #[test]
380    fn stub_check_output_accepts_empty() {
381        assert!(check_output("").is_ok());
382    }
383
384    #[test]
385    fn check_output_processes_intentional_blank_lines() {
386        // A blank line in the middle of the output should be visible
387        // to check_line (which currently accepts it because the stub
388        // always returns Ok, but once rule logic lands this test
389        // becomes meaningful — e.g. R1 must still run on the blank
390        // line to confirm it has no control characters).
391        //
392        // This test locks in the fix for the bug where
393        // `if line.is_empty() && line_no > 0 { continue; }` was
394        // silently skipping mid-output blank lines.
395        let result = check_output("first line\n\nthird line\n");
396        assert!(result.is_ok(), "check_output should process blank lines");
397    }
398
399    #[test]
400    fn check_output_strips_trailing_newline_only_once() {
401        // Double trailing newline means there IS an intentional blank
402        // as the final line. It should be checked, not silently dropped.
403        // With `split('\n')` on `"a\n\n"` after stripping one `\n`, we
404        // get ["a", ""] — two lines, both processed.
405        let result = check_output("a\n\n");
406        assert!(result.is_ok());
407    }
408
409    #[test]
410    fn rule_ids_are_r1_through_r14() {
411        assert_eq!(Rule::AsciiOnly.id(), "R1");
412        assert_eq!(Rule::LineLength.id(), "R2");
413        assert_eq!(Rule::NoAnsi.id(), "R3");
414        assert_eq!(Rule::ErrorPrefix.id(), "R4");
415        assert_eq!(Rule::WarningPrefix.id(), "R5");
416        assert_eq!(Rule::ConfirmOnSet.id(), "R6");
417        assert_eq!(Rule::ListCountSummary.id(), "R7");
418        assert_eq!(Rule::LabelColonValue.id(), "R8");
419        assert_eq!(Rule::BooleanWords.id(), "R9");
420        assert_eq!(Rule::NoNakedPrint.id(), "R10");
421        assert_eq!(Rule::NoCursorMoves.id(), "R11");
422        assert_eq!(Rule::UnitsSpelledOut.id(), "R12");
423        assert_eq!(Rule::NoAdHocTimestamps.id(), "R13");
424        assert_eq!(Rule::StdoutStderrSeparation.id(), "R14");
425    }
426
427    #[test]
428    fn violation_display_includes_rule_id_and_line() {
429        let v = Violation {
430            rule: Rule::AsciiOnly,
431            line: 3,
432            message: "byte 0xE2 at offset 14".to_string(),
433        };
434        let rendered = v.to_string();
435        assert!(rendered.contains("R1"));
436        assert!(rendered.contains("ASCII only"));
437        assert!(rendered.contains("line 3"));
438        assert!(rendered.contains("byte 0xE2 at offset 14"));
439    }
440
441    #[test]
442    fn r1_accepts_plain_ascii() {
443        assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
444        assert!(check_line("Error: invalid frequency").is_ok());
445        assert!(check_line("").is_ok());
446    }
447
448    #[test]
449    fn r1_rejects_unicode_right_arrow() {
450        assert!(check_line("Forwarding RF \u{2192} internet").is_err());
451    }
452
453    #[test]
454    fn r1_rejects_em_dash() {
455        assert!(check_line("stopped \u{2014} good").is_err());
456    }
457
458    #[test]
459    fn r1_rejects_left_right_arrow() {
460        assert!(check_line("RF \u{2194} internet").is_err());
461    }
462
463    #[test]
464    fn r1_rejects_tab() {
465        assert!(check_line("Band\tA").is_err());
466    }
467
468    #[test]
469    fn r1_rejects_carriage_return() {
470        assert!(check_line("partial\rline").is_err());
471    }
472
473    #[test]
474    fn r1_rejects_null_byte() {
475        assert!(check_line("foo\0bar").is_err());
476    }
477
478    #[test]
479    fn r1_violation_includes_byte_offset() {
480        let err = check_line("hi \u{2192} there").unwrap_err();
481        let v = err.iter().find(|v| v.rule == Rule::AsciiOnly).unwrap();
482        assert!(v.message.contains("offset 3"));
483    }
484
485    #[test]
486    fn r2_accepts_80_char_line() {
487        let s = "a".repeat(80);
488        assert!(check_line(&s).is_ok());
489    }
490
491    #[test]
492    fn r2_rejects_81_char_line() {
493        let s = "a".repeat(81);
494        let err = check_line(&s).unwrap_err();
495        assert!(err.iter().any(|v| v.rule == Rule::LineLength));
496    }
497
498    #[test]
499    fn r2_violation_reports_actual_length() {
500        let s = "a".repeat(83);
501        let err = check_line(&s).unwrap_err();
502        let v = err.iter().find(|v| v.rule == Rule::LineLength).unwrap();
503        assert!(v.message.contains("83"));
504    }
505
506    #[test]
507    fn r3_rejects_ansi_color() {
508        let err = check_line("\x1b[31mError\x1b[0m").unwrap_err();
509        assert!(err.iter().any(|v| v.rule == Rule::NoAnsi));
510    }
511
512    #[test]
513    fn r3_rejects_cursor_move() {
514        let err = check_line("\x1b[2J").unwrap_err();
515        assert!(err.iter().any(|v| v.rule == Rule::NoAnsi));
516    }
517
518    #[test]
519    fn r3_accepts_plain_text() {
520        assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
521    }
522
523    #[test]
524    fn r4_accepts_proper_error_prefix() {
525        assert!(check_line("Error: invalid frequency").is_ok());
526        assert!(check_line("Error: something went wrong").is_ok());
527    }
528
529    #[test]
530    fn r4_accepts_line_without_error_word() {
531        assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
532    }
533
534    #[test]
535    fn r4_rejects_missing_prefix_on_error_line() {
536        let err = check_line("error: bad thing happened").unwrap_err();
537        assert!(err.iter().any(|v| v.rule == Rule::ErrorPrefix));
538    }
539
540    #[test]
541    fn r4_rejects_uppercase_no_colon() {
542        let err = check_line("Error invalid frequency").unwrap_err();
543        assert!(err.iter().any(|v| v.rule == Rule::ErrorPrefix));
544    }
545
546    #[test]
547    fn r4_accepts_word_errors_in_declarative_line() {
548        assert!(check_line("Total errors found: 0").is_ok());
549    }
550
551    #[test]
552    fn r5_accepts_proper_warning_prefix() {
553        assert!(check_line("Warning: authentication failed").is_ok());
554    }
555
556    #[test]
557    fn r5_rejects_lowercase_warning() {
558        let err = check_line("warning: auth failed").unwrap_err();
559        assert!(err.iter().any(|v| v.rule == Rule::WarningPrefix));
560    }
561
562    #[test]
563    fn r5_rejects_missing_colon() {
564        let err = check_line("Warning auth failed").unwrap_err();
565        assert!(err.iter().any(|v| v.rule == Rule::WarningPrefix));
566    }
567
568    #[test]
569    fn r9_accepts_on_off() {
570        assert!(check_line("Key lock: on").is_ok());
571        assert!(check_line("Bluetooth: off").is_ok());
572    }
573
574    #[test]
575    fn r9_rejects_true() {
576        let err = check_line("Key lock: true").unwrap_err();
577        assert!(err.iter().any(|v| v.rule == Rule::BooleanWords));
578    }
579
580    #[test]
581    fn r9_rejects_false() {
582        let err = check_line("Bluetooth: false").unwrap_err();
583        assert!(err.iter().any(|v| v.rule == Rule::BooleanWords));
584    }
585
586    #[test]
587    fn r9_accepts_truth_in_word() {
588        assert!(check_line("The truth is simple").is_ok());
589    }
590
591    #[test]
592    fn r12_accepts_spelled_units() {
593        assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
594        assert!(check_line("Band A power: high, 5 watts").is_ok());
595    }
596
597    #[test]
598    fn r12_rejects_mhz_shorthand() {
599        let err = check_line("146.52 MHz").unwrap_err();
600        assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
601    }
602
603    #[test]
604    fn r12_rejects_khz_shorthand() {
605        let err = check_line("step 25 kHz").unwrap_err();
606        assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
607    }
608
609    #[test]
610    fn r12_rejects_db_shorthand() {
611        let err = check_line("signal -110 dB").unwrap_err();
612        assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
613    }
614
615    #[test]
616    fn r12_accepts_hz_inside_word() {
617        assert!(check_line("Frequency in hertz").is_ok());
618    }
619
620    #[test]
621    fn r8_accepts_label_colon_value() {
622        assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
623        assert!(check_line("Radio model: TH-D75").is_ok());
624    }
625
626    #[test]
627    fn r8_accepts_imperative_sentence() {
628        assert!(check_line("Reading radio status, please wait.").is_ok());
629    }
630
631    #[test]
632    fn r8_rejects_labeled_line_without_colon() {
633        let err = check_line("Band A frequency 146.52 megahertz").unwrap_err();
634        assert!(err.iter().any(|v| v.rule == Rule::LabelColonValue));
635    }
636}