Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
version: 0.15.2
- uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy --all-targets --all-features -- --deny clippy::pedantic
run: cargo clippy --all-targets --all-features -- --deny clippy::pedantic --deny clippy::todo
- name: Install wasm32 target
run: rustup target add wasm32-unknown-unknown
- name: Run clippy (WASM editor)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions acdc-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`--watermark`, `--watermark-timestamp`, `--page`, `--theme`, `--plain`,
`--toc`, and `--emit-typst`; `--strict` now makes unresolved PDF images or
logos fail instead of falling back with a warning.
- Builds with the `execute` feature expose placeholder exact-ID, regex-ID,
dry-run, stop-on-failure, and parser safe-mode options for a future command
block runner. Invoking the command is not supported yet.
- The `terminal-emulator` build feature renders `[terminal]` session blocks
through `libghostty-vt` on the `--backend terminal` path. Requires a Zig
toolchain to build the bundled library, which is statically linked so the
Expand Down
4 changes: 3 additions & 1 deletion acdc-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ crossterm.workspace = true
miette = { version = "7", features = ["derive", "fancy"] }
open = "5"
rayon.workspace = true
regex = { version = "1", optional = true }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
Expand Down Expand Up @@ -81,6 +82,7 @@ terminal-emulator = [
]

# Development tools
execute = ["dep:regex"] # Skeleton for running command blocks from AsciiDoc
inspect = [] # AST inspection tool for debugging parser output
tck = [] # Technology Compatibility Kit for spec compliance testing

Expand All @@ -90,7 +92,7 @@ network = ["acdc-parser/network", "acdc-lint?/network", "acdc-converters-pdf?/ne

# Feature groups for convenience
all-backends = ["html", "manpage", "markdown", "pdf", "terminal"] # Enable all output converters
dev-tools = ["inspect"] # Enable development utilities
dev-tools = ["execute", "inspect"] # Enable development utilities
test-tools = ["tck"] # Enable testing and compliance tools

[lints.rust]
Expand Down
7 changes: 4 additions & 3 deletions acdc-cli/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,13 @@ echo '{"contents":"= Hello","path":"test.adoc","type":"block"}' \
| no
| Remote includes and remote PDF assets when PDF is selected.

| `inspect`, `tck`
| `execute`, `inspect`, `tck`
| no
| Developer/specification commands.
| Developer/specification commands; `execute` is currently scaffolding only.
|===

Convenience groups are `all-backends`, `dev-tools` (`inspect`), and `test-tools` (`tck`).
Convenience groups are `all-backends`, `dev-tools` (`execute`, `inspect`), and `test-tools` (`tck`).
The feature-gated `execute` command is developer scaffolding and is not functional yet.
A build with no command-enabling features exits with a diagnostic that lists the available command features.

== Diagnostics and exit status
Expand Down
14 changes: 14 additions & 0 deletions acdc-cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

use std::path::Path;

#[cfg(any(
feature = "html",
feature = "manpage",
feature = "markdown",
feature = "pdf",
feature = "terminal"
))]
use acdc_converters_core::Warning as ConverterWarning;
#[cfg(feature = "lint")]
use acdc_lint::{LintDiagnostic, LintLevel};
Expand Down Expand Up @@ -282,6 +289,13 @@ impl WarningReport for ParserWarning {
}
}

#[cfg(any(
feature = "html",
feature = "manpage",
feature = "markdown",
feature = "pdf",
feature = "terminal"
))]
impl WarningReport for ConverterWarning {
fn to_report(&self, context: WarningReportContext<'_>) -> Report {
build_warning_report(
Expand Down
19 changes: 18 additions & 1 deletion acdc-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "inspect",
feature = "lint",
feature = "tck",
Expand All @@ -16,6 +17,7 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "lint"
))]
mod error;
Expand All @@ -35,6 +37,7 @@ mod timing;
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "inspect",
feature = "lint",
feature = "tck",
Expand All @@ -53,6 +56,7 @@ struct Cli {
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "inspect",
feature = "lint",
feature = "tck",
Expand All @@ -69,6 +73,10 @@ enum Commands {
/// Convert `AsciiDoc` documents to various output formats
Convert(subcommands::convert::Args),

#[cfg(feature = "execute")]
/// Execute command blocks defined in `AsciiDoc` documents
Execute(subcommands::execute::Args),

#[cfg(feature = "inspect")]
/// Show a structural outline of an `AsciiDoc` document
Inspect(subcommands::inspect::Args),
Expand Down Expand Up @@ -104,6 +112,7 @@ fn setup_logging() {
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "inspect",
feature = "lint",
feature = "tck",
Expand All @@ -127,6 +136,8 @@ fn main() {
feature = "terminal"
))]
Commands::Convert(_) => true,
#[cfg(feature = "execute")]
Commands::Execute(_) => true,
#[cfg(feature = "inspect")]
Commands::Inspect(_) => true,
#[cfg(feature = "tck")]
Expand All @@ -142,6 +153,11 @@ fn main() {
))]
Commands::Convert(args) => subcommands::convert::run(&args),

#[cfg(feature = "execute")]
Commands::Execute(args) => {
subcommands::execute::run(&args).map_err(|e| miette::miette!("Execute failed: {e}"))
}

#[cfg(feature = "inspect")]
Commands::Inspect(args) => {
subcommands::inspect::run(&args).map_err(|e| miette::miette!("Inspect failed: {e}"))
Expand Down Expand Up @@ -188,6 +204,7 @@ fn main() {
feature = "markdown",
feature = "pdf",
feature = "terminal",
feature = "execute",
feature = "inspect",
feature = "lint",
feature = "tck",
Expand All @@ -196,7 +213,7 @@ fn main() {
setup_logging();
eprintln!(
"acdc was built without any subcommand features. Enable at least \
one of: html, manpage, markdown, pdf, terminal, inspect, lint, tck."
one of: html, manpage, markdown, pdf, terminal, execute, inspect, lint, tck."
);
std::process::exit(2);
}
2 changes: 2 additions & 0 deletions acdc-cli/src/subcommands/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,8 @@ mod tests {
let cli = crate::Cli::try_parse_from(raw).map_err(|error| miette::miette!(error))?;
match cli.command {
crate::Commands::Convert(args) => Ok(args),
#[cfg(feature = "execute")]
crate::Commands::Execute(_) => Err(miette::miette!("test command selected execute")),
#[cfg(feature = "inspect")]
crate::Commands::Inspect(_) => Err(miette::miette!("test command selected inspect")),
#[cfg(feature = "lint")]
Expand Down
204 changes: 204 additions & 0 deletions acdc-cli/src/subcommands/execute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//! Skeleton for executing command blocks from `AsciiDoc` files.
//!
//! # Handoff contract
//!
//! The implementation is expected to:
//!
//! - collect listing/source blocks carrying the `command` role and an explicit ID;
//! - recursively visit eligible nested blocks and preserve document order, including
//! blocks originating in included files;
//! - select every command when no selector is supplied, otherwise use the union of
//! exact and regex selectors without executing a command more than once;
//! - report missing selectors and duplicate command IDs as normal CLI diagnostics;
//! - return a failing CLI result if any command fails, with `--exit-on-failure`
//! controlling whether later commands are attempted.
//!
//! Before implementing execution, decide whether scripts use original verbatim source
//! text or parser-transformed inline content, which interpreter and working directory
//! apply, and which environment is inherited. Parser safe mode only limits document
//! reads; it does not sandbox commands.

use std::path::{Path, PathBuf};

use acdc_parser::{Document, Location, ParseResult, SafeMode};
use clap::{ArgAction, Args as ClapArgs};
use regex::Regex;

use crate::error::{self, WarningReport, WarningReportContext};

/// Execute command blocks defined in an `AsciiDoc` file
#[derive(ClapArgs, Debug)]
pub struct Args {
/// Input `AsciiDoc` file
pub file: PathBuf,

/// Select command blocks whose id exactly matches this value
#[arg(long = "id", value_name = "ID", action = ArgAction::Append)]
pub ids: Vec<String>,

/// Select command blocks whose id matches this regex
#[arg(long = "id-regex", value_name = "REGEX", action = ArgAction::Append)]
pub id_regexes: Vec<Regex>,

/// Print selected commands in execution order instead of running them
#[arg(long)]
pub dry_run: bool,

/// Stop at the first command that exits unsuccessfully
#[arg(long)]
pub exit_on_failure: bool,

/// Safe mode to use while parsing the document
///
/// This limits document reads and includes; it does not sandbox commands.
#[arg(short = 'S', long, value_parser = clap::value_parser!(SafeMode), default_value = "safe")]
pub safe_mode: SafeMode,
}

pub fn run(args: &Args) -> miette::Result<()> {
let parser_options = acdc_parser::Options::builder()
.with_safe_mode(args.safe_mode)
.build();
let parsed =
acdc_parser::parse_file(&args.file, &parser_options).map_err(|e| error::display(&e))?;
let parsed = report_warnings(parsed, &args.file);
let candidates = collect_command_candidates(parsed.document(), &args.file)?;
let plan = select_candidates(args, candidates)?;

if args.dry_run {
render_dry_run(&plan)?;
} else {
execute_plan(&plan, args.exit_on_failure)?;
}

Ok(())
}

#[allow(dead_code)]
#[derive(Debug)]
struct CommandCandidate {
id: String,
/// Planned command text. The implementation must decide whether this is original
/// verbatim source or reconstructed parser content before populating it.
script: String,
source: CommandSource,
}

#[allow(dead_code)]
#[derive(Debug)]
struct CommandSource {
/// Root document supplied on the command line.
root_document: PathBuf,
/// Parser-remapped location. Its positions retain the include chain for content
/// originating outside the root document.
location: Location,
}

#[allow(dead_code)]
#[derive(Debug)]
struct ExecutionPlan {
commands: Vec<CommandCandidate>,
}

fn report_warnings(parsed: ParseResult, file: &Path) -> ParseResult {
let context = WarningReportContext::new().with_optional_file(Some(file));
for warning in parsed.warnings() {
eprintln!("{:?}", warning.to_report(context));
}
parsed
}

fn collect_command_candidates(
_document: &Document<'_>,
_root_document: &Path,
) -> miette::Result<Vec<CommandCandidate>> {
todo!("collect [.command] listing/source blocks from the parsed AsciiDoc document")
}

fn select_candidates(
_args: &Args,
_candidates: Vec<CommandCandidate>,
) -> miette::Result<ExecutionPlan> {
todo!("apply --id and --id-regex selectors while preserving document order")
}

fn render_dry_run(_plan: &ExecutionPlan) -> miette::Result<()> {
todo!("print selected command scripts in the sequence they would be executed")
}

fn execute_plan(_plan: &ExecutionPlan, _exit_on_failure: bool) -> miette::Result<()> {
todo!("execute selected command scripts")
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use acdc_parser::{Block, DelimitedBlockType, Options, SafeMode};
use clap::Parser;

use super::Args;

#[derive(Parser)]
struct TestCli {
#[command(flatten)]
args: Args,
}

#[test]
fn parses_execute_skeleton_flags() {
let cli = TestCli::parse_from([
"test",
"README.adoc",
"--id",
"build",
"--id",
"test",
"--id-regex",
"^deploy-",
"--dry-run",
"--exit-on-failure",
]);

assert_eq!(cli.args.file, PathBuf::from("README.adoc"));
assert_eq!(cli.args.ids, ["build", "test"]);
assert_eq!(cli.args.id_regexes.len(), 1);
assert_eq!(
cli.args.id_regexes.first().map(regex::Regex::as_str),
Some("^deploy-")
);
assert!(cli.args.dry_run);
assert!(cli.args.exit_on_failure);
assert_eq!(cli.args.safe_mode, SafeMode::Safe);
}

#[test]
fn rejects_invalid_id_regex() {
let err = TestCli::try_parse_from(["test", "README.adoc", "--id-regex", "["]);
assert!(err.is_err());
}

#[test]
fn parses_the_planned_command_block_shape() -> miette::Result<()> {
let input = "[.command, id=build]\n----\necho hello\n----\n";
let parsed = acdc_parser::parse(input, &Options::default())
.map_err(|error| miette::miette!(error.to_string()))?;
let Some(Block::DelimitedBlock(block)) = parsed.document().blocks.first() else {
return Err(miette::miette!(
"expected command markup to parse as a delimited block"
));
};

assert!(matches!(
block.inner,
DelimitedBlockType::DelimitedListing(_)
));
assert_eq!(block.metadata.roles, ["command"]);
assert_eq!(
block.metadata.id.as_ref().map(|anchor| anchor.id),
Some("build")
);

Ok(())
}
}
Loading
Loading