Skip to main content

oxixml_xml/escape/
mod.rs

1//! Manage XML character escapes.
2//!
3//! This module mirrors quick-xml 0.41 `escape`: [`escape`], [`partial_escape`]
4//! and [`minimal_escape`] replace XML special characters with entity references,
5//! while [`unescape`] / [`unescape_with`] reverse the process. Predefined XML
6//! entities are resolved by [`resolve_predefined_entity`]; with the `escape-html`
7//! feature enabled, the full set of HTML5 named references is recognized instead.
8
9use std::borrow::Cow;
10use std::fmt;
11use std::num::ParseIntError;
12use std::ops::Range;
13use std::slice::Iter;
14
15use oxixml_support::bytes::{memchr, memchr3};
16
17#[cfg(feature = "escape-html")]
18mod html5;
19#[cfg(feature = "escape-html")]
20#[cfg_attr(docsrs, doc(cfg(feature = "escape-html")))]
21pub use html5::resolve_html5_entity;
22
23/// Error while parsing a character reference (`&#<dec-number>;` or `&#x<hex-number>;`).
24#[derive(Clone, Debug, PartialEq)]
25pub enum ParseCharRefError {
26    /// The number contains a sign character (`+` or `-`), which is not allowed.
27    UnexpectedSign,
28    /// The number cannot be parsed due to non-numeric characters or numeric overflow.
29    InvalidNumber(ParseIntError),
30    /// The character reference does not represent a valid Unicode code point.
31    InvalidCodepoint(u32),
32    /// The character reference expanded to a character not permitted in XML.
33    ///
34    /// Currently only the `0x0` character produces this error.
35    IllegalCharacter(u32),
36}
37
38impl fmt::Display for ParseCharRefError {
39    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40        match self {
41            Self::UnexpectedSign => f.write_str("unexpected number sign"),
42            Self::InvalidNumber(e) => e.fmt(f),
43            Self::InvalidCodepoint(n) => write!(f, "`{}` is not a valid codepoint", n),
44            Self::IllegalCharacter(n) => write!(f, "0x{:x} character is not permitted in XML", n),
45        }
46    }
47}
48
49impl std::error::Error for ParseCharRefError {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            Self::InvalidNumber(e) => Some(e),
53            _ => None,
54        }
55    }
56}
57
58/// Error for XML escape / unescape.
59#[derive(Clone, Debug, PartialEq)]
60pub enum EscapeError {
61    /// The referenced entity is unknown to the parser. Contains the byte range of
62    /// the entity name and the name itself.
63    UnrecognizedEntity(Range<usize>, String),
64    /// Cannot find the `;` that terminates an entity started by `&`.
65    UnterminatedEntity(Range<usize>),
66    /// Parsing a character reference (`&#<dec-number>;` or `&#x<hex-number>;`) was
67    /// unsuccessful because not all characters were decimal or hexadecimal digits.
68    InvalidCharRef(ParseCharRefError),
69    /// More than the maximum number of nested entities were expanded during
70    /// attribute-value normalization.
71    TooManyNestedEntities,
72}
73
74impl fmt::Display for EscapeError {
75    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76        match self {
77            Self::UnrecognizedEntity(rge, res) => {
78                write!(f, "at {:?}: unrecognized entity `{}`", rge, res)
79            }
80            Self::UnterminatedEntity(e) => write!(
81                f,
82                "Error while escaping character at range {:?}: Cannot find ';' after '&'",
83                e
84            ),
85            Self::InvalidCharRef(e) => write!(f, "invalid character reference: {}", e),
86            Self::TooManyNestedEntities => {
87                f.write_str("too many nested entities in an attribute value")
88            }
89        }
90    }
91}
92
93impl std::error::Error for EscapeError {
94    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95        match self {
96            Self::InvalidCharRef(e) => Some(e),
97            _ => None,
98        }
99    }
100}
101
102/// Escapes an `&str`, replacing all XML special characters (`<`, `>`, `&`, `'`,
103/// `"`) with their corresponding XML escape sequences.
104///
105/// | Character | Replacement |
106/// |-----------|-------------|
107/// | `<`       | `&lt;`      |
108/// | `>`       | `&gt;`      |
109/// | `&`       | `&amp;`     |
110/// | `'`       | `&apos;`    |
111/// | `"`       | `&quot;`    |
112pub fn escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
113    _escape(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"'))
114}
115
116/// Escapes an `&str`, replacing the XML special characters `<`, `>` and `&`.
117///
118/// This should only be used for escaping text content, where the quote characters
119/// `"` and `'` may be left unescaped.
120pub fn partial_escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
121    _escape(raw, |ch| matches!(ch, b'<' | b'>' | b'&'))
122}
123
124/// Escapes an `&str`, replacing only the characters (`<` and `&`) that the XML
125/// standard [requires] to be escaped in text content and attribute values.
126///
127/// [requires]: https://www.w3.org/TR/xml11/#syntax
128pub fn minimal_escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
129    _escape(raw, |ch| matches!(ch, b'<' | b'&'))
130}
131
132/// Writes `value[from..to]` followed by the escape sequence for `value[to]`.
133pub(crate) fn escape_char<W>(writer: &mut W, value: &str, from: usize, to: usize) -> fmt::Result
134where
135    W: fmt::Write,
136{
137    writer.write_str(&value[from..to])?;
138    match value.as_bytes()[to] {
139        b'<' => writer.write_str("&lt;")?,
140        b'>' => writer.write_str("&gt;")?,
141        b'\'' => writer.write_str("&apos;")?,
142        b'&' => writer.write_str("&amp;")?,
143        b'"' => writer.write_str("&quot;")?,
144
145        // These escapes handle characters that must be escaped in elements of
146        // `xs:list`s, because they act as delimiters of list elements.
147        b'\t' => writer.write_str("&#9;")?,
148        b'\n' => writer.write_str("&#10;")?,
149        b'\r' => writer.write_str("&#13;")?,
150        b' ' => writer.write_str("&#32;")?,
151        _ => {
152            unreachable!("only '<', '>', '\\'', '&', '\"', '\\t', '\\r', '\\n' and ' ' are escaped")
153        }
154    }
155    Ok(())
156}
157
158/// Escapes an `&str`, replacing the subset of XML special characters selected by
159/// `escape_chars` with their corresponding XML escape sequences.
160fn _escape<'a, F: Fn(u8) -> bool>(raw: impl Into<Cow<'a, str>>, escape_chars: F) -> Cow<'a, str> {
161    let raw = raw.into();
162    let bytes = raw.as_bytes();
163    let mut escaped: Option<String> = None;
164    let mut iter = bytes.iter();
165    let mut pos = 0;
166    while let Some(i) = iter.position(|&b| escape_chars(b)) {
167        let escaped = escaped.get_or_insert_with(|| String::with_capacity(raw.len()));
168        let new_pos = pos + i;
169        // Writing into a `String` is infallible.
170        let _ = escape_char(escaped, &raw, pos, new_pos);
171        pos = new_pos + 1;
172    }
173
174    if let Some(mut escaped) = escaped {
175        if let Some(rest) = raw.get(pos..) {
176            escaped.push_str(rest);
177        }
178        Cow::Owned(escaped)
179    } else {
180        raw
181    }
182}
183
184/// Unescapes an `&str`, replacing all XML escapes (`&...;`) with their
185/// corresponding value.
186///
187/// With the `escape-html` feature enabled, all [HTML5 escapes] are recognized.
188///
189/// [HTML5 escapes]: https://dev.w3.org/html5/html-author/charref
190pub fn unescape(raw: &str) -> Result<Cow<'_, str>, EscapeError> {
191    unescape_with(raw, resolve_predefined_entity)
192}
193
194/// Unescapes an `&str`, replacing all XML escapes (`&...;`) with their
195/// corresponding value, using `resolve_entity` for named entities.
196///
197/// Predefined entities are resolved *after* `resolve_entity`, allowing custom
198/// dialects to override them. Character references (`&#hh;`) are always resolved
199/// before `resolve_entity` is called and cannot be overridden. Entities are not
200/// resolved recursively.
201///
202/// # Example
203///
204/// ```
205/// use oxixml_xml::escape::{resolve_xml_entity, unescape_with};
206///
207/// let override_named_entities = |entity: &str| match entity {
208///     "lt" => Some("FOO"),
209///     "gt" => Some("BAR"),
210///     "baz" => Some("&lt;"),
211///     _ => resolve_xml_entity(entity),
212/// };
213///
214/// assert_eq!(
215///     unescape_with("&amp;&lt;test&gt;&baz;", override_named_entities).expect("doc example"),
216///     "&FOOtestBAR&lt;"
217/// );
218/// ```
219pub fn unescape_with<'input, 'entity, F>(
220    raw: &'input str,
221    mut resolve_entity: F,
222) -> Result<Cow<'input, str>, EscapeError>
223where
224    // The lifetime of the output comes from a capture or is `'static`.
225    F: FnMut(&str) -> Option<&'entity str>,
226{
227    let bytes = raw.as_bytes();
228    let mut unescaped: Option<String> = None;
229    let mut last_end = 0;
230    // Positions of every `&` and `;`, in order.
231    let mut iter = bytes
232        .iter()
233        .enumerate()
234        .filter_map(|(i, &b)| (b == b'&' || b == b';').then_some(i));
235    while let Some(start) = iter.by_ref().find(|p| bytes[*p] == b'&') {
236        match iter.next() {
237            Some(end) if bytes[end] == b';' => {
238                let unescaped = unescaped.get_or_insert_with(|| String::with_capacity(raw.len()));
239                unescaped.push_str(&raw[last_end..start]);
240
241                let pat = &raw[start + 1..end];
242                if let Some(entity) = pat.strip_prefix('#') {
243                    let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
244                    unescaped.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
245                } else if let Some(value) = resolve_entity(pat) {
246                    unescaped.push_str(value);
247                } else {
248                    return Err(EscapeError::UnrecognizedEntity(
249                        start + 1..end,
250                        pat.to_string(),
251                    ));
252                }
253
254                last_end = end + 1;
255            }
256            _ => return Err(EscapeError::UnterminatedEntity(start..raw.len())),
257        }
258    }
259
260    if let Some(mut unescaped) = unescaped {
261        if let Some(rest) = raw.get(last_end..) {
262            unescaped.push_str(rest);
263        }
264        Ok(Cow::Owned(unescaped))
265    } else {
266        Ok(Cow::Borrowed(raw))
267    }
268}
269
270////////////////////////////////////////////////////////////////////////////////////////////////////
271
272/// Normalizes XML 1.1 end-of-line sequences into single `\n` characters.
273pub(crate) fn normalize_xml11_eols(text: &str) -> Cow<'_, str> {
274    let bytes = text.as_bytes();
275
276    // The following UTF-8 sequences translate into a single `\n` (U+000A):
277    // `0d 0a` (\r\n), `0d c2 85` (\r\x85), `0d` (\r), `c2 85` (\x85),
278    // `e2 80 a8` (U+2028).
279    if let Some(i) = memchr3(b'\r', 0xC2, 0xE2, bytes) {
280        let mut normalized = String::with_capacity(text.len());
281        normalized.push_str(&text[0..i]);
282
283        let mut pos = normalize_xml11_eol_step(&mut normalized, text, i, '\n');
284        while let Some(i) = memchr3(b'\r', 0xC2, 0xE2, &bytes[pos..]) {
285            let index = pos + i;
286            normalized.push_str(&text[pos..index]);
287            pos = normalize_xml11_eol_step(&mut normalized, text, index, '\n');
288        }
289        if let Some(rest) = text.get(pos..) {
290            normalized.push_str(rest);
291        }
292        return normalized.into();
293    }
294    Cow::Borrowed(text)
295}
296
297/// Performs one step of XML 1.1 EOL normalization at byte `index`, pushing `ch`
298/// in place of the recognized newline sequence. Returns the index of the next
299/// unprocessed byte.
300fn normalize_xml11_eol_step(normalized: &mut String, text: &str, index: usize, ch: char) -> usize {
301    let input = text.as_bytes();
302    match input[index] {
303        b'\r' => {
304            if index + 1 < input.len() {
305                let next = input[index + 1];
306                if next == b'\n' {
307                    normalized.push(ch);
308                    return index + 2; // skip \r\n
309                }
310                if next == 0xC2 {
311                    // UTF-8 encoding of U+0085 is `c2 85`.
312                    if index + 2 < input.len() && input[index + 2] == 0x85 {
313                        normalized.push(ch);
314                    } else {
315                        normalized.push(ch);
316                        normalized.push_str(&text[index + 1..index + 3]);
317                    }
318                    return index + 3; // skip \r + (c2 xx)
319                }
320            }
321            normalized.push(ch);
322            index + 1 // skip \r
323        }
324        b'\n' => {
325            normalized.push(ch);
326            index + 1 // skip \n
327        }
328        // Start of UTF-8 encoding of U+0085 (`c2 85`).
329        0xC2 => {
330            if index + 1 < input.len() && input[index + 1] == 0x85 {
331                normalized.push(ch);
332            } else {
333                normalized.push_str(&text[index..index + 2]);
334            }
335            index + 2 // skip (c2 xx)
336        }
337        // Start of UTF-8 encoding of U+2028 (`e2 80 a8`).
338        0xE2 => {
339            if index + 2 < input.len() && input[index + 1] == 0x80 && input[index + 2] == 0xA8 {
340                normalized.push(ch);
341            } else {
342                normalized.push_str(&text[index..index + 3]);
343            }
344            index + 3 // skip (e2 xx xx)
345        }
346        _ => index + 1,
347    }
348}
349
350////////////////////////////////////////////////////////////////////////////////////////////////////
351
352/// Normalizes XML 1.0 end-of-line sequences into single `\n` characters.
353pub(crate) fn normalize_xml10_eols(text: &str) -> Cow<'_, str> {
354    let bytes = text.as_bytes();
355
356    // The following UTF-8 sequences translate into a single `\n` (U+000A):
357    // `0d 0a` (\r\n) and `0d` (\r).
358    if let Some(i) = memchr(b'\r', bytes) {
359        let mut normalized = String::with_capacity(text.len());
360        normalized.push_str(&text[0..i]);
361
362        let mut pos = normalize_xml10_eol_step(&mut normalized, text, i, '\n');
363        while let Some(i) = memchr(b'\r', &bytes[pos..]) {
364            let index = pos + i;
365            normalized.push_str(&text[pos..index]);
366            pos = normalize_xml10_eol_step(&mut normalized, text, index, '\n');
367        }
368        if let Some(rest) = text.get(pos..) {
369            normalized.push_str(rest);
370        }
371        return normalized.into();
372    }
373    Cow::Borrowed(text)
374}
375
376/// Performs one step of XML 1.0 EOL normalization at byte `index`.
377fn normalize_xml10_eol_step(normalized: &mut String, text: &str, index: usize, ch: char) -> usize {
378    let input = text.as_bytes();
379    match input[index] {
380        b'\r' => {
381            normalized.push(ch);
382            if index + 1 < input.len() && input[index + 1] == b'\n' {
383                return index + 2; // skip \r\n
384            }
385            index + 1 // skip \r
386        }
387        b'\n' => {
388            normalized.push(ch);
389            index + 1 // skip \n
390        }
391        _ => index + 1,
392    }
393}
394
395////////////////////////////////////////////////////////////////////////////////////////////////////
396
397pub(crate) fn normalize_xml10_attribute_value<'input, 'entity, F>(
398    value: &'input str,
399    depth: usize,
400    resolve_entity: F,
401) -> Result<Cow<'input, str>, EscapeError>
402where
403    F: FnMut(&str) -> Option<&'entity str>,
404{
405    normalize_attribute_value(
406        value,
407        depth,
408        is_xml10_normalization_char,
409        normalize_xml10_eol_step,
410        resolve_entity,
411    )
412}
413
414/// Whether a byte begins a character that XML 1.0 attribute-value normalization
415/// rewrites — a callable form of the scan
416/// [`normalize_xml10_attribute_value`] performs, for a caller that wants to know
417/// whether normalizing would change anything before it pays for the attempt.
418pub(crate) const fn is_xml10_normalization_char(b: &u8) -> bool {
419    matches!(*b, b'\t' | b'\r' | b'\n' | b'&')
420}
421
422pub(crate) fn normalize_xml11_attribute_value<'input, 'entity, F>(
423    value: &'input str,
424    depth: usize,
425    resolve_entity: F,
426) -> Result<Cow<'input, str>, EscapeError>
427where
428    F: FnMut(&str) -> Option<&'entity str>,
429{
430    normalize_attribute_value(
431        value,
432        depth,
433        is_xml11_normalization_char,
434        normalize_xml11_eol_step,
435        resolve_entity,
436    )
437}
438
439const fn is_xml11_normalization_char(b: &u8) -> bool {
440    matches!(*b, b'\t' | b'\r' | b'\n' | 0xC2 | 0xE2 | b'&')
441}
442
443////////////////////////////////////////////////////////////////////////////////////////////////////
444
445/// Returns the attribute value normalized as per [the XML specification], using a
446/// custom entity resolver.
447///
448/// Do not use this method with HTML attributes.
449///
450/// Escape sequences such as `&gt;` are replaced with their unescaped equivalents,
451/// and the characters `\t`, `\r`, `\n` are replaced with a space. This allocates
452/// unless the raw attribute value does not require normalization.
453///
454/// # Parameters
455///
456/// - `value`: the unnormalized attribute value;
457/// - `depth`: maximum number of nested entities that can be expanded before
458///   [`EscapeError::TooManyNestedEntities`] is returned;
459/// - `is_normalization_char`: predicate reporting whether a byte begins a
460///   character requiring normalization (UTF-8 encoding assumed);
461/// - `normalize_eol_step`: a function performing EOL normalization of a character;
462/// - `resolve_entity`: a function to resolve named entities.
463///
464/// [the XML specification]: https://www.w3.org/TR/xml11/#AVNormalize
465pub fn normalize_attribute_value<'input, 'entity, C, E, F>(
466    value: &'input str,
467    depth: usize,
468    is_normalization_char: C,
469    normalize_eol_step: E,
470    mut resolve_entity: F,
471) -> Result<Cow<'input, str>, EscapeError>
472where
473    C: Fn(&u8) -> bool,
474    E: Fn(&mut String, &str, usize, char) -> usize,
475    F: FnMut(&str) -> Option<&'entity str>,
476{
477    let mut iter = value.as_bytes().iter();
478
479    if let Some(i) = iter.position(&is_normalization_char) {
480        let mut normalized = String::with_capacity(value.len());
481        let pos = normalize_attr_step(
482            &mut normalized,
483            &mut iter,
484            value,
485            0,
486            i,
487            depth,
488            false,
489            &is_normalization_char,
490            &normalize_eol_step,
491            &mut resolve_entity,
492        )?;
493
494        normalize_attr_steps(
495            &mut normalized,
496            &mut iter,
497            value,
498            pos,
499            depth,
500            false,
501            &is_normalization_char,
502            &normalize_eol_step,
503            &mut resolve_entity,
504        )?;
505        return Ok(normalized.into());
506    }
507    Ok(Cow::Borrowed(value))
508}
509
510/// Applies the normalization steps to a whole run of text.
511///
512/// `in_entity` says whether `input` is an entity's replacement text rather than the attribute's
513/// own value; it selects which of the two white-space rules of [XML 1.0 §3.3.3] applies — see
514/// [`normalize_entity_whitespace_step`].
515///
516/// [XML 1.0 §3.3.3]: https://www.w3.org/TR/xml/#AVNormalize
517#[allow(clippy::too_many_arguments)]
518fn normalize_attr_steps<'entity, C, E, F>(
519    normalized: &mut String,
520    iter: &mut Iter<u8>,
521    input: &str,
522    mut pos: usize,
523    depth: usize,
524    in_entity: bool,
525    is_normalization_char: &C,
526    normalize_eol_step: &E,
527    resolve_entity: &mut F,
528) -> Result<(), EscapeError>
529where
530    C: Fn(&u8) -> bool,
531    E: Fn(&mut String, &str, usize, char) -> usize,
532    F: FnMut(&str) -> Option<&'entity str>,
533{
534    while let Some(i) = iter.position(is_normalization_char) {
535        pos = normalize_attr_step(
536            normalized,
537            iter,
538            input,
539            pos,
540            pos + i,
541            depth,
542            in_entity,
543            is_normalization_char,
544            normalize_eol_step,
545            resolve_entity,
546        )?;
547    }
548    if let Some(rest) = input.get(pos..) {
549        normalized.push_str(rest);
550    }
551    Ok(())
552}
553
554/// Performs one step of the [attribute-value normalization algorithm], including
555/// the recursive expansion of entity references.
556///
557/// [attribute-value normalization algorithm]: https://www.w3.org/TR/xml11/#AVNormalize
558#[allow(clippy::too_many_arguments)]
559fn normalize_attr_step<'entity, C, E, F>(
560    normalized: &mut String,
561    iter: &mut Iter<u8>,
562    input: &str,
563    last_pos: usize,
564    index: usize,
565    depth: usize,
566    in_entity: bool,
567    is_normalization_char: &C,
568    normalize_eol_step: &E,
569    resolve_entity: &mut F,
570) -> Result<usize, EscapeError>
571where
572    C: Fn(&u8) -> bool,
573    E: Fn(&mut String, &str, usize, char) -> usize,
574    F: FnMut(&str) -> Option<&'entity str>,
575{
576    if depth == 0 {
577        return Err(EscapeError::TooManyNestedEntities);
578    }
579    // For any other character, append the character to the normalized value.
580    normalized.push_str(&input[last_pos..index]);
581
582    match input.as_bytes()[index] {
583        b'&' => {
584            let start = index + 1; // skip `&`
585            let end = start
586                + match iter.position(|&b| b == b';') {
587                    Some(end) => end,
588                    None => return Err(EscapeError::UnterminatedEntity(index..input.len())),
589                };
590
591            let pat = &input[start..end];
592            if let Some(entity) = pat.strip_prefix('#') {
593                // For a character reference, append the referenced character.
594                let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
595                normalized.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
596            } else if pat == "amp" {
597                // Special case: `&amp;` resolves to `&`; handling it directly
598                // avoids a spurious unterminated-entity error.
599                normalized.push('&');
600            } else if let Some(value) = resolve_entity(pat) {
601                // For an entity reference, recursively normalize the replacement text.
602                normalize_attr_steps(
603                    normalized,
604                    &mut value.as_bytes().iter(),
605                    value,
606                    0,
607                    depth.saturating_sub(1),
608                    true,
609                    is_normalization_char,
610                    normalize_eol_step,
611                    resolve_entity,
612                )?;
613            } else {
614                return Err(EscapeError::UnrecognizedEntity(start..end, pat.to_string()));
615            }
616            Ok(end + 1) // skip `;`
617        }
618        // For a white space character (`#x9`), append a space (`#x20`).
619        b'\t' => {
620            normalized.push(' ');
621            Ok(index + 1) // skip \t
622        }
623        _ => {
624            let pos = if in_entity {
625                normalize_entity_whitespace_step(normalized, input, index)
626            } else {
627                normalize_eol_step(normalized, input, index, ' ')
628            };
629            // Advance the iterator because several bytes may have been skipped.
630            for _ in 0..pos - index - 1 {
631                iter.next();
632            }
633            Ok(pos)
634        }
635    }
636}
637
638/// Applies the white-space rule of [XML 1.0 §3.3.3] to one character of an entity's
639/// *replacement text*, and reports the index of the next unprocessed byte.
640///
641/// Step 1 of the algorithm has the attribute's own text end-of-line normalized first, so a
642/// literal `#xD#xA` there is a single line break and contributes a single space — which is what
643/// the end-of-line steps do while they normalize. A replacement text is different: it was built
644/// from an entity declaration whose literal value had already been end-of-line normalized when
645/// the declaration was parsed, so any `#xD` still in it came from a character reference. Those
646/// are never line breaks, and step 3 appends one space for *each* white space character it
647/// visits, so `<!ENTITY e "&#13;&#10;">` used in an attribute value contributes two spaces
648/// rather than one.
649///
650/// [XML 1.0 §3.3.3]: https://www.w3.org/TR/xml/#AVNormalize
651fn normalize_entity_whitespace_step(normalized: &mut String, text: &str, index: usize) -> usize {
652    let input = text.as_bytes();
653    match input[index] {
654        b'\r' | b'\n' => {
655            normalized.push(' ');
656            index + 1
657        }
658        // Start of the UTF-8 encoding of U+0085 (`c2 85`), a line break in XML 1.1 only.
659        0xC2 => {
660            if index + 1 < input.len() && input[index + 1] == 0x85 {
661                normalized.push(' ');
662            } else {
663                normalized.push_str(&text[index..index + 2]);
664            }
665            index + 2
666        }
667        // Start of the UTF-8 encoding of U+2028 (`e2 80 a8`), a line break in XML 1.1 only.
668        0xE2 => {
669            if index + 2 < input.len() && input[index + 1] == 0x80 && input[index + 2] == 0xA8 {
670                normalized.push(' ');
671            } else {
672                normalized.push_str(&text[index..index + 3]);
673            }
674            index + 3
675        }
676        _ => index + 1,
677    }
678}
679
680////////////////////////////////////////////////////////////////////////////////////////////////////
681
682/// Resolves predefined XML entities, or all HTML5 entities when the `escape-html`
683/// feature is enabled.
684///
685/// Behaves like [`resolve_xml_entity`] without the feature, and like
686#[cfg_attr(feature = "escape-html", doc = "[`resolve_html5_entity`] with it.")]
687#[cfg_attr(not(feature = "escape-html"), doc = "`resolve_html5_entity` with it.")]
688#[inline]
689pub const fn resolve_predefined_entity(entity: &str) -> Option<&'static str> {
690    #[cfg(not(feature = "escape-html"))]
691    {
692        resolve_xml_entity(entity)
693    }
694
695    #[cfg(feature = "escape-html")]
696    {
697        resolve_html5_entity(entity)
698    }
699}
700
701/// Resolves the five predefined XML entities. Returns `None` for any other name.
702///
703/// ```
704/// # use oxixml_xml::escape::resolve_xml_entity;
705/// assert_eq!(resolve_xml_entity("lt"), Some("<"));
706/// assert_eq!(resolve_xml_entity("gt"), Some(">"));
707/// assert_eq!(resolve_xml_entity("amp"), Some("&"));
708/// assert_eq!(resolve_xml_entity("apos"), Some("'"));
709/// assert_eq!(resolve_xml_entity("quot"), Some("\""));
710///
711/// assert_eq!(resolve_xml_entity("foo"), None);
712/// ```
713pub const fn resolve_xml_entity(entity: &str) -> Option<&'static str> {
714    // `match` over strings is not allowed in `const fn`, so match on bytes.
715    let s = match entity.as_bytes() {
716        b"lt" => "<",
717        b"gt" => ">",
718        b"amp" => "&",
719        b"apos" => "'",
720        b"quot" => "\"",
721        _ => return None,
722    };
723    Some(s)
724}
725
726/// Parses the numeric part of a character reference (the text after `&#`) and
727/// returns the referenced character.
728pub(crate) fn parse_number(num: &str) -> Result<char, ParseCharRefError> {
729    let code = if let Some(hex) = num.strip_prefix('x') {
730        from_str_radix(hex, 16)?
731    } else {
732        from_str_radix(num, 10)?
733    };
734    if code == 0 {
735        return Err(ParseCharRefError::IllegalCharacter(code));
736    }
737    match std::char::from_u32(code) {
738        Some(c) => Ok(c),
739        None => Err(ParseCharRefError::InvalidCodepoint(code)),
740    }
741}
742
743#[inline]
744fn from_str_radix(src: &str, radix: u32) -> Result<u32, ParseCharRefError> {
745    match src.as_bytes().first().copied() {
746        // Sign characters are not allowed, but `u32::from_str_radix` accepts `+`.
747        // We handle `-` too for consistent errors.
748        Some(b'+') | Some(b'-') => Err(ParseCharRefError::UnexpectedSign),
749        _ => u32::from_str_radix(src, radix).map_err(ParseCharRefError::InvalidNumber),
750    }
751}
752
753#[cfg(test)]
754mod tests;