Skip to main content

htmlp/
check.rs

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