beck_syntax/
doc.rs

1//! Doc comments: `##` in the Python surface, `;;` in the S-expression one.
2//!
3//! [`docs/16-packages-and-ecosystem.md`](../../../../../docs/16-packages-and-ecosystem.md) §16.2 asks
4//! for "documentation generated from types and doc-comments for every published version,
5//! automatically". The types were already there — `beck_core::iface::Interface` has carried each
6//! name's signature, effect row and placement since Phase 2. This module supplies the other half.
7//!
8//! # Why a side pass rather than a token
9//!
10//! An ordinary comment is skipped by the lexer, and layout treats a comment-only line as having no
11//! indentation at all — that is what lets a comment sit at column zero inside an indented block
12//! without closing it ([`crate::lexer`]). Lexing `##` as a real token would put that rule at risk
13//! for every file, to serve a feature that only reads declarations.
14//!
15//! So doc comments are collected from the source text and attached to nodes afterwards, by
16//! position: a run of `##` lines belongs to the declaration on the first line beneath it. The
17//! token stream, the layout algorithm and the parser are untouched.
18//!
19//! # What attaches where
20//!
21//! A run attaches to the **outermost** node beginning that line, which is what makes
22//!
23//! ```text
24//! ## The page the browser subscribes to.
25//! @on(client)
26//! def page(…) -> Html:
27//! ```
28//!
29//! attach to the `decorate` form rather than to nothing: the decorator is part of the declaration,
30//! and the doc comment is written above the whole thing.
31//!
32//! A doc comment is [`crate::Meta`], not a form, so it is not part of a node's identity: a
33//! doc-only edit does not change [`crate::Node::structurally_eq`], does not invalidate a memo, and
34//! does not move `beck_core::iface::Interface::digest` — documenting a function is not an API
35//! change.
36
37use std::collections::BTreeMap;
38use std::sync::Arc;
39
40use crate::node::Node;
41
42/// The marker for the Python surface — `##`, one more `#` than a comment, as `///` is one more `/`.
43pub const PY_MARKER: &str = "##";
44
45/// The marker for the S-expression surface. `;;` is the Lisp convention for a comment about the
46/// form beneath it, and `;` stays an ordinary comment.
47pub const SEXPR_MARKER: &str = ";;";
48
49/// The marker a file's extension implies.
50pub fn marker_for(name: &str) -> &'static str {
51    if name.ends_with(".sx") {
52        SEXPR_MARKER
53    } else {
54        PY_MARKER
55    }
56}
57
58/// Doc-comment runs in one source file, indexed by the line they document.
59#[derive(Clone, Debug, Default)]
60pub struct DocComments {
61    /// Line index (0-based) of the first line *below* a run → the run's text.
62    runs: BTreeMap<usize, Arc<str>>,
63    /// Byte offset of the first character of each line, and of the first non-whitespace character.
64    lines: Vec<(usize, usize)>,
65}
66
67impl DocComments {
68    pub fn is_empty(&self) -> bool {
69        self.runs.is_empty()
70    }
71}
72
73/// Collect every doc-comment run in a file.
74///
75/// A line counts only when the marker is the first thing on it. That is what keeps a string
76/// containing `##` from being read as documentation — and Beck string literals cannot span lines,
77/// so scanning line by line needs no lexer state.
78pub fn collect(src: &str, marker: &str) -> DocComments {
79    let mut lines: Vec<(usize, usize)> = Vec::new();
80    let mut text: Vec<&str> = Vec::new();
81    let mut at = 0usize;
82    for line in src.split_inclusive('\n') {
83        let trimmed = line.trim_start();
84        lines.push((at, at + (line.len() - trimmed.len())));
85        text.push(trimmed.trim_end_matches(['\n', '\r']));
86        at += line.len();
87    }
88
89    // Walk the runs backwards so a run is attributed to the line beneath the *last* of its
90    // comments, and a blank line between the comment and the declaration breaks the run.
91    let mut runs: BTreeMap<usize, Arc<str>> = BTreeMap::new();
92    let mut i = 0usize;
93    while i < text.len() {
94        if !is_doc(text[i], marker) {
95            i += 1;
96            continue;
97        }
98        let start = i;
99        while i < text.len() && is_doc(text[i], marker) {
100            i += 1;
101        }
102        // `i` is now the first line that is not part of the run — the line being documented. A run
103        // with nothing beneath it (end of file) documents nothing and is dropped.
104        if i < text.len() && !text[i].is_empty() {
105            let body: Vec<&str> = text[start..i].iter().map(|l| strip(l, marker)).collect();
106            runs.insert(i, Arc::from(trim_blank_edges(&body).join("\n")));
107        }
108    }
109
110    DocComments { runs, lines }
111}
112
113fn is_doc(line: &str, marker: &str) -> bool {
114    line.starts_with(marker)
115}
116
117/// Strip the marker and, if present, exactly one space — so `## text` and `##text` both yield
118/// `text`, and an indented continuation keeps its relative indentation.
119fn strip<'a>(line: &'a str, marker: &str) -> &'a str {
120    let rest = &line[marker.len()..];
121    rest.strip_prefix(' ').unwrap_or(rest).trim_end()
122}
123
124fn trim_blank_edges<'a>(lines: &[&'a str]) -> Vec<&'a str> {
125    let start = lines.iter().position(|l| !l.is_empty()).unwrap_or(0);
126    let end = lines
127        .iter()
128        .rposition(|l| !l.is_empty())
129        .map(|e| e + 1)
130        .unwrap_or(start);
131    lines[start..end].to_vec()
132}
133
134/// Attach every run to the node it documents.
135///
136/// Outermost first, and each run is claimed once: `@on(client)` above a `def` is one declaration,
137/// and its doc comment belongs to the whole of it.
138pub fn attach(node: &mut Node, docs: &DocComments) {
139    if docs.is_empty() {
140        return;
141    }
142    let mut claimed: Vec<usize> = Vec::new();
143    walk(node, docs, &mut claimed);
144}
145
146fn walk(node: &mut Node, docs: &DocComments, claimed: &mut Vec<usize>) {
147    let start = node.span().start as usize;
148    if let Some(line) = line_starting_at(docs, start) {
149        if let Some(text) = docs.runs.get(&line) {
150            if !claimed.contains(&line) {
151                claimed.push(line);
152                node.meta.doc = Some(text.clone());
153            }
154        }
155    }
156    for a in &mut node.args {
157        walk(a, docs, claimed);
158    }
159}
160
161/// The line this offset begins, if the offset *is* that line's first non-whitespace character.
162///
163/// The restriction is what makes attachment unambiguous: a node in the middle of a line is not the
164/// thing a comment above the line was written about.
165fn line_starting_at(docs: &DocComments, offset: usize) -> Option<usize> {
166    let idx = docs
167        .lines
168        .binary_search_by(|(start, _)| start.cmp(&offset))
169        .unwrap_or_else(|i| i.saturating_sub(1));
170    let (_, first) = *docs.lines.get(idx)?;
171    (first == offset).then_some(idx)
172}
173
174/// Render a doc comment back into source, one `## ` line each, at the given indentation.
175pub fn render(doc: &str, marker: &str, indent: &str) -> String {
176    let mut out = String::new();
177    for line in doc.split('\n') {
178        out.push_str(indent);
179        out.push_str(marker);
180        if !line.is_empty() {
181            out.push(' ');
182            out.push_str(line);
183        }
184        out.push('\n');
185    }
186    out
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use beck_diag::{Diagnostics, SourceMap};
193
194    fn parse(src: &str) -> Node {
195        let mut map = SourceMap::new();
196        let file = map.add("t.beck", src);
197        let mut d = Diagnostics::new();
198        let n = crate::parse_file(file, "t.beck", src, &mut d);
199        assert!(!d.has_errors(), "{}", d.render(&map));
200        n
201    }
202
203    fn doc_of(n: &Node, name: &str) -> Option<String> {
204        for item in n.args.iter().skip(1) {
205            let mut inner = item;
206            while inner.is_form(crate::sym::DECORATE) {
207                inner = &inner.args[1];
208            }
209            let matches = inner
210                .args
211                .first()
212                .and_then(|a| a.as_var())
213                .map(|s| s.as_str() == name)
214                .unwrap_or(false);
215            if matches {
216                return item.meta.doc.as_ref().map(|d| d.to_string());
217            }
218        }
219        None
220    }
221
222    #[test]
223    fn a_run_of_doc_lines_attaches_to_the_declaration_beneath_it() {
224        let n = parse("## Adds two numbers.\n## Both of them.\ndef add(a: Int, b: Int) -> Int:\n    return a\n");
225        assert_eq!(
226            doc_of(&n, "add").as_deref(),
227            Some("Adds two numbers.\nBoth of them.")
228        );
229    }
230
231    #[test]
232    fn a_doc_comment_above_a_decorator_documents_the_whole_declaration() {
233        let n = parse("## The page.\n@on(client)\ndef page() -> Int:\n    return 1\n");
234        assert_eq!(doc_of(&n, "page").as_deref(), Some("The page."));
235    }
236
237    #[test]
238    fn a_blank_line_ends_a_run_so_a_file_header_documents_nothing() {
239        let n = parse("## A file header, about the module.\n\ndef f() -> Int:\n    return 1\n");
240        assert_eq!(doc_of(&n, "f"), None);
241    }
242
243    #[test]
244    fn an_ordinary_comment_is_still_an_ordinary_comment() {
245        let n = parse("# not documentation\ndef f() -> Int:\n    return 1\n");
246        assert_eq!(doc_of(&n, "f"), None);
247    }
248
249    #[test]
250    fn a_hash_inside_a_string_is_not_a_doc_comment() {
251        let docs = collect("x = \"## not a doc\"\n", PY_MARKER);
252        assert!(docs.is_empty());
253    }
254
255    #[test]
256    fn a_doc_comment_does_not_change_what_a_program_means() {
257        // Structural equality ignores `Meta`, so documenting a definition cannot invalidate a
258        // memo or move an interface digest.
259        let plain = parse("def f() -> Int:\n    return 1\n");
260        let documented = parse("## Documented.\ndef f() -> Int:\n    return 1\n");
261        assert_eq!(plain, documented);
262        assert!(doc_of(&documented, "f").is_some());
263    }
264
265    /// Every doc comment in the tree, keyed by the path of node indices that reaches it — so the
266    /// comparison is about *where* a comment landed as well as what it says.
267    fn all_docs(n: &Node) -> Vec<(Vec<usize>, String)> {
268        fn go(n: &Node, path: &mut Vec<usize>, out: &mut Vec<(Vec<usize>, String)>) {
269            if let Some(d) = &n.meta.doc {
270                out.push((path.clone(), d.to_string()));
271            }
272            for (i, a) in n.args.iter().enumerate() {
273                path.push(i);
274                go(a, path, out);
275                path.pop();
276            }
277        }
278        let mut out = Vec::new();
279        go(n, &mut Vec::new(), &mut out);
280        out
281    }
282
283    fn reparse(name: &str, src: &str) -> Node {
284        let mut map = SourceMap::new();
285        let file = map.add(name, src);
286        let mut d = Diagnostics::new();
287        let n = crate::parse_file(file, name, src, &mut d);
288        assert!(!d.has_errors(), "{}\n--- source ---\n{src}", d.render(&map));
289        n
290    }
291
292    const DOCUMENTED: &str = "\
293## The identifier of a todo.
294type Id = newtype[Str]
295
296## One item on the list.
297model Todo:
298    ## Stable for the life of the item.
299    id: Id
300    ## What the user typed.
301    text: Str
302
303## What may happen to the list.
304union Event:
305    Added(id: Id)
306    ## Toggling is idempotent in the fold.
307    Toggled(id: Id)
308
309## Adds two numbers, and is documented about it.
310@on(any)
311def add(a: Int, b: Int) -> Int:
312    return a
313";
314
315    #[test]
316    fn doc_comments_survive_printing_and_reparsing_in_both_surfaces() {
317        let original = reparse("t.beck", DOCUMENTED);
318        let docs = all_docs(&original);
319        assert_eq!(docs.len(), 7, "{docs:#?}");
320
321        let py = crate::print::to_python(&original);
322        assert_eq!(all_docs(&reparse("t.beck", &py)), docs, "python:\n{py}");
323
324        let sx = crate::print::to_sexpr_pretty(&original);
325        assert_eq!(all_docs(&reparse("t.sx", &sx)), docs, "sexpr:\n{sx}");
326    }
327
328    #[test]
329    fn formatting_a_documented_module_is_idempotent() {
330        let once = crate::print::to_python(&reparse("t.beck", DOCUMENTED));
331        let twice = crate::print::to_python(&reparse("t.beck", &once));
332        assert_eq!(once, twice, "once:\n{once}\ntwice:\n{twice}");
333    }
334
335    #[test]
336    fn model_fields_are_documented_too() {
337        let n = parse("model Todo:\n    ## What it says.\n    text: Str\n");
338        let model = &n.args[1];
339        let field = &model.args[2];
340        assert_eq!(field.meta.doc.as_deref(), Some("What it says."));
341    }
342}