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}