Skip to main content

stryke/
docs.rs

1//! Module-doc generator — produces Markdown documentation from a
2//! parsed stryke source file by pairing `## doc comments` with the
3//! top-level declaration immediately below them.
4//!
5//! Driven by the project-wide CLI subcommand `stryke gen-docs
6//! [PATH] [--out DIR]`, which walks a directory tree and calls
7//! [`generate_markdown`] once per source file.
8
9use crate::ast::{Program, Statement, StmtKind, SubSigParam};
10
11/// Emit Markdown for every top-level declaration in `program` that's
12/// considered a public API surface (fn / struct / enum / class /
13/// trait / package / `use constant`). Doc comments are extracted from
14/// the SOURCE — consecutive `##` lines immediately above the
15/// declaration line — so the parser/AST doesn't need to track them.
16pub fn generate_markdown(filename: &str, source: &str, program: &Program) -> String {
17    let source_lines: Vec<&str> = source.lines().collect();
18    let module_title = derive_module_title(filename, program);
19
20    let mut out = String::new();
21    out.push_str("# Module: ");
22    out.push_str(&module_title);
23    out.push_str("\n\n");
24
25    // Module-level header doc: any `##` block at the very top of the
26    // file, before the first non-blank non-comment line.
27    if let Some(header) = leading_module_doc(&source_lines) {
28        out.push_str(&header);
29        out.push_str("\n\n");
30    }
31
32    // Walk top-level statements, bucketing by category.
33    let mut subs: Vec<&Statement> = Vec::new();
34    let mut structs: Vec<&Statement> = Vec::new();
35    let mut enums: Vec<&Statement> = Vec::new();
36    let mut classes: Vec<&Statement> = Vec::new();
37    let mut traits: Vec<&Statement> = Vec::new();
38    let mut consts: Vec<&Statement> = Vec::new();
39    let mut packages: Vec<&Statement> = Vec::new();
40
41    for stmt in &program.statements {
42        match &stmt.kind {
43            StmtKind::SubDecl { .. } => subs.push(stmt),
44            StmtKind::StructDecl { .. } => structs.push(stmt),
45            StmtKind::EnumDecl { .. } => enums.push(stmt),
46            StmtKind::ClassDecl { .. } => classes.push(stmt),
47            StmtKind::TraitDecl { .. } => traits.push(stmt),
48            StmtKind::Use { module, .. } if module == "constant" => consts.push(stmt),
49            StmtKind::Package { .. } => packages.push(stmt),
50            _ => {}
51        }
52    }
53
54    if !packages.is_empty() {
55        out.push_str("## Packages\n\n");
56        for stmt in &packages {
57            if let StmtKind::Package { name } = &stmt.kind {
58                let doc = extract_doc_above(&source_lines, stmt.line);
59                out.push_str(&format!("### `package {}`\n\n", name));
60                if !doc.is_empty() {
61                    out.push_str(&doc);
62                    out.push_str("\n\n");
63                }
64            }
65        }
66    }
67
68    if !consts.is_empty() {
69        out.push_str("## Constants\n\n");
70        for stmt in &consts {
71            if let StmtKind::Use { imports, .. } = &stmt.kind {
72                let doc = extract_doc_above(&source_lines, stmt.line);
73                for name in extract_constant_names(imports) {
74                    out.push_str(&format!("### `{}`\n\n", name));
75                    if !doc.is_empty() {
76                        out.push_str(&doc);
77                        out.push_str("\n\n");
78                    }
79                }
80            }
81        }
82    }
83
84    if !traits.is_empty() {
85        out.push_str("## Traits\n\n");
86        for stmt in &traits {
87            if let StmtKind::TraitDecl { def } = &stmt.kind {
88                let doc = extract_doc_above(&source_lines, stmt.line);
89                out.push_str(&format!("### `trait {}`\n\n", def.name));
90                if !doc.is_empty() {
91                    out.push_str(&doc);
92                    out.push_str("\n\n");
93                }
94            }
95        }
96    }
97
98    if !structs.is_empty() {
99        out.push_str("## Structs\n\n");
100        for stmt in &structs {
101            if let StmtKind::StructDecl { def } = &stmt.kind {
102                let doc = extract_doc_above(&source_lines, stmt.line);
103                out.push_str(&format!("### `struct {}`\n\n", def.name));
104                if !doc.is_empty() {
105                    out.push_str(&doc);
106                    out.push_str("\n\n");
107                }
108                if !def.fields.is_empty() {
109                    out.push_str("Fields:\n");
110                    for f in &def.fields {
111                        out.push_str(&format!("- `{}`\n", f.name));
112                    }
113                    out.push('\n');
114                }
115            }
116        }
117    }
118
119    if !enums.is_empty() {
120        out.push_str("## Enums\n\n");
121        for stmt in &enums {
122            if let StmtKind::EnumDecl { def } = &stmt.kind {
123                let doc = extract_doc_above(&source_lines, stmt.line);
124                out.push_str(&format!("### `enum {}`\n\n", def.name));
125                if !doc.is_empty() {
126                    out.push_str(&doc);
127                    out.push_str("\n\n");
128                }
129                if !def.variants.is_empty() {
130                    out.push_str("Variants:\n");
131                    for v in &def.variants {
132                        out.push_str(&format!("- `{}`\n", v.name));
133                    }
134                    out.push('\n');
135                }
136            }
137        }
138    }
139
140    if !classes.is_empty() {
141        out.push_str("## Classes\n\n");
142        for stmt in &classes {
143            if let StmtKind::ClassDecl { def } = &stmt.kind {
144                let doc = extract_doc_above(&source_lines, stmt.line);
145                out.push_str(&format!("### `class {}`\n\n", def.name));
146                if !doc.is_empty() {
147                    out.push_str(&doc);
148                    out.push_str("\n\n");
149                }
150                if !def.fields.is_empty() {
151                    out.push_str("Fields:\n");
152                    for f in &def.fields {
153                        out.push_str(&format!("- `{}`\n", f.name));
154                    }
155                    out.push('\n');
156                }
157            }
158        }
159    }
160
161    if !subs.is_empty() {
162        out.push_str("## Subroutines\n\n");
163        for stmt in &subs {
164            if let StmtKind::SubDecl { name, params, .. } = &stmt.kind {
165                let doc = extract_doc_above(&source_lines, stmt.line);
166                let sig = format_sub_signature(name, params);
167                out.push_str(&format!("### `fn {}`\n\n", sig));
168                if !doc.is_empty() {
169                    out.push_str(&doc);
170                    out.push_str("\n\n");
171                }
172            }
173        }
174    }
175
176    out
177}
178
179/// Pick a reasonable module title — the first `package Foo::Bar;`
180/// declaration, or fall back to the file's basename.
181fn derive_module_title(filename: &str, program: &Program) -> String {
182    for stmt in &program.statements {
183        if let StmtKind::Package { name } = &stmt.kind {
184            return name.clone();
185        }
186    }
187    std::path::Path::new(filename)
188        .file_stem()
189        .and_then(|s| s.to_str())
190        .unwrap_or(filename)
191        .to_string()
192}
193
194/// Collect doc-comment text immediately above an AST line. Walks
195/// upward from `decl_line - 1` (AST is 1-based; the line ABOVE the
196/// decl is `decl_line - 1` in 1-based, or `decl_line - 2` in 0-based
197/// indexing into `source_lines`). Stops at the first non-`##` line.
198fn extract_doc_above(source_lines: &[&str], decl_line_1based: usize) -> String {
199    if decl_line_1based < 2 {
200        return String::new();
201    }
202    let mut collected: Vec<String> = Vec::new();
203    let mut i = decl_line_1based.saturating_sub(2); // 0-based line above
204    loop {
205        let line = source_lines.get(i).copied().unwrap_or("");
206        let trimmed = line.trim_start();
207        if let Some(rest) = trimmed.strip_prefix("## ") {
208            collected.push(rest.to_string());
209        } else if trimmed == "##" {
210            collected.push(String::new());
211        } else {
212            break;
213        }
214        if i == 0 {
215            break;
216        }
217        i -= 1;
218    }
219    collected.reverse();
220    collected.join("\n")
221}
222
223/// Doc block at the very top of the file (before any code), used as
224/// the module-level description.
225fn leading_module_doc(source_lines: &[&str]) -> Option<String> {
226    let mut collected: Vec<String> = Vec::new();
227    let mut i = 0usize;
228    // Skip shebang if present.
229    if let Some(line) = source_lines.first() {
230        if line.starts_with("#!") {
231            i = 1;
232        }
233    }
234    while i < source_lines.len() {
235        let line = source_lines[i];
236        let trimmed = line.trim_start();
237        if let Some(rest) = trimmed.strip_prefix("## ") {
238            collected.push(rest.to_string());
239        } else if trimmed == "##" {
240            collected.push(String::new());
241        } else if trimmed.is_empty() {
242            // Blank line between leading doc and code — stop.
243            if !collected.is_empty() {
244                break;
245            }
246        } else {
247            break;
248        }
249        i += 1;
250    }
251    if collected.is_empty() {
252        None
253    } else {
254        Some(collected.join("\n"))
255    }
256}
257
258/// Format a sub signature like `name($a, $b)` for the Markdown
259/// heading. Falls back to bare `name` when there are no signature
260/// params.
261fn format_sub_signature(name: &str, params: &[SubSigParam]) -> String {
262    if params.is_empty() {
263        return name.to_string();
264    }
265    let parts: Vec<String> = params
266        .iter()
267        .map(|p| match p {
268            SubSigParam::Scalar(n, _, _) => format!("${}", n),
269            SubSigParam::Array(n, _) => format!("@{}", n),
270            SubSigParam::Hash(n, _) => format!("%{}", n),
271            SubSigParam::ArrayDestruct(_) => "[…]".to_string(),
272            SubSigParam::HashDestruct(_) => "{…}".to_string(),
273        })
274        .collect();
275    format!("{}({})", name, parts.join(", "))
276}
277
278/// Pull constant names out of `use constant`'s imports list — mirrors
279/// `vm_helper::apply_use_constant`'s shape detection.
280fn extract_constant_names(imports: &[crate::ast::Expr]) -> Vec<String> {
281    let mut names: Vec<String> = Vec::new();
282    for imp in imports {
283        match &imp.kind {
284            crate::ast::ExprKind::List(items) => {
285                let mut i = 0;
286                while i + 1 < items.len() {
287                    if let Some(n) = constant_name_of(&items[i]) {
288                        names.push(n);
289                    }
290                    i += 2;
291                }
292            }
293            crate::ast::ExprKind::HashRef(pairs) => {
294                for (k, _) in pairs {
295                    if let Some(n) = constant_name_of(k) {
296                        names.push(n);
297                    }
298                }
299            }
300            _ => {
301                if let Some(n) = constant_name_of(imp) {
302                    names.push(n);
303                }
304            }
305        }
306    }
307    names
308}
309
310fn constant_name_of(e: &crate::ast::Expr) -> Option<String> {
311    match &e.kind {
312        crate::ast::ExprKind::String(s) => Some(s.clone()),
313        crate::ast::ExprKind::Bareword(s) => Some(s.clone()),
314        _ => None,
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::ast::{Expr, ExprKind, Statement, StmtKind};
322
323    fn expr(kind: ExprKind) -> Expr {
324        Expr { kind, line: 1 }
325    }
326
327    fn pkg_stmt(name: &str) -> Statement {
328        Statement::new(
329            StmtKind::Package {
330                name: name.to_string(),
331            },
332            1,
333        )
334    }
335
336    // ─── format_sub_signature ────────────────────────────────────────────
337
338    #[test]
339    fn format_sub_signature_no_params_returns_bare_name() {
340        assert_eq!(format_sub_signature("foo", &[]), "foo");
341    }
342
343    #[test]
344    fn format_sub_signature_scalar_array_hash_sigils() {
345        let params = vec![
346            SubSigParam::Scalar("a".into(), None, None),
347            SubSigParam::Array("xs".into(), None),
348            SubSigParam::Hash("h".into(), None),
349        ];
350        assert_eq!(format_sub_signature("f", &params), "f($a, @xs, %h)");
351    }
352
353    #[test]
354    fn format_sub_signature_destructure_placeholders() {
355        let params = vec![
356            SubSigParam::ArrayDestruct(vec![]),
357            SubSigParam::HashDestruct(vec![]),
358        ];
359        // Destructure params are rendered as ellipsis placeholders.
360        assert_eq!(format_sub_signature("g", &params), "g([…], {…})");
361    }
362
363    // ─── derive_module_title ─────────────────────────────────────────────
364
365    #[test]
366    fn derive_module_title_prefers_first_package_declaration() {
367        let prog = Program {
368            statements: vec![pkg_stmt("My::Mod"), pkg_stmt("Other")],
369        };
370        assert_eq!(derive_module_title("/tmp/x.stk", &prog), "My::Mod");
371    }
372
373    #[test]
374    fn derive_module_title_falls_back_to_file_stem() {
375        let prog = Program { statements: vec![] };
376        assert_eq!(derive_module_title("/tmp/my_file.stk", &prog), "my_file");
377    }
378
379    #[test]
380    fn derive_module_title_no_extension_uses_full_basename() {
381        let prog = Program { statements: vec![] };
382        assert_eq!(derive_module_title("/tmp/Makefile", &prog), "Makefile");
383    }
384
385    // ─── extract_doc_above ───────────────────────────────────────────────
386
387    #[test]
388    fn extract_doc_above_picks_up_consecutive_hash_hash_lines() {
389        let src = vec!["## line one", "## line two", "fn foo {}"];
390        // decl is on line 3 (1-based) → walks up from line 2.
391        let r = extract_doc_above(&src, 3);
392        assert_eq!(r, "line one\nline two");
393    }
394
395    #[test]
396    fn extract_doc_above_stops_at_non_doc_line() {
397        let src = vec!["## kept", "fn bar {}", "## not for next", "fn foo {}"];
398        let r = extract_doc_above(&src, 4);
399        // Line 3 is "## not for next", line 2 is `fn bar {}` → only line 3 collected.
400        assert_eq!(r, "not for next");
401    }
402
403    #[test]
404    fn extract_doc_above_returns_empty_when_decl_is_line_one() {
405        // Nothing above line 1; helper guards against decl_line < 2.
406        assert_eq!(extract_doc_above(&["fn foo {}"], 1), "");
407    }
408
409    #[test]
410    fn extract_doc_above_bare_double_hash_yields_blank_line() {
411        let src = vec!["## first", "##", "## third", "fn foo {}"];
412        let r = extract_doc_above(&src, 4);
413        assert_eq!(r, "first\n\nthird");
414    }
415
416    // ─── leading_module_doc ──────────────────────────────────────────────
417
418    #[test]
419    fn leading_module_doc_collects_top_block() {
420        let src = vec!["## module doc", "## second line", "", "fn x {}"];
421        assert_eq!(
422            leading_module_doc(&src),
423            Some("module doc\nsecond line".into())
424        );
425    }
426
427    #[test]
428    fn leading_module_doc_skips_shebang() {
429        let src = vec!["#!/usr/bin/env stryke", "## after shebang", "", "fn x {}"];
430        assert_eq!(leading_module_doc(&src), Some("after shebang".into()));
431    }
432
433    #[test]
434    fn leading_module_doc_returns_none_if_starts_with_code() {
435        let src = vec!["fn x {}", "## not module doc"];
436        assert!(leading_module_doc(&src).is_none());
437    }
438
439    // ─── extract_constant_names / constant_name_of ───────────────────────
440
441    #[test]
442    fn extract_constant_names_from_list_takes_keys_only() {
443        // `use constant ( FOO => 1, BAR => 2 )` → ["FOO", "BAR"]
444        let imports = vec![expr(ExprKind::List(vec![
445            expr(ExprKind::Bareword("FOO".into())),
446            expr(ExprKind::Integer(1)),
447            expr(ExprKind::Bareword("BAR".into())),
448            expr(ExprKind::Integer(2)),
449        ]))];
450        assert_eq!(extract_constant_names(&imports), vec!["FOO", "BAR"]);
451    }
452
453    #[test]
454    fn extract_constant_names_from_hashref_takes_keys() {
455        let imports = vec![expr(ExprKind::HashRef(vec![
456            (
457                expr(ExprKind::String("PI".into())),
458                expr(ExprKind::Float(3.14)),
459            ),
460            (
461                expr(ExprKind::Bareword("E".into())),
462                expr(ExprKind::Float(2.71)),
463            ),
464        ]))];
465        assert_eq!(extract_constant_names(&imports), vec!["PI", "E"]);
466    }
467
468    #[test]
469    fn constant_name_of_only_accepts_string_or_bareword() {
470        assert_eq!(
471            constant_name_of(&expr(ExprKind::String("X".into()))),
472            Some("X".into())
473        );
474        assert_eq!(
475            constant_name_of(&expr(ExprKind::Bareword("Y".into()))),
476            Some("Y".into())
477        );
478        // Integer is not a name → None.
479        assert_eq!(constant_name_of(&expr(ExprKind::Integer(7))), None);
480    }
481
482    // ─── generate_markdown (integration) ─────────────────────────────────
483
484    #[test]
485    fn generate_markdown_emits_header_with_module_title() {
486        let prog = Program { statements: vec![] };
487        let md = generate_markdown("/some/path/foo.stk", "", &prog);
488        assert!(md.starts_with("# Module: foo\n\n"), "got: {md:?}");
489    }
490
491    #[test]
492    fn generate_markdown_includes_packages_section_when_present() {
493        let prog = Program {
494            statements: vec![pkg_stmt("My::Pkg")],
495        };
496        let md = generate_markdown("anon.stk", "", &prog);
497        // Title pulled from first package; Packages section also rendered.
498        assert!(md.contains("# Module: My::Pkg"));
499        assert!(md.contains("## Packages"));
500        assert!(md.contains("### `package My::Pkg`"));
501    }
502
503    #[test]
504    fn generate_markdown_no_subs_skips_subroutines_section() {
505        let prog = Program { statements: vec![] };
506        let md = generate_markdown("x.stk", "", &prog);
507        assert!(!md.contains("## Subroutines"));
508    }
509}