Skip to main content

htmlp/
parser.rs

1use crate::{Diagnostic, Document, Element, Limits, Node, Position, Variable};
2use std::collections::{BTreeMap, BTreeSet};
3use xmlparser::{ElementEnd, Token, Tokenizer};
4
5/// Parse an integer token budget or a decimal-k budget: `500`, `10k`, `1.5K`.
6/// k means 1000. Fractional tokens, negatives, whitespace, and overflow fail.
7pub fn parse_limit(value: &str) -> Result<u64, String> {
8    let error = || format!("Invalid token limit {value:?}; use 500, 10k, or 1.5k");
9    let parse_digits = |s: &str| -> Result<u64, String> {
10        if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
11            return Err(error());
12        }
13        s.parse::<u64>().map_err(|_| error())
14    };
15    if let Some(number) = value.strip_suffix('k').or_else(|| value.strip_suffix('K')) {
16        let (whole, fraction) = number.split_once('.').unwrap_or((number, ""));
17        let mut n = parse_digits(whole)?.checked_mul(1000).ok_or_else(error)?;
18        if number.contains('.') {
19            if fraction.is_empty() || fraction.len() > 3 {
20                return Err(error());
21            }
22            n = n
23                .checked_add(parse_digits(fraction)? * 10u64.pow(3 - fraction.len() as u32))
24                .ok_or_else(error)?;
25        }
26        Ok(n)
27    } else {
28        parse_digits(value)
29    }
30}
31struct Locations<'a> {
32    source: &'a str,
33    starts: Vec<usize>,
34    wide: Vec<(usize, usize)>,
35}
36impl<'a> Locations<'a> {
37    fn new(source: &'a str) -> Self {
38        let mut extra = 0;
39        let wide = source
40            .char_indices()
41            .filter_map(|(i, c)| {
42                if c.is_ascii() {
43                    None
44                } else {
45                    extra += c.len_utf8() - 1;
46                    Some((i + c.len_utf8(), extra))
47                }
48            })
49            .collect();
50        Self {
51            source,
52            starts: std::iter::once(0)
53                .chain(source.match_indices('\n').map(|(i, _)| i + 1))
54                .collect(),
55            wide,
56        }
57    }
58    fn extra_bytes(&self, offset: usize) -> usize {
59        let n = self.wide.partition_point(|&(end, _)| end <= offset);
60        if n == 0 { 0 } else { self.wide[n - 1].1 }
61    }
62    fn at(&self, offset: usize) -> Position {
63        let row = self.starts.partition_point(|&i| i <= offset) - 1;
64        Position {
65            line: row + 1,
66            column: offset - self.starts[row] + 1
67                - (self.extra_bytes(offset) - self.extra_bytes(self.starts[row])),
68            offset,
69        }
70    }
71    fn error(&self, row: usize, column: usize) -> Position {
72        let start = self
73            .starts
74            .get(row.saturating_sub(1))
75            .copied()
76            .unwrap_or(self.source.len());
77        let offset = self.source[start..]
78            .char_indices()
79            .nth(column.saturating_sub(1))
80            .map_or(self.source.len(), |(i, _)| start + i);
81        Position {
82            line: row,
83            column,
84            offset,
85        }
86    }
87}
88pub(crate) fn valid_char(c: char) -> bool {
89    matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{fffe}' && c != '\u{ffff}')
90}
91fn decode(value: &str) -> Result<String, String> {
92    let mut out = String::new();
93    let mut rest = value;
94    while let Some(index) = rest.find('&') {
95        out.push_str(&rest[..index]);
96        rest = &rest[index + 1..];
97        let end = rest
98            .find(';')
99            .ok_or("Unclosed entity; escape a literal & as &amp;")?;
100        let entity = &rest[..end];
101        let ch = match entity {
102            "amp" => '&',
103            "lt" => '<',
104            "gt" => '>',
105            "quot" => '"',
106            "apos" => '\'',
107            _ => {
108                let n = if let Some(hex) = entity.strip_prefix("#x") {
109                    (!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()))
110                        .then(|| u32::from_str_radix(hex, 16).ok())
111                        .flatten()
112                } else if let Some(dec) = entity.strip_prefix('#') {
113                    (!dec.is_empty() && dec.bytes().all(|b| b.is_ascii_digit()))
114                        .then(|| dec.parse::<u32>().ok())
115                        .flatten()
116                } else {
117                    None
118                };
119                n.and_then(char::from_u32)
120                    .filter(|c| valid_char(*c) && !('\u{80}'..='\u{9f}').contains(c))
121                    .ok_or_else(|| format!("Unsupported entity &{entity};"))?
122            }
123        };
124        out.push(ch);
125        rest = &rest[end + 1..];
126    }
127    out.push_str(rest);
128    Ok(out.replace("\r\n", "\n").replace('\r', "\n"))
129}
130pub(crate) fn valid_id(id: &str) -> bool {
131    !id.is_empty()
132        && id
133            .bytes()
134            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':'))
135}
136/// Lowercase ASCII names; the htmlp name is reserved for the document root.
137pub(crate) fn valid_element_name(name: &str) -> bool {
138    name != "htmlp"
139        && name.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
140        && name
141            .bytes()
142            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
143}
144
145struct Frame {
146    name: String,
147    attrs: BTreeMap<String, String>,
148    children: Vec<Node>,
149    position: Position,
150}
151fn limits(frame: &Frame) -> Result<Limits, Diagnostic> {
152    let get = |key: &str| {
153        frame
154            .attrs
155            .get(key)
156            .map(|s| parse_limit(s).map_err(|e| Diagnostic::new("limit", e, frame.position)))
157            .transpose()
158    };
159    let limits = Limits {
160        max_tokens: get("max-tokens")?,
161        per_item: get("per-item")?,
162        reason: frame.attrs.get("reason").cloned(),
163        sig: frame.attrs.get("sig").cloned(),
164    };
165    if (limits.max_tokens.is_some() || limits.per_item.is_some())
166        && limits.reason.as_ref().is_none_or(|r| r.trim().is_empty())
167    {
168        return Err(Diagnostic::new(
169            "reason",
170            "Every declared token limit requires a nonblank reason",
171            frame.position,
172        ));
173    }
174    Ok(limits)
175}
176fn append(nodes: &mut Vec<Node>, text: String) {
177    if let Some(Node::Text { value }) = nodes.last_mut() {
178        value.push_str(&text);
179    } else if !text.is_empty() {
180        nodes.push(Node::Text { value: text });
181    }
182}
183
184/// Parse strict HTML-inspired markup using a tested XML tokenizer. XML-only
185/// constructs, namespaces, invalid names, unknown attributes, and implicit repair are rejected.
186/// Empty elements may use self-closing tags. Markdown indentation is preserved, not inferred or rewritten.
187pub fn parse(source: &str) -> Result<Document, Diagnostic> {
188    if source.len() > 4 * 1024 * 1024 {
189        return Err(Diagnostic::new(
190            "source-size",
191            "Source exceeds 4 MiB",
192            Position::default(),
193        ));
194    }
195    let locations = Locations::new(source);
196    if let Some((i, _)) = source.char_indices().find(|(_, c)| !valid_char(*c)) {
197        return Err(Diagnostic::new(
198            "character",
199            "Invalid control character",
200            locations.at(i),
201        ));
202    }
203    let mut stack: Vec<Frame> = Vec::new();
204    let mut pending: Option<Frame> = None;
205    let mut result = None;
206    let mut ids = BTreeSet::new();
207    let mut variables = BTreeSet::new();
208    for token in Tokenizer::from(source) {
209        let token = token.map_err(|e| {
210            Diagnostic::new(
211                "syntax",
212                e.to_string(),
213                locations.error(e.pos().row as usize, e.pos().col as usize),
214            )
215        })?;
216        match token {
217            Token::ElementStart {
218                prefix,
219                local,
220                span,
221            } => {
222                let p = locations.at(span.start());
223                if !prefix.is_empty()
224                    || !(local.as_str() == "htmlp" || valid_element_name(local.as_str()))
225                {
226                    return Err(Diagnostic::new(
227                        "element",
228                        "Element names must start with a lowercase ASCII letter and contain only lowercase letters, digits, or hyphens",
229                        p,
230                    ));
231                }
232                if stack.len() >= 64 {
233                    return Err(Diagnostic::new("depth", "Maximum element depth is 64", p));
234                }
235                if (stack.is_empty() && (local.as_str() != "htmlp" || result.is_some()))
236                    || (!stack.is_empty() && local.as_str() == "htmlp")
237                {
238                    return Err(Diagnostic::new(
239                        "root",
240                        "Expected exactly one htmlp root",
241                        p,
242                    ));
243                }
244                pending = Some(Frame {
245                    name: local.to_string(),
246                    attrs: BTreeMap::new(),
247                    children: Vec::new(),
248                    position: p,
249                });
250            }
251            Token::Attribute {
252                prefix,
253                local,
254                value,
255                span,
256            } => {
257                let frame = pending.as_mut().ok_or_else(|| {
258                    Diagnostic::new("syntax", "Unexpected attribute", locations.at(span.start()))
259                })?;
260                let allowed: &[&str] = match frame.name.as_str() {
261                    "htmlp" => &[
262                        "version",
263                        "tokenizer",
264                        "max-tokens",
265                        "per-item",
266                        "reason",
267                        "sig",
268                    ],
269                    _ => &["id", "max-tokens", "per-item", "reason", "sig"],
270                };
271                if !prefix.is_empty() || !allowed.contains(&local.as_str()) {
272                    return Err(Diagnostic::new(
273                        "attribute",
274                        format!("Unknown attribute {local}"),
275                        locations.at(span.start()),
276                    ));
277                }
278                let decoded = decode(value.as_str())
279                    .map_err(|e| Diagnostic::new("entity", e, locations.at(value.start())))?;
280                if frame.attrs.insert(local.to_string(), decoded).is_some() {
281                    return Err(Diagnostic::new(
282                        "attribute",
283                        "Duplicate attribute",
284                        locations.at(span.start()),
285                    ));
286                }
287            }
288            Token::ElementEnd { end, span } => {
289                if matches!(end, ElementEnd::Open | ElementEnd::Empty) {
290                    let frame = pending.take().ok_or_else(|| {
291                        Diagnostic::new(
292                            "syntax",
293                            "Unexpected opening tag",
294                            locations.at(span.start()),
295                        )
296                    })?;
297                    if let Some(id) = frame.attrs.get("id") {
298                        if !valid_id(id) || variables.contains(id) || !ids.insert(id.clone()) {
299                            return Err(Diagnostic::new(
300                                "id",
301                                "IDs must be unique and contain only ASCII letters, digits, _, -, ., or :",
302                                frame.position,
303                            ));
304                        }
305                    }
306                    stack.push(frame);
307                }
308                if !matches!(end, ElementEnd::Open) {
309                    let frame = stack.pop().ok_or_else(|| {
310                        Diagnostic::new(
311                            "syntax",
312                            "Unexpected closing tag",
313                            locations.at(span.start()),
314                        )
315                    })?;
316                    if let ElementEnd::Close(prefix, local) = end {
317                        if !prefix.is_empty() || frame.name != local.as_str() {
318                            return Err(Diagnostic::new(
319                                "syntax",
320                                format!("Expected </{}>, found </{local}>", frame.name),
321                                locations.at(span.start()),
322                            ));
323                        }
324                    }
325                    let budget = limits(&frame)?;
326                    if frame.name == "htmlp" {
327                        if frame.attrs.get("version").is_some_and(|v| v != "0.3") {
328                            return Err(Diagnostic::new(
329                                "version",
330                                "Only version 0.3 is supported",
331                                frame.position,
332                            ));
333                        }
334                        if budget.max_tokens.is_none() {
335                            return Err(Diagnostic::new(
336                                "limit",
337                                "htmlp requires max-tokens",
338                                frame.position,
339                            ));
340                        }
341                        let tokenizer = frame
342                            .attrs
343                            .get("tokenizer")
344                            .cloned()
345                            .unwrap_or_else(|| "cl100k_base".into());
346                        if !valid_id(&tokenizer) {
347                            return Err(Diagnostic::new(
348                                "tokenizer",
349                                "Invalid tokenizer identifier",
350                                frame.position,
351                            ));
352                        }
353                        result = Some(Document {
354                            version: "0.3".into(),
355                            tokenizer,
356                            limits: budget,
357                            children: frame.children,
358                            position: frame.position,
359                        });
360                    } else {
361                        let node = Node::Element(Element {
362                            name: frame.name,
363                            id: frame.attrs.get("id").cloned(),
364                            limits: budget,
365                            children: frame.children,
366                            position: frame.position,
367                        });
368                        stack
369                            .last_mut()
370                            .ok_or_else(|| Diagnostic::new("root", "Missing root", frame.position))?
371                            .children
372                            .push(node);
373                    }
374                }
375            }
376            Token::Text { text } => {
377                if let Some(frame) = stack.last_mut() {
378                    let nodes = parse_text(text.as_str(), text.start(), &locations, true)?;
379                    for node in nodes {
380                        if let Node::Variable(variable) = &node {
381                            if ids.contains(&variable.id) {
382                                return Err(Diagnostic::new(
383                                    "variable",
384                                    "Variable name cannot match an element ID",
385                                    variable.position,
386                                ));
387                            }
388                            variables.insert(variable.id.clone());
389                        }
390                        match node {
391                            Node::Text { value } => append(&mut frame.children, value),
392                            node => frame.children.push(node),
393                        }
394                    }
395                } else if !text.as_str().trim().is_empty() {
396                    return Err(Diagnostic::new(
397                        "root",
398                        "Text must be inside htmlp",
399                        locations.at(text.start()),
400                    ));
401                }
402            }
403            Token::Comment { .. } => {}
404            _ => {
405                return Err(Diagnostic::new(
406                    "syntax",
407                    "DOCTYPE, declarations, CDATA, and processing instructions are not supported",
408                    Position::default(),
409                ));
410            }
411        }
412    }
413    if !stack.is_empty() {
414        return Err(Diagnostic::new(
415            "syntax",
416            "Unclosed element",
417            stack.last().unwrap().position,
418        ));
419    }
420    result.ok_or_else(|| Diagnostic::new("root", "Missing htmlp root", Position::default()))
421}
422
423/// Parse Rust text interpolation into the same nodes as markup placeholders.
424/// Only {{name}} is special. Other text, including < and &, is literal; use
425/// Node::text for literal braces. HTML source decodes entities separately.
426pub fn parse_template(source: &str) -> Result<Vec<Node>, Diagnostic> {
427    if source.len() > 4 * 1024 * 1024 {
428        return Err(Diagnostic::new(
429            "source-size",
430            "Template exceeds 4 MiB",
431            Position::default(),
432        ));
433    }
434    let locations = Locations::new(source);
435    if let Some((offset, _)) = source.char_indices().find(|(_, c)| !valid_char(*c)) {
436        return Err(Diagnostic::new(
437            "character",
438            "Invalid control character",
439            locations.at(offset),
440        ));
441    }
442    parse_text(source, 0, &locations, false)
443}
444fn parse_text(
445    raw: &str,
446    offset: usize,
447    locations: &Locations<'_>,
448    entities: bool,
449) -> Result<Vec<Node>, Diagnostic> {
450    let literal = |start: usize, end: usize| {
451        if entities {
452            decode(&raw[start..end])
453                .map_err(|e| Diagnostic::new("entity", e, locations.at(offset + start)))
454        } else {
455            Ok(raw[start..end].to_owned())
456        }
457    };
458    let mut nodes = Vec::new();
459    let mut cursor = 0;
460    while let Some(relative) = raw[cursor..].find("{{") {
461        let start = cursor + relative;
462        let position = locations.at(offset + start);
463        let end = raw[start + 2..]
464            .find("}}")
465            .map(|i| start + 2 + i)
466            .ok_or_else(|| {
467                Diagnostic::new("variable", "Unclosed {{name}} placeholder", position)
468            })?;
469        let id = &raw[start + 2..end];
470        if !valid_id(id) {
471            return Err(Diagnostic::new(
472                "variable",
473                "Variable names must be valid IDs",
474                position,
475            ));
476        }
477        append(&mut nodes, literal(cursor, start)?);
478        nodes.push(Node::Variable(Variable {
479            id: id.into(),
480            position,
481        }));
482        cursor = end + 2;
483    }
484    append(&mut nodes, literal(cursor, raw.len())?);
485    Ok(nodes)
486}