1use bynk_syntax::ast::*;
31use bynk_syntax::lexer::tokenize;
32use bynk_syntax::parser::parse_unit_with_recovery;
33use bynk_syntax::span::Span;
34
35use crate::symbols;
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct DocModel {
40 pub unit_name: String,
43 pub unit_kind: &'static str,
46 pub unit_doc: Option<String>,
49 pub unit_span: Span,
51 pub entries: Vec<DocEntry>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
58pub struct DocEntry {
59 pub name: String,
62 pub kind: &'static str,
64 pub depth: u32,
67 pub markdown: String,
71 pub documented: bool,
75 pub span: Span,
77}
78
79pub fn documentation_model(text: &str) -> Option<DocModel> {
84 let tokens = tokenize(text).ok()?;
85 let (unit, _errs) = parse_unit_with_recovery(&tokens, text);
86 let unit = unit?;
87 let (unit_kind, unit_name, unit_span, unit_doc, items) = match &unit {
88 SourceUnit::Commons(c) => (
89 "commons",
90 c.name.joined(),
91 c.name.span,
92 &c.documentation,
93 &c.items,
94 ),
95 SourceUnit::Context(c) => (
96 "context",
97 c.name.joined(),
98 c.name.span,
99 &c.documentation,
100 &c.items,
101 ),
102 SourceUnit::Adapter(a) => (
103 "adapter",
104 a.name.joined(),
105 a.name.span,
106 &a.documentation,
107 &a.items,
108 ),
109 SourceUnit::Suite(_) => return None,
110 };
111 let mut entries = Vec::new();
112 for item in items {
113 push_item(&mut entries, item);
114 }
115 Some(DocModel {
116 unit_name,
117 unit_kind,
118 unit_doc: unit_doc.clone(),
119 unit_span,
120 entries,
121 })
122}
123
124fn push_item(out: &mut Vec<DocEntry>, item: &CommonsItem) {
132 match item {
133 CommonsItem::Type(t) => out.push(DocEntry {
134 name: t.name.name.clone(),
135 kind: "type",
136 depth: 0,
137 markdown: symbols::describe_type(t),
138 documented: t.documentation.is_some(),
139 span: t.name.span,
140 }),
141 CommonsItem::Fn(f) => out.push(DocEntry {
142 name: f.name.display(),
143 kind: match f.name {
144 FnName::Free(_) => "function",
145 FnName::Method { .. } => "method",
146 },
147 depth: 0,
148 markdown: symbols::describe_fn(f),
149 documented: f.documentation.is_some(),
150 span: f.name.ident().span,
151 }),
152 CommonsItem::Capability(c) => {
153 out.push(DocEntry {
154 name: c.name.name.clone(),
155 kind: "capability",
156 depth: 0,
157 markdown: symbols::describe_capability(c),
158 documented: c.documentation.is_some(),
159 span: c.name.span,
160 });
161 for op in &c.ops {
162 out.push(DocEntry {
163 name: format!("{}.{}", c.name.name, op.name.name),
164 kind: "operation",
165 depth: 1,
166 markdown: symbols::describe_capability_op(c, op),
167 documented: op.documentation.is_some(),
168 span: op.name.span,
169 });
170 }
171 }
172 CommonsItem::Provider(p) => out.push(DocEntry {
173 name: format!("{} = {}", p.capability.name, p.provider_name.name),
174 kind: "provider",
175 depth: 0,
176 markdown: symbols::describe_provider(p),
177 documented: p.documentation.is_some(),
178 span: p.provider_name.span,
179 }),
180 CommonsItem::Service(s) => {
181 out.push(DocEntry {
182 name: s.name.name.clone(),
183 kind: "service",
184 depth: 0,
185 markdown: symbols::describe_service(s),
186 documented: s.documentation.is_some(),
187 span: s.name.span,
188 });
189 for h in &s.handlers {
190 out.push(DocEntry {
191 name: symbols::handler_line(h),
195 kind: "handler",
196 depth: 1,
197 markdown: symbols::describe_service_handler(s, h),
198 documented: h.documentation.is_some(),
199 span: h.span,
200 });
201 }
202 }
203 CommonsItem::Agent(a) => {
204 out.push(DocEntry {
205 name: a.name.name.clone(),
206 kind: "agent",
207 depth: 0,
208 markdown: symbols::describe_agent(a),
209 documented: a.documentation.is_some(),
210 span: a.name.span,
211 });
212 for h in &a.handlers {
213 let handler = h
214 .method_name
215 .as_ref()
216 .map(|m| m.name.clone())
217 .unwrap_or_else(|| "call".to_string());
218 out.push(DocEntry {
219 name: format!("{}.{}", a.name.name, handler),
220 kind: "handler",
221 depth: 1,
222 markdown: symbols::describe_agent_handler(a, h, &handler),
223 documented: h.documentation.is_some(),
224 span: h.method_name.as_ref().map(|m| m.span).unwrap_or(h.span),
225 });
226 }
227 }
228 CommonsItem::Actor(a) => out.push(DocEntry {
229 name: a.name.name.clone(),
230 kind: "actor",
231 depth: 0,
232 markdown: symbols::describe_actor(a),
233 documented: a.documentation.is_some(),
234 span: a.name.span,
235 }),
236 CommonsItem::Messages(m) => out.push(DocEntry {
238 name: m.tag.clone(),
239 kind: "messages",
240 depth: 0,
241 markdown: symbols::describe_messages(m),
242 documented: m.documentation.is_some(),
243 span: m.tag_span,
244 }),
245 CommonsItem::Event(e) => out.push(DocEntry {
249 name: e.name.name.clone(),
250 kind: "event",
251 depth: 0,
252 markdown: symbols::describe_type(&e.as_type_decl()),
253 documented: e.documentation.is_some(),
254 span: e.name.span,
255 }),
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 const DOCUMENTED_CTX: &str = r#"---
268The demo application context.
269---
270context demo.app
271
272---
273Reads the wall clock.
274---
275capability Clock {
276 ---
277 Milliseconds since the Unix epoch.
278 ---
279 fn now() -> Int
280}
281
282---
283An amount in the smallest currency unit.
284---
285type Money = Int where NonNegative
286
287---
288The public HTTP surface.
289---
290service Api from http {
291 ---
292 Returns the current instant.
293 ---
294 on GET("/now") () -> Effect[Int] given Clock {
295 Clock.now()
296 }
297}
298
299---
300A per-key running total.
301---
302agent Counter {
303 key id: Int
304 store value: Cell[Int]
305 ---
306 Adds `amount` to the total and returns the new value.
307 ---
308 on call bump(amount: Int) -> Effect[Int] {
309 let _ <- value.update((v) => v + amount)
310 value
311 }
312}
313"#;
314
315 #[test]
316 fn documented_context_aggregates_every_declaration_in_order_and_hierarchy() {
317 let model = documentation_model(DOCUMENTED_CTX).expect("a context model");
318 assert_eq!(model.unit_name, "demo.app");
319 assert_eq!(model.unit_kind, "context");
320 assert_eq!(
321 model.unit_doc.as_deref(),
322 Some("The demo application context.")
323 );
324
325 let names: Vec<(&str, u32, &str)> = model
328 .entries
329 .iter()
330 .map(|e| (e.name.as_str(), e.depth, e.kind))
331 .collect();
332 assert_eq!(
333 names,
334 vec![
335 ("Clock", 0, "capability"),
336 ("Clock.now", 1, "operation"),
337 ("Money", 0, "type"),
338 ("Api", 0, "service"),
339 ("on GET(\"/now\")", 1, "handler"),
340 ("Counter", 0, "agent"),
341 ("Counter.bump", 1, "handler"),
342 ]
343 );
344
345 assert!(model.entries.iter().all(|e| e.documented));
348 let money = model.entries.iter().find(|e| e.name == "Money").unwrap();
349 assert!(
350 money
351 .markdown
352 .contains("An amount in the smallest currency unit.")
353 );
354 assert!(money.markdown.contains("```bynk"));
355 let bump = model
356 .entries
357 .iter()
358 .find(|e| e.name == "Counter.bump")
359 .unwrap();
360 assert!(bump.markdown.contains("Adds `amount` to the total"));
361 }
362
363 #[test]
364 fn undocumented_declarations_are_flagged_for_the_coverage_placeholder() {
365 let src = "commons demo.x {\n\
366 ---\n\
367 Has docs.\n\
368 ---\n\
369 type Documented = Int\n\
370 type Undocumented = Int\n\
371 fn helper(n: Int) -> Int { n }\n\
372 }";
373 let model = documentation_model(src).expect("a commons model");
374 assert_eq!(model.unit_kind, "commons");
375 let documented = model
376 .entries
377 .iter()
378 .find(|e| e.name == "Documented")
379 .unwrap();
380 assert!(documented.documented);
381 let undoc = model
382 .entries
383 .iter()
384 .find(|e| e.name == "Undocumented")
385 .unwrap();
386 assert!(!undoc.documented);
387 assert!(undoc.markdown.contains("```bynk"));
390 let helper = model.entries.iter().find(|e| e.name == "helper").unwrap();
391 assert!(!helper.documented);
392 }
393
394 #[test]
395 fn suite_units_have_no_documentation_page() {
396 let src = "suite for demo.app {\n\
397 case works {\n\
398 expect true\n\
399 }\n\
400 }";
401 assert!(documentation_model(src).is_none());
403 }
404
405 #[test]
406 fn empty_input_yields_no_model() {
407 assert!(documentation_model("").is_none());
408 }
409
410 #[test]
411 fn adapter_unit_documents_its_items() {
412 let src = "adapter tokens {\n\
413 binding \"./tokens.binding.ts\"\n\
414 exports capability { Jwt }\n\
415 capability Jwt {\n\
416 fn sign(secret: String) -> Effect[String]\n\
417 }\n\
418 provides Jwt = JoseJwt\n\
419 }";
420 let model = documentation_model(src).expect("an adapter model");
421 assert_eq!(model.unit_name, "tokens");
422 assert_eq!(model.unit_kind, "adapter");
423 assert!(
424 model
425 .entries
426 .iter()
427 .any(|e| e.name == "Jwt" && e.kind == "capability")
428 );
429 assert!(
430 model
431 .entries
432 .iter()
433 .any(|e| e.name == "Jwt.sign" && e.kind == "operation")
434 );
435 assert!(
436 model
437 .entries
438 .iter()
439 .any(|e| e.name == "Jwt = JoseJwt" && e.kind == "provider")
440 );
441 }
442}