Skip to main content

htmlp/
model.rs

1use std::{collections::BTreeMap, fmt};
2
3/// One-based Unicode-scalar line/column; zero-based UTF-8 byte offset.
4/// Zero line/column denotes an unknown location or an in-memory node.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
7#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8pub struct Position {
9    pub line: usize,
10    pub column: usize,
11    pub offset: usize,
12}
13
14/// Syntax, budget, tokenizer, or binding failure. No partial AST is returned.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct Diagnostic {
19    pub code: String,
20    pub message: String,
21    pub position: Position,
22}
23impl Diagnostic {
24    pub(crate) fn new(code: &str, message: impl Into<String>, position: Position) -> Self {
25        Self {
26            code: code.into(),
27            message: message.into(),
28            position,
29        }
30    }
31}
32impl fmt::Display for Diagnostic {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(
35            f,
36            "{}:{}: {}: {}",
37            self.position.line, self.position.column, self.code, self.message
38        )
39    }
40}
41impl std::error::Error for Diagnostic {}
42
43/// Limits are tokens, stored as expanded integers (`1.5k` becomes 1500).
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
48pub struct Limits {
49    pub max_tokens: Option<u64>,
50    /// Applies to each direct child element; does not change grandchildren.
51    pub per_item: Option<u64>,
52    /// Required, nonblank rationale whenever either limit is declared.
53    pub reason: Option<String>,
54    /// Short budget checksum, generated by `sign`; not an approval or identity.
55    pub sig: Option<String>,
56}
57
58/// Markdown stays literal text. These are the only content node kinds.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62#[cfg_attr(
63    feature = "json",
64    serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)
65)]
66pub enum Node {
67    Text { value: String },
68    Element(Element),
69    Variable(Variable),
70}
71
72/// A named grouping. Names and optional IDs have no provider role or trust meaning.
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
77pub struct Element {
78    pub name: String,
79    pub id: Option<String>,
80    pub limits: Limits,
81    pub children: Vec<Node>,
82    pub position: Position,
83}
84
85/// A named string slot: `{{question}}`. Variables have no limits.
86///
87/// ```compile_fail
88/// use htmlp::{Variable, Position};
89/// let slot = Variable { id: "question".into(), max_tokens: 100, position: Position::default() };
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
95pub struct Variable {
96    pub id: String,
97    pub position: Position,
98}
99
100/// Portable AST. Use [`crate::parse`] or [`Document::new`] to create a document.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
105pub struct Document {
106    pub version: String,
107    pub tokenizer: String,
108    pub limits: Limits,
109    pub children: Vec<Node>,
110    pub position: Position,
111}
112
113/// Runtime values are strings; never expressions or reparsed markup.
114pub type Bindings = BTreeMap<String, String>;
115
116/// A typed reference returned by ID lookup. Repeated variable names return
117/// the first reference in source order.
118#[derive(Debug, Clone, Copy)]
119pub enum ElementRef<'a> {
120    Element(&'a Element),
121    Variable(&'a Variable),
122}
123
124fn lookup<'a>(nodes: &'a [Node], id: &str) -> Option<ElementRef<'a>> {
125    for node in nodes {
126        match node {
127            Node::Element(s) => {
128                if s.id.as_deref() == Some(id) {
129                    return Some(ElementRef::Element(s));
130                }
131                if let Some(found) = lookup(&s.children, id) {
132                    return Some(found);
133                }
134            }
135            Node::Variable(v) if v.id == id => return Some(ElementRef::Variable(v)),
136            _ => {}
137        }
138    }
139    None
140}
141fn elements(nodes: &[Node]) -> Vec<&Element> {
142    nodes
143        .iter()
144        .filter_map(|n| {
145            if let Node::Element(s) = n {
146                Some(s)
147            } else {
148                None
149            }
150        })
151        .collect()
152}
153fn display_nodes(nodes: &[Node], f: &mut fmt::Formatter<'_>) -> fmt::Result {
154    for n in nodes {
155        match n {
156            Node::Text { value } => f.write_str(value)?,
157            Node::Element(s) => display_nodes(&s.children, f)?,
158            // Preserve unresolved slots visibly; never silently drop them.
159            Node::Variable(v) => write!(f, "{{{{{}}}}}", v.id)?,
160        }
161    }
162    Ok(())
163}
164impl Document {
165    /// Construct in memory without authoring HTMLP. [`crate::lint`] validates it.
166    pub fn new(max_tokens: u64, reason: impl Into<String>, children: Vec<Node>) -> Self {
167        Self {
168            version: "0.3".into(),
169            tokenizer: "cl100k_base".into(),
170            limits: Limits {
171                max_tokens: Some(max_tokens),
172                per_item: None,
173                reason: Some(reason.into()),
174                sig: None,
175            },
176            children,
177            position: Position::default(),
178        }
179    }
180    pub fn get_element_by_id(&self, id: &str) -> Option<ElementRef<'_>> {
181        lookup(&self.children, id)
182    }
183    pub fn elements(&self) -> Vec<&Element> {
184        elements(&self.children)
185    }
186    pub fn elements_by_name(&self, name: &str) -> Vec<&Element> {
187        elements_by_name(&self.children, name)
188    }
189    pub fn element(mut self, element: Element) -> Self {
190        self.children.push(Node::Element(element));
191        self
192    }
193    /// Bind and validate the whole document before exposing checked text.
194    pub fn render(
195        &self,
196        bindings: &Bindings,
197        counter: &impl crate::TokenCounter,
198    ) -> Result<crate::RenderedDocument, Vec<Diagnostic>> {
199        crate::render_checked(self, bindings, counter)
200    }
201}
202impl Element {
203    /// Construct a named element with no ID. Names are validated by lint/render.
204    pub fn new(name: impl Into<String>) -> Self {
205        Self {
206            name: name.into(),
207            id: None,
208            limits: Limits::default(),
209            children: Vec::new(),
210            position: Position::default(),
211        }
212    }
213    pub fn id(mut self, id: impl Into<String>) -> Self {
214        self.id = Some(id.into());
215        self
216    }
217    /// Append literal text. Braces, entities, and markup are never interpreted.
218    pub fn text(mut self, value: impl Into<String>) -> Self {
219        self.children.push(Node::text(value));
220        self
221    }
222    pub fn variable(mut self, name: impl Into<String>) -> Self {
223        self.children.push(Node::variable(name));
224        self
225    }
226    /// Append a text template. Only {{name}} is interpreted; bindings remain literal.
227    pub fn template(mut self, value: &str) -> Result<Self, Diagnostic> {
228        self.children.extend(crate::parse_template(value)?);
229        Ok(self)
230    }
231    pub fn element(mut self, element: Element) -> Self {
232        self.children.push(Node::Element(element));
233        self
234    }
235    pub fn max_tokens(mut self, tokens: u64, reason: impl Into<String>) -> Self {
236        self.limits.max_tokens = Some(tokens);
237        self.limits.reason = Some(reason.into());
238        self.limits.sig = None;
239        self
240    }
241    pub fn per_item(mut self, tokens: u64, reason: impl Into<String>) -> Self {
242        self.limits.per_item = Some(tokens);
243        self.limits.reason = Some(reason.into());
244        self.limits.sig = None;
245        self
246    }
247    pub fn get_element_by_id(&self, id: &str) -> Option<ElementRef<'_>> {
248        lookup(&self.children, id)
249    }
250    pub fn elements(&self) -> Vec<&Element> {
251        elements(&self.children)
252    }
253    pub fn elements_by_name(&self, name: &str) -> Vec<&Element> {
254        elements_by_name(&self.children, name)
255    }
256}
257fn elements_by_name<'a>(nodes: &'a [Node], name: &str) -> Vec<&'a Element> {
258    let mut found = Vec::new();
259    let mut pending: Vec<_> = nodes.iter().rev().collect();
260    while let Some(node) = pending.pop() {
261        if let Node::Element(element) = node {
262            if element.name == name {
263                found.push(element);
264            }
265            pending.extend(element.children.iter().rev());
266        }
267    }
268    found
269}
270impl Node {
271    pub fn text(value: impl Into<String>) -> Self {
272        Self::Text {
273            value: value.into(),
274        }
275    }
276    pub fn variable(id: impl Into<String>) -> Self {
277        Self::Variable(Variable {
278            id: id.into(),
279            position: Position::default(),
280        })
281    }
282}
283impl From<Element> for Node {
284    fn from(element: Element) -> Self {
285        Self::Element(element)
286    }
287}
288impl fmt::Display for Element {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        display_nodes(&self.children, f)
291    }
292}
293impl fmt::Display for Document {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        display_nodes(&self.children, f)
296    }
297}
298impl fmt::Display for ElementRef<'_> {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            Self::Element(s) => s.fmt(f),
302            Self::Variable(v) => write!(f, "{{{{{}}}}}", v.id),
303        }
304    }
305}
306
307/// Per-element measurement. `tokens` is `None` and `deferred` is true when
308/// unbound variables prevent an exact count. Render to enforce those budgets.
309#[derive(Debug, Clone)]
310#[cfg_attr(feature = "json", derive(serde::Serialize))]
311#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
312pub struct Measurement {
313    pub name: String,
314    /// Child-node indices from the document root; empty for the root itself.
315    pub path: Vec<usize>,
316    pub id: Option<String>,
317    pub tokens: Option<u64>,
318    pub limit: Option<u64>,
319    pub deferred: bool,
320    pub reason: Option<String>,
321}
322#[derive(Debug, Clone, Default)]
323#[cfg_attr(feature = "json", derive(serde::Serialize))]
324#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
325pub struct Report {
326    pub diagnostics: Vec<Diagnostic>,
327    pub measurements: Vec<Measurement>,
328}
329impl Report {
330    /// No known errors; deferred measurements still require rendering.
331    pub fn is_ok(&self) -> bool {
332        self.diagnostics.is_empty()
333    }
334}