Skip to main content

alef/docs/
mod.rs

1//! API reference documentation generator for alef polyglot bindings.
2//!
3//! Generates per-language `api-{lang}.md` files plus shared `configuration.md`
4//! and `errors.md` files from the alef IR (`ApiSurface`).
5
6use crate::core::backend::GeneratedFile;
7use crate::core::config::{Language, ResolvedCrateConfig};
8use crate::core::ir::ApiSurface;
9use heck::ToPascalCase;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13mod context;
14mod descriptions;
15pub mod doc_cleaning;
16mod examples;
17mod formatting;
18mod language_pages;
19mod naming;
20mod render;
21mod rust_static;
22mod shared_pages;
23mod signatures;
24mod sorting;
25mod template_env;
26#[cfg(test)]
27mod tests;
28mod type_mapping;
29mod version_labels;
30
31#[cfg(test)]
32pub(crate) mod test_helpers;
33
34pub use doc_cleaning::clean_doc;
35pub use type_mapping::doc_type;
36
37pub use context::{CliSurface, DocsRenderContext, McpSurface};
38
39/// Generate API reference documentation for the given languages.
40///
41/// Produces one `api-{lang}.md` per language, plus shared `configuration.md`,
42/// `types.md`, and `errors.md` files written into `output_dir`.
43pub fn generate_docs(
44    api: &ApiSurface,
45    config: &ResolvedCrateConfig,
46    languages: &[Language],
47    output_dir: &str,
48) -> anyhow::Result<Vec<GeneratedFile>> {
49    let mut files = Vec::new();
50    let ffi_prefix = &config.ffi_prefix().to_pascal_case();
51
52    for &lang in languages {
53        files.push(language_pages::generate_lang_doc(
54            api, config, lang, output_dir, ffi_prefix,
55        )?);
56    }
57
58    files.push(shared_pages::generate_configuration_doc(api, config, output_dir)?);
59    files.push(shared_pages::generate_types_doc(api, output_dir)?);
60    files.push(shared_pages::generate_errors_doc(api, output_dir)?);
61
62    for file in &mut files {
63        file.content = doc_cleaning::wrap_bare_urls(&file.content);
64        if !file.content.ends_with('\n') {
65            file.content.push('\n');
66        }
67    }
68
69    Ok(files)
70}
71
72/// Generate the complete docs stage: API reference, optional CLI/MCP reference,
73/// optional template-rendered llms.txt and skills, and configured snippet checks.
74pub fn generate_docs_stage(
75    api: &ApiSurface,
76    config: &ResolvedCrateConfig,
77    languages: &[Language],
78    output_override: Option<&str>,
79    workspace_root: &Path,
80) -> anyhow::Result<Vec<GeneratedFile>> {
81    let reference_output = output_override
82        .map(PathBuf::from)
83        .or_else(|| config.docs.as_ref().and_then(|docs| docs.reference_output.clone()))
84        .unwrap_or_else(|| PathBuf::from("docs/reference"));
85    let reference_output_str = reference_output.to_string_lossy().to_string();
86
87    let mut files = generate_docs(api, config, languages, &reference_output_str)?;
88    for file in &mut files {
89        file.content = with_markdown_alef_header(&file.content);
90        file.generated_header = true;
91    }
92
93    let mut context = build_base_context(api, config, languages, &files);
94    let snippet_dirs = build_snippet_context(config, workspace_root, &mut context)?;
95
96    if let Some(docs_cfg) = &config.docs {
97        if let Some(cli_cfg) = &docs_cfg.cli
98            && cli_cfg.is_enabled()
99        {
100            let explicit_sources = !cli_cfg.sources.is_empty();
101            let sources = docs_sources(config, &cli_cfg.sources, workspace_root);
102            warn_missing_explicit_sources("CLI", &cli_cfg.sources, workspace_root);
103            let surface = rust_static::extract_cli_surface(&sources)?;
104            if surface.commands.is_empty() {
105                if explicit_sources {
106                    tracing::warn!("docs.cli was configured but no clap commands were discovered");
107                }
108            } else {
109                let path = cli_cfg
110                    .output
111                    .clone()
112                    .unwrap_or_else(|| reference_output.join("cli.md"));
113                render::ensure_managed_or_adopted(workspace_root, &path, cli_cfg.adopt_existing)?;
114                files.push(render::generate_cli_doc(&surface, path.clone()));
115                context.references.push(context::ReferenceDoc {
116                    kind: "cli".to_string(),
117                    title: "CLI Reference".to_string(),
118                    path: path.to_string_lossy().to_string(),
119                });
120                context.cli = surface;
121            }
122        }
123
124        if let Some(mcp_cfg) = &docs_cfg.mcp
125            && mcp_cfg.is_enabled()
126        {
127            let explicit_sources = !mcp_cfg.sources.is_empty();
128            let sources = docs_sources(config, &mcp_cfg.sources, workspace_root);
129            warn_missing_explicit_sources("MCP", &mcp_cfg.sources, workspace_root);
130            let surface = rust_static::extract_mcp_surface(&sources)?;
131            if surface.tools.is_empty() && surface.prompts.is_empty() && surface.resources.is_empty() {
132                if explicit_sources {
133                    tracing::warn!("docs.mcp was configured but no rmcp tools, prompts, or resources were discovered");
134                }
135            } else {
136                let path = mcp_cfg
137                    .output
138                    .clone()
139                    .unwrap_or_else(|| reference_output.join("mcp.md"));
140                render::ensure_managed_or_adopted(workspace_root, &path, mcp_cfg.adopt_existing)?;
141                files.push(render::generate_mcp_doc(&surface, path.clone()));
142                context.references.push(context::ReferenceDoc {
143                    kind: "mcp".to_string(),
144                    title: "MCP Reference".to_string(),
145                    path: path.to_string_lossy().to_string(),
146                });
147                context.mcp = surface;
148            }
149        }
150
151        if let Some(llms_cfg) = &docs_cfg.llms {
152            files.push(render::render_llms(llms_cfg, &context, workspace_root, &snippet_dirs)?);
153        }
154
155        if let Some(skills_cfg) = &docs_cfg.skills {
156            files.extend(render::render_skills(
157                skills_cfg,
158                &context,
159                workspace_root,
160                &snippet_dirs,
161            )?);
162        }
163    }
164
165    for file in &mut files {
166        file.content = doc_cleaning::wrap_bare_urls(&file.content);
167        if !file.content.ends_with('\n') {
168            file.content.push('\n');
169        }
170    }
171
172    Ok(files)
173}
174
175fn build_base_context(
176    api: &ApiSurface,
177    config: &ResolvedCrateConfig,
178    languages: &[Language],
179    api_files: &[GeneratedFile],
180) -> DocsRenderContext {
181    let description = config
182        .scaffold
183        .as_ref()
184        .and_then(|scaffold| scaffold.description.clone())
185        .unwrap_or_else(|| format!("Bindings for {}", config.name));
186    let license = config
187        .scaffold
188        .as_ref()
189        .and_then(|scaffold| scaffold.license.clone())
190        .unwrap_or_else(|| "MIT".to_string());
191    let api_references = api_files
192        .iter()
193        .map(|file| {
194            let path = file.path.to_string_lossy().to_string();
195            context::ReferenceDoc {
196                kind: "api".to_string(),
197                title: path
198                    .rsplit('/')
199                    .next()
200                    .unwrap_or(path.as_str())
201                    .trim_end_matches(".md")
202                    .replace('-', " "),
203                path,
204            }
205        })
206        .collect::<Vec<_>>();
207
208    DocsRenderContext {
209        krate: context::CrateDocsContext {
210            name: config.name.clone(),
211            version: api.version.clone(),
212            description,
213            repository: config.github_repo(),
214            license,
215        },
216        languages: languages.iter().map(ToString::to_string).collect(),
217        references: api_references.clone(),
218        api_references,
219        ..DocsRenderContext::default()
220    }
221}
222
223fn build_snippet_context(
224    config: &ResolvedCrateConfig,
225    workspace_root: &Path,
226    context: &mut DocsRenderContext,
227) -> anyhow::Result<Vec<PathBuf>> {
228    let Some(snippet_cfg) = config.docs.as_ref().and_then(|docs| docs.snippets.as_ref()) else {
229        return Ok(Vec::new());
230    };
231
232    for dir in &snippet_cfg.dirs {
233        if !workspace_root.join(dir).exists() {
234            tracing::warn!("docs.snippets.dirs entry does not exist, skipping: {}", dir.display());
235        }
236    }
237    let snippet_dirs = snippet_cfg
238        .dirs
239        .iter()
240        .filter(|dir| workspace_root.join(dir).exists())
241        .cloned()
242        .collect::<Vec<_>>();
243    if snippet_dirs.is_empty() {
244        if snippet_cfg.validation_level.is_some() || !snippet_cfg.required_languages.is_empty() {
245            tracing::warn!("docs.snippets is configured for validation but no snippet directories exist");
246        }
247        return Ok(Vec::new());
248    }
249
250    let absolute_snippet_dirs = snippet_dirs
251        .iter()
252        .map(|dir| workspace_root.join(dir))
253        .collect::<Vec<_>>();
254    let snippets = crate::snippets::discovery::discover_snippets(&absolute_snippet_dirs, None)?;
255    let mut counts_by_language = BTreeMap::new();
256    for snippet in &snippets {
257        *counts_by_language.entry(snippet.language.to_string()).or_insert(0) += 1;
258    }
259    context.snippets = context::SnippetIndexContext {
260        dirs: snippet_dirs
261            .iter()
262            .map(|dir| dir.to_string_lossy().to_string())
263            .collect(),
264        snippets: snippets
265            .iter()
266            .map(|snippet| context::SnippetContext {
267                id: snippet.id.clone(),
268                path: snippet.path.to_string_lossy().to_string(),
269                language: snippet.language.to_string(),
270                title: snippet.title.clone(),
271                tags: snippet.metadata.tags.clone(),
272            })
273            .collect(),
274        counts_by_language,
275    };
276
277    validate_snippets(config, workspace_root, snippet_cfg, &absolute_snippet_dirs, &snippets)?;
278    Ok(snippet_dirs)
279}
280
281fn validate_snippets(
282    config: &ResolvedCrateConfig,
283    workspace_root: &Path,
284    snippet_cfg: &crate::core::config::DocsSnippetsConfig,
285    absolute_snippet_dirs: &[PathBuf],
286    snippets: &[crate::snippets::types::Snippet],
287) -> anyhow::Result<()> {
288    let docs_dirs = if snippet_cfg.docs_dirs.is_empty() {
289        Vec::new()
290    } else {
291        snippet_cfg
292            .docs_dirs
293            .iter()
294            .map(|dir| workspace_root.join(dir))
295            .collect::<Vec<_>>()
296    };
297    let include_base_paths = snippet_cfg
298        .include_base_paths
299        .iter()
300        .map(|dir| workspace_root.join(dir))
301        .collect::<Vec<_>>();
302
303    if !docs_dirs.is_empty() {
304        let audit_report = crate::snippets::audit::audit(&crate::snippets::audit::AuditConfig {
305            docs_dirs: docs_dirs.clone(),
306            snippet_dirs: absolute_snippet_dirs.to_vec(),
307            require_frontmatter: snippet_cfg.require_frontmatter,
308        });
309        if audit_report.has_errors() {
310            let summary = audit_report
311                .issues
312                .iter()
313                .take(8)
314                .map(|issue| format!("{}:{}: {}", issue.path.display(), issue.line, issue.message))
315                .collect::<Vec<_>>()
316                .join("\n");
317            anyhow::bail!("snippet audit failed for crate `{}`:\n{summary}", config.name);
318        }
319    }
320
321    let required_languages = snippet_cfg
322        .required_languages
323        .iter()
324        .map(|lang| lang.parse::<crate::snippets::types::Language>())
325        .collect::<Result<Vec<_>, _>>()
326        .map_err(|err| anyhow::anyhow!("invalid docs.snippets.required_languages entry: {err}"))?;
327
328    if !docs_dirs.is_empty() || !required_languages.is_empty() {
329        let report = crate::snippets::gaps::detect_gaps(&crate::snippets::gaps::GapConfig {
330            docs_dirs,
331            snippet_dirs: absolute_snippet_dirs.to_vec(),
332            required_languages,
333            include_base_paths,
334        })?;
335        if !report.unreferenced_snippets.is_empty() {
336            tracing::warn!(
337                "docs.snippets found {} unreferenced snippet file(s); not failing because extra examples can be intentional",
338                report.unreferenced_snippets.len()
339            );
340        }
341        if !report.missing_references.is_empty()
342            || !report.missing_language_variants.is_empty()
343            || !report.skips_without_reason.is_empty()
344            || !report.unknown_languages.is_empty()
345        {
346            anyhow::bail!("snippet gap validation failed for crate `{}`", config.name);
347        }
348    }
349
350    if let Some(level) = &snippet_cfg.validation_level {
351        let level = level
352            .parse::<crate::snippets::types::ValidationLevel>()
353            .map_err(|err| anyhow::anyhow!("invalid docs.snippets.validation_level: {err}"))?;
354        let mut runner_cfg = crate::snippets::runner::RunnerConfig {
355            level,
356            fail_fast: snippet_cfg.fail_fast,
357            ..crate::snippets::runner::RunnerConfig::default()
358        };
359        if let Some(timeout_secs) = snippet_cfg.timeout_secs {
360            runner_cfg.timeout_secs = timeout_secs;
361        }
362        let registry = crate::snippets::validators::ValidatorRegistry::default();
363        let summary = crate::snippets::runner::run_validation(snippets, &registry, &runner_cfg)?;
364        if summary.unavailable > 0 {
365            tracing::warn!(
366                "docs.snippets skipped {} snippet validation(s) because required toolchains were unavailable",
367                summary.unavailable
368            );
369        }
370        if summary.has_failures() {
371            anyhow::bail!(
372                "snippet validation failed for crate `{}`: {} failed, {} errors",
373                config.name,
374                summary.failed,
375                summary.errors
376            );
377        }
378    }
379
380    Ok(())
381}
382
383fn docs_sources(config: &ResolvedCrateConfig, configured_sources: &[PathBuf], workspace_root: &Path) -> Vec<PathBuf> {
384    let sources = if configured_sources.is_empty() {
385        config.source_hash_paths()
386    } else {
387        configured_sources.to_vec()
388    };
389    sources
390        .into_iter()
391        .map(|source| {
392            if source.is_absolute() {
393                source
394            } else {
395                workspace_root.join(source)
396            }
397        })
398        .collect()
399}
400
401fn warn_missing_explicit_sources(kind: &str, sources: &[PathBuf], workspace_root: &Path) {
402    let kind = kind.to_ascii_lowercase();
403    for source in sources {
404        if !workspace_root.join(source).exists() {
405            tracing::warn!("docs.{kind} source does not exist, skipping: {}", source.display());
406        }
407    }
408}
409
410fn with_markdown_alef_header(content: &str) -> String {
411    render::with_html_header(content.to_string())
412}