Skip to main content

jsesc/
lib.rs

1//! # jsesc — escape a string for JavaScript or JSON
2//!
3//! Escape a string so it can be safely embedded in JavaScript or JSON source — handling
4//! quotes, backslashes, control characters, line/paragraph separators, and (optionally)
5//! every non-ASCII character. A faithful Rust port of the string-escaping of the widely-used
6//! [`jsesc`](https://www.npmjs.com/package/jsesc) npm package by Mathias Bynens.
7//!
8//! ```
9//! use jsesc::jsesc;
10//!
11//! assert_eq!(jsesc("café"), "caf\\xE9");
12//! assert_eq!(jsesc("foo'bar"), "foo\\'bar");
13//! ```
14//!
15//! Use [`jsesc_with`] / [`Options`] for double/backtick quotes, wrapping, JSON mode, ES6
16//! unicode escapes, ASCII-only output, and more:
17//!
18//! ```
19//! use jsesc::{jsesc_with, Options, Quotes};
20//!
21//! assert_eq!(jsesc_with("hi", &Options::new().json(true)), "\"hi\"");
22//! assert_eq!(jsesc_with("ab", &Options::new().escape_everything(true)), "\\x61\\x62");
23//! ```
24//!
25//! **Zero dependencies** and `#![no_std]`.
26
27#![no_std]
28#![forbid(unsafe_code)]
29#![doc(html_root_url = "https://docs.rs/jsesc/0.1.0")]
30#![allow(clippy::struct_excessive_bools, clippy::format_push_string)]
31
32extern crate alloc;
33
34use alloc::format;
35use alloc::string::{String, ToString};
36use alloc::vec::Vec;
37
38// Compile-test the README's examples as part of `cargo test`.
39#[cfg(doctest)]
40#[doc = include_str!("../README.md")]
41struct ReadmeDoctests;
42
43/// The quote style to escape for (and wrap with).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Quotes {
46    /// Single quotes (`'`).
47    Single,
48    /// Double quotes (`"`).
49    Double,
50    /// Backticks (`` ` ``).
51    Backtick,
52}
53
54/// Options for [`jsesc_with`].
55#[derive(Debug, Clone, Default)]
56pub struct Options {
57    quotes: Option<Quotes>,
58    wrap: Option<bool>,
59    es6: bool,
60    escape_everything: bool,
61    minimal: bool,
62    is_script_context: bool,
63    json: bool,
64    lowercase_hex: bool,
65}
66
67impl Options {
68    /// Default options (single quotes, no wrapping, escape non-ASCII).
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Which quote character to escape (and wrap with). Defaults to [`Quotes::Single`]
75    /// (or [`Quotes::Double`] when `json` is set).
76    #[must_use]
77    pub fn quotes(mut self, quotes: Quotes) -> Self {
78        self.quotes = Some(quotes);
79        self
80    }
81
82    /// Wrap the output in the quote character. Defaults to `false` (or `true` when `json`).
83    #[must_use]
84    pub fn wrap(mut self, wrap: bool) -> Self {
85        self.wrap = Some(wrap);
86        self
87    }
88
89    /// Use ES6 unicode code point escapes (`\u{1D306}`) for astral symbols.
90    #[must_use]
91    pub fn es6(mut self, es6: bool) -> Self {
92        self.es6 = es6;
93        self
94    }
95
96    /// Escape every character, including printable ASCII.
97    #[must_use]
98    pub fn escape_everything(mut self, value: bool) -> Self {
99        self.escape_everything = value;
100        self
101    }
102
103    /// Only escape the strictly necessary characters (quotes, backslash, line breaks, and
104    /// invisible whitespace), leaving printable non-ASCII as-is.
105    #[must_use]
106    pub fn minimal(mut self, value: bool) -> Self {
107        self.minimal = value;
108        self
109    }
110
111    /// Escape `</script`, `</style`, and `<!--` so the output is safe inside a `<script>`
112    /// or `<style>` element.
113    #[must_use]
114    pub fn is_script_context(mut self, value: bool) -> Self {
115        self.is_script_context = value;
116        self
117    }
118
119    /// Produce JSON-compatible output (double quotes, wrapped, `\uXXXX` escapes only).
120    #[must_use]
121    pub fn json(mut self, value: bool) -> Self {
122        self.json = value;
123        self
124    }
125
126    /// Emit hexadecimal digits in lowercase.
127    #[must_use]
128    pub fn lowercase_hex(mut self, value: bool) -> Self {
129        self.lowercase_hex = value;
130        self
131    }
132
133    fn resolved_quotes(&self) -> Quotes {
134        self.quotes.unwrap_or(if self.json {
135            Quotes::Double
136        } else {
137            Quotes::Single
138        })
139    }
140
141    fn resolved_wrap(&self) -> bool {
142        self.wrap.unwrap_or(self.json)
143    }
144}
145
146/// Escape `string` for embedding in JavaScript source, using the default options.
147///
148/// ```
149/// # use jsesc::jsesc;
150/// assert_eq!(jsesc("a\tb"), "a\\tb");
151/// ```
152#[must_use]
153pub fn jsesc(string: &str) -> String {
154    jsesc_with(string, &Options::new())
155}
156
157/// Escape `string` with the given [`Options`].
158#[must_use]
159pub fn jsesc_with(string: &str, options: &Options) -> String {
160    let quote = match options.resolved_quotes() {
161        Quotes::Single => '\'',
162        Quotes::Double => '"',
163        Quotes::Backtick => '`',
164    };
165
166    let units: Vec<u16> = string.encode_utf16().collect();
167    let mut result = String::with_capacity(string.len());
168
169    let mut index = 0;
170    while index < units.len() {
171        let unit = units[index];
172
173        if is_high_surrogate(unit) && index + 1 < units.len() && is_low_surrogate(units[index + 1])
174        {
175            let low = units[index + 1];
176            let code_point =
177                (u32::from(unit) - 0xD800) * 0x400 + (u32::from(low) - 0xDC00) + 0x1_0000;
178            if options.minimal {
179                push_scalar(&mut result, code_point);
180            } else if options.es6 {
181                result.push_str(&format!(
182                    "\\u{{{}}}",
183                    hex(code_point, options.lowercase_hex)
184                ));
185            } else {
186                result.push_str(&four_hex_escape(unit, options.lowercase_hex));
187                result.push_str(&four_hex_escape(low, options.lowercase_hex));
188            }
189            index += 2;
190            continue;
191        }
192
193        if is_surrogate(unit) {
194            // Lone surrogate.
195            result.push_str(&four_hex_escape(unit, options.lowercase_hex));
196            index += 1;
197            continue;
198        }
199
200        let escape = options.escape_everything || is_quote(unit) || !is_safe(unit);
201        if escape {
202            let next = units.get(index + 1).copied();
203            result.push_str(&escape_unit(unit, next, options, quote));
204        } else {
205            push_scalar(&mut result, u32::from(unit));
206        }
207        index += 1;
208    }
209
210    if quote == '`' {
211        result = result.replace("${", "\\${");
212    }
213    if options.is_script_context {
214        result = escape_script_tags(&result);
215        let comment = if options.json {
216            "\\u003C!--"
217        } else {
218            "\\x3C!--"
219        };
220        result = result.replace("<!--", comment);
221    }
222    if options.resolved_wrap() {
223        let mut wrapped = String::with_capacity(result.len() + 2);
224        wrapped.push(quote);
225        wrapped.push_str(&result);
226        wrapped.push(quote);
227        result = wrapped;
228    }
229    result
230}
231
232fn is_high_surrogate(unit: u16) -> bool {
233    (0xD800..=0xDBFF).contains(&unit)
234}
235
236fn is_low_surrogate(unit: u16) -> bool {
237    (0xDC00..=0xDFFF).contains(&unit)
238}
239
240fn is_surrogate(unit: u16) -> bool {
241    (0xD800..=0xDFFF).contains(&unit)
242}
243
244fn is_quote(unit: u16) -> bool {
245    matches!(unit, 0x22 | 0x27 | 0x60)
246}
247
248/// The characters jsesc leaves untouched in the default (non-`escapeEverything`) mode:
249/// `[ !#-&(-[]-_a-~]`.
250fn is_safe(unit: u16) -> bool {
251    matches!(unit, 0x20 | 0x21 | 0x23..=0x26 | 0x28..=0x5B | 0x5D..=0x5F | 0x61..=0x7E)
252}
253
254/// Whitespace that `minimal` mode still escapes (`regexWhitespace` in the reference).
255fn is_special_whitespace(unit: u16) -> bool {
256    matches!(
257        unit,
258        0xA0 | 0x1680 | 0x2000..=0x200A | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000
259    )
260}
261
262fn hex(code: u32, lowercase: bool) -> String {
263    if lowercase {
264        format!("{code:x}")
265    } else {
266        format!("{code:X}")
267    }
268}
269
270fn four_hex_escape(unit: u16, lowercase: bool) -> String {
271    format!("\\u{:0>4}", hex(u32::from(unit), lowercase))
272}
273
274/// Push the scalar value (a non-surrogate code point) as a `char`.
275fn push_scalar(result: &mut String, code: u32) {
276    if let Some(c) = char::from_u32(code) {
277        result.push(c);
278    }
279}
280
281fn escape_unit(unit: u16, next: Option<u16>, options: &Options, quote: char) -> String {
282    // `\0` is escaped as `\0` unless it is followed by a digit (which would form `\01`), or
283    // in JSON mode.
284    if unit == 0 && !options.json && !matches!(next, Some(0x30..=0x39)) {
285        return "\\0".to_string();
286    }
287
288    if is_quote(unit) {
289        let character = char::from_u32(u32::from(unit)).unwrap_or('"');
290        if character == quote || options.escape_everything {
291            return format!("\\{character}");
292        }
293        return character.to_string();
294    }
295
296    if let Some(single) = single_escape(unit) {
297        return single.to_string();
298    }
299
300    if options.minimal && !is_special_whitespace(unit) {
301        let mut out = String::new();
302        push_scalar(&mut out, u32::from(unit));
303        return out;
304    }
305
306    let hex = hex(u32::from(unit), options.lowercase_hex);
307    if options.json || hex.len() > 2 {
308        return format!("\\u{hex:0>4}");
309    }
310    format!("\\x{hex:0>2}")
311}
312
313fn single_escape(unit: u16) -> Option<&'static str> {
314    match unit {
315        0x5C => Some("\\\\"),
316        0x08 => Some("\\b"),
317        0x0C => Some("\\f"),
318        0x0A => Some("\\n"),
319        0x0D => Some("\\r"),
320        0x09 => Some("\\t"),
321        _ => None,
322    }
323}
324
325/// Insert a backslash before the `/` of `</script` / `</style` (case-insensitive), matching
326/// the reference's `/<\/(script|style)/gi` → `<\/$1`.
327fn escape_script_tags(string: &str) -> String {
328    let mut out = String::with_capacity(string.len());
329    let mut index = 0;
330    while index < string.len() {
331        let rest = &string[index..];
332        if let Some(after) = rest.strip_prefix("</") {
333            if starts_with_ignore_ascii_case(after, "script")
334                || starts_with_ignore_ascii_case(after, "style")
335            {
336                out.push_str("<\\/");
337                index += 2;
338                continue;
339            }
340        }
341        let character = rest.chars().next().expect("non-empty remainder");
342        out.push(character);
343        index += character.len_utf8();
344    }
345    out
346}
347
348fn starts_with_ignore_ascii_case(haystack: &str, prefix: &str) -> bool {
349    haystack.len() >= prefix.len()
350        && haystack.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn defaults() {
359        assert_eq!(jsesc("café"), "caf\\xE9");
360        assert_eq!(jsesc("foo'bar"), "foo\\'bar");
361        assert_eq!(jsesc("\t\n\\"), "\\t\\n\\\\");
362        assert_eq!(jsesc("plain ASCII!"), "plain ASCII!");
363    }
364
365    #[test]
366    fn quotes_and_wrap() {
367        assert_eq!(
368            jsesc_with("a\"b", &Options::new().quotes(Quotes::Double)),
369            "a\\\"b"
370        );
371        assert_eq!(
372            jsesc_with("x`${y}", &Options::new().quotes(Quotes::Backtick)),
373            "x\\`\\${y}"
374        );
375        assert_eq!(jsesc_with("hi", &Options::new().wrap(true)), "'hi'");
376        assert_eq!(jsesc_with("hi", &Options::new().json(true)), "\"hi\"");
377    }
378
379    #[test]
380    fn astral_and_es6() {
381        assert_eq!(jsesc("\u{1D306}"), "\\uD834\\uDF06");
382        assert_eq!(
383            jsesc_with("\u{1D306}", &Options::new().es6(true)),
384            "\\u{1D306}"
385        );
386        assert_eq!(
387            jsesc_with("\u{1D306}", &Options::new().minimal(true)),
388            "\u{1D306}"
389        );
390    }
391
392    #[test]
393    fn nulls() {
394        assert_eq!(jsesc("\0"), "\\0");
395        assert_eq!(jsesc("\0a"), "\\0a");
396        assert_eq!(jsesc("\u{0}1"), "\\x001"); // null followed by a digit
397    }
398
399    #[test]
400    fn escape_everything_and_minimal() {
401        assert_eq!(
402            jsesc_with("ab", &Options::new().escape_everything(true)),
403            "\\x61\\x62"
404        );
405        assert_eq!(jsesc_with("café", &Options::new().minimal(true)), "café");
406        assert_eq!(jsesc_with("a b", &Options::new().minimal(true)), "a b");
407        assert_eq!(
408            jsesc_with("café", &Options::new().lowercase_hex(true)),
409            "caf\\xe9"
410        );
411    }
412
413    #[test]
414    fn script_context() {
415        assert_eq!(
416            jsesc_with("</script>", &Options::new().is_script_context(true)),
417            "<\\/script>"
418        );
419        assert_eq!(
420            jsesc_with("<!--x", &Options::new().is_script_context(true)),
421            "\\x3C!--x"
422        );
423    }
424
425    #[test]
426    fn line_separators_and_control() {
427        // U+2028 / U+2029 break JS string literals and must be escaped.
428        assert_eq!(jsesc("a\u{2028}b"), "a\\u2028b");
429        assert_eq!(jsesc("a\u{2029}b"), "a\\u2029b");
430        // A C1 control / high BMP code point uses a four-digit escape.
431        assert_eq!(jsesc("\u{0100}"), "\\u0100");
432    }
433}