diff --git a/src/formatting/syntax.rs b/src/formatting/syntax.rs index eeb259f..98cd117 100644 --- a/src/formatting/syntax.rs +++ b/src/formatting/syntax.rs @@ -28,6 +28,10 @@ pub enum Syntax { Language, Attribute, Structure, + Marker, + Done, + Skip, + Fail, BlockBegin, BlockEnd, } diff --git a/src/highlighting/terminal.rs b/src/highlighting/terminal.rs index 9a564b9..3cbdacc 100644 --- a/src/highlighting/terminal.rs +++ b/src/highlighting/terminal.rs @@ -95,6 +95,18 @@ impl Render for Terminal { .color(owo_colors::Rgb(153, 153, 153)) .bold() .to_string(), + Syntax::Marker => content + .color(owo_colors::Rgb(0x55, 0x57, 0x53)) + .to_string(), + Syntax::Done => content + .color(owo_colors::Rgb(0x4e, 0x9a, 0x06)) + .to_string(), + Syntax::Skip => content + .color(owo_colors::Rgb(0xc4, 0xa0, 0x00)) + .to_string(), + Syntax::Fail => content + .color(owo_colors::Rgb(0xcc, 0x00, 0x00)) + .to_string(), Syntax::BlockBegin | Syntax::BlockEnd => String::new(), } } diff --git a/src/highlighting/typst.rs b/src/highlighting/typst.rs index f6d5a7d..c520bfc 100644 --- a/src/highlighting/typst.rs +++ b/src/highlighting/typst.rs @@ -38,6 +38,10 @@ impl Render for Typst { Syntax::Language => markup("fill: rgb(0xc4, 0xa0, 0x00), weight: \"bold\"", &content), Syntax::Attribute => markup("weight: \"bold\"", &content), Syntax::Structure => markup("fill: rgb(0x99, 0x99, 0x99), weight: \"bold\"", &content), + Syntax::Marker => markup("fill: rgb(0x55, 0x57, 0x53)", &content), + Syntax::Done => markup("fill: rgb(0x4e, 0x9a, 0x06)", &content), + Syntax::Skip => markup("fill: rgb(0xc4, 0xa0, 0x00)", &content), + Syntax::Fail => markup("fill: rgb(0xcc, 0x00, 0x00)", &content), Syntax::BlockBegin | Syntax::BlockEnd => String::new(), } } diff --git a/src/main.rs b/src/main.rs index d5726ac..caf1bfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -310,6 +310,13 @@ fn main() { .action(ArgAction::Set) .help("Whether to walk the procedure interactively, prompting the operator at each step, or automatically, taking each step's computed value and running to completion or first failure."), ) + .arg( + Arg::new("raw-control-chars") + .short('R') + .long("raw-control-chars") + .action(ArgAction::SetTrue) + .help("Emit ANSI escape codes for syntax highlighting even if output is redirected to a pipe or file when running in automatic mode."), + ) .arg( Arg::new("arguments") .num_args(0..) @@ -706,6 +713,14 @@ fn main() { _ => Mode::Interactive, }; + let raw_output = *submatches + .get_one::("raw-control-chars") + .unwrap(); // flags are always present since SetTrue implies default_value + + debug!(raw_output); + + let colour = raw_output || std::io::stdout().is_terminal(); + let filename = Path::new(filename); let content = match parsing::load(&filename) { Ok(data) => data, @@ -796,7 +811,7 @@ fn main() { std::process::exit(1); } - match runner::start(mode, filename, &program, &arguments, library) { + match runner::start(mode, colour, filename, &program, &arguments, library) { Ok((run_id, Outcome::Stopped)) => { eprintln!( "stopped; resume with `technique resume {}`", diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 564dca0..7003493 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1270,10 +1270,17 @@ function being called to return a tuple of the same size. .trim_ascii() .to_string(), ), - RunnerError::ParameterArityMismatch { expected, actual } => ( + RunnerError::ParameterArityMismatch { + procedure, + parameters, + actual, + } => ( format!( - "Wrong number of arguments: procedure expects {} but {} given", - expected, actual + "Wrong number of arguments: {} expects {} ({}) but {} given", + procedure, + parameters.len(), + parameters.join(", "), + actual ), r#" Arguments after the filename are passed as the parameters for the entry @@ -1282,15 +1289,18 @@ procedure at the top of the Technique document. .trim_ascii() .to_string(), ), - RunnerError::ParameterUnexpected { actual } => ( + RunnerError::ParameterUnexpected { procedure, actual } => ( format!( - "Unexpected arguments: procedure takes no parameters but {} given", - actual + "Unexpected arguments: {} takes no parameters but {} given", + procedure, actual ), - r#" -Arguments were supplied on the command-line but the entry procedure at the top -of the document doesn't take any parameters. - "# + format!( + r#" +Arguments were supplied on the command-line but {} doesn't take any +parameters. + "#, + procedure + ) .trim_ascii() .to_string(), ), diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index c01df23..ba5dc05 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -1,6 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::runner::driver::{draw, Console, Driver, Event, Interaction, Mock, UserInput}; +use crate::runner::driver::{ + draw, Automatic, Console, Driver, Event, Interaction, Mock, UserInput, +}; use crate::value::{Numeric, Value}; #[test] @@ -11,18 +13,18 @@ fn mock_returns_canned_answers_in_order() { UserInput::Quit, ]); assert_eq!( - p.ask("/I/1", &[], Value::Unitus), + p.ask("/I/1", &[], Value::Unitus, true), UserInput::Done(Value::Unitus) ); - assert_eq!(p.ask("/I/1", &[], Value::Unitus), UserInput::Skip); - assert_eq!(p.ask("/I/1", &[], Value::Unitus), UserInput::Quit); + assert_eq!(p.ask("/I/1", &[], Value::Unitus, true), UserInput::Skip); + assert_eq!(p.ask("/I/1", &[], Value::Unitus, true), UserInput::Quit); } #[test] fn mock_records_step_and_ask_events() { let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); p.step("/local_network:I/1", "Check the cable."); - let _ = p.ask("/local_network:I/1", &[], Value::Unitus); + let _ = p.ask("/local_network:I/1", &[], Value::Unitus, true); assert_eq!( p.events(), &[ @@ -41,7 +43,7 @@ fn mock_records_step_and_ask_events() { #[test] fn mock_records_offered_choices() { let mut p = Mock::with_answers([UserInput::Done(Value::Literali("Yes".to_string()))]); - let _ = p.ask("I/1", &["Yes", "No"], Value::Unitus); + let _ = p.ask("I/1", &["Yes", "No"], Value::Unitus, true); assert_eq!( p.events(), &[Event::Ask { @@ -71,7 +73,7 @@ fn mock_records_enter_and_announce() { #[should_panic(expected = "Mock::ask called with no canned answers remaining")] fn mock_ask_without_answers_panics() { let mut p = Mock::new(); - let _ = p.ask("I/1", &[], Value::Unitus); + let _ = p.ask("I/1", &[], Value::Unitus, true); } #[test] @@ -84,6 +86,47 @@ fn console_step_writes_fqn_and_description() { assert!(written.contains(" Check the cable.")); } +#[test] +fn automatic_settles_done_when_effectful_skip_otherwise() { + let mut p = Automatic::with_handle(Vec::new()); + assert_eq!( + p.ask("/I/1", &[], Value::Literali("ran".to_string()), true), + UserInput::Done(Value::Literali("ran".to_string())) + ); + assert_eq!(p.ask("/I/2", &[], Value::Unitus, false), UserInput::Skip); + assert_eq!( + p.seal("/I", Value::Unitus, true), + UserInput::Done(Value::Unitus) + ); + assert_eq!(p.seal("/II", Value::Unitus, false), UserInput::Skip); +} + +#[test] +fn automatic_settle_renders_verdict_glyph() { + let mut output: Vec = Vec::new(); + let mut p = Automatic::with_handle(&mut output); + p.settle("→", "/I/1", &UserInput::Done(Value::Unitus)); + p.settle("→", "/I/2", &UserInput::Skip); + p.settle("↙", "/I", &UserInput::Done(Value::Unitus)); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("→ I/1 ✓")); + assert!(written.contains("→ I/2 ⊘")); + assert!(written.contains("↙ I ✓")); +} + +#[test] +fn console_settle_writes_verdict_line() { + let mut output: Vec = Vec::new(); + let mut p = Console::with_output(&mut output); + p.settle("→", "/I/1", &UserInput::Done(Value::Unitus)); + p.settle("↙", "/I", &UserInput::Skip); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("→ I/1")); + assert!(written.contains("✓")); + assert!(written.contains("↙ I")); + assert!(written.contains("⊘")); +} + #[test] fn console_enter_writes_fqn() { let mut output: Vec = Vec::new(); @@ -141,10 +184,12 @@ fn esc_edit_seeds_buffer_and_backspace_trims() { } #[test] -fn edited_quanticle_stays_a_quanticle() { +fn quanticle_edit_roundtrips() { + let quanticle = || Value::Quanticle(Numeric::Integral(42)); + // Editing a numeric value and changing it keeps it numeric: 42 -> 43 is // re-parsed back to a Quanticle, not flattened to text. - let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + let mut it = Interaction::begin(&[], quanticle()); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); @@ -156,26 +201,20 @@ fn edited_quanticle_stays_a_quanticle() { it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Done(Value::Quanticle(Numeric::Integral(43)))) ); -} -#[test] -fn unedited_quanticle_returns_verbatim() { // Entering and leaving the edit without a change returns the original // numeric value untouched. - let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + let mut it = Interaction::begin(&[], quanticle()); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - Some(UserInput::Done(Value::Quanticle(Numeric::Integral(42)))) + Some(UserInput::Done(quanticle())) ); -} -#[test] -fn edited_quanticle_rejects_non_numeric() { // A numeric value edited into something that is not a number is not // accepted: Enter stays in the edit so it can be corrected. - let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42))); + let mut it = Interaction::begin(&[], quanticle()); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); @@ -350,13 +389,13 @@ fn choices_esc_opens_menu() { } #[test] -fn complex_value_is_read_only() { +fn read_only_values_accept_intact() { + // A tablet and multi-line text are both read-only: typing is ignored. let tablet = Value::Tabularum(vec![( "name".to_string(), Value::Literali("eth0".to_string()), )]); let mut it = Interaction::begin(&[], tablet.clone()); - // Typing into a frozen value does nothing; Enter accepts it intact. assert_eq!( it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), None @@ -365,12 +404,7 @@ fn complex_value_is_read_only() { it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Done(tablet)) ); -} -#[test] -fn multiline_scalar_is_read_only() { - // A step whose body computed multi-line text (e.g. captured exec output) - // is not editable inline; it is accepted intact, like a complex value. let dump = Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()); let mut it = Interaction::begin(&[], dump.clone()); assert_eq!( diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs index f790b28..6398862 100644 --- a/src/runner/checks/path.rs +++ b/src/runner/checks/path.rs @@ -1,5 +1,5 @@ use crate::language::{Attribute, Identifier, Span}; -use crate::runner::path::{PathSegment, QualifiedPath}; +use crate::runner::path::{display_path, PathSegment, QualifiedPath}; #[test] fn empty_stack_renders_root() { @@ -119,6 +119,35 @@ fn procedure_segment() { assert_eq!(stack.render(), "/outer:/1/inner:/2"); } +#[test] +fn display_trims_entry_head() { + // Within a section the entry head is dropped, the numeral anchors instead. + assert_eq!( + display_path("/connectivity_check:/VI/check_aws_health:/7"), + "VI/check_aws_health:/7" + ); + + // A flat entry with no section keeps its name; only the leading slash goes. + assert_eq!( + display_path("/connectivity_check:/1"), + "connectivity_check:/1" + ); + assert_eq!( + display_path("/activate_crisis_management:/-1"), + "activate_crisis_management:/-1" + ); + + // An ` ` annotation on the numeral is kept; the head still drops. + assert_eq!( + display_path("/connectivity_check:/VII "), + "VII " + ); + + // An anonymous technique has no head to drop; the leading slash still goes. + assert_eq!(display_path("/I/1"), "I/1"); + assert_eq!(display_path("/I"), "I"); +} + #[test] fn full_qualified_example_from_objective() { // /2/@barista/a/-1 — dependent step 2, role @barista, substep a, first parallel sub-substep diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 0e8f520..934aeb4 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -11,7 +11,7 @@ use crate::program::{ use crate::runner::driver::{Automatic, Event, Mock, UserInput}; use crate::runner::evaluator::Environment; use crate::runner::library::Library; -use crate::runner::runner::{bind_parameters, Outcome, Runner, RunnerError}; +use crate::runner::runner::{bind_parameters, render_argument_echo, Outcome, Runner, RunnerError}; use crate::runner::state::{ parse_record, Appender, InvokeTarget, State, Store, Value as RecordValue, }; @@ -1108,6 +1108,52 @@ test : assert_eq!(asks, vec!["/test:/1"]); } +#[test] +fn automatic_substantiates_only_effectful_steps() { + // An exec step settles Done; a pure-prose sibling Skip; the procedure + // seals Done since one step beneath it was effectful. + let source = r#" +% technique v1 + +check : + +1. Run it { exec("true") } + +2. Just read this step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + let mut library = Library::core(); + library.extend(Library::system()); + crate::linking::link(&mut program, &library).expect("linked"); + + let mut fixture = StoreFixture::new("automatic-substantiation"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + library, + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Done(_) => {} + other => panic!("expected Done, got {:?}", other), + } + let trace = String::from_utf8( + runner + .into_driver() + .into_output(), + ) + .expect("utf8"); + assert!(trace.contains("→ check:/1 ✓")); + assert!(trace.contains("→ check:/2 ⊘")); + assert!(trace.contains("↙ check: ✓")); +} + #[test] fn loop_inside_step_produces_one_result() { let mut fixture = StoreFixture::new("loop-in-step"); @@ -1675,13 +1721,19 @@ connectivity_check(e, s) : Some(&Value::Literali("192.168.1.5".to_string())) ); - // Too few arguments: ParameterArityMismatch. + // Too few arguments: ParameterArityMismatch names procedure and parameters. let args = ["foo".to_string()]; let error = bind_parameters(&program, &args).expect_err("expected arity error"); - let RunnerError::ParameterArityMismatch { expected, actual } = error else { + let RunnerError::ParameterArityMismatch { + procedure, + parameters, + actual, + } = error + else { panic!("expected ParameterArityMismatch, got {:?}", error); }; - assert_eq!(expected, 2); + assert_eq!(procedure, "connectivity_check"); + assert_eq!(parameters, vec!["e".to_string(), "s".to_string()]); assert_eq!(actual, 1); // Too many arguments: also ParameterArityMismatch. @@ -1691,12 +1743,38 @@ connectivity_check(e, s) : "extra".to_string(), ]; let error = bind_parameters(&program, &args).expect_err("expected arity error"); - let RunnerError::ParameterArityMismatch { expected, actual } = error else { + let RunnerError::ParameterArityMismatch { + parameters, actual, .. + } = error + else { panic!("expected ParameterArityMismatch, got {:?}", error); }; - assert_eq!(expected, 2); + assert_eq!(parameters.len(), 2); assert_eq!(actual, 3); + // With a signature, parameters are described as `name : Type`. + let source = r#" +% technique v1 + +connectivity_check(e, s) : LocalEnvironment, TargetService -> NetworkHealth + +1. step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + let error = bind_parameters(&program, &[]).expect_err("expected arity error"); + let RunnerError::ParameterArityMismatch { parameters, .. } = error else { + panic!("expected ParameterArityMismatch, got {:?}", error); + }; + assert_eq!( + parameters, + vec![ + "e : LocalEnvironment".to_string(), + "s : TargetService".to_string() + ] + ); + // Procedure declares no parameters but args supplied: ParameterUnexpected. let source = r#" % technique v1 @@ -1710,9 +1788,10 @@ test : let program = translate(&document).expect("translate"); let args = ["unwanted".to_string()]; let error = bind_parameters(&program, &args).expect_err("expected unexpected error"); - let RunnerError::ParameterUnexpected { actual } = error else { + let RunnerError::ParameterUnexpected { procedure, actual } = error else { panic!("expected ParameterUnexpected, got {:?}", error); }; + assert_eq!(procedure, "test"); assert_eq!(actual, 1); // No parameters and no args: empty environment, no error. @@ -1722,6 +1801,30 @@ test : .is_none()); } +#[test] +fn argument_echo_binds_each_parameter() { + let source = r#" +% technique v1 + +connectivity_check(e, s) : + +1. step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + let args = ["[]".to_string(), "0".to_string()]; + let env = bind_parameters(&program, &args).expect("bind"); + let params = program + .subroutines + .first() + .unwrap() + .parameters + .unwrap(); + let echo = render_argument_echo("connectivity_check", params, &env); + assert_eq!(echo, "connectivity_check: ([] ~ e, 0 ~ s)"); +} + #[test] fn entry_procedure_parameters_visible_in_descriptions() { let source = r#" @@ -1917,110 +2020,65 @@ test : } #[test] -fn automatic_driver_records_body_value() { - // A value-bearing body under the automatic driver: no operator, no canned - // answers; the step's outcome is the body's computed value, recorded. - let mut fixture = StoreFixture::new("automatic-records-value"); - let body = Operation::Sequence(vec![step( - Ordinal::Dependent("1"), +fn automatic_propagates_body_value_but_records_skip() { + // Under the automatic driver the body value propagates as the outcome, + // but with no effectful work the step records Skip. + fn skip_of(label: &str, body: Operation<'static>) -> (Outcome, State) { + let mut fixture = StoreFixture::new(label); + let program = anonymous_with_body(Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + body, + )])); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let state = parse_record(lines[2]) + .expect("parse record") + .state; + (outcome, state) + } + + // A single-line value propagates as the outcome; the step records Skip. + let (outcome, state) = skip_of( + "automatic-records-value", Operation::String(vec![Fragment::Text("probe output")]), - )]); - let program = anonymous_with_body(body); - let mut runner = Runner::new( - &program, - fixture.take_appender(), - HashSet::new(), - Automatic::with_handle(Vec::new()), - Library::stub(), ); - let env = Environment::new(); - let outcome = runner - .run(env) - .expect("run"); assert_eq!( outcome, Outcome::Done(Value::Literali("probe output".to_string())) ); - let pfftt = fixture.pfftt_contents(); - let lines: Vec<&str> = pfftt - .lines() - .filter(|line| { - !line - .trim() - .is_empty() - }) - .collect(); - let record = parse_record(lines[2]).expect("parse record"); - assert_eq!( - record.state, - State::Done(Some(RecordValue::Literal("probe output".to_string()))) - ); + assert_eq!(state, State::Skip); - // A pure-prose step (empty body) records () — nothing was computed. - let mut fixture = StoreFixture::new("automatic-empty-body"); - let body = Operation::Sequence(vec![step( - Ordinal::Dependent("1"), - Operation::Sequence(vec![]), - )]); - let program = anonymous_with_body(body); - let mut runner = Runner::new( - &program, - fixture.take_appender(), - HashSet::new(), - Automatic::with_handle(Vec::new()), - Library::stub(), - ); - let env = Environment::new(); - runner - .run(env) - .expect("run"); - let pfftt = fixture.pfftt_contents(); - let lines: Vec<&str> = pfftt - .lines() - .filter(|line| { - !line - .trim() - .is_empty() - }) - .collect(); - let record = parse_record(lines[2]).expect("parse record"); - assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); -} - -#[test] -fn multiline_body_value_records_unit_but_still_propagates() { - // A step whose body computes multi-line text (raw exec output) records () - let mut fixture = StoreFixture::new("multiline-records-unit"); - let body = Operation::Sequence(vec![step( - Ordinal::Dependent("1"), + // Multi-line text propagates intact and still records Skip. + let (outcome, state) = skip_of( + "multiline-records-unit", Operation::String(vec![Fragment::Text("1: lo\n2: eth0\n3: wlan0")]), - )]); - let program = anonymous_with_body(body); - let mut runner = Runner::new( - &program, - fixture.take_appender(), - HashSet::new(), - Automatic::with_handle(Vec::new()), - Library::stub(), ); - let outcome = runner - .run(Environment::new()) - .expect("run"); assert_eq!( outcome, Outcome::Done(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string())) ); - let pfftt = fixture.pfftt_contents(); - let lines: Vec<&str> = pfftt - .lines() - .filter(|line| { - !line - .trim() - .is_empty() - }) - .collect(); - let record = parse_record(lines[2]).expect("parse record"); - assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); + assert_eq!(state, State::Skip); + + // A pure-prose step (empty body) also records Skip — nothing effectful ran. + let (_, state) = skip_of("automatic-empty-body", Operation::Sequence(vec![])); + assert_eq!(state, State::Skip); } #[test] @@ -2055,18 +2113,14 @@ fn sequence_value_is_last_member() { Outcome::Done(Value::Literali("second".to_string())) ); - // Both steps ran, recording their own value in order (a trailing scope - // seal records Unit, not a Literal, so it is excluded). + // Neither step is effectful, so each records Skip. let pfftt = fixture.pfftt_contents(); - let dones: Vec = pfftt + let skips = pfftt .lines() .filter_map(|line| parse_record(line).ok()) - .filter_map(|record| match record.state { - State::Done(Some(RecordValue::Literal(text))) => Some(text), - _ => None, - }) - .collect(); - assert_eq!(dones, vec!["first".to_string(), "second".to_string()]); + .filter(|record| record.state == State::Skip) + .count(); + assert_eq!(skips, 2); } #[test] diff --git a/src/runner/driver.rs b/src/runner/driver.rs index f5f5af4..764e885 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -13,11 +13,14 @@ use std::io::{self, Write}; use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crossterm::style::{Attribute, SetAttribute, Stylize}; +use crossterm::style::{ + Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor, Stylize, +}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; use crossterm::{cursor, queue}; -use crate::formatting::{Identity, Render}; +use super::path::display_path; +use crate::formatting::{Identity, Render, Syntax}; use crate::highlighting::Terminal; use crate::value::Value; @@ -68,8 +71,16 @@ pub trait Driver { /// response values, yielding `Done(Literali(choice))`. Skip, fail, and /// quit are available either way. `produced` is consumed: the driver /// either moves it into the returned `Done` or discards it. `qualified` is - /// the step's Qualified Name, repeated on the live prompt line. - fn ask(&mut self, qualified: &str, choices: &[&str], produced: Value) -> UserInput; + /// the step's Qualified Name, repeated on the live prompt line. `effectful` + /// is whether the body ran an `exec`; an unattended driver settles `Done` + /// only when it did, otherwise `Skip`. + fn ask( + &mut self, + qualified: &str, + choices: &[&str], + produced: Value, + effectful: bool, + ) -> UserInput; /// Settle an external invocation this run cannot perform (a `` call /// into another document or system). `Console` prompts the operator to @@ -93,16 +104,21 @@ pub trait Driver { /// Prompt the operator to sign off a completed structural scope — a Section /// at its close, or the whole run at the entry procedure's close. Like /// `ask` but with no response choices, settling to the `↙` close marker; - /// `produced` is the scope's value, offered for acceptance. - fn seal(&mut self, qualified: &str, produced: Value) -> UserInput; + /// `produced` is the scope's value, offered for acceptance; `effectful` + /// governs an unattended driver's `Done`/`Skip` sign-off as in `ask`. + fn seal(&mut self, qualified: &str, produced: Value, effectful: bool) -> UserInput; + + /// Render the settled verdict line for a step or scope close: `marker` + /// (`→` step, `↙` scope close), Qualified Name, and the verdict's glyph. + /// Quit renders nothing. + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput); /// Obtain a value for a deferred input: `Done` supplies it, Skip / Fail /// abandon the call, Quit stops the run. fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput; /// The syntax renderer for highlighting source fragments shown to the - /// user. `Console` returns the ANSI `Terminal` renderer; non-interactive - /// drivers return `Identity` (no markup). + /// user — the ANSI `Terminal` when colouring, otherwise `Identity`. fn renderer(&self) -> &'static dyn Render; } @@ -134,14 +150,13 @@ impl Console { impl Driver for Console { fn step(&mut self, fqn: &str, description: &str) { - let _ = writeln!(self.output, "{}", format!("→ {}", fqn).dark_grey()); - let _ = writeln!(self.output); - write_indented(&mut self.output, description); - let _ = writeln!(self.output); + let renderer = self.renderer(); + render_step(&mut self.output, &display_path(fqn), description, renderer); } fn enter(&mut self, qualified: &str) { - let _ = writeln!(self.output, "{}", format!("↘ {}", qualified).dark_grey()); + let renderer = self.renderer(); + render_enter(&mut self.output, &display_path(qualified), renderer); let _ = writeln!(self.output); } @@ -151,8 +166,9 @@ impl Driver for Console { } fn section(&mut self, qualified: &str, numeral: &str, title: &str) { + let qualified = display_path(qualified); let renderer = self.renderer(); - let _ = writeln!(self.output, "{}", format!("↘ {}", qualified).dark_grey()); + write_marker_line(&mut self.output, &format!("↘ {}", qualified), renderer); let _ = writeln!(self.output); render_section(&mut self.output, numeral, title, renderer); let _ = writeln!(self.output); @@ -162,7 +178,13 @@ impl Driver for Console { write_indented(&mut self.output, message); } - fn ask(&mut self, qualified: &str, choices: &[&str], produced: Value) -> UserInput { + fn ask( + &mut self, + qualified: &str, + choices: &[&str], + produced: Value, + _effectful: bool, + ) -> UserInput { prompt(&mut self.output, qualified, "→", choices, produced) } @@ -174,14 +196,23 @@ impl Driver for Console { prompt_command(&mut self.output, qualified, script) } - fn seal(&mut self, qualified: &str, produced: Value) -> UserInput { + fn seal(&mut self, qualified: &str, produced: Value, _effectful: bool) -> UserInput { prompt(&mut self.output, qualified, "↙", &[], produced) } + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput) { + let qualified = display_path(qualified); + let renderer = self.renderer(); + render_settle(&mut self.output, marker, &qualified, verdict, renderer); + let _ = self + .output + .flush(); + } + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { let label = format!( "{}({} : {})", - qualified, + display_path(qualified), name.unwrap_or("?"), forma.unwrap_or("?") ); @@ -193,12 +224,8 @@ impl Driver for Console { } } -/// Run one interactive prompt and settle it. The live `▶` line is drawn and -/// re-drawn as keys arrive; on settle it is replaced by a `settle` verdict line -/// (`→` for a step, `↙` for a scope close) in dark grey with a trailing verdict -/// glyph (`✓` done, `⊘` skip, `✗` fail), which scrolls up into the record — -/// except Quit, which leaves the scope unfinished (resume re-runs it) and just -/// clears the row. +/// Run one interactive prompt and return the user's verdict, clearing the +/// live `▶` row on settle. fn prompt( out: &mut W, qualified: &str, @@ -206,43 +233,14 @@ fn prompt( choices: &[&str], produced: Value, ) -> UserInput { + let qualified = display_path(qualified); let result = interact( out, - qualified, + &qualified, settle, Interaction::begin(choices, produced), ); - let col = settle - .chars() - .count() as u16 - + 1 - + qualified - .chars() - .count() as u16 - + 1; - match &result { - UserInput::Done(_) => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "✓".green()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } - UserInput::Skip => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "⊘".yellow()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } - UserInput::Fail(_) => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "✗".red()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } - UserInput::Quit => { - let _ = writeln!(out); - } - } + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); let _ = out.flush(); result } @@ -253,10 +251,11 @@ fn prompt( /// `Done` no settle line is printed — the command's output follows immediately /// and the step's own verdict prompt judges the result. fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserInput { + let qualified = display_path(qualified); let field = edit(script.to_string(), Value::Literali(script.to_string())); let result = interact( out, - qualified, + &qualified, "→", Interaction { field, @@ -357,17 +356,38 @@ fn write_indented(out: &mut W, text: &str) { } } +fn write_marker_line(out: &mut W, text: &str, renderer: &dyn Render) { + let _ = writeln!(out, "{}", renderer.style(Syntax::Marker, text)); +} + /// Render a step's `→` line and description. -fn render_step(out: &mut W, fqn: &str, description: &str) { - let _ = writeln!(out, "→ {}", fqn); +fn render_step(out: &mut W, fqn: &str, description: &str, renderer: &dyn Render) { + write_marker_line(out, &format!("→ {}", fqn), renderer); let _ = writeln!(out); write_indented(out, description); let _ = writeln!(out); } /// Render a named scope's `↘` descent line. -fn render_enter(out: &mut W, qualified: &str) { - let _ = writeln!(out, "↘ {}", qualified); +fn render_enter(out: &mut W, qualified: &str, renderer: &dyn Render) { + write_marker_line(out, &format!("↘ {}", qualified), renderer); +} + +fn render_settle( + out: &mut W, + marker: &str, + qualified: &str, + verdict: &UserInput, + renderer: &dyn Render, +) { + let (glyph, syntax) = match verdict { + UserInput::Done(_) => ("✓", Syntax::Done), + UserInput::Skip => ("⊘", Syntax::Skip), + UserInput::Fail(_) => ("✗", Syntax::Fail), + UserInput::Quit => return, + }; + let path = renderer.style(Syntax::Marker, &format!("{} {}", marker, qualified)); + let _ = writeln!(out, "{} {}", path, renderer.style(syntax, glyph)); } /// Render a Section heading: its numeral and title. @@ -584,6 +604,35 @@ impl Interaction { } } + /// Carry out a menu item, as `` does on the highlighted one. Fail + /// opens the reason submenu, leaving the underlying value untouched so Esc + /// can back out cleanly. + fn activate(&mut self, item: MenuItem) -> Option { + match item { + MenuItem::Edit => { + self.enter_edit(); + None + } + MenuItem::Skip => Some(UserInput::Skip), + MenuItem::Fail => { + self.reason = Some(Reason { + buffer: String::new(), + cursor: 0, + }); + None + } + MenuItem::Quit => Some(UserInput::Quit), + } + } + + /// A letter shortcut: rest the highlight on `item`, then activate it. + fn choose(&mut self, item: MenuItem) -> Option { + self.menu = MENU + .iter() + .position(|m| *m == item); + self.activate(item) + } + fn menu_key(&mut self, code: KeyCode) -> Option { let active = (*self .menu @@ -602,23 +651,10 @@ impl Interaction { } None } - KeyCode::Enter => match MENU[active] { - MenuItem::Edit => { - self.enter_edit(); - None - } - MenuItem::Skip => Some(UserInput::Skip), - MenuItem::Fail => { - // Open the reason submenu, leaving Fail highlighted and the - // underlying value untouched so Esc can back out cleanly. - self.reason = Some(Reason { - buffer: String::new(), - cursor: 0, - }); - None - } - MenuItem::Quit => Some(UserInput::Quit), - }, + KeyCode::Enter => self.activate(MENU[active]), + KeyCode::Char('s') | KeyCode::Char('S') => self.choose(MenuItem::Skip), + KeyCode::Char('f') | KeyCode::Char('F') => self.choose(MenuItem::Fail), + KeyCode::Char('q') | KeyCode::Char('Q') => self.choose(MenuItem::Quit), KeyCode::Esc => { self.menu = None; None @@ -781,7 +817,23 @@ fn draw( out.flush() } -/// Render a horizontal row of options with the active one in reverse video. +/// The orange that highlights Responses in the formatter, mirrored here so the +/// menu carries the same identity. +const RESPONSE: Color = Color::Rgb { + r: 0xf5, + g: 0x79, + b: 0x00, +}; + +/// A darker brown for the active Response, legible on the white highlight bar. +const RESPONSE_ACTIVE: Color = Color::Rgb { + r: 0x8f, + g: 0x59, + b: 0x02, +}; + +/// Render a horizontal row of Response options in the formatter's orange, the +/// active one in reverse video. fn render_choices(out: &mut W, choices: &[&str], active: usize) -> io::Result<()> { for (i, choice) in choices .iter() @@ -790,13 +842,19 @@ fn render_choices(out: &mut W, choices: &[&str], active: usize) -> io: if i > 0 { write!(out, " ")?; } + queue!(out, SetAttribute(Attribute::Bold))?; if i == active { - queue!(out, SetAttribute(Attribute::Reverse))?; + queue!( + out, + SetBackgroundColor(Color::White), + SetForegroundColor(RESPONSE_ACTIVE) + )?; write!(out, " {} ", choice)?; - queue!(out, SetAttribute(Attribute::Reset))?; } else { + queue!(out, SetForegroundColor(RESPONSE))?; write!(out, " {} ", choice)?; } + queue!(out, ResetColor, SetAttribute(Attribute::Reset))?; } Ok(()) } @@ -815,14 +873,14 @@ fn render_menu(out: &mut W, interaction: &Interaction, active: usize) let label = item.label(); if i == active { queue!(out, SetAttribute(Attribute::Reverse))?; - write!(out, "{}", label)?; + write!(out, " {} ", label)?; queue!(out, SetAttribute(Attribute::Reset))?; } else if !interaction.enabled(*item) { queue!(out, SetAttribute(Attribute::Dim))?; - write!(out, "{}", label)?; + write!(out, " {} ", label)?; queue!(out, SetAttribute(Attribute::Reset))?; } else { - write!(out, "{}", label)?; + write!(out, " {} ", label)?; } } Ok(()) @@ -917,12 +975,14 @@ fn text_key(buffer: &mut String, cursor: &mut usize, code: KeyCode) -> bool { /// or first failure. A pure-prose step (empty body value) records (). pub struct Automatic { output: W, + renderer: &'static dyn Render, } impl Automatic { - pub fn new() -> Self { + pub fn new(colour: bool) -> Self { Automatic { output: io::stdout(), + renderer: if colour { &Terminal } else { &Identity }, } } } @@ -930,17 +990,29 @@ impl Automatic { #[cfg(test)] impl Automatic { pub fn with_handle(output: W) -> Self { - Automatic { output } + Automatic { + output, + renderer: &Identity, + } + } + + pub fn into_output(self) -> W { + self.output } } impl Driver for Automatic { fn step(&mut self, fqn: &str, description: &str) { - render_step(&mut self.output, fqn, description); + render_step( + &mut self.output, + &display_path(fqn), + description, + self.renderer, + ); } fn enter(&mut self, qualified: &str) { - render_enter(&mut self.output, qualified); + render_enter(&mut self.output, &display_path(qualified), self.renderer); let _ = writeln!(self.output); } @@ -950,10 +1022,10 @@ impl Driver for Automatic { } fn section(&mut self, qualified: &str, numeral: &str, title: &str) { - let renderer = self.renderer(); - let _ = writeln!(self.output, "↘ {}", qualified); + let qualified = display_path(qualified); + write_marker_line(&mut self.output, &format!("↘ {}", qualified), self.renderer); let _ = writeln!(self.output); - render_section(&mut self.output, numeral, title, renderer); + render_section(&mut self.output, numeral, title, self.renderer); let _ = writeln!(self.output); } @@ -961,8 +1033,18 @@ impl Driver for Automatic { write_indented(&mut self.output, message); } - fn ask(&mut self, _qualified: &str, _choices: &[&str], produced: Value) -> UserInput { - UserInput::Done(produced) + fn ask( + &mut self, + _qualified: &str, + _choices: &[&str], + produced: Value, + effectful: bool, + ) -> UserInput { + if effectful { + UserInput::Done(produced) + } else { + UserInput::Skip + } } fn external(&mut self, _qualified: &str) -> UserInput { @@ -974,8 +1056,17 @@ impl Driver for Automatic { UserInput::Done(Value::Literali(script.to_string())) } - fn seal(&mut self, _qualified: &str, produced: Value) -> UserInput { - UserInput::Done(produced) + fn seal(&mut self, _qualified: &str, produced: Value, effectful: bool) -> UserInput { + if effectful { + UserInput::Done(produced) + } else { + UserInput::Skip + } + } + + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput) { + let qualified = display_path(qualified); + render_settle(&mut self.output, marker, &qualified, verdict, self.renderer); } fn acquire( @@ -988,7 +1079,7 @@ impl Driver for Automatic { } fn renderer(&self) -> &'static dyn Render { - &Identity + self.renderer } } @@ -1019,7 +1110,13 @@ impl Driver for Headless { fn announce(&mut self, _message: &str) {} - fn ask(&mut self, _qualified: &str, _choices: &[&str], produced: Value) -> UserInput { + fn ask( + &mut self, + _qualified: &str, + _choices: &[&str], + produced: Value, + _effectful: bool, + ) -> UserInput { self.results += 1; UserInput::Done(produced) } @@ -1035,11 +1132,13 @@ impl Driver for Headless { fn section(&mut self, _qualified: &str, _numeral: &str, _title: &str) {} - fn seal(&mut self, _qualified: &str, produced: Value) -> UserInput { + fn seal(&mut self, _qualified: &str, produced: Value, _effectful: bool) -> UserInput { self.results += 1; UserInput::Done(produced) } + fn settle(&mut self, _marker: &str, _qualified: &str, _verdict: &UserInput) {} + fn acquire( &mut self, _qualified: &str, @@ -1165,7 +1264,13 @@ impl Driver for Mock { .push(Event::Announce(message.to_string())); } - fn ask(&mut self, qualified: &str, choices: &[&str], _produced: Value) -> UserInput { + fn ask( + &mut self, + qualified: &str, + choices: &[&str], + _produced: Value, + _effectful: bool, + ) -> UserInput { self.events .push(Event::Ask { qualified: qualified.to_string(), @@ -1206,7 +1311,7 @@ impl Driver for Mock { /// rather than draining the `ask` answer queue — the structural-scope /// close is orthogonal to the step verdicts a test drives. A test /// asserting sign-off behaviour inspects the recorded `Seal` event. - fn seal(&mut self, qualified: &str, _produced: Value) -> UserInput { + fn seal(&mut self, qualified: &str, _produced: Value, _effectful: bool) -> UserInput { self.events .push(Event::Seal { qualified: qualified.to_string(), @@ -1214,6 +1319,8 @@ impl Driver for Mock { UserInput::Done(Value::Unitus) } + fn settle(&mut self, _marker: &str, _qualified: &str, _verdict: &UserInput) {} + fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { self.events .push(Event::Acquire { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 9a30e8e..236a5fc 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -35,6 +35,7 @@ const STORE_ROOT: &str = ".store"; /// parameters before the beginning the walk. pub fn start<'i>( mode: Mode, + colour: bool, document: &Path, program: &'i Program<'i>, arguments: &[String], @@ -55,8 +56,13 @@ pub fn start<'i>( runner.run(env)? } Mode::Automatic => { - let mut runner = - Runner::new(program, appender, HashSet::new(), Automatic::new(), library); + let mut runner = Runner::new( + program, + appender, + HashSet::new(), + Automatic::new(colour), + library, + ); runner.run(env)? } }; diff --git a/src/runner/path.rs b/src/runner/path.rs index cdecad7..af793d6 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -67,6 +67,34 @@ impl<'i> QualifiedPath<'i> { } } +/// Trim a rendered PFFTT path to the live-prompt form: drop the leading `/`, +/// and the entry procedure's `name:` head when a section immediately follows. +pub fn display_path(qualified: &str) -> String { + let body = qualified + .strip_prefix('/') + .unwrap_or(qualified); + let mut parts: Vec<&str> = body + .split('/') + .collect(); + if parts.len() >= 2 && parts[0].ends_with(':') && is_section_component(parts[1]) { + parts.remove(0); + } + parts.join("/") +} + +/// Leading token, not the whole component: an acquire label can glue an +/// ` ` annotation after the numeral. +fn is_section_component(part: &str) -> bool { + let token = part + .split_whitespace() + .next() + .unwrap_or(""); + !token.is_empty() + && token + .bytes() + .all(|b| b"IVXLCDM".contains(&b)) +} + /// Render a segment slice as a PFFTT absolute path, always rooted at `/`. pub fn render_path(segments: &[PathSegment]) -> String { let pieces: Vec = segments diff --git a/src/runner/runner.rs b/src/runner/runner.rs index ff4c650..64a7ab3 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -32,7 +32,8 @@ use crate::value::Value; #[derive(Debug, Clone, PartialEq)] pub enum Outcome { Done(Value), - Skipped, + /// Carries the body's computed value for block semantics; recorded as no value. + Skipped(Value), Failed(Failure), Stopped, } @@ -88,10 +89,12 @@ pub enum RunnerError { right: &'static str, }, ParameterArityMismatch { - expected: usize, + procedure: String, + parameters: Vec, actual: usize, }, ParameterUnexpected { + procedure: String, actual: usize, }, TerminalRequired, @@ -111,6 +114,9 @@ pub struct Runner<'i, D: Driver> { path: QualifiedPath<'i>, library: Library, context: Context, + /// Count of `exec` actions run; a rise across a step or scope's body + /// substantiates it for an unattended driver. + actions: usize, } impl<'i, D: Driver> Runner<'i, D> { @@ -129,6 +135,7 @@ impl<'i, D: Driver> Runner<'i, D> { path: QualifiedPath::new(), library, context: Context::native(), + actions: 0, } } @@ -178,8 +185,49 @@ impl<'i, D: Driver> Runner<'i, D> { .path .render(); self.begin_scope(&qualified)?; - self.announce_procedure(entry, name, &qualified); + let params = entry + .parameters + .unwrap_or(&[]); + if params.is_empty() { + self.driver + .enter(&qualified); + } else { + let echo = render_argument_echo(name, params, &env); + self.driver + .enter(&echo); + } + let declaration = crate::formatting::formatter::render_declaration( + name, + entry.parameters, + entry.signature, + self.driver + .renderer(), + ); + self.driver + .display(&declaration); + if let Some(t) = entry.title { + let title_text = crate::formatting::formatter::render_title( + t, + self.driver + .renderer(), + ); + self.driver + .display(&title_text); + } + if !entry + .description + .is_empty() + { + let description = crate::formatting::formatter::render_description( + entry.description, + self.driver + .renderer(), + ); + self.driver + .display(&description); + } } + let actions_before = self.actions; let result = self.walk(&mut env, &entry.body); // A named entry procedure is a structural scope: a completed run closes // with a final sign-off prompt at its path. A Quit or error walk skips @@ -189,9 +237,10 @@ impl<'i, D: Driver> Runner<'i, D> { let qualified = self .path .render(); + let effectful = self.actions > actions_before; let sealed = match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&qualified, outcome), + Ok(outcome) => self.seal_scope(&qualified, outcome, effectful), Err(error) => Err(error), }; self.path @@ -274,9 +323,10 @@ impl<'i, D: Driver> Runner<'i, D> { executable, Some(&[chosen]), )?; + self.actions += 1; Ok(Outcome::Done(value)) } - UserInput::Skip => Ok(Outcome::Skipped), + UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), UserInput::Fail(reason) => Ok(Outcome::Failed(Failure::Aborted(reason))), UserInput::Quit => self.record_stop(), } @@ -367,11 +417,21 @@ impl<'i, D: Driver> Runner<'i, D> { // A bare call defers every argument and is exempt; a written // argument list must match arity exactly. if !invocable.elided { + let procedure = subroutine + .name + .as_ref() + .map(|n| n.value) + .unwrap_or("the procedure") + .to_string(); if expected == 0 && actual > 0 { - return Err(RunnerError::ParameterUnexpected { actual }); + return Err(RunnerError::ParameterUnexpected { procedure, actual }); } if expected != actual { - return Err(RunnerError::ParameterArityMismatch { expected, actual }); + return Err(RunnerError::ParameterArityMismatch { + procedure, + parameters: describe_parameters(params, subroutine.signature), + actual, + }); } } let mut local = Environment::new(); @@ -483,10 +543,12 @@ impl<'i, D: Driver> Runner<'i, D> { // Walk the callee's body in its own `local` environment, // then sign off its scope; a Quit or error skips the // sign-off, leaving the procedure unfinished. + let actions_before = self.actions; let result = self.walk(&mut local, &subroutine.body); + let effectful = self.actions > actions_before; let sealed = match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&lexical, outcome), + Ok(outcome) => self.seal_scope(&lexical, outcome, effectful), Err(error) => Err(error), }; self.path @@ -552,6 +614,8 @@ impl<'i, D: Driver> Runner<'i, D> { return self.record_stop(); } + self.driver + .settle("→", &qualified, &input); let outcome = outcome_from(input); self.appender .append(&Record { @@ -583,19 +647,11 @@ impl<'i, D: Driver> Runner<'i, D> { over: Option<&'i Operation<'i>>, body: &'i Operation<'i>, ) -> Result { - self.driver - .announce(&describe_loop(names, over)); match over { None => { let mut number = 1; loop { - self.path - .push(PathSegment::Iteration(number)); - let result = self.walk(env, body); - self.path - .pop(); - - if let Outcome::Stopped = result? { + if let Outcome::Stopped = self.walk_iteration(env, number, body)? { return Ok(Outcome::Stopped); } number += 1; @@ -621,13 +677,7 @@ impl<'i, D: Driver> Runner<'i, D> { super::evaluator::bind_names(env, names, item)?; let number = i + 1; - self.path - .push(PathSegment::Iteration(number)); - let result = self.walk(env, body); - self.path - .pop(); - - if let Outcome::Stopped = result? { + if let Outcome::Stopped = self.walk_iteration(env, number, body)? { return Ok(Outcome::Stopped); } } @@ -636,6 +686,37 @@ impl<'i, D: Driver> Runner<'i, D> { } } + /// Walk one pass of a loop body within its `[number]` iteration scope, + /// bracketing it with `↘`/`↙` chrome. + fn walk_iteration( + &mut self, + env: &mut Environment, + number: usize, + body: &'i Operation<'i>, + ) -> Result { + self.path + .push(PathSegment::Iteration(number)); + let qualified = self + .path + .render(); + self.driver + .enter(&qualified); + let result = self.walk(env, body); + let verdict = match &result { + Ok(Outcome::Done(_)) => Some(UserInput::Done(Value::Unitus)), + Ok(Outcome::Skipped(_)) => Some(UserInput::Skip), + Ok(Outcome::Failed(Failure::Aborted(reason))) => Some(UserInput::Fail(reason.clone())), + _ => None, + }; + if let Some(verdict) = verdict { + self.driver + .settle("↙", &qualified, &verdict); + } + self.path + .pop(); + result + } + fn walk_sequence( &mut self, env: &mut Environment, @@ -658,9 +739,9 @@ impl<'i, D: Driver> Runner<'i, D> { _ => self.walk(env, op)?, }; match outcome { - Outcome::Done(value) => last = value, + Outcome::Done(value) | Outcome::Skipped(value) => last = value, Outcome::Stopped => return Ok(Outcome::Stopped), - Outcome::Skipped | Outcome::Failed(_) => {} + Outcome::Failed(_) => {} } } Ok(Outcome::Done(last)) @@ -686,16 +767,18 @@ impl<'i, D: Driver> Runner<'i, D> { .pop(); return Ok(Outcome::Done(Value::Unitus)); } + let actions_before = self.actions; self.begin_scope(&qualified)?; let result = self.perform_section(env, numeral, title, body); self.path .pop(); + let effectful = self.actions > actions_before; // A section is a structural scope: the operator signs it off at its // close before the next sibling runs. A Quit or error walk skips the // prompt — the section did not complete. match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&qualified, outcome), + Ok(outcome) => self.seal_scope(&qualified, outcome, effectful), Err(error) => Err(error), } } @@ -806,6 +889,7 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .step(qualified, &step_text); + let actions_before = self.actions; let produced = match self.walk(env, body)? { Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, @@ -829,9 +913,12 @@ impl<'i, D: Driver> Runner<'i, D> { .iter() .map(|r| r.value) .collect(); + let effectful = self.actions > actions_before; + // `ask` consumes `produced`; keep a copy for a Skip to propagate. + let propagate = produced.clone(); let input = self .driver - .ask(qualified, &choices, produced); + .ask(qualified, &choices, produced, effectful); // Quit halts the walk; this step's Begin stands without a matching // outcome, so resume re-runs it. @@ -839,7 +926,12 @@ impl<'i, D: Driver> Runner<'i, D> { return self.record_stop(); } - let outcome = outcome_from(input); + self.driver + .settle("→", qualified, &input); + let outcome = match input { + UserInput::Skip => Outcome::Skipped(propagate), + other => outcome_from(other), + }; let record = Record { recorded: now_iso8601(), run_id, @@ -913,21 +1005,32 @@ impl<'i, D: Driver> Runner<'i, D> { /// Sign off a completed structural scope — a Section at its close, or the /// whole run at the entry procedure. - fn seal_scope(&mut self, qualified: &str, outcome: Outcome) -> Result { + fn seal_scope( + &mut self, + qualified: &str, + outcome: Outcome, + effectful: bool, + ) -> Result { let produced = match outcome { - Outcome::Done(value) => value, + Outcome::Done(value) | Outcome::Skipped(value) => value, _ => Value::Unitus, }; + let propagate = produced.clone(); let run_id = self .appender .run_id(); let input = self .driver - .seal(qualified, produced); + .seal(qualified, produced, effectful); if let UserInput::Quit = input { return self.record_stop(); } - let outcome = outcome_from(input); + self.driver + .settle("↙", qualified, &input); + let outcome = match input { + UserInput::Skip => Outcome::Skipped(propagate), + other => outcome_from(other), + }; let record = Record { recorded: now_iso8601(), run_id, @@ -976,21 +1079,6 @@ impl<'i, D: Driver> Runner<'i, D> { } } -fn describe_loop( - names: &[crate::language::Identifier<'_>], - over: Option<&Operation<'_>>, -) -> String { - let joined: Vec<&str> = names - .iter() - .map(|n| n.value) - .collect(); - match over { - None => "repeat".to_string(), - Some(_) if joined.is_empty() => "foreach".to_string(), - Some(_) => format!("foreach {}", joined.join(", ")), - } -} - fn describe_execute(function: &str) -> String { format!("{}()", function) } @@ -999,7 +1087,7 @@ fn describe_execute(function: &str) -> String { fn outcome_from(input: UserInput) -> Outcome { match input { UserInput::Done(value) => Outcome::Done(value), - UserInput::Skip => Outcome::Skipped, + UserInput::Skip => Outcome::Skipped(Value::Unitus), UserInput::Fail(reason) => Outcome::Failed(Failure::Aborted(reason)), UserInput::Quit => Outcome::Stopped, } @@ -1017,7 +1105,7 @@ fn record_state(outcome: &Outcome) -> State { State::Done(Some(RecordValue::Literal(text.clone()))) } Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), - Outcome::Skipped => State::Skip, + Outcome::Skipped(_) => State::Skip, Outcome::Failed(Failure::Aborted(reason)) => { if reason.is_empty() { // The operator failed the step without giving a reason; record @@ -1033,6 +1121,56 @@ fn record_state(outcome: &Outcome) -> State { } } +/// Render the entry call with arguments bound to each parameter in +/// `value ~ name` form, e.g. `connectivity_check([] ~ e, 0 ~ s)`. +fn render_argument_echo(name: &str, params: &[language::Identifier], env: &Environment) -> String { + let bindings: Vec = params + .iter() + .map(|p| { + let value = match env.lookup(p.value) { + Some(Value::Literali(text)) => text.clone(), + Some(other) => other.to_string(), + None => String::new(), + }; + format!("{} ~ {}", value, p.value) + }) + .collect(); + format!("{}: ({})", name, bindings.join(", ")) +} + +/// Describe a procedure's expected parameters as `name : Type` fragments for +/// an arity error, falling back to whichever of name or forma is known. +fn describe_parameters( + params: &[language::Identifier], + signature: Option<&language::Signature>, +) -> Vec { + let formae = signature + .map(|s| { + s.requires + .formae() + }) + .unwrap_or_default(); + let count = params + .len() + .max(formae.len()); + (0..count) + .map(|i| { + let name = params + .get(i) + .map(|p| p.value); + let forma = formae + .get(i) + .map(|f| f.value); + match (name, forma) { + (Some(n), Some(t)) => format!("{} : {}", n, t), + (Some(n), None) => n.to_string(), + (None, Some(t)) => t.to_string(), + (None, None) => "?".to_string(), + } + }) + .collect() +} + /// Build an `Environment` seeded with the entry procedure's parameters /// bound to the supplied CLI arguments. pub(super) fn bind_parameters( @@ -1048,11 +1186,21 @@ pub(super) fn bind_parameters( .unwrap_or(&[]); let expected = params.len(); let actual = arguments.len(); + let procedure = entry + .name + .as_ref() + .map(|n| n.value) + .unwrap_or("the entry procedure") + .to_string(); if expected == 0 && actual > 0 { - return Err(RunnerError::ParameterUnexpected { actual }); + return Err(RunnerError::ParameterUnexpected { procedure, actual }); } if expected != actual { - return Err(RunnerError::ParameterArityMismatch { expected, actual }); + return Err(RunnerError::ParameterArityMismatch { + procedure, + parameters: describe_parameters(params, entry.signature), + actual, + }); } let mut env = Environment::new(); for (param, argument) in params diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index ed30e36..4fd45f6 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -1832,11 +1832,8 @@ choose : #[test] fn section_title_invocation_hoists_into_body() { - // A `` in a section title is an executable Descriptive: it must - // be evaluated to render the title. It hoists into Section.body in the - // same way a procedure description's executables hoist into the - // procedure body, and so passes through the resolve pass like any - // other Invoke. + // A `` in a section title hoists into the body as the explicit + // entry, suppressing the auto-descent so `init` runs once, not twice. let source = r#" % technique v1 @@ -1887,3 +1884,37 @@ init : () -> () }; assert_eq!(idx, init_idx); } + +#[test] +fn section_title_non_invoke_keeps_descent() { + // A title executable that is not an entry invocation keeps the descent. + let source = r#" +% technique v1 + +main : + +I. Count is { 42 } + +init : () -> () + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + let Operation::Section { body, .. } = &ops[0] else { + panic!("expected Section"); + }; + let Operation::Sequence(section_body) = body.as_ref() else { + panic!("expected Section body Sequence"); + }; + // The title's value-read, then the descent into `init`. + assert_eq!(section_body.len(), 2); + let Operation::Invoke(invocable) = §ion_body[1] else { + panic!("expected Invoke, got {:?}", section_body[1]); + }; + assert_eq!(invocable.target, SubroutineRef::Resolved(SubroutineId(1))); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index ecd2bf1..b2a2ed4 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -50,19 +50,6 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec` in the section heading. -fn descends(ops: &[Operation<'_>]) -> bool { - ops.iter() - .any(|op| { - if let Operation::Invoke(_) = op { - true - } else { - false - } - }) -} - #[derive(Debug, Eq, PartialEq)] pub enum TranslationError<'i> { DuplicateProcedure(language::Identifier<'i>), @@ -312,24 +299,26 @@ impl<'i> Translator<'i> { } } language::Technique::Procedures(procedures) => { - // A section descends into the first procedure its body - // declares, its entry point. A heading that already - // invokes one explicitly (a - // - // II. Do it now - // - // in the title) has hoisted that invoke above and is - // the descent already, pre-empting this one so the - // procedure isn't run twice. - if let Some(procedure) = procedures - .first() - .filter(|_| !descends(&body_ops)) - { - body_ops.push(Operation::Invoke(Invocable { - target: SubroutineRef::Unresolved(procedure.name), - arguments: Vec::new(), - elided: true, - })); + // Descend into the first procedure, the section's entry + // point, unless the title already invoked it. + if let Some(procedure) = procedures.first() { + let invoked = body_ops + .iter() + .any(|op| { + if let Operation::Invoke(invocable) = op { + invocable.target + == SubroutineRef::Unresolved(procedure.name) + } else { + false + } + }); + if !invoked { + body_ops.push(Operation::Invoke(Invocable { + target: SubroutineRef::Unresolved(procedure.name), + arguments: Vec::new(), + elided: true, + })); + } } } language::Technique::Empty => {}