diff --git a/src/app.rs b/src/app.rs index 1023b97..ffabcb4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use crate::commands::AppCommand; use crate::engine::{ LinkState, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, StepSnapshot, }; -use crate::model::{self, Bank, Pattern, Rng, ScriptEngine, StepContext, Variables}; +use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, StepContext, Variables}; use crate::page::Page; use crate::services::pattern_editor; use crate::settings::Settings; @@ -35,6 +35,7 @@ pub struct App { pub metrics: Metrics, pub script_engine: ScriptEngine, pub variables: Variables, + pub dict: Dictionary, pub rng: Rng, pub live_keys: Arc, pub clipboard: Option, @@ -47,8 +48,9 @@ pub struct App { impl App { pub fn new() -> Self { let variables = Arc::new(Mutex::new(HashMap::new())); + let dict = Arc::new(Mutex::new(HashMap::new())); let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); - let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&rng)); + let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), Arc::clone(&rng)); let live_keys = Arc::new(LiveKeyState::new()); Self { @@ -63,6 +65,7 @@ impl App { metrics: Metrics::default(), variables, + dict, rng, live_keys, script_engine, diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 58e8149..2775e6f 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -6,7 +6,7 @@ use std::time::Duration; use super::LinkState; use crate::config::{MAX_BANKS, MAX_PATTERNS}; -use crate::model::{ExecutionTrace, Rng, ScriptEngine, StepContext, Variables}; +use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Variables}; use crate::state::LiveKeyState; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -177,6 +177,7 @@ pub fn spawn_sequencer( link: Arc, playing: Arc, variables: Variables, + dict: Dictionary, rng: Rng, quantum: f64, live_keys: Arc, @@ -197,6 +198,7 @@ pub fn spawn_sequencer( link, playing, variables, + dict, rng, quantum, shared_state_clone, @@ -291,6 +293,7 @@ fn sequencer_loop( link: Arc, playing: Arc, variables: Variables, + dict: Dictionary, rng: Rng, quantum: f64, shared_state: Arc>, @@ -298,7 +301,7 @@ fn sequencer_loop( ) { use std::sync::atomic::Ordering; - let script_engine = ScriptEngine::new(Arc::clone(&variables), rng); + let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng); let mut audio_state = AudioState::new(); let mut pattern_cache = PatternCache::new(); let mut runs_counter = RunsCounter::new(); diff --git a/src/main.rs b/src/main.rs index 7fad8b4..d4605ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -102,6 +102,7 @@ fn main() -> io::Result<()> { Arc::clone(&link), Arc::clone(&playing), Arc::clone(&app.variables), + Arc::clone(&app.dict), Arc::clone(&app.rng), settings.link.quantum, Arc::clone(&app.live_keys), diff --git a/src/model/forth.rs b/src/model/forth.rs index c5e5f0c..7e04289 100644 --- a/src/model/forth.rs +++ b/src/model/forth.rs @@ -35,6 +35,7 @@ impl StepContext { } pub type Variables = Arc>>; +pub type Dictionary = Arc>>>; pub type Rng = Arc>; #[derive(Clone, Debug)] @@ -212,6 +213,7 @@ pub enum Op { LocalCycleEnd, Echo, Necho, + Apply, } pub enum WordCompile { @@ -1606,6 +1608,29 @@ pub const WORDS: &[Word] = &[ example: "1 reset", compile: Param, }, + // Quotation execution + Word { + name: "apply", + stack: "(quot --)", + desc: "Execute quotation unconditionally", + example: "{ 2 * } apply", + compile: Simple, + }, + // Word definitions + Word { + name: ":", + stack: "( -- )", + desc: "Begin word definition", + example: ": kick \"kick\" s emit ;", + compile: Simple, + }, + Word { + name: ";", + stack: "( -- )", + desc: "End word definition", + example: ": kick \"kick\" s emit ;", + compile: Simple, + }, ]; fn simple_op(name: &str) -> Option { @@ -1672,6 +1697,7 @@ fn simple_op(name: &str) -> Option { "for" => Op::For, "echo" => Op::Echo, "necho" => Op::Necho, + "apply" => Op::Apply, _ => return None, }) } @@ -1751,7 +1777,7 @@ fn parse_interval(name: &str) -> Option { Some(simple) } -fn compile_word(name: &str, span: Option, ops: &mut Vec) -> bool { +fn compile_word(name: &str, span: Option, ops: &mut Vec, dict: &Dictionary) -> bool { for word in WORDS { if word.name == name { match &word.compile { @@ -1762,7 +1788,7 @@ fn compile_word(name: &str, span: Option, ops: &mut Vec) -> bool } Context(ctx) => ops.push(Op::GetContext((*ctx).into())), Param => ops.push(Op::SetParam(name.into())), - Alias(target) => return compile_word(target, span, ops), + Alias(target) => return compile_word(target, span, ops, dict), Probability(p) => { ops.push(Op::PushFloat(*p, None)); ops.push(Op::ChanceExec); @@ -1810,6 +1836,12 @@ fn compile_word(name: &str, span: Option, ops: &mut Vec) -> bool return true; } + // User-defined words from dictionary + if let Some(body) = dict.lock().unwrap().get(name) { + ops.extend(body.iter().cloned()); + return true; + } + false } @@ -1905,7 +1937,7 @@ fn tokenize(input: &str) -> Vec { tokens } -fn compile(tokens: &[Token]) -> Result, String> { +fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { let mut ops = Vec::new(); let mut i = 0; let mut pipe_parity = false; @@ -1916,7 +1948,7 @@ fn compile(tokens: &[Token]) -> Result, String> { Token::Float(f, span) => ops.push(Op::PushFloat(*f, Some(*span))), Token::Str(s, span) => ops.push(Op::PushStr(s.clone(), Some(*span))), Token::QuoteStart(start_pos) => { - let (quote_ops, consumed, end_pos) = compile_quotation(&tokens[i + 1..])?; + let (quote_ops, consumed, end_pos) = compile_quotation(&tokens[i + 1..], dict)?; i += consumed; let body_span = SourceSpan { start: *start_pos, end: end_pos + 1 }; ops.push(Op::Quotation(quote_ops, Some(body_span))); @@ -1926,7 +1958,13 @@ fn compile(tokens: &[Token]) -> Result, String> { } Token::Word(w, span) => { let word = w.as_str(); - if word == "|" { + if word == ":" { + let (consumed, name, body) = compile_colon_def(&tokens[i + 1..], dict)?; + i += consumed; + dict.lock().unwrap().insert(name, body); + } else if word == ";" { + return Err("unexpected ;".into()); + } else if word == "|" { if pipe_parity { ops.push(Op::LocalCycleEnd); } else { @@ -1934,7 +1972,7 @@ fn compile(tokens: &[Token]) -> Result, String> { } pipe_parity = !pipe_parity; } else if word == "if" { - let (then_ops, else_ops, consumed, then_span, else_span) = compile_if(&tokens[i + 1..])?; + let (then_ops, else_ops, consumed, then_span, else_span) = compile_if(&tokens[i + 1..], dict)?; i += consumed; if else_ops.is_empty() { ops.push(Op::BranchIfZero(then_ops.len(), then_span, None)); @@ -1945,7 +1983,7 @@ fn compile(tokens: &[Token]) -> Result, String> { ops.push(Op::Branch(else_ops.len())); ops.extend(else_ops); } - } else if !compile_word(word, Some(*span), &mut ops) { + } else if !compile_word(word, Some(*span), &mut ops, dict) { return Err(format!("unknown word: {word}")); } } @@ -1956,7 +1994,7 @@ fn compile(tokens: &[Token]) -> Result, String> { Ok(ops) } -fn compile_quotation(tokens: &[Token]) -> Result<(Vec, usize, usize), String> { +fn compile_quotation(tokens: &[Token], dict: &Dictionary) -> Result<(Vec, usize, usize), String> { let mut depth = 1; let mut end_idx = None; @@ -1979,7 +2017,7 @@ fn compile_quotation(tokens: &[Token]) -> Result<(Vec, usize, usize), String Token::QuoteEnd(pos) => *pos, _ => unreachable!(), }; - let quote_ops = compile(&tokens[..end_idx])?; + let quote_ops = compile(&tokens[..end_idx], dict)?; Ok((quote_ops, end_idx + 1, byte_pos)) } @@ -1991,6 +2029,30 @@ fn token_span(tok: &Token) -> Option { } } +fn compile_colon_def(tokens: &[Token], dict: &Dictionary) -> Result<(usize, String, Vec), String> { + if tokens.is_empty() { + return Err("expected word name after ':'".into()); + } + let name = match &tokens[0] { + Token::Word(w, _) => w.clone(), + _ => return Err("expected word name after ':'".into()), + }; + let mut semi_pos = None; + for (i, tok) in tokens[1..].iter().enumerate() { + if let Token::Word(w, _) = tok { + if w == ";" { + semi_pos = Some(i + 1); + break; + } + } + } + let semi_pos = semi_pos.ok_or("missing ';' in word definition")?; + let body_tokens = &tokens[1..semi_pos]; + let body_ops = compile(body_tokens, dict)?; + // consumed = name + body + semicolon + Ok((semi_pos + 1, name, body_ops)) +} + fn tokens_span(tokens: &[Token]) -> Option { let first = tokens.first().and_then(token_span)?; let last = tokens.last().and_then(token_span)?; @@ -1998,7 +2060,7 @@ fn tokens_span(tokens: &[Token]) -> Option { } #[allow(clippy::type_complexity)] -fn compile_if(tokens: &[Token]) -> Result<(Vec, Vec, usize, Option, Option), String> { +fn compile_if(tokens: &[Token], dict: &Dictionary) -> Result<(Vec, Vec, usize, Option, Option), String> { let mut depth = 1; let mut else_pos = None; let mut then_pos = None; @@ -2027,13 +2089,13 @@ fn compile_if(tokens: &[Token]) -> Result<(Vec, Vec, usize, Option>>; pub struct Forth { stack: Stack, vars: Variables, + dict: Dictionary, rng: Rng, } impl Forth { - pub fn new(vars: Variables, rng: Rng) -> Self { + pub fn new(vars: Variables, dict: Dictionary, rng: Rng) -> Self { Self { stack: Arc::new(Mutex::new(Vec::new())), vars, + dict, rng, } } @@ -2091,7 +2155,7 @@ impl Forth { } let tokens = tokenize(script); - let ops = compile(&tokens)?; + let ops = compile(&tokens, &self.dict)?; self.execute(&ops, ctx, trace) } @@ -2824,6 +2888,23 @@ impl Forth { } stack.push(selected); } + + Op::Apply => { + let quot = stack.pop().ok_or("stack underflow")?; + match quot { + Value::Quotation(quot_ops, body_span) => { + if let Some(span) = body_span { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.executed_spans.push(span); + } + } + let mut trace_opt = trace_cell.borrow_mut().take(); + self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, trace_opt.as_deref_mut())?; + *trace_cell.borrow_mut() = trace_opt; + } + _ => return Err("expected quotation".into()), + } + } } pc += 1; } diff --git a/src/model/mod.rs b/src/model/mod.rs index c311afb..9ad274c 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -5,4 +5,4 @@ mod script; pub use file::{load, save}; pub use project::{Bank, Pattern, PatternSpeed, Project}; -pub use script::{ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables}; +pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables}; diff --git a/src/model/script.rs b/src/model/script.rs index d0f6bdd..bc6263b 100644 --- a/src/model/script.rs +++ b/src/model/script.rs @@ -1,15 +1,15 @@ use super::forth::Forth; -pub use super::forth::{ExecutionTrace, Rng, SourceSpan, StepContext, Variables}; +pub use super::forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Variables}; pub struct ScriptEngine { forth: Forth, } impl ScriptEngine { - pub fn new(vars: Variables, rng: Rng) -> Self { + pub fn new(vars: Variables, dict: Dictionary, rng: Rng) -> Self { Self { - forth: Forth::new(vars, rng), + forth: Forth::new(vars, dict, rng), } } diff --git a/src/views/highlight.rs b/src/views/highlight.rs index 97f5d29..b376cf0 100644 --- a/src/views/highlight.rs +++ b/src/views/highlight.rs @@ -55,7 +55,7 @@ const KEYWORDS: &[&str] = &[ "zoom", "scale!", "stack", "echo", "necho", "for", "div", "each", "at", "pop", "adsr", "ad", "?", "!?", "<<", ">>", "|", "@", "!", "pcycle", "tempo!", "prob", "sometimes", "often", "rarely", "almostAlways", "almostNever", "always", "never", "coin", "fill", "iter", "every", - "gt", "lt", + "gt", "lt", ":", ";", "apply", ]; const SOUND: &[&str] = &["sound", "s"]; const CONTEXT: &[&str] = &[ diff --git a/tests/forth.rs b/tests/forth.rs index e14f0f0..d0c95cb 100644 --- a/tests/forth.rs +++ b/tests/forth.rs @@ -42,3 +42,6 @@ mod notes; #[path = "forth/intervals.rs"] mod intervals; + +#[path = "forth/definitions.rs"] +mod definitions; diff --git a/tests/forth/definitions.rs b/tests/forth/definitions.rs new file mode 100644 index 0000000..48435a7 --- /dev/null +++ b/tests/forth/definitions.rs @@ -0,0 +1,115 @@ +use super::harness::*; + +#[test] +fn define_and_use_word() { + expect_int(": double 2 * ; 5 double", 10); +} + +#[test] +fn define_word_with_multiple_ops() { + expect_int(": triple dup dup + + ; 3 triple", 9); +} + +#[test] +fn define_word_using_another_user_word() { + expect_int(": double 2 * ; : quad double double ; 3 quad", 12); +} + +#[test] +fn redefine_word_overwrites() { + expect_int(": foo 10 ; : foo 20 ; foo", 20); +} + +#[test] +fn word_with_param() { + let outputs = expect_outputs(": loud 0.9 gain ; \"kick\" s loud emit", 1); + assert!(outputs[0].contains("gain/0.9")); +} + +#[test] +fn word_available_across_evaluations() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(": hi 42 ;", &ctx).unwrap(); + f.evaluate("hi", &ctx).unwrap(); + assert_eq!(stack_int(&f), 42); +} + +#[test] +fn word_defined_in_one_forth_available_in_same() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(": ten 10 ;", &ctx).unwrap(); + f.clear_stack(); + f.evaluate("ten ten +", &ctx).unwrap(); + assert_eq!(stack_int(&f), 20); +} + +#[test] +fn unknown_word_errors() { + expect_error("nosuchword", "unknown word"); +} + +#[test] +fn missing_semicolon_errors() { + expect_error(": foo 10", "missing ';'"); +} + +#[test] +fn missing_name_errors() { + expect_error(":", "expected word name"); +} + +#[test] +fn unexpected_semicolon_errors() { + expect_error(";", "unexpected ;"); +} + +#[test] +fn apply_executes_quotation() { + expect_int("5 { 2 * } apply", 10); +} + +#[test] +fn apply_with_stack_ops() { + expect_int("3 4 { + } apply", 7); +} + +#[test] +fn apply_empty_stack_errors() { + expect_error("apply", "stack underflow"); +} + +#[test] +fn apply_non_quotation_errors() { + expect_error("42 apply", "expected quotation"); +} + +#[test] +fn apply_nested() { + expect_int("2 { { 3 * } apply } apply", 6); +} + +#[test] +fn define_word_containing_quotation() { + expect_int(": dbl { 2 * } apply ; 7 dbl", 14); +} + +#[test] +fn define_word_with_sound() { + let outputs = expect_outputs(": kick \"kick\" s emit ; kick", 1); + assert!(outputs[0].contains("sound/kick")); +} + +#[test] +fn define_word_with_conditional() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(": maybe-double dup 5 gt if 2 * then ;", &ctx).unwrap(); + f.clear_stack(); + f.evaluate("3 maybe-double", &ctx).unwrap(); + assert_eq!(stack_int(&f), 3); + f.clear_stack(); + f.evaluate("10 maybe-double", &ctx).unwrap(); + assert_eq!(stack_int(&f), 20); +} diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs index 36e0619..204204a 100644 --- a/tests/forth/harness.rs +++ b/tests/forth/harness.rs @@ -1,6 +1,6 @@ use rand::rngs::StdRng; use rand::SeedableRng; -use cagire::model::forth::{Forth, Rng, StepContext, Value, Variables}; +use cagire::model::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -29,16 +29,20 @@ pub fn new_vars() -> Variables { Arc::new(Mutex::new(HashMap::new())) } +pub fn new_dict() -> Dictionary { + Arc::new(Mutex::new(HashMap::new())) +} + pub fn seeded_rng(seed: u64) -> Rng { Arc::new(Mutex::new(StdRng::seed_from_u64(seed))) } pub fn forth() -> Forth { - Forth::new(new_vars(), seeded_rng(42)) + Forth::new(new_vars(), new_dict(), seeded_rng(42)) } pub fn forth_seeded(seed: u64) -> Forth { - Forth::new(new_vars(), seeded_rng(seed)) + Forth::new(new_vars(), new_dict(), seeded_rng(seed)) } pub fn run(script: &str) -> Forth {