Skip to main content

forge/cmd/
doc.rs

1//! `forge doc`
2
3use crate::cmd::{install, watch::WatchArgs};
4use clap::{Parser, ValueHint};
5use eyre::Result;
6use forge_doc::DocBuilder;
7use foundry_cli::{opts::GH_REPO_PREFIX_REGEX, utils::Git};
8use foundry_common::{compile::ProjectCompiler, shell};
9use foundry_config::{Config, load_config_with_root};
10use std::{path::PathBuf, time::Instant};
11
12#[derive(Clone, Debug, Parser)]
13pub struct DocArgs {
14    /// The project's root path.
15    ///
16    /// By default root of the Git repository, if in one,
17    /// or the current working directory.
18    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
19    pub root: Option<PathBuf>,
20
21    /// The doc's output path.
22    ///
23    /// By default, it is the `docs/` directory in the project root.
24    /// The directory is created if it does not exist, and contains a
25    /// ready-to-use [vocs](https://vocs.dev) site scaffold alongside the
26    /// generated MDX pages.
27    #[arg(long, short, value_hint = ValueHint::DirPath, value_name = "PATH")]
28    out: Option<PathBuf>,
29
30    /// Document external library sources as well as the project's own sources.
31    #[arg(long, short)]
32    pub include_libraries: bool,
33
34    /// Path to the `hardhat-deploy` or `forge-deploy` artifact directory.
35    ///
36    /// Leave blank to use the default (`<root>/deployments`).
37    /// Omit the flag entirely to disable deployment address injection.
38    #[arg(long, value_name = "PATH", num_args(0..=1))]
39    pub deployments: Option<Option<PathBuf>>,
40
41    #[command(flatten)]
42    pub watch: WatchArgs,
43
44    /// Deprecated flag after the migration to Vocs. Previously, it was used to serve docs locally.
45    #[arg(long, hide = true)]
46    serve: bool,
47}
48
49impl DocArgs {
50    pub async fn run(self) -> Result<()> {
51        if self.serve {
52            eyre::bail!(
53                "`--serve` has been removed. Generate the docs with `forge doc`, \
54                 then run `npm run dev` from the generated docs directory."
55            );
56        }
57        let mut config = self.config()?;
58
59        if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
60        {
61            // need to re-configure here to also catch additional remappings
62            config = self.config()?;
63        }
64
65        let root = &config.root;
66        let project = config.project()?;
67
68        // Compile the project first so the doc renderer has access to a fully
69        // resolved HIR (needed for `@inheritdoc`, cross-references, etc.).
70        // We let the standard compilation reporter print progress so users get
71        // the same visual feedback as `forge build`; pass `--quiet` to silence.
72        let mut output = ProjectCompiler::new().compile(&project)?;
73        let compiler = output.parser_mut().solc_mut().compiler_mut();
74
75        let mut doc_cfg = config.doc;
76        if let Some(out) = self.out.clone() {
77            doc_cfg.out = out;
78        }
79
80        // Auto-detect repository URL from git remote.
81        if doc_cfg.repository.is_none()
82            && let Some(remote) = Git::new(root).remote_url("origin")
83            && let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote)
84        {
85            let brand = captures.name("brand").unwrap().as_str();
86            let tld = captures.name("tld").unwrap().as_str();
87            let project_path = GH_REPO_PREFIX_REGEX.replace(&remote, "");
88            doc_cfg.repository =
89                Some(format!("https://{brand}.{tld}/{}", project_path.trim_end_matches(".git")));
90        }
91
92        let git = Git::new(root);
93        let commit = git.commit_hash(false, "HEAD").ok();
94        // Best-effort branch detection for editLink. May yield "HEAD" when in
95        // detached HEAD state; treat that as unknown.
96        let branch = git.current_rev_branch(root).ok().map(|(_, b)| b).filter(|b| b != "HEAD");
97
98        let mut builder = DocBuilder::new(
99            root.clone(),
100            project.paths.sources,
101            project.paths.libraries,
102            self.include_libraries,
103        );
104        builder.commit = commit;
105        builder.branch = branch;
106        builder.deployments = self.deployments;
107        builder.config = doc_cfg.clone();
108
109        let out_dir = if doc_cfg.out.is_absolute() { doc_cfg.out } else { root.join(&doc_cfg.out) };
110
111        if !shell::is_quiet() {
112            sh_println!("Generating documentation...")?;
113        }
114        let started = Instant::now();
115        let stats = builder.build(compiler)?;
116        let elapsed = started.elapsed();
117
118        if !shell::is_quiet() {
119            sh_println!(
120                "Generated {pages} page{ps} from {sources} source{ss} in {elapsed:.2?} (render {render:.2?}, site {site:.2?})",
121                pages = stats.pages,
122                ps = if stats.pages <= 1 { "" } else { "s" },
123                sources = stats.sources,
124                ss = if stats.sources <= 1 { "" } else { "s" },
125                elapsed = elapsed,
126                render = stats.render_elapsed,
127                site = stats.site_elapsed,
128            )?;
129
130            // TODO: `--legacy-peer-deps` flag is required waku dependency conflict resolution.
131            // Remove this flag once vocs v2 and waku v1 are released.
132            sh_println!(
133                "\nDocumentation written to: {}\n\nTo preview:\n  cd {}\n  npm install --legacy-peer-deps\n  npm run dev",
134                out_dir.display(),
135                out_dir.display(),
136            )?;
137        }
138
139        Ok(())
140    }
141
142    /// Returns whether watch mode is enabled.
143    pub const fn is_watch(&self) -> bool {
144        self.watch.watch.is_some()
145    }
146
147    pub fn config(&self) -> Result<Config> {
148        load_config_with_root(self.root.as_deref())
149    }
150}