1fn closing(open: char) -> Option<char> {
10 match open {
11 '{' => Some('}'),
12 '(' => Some(')'),
13 '[' => Some(']'),
14 _ => None,
15 }
16}
17
18pub fn match_delim(s: &str, open: usize) -> Option<usize> {
24 let open_ch = s[open..].chars().next()?;
25 let close_ch = closing(open_ch)?;
26 let mut depth = 0usize;
27 let mut it = s[open..].char_indices();
28 while let Some((i, c)) = it.next() {
29 match c {
30 '"' | '\'' | '`' => skip_string(&mut it, c),
31 _ if c == open_ch => depth += 1,
32 _ if c == close_ch => {
33 depth -= 1;
34 if depth == 0 {
35 return Some(open + i);
36 }
37 }
38 _ => {}
39 }
40 }
41 None
42}
43
44fn skip_string(it: &mut std::str::CharIndices<'_>, quote: char) {
46 let mut escaped = false;
47 for (_, c) in it.by_ref() {
48 if escaped {
49 escaped = false;
50 } else if c == '\\' {
51 escaped = true;
52 } else if c == quote {
53 return;
54 }
55 }
56}
57
58pub fn split_top_level(s: &str, sep: char) -> Vec<&str> {
62 let mut out = Vec::new();
63 let mut depth = 0i32;
64 let mut start = 0usize;
65 let mut it = s.char_indices();
66 while let Some((i, c)) = it.next() {
67 match c {
68 '"' | '\'' | '`' => skip_string(&mut it, c),
69 '{' | '(' | '[' => depth += 1,
70 '}' | ')' | ']' => depth -= 1,
71 _ if c == sep && depth == 0 => {
72 push_trimmed(&mut out, &s[start..i]);
73 start = i + c.len_utf8();
74 }
75 _ => {}
76 }
77 }
78 push_trimmed(&mut out, &s[start..]);
79 out
80}
81
82fn push_trimmed<'a>(out: &mut Vec<&'a str>, piece: &'a str) {
83 let piece = piece.trim();
84 if !piece.is_empty() {
85 out.push(piece);
86 }
87}
88
89pub fn find_top_level(s: &str, needle: &str) -> Option<usize> {
92 let mut depth = 0i32;
93 let mut it = s.char_indices();
94 while let Some((i, c)) = it.next() {
95 if depth == 0 && s[i..].starts_with(needle) {
96 return Some(i);
97 }
98 match c {
99 '"' | '\'' | '`' => skip_string(&mut it, c),
100 '{' | '(' | '[' => depth += 1,
101 '}' | ')' | ']' => depth -= 1,
102 _ => {}
103 }
104 }
105 None
106}
107
108pub fn strip_comments(s: &str) -> String {
111 let mut out = String::with_capacity(s.len());
112 let mut it = s.char_indices();
113 while let Some((i, c)) = it.next() {
114 match c {
115 '/' if s[i..].starts_with("//") => {
116 for (_, c) in it.by_ref() {
117 if c == '\n' {
118 break;
119 }
120 }
121 out.push('\n');
122 }
123 '/' if s[i..].starts_with("/*") => {
124 let mut prev = ' ';
125 for (_, c) in it.by_ref() {
126 if c == '\n' {
127 out.push('\n');
128 }
129 if prev == '*' && c == '/' {
130 break;
131 }
132 prev = c;
133 }
134 }
135 '"' | '\'' | '`' => {
136 out.push(c);
137 let quote = c;
138 let mut escaped = false;
139 for (_, c) in it.by_ref() {
140 out.push(c);
141 if escaped {
142 escaped = false;
143 } else if c == '\\' {
144 escaped = true;
145 } else if c == quote {
146 break;
147 }
148 }
149 }
150 _ => out.push(c),
151 }
152 }
153 out
154}
155
156pub fn blank_lifetimes(s: &str) -> String {
175 let mut out = String::with_capacity(s.len());
176 let mut rest = s;
177 while let Some(at) = rest.find('\'') {
178 out.push_str(&rest[..at]);
179 let after = &rest[at + 1..];
180 let name = read_ident(after, 0);
181 let is_lifetime = !name.is_empty() && !after[name.len()..].starts_with('\'');
184 out.push(if is_lifetime { ' ' } else { '\'' });
185 rest = after;
186 }
187 out.push_str(rest);
188 out
189}
190
191pub fn read_ident(s: &str, at: usize) -> &str {
193 let rest = &s[at..];
194 let end = rest
195 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
196 .unwrap_or(rest.len());
197 &rest[..end]
198}
199
200pub fn screaming_snake_to_pascal(s: &str) -> String {
202 s.split('_')
203 .filter(|part| !part.is_empty())
204 .map(|part| {
205 let mut c = part.chars();
206 match c.next() {
207 Some(first) => first.to_ascii_uppercase().to_string() + &c.as_str().to_lowercase(),
208 None => String::new(),
209 }
210 })
211 .collect()
212}
213
214pub fn pascal_to_screaming_snake(s: &str) -> String {
220 let chars: Vec<char> = s.chars().collect();
221 let mut out = String::with_capacity(s.len() + 4);
222 for (i, &c) in chars.iter().enumerate() {
223 if c.is_uppercase() && i > 0 {
224 let prev = chars[i - 1];
225 let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
226 if !prev.is_uppercase() || next_is_lower {
227 out.push('_');
228 }
229 }
230 out.push(c.to_ascii_uppercase());
231 }
232 out
233}
234
235pub fn snake_to_camel(s: &str) -> String {
237 let pascal = snake_to_pascal(s);
238 let mut c = pascal.chars();
239 match c.next() {
240 Some(first) => first.to_lowercase().to_string() + c.as_str(),
241 None => String::new(),
242 }
243}
244
245pub fn snake_to_pascal(s: &str) -> String {
247 s.split('_')
248 .map(|part| {
249 let mut c = part.chars();
250 match c.next() {
251 Some(first) => first.to_uppercase().to_string() + c.as_str(),
252 None => String::new(),
253 }
254 })
255 .collect()
256}
257
258pub fn apply_rename_all(name: &str, rule: &str) -> String {
263 match rule {
264 "lowercase" => name.replace('_', "").to_lowercase(),
265 "UPPERCASE" => name.replace('_', "").to_uppercase(),
266 "PascalCase" => snake_to_pascal(name),
267 "camelCase" => snake_to_camel(name),
268 "snake_case" => name.to_string(),
269 "SCREAMING_SNAKE_CASE" => name.to_uppercase(),
270 "kebab-case" => name.replace('_', "-"),
271 "SCREAMING-KEBAB-CASE" => name.to_uppercase().replace('_', "-"),
272 _ => name.to_string(),
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn matches_nested_delimiters_and_skips_strings() {
282 let s = "a({ b: \"})\" , c: (1) })x";
283 let open = s.find('(').unwrap();
284 let close = match_delim(s, open).unwrap();
285 assert_eq!(&s[open..=close], "({ b: \"})\" , c: (1) })");
286 }
287
288 #[test]
289 fn splits_only_at_depth_zero() {
290 let parts = split_top_level("a: z.foo(1, 2), b: [3, 4], c: \"x,y\",", ',');
291 assert_eq!(parts, vec!["a: z.foo(1, 2)", "b: [3, 4]", "c: \"x,y\""]);
292 }
293
294 #[test]
295 fn find_top_level_ignores_nested_matches() {
296 assert_eq!(
297 find_top_level("z.array(z.any().optional())", ".optional("),
298 None
299 );
300 assert_eq!(
301 find_top_level("z.string().optional()", ".optional("),
302 Some(10)
303 );
304 }
305
306 #[test]
310 fn lifetimes_are_blanked_so_they_cannot_open_a_string() {
311 let src = "fn f<'de, D>(d: D) -> &'static str { \"x\" }\nstruct S { a: u8 }\n";
312 let blanked = blank_lifetimes(src);
313 assert_eq!(blanked.len(), src.len(), "offsets must survive");
314 assert!(blanked.contains("fn f< de, D>"), "{blanked}");
315 assert!(blanked.contains("& static str"), "{blanked}");
316
317 let open = blanked.rfind('{').unwrap();
318 assert_eq!(
319 match_delim(&blanked, open).map(|c| &blanked[open..=c]),
320 Some("{ a: u8 }")
321 );
322 }
323
324 #[test]
327 fn a_lifetime_does_not_swallow_the_comments_after_it() {
328 let src = "fn f() -> &'static str { \"x\" }\n/// see [`crate::event`]\nstruct S;\n";
329 let out = strip_comments(&blank_lifetimes(src));
330 assert!(!out.contains("crate::event"), "{out}");
331 }
332
333 #[test]
334 fn char_literals_and_single_quoted_strings_are_left_as_strings() {
335 let rust = "match c { '}' => 1, 'a' => 2, _ => 0 }";
338 let blanked = blank_lifetimes(rust);
339 assert_eq!(blanked, rust);
340 let open = rust.find('{').unwrap();
341 assert_eq!(match_delim(rust, open), Some(rust.len() - 1));
342 assert_eq!(
345 split_top_level("a: 'x,y', b: 1", ','),
346 vec!["a: 'x,y'", "b: 1"]
347 );
348 }
349
350 #[test]
351 fn strips_comments_but_keeps_lines() {
352 let src = "a // one\nb /* two\nthree */ c\nd \"// not a comment\"\n";
353 let out = strip_comments(src);
354 assert_eq!(out.lines().count(), src.lines().count());
355 assert!(!out.contains("one"));
356 assert!(!out.contains("three"));
357 assert!(out.contains("// not a comment"));
358 }
359
360 #[test]
361 fn case_conversions_round_trip_event_names() {
362 assert_eq!(
363 screaming_snake_to_pascal("TEXT_MESSAGE_START"),
364 "TextMessageStart"
365 );
366 assert_eq!(
367 pascal_to_screaming_snake("TextMessageStart"),
368 "TEXT_MESSAGE_START"
369 );
370 assert_eq!(pascal_to_screaming_snake("Raw"), "RAW");
371 assert_eq!(
372 pascal_to_screaming_snake("ReasoningEncryptedValue"),
373 "REASONING_ENCRYPTED_VALUE"
374 );
375 assert_eq!(snake_to_camel("message_id"), "messageId");
376 assert_eq!(apply_rename_all("message_id", "camelCase"), "messageId");
377 assert_eq!(
378 apply_rename_all("tool_call_id", "SCREAMING_SNAKE_CASE"),
379 "TOOL_CALL_ID"
380 );
381 }
382}