Skip to main content

spikard_cli/codegen/common/
escaping.rs

1//! Language-specific string escaping utilities for code generation.
2//!
3//! This module provides unified escaping functions for strings that will be embedded
4//! in generated code across multiple languages. Each language has specific quote characters,
5//! escape sequences, and special cases that must be handled correctly to produce valid,
6//! compilable code.
7//!
8//! # Examples
9//!
10//! ```no_run
11//! use spikard_cli::codegen::common::escaping::{EscapeContext, escape_quotes, escape_for_docstring};
12//!
13//! // Escape quotes for a Python single-quoted string
14//! let python_str = escape_quotes("it's a string", EscapeContext::Python);
15//! assert!(python_str.contains("\\'"));
16//!
17//! // Escape for a Python docstring (triple-quoted)
18//! let docstring = escape_for_docstring("The description says \"\"\"", EscapeContext::Python);
19//! assert!(!docstring.contains("\"\"\""));
20//! ```
21
22/// Language context for determining escape sequences and quote characters.
23///
24/// Different languages have different conventions for string literals:
25/// - **Python**: Uses both single/double quotes and triple-quotes for docstrings
26/// - **JavaScript/TypeScript**: Supports template literals with backticks, and single/double quotes
27/// - **Ruby**: Uses both single and double quotes with different escape rules
28/// - **PHP**: Requires strict escaping of backslashes and quotes
29/// - **Rust**: Raw strings and standard escape sequences
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum EscapeContext {
32    /// Python (3.10+): Handles single/double quotes and triple-quoted strings
33    Python,
34    /// JavaScript/TypeScript: Template literals with backtick support
35    JavaScript,
36    /// Ruby: Single and double quoted strings with different escape sequences
37    Ruby,
38    /// PHP: Single and double quoted strings with strict escaping rules
39    Php,
40    /// Rust: Standard escape sequences and raw strings
41    Rust,
42}
43
44/// Escape a string for use in a single-quoted string literal in the target language.
45///
46/// This handles language-specific quote character escaping and necessary backslash escaping.
47/// The result can be safely embedded in a single-quoted string of the target language.
48///
49/// # Arguments
50///
51/// * `s` - The string to escape
52/// * `context` - The target language context
53///
54/// # Returns
55///
56/// A string with appropriate escape sequences for the target language's single-quoted strings
57///
58/// # Examples
59///
60/// ```no_run
61/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_quotes};
62///
63/// // Escape for PHP single-quoted strings
64/// assert_eq!(escape_quotes("path\\to\\file", EscapeContext::Php), "path\\\\to\\\\file");
65/// assert_eq!(escape_quotes("it's", EscapeContext::Php), "it\\'s");
66///
67/// // Escape for Ruby single-quoted strings
68/// assert_eq!(escape_quotes("it's", EscapeContext::Ruby), "it\\'s");
69/// ```
70#[must_use]
71pub fn escape_quotes(s: &str, context: EscapeContext) -> String {
72    match context {
73        EscapeContext::Python => s.replace('\\', "\\\\").replace('\'', "\\'"),
74        EscapeContext::JavaScript => s.replace('\\', "\\\\").replace('\'', "\\'"),
75        EscapeContext::Ruby => s.replace('\\', "\\\\").replace('\'', "\\'"),
76        EscapeContext::Php => s.replace('\\', "\\\\").replace('\'', "\\'"),
77        EscapeContext::Rust => s.replace('\\', "\\\\").replace('\'', "\\'"),
78    }
79}
80
81/// Escape a string for use in double-quoted string literals.
82///
83/// This handles double-quote escaping, backslash escaping, and any language-specific
84/// special characters (like dollar signs in template strings).
85///
86/// # Arguments
87///
88/// * `s` - The string to escape
89/// * `context` - The target language context
90///
91/// # Returns
92///
93/// A string with appropriate escape sequences for the target language's double-quoted strings
94///
95/// # Examples
96///
97/// ```no_run
98/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_double_quotes};
99///
100/// // Python and Ruby double-quoted strings need standard escaping
101/// assert_eq!(escape_double_quotes("say \"hi\"", EscapeContext::Python), "say \\\"hi\\\"");
102/// ```
103#[must_use]
104pub fn escape_double_quotes(s: &str, context: EscapeContext) -> String {
105    match context {
106        EscapeContext::Python | EscapeContext::Rust => s.replace('\\', "\\\\").replace('"', "\\\""),
107        EscapeContext::JavaScript => s.replace('\\', "\\\\").replace('"', "\\\""),
108        EscapeContext::Ruby => s.replace('\\', "\\\\").replace('"', "\\\""),
109        EscapeContext::Php => s.replace('\\', "\\\\").replace('"', "\\\""),
110    }
111}
112
113/// Escape a string for use in template literals or backtick-delimited strings.
114///
115/// Template literals are used in JavaScript/TypeScript and some other languages.
116/// They require escaping of backticks and dollar signs (for interpolation).
117///
118/// # Arguments
119///
120/// * `s` - The string to escape
121/// * `context` - The target language context
122///
123/// # Returns
124///
125/// A string with appropriate escape sequences for template literal context
126///
127/// # Examples
128///
129/// ```no_run
130/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_template_literal};
131///
132/// // JavaScript template literals need backtick and dollar-sign escaping
133/// assert_eq!(escape_template_literal("hello $name", EscapeContext::JavaScript), "hello \\$name");
134/// assert_eq!(escape_template_literal("say `hi`", EscapeContext::JavaScript), "say \\`hi\\`");
135/// ```
136#[must_use]
137pub fn escape_template_literal(s: &str, context: EscapeContext) -> String {
138    match context {
139        EscapeContext::JavaScript => s.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$"),
140        EscapeContext::Python | EscapeContext::Ruby | EscapeContext::Php | EscapeContext::Rust => {
141            s.replace('\\', "\\\\").replace('"', "\\\"")
142        }
143    }
144}
145
146/// Escape a string for use in docstrings or documentation comments.
147///
148/// Docstrings have language-specific delimiters and rules:
149/// - **Python**: Triple-quoted strings (`"""`) with different escape patterns
150/// - **JavaScript/TypeScript**: `JSDoc` comments with `/**` and `*/`
151/// - **Ruby**: YARD documentation with special comment markers
152/// - **PHP**: `PHPDoc` comments with special markers
153/// - **Rust**: rustdoc with `///` or `//!`
154///
155/// # Arguments
156///
157/// * `s` - The string to escape
158/// * `context` - The target language context
159///
160/// # Returns
161///
162/// A string escaped for safe embedding in docstrings of the target language
163///
164/// # Examples
165///
166/// ```no_run
167/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_for_docstring};
168///
169/// // Python docstrings use triple quotes, which must be escaped
170/// let result = escape_for_docstring("Description with \"\"\" in it", EscapeContext::Python);
171/// assert!(!result.contains("\"\"\""));
172/// assert!(result.contains("\\\""));
173/// ```
174#[must_use]
175pub fn escape_for_docstring(s: &str, context: EscapeContext) -> String {
176    match context {
177        EscapeContext::Python => s.replace("\"\"\"", "\" \" \""),
178        EscapeContext::JavaScript => s.replace("*/", "*\\/").replace('\\', "\\\\").replace('"', "\\\""),
179        EscapeContext::Ruby => s.replace('\\', "\\\\").replace('"', "\\\""),
180        EscapeContext::Php => s.replace("*/", "*\\/").replace('\\', "\\\\").replace('"', "\\\""),
181        EscapeContext::Rust => s.replace('\\', "\\\\").replace('"', "\\\""),
182    }
183}
184
185/// Escape a string for use in GraphQL SDL (Schema Definition Language) descriptions.
186///
187/// GraphQL SDL uses triple-quoted strings for descriptions, similar to Python.
188/// However, the escape rules are different - we need to escape triple quotes
189/// with backslashes.
190///
191/// # Arguments
192///
193/// * `s` - The string to escape
194/// * `context` - The target language context (mostly for consistency in codegen)
195///
196/// # Returns
197///
198/// A string escaped for safe embedding in GraphQL SDL descriptions
199///
200/// # Examples
201///
202/// ```no_run
203/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_graphql_sdl_description};
204///
205/// // GraphQL SDL descriptions use triple quotes
206/// let result = escape_graphql_sdl_description("Has \"\"\" in description", EscapeContext::Python);
207/// assert!(result.contains("\\\\\\\""));
208/// ```
209#[must_use]
210pub fn escape_graphql_sdl_description(s: &str, _context: EscapeContext) -> String {
211    s.replace("\"\"\"", "\\\"\\\"\\\"")
212}
213
214/// Escape a string for use in GraphQL SDL (Schema Definition Language) as a complete quoted string.
215///
216/// This handles escaping for string values within GraphQL SDL (like argument defaults,
217/// directive values, etc.), which use standard GraphQL string escaping rules.
218///
219/// # Arguments
220///
221/// * `s` - The string to escape
222/// * `context` - The target language context
223///
224/// # Returns
225///
226/// A string escaped for safe embedding in GraphQL SDL quoted strings
227///
228/// # Examples
229///
230/// ```no_run
231/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_graphql_string};
232///
233/// // Standard GraphQL string escaping
234/// assert_eq!(escape_graphql_string("hello \"world\"", EscapeContext::Rust), "hello \\\"world\\\"");
235/// ```
236#[must_use]
237pub fn escape_graphql_string(s: &str, _context: EscapeContext) -> String {
238    s.replace('\\', "\\\\").replace('"', "\\\"")
239}
240
241/// Escape a string for embedding in JSON strings.
242///
243/// JSON has strict escaping rules for special characters including quotes,
244/// backslashes, newlines, tabs, and control characters.
245///
246/// # Arguments
247///
248/// * `s` - The string to escape
249/// * `context` - The target language context
250///
251/// # Returns
252///
253/// A string with JSON-safe escape sequences
254///
255/// # Examples
256///
257/// ```no_run
258/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_json_string};
259///
260/// let result = escape_json_string("line1\nline2\t\"quoted\"", EscapeContext::Rust);
261/// assert!(result.contains("\\n"));
262/// assert!(result.contains("\\t"));
263/// assert!(result.contains("\\\""));
264/// ```
265#[must_use]
266pub fn escape_json_string(s: &str, _context: EscapeContext) -> String {
267    let mut result = String::new();
268    for ch in s.chars() {
269        match ch {
270            '"' => result.push_str("\\\""),
271            '\\' => result.push_str("\\\\"),
272            '\n' => result.push_str("\\n"),
273            '\r' => result.push_str("\\r"),
274            '\t' => result.push_str("\\t"),
275            '\x08' => result.push_str("\\b"),
276            '\x0C' => result.push_str("\\f"),
277            c if c.is_control() => {
278                result.push_str(&format!("\\u{:04x}", c as u32));
279            }
280            c => result.push(c),
281        }
282    }
283    result
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    mod python {
291        use super::*;
292
293        #[test]
294        fn test_escape_quotes_simple() {
295            assert_eq!(escape_quotes("hello", EscapeContext::Python), "hello");
296        }
297
298        #[test]
299        fn test_escape_quotes_single_quote() {
300            assert_eq!(escape_quotes("it's a string", EscapeContext::Python), "it\\'s a string");
301        }
302
303        #[test]
304        fn test_escape_quotes_backslash() {
305            assert_eq!(
306                escape_quotes("path\\to\\file", EscapeContext::Python),
307                "path\\\\to\\\\file"
308            );
309        }
310
311        #[test]
312        fn test_escape_double_quotes() {
313            assert_eq!(
314                escape_double_quotes("say \"hello\"", EscapeContext::Python),
315                "say \\\"hello\\\""
316            );
317        }
318
319        #[test]
320        fn test_escape_for_docstring_triple_quotes() {
321            let result = escape_for_docstring("Description with \"\"\" in it", EscapeContext::Python);
322            assert!(!result.contains("\"\"\""));
323            assert_eq!(result, "Description with \" \" \" in it");
324        }
325
326        #[test]
327        fn test_escape_graphql_sdl_description() {
328            let result = escape_graphql_sdl_description("Has \"\"\" in description", EscapeContext::Python);
329            assert!(result.contains("\\\"\\\"\\\""));
330            assert_eq!(result, "Has \\\"\\\"\\\" in description");
331        }
332
333        #[test]
334        fn test_escape_docstring_multiple_triple_quotes() {
335            let result = escape_for_docstring("First \"\"\" and second \"\"\"", EscapeContext::Python);
336            assert!(!result.contains("\"\"\""));
337        }
338    }
339
340    mod php {
341        use super::*;
342
343        #[test]
344        fn test_escape_quotes_simple() {
345            assert_eq!(escape_quotes("hello", EscapeContext::Php), "hello");
346        }
347
348        #[test]
349        fn test_escape_quotes_single_quote() {
350            assert_eq!(escape_quotes("it's a string", EscapeContext::Php), "it\\'s a string");
351        }
352
353        #[test]
354        fn test_escape_quotes_backslash() {
355            assert_eq!(
356                escape_quotes("path\\to\\file", EscapeContext::Php),
357                "path\\\\to\\\\file"
358            );
359        }
360
361        #[test]
362        fn test_escape_double_quotes() {
363            assert_eq!(
364                escape_double_quotes("say \"hello\"", EscapeContext::Php),
365                "say \\\"hello\\\""
366            );
367        }
368
369        #[test]
370        fn test_escape_quotes_combined() {
371            assert_eq!(escape_quotes("path\\it's", EscapeContext::Php), "path\\\\it\\'s");
372        }
373    }
374
375    mod javascript {
376        use super::*;
377
378        #[test]
379        fn test_escape_quotes_simple() {
380            assert_eq!(escape_quotes("hello", EscapeContext::JavaScript), "hello");
381        }
382
383        #[test]
384        fn test_escape_quotes_single_quote() {
385            assert_eq!(
386                escape_quotes("it's a string", EscapeContext::JavaScript),
387                "it\\'s a string"
388            );
389        }
390
391        #[test]
392        fn test_escape_template_literal_backtick() {
393            assert_eq!(
394                escape_template_literal("say `hi`", EscapeContext::JavaScript),
395                "say \\`hi\\`"
396            );
397        }
398
399        #[test]
400        fn test_escape_template_literal_dollar_sign() {
401            assert_eq!(
402                escape_template_literal("hello $name", EscapeContext::JavaScript),
403                "hello \\$name"
404            );
405        }
406
407        #[test]
408        fn test_escape_template_literal_combined() {
409            assert_eq!(
410                escape_template_literal("say `$name`", EscapeContext::JavaScript),
411                "say \\`\\$name\\`"
412            );
413        }
414
415        #[test]
416        fn test_escape_for_docstring_jsdoc() {
417            let result = escape_for_docstring("Some text */", EscapeContext::JavaScript);
418            assert!(!result.contains("*/"));
419        }
420    }
421
422    mod ruby {
423        use super::*;
424
425        #[test]
426        fn test_escape_quotes_simple() {
427            assert_eq!(escape_quotes("hello", EscapeContext::Ruby), "hello");
428        }
429
430        #[test]
431        fn test_escape_quotes_single_quote() {
432            assert_eq!(escape_quotes("it's a string", EscapeContext::Ruby), "it\\'s a string");
433        }
434
435        #[test]
436        fn test_escape_quotes_backslash() {
437            assert_eq!(
438                escape_quotes("path\\to\\file", EscapeContext::Ruby),
439                "path\\\\to\\\\file"
440            );
441        }
442
443        #[test]
444        fn test_escape_double_quotes() {
445            assert_eq!(
446                escape_double_quotes("say \"hello\"", EscapeContext::Ruby),
447                "say \\\"hello\\\""
448            );
449        }
450    }
451
452    mod json {
453        use super::*;
454
455        #[test]
456        fn test_escape_json_simple() {
457            assert_eq!(escape_json_string("hello", EscapeContext::Rust), "hello");
458        }
459
460        #[test]
461        fn test_escape_json_double_quote() {
462            assert_eq!(
463                escape_json_string("say \"hello\"", EscapeContext::Rust),
464                "say \\\"hello\\\""
465            );
466        }
467
468        #[test]
469        fn test_escape_json_newline() {
470            assert_eq!(escape_json_string("line1\nline2", EscapeContext::Rust), "line1\\nline2");
471        }
472
473        #[test]
474        fn test_escape_json_tab() {
475            assert_eq!(escape_json_string("col1\tcol2", EscapeContext::Rust), "col1\\tcol2");
476        }
477
478        #[test]
479        fn test_escape_json_combined() {
480            let result = escape_json_string("path\\to\\file with \"quotes\"\nand\ttabs", EscapeContext::Rust);
481            assert!(result.contains("\\\\"));
482            assert!(result.contains("\\\""));
483            assert!(result.contains("\\n"));
484            assert!(result.contains("\\t"));
485        }
486    }
487
488    mod graphql {
489        use super::*;
490
491        #[test]
492        fn test_escape_graphql_string() {
493            assert_eq!(
494                escape_graphql_string("hello \"world\"", EscapeContext::Rust),
495                "hello \\\"world\\\""
496            );
497        }
498
499        #[test]
500        fn test_escape_graphql_backslash() {
501            assert_eq!(
502                escape_graphql_string("path\\to\\file", EscapeContext::Rust),
503                "path\\\\to\\\\file"
504            );
505        }
506    }
507
508    mod consistency {
509        use super::*;
510
511        #[test]
512        fn test_all_contexts_handle_empty_string() {
513            for context in &[
514                EscapeContext::Python,
515                EscapeContext::JavaScript,
516                EscapeContext::Ruby,
517                EscapeContext::Php,
518                EscapeContext::Rust,
519            ] {
520                assert_eq!(escape_quotes("", *context), "");
521                assert_eq!(escape_double_quotes("", *context), "");
522            }
523        }
524
525        #[test]
526        fn test_all_contexts_escape_backslash() {
527            for context in &[
528                EscapeContext::Python,
529                EscapeContext::JavaScript,
530                EscapeContext::Ruby,
531                EscapeContext::Php,
532                EscapeContext::Rust,
533            ] {
534                assert!(escape_quotes("\\", *context).contains("\\\\"));
535            }
536        }
537
538        #[test]
539        fn test_docstring_all_contexts() {
540            for context in &[
541                EscapeContext::Python,
542                EscapeContext::JavaScript,
543                EscapeContext::Ruby,
544                EscapeContext::Php,
545                EscapeContext::Rust,
546            ] {
547                let result = escape_for_docstring("test string", *context);
548                assert!(!result.is_empty());
549            }
550        }
551    }
552}