use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser as ClapParser, Subcommand, ValueEnum};
use colored::Colorize;
use walkdir::WalkDir;
use codoc::parser::ruby::RubyParser;
use codoc::parser::typescript::TypeScriptParser;
use codoc::parser::{ParseContext, Parser, ParserConfig};
use codoc::schema::Language;
#[derive(ClapParser)]
#[command(name = "codoc")]
#[command(about = "Unified code documentation parser for Ruby and TypeScript")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Parse {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long, value_enum)]
language: LanguageArg,
#[arg(short, long, default_value = "project")]
name: String,
#[arg(long)]
version: Option<String>,
#[arg(long)]
id_prefix: Option<String>,
#[arg(long, default_value = "true")]
pretty: bool,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum LanguageArg {
Ruby,
Typescript,
}
impl From<LanguageArg> for Language {
fn from(arg: LanguageArg) -> Self {
match arg {
LanguageArg::Ruby => Language::Ruby,
LanguageArg::Typescript => Language::TypeScript,
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Parse {
path,
output,
language,
name,
version,
id_prefix,
pretty,
} => {
let (document, stats) = parse_sources(&path, language, &name, version, id_prefix)?;
let json = if pretty {
serde_json::to_string_pretty(&document)?
} else {
serde_json::to_string(&document)?
};
match output {
Some(output_path) => {
fs::write(&output_path, &json)
.with_context(|| format!("Failed to write to {}", output_path.display()))?;
print_summary(&stats, Some(&output_path));
}
None => {
println!("{}", json);
}
}
}
}
Ok(())
}
struct ParseStats {
files_parsed: usize,
files_failed: usize,
entities_found: usize,
symbols_found: usize,
}
fn print_summary(stats: &ParseStats, output_path: Option<&Path>) {
eprintln!();
eprintln!("{}", "Parsing complete".green().bold());
eprintln!(" {} {} parsed", "Files:".cyan(), stats.files_parsed);
if stats.files_failed > 0 {
eprintln!(
" {} {} files failed",
"Warnings:".yellow(),
stats.files_failed
);
}
eprintln!(" {} {}", "Entities:".cyan(), stats.entities_found);
eprintln!(" {} {}", "Symbols:".cyan(), stats.symbols_found);
if let Some(path) = output_path {
eprintln!(" {} {}", "Output:".cyan(), path.display());
}
}
fn parse_sources(
path: &Path,
language: LanguageArg,
name: &str,
version: Option<String>,
id_prefix: Option<String>,
) -> Result<(codoc::Document, ParseStats)> {
let source_root = if path.is_dir() {
path.to_path_buf()
} else {
path.parent().unwrap_or(Path::new(".")).to_path_buf()
};
let mut config = ParserConfig::new(name, language.into())
.with_source_root(source_root.to_string_lossy().to_string());
if let Some(ver) = version {
config = config.with_version(ver);
}
if let Some(prefix) = id_prefix {
config = config.with_id_prefix(prefix);
}
let mut ctx = ParseContext::new(config);
let files = collect_files(path, language)?;
let mut parser: Box<dyn Parser> = match language {
LanguageArg::Ruby => Box::new(RubyParser::new()?),
LanguageArg::Typescript => Box::new(TypeScriptParser::new()?),
};
let mut files_parsed = 0;
let mut files_failed = 0;
for file_path in &files {
let content = fs::read_to_string(file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let relative = file_path
.strip_prefix(&source_root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
match parser.parse_file(file_path, &content) {
Ok(entities) => {
let symbols = parser.generate_symbols(&entities, None);
ctx.symbols.extend(symbols);
ctx.entities.extend(entities);
ctx.files.push(relative);
files_parsed += 1;
}
Err(e) => {
eprintln!(
"{} {} {}",
"warning:".yellow().bold(),
"Failed to parse".yellow(),
file_path.display()
);
eprintln!(" {}", e.to_string().dimmed());
files_failed += 1;
}
}
}
let stats = ParseStats {
files_parsed,
files_failed,
entities_found: ctx.entities.len(),
symbols_found: ctx.symbols.len(),
};
Ok((ctx.into_document(), stats))
}
fn collect_files(path: &Path, language: LanguageArg) -> Result<Vec<PathBuf>> {
let extensions: &[&str] = match language {
LanguageArg::Ruby => &["rb"],
LanguageArg::Typescript => &["ts", "tsx"],
};
let mut files = Vec::new();
if path.is_file() {
files.push(path.to_path_buf());
} else {
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file() {
if let Some(ext) = entry_path.extension() {
if extensions.contains(&ext.to_string_lossy().as_ref()) {
let path_str = entry_path.to_string_lossy();
if !path_str.contains("node_modules")
&& !path_str.contains("/.")
&& !path_str.contains("\\.")
{
files.push(entry_path.to_path_buf());
}
}
}
}
}
}
files.sort();
Ok(files)
}