Skip to main content

yog_formats/
yui.rs

1//! The `.yui` XML subset — what `yog build` and Yog-IDLE read screens,
2//! book pages and recipe layouts from: elements with attributes in source
3//! order and their lines, for messages.
4
5use yog_data::Diagnostic;
6
7/// One XML element: name, attributes in source order, child elements.
8#[derive(Debug, Clone)]
9pub struct Element {
10    pub name: String,
11    pub attrs: Vec<(String, String)>,
12    pub children: Vec<Element>,
13    /// 1-based line of the start tag, for error messages.
14    pub line: usize,
15}
16
17impl Element {
18    pub fn attr(&self, key: &str) -> Option<&str> {
19        self.attrs
20            .iter()
21            .find(|(k, _)| k == key)
22            .map(|(_, v)| v.as_str())
23    }
24}
25
26struct Parser<'a> {
27    src: &'a str,
28    pos: usize,
29    /// The last [`Parser::line`] answer: (position, line there) — the
30    /// parser only moves forward, so lines are counted from there on.
31    line_at: std::cell::Cell<(usize, usize)>,
32}
33
34impl<'a> Parser<'a> {
35    fn new(src: &'a str) -> Self {
36        Parser {
37            src,
38            pos: 0,
39            line_at: std::cell::Cell::new((0, 1)),
40        }
41    }
42
43    fn line(&self) -> usize {
44        let (at, line) = self.line_at.get();
45        let (from, line) = if self.pos >= at { (at, line) } else { (0, 1) };
46        let line = line
47            + self.src.as_bytes()[from..self.pos]
48                .iter()
49                .filter(|&&b| b == b'\n')
50                .count();
51        self.line_at.set((self.pos, line));
52        line
53    }
54
55    fn err<T>(&self, msg: impl std::fmt::Display) -> Result<T, Diagnostic> {
56        Err(Diagnostic::from(msg.to_string()).at_line(self.line()))
57    }
58
59    fn rest(&self) -> &'a str {
60        &self.src[self.pos..]
61    }
62
63    fn skip_ws(&mut self) {
64        let trimmed = self.rest().trim_start();
65        self.pos = self.src.len() - trimmed.len();
66    }
67
68    /// Skips whitespace, comments, `<?...?>` and `<!DOCTYPE ...>`.
69    fn skip_misc(&mut self) -> Result<(), Diagnostic> {
70        loop {
71            self.skip_ws();
72            let rest = self.rest();
73            let end = if rest.starts_with("<!--") {
74                rest.find("-->").map(|i| i + 3)
75            } else if rest.starts_with("<?") {
76                rest.find("?>").map(|i| i + 2)
77            } else if rest.starts_with("<!") {
78                rest.find('>').map(|i| i + 1)
79            } else {
80                return Ok(());
81            };
82            match end {
83                Some(n) => self.pos += n,
84                None => return self.err("unterminated comment or declaration"),
85            }
86        }
87    }
88
89    fn name(&mut self) -> Result<String, Diagnostic> {
90        let rest = self.rest();
91        let len = rest
92            .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | ':' | '.')))
93            .unwrap_or(rest.len());
94        if len == 0 {
95            return self.err("expected a name");
96        }
97        self.pos += len;
98        Ok(rest[..len].to_string())
99    }
100
101    /// Steps over `token` if the rest starts with it.
102    fn eat(&mut self, token: &str) -> bool {
103        let found = self.rest().starts_with(token);
104        if found {
105            self.pos += token.len();
106        }
107        found
108    }
109
110    fn element(&mut self) -> Result<Element, Diagnostic> {
111        let line = self.line();
112        if !self.eat("<") {
113            return self.err("expected an element");
114        }
115        let name = self.name()?;
116        let mut attrs: Vec<(String, String)> = Vec::new();
117        loop {
118            self.skip_ws();
119            if self.eat("/>") {
120                return Ok(Element {
121                    name,
122                    attrs,
123                    children: Vec::new(),
124                    line,
125                });
126            }
127            if self.eat(">") {
128                break;
129            }
130            if self.rest().is_empty() {
131                return self.err(format!("unterminated <{name}>"));
132            }
133            let (key, value) = self.attribute()?;
134            if attrs.iter().any(|(k, _)| *k == key) {
135                return self.err(format!("<{name}>: duplicate attribute `{key}`"));
136            }
137            attrs.push((key, value));
138        }
139        let children = self.children(&name)?;
140        Ok(Element {
141            name,
142            attrs,
143            children,
144            line,
145        })
146    }
147
148    /// `key="value"` (or single-quoted), unescaped.
149    fn attribute(&mut self) -> Result<(String, String), Diagnostic> {
150        let key = self.name()?;
151        self.skip_ws();
152        if !self.eat("=") {
153            return self.err(format!("expected `=` after attribute `{key}`"));
154        }
155        self.skip_ws();
156        let quote = match self.rest().chars().next() {
157            Some(q @ ('"' | '\'')) => q,
158            _ => return self.err(format!("attribute `{key}`: expected a quoted value")),
159        };
160        self.pos += quote.len_utf8();
161        let Some(end) = self.rest().find(quote) else {
162            return self.err(format!("attribute `{key}`: unterminated value"));
163        };
164        let value =
165            unescape(&self.rest()[..end]).map_err(|e| Diagnostic::from(e).at_line(self.line()))?;
166        self.pos += end + quote.len_utf8();
167        Ok((key, value))
168    }
169
170    /// The elements inside `<name>`, through its closing tag.
171    fn children(&mut self, name: &str) -> Result<Vec<Element>, Diagnostic> {
172        let mut children = Vec::new();
173        loop {
174            self.skip_misc()?;
175            if self.eat("</") {
176                let close = self.name()?;
177                if close != name {
178                    return self.err(format!("</{close}> closes <{name}>"));
179                }
180                self.skip_ws();
181                if !self.eat(">") {
182                    return self.err("expected `>`");
183                }
184                return Ok(children);
185            }
186            let rest = self.rest();
187            if rest.is_empty() {
188                return self.err(format!("<{name}> is never closed"));
189            }
190            if !rest.starts_with('<') {
191                return self.err(format!(
192                    "<{name}>: text content isn't allowed — use a `text` attribute"
193                ));
194            }
195            children.push(self.element()?);
196        }
197    }
198}
199
200fn unescape(raw: &str) -> Result<String, String> {
201    let mut out = String::with_capacity(raw.len());
202    let mut rest = raw;
203    while let Some(i) = rest.find('&') {
204        out.push_str(&rest[..i]);
205        let Some(semi) = rest[i..].find(';') else {
206            return Err("`&` without `;` — write `&amp;`".into());
207        };
208        let entity = &rest[i + 1..i + semi];
209        match entity {
210            "amp" => out.push('&'),
211            "lt" => out.push('<'),
212            "gt" => out.push('>'),
213            "quot" => out.push('"'),
214            "apos" => out.push('\''),
215            _ => {
216                let code = if let Some(hex) = entity.strip_prefix("#x") {
217                    u32::from_str_radix(hex, 16).ok()
218                } else if let Some(dec) = entity.strip_prefix('#') {
219                    dec.parse().ok()
220                } else {
221                    None
222                };
223                match code.and_then(char::from_u32) {
224                    Some(c) => out.push(c),
225                    None => return Err(format!("unknown entity `&{entity};`")),
226                }
227            }
228        }
229        rest = &rest[i + semi + 1..];
230    }
231    out.push_str(rest);
232    Ok(out)
233}
234
235/// Parses an XML document (the subset `.yui` uses) into its root element.
236pub fn parse(src: &str) -> Result<Element, Diagnostic> {
237    let src = src.strip_prefix('\u{feff}').unwrap_or(src);
238    let mut p = Parser::new(src);
239    p.skip_misc()?;
240    let root = p.element()?;
241    p.skip_misc()?;
242    if !p.rest().is_empty() {
243        return p.err("content after the root element");
244    }
245    Ok(root)
246}