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}