beck_core/
docgen.rs

1//! `beck doc` — a module's reference documentation, derived from the module.
2//!
3//! [`docs/16-packages-and-ecosystem.md`](../../../../../docs/16-packages-and-ecosystem.md) §16.2 names
4//! the model: "documentation generated from types and doc-comments for every published version,
5//! automatically". Half of that has existed since Phase 2 — [`crate::iface::Interface`] is every
6//! published name's type, effect row and placement, and it is derived rather than declared. The
7//! other half is [`beck_syntax::doc`].
8//!
9//! # What is generated, and what is written
10//!
11//! | Part of the page | Where it comes from |
12//! |---|---|
13//! | Signature — parameters, result, type arguments | Inference. Nobody writes it. |
14//! | **Effects** — what a name performs | The inferred row (§3.2), closed at the boundary |
15//! | **Placement** — which tier it runs on | The solver (§3.4), not an annotation |
16//! | Types, fields, variants | The module's own declarations |
17//! | Prose | The `##` doc comment, if there is one |
18//!
19//! Three of those five are things a language with an effect system and a placement solver knows and
20//! a hand-written reference page would get wrong within a week. That is the argument for generating
21//! this rather than writing it: **a doc comment can go stale, a signature cannot**.
22//!
23//! # What it deliberately does not do
24//!
25//! * **No prose is invented.** A name with no doc comment is rendered with its signature and
26//!   nothing else, and [`Docs::documented`] counts the difference so a coverage number is a
27//!   measurement rather than an impression.
28//! * **No cross-module linking.** A type from an imported module renders as its name. The Mere
29//!   (§16.2) is where a link between published versions would live, and it is not built.
30//! * **Markdown in a doc comment is passed through, not parsed.** The HTML renderer escapes and
31//!   preserves paragraph breaks; it is not a Markdown implementation.
32
33use std::collections::BTreeMap;
34use std::fmt::Write as _;
35use std::sync::Arc;
36
37use beck_syntax::{sym, Node};
38
39use crate::check::Program;
40use crate::iface::{Interface, Item, Kind};
41use crate::ty::{Tier, TyDecl};
42
43/// Collect every doc comment in a module's top-level items, keyed by the name it documents.
44///
45/// The key for a nested declaration is qualified — `Todo.text`, `Event.Toggled` — so one flat map
46/// serves the whole module and nothing has to be threaded through the checker.
47pub fn collect_docs(items: &[&Node]) -> BTreeMap<Arc<str>, Arc<str>> {
48    let mut out = BTreeMap::new();
49    for item in items {
50        // The doc comment is on the outermost node, which is the `decorate` when there is one
51        // (`beck_syntax::doc`), and the name is on the innermost.
52        let doc = item.meta.doc.clone();
53        let mut inner = *item;
54        while inner.is_form(sym::DECORATE) {
55            inner = &inner.args[1];
56        }
57        let Some(name) = declared_name(inner) else {
58            continue;
59        };
60        if let Some(d) = doc.or_else(|| inner.meta.doc.clone()) {
61            out.insert(name.clone(), d);
62        }
63        // Model fields and union variants carry their own, and are named under their type.
64        if inner.is_form(sym::MODEL) || inner.is_form(sym::UNION) {
65            for member in &inner.args[1..] {
66                let Some(d) = member.meta.doc.clone() else {
67                    continue;
68                };
69                let Some(mname) = member.args.first().and_then(|a| a.as_var()) else {
70                    continue;
71                };
72                out.insert(Arc::from(format!("{name}.{}", mname.as_str())), d);
73            }
74        }
75    }
76    out
77}
78
79fn declared_name(inner: &Node) -> Option<&Arc<str>> {
80    const NAMED: &[&str] = &[
81        sym::DEF,
82        sym::LET,
83        sym::VAR,
84        sym::MODEL,
85        sym::UNION,
86        sym::TYPE,
87        sym::NEWTYPE,
88        sym::TRAIT,
89    ];
90    if !NAMED.iter().any(|f| inner.is_form(f)) {
91        return None;
92    }
93    inner.args.first().and_then(|a| a.as_var()).map(|s| &s.name)
94}
95
96/// One documented name.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct Entry {
99    pub name: Arc<str>,
100    /// `def` / `signal` — what the reader is looking at.
101    pub kind: &'static str,
102    /// The signature as it is written in the language: `add(a: Int, b: Int) -> Int`.
103    pub signature: String,
104    /// The inferred effect row, as atom names. Empty means pure.
105    pub effects: Vec<String>,
106    pub tier: Tier,
107    pub doc: Option<Arc<str>>,
108}
109
110/// One documented type, with its fields or variants.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct TypeEntry {
113    pub name: Arc<str>,
114    /// `model` / `union` / `newtype` / `type`.
115    pub kind: &'static str,
116    pub declaration: String,
117    pub doc: Option<Arc<str>>,
118    /// Field or variant name, its rendered type, and its own doc comment.
119    pub members: Vec<(Arc<str>, String, Option<Arc<str>>)>,
120}
121
122/// A module's reference documentation.
123#[derive(Clone, Debug, PartialEq, Eq, Default)]
124pub struct Docs {
125    pub module: String,
126    /// The interface digest this page was generated from — the same value
127    /// [`crate::iface::Interface::digest`] publishes, so a page can be matched to a contract.
128    pub digest: String,
129    pub types: Vec<TypeEntry>,
130    pub items: Vec<Entry>,
131}
132
133impl Docs {
134    /// Derive a module's documentation from the checked, placed program.
135    pub fn of(program: &Program) -> Docs {
136        Docs::of_interface(&Interface::of(program), &program.docs)
137    }
138
139    /// The page for an interface that has already been computed.
140    ///
141    /// A module that **imports** another is checked as part of a project, and the program that
142    /// comes out of the slicer is every module merged — which is right for slicing and wrong for a
143    /// documentation page, because `beck doc` on one module would then publish the names of every
144    /// module beneath it. [`Project::interface`](crate::project::Project) is the root module's own
145    /// contract, and it is what a page is of. `docs/56` §56.5 is where that was found.
146    ///
147    /// The doc-comment map is the *program's*, because a comment is looked up by the name it
148    /// documents and the interface selects which names those are.
149    pub fn of_interface(iface: &Interface, comments: &BTreeMap<Arc<str>, Arc<str>>) -> Docs {
150        let types = iface
151            .types
152            .iter()
153            .map(|t| type_entry(t, comments))
154            .collect();
155        let items = iface.items.iter().map(|i| entry(i, comments)).collect();
156        Docs {
157            module: iface.module.clone(),
158            digest: iface.digest(),
159            types,
160            items,
161        }
162    }
163
164    /// How many published names carry a doc comment, and how many there are.
165    ///
166    /// Reported rather than enforced: a coverage gate that fails a build is how a codebase ends up
167    /// with `## the id` on a field called `id`. The number is here so it can be looked at.
168    pub fn documented(&self) -> (usize, usize) {
169        let all = self.items.len() + self.types.len();
170        let with = self.items.iter().filter(|i| i.doc.is_some()).count()
171            + self.types.iter().filter(|t| t.doc.is_some()).count();
172        (with, all)
173    }
174}
175
176fn entry(i: &Item, docs: &BTreeMap<Arc<str>, Arc<str>>) -> Entry {
177    let (kind, signature) = match &i.kind {
178        Kind::Function {
179            typarams,
180            params,
181            ret,
182        } => (
183            "def",
184            format!(
185                "{}{}({}) -> {ret}",
186                i.name,
187                // A generic definition publishes what it quantifies over (§3.6), so the page shows
188                // it: `pair[T](a: T, b: T)` is a different contract from `pair(a: T, b: T)`.
189                if typarams.is_empty() {
190                    String::new()
191                } else {
192                    format!("[{}]", typarams.join(", "))
193                },
194                params
195                    .iter()
196                    .map(|(n, t)| format!("{n}: {t}"))
197                    .collect::<Vec<_>>()
198                    .join(", ")
199            ),
200        ),
201        Kind::Signal { ty } => ("signal", format!("{}: {ty}", i.name)),
202    };
203    Entry {
204        name: i.name.clone(),
205        kind,
206        signature,
207        effects: i.effects.iter().map(|e| e.name().to_string()).collect(),
208        tier: i.tier,
209        doc: docs.get(&i.name).cloned(),
210    }
211}
212
213fn type_entry(t: &TyDecl, docs: &BTreeMap<Arc<str>, Arc<str>>) -> TypeEntry {
214    let name = t.name().clone();
215    let member_doc = |m: &str| {
216        docs.get(&Arc::from(format!("{name}.{m}")) as &Arc<str>)
217            .cloned()
218    };
219    let (kind, declaration, members) = match t {
220        TyDecl::Model { fields, .. } => (
221            "model",
222            format!("model {name}"),
223            fields
224                .iter()
225                .map(|(f, ty)| (f.clone(), format!("{ty}"), member_doc(f)))
226                .collect(),
227        ),
228        TyDecl::Union { variants, .. } => (
229            "union",
230            format!("union {name}"),
231            variants
232                .iter()
233                .map(|v| {
234                    let fields = v
235                        .fields
236                        .iter()
237                        .map(|(f, ty)| format!("{f}: {ty}"))
238                        .collect::<Vec<_>>()
239                        .join(", ");
240                    let rendered = if v.fields.is_empty() {
241                        v.name.to_string()
242                    } else {
243                        format!("{}({fields})", v.name)
244                    };
245                    (v.name.clone(), rendered, member_doc(&v.name))
246                })
247                .collect(),
248        ),
249        TyDecl::Newtype { inner, .. } => (
250            "newtype",
251            format!("type {name} = newtype[{inner}]"),
252            Vec::new(),
253        ),
254        TyDecl::Alias { ty, .. } => ("type", format!("type {name} = {ty}"), Vec::new()),
255    };
256    TypeEntry {
257        name: name.clone(),
258        kind,
259        declaration,
260        doc: docs.get(&name).cloned(),
261        members,
262    }
263}
264
265// ---------------------------------------------------------------------------- renderers
266
267impl Docs {
268    /// Markdown — the form that is checked in and diffed.
269    pub fn to_markdown(&self) -> String {
270        let mut out = String::new();
271        let _ = writeln!(out, "# Module `{}`\n", self.module);
272        let (with, all) = self.documented();
273        let _ = writeln!(
274            out,
275            "Generated by `beck doc`. Signatures, effects and placements are derived from the \
276             module and are not written by hand; prose comes from `##` doc comments.\n"
277        );
278        let _ = writeln!(
279            out,
280            "- Interface digest: `{}`\n- Documented: {with}/{all} published names\n",
281            self.digest
282        );
283
284        if !self.types.is_empty() {
285            let _ = writeln!(out, "## Types\n");
286            for t in &self.types {
287                let _ = writeln!(out, "### `{}`\n", t.name);
288                let _ = writeln!(out, "```beck\n{}\n```\n", t.declaration);
289                if let Some(d) = &t.doc {
290                    let _ = writeln!(out, "{d}\n");
291                }
292                if !t.members.is_empty() {
293                    let heading = if t.kind == "union" {
294                        "Variant"
295                    } else {
296                        "Field"
297                    };
298                    let _ = writeln!(out, "| {heading} | |\n|---|---|");
299                    for (m, ty, doc) in &t.members {
300                        // A variant renders whole (`Added(id: Id)`); a field is name and type.
301                        let shown = if t.kind == "union" {
302                            ty.clone()
303                        } else {
304                            format!("{m}: {ty}")
305                        };
306                        let _ = writeln!(
307                            out,
308                            "| `{}` | {} |",
309                            shown.replace('|', "\\|"),
310                            doc.as_deref().unwrap_or("").replace('\n', " ")
311                        );
312                    }
313                    out.push('\n');
314                }
315            }
316        }
317
318        if !self.items.is_empty() {
319            let _ = writeln!(out, "## Names\n");
320            let _ = writeln!(out, "| Name | Runs on | Effects |\n|---|---|---|");
321            for i in &self.items {
322                let _ = writeln!(
323                    out,
324                    "| [`{}`](#{}) | `{}` | {} |",
325                    i.name,
326                    anchor(&i.name),
327                    i.tier.name(),
328                    effects_md(&i.effects)
329                );
330            }
331            out.push('\n');
332            for i in &self.items {
333                let _ = writeln!(out, "### `{}`\n", i.name);
334                let _ = writeln!(out, "```beck\n{}\n```\n", i.signature);
335                let _ = writeln!(
336                    out,
337                    "*{}* — runs on `{}`, performs {}.\n",
338                    i.kind,
339                    i.tier.name(),
340                    effects_md(&i.effects)
341                );
342                if let Some(d) = &i.doc {
343                    let _ = writeln!(out, "{d}\n");
344                }
345            }
346        }
347        out
348    }
349
350    /// JSON — the form a tool consumes. Hand-written rather than derived, so the shape is a
351    /// decision in this file rather than a consequence of the Rust field names.
352    pub fn to_json(&self) -> String {
353        let mut out = String::new();
354        let _ = write!(
355            out,
356            "{{\n  \"module\": {},\n  \"digest\": {},\n  \"types\": [",
357            json_str(&self.module),
358            json_str(&self.digest)
359        );
360        for (n, t) in self.types.iter().enumerate() {
361            if n > 0 {
362                out.push(',');
363            }
364            let _ = write!(
365                out,
366                "\n    {{\"name\": {}, \"kind\": {}, \"declaration\": {}, \"doc\": {}, \"members\": [",
367                json_str(&t.name),
368                json_str(t.kind),
369                json_str(&t.declaration),
370                json_opt(&t.doc)
371            );
372            for (m, (name, ty, doc)) in t.members.iter().enumerate() {
373                if m > 0 {
374                    out.push(',');
375                }
376                let _ = write!(
377                    out,
378                    "{{\"name\": {}, \"type\": {}, \"doc\": {}}}",
379                    json_str(name),
380                    json_str(ty),
381                    json_opt(doc)
382                );
383            }
384            out.push_str("]}");
385        }
386        out.push_str("\n  ],\n  \"items\": [");
387        for (n, i) in self.items.iter().enumerate() {
388            if n > 0 {
389                out.push(',');
390            }
391            let _ = write!(
392                out,
393                "\n    {{\"name\": {}, \"kind\": {}, \"signature\": {}, \"tier\": {}, \"effects\": [{}], \"doc\": {}}}",
394                json_str(&i.name),
395                json_str(i.kind),
396                json_str(&i.signature),
397                json_str(i.tier.name()),
398                i.effects
399                    .iter()
400                    .map(|e| json_str(e))
401                    .collect::<Vec<_>>()
402                    .join(", "),
403                json_opt(&i.doc)
404            );
405        }
406        out.push_str("\n  ]\n}\n");
407        out
408    }
409}
410
411/// The site shell: one stylesheet, inline, no fonts and no scripts.
412///
413/// The whole generated site is static files that open from `file://` as readily as from a server,
414/// which is what keeps the documentation reviewable in a pull request and buildable offline.
415/// [`docs/07-dependencies.md`](../../../../../docs/07-dependencies.md) lists mdBook for the eventual
416/// book; a reference page needs less than a book does, and less is one fewer dependency.
417///
418/// `home` is the href of the site index *relative to this page*. It is a parameter rather than a
419/// constant because a module page is written one directory down from the reference pages, and a
420/// header link that 404s from half the site is worse than no header link.
421///
422/// `repo` is the repository the site is generated from, and every page carries it: a published page
423/// is derived from a program, and a reader who wants the program should not have to guess where it
424/// lives. It is a parameter rather than a constant because the compiler does not know what
425/// repository it was built in — `.github/workflows/docs.yml` passes GitHub's own
426/// `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}`, so a fork's site links to the fork. `None` renders
427/// no link, which is what a local `beck doc` run wants.
428pub fn page(title: &str, home: &str, breadcrumb: &str, repo: Option<&str>, body: &str) -> String {
429    let source = repo.map_or_else(String::new, |url| {
430        format!("<a class=\"repo\" href=\"{}\">Source</a>", escape(url))
431    });
432    format!(
433        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
434         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
435         <title>{}</title>\n<style>{CSS}</style>\n</head>\n<body>\n\
436         <header><a href=\"{}\">beck</a><span>{}</span>{source}</header>\n<main>\n{body}</main>\n\
437         <footer>Generated by <code>beck doc</code>. Signatures, effects and placements are \
438         derived from the program.</footer>\n</body>\n</html>\n",
439        escape(title),
440        escape(home),
441        breadcrumb,
442    )
443}
444
445/// Where a module page's header link points: module pages are written into `module/` beneath the
446/// site root, so the index is one level up.
447pub const MODULE_PAGE_HOME: &str = "../index.html";
448
449/// Where a reference page's header link points — they sit at the site root, beside the index.
450pub const REFERENCE_PAGE_HOME: &str = "index.html";
451
452const CSS: &str = "\
453:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fff;--muted:#5a6270;--line:#d8dde5;--code:#f5f6f8;--link:#0a5aa8}\
454@media(prefers-color-scheme:dark){:root{--fg:#e6e8ec;--bg:#14161a;--muted:#9aa3b2;--line:#2c313a;--code:#1c1f26;--link:#79b8ff}}\
455*{box-sizing:border-box}\
456body{margin:0;font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg)}\
457header,footer{padding:.75rem 1.25rem;border-bottom:1px solid var(--line);font-size:.9rem;color:var(--muted)}\
458header{display:flex;align-items:baseline;gap:.4rem}\
459footer{border-bottom:none;border-top:1px solid var(--line);margin-top:3rem}\
460header a{color:var(--link);text-decoration:none;font-weight:600}\
461header .repo{margin-left:auto}\
462main{max-width:52rem;margin:0 auto;padding:1.5rem 1.25rem 4rem}\
463h1{font-size:1.75rem;margin:1rem 0 .25rem}h2{font-size:1.3rem;margin:2.5rem 0 .5rem;padding-bottom:.3rem;border-bottom:1px solid var(--line)}\
464h3{font-size:1.05rem;margin:2rem 0 .4rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
465code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.875em}\
466pre{background:var(--code);border:1px solid var(--line);border-radius:6px;padding:.7rem .9rem;overflow-x:auto}\
467code:not(pre code){background:var(--code);border-radius:3px;padding:.1em .35em}\
468table{border-collapse:collapse;width:100%;margin:.75rem 0;display:block;overflow-x:auto}\
469th,td{border:1px solid var(--line);padding:.35rem .6rem;text-align:left;vertical-align:top}\
470th{background:var(--code);font-weight:600}\
471a{color:var(--link)}.muted{color:var(--muted)}\
472.tag{display:inline-block;background:var(--code);border:1px solid var(--line);border-radius:999px;padding:.05rem .55rem;font-size:.78rem;font-family:ui-monospace,monospace;margin-right:.3rem}\
473";
474
475/// Escape text for HTML. The one place `&`, `<` and `>` are handled, so no renderer has to
476/// remember to.
477pub fn escape(s: &str) -> String {
478    let mut out = String::with_capacity(s.len());
479    for c in s.chars() {
480        match c {
481            '&' => out.push_str("&amp;"),
482            '<' => out.push_str("&lt;"),
483            '>' => out.push_str("&gt;"),
484            '"' => out.push_str("&quot;"),
485            c => out.push(c),
486        }
487    }
488    out
489}
490
491/// A doc comment as HTML: escaped, with a blank line starting a new paragraph.
492///
493/// Not a Markdown parser, and does not pretend to be one — the module docs above say so.
494pub fn prose(doc: &str) -> String {
495    doc.split("\n\n")
496        .filter(|p| !p.trim().is_empty())
497        .map(|p| format!("<p>{}</p>", escape(p.trim())))
498        .collect::<Vec<_>>()
499        .join("\n")
500}
501
502impl Docs {
503    /// HTML — the form that is published. `repo` is [`page`]'s: the repository to link back to.
504    pub fn to_html(&self, repo: Option<&str>) -> String {
505        let mut b = String::new();
506        let (with, all) = self.documented();
507        let _ = writeln!(b, "<h1>Module <code>{}</code></h1>", escape(&self.module));
508        let _ = writeln!(
509            b,
510            "<p class=\"muted\">Interface digest <code>{}</code> · {with}/{all} published names \
511             documented</p>",
512            escape(&self.digest)
513        );
514
515        if !self.types.is_empty() {
516            b.push_str("<h2>Types</h2>\n");
517            for t in &self.types {
518                let _ = write!(
519                    b,
520                    "<h3 id=\"{}\">{}</h3>\n<pre><code>{}</code></pre>\n",
521                    anchor(&t.name),
522                    escape(&t.name),
523                    escape(&t.declaration)
524                );
525                if let Some(d) = &t.doc {
526                    b.push_str(&prose(d));
527                    b.push('\n');
528                }
529                if !t.members.is_empty() {
530                    let heading = if t.kind == "union" {
531                        "Variant"
532                    } else {
533                        "Field"
534                    };
535                    let _ = writeln!(b, "<table><tr><th>{heading}</th><th></th></tr>");
536                    for (m, ty, doc) in &t.members {
537                        let shown = if t.kind == "union" {
538                            ty.clone()
539                        } else {
540                            format!("{m}: {ty}")
541                        };
542                        let _ = write!(
543                            b,
544                            "<tr><td><code>{}</code></td><td>{}</td></tr>",
545                            escape(&shown),
546                            doc.as_deref().map(prose).unwrap_or_default()
547                        );
548                    }
549                    b.push_str("</table>\n");
550                }
551            }
552        }
553
554        if !self.items.is_empty() {
555            b.push_str(
556                "<h2>Names</h2>\n<table><tr><th>Name</th><th>Runs on</th><th>Effects</th></tr>\n",
557            );
558            for i in &self.items {
559                let _ = write!(
560                    b,
561                    "<tr><td><a href=\"#{}\"><code>{}</code></a></td><td><code>{}</code></td><td>{}</td></tr>",
562                    anchor(&i.name),
563                    escape(&i.name),
564                    i.tier.name(),
565                    effects_html(&i.effects)
566                );
567            }
568            b.push_str("</table>\n");
569            for i in &self.items {
570                let _ = write!(
571                    b,
572                    "<h3 id=\"{}\">{}</h3>\n<pre><code>{}</code></pre>\n\
573                     <p><span class=\"tag\">{}</span><span class=\"tag\">on {}</span>{}</p>\n",
574                    anchor(&i.name),
575                    escape(&i.name),
576                    escape(&i.signature),
577                    i.kind,
578                    i.tier.name(),
579                    effects_html(&i.effects)
580                );
581                if let Some(d) = &i.doc {
582                    b.push_str(&prose(d));
583                    b.push('\n');
584                }
585            }
586        }
587        page(
588            &format!("Module {} — beck", self.module),
589            MODULE_PAGE_HOME,
590            &format!(" / module <code>{}</code>", escape(&self.module)),
591            repo,
592            &b,
593        )
594    }
595}
596
597fn effects_html(effects: &[String]) -> String {
598    if effects.is_empty() {
599        "<span class=\"muted\">no effects</span>".to_string()
600    } else {
601        effects
602            .iter()
603            .map(|e| format!("<span class=\"tag\">{}</span>", escape(e)))
604            .collect::<Vec<_>>()
605            .join("")
606    }
607}
608
609fn effects_md(effects: &[String]) -> String {
610    if effects.is_empty() {
611        "no effects".to_string()
612    } else {
613        effects
614            .iter()
615            .map(|e| format!("`{e}`"))
616            .collect::<Vec<_>>()
617            .join(", ")
618    }
619}
620
621/// A GitHub-flavoured heading anchor for a name.
622pub fn anchor(name: &str) -> String {
623    name.chars()
624        .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
625        .flat_map(|c| c.to_lowercase())
626        .collect()
627}
628
629pub fn json_str(s: &str) -> String {
630    let mut out = String::with_capacity(s.len() + 2);
631    out.push('"');
632    for c in s.chars() {
633        match c {
634            '"' => out.push_str("\\\""),
635            '\\' => out.push_str("\\\\"),
636            '\n' => out.push_str("\\n"),
637            '\r' => out.push_str("\\r"),
638            '\t' => out.push_str("\\t"),
639            c if (c as u32) < 0x20 => {
640                let _ = write!(out, "\\u{:04x}", c as u32);
641            }
642            c => out.push(c),
643        }
644    }
645    out.push('"');
646    out
647}
648
649fn json_opt(s: &Option<Arc<str>>) -> String {
650    match s {
651        Some(s) => json_str(s),
652        None => "null".to_string(),
653    }
654}
655
656// -------------------------------------------------------------------------------------------
657// Guides
658// -------------------------------------------------------------------------------------------
659
660/// Where a guide's relative links should point once it is published.
661///
662/// A checked-in guide links to the repository it lives in — other documents, source files, a
663/// harness. Those links resolve when the file is read on GitHub and resolve nowhere on a static
664/// site, so publishing one means rewriting them.
665///
666/// `base` is the URL of the directory the guide itself lives in, because that is what its links are
667/// written relative to. A `..` in a link therefore has to walk *out* of the base, which is why this
668/// resolves path components rather than concatenating strings.
669pub struct Links<'a> {
670    pub base: &'a str,
671}
672
673impl Links<'_> {
674    /// A link target as it should appear on the published page.
675    fn resolve(&self, target: &str) -> String {
676        if target.starts_with("http://")
677            || target.starts_with("https://")
678            || target.starts_with("mailto:")
679            || target.starts_with('#')
680        {
681            return target.to_string();
682        }
683        let (path, anchor) = match target.split_once('#') {
684            Some((p, a)) => (p, format!("#{a}")),
685            None => (target, String::new()),
686        };
687        // The scheme and authority are not path: `..` must not eat the host.
688        let (prefix, rest) = match self.base.find("://") {
689            Some(i) => match self.base[i + 3..].find('/') {
690                Some(j) => self.base.split_at(i + 3 + j),
691                None => (self.base, ""),
692            },
693            None => ("", self.base),
694        };
695        let mut parts: Vec<&str> = Vec::new();
696        for part in rest.split('/').chain(path.split('/')) {
697            match part {
698                "" | "." => {}
699                ".." => {
700                    parts.pop();
701                }
702                other => parts.push(other),
703            }
704        }
705        format!("{prefix}/{}{anchor}", parts.join("/"))
706    }
707}
708
709/// A written guide as HTML: the subset of Markdown the guides in `docs/` actually use.
710///
711/// Headings, fenced code, block quotes, tables, bullets, and inline code, emphasis and links.
712/// Deliberately not a Markdown implementation — `docs/07` §7.2 lists mdBook for the eventual book,
713/// and a guide is not a book. What it is instead is *checked*: the guide it renders is the one
714/// `beck-cli/tests/getting_started.rs` compiles and runs, so the published page cannot describe a
715/// program that does not work.
716///
717/// Anything it does not understand is emitted as escaped text rather than dropped, which is the
718/// safe direction: a page with a stray asterisk is a page, and a page missing a paragraph is a lie.
719pub fn guide(src: &str, links: Option<Links<'_>>) -> String {
720    let mut out = String::new();
721    let mut para: Vec<&str> = Vec::new();
722    let mut quote: Vec<&str> = Vec::new();
723    let mut table: Vec<&str> = Vec::new();
724    let mut list = false;
725    let mut fence: Option<Vec<&str>> = None;
726    let links = links.as_ref();
727
728    // Every block form ends the paragraph before it, so flushing is one closure rather than a rule
729    // repeated at each branch — which is where a renderer like this usually goes wrong.
730    macro_rules! flush {
731        ($out:expr) => {{
732            if !para.is_empty() {
733                let _ = writeln!($out, "<p>{}</p>", inline(&para.join(" "), links));
734                para.clear();
735            }
736            if !quote.is_empty() {
737                // Lines join into a paragraph and a blank one starts the next, exactly as they do
738                // outside a quote. Rendering each line as its own paragraph is the mistake that
739                // makes a quoted paragraph look like a list of sentences.
740                let _ = writeln!(
741                    $out,
742                    "<blockquote>{}</blockquote>",
743                    quote
744                        .split(|l: &&str| l.trim().is_empty())
745                        .filter(|p| !p.is_empty())
746                        .map(|p| format!("<p>{}</p>", inline(&p.join(" "), links)))
747                        .collect::<Vec<_>>()
748                        .join("")
749                );
750                quote.clear();
751            }
752            if !table.is_empty() {
753                $out.push_str(&table_html(&table, links));
754                table.clear();
755            }
756            if list {
757                $out.push_str("</ul>\n");
758                list = false;
759            }
760        }};
761    }
762
763    for line in src.lines() {
764        // Inside a fence, nothing is markup: that is what a fence is for.
765        if let Some(code) = &mut fence {
766            if line.trim_start().starts_with("```") {
767                let _ = writeln!(out, "<pre><code>{}</code></pre>", escape(&code.join("\n")));
768                fence = None;
769            } else {
770                code.push(line);
771            }
772            continue;
773        }
774        let trimmed = line.trim();
775        if trimmed.starts_with("```") {
776            flush!(out);
777            fence = Some(Vec::new());
778        } else if let Some(rest) = heading(trimmed) {
779            flush!(out);
780            let (level, text) = rest;
781            let text = if level == 1 {
782                untitled_number(text)
783            } else {
784                text
785            };
786            let id = slug(text);
787            let _ = writeln!(
788                out,
789                "<h{level} id=\"{id}\">{}</h{level}>",
790                inline(text, links)
791            );
792        } else if trimmed.is_empty() {
793            flush!(out);
794        } else if let Some(rest) = trimmed.strip_prefix("> ").or(trimmed.strip_prefix(">")) {
795            if !para.is_empty() || !table.is_empty() || list {
796                flush!(out);
797            }
798            quote.push(rest);
799        } else if trimmed.starts_with('|') {
800            if !para.is_empty() || !quote.is_empty() || list {
801                flush!(out);
802            }
803            table.push(trimmed);
804        } else if let Some(item) = trimmed.strip_prefix("- ").or(trimmed.strip_prefix("* ")) {
805            if !para.is_empty() || !quote.is_empty() || !table.is_empty() {
806                flush!(out);
807            }
808            if !list {
809                out.push_str("<ul>\n");
810                list = true;
811            }
812            let _ = writeln!(out, "<li>{}</li>", inline(item, links));
813        } else if list && line.starts_with("  ") {
814            // A continuation of the item above, which is how every wrapped bullet in `docs/` is
815            // written. Appending to the last `<li>` would need a buffer per item; a paragraph
816            // inside the list reads the same and costs nothing.
817            let _ = writeln!(out, "<li class=\"cont\">{}</li>", inline(trimmed, links));
818        } else if trimmed.chars().all(|c| c == '-') && trimmed.len() >= 3 {
819            flush!(out);
820            out.push_str("<hr>\n");
821        } else {
822            if !quote.is_empty() || !table.is_empty() || list {
823                flush!(out);
824            }
825            para.push(trimmed);
826        }
827    }
828    if let Some(code) = fence {
829        let _ = writeln!(out, "<pre><code>{}</code></pre>", escape(&code.join("\n")));
830    }
831    flush!(out);
832    // The last flush closes an open list and then nothing reads the flag again.
833    let _ = list;
834    out
835}
836
837/// A guide's own title: its first heading, without the document number `docs/` files carry.
838///
839/// `# 86 — Getting started` is a file in a numbered directory; "Getting started" is a page.
840pub fn guide_title(src: &str) -> Option<&str> {
841    src.lines().find_map(|l| {
842        l.trim()
843            .strip_prefix("# ")
844            .map(|t| untitled_number(t.trim()))
845    })
846}
847
848/// Strip a leading `NN — ` or `NN. `, which is how a numbered document names itself.
849fn untitled_number(title: &str) -> &str {
850    let rest = title.trim_start_matches(|c: char| c.is_ascii_digit());
851    if rest.len() == title.len() {
852        return title;
853    }
854    for sep in [" — ", " - ", ". ", " "] {
855        if let Some(t) = rest.strip_prefix(sep) {
856            return t.trim();
857        }
858    }
859    title
860}
861
862fn heading(line: &str) -> Option<(usize, &str)> {
863    let hashes = line.chars().take_while(|c| *c == '#').count();
864    if hashes == 0 || hashes > 6 {
865        return None;
866    }
867    let rest = line[hashes..].strip_prefix(' ')?;
868    // `h1` is the page title, so a guide's own `#` becomes the page's `h1` and everything below it
869    // keeps its relative depth.
870    Some((hashes.min(6), rest.trim()))
871}
872
873/// A heading's anchor: lower-case, words joined by hyphens — the same shape GitHub produces, so a
874/// `#section` link written for the repository still lands on the published page.
875fn slug(text: &str) -> String {
876    let mut out = String::new();
877    for c in text.chars() {
878        if c.is_alphanumeric() {
879            out.extend(c.to_lowercase());
880        } else if matches!(c, ' ' | '-' | '_' | '.') && !out.ends_with('-') {
881            out.push('-');
882        }
883    }
884    out.trim_matches('-').to_string()
885}
886
887fn table_html(rows: &[&str], links: Option<&Links<'_>>) -> String {
888    let cells = |row: &str| -> Vec<String> {
889        row.trim_matches('|')
890            .split('|')
891            .map(|c| c.trim().to_string())
892            .collect()
893    };
894    let mut out = String::from("<table>\n");
895    for (i, row) in rows.iter().enumerate() {
896        // The `|---|---|` separator is layout rather than data.
897        if row.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
898            continue;
899        }
900        let tag = if i == 0 { "th" } else { "td" };
901        let _ = writeln!(
902            out,
903            "<tr>{}</tr>",
904            cells(row)
905                .iter()
906                .map(|c| format!("<{tag}>{}</{tag}>", inline(c, links)))
907                .collect::<Vec<_>>()
908                .join("")
909        );
910    }
911    out.push_str("</table>\n");
912    out
913}
914
915/// Inline markup: code spans first, because nothing inside one is markup.
916fn inline(src: &str, links: Option<&Links<'_>>) -> String {
917    let cs: Vec<char> = src.chars().collect();
918    let mut out = String::new();
919    let mut i = 0;
920    while i < cs.len() {
921        match cs[i] {
922            // A code span is delimited by a *run* of backticks, and the closing run has to be the
923            // same length. `docs/86` quotes a fenced block inline with four of them, so counting is
924            // not a nicety: taking the first backtick as the close cuts the sentence in half.
925            '`' => {
926                let run = cs[i..].iter().take_while(|c| **c == '`').count();
927                match closing_run(&cs, i + run, run) {
928                    Some(end) => {
929                        let text: String = cs[i + run..end].iter().collect();
930                        let _ = write!(out, "<code>{}</code>", escape(text.trim()));
931                        i = end + run;
932                    }
933                    None => {
934                        for _ in 0..run {
935                            out.push_str("&#96;");
936                        }
937                        i += run;
938                    }
939                }
940            }
941            '[' => match link_at(&cs, i) {
942                Some((text, target, next)) => {
943                    let href = match links {
944                        Some(l) => l.resolve(&target),
945                        None => target,
946                    };
947                    let _ = write!(
948                        out,
949                        "<a href=\"{}\">{}</a>",
950                        escape(&href),
951                        inline(&text, links)
952                    );
953                    i = next;
954                }
955                None => {
956                    out.push('[');
957                    i += 1;
958                }
959            },
960            '*' if cs.get(i + 1) == Some(&'*') => match find(&cs, i + 2, "**") {
961                Some(end) => {
962                    let text: String = cs[i + 2..end].iter().collect();
963                    let _ = write!(out, "<strong>{}</strong>", inline(&text, links));
964                    i = end + 2;
965                }
966                None => {
967                    out.push_str("**");
968                    i += 2;
969                }
970            },
971            '*' => match cs[i + 1..].iter().position(|c| *c == '*') {
972                Some(end) if end > 0 => {
973                    let text: String = cs[i + 1..i + 1 + end].iter().collect();
974                    let _ = write!(out, "<em>{}</em>", inline(&text, links));
975                    i += end + 2;
976                }
977                _ => {
978                    out.push('*');
979                    i += 1;
980                }
981            },
982            c => {
983                out.push_str(&escape(&c.to_string()));
984                i += 1;
985            }
986        }
987    }
988    out
989}
990
991/// The start of the next run of exactly `run` backticks at or after `from`.
992fn closing_run(cs: &[char], from: usize, run: usize) -> Option<usize> {
993    let mut i = from;
994    while i < cs.len() {
995        if cs[i] != '`' {
996            i += 1;
997            continue;
998        }
999        let here = cs[i..].iter().take_while(|c| **c == '`').count();
1000        if here == run {
1001            return Some(i);
1002        }
1003        i += here;
1004    }
1005    None
1006}
1007
1008fn find(cs: &[char], from: usize, needle: &str) -> Option<usize> {
1009    let n: Vec<char> = needle.chars().collect();
1010    (from..cs.len().saturating_sub(n.len() - 1)).find(|&i| cs[i..i + n.len()] == n[..])
1011}
1012
1013/// `[text](target)` starting at `i`, and where it ends.
1014fn link_at(cs: &[char], i: usize) -> Option<(String, String, usize)> {
1015    let close = cs[i..].iter().position(|c| *c == ']')? + i;
1016    if cs.get(close + 1) != Some(&'(') {
1017        return None;
1018    }
1019    let end = cs[close + 2..].iter().position(|c| *c == ')')? + close + 2;
1020    Some((
1021        cs[i + 1..close].iter().collect(),
1022        cs[close + 2..end].iter().collect(),
1023        end + 1,
1024    ))
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    const SRC: &str = "\
1032## One item on the list.
1033model Todo:
1034    ## Stable for the life of the item.
1035    id: Str
1036    text: Str
1037
1038## Adds two numbers.
1039def add(a: Int, b: Int) -> Int:
1040    return a
1041";
1042
1043    /// Through the placement solver, because a page's `runs on` column is the solver's answer.
1044    fn docs_of(src: &str) -> Docs {
1045        let (placed, diags, map) = crate::compile_or_library_str("t.beck", src);
1046        assert!(!diags.has_errors(), "{}", diags.render(&map));
1047        Docs::of(&placed.expect("a library compiles").program)
1048    }
1049
1050    #[test]
1051    fn a_signature_is_derived_and_the_prose_is_written() {
1052        let docs = docs_of(SRC);
1053        let add = docs
1054            .items
1055            .iter()
1056            .find(|i| i.name.as_ref() == "add")
1057            .unwrap();
1058        assert_eq!(add.signature, "add(a: Int, b: Int) -> Int");
1059        assert_eq!(add.doc.as_deref(), Some("Adds two numbers."));
1060        assert!(add.effects.is_empty(), "{:?}", add.effects);
1061    }
1062
1063    #[test]
1064    fn an_undocumented_name_gets_a_signature_and_no_invented_prose() {
1065        let docs = docs_of("def f(a: Int) -> Int:\n    return a\n");
1066        let f = docs.items.iter().find(|i| i.name.as_ref() == "f").unwrap();
1067        assert_eq!(f.doc, None);
1068        assert_eq!(f.signature, "f(a: Int) -> Int");
1069        assert_eq!(docs.documented(), (0, 1));
1070    }
1071
1072    #[test]
1073    fn a_models_fields_carry_their_own_documentation() {
1074        let docs = docs_of(SRC);
1075        let todo = docs
1076            .types
1077            .iter()
1078            .find(|t| t.name.as_ref() == "Todo")
1079            .unwrap();
1080        assert_eq!(todo.doc.as_deref(), Some("One item on the list."));
1081        assert_eq!(todo.members[0].0.as_ref(), "id");
1082        assert_eq!(
1083            todo.members[0].2.as_deref(),
1084            Some("Stable for the life of the item.")
1085        );
1086        assert_eq!(todo.members[1].2, None, "text is undocumented");
1087    }
1088
1089    #[test]
1090    fn documenting_a_module_does_not_change_its_contract() {
1091        // The digest is the firewall (§3.6). Adding a doc comment must not move it, or every
1092        // downstream module would rebuild because somebody wrote a sentence.
1093        let plain = docs_of("def f(a: Int) -> Int:\n    return a\n");
1094        let documented = docs_of("## Now documented.\ndef f(a: Int) -> Int:\n    return a\n");
1095        assert_eq!(plain.digest, documented.digest);
1096        assert_ne!(plain.items[0].doc, documented.items[0].doc);
1097    }
1098
1099    #[test]
1100    fn the_json_is_parseable_and_carries_the_derived_facts() {
1101        let json = docs_of(SRC).to_json();
1102        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
1103        let add = v["items"]
1104            .as_array()
1105            .unwrap()
1106            .iter()
1107            .find(|i| i["name"] == "add")
1108            .unwrap();
1109        assert_eq!(add["signature"], "add(a: Int, b: Int) -> Int");
1110        assert_eq!(add["tier"], "any");
1111        assert_eq!(add["doc"], "Adds two numbers.");
1112    }
1113
1114    #[test]
1115    fn a_doc_comment_cannot_inject_html() {
1116        let docs =
1117            docs_of("## <script>alert(1)</script> & \"quoted\".\ndef f() -> Int:\n    return 1\n");
1118        let html = docs.to_html(None);
1119        assert!(!html.contains("<script>"), "{html}");
1120        assert!(html.contains("&lt;script&gt;"), "{html}");
1121    }
1122
1123    #[test]
1124    fn a_repository_url_reaches_the_page_escaped_or_not_at_all() {
1125        let docs = docs_of(SRC);
1126        assert!(
1127            !docs.to_html(None).contains("class=\"repo\""),
1128            "no repository was given, so no link should be rendered"
1129        );
1130        // The URL comes from the environment the site is built in, so it is escaped like any other
1131        // untrusted string rather than trusted for being a URL.
1132        let html = docs.to_html(Some(
1133            "https://example.invalid/a\"><script>alert(1)</script>",
1134        ));
1135        assert!(!html.contains("<script>"), "{html}");
1136        assert!(
1137            html.contains("https://example.invalid/a&quot;&gt;"),
1138            "{html}"
1139        );
1140    }
1141
1142    #[test]
1143    fn the_markdown_names_every_published_item() {
1144        let md = docs_of(SRC).to_markdown();
1145        assert!(md.contains("### `add`"), "{md}");
1146        assert!(md.contains("add(a: Int, b: Int) -> Int"), "{md}");
1147        assert!(md.contains("### `Todo`"), "{md}");
1148        assert!(md.contains("Stable for the life of the item."), "{md}");
1149    }
1150}