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//!
37//! # Ordinary comments are collected by the same pass, for the same reason
38//!
39//! `beck fmt` prints from the tree, so a comment the tree does not carry is one the formatter
40//! deletes — and a formatter an editor runs on save must not delete what somebody wrote. That was
41//! `DEFECTS.md::fmt-comments`, and it is why `textDocument/formatting` was deliberately not
42//! offered.
43//!
44//! One pass rather than two, because **what separates the two kinds is one decision**: a line
45//! beginning `##` is documentation and a line beginning `#` is a comment. Collected apart, that
46//! rule would be written twice and the copies would disagree about `###`.
47//!
48//! Three positions, and each attaches differently:
49//!
50//! * **Above a node**, as [`crate::Comments::before`] — a run of full-line comments, claimed by
51//!   the outermost node beginning the first line beneath it.
52//! * **At the end of a node's own line**, as [`crate::Comments::trailing`]. Finding it means
53//!   skipping string literals, because `"a # b"` is not a comment.
54//! * **Below a node with nothing after it**, as [`crate::Comments::after`] — the end of a body or
55//!   of the file. These attach *backwards*, to the last node that began a line above them, because
56//!   there is nothing beneath to attach forwards to. Without this case the comment at the end of a
57//!   function would move to whatever came next and out of the block it was written in.
58
59use std::collections::BTreeMap;
60use std::sync::Arc;
61
62use crate::node::Node;
63
64/// The marker for the Python surface — `##`, one more `#` than a comment, as `///` is one more `/`.
65pub const PY_MARKER: &str = "##";
66
67/// The marker for the S-expression surface. `;;` is the Lisp convention for a comment about the
68/// form beneath it, and `;` stays an ordinary comment.
69pub const SEXPR_MARKER: &str = ";;";
70
71/// The marker a file's extension implies.
72pub fn marker_for(name: &str) -> &'static str {
73    if name.ends_with(".sx") {
74        SEXPR_MARKER
75    } else {
76        PY_MARKER
77    }
78}
79
80/// Every comment in one source file, indexed by the line it belongs to.
81#[derive(Clone, Debug, Default)]
82pub struct DocComments {
83    /// Line index (0-based) of the first line *below* a doc run → the run's text.
84    runs: BTreeMap<usize, Arc<str>>,
85    /// Line index of the line below an ordinary run → its lines, in source order.
86    before: BTreeMap<usize, Vec<Arc<str>>>,
87    /// Line index → the comment that ends that line.
88    trailing: BTreeMap<usize, Arc<str>>,
89    /// Line index of the last line that began a node above the run → the run's lines. Where a run
90    /// has nothing beneath it in its block.
91    after: BTreeMap<usize, Vec<Arc<str>>>,
92    /// Byte offset of the first character of each line, and of the first non-whitespace character.
93    lines: Vec<(usize, usize)>,
94}
95
96impl DocComments {
97    pub fn is_empty(&self) -> bool {
98        self.runs.is_empty()
99            && self.before.is_empty()
100            && self.trailing.is_empty()
101            && self.after.is_empty()
102    }
103}
104
105/// Collect every doc-comment run in a file.
106///
107/// A line counts only when the marker is the first thing on it. That is what keeps a string
108/// containing `##` from being read as documentation — and Beck string literals cannot span lines,
109/// so scanning line by line needs no lexer state.
110pub fn collect(src: &str, marker: &str) -> DocComments {
111    let mut lines: Vec<(usize, usize)> = Vec::new();
112    let mut text: Vec<&str> = Vec::new();
113    let mut at = 0usize;
114    for line in src.split_inclusive('\n') {
115        let trimmed = line.trim_start();
116        lines.push((at, at + (line.len() - trimmed.len())));
117        text.push(trimmed.trim_end_matches(['\n', '\r']));
118        at += line.len();
119    }
120
121    // Walk the runs backwards so a run is attributed to the line beneath the *last* of its
122    // comments, and a blank line between the comment and the declaration breaks the run.
123    let mut runs: BTreeMap<usize, Arc<str>> = BTreeMap::new();
124    let mut i = 0usize;
125    while i < text.len() {
126        if !is_doc(text[i], marker) {
127            i += 1;
128            continue;
129        }
130        let start = i;
131        while i < text.len() && is_doc(text[i], marker) {
132            i += 1;
133        }
134        // `i` is now the first line that is not part of the run — the line being documented, once
135        // any *ordinary* comment lines between the two are stepped over. That case is rare and it
136        // used to lose the documentation outright: the run attached to a line no node begins, and
137        // nothing claimed it. A blank line still breaks the association, which is the difference
138        // between "this documents that" and "this is a note that happens to be above it".
139        let mut target = i;
140        while target < text.len() && text[target].starts_with('#') && !is_doc(text[target], marker)
141        {
142            target += 1;
143        }
144        if target < text.len() && !text[target].is_empty() {
145            let body: Vec<&str> = text[start..i].iter().map(|l| strip(l, marker)).collect();
146            runs.insert(target, Arc::from(trim_blank_edges(&body).join("\n")));
147        }
148    }
149
150    let indents: Vec<usize> = lines.iter().map(|(start, first)| first - start).collect();
151    let (before, trailing, after) = ordinary(&text, &indents, marker);
152    DocComments {
153        runs,
154        before,
155        trailing,
156        after,
157        lines,
158    }
159}
160
161/// Every ordinary comment, in the three positions of this module's third section.
162///
163/// `text` is the file's lines, trimmed of indentation and line endings, and `marker` is what makes
164/// a line documentation rather than a comment.
165type Ordinary = (
166    BTreeMap<usize, Vec<Arc<str>>>,
167    BTreeMap<usize, Arc<str>>,
168    BTreeMap<usize, Vec<Arc<str>>>,
169);
170
171fn ordinary(text: &[&str], indents: &[usize], marker: &str) -> Ordinary {
172    let mut before: BTreeMap<usize, Vec<Arc<str>>> = BTreeMap::new();
173    let mut trailing: BTreeMap<usize, Arc<str>> = BTreeMap::new();
174    let mut after: BTreeMap<usize, Vec<Arc<str>>> = BTreeMap::new();
175
176    let is_ordinary = |l: &str| l.starts_with('#') && !is_doc(l, marker);
177    // Every line that carried code, with its indentation. An `after` run hangs on the last one
178    // **at or above its own level**: a comment at column zero at the end of a file belongs to the
179    // declaration it follows, not to the last statement of that declaration's innermost block.
180    let mut code: Vec<(usize, usize)> = Vec::new();
181
182    let mut i = 0usize;
183    while i < text.len() {
184        let line = text[i];
185        if is_ordinary(line) {
186            let start = i;
187            // **A preamble is one block, blank lines included.** A file header, a blank line and a
188            // section rule above the first declaration are three things a reader sees as one, and
189            // collecting them as separate runs leaves the first with only a comment beneath it —
190            // nothing to attach to, so it would travel to the end of the file. Interior blanks are
191            // kept as empty entries so the block prints back with its own shape.
192            // A block continues through blank lines, but only into comments at its **own
193            // indentation**: the note at the end of a function body and the note above the next
194            // declaration are two blocks with a blank line between them, and joining them would
195            // print one of them in the other's place.
196            let mut run: Vec<Arc<str>> = Vec::new();
197            while i < text.len()
198                && (text[i].is_empty() || (is_ordinary(text[i]) && indents[i] == indents[start]))
199            {
200                run.push(Arc::from(text[i]));
201                i += 1;
202            }
203            // A block that ran on through blank lines and stopped at something else gives the
204            // blanks back, so that `i` is where the scan continues from.
205            while run.last().is_some_and(|l| l.is_empty()) {
206                run.pop();
207                i -= 1;
208            }
209            // The line the run is about: the next one that carries something. Blank lines are
210            // stepped over rather than breaking the run, because a comment separated from its
211            // declaration by a blank line is still that declaration's — and dropping it, which is
212            // what a doc run does, would delete it.
213            // The line the run is about: the next one carrying something that is not itself a
214            // comment. Blank lines are stepped over rather than breaking the run — a comment
215            // separated from its declaration by a blank line is still that declaration's, and
216            // dropping it, which is what a doc run does, would delete it. A **doc** run beneath is
217            // stepped over too: `# note` above `## documentation` above `def` is all one preamble
218            // and belongs to the same declaration.
219            let mut target = i;
220            while target < text.len() && (text[target].is_empty() || is_doc(text[target], marker)) {
221                target += 1;
222            }
223            // **Which way it attaches is decided by indentation.** A run indented further than
224            // the line beneath it is the end of the block it sits in — the comment at the bottom
225            // of a function body — and attaching it forwards would move it out of that block and
226            // print it against the next declaration. There is nothing beneath it *in its own
227            // block*, so it hangs backwards on the last line that began one.
228            let deeper = target < text.len() && indents[start] > indents[target];
229            let hangs_on = || {
230                code.iter()
231                    .rev()
232                    .find(|(indent, _)| *indent <= indents[start])
233                    .map(|(_, line)| *line)
234            };
235            match (target < text.len() && !deeper, hangs_on()) {
236                (true, _) => before.entry(target).or_default().extend(run),
237                (false, Some(line)) => {
238                    let entry = after.entry(line).or_default();
239                    // The blank line above it is part of how a tail comment reads, and there is
240                    // nothing else left to supply it: the printer's own spacing goes *between*
241                    // items, and this is inside one.
242                    if entry.is_empty() && start > 0 && text[start - 1].is_empty() {
243                        entry.push(Arc::from(""));
244                    }
245                    entry.extend(run);
246                }
247                // Nothing above it at its level and nothing below it at all — a file that is only
248                // comments. `attach` puts it on the root rather than dropping it.
249                (false, None) => {}
250            }
251            continue;
252        }
253        if line.is_empty() || is_doc(line, marker) {
254            i += 1;
255            continue;
256        }
257        if let Some(text) = comment_ending(line) {
258            trailing.insert(i, Arc::from(text));
259        }
260        code.push((indents[i], i));
261        i += 1;
262    }
263    (before, trailing, after)
264}
265
266/// The comment that ends this line, if it has one.
267///
268/// A `#` inside a string literal is not a comment, so this walks the line rather than searching
269/// it. Beck's strings cannot span lines, so no state carries between lines and the walk is exact.
270fn comment_ending(line: &str) -> Option<&str> {
271    let bytes = line.as_bytes();
272    let mut in_string = false;
273    let mut i = 0usize;
274    while i < bytes.len() {
275        match bytes[i] {
276            b'\\' if in_string => i += 1,
277            b'"' => in_string = !in_string,
278            b'#' if !in_string => return Some(line[i..].trim_end()),
279            _ => {}
280        }
281        i += 1;
282    }
283    None
284}
285
286fn is_doc(line: &str, marker: &str) -> bool {
287    line.starts_with(marker)
288}
289
290/// Strip the marker and, if present, exactly one space — so `## text` and `##text` both yield
291/// `text`, and an indented continuation keeps its relative indentation.
292fn strip<'a>(line: &'a str, marker: &str) -> &'a str {
293    let rest = &line[marker.len()..];
294    rest.strip_prefix(' ').unwrap_or(rest).trim_end()
295}
296
297fn trim_blank_edges<'a>(lines: &[&'a str]) -> Vec<&'a str> {
298    let start = lines.iter().position(|l| !l.is_empty()).unwrap_or(0);
299    let end = lines
300        .iter()
301        .rposition(|l| !l.is_empty())
302        .map(|e| e + 1)
303        .unwrap_or(start);
304    lines[start..end].to_vec()
305}
306
307/// Attach every run to the node it documents.
308///
309/// Outermost first, and each run is claimed once: `@on(client)` above a `def` is one declaration,
310/// and its doc comment belongs to the whole of it.
311pub fn attach(node: &mut Node, docs: &DocComments) {
312    if docs.is_empty() {
313        return;
314    }
315    let mut claimed: Vec<usize> = Vec::new();
316    let mut lines: Vec<usize> = Vec::new();
317    walk(node, docs, &mut claimed, &mut lines);
318
319    // **Nothing is dropped, even when nothing wanted it.** A comment can sit on a line no node
320    // begins — a continuation inside a bracketed call, say — and the formatter prints from the
321    // tree, so a comment the tree does not carry is deleted. Anything unclaimed goes to the end of
322    // the root, which is bad placement and not a lost line;
323    // `roundtrip.rs::formatting_keeps_every_comment` is what says the difference matters.
324    let mut orphans: Vec<Arc<str>> = Vec::new();
325    for (line, run) in docs.before.iter().chain(docs.after.iter()) {
326        if !lines.contains(line) {
327            orphans.extend(run.iter().cloned());
328        }
329    }
330    for (line, text) in &docs.trailing {
331        if !lines.contains(line) {
332            orphans.push(text.clone());
333        }
334    }
335    if !orphans.is_empty() {
336        node.meta
337            .comments
338            .get_or_insert_with(Default::default)
339            .after
340            .extend(orphans);
341    }
342}
343
344fn walk(node: &mut Node, docs: &DocComments, claimed: &mut Vec<usize>, lines: &mut Vec<usize>) {
345    let start = node.span().start as usize;
346    if let Some(line) = line_starting_at(docs, start) {
347        if !claimed.contains(&line) {
348            claimed.push(line);
349            lines.push(line);
350            if let Some(text) = docs.runs.get(&line) {
351                node.meta.doc = Some(text.clone());
352            }
353            // The outermost node beginning a line takes that line's comments, for the reason it
354            // takes the doc run: a comment above `@on(client)` is about the declaration under it,
355            // not about the annotation.
356            let before = docs.before.get(&line);
357            let trailing = docs.trailing.get(&line);
358            let after = docs.after.get(&line);
359            if before.is_some() || trailing.is_some() || after.is_some() {
360                let c = node.meta.comments.get_or_insert_with(Default::default);
361                c.before = before.cloned().unwrap_or_default();
362                c.trailing = trailing.cloned();
363                c.after = after.cloned().unwrap_or_default();
364            }
365        }
366    }
367    for a in &mut node.args {
368        walk(a, docs, claimed, lines);
369    }
370}
371
372/// The line this offset begins, if the offset *is* that line's first non-whitespace character.
373///
374/// The restriction is what makes attachment unambiguous: a node in the middle of a line is not the
375/// thing a comment above the line was written about.
376fn line_starting_at(docs: &DocComments, offset: usize) -> Option<usize> {
377    let idx = docs
378        .lines
379        .binary_search_by(|(start, _)| start.cmp(&offset))
380        .unwrap_or_else(|i| i.saturating_sub(1));
381    let (_, first) = *docs.lines.get(idx)?;
382    (first == offset).then_some(idx)
383}
384
385/// Render a doc comment back into source, one `## ` line each, at the given indentation.
386pub fn render(doc: &str, marker: &str, indent: &str) -> String {
387    let mut out = String::new();
388    for line in doc.split('\n') {
389        out.push_str(indent);
390        out.push_str(marker);
391        if !line.is_empty() {
392            out.push(' ');
393            out.push_str(line);
394        }
395        out.push('\n');
396    }
397    out
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use beck_diag::{Diagnostics, SourceMap};
404
405    fn parse(src: &str) -> Node {
406        let mut map = SourceMap::new();
407        let file = map.add("t.beck", src);
408        let mut d = Diagnostics::new();
409        let n = crate::parse_file(file, "t.beck", src, &mut d);
410        assert!(!d.has_errors(), "{}", d.render(&map));
411        n
412    }
413
414    fn doc_of(n: &Node, name: &str) -> Option<String> {
415        for item in n.args.iter().skip(1) {
416            let mut inner = item;
417            while inner.is_form(crate::sym::DECORATE) {
418                inner = &inner.args[1];
419            }
420            let matches = inner
421                .args
422                .first()
423                .and_then(|a| a.as_var())
424                .map(|s| s.as_str() == name)
425                .unwrap_or(false);
426            if matches {
427                return item.meta.doc.as_ref().map(|d| d.to_string());
428            }
429        }
430        None
431    }
432
433    #[test]
434    fn a_run_of_doc_lines_attaches_to_the_declaration_beneath_it() {
435        let n = parse("## Adds two numbers.\n## Both of them.\ndef add(a: Int, b: Int) -> Int:\n    return a\n");
436        assert_eq!(
437            doc_of(&n, "add").as_deref(),
438            Some("Adds two numbers.\nBoth of them.")
439        );
440    }
441
442    #[test]
443    fn a_doc_comment_above_a_decorator_documents_the_whole_declaration() {
444        let n = parse("## The page.\n@on(client)\ndef page() -> Int:\n    return 1\n");
445        assert_eq!(doc_of(&n, "page").as_deref(), Some("The page."));
446    }
447
448    #[test]
449    fn a_blank_line_ends_a_run_so_a_file_header_documents_nothing() {
450        let n = parse("## A file header, about the module.\n\ndef f() -> Int:\n    return 1\n");
451        assert_eq!(doc_of(&n, "f"), None);
452    }
453
454    #[test]
455    fn an_ordinary_comment_is_still_an_ordinary_comment() {
456        let n = parse("# not documentation\ndef f() -> Int:\n    return 1\n");
457        assert_eq!(doc_of(&n, "f"), None);
458    }
459
460    #[test]
461    fn a_hash_inside_a_string_is_not_a_doc_comment() {
462        let docs = collect("x = \"## not a doc\"\n", PY_MARKER);
463        assert!(docs.is_empty());
464    }
465
466    #[test]
467    fn a_doc_comment_does_not_change_what_a_program_means() {
468        // Structural equality ignores `Meta`, so documenting a definition cannot invalidate a
469        // memo or move an interface digest.
470        let plain = parse("def f() -> Int:\n    return 1\n");
471        let documented = parse("## Documented.\ndef f() -> Int:\n    return 1\n");
472        assert_eq!(plain, documented);
473        assert!(doc_of(&documented, "f").is_some());
474    }
475
476    /// Every doc comment in the tree, keyed by the path of node indices that reaches it — so the
477    /// comparison is about *where* a comment landed as well as what it says.
478    fn all_docs(n: &Node) -> Vec<(Vec<usize>, String)> {
479        fn go(n: &Node, path: &mut Vec<usize>, out: &mut Vec<(Vec<usize>, String)>) {
480            if let Some(d) = &n.meta.doc {
481                out.push((path.clone(), d.to_string()));
482            }
483            for (i, a) in n.args.iter().enumerate() {
484                path.push(i);
485                go(a, path, out);
486                path.pop();
487            }
488        }
489        let mut out = Vec::new();
490        go(n, &mut Vec::new(), &mut out);
491        out
492    }
493
494    fn reparse(name: &str, src: &str) -> Node {
495        let mut map = SourceMap::new();
496        let file = map.add(name, src);
497        let mut d = Diagnostics::new();
498        let n = crate::parse_file(file, name, src, &mut d);
499        assert!(!d.has_errors(), "{}\n--- source ---\n{src}", d.render(&map));
500        n
501    }
502
503    const DOCUMENTED: &str = "\
504## The identifier of a todo.
505type Id = newtype[Str]
506
507## One item on the list.
508model Todo:
509    ## Stable for the life of the item.
510    id: Id
511    ## What the user typed.
512    text: Str
513
514## What may happen to the list.
515union Event:
516    Added(id: Id)
517    ## Toggling is idempotent in the fold.
518    Toggled(id: Id)
519
520## Adds two numbers, and is documented about it.
521@on(any)
522def add(a: Int, b: Int) -> Int:
523    return a
524";
525
526    #[test]
527    fn doc_comments_survive_printing_and_reparsing_in_both_surfaces() {
528        let original = reparse("t.beck", DOCUMENTED);
529        let docs = all_docs(&original);
530        assert_eq!(docs.len(), 7, "{docs:#?}");
531
532        let py = crate::print::to_python(&original);
533        assert_eq!(all_docs(&reparse("t.beck", &py)), docs, "python:\n{py}");
534
535        let sx = crate::print::to_sexpr_pretty(&original);
536        assert_eq!(all_docs(&reparse("t.sx", &sx)), docs, "sexpr:\n{sx}");
537    }
538
539    #[test]
540    fn formatting_a_documented_module_is_idempotent() {
541        let once = crate::print::to_python(&reparse("t.beck", DOCUMENTED));
542        let twice = crate::print::to_python(&reparse("t.beck", &once));
543        assert_eq!(once, twice, "once:\n{once}\ntwice:\n{twice}");
544    }
545
546    #[test]
547    fn model_fields_are_documented_too() {
548        let n = parse("model Todo:\n    ## What it says.\n    text: Str\n");
549        let model = &n.args[1];
550        let field = &model.args[2];
551        assert_eq!(field.meta.doc.as_deref(), Some("What it says."));
552    }
553}