Skip to main content

polydat_nodes/
regex.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Regex processing nodes.
5
6use regex::Regex;
7
8/// Regex replace: substitute all matches of a pattern with the
9/// replacement string. The compiled `Regex` is cached at
10/// construction.
11#[polydat::polydat_node(category = Regex)]
12fn regex_replace(
13    input: &str,
14    pattern: polydat::derive_support::Const<&str>,
15    replacement: polydat::derive_support::Const<&str>,
16    #[poly_const(compile_regex, from = pattern)] re: &Regex,
17) -> String {
18    let _ = pattern;
19    re.replace_all(input, replacement.0).into_owned()
20}
21
22/// Build a Regex from a pattern. Panics on invalid pattern;
23/// the const-arg constraint (registered in the FuncSig the
24/// macro emits is forthcoming) catches malformed patterns at
25/// workload-compile-time, so the panic here is a true bug
26/// indicator only.
27fn compile_regex(pattern: &str) -> Regex {
28    Regex::new(pattern).expect("invalid regex")
29}
30
31/// Regex match: test if input matches a pattern.
32#[polydat::polydat_node(category = Regex)]
33fn regex_match(
34    input: &str,
35    pattern: polydat::derive_support::Const<&str>,
36    #[poly_const(compile_regex, from = pattern)] re: &Regex,
37) -> bool {
38    let matched = re.is_match(input);
39    if polydat::library::debug_nodes_enabled() {
40        let snippet: String = input.chars().take(200).collect();
41        let ellipsis = if input.len() > snippet.len() {
42            "…"
43        } else {
44            ""
45        };
46        polydat::library::support::audit::debug(&format!(
47            "regex_match: pattern={:?} input.len={} matched={matched} input.snippet={:?}{ellipsis}",
48            re.as_str(),
49            input.len(),
50            snippet,
51        ));
52    }
53    matched
54}
55
56/// Regex extract: extract the first capture group (or full match).
57#[polydat::polydat_node(category = Regex)]
58fn regex_extract(
59    input: &str,
60    pattern: polydat::derive_support::Const<&str>,
61    #[poly_const(compile_regex, from = pattern)] re: &Regex,
62) -> String {
63    if let Some(caps) = re.captures(input) {
64        caps.get(1)
65            .or_else(|| caps.get(0))
66            .map(|m| m.as_str().to_string())
67            .unwrap_or_default()
68    } else {
69        String::new()
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Pattern promotion (literal / glob / regex) + the `pattern_match` node
75// ---------------------------------------------------------------------------
76//
77// A *pattern* is a source string lifted into an anchored regex by the
78// shape of the source. Shared with `nbrs-runtime`'s phase-name filter
79// (which delegates to `compile_pattern`) so `phases=…` and a workload's
80// `pattern_match(...)` agree on what a pattern means.
81
82pub use polydat::library::support::pattern::{PatternDialect, compile_pattern, promote_pattern};
83
84/// Build-time helper for [`pattern_match`]: promote + compile the const
85/// pattern once. Panics on an un-compilable pattern, like
86/// [`compile_regex`].
87fn compile_promoted(pattern: &str) -> Regex {
88    compile_pattern(pattern).unwrap_or_else(|e| panic!("{e}")).0
89}
90
91/// Promoted pattern match: `true` if `input` matches `pattern` under the
92/// literal / glob / regex promotion rules. Sibling to `regex_match`
93/// (which takes a *raw* regex) — use this when the pattern may be a
94/// strict string, a `*` glob, or a regex and the kind should be
95/// auto-detected from its shape.
96#[polydat::polydat_node(category = Regex)]
97fn pattern_match(
98    input: &str,
99    pattern: polydat::derive_support::Const<&str>,
100    #[poly_const(compile_promoted, from = pattern)] re: &Regex,
101) -> bool {
102    re.is_match(input)
103}
104
105#[cfg(test)]
106mod pattern_tests {
107    use super::*;
108
109    #[test]
110    fn literal_is_strict_anchored() {
111        let (re, d) = compile_pattern("schema").unwrap();
112        assert_eq!(d, PatternDialect::Literal);
113        assert!(re.is_match("schema"));
114        assert!(!re.is_match("schema_v2"));
115        assert!(!re.is_match("pre_schema"));
116    }
117
118    #[test]
119    fn glob_star_distinguishes_suffix_series() {
120        // The motivating case: `*m` vs `*mi`.
121        let (m, dm) = compile_pattern("*m").unwrap();
122        assert_eq!(dm, PatternDialect::Glob);
123        assert!(m.is_match("100m"));
124        assert!(!m.is_match("100mi"));
125        let (mi, _) = compile_pattern("*mi").unwrap();
126        assert!(mi.is_match("100mi"));
127        assert!(!mi.is_match("100m"));
128    }
129
130    #[test]
131    fn regex_metachar_promotes() {
132        let (re, d) = compile_pattern("(100|200)m").unwrap();
133        assert_eq!(d, PatternDialect::Regex);
134        assert!(re.is_match("100m"));
135        assert!(re.is_match("200m"));
136        assert!(!re.is_match("300m"));
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use polydat::ast::{PolydatNode, Value};
144
145    #[test]
146    fn regex_replace_basic() {
147        let node = RegexReplace::new(r"\d+".to_string(), "NUM".to_string());
148        let mut out = [Value::None];
149        node.eval(&[Value::Str("abc 123 def 456".into())], &mut out);
150        assert_eq!(out[0].as_str(), "abc NUM def NUM");
151    }
152
153    #[test]
154    fn regex_replace_no_match() {
155        let node = RegexReplace::new(r"\d+".to_string(), "NUM".to_string());
156        let mut out = [Value::None];
157        node.eval(&[Value::Str("no numbers here".into())], &mut out);
158        assert_eq!(out[0].as_str(), "no numbers here");
159    }
160
161    #[test]
162    fn regex_match_true() {
163        let node = RegexMatch::new(r"^\d{3}-\d{4}$".to_string());
164        let mut out = [Value::None];
165        node.eval(&[Value::Str("123-4567".into())], &mut out);
166        assert!(out[0].as_bool());
167    }
168
169    #[test]
170    fn regex_match_false() {
171        let node = RegexMatch::new(r"^\d{3}-\d{4}$".to_string());
172        let mut out = [Value::None];
173        node.eval(&[Value::Str("hello".into())], &mut out);
174        assert!(!out[0].as_bool());
175    }
176
177    #[test]
178    fn regex_extract_capture_group() {
179        let node = RegexExtract::new(r"name=(\w+)".to_string());
180        let mut out = [Value::None];
181        node.eval(&[Value::Str("name=Alice age=30".into())], &mut out);
182        assert_eq!(out[0].as_str(), "Alice");
183    }
184
185    #[test]
186    fn regex_extract_no_group() {
187        let node = RegexExtract::new(r"\d+".to_string());
188        let mut out = [Value::None];
189        node.eval(&[Value::Str("abc 42 def".into())], &mut out);
190        assert_eq!(out[0].as_str(), "42");
191    }
192
193    #[test]
194    fn regex_extract_no_match() {
195        let node = RegexExtract::new(r"\d+".to_string());
196        let mut out = [Value::None];
197        node.eval(&[Value::Str("no digits".into())], &mut out);
198        assert_eq!(out[0].as_str(), "");
199    }
200}