Skip to main content

blazingly_aasa/
wildcard.rs

1//! A standalone Apple wildcard pattern, outside any association file.
2//!
3//! Useful when you want to check one pattern against one string — in a test, a REPL, or an editor
4//! plugin — without constructing a whole document.
5
6use crate::pattern::{Pattern, PatternError};
7use crate::substitution::SubstitutionTable;
8use std::collections::BTreeMap;
9use std::fmt;
10
11/// A pattern that could not be compiled.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PatternSyntaxError {
14    message: String,
15}
16
17impl PatternSyntaxError {
18    /// What is wrong with the pattern.
19    #[must_use]
20    pub fn message(&self) -> &str {
21        &self.message
22    }
23}
24
25impl fmt::Display for PatternSyntaxError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.write_str(&self.message)
28    }
29}
30
31impl std::error::Error for PatternSyntaxError {}
32
33/// A compiled Apple URL-component pattern.
34///
35/// `*` matches zero or more characters, `?` matches exactly one, and therefore `?*` matches one or
36/// more. `$(name)` expands to any one of a substitution variable's alternatives.
37///
38/// ```
39/// use blazingly_aasa::WildcardPattern;
40///
41/// let pattern = WildcardPattern::compile("/help/*", true)?;
42/// assert!(pattern.matches("/help/website"));
43/// assert!(!pattern.matches("/support/website"));
44///
45/// let predefined = WildcardPattern::compile("/id/$(digit)$(digit)", true)?;
46/// assert!(predefined.matches("/id/42"));
47/// assert!(!predefined.matches("/id/4x"));
48/// # Ok::<(), blazingly_aasa::PatternSyntaxError>(())
49/// ```
50///
51/// # This is the glob engine, not AASA path matching
52///
53/// It compares a pattern to a string and nothing else. The `/` component of a rule carries extra
54/// semantics that Apple's `swcutil` confirms and this type deliberately does not implement:
55/// a pattern ending in `/*` also matches its parent path, trailing slashes are insignificant, and
56/// a leading slash in the pattern is optional.
57///
58/// ```
59/// use blazingly_aasa::{CompiledAasa, MatchDecision, WildcardPattern};
60/// // The glob engine says no, because `/buy` is not `/buy/` followed by anything.
61/// assert!(!WildcardPattern::compile("/buy/*", true)?.matches("/buy"));
62///
63/// // Apple says yes, and so does the matcher.
64/// let doc = br#"{"applinks":{"details":[{"appIDs":["A.b"],"components":[{"/":"/buy/*"}]}]}}"#;
65/// let aasa = CompiledAasa::parse(doc).unwrap();
66/// assert_eq!(
67///     aasa.decide("example.com", "A.b", "https://example.com/buy").unwrap(),
68///     MatchDecision::Match,
69/// );
70/// # Ok::<(), blazingly_aasa::PatternSyntaxError>(())
71/// ```
72///
73/// Use [`CompiledAasa::decide`](crate::CompiledAasa::decide) to answer a question about a URL.
74/// Reach for this type only to test a pattern in isolation.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct WildcardPattern {
77    inner: Pattern,
78}
79
80impl WildcardPattern {
81    /// Compiles a pattern with only Apple's predefined substitution variables available.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`PatternSyntaxError`] for an unterminated `$(` or an unknown variable name.
86    pub fn compile(source: &str, case_sensitive: bool) -> Result<Self, PatternSyntaxError> {
87        Self::compile_with(source, case_sensitive, &BTreeMap::new())
88    }
89
90    /// Compiles a pattern with custom substitution variables in scope.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`PatternSyntaxError`] for an unterminated `$(`, an unknown variable name, an empty
95    /// variable, or a value that references another variable.
96    pub fn compile_with(
97        source: &str,
98        case_sensitive: bool,
99        variables: &BTreeMap<String, Vec<String>>,
100    ) -> Result<Self, PatternSyntaxError> {
101        let table = SubstitutionTable::from_custom(variables.clone());
102        let mut errors = Vec::new();
103        let inner = Pattern::compile(source, case_sensitive, &table, &mut errors);
104        if let Some(error) = errors.first() {
105            return Err(PatternSyntaxError {
106                message: match error {
107                    PatternError::UnterminatedReference => {
108                        format!("`{source}` contains a `$(` that is never closed")
109                    }
110                    PatternError::UnknownVariable(name) => format!(
111                        "`$({name})` is neither predefined nor supplied as a custom variable"
112                    ),
113                    PatternError::NestedSubstitution { variable, value } => format!(
114                        "substitution value `{value}` references `$({variable})`, which Apple does \
115                         not allow"
116                    ),
117                    PatternError::EmptyVariable(name) => {
118                        format!("`$({name})` has no values, so the pattern can never match")
119                    }
120                },
121            });
122        }
123        Ok(Self { inner })
124    }
125
126    /// Whether the whole of `input` matches.
127    #[must_use]
128    pub fn matches(&self, input: &str) -> bool {
129        self.inner.matches(input)
130    }
131
132    /// Whether the whole of `input` matches, overriding case sensitivity for this call.
133    #[must_use]
134    pub fn matches_with_case(&self, input: &str, case_sensitive: bool) -> bool {
135        self.inner.matches_with(input, case_sensitive)
136    }
137
138    /// The pattern text as written.
139    #[must_use]
140    pub fn source(&self) -> &str {
141        self.inner.source()
142    }
143}