Skip to main content

intent_render/
html.rs

1//! Render a parsed intent specification as a self-contained HTML page.
2
3use intent_parser::ast;
4
5use crate::format_type;
6
7/// Render an AST [`File`] to a complete HTML document string.
8pub fn render(file: &ast::File) -> String {
9    let mut body = String::new();
10    body.push_str(&format!("<h1>{}</h1>\n", esc(&file.module.name)));
11
12    if let Some(doc) = &file.doc {
13        body.push_str("<p class=\"doc\">");
14        body.push_str(&esc(&doc.lines.join(" ")));
15        body.push_str("</p>\n");
16    }
17
18    if !file.imports.is_empty() {
19        body.push_str("<section class=\"imports\">\n<h2>Imports</h2>\n<ul>\n");
20        for use_decl in &file.imports {
21            if let Some(item) = &use_decl.item {
22                body.push_str(&format!(
23                    "<li><code>{}.{}</code></li>\n",
24                    esc(&use_decl.module_name),
25                    esc(item)
26                ));
27            } else {
28                body.push_str(&format!(
29                    "<li><code>{}</code></li>\n",
30                    esc(&use_decl.module_name)
31                ));
32            }
33        }
34        body.push_str("</ul>\n</section>\n");
35    }
36
37    for item in &file.items {
38        match item {
39            ast::TopLevelItem::Entity(e) => render_entity(&mut body, e),
40            ast::TopLevelItem::Action(a) => render_action(&mut body, a),
41            ast::TopLevelItem::Invariant(i) => render_invariant(&mut body, i),
42            ast::TopLevelItem::EdgeCases(ec) => render_edge_cases(&mut body, ec),
43            ast::TopLevelItem::Test(_) => {} // Tests are not rendered
44        }
45    }
46
47    format!(
48        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
49         <title>{title}</title>\n<style>\n{css}\n</style>\n</head>\n\
50         <body>\n{body}</body>\n</html>\n",
51        title = esc(&file.module.name),
52        css = CSS,
53        body = body,
54    )
55}
56
57fn render_entity(out: &mut String, entity: &ast::EntityDecl) {
58    out.push_str(&format!(
59        "<section class=\"entity\">\n<h2>Entity: {}</h2>\n",
60        esc(&entity.name)
61    ));
62    render_doc(out, &entity.doc);
63    if !entity.fields.is_empty() {
64        out.push_str("<table>\n<thead><tr><th>Field</th><th>Type</th></tr></thead>\n<tbody>\n");
65        for field in &entity.fields {
66            out.push_str(&format!(
67                "<tr><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
68                esc(&field.name),
69                esc(&format_type(&field.ty))
70            ));
71        }
72        out.push_str("</tbody>\n</table>\n");
73    }
74    out.push_str("</section>\n");
75}
76
77fn render_action(out: &mut String, action: &ast::ActionDecl) {
78    out.push_str(&format!(
79        "<section class=\"action\">\n<h2>Action: {}</h2>\n",
80        esc(&action.name)
81    ));
82    render_doc(out, &action.doc);
83    if !action.params.is_empty() {
84        out.push_str("<h3>Parameters</h3>\n<ul>\n");
85        for p in &action.params {
86            out.push_str(&format!(
87                "<li><code>{}</code>: <code>{}</code></li>\n",
88                esc(&p.name),
89                esc(&format_type(&p.ty))
90            ));
91        }
92        out.push_str("</ul>\n");
93    }
94    if let Some(req) = &action.requires {
95        out.push_str("<h3>Requires</h3>\n<ul class=\"constraints\">\n");
96        for cond in &req.conditions {
97            out.push_str(&format!(
98                "<li><code>{}</code></li>\n",
99                esc(&format_expr(cond))
100            ));
101        }
102        out.push_str("</ul>\n");
103    }
104    if let Some(ens) = &action.ensures {
105        out.push_str("<h3>Ensures</h3>\n<ul class=\"constraints\">\n");
106        for item in &ens.items {
107            match item {
108                ast::EnsuresItem::Expr(e) => {
109                    out.push_str(&format!("<li><code>{}</code></li>\n", esc(&format_expr(e))));
110                }
111                ast::EnsuresItem::When(w) => {
112                    out.push_str(&format!(
113                        "<li>when <code>{}</code> &rArr; <code>{}</code></li>\n",
114                        esc(&format_expr(&w.condition)),
115                        esc(&format_expr(&w.consequence))
116                    ));
117                }
118            }
119        }
120        out.push_str("</ul>\n");
121    }
122    if let Some(props) = &action.properties {
123        out.push_str("<h3>Properties</h3>\n<table>\n<thead><tr><th>Key</th><th>Value</th></tr></thead>\n<tbody>\n");
124        for entry in &props.entries {
125            out.push_str(&format!(
126                "<tr><td><code>{}</code></td><td><code>{}</code></td></tr>\n",
127                esc(&entry.key),
128                esc(&format_prop_value(&entry.value))
129            ));
130        }
131        out.push_str("</tbody>\n</table>\n");
132    }
133    out.push_str("</section>\n");
134}
135
136fn render_invariant(out: &mut String, inv: &ast::InvariantDecl) {
137    out.push_str(&format!(
138        "<section class=\"invariant\">\n<h2>Invariant: {}</h2>\n",
139        esc(&inv.name)
140    ));
141    render_doc(out, &inv.doc);
142    out.push_str(&format!(
143        "<pre><code>{}</code></pre>\n",
144        esc(&format_expr(&inv.body))
145    ));
146    out.push_str("</section>\n");
147}
148
149fn render_edge_cases(out: &mut String, ec: &ast::EdgeCasesDecl) {
150    out.push_str("<section class=\"edge-cases\">\n<h2>Edge Cases</h2>\n<ul>\n");
151    for rule in &ec.rules {
152        out.push_str(&format!(
153            "<li>when <code>{}</code> &rArr; <code>{}({})</code></li>\n",
154            esc(&format_expr(&rule.condition)),
155            esc(&rule.action.name),
156            esc(&format_call_args(&rule.action.args))
157        ));
158    }
159    out.push_str("</ul>\n</section>\n");
160}
161
162fn render_doc(out: &mut String, doc: &Option<ast::DocBlock>) {
163    if let Some(d) = doc {
164        out.push_str("<p class=\"doc\">");
165        out.push_str(&esc(&d.lines.join(" ")));
166        out.push_str("</p>\n");
167    }
168}
169
170// ── Expression formatting ───────────────────────────────────
171
172fn format_expr(expr: &ast::Expr) -> String {
173    match &expr.kind {
174        ast::ExprKind::Implies(l, r) => format!("{} => {}", format_expr(l), format_expr(r)),
175        ast::ExprKind::Or(l, r) => format!("{} || {}", format_expr(l), format_expr(r)),
176        ast::ExprKind::And(l, r) => format!("{} && {}", format_expr(l), format_expr(r)),
177        ast::ExprKind::Not(e) => format!("!{}", format_expr(e)),
178        ast::ExprKind::Compare { left, op, right } => {
179            let op_str = match op {
180                ast::CmpOp::Eq => "==",
181                ast::CmpOp::Ne => "!=",
182                ast::CmpOp::Lt => "<",
183                ast::CmpOp::Gt => ">",
184                ast::CmpOp::Le => "<=",
185                ast::CmpOp::Ge => ">=",
186            };
187            format!("{} {} {}", format_expr(left), op_str, format_expr(right))
188        }
189        ast::ExprKind::Arithmetic { left, op, right } => {
190            let op_str = match op {
191                ast::ArithOp::Add => "+",
192                ast::ArithOp::Sub => "-",
193            };
194            format!("{} {} {}", format_expr(left), op_str, format_expr(right))
195        }
196        ast::ExprKind::Old(e) => format!("old({})", format_expr(e)),
197        ast::ExprKind::Quantifier {
198            kind,
199            binding,
200            ty,
201            body,
202        } => {
203            let kw = match kind {
204                ast::QuantifierKind::Forall => "forall",
205                ast::QuantifierKind::Exists => "exists",
206            };
207            format!("{} {}: {} => {}", kw, binding, ty, format_expr(body))
208        }
209        ast::ExprKind::Call { name, args } => {
210            format!("{}({})", name, format_call_args(args))
211        }
212        ast::ExprKind::FieldAccess { root, fields } => {
213            format!("{}.{}", format_expr(root), fields.join("."))
214        }
215        ast::ExprKind::List(items) => {
216            let inner: Vec<_> = items.iter().map(format_expr).collect();
217            format!("[{}]", inner.join(", "))
218        }
219        ast::ExprKind::Ident(name) => name.clone(),
220        ast::ExprKind::Literal(lit) => format_literal(lit),
221    }
222}
223
224fn format_literal(lit: &ast::Literal) -> String {
225    match lit {
226        ast::Literal::Null => "null".to_string(),
227        ast::Literal::Bool(b) => b.to_string(),
228        ast::Literal::Int(n) => n.to_string(),
229        ast::Literal::Decimal(s) => s.clone(),
230        ast::Literal::String(s) => format!("\"{}\"", s),
231    }
232}
233
234fn format_call_args(args: &[ast::CallArg]) -> String {
235    args.iter()
236        .map(|a| match a {
237            ast::CallArg::Named { key, value, .. } => {
238                format!("{}: {}", key, format_expr(value))
239            }
240            ast::CallArg::Positional(e) => format_expr(e),
241        })
242        .collect::<Vec<_>>()
243        .join(", ")
244}
245
246fn format_prop_value(val: &ast::PropValue) -> String {
247    match val {
248        ast::PropValue::Literal(lit) => format_literal(lit),
249        ast::PropValue::Ident(name) => name.clone(),
250        ast::PropValue::List(items) => {
251            let inner: Vec<String> = items.iter().map(format_prop_value).collect();
252            format!("[{}]", inner.join(", "))
253        }
254        ast::PropValue::Object(fields) => {
255            let inner: Vec<String> = fields
256                .iter()
257                .map(|(k, v)| format!("{}: {}", k, format_prop_value(v)))
258                .collect();
259            format!("{{{}}}", inner.join(", "))
260        }
261    }
262}
263
264// ── HTML escaping ───────────────────────────────────────────
265
266fn esc(s: &str) -> String {
267    s.replace('&', "&amp;")
268        .replace('<', "&lt;")
269        .replace('>', "&gt;")
270        .replace('"', "&quot;")
271}
272
273const CSS: &str = "\
274body { font-family: system-ui, -apple-system, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; line-height: 1.6; }
275h1 { border-bottom: 2px solid #333; padding-bottom: 0.3rem; }
276h2 { margin-top: 2rem; color: #2c5282; }
277h3 { color: #555; }
278.doc { color: #555; font-style: italic; }
279table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
280th, td { border: 1px solid #ddd; padding: 0.4rem 0.8rem; text-align: left; }
281th { background: #f7f7f7; }
282code { background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
283pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; }
284pre code { background: none; padding: 0; }
285ul.constraints { list-style: none; padding-left: 1rem; }
286ul.constraints li::before { content: '\\2713 '; color: #38a169; }
287section { margin-bottom: 1.5rem; }
288.entity { border-left: 3px solid #3182ce; padding-left: 1rem; }
289.action { border-left: 3px solid #d69e2e; padding-left: 1rem; }
290.invariant { border-left: 3px solid #38a169; padding-left: 1rem; }
291.edge-cases { border-left: 3px solid #e53e3e; padding-left: 1rem; }";