Skip to main content

alef_e2e/
escape.rs

1//! Language-specific string escaping for e2e test code generation.
2
3/// Escape a string for embedding in a Python string literal.
4pub fn escape_python(s: &str) -> String {
5    s.replace('\\', "\\\\")
6        .replace('"', "\\\"")
7        .replace('\n', "\\n")
8        .replace('\r', "\\r")
9        .replace('\t', "\\t")
10}
11
12/// Escape a string for embedding in a Rust string literal.
13pub fn escape_rust(s: &str) -> String {
14    s.replace('\\', "\\\\")
15        .replace('"', "\\\"")
16        .replace('\n', "\\n")
17        .replace('\r', "\\r")
18        .replace('\t', "\\t")
19}
20
21/// Compute the number of # needed for a Rust raw string literal.
22pub fn raw_string_hashes(s: &str) -> usize {
23    let mut max_hashes = 0;
24    let mut current = 0;
25    let mut after_quote = false;
26    for ch in s.chars() {
27        if ch == '"' {
28            after_quote = true;
29            current = 0;
30        } else if ch == '#' && after_quote {
31            current += 1;
32            max_hashes = max_hashes.max(current);
33        } else {
34            after_quote = false;
35            current = 0;
36        }
37    }
38    max_hashes + 1
39}
40
41/// Format a string as a Rust raw string literal (r#"..."#).
42pub fn rust_raw_string(s: &str) -> String {
43    let hashes = raw_string_hashes(s);
44    let h: String = "#".repeat(hashes);
45    format!("r{h}\"{s}\"{h}")
46}
47
48/// Escape a string for embedding in a JavaScript/TypeScript double-quoted string literal.
49///
50/// `$` does not need escaping in double-quoted strings (only in template literals).
51/// Escaping it would produce `\$` which Biome flags as `noUselessEscapeInString`.
52pub fn escape_js(s: &str) -> String {
53    s.replace('\\', "\\\\")
54        .replace('"', "\\\"")
55        .replace('\n', "\\n")
56        .replace('\r', "\\r")
57        .replace('\t', "\\t")
58}
59
60/// Escape a string for embedding in a JavaScript/TypeScript template literal (backtick string).
61///
62/// Template literals interpolate `${...}` and use backtick delimiters, so both
63/// `` ` `` and `$` must be escaped to prevent unintended interpolation.
64pub fn escape_js_template(s: &str) -> String {
65    s.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$")
66}
67
68/// Returns `true` if the string must use a Go interpreted (double-quoted) literal
69/// rather than a raw (backtick) literal.
70///
71/// Go raw string literals cannot contain backtick characters or NUL bytes, and
72/// `\r` inside a raw string is passed through as a literal CR which gofmt rejects.
73fn go_needs_quoted(s: &str) -> bool {
74    s.contains('`') || s.bytes().any(|b| b == 0 || b == b'\r')
75}
76
77/// Format a string as a Go string literal (backtick or quoted).
78///
79/// Prefers backtick raw literals for readability, but falls back to double-quoted
80/// interpreted literals when the string contains characters that raw literals
81/// cannot represent: backtick `` ` ``, NUL (`\x00`), or carriage return (`\r`).
82pub fn go_string_literal(s: &str) -> String {
83    if go_needs_quoted(s) {
84        format!("\"{}\"", escape_go(s))
85    } else {
86        format!("`{s}`")
87    }
88}
89
90/// Escape a string for embedding in a Go double-quoted string.
91///
92/// Handles all characters that cannot appear literally in a Go interpreted string:
93/// `\\`, `"`, `\n`, `\r`, `\t`, and NUL (`\x00`). Other non-printable bytes are
94/// emitted as `\xNN` hex escape sequences.
95pub fn escape_go(s: &str) -> String {
96    let mut out = String::with_capacity(s.len());
97    for b in s.bytes() {
98        match b {
99            b'\\' => out.push_str("\\\\"),
100            b'"' => out.push_str("\\\""),
101            b'\n' => out.push_str("\\n"),
102            b'\r' => out.push_str("\\r"),
103            b'\t' => out.push_str("\\t"),
104            0 => out.push_str("\\x00"),
105            // Other control characters or non-ASCII bytes: hex escape.
106            b if b < 0x20 || b == 0x7f => {
107                out.push_str(&format!("\\x{b:02x}"));
108            }
109            _ => out.push(b as char),
110        }
111    }
112    out
113}
114
115/// Escape a string for embedding in a Java string literal.
116pub fn escape_java(s: &str) -> String {
117    s.replace('\\', "\\\\")
118        .replace('"', "\\\"")
119        .replace('\n', "\\n")
120        .replace('\r', "\\r")
121        .replace('\t', "\\t")
122}
123
124/// Escape a string for embedding in a Kotlin double-quoted string literal.
125/// Like Java escaping but also escapes `$` which triggers Kotlin string interpolation.
126pub fn escape_kotlin(s: &str) -> String {
127    s.replace('\\', "\\\\")
128        .replace('"', "\\\"")
129        .replace('$', "\\$")
130        .replace('\n', "\\n")
131        .replace('\r', "\\r")
132        .replace('\t', "\\t")
133}
134
135/// Escape a string for embedding in a C# string literal.
136pub fn escape_csharp(s: &str) -> String {
137    s.replace('\\', "\\\\")
138        .replace('"', "\\\"")
139        .replace('\n', "\\n")
140        .replace('\r', "\\r")
141        .replace('\t', "\\t")
142}
143
144/// Escape a string for embedding in a PHP string literal.
145pub fn escape_php(s: &str) -> String {
146    s.replace('\\', "\\\\")
147        .replace('"', "\\\"")
148        .replace('$', "\\$")
149        .replace('\n', "\\n")
150        .replace('\r', "\\r")
151        .replace('\t', "\\t")
152}
153
154/// Escape a string for embedding in a double-quoted Ruby string literal.
155pub fn escape_ruby(s: &str) -> String {
156    s.replace('\\', "\\\\")
157        .replace('"', "\\\"")
158        .replace('#', "\\#")
159        .replace('\n', "\\n")
160        .replace('\r', "\\r")
161        .replace('\t', "\\t")
162}
163
164/// Escape a string for embedding in a single-quoted Ruby string literal.
165/// Single-quoted Ruby strings only interpret `\\` and `\'`.
166pub fn escape_ruby_single(s: &str) -> String {
167    s.replace('\\', "\\\\").replace('\'', "\\'")
168}
169
170/// Returns true if the string needs double quotes (contains control characters
171/// that require escape sequences only available in double-quoted strings).
172pub fn ruby_needs_double_quotes(s: &str) -> bool {
173    s.contains('\n') || s.contains('\r') || s.contains('\t') || s.contains('\0')
174}
175
176/// Format a string as a Ruby literal, preferring single quotes.
177pub fn ruby_string_literal(s: &str) -> String {
178    if ruby_needs_double_quotes(s) {
179        format!("\"{}\"", escape_ruby(s))
180    } else {
181        format!("'{}'", escape_ruby_single(s))
182    }
183}
184
185/// Escape a string for embedding in an Elixir string literal.
186pub fn escape_elixir(s: &str) -> String {
187    s.replace('\\', "\\\\")
188        .replace('"', "\\\"")
189        .replace('#', "\\#")
190        .replace('\n', "\\n")
191        .replace('\r', "\\r")
192        .replace('\t', "\\t")
193}
194
195/// Escape a string for embedding in an R string literal.
196pub fn escape_r(s: &str) -> String {
197    s.replace('\\', "\\\\")
198        .replace('"', "\\\"")
199        .replace('\n', "\\n")
200        .replace('\r', "\\r")
201        .replace('\t', "\\t")
202}
203
204/// Escape a string for embedding in a C string literal.
205pub fn escape_c(s: &str) -> String {
206    s.replace('\\', "\\\\")
207        .replace('"', "\\\"")
208        .replace('\n', "\\n")
209        .replace('\r', "\\r")
210        .replace('\t', "\\t")
211}
212
213/// Sanitize an identifier for use as a test function name.
214/// Replaces non-alphanumeric characters with underscores, strips leading digits.
215pub fn sanitize_ident(s: &str) -> String {
216    let mut result = String::with_capacity(s.len());
217    for ch in s.chars() {
218        if ch.is_ascii_alphanumeric() || ch == '_' {
219            result.push(ch);
220        } else {
221            result.push('_');
222        }
223    }
224    // Strip leading digits
225    let trimmed = result.trim_start_matches(|c: char| c.is_ascii_digit());
226    if trimmed.is_empty() {
227        "_".to_string()
228    } else {
229        trimmed.to_string()
230    }
231}
232
233/// Convert a category name to a sanitized filename component.
234pub fn sanitize_filename(s: &str) -> String {
235    s.chars()
236        .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
237        .collect::<String>()
238        .to_lowercase()
239}
240
241/// Expand fixture template expressions in a string value.
242///
243/// Supported templates:
244/// - `{{ repeat 'X' N times }}` — expands to the character X repeated N times
245///
246/// If no templates are found, the original string is returned unchanged.
247pub fn expand_fixture_templates(s: &str) -> String {
248    const PREFIX: &str = "{{ repeat '";
249    const SUFFIX: &str = " times }}";
250
251    let mut result = String::with_capacity(s.len());
252    let mut remaining = s;
253
254    while let Some(start) = remaining.find(PREFIX) {
255        result.push_str(&remaining[..start]);
256        let after_prefix = &remaining[start + PREFIX.len()..];
257
258        // Expect character(s) followed by `' N times }}`
259        if let Some(quote_pos) = after_prefix.find("' ") {
260            let ch = &after_prefix[..quote_pos];
261            let after_quote = &after_prefix[quote_pos + 2..];
262
263            if let Some(end) = after_quote.find(SUFFIX) {
264                let count_str = after_quote[..end].trim();
265                if let Ok(count) = count_str.parse::<usize>() {
266                    result.push_str(&ch.repeat(count));
267                    remaining = &after_quote[end + SUFFIX.len()..];
268                    continue;
269                }
270            }
271        }
272
273        // Template didn't match — emit the prefix literally and continue
274        result.push_str(PREFIX);
275        remaining = after_prefix;
276    }
277    result.push_str(remaining);
278    result
279}
280
281/// Escape a string for embedding in a POSIX single-quoted shell string literal.
282///
283/// Wraps the string in single quotes and escapes embedded single quotes as `'\''`.
284/// Single-quoted shell strings treat every character literally except `'`, so
285/// no other escaping is needed.
286pub fn escape_shell(s: &str) -> String {
287    s.replace('\'', r"'\''")
288}
289
290/// Escape a string for embedding in a Gleam string literal.
291pub fn escape_gleam(s: &str) -> String {
292    s.replace('\\', "\\\\")
293        .replace('"', "\\\"")
294        .replace('\n', "\\n")
295        .replace('\r', "\\r")
296        .replace('\t', "\\t")
297}
298
299/// Escape a string for embedding in a Zig string literal.
300pub fn escape_zig(s: &str) -> String {
301    s.replace('\\', "\\\\")
302        .replace('"', "\\\"")
303        .replace('\n', "\\n")
304        .replace('\r', "\\r")
305        .replace('\t', "\\t")
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    /// Go raw string literals (backticks) cannot contain NUL bytes — gofmt rejects them.
313    /// Strings with NUL must fall back to a double-quoted interpreted literal with `\x00`.
314    #[test]
315    fn go_string_literal_nul_bytes_use_quoted_form() {
316        let s = "Hello\x00World";
317        let lit = go_string_literal(s);
318        // Must not contain a raw NUL byte
319        assert!(
320            !lit.as_bytes().contains(&0u8),
321            "go_string_literal emitted a NUL byte — gofmt would reject this: {lit:?}"
322        );
323        // Must be a double-quoted string, not a backtick raw string
324        assert!(
325            lit.starts_with('"'),
326            "expected double-quoted string for NUL input, got: {lit:?}"
327        );
328        // The NUL must be represented as \\x00
329        assert!(
330            lit.contains("\\x00"),
331            "expected \\x00 escape sequence for NUL byte, got: {lit:?}"
332        );
333    }
334
335    /// Strings with carriage return must also use the double-quoted form
336    /// because Go raw strings cannot represent `\r`.
337    #[test]
338    fn go_string_literal_carriage_return_uses_quoted_form() {
339        let s = "line1\r\nline2";
340        let lit = go_string_literal(s);
341        assert!(
342            !lit.as_bytes().contains(&b'\r'),
343            "go_string_literal emitted a literal CR — gofmt would reject this: {lit:?}"
344        );
345        assert!(
346            lit.starts_with('"'),
347            "expected double-quoted string for CR input, got: {lit:?}"
348        );
349    }
350
351    /// Strings with only printable chars and no backtick should still use the
352    /// readable backtick form.
353    #[test]
354    fn go_string_literal_plain_string_uses_backtick() {
355        let s = "Hello World\nwith newline";
356        let lit = go_string_literal(s);
357        assert!(
358            lit.starts_with('`'),
359            "expected backtick form for plain string, got: {lit:?}"
360        );
361    }
362
363    /// Strings that contain a backtick must fall back to double-quoted form.
364    #[test]
365    fn go_string_literal_backtick_in_string_uses_quoted_form() {
366        let s = "has `backtick`";
367        let lit = go_string_literal(s);
368        assert!(
369            lit.starts_with('"'),
370            "expected double-quoted form when string contains backtick, got: {lit:?}"
371        );
372    }
373}