diff --git a/examples/prototype/AirlockPowerdown.tq b/examples/prototype/AirlockPowerdown.tq index b43e965e..180dac22 100644 --- a/examples/prototype/AirlockPowerdown.tq +++ b/examples/prototype/AirlockPowerdown.tq @@ -21,19 +21,19 @@ node1_htr_avail_16 : # Inhibiting Node 1 B HTRS (1 to 6) @pcs - { foreach node in seq(1, 6) } - 1. Check Availability - 2. Perform { cmd("Inhibit") } - 3. Check Availability - 'Inhibited' + { foreach node in seq(1, 6) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' node1_htr_avail_79 : # Inhibiting Node 1 B HTRS (7 to 9) @pcs - { foreach node in seq(7, 9) } - 1. Check Availability - 2. Perform { cmd("Inhibit") } - 3. Check Availability - 'Inhibited' + { foreach node in seq(7, 9) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 6632252c..cf743cd8 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -297,6 +297,9 @@ fn render_expression(expr: &Expression) -> String { Expression::Repeat(inner, _) => { format!("repeat {}", render_expression(inner)) } + Expression::Within(inner, _) => { + format!("within {}", render_expression(inner)) + } Expression::Foreach(ids, inner, _) => { let vars = if ids.len() == 1 { ids[0] diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 25fc76cf..42b30a71 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -317,6 +317,30 @@ fn is_tablet_list_expr(expr: &Expression) -> bool { } } +/// Whether a code block's expressions render as a single inline line +/// (`{ expr }`) rather than the multi-line block form. A `;`-separated +/// sequence and any single non-tablet expression (a value, a control +/// structure like `foreach`/`repeat`/`within`, ...) are inline; a tablet +/// literal is not. +fn is_inline_code_block(expressions: &[Expression]) -> bool { + let has_separator = expressions + .iter() + .any(|e| { + if let Expression::Separator = e { + true + } else { + false + } + }); + if has_separator { + true + } else if expressions.len() == 1 { + !is_tablet_list_expr(&expressions[0]) + } else { + false + } +} + struct Formatter<'i> { fragments: Vec<(Syntax, Cow<'i, str>)>, nesting: u8, @@ -1091,8 +1115,13 @@ impl<'i> Formatter<'i> { return; } - let is_code = if let Scope::CodeBlock { .. } = subscopes[0] { - true + // A multi-line code block (a tablet literal) stays flush + // with the attribute line, its braces already providing + // visual grouping; a single-line code block — whether a bare + // value/call or a control structure (foreach/repeat/within) + // — gets a normal indent like any other subscope. + let keep_flush = if let Scope::CodeBlock { expressions, .. } = &subscopes[0] { + !is_inline_code_block(expressions) } else { false }; @@ -1103,7 +1132,7 @@ impl<'i> Formatter<'i> { self.append_attributes(attributes); self.add_fragment_reference(Syntax::Newline, "\n"); - if !is_code { + if !keep_flush { self.increase(4); } self.append_scope(&subscopes[0]); @@ -1113,7 +1142,7 @@ impl<'i> Formatter<'i> { self.append_scope(scope); } - if !is_code { + if !keep_flush { self.decrease(4); } } @@ -1122,22 +1151,7 @@ impl<'i> Formatter<'i> { subscopes: substeps, .. } => { - let has_separator = expressions - .iter() - .any(|e| { - if let Expression::Separator = e { - true - } else { - false - } - }); - let inline = if has_separator { - true - } else if expressions.len() == 1 { - !is_tablet_list_expr(&expressions[0]) - } else { - false - }; + let inline = is_inline_code_block(expressions); if inline { self.indent(); @@ -1308,6 +1322,11 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::Neutral, " "); self.append_expression(expression); } + Expression::Within(expression, _) => { + self.add_fragment_reference(Syntax::Keyword, "within"); + self.add_fragment_reference(Syntax::Neutral, " "); + self.append_expression(expression); + } Expression::Foreach(variables, expression, _) => { self.add_fragment_reference(Syntax::Keyword, "foreach"); self.add_fragment_reference(Syntax::Neutral, " "); diff --git a/src/language/types.rs b/src/language/types.rs index d3fdbe4b..a7fa374c 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -442,6 +442,7 @@ pub enum Expression<'i> { Multiline(Option<&'i str>, Vec<&'i str>, Span), Repeat(Box>, Span), Foreach(Vec>, Box>, Span), + Within(Box>, Span), Application(Invocation<'i>, Span), Execution(Function<'i>, Span), Binding(Box>, Vec>, Span), @@ -467,6 +468,7 @@ impl PartialEq for Expression<'_> { (Expression::Foreach(a1, a2, _), Expression::Foreach(b1, b2, _)) => { a1 == b1 && a2 == b2 } + (Expression::Within(a, _), Expression::Within(b, _)) => a == b, (Expression::Application(a, _), Expression::Application(b, _)) => a == b, (Expression::Execution(a, _), Expression::Execution(b, _)) => a == b, (Expression::Binding(a1, a2, _), Expression::Binding(b1, b2, _)) => { diff --git a/src/linking/linker.rs b/src/linking/linker.rs index 7494c280..d3676274 100644 --- a/src/linking/linker.rs +++ b/src/linking/linker.rs @@ -93,6 +93,10 @@ fn link_operation<'i>( } link_operation(body, library, problems); } + Operation::Within { bound, body, .. } => { + link_operation(bound, library, problems); + link_operation(body, library, problems); + } Operation::Bind { value, .. } => link_operation(value, library, problems), Operation::String(fragments) => { for fragment in fragments { diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index ecbe1490..17e9b1d3 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1144,12 +1144,13 @@ impl<'i> Parser<'i> { } else if is_code_block(content) { match parser.read_code_block() { Ok(expressions) => { - // A loop code block owns the steps below it as its body, - // the way an attribute block owns its scope; `read_scopes` - // stops at a trailing description, leaving it to be flagged. - // A plain code block owns nothing, so following content - // stays at this level. - let subscopes = if is_loop_block(&expressions) { + // A control-structure code block owns the steps below + // it as its body, the way an attribute block owns its + // scope; `read_scopes` stops at a trailing + // description, leaving it to be flagged. A plain code + // block owns nothing, so following content stays at + // this level. + let subscopes = if is_control_structure(&expressions) { match parser.read_scopes() { Ok(subscopes) => subscopes, Err(error) => { @@ -1530,6 +1531,8 @@ impl<'i> Parser<'i> { self.read_repeat_expression() } else if is_foreach_keyword(content) { self.read_foreach_expression() + } else if is_within_keyword(content) { + self.read_within_expression() } else if content.starts_with("foreach ") { // Malformed foreach expression return Err(ParsingError::InvalidForeach(Span::new(self.offset, 0))); @@ -1746,6 +1749,18 @@ impl<'i> Parser<'i> { Ok(Expression::Repeat(Box::new(expression), span)) } + fn read_within_expression(&mut self) -> Result, ParsingError> { + // Parse "within " + let start = self.offset; + self.advance(6); + self.trim_whitespace(); + + let expression = self.read_expression()?; + + let span = self.span_since(start); + Ok(Expression::Within(Box::new(expression), span)) + } + fn read_binding_expression(&mut self) -> Result, ParsingError> { let start = self.offset; @@ -2400,7 +2415,7 @@ impl<'i> Parser<'i> { || is_enum_response(line) || malformed_step_pattern(line) || malformed_response_pattern(line) - || is_loop_block_line(line) + || is_control_structure_line(line) }, |outer| { let mut results = vec![]; @@ -3189,29 +3204,29 @@ fn is_code_block(content: &str) -> bool { re.is_match(content) } -/// Does this line open a control-structure code block (`{ foreach ... }` or -/// `{ repeat ... }`)? Only a control structure owns the substeps below it and -/// so opens a scope; otherwise a plain expression block (literal value, -/// function call, tablet etc) is inline. -fn is_loop_block_line(content: &str) -> bool { +/// Does this line open a control-structure code block (`{ foreach ... }`, +/// `{ repeat ... }`, or `{ within ... }`)? Only a control structure owns the +/// substeps below it and so opens a scope; otherwise a plain expression block +/// (literal value, function call, tablet etc) is inline. +fn is_control_structure_line(content: &str) -> bool { if let Some(rest) = content .trim_ascii_start() .strip_prefix('{') { - is_foreach_keyword(rest) || is_repeat_keyword(rest) + is_foreach_keyword(rest) || is_repeat_keyword(rest) || is_within_keyword(rest) } else { false } } -/// Is this code block is a control structure (`foreach` or `repeat`) which -/// owns the steps below it as its body. A plain code does not. -fn is_loop_block(expressions: &[Expression]) -> bool { +/// Is this code block a control structure (`foreach`, `repeat`, or `within`) +/// which owns the steps below it as its body. A plain code block does not. +fn is_control_structure(expressions: &[Expression]) -> bool { if expressions.len() != 1 { return false; } match &expressions[0] { - Expression::Foreach(..) | Expression::Repeat(..) => true, + Expression::Foreach(..) | Expression::Repeat(..) | Expression::Within(..) => true, _ => false, } } @@ -3236,6 +3251,12 @@ fn is_repeat_keyword(content: &str) -> bool { re.is_match(content) } +fn is_within_keyword(content: &str) -> bool { + let re = regex!(r"^\s*within\s+"); + + re.is_match(content) +} + fn is_unit(content: &str) -> bool { content.starts_with("()") } diff --git a/src/program/types.rs b/src/program/types.rs index a3c11a9b..a8b5c458 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -157,6 +157,13 @@ pub enum Operation<'i> { body: Box>, responses: Vec<&'i language::Response<'i>>, }, + /// A `within` block is _not_ generative like `Loop`, but a predicate + /// governing a run-once body. + Within { + bound: Box>, + body: Box>, + responses: Vec<&'i language::Response<'i>>, + }, Bind { names: &'i [language::Identifier<'i>], value: Box>, diff --git a/src/resolution/resolver.rs b/src/resolution/resolver.rs index c64237b5..255f8a39 100644 --- a/src/resolution/resolver.rs +++ b/src/resolution/resolver.rs @@ -126,6 +126,10 @@ fn resolve_operation<'i>( } resolve_operation(body, known, arities, problems); } + Operation::Within { bound, body, .. } => { + resolve_operation(bound, known, arities, problems); + resolve_operation(body, known, arities, problems); + } Operation::Bind { value, .. } => resolve_operation(value, known, arities, problems), Operation::Execute(executable) => { for arg in &mut executable.arguments { @@ -177,6 +181,10 @@ fn gather_iterated<'i>(op: &Operation<'i>, iterated: &mut HashSet<&'i str>) { } gather_iterated(body, iterated); } + Operation::Within { bound, body, .. } => { + gather_iterated(bound, iterated); + gather_iterated(body, iterated); + } Operation::Bind { value, .. } => gather_iterated(value, iterated), Operation::Sequence(ops) | Operation::List(ops) @@ -243,6 +251,10 @@ fn mark_iterated<'i>(op: &mut Operation<'i>, iterated: &HashSet<&str>) { } mark_iterated(body, iterated); } + Operation::Within { bound, body, .. } => { + mark_iterated(bound, iterated); + mark_iterated(body, iterated); + } Operation::Sequence(ops) | Operation::List(ops) | Operation::Tuple(ops) @@ -340,6 +352,10 @@ fn check_scope<'i>( } check_scope(body, scope, problems); } + Operation::Within { bound, body, .. } => { + check_scope(bound, scope, problems); + check_scope(body, scope, problems); + } Operation::Sequence(ops) | Operation::List(ops) | Operation::Tuple(ops) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index c7116a57..ff837960 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -30,7 +30,7 @@ fn mock_returns_canned_answers_in_order() { #[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.", 1); + p.step("/local_network:I/1", "", "Check the cable.", 1); let _ = p.ask("/local_network:I/1", &[], Value::Unitus, Kind::Computable); assert_eq!( p.events(), @@ -63,7 +63,7 @@ fn mock_records_offered_choices() { #[test] fn mock_records_enter_and_announce() { let mut p = Mock::new(); - p.enter("I"); + p.enter("I", ""); p.announce("Calling helper"); assert_eq!( p.events(), @@ -87,7 +87,7 @@ fn mock_ask_without_answers_panics() { fn console_step_writes_fqn_and_description() { let mut output: Vec = Vec::new(); let mut p = Console::with_output(&mut output); - p.step("local_network:I/1", "Check the cable.", 1); + p.step("local_network:I/1", "", "Check the cable.", 1); let written = String::from_utf8(output).expect("utf8"); assert!(written.contains("→ local_network:I/1")); assert!(written.contains(" Check the cable.")); @@ -99,7 +99,7 @@ fn console_step_indents_description_to_depth() { let mut p = Console::with_output(&mut output); // A depth-2 substep indents its prose by eight spaces while the marker // stays at the left margin. - p.step("local_network:I/1/a", "Inspect the connector.", 2); + p.step("local_network:I/1/a", "", "Inspect the connector.", 2); let written = String::from_utf8(output).expect("utf8"); assert!(written.contains("→ local_network:I/1/a")); assert!(written.contains("\n Inspect the connector.")); @@ -193,7 +193,7 @@ fn console_settle_writes_verdict_line() { fn console_enter_writes_fqn() { let mut output: Vec = Vec::new(); let mut p = Console::with_output(&mut output); - p.enter("I"); + p.enter("I", ""); let written = String::from_utf8(output).expect("utf8"); assert!(written.contains("↘ I")); } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index b5865fb3..0c20a656 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -2147,11 +2147,8 @@ connectivity_check(e, s, address) : .unwrap() .parameters .unwrap(); - let echo = render_argument_echo("connectivity_check:", params, &env); - assert_eq!( - echo, - "connectivity_check: ([] ~ e, 0 ~ s, \"10 Downing Street\" ~ address)" - ); + let echo = render_argument_echo(params, &env); + assert_eq!(echo, "([] ~ e, 0 ~ s, \"10 Downing Street\" ~ address)"); } #[test] diff --git a/src/runner/driver.rs b/src/runner/driver.rs index cf7caffc..619eb532 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -80,11 +80,11 @@ pub trait Driver { /// markers like `→` stay at the left margin. The implementation displays /// them; it does not block waiting for input — the walker calls `ask` for /// that separately. - fn step(&mut self, qualified: &str, description: &str, depth: usize); + fn step(&mut self, qualified: &str, text: &str, description: &str, depth: usize); /// Announce descent into a named scope — a Section or an invoked /// subroutine — with its Qualified Name (the `↘` marker). - fn enter(&mut self, qualified: &str); + fn enter(&mut self, qualified: &str, text: &str); /// Cross into this document on the way in: the `⇒` boundary line carrying /// the document name, version, and run identifier (`/ NetworkProbe,1 000096`). @@ -119,7 +119,7 @@ pub trait Driver { /// `settle` closes with the verdict. The same document-boundary crossing the /// run's own `commence`/`conclude` makes, one level down. `Quit` abandons /// before departing; the unattended drivers proceed with `Done`. - fn depart(&mut self, qualified: &str) -> UserInput; + fn depart(&mut self, qualified: &str, text: &str) -> UserInput; /// Record an external invocation this run cannot perform (a `` call /// into another document or system). `Console` prompts the user to @@ -174,7 +174,13 @@ pub trait Driver { /// 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; + fn acquire( + &mut self, + qualified: &str, + text: &str, + name: Option<&str>, + forma: Option<&str>, + ) -> UserInput; /// The syntax renderer for highlighting source fragments shown to the /// user — the ANSI `Terminal` when colouring, otherwise `Identity`. @@ -186,8 +192,8 @@ pub trait Driver { /// `Batch` verdict still displays for a point it decides without prompting; /// `surface` hands an interactive verdict the raw sink to prompt on. pub trait Output { - fn step(&mut self, qualified: &str, description: &str, depth: usize); - fn enter(&mut self, qualified: &str); + fn step(&mut self, qualified: &str, text: &str, description: &str, depth: usize); + fn enter(&mut self, qualified: &str, text: &str); fn commence(&mut self, label: &str); fn conclude(&mut self, label: &str, verdict: &UserInput); fn display(&mut self, content: &str); @@ -249,11 +255,12 @@ pub trait Verdict { &mut self, out: &mut O, qualified: &str, + text: &str, name: Option<&str>, forma: Option<&str>, ) -> UserInput; - fn depart(&mut self, out: &mut O, qualified: &str) -> UserInput; + fn depart(&mut self, out: &mut O, qualified: &str, text: &str) -> UserInput; fn external(&mut self, out: &mut O, qualified: &str) -> UserInput; @@ -274,18 +281,22 @@ pub struct Visual { } impl Output for Visual { - fn step(&mut self, fqn: &str, description: &str, depth: usize) { + fn step(&mut self, fqn: &str, text: &str, description: &str, depth: usize) { render_step( &mut self.output, - &display_path(fqn), + &announce(&display_path(fqn), text), description, depth, self.renderer, ); } - fn enter(&mut self, qualified: &str) { - render_enter(&mut self.output, &display_path(qualified), self.renderer); + fn enter(&mut self, qualified: &str, text: &str) { + render_enter( + &mut self.output, + &announce(&display_path(qualified), text), + self.renderer, + ); let _ = writeln!(self.output); } @@ -366,8 +377,8 @@ pub struct Silent { } impl Output for Silent { - fn step(&mut self, _qualified: &str, _description: &str, _depth: usize) {} - fn enter(&mut self, _qualified: &str) {} + fn step(&mut self, _qualified: &str, _text: &str, _description: &str, _depth: usize) {} + fn enter(&mut self, _qualified: &str, _text: &str) {} fn commence(&mut self, _label: &str) {} fn conclude(&mut self, _label: &str, _verdict: &UserInput) {} fn display(&mut self, _content: &str) {} @@ -435,23 +446,28 @@ impl Verdict for Interactive { &mut self, out: &mut O, qualified: &str, + text: &str, name: Option<&str>, forma: Option<&str>, ) -> UserInput { + // The trailing space before `(name : forma)` is unconditional, same + // as when `text` is empty: {qualified} {name : forma}. let label = format!( - "{}({} : {})", + "{} {}({} : {})", display_path(qualified), + text, name.unwrap_or("?"), forma.unwrap_or("?") ); prompt_acquire(out.surface(), &label, is_list_forma(forma)) } - fn depart(&mut self, out: &mut O, qualified: &str) -> UserInput { - let input = prompt(out.surface(), qualified, "⇒", &[], Value::Unitus); + fn depart(&mut self, out: &mut O, qualified: &str, text: &str) -> UserInput { + let announced = announce(qualified, text); + let input = prompt(out.surface(), &announced, "⇒", &[], Value::Unitus); if let UserInput::Quit = input { } else { - out.show_depart(qualified); + out.show_depart(&announced); } input } @@ -526,14 +542,15 @@ impl Verdict for Batch { &mut self, _out: &mut O, _qualified: &str, + _text: &str, _name: Option<&str>, _forma: Option<&str>, ) -> UserInput { UserInput::Done(Value::Unitus) } - fn depart(&mut self, out: &mut O, qualified: &str) -> UserInput { - out.show_depart(qualified); + fn depart(&mut self, out: &mut O, qualified: &str, text: &str) -> UserInput { + out.show_depart(&announce(qualified, text)); UserInput::Done(Value::Unitus) } @@ -565,14 +582,14 @@ pub struct Interface { } impl Driver for Interface { - fn step(&mut self, qualified: &str, description: &str, depth: usize) { + fn step(&mut self, qualified: &str, text: &str, description: &str, depth: usize) { self.out - .step(qualified, description, depth); + .step(qualified, text, description, depth); } - fn enter(&mut self, qualified: &str) { + fn enter(&mut self, qualified: &str, text: &str) { self.out - .enter(qualified); + .enter(qualified, text); } fn commence(&mut self, label: &str) { @@ -605,9 +622,9 @@ impl Driver for Interface { .ask(&mut self.out, qualified, choices, produced, kind) } - fn depart(&mut self, qualified: &str) -> UserInput { + fn depart(&mut self, qualified: &str, text: &str) -> UserInput { self.verdict - .depart(&mut self.out, qualified) + .depart(&mut self.out, qualified, text) } fn external(&mut self, qualified: &str) -> UserInput { @@ -640,9 +657,15 @@ impl Driver for Interface { .show_verdict(marker, qualified, verdict); } - fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { + fn acquire( + &mut self, + qualified: &str, + text: &str, + name: Option<&str>, + forma: Option<&str>, + ) -> UserInput { self.verdict - .acquire(&mut self.out, qualified, name, forma) + .acquire(&mut self.out, qualified, text, name, forma) } fn renderer(&self) -> &'static dyn Render { @@ -791,7 +814,11 @@ fn prompt_action( Clear(ClearType::CurrentLine) ); if let UserInput::Done(_) = &result { - let _ = writeln!(out, "{}", format!("» {} {}()", qualified, name).dark_grey()); + let _ = writeln!( + out, + "{}", + format!("» {} {}()", qualified, name).with(MARKER_GREY) + ); } let _ = out.flush(); result @@ -833,7 +860,11 @@ fn prompt_command(mut out: &mut dyn Write, qualified: &str, script: &str) -> Use } else { script.trim_end() }; - let _ = writeln!(out, "{}", format!("→ {} $ {}", qualified, ran).dark_grey()); + let _ = writeln!( + out, + "{}", + format!("→ {} $ {}", qualified, ran).with(MARKER_GREY) + ); } let _ = out.flush(); result @@ -918,6 +949,17 @@ fn write_marker_line(out: &mut W, text: &str, renderer: &dyn Render) { let _ = writeln!(out, "{}", renderer.style(Syntax::Marker, text)); } +/// Glue an extra annotation onto a displayed path with a trailing space, the +/// way an acquire prompt sets `(name : forma)` off from its path — empty +/// `text` leaves `qualified` untouched. +fn announce(qualified: &str, text: &str) -> String { + if text.is_empty() { + qualified.to_string() + } else { + format!("{} {}", qualified, text) + } +} + /// Render a step's `→` line and description. The marker stays at the left /// margin; the description is indented to its document nesting `depth`. fn render_step( @@ -1472,7 +1514,7 @@ fn draw( write!( out, "{} {} ", - format!("{} {}", settle, qualified).dark_grey(), + format!("{} {}", settle, qualified).with(MARKER_GREY), symbol, )?; let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; @@ -1494,7 +1536,7 @@ fn draw_action( cursor::MoveToColumn(0), Clear(ClearType::CurrentLine) )?; - write!(out, "{} ", format!("» {}", qualified).dark_grey())?; + write!(out, "{} ", format!("» {}", qualified).with(MARKER_GREY))?; queue!(&mut out, SetForegroundColor(LIGHT_BROWN))?; write!(out, "{}", verb)?; queue!(&mut out, ResetColor)?; @@ -1651,6 +1693,15 @@ const LIGHT_BROWN: Color = Color::Rgb { b: 0x4b, }; +/// The grey the `Terminal` renderer uses, mirrored here so live-prompt chrome +/// matches it. We explicitly do NOT use crossterm's named colours to ensure +/// correct rendering. +const MARKER_GREY: Color = Color::Rgb { + r: 0x55, + g: 0x57, + b: 0x53, +}; + /// Render a horizontal row of Response options in the formatter's orange, the /// active one in reverse video. fn render_choices(mut out: &mut dyn Write, choices: &[&str], active: usize) -> io::Result<()> { @@ -1867,20 +1918,20 @@ impl Transcript { } impl Driver for Transcript { - fn step(&mut self, qualified: &str, description: &str, depth: usize) { + fn step(&mut self, qualified: &str, text: &str, description: &str, depth: usize) { self.emit(Trace::Enter { - path: qualified.to_string(), + path: announce(qualified, text), }); self.inner - .step(qualified, description, depth); + .step(qualified, text, description, depth); } - fn enter(&mut self, qualified: &str) { + fn enter(&mut self, qualified: &str, text: &str) { self.emit(Trace::Enter { - path: qualified.to_string(), + path: announce(qualified, text), }); self.inner - .enter(qualified); + .enter(qualified, text); } fn commence(&mut self, label: &str) { @@ -1911,9 +1962,9 @@ impl Driver for Transcript { outcome } - fn depart(&mut self, qualified: &str) -> UserInput { + fn depart(&mut self, qualified: &str, text: &str) -> UserInput { self.inner - .depart(qualified) + .depart(qualified, text) } fn external(&mut self, qualified: &str) -> UserInput { @@ -1965,17 +2016,23 @@ impl Driver for Transcript { .show_verdict(marker, qualified, verdict); } - fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { + fn acquire( + &mut self, + qualified: &str, + text: &str, + name: Option<&str>, + forma: Option<&str>, + ) -> UserInput { let outcome = self .inner - .acquire(qualified, name, forma); + .acquire(qualified, text, name, forma); let supplied = if let UserInput::Done(value) = &outcome { value.clone() } else { Value::Unitus }; self.emit(Trace::Acquire { - path: qualified.to_string(), + path: announce(qualified, text), name: name.map(|n| n.to_string()), forma: forma.map(|f| f.to_string()), supplied, @@ -2072,18 +2129,18 @@ impl Mock { #[cfg(test)] impl Driver for Mock { - fn step(&mut self, fqn: &str, description: &str, _depth: usize) { + fn step(&mut self, fqn: &str, text: &str, description: &str, _depth: usize) { self.events .push(Event::Step { - qualified: fqn.to_string(), + qualified: announce(fqn, text), description: description.to_string(), }); } - fn enter(&mut self, fqn: &str) { + fn enter(&mut self, fqn: &str, text: &str) { self.events .push(Event::Enter { - qualified: fqn.to_string(), + qualified: announce(fqn, text), }); } @@ -2130,7 +2187,7 @@ impl Driver for Mock { .expect("Mock::ask called with no canned answers remaining") } - fn depart(&mut self, _qualified: &str) -> UserInput { + fn depart(&mut self, _qualified: &str, _text: &str) -> UserInput { self.answers .pop_front() .expect("Mock::depart called with no canned answers remaining") @@ -2201,7 +2258,13 @@ impl Driver for Mock { fn show_verdict(&mut self, _marker: &str, _qualified: &str, _verdict: &UserInput) {} - fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { + fn acquire( + &mut self, + _qualified: &str, + _text: &str, + name: Option<&str>, + forma: Option<&str>, + ) -> UserInput { self.events .push(Event::Acquire { name: name.map(|n| n.to_string()), diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 89f85d0b..91ae48b1 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -214,6 +214,7 @@ pub fn evaluate<'i>( | Operation::Section { .. } | Operation::Step { .. } | Operation::Loop { .. } + | Operation::Within { .. } | Operation::Invoke(_) => Ok(Value::Unitus), } } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index a4475386..da71dc37 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -113,6 +113,7 @@ pub struct Runner<'i, D: Driver> { inputs: HashMap>, driver: D, path: QualifiedPath<'i>, + constraints: Vec, library: Library, context: Context, document: Option, @@ -133,6 +134,7 @@ impl<'i, D: Driver> Runner<'i, D> { inputs: HashMap::new(), driver, path: QualifiedPath::new(), + constraints: Vec::new(), library, context: Context::native(false), document: None, @@ -223,11 +225,11 @@ impl<'i, D: Driver> Runner<'i, D> { self.begin_scope(&qualified)?; if params.is_empty() { self.driver - .enter(&qualified); + .enter(&qualified, ""); } else { - let echo = render_argument_echo(&qualified, params, &env); + let echo = render_argument_echo(params, &env); self.driver - .enter(&echo); + .enter(&qualified, &echo); } let declaration = crate::formatting::formatter::render_declaration( name, @@ -341,6 +343,7 @@ impl<'i, D: Driver> Runner<'i, D> { Operation::Loop { names, over, body, .. } => self.walk_loop(env, names, over.as_deref(), body), + Operation::Within { bound, body, .. } => self.walk_within(env, bound, body), Operation::Invoke(invocable) => self.walk_invoke(env, invocable), Operation::Execute(executable) => { let function = self.executable_name(&executable.target); @@ -544,11 +547,10 @@ impl<'i, D: Driver> Runner<'i, D> { fn render_deferred_echo( &self, env: &mut Environment, - qualified: &str, arguments: &[Operation<'i>], ) -> Result { if arguments.is_empty() { - return Ok(qualified.to_string()); + return Ok(String::new()); } let mut parts = Vec::new(); for arg in arguments { @@ -560,7 +562,7 @@ impl<'i, D: Driver> Runner<'i, D> { }; parts.push(part); } - Ok(format!("{} ({})", qualified, parts.join(", "))) + Ok(format!("({})", parts.join(", "))) } /// An action's parts for the user to confirm: its imperative verb (the @@ -660,7 +662,7 @@ impl<'i, D: Driver> Runner<'i, D> { let caller = self .path .render(); - let invoked = format!("{} <{}>", caller, name); + let invoked = format!("<{}>", name); let formae = render_parameter_formae(subroutine.signature); @@ -694,7 +696,7 @@ impl<'i, D: Driver> Runner<'i, D> { .clone(), None => match self .driver - .acquire(&invoked, bind, forma) + .acquire(&caller, &invoked, bind, forma) { UserInput::Done(value) => value, other => return self.abandon(&lexical, other), @@ -732,7 +734,7 @@ impl<'i, D: Driver> Runner<'i, D> { .map(|s| s.as_str()); match self .driver - .acquire(&invoked, bind, forma) + .acquire(&caller, &invoked, bind, forma) { UserInput::Done(value) => value, other => return self.abandon(&lexical, other), @@ -859,10 +861,10 @@ impl<'i, D: Driver> Runner<'i, D> { self.begin_scope(&qualified)?; // Prompt at the departure, echoing the arguments flowing into // the external Technque. - let echo = self.render_deferred_echo(env, &qualified, &invocable.arguments)?; + let echo = self.render_deferred_echo(env, &invocable.arguments)?; let embarked = self .driver - .depart(&echo); + .depart(&qualified, &echo); let input = match embarked { UserInput::Quit => { self.path @@ -935,15 +937,11 @@ impl<'i, D: Driver> Runner<'i, D> { Some(text) => Some(text.as_str()), None => None, }; - // Set the acquired `(name : forma)` off from the path with a - // trailing space; an invocation prompt instead glues its arguments - // straight to the ``. - let prompt = format!("{qualified} "); let mut acquired = Vec::with_capacity(names.len()); for name in names { match self .driver - .acquire(&prompt, Some(name.value), forma) + .acquire(&qualified, "", Some(name.value), forma) { UserInput::Done(value) => acquired.push(value), UserInput::Skip => { @@ -1066,6 +1064,24 @@ impl<'i, D: Driver> Runner<'i, D> { } } + /// Walk a `within` block's body once. The budget is evaluated up front + /// and pushed onto `self.constraints` for the duration of the body walk, + /// so every enclosed step's prompt line can announce it. + fn walk_within( + &mut self, + env: &mut Environment, + bound: &'i Operation<'i>, + body: &'i Operation<'i>, + ) -> Result { + let budget = super::evaluator::evaluate(&self.library, &self.context, env, bound)?; + self.constraints + .push(budget); + let result = self.walk(env, body); + self.constraints + .pop(); + result + } + /// Walk one pass of a loop body within its `[number]` iteration scope, /// bracketing it with `↘`/`↙` chrome. The `↘` line echoes the loop /// variable(s) bound for this pass, in the same `value ~ name` form used @@ -1082,9 +1098,9 @@ impl<'i, D: Driver> Runner<'i, D> { let qualified = self .path .render(); - let echo = render_iteration_echo(&qualified, names, env); + let echo = render_iteration_echo(names, env); self.driver - .enter(&echo); + .enter(&qualified, &echo); let result = self.walk(env, body); let verdict = match &result { Ok(Conclusion::Completed(Outcome::Done(_))) => Some(UserInput::Done(Value::Unitus)), @@ -1356,8 +1372,9 @@ impl<'i, D: Driver> Runner<'i, D> { let depth = self .path .depth(); + let text = render_constraints(&self.constraints).unwrap_or_default(); self.driver - .step(qualified, &step_text, depth); + .step(qualified, &text, &step_text, depth); // A descriptive binding on a step with response choices takes its value // from the chosen response, not a separate acquire: skip the body walk @@ -1510,11 +1527,11 @@ impl<'i, D: Driver> Runner<'i, D> { .unwrap_or(&[]); if params.is_empty() { self.driver - .enter(qualified); + .enter(qualified, ""); } else { - let echo = render_argument_echo(qualified, params, env); + let echo = render_argument_echo(params, env); self.driver - .enter(&echo); + .enter(qualified, &echo); } let declaration = crate::formatting::formatter::render_declaration( name, @@ -1975,32 +1992,39 @@ fn nests_work(op: &Operation) -> bool { } } -/// Render a procedure's qualified path with arguments bound to each parameter -/// in `value ~ name` form, e.g. `connectivity_check: ([] ~ e, 0 ~ s)`. The -/// qualified path already carries its trailing `:`. -fn render_argument_echo( - qualified: &str, - params: &[language::Identifier], - env: &Environment, -) -> String { - format!("{} ({})", qualified, render_bindings(params, env)) +/// Render a procedure's bound arguments in `value ~ name` form, e.g. +/// `([] ~ e, 0 ~ s)`, to announce alongside the qualified path. +fn render_argument_echo(params: &[language::Identifier], env: &Environment) -> String { + format!("({})", render_bindings(params, env)) } -/// Append a loop iteration's bound variable(s) to its path in `value ~ name` -/// form, e.g. `cleanup_ec2:/3/[1] ("i-1234" ~ instance)` — a string value -/// shows quoted. A `repeat` with no iteration variable echoes the bare path. -fn render_iteration_echo( - qualified: &str, - names: &[language::Identifier], - env: &Environment, -) -> String { +/// Render a loop iteration's bound variable(s) in `value ~ name` form, e.g. +/// `("i-1234" ~ instance)` — a string value shows quoted — to announce +/// alongside the iteration's path. Empty when `repeat` binds no name. +fn render_iteration_echo(names: &[language::Identifier], env: &Environment) -> String { if names.is_empty() { - qualified.to_string() + String::new() } else { - format!("{} ({})", qualified, render_bindings(names, env)) + format!("({})", render_bindings(names, env)) } } +/// Render the enclosing `within` budgets as a `$(...)`-annotated suffix for a +/// step's prompt line, set off from the path with a space like an acquire +/// prompt's `(name : forma)` — an announcement, not part of the addressable +/// path. `None` when no `within` block encloses the step. +fn render_constraints(constraints: &[Value]) -> Option { + if constraints.is_empty() { + return None; + } + let rendered = constraints + .iter() + .map(|budget| format!("$({budget})")) + .collect::>() + .join(" "); + Some(rendered) +} + /// Comma-join a set of bindings in `value ~ name` form with each value read /// from the environment. fn render_bindings(names: &[language::Identifier], env: &Environment) -> String { diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 2be994f2..508ae425 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -229,8 +229,8 @@ impl<'i> Translator<'i> { } } language::Element::CodeBlock(expressions, subscopes, _) => { - match self.translate_loop_block(expressions, subscopes, &[]) { - Some(loop_op) => ops.push(loop_op), + match self.translate_control_block(expressions, subscopes, &[]) { + Some(control_op) => ops.push(control_op), None => { for expression in expressions { ops.push(self.translate_expression(expression)); @@ -379,8 +379,8 @@ impl<'i> Translator<'i> { expressions, subscopes, .. - } => match self.translate_loop_block(expressions, subscopes, attrs) { - Some(loop_op) => loop_op, + } => match self.translate_control_block(expressions, subscopes, attrs) { + Some(control_op) => control_op, None => { let mut ops = Vec::new(); for expression in expressions { @@ -396,10 +396,11 @@ impl<'i> Translator<'i> { } } - // Build the `Loop` for a foreach or repeat code block, its subscopes - // forming the body. Returns None for any other code block, which the - // caller translates as a plain sequence of its expressions. - fn translate_loop_block( + // Build the `Loop` for a foreach or repeat code block, or the `Within` + // for a within block, its subscopes forming the body. Returns None for + // any other code block, which the caller translates as a plain sequence + // of its expressions. + fn translate_control_block( &mut self, expressions: &'i [language::Expression<'i>], subscopes: &'i [language::Scope<'i>], @@ -437,6 +438,21 @@ impl<'i> Translator<'i> { responses, }) } + language::Expression::Within(inner, _) => { + // `within ` governs the subscopes beneath it, the way + // `foreach`'s source does: the budget is a separate field, + // not part of the body. + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } + Some(Operation::Within { + bound: Box::new(self.translate_expression(inner)), + body: Box::new(Operation::Sequence(body_ops)), + responses, + }) + } _ => None, } } @@ -829,6 +845,13 @@ impl<'i> Translator<'i> { body: Box::new(Operation::Sequence(Vec::new())), responses: Vec::new(), }, + // Standalone Within, likewise: the body is supplied by the + // enclosing CodeBlock's subscopes when one is present. + language::Expression::Within(bound, _) => Operation::Within { + bound: Box::new(self.translate_expression(bound)), + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }, language::Expression::Binding(value, names, span) => { if let language::Expression::Repeat(_, _) = value.as_ref() { self.problems diff --git a/tests/golden/parsing/Bedtime.tq b/tests/golden/parsing/MorningToBedtime.tq similarity index 67% rename from tests/golden/parsing/Bedtime.tq rename to tests/golden/parsing/MorningToBedtime.tq index ec0a81f3..a7d27a34 100644 --- a/tests/golden/parsing/Bedtime.tq +++ b/tests/golden/parsing/MorningToBedtime.tq @@ -1,16 +1,17 @@ % technique v1 -! Private; © 2025 My Lovely Family -& childrens-task-list +! Private and Confidential +& checklist I. Before school - 1. Drink glass of water + 1. Drink first glass of water 2. Eat breakfast 3. Put dishes into sink 4. Get changed - 5. Pack school bag - 6. Clean teeth - 7. Shoes on, wait by front door! + 5. Drink second glass of water + 6. Pack school bag + 7. Clean teeth + 8. Shoes on, wait by front door! II. After school @@ -31,5 +32,5 @@ IV. Bedtime 1. Get changed into pyjamas 2. Clean teeth - 3. Read story - 4. Into bed! + 3. Into bed! + 4. Read story diff --git a/tests/golden/parsing/NewVersionToProduction.tq b/tests/golden/parsing/NewVersionToProduction.tq new file mode 100644 index 00000000..b8c31c33 --- /dev/null +++ b/tests/golden/parsing/NewVersionToProduction.tq @@ -0,0 +1,250 @@ +% technique v1 +& procedure + +new_version_to_production : + +# New Version of Wishing Well code to Live + +Promotion of Wishing Well 2.1.0 - 18 Sep 2004, 0515 hrs EST. + +I. Preparation + +preparation : + +# Preparation + +0500 hrs; Andrew, Kermit, Beaker, Fozzie, Gonzo on site. + + 1. In office; setup; coordination + @all + a. Computers on, connections established + 2. Safety steps + @beaker + a. Backup properties files + b. Take smtp4:25 out-of-service in load balancer to allow its + queues to fall to zero + @fozzie + a. Verify nightly database backups and scripts ran ok + @kermit + a. Call ROC advise scheduled outage + b. Email to Customer Alert advising downtime + 3. Verification + @all + a. Review procedure (Coffee Table) + b. Return to workstations and report to Kermit ready to Begin + @kermit + a. Poll ready + b. Go/NoGo to Begin + +II. Take Site Down + +take_site_down : + +# Take Site Down + +0515 hrs; start time critical due to expected duration of database scripts. + + 4. Enter Maintenance Mode + @beaker + a. Web and Wap Maintenance pages activated (smtp1:80 in-service + in load balancer; web1:80 and web2:80 out-of-service) + @fozzie + a. Put IVRs into Maintenance Mode + 5. Down Services + @beaker + a. Stop all VMs less IVRs + b. Stop HA Clustered Filesystem on oracle2, then + c. Stop HA Clustered Filesystem on oracle1 + d. Ensure RAID filesystems still mounted on oracle1 + @fozzie + a. Stop Apache on web1, web2 + 6. Verification + @kermit + a. Verify VMs down (less IVR VMs) + b. Verify volume manager status + c. Verify Maintenance mode (Web, Wap and IVR) + d. Go/NoGo for Upgrade + e. Change CVS branch control file to HEAD + +III. Upgrade to 2.1 + +upgrade : + +# Upgrade to 2.1 + + 7. Database scripts and Package revs + @fozzie + @gonzo + a. Take an export dump of live database + b. Stop database + c. Database into Exclusive mode + d. Begin database change script + e. Manually change SMPP data + @beaker + a. Install new packages on servers storage2, oracle1, oracle2, + dns, message1 + b. Install new packages on servers web1, web2, servlet1, servlet2 + c. Install new packages on servers smtp1, smtp2, smtp3, smtp4, + ivr1, ivr2 + d. Verify CVS checkouts switched to HEAD + e. Delete compiled class files on servlet machines + f. Deploy properties file changes and new files + @kermit + a. Deploy custom jars + b. Deploy SMPP changes to web1, web2 + c. DNS changes for smpp.wish-well-corp.net + 8. Verification + @kermit + a. Verify database changes complete + b. Report steps complete + c. Spot check package versions, object/relational mappings, and + properties files + d. Go/NoGo for Restart and System Test + +IV. System maintenance + +system_maintenance : + +# System maintenance: Change server SCSI cables + +Approx 0630, Sam the Eagle out in Docklands. + + 9. Halt machines + @fozzie + a. Stop oracle databases REPORTING, STAGE, PREVIEW on oracle2 + b. Stop oracle databases WISH on oracle1 + c. Stop cluster manager on oracle2, oracle1 + d. Console, halt oracle1, oracle2 + @beaker + a. Stop cluster manager on storage2, storage1 + b. Console, halt storage1, storage2 + 10. Change server SCSI cables + @sam_the_eagle + a. Contact Kermit by Phone and Instant Messenger + b. Remove HVD SCSI cables + c. Install LVD SCSI cables + 11. Restart machines + @fozzie + a. Console, boot oracle1, oracle2 + b. Start cluster manager + c. Start WISH database on oracle1 + d. Verify database + @beaker + a. Console, boot storage1, storage2 + b. Start cluster manager + c. Verify NFS + +V. Initial Restart and System Test + +restart_and_test : + +# Initial Restart and System Test + + 12. Restart VMs + @beaker + a. Start queue server VM on message1 + b. Manually start queue server on message1 command console + c. Open 8088 (Web), 8080 (IM) in Resonate 172.16.2.10 VIP + @fozzie + a. Start Apache on web1, web2 + b. Start all other VMs less smtp, ivr + 13. Verification + @beaker + a. Monitor message1 queue server, robot server VM logs + @fozzie + a. Monitor servlet1,2 resin VM logs + b. Hit servlet1:8081 and servlet2:8081 to test individual Resin + VMs + @kermit + a. Verify VMs up (less ivr, smtp) + b. Monitor web1,2 imserver VM logs + c. Hit web1:8088 and web2:8088 to test individual web servers + d. Attempt login + e. Send test message + f. Go/NoGo to begin Integrity testing + +VI. Initial Integrity Test + +integrity_test : + +# Initial Integrity Test and follow on Restarts + +Expected approx 0630 hrs; John Denver, Animal on site. Scotty the Chief +Engineer and Engineering Department Leads assisting as available. + + 14. Initial testing + @animal + a. Begin integrity test: Web, Wap, IM + @john_denver + @kermit + a. Channel and Microsite Ports as requested + @all + a. General testing + 15. Restart IVRs + @beaker + a. Restart ivr1,2 ports 8800, 8802 ivrserver VMs using idle + command + 16. Verification + @beaker + a. Monitor ivr1,2 ivrserver0,1 VM logs + @kermit + a. Monitor ivr1,2 ports 8800, 8802 admin console + b. Verify IVRs still in Maintenance Mode + 17. Testing continues: IVRs + @animal + a. Integrity test: IVR, calling 1800 998 119 + 18. Start single email filter for testing + @beaker + a. Restart smtp4 email filter only + b. Monitor smtp4 Qmail log and email filter VM log + 19. Verification + @beaker + a. Test non-handle email + b. Test handle email to email filter + @kermit + a. Verify smtp1,2,3 emailadapters still down + b. Verify smtp4 emailadapter up + c. Go/NoGo to begin email Integrity tests + 20. Testing continues: email and SMS + @animal + a. Integrity test: individual and group email via smtp4 + 21. Final Verification + @kermit + a. Recommend Go for going Live + @john_denver + a. Report Integrity test complete + b. Recommend Go for going Live + @scotty + a. Recommend Go for going Live + @andrew + a. Go/NoGo for going Live + +VII. Go Live + +go_live : + +# Go Live + + 22. Restart remaining VMs + @beaker + a. Restart smtp1,2,3 emailadapters + 23. Deactivate Maintenance Mode + @beaker + a. Deactivate Web, Wap Maintenance pages in load balancer, + bringing web1:80 and web2:80 in-service + b. Restart oracle1 then oracle2 Veritas + @fozzie + a. Deactivate Maintenance Mode (IVRs) + 24. Verification + @all + a. Continue testing, now on www:80 (not 8088) and on 1800 116 342 + @beaker + a. Monitor smtp1,2,3 Qmail log and emailadapter VM log + @kermit + a. Verify Maintenance mode deactivated + b. Verify smtp1,2,3 email filters up + c. Verify IVR lines active using web monitor + d. Verify cluster manager and load balancer status + e. Call NOC, request resume normal monitoring + f. Email to All Staff advising Live back up + g. Declare no rollback + h. Go/NoGo for Follow on work diff --git a/tests/golden/parsing/PatioCleaning.tq b/tests/golden/parsing/PatioCleaning.tq new file mode 100644 index 00000000..9a8c7c04 --- /dev/null +++ b/tests/golden/parsing/PatioCleaning.tq @@ -0,0 +1,52 @@ +% technique v1 +& checklist + +high_pressure_cleaning : + +I. Setup + +setup_machine : + +# Setup Machine + + 1. Water supply + a. Unroll garden water hose + b. Attach water hose to machine + 2. Sprayer + c. Unroll machine black water hose + d. Assemble wand + e. Connect wand to black water hose + 3. Power + f. Extend machine power cord + g. Uncoil extension power cord + h. Connect to mains + 4. Turn on water tap + 5. Turn on machine + +II. Cleaning + +clean_patio : + +# Perform Cleaning + +III. Finish + +tidy_up : + +# Tidy everything away + + 1. Turn off machine + 2. Turn off water tap + 3. Water supply + a. Drain nozzle pressure + b. Detach water hose from machine + c. Roll up garden water hose + 4. Sprayer + d. Disassemble wand + e. Roll up machine black water hose + 5. Power + f. Roll up machine power cord + g. Coil extension power cord + 6. Return equipment to storage + h. Put machine back + j. Put accessories away diff --git a/tests/samples/parsing/RoastTurkey.tq b/tests/samples/parsing/RoastTurkey.tq index 47d9aa2a..1b2241d0 100644 --- a/tests/samples/parsing/RoastTurkey.tq +++ b/tests/samples/parsing/RoastTurkey.tq @@ -3,6 +3,7 @@ roast_turkey(i) : Ingredients -> Turkey # Roast Turkey @chef - 1. Set oven temperature { oven(180 °C) ~ temp } - 2. Place bacon strips onto bird - 3. Put bird into oven + { within 30 minutes } + 1. Set oven temperature + 2. Place bacon strips onto bird + 3. Put bird into oven