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 => {
74            // Python single-quoted strings: escape single quotes and backslashes
75            s.replace('\\', "\\\\").replace('\'', "\\'")
76        }
77        EscapeContext::JavaScript => {
78            // JavaScript single-quoted strings: escape single quotes and backslashes
79            s.replace('\\', "\\\\").replace('\'', "\\'")
80        }
81        EscapeContext::Ruby => {
82            // Ruby single-quoted strings: escape single quotes and backslashes
83            s.replace('\\', "\\\\").replace('\'', "\\'")
84        }
85        EscapeContext::Php => {
86            // PHP single-quoted strings: escape single quotes and backslashes
87            s.replace('\\', "\\\\").replace('\'', "\\'")
88        }
89        EscapeContext::Rust => {
90            // Rust string literals: escape single quotes and backslashes
91            s.replace('\\', "\\\\").replace('\'', "\\'")
92        }
93    }
94}
95
96/// Escape a string for use in double-quoted string literals.
97///
98/// This handles double-quote escaping, backslash escaping, and any language-specific
99/// special characters (like dollar signs in template strings).
100///
101/// # Arguments
102///
103/// * `s` - The string to escape
104/// * `context` - The target language context
105///
106/// # Returns
107///
108/// A string with appropriate escape sequences for the target language's double-quoted strings
109///
110/// # Examples
111///
112/// ```no_run
113/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_double_quotes};
114///
115/// // Python and Ruby double-quoted strings need standard escaping
116/// assert_eq!(escape_double_quotes("say \"hi\"", EscapeContext::Python), "say \\\"hi\\\"");
117/// ```
118#[must_use]
119pub fn escape_double_quotes(s: &str, context: EscapeContext) -> String {
120    match context {
121        EscapeContext::Python | EscapeContext::Rust => {
122            // Python and Rust: escape backslashes first, then double quotes
123            s.replace('\\', "\\\\").replace('"', "\\\"")
124        }
125        EscapeContext::JavaScript => {
126            // JavaScript: same as Python but also escape backticks
127            s.replace('\\', "\\\\").replace('"', "\\\"")
128        }
129        EscapeContext::Ruby => {
130            // Ruby: same escaping as Python
131            s.replace('\\', "\\\\").replace('"', "\\\"")
132        }
133        EscapeContext::Php => {
134            // PHP: same escaping as Python
135            s.replace('\\', "\\\\").replace('"', "\\\"")
136        }
137    }
138}
139
140/// Escape a string for use in template literals or backtick-delimited strings.
141///
142/// Template literals are used in JavaScript/TypeScript and some other languages.
143/// They require escaping of backticks and dollar signs (for interpolation).
144///
145/// # Arguments
146///
147/// * `s` - The string to escape
148/// * `context` - The target language context
149///
150/// # Returns
151///
152/// A string with appropriate escape sequences for template literal context
153///
154/// # Examples
155///
156/// ```no_run
157/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_template_literal};
158///
159/// // JavaScript template literals need backtick and dollar-sign escaping
160/// assert_eq!(escape_template_literal("hello $name", EscapeContext::JavaScript), "hello \\$name");
161/// assert_eq!(escape_template_literal("say `hi`", EscapeContext::JavaScript), "say \\`hi\\`");
162/// ```
163#[must_use]
164pub fn escape_template_literal(s: &str, context: EscapeContext) -> String {
165    match context {
166        EscapeContext::JavaScript => {
167            // Escape backticks, dollar signs, and backslashes
168            s.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$")
169        }
170        EscapeContext::Python | EscapeContext::Ruby | EscapeContext::Php | EscapeContext::Rust => {
171            // Other languages don't use template literals, just escape standard sequences
172            s.replace('\\', "\\\\").replace('"', "\\\"")
173        }
174    }
175}
176
177/// Escape a string for use in docstrings or documentation comments.
178///
179/// Docstrings have language-specific delimiters and rules:
180/// - **Python**: Triple-quoted strings (`"""`) with different escape patterns
181/// - **JavaScript/TypeScript**: `JSDoc` comments with `/**` and `*/`
182/// - **Ruby**: YARD documentation with special comment markers
183/// - **PHP**: `PHPDoc` comments with special markers
184/// - **Rust**: rustdoc with `///` or `//!`
185///
186/// # Arguments
187///
188/// * `s` - The string to escape
189/// * `context` - The target language context
190///
191/// # Returns
192///
193/// A string escaped for safe embedding in docstrings of the target language
194///
195/// # Examples
196///
197/// ```no_run
198/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_for_docstring};
199///
200/// // Python docstrings use triple quotes, which must be escaped
201/// let result = escape_for_docstring("Description with \"\"\" in it", EscapeContext::Python);
202/// assert!(!result.contains("\"\"\""));
203/// assert!(result.contains("\\\""));
204/// ```
205#[must_use]
206pub fn escape_for_docstring(s: &str, context: EscapeContext) -> String {
207    match context {
208        EscapeContext::Python => {
209            // Python triple-quoted docstrings:
210            // Replace """ with \" \" \" to break up the delimiter sequence
211            // This is safer than trying to escape within the string since Python
212            // doesn't support escaping triple quotes with backslashes
213            s.replace("\"\"\"", "\" \" \"")
214        }
215        EscapeContext::JavaScript => {
216            // JSDoc comments: standard escape for double quotes and special comment markers
217            // Avoid */ and /** within the docstring
218            s.replace("*/", "*\\/").replace('\\', "\\\\").replace('"', "\\\"")
219        }
220        EscapeContext::Ruby => {
221            // YARD documentation: standard escape for quotes
222            // Ruby uses # for comments, so avoid breaking those
223            s.replace('\\', "\\\\").replace('"', "\\\"")
224        }
225        EscapeContext::Php => {
226            // PHPDoc comments: standard escape for quotes and special markers
227            // Avoid */ and /** within the docstring
228            s.replace("*/", "*\\/").replace('\\', "\\\\").replace('"', "\\\"")
229        }
230        EscapeContext::Rust => {
231            // Rustdoc: standard escape for quotes
232            // Avoid breaking /// or //! comment markers
233            s.replace('\\', "\\\\").replace('"', "\\\"")
234        }
235    }
236}
237
238/// Escape a string for use in GraphQL SDL (Schema Definition Language) descriptions.
239///
240/// GraphQL SDL uses triple-quoted strings for descriptions, similar to Python.
241/// However, the escape rules are different - we need to escape triple quotes
242/// with backslashes.
243///
244/// # Arguments
245///
246/// * `s` - The string to escape
247/// * `context` - The target language context (mostly for consistency in codegen)
248///
249/// # Returns
250///
251/// A string escaped for safe embedding in GraphQL SDL descriptions
252///
253/// # Examples
254///
255/// ```no_run
256/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_graphql_sdl_description};
257///
258/// // GraphQL SDL descriptions use triple quotes
259/// let result = escape_graphql_sdl_description("Has \"\"\" in description", EscapeContext::Python);
260/// assert!(result.contains("\\\\\\\""));
261/// ```
262#[must_use]
263pub fn escape_graphql_sdl_description(s: &str, _context: EscapeContext) -> String {
264    // GraphQL SDL descriptions use triple-quoted strings.
265    // Escape """ with \"\"\", similar to GraphQL string escaping
266    // This produces valid GraphQL SDL that can be parsed
267    s.replace("\"\"\"", "\\\"\\\"\\\"")
268}
269
270/// Escape a string for use in GraphQL SDL (Schema Definition Language) as a complete quoted string.
271///
272/// This handles escaping for string values within GraphQL SDL (like argument defaults,
273/// directive values, etc.), which use standard GraphQL string escaping rules.
274///
275/// # Arguments
276///
277/// * `s` - The string to escape
278/// * `context` - The target language context
279///
280/// # Returns
281///
282/// A string escaped for safe embedding in GraphQL SDL quoted strings
283///
284/// # Examples
285///
286/// ```no_run
287/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_graphql_string};
288///
289/// // Standard GraphQL string escaping
290/// assert_eq!(escape_graphql_string("hello \"world\"", EscapeContext::Rust), "hello \\\"world\\\"");
291/// ```
292#[must_use]
293pub fn escape_graphql_string(s: &str, _context: EscapeContext) -> String {
294    // GraphQL string escaping: escape quotes and backslashes
295    s.replace('\\', "\\\\").replace('"', "\\\"")
296}
297
298/// Escape a string for embedding in JSON strings.
299///
300/// JSON has strict escaping rules for special characters including quotes,
301/// backslashes, newlines, tabs, and control characters.
302///
303/// # Arguments
304///
305/// * `s` - The string to escape
306/// * `context` - The target language context
307///
308/// # Returns
309///
310/// A string with JSON-safe escape sequences
311///
312/// # Examples
313///
314/// ```no_run
315/// use spikard_cli::codegen::common::escaping::{EscapeContext, escape_json_string};
316///
317/// let result = escape_json_string("line1\nline2\t\"quoted\"", EscapeContext::Rust);
318/// assert!(result.contains("\\n"));
319/// assert!(result.contains("\\t"));
320/// assert!(result.contains("\\\""));
321/// ```
322#[must_use]
323pub fn escape_json_string(s: &str, _context: EscapeContext) -> String {
324    let mut result = String::new();
325    for ch in s.chars() {
326        match ch {
327            '"' => result.push_str("\\\""),
328            '\\' => result.push_str("\\\\"),
329            '\n' => result.push_str("\\n"),
330            '\r' => result.push_str("\\r"),
331            '\t' => result.push_str("\\t"),
332            '\x08' => result.push_str("\\b"), // backspace
333            '\x0C' => result.push_str("\\f"), // form feed
334            c if c.is_control() => {
335                result.push_str(&format!("\\u{:04x}", c as u32));
336            }
337            c => result.push(c),
338        }
339    }
340    result
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    mod python {
348        use super::*;
349
350        #[test]
351        fn test_escape_quotes_simple() {
352            assert_eq!(escape_quotes("hello", EscapeContext::Python), "hello");
353        }
354
355        #[test]
356        fn test_escape_quotes_single_quote() {
357            assert_eq!(escape_quotes("it's a string", EscapeContext::Python), "it\\'s a string");
358        }
359
360        #[test]
361        fn test_escape_quotes_backslash() {
362            assert_eq!(
363                escape_quotes("path\\to\\file", EscapeContext::Python),
364                "path\\\\to\\\\file"
365            );
366        }
367
368        #[test]
369        fn test_escape_double_quotes() {
370            assert_eq!(
371                escape_double_quotes("say \"hello\"", EscapeContext::Python),
372                "say \\\"hello\\\""
373            );
374        }
375
376        #[test]
377        fn test_escape_for_docstring_triple_quotes() {
378            let result = escape_for_docstring("Description with \"\"\" in it", EscapeContext::Python);
379            assert!(!result.contains("\"\"\""));
380            assert_eq!(result, "Description with \" \" \" in it");
381        }
382
383        #[test]
384        fn test_escape_graphql_sdl_description() {
385            let result = escape_graphql_sdl_description("Has \"\"\" in description", EscapeContext::Python);
386            assert!(result.contains("\\\"\\\"\\\""));
387            assert_eq!(result, "Has \\\"\\\"\\\" in description");
388        }
389
390        #[test]
391        fn test_escape_docstring_multiple_triple_quotes() {
392            let result = escape_for_docstring("First \"\"\" and second \"\"\"", EscapeContext::Python);
393            assert!(!result.contains("\"\"\""));
394        }
395    }
396
397    mod php {
398        use super::*;
399
400        #[test]
401        fn test_escape_quotes_simple() {
402            assert_eq!(escape_quotes("hello", EscapeContext::Php), "hello");
403        }
404
405        #[test]
406        fn test_escape_quotes_single_quote() {
407            assert_eq!(escape_quotes("it's a string", EscapeContext::Php), "it\\'s a string");
408        }
409
410        #[test]
411        fn test_escape_quotes_backslash() {
412            assert_eq!(
413                escape_quotes("path\\to\\file", EscapeContext::Php),
414                "path\\\\to\\\\file"
415            );
416        }
417
418        #[test]
419        fn test_escape_double_quotes() {
420            assert_eq!(
421                escape_double_quotes("say \"hello\"", EscapeContext::Php),
422                "say \\\"hello\\\""
423            );
424        }
425
426        #[test]
427        fn test_escape_quotes_combined() {
428            assert_eq!(escape_quotes("path\\it's", EscapeContext::Php), "path\\\\it\\'s");
429        }
430    }
431
432    mod javascript {
433        use super::*;
434
435        #[test]
436        fn test_escape_quotes_simple() {
437            assert_eq!(escape_quotes("hello", EscapeContext::JavaScript), "hello");
438        }
439
440        #[test]
441        fn test_escape_quotes_single_quote() {
442            assert_eq!(
443                escape_quotes("it's a string", EscapeContext::JavaScript),
444                "it\\'s a string"
445            );
446        }
447
448        #[test]
449        fn test_escape_template_literal_backtick() {
450            assert_eq!(
451                escape_template_literal("say `hi`", EscapeContext::JavaScript),
452                "say \\`hi\\`"
453            );
454        }
455
456        #[test]
457        fn test_escape_template_literal_dollar_sign() {
458            assert_eq!(
459                escape_template_literal("hello $name", EscapeContext::JavaScript),
460                "hello \\$name"
461            );
462        }
463
464        #[test]
465        fn test_escape_template_literal_combined() {
466            assert_eq!(
467                escape_template_literal("say `$name`", EscapeContext::JavaScript),
468                "say \\`\\$name\\`"
469            );
470        }
471
472        #[test]
473        fn test_escape_for_docstring_jsdoc() {
474            let result = escape_for_docstring("Some text */", EscapeContext::JavaScript);
475            assert!(!result.contains("*/"));
476        }
477    }
478
479    mod ruby {
480        use super::*;
481
482        #[test]
483        fn test_escape_quotes_simple() {
484            assert_eq!(escape_quotes("hello", EscapeContext::Ruby), "hello");
485        }
486
487        #[test]
488        fn test_escape_quotes_single_quote() {
489            assert_eq!(escape_quotes("it's a string", EscapeContext::Ruby), "it\\'s a string");
490        }
491
492        #[test]
493        fn test_escape_quotes_backslash() {
494            assert_eq!(
495                escape_quotes("path\\to\\file", EscapeContext::Ruby),
496                "path\\\\to\\\\file"
497            );
498        }
499
500        #[test]
501        fn test_escape_double_quotes() {
502            assert_eq!(
503                escape_double_quotes("say \"hello\"", EscapeContext::Ruby),
504                "say \\\"hello\\\""
505            );
506        }
507    }
508
509    mod json {
510        use super::*;
511
512        #[test]
513        fn test_escape_json_simple() {
514            assert_eq!(escape_json_string("hello", EscapeContext::Rust), "hello");
515        }
516
517        #[test]
518        fn test_escape_json_double_quote() {
519            assert_eq!(
520                escape_json_string("say \"hello\"", EscapeContext::Rust),
521                "say \\\"hello\\\""
522            );
523        }
524
525        #[test]
526        fn test_escape_json_newline() {
527            assert_eq!(escape_json_string("line1\nline2", EscapeContext::Rust), "line1\\nline2");
528        }
529
530        #[test]
531        fn test_escape_json_tab() {
532            assert_eq!(escape_json_string("col1\tcol2", EscapeContext::Rust), "col1\\tcol2");
533        }
534
535        #[test]
536        fn test_escape_json_combined() {
537            let result = escape_json_string("path\\to\\file with \"quotes\"\nand\ttabs", EscapeContext::Rust);
538            assert!(result.contains("\\\\"));
539            assert!(result.contains("\\\""));
540            assert!(result.contains("\\n"));
541            assert!(result.contains("\\t"));
542        }
543    }
544
545    mod graphql {
546        use super::*;
547
548        #[test]
549        fn test_escape_graphql_string() {
550            assert_eq!(
551                escape_graphql_string("hello \"world\"", EscapeContext::Rust),
552                "hello \\\"world\\\""
553            );
554        }
555
556        #[test]
557        fn test_escape_graphql_backslash() {
558            assert_eq!(
559                escape_graphql_string("path\\to\\file", EscapeContext::Rust),
560                "path\\\\to\\\\file"
561            );
562        }
563    }
564
565    mod consistency {
566        use super::*;
567
568        #[test]
569        fn test_all_contexts_handle_empty_string() {
570            for context in &[
571                EscapeContext::Python,
572                EscapeContext::JavaScript,
573                EscapeContext::Ruby,
574                EscapeContext::Php,
575                EscapeContext::Rust,
576            ] {
577                assert_eq!(escape_quotes("", *context), "");
578                assert_eq!(escape_double_quotes("", *context), "");
579            }
580        }
581
582        #[test]
583        fn test_all_contexts_escape_backslash() {
584            for context in &[
585                EscapeContext::Python,
586                EscapeContext::JavaScript,
587                EscapeContext::Ruby,
588                EscapeContext::Php,
589                EscapeContext::Rust,
590            ] {
591                assert!(escape_quotes("\\", *context).contains("\\\\"));
592            }
593        }
594
595        #[test]
596        fn test_docstring_all_contexts() {
597            // All contexts should handle basic docstring escaping
598            for context in &[
599                EscapeContext::Python,
600                EscapeContext::JavaScript,
601                EscapeContext::Ruby,
602                EscapeContext::Php,
603                EscapeContext::Rust,
604            ] {
605                let result = escape_for_docstring("test string", *context);
606                assert!(!result.is_empty());
607            }
608        }
609    }
610}