use anyhow::Result;
use clap::{ArgAction, Parser, Subcommand};
use log::{info, warn};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use sphinx_ultra::{analyze_project, BuildConfig, SphinxBuilder};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(short, long, global = true)]
config: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Commands {
Build {
#[arg(short, long, default_value = ".")]
source: PathBuf,
#[arg(short, long, default_value = "_build")]
output: PathBuf,
#[arg(short, long)]
jobs: Option<usize>,
#[arg(long)]
clean: bool,
#[arg(long)]
incremental: bool,
#[arg(short = 'W', long)]
fail_on_warning: bool,
#[arg(short = 'w', long)]
warning_file: Option<PathBuf>,
},
Clean {
#[arg(short, long, default_value = "_build")]
output: PathBuf,
},
Stats {
#[arg(short, long, default_value = ".")]
source: PathBuf,
},
}
#[derive(Parser)]
#[command(
name = "sphinx-ultra",
version,
about = "sphinx-build compatible mode",
long_about = None
)]
struct SphinxBuildCli {
sourcedir: PathBuf,
outputdir: PathBuf,
filenames: Vec<PathBuf>,
#[arg(short = 'b', long = "builder", default_value = "html")]
builder: String,
#[arg(short = 'M', value_name = "MODE")]
make_mode: Option<String>,
#[arg(short = 'c', long = "conf-dir", value_name = "PATH")]
confdir: Option<PathBuf>,
#[arg(short = 'd', long = "doctree-dir", value_name = "PATH")]
doctreedir: Option<PathBuf>,
#[arg(short = 'D', value_name = "setting=value", value_parser = parse_key_val, action = ArgAction::Append)]
define: Vec<(String, String)>,
#[arg(short = 'A', value_name = "name=value", value_parser = parse_key_val, action = ArgAction::Append)]
html_define: Vec<(String, String)>,
#[arg(short = 't', long = "tag", value_name = "TAG", action = ArgAction::Append)]
tags: Vec<String>,
#[arg(short = 'n', long = "nitpicky")]
nitpicky: bool,
#[arg(short = 'q', long = "quiet")]
quiet: bool,
#[arg(short = 'E', long = "fresh-env")]
fresh_env: bool,
#[arg(short = 'a', long = "write-all")]
write_all: bool,
#[arg(short = 'T', long = "show-traceback")]
traceback: bool,
#[arg(short = 'j', long = "jobs", value_name = "N", value_parser = parse_jobs)]
jobs: Option<usize>,
#[arg(short = 'W', long = "fail-on-warning")]
fail_on_warning: bool,
#[arg(long = "keep-going")]
keep_going: bool,
#[arg(short = 'w', long = "warning-file", value_name = "FILE")]
warning_file: Option<PathBuf>,
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
match s.split_once('=') {
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
_ => Err(format!("expected key=value, got '{s}'")),
}
}
fn parse_jobs(s: &str) -> Result<usize, String> {
if s == "auto" {
Ok(std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1))
} else {
s.parse::<usize>()
.map_err(|_| format!("expected a number or 'auto', got '{s}'"))
}
}
fn wants_sphinx_build_mode(args: &[String]) -> bool {
let first = match args.get(1) {
Some(f) => f.as_str(),
None => return false,
};
if matches!(
first,
"build" | "clean" | "stats" | "help" | "-h" | "--help" | "-V" | "--version"
) {
return false;
}
if !first.starts_with('-') {
return true;
}
if !matches!(first, "-v" | "--verbose" | "-c" | "--config") {
return true;
}
!args
.iter()
.skip(1)
.any(|a| matches!(a.as_str(), "build" | "clean" | "stats"))
}
fn init_logging(default_level: &str) {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.init();
}
struct RunArgs {
source: PathBuf,
output: PathBuf,
config_file: Option<PathBuf>,
confdir: Option<PathBuf>,
overrides: Vec<(String, String)>,
html_defines: Vec<(String, String)>,
tags: Vec<String>,
doctree_dir: Option<PathBuf>,
nitpicky: bool,
jobs: Option<usize>,
clean_first: bool,
incremental: bool,
fresh_env: bool,
fail_on_warning: bool,
warning_file: Option<PathBuf>,
print_final_location: bool,
}
impl RunArgs {
fn base(source: PathBuf, output: PathBuf) -> Self {
Self {
source,
output,
config_file: None,
confdir: None,
overrides: vec![],
html_defines: vec![],
tags: vec![],
doctree_dir: None,
nitpicky: false,
jobs: None,
clean_first: false,
incremental: false,
fresh_env: false,
fail_on_warning: false,
warning_file: None,
print_final_location: false,
}
}
}
async fn run_build(args: RunArgs) -> Result<i32> {
let mut config = if let Some(ref config_path) = args.config_file {
BuildConfig::from_file(config_path)?
} else if let Some(ref confdir) = args.confdir {
let conf_path = if confdir.is_dir() {
confdir.join("conf.py")
} else {
confdir.clone()
};
if !conf_path.exists() {
anyhow::bail!(
"config directory doesn't contain a conf.py file ({})",
confdir.display()
);
}
BuildConfig::from_file(&conf_path)?
} else {
BuildConfig::auto_detect(&args.source)?
};
let mut config_warnings: Vec<String> = Vec::new();
for (key, value) in &args.overrides {
if let Some(message) = config.apply_override(key, value)? {
warn!("{}", message);
config_warnings.push(message);
}
}
for (key, value) in &args.html_defines {
config
.html_context
.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config.tags.extend(args.tags.iter().cloned());
if args.nitpicky {
config.nitpicky = true;
}
if args.doctree_dir.is_some() {
config.doctree_dir = args.doctree_dir.clone();
}
if args.fail_on_warning {
config.fail_on_warning = true;
}
let should_fail_on_warning = config.fail_on_warning;
let mut builder = SphinxBuilder::new(config, args.source, args.output.clone())?;
if let Some(jobs) = args.jobs {
builder.set_parallel_jobs(jobs);
}
if args.clean_first {
builder.clean().await?;
}
if args.fresh_env {
builder.fresh_env()?;
}
if args.incremental {
builder.enable_incremental();
}
let stats = builder.build().await?;
let mut warning_file_handle = if let Some(ref warning_file_path) = args.warning_file {
if let Some(parent) = warning_file_path.parent() {
std::fs::create_dir_all(parent)?;
}
Some(
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(warning_file_path)?,
)
} else {
None
};
if let Some(ref mut file) = warning_file_handle {
for message in &config_warnings {
writeln!(file, "WARNING: {}", message)?;
}
}
let total_warnings = stats.warnings + config_warnings.len();
for warning in &stats.warning_details {
let file_path = warning.file.display();
let line_info = if let Some(line) = warning.line {
format!(":{}", line)
} else {
String::new()
};
let warning_msg = format!("{}{}: WARNING: {}", file_path, line_info, warning.message);
if let Some(ref mut file) = warning_file_handle {
writeln!(file, "{}", warning_msg)?;
}
warn!("{}", warning_msg);
}
for error in &stats.error_details {
let file_path = error.file.display();
let line_info = if let Some(line) = error.line {
format!(":{}", line)
} else {
String::new()
};
let error_msg = format!("{}{}: ERROR: {}", file_path, line_info, error.message);
if let Some(ref mut file) = warning_file_handle {
writeln!(file, "{}", error_msg)?;
}
eprintln!("{}", error_msg);
}
if let Some(mut file) = warning_file_handle {
file.flush()?;
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
if should_fail_on_warning && total_warnings > 0 {
eprintln!(
"build finished with problems, {} warning{} (with warnings treated as errors).",
total_warnings,
plural(total_warnings)
);
return Ok(1);
}
if stats.errors > 0 {
eprintln!(
"build finished with problems, {} error{}{}.",
stats.errors,
plural(stats.errors),
if total_warnings > 0 {
format!(", {} warning{}", total_warnings, plural(total_warnings))
} else {
String::new()
}
);
return Ok(1);
}
if total_warnings > 0 {
warn!(
"build succeeded, {} warning{}.",
total_warnings,
plural(total_warnings)
);
}
info!("Build completed successfully!");
info!("Files processed: {}", stats.files_processed);
info!("Files skipped: {}", stats.files_skipped);
info!("Cache hits: {}", stats.cache_hits);
info!("Build time: {:?}", stats.build_time);
info!("Output size: {} MB", stats.output_size_mb);
if args.print_final_location {
println!("\nThe HTML pages are in {}.", args.output.display());
}
Ok(0)
}
async fn run_sphinx_build_mode(sb: SphinxBuildCli) -> i32 {
let default_level = if sb.quiet {
"warn"
} else {
match sb.verbose {
0 => "info",
1 => "debug",
_ => "trace",
}
};
init_logging(default_level);
if let Some(reason) = output_overlaps_source(&sb.sourcedir, &sb.outputdir) {
eprintln!("Error: {reason}");
return 1;
}
let mut make_mode_cache_dir = None;
let (output, is_build) = if let Some(ref mode) = sb.make_mode {
match mode.as_str() {
"html" => {
make_mode_cache_dir = Some(sb.outputdir.join(".sphinx-ultra-cache"));
(sb.outputdir.join("html"), true)
}
"clean" => {
if sb.outputdir.exists() {
println!("Removing everything under '{}'...", sb.outputdir.display());
if let Err(e) = remove_dir_contents(&sb.outputdir) {
eprintln!("Error: {e:#}");
return 2;
}
}
return 0;
}
other => {
eprintln!(
"make-mode target '{other}' is not supported yet — sphinx-ultra 0.4 supports 'html' and 'clean' only"
);
return 2;
}
}
} else {
if sb.builder != "html" {
eprintln!(
"builder '{}' is not supported yet — sphinx-ultra 0.4 supports 'html' only",
sb.builder
);
return 2;
}
(sb.outputdir.clone(), true)
};
debug_assert!(is_build);
if sb.confdir.is_none()
&& ![
"conf.py",
"sphinx-ultra.yaml",
"sphinx-ultra.yml",
"sphinx-ultra.json",
]
.iter()
.any(|f| sb.sourcedir.join(f).exists())
{
eprintln!(
"Error: config directory doesn't contain a conf.py file ({})",
sb.sourcedir.display()
);
return 2;
}
info!(
"Running sphinx-ultra v{} (sphinx-build compatible mode)",
env!("CARGO_PKG_VERSION")
);
if !sb.filenames.is_empty() {
warn!("building specific files is not supported yet; building the full project");
}
let incremental = !sb.write_all;
let args = RunArgs {
confdir: sb.confdir.clone(),
overrides: sb.define.clone(),
html_defines: sb.html_define.clone(),
tags: sb.tags.clone(),
doctree_dir: sb.doctreedir.clone().or(make_mode_cache_dir),
nitpicky: sb.nitpicky,
jobs: sb.jobs,
incremental,
fresh_env: sb.fresh_env,
fail_on_warning: sb.fail_on_warning,
warning_file: sb.warning_file.clone(),
print_final_location: !sb.quiet,
..RunArgs::base(sb.sourcedir.clone(), output)
};
match run_build(args).await {
Ok(code) => code,
Err(e) => {
if sb.traceback {
eprintln!("Error: {e:?}");
} else {
eprintln!("Error: {e:#}");
}
2
}
}
}
fn output_overlaps_source(source: &std::path::Path, output: &std::path::Path) -> Option<String> {
let source = source.canonicalize().ok()?;
let output = output.canonicalize().ok()?;
if source == output {
Some(format!(
"'{}' is same as source directory!",
output.display()
))
} else if source.starts_with(&output) {
Some(format!(
"'{}' directory contains source directory!",
output.display()
))
} else {
None
}
}
fn remove_dir_contents(dir: &std::path::Path) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let raw_args: Vec<String> = std::env::args().collect();
if wants_sphinx_build_mode(&raw_args) {
let sb = SphinxBuildCli::parse();
let code = run_sphinx_build_mode(sb).await;
std::process::exit(code);
}
let cli = Cli::parse();
init_logging(if cli.verbose { "debug" } else { "info" });
info!("Sphinx Ultra Builder v{}", env!("CARGO_PKG_VERSION"));
match cli.command {
Commands::Build {
source,
output,
jobs,
clean,
incremental,
fail_on_warning,
warning_file,
} => {
let code = run_build(RunArgs {
config_file: cli.config,
jobs,
clean_first: clean,
incremental,
fail_on_warning,
warning_file,
..RunArgs::base(source, output)
})
.await?;
if code != 0 {
std::process::exit(code);
}
}
Commands::Clean { output } => {
info!("Cleaning output directory: {}", output.display());
if output.exists() {
std::fs::remove_dir_all(&output)?;
info!("Clean completed");
} else {
warn!("Output directory does not exist");
}
}
Commands::Stats { source } => {
let stats = analyze_project(&source).await?;
println!("Project Statistics:");
println!(" Source files: {}", stats.source_files);
println!(" Total lines: {}", stats.total_lines);
println!(" Average file size: {} KB", stats.avg_file_size_kb);
println!(" Largest file: {} KB", stats.largest_file_kb);
println!(" Directory depth: {}", stats.max_depth);
println!(" Cross-references: {}", stats.cross_references);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(args: &[&str]) -> Vec<String> {
std::iter::once("sphinx-ultra")
.chain(args.iter().copied())
.map(String::from)
.collect()
}
#[test]
fn dispatch_native_subcommands() {
assert!(!wants_sphinx_build_mode(&argv(&["build", "-s", "docs"])));
assert!(!wants_sphinx_build_mode(&argv(&["clean"])));
assert!(!wants_sphinx_build_mode(&argv(&["stats"])));
assert!(!wants_sphinx_build_mode(&argv(&["--help"])));
assert!(!wants_sphinx_build_mode(&argv(&["-V"])));
assert!(!wants_sphinx_build_mode(&argv(&[])));
}
#[test]
fn dispatch_native_global_flags_before_subcommand() {
assert!(!wants_sphinx_build_mode(&argv(&[
"--config", "conf.py", "build", "-s", "docs"
])));
assert!(!wants_sphinx_build_mode(&argv(&["--verbose", "build"])));
}
#[test]
fn dispatch_positional_paths_go_compat() {
assert!(wants_sphinx_build_mode(&argv(&["docs", "_build"])));
assert!(wants_sphinx_build_mode(&argv(&["./build", "out"])));
assert!(wants_sphinx_build_mode(&argv(&["/abs/src", "/abs/out"])));
}
#[test]
fn dispatch_compat_only_flags_win() {
assert!(wants_sphinx_build_mode(&argv(&[
"-M", "html", "src", "out"
])));
assert!(wants_sphinx_build_mode(&argv(&[
"-b", "html", "src", "out"
])));
assert!(wants_sphinx_build_mode(&argv(&[
"-M", "html", "src", "build"
])));
}
#[test]
fn dispatch_nonglobal_leading_flag_is_compat_even_with_build_positional() {
assert!(wants_sphinx_build_mode(&argv(&["-W", "docs", "build"])));
assert!(wants_sphinx_build_mode(&argv(&[
"-w", "log", "docs", "build"
])));
assert!(wants_sphinx_build_mode(&argv(&[
"-j", "2", "docs", "stats"
])));
}
#[test]
fn jobs_parser_accepts_auto_and_numbers() {
assert!(parse_jobs("auto").unwrap() >= 1);
assert_eq!(parse_jobs("4").unwrap(), 4);
assert!(parse_jobs("many").is_err());
}
#[test]
fn key_val_parser() {
assert_eq!(
parse_key_val("a=b").unwrap(),
("a".to_string(), "b".to_string())
);
assert_eq!(
parse_key_val("exclude_patterns=a,b").unwrap(),
("exclude_patterns".to_string(), "a,b".to_string())
);
assert!(parse_key_val("novalue").is_err());
assert!(parse_key_val("=x").is_err());
}
}