use syn::visit::Visit;
#[derive(Debug, Clone)]
struct CharArm {
ch: char,
guarded: bool,
index: usize,
line: usize,
}
#[derive(Debug, Clone)]
struct Shadowed {
ch: char,
unguarded_line: usize,
guarded_line: usize,
}
#[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"
)
})
}
fn chars_in_pattern(pat: &syn::Pat, out: &mut Vec<char>) {
match pat {
syn::Pat::TupleStruct(ts) => {
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());
}
}
}
}
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>,
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() {
let guarded = arm
.guard
.as_ref()
.is_some_and(|(_, g)| is_modifier_guard(g));
let line = arm.pat.span().start().line;
let mut chars = Vec::new();
chars_in_pattern(&arm.pat, &mut chars);
for ch in chars {
arms.push(CharArm {
ch,
guarded,
index,
line,
});
}
}
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);
}
}
fn shadowed_key_arms(src: &str) -> (Vec<Shadowed>, Vec<char>) {
let file = match syn::parse_file(src) {
Ok(f) => f,
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)
}
#[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());
}
#[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"
);
}
#[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());
}
#[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']);
}
#[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");
}
#[test]
#[should_panic(expected = "could not parse source")]
fn unparseable_source_is_loud() {
shadowed_key_arms("fn f( {");
}
#[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("; ")
);
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:?}"
);
}
#[test]
fn every_source_file_puts_guarded_key_arms_first() {
let mut offenders: Vec<String> = Vec::new();
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")
);
}