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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 16:16:04 -05:00
parent 872107e4d6
commit 57dd00a1a5
4 changed files with 108 additions and 6 deletions

View file

@ -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 =

View file

@ -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

View file

@ -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");
}
}

View file

@ -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<AttrBlock>,
}
/// 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<String>,
@ -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<String>,
pub statements: Vec<Statement>,
}
/// 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<Statement>,