1#![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#[cfg(doctest)]
40#[doc = include_str!("../README.md")]
41struct ReadmeDoctests;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Quotes {
46 Single,
48 Double,
50 Backtick,
52}
53
54#[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 #[must_use]
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 #[must_use]
77 pub fn quotes(mut self, quotes: Quotes) -> Self {
78 self.quotes = Some(quotes);
79 self
80 }
81
82 #[must_use]
84 pub fn wrap(mut self, wrap: bool) -> Self {
85 self.wrap = Some(wrap);
86 self
87 }
88
89 #[must_use]
91 pub fn es6(mut self, es6: bool) -> Self {
92 self.es6 = es6;
93 self
94 }
95
96 #[must_use]
98 pub fn escape_everything(mut self, value: bool) -> Self {
99 self.escape_everything = value;
100 self
101 }
102
103 #[must_use]
106 pub fn minimal(mut self, value: bool) -> Self {
107 self.minimal = value;
108 self
109 }
110
111 #[must_use]
114 pub fn is_script_context(mut self, value: bool) -> Self {
115 self.is_script_context = value;
116 self
117 }
118
119 #[must_use]
121 pub fn json(mut self, value: bool) -> Self {
122 self.json = value;
123 self
124 }
125
126 #[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#[must_use]
153pub fn jsesc(string: &str) -> String {
154 jsesc_with(string, &Options::new())
155}
156
157#[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 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
248fn is_safe(unit: u16) -> bool {
251 matches!(unit, 0x20 | 0x21 | 0x23..=0x26 | 0x28..=0x5B | 0x5D..=0x5F | 0x61..=0x7E)
252}
253
254fn 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
274fn 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 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
325fn 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"); }
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 assert_eq!(jsesc("a\u{2028}b"), "a\\u2028b");
429 assert_eq!(jsesc("a\u{2029}b"), "a\\u2029b");
430 assert_eq!(jsesc("\u{0100}"), "\\u0100");
432 }
433}