vrl/stdlib/casing/
snakecase.rs

1use crate::compiler::function::EnumVariant;
2use crate::compiler::prelude::*;
3
4use crate::stdlib::casing::{ORIGINAL_CASE, into_case};
5use convert_case::Case;
6
7use super::into_boundary;
8
9#[derive(Clone, Copy, Debug)]
10pub struct Snakecase;
11
12impl Function for Snakecase {
13    fn identifier(&self) -> &'static str {
14        "snakecase"
15    }
16
17    fn usage(&self) -> &'static str {
18        "Takes the `value` string, and turns it into snake_case. Optionally, you can pass in the existing case of the function, or else we will try to figure out the case automatically."
19    }
20
21    fn category(&self) -> &'static str {
22        Category::String.as_ref()
23    }
24
25    fn return_kind(&self) -> u16 {
26        kind::BYTES
27    }
28
29    fn parameters(&self) -> &'static [Parameter] {
30        const PARAMETERS: &[Parameter] = &[
31            Parameter::required("value", kind::BYTES, "The string to convert to snake_case."),
32            ORIGINAL_CASE,
33            Parameter::optional("excluded_boundaries", kind::ARRAY, "Case boundaries to exclude during conversion.")
34                .enum_variants(&[
35                    EnumVariant {
36                        value: "lower_upper",
37                        description: "Lowercase to uppercase transitions (e.g., 'camelCase' → 'camel' + 'case')",
38                    },
39                    EnumVariant {
40                        value: "upper_lower",
41                        description: "Uppercase to lowercase transitions (e.g., 'CamelCase' → 'Camel' + 'Case')",
42                    },
43                    EnumVariant {
44                        value: "acronym",
45                        description: "Acronyms from words (e.g., 'XMLHttpRequest' → 'xmlhttp' + 'request')",
46                    },
47                    EnumVariant {
48                        value: "lower_digit",
49                        description: "Lowercase to digit transitions (e.g., 'foo2bar' → 'foo2_bar')",
50                    },
51                    EnumVariant {
52                        value: "upper_digit",
53                        description: "Uppercase to digit transitions (e.g., 'versionV2' → 'version_v2')",
54                    },
55                    EnumVariant {
56                        value: "digit_lower",
57                        description: "Digit to lowercase transitions (e.g., 'Foo123barBaz' → 'foo' + '123bar' + 'baz')",
58                    },
59                    EnumVariant {
60                        value: "digit_upper",
61                        description: "Digit to uppercase transitions (e.g., 'Version123Test' → 'version' + '123test')",
62                    },
63                ]),
64        ];
65        PARAMETERS
66    }
67
68    fn compile(
69        &self,
70        state: &state::TypeState,
71        _ctx: &mut FunctionCompileContext,
72        arguments: ArgumentList,
73    ) -> Compiled {
74        let value = arguments.required("value");
75        let original_case = arguments
76            .optional_enum("original_case", &super::variants(), state)?
77            .map(|b| {
78                into_case(
79                    b.try_bytes_utf8_lossy()
80                        .expect("cant convert to string")
81                        .as_ref(),
82                )
83            })
84            .transpose()?;
85
86        let excluded_boundaries = arguments
87            .optional_array("excluded_boundaries")?
88            .map(|arr| {
89                let mut boundaries = Vec::new();
90                for expr in arr {
91                    let value = expr.resolve_constant(state).ok_or_else(
92                        || -> Box<dyn DiagnosticMessage> {
93                            Box::new(ExpressionError::from(
94                                "expected static string for excluded_boundaries",
95                            ))
96                        },
97                    )?;
98                    let boundary = into_boundary(
99                        value
100                            .try_bytes_utf8_lossy()
101                            .expect("cant convert to string")
102                            .as_ref(),
103                    )?;
104                    boundaries.push(boundary);
105                }
106                Ok::<_, Box<dyn DiagnosticMessage>>(boundaries)
107            })
108            .transpose()?;
109
110        Ok(SnakecaseFn {
111            value,
112            original_case,
113            excluded_boundaries,
114        }
115        .as_expr())
116    }
117
118    fn examples(&self) -> &'static [Example] {
119        &[
120            example! {
121                title: "snake_case a string",
122                source: r#"snakecase("input-string")"#,
123                result: Ok("input_string"),
124            },
125            example! {
126                title: "snake_case a string with original case",
127                source: r#"snakecase("input-string", original_case: "kebab-case")"#,
128                result: Ok("input_string"),
129            },
130            example! {
131                title: "snake_case with excluded boundaries",
132                source: r#"snakecase("s3BucketDetails", excluded_boundaries: ["lower_digit"])"#,
133                result: Ok("s3_bucket_details"),
134            },
135        ]
136    }
137}
138
139#[derive(Debug, Clone)]
140struct SnakecaseFn {
141    value: Box<dyn Expression>,
142    original_case: Option<Case>,
143    excluded_boundaries: Option<Vec<convert_case::Boundary>>,
144}
145
146impl FunctionExpression for SnakecaseFn {
147    fn resolve(&self, ctx: &mut Context) -> Resolved {
148        let value = self.value.resolve(ctx)?;
149        let string_value = value
150            .try_bytes_utf8_lossy()
151            .expect("can't convert to string");
152
153        match &self.excluded_boundaries {
154            Some(boundaries) if !boundaries.is_empty() => {
155                Ok(super::convert_case_with_excluded_boundaries(
156                    &string_value,
157                    Case::Snake,
158                    self.original_case,
159                    boundaries.as_slice(),
160                ))
161            }
162            _ => super::convert_case(&value, Case::Snake, self.original_case),
163        }
164    }
165
166    fn type_def(&self, _: &state::TypeState) -> TypeDef {
167        TypeDef::bytes().infallible()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::value;
175
176    test_function![
177        snakecase => Snakecase;
178
179        simple {
180            args: func_args![value: value!("camelCase"), original_case: "camelCase"],
181            want: Ok(value!("camel_case")),
182            tdef: TypeDef::bytes(),
183        }
184
185        no_case {
186            args: func_args![value: value!("camelCase")],
187            want: Ok(value!("camel_case")),
188            tdef: TypeDef::bytes(),
189        }
190
191        with_empty_excluded_boundary {
192            args: func_args![value: value!("camelCase"), excluded_boundaries: value!([])],
193            want: Ok(value!("camel_case")),
194            tdef: TypeDef::bytes(),
195        }
196
197        with_lower_upper_excluded {
198            args: func_args![value: value!("camelCase"), excluded_boundaries: value!(["lower_upper"])],
199            want: Ok(value!("camelcase")),
200            tdef: TypeDef::bytes(),
201        }
202
203        with_s3_bucket_details {
204            args: func_args![value: value!("s3BucketDetails")],
205            want: Ok(value!("s_3_bucket_details")),
206            tdef: TypeDef::bytes(),
207        }
208
209        with_s3_bucket_details_exclude_acronym {
210            args: func_args![value: value!("s3BucketDetails"), excluded_boundaries: value!(["digit_lower", "lower_digit", "upper_digit"])],
211            want: Ok(value!("s3_bucket_details")),
212            tdef: TypeDef::bytes(),
213        }
214    ];
215}