1use std::collections::BTreeMap;
38use std::sync::Arc;
39
40use crate::node::Node;
41
42pub const PY_MARKER: &str = "##";
44
45pub const SEXPR_MARKER: &str = ";;";
48
49pub 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#[derive(Clone, Debug, Default)]
60pub struct DocComments {
61 runs: BTreeMap<usize, Arc<str>>,
63 lines: Vec<(usize, usize)>,
65}
66
67impl DocComments {
68 pub fn is_empty(&self) -> bool {
69 self.runs.is_empty()
70 }
71}
72
73pub 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 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 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
117fn 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
134pub 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
161fn 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
174pub 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 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 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}