mod example;
mod failure;
mod flavor;
mod input;
mod output;
mod png;
mod report;
mod skill;
mod tree;
mod view;
mod workflow_choice;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand, ValueEnum};
use svugy_model::model::ShownWorkflow;
use svugy_parse::json::JsonPrinter;
use crate::example::ExampleCommand;
use crate::failure::Failure;
use crate::flavor::SvgFlavor;
use crate::input::{LoadedInput, ModelInput};
use crate::output::{ModelFile, OutputTarget};
use crate::png::PngExport;
use crate::report::InputReport;
use crate::skill::SkillCommand;
use crate::tree::SourceTree;
use crate::view::{LIVE_VIEW_HELP, ViewCommand};
use crate::workflow_choice::{ShownWorkflows, WorkflowChoice};
const EXIT_CODES_HELP: &str = "\
New here? svugy example -o webapp.md writes an example diagram, and
svugy render webapp.md -o webapp.svg --open draws it.
INPUT is a Markdown diagram (.md), a d2 file (.d2) or a JSON model (.json), told
apart by its extension. Diagnostics go to stderr, except for validate --format
json, which prints them on stdout. Warnings never stop a subcommand; errors
always do.
Exit codes, identical for every subcommand:
0 success, warnings included
1 the input has errors: Markdown syntax, d2 syntax, malformed JSON or model
validation; or validate finds a stale code reference; or import -o FILE.md
meets a model Markdown cannot say
2 usage error: unknown flag, missing argument, INPUT not .md, .d2 or .json,
render --workflow naming no workflow of the input, render --all-workflows
with an -o naming no file stem, or render --depth past the workflow's
deepest layer
3 I/O failure: a file cannot be read, written or opened, or view cannot
listen on its port";
const VALIDATE_REPORT_HELP: &str = "\
Code references:
Each component's code=FILE#SYMBOL is checked against the files on disk, FILE
relative to the git checkout holding INPUT (else INPUT's directory). FILE
must exist, and it must spell each `::`- or `.`-separated segment of SYMBOL
as a whole identifier. A miss is a svugy::stale_code_reference error. Only
validate runs this check; render, view and import never read the tree.
Formats:
human miette's graphical report on stderr, with source snippets for .md and
d2 input
json one JSON object on stdout, printed even when the input is clean
The json report is miette's JSON report of the whole input:
message \"<INPUT>: <N> error(s), <M> warning(s)\"
code \"svugy::input_report\"
severity \"error\" if any diagnostic is one, else \"warning\", or \"advice\" when clean
related one object per diagnostic: Markdown errors, d2 syntax errors then
d2 subset warnings, or the malformed-JSON error; then model
problems; then stale code references
Each related object:
message what is wrong; a Markdown error ends with its did-you-mean
code stable diagnostic code, e.g. \"svugy::markdown\", \"svugy::d2::syntax\",
\"svugy::dangling_reference\"
severity \"error\" or \"warning\"
help the did-you-mean of a d2 diagnostic or model problem; the key is
absent when there is none
filename INPUT for a labelled diagnostic of a .md or d2 input; \"\" or absent
otherwise
labels [{\"label\": text, \"span\": {\"offset\": byte offset, \"length\": bytes},
\"line\": 1-based line, \"column\": 1-based byte column}]
line and column are absent for a JSON model, whose spans point
into the text it was imported from; a malformed-JSON message
ends with serde's line and column instead
causes underlying error messages, when there are any
related always []";
#[derive(Parser)]
#[command(name = "svugy", version, about, after_long_help = EXIT_CODES_HELP)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Render(RenderCommand),
#[command(after_long_help = LIVE_VIEW_HELP)]
View(ViewCommand),
#[command(after_long_help = VALIDATE_REPORT_HELP)]
Validate(ValidateCommand),
Import(ImportCommand),
Skill(SkillCommand),
Example(ExampleCommand),
}
#[derive(Args)]
struct RenderCommand {
#[arg(value_parser = ModelInput::parse)]
input: ModelInput,
#[command(flatten)]
flavor: SvgFlavor,
#[command(flatten)]
workflow_choice: WorkflowChoice,
#[command(flatten)]
png_export: PngExport,
#[command(flatten)]
output: OutputTarget,
#[arg(long, requires = "output", conflicts_with = "all_workflows")]
open: bool,
}
#[derive(Args)]
struct ValidateCommand {
#[arg(value_parser = ModelInput::parse)]
input: ModelInput,
#[arg(long, value_enum, default_value_t = ReportFormat::Human)]
format: ReportFormat,
}
#[derive(Args)]
struct ImportCommand {
#[arg(value_parser = ModelInput::parse)]
input: ModelInput,
#[arg(short, long, value_parser = ModelFile::parse)]
output: Option<ModelFile>,
}
#[derive(Clone, Copy, ValueEnum)]
enum ReportFormat {
Human,
Json,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let outcome = cli.command.run();
let Err(failure) = outcome else {
return ExitCode::SUCCESS;
};
failure.eprint();
failure.exit_code()
}
impl Command {
fn run(self) -> Result<(), Failure> {
match self {
Self::Render(render_command) => render_command.run(),
Self::View(view_command) => view_command.run(),
Self::Validate(validate_command) => validate_command.run(),
Self::Import(import_command) => import_command.run(),
Self::Skill(skill_command) => skill_command.run(),
Self::Example(example_command) => example_command.run(),
}
}
}
impl RenderCommand {
fn run(self) -> Result<(), Failure> {
let model = self.input.load_model_printing_diagnostics()?;
let shown_workflows = self.workflow_choice.shown_workflows(&model)?;
match shown_workflows {
ShownWorkflows::One(shown_workflow) => self.write_opening_it_if_asked(shown_workflow),
ShownWorkflows::Each(each_workflow) => self.write_per_workflow(each_workflow),
}
}
fn write_opening_it_if_asked(&self, shown_workflow: ShownWorkflow<'_>) -> Result<(), Failure> {
self.write_workflow(shown_workflow, &self.output)?;
let is_open_requested = self.open;
if is_open_requested {
self.output.open_in_default_browser()?;
}
Ok(())
}
fn write_per_workflow(&self, each_workflow: Vec<ShownWorkflow<'_>>) -> Result<(), Failure> {
let output_path = self
.output
.path()
.expect("clap requires -o with --all-workflows");
let is_png_requested = self.png_export.is_requested();
let extension = if is_png_requested { "png" } else { "svg" };
for shown_workflow in each_workflow {
let workflow = shown_workflow.workflow();
let workflow_id = workflow.id();
let workflow_tag = workflow_id.as_str();
let workflow_path = OutputTarget::tagged_path(output_path, workflow_tag, extension)?;
let workflow_output = OutputTarget::file(workflow_path);
self.write_workflow(shown_workflow, &workflow_output)?;
}
Ok(())
}
fn write_workflow(
&self,
shown_workflow: ShownWorkflow<'_>,
output: &OutputTarget,
) -> Result<(), Failure> {
let is_png_requested = self.png_export.is_requested();
if is_png_requested {
let theme = self.flavor.theme();
return self.png_export.write(shown_workflow, theme, output);
}
let svg = self.flavor.render(shown_workflow);
output.write(svg)
}
}
impl ValidateCommand {
fn run(self) -> Result<(), Failure> {
let LoadedInput { model, report } = self.input.load()?;
let input_path = self.input.path();
let source_tree = SourceTree::holding(input_path);
let stale_code_references = match &model {
Some(admitted_model) => source_tree.find_stale_code_references(admitted_model),
None => Vec::new(),
};
let report = report.with_stale_code_references(stale_code_references);
self.format.print(&report)?;
let has_errors = report.has_errors();
if has_errors {
return Err(Failure::InvalidInput);
}
Ok(())
}
}
impl ReportFormat {
fn print(self, report: &InputReport) -> Result<(), Failure> {
match self {
Self::Human => {
report.eprint_diagnostics();
Ok(())
}
Self::Json => {
let json_report = report.to_json();
OutputTarget::write_stdout(&json_report)
}
}
}
}
impl ImportCommand {
fn run(self) -> Result<(), Failure> {
let model = self.input.load_model_printing_diagnostics()?;
let Some(model_file) = self.output else {
let model_json = JsonPrinter::print(&model);
return OutputTarget::write_stdout(&model_json);
};
model_file.write(&model)
}
}