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}