Skip to main content

linkmarks_core/parser/
mod.rs

1//! Shared parsing helpers used by bridges.
2//!
3//! v1 has minimal helpers — most parsing is format-specific and lives
4//! in the bridge crate (e.g., Chromium JSON parsing in
5//! `linkmarks-bridge-chromium`). This module is a placeholder for the
6//! HTML/CSV/MIME helpers that later phases will need.
7
8/// Decode the five named XML/HTML entities and numeric character
9/// references (`&#NN;`, `&#xHH;`) commonly seen in Netscape bookmark
10/// files. Other entities are passed through unchanged.
11///
12/// This is a v1 minimum implementation: full entity decoding is a
13/// shared helper used by the Netscape bridge.
14#[must_use]
15pub fn decode_html_entities(input: &str) -> String {
16    let mut out = String::with_capacity(input.len());
17    let mut chars = input.chars().peekable();
18    while let Some(c) = chars.next() {
19        if c != '&' {
20            out.push(c);
21            continue;
22        }
23        // Try to read up to ';' (bounded scan).
24        let mut buf = String::new();
25        let mut ended = false;
26        while let Some(&next) = chars.peek() {
27            if next == ';' {
28                chars.next();
29                ended = true;
30                break;
31            }
32            if buf.len() > 8 {
33                break;
34            }
35            buf.push(next);
36            chars.next();
37        }
38        if !ended {
39            out.push('&');
40            out.push_str(&buf);
41            continue;
42        }
43        match buf.as_str() {
44            "amp" => out.push('&'),
45            "lt" => out.push('<'),
46            "gt" => out.push('>'),
47            "quot" => out.push('"'),
48            "apos" => out.push('\''),
49            "nbsp" => out.push('\u{00A0}'),
50            other if other.starts_with('#') => {
51                let body = &other[1..];
52                let code =
53                    if let Some(hex) = body.strip_prefix('x').or_else(|| body.strip_prefix('X')) {
54                        u32::from_str_radix(hex, 16).ok()
55                    } else {
56                        body.parse::<u32>().ok()
57                    };
58                if let Some(code) = code {
59                    if let Some(ch) = char::from_u32(code) {
60                        out.push(ch);
61                    }
62                }
63            }
64            _ => {
65                out.push('&');
66                out.push_str(&buf);
67                out.push(';');
68            }
69        }
70    }
71    out
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn decodes_named_entities() {
80        assert_eq!(decode_html_entities("a &amp; b"), "a & b");
81        assert_eq!(decode_html_entities("&lt;tag&gt;"), "<tag>");
82        assert_eq!(decode_html_entities("&quot;x&quot;"), "\"x\"");
83        assert_eq!(decode_html_entities("&apos;x&apos;"), "'x'");
84    }
85
86    #[test]
87    fn decodes_numeric_entities() {
88        assert_eq!(decode_html_entities("&#65;"), "A");
89        assert_eq!(decode_html_entities("&#x41;"), "A");
90    }
91
92    #[test]
93    fn passes_through_unknown() {
94        assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
95    }
96
97    #[test]
98    fn handles_orphan_ampersand() {
99        assert_eq!(decode_html_entities("a & b"), "a & b");
100    }
101}