Skip to main content

htmlp/
check.rs

1use crate::{
2    Bindings, Diagnostic, Document, ElementOrigin, Limits, Measurement, Node, Position, Report,
3};
4use std::collections::BTreeMap;
5
6/// Inject a tokenizer to keep the parser independent of large vocabulary tables.
7/// `name` must match the document's tokenizer identifier. Implementations must
8/// count the supplied string as ordinary text, without interpreting special IDs.
9pub trait TokenCounter {
10    fn name(&self) -> &str;
11    fn count(&self, text: &str) -> u64;
12}
13
14enum Piece {
15    Text(String),
16    Unbound,
17}
18fn extend(into: &mut Vec<Piece>, from: Vec<Piece>) {
19    for piece in from {
20        match (into.last_mut(), piece) {
21            (Some(Piece::Text(previous)), Piece::Text(text)) => previous.push_str(&text),
22            (_, piece) => into.push(piece),
23        }
24    }
25}
26fn size(pieces: &[Piece], counter: &impl TokenCounter) -> Option<u64> {
27    // Adjacent literal runs are always merged. Without variables there is
28    // either one complete string or an empty subtree.
29    if pieces.iter().any(|p| matches!(p, Piece::Unbound)) {
30        return None;
31    }
32    Some(
33        pieces
34            .iter()
35            .map(|p| match p {
36                Piece::Text(s) => counter.count(s),
37                Piece::Unbound => 0,
38            })
39            .sum(),
40    )
41}
42
43fn require_reason(limits: &Limits, position: Position, errors: &mut Vec<Diagnostic>) {
44    if limits.sig != crate::budget_signature(limits) {
45        errors.push(Diagnostic::new(
46            "signature",
47            "Missing or mismatched budget signature. If you intend to do this, please rerun `htmlp sign` to regenerate the signature.",
48            position,
49        ));
50    }
51    if (limits.max_tokens.is_some() || limits.per_item.is_some())
52        && limits.reason.as_ref().is_none_or(|r| r.trim().is_empty())
53    {
54        errors.push(Diagnostic::new(
55            "reason",
56            "Every declared token limit requires a nonblank reason",
57            position,
58        ));
59    }
60}
61fn validate(doc: &Document, counter: &impl TokenCounter) -> Vec<Diagnostic> {
62    let mut errors = Vec::new();
63    require_reason(&doc.limits, doc.position, &mut errors);
64    if doc.version != "0.3" || doc.limits.max_tokens.is_none() {
65        errors.push(Diagnostic::new(
66            "document",
67            "Expected version 0.3 and a root max-tokens limit",
68            doc.position,
69        ));
70    }
71    if doc.tokenizer != counter.name() {
72        errors.push(Diagnostic::new(
73            "tokenizer",
74            format!(
75                "Document uses {}, counter uses {}",
76                doc.tokenizer,
77                counter.name()
78            ),
79            doc.position,
80        ));
81    }
82    let mut ids = BTreeMap::new();
83    fn walk(
84        nodes: &[Node],
85        ids: &mut BTreeMap<String, bool>,
86        depth: usize,
87        errors: &mut Vec<Diagnostic>,
88    ) {
89        for node in nodes {
90            if matches!(node, Node::Element(_)) && depth > 64 {
91                errors.push(Diagnostic::new(
92                    "depth",
93                    "Maximum element depth is 64",
94                    Position::default(),
95                ));
96                return;
97            }
98            let (id, position) = match node {
99                Node::Text { .. } => continue,
100                Node::Element(s) => {
101                    if !crate::parser::valid_element_name(&s.name) {
102                        errors.push(Diagnostic::new(
103                            "element",
104                            format!("Invalid element name: {}", s.name),
105                            s.position,
106                        ));
107                    }
108                    require_reason(&s.limits, s.position, errors);
109                    walk(&s.children, ids, depth + 1, errors);
110                    (s.id.as_deref(), s.position)
111                }
112                Node::Variable(v) => (Some(v.id.as_str()), v.position),
113            };
114            if let Some(id) = id {
115                let variable = matches!(node, Node::Variable(_));
116                let previous = ids.insert(id.to_string(), variable);
117                if !crate::parser::valid_id(id)
118                    || previous.is_some_and(|was_variable| !(variable && was_variable))
119                {
120                    errors.push(Diagnostic::new(
121                        "id",
122                        format!("Invalid or duplicate ID: {id}"),
123                        position,
124                    ));
125                }
126            }
127        }
128    }
129    walk(&doc.children, &mut ids, 2, &mut errors);
130    errors
131}
132struct Analyzer<'a, C> {
133    counter: &'a C,
134    bindings: Option<&'a Bindings>,
135    report: Report,
136}
137impl<C: TokenCounter> Analyzer<'_, C> {
138    fn measure(
139        &mut self,
140        pieces: &[Piece],
141        cap: Option<u64>,
142        origin: &ElementOrigin,
143        reason: Option<&str>,
144    ) {
145        let tokens = size(pieces, self.counter);
146        if let (Some(tokens), Some(limit)) = (tokens, cap) {
147            if tokens > limit {
148                self.report.diagnostics.push(Diagnostic::new(
149                    "budget",
150                    format!(
151                        "{}: {tokens} tokens exceeds {limit} — {}",
152                        origin.id.as_deref().unwrap_or(&origin.name),
153                        reason.unwrap_or("inherited item limit")
154                    ),
155                    origin.position,
156                ));
157            }
158        }
159        self.report.measurements.push(Measurement {
160            name: origin.name.clone(),
161            path: origin.path.clone(),
162            id: origin.id.clone(),
163            tokens,
164            limit: cap,
165            deferred: tokens.is_none(),
166            reason: reason.map(str::to_string),
167        });
168    }
169
170    fn content(
171        &mut self,
172        nodes: &[Node],
173        limits: &Limits,
174        origin: &ElementOrigin,
175        inherited: Option<(u64, &str)>,
176    ) -> Vec<Piece> {
177        let mut pieces = Vec::new();
178        for (index, node) in nodes.iter().enumerate() {
179            match node {
180                Node::Text { value } => extend(&mut pieces, vec![Piece::Text(value.clone())]),
181                Node::Element(s) => {
182                    let inherited = limits
183                        .per_item
184                        .map(|cap| (cap, limits.reason.as_deref().unwrap_or_default()));
185                    let child = self.content(
186                        &s.children,
187                        &s.limits,
188                        &ElementOrigin {
189                            name: s.name.clone(),
190                            id: s.id.clone(),
191                            position: s.position,
192                            path: origin.path.iter().copied().chain([index]).collect(),
193                        },
194                        inherited,
195                    );
196                    extend(&mut pieces, child);
197                }
198                Node::Variable(v) => {
199                    let piece = if let Some(bindings) = self.bindings {
200                        match bindings.get(&v.id) {
201                            Some(value) => Piece::Text(value.clone()),
202                            None => {
203                                self.report.diagnostics.push(Diagnostic::new(
204                                    "binding",
205                                    format!("Missing string binding: {}", v.id),
206                                    v.position,
207                                ));
208                                Piece::Text(String::new())
209                            }
210                        }
211                    } else {
212                        Piece::Unbound
213                    };
214                    extend(&mut pieces, vec![piece]);
215                }
216            }
217        }
218        let (cap, reason) = match inherited {
219            Some((cap, reason)) if limits.max_tokens.is_none_or(|own| cap < own) => {
220                (Some(cap), Some(reason))
221            }
222            _ => (limits.max_tokens, limits.reason.as_deref()),
223        };
224        self.measure(&pieces, cap, origin, reason);
225        pieces
226    }
227}
228
229/// Check fully static subtrees exactly. Subtrees containing variables are
230/// reported as deferred, with no guessed count. Render to check their budgets.
231pub fn lint(doc: &Document, counter: &impl TokenCounter) -> Report {
232    let diagnostics = validate(doc, counter);
233    if !diagnostics.is_empty() {
234        return Report {
235            diagnostics,
236            measurements: Vec::new(),
237        };
238    }
239    let mut analyzer = Analyzer {
240        counter,
241        bindings: None,
242        report: Report::default(),
243    };
244    analyzer.content(
245        &doc.children,
246        &doc.limits,
247        &ElementOrigin {
248            name: "htmlp".into(),
249            id: None,
250            position: doc.position,
251            path: Vec::new(),
252        },
253        None,
254    );
255    analyzer.report
256}
257
258/// Validate static content, substitute inert strings, then check every final
259/// file and element budget. Variables have no separate limit. No output is
260/// returned on any error.
261pub fn render(
262    doc: &Document,
263    bindings: &Bindings,
264    counter: &impl TokenCounter,
265) -> Result<String, Vec<Diagnostic>> {
266    render_with_report(doc, bindings, counter).map(|(text, _)| text)
267}
268pub(crate) fn render_with_report(
269    doc: &Document,
270    bindings: &Bindings,
271    counter: &impl TokenCounter,
272) -> Result<(String, Report), Vec<Diagnostic>> {
273    let checked = lint(doc, counter);
274    if !checked.is_ok() {
275        return Err(checked.diagnostics);
276    }
277    let mut analyzer = Analyzer {
278        counter,
279        bindings: Some(bindings),
280        report: Report::default(),
281    };
282    let pieces = analyzer.content(
283        &doc.children,
284        &doc.limits,
285        &ElementOrigin {
286            name: "htmlp".into(),
287            id: None,
288            position: doc.position,
289            path: Vec::new(),
290        },
291        None,
292    );
293    if !analyzer.report.is_ok() {
294        return Err(analyzer.report.diagnostics);
295    }
296    let text = pieces
297        .into_iter()
298        .filter_map(|p| {
299            if let Piece::Text(s) = p {
300                Some(s)
301            } else {
302                None
303            }
304        })
305        .collect();
306    Ok((text, analyzer.report))
307}