use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use aion_awl::{CompileError, CompiledWorkflow, Span};
use aion_package::CanonicalJson;
use clap::{Subcommand, ValueEnum};
#[derive(Debug, Subcommand)]
pub(crate) enum AwlCommand {
Check {
file: PathBuf,
},
Fmt {
file: PathBuf,
},
Emit {
file: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = EmitTarget::Gleam)]
target: EmitTarget,
},
Scaffold {
file: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
worker: Option<String>,
#[arg(long)]
aion_crates: Option<PathBuf>,
},
Schema {
file: PathBuf,
#[arg(long, conflicts_with = "queries")]
r#type: Option<String>,
#[arg(long, conflicts_with = "type")]
queries: bool,
},
Guide {
word: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with_all = ["word", "json"])]
reference: bool,
},
Recipe {
#[command(subcommand)]
command: crate::awl_recipe::RecipeCommand,
},
Lsp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub(crate) enum EmitTarget {
#[default]
Gleam,
Beam,
}
pub(crate) fn run(command: &AwlCommand) -> ExitCode {
match command {
AwlCommand::Check { file } => check_command(file),
AwlCommand::Fmt { file } => fmt_command(file),
AwlCommand::Emit {
file,
output,
target,
} => emit_command(file, output.as_deref(), *target),
AwlCommand::Scaffold {
file,
out,
worker,
aion_crates,
} => crate::awl_scaffold::run(file, out, worker.as_deref(), aion_crates.as_deref()),
AwlCommand::Schema {
file,
r#type,
queries,
} => schema_command(file, r#type.as_deref(), *queries),
AwlCommand::Guide {
word,
json,
reference,
} => crate::awl_guide::run(word.as_deref(), *json, *reference),
AwlCommand::Recipe { command } => crate::awl_recipe::run(command),
AwlCommand::Lsp => match aion_awl_lsp::run_stdio() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("error: AWL language server failed: {error}");
ExitCode::FAILURE
}
},
}
}
fn check_command(file: &Path) -> ExitCode {
let Some(source) = read_source(file) else {
return ExitCode::FAILURE;
};
match check_source(file, &source) {
Ok(summary) => {
println!("ok: {} ({summary})", file.display());
ExitCode::SUCCESS
}
Err(diagnostics) => report(&diagnostics),
}
}
fn fmt_command(file: &Path) -> ExitCode {
let Some(source) = read_source(file) else {
return ExitCode::FAILURE;
};
match format_source(file, &source) {
Ok(formatted) => {
if let Err(error) = fs::write(file, formatted) {
eprintln!("error: failed to write {}: {error}", file.display());
return ExitCode::FAILURE;
}
println!("formatted: {}", file.display());
ExitCode::SUCCESS
}
Err(diagnostics) => report(&diagnostics),
}
}
fn emit_command(file: &Path, output: Option<&Path>, target: EmitTarget) -> ExitCode {
match target {
EmitTarget::Gleam => emit_gleam_command(file, output),
EmitTarget::Beam => emit_beam_command(file, output),
}
}
fn emit_gleam_command(file: &Path, output: Option<&Path>) -> ExitCode {
let Some(source) = read_source(file) else {
return ExitCode::FAILURE;
};
match emit_artifact_source(file, &source) {
Ok(artifact) => {
if let Some(output) = output {
if let Err(error) = fs::write(output, &artifact.source) {
eprintln!("error: failed to write {}: {error}", output.display());
return ExitCode::FAILURE;
}
if let Err(error) = write_entry_sidecar(output, &artifact) {
eprintln!("error: failed to write generated entry metadata: {error}");
return ExitCode::FAILURE;
}
println!("emitted: {}", output.display());
} else {
print!("{}", artifact.source);
}
ExitCode::SUCCESS
}
Err(diagnostics) => report(&diagnostics),
}
}
fn emit_beam_command(file: &Path, output: Option<&Path>) -> ExitCode {
let Some(output) = output else {
eprintln!(
"error: `--target beam` requires `--output` \
(BEAM bytes are never written to stdout)"
);
return ExitCode::FAILURE;
};
let Some(source) = read_source(file) else {
return ExitCode::FAILURE;
};
let compiled = match aion_awl::compile(&source, document_root(file)) {
Ok(compiled) => compiled,
Err(error) => return report(&compile_diagnostics(file, &error)),
};
if let Err(error) = write_beam_artifact(output, &compiled) {
eprintln!("error: failed to write {}: {error}", output.display());
return ExitCode::FAILURE;
}
println!("emitted: {}", output.display());
ExitCode::SUCCESS
}
fn write_beam_artifact(
output: &Path,
compiled: &CompiledWorkflow,
) -> Result<(), Box<dyn std::error::Error>> {
fs::write(output, &compiled.beam_bytes)?;
let mut sidecar_path = OsString::from(output.as_os_str());
sidecar_path.push(".json");
fs::write(
sidecar_path,
serde_json::to_vec_pretty(&beam_sidecar(compiled))?,
)?;
Ok(())
}
fn beam_sidecar(compiled: &CompiledWorkflow) -> CanonicalJson {
let actions = compiled
.actions
.iter()
.map(|action| {
serde_json::json!({
"task_queue": action.task_queue,
"action": action.action,
"node": action.node,
})
})
.collect::<Vec<_>>();
let synthesized = compiled
.synthesized_workflows
.iter()
.map(|entry| {
serde_json::json!({
"workflow_type": entry.workflow_type,
"entry_module": entry.entry_module,
"entry_function": entry.entry_function,
"timeout_seconds": entry.timeout.map(|timeout| timeout.as_secs()),
"input_schema": entry.input_schema,
"output_schema": entry.output_schema,
"internal": entry.internal,
})
})
.collect::<Vec<_>>();
CanonicalJson::new(serde_json::json!({
"target": "beam",
"workflow_name": compiled.workflow_name,
"timeout_seconds": compiled.timeout.map(|timeout| timeout.as_secs()),
"input_schema": compiled.input_schema,
"output_schema": compiled.output_schema,
"actions": actions,
"synthesized_workflows": synthesized,
}))
}
pub(crate) fn compile_diagnostics(file: &Path, error: &CompileError) -> Vec<String> {
match error {
CompileError::Parse(parse) => vec![diagnostic(file, parse.span, &parse.message)],
CompileError::Check(errors) => errors
.iter()
.map(|check| diagnostic(file, check.span, &check.message))
.collect(),
CompileError::Schema(schema) => vec![diagnostic(file, schema.span(), &schema.to_string())],
CompileError::Unsupported { shape, span } => {
vec![diagnostic(
file,
*span,
&format!("does not yet lower {shape}"),
)]
}
CompileError::Family { message, span } | CompileError::Lower { message, span } => {
vec![diagnostic(file, *span, message)]
}
CompileError::Planning { message } | CompileError::Backend { message } => {
vec![format!("{}: error: {message}", file.display())]
}
}
}
fn schema_command(file: &Path, type_name: Option<&str>, queries: bool) -> ExitCode {
let Some(source) = read_source(file) else {
return ExitCode::FAILURE;
};
match schema_source(file, &source, type_name, queries) {
Ok(schema) => {
print!("{schema}");
ExitCode::SUCCESS
}
Err(diagnostics) => report(&diagnostics),
}
}
fn check_summary(document: &aion_awl::Document) -> String {
match document.family {
aion_awl::DocumentFamily::Workflow => {
let steps = document.steps.len();
let noun = if steps == 1 { "step" } else { "steps" };
format!("{steps} {noun}")
}
aion_awl::DocumentFamily::Worker => {
let actions: usize = document
.workers
.iter()
.map(|worker| worker.actions.len())
.sum();
let noun = if actions == 1 { "action" } else { "actions" };
format!("worker `{}`, {actions} {noun}", document.name)
}
}
}
fn check_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
let document = aion_awl::parse(source)
.map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
let errors = aion_awl::check_in(&document, document_root(file));
if errors.is_empty() {
Ok(check_summary(&document))
} else {
Err(errors
.iter()
.map(|error| diagnostic(file, error.span, &error.message))
.collect())
}
}
fn format_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
let document = aion_awl::parse(source)
.map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
Ok(aion_awl::print(&document))
}
#[cfg(test)]
fn emit_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
Ok(emit_artifact_source(file, source)?.source)
}
fn emit_artifact_source(
file: &Path,
source: &str,
) -> Result<aion_awl::EmittedArtifact, Vec<String>> {
let document = aion_awl::parse(source)
.map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
let root = document_root(file);
let errors = aion_awl::check_in(&document, root);
if !errors.is_empty() {
return Err(errors
.iter()
.map(|error| diagnostic(file, error.span, &error.message))
.collect());
}
aion_awl::emit_artifact_in(&document, root)
.map_err(|error| vec![diagnostic(file, error.span, &error.message)])
}
fn write_entry_sidecar(
output: &Path,
artifact: &aion_awl::EmittedArtifact,
) -> Result<(), Box<dyn std::error::Error>> {
let path = output.with_extension("awl.json");
if artifact.synthesized_workflows.is_empty() {
match fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
return Ok(());
}
fs::write(
path,
serde_json::to_vec_pretty(&artifact.project_metadata())?,
)?;
Ok(())
}
pub(crate) fn document_root(file: &Path) -> &Path {
match file.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
}
}
fn schema_source(
file: &Path,
source: &str,
requested_type: Option<&str>,
queries: bool,
) -> Result<String, Vec<String>> {
let document = aion_awl::parse(source)
.map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
let root = document_root(file);
let errors = aion_awl::check_in(&document, root);
if !errors.is_empty() {
return Err(errors
.iter()
.map(|error| diagnostic(file, error.span, &error.message))
.collect());
}
let derived = match (requested_type, queries) {
(Some(name), _) => aion_awl::schema_for_type_in(&document, root, name),
(None, true) => aion_awl::schema_for_queries_in(&document, root),
(None, false) => aion_awl::schema_for_workflow_in(&document, root),
};
let schema =
derived.map_err(|error| vec![diagnostic(file, error.span(), &error.to_string())])?;
serde_json::to_string_pretty(&CanonicalJson::new(schema))
.map(|json| format!("{json}\n"))
.map_err(|error| vec![diagnostic(file, document.span, &error.to_string())])
}
fn diagnostic(file: &Path, span: Span, message: &str) -> String {
format!(
"{}:{}:{}: error: {message}",
file.display(),
span.line,
span.column
)
}
pub(crate) fn report(diagnostics: &[String]) -> ExitCode {
for line in diagnostics {
eprintln!("{line}");
}
ExitCode::FAILURE
}
pub(crate) fn read_source(file: &Path) -> Option<String> {
match fs::read_to_string(file) {
Ok(source) => Some(source),
Err(error) => {
eprintln!("error: failed to read {}: {error}", file.display());
None
}
}
}
#[cfg(test)]
#[path = "awl_tests.rs"]
mod tests;