Skip to main content

veil_engine/
placeholder.rs

1//! Placeholder format, such as the default `[{TYPE}_{N}]`.
2
3use regex::Regex;
4
5use crate::EntityKind;
6
7/// The default placeholder template, giving `[EMAIL_1]`, `[PHONE_2]` and so on.
8pub const DEFAULT_FORMAT: &str = "[{TYPE}_{N}]";
9
10#[derive(Clone, Debug)]
11enum Seg {
12    Lit(String),
13    Type,
14    Num,
15}
16
17/// A parsed placeholder template.
18///
19/// Rules: it contains `{TYPE}` and `{N}` once each, separated by literal
20/// text, and it starts and ends with literal text. The opening text must start
21/// with a character that is not a letter, digit or `_`, so placeholders can't
22/// run into surrounding words.
23#[derive(Clone, Debug)]
24pub struct PlaceholderFormat {
25    template: String,
26    segs: Vec<Seg>,
27    regex: Regex,
28    /// Also matches the variants models sometimes write; see [`PlaceholderFormat::fuzzy_tokens`].
29    fuzzy: Regex,
30    opener: String,
31    closer: String,
32    inner: Vec<char>,
33    max_len: usize,
34}
35
36/// A placeholder found by fuzzy matching. `exact` is true if it is written
37/// exactly as the format produces it.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub(crate) struct FuzzyToken {
40    pub start: usize,
41    pub end: usize,
42    pub kind: String,
43    pub n: u64,
44    pub exact: bool,
45    /// Ends with the closing text, so more text can't change it.
46    closed: bool,
47}
48
49/// A placeholder-shaped token found in text.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Token<'a> {
52    /// Byte offset where the token starts.
53    pub start: usize,
54    /// Byte offset just past the token.
55    pub end: usize,
56    /// The `{TYPE}` part, as written.
57    pub kind: &'a str,
58    /// The `{N}` part.
59    pub n: u64,
60}
61
62impl PlaceholderFormat {
63    /// Parses a template. The error says which rule it breaks.
64    pub fn parse(template: &str) -> Result<Self, String> {
65        let mut segs = Vec::new();
66        let mut rest = template;
67        while !rest.is_empty() {
68            if let Some(r) = rest.strip_prefix("{TYPE}") {
69                segs.push(Seg::Type);
70                rest = r;
71            } else if let Some(r) = rest.strip_prefix("{N}") {
72                segs.push(Seg::Num);
73                rest = r;
74            } else {
75                let next = [rest.find("{TYPE}"), rest.find("{N}")].into_iter().flatten().min().unwrap_or(rest.len());
76                let next = if next == 0 { 1 } else { next };
77                match segs.last_mut() {
78                    Some(Seg::Lit(s)) => s.push_str(&rest[..next]),
79                    _ => segs.push(Seg::Lit(rest[..next].to_owned())),
80                }
81                rest = &rest[next..];
82            }
83        }
84        let count = |f: fn(&Seg) -> bool| segs.iter().filter(|s| f(s)).count();
85        if count(|s| matches!(s, Seg::Type)) != 1 || count(|s| matches!(s, Seg::Num)) != 1 {
86            return Err("the format must contain {TYPE} and {N} exactly once".into());
87        }
88        let (Some(Seg::Lit(opener)), Some(Seg::Lit(closer))) = (segs.first(), segs.last()) else {
89            return Err("the format must start and end with literal text, such as [ and ]".into());
90        };
91        if opener.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
92            return Err("the format must start with a symbol, not a letter, digit or _".into());
93        }
94        if segs.windows(2).any(|w| !matches!(w[0], Seg::Lit(_)) && !matches!(w[1], Seg::Lit(_))) {
95            return Err("{TYPE} and {N} must be separated by literal text, such as _".into());
96        }
97        let (opener, closer) = (opener.clone(), closer.clone());
98        let mut pattern = String::new();
99        let mut inner = Vec::new();
100        let mut literal_len = 0;
101        for (i, seg) in segs.iter().enumerate() {
102            match seg {
103                Seg::Lit(s) => {
104                    pattern.push_str(&regex::escape(s));
105                    literal_len += s.len();
106                    if i != 0 && i != segs.len() - 1 {
107                        inner.extend(s.chars());
108                    }
109                }
110                Seg::Type => pattern.push_str("(?P<t>[A-Z][A-Z0-9_]{0,31}?)"),
111                Seg::Num => pattern.push_str("(?P<n>[0-9]{1,9})"),
112            }
113        }
114        let regex = Regex::new(&pattern).map_err(|e| e.to_string())?;
115        let fuzzy = Regex::new(&fuzzy_pattern(&segs)).map_err(|e| e.to_string())?;
116        Ok(PlaceholderFormat {
117            template: template.to_owned(),
118            segs,
119            regex,
120            fuzzy,
121            opener,
122            closer,
123            inner,
124            max_len: literal_len + 32 + 9,
125        })
126    }
127
128    /// The template, as given.
129    pub fn template(&self) -> &str {
130        &self.template
131    }
132
133    /// The placeholder for number `n` of `kind`, such as `[EMAIL_1]`.
134    pub fn format(&self, kind: &EntityKind, n: u64) -> String {
135        let mut out = String::new();
136        for seg in &self.segs {
137            match seg {
138                Seg::Lit(s) => out.push_str(s),
139                Seg::Type => out.push_str(kind.as_str()),
140                Seg::Num => out.push_str(&n.to_string()),
141            }
142        }
143        out
144    }
145
146    /// Finds every placeholder-shaped token in `text`.
147    pub fn tokens<'a>(&'a self, text: &'a str) -> impl Iterator<Item = Token<'a>> + 'a {
148        self.regex.captures_iter(text).filter_map(|c| {
149            let whole = c.get(0)?;
150            Some(Token {
151                start: whole.start(),
152                end: whole.end(),
153                kind: c.name("t")?.as_str(),
154                n: c.name("n")?.as_str().parse().ok()?,
155            })
156        })
157    }
158
159    /// Finds placeholders including the variants models sometimes write:
160    ///
161    /// - different case or spaces inside the brackets: `[email_1]`, `[ EMAIL 1 ]`
162    /// - Markdown escapes: `[EMAIL\_1]`, `\[EMAIL_1\]`
163    /// - missing brackets, only when the rest is exact and stands alone: `EMAIL_1`
164    ///
165    /// Exact placeholders are found too.
166    pub(crate) fn fuzzy_tokens(&self, text: &str) -> Vec<FuzzyToken> {
167        self.fuzzy_tokens_after(None, text)
168    }
169
170    /// Like [`PlaceholderFormat::fuzzy_tokens`] for text that follows
171    /// `before`, e.g. the last character already sent in a stream.
172    pub(crate) fn fuzzy_tokens_after(&self, before: Option<char>, text: &str) -> Vec<FuzzyToken> {
173        let is_word = |c: char| c.is_alphanumeric() || c == '_';
174        let mut out = Vec::new();
175        for c in self.fuzzy.captures_iter(text) {
176            let (Some(whole), Some(t), Some(n_match)) = (c.get(0), c.name("t"), c.name("n")) else { continue };
177            if text[n_match.end()..].starts_with(|c: char| c.is_ascii_digit()) {
178                continue; // more than nine digits
179            }
180            let Ok(n) = n_match.as_str().parse() else { continue };
181            let Ok(kind) = EntityKind::new(&t.as_str().replace('\\', "").to_ascii_uppercase()) else { continue };
182            let (opened, closed) = (c.name("o").is_some(), c.name("c").is_some());
183            let exact_text = self.format(&kind, n);
184            if !(opened && closed) {
185                // Without both brackets, only the exact core counts, and it
186                // must not be part of a longer word.
187                let core = &text[t.start()..n_match.end()];
188                let exact_core = &exact_text[self.opener.len()..exact_text.len() - self.closer.len()];
189                let before = if whole.start() == 0 { before } else { text[..whole.start()].chars().next_back() };
190                let after = text[whole.end()..].chars().next();
191                if core != exact_core
192                    || (!opened && before.is_some_and(is_word))
193                    || (!closed && after.is_some_and(is_word))
194                {
195                    continue;
196                }
197            }
198            out.push(FuzzyToken {
199                start: whole.start(),
200                end: whole.end(),
201                exact: whole.as_str() == exact_text,
202                kind: kind.as_str().to_owned(),
203                n,
204                closed,
205            });
206        }
207        out
208    }
209
210    /// Like [`PlaceholderFormat::hold_from`], for fuzzy restoration: also holds
211    /// a tail that could still become one of the variants.
212    pub(crate) fn hold_from_fuzzy(&self, text: &str) -> usize {
213        let max = self.max_len * 2;
214        let min = text.len().saturating_sub(max);
215        let in_brackets = |c: char| {
216            c.is_ascii_alphanumeric()
217                || matches!(c, '_' | '\\' | ' ' | '\t' | '-')
218                || self.opener.contains(c)
219                || self.closer.contains(c)
220                || self.inner.contains(&c)
221        };
222        let bare = |c: char| {
223            c.is_ascii_uppercase() || c.is_ascii_digit() || self.inner.contains(&c) || self.closer.contains(c)
224        };
225        let prev_is_word = |i: usize| text[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
226        text.char_indices()
227            .map(|(i, _)| i)
228            .skip_while(|&i| i < min)
229            .find(|&i| {
230                let tail = &text[i..];
231                let first = tail.chars().next().expect("non-empty tail");
232                // A placeholder can start at an opener (maybe escaped), or at
233                // an uppercase word for the bracketless form.
234                // A backslash only starts one if the opener (or its start) follows.
235                let unescaped = tail.strip_prefix('\\').unwrap_or(tail);
236                let opens =
237                    unescaped.is_empty() || unescaped.chars().next().is_some_and(|c| self.opener.starts_with(c));
238                let could_be = if opens {
239                    tail.chars().all(in_brackets)
240                } else {
241                    first.is_ascii_uppercase() && !prev_is_word(i) && tail.chars().all(bare)
242                };
243                let complete = || self.fuzzy_tokens(tail).first().is_some_and(|t| t.start == 0 && t.closed);
244                tail.len() < max && could_be && !complete()
245            })
246            .unwrap_or(text.len())
247    }
248
249    /// The offset from which `text` must be held back because its tail could
250    /// still grow into a placeholder. Returns `text.len()` if nothing needs holding.
251    pub(crate) fn hold_from(&self, text: &str) -> usize {
252        let min = text.len().saturating_sub(self.max_len);
253        text.char_indices()
254            .map(|(i, _)| i)
255            .skip_while(|&i| i < min)
256            .find(|&i| self.could_complete(&text[i..]))
257            .unwrap_or(text.len())
258    }
259
260    fn could_complete(&self, tail: &str) -> bool {
261        if tail.is_empty() || tail.len() >= self.max_len {
262            return false;
263        }
264        if self.opener.starts_with(tail) {
265            return true;
266        }
267        let Some(rest) = tail.strip_prefix(self.opener.as_str()) else {
268            return false;
269        };
270        let body_end = rest
271            .char_indices()
272            .find(|&(_, c)| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_' || self.inner.contains(&c)))
273            .map_or(rest.len(), |(i, _)| i);
274        let after = &rest[body_end..];
275        after.is_empty() || (self.closer.starts_with(after) && after != self.closer)
276    }
277}
278
279/// The fuzzy pattern: literal parts may be Markdown-escaped and padded with
280/// spaces, the separator may be a space or `-`, the type any case, and the
281/// opening and closing text may be missing (checked by the caller).
282fn fuzzy_pattern(segs: &[Seg]) -> String {
283    let lit = |s: &str| s.chars().map(|c| format!(r"\\?{}", regex::escape(&c.to_string()))).collect::<String>();
284    let mut pattern = String::new();
285    let last = segs.len() - 1;
286    for (i, seg) in segs.iter().enumerate() {
287        match seg {
288            Seg::Lit(s) if i == 0 => pattern.push_str(&format!(r"(?P<o>{}[ \t]*)?", lit(s))),
289            Seg::Lit(s) if i == last => pattern.push_str(&format!(r"(?P<c>[ \t]*{})?", lit(s))),
290            Seg::Lit(s) => pattern.push_str(&format!(r"(?:[ \t]*{}[ \t]*|[ \t]+|[ \t]*-[ \t]*)", lit(s))),
291            Seg::Type => pattern.push_str(r"(?P<t>[A-Za-z](?:[A-Za-z0-9]|\\?_){0,40}?)"),
292            Seg::Num => pattern.push_str(r"(?P<n>[0-9]{1,9})"),
293        }
294    }
295    pattern
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn kind(s: &str) -> EntityKind {
303        EntityKind::new(s).unwrap()
304    }
305
306    #[test]
307    fn default_format_round_trips() {
308        let f = PlaceholderFormat::parse(DEFAULT_FORMAT).unwrap();
309        assert_eq!(f.format(&kind("NATIONAL_ID"), 12), "[NATIONAL_ID_12]");
310        let toks: Vec<_> = f.tokens("a [NATIONAL_ID_12] b [EMAIL_1] [email_1] [EMAIL]").collect();
311        assert_eq!(toks.len(), 2);
312        assert_eq!((toks[0].kind, toks[0].n), ("NATIONAL_ID", 12));
313        assert_eq!((toks[1].kind, toks[1].n), ("EMAIL", 1));
314    }
315
316    #[test]
317    fn custom_formats() {
318        let f = PlaceholderFormat::parse("<<{TYPE}:{N}>>").unwrap();
319        assert_eq!(f.format(&kind("EMAIL"), 3), "<<EMAIL:3>>");
320        assert_eq!(f.tokens("x <<EMAIL:3>> y").count(), 1);
321        assert_eq!(f.hold_from("text <"), 5);
322        assert_eq!(f.hold_from("text <<EMA"), 5);
323        assert_eq!(f.hold_from("text <<EMAIL:3>"), 5);
324        assert_eq!(f.hold_from("text <<EMAIL:3>>"), 16);
325        assert_eq!(f.hold_from("a <b"), 4);
326    }
327
328    #[test]
329    fn rejects_bad_formats() {
330        for bad in ["{TYPE}_{N}", "[{TYPE}{N}]", "[{TYPE}]", "[{N}_{N}]", "x{TYPE}_{N}]", "[{TYPE}_{N}"] {
331            assert!(PlaceholderFormat::parse(bad).is_err(), "{bad}");
332        }
333    }
334
335    fn fuzzy(f: &PlaceholderFormat, text: &str) -> Vec<(String, String, u64, bool)> {
336        f.fuzzy_tokens(text).into_iter().map(|t| (text[t.start..t.end].to_owned(), t.kind, t.n, t.exact)).collect()
337    }
338
339    #[test]
340    fn fuzzy_variants() {
341        let f = PlaceholderFormat::parse(DEFAULT_FORMAT).unwrap();
342        let t = |s: &str, k: &str, n, exact| (s.to_owned(), k.to_owned(), n, exact);
343        assert_eq!(fuzzy(&f, "[EMAIL_1]"), [t("[EMAIL_1]", "EMAIL", 1, true)]);
344        assert_eq!(fuzzy(&f, "a [email_2] b"), [t("[email_2]", "EMAIL", 2, false)]);
345        assert_eq!(fuzzy(&f, "[ Email 3 ]"), [t("[ Email 3 ]", "EMAIL", 3, false)]);
346        assert_eq!(fuzzy(&f, "[EMAIL - 4]"), [t("[EMAIL - 4]", "EMAIL", 4, false)]);
347        assert_eq!(fuzzy(&f, r"[NATIONAL\_ID\_5]"), [t(r"[NATIONAL\_ID\_5]", "NATIONAL_ID", 5, false)]);
348        assert_eq!(fuzzy(&f, r"\[EMAIL_6\]"), [t(r"\[EMAIL_6\]", "EMAIL", 6, false)]);
349        assert_eq!(fuzzy(&f, "to EMAIL_7, and"), [t("EMAIL_7", "EMAIL", 7, false)]);
350        assert_eq!(fuzzy(&f, "x [PHONE_8 y"), [t("[PHONE_8", "PHONE", 8, false)]);
351        // Bracketless forms must be exact and stand alone.
352        for no in ["send email 1 now", "aEMAIL_1", "EMAIL_12a", "Email_1", "EMAIL 1", "[EMAIL_1234567890]"] {
353            assert_eq!(fuzzy(&f, no), [], "{no}");
354        }
355        let f = PlaceholderFormat::parse("<<{TYPE}:{N}>>").unwrap();
356        assert_eq!(fuzzy(&f, "<< email : 3 >>"), [t("<< email : 3 >>", "EMAIL", 3, false)]);
357    }
358
359    #[test]
360    fn fuzzy_hold_back() {
361        let f = PlaceholderFormat::parse(DEFAULT_FORMAT).unwrap();
362        assert_eq!(f.hold_from_fuzzy("Hi [ em"), 3);
363        assert_eq!(f.hold_from_fuzzy(r"Hi \[EMAIL\_"), 3);
364        assert_eq!(f.hold_from_fuzzy("Hi EMAIL_"), 3);
365        assert_eq!(f.hold_from_fuzzy("Hi [email_1]"), 12);
366        assert_eq!(f.hold_from_fuzzy("Hi there."), 9);
367        assert_eq!(f.hold_from_fuzzy("lowercase words"), 15);
368    }
369
370    #[test]
371    fn hold_from_default() {
372        let f = PlaceholderFormat::parse(DEFAULT_FORMAT).unwrap();
373        assert_eq!(f.hold_from("Hi ["), 3);
374        assert_eq!(f.hold_from("Hi [EMAIL_1"), 3);
375        assert_eq!(f.hold_from("Hi [EMAIL_1]"), 12);
376        assert_eq!(f.hold_from("Hi [a"), 5);
377        assert_eq!(f.hold_from("[[EMAIL_1"), 1);
378        let long = format!("[{}", "A".repeat(60));
379        assert_eq!(f.hold_from(&long), long.len());
380    }
381}