diff --git a/crates/forth/src/compiler.rs b/crates/forth/src/compiler.rs index 29e68c0..7f8cee3 100644 --- a/crates/forth/src/compiler.rs +++ b/crates/forth/src/compiler.rs @@ -1,6 +1,6 @@ use super::ops::Op; use super::types::{Dictionary, SourceSpan}; -use super::words::{compile_word, simple_op}; +use super::words::compile_word; #[derive(Clone, Debug)] enum Token { @@ -25,6 +25,11 @@ fn tokenize(input: &str) -> Vec { continue; } + if c == '(' || c == ')' { + chars.next(); + continue; + } + if c == '"' { let start = pos; chars.next(); @@ -88,7 +93,6 @@ fn tokenize(input: &str) -> Vec { fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { let mut ops = Vec::new(); let mut i = 0; - let mut list_depth: usize = 0; while i < tokens.len() { match &tokens[i] { @@ -122,20 +126,6 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { ops.push(Op::Branch(else_ops.len())); ops.extend(else_ops); } - } else if is_list_start(word) { - ops.push(Op::ListStart); - list_depth += 1; - } else if is_list_end(word) { - list_depth = list_depth.saturating_sub(1); - if let Some(op) = simple_op(word) { - ops.push(op); - } - } else if list_depth > 0 { - let mut word_ops = Vec::new(); - if !compile_word(word, Some(*span), &mut word_ops, dict) { - return Err(format!("unknown word: {word}")); - } - ops.push(Op::Quotation(word_ops, Some(*span))); } else if !compile_word(word, Some(*span), &mut ops, dict) { return Err(format!("unknown word: {word}")); } @@ -147,14 +137,6 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { Ok(ops) } -fn is_list_start(word: &str) -> bool { - matches!(word, "[" | "<" | "<<") -} - -fn is_list_end(word: &str) -> bool { - matches!(word, "]" | ">" | ">>") -} - fn compile_quotation(tokens: &[Token], dict: &Dictionary) -> Result<(Vec, usize, SourceSpan), String> { let mut depth = 1; let mut end_idx = None; diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 6a0f5c1..998f18c 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -55,17 +55,14 @@ pub enum Op { Rand, Seed, Cycle, + PCycle, + TCycle, Choose, ChanceExec, ProbExec, Coin, Mtof, Ftom, - ListStart, - ListEnd, - ListEndCycle, - PCycle, - ListEndPCycle, SetTempo, Every, Quotation(Vec, Option), @@ -85,4 +82,5 @@ pub enum Op { EmitN, ClearCmd, SetSpeed, + At, } diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index 1e429db..b1768e8 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -47,8 +47,8 @@ pub enum Value { Int(i64, Option), Float(f64, Option), Str(String, Option), - Marker, Quotation(Vec, Option), + CycleList(Vec), } impl PartialEq for Value { @@ -57,8 +57,8 @@ impl PartialEq for Value { (Value::Int(a, _), Value::Int(b, _)) => a == b, (Value::Float(a, _), Value::Float(b, _)) => a == b, (Value::Str(a, _), Value::Str(b, _)) => a == b, - (Value::Marker, Value::Marker) => true, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, + (Value::CycleList(a), Value::CycleList(b)) => a == b, _ => false, } } @@ -93,29 +93,25 @@ impl Value { Value::Int(i, _) => *i != 0, Value::Float(f, _) => *f != 0.0, Value::Str(s, _) => !s.is_empty(), - Value::Marker => false, Value::Quotation(..) => true, + Value::CycleList(items) => !items.is_empty(), } } - pub(super) fn is_marker(&self) -> bool { - matches!(self, Value::Marker) - } - pub(super) fn to_param_string(&self) -> String { match self { Value::Int(i, _) => i.to_string(), Value::Float(f, _) => f.to_string(), Value::Str(s, _) => s.clone(), - Value::Marker => String::new(), Value::Quotation(..) => String::new(), + Value::CycleList(_) => String::new(), } } pub(super) fn span(&self) -> Option { match self { Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s, - Value::Marker => None, + Value::CycleList(_) => None, } } } @@ -124,6 +120,7 @@ impl Value { pub(super) struct CmdRegister { sound: Option, params: Vec<(String, Value)>, + deltas: Vec, } impl CmdRegister { @@ -135,6 +132,14 @@ impl CmdRegister { self.params.push((key, val)); } + pub(super) fn set_deltas(&mut self, deltas: Vec) { + self.deltas = deltas; + } + + pub(super) fn deltas(&self) -> &[Value] { + &self.deltas + } + pub(super) fn snapshot(&self) -> Option<(Value, Vec<(String, Value)>)> { self.sound .as_ref() diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 3f2a7e3..d8ea3af 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -145,35 +145,26 @@ impl Forth { select_and_run(selected, stack, outputs, cmd) }; - let drain_list_select_run = |idx_source: usize, - err_msg: &str, - stack: &mut Vec, - outputs: &mut Vec, - cmd: &mut CmdRegister| - -> Result<(), String> { - let mut values = Vec::new(); - while let Some(v) = stack.pop() { - if v.is_marker() { - break; - } - values.push(v); - } - if values.is_empty() { - return Err(err_msg.into()); - } - values.reverse(); - let idx = idx_source % values.len(); - let selected = values[idx].clone(); - select_and_run(selected, stack, outputs, cmd) - }; - - let emit_once = |cmd: &CmdRegister, outputs: &mut Vec| -> Result, String> { + let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, outputs: &mut Vec| -> Result, String> { let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; - let sound = sound_val.as_str()?.to_string(); + let resolved_sound_val = resolve_cycling(&sound_val, emit_idx); + // Note: sound span is recorded by Op::Emit, not here + let sound = resolved_sound_val.as_str()?.to_string(); let resolved_params: Vec<(String, String)> = - params.iter().map(|(k, v)| (k.clone(), v.to_param_string())).collect(); - emit_output(&sound, &resolved_params, ctx.step_duration(), ctx.nudge_secs, outputs); - Ok(Some(sound_val)) + params.iter().map(|(k, v)| { + let resolved = resolve_cycling(v, emit_idx); + // Record selected span for params if they came from a CycleList + if let Value::CycleList(_) = v { + if let Some(span) = resolved.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + } + (k.clone(), resolved.to_param_string()) + }).collect(); + emit_output(&sound, &resolved_params, ctx.step_duration(), delta_secs, outputs); + Ok(Some(resolved_sound_val)) }; while pc < ops.len() { @@ -361,12 +352,28 @@ impl Forth { } Op::Emit => { - if let Some(sound_val) = emit_once(cmd, outputs)? { - if let Some(span) = sound_val.span() { + let deltas = if cmd.deltas().is_empty() { + vec![Value::Float(0.0, None)] + } else { + cmd.deltas().to_vec() + }; + + for (emit_idx, delta_val) in deltas.iter().enumerate() { + let delta_frac = delta_val.as_float()?; + let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration(); + // Record delta span for highlighting + if let Some(span) = delta_val.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); } } + if let Some(sound_val) = emit_with_cycling(cmd, emit_idx, delta_secs, outputs)? { + if let Some(span) = sound_val.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + } } } @@ -438,6 +445,19 @@ impl Forth { drain_select_run(count, idx, stack, outputs, cmd)?; } + Op::TCycle => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("tcycle count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + stack.push(Value::CycleList(values)); + } + Op::Choose => { let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; if count == 0 { @@ -606,37 +626,23 @@ impl Forth { cmd.set_param("dur".into(), Value::Float(dur, None)); } - Op::ListStart => { - stack.push(Value::Marker); - } - - Op::ListEnd => { - let mut count = 0; - let mut values = Vec::new(); - while let Some(v) = stack.pop() { - if v.is_marker() { - break; + Op::At => { + let top = stack.pop().ok_or("stack underflow")?; + let deltas = match &top { + Value::Float(..) => vec![top], + Value::Int(n, _) if *n > 0 && stack.len() >= *n as usize => { + let count = *n as usize; + let mut vals = Vec::with_capacity(count); + for _ in 0..count { + vals.push(stack.pop().ok_or("stack underflow")?); + } + vals.reverse(); + vals } - values.push(v); - count += 1; - } - values.reverse(); - for v in values { - stack.push(v); - } - stack.push(Value::Int(count, None)); - } - - Op::ListEndCycle | Op::ListEndPCycle => { - let idx_source = match &ops[pc] { - Op::ListEndCycle => ctx.runs, - _ => ctx.iter, + Value::Int(..) => vec![top], + _ => return Err("at expects number or list".into()), }; - let err_msg = match &ops[pc] { - Op::ListEndCycle => "empty cycle list", - _ => "empty pattern cycle list", - }; - drain_list_select_run(idx_source, err_msg, stack, outputs, cmd)?; + cmd.set_deltas(deltas); } Op::Adsr => { @@ -699,8 +705,8 @@ impl Forth { if n < 0 { return Err("emit count must be >= 0".into()); } - for _ in 0..n { - emit_once(cmd, outputs)?; + for i in 0..n as usize { + emit_with_cycling(cmd, i, ctx.nudge_secs, outputs)?; } } } @@ -846,3 +852,12 @@ fn format_cmd(pairs: &[(String, String)]) -> String { let parts: Vec = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); format!("/{}", parts.join("/")) } + +fn resolve_cycling(val: &Value, emit_idx: usize) -> Value { + match val { + Value::CycleList(items) if !items.is_empty() => { + items[emit_idx % items.len()].clone() + } + other => other.clone(), + } +} diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 2e2bdd1..86c547e 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -34,7 +34,7 @@ pub const WORDS: &[Word] = &[ }, Word { name: "dupn", - aliases: &[], + aliases: &["!"], category: "Stack", stack: "(a n -- a a ... a)", desc: "Duplicate a onto stack n times", @@ -482,19 +482,28 @@ pub const WORDS: &[Word] = &[ Word { name: "cycle", aliases: &[], - category: "Lists", - stack: "(..n n -- val)", - desc: "Cycle through n items by step", - example: "1 2 3 3 cycle", + category: "Selection", + stack: "(v1..vn n -- selected)", + desc: "Cycle through n items by step runs", + example: "60 64 67 3 cycle", compile: Simple, }, Word { name: "pcycle", aliases: &[], - category: "Lists", - stack: "(..n n -- val)", - desc: "Cycle through n items by pattern", - example: "1 2 3 3 pcycle", + category: "Selection", + stack: "(v1..vn n -- selected)", + desc: "Cycle through n items by pattern iteration", + example: "60 64 67 3 pcycle", + compile: Simple, + }, + Word { + name: "tcycle", + aliases: &[], + category: "Selection", + stack: "(v1..vn n -- CycleList)", + desc: "Create cycle list for emit-time resolution", + example: "60 64 67 3 tcycle note", compile: Simple, }, Word { @@ -799,41 +808,13 @@ pub const WORDS: &[Word] = &[ example: "1 4 chain", compile: Simple, }, - // Lists Word { - name: "[", - aliases: &["<", "<<"], - category: "Lists", - stack: "(-- marker)", - desc: "Start list", - example: "[ 1 2 3 ]", - compile: Simple, - }, - Word { - name: "]", + name: "at", aliases: &[], - category: "Lists", - stack: "(marker..n -- n)", - desc: "End list, push count", - example: "[ 1 2 3 ] => 3", - compile: Simple, - }, - Word { - name: ">", - aliases: &[], - category: "Lists", - stack: "(marker..n -- val)", - desc: "End cycle list, pick by step", - example: "< 1 2 3 > => cycles through 1, 2, 3", - compile: Simple, - }, - Word { - name: ">>", - aliases: &[], - category: "Lists", - stack: "(marker..n -- val)", - desc: "End pattern cycle list, pick by pattern", - example: "<< 1 2 3 >> => cycles through 1, 2, 3 per pattern", + category: "Time", + stack: "(list|n --)", + desc: "Set delta context for emit timing", + example: "[ 0 0.5 ] at kick s . => emits at 0 and 0.5 of step", compile: Simple, }, // Quotations @@ -2050,6 +2031,7 @@ pub(super) fn simple_op(name: &str) -> Option { "seed" => Op::Seed, "cycle" => Op::Cycle, "pcycle" => Op::PCycle, + "tcycle" => Op::TCycle, "choose" => Op::Choose, "every" => Op::Every, "chance" => Op::ChanceExec, @@ -2061,10 +2043,7 @@ pub(super) fn simple_op(name: &str) -> Option { "!?" => Op::Unless, "tempo!" => Op::SetTempo, "speed!" => Op::SetSpeed, - "[" => Op::ListStart, - "]" => Op::ListEnd, - ">" => Op::ListEndCycle, - ">>" => Op::ListEndPCycle, + "at" => Op::At, "adsr" => Op::Adsr, "ad" => Op::Ad, "apply" => Op::Apply, diff --git a/src/input.rs b/src/input.rs index c770a01..b9f38e7 100644 --- a/src/input.rs +++ b/src/input.rs @@ -476,6 +476,9 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char('p') if ctrl => { editor.search_prev(); } + KeyCode::Char('k') if ctrl => { + ctx.app.editor_ctx.show_stack = !ctx.app.editor_ctx.show_stack; + } KeyCode::Char('a') if ctrl => { editor.select_all(); } diff --git a/src/state/editor.rs b/src/state/editor.rs index aa664a4..b9d26f0 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -54,6 +54,7 @@ pub struct EditorContext { pub editor: Editor, pub selection_anchor: Option, pub copied_steps: Option, + pub show_stack: bool, } #[derive(Clone)] @@ -94,6 +95,7 @@ impl Default for EditorContext { editor: Editor::new(), selection_anchor: None, copied_steps: None, + show_stack: false, } } } diff --git a/src/views/render.rs b/src/views/render.rs index 4fb67e4..1e91632 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -1,14 +1,19 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::Instant; +use rand::rngs::StdRng; +use rand::SeedableRng; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; use ratatui::Frame; +use cagire_forth::Forth; use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; -use crate::model::SourceSpan; +use crate::model::{SourceSpan, StepContext, Value}; use crate::page::Page; use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel}; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; @@ -20,6 +25,64 @@ use super::{ dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view, }; +fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor) -> String { + let cursor_line = editor.cursor().0; + let partial: Vec<&str> = lines.iter().take(cursor_line + 1).map(|s| s.as_str()).collect(); + let script = partial.join("\n"); + + if script.trim().is_empty() { + return "Stack: []".to_string(); + } + + let vars = Arc::new(Mutex::new(HashMap::new())); + let dict = Arc::new(Mutex::new(HashMap::new())); + let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42))); + let forth = Forth::new(vars, dict, rng); + + let ctx = StepContext { + step: 0, + beat: 0.0, + bank: 0, + pattern: 0, + tempo: 120.0, + phase: 0.0, + slot: 0, + runs: 0, + iter: 0, + speed: 1.0, + fill: false, + nudge_secs: 0.0, + }; + + match forth.evaluate(&script, &ctx) { + Ok(_) => { + let stack = forth.stack(); + let formatted: Vec = stack.iter().map(format_value).collect(); + format!("Stack: [{}]", formatted.join(" ")) + } + Err(e) => format!("Error: {e}"), + } +} + +fn format_value(v: &Value) -> String { + match v { + Value::Int(n, _) => n.to_string(), + Value::Float(f, _) => { + if f.fract() == 0.0 && f.abs() < 1_000_000.0 { + format!("{f:.1}") + } else { + format!("{f:.4}") + } + } + Value::Str(s, _) => format!("\"{s}\""), + Value::Quotation(..) => "[...]".to_string(), + Value::CycleList(items) => { + let inner: Vec = items.iter().map(format_value).collect(); + format!("({})", inner.join(" ")) + } + } +} + fn adjust_spans_for_line( spans: &[SourceSpan], line_start: usize, @@ -619,28 +682,24 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let show_search = app.editor_ctx.editor.search_active() || !app.editor_ctx.editor.search_query().is_empty(); - let (search_area, editor_area, hint_area) = if show_search { - let search_area = Rect::new(inner.x, inner.y, inner.width, 1); - let editor_area = Rect::new( - inner.x, - inner.y + 1, - inner.width, - inner.height.saturating_sub(2), - ); - let hint_area = - Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1); - (Some(search_area), editor_area, hint_area) + let reserved_lines = 1 + if show_search { 1 } else { 0 }; + let editor_height = inner.height.saturating_sub(reserved_lines); + + let mut y = inner.y; + + let search_area = if show_search { + let area = Rect::new(inner.x, y, inner.width, 1); + y += 1; + Some(area) } else { - let editor_area = Rect::new( - inner.x, - inner.y, - inner.width, - inner.height.saturating_sub(1), - ); - let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1); - (None, editor_area, hint_area) + None }; + let editor_area = Rect::new(inner.x, y, inner.width, editor_height); + y += editor_height; + + let hint_area = Rect::new(inner.x, y, inner.width, 1); + if let Some(sa) = search_area { let style = if app.editor_ctx.editor.search_active() { Style::default().fg(Color::Yellow) @@ -671,32 +730,52 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let dim = Style::default().fg(Color::DarkGray); let key = Style::default().fg(Color::Yellow); - let hint = if app.editor_ctx.editor.search_active() { - Line::from(vec![ + + if app.editor_ctx.editor.search_active() { + let hint = Line::from(vec![ Span::styled("Enter", key), Span::styled(" confirm ", dim), Span::styled("Esc", key), Span::styled(" cancel", dim), + ]); + frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + } else if app.editor_ctx.show_stack { + let stack_text = compute_stack_display(text_lines, &app.editor_ctx.editor); + let hint = Line::from(vec![ + Span::styled("Esc", key), + Span::styled(" save ", dim), + Span::styled("C-e", key), + Span::styled(" eval ", dim), + Span::styled("C-k", key), + Span::styled(" hide", dim), + ]); + let [hint_left, stack_right] = Layout::horizontal([ + Constraint::Length(hint.width() as u16), + Constraint::Fill(1), ]) + .areas(hint_area); + frame.render_widget(Paragraph::new(hint), hint_left); + frame.render_widget( + Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right), + stack_right, + ); } else { - Line::from(vec![ + let hint = Line::from(vec![ Span::styled("Esc", key), Span::styled(" save ", dim), Span::styled("C-e", key), Span::styled(" eval ", dim), Span::styled("C-f", key), Span::styled(" find ", dim), - Span::styled("C-n", key), - Span::styled("/", dim), - Span::styled("C-p", key), - Span::styled(" next/prev ", dim), + Span::styled("C-k", key), + Span::styled(" stack ", dim), Span::styled("C-u", key), Span::styled("/", dim), Span::styled("C-r", key), Span::styled(" undo/redo", dim), - ]) - }; - frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + ]); + frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + } } Modal::PatternProps { bank, diff --git a/tests/forth/errors.rs b/tests/forth/errors.rs index 7a22f42..27f69cc 100644 --- a/tests/forth/errors.rs +++ b/tests/forth/errors.rs @@ -51,22 +51,6 @@ fn string_with_spaces() { } } -#[test] -fn list_count() { - let f = run("[ 1 2 3 ]"); - assert_eq!(stack_int(&f), 3); -} - -#[test] -fn list_empty() { - expect_int("[ ]", 0); -} - -#[test] -fn list_preserves_values() { - expect_int("[ 10 20 ] drop +", 30); -} - #[test] fn conditional_based_on_step() { let ctx0 = ctx_with(|c| c.step = 0); diff --git a/tests/forth/list_words.rs b/tests/forth/list_words.rs index 1a205c8..b330f13 100644 --- a/tests/forth/list_words.rs +++ b/tests/forth/list_words.rs @@ -1,124 +1,106 @@ use super::harness::*; #[test] -fn choose_word_from_list() { - // 1 2 [ + - ] choose: picks + or -, applies to 1 2 - // With seed 42, choose picks one deterministically +fn choose_from_stack() { let f = forth(); let ctx = default_ctx(); - f.evaluate("1 2 [ + - ] choose", &ctx).unwrap(); + f.evaluate("1 2 3 3 choose", &ctx).unwrap(); let val = stack_int(&f); - assert!(val == 3 || val == -1, "expected 3 or -1, got {}", val); + assert!(val >= 1 && val <= 3, "expected 1, 2, or 3, got {}", val); } #[test] -fn cycle_word_from_list() { - // At runs=0, picks first word (dup) +fn cycle_by_runs() { let ctx = ctx_with(|c| c.runs = 0); - let f = run_ctx("5 < dup nip >", &ctx); - assert_eq!(stack_int(&f), 5); // dup leaves 5 5, but stack check takes top - - // At runs=1, picks second word (2 *) - let f = forth(); - let ctx = ctx_with(|c| c.runs = 1); - f.evaluate(": double 2 * ; 5 < dup double >", &ctx).unwrap(); + let f = run_ctx("10 20 30 3 cycle", &ctx); assert_eq!(stack_int(&f), 10); -} -#[test] -fn user_word_in_list() { - let f = forth(); - let ctx = ctx_with(|c| c.runs = 0); - f.evaluate(": add3 3 + ; : add5 5 + ; 10 < add3 add5 >", &ctx).unwrap(); - assert_eq!(stack_int(&f), 13); // runs=0 picks add3 -} - -#[test] -fn user_word_in_list_second() { - let f = forth(); let ctx = ctx_with(|c| c.runs = 1); - f.evaluate(": add3 3 + ; : add5 5 + ; 10 < add3 add5 >", &ctx).unwrap(); - assert_eq!(stack_int(&f), 15); // runs=1 picks add5 -} + let f = run_ctx("10 20 30 3 cycle", &ctx); + assert_eq!(stack_int(&f), 20); -#[test] -fn values_in_list_still_work() { - // Numbers inside lists should still push as values (not quotations) - let ctx = ctx_with(|c| c.runs = 0); - let f = run_ctx("< 10 20 30 >", &ctx); - assert_eq!(stack_int(&f), 10); -} - -#[test] -fn values_in_list_cycle() { let ctx = ctx_with(|c| c.runs = 2); - let f = run_ctx("< 10 20 30 >", &ctx); + let f = run_ctx("10 20 30 3 cycle", &ctx); + assert_eq!(stack_int(&f), 30); + + let ctx = ctx_with(|c| c.runs = 3); + let f = run_ctx("10 20 30 3 cycle", &ctx); + assert_eq!(stack_int(&f), 10); +} + +#[test] +fn pcycle_by_iter() { + let ctx = ctx_with(|c| c.iter = 0); + let f = run_ctx("10 20 30 3 pcycle", &ctx); + assert_eq!(stack_int(&f), 10); + + let ctx = ctx_with(|c| c.iter = 1); + let f = run_ctx("10 20 30 3 pcycle", &ctx); + assert_eq!(stack_int(&f), 20); + + let ctx = ctx_with(|c| c.iter = 2); + let f = run_ctx("10 20 30 3 pcycle", &ctx); assert_eq!(stack_int(&f), 30); } #[test] -fn mixed_values_and_words() { - // Values stay as values, words become quotations - // [ 10 20 ] choose just picks a number - let f = forth(); - let ctx = default_ctx(); - f.evaluate("[ 10 20 ] choose", &ctx).unwrap(); - let val = stack_int(&f); - assert!(val == 10 || val == 20, "expected 10 or 20, got {}", val); -} - -#[test] -fn word_with_sound_params() { - let f = forth(); +fn cycle_with_quotations() { let ctx = ctx_with(|c| c.runs = 0); - let outputs = f.evaluate( - ": myverb 0.5 verb ; \"sine\" s 440 freq < myverb > .", - &ctx - ).unwrap(); - assert_eq!(outputs.len(), 1); - assert!(outputs[0].contains("verb/0.5"), "expected verb/0.5 in {}", outputs[0]); -} - -#[test] -fn arithmetic_word_in_list() { - // 3 4 [ + ] choose -> picks + (only option), applies to 3 4 = 7 - let ctx = ctx_with(|c| c.runs = 0); - let f = run_ctx("3 4 < + >", &ctx); - assert_eq!(stack_int(&f), 7); -} - -#[test] -fn pcycle_word_from_list() { - let ctx = ctx_with(|c| c.iter = 0); - let f = run_ctx("10 << dup 2 * >>", &ctx); - // iter=0 picks dup: 10 10 + let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx); let stack = f.stack(); assert_eq!(stack.len(), 2); + assert_eq!(stack_int(&f), 5); + + let ctx = ctx_with(|c| c.runs = 1); + let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx); assert_eq!(stack_int(&f), 10); } #[test] -fn pcycle_word_second() { - let ctx = ctx_with(|c| c.iter = 1); - let f = run_ctx("10 << dup 2 * >>", &ctx); - // iter=1 picks "2 *" — but wait, each token is its own element - // so << dup 2 * >> has 3 elements: {dup}, 2, {*} - // iter=1 picks element index 1 which is value 2 - assert_eq!(stack_int(&f), 2); -} - -#[test] -fn multi_op_quotation_in_list() { - // Use { } for multi-op quotations inside lists +fn cycle_executes_quotation() { let ctx = ctx_with(|c| c.runs = 0); - let f = run_ctx("10 < { 2 * } { 3 + } >", &ctx); - assert_eq!(stack_int(&f), 20); // runs=0 picks {2 *} + let f = run_ctx("10 { 3 + } { 5 + } 2 cycle", &ctx); + assert_eq!(stack_int(&f), 13); } #[test] -fn multi_op_quotation_second() { - let ctx = ctx_with(|c| c.runs = 1); - let f = run_ctx("10 < { 2 * } { 3 + } >", &ctx); - assert_eq!(stack_int(&f), 13); // runs=1 picks {3 +} +fn dupn_basic() { + // 5 3 dupn -> 5 5 5, then + + -> 15 + expect_int("5 3 dupn + +", 15); } +#[test] +fn dupn_alias() { + expect_int("5 3 ! + +", 15); +} + +#[test] +fn tcycle_creates_cycle_list() { + let outputs = expect_outputs(r#"0.0 at 60 64 67 3 tcycle note sine s ."#, 1); + assert!(outputs[0].contains("note/60")); +} + +#[test] +fn tcycle_with_multiple_emits() { + let f = forth(); + let ctx = default_ctx(); + let outputs = f.evaluate(r#"0 0.5 2 at 60 64 2 tcycle note sine s ."#, &ctx).unwrap(); + assert_eq!(outputs.len(), 2); + assert!(outputs[0].contains("note/60")); + assert!(outputs[1].contains("note/64")); +} + +#[test] +fn cycle_zero_count_error() { + expect_error("1 2 3 0 cycle", "cycle count must be > 0"); +} + +#[test] +fn choose_zero_count_error() { + expect_error("1 2 3 0 choose", "choose count must be > 0"); +} + +#[test] +fn tcycle_zero_count_error() { + expect_error("1 2 3 0 tcycle", "tcycle count must be > 0"); +} diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index 5616341..1f1196d 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -42,6 +42,13 @@ fn get_sounds(outputs: &[String]) -> Vec { .collect() } +fn get_param(outputs: &[String], param: &str) -> Vec { + outputs + .iter() + .map(|o| parse_params(o).get(param).copied().unwrap_or(0.0)) + .collect() +} + const EPSILON: f64 = 1e-9; fn approx_eq(a: f64, b: f64) -> bool { @@ -95,29 +102,29 @@ fn dur_is_step_duration() { } #[test] -fn cycle_picks_by_step() { +fn cycle_picks_by_runs() { for runs in 0..4 { let ctx = ctx_with(|c| c.runs = runs); let f = forth(); - let outputs = f.evaluate(r#""kick" s < . _ >"#, &ctx).unwrap(); + let outputs = f.evaluate(r#""kick" s { . } { } 2 cycle"#, &ctx).unwrap(); if runs % 2 == 0 { - assert_eq!(outputs.len(), 1, "runs={}: . should be picked", runs); + assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs); } else { - assert_eq!(outputs.len(), 0, "runs={}: _ should be picked", runs); + assert_eq!(outputs.len(), 0, "runs={}: no-op should be picked", runs); } } } #[test] -fn pcycle_picks_by_pattern() { +fn pcycle_picks_by_iter() { for iter in 0..4 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); - let outputs = f.evaluate(r#""kick" s << . _ >>"#, &ctx).unwrap(); + let outputs = f.evaluate(r#""kick" s { . } { } 2 pcycle"#, &ctx).unwrap(); if iter % 2 == 0 { - assert_eq!(outputs.len(), 1, "iter={}: . should be picked", iter); + assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter); } else { - assert_eq!(outputs.len(), 0, "iter={}: _ should be picked", iter); + assert_eq!(outputs.len(), 0, "iter={}: no-op should be picked", iter); } } } @@ -127,7 +134,10 @@ fn cycle_with_sounds() { for runs in 0..3 { let ctx = ctx_with(|c| c.runs = runs); let f = forth(); - let outputs = f.evaluate(r#"< { "kick" s . } { "hat" s . } { "snare" s . } >"#, &ctx).unwrap(); + let outputs = f.evaluate( + r#"{ "kick" s . } { "hat" s . } { "snare" s . } 3 cycle"#, + &ctx + ).unwrap(); assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs); let sounds = get_sounds(&outputs); let expected = ["kick", "hat", "snare"][runs % 3]; @@ -154,3 +164,107 @@ fn emit_n_negative_error() { let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx()); assert!(result.is_err()); } + +#[test] +fn at_single_delta() { + let outputs = expect_outputs(r#"0.5 at "kick" s ."#, 1); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[0]); +} + +#[test] +fn at_list_deltas() { + let outputs = expect_outputs(r#"0 0.5 2 at "kick" s ."#, 2); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]); + assert!(approx_eq(deltas[1], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[1]); +} + +#[test] +fn at_three_deltas() { + let outputs = expect_outputs(r#"0 0.33 0.67 3 at "kick" s ."#, 3); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.0), "expected delta 0"); + assert!((deltas[1] - 0.33 * step_dur).abs() < 0.001, "expected delta at 0.33 of step"); + assert!((deltas[2] - 0.67 * step_dur).abs() < 0.001, "expected delta at 0.67 of step"); +} + +#[test] +fn at_persists_across_emits() { + let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . "hat" s ."#, 4); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]); +} + +#[test] +fn tcycle_basic() { + let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note sine s ."#, 3); + let notes = get_param(&outputs, "note"); + assert_eq!(notes, vec![60.0, 64.0, 67.0]); +} + +#[test] +fn tcycle_wraps() { + let outputs = expect_outputs(r#"0 0.33 0.67 3 at 60 64 2 tcycle note sine s ."#, 3); + let notes = get_param(&outputs, "note"); + assert_eq!(notes, vec![60.0, 64.0, 60.0]); +} + +#[test] +fn tcycle_with_sound() { + let outputs = expect_outputs(r#"0 0.5 2 at kick hat 2 tcycle s ."#, 2); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "hat"]); +} + +#[test] +fn tcycle_multiple_params() { + let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note 0.5 1.0 2 tcycle gain sine s ."#, 3); + let notes = get_param(&outputs, "note"); + let gains = get_param(&outputs, "gain"); + assert_eq!(notes, vec![60.0, 64.0, 67.0]); + assert_eq!(gains, vec![0.5, 1.0, 0.5]); +} + +#[test] +fn at_reset_with_zero() { + let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . 0.0 at "hat" s ."#, 3); + let sounds = get_sounds(&outputs); + assert_eq!(sounds, vec!["kick", "kick", "hat"]); +} + +#[test] +fn tcycle_records_selected_spans() { + use cagire::forth::ExecutionTrace; + + let f = forth(); + let mut trace = ExecutionTrace::default(); + let script = r#"0 0.5 2 at kick hat 2 tcycle s ."#; + f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); + + // Should have 4 selected spans: + // - 2 for at deltas (0 and 0.5) + // - 2 for tcycle sound values (kick and hat) + assert_eq!(trace.selected_spans.len(), 4, "expected 4 selected spans (2 at + 2 tcycle)"); +} + +#[test] +fn at_records_selected_spans() { + use cagire::forth::ExecutionTrace; + + let f = forth(); + let mut trace = ExecutionTrace::default(); + let script = r#"0 0.5 0.75 3 at "kick" s ."#; + f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); + + // Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit) + assert_eq!(trace.selected_spans.len(), 6, "expected 6 selected spans (3 at + 3 sound)"); + + // Verify at delta spans (even indices: 0, 2, 4) + assert_eq!(&script[trace.selected_spans[0].start..trace.selected_spans[0].end], "0"); + assert_eq!(&script[trace.selected_spans[2].start..trace.selected_spans[2].end], "0.5"); + assert_eq!(&script[trace.selected_spans[4].start..trace.selected_spans[4].end], "0.75"); +}