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::{
8    opts::{GH_REPO_PREFIX_REGEX, configure_pcx_all_sources_with_status},
9    utils::Git,
10};
11use foundry_common::{compile::ProjectCompiler, shell};
12use foundry_config::{Config, load_config_with_root};
13use solar::{interface::Session, sema::Compiler};
14use std::{path::PathBuf, time::Instant};
15
16#[derive(Clone, Debug, Parser)]
17pub struct DocArgs {
18    /// The project's root path.
19    ///
20    /// By default root of the Git repository, if in one,
21    /// or the current working directory.
22    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
23    pub root: Option<PathBuf>,
24
25    /// The doc's output path.
26    ///
27    /// By default, it is the `docs/` directory in the project root.
28    /// The directory is created if it does not exist, and contains a
29    /// ready-to-use [vocs](https://vocs.dev) site scaffold alongside the
30    /// generated MDX pages.
31    #[arg(long, short, value_hint = ValueHint::DirPath, value_name = "PATH")]
32    out: Option<PathBuf>,
33
34    /// Document external library sources as well as the project's own sources.
35    #[arg(long, short)]
36    pub include_libraries: bool,
37
38    /// Path to the `hardhat-deploy` or `forge-deploy` artifact directory.
39    ///
40    /// Leave blank to use the default (`<root>/deployments`).
41    /// Omit the flag entirely to disable deployment address injection.
42    #[arg(long, value_name = "PATH", num_args(0..=1))]
43    pub deployments: Option<Option<PathBuf>>,
44
45    #[command(flatten)]
46    pub watch: WatchArgs,
47
48    /// Deprecated flag after the migration to Vocs. Previously, it was used to serve docs locally.
49    #[arg(long, hide = true)]
50    serve: bool,
51}
52
53impl DocArgs {
54    pub async fn run(self) -> Result<()> {
55        if self.serve {
56            eyre::bail!(
57                "`--serve` has been removed. Generate the docs with `forge doc`, \
58                 then run `npm run dev` from the generated docs directory."
59            );
60        }
61        let mut config = self.config()?;
62
63        install::install_missing_dependencies(&mut config, || self.config())?;
64
65        let root = &config.root;
66        let project = config.ephemeral_project()?;
67        let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build());
68        let source_status = compiler.enter_mut(|compiler| -> Result<_> {
69            let mut pcx = compiler.parse();
70            pcx.set_resolve_imports(true);
71            let status =
72                configure_pcx_all_sources_with_status(&mut pcx, &config, Some(&project), None)?;
73            pcx.parse();
74            Ok(status)
75        })?;
76
77        // Solar does not support Solidity versions prior to 0.8.0. Preserve support for old,
78        // mixed-version, and Solidity-free projects by using the existing compiler-backed parser.
79        let mut output = if source_status.is_fully_supported() {
80            None
81        } else {
82            let mut compile_project = config.solar_project()?;
83            compile_project.no_artifacts = true;
84            Some(ProjectCompiler::new().compile(&compile_project)?)
85        };
86
87        let mut doc_cfg = config.doc;
88        if let Some(out) = self.out.clone() {
89            doc_cfg.out = out;
90        }
91
92        // Auto-detect repository URL from git remote.
93        if doc_cfg.repository.is_none()
94            && let Some(remote) = Git::new(root).remote_url("origin")
95            && let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote)
96        {
97            let brand = captures.name("brand").unwrap().as_str();
98            let tld = captures.name("tld").unwrap().as_str();
99            let project_path = GH_REPO_PREFIX_REGEX.replace(&remote, "");
100            doc_cfg.repository =
101                Some(format!("https://{brand}.{tld}/{}", project_path.trim_end_matches(".git")));
102        }
103
104        let git = Git::new(root);
105        let commit = doc_cfg.commit.clone().or_else(|| git.commit_hash(false, "HEAD").ok());
106        // Best-effort branch detection for editLink. May yield "HEAD" when in
107        // detached HEAD state; treat that as unknown.
108        let branch = git.current_rev_branch(root).ok().map(|(_, b)| b).filter(|b| b != "HEAD");
109
110        let mut builder = DocBuilder::new(
111            root.clone(),
112            project.paths.sources,
113            project.paths.libraries,
114            self.include_libraries,
115        );
116        builder.commit = commit;
117        builder.branch = branch;
118        builder.deployments = self.deployments;
119        builder.config = doc_cfg.clone();
120
121        let out_dir = if doc_cfg.out.is_absolute() { doc_cfg.out } else { root.join(&doc_cfg.out) };
122
123        if !shell::is_quiet() {
124            sh_println!("Generating documentation...")?;
125        }
126        let started = Instant::now();
127        let compiler = if let Some(output) = &mut output {
128            output.parser_mut().solc_mut().compiler_mut()
129        } else {
130            &mut compiler
131        };
132        let stats = builder.build(compiler)?;
133        let elapsed = started.elapsed();
134
135        if !shell::is_quiet() {
136            sh_println!(
137                "Generated {pages} page{ps} from {sources} source{ss} in {elapsed:.2?} (render {render:.2?}, site {site:.2?})",
138                pages = stats.pages,
139                ps = if stats.pages <= 1 { "" } else { "s" },
140                sources = stats.sources,
141                ss = if stats.sources <= 1 { "" } else { "s" },
142                elapsed = elapsed,
143                render = stats.render_elapsed,
144                site = stats.site_elapsed,
145            )?;
146
147            // TODO: `--legacy-peer-deps` flag is required waku dependency conflict resolution.
148            // Remove this flag once vocs v2 and waku v1 are released.
149            sh_println!(
150                "\nDocumentation written to: {}\n\nTo preview:\n  cd {}\n  npm install --legacy-peer-deps\n  npm run dev",
151                out_dir.display(),
152                out_dir.display(),
153            )?;
154        }
155
156        Ok(())
157    }
158
159    /// Returns whether watch mode is enabled.
160    pub const fn is_watch(&self) -> bool {
161        self.watch.watch.is_some()
162    }
163
164    pub fn config(&self) -> Result<Config> {
165        load_config_with_root(self.root.as_deref())
166    }
167}