#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/jsesc/0.1.0")]
#![allow(clippy::struct_excessive_bools, clippy::format_push_string)]
extern crate alloc;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Quotes {
Single,
Double,
Backtick,
}
#[derive(Debug, Clone, Default)]
pub struct Options {
quotes: Option<Quotes>,
wrap: Option<bool>,
es6: bool,
escape_everything: bool,
minimal: bool,
is_script_context: bool,
json: bool,
lowercase_hex: bool,
}
impl Options {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn quotes(mut self, quotes: Quotes) -> Self {
self.quotes = Some(quotes);
self
}
#[must_use]
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = Some(wrap);
self
}
#[must_use]
pub fn es6(mut self, es6: bool) -> Self {
self.es6 = es6;
self
}
#[must_use]
pub fn escape_everything(mut self, value: bool) -> Self {
self.escape_everything = value;
self
}
#[must_use]
pub fn minimal(mut self, value: bool) -> Self {
self.minimal = value;
self
}
#[must_use]
pub fn is_script_context(mut self, value: bool) -> Self {
self.is_script_context = value;
self
}
#[must_use]
pub fn json(mut self, value: bool) -> Self {
self.json = value;
self
}
#[must_use]
pub fn lowercase_hex(mut self, value: bool) -> Self {
self.lowercase_hex = value;
self
}
fn resolved_quotes(&self) -> Quotes {
self.quotes.unwrap_or(if self.json {
Quotes::Double
} else {
Quotes::Single
})
}
fn resolved_wrap(&self) -> bool {
self.wrap.unwrap_or(self.json)
}
}
#[must_use]
pub fn jsesc(string: &str) -> String {
jsesc_with(string, &Options::new())
}
#[must_use]
pub fn jsesc_with(string: &str, options: &Options) -> String {
let quote = match options.resolved_quotes() {
Quotes::Single => '\'',
Quotes::Double => '"',
Quotes::Backtick => '`',
};
let units: Vec<u16> = string.encode_utf16().collect();
let mut result = String::with_capacity(string.len());
let mut index = 0;
while index < units.len() {
let unit = units[index];
if is_high_surrogate(unit) && index + 1 < units.len() && is_low_surrogate(units[index + 1])
{
let low = units[index + 1];
let code_point =
(u32::from(unit) - 0xD800) * 0x400 + (u32::from(low) - 0xDC00) + 0x1_0000;
if options.minimal {
push_scalar(&mut result, code_point);
} else if options.es6 {
result.push_str(&format!(
"\\u{{{}}}",
hex(code_point, options.lowercase_hex)
));
} else {
result.push_str(&four_hex_escape(unit, options.lowercase_hex));
result.push_str(&four_hex_escape(low, options.lowercase_hex));
}
index += 2;
continue;
}
if is_surrogate(unit) {
result.push_str(&four_hex_escape(unit, options.lowercase_hex));
index += 1;
continue;
}
let escape = options.escape_everything || is_quote(unit) || !is_safe(unit);
if escape {
let next = units.get(index + 1).copied();
result.push_str(&escape_unit(unit, next, options, quote));
} else {
push_scalar(&mut result, u32::from(unit));
}
index += 1;
}
if quote == '`' {
result = result.replace("${", "\\${");
}
if options.is_script_context {
result = escape_script_tags(&result);
let comment = if options.json {
"\\u003C!--"
} else {
"\\x3C!--"
};
result = result.replace("<!--", comment);
}
if options.resolved_wrap() {
let mut wrapped = String::with_capacity(result.len() + 2);
wrapped.push(quote);
wrapped.push_str(&result);
wrapped.push(quote);
result = wrapped;
}
result
}
fn is_high_surrogate(unit: u16) -> bool {
(0xD800..=0xDBFF).contains(&unit)
}
fn is_low_surrogate(unit: u16) -> bool {
(0xDC00..=0xDFFF).contains(&unit)
}
fn is_surrogate(unit: u16) -> bool {
(0xD800..=0xDFFF).contains(&unit)
}
fn is_quote(unit: u16) -> bool {
matches!(unit, 0x22 | 0x27 | 0x60)
}
fn is_safe(unit: u16) -> bool {
matches!(unit, 0x20 | 0x21 | 0x23..=0x26 | 0x28..=0x5B | 0x5D..=0x5F | 0x61..=0x7E)
}
fn is_special_whitespace(unit: u16) -> bool {
matches!(
unit,
0xA0 | 0x1680 | 0x2000..=0x200A | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000
)
}
fn hex(code: u32, lowercase: bool) -> String {
if lowercase {
format!("{code:x}")
} else {
format!("{code:X}")
}
}
fn four_hex_escape(unit: u16, lowercase: bool) -> String {
format!("\\u{:0>4}", hex(u32::from(unit), lowercase))
}
fn push_scalar(result: &mut String, code: u32) {
if let Some(c) = char::from_u32(code) {
result.push(c);
}
}
fn escape_unit(unit: u16, next: Option<u16>, options: &Options, quote: char) -> String {
if unit == 0 && !options.json && !matches!(next, Some(0x30..=0x39)) {
return "\\0".to_string();
}
if is_quote(unit) {
let character = char::from_u32(u32::from(unit)).unwrap_or('"');
if character == quote || options.escape_everything {
return format!("\\{character}");
}
return character.to_string();
}
if let Some(single) = single_escape(unit) {
return single.to_string();
}
if options.minimal && !is_special_whitespace(unit) {
let mut out = String::new();
push_scalar(&mut out, u32::from(unit));
return out;
}
let hex = hex(u32::from(unit), options.lowercase_hex);
if options.json || hex.len() > 2 {
return format!("\\u{hex:0>4}");
}
format!("\\x{hex:0>2}")
}
fn single_escape(unit: u16) -> Option<&'static str> {
match unit {
0x5C => Some("\\\\"),
0x08 => Some("\\b"),
0x0C => Some("\\f"),
0x0A => Some("\\n"),
0x0D => Some("\\r"),
0x09 => Some("\\t"),
_ => None,
}
}
fn escape_script_tags(string: &str) -> String {
let mut out = String::with_capacity(string.len());
let mut index = 0;
while index < string.len() {
let rest = &string[index..];
if let Some(after) = rest.strip_prefix("</") {
if starts_with_ignore_ascii_case(after, "script")
|| starts_with_ignore_ascii_case(after, "style")
{
out.push_str("<\\/");
index += 2;
continue;
}
}
let character = rest.chars().next().expect("non-empty remainder");
out.push(character);
index += character.len_utf8();
}
out
}
fn starts_with_ignore_ascii_case(haystack: &str, prefix: &str) -> bool {
haystack.len() >= prefix.len()
&& haystack.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
assert_eq!(jsesc("café"), "caf\\xE9");
assert_eq!(jsesc("foo'bar"), "foo\\'bar");
assert_eq!(jsesc("\t\n\\"), "\\t\\n\\\\");
assert_eq!(jsesc("plain ASCII!"), "plain ASCII!");
}
#[test]
fn quotes_and_wrap() {
assert_eq!(
jsesc_with("a\"b", &Options::new().quotes(Quotes::Double)),
"a\\\"b"
);
assert_eq!(
jsesc_with("x`${y}", &Options::new().quotes(Quotes::Backtick)),
"x\\`\\${y}"
);
assert_eq!(jsesc_with("hi", &Options::new().wrap(true)), "'hi'");
assert_eq!(jsesc_with("hi", &Options::new().json(true)), "\"hi\"");
}
#[test]
fn astral_and_es6() {
assert_eq!(jsesc("\u{1D306}"), "\\uD834\\uDF06");
assert_eq!(
jsesc_with("\u{1D306}", &Options::new().es6(true)),
"\\u{1D306}"
);
assert_eq!(
jsesc_with("\u{1D306}", &Options::new().minimal(true)),
"\u{1D306}"
);
}
#[test]
fn nulls() {
assert_eq!(jsesc("\0"), "\\0");
assert_eq!(jsesc("\0a"), "\\0a");
assert_eq!(jsesc("\u{0}1"), "\\x001"); }
#[test]
fn escape_everything_and_minimal() {
assert_eq!(
jsesc_with("ab", &Options::new().escape_everything(true)),
"\\x61\\x62"
);
assert_eq!(jsesc_with("café", &Options::new().minimal(true)), "café");
assert_eq!(jsesc_with("a b", &Options::new().minimal(true)), "a b");
assert_eq!(
jsesc_with("café", &Options::new().lowercase_hex(true)),
"caf\\xe9"
);
}
#[test]
fn script_context() {
assert_eq!(
jsesc_with("</script>", &Options::new().is_script_context(true)),
"<\\/script>"
);
assert_eq!(
jsesc_with("<!--x", &Options::new().is_script_context(true)),
"\\x3C!--x"
);
}
#[test]
fn line_separators_and_control() {
assert_eq!(jsesc("a\u{2028}b"), "a\\u2028b");
assert_eq!(jsesc("a\u{2029}b"), "a\\u2029b");
assert_eq!(jsesc("\u{0100}"), "\\u0100");
}
}