1use std::borrow::Cow;
4
5use crate::entities_data::{LABELS, NODES, NamedNode, VALUE_DATA, VALUES};
6
7#[inline]
13pub fn decode(input: &str, attribute: bool) -> Cow<'_, str> {
14 let bytes = input.as_bytes();
15 let first = if bytes.len() < 16 {
16 bytes
17 .iter()
18 .position(|&byte| matches!(byte, b'&' | b'\r') || (attribute && byte == 0))
19 } else if attribute {
20 memchr::memchr3(b'&', b'\r', 0, bytes)
21 } else {
22 memchr::memchr2(b'&', b'\r', bytes)
23 };
24 let Some(first) = first else {
25 return Cow::Borrowed(input);
26 };
27 decode_from(input, attribute, first)
28}
29
30#[inline(never)]
32pub(crate) fn decode_from(input: &str, attribute: bool, mut cursor: usize) -> Cow<'_, str> {
33 let bytes = input.as_bytes();
34 let mut output = None;
35 let mut unchanged = 0;
36 while cursor < bytes.len() {
37 let (next, replacement) = match bytes[cursor] {
38 b'&' => {
39 let Some((consumed, replacement)) = reference(&input[cursor + 1..], attribute)
40 else {
41 cursor += 1;
42 continue;
43 };
44 (cursor + consumed + 1, replacement)
45 }
46 b'\r' => (
47 cursor + 1 + usize::from(bytes.get(cursor + 1) == Some(&b'\n')),
48 Replacement::Character('\n'),
49 ),
50 0 if attribute => (cursor + 1, Replacement::Character('\u{fffd}')),
51 _ => {
52 cursor += if attribute {
53 memchr::memchr3(b'&', b'\r', 0, &bytes[cursor..])
54 } else {
55 memchr::memchr2(b'&', b'\r', &bytes[cursor..])
56 }
57 .unwrap_or(bytes.len() - cursor);
58 continue;
59 }
60 };
61 let output = output.get_or_insert_with(|| String::with_capacity(input.len()));
62 output.push_str(&input[unchanged..cursor]);
63 match replacement {
64 Replacement::Character(character) => output.push(character),
65 Replacement::Named(text) => output.push_str(text),
66 }
67 cursor = next;
68 unchanged = next;
69 }
70 if let Some(mut output) = output {
71 output.push_str(&input[unchanged..]);
72 if output.capacity() > 64 && output.capacity() > output.len().saturating_mul(2) {
75 output.shrink_to_fit();
76 }
77 Cow::Owned(output)
78 } else {
79 Cow::Borrowed(input)
80 }
81}
82
83pub fn normalize(input: &str) -> Cow<'_, str> {
85 let Some(first) = memchr::memchr(b'\r', input.as_bytes()) else {
86 return Cow::Borrowed(input);
87 };
88 let mut output = String::with_capacity(input.len());
89 output.push_str(&input[..first]);
90 let mut rest = &input[first..];
91 while let Some(offset) = memchr::memchr(b'\r', rest.as_bytes()) {
92 output.push_str(&rest[..offset]);
93 output.push('\n');
94 rest = &rest[offset + 1..];
95 if let Some(suffix) = rest.strip_prefix('\n') {
96 rest = suffix;
97 }
98 }
99 output.push_str(rest);
100 Cow::Owned(output)
101}
102
103enum Replacement {
104 Character(char),
105 Named(&'static str),
106}
107
108fn reference(input: &str, attribute: bool) -> Option<(usize, Replacement)> {
110 let bytes = input.as_bytes();
111 if bytes.first() == Some(&b'#') {
112 let hex = matches!(bytes.get(1), Some(b'x' | b'X'));
113 let start = if hex { 2 } else { 1 };
114 let radix = if hex { 16 } else { 10 };
115 let mut end = start;
116 let mut number = 0u32;
117 while let Some(digit) = bytes
118 .get(end)
119 .and_then(|byte| char::from(*byte).to_digit(radix))
120 {
121 number = number.saturating_mul(radix).saturating_add(digit);
122 end += 1;
123 }
124 if end == start {
125 return None;
126 }
127 if bytes.get(end) == Some(&b';') {
128 end += 1;
129 }
130 return Some((end, Replacement::Character(numeric(number))));
131 }
132
133 let common = match bytes {
135 [b'g', b't', b';', ..] => Some((3, ">")),
136 [b'l', b't', b';', ..] => Some((3, "<")),
137 [b'a', b'm', b'p', b';', ..] => Some((4, "&")),
138 [b'q', b'u', b'o', b't', b';', ..] => Some((5, "\"")),
139 [b'a', b'p', b'o', b's', b';', ..] => Some((5, "'")),
140 _ => None,
141 };
142 if let Some((length, value)) = common {
143 return Some((length, Replacement::Named(value)));
144 }
145
146 let initial = match bytes.first()? {
147 b'A'..=b'Z' => usize::from(bytes[0] - b'A'),
148 b'a'..=b'z' => usize::from(bytes[0] - b'a') + 26,
149 _ => return None,
150 };
151 let mut node = &NODES[initial];
152 let mut consumed = 0;
153 let mut matched = None;
154 loop {
155 let &NamedNode(label, children, value, label_len, child_count) = node;
156 let label = usize::from(label);
157 if !bytes[consumed..].starts_with(&LABELS[label..label + usize::from(label_len)]) {
158 break;
159 }
160 consumed += usize::from(label_len);
161 if value != 0 {
162 if bytes.get(consumed) == Some(&b';') {
163 matched = Some((consumed + 1, value));
164 break;
165 }
166 if value & 0x8000 != 0
168 && (!attribute
169 || !bytes
170 .get(consumed)
171 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'='))
172 {
173 matched = Some((consumed, value));
174 }
175 }
176 let Some(&next) = bytes.get(consumed) else {
177 break;
178 };
179 let children = usize::from(children);
180 let children = &NODES[children..children + usize::from(child_count)];
181 let Ok(index) = children.binary_search_by_key(&next, |node| LABELS[usize::from(node.0)])
182 else {
183 break;
184 };
185 node = &children[index];
186 }
187 matched.map(|(length, value)| {
188 let (start, len) = VALUES[usize::from((value & 0x7fff) - 1)];
189 let start = usize::from(start);
190 (
191 length,
192 Replacement::Named(&VALUE_DATA[start..start + usize::from(len)]),
193 )
194 })
195}
196
197fn numeric(number: u32) -> char {
199 const C1: [u32; 32] = [
200 0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021, 0x2c6, 0x2030, 0x160, 0x2039,
201 0x152, 0x8d, 0x17d, 0x8f, 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
202 0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
203 ];
204 let number = match number {
205 0 => 0xfffd,
206 0x80..=0x9f => C1[(number - 0x80) as usize],
207 number => number,
208 };
209 char::from_u32(number).unwrap_or('\u{fffd}')
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::entities_data::NAMED;
216
217 #[test]
218 fn named_references_follow_attribute_context() {
219 assert_eq!(
220 decode("¬it; &=1 & ≂̸", false),
221 "¬it; &=1 & ≂\u{338}"
222 );
223 assert_eq!(
224 decode("¬it; &=1 & ≂̸", true),
225 "¬it; &=1 & ≂\u{338}"
226 );
227 assert_eq!(decode("&lt;", true), "<");
228 }
229
230 #[test]
231 fn unterminated_named_references_choose_the_longest_legacy_prefix() {
232 for &(name, _) in NAMED {
233 let name = name.trim_end_matches(';');
234 let source = format!("&{name}");
235 let legacy = NAMED
236 .iter()
237 .filter(|(legacy, _)| !legacy.ends_with(';') && name.starts_with(legacy))
238 .max_by_key(|(legacy, _)| legacy.len());
239 let expected = legacy.map_or_else(
240 || source.clone(),
241 |(legacy, value)| format!("{value}{}", &name[legacy.len()..]),
242 );
243 assert_eq!(decode(&source, false), expected, "{source}");
244 let expected = legacy
245 .filter(|(legacy, _)| legacy.len() == name.len())
246 .map_or(source.as_str(), |(_, value)| *value);
247 assert_eq!(decode(&source, true), expected, "{source}");
248 }
249 }
250
251 #[test]
252 fn unchanged_ampersands_borrow_the_input() {
253 for source in [
254 "a & b",
255 "?a=1&unknown=2",
256 "&unknown;",
257 "&#x;",
258 "é &unknown; 🦀",
259 ] {
260 for attribute in [false, true] {
261 assert!(
262 matches!(decode(source, attribute), Cow::Borrowed(value) if value == source)
263 );
264 }
265 }
266 assert!(matches!(decode("¬it;", true), Cow::Borrowed("¬it;")));
267 assert_eq!(
268 decode("&unknown; then & and &unknown;", false),
269 "&unknown; then & and &unknown;"
270 );
271 }
272
273 #[test]
274 fn numeric_references_replace_invalid_scalars() {
275 assert_eq!(
276 decode("� � � € 🦀", false),
277 "� � � € 🦀"
278 );
279 assert_eq!(decode("�", true), "�");
280 assert_eq!(decode("&#x; &#; A=", true), "&#x; &#; A=");
281 }
282
283 #[test]
284 fn compressed_references_do_not_retain_input_sized_buffers() {
285 for (source, expected) in [
286 (format!("&#{}65;", "0".repeat(1024 * 1024)), "A".to_owned()),
287 (format!("&#x{}41;", "0".repeat(1024 * 1024)), "A".to_owned()),
288 ("A".repeat(8192), "A".repeat(8192)),
289 (
290 "∳".repeat(8192),
291 "∳".repeat(8192),
292 ),
293 ] {
294 for attribute in [false, true] {
295 let Cow::Owned(decoded) = decode(&source, attribute) else {
296 panic!("decoded references must be owned");
297 };
298 assert_eq!(decoded, expected);
299 assert!(decoded.capacity() <= 2 * decoded.len());
300 }
301 }
302 }
303
304 #[test]
305 fn newlines_and_null_are_contextual() {
306 assert_eq!(decode("a\r\nb\rc\0", true), "a\nb\nc�");
307 assert_eq!(decode("a\r\nb\rc\0", false), "a\nb\nc\0");
308 assert_eq!(normalize("\r\n\r©\0"), "\n\n©\0");
309 assert!(matches!(decode("plain 🦀 text", true), Cow::Borrowed(_)));
310 }
311
312 #[test]
313 fn every_named_reference_decodes() {
314 assert_eq!(NAMED.len(), 2231);
315 for &(name, expected) in NAMED {
316 for attribute in [false, true] {
317 assert_eq!(decode(&format!("&{name}"), attribute), expected, "{name}");
318 }
319 if !name.ends_with(';') {
320 let terminated = format!("{name};");
321 assert!(
322 NAMED.contains(&(terminated.as_str(), expected)),
323 "missing terminated spelling for {name}"
324 );
325 }
326 }
327 }
328}