Skip to main content

contextual_encoder/
javascript.rs

1//! javascript contextual output encoders.
2//!
3//! provides five encoding contexts:
4//!
5//! - [`for_javascript`] — universal encoder, safe in HTML attributes, script
6//!   blocks, and standalone .js files
7//! - [`for_javascript_attribute`] — optimized for HTML event attributes
8//!   (e.g., `onclick="..."`)
9//! - [`for_javascript_block`] — optimized for `<script>` blocks
10//! - [`for_javascript_source`] — optimized for standalone .js files
11//! - [`for_js_template`] — for ES6 template literal content (`` `...` ``)
12//!
13//! # security notes
14//!
15//! - the string literal encoders ([`for_javascript`], [`for_javascript_attribute`],
16//!   [`for_javascript_block`], [`for_javascript_source`]) do **not** encode the
17//!   grave accent (`` ` ``). do not use them to embed data inside template
18//!   literals — use [`for_js_template`] instead.
19//! - these encoders are for string/template literal contexts only. they cannot
20//!   make arbitrary javascript expressions, variable names, or property
21//!   accessors safe.
22//! - `for_javascript_block` and `for_javascript_source` use backslash escapes
23//!   for quotes (`\"`, `\'`) which are **not safe in HTML attribute contexts**.
24//! - `for_javascript_attribute` does not escape `<` or `/` and is **not safe
25//!   in `<script>` blocks** where `</script>` could appear.
26//! - `for_javascript_source` does not escape `&`, so it is **not safe in an
27//!   XHTML `<script>`**, where character references are decoded before
28//!   javascript sees the text. the other four encoders escape it.
29//! - all five encoders escape the bidi formatting controls (U+202A-U+202E,
30//!   U+2066-U+2069) as `\uHHHH`, so a direction override in the data cannot
31//!   reorder how the generated javascript reads.
32//! - none of these encoders produce valid JSON — the `\xHH` escapes they emit
33//!   for control characters are not permitted in JSON. use
34//!   [`for_json`](crate::for_json) for JSON string values.
35
36use std::fmt;
37
38use crate::engine::{encode_loop, is_text_direction_control};
39
40/// configuration flags controlling context-specific encoding differences.
41#[derive(Clone, Copy)]
42struct JsConfig {
43    /// true: `"` → `\x22`, `'` → `\x27` (safe in HTML attributes).
44    /// false: `"` → `\"`, `'` → `\'` (more readable, not HTML-attr safe).
45    hex_quotes: bool,
46    /// true: encode `&` as `\x26` (prevents HTML entity interpretation).
47    encode_ampersand: bool,
48    /// true: the output can land in HTML script data, so encode every
49    /// character that can move the tokenizer out of it — `<` → `\x3c` and
50    /// `/` → `\/`.
51    script_data: bool,
52}
53
54const JS_UNIVERSAL: JsConfig = JsConfig {
55    hex_quotes: true,
56    encode_ampersand: true,
57    script_data: true,
58};
59
60const JS_ATTRIBUTE: JsConfig = JsConfig {
61    hex_quotes: true,
62    encode_ampersand: true,
63    script_data: false,
64};
65
66const JS_BLOCK: JsConfig = JsConfig {
67    hex_quotes: false,
68    encode_ampersand: true,
69    script_data: true,
70};
71
72const JS_SOURCE: JsConfig = JsConfig {
73    hex_quotes: false,
74    encode_ampersand: false,
75    script_data: false,
76};
77
78/// encodes `input` for safe embedding in a javascript string literal.
79///
80/// this is the universal javascript encoder — its output is safe in HTML
81/// event attributes, `<script>` blocks, and standalone .js files. it is
82/// slightly more conservative than the context-specific encoders.
83///
84/// # encoding rules
85///
86/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
87///   (`\xHH`)
88/// - `"` → `\x22`, `'` → `\x27` (hex escapes for HTML attribute safety)
89/// - `&` → `\x26` (prevents HTML entity interpretation)
90/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
91///   so the enclosing `</script>` still closes the block)
92/// - `\` → `\\`
93/// - U+2028 → `\u2028`, U+2029 → `\u2029` (javascript line terminators)
94/// - bidi formatting controls (U+202A-U+202E, U+2066-U+2069) → `\uHHHH`
95///
96/// # caveat: template literals
97///
98/// this encoder does **not** encode the grave accent (`` ` ``). never
99/// embed untrusted data directly inside template literals. instead:
100///
101/// ```js
102/// // WRONG — vulnerable to XSS:
103/// // `Hello ${unsafeInput}`
104/// //
105/// // RIGHT — encode into a variable first:
106/// // var x = '<encoded>';
107/// // `Hello ${x}`
108/// ```
109///
110/// # examples
111///
112/// ```
113/// use contextual_encoder::for_javascript;
114///
115/// assert_eq!(for_javascript(r#"it's "unsafe" </script>"#),
116///            r"it\x27s \x22unsafe\x22 \x3c\/script>");
117/// assert_eq!(for_javascript("safe"), "safe");
118/// ```
119pub fn for_javascript(input: &str) -> String {
120    encode_js(input, &JS_UNIVERSAL)
121}
122
123/// writes the javascript-encoded form of `input` to `out`.
124///
125/// see [`for_javascript`] for encoding rules.
126pub fn write_javascript<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
127    write_js(out, input, &JS_UNIVERSAL)
128}
129
130/// encodes `input` for safe embedding in a javascript string literal inside
131/// an HTML event attribute (e.g., `onclick="..."`).
132///
133/// identical to [`for_javascript`] except `<` and `/` are **not** escaped. an
134/// attribute value is never tokenized as script data, so neither character can
135/// affect where the enclosing element ends.
136///
137/// **not safe in `<script>` blocks** — use [`for_javascript`] or
138/// [`for_javascript_block`] instead.
139///
140/// # examples
141///
142/// ```
143/// use contextual_encoder::for_javascript_attribute;
144///
145/// assert_eq!(for_javascript_attribute("a/b"), "a/b");
146/// assert_eq!(for_javascript_attribute("a<b"), "a<b");
147/// assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
148/// ```
149pub fn for_javascript_attribute(input: &str) -> String {
150    encode_js(input, &JS_ATTRIBUTE)
151}
152
153/// writes the javascript-attribute-encoded form of `input` to `out`.
154///
155/// see [`for_javascript_attribute`] for encoding rules.
156pub fn write_javascript_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
157    write_js(out, input, &JS_ATTRIBUTE)
158}
159
160/// encodes `input` for safe embedding in a javascript string literal inside
161/// an HTML `<script>` block.
162///
163/// uses backslash escapes for quotes (`\"`, `\'`) which are more readable
164/// but **not safe in HTML attribute contexts**. still encodes `&` (for XHTML
165/// compatibility) and `<`/`/`, which keep the HTML tokenizer in script data
166/// state so the enclosing `</script>` still closes the block.
167///
168/// # examples
169///
170/// ```
171/// use contextual_encoder::for_javascript_block;
172///
173/// assert_eq!(for_javascript_block(r#"he said "hi""#), r#"he said \"hi\""#);
174/// assert_eq!(for_javascript_block("</script>"), r"\x3c\/script>");
175/// assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
176/// ```
177pub fn for_javascript_block(input: &str) -> String {
178    encode_js(input, &JS_BLOCK)
179}
180
181/// writes the javascript-block-encoded form of `input` to `out`.
182///
183/// see [`for_javascript_block`] for encoding rules.
184pub fn write_javascript_block<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
185    write_js(out, input, &JS_BLOCK)
186}
187
188/// encodes `input` for safe embedding in a javascript string literal in a
189/// standalone .js file.
190///
191/// the most minimal javascript encoder — does not encode `<`, `/` or `&`
192/// since a standalone .js file is never HTML-tokenized. **not safe for any
193/// HTML-embedded context.**
194///
195/// **not a JSON encoder.** it emits `\'` for single quotes and `\xHH` for
196/// control characters, neither of which JSON permits. use
197/// [`for_json`](crate::for_json) for JSON string values.
198///
199/// # examples
200///
201/// ```
202/// use contextual_encoder::{for_javascript_source, for_json};
203///
204/// assert_eq!(for_javascript_source("a/b&c<d"), "a/b&c<d");
205/// assert_eq!(for_javascript_source("line\nbreak"), r"line\nbreak");
206///
207/// assert_eq!(for_javascript_source("it's"), r"it\'s");
208/// assert_eq!(for_json("it's"), "it's");
209/// ```
210pub fn for_javascript_source(input: &str) -> String {
211    encode_js(input, &JS_SOURCE)
212}
213
214/// writes the javascript-source-encoded form of `input` to `out`.
215///
216/// see [`for_javascript_source`] for encoding rules.
217pub fn write_javascript_source<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
218    write_js(out, input, &JS_SOURCE)
219}
220
221/// encodes `input` for safe embedding inside an ES6 template literal
222/// (`` `...` ``).
223///
224/// template literals use backticks as delimiters and `${...}` for
225/// interpolation. this encoder escapes both so untrusted data cannot break
226/// out of the literal or inject expressions.
227///
228/// # encoding rules
229///
230/// - `` ` `` → `` \` `` (prevents breaking out of the template literal)
231/// - `$` followed by `{`, and `$` at the end of the input → `\$` (prevents
232///   expression interpolation, including a `${` the caller completes)
233/// - `\` → `\\`
234/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
235///   so the enclosing `</script>` still closes the block)
236/// - `&` → `\x26` (stops an XHTML parser decoding a character reference into
237///   a `` ` `` or a `${` before javascript sees the text)
238/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
239///   (`\xHH`)
240/// - U+2028 → `\u2028`, U+2029 → `\u2029` (line/paragraph separators)
241/// - bidi formatting controls (U+202A-U+202E, U+2066-U+2069) → `\uHHHH`
242///
243/// unlike the string literal encoders, this does **not** escape `"` or `'`
244/// (they are ordinary characters inside template literals).
245///
246/// # examples
247///
248/// ```
249/// use contextual_encoder::for_js_template;
250///
251/// assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
252/// assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
253/// assert_eq!(for_js_template("safe"), "safe");
254/// // `\x26` is `&` in a template literal, so the decoded value is unchanged
255/// assert_eq!(for_js_template("a&b"), r"a\x26b");
256/// assert_eq!(for_js_template("&#96;"), r"\x26#96;");
257/// assert_eq!(for_js_template("a $ b"), "a $ b");
258/// // `\$` is `$` in a template literal, so the decoded value is unchanged
259/// assert_eq!(for_js_template("cost: $"), r"cost: \$");
260/// ```
261pub fn for_js_template(input: &str) -> String {
262    let mut out = String::with_capacity(input.len());
263    write_js_template(&mut out, input).expect("writing to string cannot fail");
264    out
265}
266
267/// writes the template-literal-encoded form of `input` to `out`.
268///
269/// see [`for_js_template`] for encoding rules.
270pub fn write_js_template<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
271    encode_loop(
272        out,
273        input,
274        needs_js_template_encoding,
275        write_js_template_encoded,
276    )
277}
278
279fn needs_js_template_encoding(c: char) -> bool {
280    matches!(
281        c,
282        '\x00'..='\x1F' | '\\' | '`' | '$' | '&' | '/' | '<' | '\u{2028}' | '\u{2029}'
283    ) || is_text_direction_control(c)
284}
285
286fn write_js_template_encoded<W: fmt::Write>(
287    out: &mut W,
288    c: char,
289    next: Option<char>,
290) -> fmt::Result {
291    match c {
292        // template-specific characters
293        '`' => out.write_str("\\`"),
294        '$' if matches!(next, Some('{') | None) => out.write_str("\\$"),
295        '$' => out.write_char('$'),
296        '&' => out.write_str("\\x26"),
297        '/' => out.write_str("\\/"),
298        '<' => out.write_str("\\x3c"),
299        c => write_js_shared_escape(out, c),
300    }
301}
302
303fn encode_js(input: &str, config: &JsConfig) -> String {
304    let mut out = String::with_capacity(input.len());
305    write_js(&mut out, input, config).expect("writing to string cannot fail");
306    out
307}
308
309fn write_js<W: fmt::Write>(out: &mut W, input: &str, config: &JsConfig) -> fmt::Result {
310    encode_loop(
311        out,
312        input,
313        |c| needs_js_encoding(c, config),
314        |out, c, _next| write_js_encoded(out, c, config),
315    )
316}
317
318fn needs_js_encoding(c: char, config: &JsConfig) -> bool {
319    match c {
320        '\x00'..='\x1F' | '\\' | '"' | '\'' | '\u{2028}' | '\u{2029}' => true,
321        '&' => config.encode_ampersand,
322        '/' | '<' => config.script_data,
323        c => is_text_direction_control(c),
324    }
325}
326
327fn write_js_encoded<W: fmt::Write>(out: &mut W, c: char, config: &JsConfig) -> fmt::Result {
328    match c {
329        // string-literal-specific characters
330        '"' if config.hex_quotes => out.write_str("\\x22"),
331        '"' => out.write_str("\\\""),
332        '\'' if config.hex_quotes => out.write_str("\\x27"),
333        '\'' => out.write_str("\\'"),
334        '&' => out.write_str("\\x26"),
335        '/' => out.write_str("\\/"),
336        '<' => out.write_str("\\x3c"),
337        c => write_js_shared_escape(out, c),
338    }
339}
340
341/// writes the escape shared by both js encoders. any other character falls back
342/// to `\u{...}` so a character a predicate flags can never be dropped from the
343/// output.
344fn write_js_shared_escape<W: fmt::Write>(out: &mut W, c: char) -> fmt::Result {
345    match c {
346        '\x08' => out.write_str("\\b"),
347        '\t' => out.write_str("\\t"),
348        '\n' => out.write_str("\\n"),
349        '\x0B' => out.write_str("\\x0b"),
350        '\x0C' => out.write_str("\\f"),
351        '\r' => out.write_str("\\r"),
352        '\\' => out.write_str("\\\\"),
353        '\u{2028}' => out.write_str("\\u2028"),
354        '\u{2029}' => out.write_str("\\u2029"),
355        '\x00'..='\x1F' => write!(out, "\\x{:02x}", c as u32),
356        c if is_text_direction_control(c) => write!(out, "\\u{:04x}", c as u32),
357        c => write!(out, "\\u{{{:x}}}", c as u32),
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    /// the bidi formatting controls, with their escaped forms.
366    const TEXT_DIRECTION: [(&str, &str); 9] = [
367        ("\u{202A}", r"\u202a"),
368        ("\u{202B}", r"\u202b"),
369        ("\u{202C}", r"\u202c"),
370        ("\u{202D}", r"\u202d"),
371        ("\u{202E}", r"\u202e"),
372        ("\u{2066}", r"\u2066"),
373        ("\u{2067}", r"\u2067"),
374        ("\u{2068}", r"\u2068"),
375        ("\u{2069}", r"\u2069"),
376    ];
377
378    // -- for_javascript (universal) --
379
380    #[test]
381    fn js_no_encoding_needed() {
382        assert_eq!(for_javascript("hello world"), "hello world");
383        assert_eq!(for_javascript(""), "");
384    }
385
386    #[test]
387    fn js_encodes_quotes_as_hex() {
388        assert_eq!(for_javascript(r#"a"b"#), r"a\x22b");
389        assert_eq!(for_javascript("a'b"), r"a\x27b");
390    }
391
392    #[test]
393    fn js_encodes_backslash() {
394        assert_eq!(for_javascript(r"a\b"), r"a\\b");
395    }
396
397    #[test]
398    fn js_encodes_ampersand() {
399        assert_eq!(for_javascript("a&b"), r"a\x26b");
400    }
401
402    #[test]
403    fn js_encodes_slash() {
404        assert_eq!(for_javascript("a/b"), r"a\/b");
405        assert_eq!(for_javascript("</script>"), r"\x3c\/script>");
406    }
407
408    #[test]
409    fn js_encodes_lt() {
410        assert_eq!(for_javascript("a<b"), r"a\x3cb");
411        assert_eq!(for_javascript("<!--<script>"), r"\x3c!--\x3cscript>");
412        assert_eq!(for_javascript("<!--"), r"\x3c!--");
413        assert_eq!(for_javascript("<script"), r"\x3cscript");
414    }
415
416    #[test]
417    fn js_encodes_control_chars() {
418        assert_eq!(for_javascript("\x00"), r"\x00");
419        assert_eq!(for_javascript("\x08"), r"\b");
420        assert_eq!(for_javascript("\t"), r"\t");
421        assert_eq!(for_javascript("\n"), r"\n");
422        assert_eq!(for_javascript("\x0B"), r"\x0b");
423        assert_eq!(for_javascript("\x0C"), r"\f");
424        assert_eq!(for_javascript("\r"), r"\r");
425        assert_eq!(for_javascript("\x1F"), r"\x1f");
426    }
427
428    #[test]
429    fn js_encodes_line_separators() {
430        assert_eq!(for_javascript("\u{2028}"), r"\u2028");
431        assert_eq!(for_javascript("\u{2029}"), r"\u2029");
432    }
433
434    #[test]
435    fn js_escapes_text_direction_controls() {
436        for (raw, escaped) in TEXT_DIRECTION {
437            assert_eq!(for_javascript(raw), escaped);
438            assert_eq!(for_javascript(&format!("a{raw}b")), format!("a{escaped}b"));
439        }
440    }
441
442    #[test]
443    fn js_preserves_non_ascii() {
444        assert_eq!(for_javascript("café"), "café");
445        assert_eq!(for_javascript("日本語"), "日本語");
446    }
447
448    #[test]
449    fn js_writer_variant() {
450        let mut out = String::new();
451        write_javascript(&mut out, "a'b").unwrap();
452        assert_eq!(out, r"a\x27b");
453    }
454
455    // -- for_javascript_attribute --
456
457    #[test]
458    fn js_attr_does_not_encode_slash_or_lt() {
459        assert_eq!(for_javascript_attribute("a/b"), "a/b");
460        assert_eq!(for_javascript_attribute("<!--<script>"), "<!--<script>");
461    }
462
463    #[test]
464    fn js_attr_encodes_quotes_as_hex() {
465        assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
466    }
467
468    #[test]
469    fn js_attr_encodes_ampersand() {
470        assert_eq!(for_javascript_attribute("a&b"), r"a\x26b");
471    }
472
473    #[test]
474    fn js_attr_escapes_text_direction_controls() {
475        for (raw, escaped) in TEXT_DIRECTION {
476            assert_eq!(for_javascript_attribute(raw), escaped);
477        }
478    }
479
480    // -- for_javascript_block --
481
482    #[test]
483    fn js_block_uses_backslash_quotes() {
484        assert_eq!(for_javascript_block(r#"a"b"#), r#"a\"b"#);
485        assert_eq!(for_javascript_block("a'b"), r"a\'b");
486    }
487
488    #[test]
489    fn js_block_encodes_slash() {
490        assert_eq!(for_javascript_block("a/b"), r"a\/b");
491    }
492
493    #[test]
494    fn js_block_encodes_lt() {
495        assert_eq!(for_javascript_block("a<b"), r"a\x3cb");
496        assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
497        assert_eq!(for_javascript_block("<!--"), r"\x3c!--");
498        assert_eq!(for_javascript_block("<script"), r"\x3cscript");
499    }
500
501    #[test]
502    fn js_block_encodes_ampersand() {
503        assert_eq!(for_javascript_block("a&b"), r"a\x26b");
504    }
505
506    #[test]
507    fn js_block_escapes_text_direction_controls() {
508        for (raw, escaped) in TEXT_DIRECTION {
509            assert_eq!(for_javascript_block(raw), escaped);
510        }
511    }
512
513    // -- for_javascript_source --
514
515    #[test]
516    fn js_source_uses_backslash_quotes() {
517        assert_eq!(for_javascript_source(r#"a"b"#), r#"a\"b"#);
518        assert_eq!(for_javascript_source("a'b"), r"a\'b");
519    }
520
521    #[test]
522    fn js_source_does_not_encode_slash_ampersand_or_lt() {
523        assert_eq!(for_javascript_source("a/b&c"), "a/b&c");
524        assert_eq!(for_javascript_source("<!--<script>"), "<!--<script>");
525    }
526
527    #[test]
528    fn js_source_encodes_line_separators() {
529        assert_eq!(for_javascript_source("\u{2028}"), r"\u2028");
530    }
531
532    #[test]
533    fn js_source_escapes_text_direction_controls() {
534        for (raw, escaped) in TEXT_DIRECTION {
535            assert_eq!(for_javascript_source(raw), escaped);
536            assert_eq!(
537                for_javascript_source(&format!("var x = '{raw}';")),
538                format!(r"var x = \'{escaped}\';")
539            );
540        }
541    }
542
543    // -- for_js_template --
544
545    #[test]
546    fn js_template_no_encoding_needed() {
547        assert_eq!(for_js_template("hello world"), "hello world");
548        assert_eq!(for_js_template(""), "");
549    }
550
551    #[test]
552    fn js_template_encodes_backtick() {
553        assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
554        assert_eq!(for_js_template("`"), r"\`");
555    }
556
557    #[test]
558    fn js_template_encodes_interpolation() {
559        assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
560        assert_eq!(for_js_template("a${b}c"), r"a\${b}c");
561        assert_eq!(for_js_template("${a}${b}"), r"\${a}\${b}");
562    }
563
564    #[test]
565    fn js_template_dollar_without_brace_passes_through() {
566        assert_eq!(for_js_template("a $ b"), "a $ b");
567        assert_eq!(for_js_template("$100"), "$100");
568    }
569
570    #[test]
571    fn js_template_escapes_trailing_dollar() {
572        assert_eq!(for_js_template("a$"), r"a\$");
573        assert_eq!(for_js_template("$"), r"\$");
574    }
575
576    #[test]
577    fn js_template_encodes_backslash() {
578        assert_eq!(for_js_template(r"a\b"), r"a\\b");
579    }
580
581    #[test]
582    fn js_template_encodes_slash() {
583        assert_eq!(for_js_template("a/b"), r"a\/b");
584        assert_eq!(for_js_template("</script>"), r"\x3c\/script>");
585    }
586
587    #[test]
588    fn js_template_encodes_lt() {
589        assert_eq!(for_js_template("a<b"), r"a\x3cb");
590        assert_eq!(for_js_template("<!--<script>"), r"\x3c!--\x3cscript>");
591        assert_eq!(for_js_template("<!--"), r"\x3c!--");
592        assert_eq!(for_js_template("<script"), r"\x3cscript");
593    }
594
595    #[test]
596    fn js_template_does_not_encode_quotes() {
597        assert_eq!(for_js_template(r#"a"b"#), r#"a"b"#);
598        assert_eq!(for_js_template("a'b"), "a'b");
599    }
600
601    #[test]
602    fn js_template_encodes_control_chars() {
603        assert_eq!(for_js_template("\x00"), r"\x00");
604        assert_eq!(for_js_template("\x08"), r"\b");
605        assert_eq!(for_js_template("\t"), r"\t");
606        assert_eq!(for_js_template("\n"), r"\n");
607        assert_eq!(for_js_template("\x0B"), r"\x0b");
608        assert_eq!(for_js_template("\x0C"), r"\f");
609        assert_eq!(for_js_template("\r"), r"\r");
610        assert_eq!(for_js_template("\x1F"), r"\x1f");
611    }
612
613    #[test]
614    fn js_template_escapes_text_direction_controls() {
615        for (raw, escaped) in TEXT_DIRECTION {
616            assert_eq!(for_js_template(raw), escaped);
617            assert_eq!(for_js_template(&format!("a{raw}b")), format!("a{escaped}b"));
618        }
619    }
620
621    #[test]
622    fn js_template_encodes_line_separators() {
623        assert_eq!(for_js_template("\u{2028}"), r"\u2028");
624        assert_eq!(for_js_template("\u{2029}"), r"\u2029");
625    }
626
627    #[test]
628    fn js_template_preserves_non_ascii() {
629        assert_eq!(for_js_template("café"), "café");
630        assert_eq!(for_js_template("日本語"), "日本語");
631        assert_eq!(for_js_template("😀"), "😀");
632    }
633
634    #[test]
635    fn js_template_mixed_input() {
636        assert_eq!(
637            for_js_template("`Hello ${name}`, welcome\\n"),
638            r"\`Hello \${name}\`, welcome\\n"
639        );
640    }
641
642    #[test]
643    fn js_template_writer_variant() {
644        let input = "`test` ${x} café";
645        let string_result = for_js_template(input);
646        let mut writer_result = String::new();
647        write_js_template(&mut writer_result, input).unwrap();
648        assert_eq!(string_result, writer_result);
649    }
650
651    // -- write_js_shared_escape helper --
652
653    #[test]
654    fn shared_escape_handles_shared_chars() {
655        let cases = [
656            ('\x08', r"\b"),
657            ('\t', r"\t"),
658            ('\n', r"\n"),
659            ('\x0B', r"\x0b"),
660            ('\x0C', r"\f"),
661            ('\r', r"\r"),
662            ('\\', r"\\"),
663            ('\x00', r"\x00"),
664            ('\x1F', r"\x1f"),
665            ('\u{2028}', r"\u2028"),
666            ('\u{2029}', r"\u2029"),
667        ];
668        for (c, expected) in cases {
669            let mut out = String::new();
670            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
671            assert_eq!(out, expected);
672        }
673    }
674
675    #[test]
676    fn shared_escape_never_drops_a_character() {
677        for (c, expected) in [('a', r"\u{61}"), ('"', r"\u{22}"), ('é', r"\u{e9}")] {
678            let mut out = String::new();
679            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
680            assert_eq!(out, expected);
681        }
682    }
683}