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 section; 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    Section(Section),
69    Variable(Variable),
70}
71
72/// A semantic grouping. IDs have no provider-specific 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 Section {
78    pub id: Option<String>,
79    pub limits: Limits,
80    pub children: Vec<Node>,
81    pub position: Position,
82}
83
84/// A named string slot: `{{question}}`. Variables have no limits.
85///
86/// ```compile_fail
87/// use htmlp::{Variable, Position};
88/// let slot = Variable { id: "question".into(), max_tokens: 100, position: Position::default() };
89/// ```
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
94pub struct Variable {
95    pub id: String,
96    pub position: Position,
97}
98
99/// Portable AST. Use [`crate::parse`] or [`Document::new`] to create a document.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "json", serde(deny_unknown_fields))]
104pub struct Document {
105    pub version: String,
106    pub tokenizer: String,
107    pub limits: Limits,
108    pub children: Vec<Node>,
109    pub position: Position,
110}
111
112/// Runtime values are strings; never expressions or reparsed markup.
113pub type Bindings = BTreeMap<String, String>;
114
115/// A typed reference returned by ID lookup. Repeated variable names return
116/// the first reference in source order.
117#[derive(Debug, Clone, Copy)]
118pub enum ElementRef<'a> {
119    Section(&'a Section),
120    Variable(&'a Variable),
121}
122
123fn lookup<'a>(nodes: &'a [Node], id: &str) -> Option<ElementRef<'a>> {
124    for node in nodes {
125        match node {
126            Node::Section(s) => {
127                if s.id.as_deref() == Some(id) {
128                    return Some(ElementRef::Section(s));
129                }
130                if let Some(found) = lookup(&s.children, id) {
131                    return Some(found);
132                }
133            }
134            Node::Variable(v) if v.id == id => return Some(ElementRef::Variable(v)),
135            _ => {}
136        }
137    }
138    None
139}
140fn sections(nodes: &[Node]) -> Vec<&Section> {
141    nodes
142        .iter()
143        .filter_map(|n| {
144            if let Node::Section(s) = n {
145                Some(s)
146            } else {
147                None
148            }
149        })
150        .collect()
151}
152fn display_nodes(nodes: &[Node], f: &mut fmt::Formatter<'_>) -> fmt::Result {
153    for n in nodes {
154        match n {
155            Node::Text { value } => f.write_str(value)?,
156            Node::Section(s) => display_nodes(&s.children, f)?,
157            // Preserve unresolved slots visibly; never silently drop them.
158            Node::Variable(v) => write!(f, "{{{{{}}}}}", v.id)?,
159        }
160    }
161    Ok(())
162}
163impl Document {
164    /// Construct in memory without authoring HTMLP. [`crate::lint`] validates it.
165    pub fn new(max_tokens: u64, reason: impl Into<String>, children: Vec<Node>) -> Self {
166        Self {
167            version: "0.2".into(),
168            tokenizer: "cl100k_base".into(),
169            limits: Limits {
170                max_tokens: Some(max_tokens),
171                per_item: None,
172                reason: Some(reason.into()),
173                sig: None,
174            },
175            children,
176            position: Position::default(),
177        }
178    }
179    pub fn get_element_by_id(&self, id: &str) -> Option<ElementRef<'_>> {
180        lookup(&self.children, id)
181    }
182    pub fn sections(&self) -> Vec<&Section> {
183        sections(&self.children)
184    }
185}
186impl Section {
187    pub fn new(id: impl Into<String>, children: Vec<Node>) -> Self {
188        Self {
189            id: Some(id.into()),
190            limits: Limits::default(),
191            children,
192            position: Position::default(),
193        }
194    }
195    pub fn get_element_by_id(&self, id: &str) -> Option<ElementRef<'_>> {
196        lookup(&self.children, id)
197    }
198    pub fn sections(&self) -> Vec<&Section> {
199        sections(&self.children)
200    }
201}
202impl Node {
203    pub fn text(value: impl Into<String>) -> Self {
204        Self::Text {
205            value: value.into(),
206        }
207    }
208}
209impl fmt::Display for Section {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        display_nodes(&self.children, f)
212    }
213}
214impl fmt::Display for Document {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        display_nodes(&self.children, f)
217    }
218}
219impl fmt::Display for ElementRef<'_> {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            Self::Section(s) => s.fmt(f),
223            Self::Variable(v) => write!(f, "{{{{{}}}}}", v.id),
224        }
225    }
226}
227
228/// Per-element measurement. `tokens` is `None` and `deferred` is true when
229/// unbound variables prevent an exact count. Render to enforce those budgets.
230#[derive(Debug, Clone)]
231#[cfg_attr(feature = "json", derive(serde::Serialize))]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233pub struct Measurement {
234    pub id: Option<String>,
235    pub tokens: Option<u64>,
236    pub limit: Option<u64>,
237    pub deferred: bool,
238    pub reason: Option<String>,
239}
240#[derive(Debug, Clone, Default)]
241#[cfg_attr(feature = "json", derive(serde::Serialize))]
242#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
243pub struct Report {
244    pub diagnostics: Vec<Diagnostic>,
245    pub measurements: Vec<Measurement>,
246}
247impl Report {
248    /// No known errors; deferred measurements still require rendering.
249    pub fn is_ok(&self) -> bool {
250        self.diagnostics.is_empty()
251    }
252}