1use super::chars_util::{str_find_from, walk_forward_while};
15use super::pyregex::{Candidate, PyRegex};
16
17const KEYWORD_INDICATOR: &str = r"(?:system|exec|popen|eval|require|include)\s*\z";
18const DOLLAR_INDICATOR: &str = r"@[\w.]+@|\b\w+\s*\(";
19const CURLY_INDICATOR: &str = r"@[\w.]+@|\b\w+\(\s*\)";
20const HASH_INDICATOR: &str = r"@[\w.]+@|\b\w+\s*\(";
21const ASP_INDICATOR: &str = r"system|exec|eval|`|Runtime|IO\.|File\.|Dir\.";
22const ARITH_UNQUOTED: &str = r"\d+\s*[*/%+\-]\s*\d+";
23const ARITH_ASP: &str = r"\d+\s*[-+*/]\s*\d+";
24const ARITH_QUOTED: &str = r#"['"]?\d+['"]?\s*[*/%+\-]\s*['"]?\d+['"]?"#;
25const DATE_SOURCE: &str = r"\d{4}-\d{1,2}-\d{1,2}";
26
27pub struct TemplateKind {
28 pub opening: &'static str,
29 pub closing: &'static str,
30 pub indicator: &'static str,
32 pub arithmetic: &'static str,
35}
36
37pub const KIND_DOLLAR: TemplateKind = TemplateKind {
38 opening: "${",
39 closing: "}",
40 indicator: DOLLAR_INDICATOR,
41 arithmetic: ARITH_UNQUOTED,
42};
43pub const KIND_CURLY_CALL: TemplateKind = TemplateKind {
44 opening: "{{",
45 closing: "}}",
46 indicator: CURLY_INDICATOR,
47 arithmetic: ARITH_QUOTED,
48};
49pub const KIND_CURLY_KEYWORD: TemplateKind = TemplateKind {
50 opening: "{{",
51 closing: "}}",
52 indicator: KEYWORD_INDICATOR,
53 arithmetic: "",
54};
55pub const KIND_HASH: TemplateKind = TemplateKind {
56 opening: "#{",
57 closing: "}",
58 indicator: HASH_INDICATOR,
59 arithmetic: ARITH_QUOTED,
60};
61pub const KIND_PERCENT_KEYWORD: TemplateKind = TemplateKind {
62 opening: "{%",
63 closing: "%}",
64 indicator: KEYWORD_INDICATOR,
65 arithmetic: "",
66};
67pub const KIND_ASP: TemplateKind = TemplateKind {
68 opening: "<%",
69 closing: "%>",
70 indicator: ASP_INDICATOR,
71 arithmetic: ARITH_ASP,
72};
73
74pub struct TemplateRegion {
75 pub start: usize,
76 pub barrier: usize,
77 pub end: usize,
78}
79
80#[must_use]
83pub fn template_regions(content: &str, opening: &str, closing: &str) -> Vec<TemplateRegion> {
84 let mut regions = Vec::new();
85 let mut cursor = 0usize;
86 while let Some(start) = str_find_from(content, opening, cursor) {
87 let body_start = start + opening.len();
88 let Some(barrier) = str_find_from(content, &closing[..1], body_start) else {
89 break;
90 };
91 cursor = body_start.max(barrier + 1 - opening.len());
92 if content[barrier..].starts_with(closing) {
93 regions.push(TemplateRegion {
94 start,
95 barrier,
96 end: barrier + closing.len(),
97 });
98 }
99 }
100 regions
101}
102
103fn template_frame(
106 content: &str,
107 opening: &str,
108 closing: &str,
109 start: usize,
110 end: usize,
111) -> Option<Candidate> {
112 if !content[start..].starts_with(opening) {
113 return None;
114 }
115 let body_start = start + opening.len();
116 let first = closing.chars().next()?;
117 let body_end = walk_forward_while(content, body_start, |c| c != first);
118 if !content[body_end..].starts_with(closing) {
119 return None;
120 }
121 let frame_end = body_end + closing.len();
122 if frame_end > end {
123 return None;
124 }
125 Some(Candidate::new(start, frame_end))
126}
127
128fn search_between(re: &PyRegex, haystack: &str, start: usize, end: usize) -> bool {
129 if start >= end || end > haystack.len() {
130 return false;
131 }
132 re.re().is_match(&haystack[start..end])
133}
134
135#[must_use]
137fn arithmetic_in_window(content: &str, arithmetic: &PyRegex, start: usize, end: usize) -> bool {
138 if start >= end || end > content.len() {
139 return false;
140 }
141 let window = &content[start..end];
142 for m in arithmetic.re().find_iter(window) {
143 let abs_start = start + m.start();
145 let digit_before = content[..abs_start]
146 .chars()
147 .next_back()
148 .is_some_and(|c| c.is_ascii_digit());
149 if !digit_before {
150 return true;
151 }
152 }
153 false
154}
155
156fn date_restart(
157 content: &str,
158 opening: &str,
159 start: usize,
160 barrier: usize,
161 dates: &PyRegex,
162) -> Option<usize> {
163 let from = (start + 2).min(content.len());
164 let to = barrier.min(content.len());
165 if from >= to {
166 return Some(start);
167 }
168 let last_date = dates
169 .re()
170 .find_iter(&content[from..to])
171 .last()
172 .map(|m| m.start() + from);
173 last_date.map_or(Some(start), |pos| {
174 str_find_from(content, opening, pos + 1).filter(|i| *i < barrier)
177 })
178}
179
180#[must_use]
183pub fn template_keyword_matches(
184 content: &str,
185 kind: &TemplateKind,
186 ignore_case: bool,
187) -> Vec<Candidate> {
188 #[cfg(not(coverage))] let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
191 return Vec::new();
192 };
193 #[cfg(coverage)]
194 let indicator = PyRegex::compile(kind.indicator, ignore_case).expect("statically valid");
195 let mut matches = Vec::new();
196 for region in template_regions(content, kind.opening, kind.closing) {
197 let from = region.start + kind.opening.len() + 1;
198 if search_between(&indicator, content, from, region.barrier)
199 && let Some(frame) = template_frame(
200 content,
201 kind.opening,
202 kind.closing,
203 region.start,
204 region.end,
205 )
206 {
207 matches.push(frame);
208 }
209 }
210 matches
211}
212
213#[must_use]
216pub fn template_expression_matches(
217 content: &str,
218 kind: &TemplateKind,
219 ignore_case: bool,
220) -> Vec<Candidate> {
221 #[cfg(not(coverage))] let Ok(indicator) = PyRegex::compile(kind.indicator, ignore_case) else {
224 return Vec::new();
225 };
226 #[cfg(coverage)]
227 let indicator = PyRegex::compile(kind.indicator, ignore_case).expect("statically valid");
228 let arithmetic = PyRegex::compile(kind.arithmetic, ignore_case).ok();
229 let dates = PyRegex::compile(DATE_SOURCE, ignore_case).ok();
230 let has_dates = matches!(kind.opening, "{{" | "#{");
231 let mut matches = Vec::new();
232 let mut last_end = 0usize;
233 for region in template_regions(content, kind.opening, kind.closing) {
234 if region.start < last_end {
235 continue;
236 }
237 let mut start = region.start;
238 if has_dates && let Some(dates) = &dates {
239 let Some(restarted) = date_restart(content, kind.opening, start, region.barrier, dates)
240 else {
241 continue;
242 };
243 start = restarted;
244 }
245 let hit = search_between(
246 &indicator,
247 content,
248 start + kind.opening.len(),
249 region.barrier,
250 ) || arithmetic.as_ref().is_some_and(|a| {
251 arithmetic_in_window(content, a, start + kind.opening.len(), region.barrier)
252 });
253 if hit
254 && let Some(frame) =
255 template_frame(content, kind.opening, kind.closing, start, region.end)
256 {
257 matches.push(frame);
258 last_end = region.end;
259 }
260 }
261 matches
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn curly_keyword_requires_indicator_inside() {
270 assert!(template_keyword_matches("{{system('id')}}", &KIND_CURLY_KEYWORD, true).is_empty());
273 let ms = template_keyword_matches("{{ system }}", &KIND_CURLY_KEYWORD, true);
274 assert_eq!(ms.len(), 1);
275 assert!(template_keyword_matches("{{name}}", &KIND_CURLY_KEYWORD, true).is_empty());
276 }
277
278 #[test]
279 fn curly_call_date_suppression() {
280 let stale = "{{ 2024-01-02 }}";
281 assert!(template_expression_matches(stale, &KIND_CURLY_CALL, true).is_empty());
282 let call = "{{ render() }}";
283 assert_eq!(
284 template_expression_matches(call, &KIND_CURLY_CALL, true).len(),
285 1
286 );
287 }
288
289 #[test]
290 fn hash_brace_call() {
291 let ms = template_expression_matches("#{7*7}", &KIND_HASH, true);
292 assert_eq!(ms.len(), 1);
293 }
294
295 #[test]
296 fn dollar_brace_call() {
297 let ms = template_expression_matches("${eval(x)}", &KIND_DOLLAR, true);
298 assert_eq!(ms.len(), 1);
299 assert_eq!(ms[0].text("${eval(x)}"), "${eval(x)}");
300 }
301
302 #[test]
303 fn arithmetic_guard_blocks_digit_preceded_start() {
304 assert_eq!(
306 template_expression_matches("{{12 3*4}}", &KIND_CURLY_CALL, true).len(),
307 1
308 );
309 assert_eq!(
310 template_expression_matches("{{7*6}}", &KIND_CURLY_CALL, true).len(),
311 1
312 );
313 assert_eq!(
315 template_expression_matches("{{a123*4}}", &KIND_CURLY_CALL, true).len(),
316 1
317 );
318 assert_eq!(
321 template_expression_matches("{{x1 3*4}}", &KIND_CURLY_CALL, true).len(),
322 1
323 );
324 }
325}
326
327#[cfg(test)]
328mod coverage_tests {
329 use super::*;
330
331 #[test]
332 fn malformed_frames_and_missing_closers_are_rejected() {
333 assert_eq!(
335 template_keyword_matches("{{ system }}{{ system }}", &KIND_CURLY_KEYWORD, true).len(),
336 2
337 );
338 assert!(template_keyword_matches("{{system}}", &KIND_CURLY_KEYWORD, true).is_empty());
340 assert!(template_keyword_matches("{{ system ", &KIND_CURLY_KEYWORD, true).is_empty());
342 }
343}
344
345#[cfg(test)]
346mod gap_tests {
347 use super::*;
348
349 #[test]
350 fn digit_preceded_arithmetic_candidates_are_rejected() {
351 assert!(template_expression_matches("#{1\"2*3}", &KIND_HASH, true).is_empty());
354 assert_eq!(
356 template_expression_matches("#{1\"2*3}#{eval(x)}", &KIND_HASH, true).len(),
357 1
358 );
359 }
360
361 #[test]
362 fn a_region_body_shorter_than_the_restart_lookback_restarts_in_place() {
363 assert_eq!(
366 template_expression_matches("#{}#{eval(x)}", &KIND_HASH, true).len(),
367 1
368 );
369 }
370
371 #[test]
372 fn an_overlapping_second_region_is_skipped_after_a_hit() {
373 assert_eq!(
376 template_expression_matches("<%eval<%>%>", &KIND_ASP, true).len(),
377 1
378 );
379 }
380}
381
382#[cfg(test)]
383mod unit_twins {
384 use super::*;
385
386 #[test]
387 fn template_frame_rejects_misaligned_and_unterminated_windows() {
388 assert_eq!(template_frame("{{x}}", "{{", "}}", 1, 5), None);
390 assert_eq!(template_frame("{{x", "{{", "}}", 0, 3), None);
392 assert_eq!(template_frame("{{x}} tail", "{{", "}}", 0, 4), None);
394 let frame = template_frame("{{x}} tail", "{{", "}}", 0, 10).expect("frame");
396 assert_eq!((frame.start, frame.end), (0, 5));
397 assert_eq!(template_frame("{{x}}", "{{", "", 0, 5), None);
399 }
400
401 #[test]
402 fn date_restart_finds_the_next_opening_inside_the_barrier() {
403 let dates = PyRegex::compile(DATE_SOURCE, false).expect("statically valid");
404 let content = "{{a}} 2024-01-01 {{b}}";
407 let barrier = content.len();
408 assert_eq!(date_restart(content, "{{", 0, barrier, &dates), Some(17));
409 assert_eq!(date_restart(content, "{{", 0, 17, &dates), None);
411 }
412}