1use 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
43pub fn collect_docs(items: &[&Node]) -> BTreeMap<Arc<str>, Arc<str>> {
48 let mut out = BTreeMap::new();
49 for item in items {
50 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 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#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct Entry {
99 pub name: Arc<str>,
100 pub kind: &'static str,
102 pub signature: String,
104 pub effects: Vec<String>,
106 pub tier: Tier,
107 pub doc: Option<Arc<str>>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct TypeEntry {
113 pub name: Arc<str>,
114 pub kind: &'static str,
116 pub declaration: String,
117 pub doc: Option<Arc<str>>,
118 pub members: Vec<(Arc<str>, String, Option<Arc<str>>)>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Default)]
124pub struct Docs {
125 pub module: String,
126 pub digest: String,
129 pub types: Vec<TypeEntry>,
130 pub items: Vec<Entry>,
131}
132
133impl Docs {
134 pub fn of(program: &Program) -> Docs {
136 Docs::of_interface(&Interface::of(program), &program.docs)
137 }
138
139 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 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 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
265impl Docs {
268 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 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 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
411pub 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
445pub const MODULE_PAGE_HOME: &str = "../index.html";
448
449pub 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
475pub 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("&"),
482 '<' => out.push_str("<"),
483 '>' => out.push_str(">"),
484 '"' => out.push_str("""),
485 c => out.push(c),
486 }
487 }
488 out
489}
490
491pub 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 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
621pub 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
656pub struct Links<'a> {
670 pub base: &'a str,
671}
672
673impl Links<'_> {
674 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 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
709pub 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 macro_rules! flush {
731 ($out:expr) => {{
732 if !para.is_empty() {
733 let _ = writeln!($out, "<p>{}</p>", inline(¶.join(" "), links));
734 para.clear();
735 }
736 if !quote.is_empty() {
737 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 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 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 let _ = list;
834 out
835}
836
837pub 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
848fn 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 Some((hashes.min(6), rest.trim()))
871}
872
873fn 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 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
915fn 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 '`' => {
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("`");
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
991fn 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
1013fn 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 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 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("<script>"), "{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 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">"),
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}