harn-cli 0.10.11

CLI for the Harn programming language — run, test, REPL, format, and lint
//! `harn doc [path]` — render Markdown API reference docs for a Harn file or
//! project's `pub` symbols.
//!
//! This reuses the package pipeline's HarnDoc extractor
//! ([`extract_api_symbols`]) and per-symbol renderer ([`push_api_symbol`]) so
//! there is a single source of truth for how a documented symbol is parsed and
//! rendered. Unlike `harn package docs`, which only documents the modules
//! declared in a `harn.toml` `[exports]` table, `harn doc` walks the target
//! path directly, so a plain project (with no declared exports) still produces
//! real reference docs for every `pub` symbol it defines.

use std::path::Path;

use crate::package::{
    collect_package_files, extract_api_symbols, push_api_symbol, PackageApiSymbol,
};

/// One documented source file: its display path plus the public symbols it
/// declares, in source order.
struct DocModule {
    path: String,
    symbols: Vec<PackageApiSymbol>,
}

/// Entry point for the `harn doc` command. Returns a process exit code.
pub(crate) fn run(path: &Path, output: Option<&Path>) -> i32 {
    let modules = match collect_doc_modules(path) {
        Ok(modules) => modules,
        Err(error) => {
            eprintln!("error: {error}");
            return 1;
        }
    };
    if modules.is_empty() {
        eprintln!(
            "error: no public (`pub`) symbols found under {}",
            path.display()
        );
        return 1;
    }

    let rendered = render_docs(&modules);
    match output {
        Some(out) => {
            if let Err(error) = harn_vm::atomic_io::atomic_write(out, rendered.as_bytes()) {
                eprintln!("error: failed to write {}: {error}", out.display());
                return 1;
            }
            let symbols: usize = modules.iter().map(|module| module.symbols.len()).sum();
            eprintln!(
                "Wrote {symbols} symbol(s) from {} module(s) to {}",
                modules.len(),
                out.display()
            );
        }
        None => print!("{rendered}"),
    }
    0
}

/// Collect the documented modules under `path`. A file target yields at most
/// one module; a directory target yields one per `.harn` file that declares at
/// least one `pub` symbol, sorted by path.
fn collect_doc_modules(path: &Path) -> Result<Vec<DocModule>, String> {
    let metadata = std::fs::metadata(path)
        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    let mut modules = Vec::new();
    if metadata.is_file() {
        if let Some(module) = doc_module_for_file(path, &path.to_string_lossy())? {
            modules.push(module);
        }
        return Ok(modules);
    }

    let files = collect_package_files(path).map_err(|error| error.to_string())?;
    for rel in files {
        if !rel.ends_with(".harn") {
            continue;
        }
        if let Some(module) = doc_module_for_file(&path.join(&rel), &rel)? {
            modules.push(module);
        }
    }
    Ok(modules)
}

/// Read one `.harn` file and extract its public symbols. Returns `None` when
/// the file declares nothing to document.
fn doc_module_for_file(full: &Path, display: &str) -> Result<Option<DocModule>, String> {
    let source = std::fs::read_to_string(full)
        .map_err(|error| format!("failed to read {}: {error}", full.display()))?;
    let symbols = extract_api_symbols(&source);
    if symbols.is_empty() {
        return Ok(None);
    }
    Ok(Some(DocModule {
        path: display.replace('\\', "/"),
        symbols,
    }))
}

/// Assemble the full Markdown document: a title, then one `## `<path>`` section
/// per module, each holding its symbols rendered by the shared per-symbol
/// renderer.
fn render_docs(modules: &[DocModule]) -> String {
    let mut out = String::from("# API Reference\n\nGenerated by `harn doc`.\n");
    for module in modules {
        out.push_str(&format!("\n## `{}`\n", module.path));
        for symbol in &module.symbols {
            push_api_symbol(&mut out, symbol);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    const MATH_SOURCE: &str = "\
/// Adds two integers.
/// @effects none
/// @errors none
pub fn add(a: int, b: int) -> int {
  a + b
}

/// Circle constant to a few digits.
pub const PI: float = 3.14159

/// A 2D point in the plane.
pub struct Point {
  x: float,
  y: float,
}

/// An undocumented-below private helper stays out of the docs.
fn private_helper(n: int) -> int {
  n * 2
}
";

    #[test]
    fn doc_renders_pub_symbols_with_descriptions_and_signatures() {
        let tmp = tempfile::tempdir().unwrap();
        let src = tmp.path().join("src");
        fs::create_dir_all(&src).unwrap();
        fs::write(src.join("math.harn"), MATH_SOURCE).unwrap();

        let out = tmp.path().join("api.md");
        assert_eq!(run(tmp.path(), Some(&out)), 0);
        let doc = fs::read_to_string(&out).unwrap();

        // Every pub symbol: name heading + description + fenced signature.
        assert!(doc.contains("### fn `add`"), "{doc}");
        assert!(doc.contains("Adds two integers."), "{doc}");
        assert!(doc.contains("pub fn add(a: int, b: int) -> int"), "{doc}");

        assert!(doc.contains("### const `PI`"), "{doc}");
        assert!(doc.contains("Circle constant to a few digits."), "{doc}");
        assert!(doc.contains("pub const PI: float"), "{doc}");

        assert!(doc.contains("### struct `Point`"), "{doc}");
        assert!(doc.contains("A 2D point in the plane."), "{doc}");

        // @effects / @errors from the HarnDoc survive into the description.
        assert!(doc.contains("@effects none"), "{doc}");
        assert!(doc.contains("@errors none"), "{doc}");

        // The module heading references the source path.
        assert!(doc.contains("src/math.harn"), "{doc}");

        // Private (non-`pub`) declarations are never documented.
        assert!(!doc.contains("private_helper"), "{doc}");
    }

    #[test]
    fn doc_targets_a_single_file() {
        let tmp = tempfile::tempdir().unwrap();
        let file = tmp.path().join("lib.harn");
        fs::write(
            &file,
            "/// Greets a name.\npub fn greet(name: string) -> string {\n  \"hi\"\n}\n",
        )
        .unwrap();

        let out = tmp.path().join("out.md");
        assert_eq!(run(&file, Some(&out)), 0);
        let doc = fs::read_to_string(&out).unwrap();
        assert!(doc.contains("### fn `greet`"), "{doc}");
        assert!(doc.contains("Greets a name."), "{doc}");
        assert!(
            doc.contains("pub fn greet(name: string) -> string"),
            "{doc}"
        );
    }

    #[test]
    fn doc_reports_when_no_public_symbols_exist() {
        let tmp = tempfile::tempdir().unwrap();
        fs::write(tmp.path().join("private.harn"), "fn only_private() {}\n").unwrap();
        // No `pub` symbols anywhere: non-zero exit, nothing written.
        let out = tmp.path().join("api.md");
        assert_eq!(run(tmp.path(), Some(&out)), 1);
        assert!(!out.exists());
    }
}