use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use heck::ToPascalCase;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
mod context;
mod descriptions;
pub mod doc_cleaning;
mod enum_variant_ref;
mod examples;
mod formatting;
pub(crate) mod language_pages;
pub(crate) mod naming;
mod render;
mod rust_static;
pub(crate) mod rust_types;
mod shared_pages;
mod signatures;
pub(crate) mod snippet_summary;
mod sorting;
pub(crate) mod template_env;
#[cfg(test)]
mod tests;
mod type_mapping;
mod version_labels;
pub(crate) use snippet_summary::enforce_snippet_summary;
#[cfg(test)]
pub(crate) mod test_helpers;
pub use doc_cleaning::clean_doc;
pub use type_mapping::doc_type;
pub use context::{CliSurface, DocsRenderContext, McpSurface};
pub(crate) use render::with_html_header;
fn canonical_docs_api(api: &ApiSurface, config: &ResolvedCrateConfig) -> ApiSurface {
let mut canonical_features: HashSet<String> = HashSet::new();
let mut has_configured_language = false;
for &lang in &config.languages {
if matches!(lang, Language::C | Language::Jni) {
continue;
}
has_configured_language = true;
canonical_features.extend(language_pages::effective_docs_features(api, config, lang));
}
if !has_configured_language {
return api.clone();
}
let enabled_features: HashSet<&str> = canonical_features.iter().map(String::as_str).collect();
api.with_cfg_filtered_deep(&enabled_features)
}
pub fn generate_docs(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
output_dir: &str,
) -> anyhow::Result<Vec<GeneratedFile>> {
let mut files = Vec::new();
let ffi_prefix = &config.ffi_prefix().to_pascal_case();
for &lang in languages {
if matches!(lang, Language::C | Language::Jni) {
continue;
}
files.push(language_pages::generate_lang_doc(
api, config, lang, output_dir, ffi_prefix,
)?);
}
let canonical_api = &canonical_docs_api(api, config);
files.push(shared_pages::generate_configuration_doc(
canonical_api,
config,
output_dir,
)?);
files.push(shared_pages::generate_types_doc(canonical_api, config, output_dir)?);
files.push(shared_pages::generate_errors_doc(canonical_api, output_dir)?);
for file in &mut files {
file.content = doc_cleaning::wrap_bare_urls(&file.content);
if !file.content.ends_with('\n') {
file.content.push('\n');
}
}
Ok(files)
}
pub fn reference_output_dir(config: &ResolvedCrateConfig) -> PathBuf {
config
.docs
.as_ref()
.and_then(|docs| docs.reference_output.clone())
.unwrap_or_else(|| PathBuf::from("docs/reference"))
}
pub fn generate_docs_stage(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
output_override: Option<&str>,
workspace_root: &Path,
) -> (Vec<GeneratedFile>, anyhow::Result<()>) {
generate_docs_stage_impl(api, config, languages, output_override, workspace_root, true)
}
pub fn generate_docs_stage_without_snippet_compile_validation(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
output_override: Option<&str>,
workspace_root: &Path,
) -> (Vec<GeneratedFile>, anyhow::Result<()>) {
generate_docs_stage_impl(api, config, languages, output_override, workspace_root, false)
}
fn generate_docs_stage_impl(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
output_override: Option<&str>,
workspace_root: &Path,
run_snippet_compile_validation: bool,
) -> (Vec<GeneratedFile>, anyhow::Result<()>) {
let reference_output = output_override
.map(PathBuf::from)
.unwrap_or_else(|| reference_output_dir(config));
let reference_output_str = reference_output.to_string_lossy().to_string();
let mut files = match generate_docs(api, config, languages, &reference_output_str) {
Ok(files) => files,
Err(err) => return (Vec::new(), Err(err)),
};
for file in &mut files {
file.content = with_markdown_alef_header(&file.content);
file.generated_header = true;
}
let result = generate_docs_stage_extras(
api,
config,
languages,
workspace_root,
&reference_output,
&mut files,
run_snippet_compile_validation,
);
for file in &mut files {
file.content = doc_cleaning::wrap_bare_urls(&file.content);
if !file.content.ends_with('\n') {
file.content.push('\n');
}
}
(files, result)
}
fn emit_page(
files: &mut Vec<GeneratedFile>,
context: &mut DocsRenderContext,
file: GeneratedFile,
reference: Option<(&str, &str)>,
) {
if let Some((kind, title)) = reference {
context.references.push(context::ReferenceDoc {
kind: kind.to_string(),
title: title.to_string(),
path: file.path.to_string_lossy().to_string(),
});
}
files.push(file);
}
fn generate_docs_stage_extras(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
workspace_root: &Path,
reference_output: &Path,
files: &mut Vec<GeneratedFile>,
run_snippet_compile_validation: bool,
) -> anyhow::Result<()> {
let mut context = build_base_context(api, config, languages, files.as_slice());
let Some(docs_cfg) = &config.docs else {
return Ok(());
};
if let Some(cli_cfg) = &docs_cfg.cli
&& cli_cfg.is_enabled()
{
let explicit_sources = !cli_cfg.sources.is_empty();
let sources = docs_sources(config, &cli_cfg.sources, workspace_root);
warn_missing_explicit_sources("CLI", &cli_cfg.sources, workspace_root);
let surface = rust_static::extract_cli_surface(&sources)?;
if surface.commands.is_empty() {
if explicit_sources {
tracing::warn!("docs.cli was configured but no clap commands were discovered");
}
} else {
let path = cli_cfg
.output
.clone()
.unwrap_or_else(|| reference_output.join("cli.md"));
render::ensure_managed_or_adopted(workspace_root, &path, cli_cfg.adopt_existing)?;
let page = render::generate_cli_doc(&surface, path);
emit_page(files, &mut context, page, Some(("cli", "CLI Reference")));
context.cli = surface;
}
}
if let Some(mcp_cfg) = &docs_cfg.mcp
&& mcp_cfg.is_enabled()
{
let explicit_sources = !mcp_cfg.sources.is_empty();
let sources = docs_sources(config, &mcp_cfg.sources, workspace_root);
warn_missing_explicit_sources("MCP", &mcp_cfg.sources, workspace_root);
let surface = rust_static::extract_mcp_surface(&sources, &mcp_cfg.declared)?;
if surface.tools.is_empty() && surface.prompts.is_empty() && surface.resources.is_empty() {
if explicit_sources {
tracing::warn!("docs.mcp was configured but no rmcp tools, prompts, or resources were discovered");
}
} else {
let path = mcp_cfg
.output
.clone()
.unwrap_or_else(|| reference_output.join("mcp.md"));
render::ensure_managed_or_adopted(workspace_root, &path, mcp_cfg.adopt_existing)?;
let page = render::generate_mcp_doc(&surface, path);
emit_page(files, &mut context, page, Some(("mcp", "MCP Reference")));
context.mcp = surface;
}
}
let snippets = build_snippet_context(config, workspace_root, &mut context)?;
let snippet_dirs: &[PathBuf] = snippets.as_ref().map_or(&[], |stage| stage.dirs.as_slice());
if let Some(llms_cfg) = &docs_cfg.llms {
let page = render::render_llms(llms_cfg, &context, workspace_root, snippet_dirs)?;
emit_page(files, &mut context, page, None);
}
if let Some(skills_cfg) = &docs_cfg.skills {
let pages = render::render_skills(skills_cfg, &context, workspace_root, snippet_dirs)?;
for page in pages {
emit_page(files, &mut context, page, None);
}
}
if let Some(stage) = &snippets {
validate_snippets(
config,
workspace_root,
stage.config,
&stage.absolute_dirs,
&stage.snippets,
run_snippet_compile_validation,
)?;
}
Ok(())
}
fn build_base_context(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
api_files: &[GeneratedFile],
) -> DocsRenderContext {
let description = config
.scaffold
.as_ref()
.and_then(|scaffold| scaffold.description.clone())
.unwrap_or_else(|| format!("Bindings for {}", config.name));
let license = config
.scaffold
.as_ref()
.and_then(|scaffold| scaffold.license.clone())
.unwrap_or_else(|| "MIT".to_string());
let api_references = api_files
.iter()
.map(|file| {
let path = file.path.to_string_lossy().to_string();
context::ReferenceDoc {
kind: "api".to_string(),
title: path
.rsplit('/')
.next()
.unwrap_or(path.as_str())
.trim_end_matches(".md")
.replace('-', " "),
path,
}
})
.collect::<Vec<_>>();
DocsRenderContext {
krate: context::CrateDocsContext {
name: config.name.clone(),
version: api.version.clone(),
description,
repository: config.github_repo(),
license,
},
languages: languages.iter().map(ToString::to_string).collect(),
references: api_references.clone(),
api_references,
..DocsRenderContext::default()
}
}
struct SnippetStage<'cfg> {
config: &'cfg crate::core::config::DocsSnippetsConfig,
dirs: Vec<PathBuf>,
absolute_dirs: Vec<PathBuf>,
snippets: Vec<crate::snippets::types::Snippet>,
}
fn build_snippet_context<'cfg>(
config: &'cfg ResolvedCrateConfig,
workspace_root: &Path,
context: &mut DocsRenderContext,
) -> anyhow::Result<Option<SnippetStage<'cfg>>> {
let Some(snippet_cfg) = config.docs.as_ref().and_then(|docs| docs.snippets.as_ref()) else {
return Ok(None);
};
for dir in snippet_cfg.dirs.iter().chain(&snippet_cfg.inline_dirs) {
let abs_dir = workspace_root.join(dir);
if !abs_dir.exists() {
anyhow::bail!(
"configured docs.snippets.dirs root '{}' (resolved to '{}') does not exist",
dir.display(),
abs_dir.display()
);
}
}
let snippet_dirs = snippet_cfg.dirs.clone();
let discovery_dirs = snippet_cfg
.dirs
.iter()
.chain(&snippet_cfg.inline_dirs)
.cloned()
.collect::<Vec<_>>();
if discovery_dirs.is_empty() {
if snippet_cfg.validation_level.is_some() || !snippet_cfg.required_languages.is_empty() {
tracing::warn!("docs.snippets is configured for validation but docs.snippets.dirs is empty");
}
return Ok(None);
}
let absolute_snippet_dirs = snippet_dirs
.iter()
.map(|dir| workspace_root.join(dir))
.collect::<Vec<_>>();
let absolute_discovery_dirs = discovery_dirs
.iter()
.map(|dir| workspace_root.join(dir))
.collect::<Vec<_>>();
let excluded = snippet_cfg
.exclude
.iter()
.map(|path| workspace_root.join(path))
.collect::<Vec<_>>();
let snippets = crate::snippets::discovery::discover_snippets(&absolute_discovery_dirs, None)?
.into_iter()
.filter(|snippet| !excluded.iter().any(|prefix| snippet.path.starts_with(prefix)))
.collect::<Vec<_>>();
let mut counts_by_language = BTreeMap::new();
for snippet in &snippets {
*counts_by_language.entry(snippet.language.to_string()).or_insert(0) += 1;
}
context.snippets = context::SnippetIndexContext {
dirs: snippet_dirs
.iter()
.map(|dir| dir.to_string_lossy().to_string())
.collect(),
snippets: snippets
.iter()
.map(|snippet| context::SnippetContext {
id: snippet.id.clone(),
path: snippet.path.to_string_lossy().to_string(),
language: snippet.language.to_string(),
title: snippet.title.clone(),
tags: snippet.metadata.tags.clone(),
})
.collect(),
counts_by_language,
};
Ok(Some(SnippetStage {
config: snippet_cfg,
dirs: snippet_dirs,
absolute_dirs: absolute_snippet_dirs,
snippets,
}))
}
fn validate_snippets(
config: &ResolvedCrateConfig,
workspace_root: &Path,
snippet_cfg: &crate::core::config::DocsSnippetsConfig,
absolute_snippet_dirs: &[PathBuf],
snippets: &[crate::snippets::types::Snippet],
run_snippet_compile_validation: bool,
) -> anyhow::Result<()> {
let docs_dirs = if snippet_cfg.docs_dirs.is_empty() {
Vec::new()
} else {
snippet_cfg
.docs_dirs
.iter()
.map(|dir| workspace_root.join(dir))
.collect::<Vec<_>>()
};
let include_base_paths = if snippet_cfg.include_base_paths.is_empty() {
docs_dirs.clone()
} else {
snippet_cfg
.include_base_paths
.iter()
.map(|dir| workspace_root.join(dir))
.collect::<Vec<_>>()
};
let exclude = snippet_cfg
.exclude
.iter()
.map(|path| workspace_root.join(path))
.collect::<Vec<_>>();
let mut configured_references =
crate::snippets::gaps::readme_snippet_references(workspace_root, config.readme.as_ref());
configured_references.extend(crate::snippets::gaps::coverage_ledger_references(
absolute_snippet_dirs,
)?);
let content_collections = snippet_cfg
.content_collections
.iter()
.map(|(name, root)| (name.clone(), workspace_root.join(root)))
.collect();
configured_references.extend(crate::snippets::gaps::astro_collection_references(
&docs_dirs,
&content_collections,
)?);
if !docs_dirs.is_empty() {
let audit_report = crate::snippets::audit::audit(&crate::snippets::audit::AuditConfig {
docs_dirs: docs_dirs.clone(),
snippet_dirs: absolute_snippet_dirs.to_vec(),
include_base_paths: include_base_paths.clone(),
configured_references: configured_references.clone(),
exclude: exclude.clone(),
require_frontmatter: snippet_cfg.require_frontmatter,
accounting: crate::snippets::audit::SnippetAccounting::default(),
});
if audit_report.has_errors() {
let summary = audit_report
.issues
.iter()
.take(8)
.map(|issue| format!("{}:{}: {}", issue.path.display(), issue.line, issue.message))
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!("snippet audit failed for crate `{}`:\n{summary}", config.name);
}
}
let required_languages = snippet_cfg
.required_languages
.iter()
.map(|lang| crate::snippets::types::resolve_required_language(lang))
.collect::<Result<Vec<_>, _>>()
.map_err(|err| anyhow::anyhow!("invalid docs.snippets.required_languages entry: {err}"))?;
if !docs_dirs.is_empty() || !required_languages.is_empty() {
let report = crate::snippets::gaps::detect_gaps(&crate::snippets::gaps::GapConfig {
docs_dirs,
snippet_dirs: absolute_snippet_dirs.to_vec(),
required_languages,
include_base_paths,
configured_references,
exclude,
})?;
if !report.unreferenced_snippets.is_empty() && snippet_cfg.strict {
anyhow::bail!(
"strict snippet coverage failed for crate `{}`: {} unreferenced snippet file(s)",
config.name,
report.unreferenced_snippets.len()
);
}
if !report.unreferenced_snippets.is_empty() {
tracing::warn!(
"docs.snippets found {} unreferenced snippet file(s)",
report.unreferenced_snippets.len()
);
}
if !report.missing_references.is_empty()
|| !report.missing_language_variants.is_empty()
|| !report.skips_without_reason.is_empty()
|| !report.unknown_languages.is_empty()
{
anyhow::bail!("snippet gap validation failed for crate `{}`", config.name);
}
}
if run_snippet_compile_validation && let Some(level) = &snippet_cfg.validation_level {
let level = level
.parse::<crate::snippets::types::ValidationLevel>()
.map_err(|err| anyhow::anyhow!("invalid docs.snippets.validation_level: {err}"))?;
tracing::info!(
crate_name = %config.name,
snippet_count = snippets.len(),
level = %level,
"starting snippet compile validation: spawns a real toolchain per session/snippet; pass \
--skip-snippet-validation for a generate-only run"
);
let mut runner_cfg = crate::snippets::runner::RunnerConfig {
level,
fail_fast: snippet_cfg.fail_fast,
deny_unclassified: snippet_cfg.deny_unclassified,
allowed_side_effects: parse_allowed_side_effects(&snippet_cfg.allowed_side_effects)?,
cache_dir: Some(workspace_root.join(snippet_cfg.cache_dir())),
changed_only: true,
sessions: snippet_cfg
.sessions
.iter()
.map(|(target, session)| {
let normalized = crate::snippets::types::Language::normalize_session_target(target);
let language = crate::snippets::types::Language::from_session_target(&normalized);
if language == crate::snippets::types::Language::Unknown {
anyhow::bail!("unknown docs.snippets session target `{target}`");
}
let mut rust_features = session.rust_features.clone();
if language == crate::snippets::types::Language::Rust {
rust_features.extend(config.features.iter().cloned());
rust_features.sort();
rust_features.dedup();
}
Ok((
normalized,
crate::snippets::session::SessionSpec {
language,
working_directory: workspace_root.join(&session.cwd),
manifest: session.manifest.as_ref().map(|path| workspace_root.join(path)),
before: session.before.clone(),
env: session.env.clone(),
include_paths: session
.include_paths
.iter()
.map(|path| workspace_root.join(path))
.collect(),
rust_features,
rust_dependencies: session.rust_dependencies.clone(),
},
))
})
.collect::<anyhow::Result<_>>()?,
..crate::snippets::runner::RunnerConfig::default()
};
if let Some(timeout_secs) = snippet_cfg.timeout_secs {
runner_cfg.timeout_secs = timeout_secs;
}
runner_cfg.before_timeout_secs = snippet_cfg.before_timeout_secs;
let registry = crate::snippets::validators::ValidatorRegistry::default();
let summary = crate::snippets::runner::run_validation(snippets, ®istry, &runner_cfg)?;
if let Some(path) = &snippet_cfg.report_output {
let report_path = workspace_root.join(path);
crate::snippets::output::write_report(&summary, &report_path, false).map_err(|err| {
anyhow::anyhow!(
"writing snippet validation report to '{}': {err}",
report_path.display()
)
})?;
}
enforce_snippet_summary(&config.name, snippet_cfg.strict, &summary)?;
}
Ok(())
}
fn parse_allowed_side_effects(configured: &[String]) -> anyhow::Result<Vec<crate::snippets::types::SideEffectClass>> {
configured
.iter()
.map(|value| match value.as_str() {
"safe" => Ok(crate::snippets::types::SideEffectClass::Safe),
"network" => Ok(crate::snippets::types::SideEffectClass::Network),
"process" => Ok(crate::snippets::types::SideEffectClass::Process),
"install" => Ok(crate::snippets::types::SideEffectClass::Install),
"server" => Ok(crate::snippets::types::SideEffectClass::Server),
_ => anyhow::bail!("invalid docs.snippets.allowed_side_effects entry: `{value}`"),
})
.collect()
}
fn docs_sources(config: &ResolvedCrateConfig, configured_sources: &[PathBuf], workspace_root: &Path) -> Vec<PathBuf> {
let sources = if configured_sources.is_empty() {
config.source_hash_paths()
} else {
configured_sources.to_vec()
};
sources
.into_iter()
.map(|source| {
if source.is_absolute() {
source
} else {
workspace_root.join(source)
}
})
.collect()
}
fn warn_missing_explicit_sources(kind: &str, sources: &[PathBuf], workspace_root: &Path) {
let kind = kind.to_ascii_lowercase();
for source in sources {
if !workspace_root.join(source).exists() {
tracing::warn!("docs.{kind} source does not exist, skipping: {}", source.display());
}
}
}
fn with_markdown_alef_header(content: &str) -> String {
render::with_html_header(content.to_string(), "alef docs")
}