diff --git a/Cargo.lock b/Cargo.lock index 01dc0e2c7..a42a6329f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,12 +4086,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -6927,18 +6926,23 @@ dependencies = [ "cairo-lang-utils", "camino", "clap", + "console 0.16.1", + "create-output-dir", "expect-test", "indoc", "itertools 0.14.0", "mimalloc", "salsa", + "scarb-build-metadata", "scarb-extensions-cli", + "scarb-fs-utils", "scarb-metadata 1.15.1", "scarb-test-support", "scarb-ui", "serde", "serde_json", "snapbox", + "tempfile", "thiserror 2.0.17", ] diff --git a/extensions/scarb-doc/Cargo.toml b/extensions/scarb-doc/Cargo.toml index 804eb2766..f306d88c0 100644 --- a/extensions/scarb-doc/Cargo.toml +++ b/extensions/scarb-doc/Cargo.toml @@ -21,13 +21,18 @@ cairo-lang-semantic.workspace = true cairo-lang-starknet.workspace = true cairo-lang-syntax.workspace = true cairo-lang-utils.workspace = true +create-output-dir = { path = "../../utils/create-output-dir" } +console.workspace = true expect-test.workspace = true indoc.workspace = true itertools.workspace = true mimalloc.workspace = true +tempfile.workspace = true scarb-metadata = { path = "../../scarb-metadata" } +scarb-build-metadata = { path = "../../utils/scarb-build-metadata" } scarb-ui = { path = "../../utils/scarb-ui" } scarb-extensions-cli = { path = "../../utils/scarb-extensions-cli", default-features = false, features = ["doc"] } +scarb-fs-utils = { path = "../../utils/scarb-fs-utils" } serde.workspace = true serde_json.workspace = true salsa.workspace = true diff --git a/extensions/scarb-doc/src/doc_test.rs b/extensions/scarb-doc/src/doc_test.rs new file mode 100644 index 000000000..30e7cf7b2 --- /dev/null +++ b/extensions/scarb-doc/src/doc_test.rs @@ -0,0 +1,4 @@ +pub mod code_blocks; +pub mod runner; +mod ui; +mod workspace; diff --git a/extensions/scarb-doc/src/doc_test/code_blocks.rs b/extensions/scarb-doc/src/doc_test/code_blocks.rs new file mode 100644 index 000000000..82229882b --- /dev/null +++ b/extensions/scarb-doc/src/doc_test/code_blocks.rs @@ -0,0 +1,293 @@ +use crate::doc_test::runner::{ExecutionOutcome, RunStrategy}; +use crate::docs_generation::markdown::traits::WithItemDataCommon; +use crate::types::crate_type::Crate; +use crate::types::module_type::Module; +use cairo_lang_doc::parser::DocumentationCommentToken; +use itertools::Itertools; +use std::collections::HashMap; +use std::str::from_utf8; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct CodeBlockId { + pub item_full_path: String, + pub close_token_idx: usize, + /// Index of this block in the item's documentation. + pub block_index: usize, +} + +impl CodeBlockId { + pub fn new(item_full_path: String, block_index: usize, close_token_idx: usize) -> Self { + Self { + item_full_path, + block_index, + close_token_idx, + } + } + + // TODO: (#2888): Display exact code block location when running doc-tests + pub fn display_name(&self, total_blocks_in_item: usize) -> String { + if total_blocks_in_item <= 1 { + self.item_full_path.clone() + } else { + format!("{} (example {})", self.item_full_path, self.block_index) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodeBlockAttribute { + Cairo, + Runnable, + Ignore, + NoRun, + Other(String), +} + +impl From<&str> for CodeBlockAttribute { + fn from(string: &str) -> Self { + match string.to_lowercase().as_str() { + "cairo" => CodeBlockAttribute::Cairo, + "runnable" => CodeBlockAttribute::Runnable, + "ignore" => CodeBlockAttribute::Ignore, + "no_run" => CodeBlockAttribute::NoRun, + _ => CodeBlockAttribute::Other(string.to_string()), + } + } +} + +/// Represents code block extracted from doc comments. +#[derive(Debug, Clone, PartialEq)] +pub struct CodeBlock { + pub id: CodeBlockId, + pub content: String, + pub attributes: Vec, +} + +impl CodeBlock { + pub fn new(id: CodeBlockId, content: String, info_string: &str) -> Self { + let attributes = Self::parse_attributes(info_string); + Self { + id, + content, + attributes, + } + } + + // TODO: default to Cairo unless specified otherwise + fn is_cairo(&self) -> bool { + if self.attributes.contains(&CodeBlockAttribute::Cairo) { + return true; + } + false + } + + pub fn run_strategy(&self) -> RunStrategy { + if self.attributes.contains(&CodeBlockAttribute::Ignore) { + return RunStrategy::Ignore; + } + // TODO: drop the `runnable` attribute requirement; default to runnable for Cairo blocks + if !self.is_cairo() || !self.attributes.contains(&CodeBlockAttribute::Runnable) { + return RunStrategy::Ignore; + } + match self.expected_outcome() { + ExecutionOutcome::None => RunStrategy::Ignore, + ExecutionOutcome::BuildSuccess => RunStrategy::Build, + ExecutionOutcome::RunSuccess => RunStrategy::Execute, + ExecutionOutcome::CompileError => RunStrategy::Build, + ExecutionOutcome::RuntimeError => RunStrategy::Execute, + } + } + + pub fn expected_outcome(&self) -> ExecutionOutcome { + if self.attributes.contains(&CodeBlockAttribute::Ignore) { + return ExecutionOutcome::None; + } + if self.attributes.contains(&CodeBlockAttribute::NoRun) { + return ExecutionOutcome::BuildSuccess; + } + ExecutionOutcome::RunSuccess + } + + fn parse_attributes(info_string: &str) -> Vec { + info_string + .split(',') + .map(|attr| attr.trim()) + .filter(|attr| !attr.is_empty()) + .dedup() + .map(Into::into) + .collect() + } +} + +pub fn collect_code_blocks(crate_: &Crate<'_>) -> Vec { + let mut runnable_code_blocks = Vec::new(); + collect_from_module(&crate_.root_module, &mut runnable_code_blocks); + for module in &crate_.foreign_crates { + collect_from_module(module, &mut runnable_code_blocks); + } + runnable_code_blocks.sort_by_key(|block| block.id.clone()); + runnable_code_blocks +} + +/// Counts the number of code blocks per documented item. Used to generate display names +/// for code blocks, allowing to distinguish between multiple code blocks in the same item. +/// +/// Returns the mapping from `item_full_path` to the number of code blocks in that item. +pub fn count_blocks_per_item(code_blocks: &[CodeBlock]) -> HashMap { + let mut counts = HashMap::new(); + for block in code_blocks { + *counts.entry(block.id.item_full_path.clone()).or_insert(0) += 1; + } + counts +} + +fn collect_from_module(module: &Module<'_>, runnable_code_blocks: &mut Vec) { + for &item_data in module.get_all_item_ids().values() { + collect_from_item_data(item_data, runnable_code_blocks); + } + for &item_data in module.pub_uses.get_all_item_ids().values() { + collect_from_item_data(item_data, runnable_code_blocks); + } +} + +fn collect_from_item_data( + item_data: &dyn WithItemDataCommon, + runnable_code_blocks: &mut Vec, +) { + for block in &item_data.code_blocks() { + runnable_code_blocks.push(block.clone()); + } +} + +pub fn collect_code_blocks_from_tokens( + doc_tokens: &Option>, + full_path: &str, +) -> Vec { + let Some(tokens) = doc_tokens else { + return Vec::new(); + }; + + #[derive(Debug)] + struct CodeFence { + token_idx: usize, + char: u8, + len: usize, + info_string: String, + } + + let mut code_blocks = Vec::new(); + let mut current_fence: Option = None; + let mut block_index: usize = 0; + + for (idx, token) in tokens.iter().enumerate() { + let content = match token { + DocumentationCommentToken::Content(content) => content.trim(), + DocumentationCommentToken::Link(_) => continue, + }; + if content.is_empty() { + continue; + } + match current_fence { + // Handle potential closing fence. + Some(ref opening) => { + if is_matching_closing_fence(content, opening.char, opening.len) { + let end_idx = idx; + let body = get_block_body(tokens, opening.token_idx + 1, end_idx); + + // Skip empty code blocks. + if !body.is_empty() { + let id = CodeBlockId::new(full_path.to_string(), block_index, end_idx); + code_blocks.push(CodeBlock::new( + id, + body.to_string(), + &opening.info_string, + )); + block_index += 1; + } + current_fence = None; + } + } + // Handle potential opening fence. + None => { + if let Some((len, char)) = scan_code_fence(content.as_bytes()) { + let bytes = content.as_bytes(); + let after = &bytes[len..]; + let info_string = from_utf8(after).unwrap_or("").trim().to_string(); + + current_fence = Some(CodeFence { + token_idx: idx, + char, + len, + info_string, + }); + } + } + } + } + // There may be an unterminated fence at this point, but this is allowed from the spec perspective, so we ignore it. + code_blocks +} + +fn get_block_body( + tokens: &[DocumentationCommentToken], + start_idx: usize, + end_idx: usize, +) -> String { + tokens[start_idx..end_idx] + .iter() + .filter_map(|token| match token { + DocumentationCommentToken::Content(content) => Some(content.as_str()), + DocumentationCommentToken::Link(_) => None, + }) + .collect::>() + .join("") + .trim() + .to_string() +} + +/// Checks if the given `content` is a closing fence matching the given opening fence. +fn is_matching_closing_fence(content: &str, opening_char: u8, opening_len: usize) -> bool { + let bytes = content.as_bytes(); + let Some((len, ch)) = scan_code_fence(bytes) else { + return false; + }; + ch == opening_char + && len >= opening_len + && bytes[len..] + .iter() + .all(|&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n')) +} + +/// Copied from `pulldown-cmark`: +/// https://github.com/pulldown-cmark/pulldown-cmark/blob/a574ea8a5e6fda7bc26542a612130a2b458a68a7/pulldown-cmark/src/scanners.rs#L744 +fn scan_code_fence(data: &[u8]) -> Option<(usize, u8)> { + let c = *data.first()?; + if !(c == b'`' || c == b'~') { + return None; + } + let i = 1 + scan_ch_repeat(&data[1..], c); + if i >= 3 { + if c == b'`' { + let suffix = &data[i..]; + let next_line = i + scan_nextline(suffix); + // FIXME: make sure this is correct + if suffix[..(next_line - i)].contains(&b'`') { + return None; + } + } + Some((i, c)) + } else { + None + } +} + +fn scan_ch_repeat(data: &[u8], c: u8) -> usize { + data.iter().take_while(|&&b| b == c).count() +} + +fn scan_nextline(bytes: &[u8]) -> usize { + bytes + .iter() + .position(|&b| b == b'\n') + .map_or(bytes.len(), |x| x + 1) +} diff --git a/extensions/scarb-doc/src/doc_test/runner.rs b/extensions/scarb-doc/src/doc_test/runner.rs new file mode 100644 index 000000000..9e386d240 --- /dev/null +++ b/extensions/scarb-doc/src/doc_test/runner.rs @@ -0,0 +1,244 @@ +use crate::AdditionalMetadata; +use crate::doc_test::code_blocks::{CodeBlock, CodeBlockId, count_blocks_per_item}; +use crate::doc_test::ui::TestResult; +use crate::doc_test::workspace::TestWorkspace; +use anyhow::Result; +use create_output_dir::create_output_dir; +use scarb_fs_utils::{ + EXECUTE_PRINT_OUTPUT_FILENAME, EXECUTE_PROGRAM_OUTPUT_FILENAME, incremental_create_dir_unique, +}; +use scarb_metadata::ScarbCommand; +use scarb_ui::Ui; +use scarb_ui::components::{NewLine, Status}; +use serde::Serialize; +use std::collections::HashMap; +use std::fs; + +pub type ExecutionResults = HashMap; + +#[derive(Debug, Clone)] +pub struct ExecutionResult { + pub status: TestStatus, + pub print_output: String, + pub program_output: String, + pub outcome: ExecutionOutcome, +} + +impl ExecutionResult { + pub fn as_markdown(&self) -> String { + let mut output = String::new(); + if !self.print_output.is_empty() { + output.push_str("\nOutput:\n```\n"); + output.push_str(&self.print_output); + output.push_str("\n```\n"); + } + if !self.program_output.is_empty() { + output.push_str("\nResult:\n```\n"); + output.push_str(&self.program_output); + output.push_str("\n```\n"); + } + if output.is_empty() && self.outcome == ExecutionOutcome::RunSuccess { + output.push_str("\n*No output.*\n"); + } + output + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct TestSummary { + pub passed: usize, + pub failed: usize, + pub ignored: usize, +} + +impl TestSummary { + pub fn is_ok(&self) -> bool { + self.failed == 0 + } + + pub fn is_fail(&self) -> bool { + self.failed > 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TestStatus { + Passed, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionOutcome { + BuildSuccess, + RunSuccess, + CompileError, + RuntimeError, + None, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunStrategy { + Ignore, + Build, + Execute, +} + +/// A runner for executing ([`CodeBlock`]) examples found in documentation. +/// Uses the target package as a dependency and runs each code block in an isolated temporary workspace. +/// Relies on `scarb build` and `scarb execute` commands to build and run the examples, +/// based on the requested [`RunStrategy`] for the given code block. +/// +/// Note: it is expected examples (`code_blocks`) that this runner executes only depend on the target package and standard libraries. +pub struct TestRunner<'a> { + /// Metadata of the target package whose documentation is being tested. + metadata: &'a AdditionalMetadata, + ui: Ui, +} + +impl<'a> TestRunner<'a> { + pub fn new(metadata: &'a AdditionalMetadata, ui: Ui) -> Self { + Self { metadata, ui } + } + + pub fn run_all(&self, code_blocks: &[CodeBlock]) -> Result<(TestSummary, ExecutionResults)> { + let pkg_name = &self.metadata.name; + + let mut results = HashMap::new(); + let mut summary = TestSummary::default(); + let mut failed_names = Vec::new(); + let blocks_per_item = count_blocks_per_item(code_blocks); + + self.ui.print(Status::new( + "Running", + &format!("{} doc examples for `{pkg_name}`", code_blocks.len()), + )); + + let mut idx = 0; + for block in code_blocks { + let strategy = block.run_strategy(); + let total_in_item = *blocks_per_item.get(&block.id.item_full_path).unwrap_or(&1); + let display_name = block.id.display_name(total_in_item); + + match strategy { + RunStrategy::Ignore => { + summary.ignored += 1; + self.ui.print(TestResult::ignored(&display_name)); + } + _ => { + idx += 1; + match self.run_single(block, strategy, idx) { + Ok(res) => match res.status { + TestStatus::Passed => { + summary.passed += 1; + self.ui.print(TestResult::ok(&display_name)); + results.insert(block.id.clone(), res); + } + TestStatus::Failed => { + summary.failed += 1; + self.ui.print(TestResult::failed(&display_name)); + failed_names.push(display_name); + } + }, + Err(e) => { + summary.failed += 1; + self.ui.print(TestResult::failed(&display_name)); + failed_names.push(display_name); + self.ui.error(format!("Error running example: {:#}", e)); + } + } + } + } + } + // TODO: add struct with `impl Message` to display this + if !failed_names.is_empty() { + self.ui.print("\nfailures:"); + for display_name in &failed_names { + self.ui.print(format!(" {}", display_name)); + } + } + self.ui.print(NewLine::new()); + self.ui.print(summary.clone()); + + Ok((summary, results)) + } + + fn run_single( + &self, + code_block: &CodeBlock, + strategy: RunStrategy, + index: usize, + ) -> Result { + let ws = TestWorkspace::new(self.metadata, index, code_block)?; + let (actual, print_output, program_output) = self.run_single_inner(&ws, strategy)?; + let expected = code_block.expected_outcome(); + let status = if actual == expected { + TestStatus::Passed + } else { + TestStatus::Failed + }; + + Ok(ExecutionResult { + outcome: actual, + status, + print_output, + program_output, + }) + } + + fn run_single_inner( + &self, + ws: &TestWorkspace, + strategy: RunStrategy, + ) -> Result<(ExecutionOutcome, String, String)> { + if strategy == RunStrategy::Ignore { + unreachable!("the code block should be filtered out before reaching here"); + } + let target_dir = ws.root().join("target"); + let build_result = ScarbCommand::new() + .arg("build") + .current_dir(ws.root()) + .env("SCARB_TARGET_DIR", target_dir.as_str()) + .env("SCARB_UI_VERBOSITY", self.ui.verbosity().to_string()) + .env("SCARB_MANIFEST_PATH", ws.manifest_path().as_str()) + .env("SCARB_ALL_FEATURES", "true") + .run(); + + if build_result.is_err() { + return Ok((ExecutionOutcome::CompileError, String::new(), String::new())); + } else if strategy == RunStrategy::Build { + return Ok((ExecutionOutcome::BuildSuccess, String::new(), String::new())); + } + + let output_dir = target_dir.join("execute").join(ws.package_name()); + create_output_dir(output_dir.as_std_path())?; + let (output_dir, execution_id) = incremental_create_dir_unique(&output_dir, "execution")?; + + let run_result = ScarbCommand::new() + .arg("execute") + .arg("--no-build") + .arg("--save-print-output") + .arg("--save-program-output") + .current_dir(ws.root()) + .env("SCARB_EXECUTION_ID", execution_id.to_string()) + .env("SCARB_TARGET_DIR", target_dir.as_str()) + .env("SCARB_UI_VERBOSITY", self.ui.verbosity().to_string()) + .env("SCARB_MANIFEST_PATH", ws.manifest_path().as_str()) + .env("SCARB_ALL_FEATURES", "true") + .run(); + + if run_result.is_err() { + Ok((ExecutionOutcome::RuntimeError, String::new(), String::new())) + } else { + let print_output = fs::read_to_string(output_dir.join(EXECUTE_PRINT_OUTPUT_FILENAME)) + .unwrap_or_default() + .trim() + .to_string(); + let program_output = + fs::read_to_string(output_dir.join(EXECUTE_PROGRAM_OUTPUT_FILENAME)) + .unwrap_or_default() + .trim() + .to_string(); + Ok((ExecutionOutcome::RunSuccess, print_output, program_output)) + } + } +} diff --git a/extensions/scarb-doc/src/doc_test/ui.rs b/extensions/scarb-doc/src/doc_test/ui.rs new file mode 100644 index 000000000..e3e3f44cd --- /dev/null +++ b/extensions/scarb-doc/src/doc_test/ui.rs @@ -0,0 +1,94 @@ +use crate::doc_test::runner::TestSummary; +use console::Style; +use scarb_ui::Message; +use serde::{Serialize, Serializer}; + +impl Message for TestSummary { + fn text(self) -> String { + let (result, style) = if self.is_ok() { + ("ok", Style::new().green()) + } else { + ("FAILED", Style::new().red()) + }; + format!( + "test result: {}. {} passed; {} failed; {} ignored", + style.apply_to(result), + self.passed, + self.failed, + self.ignored + ) + } + + fn structured(self, ser: S) -> anyhow::Result { + self.serialize(ser) + } +} + +/// Result of a single test execution. +/// +/// Displays as `test {name} ... {status}` where `status` is colored: +/// - `ok` in green +/// - `FAILED` in red +/// - `ignored` in yellow +#[derive(Serialize)] +pub struct TestResult<'a> { + name: &'a str, + status: TestResultStatus, +} + +#[derive(Serialize)] +pub enum TestResultStatus { + Ok, + Failed, + Ignored, +} + +impl TestResultStatus { + fn as_str(&self) -> &'static str { + match self { + TestResultStatus::Ok => "ok", + TestResultStatus::Failed => "FAILED", + TestResultStatus::Ignored => "ignored", + } + } + + fn style(&self) -> Style { + match self { + TestResultStatus::Ok => Style::new().green(), + TestResultStatus::Failed => Style::new().red(), + TestResultStatus::Ignored => Style::new().yellow(), + } + } +} + +impl<'a> TestResult<'a> { + pub fn new(name: &'a str, status: TestResultStatus) -> Self { + Self { name, status } + } + + pub fn ok(name: &'a str) -> Self { + Self::new(name, TestResultStatus::Ok) + } + + pub fn failed(name: &'a str) -> Self { + Self::new(name, TestResultStatus::Failed) + } + + pub fn ignored(name: &'a str) -> Self { + Self::new(name, TestResultStatus::Ignored) + } +} + +impl Message for TestResult<'_> { + fn text(self) -> String { + format!( + "test {} ... {}", + self.name, + self.status.style().apply_to(self.status.as_str()) + ) + } + + fn structured(self, ser: S) -> Result { + self.serialize(ser) + } +} diff --git a/extensions/scarb-doc/src/doc_test/workspace.rs b/extensions/scarb-doc/src/doc_test/workspace.rs new file mode 100644 index 000000000..194e8fa7f --- /dev/null +++ b/extensions/scarb-doc/src/doc_test/workspace.rs @@ -0,0 +1,122 @@ +use crate::AdditionalMetadata; +use crate::doc_test::code_blocks::CodeBlock; +use anyhow::{Context, Result, anyhow}; +use cairo_lang_filesystem::db::Edition; +use camino::{Utf8Path, Utf8PathBuf}; +use indoc::formatdoc; +use scarb_build_metadata::CAIRO_VERSION; +use std::fmt::Write; +use std::fs; +use tempfile::{TempDir, tempdir}; + +pub(crate) struct TestWorkspace { + _temp_dir: TempDir, + root: Utf8PathBuf, + package_name: String, +} + +impl TestWorkspace { + pub fn new( + metadata: &AdditionalMetadata, + index: usize, + code_block: &CodeBlock, + ) -> Result { + let temp_dir = tempdir().context("failed to create temporary workspace")?; + let root = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()) + .map_err(|path| anyhow!("path `{}` is not UTF-8 encoded", path.display()))?; + + let package_name = format!("{}_example_{}", metadata.name, index); + + let workspace = Self { + _temp_dir: temp_dir, + root, + package_name, + }; + workspace.write_manifest(metadata)?; + workspace.write_src(&code_block.content, &metadata.name)?; + + Ok(workspace) + } + + pub fn root(&self) -> &Utf8Path { + &self.root + } + + pub fn manifest_path(&self) -> Utf8PathBuf { + self.root.join("Scarb.toml") + } + + pub fn package_name(&self) -> &str { + &self.package_name + } + + fn write_manifest(&self, metadata: &AdditionalMetadata) -> Result<()> { + let package_dir = metadata + .manifest_path + .parent() + .context("package manifest path has no parent directory")?; + + let dep = &metadata.name; + let dep_path = format!("{}", package_dir); + let name = &self.package_name; + let edition = edition_variant(Edition::latest()); + + let manifest = formatdoc! {r#" + [package] + name = "{name}" + version = "0.1.0" + edition = "{edition}" + + [dependencies] + {dep} = {{ path = "{dep_path}" }} + cairo_execute = "{CAIRO_VERSION}" + + [cairo] + enable-gas = false + + [executable] + "# + }; + fs::write(self.manifest_path(), manifest).context("failed to write manifest")?; + Ok(()) + } + + fn write_src(&self, content: &str, package_name: &str) -> Result<()> { + let src_dir = self.root().join("src"); + fs::create_dir_all(&src_dir).context("failed to create src directory")?; + + // TODO: (#2889) Improve this logic to be more precise + let has_main_fn = content.lines().any(|line| { + line.trim_start().starts_with("fn main()") + || line.trim_start().starts_with("pub fn main()") + }); + + let body = if has_main_fn { + content.to_string() + } else { + let mut body = String::with_capacity(content.len() + content.lines().count() * 5); + writeln!(body, "fn main() {{")?; + for line in content.lines() { + writeln!(body, " {}", line)?; + } + writeln!(body, "}}")?; + body + }; + let lib_cairo = formatdoc! {r#" + use {package_name}::*; + + #[executable] + {body} + "#}; + fs::write(src_dir.join("lib.cairo"), lib_cairo).context("failed to write lib.cairo")?; + Ok(()) + } +} + +fn edition_variant(edition: Edition) -> String { + let edition = serde_json::to_value(edition).unwrap(); + let serde_json::Value::String(edition) = edition else { + panic!("Edition should always be a string.") + }; + edition +} diff --git a/extensions/scarb-doc/src/docs_generation.rs b/extensions/scarb-doc/src/docs_generation.rs index 467cf5402..c1e248b36 100644 --- a/extensions/scarb-doc/src/docs_generation.rs +++ b/extensions/scarb-doc/src/docs_generation.rs @@ -1,3 +1,4 @@ +use crate::doc_test::code_blocks::CodeBlock; use crate::location_links::DocLocationLink; use crate::types::module_type::Module; use crate::types::other_types::{ @@ -79,6 +80,7 @@ pub trait DocItem { fn doc_location_links(&self) -> &Vec; fn markdown_formatted_path(&self) -> String; fn group_name(&self) -> &Option; + fn code_blocks(&self) -> &Vec; } macro_rules! impl_doc_item { @@ -113,6 +115,10 @@ macro_rules! impl_doc_item { fn group_name(&self) -> &Option { &self.item_data.group } + + fn code_blocks(&self) -> &Vec { + &self.item_data.code_blocks + } } }; } diff --git a/extensions/scarb-doc/src/docs_generation/markdown.rs b/extensions/scarb-doc/src/docs_generation/markdown.rs index 5ac2f722e..3a0794e55 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown.rs @@ -3,7 +3,7 @@ use crate::docs_generation::markdown::book_toml::generate_book_toml_content; use crate::docs_generation::markdown::summary::generate_summary_file_content; use crate::errors::{IODirectoryCreationError, IOWriteError}; use anyhow::Result; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use std::collections::HashMap; use std::fs; @@ -11,6 +11,7 @@ mod book_toml; pub mod context; mod summary; pub mod traits; +use crate::doc_test::runner::ExecutionResults; use crate::docs_generation::common::{ GeneratedFile, OutputFilesExtension, SummaryIndexMap, SummaryListItem, }; @@ -27,7 +28,8 @@ pub const GROUP_CHAPTER_PREFIX: &str = "- ###"; /// Prefixes that indicate the start of complex Markdown structures, /// such as tables. These should be avoided in brief documentation to maintain simple text /// formatting and prevent disruption of the layout. -const SHORT_DOCUMENTATION_AVOID_PREFIXES: &[&str] = &["#", "\n\n", "```", "- ", "1. ", "{{#"]; +const SHORT_DOCUMENTATION_AVOID_PREFIXES: &[&str] = + &["#", "\n\n", "```", "~~~", "- ", "1. ", "{{#"]; pub struct MarkdownContent { book_toml: String, @@ -40,10 +42,10 @@ impl MarkdownContent { pub fn from_crate( package_information: &PackageInformation, format: OutputFilesExtension, + execution_results: Option, ) -> Result { let (summary, doc_files) = - generate_summary_file_content(&package_information.crate_, format)?; - + generate_summary_file_content(&package_information.crate_, format, execution_results)?; Ok(Self { book_toml: generate_book_toml_content(&package_information.metadata), summary, @@ -77,7 +79,7 @@ impl WorkspaceMarkdownBuilder { self.book_toml = Some(generate_book_toml_content(&package_information.metadata)); } let (summary, files) = - generate_summary_file_content(&package_information.crate_, self.output_format)?; + generate_summary_file_content(&package_information.crate_, self.output_format, None)?; let current = std::mem::replace(&mut self.summary, SummaryIndexMap::new()); self.summary = current.add(summary); self.doc_files.extend(files); @@ -102,6 +104,7 @@ fn package_information_placeholder() -> crate::AdditionalMetadata { crate::AdditionalMetadata { name: "workspace".to_string(), authors: None, + manifest_path: Utf8PathBuf::from("Scarb.toml"), } } diff --git a/extensions/scarb-doc/src/docs_generation/markdown/context.rs b/extensions/scarb-doc/src/docs_generation/markdown/context.rs index 2a9fc493d..e5b174f18 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown/context.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown/context.rs @@ -1,6 +1,7 @@ +use crate::doc_test::runner::ExecutionResults; use crate::docs_generation::common::{OutputFilesExtension, SummaryIndexMap}; use crate::docs_generation::markdown::SUMMARY_FILENAME; -use crate::docs_generation::markdown::traits::WithPath; +use crate::docs_generation::markdown::traits::WithItemDataCommon; use crate::location_links::DocLocationLink; use crate::types::crate_type::Crate; use cairo_lang_defs::ids::{ImplItemId, LookupItemId, TraitItemId}; @@ -9,12 +10,13 @@ use cairo_lang_doc::parser::CommentLinkToken; use itertools::Itertools; use std::collections::HashMap; -pub type IncludedItems<'a, 'db> = HashMap, &'a dyn WithPath>; +pub type IncludedItems<'a, 'db> = HashMap, &'a dyn WithItemDataCommon>; pub struct MarkdownGenerationContext<'a, 'db> { included_items: IncludedItems<'a, 'db>, formatting: Box, pub(crate) files_extension: &'static str, + execution_results: Option, } pub trait Formatting { @@ -103,7 +105,11 @@ impl Formatting for MarkdownFormatting { } impl<'a, 'db> MarkdownGenerationContext<'a, 'db> { - pub fn from_crate(crate_: &'a Crate<'db>, format: OutputFilesExtension) -> Self + pub fn from_crate( + crate_: &'a Crate<'db>, + format: OutputFilesExtension, + execution_results: Option, + ) -> Self where 'a: 'db, { @@ -118,6 +124,7 @@ impl<'a, 'db> MarkdownGenerationContext<'a, 'db> { included_items, formatting, files_extension: format.get_string(), + execution_results, } } @@ -190,6 +197,10 @@ impl<'a, 'db> MarkdownGenerationContext<'a, 'db> { self.formatting .header_primitive(header_level, name, full_path) } + + pub fn execution_results(&self) -> Option<&ExecutionResults> { + self.execution_results.as_ref() + } } pub fn path_to_file_link(path: &str, files_extension: &str) -> String { diff --git a/extensions/scarb-doc/src/docs_generation/markdown/summary.rs b/extensions/scarb-doc/src/docs_generation/markdown/summary.rs index 67dd3c9b4..7de5ba144 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown/summary.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown/summary.rs @@ -2,6 +2,7 @@ pub mod content; pub mod files; pub mod group_files; +use crate::doc_test::runner::ExecutionResults; use crate::docs_generation::common::OutputFilesExtension; use crate::docs_generation::markdown::context::MarkdownGenerationContext; use crate::docs_generation::markdown::summary::content::{ @@ -20,9 +21,10 @@ use group_files::generate_global_groups_summary_files; pub fn generate_summary_file_content( crate_: &Crate, output_format: OutputFilesExtension, + execution_results: Option, ) -> Result<(SummaryIndexMap, Vec<(String, String)>)> { let mut summary_index_map = SummaryIndexMap::new(); - let context = MarkdownGenerationContext::from_crate(crate_, output_format); + let context = MarkdownGenerationContext::from_crate(crate_, output_format, execution_results); generate_module_summary_content( &crate_.root_module, diff --git a/extensions/scarb-doc/src/docs_generation/markdown/summary/files.rs b/extensions/scarb-doc/src/docs_generation/markdown/summary/files.rs index 44c3b7286..103564764 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown/summary/files.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown/summary/files.rs @@ -4,10 +4,8 @@ use crate::docs_generation::markdown::traits::{ MarkdownDocItem, TopLevelMarkdownDocItem, generate_markdown_table_summary_for_top_level_subitems, }; -use crate::docs_generation::markdown::{ - BASE_HEADER_LEVEL, BASE_MODULE_CHAPTER_PREFIX, SummaryIndexMap, -}; -use crate::docs_generation::{DocItem, TopLevelItems}; +use crate::docs_generation::markdown::{BASE_HEADER_LEVEL, BASE_MODULE_CHAPTER_PREFIX}; +use crate::docs_generation::{DocItem, TopLevelItems, common}; use crate::types::module_type::Module; use crate::types::other_types::{ Constant, Enum, ExternFunction, ExternType, FreeFunction, Impl, ImplAlias, MacroDeclaration, @@ -15,6 +13,7 @@ use crate::types::other_types::{ }; use crate::types::struct_types::Struct; use anyhow::Result; +use common::SummaryIndexMap; use itertools::chain; macro_rules! module_summary { @@ -148,19 +147,19 @@ pub fn generate_doc_files_for_module_items( summary_index_map: &SummaryIndexMap, ) -> Result> { Ok(chain!( - generate_top_level_docs_contents(&top_level_items.modules, context, summary_index_map)?, - generate_top_level_docs_contents(&top_level_items.constants, context, summary_index_map)?, + generate_top_level_docs_contents(&top_level_items.modules, context, summary_index_map,)?, + generate_top_level_docs_contents(&top_level_items.constants, context, summary_index_map,)?, generate_top_level_docs_contents( &top_level_items.free_functions, context, - summary_index_map + summary_index_map, )?, - generate_top_level_docs_contents(&top_level_items.structs, context, summary_index_map)?, - generate_top_level_docs_contents(&top_level_items.enums, context, summary_index_map)?, + generate_top_level_docs_contents(&top_level_items.structs, context, summary_index_map,)?, + generate_top_level_docs_contents(&top_level_items.enums, context, summary_index_map,)?, generate_top_level_docs_contents( &top_level_items.type_aliases, context, - summary_index_map + summary_index_map, )?, generate_top_level_docs_contents( &top_level_items.impl_aliases, diff --git a/extensions/scarb-doc/src/docs_generation/markdown/summary/group_files.rs b/extensions/scarb-doc/src/docs_generation/markdown/summary/group_files.rs index 59591a880..0b70d35e2 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown/summary/group_files.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown/summary/group_files.rs @@ -1,11 +1,13 @@ use crate::docs_generation::TopLevelItems; +use crate::docs_generation::markdown::GROUP_CHAPTER_PREFIX; use crate::docs_generation::markdown::context::MarkdownGenerationContext; use crate::docs_generation::markdown::summary::files::{ generate_doc_files_for_module_items, generate_modules_summary_files, generate_summary_files_for_module_items, }; use crate::docs_generation::markdown::traits::generate_markdown_table_summary_for_top_level_subitems; -use crate::docs_generation::markdown::{GROUP_CHAPTER_PREFIX, SummaryIndexMap}; + +use crate::docs_generation::common::SummaryIndexMap; use crate::types::groups::Group; use itertools::Itertools; diff --git a/extensions/scarb-doc/src/docs_generation/markdown/traits.rs b/extensions/scarb-doc/src/docs_generation/markdown/traits.rs index aa9d9cf19..071d495e6 100644 --- a/extensions/scarb-doc/src/docs_generation/markdown/traits.rs +++ b/extensions/scarb-doc/src/docs_generation/markdown/traits.rs @@ -1,9 +1,10 @@ use super::context::MarkdownGenerationContext; +use crate::doc_test::code_blocks::CodeBlock; use crate::docs_generation::markdown::{ BASE_MODULE_CHAPTER_PREFIX, GROUP_CHAPTER_PREFIX, SHORT_DOCUMENTATION_AVOID_PREFIXES, - SHORT_DOCUMENTATION_LEN, SummaryIndexMap, + SHORT_DOCUMENTATION_LEN, }; -use crate::docs_generation::{DocItem, PrimitiveDocItem, SubPathDocItem, TopLevelDocItem}; +use crate::docs_generation::{DocItem, PrimitiveDocItem, SubPathDocItem, TopLevelDocItem, common}; use crate::types::groups::Group; use crate::types::item_data::{ItemData, SubItemData}; use crate::types::module_type::{Module, ModulePubUses}; @@ -15,6 +16,7 @@ use crate::types::other_types::{ use crate::types::struct_types::{Member, Struct}; use anyhow::Result; use cairo_lang_doc::parser::{CommentLinkToken, DocumentationCommentToken}; +use common::SummaryIndexMap; use itertools::Itertools; use std::collections::HashMap; use std::fmt::Write; @@ -181,11 +183,30 @@ pub trait MarkdownDocItem: DocItem { } fn get_documentation(&self, context: &MarkdownGenerationContext) -> Option { + let execution_results_map = context + .execution_results() + .map(|results| { + self.code_blocks() + .iter() + .filter_map(|block| { + results + .get(&block.id) + .map(|res| (block.id.close_token_idx, res)) + }) + .collect::>() + }) + .unwrap_or_default(); self.doc().as_ref().map(|doc_tokens| { doc_tokens .iter() - .map(|doc_token| match doc_token { - DocumentationCommentToken::Content(content) => content.clone(), + .enumerate() + .map(|(idx, token)| match token { + DocumentationCommentToken::Content(content) => { + match execution_results_map.get(&idx) { + Some(res) => format!("{}{}", content, res.as_markdown()), + None => content.clone(), + } + } DocumentationCommentToken::Link(link) => { self.format_link_to_path(link, context) } @@ -779,7 +800,7 @@ fn generate_markdown_for_subitems( writeln!( &mut markdown, "{}", - item.generate_markdown(context, header_level + 2, postfix, summary_index_map)? + item.generate_markdown(context, header_level + 2, postfix, summary_index_map,)? )?; } } @@ -868,17 +889,18 @@ fn get_full_subitem_path( } } -pub trait WithPath { +pub trait WithItemDataCommon { fn name(&self) -> &str; fn full_path(&self) -> String; fn parent_full_path(&self) -> Option; + fn code_blocks(&self) -> Vec; } pub trait WithItemData { fn item_data(&self) -> &ItemData<'_>; } -impl WithPath for T { +impl WithItemDataCommon for T { fn name(&self) -> &str { self.item_data().name.as_str() } @@ -890,6 +912,9 @@ impl WithPath for T { fn parent_full_path(&self) -> Option { self.item_data().parent_full_path.clone() } + fn code_blocks(&self) -> Vec { + self.item_data().code_blocks.clone() + } } impl<'db> WithItemData for ItemData<'db> { @@ -898,8 +923,8 @@ impl<'db> WithItemData for ItemData<'db> { } } -// Allow SubItemData to be used wherever a WithPath is expected without converting into ItemData. -impl<'db> WithPath for SubItemData<'db> { +/// Allow SubItemData to be used wherever a [`WithItemDataCommon`] is expected without converting into ItemData. +impl<'db> WithItemDataCommon for SubItemData<'db> { fn name(&self) -> &str { self.name.as_str() } @@ -909,4 +934,7 @@ impl<'db> WithPath for SubItemData<'db> { fn parent_full_path(&self) -> Option { self.parent_full_path.clone() } + fn code_blocks(&self) -> Vec { + self.code_blocks.clone() + } } diff --git a/extensions/scarb-doc/src/lib.rs b/extensions/scarb-doc/src/lib.rs index 208a438b3..2c14569d9 100644 --- a/extensions/scarb-doc/src/lib.rs +++ b/extensions/scarb-doc/src/lib.rs @@ -17,6 +17,7 @@ use cairo_lang_filesystem::{ ids::{CrateId, CrateLongId}, }; use cairo_lang_utils::Intern; +use camino::Utf8PathBuf; use errors::DiagnosticError; use itertools::Itertools; use scarb_metadata::{ @@ -28,6 +29,7 @@ use serde::Serialize; pub mod attributes; pub mod db; pub mod diagnostics; +pub mod doc_test; pub mod docs_generation; pub mod errors; pub mod location_links; @@ -45,6 +47,8 @@ pub struct PackageInformation<'db> { pub struct AdditionalMetadata { pub name: String, pub authors: Option>, + #[serde(skip)] + pub manifest_path: Utf8PathBuf, } pub struct PackageContext { @@ -101,6 +105,7 @@ pub fn generate_package_context( metadata: AdditionalMetadata { name: package_metadata.name.clone(), authors, + manifest_path: package_metadata.manifest_path.clone(), }, }) } diff --git a/extensions/scarb-doc/src/main.rs b/extensions/scarb-doc/src/main.rs index 96e68332d..6970ebf4a 100644 --- a/extensions/scarb-doc/src/main.rs +++ b/extensions/scarb-doc/src/main.rs @@ -3,6 +3,8 @@ use camino::Utf8PathBuf; use clap::Parser; use mimalloc::MiMalloc; use scarb_doc::diagnostics::print_diagnostics; +use scarb_doc::doc_test::code_blocks::collect_code_blocks; +use scarb_doc::doc_test::runner::{ExecutionResults, TestRunner}; use scarb_doc::docs_generation::common::OutputFilesExtension; use scarb_doc::docs_generation::markdown::{MarkdownContent, WorkspaceMarkdownBuilder}; use scarb_doc::errors::{MetadataCommandError, PackagesSerializationError}; @@ -37,6 +39,7 @@ fn main_inner(args: Args, ui: Ui) -> Result<()> { let metadata_for_packages = args.packages_filter.match_many(&metadata)?; let output_dir = get_target_dir(&metadata).join(OUTPUT_DIR); let workspace_root = metadata.workspace.root.clone(); + let doc_tests_enabled = !args.no_run; if args.packages_filter.get_workspace() & !matches!(args.output_format, OutputFormat::Json) { let mut builder = WorkspaceMarkdownBuilder::new(args.output_format.into()); @@ -67,13 +70,28 @@ fn main_inner(args: Args, ui: Ui) -> Result<()> { let ctx = generate_package_context(&metadata, pm, args.document_private_items)?; let info = generate_package_information(&ctx, ui.clone())?; print_diagnostics(&ui); - output.write(info)?; + let execution_results = doc_tests_enabled + .then(|| run_doc_tests(&info, &ui)) + .transpose()?; + output.write(info, execution_results)?; } output.flush()?; } Ok(()) } +fn run_doc_tests(package: &PackageInformation, ui: &Ui) -> Result { + let runnable_code_blocks = collect_code_blocks(&package.crate_); + if runnable_code_blocks.is_empty() { + Ok(Default::default()) + } else { + let runner = TestRunner::new(&package.metadata, ui.clone()); + let (summary, execution_results) = runner.run_all(&runnable_code_blocks)?; + ensure!(!summary.is_fail(), "doc tests failed"); + Ok(execution_results) + } +} + pub enum OutputEmit { Markdown { output_dir: Utf8PathBuf, @@ -125,7 +143,11 @@ impl OutputEmit { } } - pub fn write(&mut self, package: PackageInformation) -> Result<()> { + pub fn write( + &mut self, + package: PackageInformation, + execution_results: Option, + ) -> Result<()> { match self { OutputEmit::Markdown { output_dir, @@ -134,7 +156,8 @@ impl OutputEmit { ui, files_extension, } => { - let content = MarkdownContent::from_crate(&package, *files_extension)?; + let content = + MarkdownContent::from_crate(&package, *files_extension, execution_results)?; output_markdown( content, Some(package.metadata.name), diff --git a/extensions/scarb-doc/src/types/item_data.rs b/extensions/scarb-doc/src/types/item_data.rs index 135902f08..f6b1038d2 100644 --- a/extensions/scarb-doc/src/types/item_data.rs +++ b/extensions/scarb-doc/src/types/item_data.rs @@ -1,5 +1,6 @@ use crate::attributes::find_groups_from_attributes; use crate::db::ScarbDocDatabase; +use crate::doc_test::code_blocks::{CodeBlock, collect_code_blocks_from_tokens}; use crate::location_links::DocLocationLink; use crate::types::other_types::doc_full_path; use cairo_lang_defs::ids::{ModuleId, TopLevelLanguageElementId}; @@ -23,6 +24,8 @@ pub struct ItemData<'db> { pub signature: Option, pub full_path: String, #[serde(skip_serializing)] + pub code_blocks: Vec, + #[serde(skip_serializing)] pub doc_location_links: Vec, pub group: Option, } @@ -41,13 +44,18 @@ impl<'db> ItemData<'db> { .map(|link| DocLocationLink::new(link.start, link.end, link.item_id, db)) .collect::>(); let group = find_groups_from_attributes(db, &id); + let full_path = id.full_path(db); + let doc = db.get_item_documentation_as_tokens(documentable_item_id); + let code_blocks = collect_code_blocks_from_tokens(&doc, &full_path); + Self { id: documentable_item_id, name: id.name(db).to_string(db), - doc: db.get_item_documentation_as_tokens(documentable_item_id), + doc, signature, full_path: format!("{}::{}", parent_full_path, id.name(db).long(db)), parent_full_path: Some(parent_full_path), + code_blocks, doc_location_links, group, } @@ -58,17 +66,22 @@ impl<'db> ItemData<'db> { id: impl TopLevelLanguageElementId<'db>, documentable_item_id: DocumentableItemId<'db>, ) -> Self { + let full_path = format!( + "{}::{}", + doc_full_path(&id.parent_module(db), db), + id.name(db).long(db) + ); + let doc = db.get_item_documentation_as_tokens(documentable_item_id); + let code_blocks = collect_code_blocks_from_tokens(&doc, &full_path); + Self { id: documentable_item_id, name: id.name(db).to_string(db), - doc: db.get_item_documentation_as_tokens(documentable_item_id), + doc, signature: None, - full_path: format!( - "{}::{}", - doc_full_path(&id.parent_module(db), db), - id.name(db).long(db) - ), - parent_full_path: Some(doc_full_path(&id.parent_module(db), db)), + full_path, + parent_full_path: Some(id.parent_module(db).full_path(db)), + code_blocks, doc_location_links: vec![], group: find_groups_from_attributes(db, &id), } @@ -76,13 +89,18 @@ impl<'db> ItemData<'db> { pub fn new_crate(db: &'db ScarbDocDatabase, id: CrateId<'db>) -> Self { let documentable_id = DocumentableItemId::Crate(id); + let full_path = ModuleId::CrateRoot(id).full_path(db); + let doc = db.get_item_documentation_as_tokens(documentable_id); + let code_blocks = collect_code_blocks_from_tokens(&doc, &full_path); + Self { id: documentable_id, name: id.long(db).name().to_string(db), - doc: db.get_item_documentation_as_tokens(documentable_id), + doc, signature: None, - full_path: ModuleId::CrateRoot(id).full_path(db), + full_path, parent_full_path: None, + code_blocks, doc_location_links: vec![], group: None, } @@ -102,6 +120,8 @@ pub struct SubItemData<'db> { pub signature: Option, pub full_path: String, #[serde(skip_serializing)] + pub code_blocks: Vec, + #[serde(skip_serializing)] pub doc_location_links: Vec, #[serde(skip_serializing)] pub group: Option, @@ -116,6 +136,7 @@ impl<'db> From> for ItemData<'db> { doc: val.doc, signature: val.signature, full_path: val.full_path, + code_blocks: val.code_blocks, doc_location_links: val.doc_location_links, group: val.group, } @@ -132,6 +153,7 @@ impl<'db> From> for SubItemData<'db> { signature: val.signature, full_path: val.full_path, doc_location_links: val.doc_location_links, + code_blocks: val.code_blocks, group: val.group, } } @@ -140,7 +162,7 @@ impl<'db> From> for SubItemData<'db> { fn documentation_serializer( docs: &Option>, serializer: S, -) -> anyhow::Result +) -> Result where S: Serializer, { diff --git a/extensions/scarb-doc/src/types/module_type.rs b/extensions/scarb-doc/src/types/module_type.rs index 38a136b51..227df9bbc 100644 --- a/extensions/scarb-doc/src/types/module_type.rs +++ b/extensions/scarb-doc/src/types/module_type.rs @@ -240,6 +240,53 @@ impl<'db> ModulePubUses<'db> { self.use_submodules.extend(use_submodules); self.use_macro_declarations.extend(use_macro_declarations); } + + pub fn get_all_item_ids<'a>(&'a self) -> IncludedItems<'a, 'db> { + let mut ids: IncludedItems<'a, 'db> = HashMap::default(); + + self.use_constants.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_free_functions.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_module_type_aliases.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_impl_aliases.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_extern_types.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_extern_functions.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_macro_declarations.iter().for_each(|item| { + ids.insert(item.item_data.id, &item.item_data); + }); + self.use_structs.iter().for_each(|struct_| { + ids.insert(struct_.item_data.id, &struct_.item_data); + ids.extend(struct_.get_all_item_ids()); + }); + self.use_enums.iter().for_each(|enum_| { + ids.insert(enum_.item_data.id, &enum_.item_data); + ids.extend(enum_.get_all_item_ids()); + }); + self.use_traits.iter().for_each(|trait_| { + ids.insert(trait_.item_data.id, &trait_.item_data); + ids.extend(trait_.get_all_item_ids()); + }); + self.use_impl_defs.iter().for_each(|impl_| { + ids.insert(impl_.item_data.id, &impl_.item_data); + ids.extend(impl_.get_all_item_ids()); + }); + self.use_submodules.iter().for_each(|sub_module| { + ids.extend(sub_module.get_all_item_ids()); + }); + + ids + } } macro_rules! define_insert_function { diff --git a/extensions/scarb-doc/src/types/other_types.rs b/extensions/scarb-doc/src/types/other_types.rs index d36522746..de6678e91 100644 --- a/extensions/scarb-doc/src/types/other_types.rs +++ b/extensions/scarb-doc/src/types/other_types.rs @@ -1,19 +1,22 @@ use crate::db::ScarbDocDatabase; use crate::docs_generation::markdown::context::IncludedItems; -use crate::docs_generation::markdown::traits::WithPath; +use crate::docs_generation::markdown::traits::WithItemDataCommon; use crate::types::item_data::{ItemData, SubItemData}; +use crate::types::module_type::is_doc_hidden_attr; use cairo_lang_defs::ids::NamedLanguageElementId; use cairo_lang_defs::ids::{ ConstantId, EnumId, ExternFunctionId, ExternTypeId, FreeFunctionId, ImplAliasId, ImplConstantDefId, ImplDefId, ImplFunctionId, ImplItemId, ImplTypeDefId, LanguageElementId, - LookupItemId, MacroDeclarationId, ModuleId, ModuleItemId, ModuleTypeAliasId, TraitConstantId, - TraitFunctionId, TraitId, TraitItemId, TraitTypeId, VariantId, + LookupItemId, MacroDeclarationId, MemberId, ModuleId, ModuleItemId, ModuleTypeAliasId, + StructId, TraitConstantId, TraitFunctionId, TraitId, TraitItemId, TraitTypeId, VariantId, }; use cairo_lang_diagnostics::Maybe; use cairo_lang_doc::documentable_item::DocumentableItemId; use cairo_lang_semantic::items::enm::EnumSemantic; use cairo_lang_semantic::items::imp::ImplSemantic; +use cairo_lang_semantic::items::structure::StructSemantic; use cairo_lang_semantic::items::trt::TraitSemantic; +use cairo_lang_semantic::items::visibility::Visibility; use cairo_lang_syntax::node::ast; use serde::Serialize; use std::collections::HashMap; @@ -89,6 +92,94 @@ impl<'db> FreeFunction<'db> { } } +#[derive(Serialize, Clone)] +pub struct Struct<'db> { + #[serde(skip)] + pub id: StructId<'db>, + #[serde(skip)] + pub node: ast::ItemStructPtr<'db>, + + pub members: Vec>, + + pub item_data: ItemData<'db>, +} + +impl<'db> Struct<'db> { + pub fn new( + db: &'db ScarbDocDatabase, + id: StructId<'db>, + include_private_items: bool, + ) -> Maybe { + let members = db.struct_members(id)?; + + let item_data = ItemData::new( + db, + id, + LookupItemId::ModuleItem(ModuleItemId::Struct(id)).into(), + doc_full_path(&id.parent_module(db), db), + ); + let members = members + .iter() + .filter_map(|(_, semantic_member)| { + let visible = matches!(semantic_member.visibility, Visibility::Public); + let syntax_node = &semantic_member.id.stable_location(db).syntax_node(db); + if (include_private_items || visible) && !is_doc_hidden_attr(db, syntax_node) { + Some(Ok(Member::new(db, semantic_member.id))) + } else { + None + } + }) + .collect::>>()?; + + let node = id.stable_ptr(db); + Ok(Self { + id, + node, + members, + item_data, + }) + } + + pub fn get_all_item_ids<'a>(&'a self) -> IncludedItems<'a, 'db> { + self.members + .iter() + .map(|item| { + ( + item.item_data.id, + &item.item_data as &dyn WithItemDataCommon, + ) + }) + .collect() + } +} + +#[derive(Serialize, Clone)] +pub struct Member<'db> { + #[serde(skip)] + pub id: MemberId<'db>, + #[serde(skip)] + pub node: ast::MemberPtr<'db>, + + pub item_data: SubItemData<'db>, +} + +impl<'db> Member<'db> { + pub fn new(db: &'db ScarbDocDatabase, id: MemberId<'db>) -> Self { + let node = id.stable_ptr(db); + + let parent_path = format!( + "{}::{}", + doc_full_path(&id.parent_module(db), db), + id.struct_id(db).name(db).to_string(db) + ); + Self { + id, + node, + item_data: ItemData::new(db, id, DocumentableItemId::Member(id), parent_path).into(), + } + } +} + #[derive(Serialize, Clone)] pub struct Enum<'db> { #[serde(skip)] @@ -128,7 +219,12 @@ impl<'db> Enum<'db> { pub fn get_all_item_ids<'a>(&'a self) -> IncludedItems<'a, 'db> { self.variants .iter() - .map(|item| (item.item_data.id, &item.item_data as &dyn WithPath)) + .map(|item| { + ( + item.item_data.id, + &item.item_data as &dyn WithItemDataCommon, + ) + }) .collect() } } diff --git a/extensions/scarb-doc/src/types/struct_types.rs b/extensions/scarb-doc/src/types/struct_types.rs index d9465c525..72c5b3540 100644 --- a/extensions/scarb-doc/src/types/struct_types.rs +++ b/extensions/scarb-doc/src/types/struct_types.rs @@ -1,6 +1,6 @@ use crate::db::ScarbDocDatabase; use crate::docs_generation::markdown::context::IncludedItems; -use crate::docs_generation::markdown::traits::WithPath; +use crate::docs_generation::markdown::traits::WithItemDataCommon; use crate::location_links::DocLocationLink; use crate::types::item_data::{ItemData, SubItemData}; use crate::types::module_type::is_doc_hidden_attr; @@ -61,7 +61,12 @@ impl<'db> Struct<'db> { pub fn get_all_item_ids<'a>(&'a self) -> IncludedItems<'a, 'db> { self.members .iter() - .map(|item| (item.item_data.id, &item.item_data as &dyn WithPath)) + .map(|item| { + ( + item.item_data.id, + &item.item_data as &dyn WithItemDataCommon, + ) + }) .collect() } } diff --git a/extensions/scarb-doc/tests/code/code_12.cairo b/extensions/scarb-doc/tests/code/code_12.cairo new file mode 100644 index 000000000..c5b20c2d5 --- /dev/null +++ b/extensions/scarb-doc/tests/code/code_12.cairo @@ -0,0 +1,33 @@ + /// Function that prints "foo" to stdout with endline. + /// Can invoke it like that: + /// ```runnable + /// foo(); + /// ``` + pub fn foo() { + println!("foo"); + } + + /// Function that prints "bar" to stdout with endline. + /// Can invoke it like that: + /// ```cairo + /// bar(); + /// ``` + pub fn bar() { + println!("bar"); + } + + /// Function that calls both foo and bar functions. + /// Can invoke it like that: + /// ```cairo,runnable + /// foo_bar(); + /// ``` + pub fn foo_bar() { + foo(); + bar(); + } + + + /// Main function that cairo runs as a binary entrypoint. + fn main() { + println!("hello_world"); + } diff --git a/extensions/scarb-doc/tests/code/code_13.cairo b/extensions/scarb-doc/tests/code/code_13.cairo new file mode 100644 index 000000000..ce03adfe0 --- /dev/null +++ b/extensions/scarb-doc/tests/code/code_13.cairo @@ -0,0 +1,13 @@ +/// Function with a runnable example that fails at compile time. +/// The example calls a function that doesn't exist: +/// ```cairo,runnable +/// undefined(); +/// ``` +pub fn foo() { + println!("foo"); +} + +fn main() { + println!("hello_world"); +} + diff --git a/extensions/scarb-doc/tests/code/code_14.cairo b/extensions/scarb-doc/tests/code/code_14.cairo new file mode 100644 index 000000000..4b1c18d25 --- /dev/null +++ b/extensions/scarb-doc/tests/code/code_14.cairo @@ -0,0 +1,13 @@ +/// Function with a runnable example that fails at runtime. +/// The example panics: +/// ```cairo,runnable +/// foo(); +/// ``` +pub fn foo() { + panic!("Runtime error occurred"); +} + +fn main() { + println!("hello_world"); +} + diff --git a/extensions/scarb-doc/tests/code/code_15.cairo b/extensions/scarb-doc/tests/code/code_15.cairo new file mode 100644 index 000000000..d7bd130fe --- /dev/null +++ b/extensions/scarb-doc/tests/code/code_15.cairo @@ -0,0 +1,20 @@ + /// Function that returns the sum of two integers. + /// Example 1: + /// ```cairo, runnable + /// let x = add(2, 3); + /// println!("{}", x); + /// ``` + /// Example 2: + /// ```cairo, runnable + /// fn main() -> i32 { + /// add(-1, 1) + /// } + /// ``` + pub fn add(a: i32, b: i32) -> i32 { + a + b + } + + /// Main function that cairo runs as a binary entrypoint. + fn main() { + println!("hello_world"); + } diff --git a/extensions/scarb-doc/tests/data/runnable_examples/book.toml b/extensions/scarb-doc/tests/data/runnable_examples/book.toml new file mode 100644 index 000000000..cbcfa69da --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/book.toml @@ -0,0 +1,19 @@ +[book] +authors = [""] +language = "en" +multilingual = false +src = "src" +title = "hello_world - Cairo" + +[output.html] +no-section-label = true + +[output.html.playground] +runnable = false + +[output.html.fold] +enable = true +level = 0 + +[output.html.code.hidelines] +cairo = "#" diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/SUMMARY.md b/extensions/scarb-doc/tests/data/runnable_examples/src/SUMMARY.md new file mode 100644 index 000000000..038151544 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/SUMMARY.md @@ -0,0 +1,5 @@ +- [hello_world](./hello_world.md) + - [Free functions](./hello_world-free_functions.md) + - [foo](./hello_world-foo.md) + - [bar](./hello_world-bar.md) + - [foo_bar](./hello_world-foo_bar.md) diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-bar.md b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-bar.md new file mode 100644 index 000000000..7fe0ce410 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-bar.md @@ -0,0 +1,12 @@ +# bar + +Function that prints "bar" to stdout with endline. +Can invoke it like that: +```cairo + bar(); +``` + +Fully qualified path: [hello_world](./hello_world.md)::[bar](./hello_world-bar.md) + +
pub fn bar()
+ diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo.md b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo.md new file mode 100644 index 000000000..264290a17 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo.md @@ -0,0 +1,12 @@ +# foo + +Function that prints "foo" to stdout with endline. +Can invoke it like that: +```runnable +foo(); +``` + +Fully qualified path: [hello_world](./hello_world.md)::[foo](./hello_world-foo.md) + +
pub fn foo()
+ diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo_bar.md b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo_bar.md new file mode 100644 index 000000000..4400713c3 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-foo_bar.md @@ -0,0 +1,18 @@ +# foo_bar + +Function that calls both foo and bar functions. +Can invoke it like that: +```cairo,runnable +foo_bar(); +``` +Output: +``` +foo +bar +``` + + +Fully qualified path: [hello_world](./hello_world.md)::[foo_bar](./hello_world-foo_bar.md) + +
pub fn foo_bar()
+ diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-free_functions.md b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-free_functions.md new file mode 100644 index 000000000..336c0f02b --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world-free_functions.md @@ -0,0 +1,8 @@ + +## [Free functions](./hello_world-free_functions.md) + +| | | +|:---|:---| +| [foo](./hello_world-foo.md) | Function that prints "foo" to stdout with endline. Can invoke it like that:... | +| [bar](./hello_world-bar.md) | Function that prints "bar" to stdout with endline. Can invoke it like that:... | +| [foo_bar](./hello_world-foo_bar.md) | Function that calls both foo and bar functions. Can invoke it like that:... | diff --git a/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world.md b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world.md new file mode 100644 index 000000000..404190827 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples/src/hello_world.md @@ -0,0 +1,12 @@ +# hello_world + +Fully qualified path: [hello_world](./hello_world.md) + + +## [Free functions](./hello_world-free_functions.md) + +| | | +|:---|:---| +| [foo](./hello_world-foo.md) | Function that prints "foo" to stdout with endline. Can invoke it like that:... | +| [bar](./hello_world-bar.md) | Function that prints "bar" to stdout with endline. Can invoke it like that:... | +| [foo_bar](./hello_world-foo_bar.md) | Function that calls both foo and bar functions. Can invoke it like that:... | diff --git a/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/book.toml b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/book.toml new file mode 100644 index 000000000..cbcfa69da --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/book.toml @@ -0,0 +1,19 @@ +[book] +authors = [""] +language = "en" +multilingual = false +src = "src" +title = "hello_world - Cairo" + +[output.html] +no-section-label = true + +[output.html.playground] +runnable = false + +[output.html.fold] +enable = true +level = 0 + +[output.html.code.hidelines] +cairo = "#" diff --git a/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/SUMMARY.md b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/SUMMARY.md new file mode 100644 index 000000000..9fc9b6790 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/SUMMARY.md @@ -0,0 +1,3 @@ +- [hello_world](./hello_world.md) + - [Free functions](./hello_world-free_functions.md) + - [add](./hello_world-add.md) diff --git a/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-add.md b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-add.md new file mode 100644 index 000000000..b3876f0e8 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-add.md @@ -0,0 +1,30 @@ +# add + +Function that returns the sum of two integers. +Example 1: +```cairo, runnable +let x = add(2, 3); +println!("{}", x); +``` + +Output: +``` +5 +``` + +Example 2: +```cairo, runnable +fn main() -> i32 { + add(-1, 1) +} +``` +Result: +``` +0 +``` + + +Fully qualified path: [hello_world](./hello_world.md)::[add](./hello_world-add.md) + +
pub fn add(a: i32, b: i32) -> i32
+ diff --git a/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-free_functions.md b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-free_functions.md new file mode 100644 index 000000000..237a2f634 --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world-free_functions.md @@ -0,0 +1,6 @@ + +## [Free functions](./hello_world-free_functions.md) + +| | | +|:---|:---| +| [add](./hello_world-add.md) | Function that returns the sum of two integers. Example 1:... | diff --git a/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world.md b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world.md new file mode 100644 index 000000000..8d89eb0fb --- /dev/null +++ b/extensions/scarb-doc/tests/data/runnable_examples_multiple_per_item/src/hello_world.md @@ -0,0 +1,10 @@ +# hello_world + +Fully qualified path: [hello_world](./hello_world.md) + + +## [Free functions](./hello_world-free_functions.md) + +| | | +|:---|:---| +| [add](./hello_world-add.md) | Function that returns the sum of two integers. Example 1:... | diff --git a/extensions/scarb-doc/tests/runnable_examples.rs b/extensions/scarb-doc/tests/runnable_examples.rs new file mode 100644 index 000000000..712e7e340 --- /dev/null +++ b/extensions/scarb-doc/tests/runnable_examples.rs @@ -0,0 +1,169 @@ +use assert_fs::TempDir; +use indoc::{formatdoc, indoc}; +use scarb_test_support::command::Scarb; +// use scarb_test_support::filesystem::dump_temp; +use scarb_test_support::project_builder::ProjectBuilder; +mod markdown_target; +use markdown_target::MarkdownTargetChecker; + +const CODE_WITH_RUNNABLE_CODE_BLOCKS: &str = include_str!("code/code_12.cairo"); +const CODE_WITH_COMPILE_ERROR: &str = include_str!("code/code_13.cairo"); +const CODE_WITH_RUNTIME_ERROR: &str = include_str!("code/code_14.cairo"); +const CODE_WITH_MULTIPLE_CODE_BLOCKS_PER_ITEM: &str = include_str!("code/code_15.cairo"); +const EXPECTED_WITH_EMBEDDINGS_PATH: &str = "tests/data/runnable_examples"; +const EXPECTED_MULTIPLE_PER_ITEM_PATH: &str = "tests/data/runnable_examples_multiple_per_item"; + +#[test] +fn supports_runnable_examples() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("hello_world") + .lib_cairo(CODE_WITH_RUNNABLE_CODE_BLOCKS) + .build(&t); + + Scarb::quick_command() + .arg("doc") + .args(["--output-format", "markdown"]) + .arg("--build") + .current_dir(&t) + .assert() + .success() + .stdout_eq(formatdoc! {r#" + [..] Running 3 doc examples for `hello_world` + test hello_world::bar ... ignored + test hello_world::foo ... ignored + [..] Compiling hello_world_example_1 v0.1.0 ([..]) + [..] Finished `dev` profile target(s) in [..] + [..] Executing hello_world_example_1 + foo + bar + Saving output to: target/execute/hello_world_example_1/execution1 + test hello_world::foo_bar ... ok + + test result: ok. 1 passed; 0 failed; 2 ignored + Saving output to: target/doc/hello_world + Saving build output to: target/doc/hello_world/book + + Run the following to see the results:[..] + `mdbook serve target/doc/hello_world` + + Or open the following in your browser:[..] + `[..]/target/doc/hello_world/book/index.html` + "#}); + + MarkdownTargetChecker::lenient() + .actual(t.path().join("target/doc/hello_world").to_str().unwrap()) + .expected(EXPECTED_WITH_EMBEDDINGS_PATH) + .assert_all_files_match(); +} + +#[test] +fn supports_runnable_examples_multiple_per_item() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("hello_world") + .lib_cairo(CODE_WITH_MULTIPLE_CODE_BLOCKS_PER_ITEM) + .build(&t); + + Scarb::quick_command() + .arg("doc") + .args(["--output-format", "markdown"]) + .arg("--build") + .current_dir(&t) + .assert() + .success() + .stdout_eq(formatdoc! {r#" + [..] Running 2 doc examples for `hello_world` + [..] Compiling hello_world_example_1 v0.1.0 ([..]) + [..] Finished `dev` profile target(s) in [..] + [..] Executing hello_world_example_1 + 5 + Saving output to: target/execute/hello_world_example_1/execution1 + test hello_world::add (example 0) ... ok + [..] Compiling hello_world_example_2 v0.1.0 ([..]) + [..] Finished `dev` profile target(s) in [..] + [..] Executing hello_world_example_2 + Saving output to: target/execute/hello_world_example_2/execution1 + test hello_world::add (example 1) ... ok + + test result: ok. 2 passed; 0 failed; 0 ignored + Saving output to: target/doc/hello_world + Saving build output to: target/doc/hello_world/book + + Run the following to see the results:[..] + `mdbook serve target/doc/hello_world` + + Or open the following in your browser:[..] + `[..]/target/doc/hello_world/book/index.html` + "#}); + + MarkdownTargetChecker::lenient() + .actual(t.path().join("target/doc/hello_world").to_str().unwrap()) + .expected(EXPECTED_MULTIPLE_PER_ITEM_PATH) + .assert_all_files_match(); +} + +#[test] +fn runnable_example_fails_at_compile_time() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("hello_world") + .lib_cairo(CODE_WITH_COMPILE_ERROR) + .build(&t); + + Scarb::quick_command() + .arg("doc") + .args(["--output-format", "markdown"]) + .arg("--build") + .current_dir(&t) + .assert() + .failure() + .stdout_eq(indoc! {r#" + [..] Running 1 doc examples for `hello_world` + [..] Compiling hello_world_example_1 v0.1.0 ([..]) + error[E0006]: Function not found. + --> [..]lib.cairo[..] + undefined(); + ^^^^^^^^^ + + error: could not compile `hello_world_example_1` due to 1 previous error + test hello_world::foo ... FAILED + + failures: + hello_world::foo + + test result: FAILED. 0 passed; 1 failed; 0 ignored + error: doc tests failed + "#}); +} + +#[test] +fn runnable_example_fails_at_runtime() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("hello_world") + .lib_cairo(CODE_WITH_RUNTIME_ERROR) + .build(&t); + + Scarb::quick_command() + .arg("doc") + .args(["--output-format", "markdown"]) + .arg("--build") + .current_dir(&t) + .assert() + .failure() + .stdout_eq(indoc! {r#" + [..] Running 1 doc examples for `hello_world` + [..] Compiling hello_world_example_1 v0.1.0 ([..]) + [..] Finished `dev` profile target(s) in [..] + [..] Executing hello_world_example_1 + error: Panicked with "Runtime error occurred". + test hello_world::foo ... FAILED + + failures: + hello_world::foo + + test result: FAILED. 0 passed; 1 failed; 0 ignored + error: doc tests failed + "#}); +} diff --git a/utils/scarb-extensions-cli/src/doc.rs b/utils/scarb-extensions-cli/src/doc.rs index 9958845b6..0beecd8c2 100644 --- a/utils/scarb-extensions-cli/src/doc.rs +++ b/utils/scarb-extensions-cli/src/doc.rs @@ -45,6 +45,10 @@ pub struct Args { #[arg(long, default_value_t = false)] pub build: bool, + /// Do not run doc tests. + #[arg(long, default_value_t = false)] + pub no_run: bool, + /// Specifies features to enable. #[command(flatten)] pub features: FeaturesSpec,