use std::path::Path;
pub fn generate_markdown(filename: &str, source: &str) -> String {
let source_lines: Vec<&str> = source.lines().collect();
let module_title = derive_module_title(filename);
let mut out = String::new();
out.push_str("# Module: ");
out.push_str(&module_title);
out.push_str("\n\n");
if let Some(header) = leading_module_doc(&source_lines) {
out.push_str(&header);
out.push_str("\n\n");
}
let mut funcs: Vec<FuncDecl> = Vec::new();
let mut aliases: Vec<AliasDecl> = Vec::new();
let mut exports: Vec<ExportDecl> = Vec::new();
for (i, line) in source_lines.iter().enumerate() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("function ").or_else(|| t.strip_prefix("function\t")) {
if let Some(name) = first_fn_name(rest) {
funcs.push(FuncDecl {
name,
decl_line: i + 1,
});
continue;
}
}
if let Some(idx) = t.find("()") {
let head = &t[..idx];
if is_simple_identifier(head)
&& !head.is_empty()
&& line
.chars()
.take_while(|c| c.is_whitespace())
.next()
.map(|c| c == ' ' || c == '\t')
.unwrap_or(true)
{
funcs.push(FuncDecl {
name: head.to_string(),
decl_line: i + 1,
});
continue;
}
}
if let Some(rest) = t.strip_prefix("alias ") {
let rest = rest.trim_start_matches("-g ").trim_start_matches("-s ");
if let Some(eq) = rest.find('=') {
let name = rest[..eq].trim().to_string();
if !name.is_empty() {
aliases.push(AliasDecl {
name,
rhs: rest[eq + 1..].trim().to_string(),
decl_line: i + 1,
});
continue;
}
}
}
for verb in &["export ", "readonly ", "typeset -gx "] {
if let Some(rest) = t.strip_prefix(verb) {
if let Some(eq) = rest.find('=') {
let name = rest[..eq].trim().to_string();
if !name.is_empty() {
exports.push(ExportDecl {
name,
verb: (*verb).trim().to_string(),
decl_line: i + 1,
});
}
}
}
}
}
if !funcs.is_empty() {
out.push_str("## Functions\n\n");
for f in &funcs {
out.push_str(&format!("### `{}`\n\n", f.name));
let doc = extract_doc_above(&source_lines, f.decl_line);
if !doc.is_empty() {
out.push_str(&doc);
out.push_str("\n\n");
} else {
out.push_str("_Defined at line ");
out.push_str(&f.decl_line.to_string());
out.push_str("._\n\n");
}
}
}
if !aliases.is_empty() {
out.push_str("## Aliases\n\n");
for a in &aliases {
out.push_str(&format!("- `{}` = `{}`\n", a.name, a.rhs));
}
out.push('\n');
}
if !exports.is_empty() {
out.push_str("## Exports / Constants\n\n");
for e in &exports {
out.push_str(&format!("- `{}` ({}) — line {}\n", e.name, e.verb, e.decl_line));
}
out.push('\n');
}
if funcs.is_empty() && aliases.is_empty() && exports.is_empty() {
out.push_str("_No public-API decls found in this file._\n");
}
out
}
struct FuncDecl {
name: String,
decl_line: usize,
}
struct AliasDecl {
name: String,
rhs: String,
decl_line: usize,
}
struct ExportDecl {
name: String,
verb: String,
decl_line: usize,
}
fn derive_module_title(filename: &str) -> String {
Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| filename.to_string())
}
fn leading_module_doc(lines: &[&str]) -> Option<String> {
let mut buf = Vec::new();
for l in lines {
let t = l.trim_start();
if t.is_empty() {
if !buf.is_empty() {
return Some(buf.join("\n"));
}
continue;
}
if let Some(body) = t.strip_prefix("##") {
let body = body.strip_prefix(' ').unwrap_or(body);
buf.push(body.to_string());
continue;
}
if t.starts_with("#!") || t.starts_with('#') {
continue;
}
break;
}
if buf.is_empty() {
None
} else {
Some(buf.join("\n"))
}
}
fn extract_doc_above(lines: &[&str], decl_line_1based: usize) -> String {
let mut buf: Vec<String> = Vec::new();
let mut i = decl_line_1based.saturating_sub(1);
while i > 0 {
i -= 1;
let t = lines[i].trim_start();
if let Some(body) = t.strip_prefix("##") {
let body = body.strip_prefix(' ').unwrap_or(body);
buf.push(body.to_string());
continue;
}
break;
}
buf.reverse();
buf.join("\n")
}
fn first_fn_name(rest: &str) -> Option<String> {
let s = rest.trim_start();
let end = s
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':'))
.unwrap_or(s.len());
if end == 0 {
None
} else {
Some(s[..end].to_string())
}
}
fn is_simple_identifier(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':' || c == '+' || c == '@')
}
pub fn collect_doc_sources(root: &Path, out: &mut Vec<std::path::PathBuf>) {
if root.is_file() {
if is_shell_source(root) {
out.push(root.to_path_buf());
}
return;
}
let Ok(entries) = std::fs::read_dir(root) else { return };
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if matches!(name, ".git" | "node_modules" | "target" | "build" | "dist") {
continue;
}
if p.is_dir() {
collect_doc_sources(&p, out);
} else if is_shell_source(&p) {
out.push(p);
}
}
}
fn is_shell_source(p: &Path) -> bool {
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if matches!(
name,
".zshrc" | ".zshenv" | ".zlogin" | ".zlogout" | ".zprofile" | ".zpreztorc"
| ".bashrc" | ".bash_profile" | ".profile"
) {
return true;
}
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
matches!(ext, "zsh" | "sh" | "bash" | "ksh")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_function_decls_and_doc_comments() {
let src = "## Top-level header doc for the module.\n\
## Spans multiple lines.\n\
\n\
## Greet someone.\n\
## Prints `hello NAME` to stdout.\n\
greet() {\n\
\techo hello $1\n\
}\n\
\n\
alias ll='ls -al'\n\
export FOO=bar\n";
let md = generate_markdown("greet.zsh", src);
assert!(md.contains("# Module: greet"), "missing module title: {}", md);
assert!(md.contains("Top-level header doc"), "missing header doc: {}", md);
assert!(md.contains("### `greet`"), "missing function entry: {}", md);
assert!(md.contains("Greet someone"), "missing per-fn doc: {}", md);
assert!(md.contains("- `ll` = `'ls -al'`"), "missing alias: {}", md);
assert!(md.contains("- `FOO` (export)"), "missing export: {}", md);
}
#[test]
fn empty_file_yields_no_decls_marker() {
let md = generate_markdown("empty.zsh", "");
assert!(md.contains("No public-API decls"), "{}", md);
}
#[test]
fn function_keyword_form_is_recognized() {
let src = "function foo {\n echo hi\n}\n";
let md = generate_markdown("foo.zsh", src);
assert!(md.contains("### `foo`"), "{}", md);
}
}