Skip to main content

use_wildcard/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5enum MatchToken {
6    Literal(char),
7    Star,
8    Question,
9}
10
11fn tokenize_wildcard_pattern(pattern: &str) -> Vec<MatchToken> {
12    let pattern_chars: Vec<char> = pattern.chars().collect();
13    let mut tokens = Vec::new();
14    let mut pattern_index = 0;
15
16    while pattern_index < pattern_chars.len() {
17        match pattern_chars[pattern_index] {
18            '\\' => {
19                if let Some(next_char) = pattern_chars.get(pattern_index + 1) {
20                    tokens.push(MatchToken::Literal(*next_char));
21                    pattern_index += 2;
22                } else {
23                    tokens.push(MatchToken::Literal('\\'));
24                    pattern_index += 1;
25                }
26            }
27            '*' => {
28                tokens.push(MatchToken::Star);
29                pattern_index += 1;
30            }
31            '?' => {
32                tokens.push(MatchToken::Question);
33                pattern_index += 1;
34            }
35            literal => {
36                tokens.push(MatchToken::Literal(literal));
37                pattern_index += 1;
38            }
39        }
40    }
41
42    tokens
43}
44
45fn escape_regex_char(character: char, output: &mut String) {
46    match character {
47        '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => {
48            output.push('\\');
49            output.push(character);
50        }
51        _ => output.push(character),
52    }
53}
54
55fn wildcard_matches_impl(pattern: &str, input: &str) -> bool {
56    let tokens = tokenize_wildcard_pattern(pattern);
57    let input_chars: Vec<char> = input.chars().collect();
58    let token_count = tokens.len();
59    let input_count = input_chars.len();
60    let mut matrix = vec![vec![false; input_count + 1]; token_count + 1];
61
62    matrix[0][0] = true;
63
64    for token_index in 1..=token_count {
65        if matches!(tokens[token_index - 1], MatchToken::Star) {
66            matrix[token_index][0] = matrix[token_index - 1][0];
67        }
68    }
69
70    for token_index in 1..=token_count {
71        for input_index in 1..=input_count {
72            matrix[token_index][input_index] = match tokens[token_index - 1] {
73                MatchToken::Literal(expected) => {
74                    matrix[token_index - 1][input_index - 1]
75                        && input_chars[input_index - 1] == expected
76                }
77                MatchToken::Question => matrix[token_index - 1][input_index - 1],
78                MatchToken::Star => {
79                    matrix[token_index - 1][input_index] || matrix[token_index][input_index - 1]
80                }
81            };
82        }
83    }
84
85    matrix[token_count][input_count]
86}
87
88/// A simple wildcard pattern with optional case-sensitivity metadata.
89#[derive(Clone, Debug, Default, PartialEq, Eq)]
90pub struct WildcardPattern {
91    pub pattern: String,
92    pub case_sensitive: bool,
93}
94
95/// Returns `true` when the input contains an unescaped `*` or `?`.
96pub fn has_wildcard(input: &str) -> bool {
97    let tokens = tokenize_wildcard_pattern(input);
98
99    tokens
100        .iter()
101        .any(|token| matches!(token, MatchToken::Star | MatchToken::Question))
102}
103
104/// Escapes wildcard metacharacters so they are treated literally.
105pub fn escape_wildcard(input: &str) -> String {
106    let mut escaped = String::new();
107
108    for character in input.chars() {
109        if matches!(character, '*' | '?' | '\\') {
110            escaped.push('\\');
111        }
112
113        escaped.push(character);
114    }
115
116    escaped
117}
118
119/// Converts a wildcard pattern into an anchored regex string.
120pub fn wildcard_to_regex(pattern: &str) -> String {
121    let tokens = tokenize_wildcard_pattern(pattern);
122    let mut regex_pattern = String::from("(?s)^");
123
124    for token in tokens {
125        match token {
126            MatchToken::Literal(character) => escape_regex_char(character, &mut regex_pattern),
127            MatchToken::Star => regex_pattern.push_str(".*"),
128            MatchToken::Question => regex_pattern.push('.'),
129        }
130    }
131
132    regex_pattern.push('$');
133    regex_pattern
134}
135
136/// Returns `true` when the pattern matches the entire input.
137pub fn wildcard_matches(pattern: &str, input: &str) -> bool {
138    wildcard_matches_impl(pattern, input)
139}
140
141/// Returns `true` when the pattern matches the entire input after lowercasing both sides.
142pub fn wildcard_matches_case_insensitive(pattern: &str, input: &str) -> bool {
143    wildcard_matches_impl(&pattern.to_lowercase(), &input.to_lowercase())
144}
145
146/// Returns `true` when the first unescaped token is a wildcard.
147pub fn starts_with_wildcard(pattern: &str) -> bool {
148    matches!(
149        tokenize_wildcard_pattern(pattern).first(),
150        Some(MatchToken::Star | MatchToken::Question)
151    )
152}
153
154/// Returns `true` when the last unescaped token is a wildcard.
155pub fn ends_with_wildcard(pattern: &str) -> bool {
156    matches!(
157        tokenize_wildcard_pattern(pattern).last(),
158        Some(MatchToken::Star | MatchToken::Question)
159    )
160}