Skip to main content

pedant_core/
pattern.rs

1use syn::{Attribute, ExprMethodCall, Macro, Type};
2
3/// Test whether `text` matches a glob pattern (`*` matches any characters).
4pub fn matches_pattern(text: &str, pattern: &str) -> bool {
5    if !pattern.contains('*') {
6        return text == pattern;
7    }
8    let star_count = pattern.matches('*').count();
9    match star_count {
10        1 => match_single_wildcard(text, pattern),
11        _ => match_multiple_wildcards(text, pattern, star_count),
12    }
13}
14
15pub(crate) fn match_single_wildcard(text: &str, pattern: &str) -> bool {
16    let Some((prefix, suffix)) = pattern.split_once('*') else {
17        return false;
18    };
19    text.starts_with(prefix) && text.ends_with(suffix)
20}
21
22fn match_multiple_wildcards(text: &str, pattern: &str, star_count: usize) -> bool {
23    let total = star_count + 1;
24    let mut pos = 0;
25
26    for (index, part) in pattern.split('*').enumerate() {
27        match try_match_part(text, &mut pos, part, index, total) {
28            PartMatch::Continue => continue,
29            PartMatch::Fail => return false,
30            PartMatch::Ok => {}
31        }
32    }
33    true
34}
35
36enum PartMatch {
37    Continue,
38    Fail,
39    Ok,
40}
41
42fn try_match_part(
43    text: &str,
44    pos: &mut usize,
45    part: &str,
46    index: usize,
47    total: usize,
48) -> PartMatch {
49    match (part.is_empty(), text[*pos..].find(part)) {
50        (true, _) => PartMatch::Continue,
51        (false, None) => PartMatch::Fail,
52        (false, Some(found)) => check_position_constraints(text, pos, part, found, index, total),
53    }
54}
55
56fn check_position_constraints(
57    text: &str,
58    pos: &mut usize,
59    part: &str,
60    found: usize,
61    index: usize,
62    total: usize,
63) -> PartMatch {
64    let is_first = index == 0;
65    let is_last = index == total - 1;
66    let first_mismatch = is_first && found != 0;
67    let last_mismatch = is_last && *pos + found + part.len() != text.len();
68
69    match (first_mismatch, last_mismatch) {
70        (true, _) | (_, true) => PartMatch::Fail,
71        _ => {
72            *pos += found + part.len();
73            PartMatch::Ok
74        }
75    }
76}
77
78/// Path-aware glob: `*` matches one segment, `**` matches zero or more segments.
79///
80/// When the pattern is relative and the path is absolute, tries matching
81/// against every suffix of the path (so `pedant/src/main.rs` matches
82/// `/Users/jem/.../pedant/src/main.rs`).
83pub(crate) fn matches_glob(pattern: &str, path: &str) -> bool {
84    let path = path.strip_prefix("./").unwrap_or(path);
85    let pat_segs: Box<[&str]> = pattern.split('/').collect::<Vec<_>>().into_boxed_slice();
86    let path_segs: Box<[&str]> = path.split('/').collect::<Vec<_>>().into_boxed_slice();
87
88    // Direct match (both relative, or both absolute).
89    if matches_glob_at(&pat_segs, 0, &path_segs, 0) {
90        return true;
91    }
92
93    // Suffix match: try aligning the pattern against each tail of the path.
94    // Only when the pattern doesn't start with `/` or `**`.
95    let pattern_is_relative = !pattern.starts_with('/') && pat_segs.first() != Some(&"**");
96    match pattern_is_relative && path_segs.len() > pat_segs.len() {
97        true => (1..=path_segs.len() - pat_segs.len())
98            .any(|offset| matches_glob_at(&pat_segs, 0, &path_segs, offset)),
99        false => false,
100    }
101}
102
103fn matches_glob_at(pat_segs: &[&str], pi: usize, path_segs: &[&str], si: usize) -> bool {
104    match (pat_segs.get(pi), path_segs.get(si)) {
105        (None, None) => true,
106        (Some(&"**"), _) => matches_double_star_at(pat_segs, pi + 1, path_segs, si),
107        (Some(p), Some(s)) if matches_segment(p, s) => {
108            matches_glob_at(pat_segs, pi + 1, path_segs, si + 1)
109        }
110        _ => false,
111    }
112}
113
114fn matches_double_star_at(pat_segs: &[&str], pi: usize, path_segs: &[&str], si: usize) -> bool {
115    match pat_segs.get(pi) {
116        None => true,
117        Some(_) => (si..=path_segs.len()).any(|i| matches_glob_at(pat_segs, pi, path_segs, i)),
118    }
119}
120
121fn matches_segment(pattern: &str, segment: &str) -> bool {
122    match pattern {
123        "*" => true,
124        p if p.contains('*') => match_single_wildcard(segment, p),
125        _ => pattern == segment,
126    }
127}
128
129/// Render the inner content of an attribute (e.g., `allow(dead_code)` from `#[allow(dead_code)]`).
130pub fn extract_attribute_text(attr: &Attribute) -> Box<str> {
131    let tokens = &attr.meta;
132    quote::quote!(#tokens)
133        .to_string()
134        .replace(' ', "")
135        .into_boxed_str()
136}
137
138/// Render a type with whitespace stripped for consistent pattern matching.
139pub fn extract_type_text(ty: &Type) -> Box<str> {
140    quote::quote!(#ty)
141        .to_string()
142        .replace(' ', "")
143        .into_boxed_str()
144}
145
146/// Render a method call as `.method_name()` for pattern matching.
147pub fn extract_method_call_text(call: &ExprMethodCall) -> Box<str> {
148    format!(".{}()", call.method).into_boxed_str()
149}
150
151/// Render a macro invocation as `name!` (e.g., `println!`) for pattern matching.
152pub fn extract_macro_text(mac: &Macro) -> Box<str> {
153    let path = &mac.path;
154    format!("{}!", quote::quote!(#path).to_string().replace(' ', "")).into_boxed_str()
155}