1use regex::Regex;
4
5use crate::EntityKind;
6
7pub const DEFAULT_FORMAT: &str = "[{TYPE}_{N}]";
9
10#[derive(Clone, Debug)]
11enum Seg {
12 Lit(String),
13 Type,
14 Num,
15}
16
17#[derive(Clone, Debug)]
24pub struct PlaceholderFormat {
25 template: String,
26 segs: Vec<Seg>,
27 regex: Regex,
28 fuzzy: Regex,
30 opener: String,
31 closer: String,
32 inner: Vec<char>,
33 max_len: usize,
34}
35
36#[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 closed: bool,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Token<'a> {
52 pub start: usize,
54 pub end: usize,
56 pub kind: &'a str,
58 pub n: u64,
60}
61
62impl PlaceholderFormat {
63 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(®ex::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 pub fn template(&self) -> &str {
130 &self.template
131 }
132
133 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 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 pub(crate) fn fuzzy_tokens(&self, text: &str) -> Vec<FuzzyToken> {
167 self.fuzzy_tokens_after(None, text)
168 }
169
170 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; }
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 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 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 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 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
279fn 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 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}