1use std::path::{Path, PathBuf};
35
36use bynk_syntax::ast::*;
37use bynk_syntax::lexer::tokenize;
38use bynk_syntax::parser::parse_unit_with_recovery;
39use bynk_syntax::span::Span;
40
41use crate::symbols;
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct DocModel {
46 pub unit_name: String,
49 pub unit_kind: &'static str,
52 pub unit_doc: Option<String>,
55 pub unit_span: Span,
57 pub entries: Vec<DocEntry>,
59}
60
61#[derive(Debug, Clone, PartialEq)]
64pub struct DocEntry {
65 pub name: String,
68 pub kind: &'static str,
70 pub depth: u32,
73 pub markdown: String,
77 pub documented: bool,
81 pub span: Span,
83 pub file: Option<PathBuf>,
87}
88
89type PageParts<'u> = (
92 &'static str,
93 String,
94 Span,
95 &'u Option<Doc>,
96 &'u [CommonsItem],
97);
98
99fn page_parts(unit: &SourceUnit) -> Option<PageParts<'_>> {
101 match unit {
102 SourceUnit::Commons(c) => Some((
103 "commons",
104 c.name.joined(),
105 c.name.span,
106 &c.documentation,
107 &c.items,
108 )),
109 SourceUnit::Context(c) => Some((
110 "context",
111 c.name.joined(),
112 c.name.span,
113 &c.documentation,
114 &c.items,
115 )),
116 SourceUnit::Adapter(a) => Some((
117 "adapter",
118 a.name.joined(),
119 a.name.span,
120 &a.documentation,
121 &a.items,
122 )),
123 SourceUnit::Suite(_) => None,
124 }
125}
126
127fn parse(text: &str) -> Option<SourceUnit> {
128 let tokens = tokenize(text).ok()?;
129 parse_unit_with_recovery(&tokens, text).0
130}
131
132pub fn documentation_model(text: &str) -> Option<DocModel> {
143 let unit = parse(text)?;
144 let (unit_kind, unit_name, unit_span, unit_doc, items) = page_parts(&unit)?;
145 let mut entries = Vec::new();
146 for item in items {
147 push_item(&mut entries, item);
148 }
149 Some(DocModel {
150 unit_name,
151 unit_kind,
152 unit_doc: unit_doc.as_deref().map(str::to_owned),
153 unit_span,
154 entries,
155 })
156}
157
158pub fn documentation_model_merged<'a>(
172 primary: (&'a Path, &'a str),
173 files: impl IntoIterator<Item = (&'a Path, &'a str)>,
174) -> Option<DocModel> {
175 let unit = parse(primary.1)?;
176 let (unit_kind, unit_name, unit_span, _, _) = page_parts(&unit)?;
177 let mut all: Vec<(&Path, &str)> = files.into_iter().collect();
178 if !all.iter().any(|(p, _)| *p == primary.0) {
179 all.push(primary);
180 }
181 all.sort_by(|a, b| a.0.cmp(b.0));
182 all.dedup_by(|a, b| a.0 == b.0);
183 let mut unit_doc: Option<String> = None;
184 let mut entries = Vec::new();
185 for (path, text) in all {
186 let Some(unit) = parse(text) else {
187 continue;
188 };
189 let Some((kind, name, _, _, items)) = page_parts(&unit) else {
190 continue;
191 };
192 if kind != unit_kind || name != unit_name {
193 continue;
194 }
195 if unit_doc.is_none() {
196 unit_doc = unit.module_doc().map(str::to_string);
197 }
198 let start = entries.len();
199 for item in items {
200 push_item(&mut entries, item);
201 }
202 for e in &mut entries[start..] {
203 e.file = Some(path.to_path_buf());
204 }
205 }
206 Some(DocModel {
207 unit_name,
208 unit_kind,
209 unit_doc,
210 unit_span,
211 entries,
212 })
213}
214
215fn push_item(out: &mut Vec<DocEntry>, item: &CommonsItem) {
223 match item {
224 CommonsItem::Type(t) => {
225 out.push(DocEntry {
226 name: t.name.name.clone(),
227 kind: "type",
228 depth: 0,
229 markdown: symbols::describe_type(t),
230 documented: t.documentation.is_some(),
231 span: t.name.span,
232 file: None,
233 });
234 push_member_docs(out, t);
235 }
236 CommonsItem::Fn(f) => out.push(DocEntry {
237 name: f.name.display(),
238 kind: match f.name {
239 FnName::Free(_) => "function",
240 FnName::Method { .. } => "method",
241 },
242 depth: 0,
243 markdown: symbols::describe_fn(f),
244 documented: f.documentation.is_some(),
245 span: f.name.ident().span,
246 file: None,
247 }),
248 CommonsItem::Capability(c) => {
249 out.push(DocEntry {
250 name: c.name.name.clone(),
251 kind: "capability",
252 depth: 0,
253 markdown: symbols::describe_capability(c),
254 documented: c.documentation.is_some(),
255 span: c.name.span,
256 file: None,
257 });
258 for op in &c.ops {
259 out.push(DocEntry {
260 name: format!("{}.{}", c.name.name, op.name.name),
261 kind: "operation",
262 depth: 1,
263 markdown: symbols::describe_capability_op(c, op),
264 documented: op.documentation.is_some(),
265 span: op.name.span,
266 file: None,
267 });
268 }
269 }
270 CommonsItem::Provider(p) => out.push(DocEntry {
271 name: format!("{} = {}", p.capability.name, p.provider_name.name),
272 kind: "provider",
273 depth: 0,
274 markdown: symbols::describe_provider(p),
275 documented: p.documentation.is_some(),
276 span: p.provider_name.span,
277 file: None,
278 }),
279 CommonsItem::Service(s) => {
280 out.push(DocEntry {
281 name: s.name.name.clone(),
282 kind: "service",
283 depth: 0,
284 markdown: symbols::describe_service(s),
285 documented: s.documentation.is_some(),
286 span: s.name.span,
287 file: None,
288 });
289 for h in &s.handlers {
290 out.push(DocEntry {
291 name: symbols::handler_line(h),
295 kind: "handler",
296 depth: 1,
297 markdown: symbols::describe_service_handler(s, h),
298 documented: h.documentation.is_some(),
299 span: h.span,
300 file: None,
301 });
302 }
303 }
304 CommonsItem::Agent(a) => {
305 out.push(DocEntry {
306 name: a.name.name.clone(),
307 kind: "agent",
308 depth: 0,
309 markdown: symbols::describe_agent(a),
310 documented: a.documentation.is_some(),
311 span: a.name.span,
312 file: None,
313 });
314 for h in &a.handlers {
315 let handler = h
316 .method_name
317 .as_ref()
318 .map(|m| m.name.clone())
319 .unwrap_or_else(|| "call".to_string());
320 out.push(DocEntry {
321 name: format!("{}.{}", a.name.name, handler),
322 kind: "handler",
323 depth: 1,
324 markdown: symbols::describe_agent_handler(a, h, &handler),
325 documented: h.documentation.is_some(),
326 span: h.method_name.as_ref().map(|m| m.span).unwrap_or(h.span),
327 file: None,
328 });
329 }
330 }
331 CommonsItem::Actor(a) => out.push(DocEntry {
332 name: a.name.name.clone(),
333 kind: "actor",
334 depth: 0,
335 markdown: symbols::describe_actor(a),
336 documented: a.documentation.is_some(),
337 span: a.name.span,
338 file: None,
339 }),
340 CommonsItem::Messages(m) => {
342 out.push(DocEntry {
343 name: m.tag.clone(),
344 kind: "messages",
345 depth: 0,
346 markdown: symbols::describe_messages(m),
347 documented: m.documentation.is_some(),
348 span: m.tag_span,
349 file: None,
350 });
351 for e in &m.entries {
353 if let Some(doc) = &e.documentation {
354 out.push(DocEntry {
355 name: format!("{}.{}", m.tag, e.code),
356 kind: "message",
357 depth: 1,
358 markdown: format!(
359 "```bynk\n\"{}\" => …\n```\n\n{}",
360 e.code,
361 doc.trim_end()
362 ),
363 documented: true,
364 span: e.code_span,
365 file: None,
366 });
367 }
368 }
369 }
370 CommonsItem::Event(e) => {
374 out.push(DocEntry {
375 name: e.name.name.clone(),
376 kind: "event",
377 depth: 0,
378 markdown: symbols::describe_type(&e.as_type_decl()),
379 documented: e.documentation.is_some(),
380 span: e.name.span,
381 file: None,
382 });
383 push_member_docs(out, &e.as_type_decl());
384 }
385 CommonsItem::Asset(a) => out.push(DocEntry {
387 name: a.name.name.clone(),
388 kind: "asset",
389 depth: 0,
390 markdown: symbols::describe_asset(a),
391 documented: a.documentation.is_some(),
392 span: a.name.span,
393 file: None,
394 }),
395 CommonsItem::Embed(e) => out.push(DocEntry {
397 name: e.name.name.clone(),
398 kind: "embed",
399 depth: 0,
400 markdown: symbols::describe_embed(e),
401 documented: e.documentation.is_some(),
402 span: e.name.span,
403 file: None,
404 }),
405 }
406}
407
408fn push_member_docs(out: &mut Vec<DocEntry>, t: &TypeDecl) {
414 match &t.body {
415 TypeBody::Record(r) => {
416 for f in r.fields.iter().filter(|f| f.documentation.is_some()) {
417 out.push(DocEntry {
418 name: format!("{}.{}", t.name.name, f.name.name),
419 kind: "field",
420 depth: 1,
421 markdown: symbols::describe_record_field(t, f),
422 documented: true,
423 span: f.name.span,
424 file: None,
425 });
426 }
427 }
428 TypeBody::Sum(s) => {
429 for v in &s.variants {
430 if v.documentation.is_some() {
431 out.push(DocEntry {
432 name: format!("{}.{}", t.name.name, v.name.name),
433 kind: "variant",
434 depth: 1,
435 markdown: symbols::describe_variant(t, v),
436 documented: true,
437 span: v.name.span,
438 file: None,
439 });
440 }
441 for p in v.payload.iter().filter(|p| p.documentation.is_some()) {
442 out.push(DocEntry {
443 name: format!("{}.{}.{}", t.name.name, v.name.name, p.name.name),
444 kind: "field",
445 depth: 2,
446 markdown: symbols::describe_variant_field(t, v, p),
447 documented: true,
448 span: p.name.span,
449 file: None,
450 });
451 }
452 }
453 }
454 TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 const DOCUMENTED_CTX: &str = r#"---
467The demo application context.
468---
469context demo.app
470
471---
472Reads the wall clock.
473---
474capability Clock {
475 ---
476 Milliseconds since the Unix epoch.
477 ---
478 fn now() -> Int
479}
480
481---
482An amount in the smallest currency unit.
483---
484type Money = Int where NonNegative
485
486---
487The public HTTP surface.
488---
489service Api from http {
490 ---
491 Returns the current instant.
492 ---
493 on GET("/now") () -> Effect[Int] given Clock {
494 Clock.now()
495 }
496}
497
498---
499A per-key running total.
500---
501agent Counter {
502 key id: Int
503 store value: Cell[Int]
504 ---
505 Adds `amount` to the total and returns the new value.
506 ---
507 on call bump(amount: Int) -> Effect[Int] {
508 let _ <- value.update((v) => v + amount)
509 value
510 }
511}
512"#;
513
514 #[test]
515 fn documented_context_aggregates_every_declaration_in_order_and_hierarchy() {
516 let model = documentation_model(DOCUMENTED_CTX).expect("a context model");
517 assert_eq!(model.unit_name, "demo.app");
518 assert_eq!(model.unit_kind, "context");
519 assert_eq!(
520 model.unit_doc.as_deref(),
521 Some("The demo application context.")
522 );
523
524 let names: Vec<(&str, u32, &str)> = model
527 .entries
528 .iter()
529 .map(|e| (e.name.as_str(), e.depth, e.kind))
530 .collect();
531 assert_eq!(
532 names,
533 vec![
534 ("Clock", 0, "capability"),
535 ("Clock.now", 1, "operation"),
536 ("Money", 0, "type"),
537 ("Api", 0, "service"),
538 ("on GET(\"/now\")", 1, "handler"),
539 ("Counter", 0, "agent"),
540 ("Counter.bump", 1, "handler"),
541 ]
542 );
543
544 assert!(model.entries.iter().all(|e| e.documented));
547 let money = model.entries.iter().find(|e| e.name == "Money").unwrap();
548 assert!(
549 money
550 .markdown
551 .contains("An amount in the smallest currency unit.")
552 );
553 assert!(money.markdown.contains("```bynk"));
554 let bump = model
555 .entries
556 .iter()
557 .find(|e| e.name == "Counter.bump")
558 .unwrap();
559 assert!(bump.markdown.contains("Adds `amount` to the total"));
560 }
561
562 #[test]
563 fn undocumented_declarations_are_flagged_for_the_coverage_placeholder() {
564 let src = "commons demo.x {\n\
565 ---\n\
566 Has docs.\n\
567 ---\n\
568 type Documented = Int\n\
569 type Undocumented = Int\n\
570 fn helper(n: Int) -> Int { n }\n\
571 }";
572 let model = documentation_model(src).expect("a commons model");
573 assert_eq!(model.unit_kind, "commons");
574 let documented = model
575 .entries
576 .iter()
577 .find(|e| e.name == "Documented")
578 .unwrap();
579 assert!(documented.documented);
580 let undoc = model
581 .entries
582 .iter()
583 .find(|e| e.name == "Undocumented")
584 .unwrap();
585 assert!(!undoc.documented);
586 assert!(undoc.markdown.contains("```bynk"));
589 let helper = model.entries.iter().find(|e| e.name == "helper").unwrap();
590 assert!(!helper.documented);
591 }
592
593 #[test]
594 fn suite_units_have_no_documentation_page() {
595 let src = "suite for demo.app {\n\
596 case works {\n\
597 expect true\n\
598 }\n\
599 }";
600 assert!(documentation_model(src).is_none());
602 }
603
604 #[test]
605 fn empty_input_yields_no_model() {
606 assert!(documentation_model("").is_none());
607 }
608
609 #[test]
613 fn documented_members_are_listed_under_their_type() {
614 let src = "commons m\n\nuses bynk.locale\nuses bynk.locale.types\n\n--- A run.\ntype Run = {\n --- The repo.\n repo: String,\n n: Int,\n}\n\ntype Shape =\n ---\n Round.\n ---\n | Circle(\n --- The radius.\n r: Int,\n )\n | Dot\n\nmessages \"en\" @reference {\n --- Greets.\n \"hi\" => \"Hello\"\n \"bye\" => \"Bye\"\n}\n";
615 let model = documentation_model(src).expect("a model");
616 let rows: Vec<(&str, &str, u32)> = model
617 .entries
618 .iter()
619 .map(|e| (e.name.as_str(), e.kind, e.depth))
620 .collect();
621 assert_eq!(
622 rows,
623 [
624 ("Run", "type", 0),
625 ("Run.repo", "field", 1),
626 ("Shape", "type", 0),
627 ("Shape.Circle", "variant", 1),
628 ("Shape.Circle.r", "field", 2),
629 ("en", "messages", 0),
630 ("en.hi", "message", 1),
631 ]
632 );
633 let repo = &model.entries[1];
634 assert!(repo.documented && repo.markdown.contains("The repo."));
635 assert!(model.entries[3].markdown.contains("Round."));
636 assert!(model.entries[4].markdown.contains("The radius."));
637 assert!(model.entries[6].markdown.contains("Greets."));
638 }
639
640 #[test]
644 fn the_merged_page_lists_member_docs_with_their_file() {
645 use std::path::Path;
646 let a = "--- The model.\ncommons m\n\ntype Run = {\n --- The repo.\n repo: String,\n}\n";
647 let b = "commons m\n\ntype Outcome = enum {\n --- Passed.\n Pass,\n Fail,\n}\n";
648 let (pa, pb) = (Path::new("a.bynk"), Path::new("b.bynk"));
649 let model = documentation_model_merged((pa, a), [(pb, b)]).expect("a model");
650 assert_eq!(model.unit_doc.as_deref(), Some("The model."));
651 let rows: Vec<(&str, u32, Option<&Path>)> = model
652 .entries
653 .iter()
654 .map(|e| (e.name.as_str(), e.depth, e.file.as_deref()))
655 .collect();
656 assert_eq!(
657 rows,
658 [
659 ("Run", 0, Some(pa)),
660 ("Run.repo", 1, Some(pa)),
661 ("Outcome", 0, Some(pb)),
662 ("Outcome.Pass", 1, Some(pb)),
663 ]
664 );
665 }
666
667 #[test]
668 fn adapter_unit_documents_its_items() {
669 let src = "adapter tokens {\n\
670 binding \"./tokens.binding.ts\"\n\
671 exports capability { Jwt }\n\
672 capability Jwt {\n\
673 fn sign(secret: String) -> Effect[String]\n\
674 }\n\
675 provides Jwt = JoseJwt\n\
676 }";
677 let model = documentation_model(src).expect("an adapter model");
678 assert_eq!(model.unit_name, "tokens");
679 assert_eq!(model.unit_kind, "adapter");
680 assert!(
681 model
682 .entries
683 .iter()
684 .any(|e| e.name == "Jwt" && e.kind == "capability")
685 );
686 assert!(
687 model
688 .entries
689 .iter()
690 .any(|e| e.name == "Jwt.sign" && e.kind == "operation")
691 );
692 assert!(
693 model
694 .entries
695 .iter()
696 .any(|e| e.name == "Jwt = JoseJwt" && e.kind == "provider")
697 );
698 }
699}