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        if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
64        {
65            // need to re-configure here to also catch additional remappings
66            config = self.config()?;
67        }
68
69        let root = &config.root;
70        let project = config.ephemeral_project()?;
71        let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build());
72        let source_status = compiler.enter_mut(|compiler| -> Result<_> {
73            let mut pcx = compiler.parse();
74            pcx.set_resolve_imports(true);
75            let status =
76                configure_pcx_all_sources_with_status(&mut pcx, &config, Some(&project), None)?;
77            pcx.parse();
78            Ok(status)
79        })?;
80
81        // Solar does not support Solidity versions prior to 0.8.0. Preserve support for old,
82        // mixed-version, and Solidity-free projects by using the existing compiler-backed parser.
83        let mut output = if source_status.is_fully_supported() {
84            None
85        } else {
86            let mut compile_project = config.solar_project()?;
87            compile_project.no_artifacts = true;
88            Some(ProjectCompiler::new().compile(&compile_project)?)
89        };
90
91        let mut doc_cfg = config.doc;
92        if let Some(out) = self.out.clone() {
93            doc_cfg.out = out;
94        }
95
96        // Auto-detect repository URL from git remote.
97        if doc_cfg.repository.is_none()
98            && let Some(remote) = Git::new(root).remote_url("origin")
99            && let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote)
100        {
101            let brand = captures.name("brand").unwrap().as_str();
102            let tld = captures.name("tld").unwrap().as_str();
103            let project_path = GH_REPO_PREFIX_REGEX.replace(&remote, "");
104            doc_cfg.repository =
105                Some(format!("https://{brand}.{tld}/{}", project_path.trim_end_matches(".git")));
106        }
107
108        let git = Git::new(root);
109        let commit = doc_cfg.commit.clone().or_else(|| git.commit_hash(false, "HEAD").ok());
110        // Best-effort branch detection for editLink. May yield "HEAD" when in
111        // detached HEAD state; treat that as unknown.
112        let branch = git.current_rev_branch(root).ok().map(|(_, b)| b).filter(|b| b != "HEAD");
113
114        let mut builder = DocBuilder::new(
115            root.clone(),
116            project.paths.sources,
117            project.paths.libraries,
118            self.include_libraries,
119        );
120        builder.commit = commit;
121        builder.branch = branch;
122        builder.deployments = self.deployments;
123        builder.config = doc_cfg.clone();
124
125        let out_dir = if doc_cfg.out.is_absolute() { doc_cfg.out } else { root.join(&doc_cfg.out) };
126
127        if !shell::is_quiet() {
128            sh_println!("Generating documentation...")?;
129        }
130        let started = Instant::now();
131        let compiler = if let Some(output) = &mut output {
132            output.parser_mut().solc_mut().compiler_mut()
133        } else {
134            &mut compiler
135        };
136        let stats = builder.build(compiler)?;
137        let elapsed = started.elapsed();
138
139        if !shell::is_quiet() {
140            sh_println!(
141                "Generated {pages} page{ps} from {sources} source{ss} in {elapsed:.2?} (render {render:.2?}, site {site:.2?})",
142                pages = stats.pages,
143                ps = if stats.pages <= 1 { "" } else { "s" },
144                sources = stats.sources,
145                ss = if stats.sources <= 1 { "" } else { "s" },
146                elapsed = elapsed,
147                render = stats.render_elapsed,
148                site = stats.site_elapsed,
149            )?;
150
151            // TODO: `--legacy-peer-deps` flag is required waku dependency conflict resolution.
152            // Remove this flag once vocs v2 and waku v1 are released.
153            sh_println!(
154                "\nDocumentation written to: {}\n\nTo preview:\n  cd {}\n  npm install --legacy-peer-deps\n  npm run dev",
155                out_dir.display(),
156                out_dir.display(),
157            )?;
158        }
159
160        Ok(())
161    }
162
163    /// Returns whether watch mode is enabled.
164    pub const fn is_watch(&self) -> bool {
165        self.watch.watch.is_some()
166    }
167
168    pub fn config(&self) -> Result<Config> {
169        load_config_with_root(self.root.as_deref())
170    }
171}