1use crate::{Diagnostic, Document, Element, Limits, Node, Position, Variable};
2use std::collections::{BTreeMap, BTreeSet};
3use xmlparser::{ElementEnd, Token, Tokenizer};
4
5pub 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 &")?;
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}
136pub(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
184pub 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
423pub 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}