ebman 0.40.0

k9s-style TUI for AWS Elastic Beanstalk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! ARCHITECTURE rule 4: a guarded `KeyCode::Char(c) if <modifier>` arm must
//! come BEFORE the unguarded arm for the same character.
//!
//! Rust tries arms top to bottom, so an unguarded `KeyCode::Char('d')` placed
//! first swallows `Ctrl-D` — the guarded arm below it never runs and the chord
//! silently does the unmodified thing. The compiler stays quiet: both arms are
//! reachable *patterns*, and it is only the guard that makes one a subset of
//! the other.
//!
//! This was the one rule in `ARCHITECTURE.md` with nothing behind it, and it
//! has bitten. Neither existing mechanism can express it:
//!
//! - **A source scan can't**, because judging arm order means knowing which
//!   `match` an arm belongs to, and a line-level scan cannot tell. A naive
//!   attempt at exactly this reported four violations in `input.rs`, all
//!   false — it compared arms sitting in different `match` blocks, and
//!   attributed two of them to a function neither was in.
//! - **Mutation testing can't**, because `cargo-mutants` deletes bodies and
//!   flips operators. It does not permute match arms.
//!
//! So this one parses. `syn` hands back `Expr::Match` → `Arm { pat, guard }`,
//! which is precisely the question the rule asks. Matching on the AST rather
//! than on rendered text is the point: this file asks "is this pattern a
//! `KeyCode::Char` tuple-struct whose one field is a char literal", not "does
//! this line contain some characters".

use syn::visit::Visit;

/// One `KeyCode::Char(..)` arm: which char, whether it carries a modifier
/// guard, and where it sits within its own `match`.
#[derive(Debug, Clone)]
struct CharArm {
    ch: char,
    guarded: bool,
    index: usize,
    line: usize,
}

/// An unguarded arm for `ch` preceding a guarded one in the same `match`.
#[derive(Debug, Clone)]
struct Shadowed {
    ch: char,
    unguarded_line: usize,
    guarded_line: usize,
}

/// Idents a guard expression mentions, so a *modifier* guard can be told from
/// any other guard.
///
/// The distinction matters both ways. `if key.modifiers.contains(CONTROL)` is
/// the rule-4 shape. `if *cursor > 0` is not, and an arm guarded on that
/// legitimately precedes the unguarded arm for the same char — calling it a
/// violation is how a blunt detector invents work.
#[derive(Default)]
struct IdentCollector {
    idents: Vec<String>,
}

impl<'ast> Visit<'ast> for IdentCollector {
    fn visit_ident(&mut self, i: &'ast proc_macro2::Ident) {
        self.idents.push(i.to_string());
    }
}

fn is_modifier_guard(guard: &syn::Expr) -> bool {
    let mut c = IdentCollector::default();
    c.visit_expr(guard);
    c.idents.iter().any(|i| {
        matches!(
            i.as_str(),
            "modifiers" | "KeyModifiers" | "CONTROL" | "SHIFT" | "ALT" | "SUPER"
        )
    })
}

/// Every char literal reached through a `KeyCode::Char(..)` pattern, following
/// the pattern nesting the keymap actually uses: `|` alternations, tuple
/// patterns like `(KeyCode::Char('y'), Mode::Detail)`, parens and references.
/// The guard expression on a match arm.
///
/// `syn` 3 removed `Arm::guard` and represents a guarded arm as
/// `Pat::Guard { pat, guard }` — the guard moved from the arm to the
/// pattern. Extracted into a named function because the same structural
/// change has to be applied in two places, and missing the second one
/// leaves this rule compiling and checking nothing.
fn arm_guard(arm: &syn::Arm) -> Option<&syn::Expr> {
    match &arm.pat {
        syn::Pat::Guard(g) => Some(&g.guard),
        _ => None,
    }
}

/// The pattern with any guard wrapper removed.
///
/// Under `syn` 3 a guarded arm's `pat` IS the `Pat::Guard` wrapper, so
/// a walker looking for `KeyCode::Char(..)` finds nothing inside it.
/// This rule's whole job is ordering guarded Ctrl arms against
/// unguarded ones for the same character, so failing to unwrap would
/// make it see no guarded arms at all.
fn unguarded_pat(pat: &syn::Pat) -> &syn::Pat {
    match pat {
        syn::Pat::Guard(g) => &g.pat,
        other => other,
    }
}

fn chars_in_pattern(pat: &syn::Pat, out: &mut Vec<char>) {
    match pat {
        syn::Pat::TupleStruct(ts) => {
            // `KeyCode::Char(..)`, or a bare `Char(..)` under a glob
            // import. The qualifier check has to look at the segment
            // immediately before `Char`: written as "is KeyCode anywhere in
            // the path" it is satisfied by the `Char` segment itself and so
            // excludes nothing, which would admit some unrelated enum's
            // `Char(char)` variant into the rule.
            let segs: Vec<String> = ts
                .path
                .segments
                .iter()
                .map(|s| s.ident.to_string())
                .collect();
            let is_char_ctor = segs.last().is_some_and(|s| s == "Char")
                && segs.iter().rev().nth(1).is_none_or(|q| q == "KeyCode");
            if is_char_ctor {
                for elem in &ts.elems {
                    if let syn::Pat::Lit(lit) = elem {
                        if let syn::Lit::Char(c) = &lit.lit {
                            out.push(c.value());
                        }
                    }
                }
            }
            // A `KeyCode::Char` never nests another, but other tuple structs
            // can wrap one — keep descending either way.
            for elem in &ts.elems {
                chars_in_pattern(elem, out);
            }
        }
        syn::Pat::Or(or) => {
            for case in &or.cases {
                chars_in_pattern(case, out);
            }
        }
        syn::Pat::Tuple(t) => {
            for elem in &t.elems {
                chars_in_pattern(elem, out);
            }
        }
        syn::Pat::Slice(s) => {
            for elem in &s.elems {
                chars_in_pattern(elem, out);
            }
        }
        syn::Pat::Struct(s) => {
            for field in &s.fields {
                chars_in_pattern(&field.pat, out);
            }
        }
        syn::Pat::Paren(p) => chars_in_pattern(&p.pat, out),
        syn::Pat::Reference(r) => chars_in_pattern(&r.pat, out),
        syn::Pat::Type(t) => chars_in_pattern(&t.pat, out),
        syn::Pat::Ident(i) => {
            if let Some((_, sub)) = &i.subpat {
                chars_in_pattern(sub, out);
            }
        }
        _ => {}
    }
}

#[derive(Default)]
struct MatchVisitor {
    violations: Vec<Shadowed>,
    /// Chars seen in both forms *somewhere*, used to prove the guard is
    /// actually looking at something.
    both_forms: Vec<char>,
}

impl<'ast> Visit<'ast> for MatchVisitor {
    fn visit_expr_match(&mut self, m: &'ast syn::ExprMatch) {
        use syn::spanned::Spanned as _;

        let mut arms: Vec<CharArm> = Vec::new();
        for (index, arm) in m.arms.iter().enumerate() {
            // `syn` 3 removed `Arm::guard`: a guarded arm is now a
            // `Pat::Guard { pat, guard }`, following Rust's
            // guard-patterns RFC. The guard moved from the ARM to the
            // PATTERN, so both reads below had to move with it.
            let guarded = arm_guard(arm).is_some_and(is_modifier_guard);
            let line = arm.pat.span().start().line;
            let mut chars = Vec::new();
            // Unwrap the guard wrapper before walking for characters.
            // Without this a guarded arm contributes NO chars, so the
            // rule silently stops seeing exactly the arms it exists to
            // order — a guard that compiles, runs, and checks nothing.
            chars_in_pattern(unguarded_pat(&arm.pat), &mut chars);
            for ch in chars {
                arms.push(CharArm {
                    ch,
                    guarded,
                    index,
                    line,
                });
            }
        }

        // Within THIS match only: does an unguarded arm precede a guarded one
        // for the same char?
        for a in arms.iter().filter(|a| a.guarded) {
            if let Some(earlier) = arms
                .iter()
                .find(|b| b.ch == a.ch && !b.guarded && b.index < a.index)
            {
                self.violations.push(Shadowed {
                    ch: a.ch,
                    unguarded_line: earlier.line,
                    guarded_line: a.line,
                });
            }
            if arms.iter().any(|b| b.ch == a.ch && !b.guarded) && !self.both_forms.contains(&a.ch) {
                self.both_forms.push(a.ch);
            }
        }

        syn::visit::visit_expr_match(self, m);
    }
}

/// Rule-4 violations in one file's source, plus the chars that appear in both
/// guarded and unguarded form (the surface the rule has to police).
fn shadowed_key_arms(src: &str) -> (Vec<Shadowed>, Vec<char>) {
    let file = match syn::parse_file(src) {
        Ok(f) => f,
        // A parse failure must not read as "no violations" — that is the
        // vacuous pass this codebase has shipped before.
        Err(e) => panic!("could not parse source for the key-arm guard: {e}"),
    };
    let mut v = MatchVisitor::default();
    v.visit_file(&file);
    (v.violations, v.both_forms)
}

/// The guard has to FIND a planted violation. Without this it could return an
/// empty vec forever and read as a clean tree.
#[test]
fn an_unguarded_arm_shadowing_a_guarded_one_is_found() {
    let src = r#"
        fn f(key: KeyEvent) {
            match key.code {
                KeyCode::Char('d') => self.detail(),
                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => self.dlq(),
                _ => {}
            }
        }
    "#;
    let (v, _) = shadowed_key_arms(src);
    assert_eq!(v.len(), 1, "the shadowed Ctrl-D arm must be found: {v:?}");
    assert_eq!(v[0].ch, 'd');
    assert!(
        v[0].unguarded_line < v[0].guarded_line,
        "the report names the offending order: {v:?}"
    );
}

#[test]
fn the_correct_order_is_clean() {
    let src = r#"
        fn f(key: KeyEvent) {
            match key.code {
                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => self.dlq(),
                KeyCode::Char('d') => self.detail(),
                _ => {}
            }
        }
    "#;
    assert!(shadowed_key_arms(src).0.is_empty());
}

/// The false-positive shape that defeated the line-level attempt: the same
/// char in two DIFFERENT matches, unguarded first in one of them.
#[test]
fn arms_in_different_matches_do_not_shadow_each_other() {
    let src = r#"
        fn f(key: KeyEvent) {
            match a {
                KeyCode::Char('k') => self.up(),
                _ => {}
            }
            match b {
                KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => self.top(),
                _ => {}
            }
        }
    "#;
    assert!(
        shadowed_key_arms(src).0.is_empty(),
        "arms in separate matches are independent — this is exactly what a \
         line-level scan gets wrong"
    );
}

/// A non-modifier guard is not a rule-4 guard.
#[test]
fn a_non_modifier_guard_is_not_treated_as_one() {
    let src = r#"
        fn f() {
            match key.code {
                KeyCode::Char('k') if *cursor > 0 => self.up(),
                KeyCode::Char('k') => self.wrap(),
                _ => {}
            }
        }
    "#;
    assert!(shadowed_key_arms(src).0.is_empty());
}

/// The keymap nests: `(KeyCode::Char('y'), Mode::Detail)` and `'a' | 'b'`.
#[test]
fn chars_are_found_through_tuples_and_alternations() {
    let src = r#"
        fn f() {
            match (key.code, mode) {
                (KeyCode::Char('y') | KeyCode::Char('Y'), Mode::Detail) => self.yank(),
                (KeyCode::Char('y'), _) if key.modifiers.contains(KeyModifiers::CONTROL) => self.other(),
                _ => {}
            }
        }
    "#;
    let (v, both) = shadowed_key_arms(src);
    assert_eq!(v.len(), 1, "the tuple-nested 'y' pair must be seen: {v:?}");
    assert_eq!(both, vec!['y']);
}

/// Some other enum's `Char(char)` variant is not a key. Before the qualifier
/// check looked at the segment immediately before `Char`, it asked whether
/// `KeyCode` appeared anywhere in the path — which the `Char` segment itself
/// satisfies, so the check excluded nothing.
#[test]
fn an_unrelated_char_variant_is_not_a_key_arm() {
    let src = r#"
        fn f() {
            match token {
                Token::Char('d') => self.a(),
                Token::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => self.b(),
                _ => {}
            }
        }
    "#;
    let (v, both) = shadowed_key_arms(src);
    assert!(v.is_empty(), "Token::Char is not KeyCode::Char: {v:?}");
    assert!(both.is_empty(), "and it is not part of the policed surface");
}

/// A file that will not parse must be loud, not "clean".
#[test]
#[should_panic(expected = "could not parse source")]
fn unparseable_source_is_loud() {
    shadowed_key_arms("fn f( {");
}

/// The rule itself, against the real keymap.
#[test]
fn the_keymap_puts_guarded_key_arms_first() {
    let src =
        std::fs::read_to_string("src/app/input.rs").expect("the keymap lives at src/app/input.rs");
    let (violations, both_forms) = shadowed_key_arms(&src);

    assert!(
        violations.is_empty(),
        "ARCHITECTURE rule 4: an unguarded KeyCode::Char arm precedes the \
         guarded arm for the same character, so the chord is unreachable. \
         Move the guarded arm above it. {}",
        violations
            .iter()
            .map(|s| format!(
                "'{}' unguarded at input.rs:{} shadows the guard at :{}",
                s.ch, s.unguarded_line, s.guarded_line
            ))
            .collect::<Vec<_>>()
            .join("; ")
    );

    // Non-vacuous: if the keymap ever stops having chars in both forms, this
    // test is passing on an empty set and should be re-pointed, not deleted.
    assert!(
        both_forms.len() >= 5,
        "expected the keymap to still have several chars in both guarded and \
         unguarded form for this rule to police; found {both_forms:?}"
    );
}

/// Rule 4 is not confined to `input.rs` — any module that matches on
/// `KeyCode::Char` is subject to it. Sweep the tree so a new keymap surface
/// inherits the guard instead of needing to remember it.
#[test]
fn every_source_file_puts_guarded_key_arms_first() {
    let mut offenders: Vec<String> = Vec::new();
    // 20 files match today. A floor of 2 would still have passed on a
    // walk that had collapsed to almost nothing.
    let mut checked = 0usize;
    for (path, src) in super::scan::source_files() {
        if !src.contains("KeyCode::Char") {
            continue;
        }
        checked += 1;
        let (violations, _) = shadowed_key_arms(&src);
        for s in violations {
            offenders.push(format!(
                "{}:{} — unguarded '{}' shadows the guarded arm at :{}",
                path, s.unguarded_line, s.ch, s.guarded_line
            ));
        }
    }
    assert!(
        checked >= 10,
        "expected at least 10 modules to match on KeyCode::Char; the sweep \
         found {checked} and is probably looking in the wrong place"
    );
    assert!(
        offenders.is_empty(),
        "ARCHITECTURE rule 4 violated:\n{}",
        offenders.join("\n")
    );
}

/// The canary: the rule must still DETECT a violation.
///
/// Without this the sweep passes identically whether it is working or
/// blind — a clean tree and a broken parser look the same. That became
/// concrete with `syn` 3, which moved the guard from the ARM to the
/// PATTERN: `chars_in_pattern` on a `Pat::Guard` wrapper finds no
/// characters, so the rule would have compiled, run, reported zero
/// offenders, and checked nothing.
#[test]
fn the_key_arm_rule_can_see_a_violation() {
    // An unguarded 'p' BEFORE the Ctrl-guarded 'p' — the shadowing this
    // rule exists to stop, since the compiler does not warn on it.
    let bad = r#"
        fn handle(k: KeyCode, m: KeyModifiers) {
            match k {
                KeyCode::Char('p') => purge(),
                KeyCode::Char('p') if m.contains(KeyModifiers::CONTROL) => pin(),
                _ => {}
            }
        }
    "#;
    let (violations, _) = shadowed_key_arms(bad);
    assert_eq!(
        violations.len(),
        1,
        "the rule must flag an unguarded arm shadowing a guarded one: {violations:?}"
    );
    assert_eq!(violations[0].ch, 'p');

    // And the correct order must NOT be flagged, or the rule is just
    // noise that everyone learns to ignore.
    let good = r#"
        fn handle(k: KeyCode, m: KeyModifiers) {
            match k {
                KeyCode::Char('p') if m.contains(KeyModifiers::CONTROL) => pin(),
                KeyCode::Char('p') => purge(),
                _ => {}
            }
        }
    "#;
    let (violations, chars_seen) = shadowed_key_arms(good);
    assert!(
        violations.is_empty(),
        "correct order must pass: {violations:?}"
    );
    assert!(
        chars_seen.contains(&'p'),
        "and the walk must have SEEN the 'p' arms — an empty set here \
         means the parser stopped extracting characters from guarded \
         patterns, which is exactly how this rule goes quiet: {chars_seen:?}"
    );
}