From 7b71c4be95688585f1d37d120f12d0156d685274 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 17:47:12 +0800 Subject: [PATCH] feat(cli): modernize standalone command surface Fixes #43 --- Cargo.lock | 137 +++++++++++++++ README.md | 1 + crates/opy-cli/Cargo.toml | 2 + crates/opy-cli/src/cli.rs | 120 ++++++++++++++ crates/opy-cli/src/main.rs | 289 +++++++++++++++++--------------- crates/opy-cli/src/present.rs | 304 ++++++++++++++++++++++++++++++++++ crates/opy-cli/tests/cli.rs | 214 +++++++++++++++++++++++- docs/opy/tooling-api.md | 28 ++++ 8 files changed, 963 insertions(+), 132 deletions(-) create mode 100644 crates/opy-cli/src/cli.rs create mode 100644 crates/opy-cli/src/present.rs diff --git a/Cargo.lock b/Cargo.lock index 494c7bd..caf7433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -84,6 +134,61 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "copy_dir" version = "0.1.3" @@ -161,6 +266,18 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -241,10 +358,18 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opy-cli" version = "0.1.0" dependencies = [ + "clap", + "clap_complete", "opy-frontend", "serde", "serde_json", @@ -419,6 +544,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -453,6 +584,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" diff --git a/README.md b/README.md index db25f73..80d6803 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The standalone CLI exposes the current tooling surface: opy-cli check main.opy # diagnostics; exit 0 clean / 1 diagnostics opy-cli inspect main.opy # resolved semantic model as JSON opy-cli support --json # detailed machine-readable support data +opy-cli completion bash # static completion (zsh/fish/powershell also supported) opy-cli version ``` diff --git a/crates/opy-cli/Cargo.toml b/crates/opy-cli/Cargo.toml index 6dad762..b642ce1 100644 --- a/crates/opy-cli/Cargo.toml +++ b/crates/opy-cli/Cargo.toml @@ -10,6 +10,8 @@ description = "Standalone OPY frontend CLI: check, inspect, support-matrix, vers workspace = true [dependencies] +clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" opy-frontend = { path = "../opy-frontend" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/opy-cli/src/cli.rs b/crates/opy-cli/src/cli.rs new file mode 100644 index 0000000..13028af --- /dev/null +++ b/crates/opy-cli/src/cli.rs @@ -0,0 +1,120 @@ +//! The authoritative structured command model for `opy-cli`. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +/// One command model drives argv parsing, generated help, and completion. +#[derive(Debug, Parser)] +#[command( + name = "opy-cli", + disable_version_flag = true, + disable_help_subcommand = true, + subcommand_precedence_over_arg = true, + about = "Workshop-independent OPY frontend tooling", + after_help = EXIT_CODES +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Option, + + /// Print the same version identity as the `version` command. + #[arg(short = 'V', long, global = true, action = clap::ArgAction::SetTrue)] + pub(crate) version: bool, + + /// Presentation renderer; `auto` detects terminals, CI, and GitHub Actions. + #[arg(long, global = true, value_enum, default_value_t = RendererArg::Auto)] + pub(crate) renderer: RendererArg, + + /// ANSI color policy. + #[arg(long, global = true, value_enum, default_value_t = ColorArg::Auto)] + pub(crate) color: ColorArg, +} + +pub(crate) const EXIT_CODES: &str = "EXIT CODES: + 0 clean or successful operation + 1 source diagnostics + 2 usage or I/O error"; + +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Parse, preprocess, and resolve; print diagnostics to stderr. + Check(CheckArgs), + /// Print the resolved program model as JSON. + Inspect(FileArgs), + /// Print the compatibility support matrix or a filtered slice as JSON. + Support(SupportArgs), + /// Generate static shell completion from this command model. + Completion(CompletionArgs), + /// Show the top-level help. + Help, + /// Print crate and frontend protocol identities. + Version, +} + +#[derive(Debug, Args)] +pub(crate) struct CheckArgs { + #[command(flatten)] + pub(crate) file: FileArgs, + + /// Output format; JSON contains only the check result and diagnostics. + #[arg(long, value_enum, default_value_t = OutputFormatArg::Text)] + pub(crate) format: OutputFormatArg, +} + +#[derive(Debug, Args)] +pub(crate) struct FileArgs { + /// Main OPY source file. + #[arg(value_name = "MAIN.OPY")] + pub(crate) main: PathBuf, +} + +#[derive(Debug, Args)] +pub(crate) struct SupportArgs { + /// Explicitly request the existing JSON output (output is JSON by default). + #[arg(long)] + pub(crate) json: bool, + + /// Filter by category or feature id. + #[arg(value_name = "CATEGORY|FEATURE-ID")] + pub(crate) filter: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct CompletionArgs { + /// Shell to generate completion for. + #[arg(value_enum, value_name = "SHELL")] + pub(crate) shell: ShellArg, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum RendererArg { + Auto, + Terminal, + Plain, + #[value(name = "github-actions", alias = "github")] + GithubActions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ColorArg { + Auto, + Always, + Never, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum OutputFormatArg { + Text, + #[value(alias = "machine")] + Json, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ShellArg { + Bash, + Zsh, + Fish, + #[value(name = "powershell", alias = "pwsh")] + PowerShell, +} diff --git a/crates/opy-cli/src/main.rs b/crates/opy-cli/src/main.rs index 06dcb2b..8508346 100644 --- a/crates/opy-cli/src/main.rs +++ b/crates/opy-cli/src/main.rs @@ -1,162 +1,139 @@ -//! `opy-cli` — the standalone Workshop-independent OPY frontend CLI (issue -//! #7). +//! `opy-cli` — the standalone Workshop-independent OPY frontend CLI. //! -//! Surfaces the [`opy_frontend::tooling`] library API for frontend -//! validation and inspection without pretending Workshop emission is -//! available: +//! The CLI owns command parsing and presentation. `opy-frontend` owns OPY +//! parsing, semantic resolution, and structured diagnostics. //! -//! * `opy-cli check ` — preprocess/parse/resolve and print -//! structured diagnostics to stderr; exit 0 clean, 1 on diagnostics. -//! * `opy-cli inspect ` — print the resolved program model -//! (declarations, rules, references, enums) as JSON. -//! * `opy-cli support [--json] []` — print the embedded -//! compatibility support matrix (or a filtered slice) as JSON. -//! * `opy-cli version` — crate version and frontend protocol identity. -//! -//! Exit codes: 0 clean/success, 1 diagnostics found, 2 usage or I/O errors. -//! Workshop emission and decompilation are deliberately out of scope; -//! lowering-dependent gaps are reported through `support` and documented in -//! `docs/opy/tooling-api.md`. +//! Exit codes remain: 0 clean/success, 1 source diagnostics, and 2 usage or +//! I/O errors. Machine-readable output is written directly to stdout without +//! passing through human or GitHub Actions presentation. + +mod cli; +mod present; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::ExitCode; +use clap::{CommandFactory, Parser, error::ErrorKind}; +use clap_complete::{generate, shells}; use opy_frontend::support::{self, SupportMatrixError}; -use opy_frontend::tooling::{Diagnostic, check}; +use opy_frontend::tooling::{CheckOutcome, Diagnostic as FrontendDiagnostic, check}; use opy_frontend::{FRONTEND_NAME, FRONTEND_VERSION}; +use serde::Serialize; -const USAGE: &str = "\ -opy-cli — Workshop-independent OPY frontend tooling. - -Usage: - opy-cli check Parse, preprocess, and resolve; print diagnostics to stderr. - opy-cli inspect Print the resolved program model (declarations, rules, references) as JSON. - opy-cli support [--json] [] Print the compatibility support matrix (or a filtered slice) as JSON. - opy-cli version Print the crate version and frontend protocol identity. - opy-cli help Show this help. - -Exit codes: 0 clean, 1 diagnostics found, 2 usage or I/O errors."; +use crate::cli::{CheckArgs, Cli, Command, FileArgs, OutputFormatArg, SupportArgs}; +use crate::present::{ + CheckView, DiagnosticSeverity, DiagnosticView, PositionView, Presentation, SpanView, +}; fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); - match args.first().map(String::as_str) { + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(error) => match error.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + print!("{error}"); + return ExitCode::SUCCESS; + } + _ => { + if error.kind() == ErrorKind::InvalidSubcommand { + eprintln!("opy-cli: unknown command"); + } + eprint!("{error}"); + return ExitCode::from(2); + } + }, + }; + + let presentation = Presentation::from_cli(cli.renderer, cli.color); + if cli.version { + return cmd_version(); + } + match cli.command { None => { - eprintln!("{USAGE}"); + eprintln!("{}", Cli::command().render_help()); ExitCode::from(2) } - Some("check") => cmd_check(&args[1..]), - Some("inspect") => cmd_inspect(&args[1..]), - Some("support") => cmd_support(&args[1..]), - Some("version" | "-V" | "--version") => cmd_version(), - Some("help" | "-h" | "--help") => { - println!("{USAGE}"); + Some(Command::Check(args)) => cmd_check(&args, presentation), + Some(Command::Inspect(args)) => cmd_inspect(&args, presentation), + Some(Command::Support(args)) => cmd_support(&args), + Some(Command::Completion(args)) => cmd_completion(args.shell), + Some(Command::Help) => { + print!("{}", Cli::command().render_help()); ExitCode::SUCCESS } - Some(other) => { - eprintln!("opy-cli: unknown command '{other}'"); - eprintln!("{USAGE}"); - ExitCode::from(2) - } + Some(Command::Version) => cmd_version(), } } -/// One positional main-file argument; I/O errors are usage-class failures. -fn read_main(args: &[String]) -> Result<(String, String), ExitCode> { - let Some(main) = args.first() else { - eprintln!("opy-cli: missing argument"); - return Err(ExitCode::from(2)); - }; - if args.len() > 1 { - eprintln!("opy-cli: unexpected extra arguments after '{main}'"); - return Err(ExitCode::from(2)); - } +fn read_main(args: &FileArgs) -> Result<(String, PathBuf, PathBuf), ExitCode> { + let main = &args.main; match std::fs::read_to_string(main) { Ok(text) => { - let root = Path::new(main) + let root = main .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or(Path::new(".")); - Ok((text, root.to_string_lossy().into_owned())) + Ok((text, root.to_path_buf(), main.clone())) } Err(error) => { - eprintln!("opy-cli: cannot read '{main}': {error}"); + eprintln!("opy-cli: cannot read '{}': {error}", main.display()); Err(ExitCode::from(2)) } } } -fn cmd_check(args: &[String]) -> ExitCode { - let (text, root) = match read_main(args) { +fn cmd_check(args: &CheckArgs, presentation: Presentation) -> ExitCode { + let (text, root, main) = match read_main(&args.file) { Ok(parsed) => parsed, Err(code) => return code, }; - let main = args[0].clone(); - let outcome = check(&text, &main, Path::new(&root)); - if outcome.is_clean() { - let model = outcome - .model - .as_ref() - .expect("a clean check produces a model"); - println!( - "check passed: {} file(s), {} declaration(s), {} rule entry(ies), {} symbol(s)", - outcome.files.len(), - model.declarations().len(), - model.rules().len(), - model.symbols().len(), - ); - ExitCode::SUCCESS - } else { - for diagnostic in &outcome.diagnostics { - eprintln!("{}", format_diagnostic(diagnostic)); - } - ExitCode::from(1) + let outcome = check(&text, &main.to_string_lossy(), &root); + if args.format == OutputFormatArg::Json { + return match print_json(&CheckReport { + ok: outcome.is_clean(), + diagnostics: &outcome.diagnostics, + }) { + Ok(()) => diagnostic_exit(&outcome), + Err(code) => code, + }; } + + let code = diagnostic_exit(&outcome); + presentation.render_check(&check_view(&outcome)); + code } -fn cmd_inspect(args: &[String]) -> ExitCode { - let (text, root) = match read_main(args) { +fn cmd_inspect(args: &FileArgs, presentation: Presentation) -> ExitCode { + let (text, root, main) = match read_main(args) { Ok(parsed) => parsed, Err(code) => return code, }; - let main = args[0].clone(); - let outcome = check(&text, &main, Path::new(&root)); + let outcome = check(&text, &main.to_string_lossy(), &root); if !outcome.is_clean() { - for diagnostic in &outcome.diagnostics { - eprintln!("{}", format_diagnostic(diagnostic)); - } + let diagnostics = outcome + .diagnostics + .iter() + .map(diagnostic_view) + .collect::>(); + presentation.render_diagnostics("inspect", &diagnostics); return ExitCode::from(1); } let model = outcome .model .as_ref() .expect("a clean check produces a model"); - print_json(model) + match print_json(model) { + Ok(()) => ExitCode::SUCCESS, + Err(code) => code, + } } -fn cmd_support(args: &[String]) -> ExitCode { - let mut filter: Option<&str> = None; - for arg in args { - match arg.as_str() { - "--json" => {} // Output is always JSON; accepted for explicitness. - "-h" | "--help" => { - println!( - "Print the embedded compatibility support matrix as JSON.\n\ - Usage: opy-cli support [--json] []" - ); - return ExitCode::SUCCESS; - } - _ if filter.is_none() => filter = Some(arg), - _ => { - eprintln!("opy-cli: unexpected argument '{arg}'"); - return ExitCode::from(2); - } - } - } +fn cmd_support(args: &SupportArgs) -> ExitCode { + let _json_flag_is_accepted_for_compatibility = args.json; let matrix = match support::SupportMatrix::builtin() { Ok(matrix) => matrix, Err(error) => return matrix_error_exit(error), }; - let value = match filter { + let value = match args.filter.as_deref() { None => serde_json::to_value(matrix).expect("the matrix is serializable"), Some(filter) => { if let Some(feature) = matrix.feature(filter) { @@ -181,7 +158,24 @@ fn cmd_support(args: &[String]) -> ExitCode { } } }; - print_json(&value) + match print_json(&value) { + Ok(()) => ExitCode::SUCCESS, + Err(code) => code, + } +} + +fn cmd_completion(shell: cli::ShellArg) -> ExitCode { + let mut command = Cli::command(); + let mut stdout = std::io::stdout(); + match shell { + cli::ShellArg::Bash => generate(shells::Bash, &mut command, "opy-cli", &mut stdout), + cli::ShellArg::Zsh => generate(shells::Zsh, &mut command, "opy-cli", &mut stdout), + cli::ShellArg::Fish => generate(shells::Fish, &mut command, "opy-cli", &mut stdout), + cli::ShellArg::PowerShell => { + generate(shells::PowerShell, &mut command, "opy-cli", &mut stdout) + } + } + ExitCode::SUCCESS } fn cmd_version() -> ExitCode { @@ -195,15 +189,68 @@ fn cmd_version() -> ExitCode { ExitCode::SUCCESS } -fn print_json(value: &T) -> ExitCode { +#[derive(Serialize)] +struct CheckReport<'a> { + ok: bool, + diagnostics: &'a [FrontendDiagnostic], +} + +fn check_view(outcome: &CheckOutcome) -> CheckView { + CheckView { + clean: outcome.is_clean(), + diagnostics: outcome.diagnostics.iter().map(diagnostic_view).collect(), + file_count: outcome.files.len(), + declaration_count: outcome + .model + .as_ref() + .map_or(0, |model| model.declarations().len()), + rule_count: outcome + .model + .as_ref() + .map_or(0, |model| model.rules().len()), + symbol_count: outcome + .model + .as_ref() + .map_or(0, |model| model.symbols().len()), + } +} + +fn diagnostic_view(diagnostic: &FrontendDiagnostic) -> DiagnosticView { + DiagnosticView { + severity: DiagnosticSeverity::Error, + code: diagnostic.code.clone(), + message: diagnostic.message.clone(), + span: diagnostic.span.as_ref().map(|span| SpanView { + path: span.path.clone(), + start: PositionView { + line: span.start.line, + col: span.start.col, + }, + end: PositionView { + line: span.end.line, + col: span.end.col, + }, + }), + } +} + +fn diagnostic_exit(outcome: &CheckOutcome) -> ExitCode { + if outcome.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } +} + +fn print_json(value: &T) -> Result<(), ExitCode> { match serde_json::to_string_pretty(value) { Ok(rendered) => { println!("{rendered}"); - ExitCode::SUCCESS + Ok(()) } Err(error) => { eprintln!("opy-cli: cannot serialize output: {error}"); - ExitCode::from(2) + Err(ExitCode::from(2)) } } } @@ -212,23 +259,3 @@ fn matrix_error_exit(error: SupportMatrixError) -> ExitCode { eprintln!("opy-cli: the embedded support matrix is invalid: {error}"); ExitCode::from(2) } - -fn format_diagnostic(diagnostic: &Diagnostic) -> String { - match &diagnostic.span { - Some(span) => format!( - "{}[{}]: {}\n --> {}:{}:{}", - diagnostic.severity.as_str(), - diagnostic.code, - diagnostic.message, - span.path, - span.start.line, - span.start.col, - ), - None => format!( - "{}[{}]: {}", - diagnostic.severity.as_str(), - diagnostic.code, - diagnostic.message, - ), - } -} diff --git a/crates/opy-cli/src/present.rs b/crates/opy-cli/src/present.rs new file mode 100644 index 0000000..41b697e --- /dev/null +++ b/crates/opy-cli/src/present.rs @@ -0,0 +1,304 @@ +//! CLI-local presentation policy for human and GitHub Actions output. +//! +//! `opy-frontend` owns structured diagnostics. This module owns only their +//! terminal, plain, and GitHub Actions presentation. Machine-readable output +//! is rendered by the command handlers before this boundary is entered. + +use std::io::{IsTerminal, Write}; + +use crate::cli::{ColorArg, RendererArg}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiagnosticSeverity { + Error, +} + +impl DiagnosticSeverity { + fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PositionView { + pub(crate) line: u32, + pub(crate) col: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SpanView { + pub(crate) path: String, + pub(crate) start: PositionView, + pub(crate) end: PositionView, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiagnosticView { + pub(crate) severity: DiagnosticSeverity, + pub(crate) code: String, + pub(crate) message: String, + pub(crate) span: Option, +} + +pub(crate) struct CheckView { + pub(crate) clean: bool, + pub(crate) diagnostics: Vec, + pub(crate) file_count: usize, + pub(crate) declaration_count: usize, + pub(crate) rule_count: usize, + pub(crate) symbol_count: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Presentation { + renderer: Renderer, + color: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Renderer { + Terminal, + Plain, + GithubActions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RuntimeEnvironment { + github_actions: bool, + ci: bool, + stdout_terminal: bool, + no_color: bool, +} + +impl RuntimeEnvironment { + fn process() -> Self { + Self { + github_actions: env_truthy("GITHUB_ACTIONS"), + ci: env_truthy("CI"), + stdout_terminal: std::io::stdout().is_terminal(), + no_color: std::env::var_os("NO_COLOR").is_some(), + } + } +} + +impl Presentation { + pub(crate) fn from_cli(renderer: RendererArg, color: ColorArg) -> Self { + Self::resolve(renderer, color, RuntimeEnvironment::process()) + } + + fn resolve(renderer: RendererArg, color: ColorArg, environment: RuntimeEnvironment) -> Self { + let renderer = match renderer { + RendererArg::Terminal => Renderer::Terminal, + RendererArg::Plain => Renderer::Plain, + RendererArg::GithubActions => Renderer::GithubActions, + RendererArg::Auto => { + if environment.github_actions { + Renderer::GithubActions + } else if environment.ci || !environment.stdout_terminal { + Renderer::Plain + } else { + Renderer::Terminal + } + } + }; + let color = match color { + ColorArg::Always => renderer == Renderer::Terminal, + ColorArg::Never => false, + ColorArg::Auto => renderer == Renderer::Terminal && !environment.no_color, + }; + Self { renderer, color } + } + + pub(crate) fn render_check(&self, view: &CheckView) { + match self.renderer { + Renderer::GithubActions => self.render_github_check(view), + Renderer::Terminal | Renderer::Plain => { + for diagnostic in &view.diagnostics { + eprintln!("{}", format_diagnostic(diagnostic, self.color)); + } + if view.clean { + let summary = format!( + "check passed: {} file(s), {} declaration(s), {} rule entry(ies), {} symbol(s)", + view.file_count, view.declaration_count, view.rule_count, view.symbol_count, + ); + if self.color { + println!("\x1b[32m{summary}\x1b[0m"); + } else { + println!("{summary}"); + } + } + } + } + } + + pub(crate) fn render_diagnostics(&self, command: &str, diagnostics: &[DiagnosticView]) { + match self.renderer { + Renderer::GithubActions => { + for diagnostic in diagnostics { + emit_diagnostic_annotation(diagnostic); + } + eprintln!( + "::group::{}", + escape_workflow_data(&format!("opy-cli {command}")) + ); + eprintln!("ERROR {command} ({} diagnostic(s))", diagnostics.len()); + eprintln!("::endgroup::"); + emit_summary(command, "ERROR"); + } + Renderer::Terminal | Renderer::Plain => { + for diagnostic in diagnostics { + eprintln!("{}", format_diagnostic(diagnostic, self.color)); + } + } + } + } + + fn render_github_check(&self, view: &CheckView) { + for diagnostic in &view.diagnostics { + emit_diagnostic_annotation(diagnostic); + } + eprintln!("::group::opy-cli check"); + if view.clean { + eprintln!("PASS check"); + eprintln!("::endgroup::"); + emit_summary("check", "PASS"); + } else { + eprintln!("ERROR check ({} diagnostic(s))", view.diagnostics.len()); + eprintln!("::endgroup::"); + emit_summary("check", "ERROR"); + } + } +} + +fn env_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(value) => !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" + ), + Err(_) => false, + } +} + +fn format_diagnostic(diagnostic: &DiagnosticView, color: bool) -> String { + let location = diagnostic.span.as_ref().map(|span| { + format!( + "\n --> {}:{}:{}", + span.path, span.start.line, span.start.col + ) + }); + let message = if color { + format!("\x1b[31m{}\x1b[0m", diagnostic.message) + } else { + diagnostic.message.clone() + }; + format!( + "{}[{}]: {}{}", + diagnostic.severity.as_str(), + diagnostic.code, + message, + location.unwrap_or_default() + ) +} + +fn emit_diagnostic_annotation(diagnostic: &DiagnosticView) { + let kind = diagnostic.severity.as_str(); + let mut properties = vec![format!( + "title={}", + escape_workflow_property(&diagnostic.code) + )]; + if let Some(span) = diagnostic + .span + .as_ref() + .filter(|span| is_real_source_path(&span.path)) + { + properties.insert(0, format!("file={}", escape_workflow_property(&span.path))); + properties.push(format!("line={}", span.start.line)); + properties.push(format!("col={}", span.start.col)); + properties.push(format!("endLine={}", span.end.line)); + properties.push(format!("endColumn={}", span.end.col)); + } + eprintln!( + "::{kind} {}::{}", + properties.join(","), + escape_workflow_data(&diagnostic.message) + ); +} + +fn emit_summary(command: &str, status: &str) { + let line = format!("opy-cli `{command}`: **{status}**"); + if let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") { + let result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| writeln!(file, "{line}")); + if let Err(error) = result { + eprintln!( + "::warning title=opy-cli summary::{}", + escape_workflow_data(&error.to_string()) + ); + } + } else { + eprintln!( + "::notice title=opy-cli summary::{}", + escape_workflow_data(&line) + ); + } +} + +/// Workflow command properties additionally escape `:` and `,`. +pub(crate) fn escape_workflow_property(value: &str) -> String { + escape_workflow_data(value) + .replace(':', "%3A") + .replace(',', "%2C") +} + +/// Workflow command data escapes the GitHub Actions command delimiters. +pub(crate) fn escape_workflow_data(value: &str) -> String { + value + .replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +fn is_real_source_path(path: &str) -> bool { + !path.is_empty() && !path.starts_with('<') +} + +#[cfg(test)] +mod tests { + use super::{RuntimeEnvironment, escape_workflow_data, escape_workflow_property}; + use crate::cli::{ColorArg, RendererArg}; + + #[test] + fn workflow_values_are_escaped_at_the_correct_boundary() { + assert_eq!(escape_workflow_property("a:b,c%\n"), "a%3Ab%2Cc%25%0A"); + assert_eq!(escape_workflow_data("a:b,c%\n"), "a:b,c%25%0A"); + } + + #[test] + fn explicit_renderer_and_color_override_environment_selection() { + let environment = RuntimeEnvironment { + github_actions: true, + ci: true, + stdout_terminal: false, + no_color: true, + }; + let github = + super::Presentation::resolve(RendererArg::GithubActions, ColorArg::Always, environment); + assert_eq!(github.renderer, super::Renderer::GithubActions); + assert!(!github.color); + + let terminal = + super::Presentation::resolve(RendererArg::Terminal, ColorArg::Always, environment); + assert_eq!(terminal.renderer, super::Renderer::Terminal); + assert!(terminal.color); + + let plain = super::Presentation::resolve(RendererArg::Auto, ColorArg::Auto, environment); + assert_eq!(plain.renderer, super::Renderer::GithubActions); + assert!(!plain.color); + } +} diff --git a/crates/opy-cli/tests/cli.rs b/crates/opy-cli/tests/cli.rs index 32f253d..2402b56 100644 --- a/crates/opy-cli/tests/cli.rs +++ b/crates/opy-cli/tests/cli.rs @@ -17,7 +17,13 @@ const MULTI_MAIN: &str = concat!( ); fn run(args: &[&str]) -> std::process::Output { - bin().args(args).output().expect("the binary runs") + run_with_env(args, &[]) +} + +fn run_with_env(args: &[&str], vars: &[(&str, &str)]) -> std::process::Output { + let mut command = bin(); + command.env_clear().args(args).envs(vars.iter().copied()); + command.output().expect("the binary runs") } /// A fresh temp directory for per-test source files (recoverable and @@ -71,6 +77,24 @@ fn check_missing_file_is_an_io_usage_error() { fn check_without_arguments_is_a_usage_error() { let output = run(&["check"]); assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("")); + assert!(output.stdout.is_empty()); +} + +#[test] +fn check_json_input_errors_stay_human_stderr_only() { + for args in [ + vec!["check", "--format", "json"], + vec!["check", "--format", "json", "/nonexistent/opy/nope.opy"], + ] { + let output = run(&args); + assert_eq!(output.status.code(), Some(2), "args: {args:?}"); + assert!(output.stdout.is_empty(), "args: {args:?}"); + assert!(!output.stderr.is_empty(), "args: {args:?}"); + } + + let unreadable = run(&["check", "--format", "json", "/nonexistent/opy/nope.opy"]); + assert!(String::from_utf8_lossy(&unreadable.stderr).contains("cannot read")); } #[test] @@ -162,3 +186,191 @@ fn unknown_command_is_a_usage_error() { assert_eq!(output.status.code(), Some(2)); assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command")); } + +#[test] +fn help_and_parse_are_driven_by_the_structured_command_model() { + let help = run(&["--help"]); + assert_eq!(help.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&help.stdout); + for expected in [ + "opy-cli", + "check", + "inspect", + "support", + "completion", + "--renderer", + "--color", + ] { + assert!( + stdout.contains(expected), + "help missing {expected}: {stdout}" + ); + } + assert!(help.stderr.is_empty(), "help belongs on stdout"); + + let invalid = run(&["completion", "invalid-shell"]); + assert_eq!(invalid.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("possible values")); +} + +#[test] +fn all_static_completion_shells_are_generated_from_the_same_model() { + let cases = [ + ("bash", "_opy__cli"), + ("zsh", "#compdef opy-cli"), + ("fish", "complete"), + ("powershell", "Register-ArgumentCompleter"), + ]; + for (shell, marker) in cases { + let output = run(&["completion", shell]); + assert_eq!(output.status.code(), Some(0), "shell: {shell}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains(marker), "shell {shell}: {stdout}"); + assert!( + stdout.contains("completion"), + "model command missing: {shell}" + ); + assert!(output.stderr.is_empty(), "completion stderr: {shell}"); + } +} + +#[test] +fn github_actions_renderer_uses_annotations_and_step_summary_without_stdout() { + let dir = temp_dir("github"); + let main = dir.join("bad.opy"); + let summary = dir.join("summary.md"); + std::fs::write(&main, "rule \"r\":\n @Event global\n frobnicate()\n").unwrap(); + let main = main.to_str().unwrap(); + let summary = summary.to_str().unwrap(); + let output = run_with_env( + &["check", main], + &[ + ("GITHUB_ACTIONS", "true"), + ("CI", "true"), + ("GITHUB_STEP_SUMMARY", summary), + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty(), "GitHub presentation uses stderr"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("::error file="), "annotation: {stderr}"); + assert!(stderr.contains("::group::opy-cli check"), "group: {stderr}"); + assert!(stderr.contains("ERROR check"), "status: {stderr}"); + assert!( + String::from_utf8_lossy(&std::fs::read(summary).unwrap()).contains("**ERROR**"), + "step summary" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn explicit_renderer_and_color_override_injected_environment() { + let terminal = run_with_env( + &[ + "check", + "--renderer", + "terminal", + "--color", + "always", + MULTI_MAIN, + ], + &[ + ("GITHUB_ACTIONS", "true"), + ("CI", "true"), + ("NO_COLOR", "1"), + ], + ); + assert_eq!(terminal.status.code(), Some(0)); + assert!(terminal.stdout.starts_with(b"\x1b[32m")); + assert!(!String::from_utf8_lossy(&terminal.stdout).contains("::")); + + let plain = run_with_env( + &[ + "check", + "--renderer", + "plain", + "--color", + "always", + MULTI_MAIN, + ], + &[("GITHUB_ACTIONS", "true"), ("CI", "true")], + ); + assert_eq!(plain.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&plain.stdout).contains("\x1b[")); + assert!(!String::from_utf8_lossy(&plain.stdout).contains("::")); + + let no_color = run_with_env( + &[ + "check", + "--renderer", + "terminal", + "--color", + "auto", + MULTI_MAIN, + ], + &[("NO_COLOR", "1")], + ); + assert_eq!(no_color.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&no_color.stdout).contains("\x1b[")); +} + +#[test] +fn machine_json_stays_pure_under_github_and_color_environment() { + let dir = temp_dir("json-purity"); + let main = dir.join("bad.opy"); + std::fs::write(&main, "rule \"r\":\n @Event global\n frobnicate()\n").unwrap(); + let output = run_with_env( + &[ + "check", + "--format", + "json", + "--renderer", + "github-actions", + "--color", + "always", + main.to_str().unwrap(), + ], + &[("GITHUB_ACTIONS", "true"), ("NO_COLOR", "")], + ); + assert_eq!(output.status.code(), Some(1)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("pure JSON"); + assert_eq!(json["ok"], false); + assert!(!json["diagnostics"].as_array().unwrap().is_empty()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("\x1b[")); + assert!(!stdout.contains("::")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn github_workflow_path_properties_are_escaped() { + let dir = temp_dir("workflow-%,:"); + let main = dir.join("bad.opy"); + std::fs::write(&main, "rule \"r\":\n @Event global\n frobnicate()\n").unwrap(); + let output = run_with_env( + &[ + "check", + "--renderer", + "github-actions", + main.to_str().unwrap(), + ], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + let encoded = main + .to_str() + .unwrap() + .replace('%', "%25") + .replace(':', "%3A") + .replace(',', "%2C"); + assert!( + stderr.contains(&format!("file={encoded}")), + "escaped path: {stderr}" + ); + assert!( + output.stdout.is_empty(), + "workflow output must stay off stdout" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/opy/tooling-api.md b/docs/opy/tooling-api.md index 824e593..0e403c4 100644 --- a/docs/opy/tooling-api.md +++ b/docs/opy/tooling-api.md @@ -125,8 +125,10 @@ documented in the matrix itself. Workshop-dependent items stay ``` opy-cli check # diagnostics → stderr; 0 clean / 1 diagnostics +opy-cli check --format json # machine JSON result/diagnostics on stdout opy-cli inspect # resolved model as JSON on stdout opy-cli support [--json] [] # embedded matrix (or slice) as JSON +opy-cli completion bash|zsh|fish|powershell # static completion from the command model opy-cli version # crate + frontend protocol identity ``` @@ -136,6 +138,32 @@ directory. The CLI runs anywhere the binary runs: no Node, no Workshop backend, no runtime data files (the matrix and the semantic manifest are embedded). +### Presentation candidate for Issue #43 + +The following additive CLI surface is implemented as a candidate pending the +main-thread contract review; existing command defaults and exit codes remain +unchanged: + +* `--renderer auto|terminal|plain|github-actions` selects presentation. In + `auto`, truthy `GITHUB_ACTIONS` selects GitHub Actions, then truthy `CI` or a + non-TTY selects plain output, and an interactive terminal selects terminal + output. An explicit renderer overrides this detection. +* `--color auto|always|never` controls ANSI color. An explicit color policy + overrides `NO_COLOR`; `auto` disables color when `NO_COLOR` is present. + GitHub Actions never receives ANSI even when `always` is requested. +* `--format json` is currently available on `check`. It writes only `{ok, + diagnostics}` JSON to stdout and returns the same 0/1 result code. `inspect` + and `support` remain JSON by default and bypass presentation entirely. + If the required input path is missing, or the path cannot be read, the CLI + cannot produce a machine result: it returns exit `2`, writes the human I/O + error to stderr, and leaves stdout empty. + +The GitHub Actions renderer writes source-located diagnostics as escaped +workflow annotations on stderr, groups a concise PASS/ERROR status, and +appends `opy-cli` status to `GITHUB_STEP_SUMMARY` when that variable names a +usable file. Human and workflow presentation never contaminates machine JSON +stdout. + ## Known limitations * `def NAME():` bodies resolve, but calls resolve only against `subroutine