Skip to main content

doge_runtime/stdlib/
hunt.rs

1//! `hunt` — regular expressions. The dog chases a pattern through a Str: `test`
2//! asks whether it matches, `find`/`find_all` return the matching text, `groups`
3//! pulls out capture groups, and `replace` swaps every match for a replacement.
4//!
5//! Every member returns matched *substrings* (Str) and Lists of Str, never byte
6//! offsets, so the char-vs-byte indexing guarantee never leaks to the user. An
7//! invalid pattern is a catchable `ValueError`; a no-match lookup reads back as
8//! `none`. Backed by the `regex` crate, whose linear-time engine cannot blow up on
9//! a pathological pattern — matching the runtime's never-panics guarantee.
10
11use regex::Regex;
12
13use crate::error::{DogeError, DogeResult};
14use crate::value::Value;
15
16/// A Str argument as `&str`, or a catchable type error naming the `hunt` member.
17/// Thin wrapper over the shared [`crate::stdlib::str_arg`].
18fn str_arg<'a>(fname: &str, v: &'a Value) -> DogeResult<&'a str> {
19    crate::stdlib::str_arg("hunt", fname, v)
20}
21
22/// Compile `pat` into a [`Regex`], or a catchable `ValueError` describing the flaw.
23/// The raw `regex::Error` never reaches the user — only the pattern and a hint.
24fn compile(fname: &str, pat: &Value) -> Result<Regex, DogeError> {
25    let pat = str_arg(fname, pat)?;
26    Regex::new(pat).map_err(|_| DogeError::value_error(format!("not a valid pattern: \"{pat}\"")))
27}
28
29/// `hunt.test(pat, text)` — whether `pat` matches anywhere in `text`.
30pub fn hunt_test(pat: &Value, text: &Value) -> DogeResult {
31    let re = compile("test", pat)?;
32    let text = str_arg("test", text)?;
33    Ok(Value::Bool(re.is_match(text)))
34}
35
36/// `hunt.find(pat, text)` — the first substring of `text` that matches `pat`, or
37/// `none` when there is no match.
38pub fn hunt_find(pat: &Value, text: &Value) -> DogeResult {
39    let re = compile("find", pat)?;
40    let text = str_arg("find", text)?;
41    Ok(match re.find(text) {
42        Some(m) => Value::str(m.as_str()),
43        None => Value::None,
44    })
45}
46
47/// `hunt.find_all(pat, text)` — a List of every non-overlapping match of `pat` in
48/// `text`, in order; an empty List when there is none.
49pub fn hunt_find_all(pat: &Value, text: &Value) -> DogeResult {
50    let re = compile("find_all", pat)?;
51    let text = str_arg("find_all", text)?;
52    Ok(Value::list(
53        re.find_iter(text).map(|m| Value::str(m.as_str())).collect(),
54    ))
55}
56
57/// `hunt.groups(pat, text)` — the capture groups of the first match, group 0 (the
58/// whole match) first; a group that did not participate is `none`. `none` overall
59/// when `pat` does not match.
60pub fn hunt_groups(pat: &Value, text: &Value) -> DogeResult {
61    let re = compile("groups", pat)?;
62    let text = str_arg("groups", text)?;
63    Ok(match re.captures(text) {
64        Some(caps) => Value::list(
65            caps.iter()
66                .map(|g| match g {
67                    Some(m) => Value::str(m.as_str()),
68                    None => Value::None,
69                })
70                .collect(),
71        ),
72        None => Value::None,
73    })
74}
75
76/// `hunt.replace(pat, text, repl)` — every match of `pat` in `text` swapped for
77/// `repl`, where `repl` may reference capture groups as `$1` or `${name}`.
78pub fn hunt_replace(pat: &Value, text: &Value, repl: &Value) -> DogeResult {
79    let re = compile("replace", pat)?;
80    let text = str_arg("replace", text)?;
81    let repl = str_arg("replace", repl)?;
82    Ok(Value::str(re.replace_all(text, repl)))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::error::ErrorKind;
89
90    fn s(v: &str) -> Value {
91        Value::str(v)
92    }
93
94    #[test]
95    fn test_matches_anywhere() {
96        assert!(matches!(
97            hunt_test(&s("[0-9]+"), &s("order 42")).unwrap(),
98            Value::Bool(true)
99        ));
100        assert!(matches!(
101            hunt_test(&s("^woof"), &s("bark")).unwrap(),
102            Value::Bool(false)
103        ));
104    }
105
106    #[test]
107    fn find_returns_first_match_or_none() {
108        assert!(
109            matches!(hunt_find(&s("[0-9]+"), &s("order 42")).unwrap(), Value::Str(m) if &*m == "42")
110        );
111        assert!(matches!(
112            hunt_find(&s("[0-9]+"), &s("no digits")).unwrap(),
113            Value::None
114        ));
115    }
116
117    #[test]
118    fn find_all_collects_every_match() {
119        match hunt_find_all(&s("[0-9]+"), &s("1 and 22 and 333")).unwrap() {
120            Value::List(items) => {
121                let items = items.borrow();
122                assert_eq!(items.len(), 3);
123                assert!(matches!(&items[2], Value::Str(m) if &**m == "333"));
124            }
125            _ => panic!("expected a list"),
126        }
127        match hunt_find_all(&s("[0-9]+"), &s("none here")).unwrap() {
128            Value::List(items) => assert!(items.borrow().is_empty()),
129            _ => panic!("expected a list"),
130        }
131    }
132
133    #[test]
134    fn groups_returns_whole_match_then_captures() {
135        match hunt_groups(&s("(\\w+)@(\\w+)"), &s("doge@shibe")).unwrap() {
136            Value::List(items) => {
137                let items = items.borrow();
138                assert_eq!(items.len(), 3);
139                assert!(matches!(&items[0], Value::Str(m) if &**m == "doge@shibe"));
140                assert!(matches!(&items[1], Value::Str(m) if &**m == "doge"));
141                assert!(matches!(&items[2], Value::Str(m) if &**m == "shibe"));
142            }
143            _ => panic!("expected a list"),
144        }
145        assert!(matches!(
146            hunt_groups(&s("(\\w+)@(\\w+)"), &s("no at sign")).unwrap(),
147            Value::None
148        ));
149    }
150
151    #[test]
152    fn groups_non_participating_is_none() {
153        match hunt_groups(&s("(a)|(b)"), &s("a")).unwrap() {
154            Value::List(items) => {
155                let items = items.borrow();
156                assert!(matches!(&items[1], Value::Str(m) if &**m == "a"));
157                assert!(matches!(&items[2], Value::None));
158            }
159            _ => panic!("expected a list"),
160        }
161    }
162
163    #[test]
164    fn replace_swaps_every_match_with_backrefs() {
165        assert!(matches!(
166            hunt_replace(&s("[0-9]+"), &s("a1b22c"), &s("#")).unwrap(),
167            Value::Str(m) if &*m == "a#b#c"
168        ));
169        assert!(matches!(
170            hunt_replace(&s("(\\w+)@(\\w+)"), &s("doge@shibe"), &s("$2.$1")).unwrap(),
171            Value::Str(m) if &*m == "shibe.doge"
172        ));
173    }
174
175    #[test]
176    fn invalid_pattern_is_a_value_error() {
177        assert_eq!(
178            hunt_find(&s("["), &s("x")).unwrap_err().kind,
179            ErrorKind::ValueError
180        );
181    }
182
183    #[test]
184    fn non_str_argument_is_a_type_error() {
185        assert_eq!(
186            hunt_test(&Value::int(1), &s("x")).unwrap_err().kind,
187            ErrorKind::TypeError
188        );
189        assert_eq!(
190            hunt_find(&s("x"), &Value::int(1)).unwrap_err().kind,
191            ErrorKind::TypeError
192        );
193    }
194}