From d24a71ee047d2db1ca55ccae2e8a41f29b00c1e5 Mon Sep 17 00:00:00 2001 From: KtorZ Date: Wed, 13 Nov 2024 11:01:58 +0100 Subject: [PATCH] Define new event target for JSON, and revert option passing For the program to be consistent, the 'EventListener' target that we pass to a Project should be responsible for the output format. Otherwise, we are contingent on developers to remember passing the option at call-site. Plus, it overloads the project code with an extra boolean option. Instead, since the behaviour is solely driven by the execution context, we can instantiate a different event target upfront, and simply hold on to it throughout the program. As a nice side-effect, we can gently re-organize the code to keep the terminal printing logic and the json printing logic separate. --- crates/aiken-lsp/src/server/lsp_project.rs | 1 - crates/aiken-project/src/lib.rs | 29 +- crates/aiken-project/src/options.rs | 2 - crates/aiken-project/src/telemetry.rs | 593 ++---------------- crates/aiken-project/src/telemetry/json.rs | 137 ++++ .../aiken-project/src/telemetry/terminal.rs | 434 +++++++++++++ crates/aiken-project/src/watch.rs | 10 +- crates/aiken/src/cmd/build.rs | 2 - crates/aiken/src/cmd/check.rs | 4 - 9 files changed, 618 insertions(+), 594 deletions(-) create mode 100644 crates/aiken-project/src/telemetry/json.rs create mode 100644 crates/aiken-project/src/telemetry/terminal.rs diff --git a/crates/aiken-lsp/src/server/lsp_project.rs b/crates/aiken-lsp/src/server/lsp_project.rs index 7cd786ac4..8a13170f5 100644 --- a/crates/aiken-lsp/src/server/lsp_project.rs +++ b/crates/aiken-lsp/src/server/lsp_project.rs @@ -38,7 +38,6 @@ impl LspProject { PropertyTest::DEFAULT_MAX_SUCCESS, Tracing::verbose(), None, - false, ); self.project.restore(checkpoint); diff --git a/crates/aiken-project/src/lib.rs b/crates/aiken-project/src/lib.rs index b628673ad..0e1901bd1 100644 --- a/crates/aiken-project/src/lib.rs +++ b/crates/aiken-project/src/lib.rs @@ -197,13 +197,11 @@ where uplc: bool, tracing: Tracing, env: Option, - json: bool, ) -> Result<(), Vec> { let options = Options { code_gen_mode: CodeGenMode::Build(uplc), tracing, env, - json, }; self.compile(options) @@ -227,7 +225,7 @@ where let mut modules = self.parse_sources(self.config.name.clone())?; - self.type_check(&mut modules, Tracing::silent(), None, false, false)?; + self.type_check(&mut modules, Tracing::silent(), None, false)?; let destination = destination.unwrap_or_else(|| self.root.join("docs")); @@ -269,7 +267,6 @@ where property_max_success: usize, tracing: Tracing, env: Option, - json: bool, ) -> Result<(), Vec> { let options = Options { tracing, @@ -285,7 +282,6 @@ where property_max_success, } }, - json, }; self.compile(options) @@ -347,7 +343,6 @@ where root: self.root.clone(), name: self.config.name.to_string(), version: self.config.version.clone(), - json: options.json, }); let env = options.env.as_deref(); @@ -358,7 +353,7 @@ where let mut modules = self.parse_sources(self.config.name.clone())?; - self.type_check(&mut modules, options.tracing, env, true, options.json)?; + self.type_check(&mut modules, options.tracing, env, true)?; match options.code_gen_mode { CodeGenMode::Build(uplc_dump) => { @@ -405,8 +400,7 @@ where self.collect_tests(verbose, match_tests, exact_match, options.tracing)?; if !tests.is_empty() { - self.event_listener - .handle_event(Event::RunningTests { json: options.json }); + self.event_listener.handle_event(Event::RunningTests); } let tests = self.run_tests(tests, seed, property_max_success); @@ -433,11 +427,8 @@ where }) .collect(); - self.event_listener.handle_event(Event::FinishedTests { - seed, - tests, - json: options.json, - }); + self.event_listener + .handle_event(Event::FinishedTests { seed, tests }); if !errors.is_empty() { Err(errors) @@ -646,11 +637,7 @@ where Ok(blueprint) } - fn with_dependencies( - &mut self, - parsed_packages: &mut ParsedModules, - json: bool, - ) -> Result<(), Vec> { + fn with_dependencies(&mut self, parsed_packages: &mut ParsedModules) -> Result<(), Vec> { let manifest = deps::download(&self.event_listener, &self.root, &self.config)?; for package in manifest.packages { @@ -661,7 +648,6 @@ where root: lib.clone(), name: package.name.to_string(), version: package.version.clone(), - json, }); self.read_package_source_files(&lib.join("lib"))?; @@ -850,11 +836,10 @@ where tracing: Tracing, env: Option<&str>, validate_module_name: bool, - json: bool, ) -> Result<(), Vec> { let our_modules: BTreeSet = modules.keys().cloned().collect(); - self.with_dependencies(modules, json)?; + self.with_dependencies(modules)?; for name in modules.sequence(&our_modules)? { if let Some(module) = modules.remove(&name) { diff --git a/crates/aiken-project/src/options.rs b/crates/aiken-project/src/options.rs index e90d82802..c15517063 100644 --- a/crates/aiken-project/src/options.rs +++ b/crates/aiken-project/src/options.rs @@ -4,7 +4,6 @@ pub struct Options { pub code_gen_mode: CodeGenMode, pub tracing: Tracing, pub env: Option, - pub json: bool, } impl Default for Options { @@ -13,7 +12,6 @@ impl Default for Options { code_gen_mode: CodeGenMode::NoOp, tracing: Tracing::silent(), env: None, - json: false, } } } diff --git a/crates/aiken-project/src/telemetry.rs b/crates/aiken-project/src/telemetry.rs index aa259e922..3a8dee75e 100644 --- a/crates/aiken-project/src/telemetry.rs +++ b/crates/aiken-project/src/telemetry.rs @@ -1,14 +1,18 @@ -use crate::pretty; use aiken_lang::{ - ast::OnTestFailure, expr::UntypedExpr, - format::Formatter, test_framework::{PropertyTestResult, TestResult, UnitTestResult}, }; -use owo_colors::{OwoColorize, Stream::Stderr}; -use serde_json::json; -use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; -use uplc::machine::cost_model::ExBudget; +pub use json::Json; +use std::{ + collections::BTreeMap, + fmt::Display, + io::{self, IsTerminal}, + path::PathBuf, +}; +pub use terminal::Terminal; + +mod json; +mod terminal; pub trait EventListener { fn handle_event(&self, _event: Event) {} @@ -19,7 +23,6 @@ pub enum Event { name: String, version: String, root: PathBuf, - json: bool, }, BuildingDocumentation { name: String, @@ -39,13 +42,10 @@ pub enum Event { name: String, path: PathBuf, }, - RunningTests { - json: bool, - }, + RunningTests, FinishedTests { seed: u32, tests: Vec>, - json: bool, }, WaitingForBuildDirLock, ResolvingPackages { @@ -62,568 +62,45 @@ pub enum Event { ResolvingVersions, } -pub enum DownloadSource { - Network, - Cache, +pub enum EventTarget { + Json(Json), + Terminal(Terminal), } -impl Display for DownloadSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DownloadSource::Network => write!(f, "network"), - DownloadSource::Cache => write!(f, "cache"), +impl Default for EventTarget { + fn default() -> Self { + if io::stdout().is_terminal() { + EventTarget::Terminal(Terminal) + } else { + EventTarget::Json(Json) } } } -#[derive(Debug, Default, Clone, Copy)] -pub struct Terminal; - -impl EventListener for Terminal { +impl EventListener for EventTarget { fn handle_event(&self, event: Event) { - match event { - Event::StartingCompilation { - name, - version, - root, - json, - } => { - if !json { - eprintln!( - "{} {} {} ({})", - " Compiling" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - name.if_supports_color(Stderr, |s| s.bold()), - version, - root.display() - .if_supports_color(Stderr, |s| s.bright_blue()) - ); - } - } - Event::BuildingDocumentation { - name, - version, - root, - } => { - eprintln!( - "{} {} for {} {} ({})", - " Generating" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "documentation".if_supports_color(Stderr, |s| s.bold()), - name.if_supports_color(Stderr, |s| s.bold()), - version, - root.to_str() - .unwrap_or("") - .if_supports_color(Stderr, |s| s.bright_blue()) - ); - } - Event::WaitingForBuildDirLock => { - eprintln!( - "{}", - "Waiting for build directory lock ..." - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()) - ); - } - Event::DumpingUPLC { path } => { - eprintln!( - "{} {} ({})", - " Exporting" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "UPLC".if_supports_color(Stderr, |s| s.bold()), - path.display() - .if_supports_color(Stderr, |s| s.bright_blue()) - ); - } - Event::GeneratingBlueprint { path } => { - eprintln!( - "{} {} ({})", - " Generating" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "project's blueprint".if_supports_color(Stderr, |s| s.bold()), - path.display() - .if_supports_color(Stderr, |s| s.bright_blue()) - ); - } - Event::GeneratingDocFiles { output_path } => { - eprintln!( - "{} {} to {}", - " Writing" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "documentation files".if_supports_color(Stderr, |s| s.bold()), - output_path - .to_str() - .unwrap_or("") - .if_supports_color(Stderr, |s| s.bright_blue()) - ); - } - Event::GeneratingUPLCFor { name, path } => { - eprintln!( - "{} {} {}.{{{}}}", - " Generating" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "UPLC for" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.white()), - path.to_str() - .unwrap_or("") - .if_supports_color(Stderr, |s| s.blue()), - name.if_supports_color(Stderr, |s| s.bright_blue()), - ); - } - Event::RunningTests { json } => { - if !json { - eprintln!( - "{} {}\n", - " Testing" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - "...".if_supports_color(Stderr, |s| s.bold()) - ); - } - } - Event::FinishedTests { seed, tests, json } => { - let (max_mem, max_cpu, max_iter) = find_max_execution_units(&tests); - - if json { - let json_output = serde_json::json!({ - "seed": seed, - "modules": group_by_module(&tests).iter().map(|(module, results)| { - serde_json::json!({ - "name": module, - "tests": results.iter().map(|r| fmt_test_json(r)).collect::>(), - "summary": fmt_test_summary_json(results) - }) - }).collect::>(), - "summary": fmt_overall_summary_json(&tests) - }); - println!("{}", serde_json::to_string_pretty(&json_output).unwrap()); - } else { - for (module, results) in &group_by_module(&tests) { - let title = module - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.blue()) - .to_string(); - - let tests = results - .iter() - .map(|r| fmt_test(r, max_mem, max_cpu, max_iter, true)) - .collect::>() - .join("\n"); - - let seed_info = if results - .iter() - .any(|t| matches!(t, TestResult::PropertyTestResult { .. })) - { - format!( - "with {opt}={seed} → ", - opt = "--seed".if_supports_color(Stderr, |s| s.bold()), - seed = format!("{seed}").if_supports_color(Stderr, |s| s.bold()) - ) - } else { - String::new() - }; - - let summary = format!("{}{}", seed_info, fmt_test_summary(results, true)); - println!( - "{}\n", - pretty::indent( - &pretty::open_box(&title, &tests, &summary, |border| border - .if_supports_color(Stderr, |s| s.bright_black()) - .to_string()), - 4 - ) - ); - } - } - - if !tests.is_empty() { - println!(); - } - } - Event::ResolvingPackages { name } => { - eprintln!( - "{} {}", - " Resolving" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - name.if_supports_color(Stderr, |s| s.bold()) - ) - } - Event::PackageResolveFallback { name } => { - eprintln!( - "{} {}\n ↳ You're seeing this message because the package version is unpinned and the network is not accessible.", - " Using" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.yellow()), - format!("uncertain local version for {name}") - .if_supports_color(Stderr, |s| s.yellow()) - ) - } - Event::PackagesDownloaded { - start, - count, - source, - } => { - let elapsed = format!("{:.2}s", start.elapsed().as_millis() as f32 / 1000.); - - let msg = match count { - 1 => format!("1 package in {elapsed}"), - _ => format!("{count} packages in {elapsed}"), - }; - - eprintln!( - "{} {} from {source}", - match source { - DownloadSource::Network => " Downloaded", - DownloadSource::Cache => " Fetched", - } - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - msg.if_supports_color(Stderr, |s| s.bold()) - ) - } - Event::ResolvingVersions => { - eprintln!( - "{}", - " Resolving dependencies" - .if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.purple()), - ) - } - } - } -} - -fn fmt_test( - result: &TestResult, - max_mem: usize, - max_cpu: usize, - max_iter: usize, - styled: bool, -) -> String { - // Status - let mut test = if result.is_success() { - pretty::style_if(styled, "PASS".to_string(), |s| { - s.if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.green()) - .to_string() - }) - } else { - pretty::style_if(styled, "FAIL".to_string(), |s| { - s.if_supports_color(Stderr, |s| s.bold()) - .if_supports_color(Stderr, |s| s.red()) - .to_string() - }) - }; - - // Execution units / iteration steps - match result { - TestResult::UnitTestResult(UnitTestResult { spent_budget, .. }) => { - let ExBudget { mem, cpu } = spent_budget; - let mem_pad = pretty::pad_left(mem.to_string(), max_mem, " "); - let cpu_pad = pretty::pad_left(cpu.to_string(), max_cpu, " "); - - test = format!( - "{test} [mem: {mem_unit}, cpu: {cpu_unit}]", - mem_unit = pretty::style_if(styled, mem_pad, |s| s - .if_supports_color(Stderr, |s| s.cyan()) - .to_string()), - cpu_unit = pretty::style_if(styled, cpu_pad, |s| s - .if_supports_color(Stderr, |s| s.cyan()) - .to_string()), - ); - } - TestResult::PropertyTestResult(PropertyTestResult { iterations, .. }) => { - test = format!( - "{test} [after {} test{}]", - pretty::pad_left( - if *iterations == 0 { - "?".to_string() - } else { - iterations.to_string() - }, - max_iter, - " " - ), - if *iterations > 1 { "s" } else { "" } - ); - } - } - - // Title - test = format!( - "{test} {title}", - title = pretty::style_if(styled, result.title().to_string(), |s| s - .if_supports_color(Stderr, |s| s.bright_blue()) - .to_string()) - ); - - // Annotations - match result { - TestResult::UnitTestResult(UnitTestResult { - assertion: Some(assertion), - test: unit_test, - .. - }) if !result.is_success() => { - test = format!( - "{test}\n{}", - assertion.to_string( - Stderr, - match unit_test.on_test_failure { - OnTestFailure::FailImmediately => false, - OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately => - true, - } - ), - ); - } - _ => (), - } - - // CounterExamples - if let TestResult::PropertyTestResult(PropertyTestResult { counterexample, .. }) = result { - match counterexample { - Err(err) => { - test = format!( - "{test}\n{}\n{}", - "× fuzzer failed unexpectedly" - .if_supports_color(Stderr, |s| s.red()) - .if_supports_color(Stderr, |s| s.bold()), - format!("| {err}").if_supports_color(Stderr, |s| s.red()) - ); - } - - Ok(None) => { - if !result.is_success() { - test = format!( - "{test}\n{}", - "× no counterexample found" - .if_supports_color(Stderr, |s| s.red()) - .if_supports_color(Stderr, |s| s.bold()) - ); - } - } - - Ok(Some(counterexample)) => { - let is_expected_failure = result.is_success(); - - test = format!( - "{test}\n{}\n{}", - if is_expected_failure { - "★ counterexample" - .if_supports_color(Stderr, |s| s.green()) - .if_supports_color(Stderr, |s| s.bold()) - .to_string() - } else { - "× counterexample" - .if_supports_color(Stderr, |s| s.red()) - .if_supports_color(Stderr, |s| s.bold()) - .to_string() - }, - &Formatter::new() - .expr(counterexample, false) - .to_pretty_string(60) - .lines() - .map(|line| { - format!( - "{} {}", - "│".if_supports_color(Stderr, |s| if is_expected_failure { - s.green().to_string() - } else { - s.red().to_string() - }), - line - ) - }) - .collect::>() - .join("\n"), - ); - } - } - } - - // Labels - if let TestResult::PropertyTestResult(PropertyTestResult { labels, .. }) = result { - if !labels.is_empty() && result.is_success() { - test = format!( - "{test}\n{title}", - title = "· with coverage".if_supports_color(Stderr, |s| s.bold()) - ); - let mut total = 0; - let mut pad = 0; - for (k, v) in labels { - total += v; - if k.len() > pad { - pad = k.len(); - } - } - - let mut labels = labels.iter().collect::>(); - labels.sort_by(|a, b| b.1.cmp(a.1)); - - for (k, v) in labels { - test = format!( - "{test}\n| {} {:>5.1}%", - pretty::pad_right(k.to_owned(), pad, " ") - .if_supports_color(Stderr, |s| s.bold()), - 100.0 * (*v as f64) / (total as f64), - ); - } + match self { + EventTarget::Terminal(term) => term.handle_event(event), + EventTarget::Json(json) => json.handle_event(event), } } - - // Traces - if !result.traces().is_empty() { - test = format!( - "{test}\n{title}\n{traces}", - title = "· with traces".if_supports_color(Stderr, |s| s.bold()), - traces = result - .traces() - .iter() - .map(|line| { format!("| {line}",) }) - .collect::>() - .join("\n") - ); - }; - - test } -fn fmt_test_summary(tests: &[&TestResult], styled: bool) -> String { - let (n_passed, n_failed) = tests.iter().fold((0, 0), |(n_passed, n_failed), result| { - if result.is_success() { - (n_passed + 1, n_failed) - } else { - (n_passed, n_failed + 1) - } - }); - format!( - "{} | {} | {}", - pretty::style_if(styled, format!("{} tests", tests.len()), |s| s - .if_supports_color(Stderr, |s| s.bold()) - .to_string()), - pretty::style_if(styled, format!("{n_passed} passed"), |s| s - .if_supports_color(Stderr, |s| s.bright_green()) - .if_supports_color(Stderr, |s| s.bold()) - .to_string()), - pretty::style_if(styled, format!("{n_failed} failed"), |s| s - .if_supports_color(Stderr, |s| s.bright_red()) - .if_supports_color(Stderr, |s| s.bold()) - .to_string()), - ) +pub enum DownloadSource { + Network, + Cache, } -fn fmt_test_json(result: &TestResult) -> serde_json::Value { - let mut test = json!({ - "name": result.title(), - "status": if result.is_success() { "PASS" } else { "FAIL" }, - }); - - match result { - TestResult::UnitTestResult(UnitTestResult { - spent_budget, - assertion, - test: unit_test, - .. - }) => { - test["execution_units"] = json!({ - "memory": spent_budget.mem, - "cpu": spent_budget.cpu, - }); - if !result.is_success() { - if let Some(assertion) = assertion { - test["assertion"] = json!({ - "message": assertion.to_string(Stderr, false), - "expected_to_fail": matches!(unit_test.on_test_failure, OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately), - }); - } - } - } - TestResult::PropertyTestResult(PropertyTestResult { - iterations, - labels, - counterexample, - .. - }) => { - test["iterations"] = json!(iterations); - test["labels"] = json!(labels); - test["counterexample"] = match counterexample { - Ok(Some(expr)) => json!(Formatter::new().expr(expr, false).to_pretty_string(60)), - Ok(None) => json!(null), - Err(err) => json!({"error": err.to_string()}), - }; +impl Display for DownloadSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DownloadSource::Network => write!(f, "network"), + DownloadSource::Cache => write!(f, "cache"), } } - - if !result.traces().is_empty() { - test["traces"] = json!(result.traces()); - } - - test -} - -fn fmt_test_summary_json(tests: &[&TestResult]) -> serde_json::Value { - let total = tests.len(); - let passed = tests.iter().filter(|t| t.is_success()).count(); - let failed = total - passed; - - json!({ - "total": total, - "passed": passed, - "failed": failed, - }) -} - -fn fmt_overall_summary_json(tests: &[TestResult]) -> serde_json::Value { - let total = tests.len(); - let passed = tests.iter().filter(|t| t.is_success()).count(); - let failed = total - passed; - - let modules = group_by_module(tests); - let module_count = modules.len(); - - let (max_mem, max_cpu, max_iter) = find_max_execution_units(tests); - - // Separate counts for unit tests and property-based tests - let unit_tests = tests - .iter() - .filter(|t| matches!(t, TestResult::UnitTestResult { .. })) - .count(); - let property_tests = tests - .iter() - .filter(|t| matches!(t, TestResult::PropertyTestResult { .. })) - .count(); - - json!({ - "total_tests": total, - "passed_tests": passed, - "failed_tests": failed, - "unit_tests": unit_tests, - "property_tests": property_tests, - "module_count": module_count, - "max_execution_units": { - "memory": max_mem, - "cpu": max_cpu, - }, - "max_iterations": max_iter, - "modules": modules.into_iter().map(|(module, results)| { - json!({ - "name": module, - "tests": results.iter().map(|r| fmt_test_json(r)).collect::>(), - "summary": fmt_test_summary_json(&results) - }) - }).collect::>(), - }) } -fn group_by_module( +pub(crate) fn group_by_module( results: &[TestResult], ) -> BTreeMap>> { let mut modules = BTreeMap::new(); @@ -634,7 +111,7 @@ fn group_by_module( modules } -fn find_max_execution_units(xs: &[TestResult]) -> (usize, usize, usize) { +pub(crate) fn find_max_execution_units(xs: &[TestResult]) -> (usize, usize, usize) { let (max_mem, max_cpu, max_iter) = xs.iter() .fold((0, 0, 0), |(max_mem, max_cpu, max_iter), test| match test { diff --git a/crates/aiken-project/src/telemetry/json.rs b/crates/aiken-project/src/telemetry/json.rs new file mode 100644 index 000000000..84c5f9710 --- /dev/null +++ b/crates/aiken-project/src/telemetry/json.rs @@ -0,0 +1,137 @@ +use super::{find_max_execution_units, group_by_module, Event, EventListener}; +use aiken_lang::{ + ast::OnTestFailure, + expr::UntypedExpr, + format::Formatter, + test_framework::{PropertyTestResult, TestResult, UnitTestResult}, +}; +use owo_colors::Stream::Stderr; +use serde_json::json; + +#[derive(Debug, Default, Clone, Copy)] +pub struct Json; + +impl EventListener for Json { + fn handle_event(&self, event: Event) { + match event { + Event::FinishedTests { seed, tests, .. } => { + let json_output = serde_json::json!({ + "seed": seed, + "modules": group_by_module(&tests).iter().map(|(module, results)| { + serde_json::json!({ + "name": module, + "tests": results.iter().map(|r| fmt_test_json(r)).collect::>(), + "summary": fmt_test_summary_json(results) + }) + }).collect::>(), + "summary": fmt_overall_summary_json(&tests) + }); + println!("{}", serde_json::to_string_pretty(&json_output).unwrap()); + } + _ => super::Terminal.handle_event(event), + } + } +} + +fn fmt_test_json(result: &TestResult) -> serde_json::Value { + let mut test = json!({ + "name": result.title(), + "status": if result.is_success() { "PASS" } else { "FAIL" }, + }); + + match result { + TestResult::UnitTestResult(UnitTestResult { + spent_budget, + assertion, + test: unit_test, + .. + }) => { + test["execution_units"] = json!({ + "memory": spent_budget.mem, + "cpu": spent_budget.cpu, + }); + if !result.is_success() { + if let Some(assertion) = assertion { + test["assertion"] = json!({ + "message": assertion.to_string(Stderr, false), + "expected_to_fail": matches!(unit_test.on_test_failure, OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately), + }); + } + } + } + TestResult::PropertyTestResult(PropertyTestResult { + iterations, + labels, + counterexample, + .. + }) => { + test["iterations"] = json!(iterations); + test["labels"] = json!(labels); + test["counterexample"] = match counterexample { + Ok(Some(expr)) => json!(Formatter::new().expr(expr, false).to_pretty_string(60)), + Ok(None) => json!(null), + Err(err) => json!({"error": err.to_string()}), + }; + } + } + + if !result.traces().is_empty() { + test["traces"] = json!(result.traces()); + } + + test +} + +fn fmt_test_summary_json(tests: &[&TestResult]) -> serde_json::Value { + let total = tests.len(); + let passed = tests.iter().filter(|t| t.is_success()).count(); + let failed = total - passed; + + json!({ + "total": total, + "passed": passed, + "failed": failed, + }) +} + +fn fmt_overall_summary_json(tests: &[TestResult]) -> serde_json::Value { + let total = tests.len(); + let passed = tests.iter().filter(|t| t.is_success()).count(); + let failed = total - passed; + + let modules = group_by_module(tests); + let module_count = modules.len(); + + let (max_mem, max_cpu, max_iter) = find_max_execution_units(tests); + + // Separate counts for unit tests and property-based tests + let unit_tests = tests + .iter() + .filter(|t| matches!(t, TestResult::UnitTestResult { .. })) + .count(); + let property_tests = tests + .iter() + .filter(|t| matches!(t, TestResult::PropertyTestResult { .. })) + .count(); + + json!({ + "total_tests": total, + "passed_tests": passed, + "failed_tests": failed, + "unit_tests": unit_tests, + "property_tests": property_tests, + "module_count": module_count, + "max_execution_units": { + "memory": max_mem, + "cpu": max_cpu, + }, + "max_iterations": max_iter, + "modules": modules.into_iter().map(|(module, results)| { + json!({ + "name": module, + "tests": results.iter().map(|r| fmt_test_json(r)).collect::>(), + "summary": fmt_test_summary_json(&results) + }) + }).collect::>(), + }) +} diff --git a/crates/aiken-project/src/telemetry/terminal.rs b/crates/aiken-project/src/telemetry/terminal.rs new file mode 100644 index 000000000..43eeaabf4 --- /dev/null +++ b/crates/aiken-project/src/telemetry/terminal.rs @@ -0,0 +1,434 @@ +use super::{find_max_execution_units, group_by_module, DownloadSource, Event, EventListener}; +use crate::pretty; +use aiken_lang::{ + ast::OnTestFailure, + expr::UntypedExpr, + format::Formatter, + test_framework::{PropertyTestResult, TestResult, UnitTestResult}, +}; +use owo_colors::{OwoColorize, Stream::Stderr}; +use uplc::machine::cost_model::ExBudget; + +#[derive(Debug, Default, Clone, Copy)] +pub struct Terminal; + +impl EventListener for Terminal { + fn handle_event(&self, event: Event) { + match event { + Event::StartingCompilation { + name, + version, + root, + } => { + eprintln!( + "{} {} {} ({})", + " Compiling" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + name.if_supports_color(Stderr, |s| s.bold()), + version, + root.display() + .if_supports_color(Stderr, |s| s.bright_blue()) + ); + } + Event::BuildingDocumentation { + name, + version, + root, + } => { + eprintln!( + "{} {} for {} {} ({})", + " Generating" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "documentation".if_supports_color(Stderr, |s| s.bold()), + name.if_supports_color(Stderr, |s| s.bold()), + version, + root.to_str() + .unwrap_or("") + .if_supports_color(Stderr, |s| s.bright_blue()) + ); + } + Event::WaitingForBuildDirLock => { + eprintln!( + "{}", + "Waiting for build directory lock ..." + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()) + ); + } + Event::DumpingUPLC { path } => { + eprintln!( + "{} {} ({})", + " Exporting" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "UPLC".if_supports_color(Stderr, |s| s.bold()), + path.display() + .if_supports_color(Stderr, |s| s.bright_blue()) + ); + } + Event::GeneratingBlueprint { path } => { + eprintln!( + "{} {} ({})", + " Generating" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "project's blueprint".if_supports_color(Stderr, |s| s.bold()), + path.display() + .if_supports_color(Stderr, |s| s.bright_blue()) + ); + } + Event::GeneratingDocFiles { output_path } => { + eprintln!( + "{} {} to {}", + " Writing" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "documentation files".if_supports_color(Stderr, |s| s.bold()), + output_path + .to_str() + .unwrap_or("") + .if_supports_color(Stderr, |s| s.bright_blue()) + ); + } + Event::GeneratingUPLCFor { name, path } => { + eprintln!( + "{} {} {}.{{{}}}", + " Generating" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "UPLC for" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.white()), + path.to_str() + .unwrap_or("") + .if_supports_color(Stderr, |s| s.blue()), + name.if_supports_color(Stderr, |s| s.bright_blue()), + ); + } + Event::RunningTests => { + eprintln!( + "{} {}\n", + " Testing" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + "...".if_supports_color(Stderr, |s| s.bold()) + ); + } + Event::FinishedTests { seed, tests } => { + let (max_mem, max_cpu, max_iter) = find_max_execution_units(&tests); + + for (module, results) in &group_by_module(&tests) { + let title = module + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.blue()) + .to_string(); + + let tests = results + .iter() + .map(|r| fmt_test(r, max_mem, max_cpu, max_iter, true)) + .collect::>() + .join("\n"); + + let seed_info = if results + .iter() + .any(|t| matches!(t, TestResult::PropertyTestResult { .. })) + { + format!( + "with {opt}={seed} → ", + opt = "--seed".if_supports_color(Stderr, |s| s.bold()), + seed = format!("{seed}").if_supports_color(Stderr, |s| s.bold()) + ) + } else { + String::new() + }; + + let summary = format!("{}{}", seed_info, fmt_test_summary(results, true)); + println!( + "{}\n", + pretty::indent( + &pretty::open_box(&title, &tests, &summary, |border| border + .if_supports_color(Stderr, |s| s.bright_black()) + .to_string()), + 4 + ) + ); + } + + if !tests.is_empty() { + println!(); + } + } + Event::ResolvingPackages { name } => { + eprintln!( + "{} {}", + " Resolving" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + name.if_supports_color(Stderr, |s| s.bold()) + ) + } + Event::PackageResolveFallback { name } => { + eprintln!( + "{} {}\n ↳ You're seeing this message because the package version is unpinned and the network is not accessible.", + " Using" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.yellow()), + format!("uncertain local version for {name}") + .if_supports_color(Stderr, |s| s.yellow()) + ) + } + Event::PackagesDownloaded { + start, + count, + source, + } => { + let elapsed = format!("{:.2}s", start.elapsed().as_millis() as f32 / 1000.); + + let msg = match count { + 1 => format!("1 package in {elapsed}"), + _ => format!("{count} packages in {elapsed}"), + }; + + eprintln!( + "{} {} from {source}", + match source { + DownloadSource::Network => " Downloaded", + DownloadSource::Cache => " Fetched", + } + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + msg.if_supports_color(Stderr, |s| s.bold()) + ) + } + Event::ResolvingVersions => { + eprintln!( + "{}", + " Resolving dependencies" + .if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.purple()), + ) + } + } + } +} + +fn fmt_test( + result: &TestResult, + max_mem: usize, + max_cpu: usize, + max_iter: usize, + styled: bool, +) -> String { + // Status + let mut test = if result.is_success() { + pretty::style_if(styled, "PASS".to_string(), |s| { + s.if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.green()) + .to_string() + }) + } else { + pretty::style_if(styled, "FAIL".to_string(), |s| { + s.if_supports_color(Stderr, |s| s.bold()) + .if_supports_color(Stderr, |s| s.red()) + .to_string() + }) + }; + + // Execution units / iteration steps + match result { + TestResult::UnitTestResult(UnitTestResult { spent_budget, .. }) => { + let ExBudget { mem, cpu } = spent_budget; + let mem_pad = pretty::pad_left(mem.to_string(), max_mem, " "); + let cpu_pad = pretty::pad_left(cpu.to_string(), max_cpu, " "); + + test = format!( + "{test} [mem: {mem_unit}, cpu: {cpu_unit}]", + mem_unit = pretty::style_if(styled, mem_pad, |s| s + .if_supports_color(Stderr, |s| s.cyan()) + .to_string()), + cpu_unit = pretty::style_if(styled, cpu_pad, |s| s + .if_supports_color(Stderr, |s| s.cyan()) + .to_string()), + ); + } + TestResult::PropertyTestResult(PropertyTestResult { iterations, .. }) => { + test = format!( + "{test} [after {} test{}]", + pretty::pad_left( + if *iterations == 0 { + "?".to_string() + } else { + iterations.to_string() + }, + max_iter, + " " + ), + if *iterations > 1 { "s" } else { "" } + ); + } + } + + // Title + test = format!( + "{test} {title}", + title = pretty::style_if(styled, result.title().to_string(), |s| s + .if_supports_color(Stderr, |s| s.bright_blue()) + .to_string()) + ); + + // Annotations + match result { + TestResult::UnitTestResult(UnitTestResult { + assertion: Some(assertion), + test: unit_test, + .. + }) if !result.is_success() => { + test = format!( + "{test}\n{}", + assertion.to_string( + Stderr, + match unit_test.on_test_failure { + OnTestFailure::FailImmediately => false, + OnTestFailure::SucceedEventually | OnTestFailure::SucceedImmediately => + true, + } + ), + ); + } + _ => (), + } + + // CounterExamples + if let TestResult::PropertyTestResult(PropertyTestResult { counterexample, .. }) = result { + match counterexample { + Err(err) => { + test = format!( + "{test}\n{}\n{}", + "× fuzzer failed unexpectedly" + .if_supports_color(Stderr, |s| s.red()) + .if_supports_color(Stderr, |s| s.bold()), + format!("| {err}").if_supports_color(Stderr, |s| s.red()) + ); + } + + Ok(None) => { + if !result.is_success() { + test = format!( + "{test}\n{}", + "× no counterexample found" + .if_supports_color(Stderr, |s| s.red()) + .if_supports_color(Stderr, |s| s.bold()) + ); + } + } + + Ok(Some(counterexample)) => { + let is_expected_failure = result.is_success(); + + test = format!( + "{test}\n{}\n{}", + if is_expected_failure { + "★ counterexample" + .if_supports_color(Stderr, |s| s.green()) + .if_supports_color(Stderr, |s| s.bold()) + .to_string() + } else { + "× counterexample" + .if_supports_color(Stderr, |s| s.red()) + .if_supports_color(Stderr, |s| s.bold()) + .to_string() + }, + &Formatter::new() + .expr(counterexample, false) + .to_pretty_string(60) + .lines() + .map(|line| { + format!( + "{} {}", + "│".if_supports_color(Stderr, |s| if is_expected_failure { + s.green().to_string() + } else { + s.red().to_string() + }), + line + ) + }) + .collect::>() + .join("\n"), + ); + } + } + } + + // Labels + if let TestResult::PropertyTestResult(PropertyTestResult { labels, .. }) = result { + if !labels.is_empty() && result.is_success() { + test = format!( + "{test}\n{title}", + title = "· with coverage".if_supports_color(Stderr, |s| s.bold()) + ); + let mut total = 0; + let mut pad = 0; + for (k, v) in labels { + total += v; + if k.len() > pad { + pad = k.len(); + } + } + + let mut labels = labels.iter().collect::>(); + labels.sort_by(|a, b| b.1.cmp(a.1)); + + for (k, v) in labels { + test = format!( + "{test}\n| {} {:>5.1}%", + pretty::pad_right(k.to_owned(), pad, " ") + .if_supports_color(Stderr, |s| s.bold()), + 100.0 * (*v as f64) / (total as f64), + ); + } + } + } + + // Traces + if !result.traces().is_empty() { + test = format!( + "{test}\n{title}\n{traces}", + title = "· with traces".if_supports_color(Stderr, |s| s.bold()), + traces = result + .traces() + .iter() + .map(|line| { format!("| {line}",) }) + .collect::>() + .join("\n") + ); + }; + + test +} + +fn fmt_test_summary(tests: &[&TestResult], styled: bool) -> String { + let (n_passed, n_failed) = tests.iter().fold((0, 0), |(n_passed, n_failed), result| { + if result.is_success() { + (n_passed + 1, n_failed) + } else { + (n_passed, n_failed + 1) + } + }); + format!( + "{} | {} | {}", + pretty::style_if(styled, format!("{} tests", tests.len()), |s| s + .if_supports_color(Stderr, |s| s.bold()) + .to_string()), + pretty::style_if(styled, format!("{n_passed} passed"), |s| s + .if_supports_color(Stderr, |s| s.bright_green()) + .if_supports_color(Stderr, |s| s.bold()) + .to_string()), + pretty::style_if(styled, format!("{n_failed} failed"), |s| s + .if_supports_color(Stderr, |s| s.bright_red()) + .if_supports_color(Stderr, |s| s.bold()) + .to_string()), + ) +} diff --git a/crates/aiken-project/src/watch.rs b/crates/aiken-project/src/watch.rs index 6810e3438..899406683 100644 --- a/crates/aiken-project/src/watch.rs +++ b/crates/aiken-project/src/watch.rs @@ -1,4 +1,4 @@ -use crate::{telemetry::Terminal, Project}; +use crate::{telemetry::EventTarget, Project}; use miette::{Diagnostic, IntoDiagnostic}; use notify::{Event, RecursiveMode, Watcher}; use owo_colors::{OwoColorize, Stream::Stderr}; @@ -95,7 +95,7 @@ pub fn with_project( mut action: A, ) -> miette::Result<()> where - A: FnMut(&mut Project) -> Result<(), Vec>, + A: FnMut(&mut Project) -> Result<(), Vec>, { let project_path = if let Some(d) = directory { d.to_path_buf() @@ -107,7 +107,7 @@ where current_dir }; - let mut project = match Project::new(project_path, Terminal) { + let mut project = match Project::new(project_path, EventTarget::default()) { Ok(p) => Ok(p), Err(e) => { e.report(); @@ -166,7 +166,7 @@ where /// // Note: doctest disabled, because aiken_project doesn't have an implementation of EventListener I can use /// use aiken_project::watch::{watch_project, default_filter}; /// use aiken_project::{Project}; -/// watch_project(None, Terminal, default_filter, 500, |project| { +/// watch_project(None, default_filter, 500, |project| { /// println!("Project changed!"); /// Ok(()) /// }); @@ -179,7 +179,7 @@ pub fn watch_project( ) -> miette::Result<()> where F: Fn(&Event) -> bool, - A: FnMut(&mut Project) -> Result<(), Vec>, + A: FnMut(&mut Project) -> Result<(), Vec>, { let project_path = directory .map(|p| p.to_path_buf()) diff --git a/crates/aiken/src/cmd/build.rs b/crates/aiken/src/cmd/build.rs index 28a543100..7774975bc 100644 --- a/crates/aiken/src/cmd/build.rs +++ b/crates/aiken/src/cmd/build.rs @@ -79,7 +79,6 @@ pub fn exec( None => Tracing::All(trace_level), }, env.clone(), - false, ) }) } else { @@ -91,7 +90,6 @@ pub fn exec( None => Tracing::All(trace_level), }, env.clone(), - false, ) }) }; diff --git a/crates/aiken/src/cmd/check.rs b/crates/aiken/src/cmd/check.rs index feee6591d..8062d2ea5 100644 --- a/crates/aiken/src/cmd/check.rs +++ b/crates/aiken/src/cmd/check.rs @@ -110,8 +110,6 @@ pub fn exec( let seed = seed.unwrap_or_else(|| rng.gen()); - let json_output = !io::stdout().is_terminal(); - let result = if watch { watch_project(directory.as_deref(), watch::default_filter, 500, |p| { p.check( @@ -126,7 +124,6 @@ pub fn exec( None => Tracing::All(trace_level), }, env.clone(), - json_output, ) }) } else { @@ -147,7 +144,6 @@ pub fn exec( None => Tracing::All(trace_level), }, env.clone(), - json_output, ) }, )