1use memchr::{memchr, memchr2, memchr3};
7
8#[inline(always)]
14pub(crate) fn find_special(bytes: &[u8]) -> Option<usize> {
15 match (memchr3(b'<', b'>', b'&', bytes), memchr2(b'"', b'\'', bytes)) {
16 (Some(a), Some(b)) => Some(a.min(b)),
17 (a, b) => a.or(b),
18 }
19}
20
21#[inline(always)]
29pub(crate) fn entity_for(b: u8) -> &'static str {
30 match b {
31 b'<' => "<",
32 b'>' => ">",
33 b'&' => "&",
34 b'"' => """,
35 b'\'' => "'",
36 _ => unreachable!("find_special only returns special characters"),
37 }
38}
39
40#[inline]
44pub fn escape(s: &str) -> std::borrow::Cow<'_, str> {
45 let bytes = s.as_bytes();
46
47 if find_special(bytes).is_none() {
49 return std::borrow::Cow::Borrowed(s);
50 }
51
52 let mut result = String::with_capacity(s.len() + s.len() / 8);
53 escape_to_inner(bytes, &mut result);
54 std::borrow::Cow::Owned(result)
55}
56
57#[inline]
59pub fn escape_to(s: &str, out: &mut String) {
60 escape_to_inner(s.as_bytes(), out);
61}
62
63#[inline(always)]
65fn escape_to_inner(bytes: &[u8], out: &mut String) {
66 let mut start = 0;
67
68 while let Some(offset) = find_special(&bytes[start..]) {
69 let i = start + offset;
70 let escaped = entity_for(bytes[i]);
71
72 if start < i {
74 out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) });
76 }
77 out.push_str(escaped);
78 start = i + 1;
79 }
80
81 if start < bytes.len() {
83 out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) });
84 }
85}
86
87#[inline]
91pub fn unescape(s: &str) -> Result<std::borrow::Cow<'_, str>, UnescapeError> {
92 let bytes = s.as_bytes();
93
94 match memchr(b'&', bytes) {
96 None => Ok(std::borrow::Cow::Borrowed(s)),
97 Some(first_amp) => {
98 let mut result = String::with_capacity(s.len());
99 if first_amp > 0 {
101 result.push_str(unsafe {
102 std::str::from_utf8_unchecked(&bytes[..first_amp])
103 });
104 }
105 unescape_from(bytes, first_amp, &mut result)?;
106 Ok(std::borrow::Cow::Owned(result))
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct UnescapeError {
114 pub entity: String,
116 pub position: usize,
118}
119
120impl std::fmt::Display for UnescapeError {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 write!(f, "invalid XML entity '{}' at position {}", self.entity, self.position)
123 }
124}
125
126impl std::error::Error for UnescapeError {}
127
128#[inline(always)]
130fn unescape_from(bytes: &[u8], start: usize, out: &mut String) -> Result<(), UnescapeError> {
131 let mut i = start;
132
133 while i < bytes.len() {
134 if bytes[i] == b'&' {
135 let entity_start = i;
136 i += 1;
137
138 match memchr(b';', &bytes[i..]) {
140 Some(len) if len > 0 && len <= 10 => {
141 let entity = unsafe {
142 std::str::from_utf8_unchecked(&bytes[i..i + len])
143 };
144
145 if let Some(c) = decode_entity_fast(entity) {
146 out.push(c);
147 i += len + 1;
148
149 if let Some(next_amp) = memchr(b'&', &bytes[i..]) {
151 if next_amp > 0 {
152 out.push_str(unsafe {
153 std::str::from_utf8_unchecked(&bytes[i..i + next_amp])
154 });
155 }
156 i += next_amp;
157 } else {
158 out.push_str(unsafe {
160 std::str::from_utf8_unchecked(&bytes[i..])
161 });
162 return Ok(());
163 }
164 } else {
165 return Err(UnescapeError {
166 entity: format!("&{};", entity),
167 position: entity_start,
168 });
169 }
170 }
171 _ => {
172 return Err(UnescapeError {
173 entity: String::from("&"),
174 position: entity_start,
175 });
176 }
177 }
178 } else {
179 i += 1;
180 }
181 }
182
183 Ok(())
184}
185
186#[inline(always)]
188fn decode_entity_fast(entity: &str) -> Option<char> {
189 match entity.len() {
191 2 => match entity {
192 "lt" => Some('<'),
193 "gt" => Some('>'),
194 _ => decode_numeric_entity(entity),
195 },
196 3 => match entity {
197 "amp" => Some('&'),
198 _ => decode_numeric_entity(entity),
199 },
200 4 => match entity {
201 "quot" => Some('"'),
202 "apos" => Some('\''),
203 _ => decode_numeric_entity(entity),
204 },
205 _ => decode_numeric_entity(entity),
206 }
207}
208
209#[inline]
211fn decode_numeric_entity(entity: &str) -> Option<char> {
212 let bytes = entity.as_bytes();
213 if bytes.is_empty() || bytes[0] != b'#' {
214 return None;
215 }
216
217 let (radix, digits) = if bytes.len() > 1 && (bytes[1] == b'x' || bytes[1] == b'X') {
218 (16, &entity[2..])
219 } else {
220 (10, &entity[1..])
221 };
222
223 if digits.is_empty() {
224 return None;
225 }
226
227 let code = u32::from_str_radix(digits, radix).ok()?;
228 char::from_u32(code)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_escape_no_special_chars() {
237 let s = "Hello, World!";
238 let escaped = escape(s);
239 assert!(matches!(escaped, std::borrow::Cow::Borrowed(_)));
240 assert_eq!(escaped, s);
241 }
242
243 #[test]
244 fn test_escape_lt() {
245 assert_eq!(escape("<"), "<");
246 }
247
248 #[test]
249 fn test_escape_gt() {
250 assert_eq!(escape(">"), ">");
251 }
252
253 #[test]
254 fn test_escape_amp() {
255 assert_eq!(escape("&"), "&");
256 }
257
258 #[test]
259 fn test_escape_quot() {
260 assert_eq!(escape("\""), """);
261 }
262
263 #[test]
264 fn test_escape_apos() {
265 assert_eq!(escape("'"), "'");
266 }
267
268 #[test]
269 fn test_escape_mixed() {
270 assert_eq!(
271 escape("<div class=\"foo\">Hello & goodbye</div>"),
272 "<div class="foo">Hello & goodbye</div>"
273 );
274 }
275
276 #[test]
277 fn test_escape_long_mixed_offsets() {
278 let filler = "a".repeat(100);
281 let input = format!("<{filler}&{filler}\"'{filler}>");
282 let expected = format!("<{filler}&{filler}"'{filler}>");
283 assert_eq!(escape(&input), expected);
284 }
285
286 #[test]
287 fn test_escape_only_quotes() {
288 assert_eq!(escape("say \"hi\" and 'bye'"), "say "hi" and 'bye'");
291 }
292
293 #[test]
294 fn test_unescape_no_entities() {
295 let s = "Hello, World!";
296 let unescaped = unescape(s).unwrap();
297 assert!(matches!(unescaped, std::borrow::Cow::Borrowed(_)));
298 assert_eq!(unescaped, s);
299 }
300
301 #[test]
302 fn test_unescape_lt() {
303 assert_eq!(unescape("<").unwrap(), "<");
304 }
305
306 #[test]
307 fn test_unescape_gt() {
308 assert_eq!(unescape(">").unwrap(), ">");
309 }
310
311 #[test]
312 fn test_unescape_amp() {
313 assert_eq!(unescape("&").unwrap(), "&");
314 }
315
316 #[test]
317 fn test_unescape_quot() {
318 assert_eq!(unescape(""").unwrap(), "\"");
319 }
320
321 #[test]
322 fn test_unescape_apos() {
323 assert_eq!(unescape("'").unwrap(), "'");
324 }
325
326 #[test]
327 fn test_unescape_mixed() {
328 assert_eq!(
329 unescape("<div class="foo">Hello & goodbye</div>").unwrap(),
330 "<div class=\"foo\">Hello & goodbye</div>"
331 );
332 }
333
334 #[test]
335 fn test_unescape_numeric_decimal() {
336 assert_eq!(unescape("A").unwrap(), "A");
337 assert_eq!(unescape("a").unwrap(), "a");
338 assert_eq!(unescape("€").unwrap(), "€");
339 }
340
341 #[test]
342 fn test_unescape_numeric_hex() {
343 assert_eq!(unescape("A").unwrap(), "A");
344 assert_eq!(unescape("a").unwrap(), "a");
345 assert_eq!(unescape("€").unwrap(), "€");
346 }
347
348 #[test]
349 fn test_unescape_invalid_entity() {
350 let result = unescape("&invalid;");
351 assert!(result.is_err());
352 let err = result.unwrap_err();
353 assert_eq!(err.entity, "&invalid;");
354 assert_eq!(err.position, 0);
355 }
356
357 #[test]
358 fn test_unescape_unterminated_entity() {
359 let result = unescape("<");
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn test_escape_to() {
365 let mut out = String::new();
366 escape_to("<test>", &mut out);
367 assert_eq!(out, "<test>");
368 }
369
370 #[test]
371 fn test_roundtrip() {
372 let original = "<div class=\"foo\">Hello & goodbye</div>";
373 let escaped = escape(original);
374 let unescaped = unescape(&escaped).unwrap();
375 assert_eq!(unescaped, original);
376 }
377}