1use crate::{
2 Bindings, Diagnostic, Document, ElementOrigin, Limits, Measurement, Node, Position, Report,
3};
4use std::collections::BTreeMap;
5
6pub 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 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
229pub 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
258pub 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}