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