From 57dd00a1a570118f59391b3f1c6bbe3e1e5d2f64 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 5 Mar 2026 16:16:04 -0500 Subject: [PATCH] Add `arc parse FILE.dot` subcommand to print raw AST as JSON Parses a DOT file and outputs its AST as pretty-printed JSON, useful for debugging and tooling. Adds Serialize/Deserialize to all AST types. Co-Authored-By: Claude Opus 4.6 --- crates/arc-cli/src/main.rs | 6 ++ crates/arc-workflows/src/cli/mod.rs | 7 +++ crates/arc-workflows/src/cli/parse.rs | 87 ++++++++++++++++++++++++++ crates/arc-workflows/src/parser/ast.rs | 14 +++-- 4 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 crates/arc-workflows/src/cli/parse.rs diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 06c316356..46c0396e3 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -38,6 +38,8 @@ enum Command { }, /// Validate a workflow Validate(arc_workflows::cli::ValidateArgs), + /// Parse a DOT file and print its AST + Parse(arc_workflows::cli::ParseArgs), /// List and test LLM models Models { #[command(subcommand)] @@ -98,6 +100,7 @@ async fn main() -> Result<()> { Command::Agent(_) => "agent", Command::Run { .. } => "run", Command::Validate(_) => "validate", + Command::Parse(_) => "parse", Command::Models { .. } => "models", Command::Serve(_) => "serve", Command::Doctor { .. } => "doctor", @@ -154,6 +157,9 @@ async fn main() -> Result<()> { let styles = arc_util::terminal::Styles::detect_stderr(); arc_workflows::cli::validate::validate_command(&args, &styles)?; } + Command::Parse(args) => { + arc_workflows::cli::parse::parse_command(&args)?; + } Command::Models { command } => arc_llm::cli::run_models(command).await?, Command::Serve(args) => { let styles: &'static arc_util::terminal::Styles = diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 6221e5a71..35b15c3d2 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -1,5 +1,6 @@ pub mod backend; pub mod cli_backend; +pub mod parse; pub mod progress; pub mod run; pub mod run_config; @@ -140,6 +141,12 @@ pub struct ValidateArgs { pub workflow: PathBuf, } +#[derive(Args)] +pub struct ParseArgs { + /// Path to the .dot workflow file + pub workflow: PathBuf, +} + /// Read a .dot file from disk. /// /// # Errors diff --git a/crates/arc-workflows/src/cli/parse.rs b/crates/arc-workflows/src/cli/parse.rs new file mode 100644 index 000000000..37becd18d --- /dev/null +++ b/crates/arc-workflows/src/cli/parse.rs @@ -0,0 +1,87 @@ +use crate::error::ArcError; +use crate::parser::{grammar, lexer}; + +use super::{read_dot_file, ParseArgs}; + +/// Parse a DOT file and print its raw AST as JSON. +/// +/// # Errors +/// +/// Returns an error if the file cannot be read, parsed, or contains trailing content. +pub fn parse_command(args: &ParseArgs) -> anyhow::Result<()> { + let source = read_dot_file(&args.workflow)?; + let stripped = lexer::strip_comments(&source); + let (rest, ast) = grammar::parse_dot_graph(&stripped) + .map_err(|e| ArcError::Parse(format!("grammar error: {e}")))?; + + let remaining = rest.trim(); + if !remaining.is_empty() { + return Err(ArcError::Parse(format!( + "unexpected trailing content: {:?}", + &remaining[..remaining.len().min(50)] + )) + .into()); + } + + println!("{}", serde_json::to_string_pretty(&ast)?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::DotGraph; + use std::io::Write; + use std::path::PathBuf; + + #[test] + fn parse_command_outputs_json_ast() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + r#"digraph Hello {{ + start [shape=Mdiamond] + exit [shape=Msquare] + start -> exit +}}"# + ) + .unwrap(); + + let args = ParseArgs { + workflow: tmp.path().to_path_buf(), + }; + let result = parse_command(&args); + assert!(result.is_ok(), "expected Ok but got: {result:?}"); + + // Re-run and capture stdout by reading the file through the parser directly + let source = std::fs::read_to_string(tmp.path()).unwrap(); + let stripped = lexer::strip_comments(&source); + let (_, ast) = grammar::parse_dot_graph(&stripped).unwrap(); + let json = serde_json::to_string_pretty(&ast).unwrap(); + let deserialized: DotGraph = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.name, "Hello"); + assert_eq!(deserialized.statements.len(), 3); + } + + #[test] + fn parse_command_rejects_invalid_dot() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "not a valid dot file").unwrap(); + + let args = ParseArgs { + workflow: tmp.path().to_path_buf(), + }; + let result = parse_command(&args); + assert!(result.is_err(), "expected Err for invalid syntax"); + } + + #[test] + fn parse_command_rejects_missing_file() { + let args = ParseArgs { + workflow: PathBuf::from("/tmp/nonexistent_parse_test_12345.dot"), + }; + let result = parse_command(&args); + assert!(result.is_err(), "expected Err for missing file"); + } +} diff --git a/crates/arc-workflows/src/parser/ast.rs b/crates/arc-workflows/src/parser/ast.rs index 6cdaa236c..ecea9730c 100644 --- a/crates/arc-workflows/src/parser/ast.rs +++ b/crates/arc-workflows/src/parser/ast.rs @@ -1,5 +1,7 @@ +use serde::{Deserialize, Serialize}; + /// A parsed DOT value before semantic interpretation. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AstValue { Str(String), Int(i64), @@ -13,14 +15,14 @@ pub enum AstValue { pub type AttrBlock = Vec<(String, AstValue)>; /// A node statement: `id [attrs]?`. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NodeStmt { pub id: String, pub attrs: Option, } /// An edge statement: `A -> B -> C [attrs]?`. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EdgeStmt { /// Chain of node IDs (at least 2). pub nodes: Vec, @@ -28,14 +30,14 @@ pub struct EdgeStmt { } /// A subgraph statement: `subgraph name? { stmts }`. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubgraphStmt { pub name: Option, pub statements: Vec, } /// A single statement in a DOT graph body. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Statement { /// `graph [attrs]` GraphAttr(AttrBlock), @@ -54,7 +56,7 @@ pub enum Statement { } /// The top-level parsed DOT graph. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DotGraph { pub name: String, pub statements: Vec,