Skip to main content

xtask/drift/
text.rs

1//! Small text utilities shared by the TypeScript and Rust scanners.
2//!
3//! Both scanners read source as plain text — the drift check must work when
4//! `ag-ui` does not compile and when no TypeScript toolchain is present.
5//! Everything here is therefore delimiter counting with string-literal
6//! awareness, nothing more.
7
8/// The closing delimiter for an opening one.
9fn closing(open: char) -> Option<char> {
10    match open {
11        '{' => Some('}'),
12        '(' => Some(')'),
13        '[' => Some(']'),
14        _ => None,
15    }
16}
17
18/// Byte index of the delimiter matching the one at `open`, or `None` when the
19/// source is unbalanced.
20///
21/// `open` must be a byte index of an opening delimiter in `s`. Nested
22/// delimiters and string literals (`"`, `'`, backtick) are skipped.
23pub 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
44/// Consumes an iterator up to and including the closing `quote`.
45fn 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
58/// Splits `s` on `sep` occurrences that sit outside every delimiter pair and
59/// string literal. Empty (whitespace-only) pieces are dropped, so a trailing
60/// comma costs nothing.
61pub 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
89/// Byte index of the first `needle` in `s` that sits outside every delimiter
90/// pair and string literal.
91pub 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
108/// Replaces `//` and `/* */` comments with spaces, keeping every newline so
109/// byte offsets stay usable for line numbers.
110pub 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
156/// Replaces the apostrophe that opens a Rust lifetime with a space.
157///
158/// Rust and TypeScript disagree about `'`. In TypeScript it always quotes a
159/// string, which is what everything above assumes. In Rust it does too — a
160/// char literal — but it *also* introduces a lifetime, and a scanner that
161/// reads `&'static str` as an opening quote then skips everything up to the
162/// next apostrophe in the file, closing braces included. That is not
163/// hypothetical: it swallowed the end of a payload struct, the struct scanned
164/// as declaring no fields at all, and `drift-check` reported four fields as
165/// missing from a struct that has them. A scanner that invents drift is on its
166/// way to being ignored, which is the outcome this crate exists to prevent.
167///
168/// So the Rust scanner blanks lifetimes before reading anything, and the
169/// shared functions keep their one simple rule. Blanking is
170/// length-preserving, so every byte offset stays valid, and char literals
171/// (`'a'`, `'}'`) are left alone because those really are strings and must go
172/// on being skipped. TypeScript never comes through here: it has no lifetimes,
173/// and its single-quoted strings must keep working.
174pub 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        // `'a'` closes; `'\n'` and `'}'` have no identifier at all. Only an
182        // identifier with nothing closing it is a lifetime.
183        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
191/// Reads the identifier starting at byte index `at`, or an empty string.
192pub 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
200/// `TEXT_MESSAGE_START` -> `TextMessageStart`.
201pub 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
214/// `TextMessageStart` -> `TEXT_MESSAGE_START`.
215///
216/// Runs of capitals stay together (`JSONPatch` -> `JSON_PATCH`, not
217/// `J_S_O_N_PATCH`), which is the same rule serde's `SCREAMING_SNAKE_CASE`
218/// applies to variant names.
219pub 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
235/// `message_id` -> `messageId`.
236pub 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
245/// `message_id` -> `MessageId`.
246pub 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
258/// Applies a serde `rename_all` rule to an already-snake_case name.
259///
260/// Unknown rules are returned unchanged rather than guessed at — a wrong guess
261/// here would be reported as drift that does not exist.
262pub 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    /// The failure this guards: a lifetime opened a string that never closed,
307    /// so a struct's own `}` was skipped and the rest of the file read as one
308    /// item — with no fields.
309    #[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    /// Blanking must not swallow the comments after a lifetime either, or a
325    /// doc comment's brackets and braces end up scanned as code.
326    #[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        // Rust: a char literal closes, so it is not a lifetime and the brace
336        // inside it must go on being skipped.
337        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        // TypeScript, which never goes through the blanking: a single-quoted
343        // value is one piece, comma and all.
344        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}