forc_doc/
lib.rs

1pub mod doc;
2pub mod render;
3pub mod search;
4
5use anyhow::{bail, Result};
6use clap::Parser;
7use doc::{module::ModuleInfo, Documentation};
8use forc_pkg::{
9    self as pkg,
10    manifest::{GenericManifestFile, ManifestFile},
11    source::IPFSNode,
12    PackageManifestFile, Programs,
13};
14use forc_tracing::println_action_green;
15use forc_util::default_output_directory;
16use render::{
17    index::{LibraryInfo, WorkspaceIndex},
18    HTMLString, Renderable, RenderedDocumentation,
19};
20use std::{
21    fs,
22    path::{Path, PathBuf},
23};
24use sway_core::{
25    language::ty::{TyProgram, TyProgramKind},
26    BuildTarget, Engines,
27};
28use sway_features::ExperimentalFeatures;
29
30pub const DOC_DIR_NAME: &str = "doc";
31pub const ASSETS_DIR_NAME: &str = "static.files";
32
33forc_util::cli_examples! {
34    crate::Command {
35        [ Build the docs for a project or workspace in the current path => "forc doc"]
36        [ Build the docs for a project or workspace in the current path and open it in the browser => "forc doc --open" ]
37        [ Build the docs for a project located in another path => "forc doc --path {path}" ]
38        [ Build the docs for the current project exporting private types => "forc doc --document-private-items" ]
39        [ Build the docs offline without downloading any dependencies => "forc doc --offline" ]
40    }
41}
42
43/// Forc plugin for building a Sway package's documentation
44#[derive(Debug, Parser, Default)]
45#[clap(
46    name = "forc-doc",
47    after_help = help(),
48    version
49)]
50pub struct Command {
51    /// Path to the project.
52    ///
53    /// If not specified, current working directory will be used.
54    #[clap(short, long, alias = "manifest-path")]
55    pub path: Option<String>,
56    /// Include non-public items in the documentation.
57    #[clap(long)]
58    pub document_private_items: bool,
59    /// Open the docs in a browser after building them.
60    #[clap(long)]
61    pub open: bool,
62    /// Offline mode, prevents Forc from using the network when managing dependencies.
63    /// Meaning it will only try to use previously downloaded dependencies.
64    #[clap(long)]
65    pub offline: bool,
66    /// Requires that the Forc.lock file is up-to-date. If the lock file is missing, or it
67    /// needs to be updated, Forc will exit with an error.
68    #[clap(long)]
69    pub locked: bool,
70    /// Do not build documentation for dependencies.
71    #[clap(long)]
72    pub no_deps: bool,
73    /// The IPFS Node to use for fetching IPFS sources.
74    ///
75    /// Possible values: FUEL, PUBLIC, LOCAL, <GATEWAY_URL>
76    #[clap(long)]
77    pub ipfs_node: Option<IPFSNode>,
78    /// The path to the documentation output directory.
79    ///
80    /// If not specified, the default documentation output directory will be used.
81    #[clap(long)]
82    pub doc_path: Option<String>,
83    #[clap(flatten)]
84    pub experimental: sway_features::CliFields,
85    /// Silent mode. Don't output any warnings or errors to the command line.
86    #[clap(long, short = 's', action)]
87    pub silent: bool,
88}
89
90/// Result of documentation generation, either for a single package or a workspace.
91#[derive(Debug, Clone)]
92pub enum DocResult {
93    Package(Box<PackageManifestFile>),
94    Workspace {
95        name: String,
96        libraries: Vec<LibraryInfo>,
97    },
98}
99
100/// Generate documentation for a given package or workspace.
101pub fn generate_docs(opts: &Command) -> Result<(PathBuf, DocResult)> {
102    let ctx = DocContext::from_options(opts)?;
103    let mut compile_results = compile(&ctx, opts)?.collect::<Vec<_>>();
104    let doc_result = compile_html(opts, &ctx, &mut compile_results)?;
105    Ok((ctx.doc_path, doc_result))
106}
107
108/// Information passed to the render phase to get TypeInfo, CallPath or visibility for type anchors.
109#[derive(Clone)]
110pub struct RenderPlan<'e> {
111    no_deps: bool,
112    document_private_items: bool,
113    engines: &'e Engines,
114}
115
116impl<'e> RenderPlan<'e> {
117    pub fn new(
118        no_deps: bool,
119        document_private_items: bool,
120        engines: &'e Engines,
121    ) -> RenderPlan<'e> {
122        Self {
123            no_deps,
124            document_private_items,
125            engines,
126        }
127    }
128}
129
130pub struct DocContext {
131    pub manifest: ManifestFile,
132    pub doc_path: PathBuf,
133    pub engines: Engines,
134    pub build_plan: pkg::BuildPlan,
135    pub workspace_name: String,
136}
137
138impl DocContext {
139    pub fn is_workspace(&self) -> bool {
140        matches!(self.manifest, ManifestFile::Workspace(_))
141    }
142
143    /// package manifest for single packages. Returns None for workspaces.
144    pub fn pkg_manifest(&self) -> Option<&PackageManifestFile> {
145        match &self.manifest {
146            ManifestFile::Package(pkg) => Some(pkg),
147            ManifestFile::Workspace(_) => None,
148        }
149    }
150
151    pub fn from_options(opts: &Command) -> Result<Self> {
152        // get manifest directory
153        let dir = if let Some(ref path) = opts.path {
154            PathBuf::from(path)
155        } else {
156            std::env::current_dir()?
157        };
158        let manifest = ManifestFile::from_dir(dir)?;
159
160        // Get workspace name for later use
161        let workspace_name = manifest
162            .dir()
163            .file_name()
164            .and_then(|name| name.to_str())
165            .unwrap_or("workspace")
166            .to_string();
167
168        // create doc path
169        let out_path = default_output_directory(manifest.dir());
170        let doc_dir = opts
171            .doc_path
172            .clone()
173            .unwrap_or_else(|| DOC_DIR_NAME.to_string());
174        let doc_path = out_path.join(doc_dir);
175        if doc_path.exists() {
176            std::fs::remove_dir_all(&doc_path)?;
177        }
178        fs::create_dir_all(&doc_path)?;
179
180        // Build Plan
181        let member_manifests = manifest.member_manifests()?;
182        let lock_path = manifest.lock_path()?;
183
184        // Check for empty workspaces
185        if matches!(manifest, ManifestFile::Workspace(_)) && member_manifests.is_empty() {
186            bail!("Workspace contains no members");
187        }
188
189        let ipfs_node = opts.ipfs_node.clone().unwrap_or_default();
190        let build_plan = pkg::BuildPlan::from_lock_and_manifests(
191            &lock_path,
192            &member_manifests,
193            opts.locked,
194            opts.offline,
195            &ipfs_node,
196        )?;
197
198        Ok(Self {
199            manifest,
200            doc_path,
201            engines: Engines::default(),
202            build_plan,
203            workspace_name,
204        })
205    }
206}
207
208pub fn compile(ctx: &DocContext, opts: &Command) -> Result<impl Iterator<Item = Option<Programs>>> {
209    if ctx.is_workspace() {
210        println_action_green(
211            "Compiling",
212            &format!("workspace ({})", ctx.manifest.dir().to_string_lossy()),
213        );
214    } else if let Some(pkg_manifest) = ctx.pkg_manifest() {
215        println_action_green(
216            "Compiling",
217            &format!(
218                "{} ({})",
219                pkg_manifest.project_name(),
220                ctx.manifest.dir().to_string_lossy()
221            ),
222        );
223    }
224
225    let tests_enabled = opts.document_private_items;
226    pkg::check(
227        &ctx.build_plan,
228        BuildTarget::default(),
229        opts.silent,
230        None,
231        tests_enabled,
232        &ctx.engines,
233        None,
234        &opts.experimental.experimental,
235        &opts.experimental.no_experimental,
236        sway_core::DbgGeneration::Full,
237    )
238    .map(|results| results.into_iter().map(|(programs, _handler)| programs))
239}
240
241pub fn compile_html(
242    opts: &Command,
243    ctx: &DocContext,
244    compile_results: &mut Vec<Option<Programs>>,
245) -> Result<DocResult> {
246    let mut documented_libraries = Vec::new();
247
248    let raw_docs = if opts.no_deps {
249        if let Some(pkg_manifest) = ctx.pkg_manifest() {
250            // Single package mode
251            let Some(ty_program) = compile_results
252                .pop()
253                .and_then(|programs| programs)
254                .and_then(|p| p.typed.ok())
255            else {
256                bail! {
257                    "documentation could not be built from manifest located at '{}'",
258                    pkg_manifest.path().display()
259                }
260            };
261
262            // Only document if it's a library
263            if matches!(ty_program.kind, TyProgramKind::Library { .. }) {
264                let lib_info = LibraryInfo {
265                    name: pkg_manifest.project_name().to_string(),
266                    description: pkg_manifest
267                        .project
268                        .description
269                        .clone()
270                        .unwrap_or_else(|| format!("Library {}", pkg_manifest.project_name())),
271                };
272                documented_libraries.push(lib_info);
273                build_docs(opts, ctx, &ty_program, &ctx.manifest, pkg_manifest)?
274            } else {
275                bail!(
276                    "forc-doc only supports libraries. '{}' is not a library.",
277                    pkg_manifest.project_name()
278                );
279            }
280        } else {
281            // Workspace mode with no_deps
282            bail!("--no-deps flag is not meaningful for workspaces");
283        }
284    } else {
285        let (order, graph, manifest_map) = (
286            ctx.build_plan.compilation_order(),
287            ctx.build_plan.graph(),
288            ctx.build_plan.manifest_map(),
289        );
290        let mut raw_docs = Documentation(Vec::new());
291
292        for (node, compile_result) in order.iter().zip(compile_results) {
293            let id = &graph[*node].id();
294            if let Some(pkg_manifest_file) = manifest_map.get(id) {
295                let manifest_file = ManifestFile::from_dir(pkg_manifest_file.path())?;
296                let ty_program = compile_result
297                    .as_ref()
298                    .and_then(|programs| programs.typed.clone().ok())
299                    .ok_or_else(|| {
300                        anyhow::anyhow!(
301                            "documentation could not be built from manifest located at '{}'",
302                            pkg_manifest_file.path().display()
303                        )
304                    })?;
305
306                // Only document libraries that are workspace members
307                if matches!(ty_program.kind, TyProgramKind::Library { .. }) {
308                    // Check if this package is a workspace member
309                    let is_workspace_member = if ctx.is_workspace() {
310                        ctx.manifest.member_manifests()?.iter().any(|(_, member)| {
311                            member.project_name() == pkg_manifest_file.project_name()
312                        })
313                    } else {
314                        true // For single packages, always include
315                    };
316
317                    if is_workspace_member {
318                        let lib_info = LibraryInfo {
319                            name: pkg_manifest_file.project_name().to_string(),
320                            description: pkg_manifest_file
321                                .project
322                                .description
323                                .clone()
324                                .unwrap_or_else(|| {
325                                    format!("Library {}", pkg_manifest_file.project_name())
326                                }),
327                        };
328                        documented_libraries.push(lib_info);
329                        raw_docs.0.extend(
330                            build_docs(opts, ctx, &ty_program, &manifest_file, pkg_manifest_file)?
331                                .0,
332                        );
333                    }
334                }
335            }
336        }
337        raw_docs
338    };
339
340    // Create workspace index if this is a workspace
341    if ctx.is_workspace() && !documented_libraries.is_empty() {
342        // Sort libraries alphabetically for consistent display
343        documented_libraries.sort_by(|a, b| a.name.cmp(&b.name));
344        create_workspace_index(
345            &ctx.doc_path,
346            &documented_libraries,
347            &ctx.engines,
348            &ctx.workspace_name,
349        )?;
350    }
351
352    search::write_search_index(&ctx.doc_path, &raw_docs)?;
353
354    let result = if ctx.is_workspace() {
355        DocResult::Workspace {
356            name: ctx.workspace_name.clone(),
357            libraries: documented_libraries,
358        }
359    } else if let Some(pkg_manifest) = ctx.pkg_manifest() {
360        DocResult::Package(Box::new(pkg_manifest.clone()))
361    } else {
362        unreachable!("Should have either workspace or package")
363    };
364
365    Ok(result)
366}
367
368fn build_docs(
369    opts: &Command,
370    ctx: &DocContext,
371    ty_program: &TyProgram,
372    manifest: &ManifestFile,
373    pkg_manifest: &PackageManifestFile,
374) -> Result<Documentation> {
375    let experimental = ExperimentalFeatures::new(
376        &pkg_manifest.project.experimental,
377        &opts.experimental.experimental,
378        &opts.experimental.no_experimental,
379    )
380    .map_err(|err| anyhow::anyhow!("{err}"))?;
381
382    println_action_green(
383        "Building",
384        &format!(
385            "documentation for {} ({})",
386            pkg_manifest.project_name(),
387            manifest.dir().to_string_lossy()
388        ),
389    );
390
391    let raw_docs = Documentation::from_ty_program(
392        &ctx.engines,
393        pkg_manifest.project_name(),
394        ty_program,
395        opts.document_private_items,
396        experimental,
397    )?;
398    let root_attributes = (!ty_program.root_module.attributes.is_empty())
399        .then_some(ty_program.root_module.attributes.clone());
400    let forc_version = pkg_manifest
401        .project
402        .forc_version
403        .as_ref()
404        .map(|ver| format!("Forc v{}.{}.{}", ver.major, ver.minor, ver.patch));
405    // render docs to HTML
406    let rendered_docs = RenderedDocumentation::from_raw_docs(
407        raw_docs.clone(),
408        RenderPlan::new(opts.no_deps, opts.document_private_items, &ctx.engines),
409        root_attributes,
410        &ty_program.kind,
411        forc_version,
412    )?;
413
414    // write file contents to doc folder
415    write_content(rendered_docs, &ctx.doc_path)?;
416    println_action_green("Finished", pkg_manifest.project_name());
417
418    Ok(raw_docs)
419}
420
421fn write_content(rendered_docs: RenderedDocumentation, doc_path: &Path) -> Result<()> {
422    for doc in rendered_docs.0 {
423        let mut doc_path = doc_path.to_path_buf();
424        for prefix in doc.module_info.module_prefixes {
425            doc_path.push(prefix);
426        }
427        fs::create_dir_all(&doc_path)?;
428        doc_path.push(doc.html_filename);
429        fs::write(&doc_path, doc.file_contents.0.as_bytes())?;
430    }
431    Ok(())
432}
433
434fn create_workspace_index(
435    doc_path: &Path,
436    documented_libraries: &[LibraryInfo],
437    engines: &Engines,
438    workspace_name: &str,
439) -> Result<()> {
440    // Create a workspace module info with the actual directory name
441    let workspace_info = ModuleInfo::from_ty_module(vec![workspace_name.to_string()], None);
442
443    // Create the workspace index
444    let workspace_index = WorkspaceIndex::new(workspace_info, documented_libraries.to_vec());
445
446    let render_plan = RenderPlan::new(false, false, engines);
447    let rendered_content = workspace_index.render(render_plan)?;
448    let html_content = HTMLString::from_rendered_content(rendered_content)?;
449
450    fs::write(doc_path.join("index.html"), html_content.0.as_bytes())?;
451    Ok(())
452}