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 fn matches_glob(pattern: &str, path: &str) -> bool {
84    let path = path.strip_prefix("./").unwrap_or(path);
85    let pat_segs: Vec<&str> = pattern.split('/').collect();
86    let path_segs: Vec<&str> = path.split('/').collect();
87
88    if matches_glob_at(&pat_segs, &path_segs) {
89        return true;
90    }
91
92    // Suffix match: try aligning the pattern against each tail of the path.
93    // Only when the pattern doesn't start with `/` or `**`.
94    let pattern_is_relative =
95        !pattern.starts_with('/') && pat_segs.first().is_none_or(|s| *s != "**");
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, &path_segs[offset..])),
99        false => false,
100    }
101}
102
103fn matches_glob_at(pat: &[&str], path: &[&str]) -> bool {
104    let p = match pat.first() {
105        Some(seg) => *seg,
106        None => return path.is_empty(),
107    };
108
109    if p == "**" {
110        return matches_double_star_at(&pat[1..], path);
111    }
112
113    let s = match path.first() {
114        Some(seg) => *seg,
115        None => return false,
116    };
117
118    matches_segment(p, s) && matches_glob_at(&pat[1..], &path[1..])
119}
120
121fn matches_double_star_at(pat: &[&str], path: &[&str]) -> bool {
122    match pat.is_empty() {
123        true => true,
124        false => (0..=path.len()).any(|i| matches_glob_at(pat, &path[i..])),
125    }
126}
127
128fn matches_segment(pattern: &str, segment: &str) -> bool {
129    match pattern {
130        "*" => true,
131        p if p.contains('*') => match_single_wildcard(segment, p),
132        _ => pattern == segment,
133    }
134}
135
136/// Render the inner content of an attribute (e.g., `allow(dead_code)` from `#[allow(dead_code)]`).
137pub fn extract_attribute_text(attr: &Attribute) -> Box<str> {
138    let tokens = &attr.meta;
139    quote::quote!(#tokens)
140        .to_string()
141        .replace(' ', "")
142        .into_boxed_str()
143}
144
145/// Render a type with whitespace stripped for consistent pattern matching.
146pub fn extract_type_text(ty: &Type) -> Box<str> {
147    quote::quote!(#ty)
148        .to_string()
149        .replace(' ', "")
150        .into_boxed_str()
151}
152
153/// Render a method call as `.method_name()` for pattern matching.
154pub fn extract_method_call_text(call: &ExprMethodCall) -> Box<str> {
155    format!(".{}()", call.method).into_boxed_str()
156}
157
158/// Render a macro invocation as `name!` (e.g., `println!`) for pattern matching.
159pub fn extract_macro_text(mac: &Macro) -> Box<str> {
160    let path = &mac.path;
161    format!("{}!", quote::quote!(#path).to_string().replace(' ', "")).into_boxed_str()
162}