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}