yolk/script/
stdlib.rs

1use miette::{IntoDiagnostic, Result};
2use rhai::{Dynamic, EvalAltResult, ImmutableString, Map, NativeCallContext};
3use rhai::{FuncRegistration, Module};
4use std::path::PathBuf;
5
6use regex::Regex;
7
8use crate::yolk::EvalMode;
9
10use super::sysinfo::{SystemInfo, SystemInfoPaths};
11
12macro_rules! if_canonical_return {
13    ($eval_mode:expr) => {
14        if $eval_mode == EvalMode::Canonical {
15            return Ok(Default::default());
16        }
17    };
18    ($eval_mode:expr, $value:expr) => {
19        if $eval_mode == EvalMode::Canonical {
20            return Ok($value);
21        }
22    };
23}
24
25type IStr = ImmutableString;
26type Ncc<'a> = NativeCallContext<'a>;
27
28pub fn global_stuff() -> Module {
29    let mut module = Module::new();
30
31    FuncRegistration::new("to_string")
32        .in_global_namespace()
33        .set_into_module(&mut module, |x: &mut SystemInfo| format!("{x:#?}"));
34    FuncRegistration::new("to_debug")
35        .in_global_namespace()
36        .set_into_module(&mut module, |x: &mut SystemInfo| format!("{x:?}"));
37    FuncRegistration::new("to_string")
38        .in_global_namespace()
39        .set_into_module(&mut module, |x: &mut SystemInfoPaths| format!("{x:#?}"));
40    FuncRegistration::new("to_debug")
41        .in_global_namespace()
42        .set_into_module(&mut module, |x: &mut SystemInfoPaths| format!("{x:?}"));
43    module
44}
45
46pub fn utils_module() -> Module {
47    let mut module = Module::new();
48    module.set_doc(indoc::indoc! {r"
49        # Utility functions
50
51        A collection of utility functions
52    "});
53
54    let regex_match = |pattern: String, haystack: String| -> Result<bool, Box<EvalAltResult>> {
55        Ok(create_regex(&pattern)?.is_match(&haystack))
56    };
57    FuncRegistration::new("regex_match")
58        .with_comments(["/// Check if a given string matches a given regex pattern."])
59        .with_params_info(["pattern: &str", "haystack: &str", "Result<bool>"])
60        .in_global_namespace()
61        .set_into_module(&mut module, regex_match);
62
63    let regex_replace = |pattern: String,
64                         haystack: String,
65                         replacement: String|
66     -> Result<String, Box<EvalAltResult>> {
67        Ok(create_regex(&pattern)?
68            .replace_all(&haystack, &*replacement)
69            .to_string())
70    };
71    FuncRegistration::new("regex_replace")
72        .with_comments(["/// Replace a regex pattern in a string with a replacement."])
73        .with_params_info([
74            "pattern: &str",
75            "haystack: &str",
76            "replacement: &str",
77            "Result<String>",
78        ])
79        .in_global_namespace()
80        .set_into_module(&mut module, regex_replace);
81
82    let regex_captures =
83        |pattern: String, s: String| -> Result<Option<Vec<String>>, Box<EvalAltResult>> {
84            Ok(create_regex(&pattern)?.captures(s.as_str()).map(|caps| {
85                (0..caps.len())
86                    .map(|x| caps.get(x).unwrap().as_str().to_string())
87                    .collect::<Vec<_>>()
88            }))
89        };
90    FuncRegistration::new("regex_captures")
91        .with_comments([
92            "/// Match a string against a regex pattern and return the capture groups as a list.",
93        ])
94        .with_params_info(["pattern: &str", "s: &str", "Result<Option<Vec<String>>>"])
95        .in_global_namespace()
96        .set_into_module(&mut module, regex_captures);
97
98    let rhai_color_hex_to_rgb = |hex_string: String| -> Result<Map, Box<EvalAltResult>> {
99        let (r, g, b, a) = color_hex_to_rgb(&hex_string)?;
100        let mut map = Map::new();
101        map.insert("r".to_string().into(), Dynamic::from_int(r as i64));
102        map.insert("g".to_string().into(), Dynamic::from_int(g as i64));
103        map.insert("b".to_string().into(), Dynamic::from_int(b as i64));
104        map.insert("a".to_string().into(), Dynamic::from_int(a as i64));
105        Ok(map)
106    };
107    FuncRegistration::new("color_hex_to_rgb")
108        .with_comments(["/// Convert a hex color string to an RGB map."])
109        .with_params_info(["hex_string: &str", "Result<Map>"])
110        .in_global_namespace()
111        .set_into_module(&mut module, rhai_color_hex_to_rgb);
112
113    FuncRegistration::new("color_hex_to_rgb")
114        .with_comments(["/// Convert a hex color string to an RGB map."])
115        .with_params_info(["hex_string: &str", "Result<Map>"])
116        .in_global_namespace()
117        .set_into_module(&mut module, rhai_color_hex_to_rgb);
118
119    let color_hex_to_rgb_str = |hex_string: String| -> Result<String, Box<EvalAltResult>> {
120        let (r, g, b, _) = color_hex_to_rgb(&hex_string)?;
121        Ok(format!("rgb({r}, {g}, {b})"))
122    };
123
124    FuncRegistration::new("color_hex_to_rgb_str")
125        .with_comments(["/// Convert a hex color string to an RGB string."])
126        .with_params_info(["hex_string: &str", "Result<String>"])
127        .in_global_namespace()
128        .set_into_module(&mut module, color_hex_to_rgb_str);
129
130    let color_hex_to_rgba_str = |hex_string: String| -> Result<String, Box<EvalAltResult>> {
131        let (r, g, b, a) = color_hex_to_rgb(&hex_string)?;
132        Ok(format!("rgba({r}, {g}, {b}, {a})"))
133    };
134    FuncRegistration::new("color_hex_to_rgba_str")
135        .with_comments(["/// Convert a hex color string to an RGBA string."])
136        .with_params_info(["hex_string: &str", "Result<String>"])
137        .in_global_namespace()
138        .set_into_module(&mut module, color_hex_to_rgba_str);
139
140    let color_rgb_to_hex = |rgb_table: Map| -> Result<String, Box<EvalAltResult>> {
141        let r = rgb_table
142            .get("r")
143            .map(dynamic_to_u8)
144            .transpose()?
145            .unwrap_or(0);
146        let g = rgb_table
147            .get("g")
148            .map(dynamic_to_u8)
149            .transpose()?
150            .unwrap_or(0);
151        let b = rgb_table
152            .get("b")
153            .map(dynamic_to_u8)
154            .transpose()?
155            .unwrap_or(0);
156        let a = rgb_table.get("a").map(dynamic_to_u8).transpose()?;
157        match a {
158            Some(a) => Ok(format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)),
159            None => Ok(format!("#{:02x}{:02x}{:02x}", r, g, b)),
160        }
161    };
162    FuncRegistration::new("color_rgb_to_hex")
163        .with_comments(["/// Convert an RGB map to a hex color string."])
164        .with_params_info(["rgb_table: Map", "Result<String>"])
165        .in_global_namespace()
166        .set_into_module(&mut module, color_rgb_to_hex);
167
168    module
169}
170
171pub fn io_module(eval_mode: EvalMode) -> Module {
172    use which::which_all_global;
173    let mut module = Module::new();
174    module.set_doc(indoc::indoc! {r"
175        # IO Functions
176
177        A collection of functions that can read the environment and filesystem.
178        These return standardized values in canonical mode.
179    "});
180
181    let command_available = move |name: IStr| -> RhaiFnResult<bool> {
182        if_canonical_return!(eval_mode, false);
183        Ok(match which_all_global(&*name) {
184            Ok(mut iter) => iter.next().is_some(),
185            Err(err) => {
186                tracing::warn!("Error checking if command is available: {}", err);
187                false
188            }
189        })
190    };
191    FuncRegistration::new("command_available")
192        .with_comments(["/// Check if a given command is available"])
193        .with_params_info(["name: &str", "Result<bool>"])
194        .set_into_module(&mut module, command_available);
195
196    let env = move |name: IStr, def: IStr| -> RhaiFnResult<IStr> {
197        if_canonical_return!(eval_mode, def.clone());
198        Ok(std::env::var(&*name).map(|x| x.into()).unwrap_or(def))
199    };
200    FuncRegistration::new("env")
201        .with_comments(["/// Read an environment variable, or return the given default"])
202        .with_params_info(["name: &str", "def: &str", "Result<String>"])
203        .set_into_module(&mut module, env);
204
205    let path_exists = move |p: IStr| -> RhaiFnResult<bool> {
206        if_canonical_return!(eval_mode, false);
207        Ok(PathBuf::from(&*p).exists())
208    };
209    FuncRegistration::new("path_exists")
210        .with_comments(["/// Check if something exists at a given path"])
211        .with_params_info(["p: &str", "Result<bool>"])
212        .set_into_module(&mut module, path_exists);
213
214    let path_is_dir = move |p: String| -> RhaiFnResult<bool> {
215        if_canonical_return!(eval_mode, false);
216        Ok(fs_err::metadata(p).map(|m| m.is_dir()).unwrap_or(false))
217    };
218    FuncRegistration::new("path_is_dir")
219        .with_comments(["/// Check if the given path is a directory"])
220        .with_params_info(["p: &str", "Result<bool>"])
221        .set_into_module(&mut module, path_is_dir);
222
223    let path_is_file = move |p: String| -> RhaiFnResult<bool> {
224        if_canonical_return!(eval_mode, false);
225        Ok(fs_err::metadata(p).map(|m| m.is_file()).unwrap_or(false))
226    };
227    FuncRegistration::new("path_is_file")
228        .with_comments(["/// Check if the given path is a file"])
229        .with_params_info(["p: &str", "Result<bool>"])
230        .set_into_module(&mut module, path_is_file);
231
232    let read_file = move |p: String| -> RhaiFnResult<String> {
233        if_canonical_return!(eval_mode, String::new());
234        Ok(fs_err::read_to_string(p).unwrap_or_default())
235    };
236    FuncRegistration::new("read_file")
237        .with_comments(["/// Read the contents of a given file"])
238        .with_params_info(["p: &str", "Result<String>"])
239        .set_into_module(&mut module, read_file);
240
241    let read_dir = move |p: String| -> RhaiFnResult<Vec<String>> {
242        if_canonical_return!(eval_mode, vec![]);
243        fs_err::read_dir(p)
244            .into_diagnostic()
245            .map_err(|e| e.to_string())?
246            .map(|x| {
247                Ok(x.map_err(|e| e.to_string())?
248                    .path()
249                    .to_string_lossy()
250                    .to_string())
251            })
252            .collect()
253    };
254    FuncRegistration::new("read_dir")
255        .with_comments(["/// Read the children of a given dir"])
256        .with_params_info(["p: &str", "Result<Vec<String>>"])
257        .set_into_module(&mut module, read_dir);
258
259    module
260}
261
262pub fn tag_module() -> Module {
263    use indoc::indoc;
264    let mut module = rhai::Module::new();
265    module.set_doc(indoc::indoc! {r"
266        # Template tag functions
267
268        Yolk template tags simply execute rhai functions that transform the block of text the tag operates on.
269
270        Quick reminder: Yolk has three different types of tags, that differ only in what text they operate on:
271
272        - Next-line tags (`{# ... #}`): These tags operate on the line following the tag.
273        - Inline tags (`{< ... >}`): These tags operate on everything before the tag within the same line.
274        - Block tags (`{% ... %} ... {% end %}`): These tags operate on everything between the tag and the corresponding `{% end %}` tag.
275
276        Inside these tags, you can call any of Yolks template tag functions (Or, in fact, any rhai expression that returns a string).
277    "});
278
279    fn tag_text_replace(text: &str, pattern: &str, replacement: &str) -> RhaiFnResult<String> {
280        let pattern = create_regex(pattern)?;
281        let after_replace = pattern.replace(text, replacement);
282        if let Some(original_value) = pattern.find(text) {
283            let original_value = original_value.as_str();
284            let reversed = pattern.replace(&after_replace, original_value);
285            if reversed != text {
286                return Err(format!(
287                    "Refusing to run non-reversible replacement: {text} -> {after_replace}",
288                )
289                .into());
290            }
291        };
292        Ok(after_replace.to_string())
293    }
294
295    let f = |ctx: Ncc, regex: IStr, replacement: IStr| -> RhaiFnResult<_> {
296        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
297        tag_text_replace(&text, &regex, &replacement)
298    };
299    FuncRegistration::new("replace_re")
300        .with_comments([indoc! {"
301            /// **shorthand**: `rr`.
302            ///
303            /// Replaces all occurrences of a Regex `pattern` with `replacement` in the text.
304            ///
305            /// #### Example
306            ///
307            /// ```handlebars
308            /// ui_font = \"Arial\" # {< replace_re(`\".*\"`, `\"{data.font.ui}\"`) >}
309            /// ```
310            ///
311            /// Note that the replacement value needs to contain the quotes, as those are also matched against in the regex pattern.
312            /// Otherwise, we would end up with invalid toml.
313        "}])
314        .with_params_info(["regex: &str", "replacement: &str", "Result<String>"])
315        .with_namespace(rhai::FnNamespace::Global)
316        .set_into_module(&mut module, f);
317    FuncRegistration::new("rr")
318        .in_global_namespace()
319        .set_into_module(&mut module, f);
320
321    let f = |ctx: Ncc, between: IStr, replacement: IStr| -> RhaiFnResult<_> {
322        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
323        let regex = format!("{between}[^{between}]*{between}");
324        tag_text_replace(&text, &regex, &format!("{between}{replacement}{between}"))
325    };
326    FuncRegistration::new("replace_in")
327        .with_comments([indoc! {"
328            /// **shorthand**: `rin`.
329            ///
330            /// Replaces the text between two delimiters with the `replacement`.
331            ///
332            /// #### Example
333            ///
334            /// ```toml
335            /// ui_font = \"Arial\" # {< replace_in(`\"`, data.font.ui) >}
336            /// ```
337            ///
338            /// Note: we don't need to include the quotes in the replacement here.
339        "}])
340        .with_params_info(["between: &str", "replacement: &str", "Result<String>"])
341        .in_global_namespace()
342        .set_into_module(&mut module, f);
343    FuncRegistration::new("rin")
344        .in_global_namespace()
345        .set_into_module(&mut module, f);
346
347    let f = |ctx: Ncc, left: IStr, right: IStr, replacement: IStr| -> RhaiFnResult<_> {
348        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
349        let regex = format!("{left}[^{right}]*{right}");
350        tag_text_replace(&text, &regex, &format!("{left}{replacement}{right}"))
351    };
352    FuncRegistration::new("replace_between")
353        .with_comments([indoc! {"
354            /// **shorthand**: `rbet`.
355            ///
356            /// Replaces the text between two delimiters with the `replacement`.
357            ///
358            /// #### Example
359            ///
360            /// ```handlebars
361            /// ui_font = (Arial) # {< replace_between(`(`, `)`, data.font.ui) >}
362            /// ```
363            ///
364            /// Note: we don't need to include the quotes in the replacement here.
365        "}])
366        .with_params_info([
367            "left: &str",
368            "right: &str",
369            "replacement: &str",
370            "Result<String>",
371        ])
372        .in_global_namespace()
373        .set_into_module(&mut module, f);
374    FuncRegistration::new("rbet")
375        .in_global_namespace()
376        .set_into_module(&mut module, f);
377
378    let f = |ctx: Ncc, replacement: IStr| -> RhaiFnResult<_> {
379        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
380        tag_text_replace(
381            &text,
382            r"#[\da-fA-F]{6}([\da-fA-F]{2})?",
383            replacement.as_ref(),
384        )
385    };
386    FuncRegistration::new("replace_color")
387        .with_comments([indoc! {"
388            /// **shorthand**: `rcol`.
389            ///
390            /// Replaces a hex color value with a new hex color.
391            ///
392            /// #### Example
393            ///
394            /// ```handlebars
395            /// background_color = \"#282828\" # {< replace_color(data.colors.bg) >}
396            /// ```
397        "}])
398        .with_params_info(["replacement: &str", "Result<String>"])
399        .in_global_namespace()
400        .set_into_module(&mut module, f);
401    FuncRegistration::new("rcol")
402        .in_global_namespace()
403        .set_into_module(&mut module, f);
404
405    let f = |ctx: Ncc, replacement: Dynamic| -> RhaiFnResult<_> {
406        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
407        tag_text_replace(&text, r"-?\d+(?:\.\d+)?", &replacement.to_string())
408    };
409    FuncRegistration::new("replace_number")
410        .with_comments([indoc! {"
411            /// **shorthand**: `rnum`.
412            ///
413            /// Replaces a number with another number.
414            ///
415            /// #### Example
416            ///
417            /// ```handlebars
418            /// cursor_size = 32 # {< replace_number(data.cursor_size) >}
419            /// ```
420        "}])
421        .with_params_info(["replacement: Dynamic", "Result<String>"])
422        .in_global_namespace()
423        .set_into_module(&mut module, f);
424    FuncRegistration::new("rnum")
425        .in_global_namespace()
426        .set_into_module(&mut module, f);
427
428    let f = |ctx: Ncc, replacement: IStr| -> RhaiFnResult<_> {
429        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
430        let mut result = tag_text_replace(&text, r#"".*""#, &format!("\"{replacement}\""))?;
431        if result == text {
432            result = tag_text_replace(&text, r#"`.*`"#, &format!("`{replacement}`"))?;
433        }
434        if result == text {
435            result = tag_text_replace(&text, r#"'.*'"#, &format!("'{replacement}'"))?;
436        }
437        Ok(result)
438    };
439    FuncRegistration::new("replace_quoted")
440        .with_comments([indoc! {"
441            /// **shorthand**: `rq`.
442            ///
443            /// Replaces a value between quotes with another value
444            ///
445            /// #### Example
446            ///
447            /// ```handlebars
448            /// ui_font = \"Arial\" # {< replace_quoted(data.font.ui) >}
449            /// ```
450        "}])
451        .with_params_info(["replacement: &str", "Result<String>"])
452        .in_global_namespace()
453        .set_into_module(&mut module, f);
454    FuncRegistration::new("rq")
455        .in_global_namespace()
456        .set_into_module(&mut module, f);
457
458    let f = |ctx: Ncc, replacement: IStr| -> RhaiFnResult<_> {
459        let text: IStr = ctx.call_fn("get_yolk_text", ())?;
460        let regex = create_regex(r"([=:])( *)([^\s]+)").unwrap();
461
462        if let Some(caps) = regex.captures(&text) {
463            let full_match = caps.get(0).unwrap();
464            let equals = caps.get(1).unwrap();
465            let space = caps.get(2).unwrap();
466            let new_value = regex.replace(
467                &text,
468                format!("{}{}{}", equals.as_str(), space.as_str(), replacement),
469            );
470            if regex.replace(&new_value, full_match.as_str()) == *text {
471                Ok(new_value.to_string())
472            } else {
473                Err(
474                    format!("Refusing to run non-reversible replacement: {text} -> {new_value}",)
475                        .into(),
476                )
477            }
478        } else {
479            Ok(text.into())
480        }
481    };
482    FuncRegistration::new("replace_value")
483        .with_comments([indoc! {"
484            /// **shorthand**: `rv`.
485            ///
486            /// Replaces a value (without spaces) after a `:` or a `=` with another value
487            ///
488            /// #### Example
489            ///
490            /// ```handlebars
491            /// ui_font = Arial # {< replace_value(data.font.ui) >}
492            /// ```
493        "}])
494        .with_params_info(["replacement: &str", "Result<String>"])
495        .in_global_namespace()
496        .set_into_module(&mut module, f);
497    FuncRegistration::new("rv")
498        .in_global_namespace()
499        .set_into_module(&mut module, f);
500
501    module
502}
503
504fn dynamic_to_u8(x: &Dynamic) -> RhaiFnResult<u8> {
505    let int = x
506        .as_int()
507        .map_err(|actual| format!("Failed to convert {actual} to int"))?;
508    let int = int
509        .try_into()
510        .map_err(|_| format!("Failed to convert {int} to u8"))?;
511    Ok(int)
512}
513
514fn color_hex_to_rgb(hex_string: &str) -> Result<(u8, u8, u8, u8), Box<EvalAltResult>> {
515    let hex = hex_string.trim_start_matches('#');
516    if hex.len() != 6 && hex.len() != 8 {
517        return Err(format!("Invalid hex color: {}", hex_string).into());
518    }
519    let r = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
520    let g = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
521    let b = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
522    let a = if hex.len() == 8 {
523        u8::from_str_radix(&hex[6..8], 16).map_err(|e| e.to_string())?
524    } else {
525        255
526    };
527    Ok((r, g, b, a))
528}
529
530type RhaiFnResult<T> = Result<T, Box<EvalAltResult>>;
531
532fn create_regex(s: &str) -> RhaiFnResult<Regex> {
533    Ok(crate::util::create_regex(s).map_err(|e| e.to_string())?)
534}
535
536#[cfg(not(feature = "docgen"))]
537#[extend::ext]
538impl FuncRegistration {
539    fn with_comments(self, _: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
540        self
541    }
542    fn with_params_info(self, _: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
543        self
544    }
545}
546#[cfg(not(feature = "docgen"))]
547#[extend::ext]
548impl Module {
549    fn set_doc(&mut self, _: &str) {}
550}
551
552#[cfg(test)]
553mod test {
554    use crate::util::test_util::TestResult;
555    use miette::IntoDiagnostic as _;
556    use rhai::Variant;
557
558    use crate::{script::eval_ctx::EvalCtx, yolk::EvalMode};
559
560    pub fn run_expr<T: Variant + Clone>(code: &str) -> miette::Result<T> {
561        let mut eval_ctx = EvalCtx::new_in_mode(EvalMode::Local)?;
562        Ok(eval_ctx.eval_rhai::<T>(code)?)
563    }
564    pub fn run_tag_expr(text: &str, code: &str) -> miette::Result<String> {
565        let text = text.to_string();
566        let mut eval_ctx = EvalCtx::new_in_mode(EvalMode::Local)?;
567        eval_ctx
568            .engine_mut()
569            .register_fn("get_yolk_text", move || text.clone());
570        eval_ctx.eval_rhai::<String>(code).into_diagnostic()
571    }
572
573    use rstest::rstest;
574
575    #[rstest]
576    #[case::match_found(Some(vec![
577        "<aaaXb>".to_string(),
578        "aaa".to_string(),
579        "b".to_string()
580    ]), "regex_captures(`<(.*)X(.)>`, `foo <aaaXb> bar`)")]
581    #[case::no_match(None, "regex_captures(`<(.*)X(.)>`, `asdf`)")]
582    pub fn test_regex_captures(
583        #[case] expected: Option<Vec<String>>,
584        #[case] expr: &str,
585    ) -> TestResult {
586        assert_eq!(expected, run_expr::<Option<Vec<String>>>(expr)?);
587        Ok(())
588    }
589
590    #[rstest]
591    #[case::replace("foo:'aaa'", "replace_re(`'.*'`, `'xxx'`)", "foo:'xxx'")]
592    #[case::non_reversible("foo:'aaa'", "replace_re(`'.*'`, `xxx`)", "foo:'aaa'")]
593    pub fn test_replace(
594        #[case] input: &str,
595        #[case] expr: &str,
596        #[case] expected: &str,
597    ) -> TestResult {
598        if expected == input {
599            assert!(
600                run_tag_expr(input, expr).is_err(),
601                "replace performed non-reversible replacement",
602            );
603        } else {
604            assert_eq!(expected, run_tag_expr(input, expr)?);
605        }
606        Ok(())
607    }
608
609    #[rstest]
610    #[case::replace("foo:'aaa'", "replace_in(`'`, `xxx`)", "foo:'xxx'")]
611    #[case::non_reversible("foo:'aaa'", "replace_in(`'`, `x'xx`)", "foo:'aaa'")]
612    pub fn test_replace_in(
613        #[case] input: &str,
614        #[case] expr: &str,
615        #[case] expected: &str,
616    ) -> TestResult {
617        if expected == input {
618            assert!(
619                run_tag_expr(input, expr).is_err(),
620                "replace performed non-reversible replacement",
621            );
622        } else {
623            assert_eq!(expected, run_tag_expr(input, expr)?);
624        }
625        Ok(())
626    }
627
628    #[rstest]
629    #[case::replace("foo: #ff0000", "replace_color(`#00ff00`)", "foo: #00ff00")]
630    #[case::replace_alpha("foo: #ff0000", "replace_color(`#00ff0000`)", "foo: #00ff0000")]
631    #[case::non_reversible_no_hash("foo: #ff0000", "replace_color(`00ff00`)", "foo: #ff0000")]
632    #[case::non_reversible_bad_color("foo: #ff0000", "replace_color(`bad color`)", "foo: #ff0000")]
633    pub fn test_replace_color(
634        #[case] input: &str,
635        #[case] expr: &str,
636        #[case] expected: &str,
637    ) -> TestResult {
638        if expected == input {
639            assert!(
640                run_tag_expr(input, expr).is_err(),
641                "replace_color performed non-reversible replacement",
642            );
643        } else {
644            assert_eq!(expected, run_tag_expr(input, expr)?);
645        }
646        Ok(())
647    }
648
649    #[rstest]
650    #[case::single_quote("foo: 'old'", "replace_quoted(`new`)", "foo: 'new'")]
651    #[case::double_quote("foo: \"old\"", "replace_quoted(`new`)", "foo: \"new\"")]
652    #[case::backtick("foo: `old`", "replace_quoted(`new`)", "foo: `new`")]
653    pub fn test_replace_quoted(
654        #[case] input: &str,
655        #[case] expr: &str,
656        #[case] expected: &str,
657    ) -> TestResult {
658        assert_eq!(expected, run_tag_expr(input, expr)?);
659        Ok(())
660    }
661
662    #[rstest]
663    #[case::replace("foo: bar # baz", "replace_value(`xxx`)", "foo: xxx # baz")]
664    #[case::non_reversible("foo: bar # baz", "replace_value(`x xx`)", "foo: bar # baz")]
665    pub fn test_replace_value(
666        #[case] input: &str,
667        #[case] expr: &str,
668        #[case] expected: &str,
669    ) -> TestResult {
670        if expected == input {
671            assert!(
672                run_tag_expr(input, expr).is_err(),
673                "replace_value performed non-reversible replacement",
674            );
675        } else {
676            assert_eq!(expected, run_tag_expr(input, expr)?);
677        }
678        Ok(())
679    }
680
681    #[rstest]
682    #[case::integer("foo 123 bar", "replace_number(999)", "foo 999 bar")]
683    #[case::float("foo 1.23 bar", "replace_number(99.9)", "foo 99.9 bar")]
684    #[case::non_reversible("foo 99.9 bar", "replace_number(`hi`)", "foo 99.9 bar")]
685    pub fn test_replace_number(
686        #[case] input: &str,
687        #[case] expr: &str,
688        #[case] expected: &str,
689    ) -> TestResult {
690        if expected == input {
691            assert!(
692                run_tag_expr(input, expr).is_err(),
693                "replace_number performed non-reversible replacement",
694            );
695        } else {
696            assert_eq!(expected, run_tag_expr(input, expr)?);
697        }
698        Ok(())
699    }
700}