use rand::rngs::StdRng; use rand::{Rng as RngTrait, SeedableRng}; use super::compiler::compile_script; use super::ops::Op; use super::types::{ CmdRegister, Dictionary, ExecutionTrace, Rng, Stack, StepContext, Value, Variables, }; pub struct Forth { stack: Stack, vars: Variables, dict: Dictionary, rng: Rng, } impl Forth { pub fn new(vars: Variables, dict: Dictionary, rng: Rng) -> Self { Self { stack: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), vars, dict, rng, } } #[allow(dead_code)] pub fn stack(&self) -> Vec { self.stack.lock().unwrap().clone() } #[allow(dead_code)] pub fn clear_stack(&self) { self.stack.lock().unwrap().clear(); } pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { self.evaluate_impl(script, ctx, None) } pub fn evaluate_with_trace( &self, script: &str, ctx: &StepContext, trace: &mut ExecutionTrace, ) -> Result, String> { self.evaluate_impl(script, ctx, Some(trace)) } fn evaluate_impl( &self, script: &str, ctx: &StepContext, trace: Option<&mut ExecutionTrace>, ) -> Result, String> { if script.trim().is_empty() { return Err("empty script".into()); } let ops = compile_script(script, &self.dict)?; self.execute(&ops, ctx, trace) } fn execute( &self, ops: &[Op], ctx: &StepContext, trace: Option<&mut ExecutionTrace>, ) -> Result, String> { let mut stack = self.stack.lock().unwrap(); let mut outputs: Vec = Vec::new(); let mut cmd = CmdRegister::default(); self.execute_ops(ops, ctx, &mut stack, &mut outputs, &mut cmd, trace)?; Ok(outputs) } #[allow(clippy::too_many_arguments)] #[allow(clippy::only_used_in_recursion)] fn execute_ops( &self, ops: &[Op], ctx: &StepContext, stack: &mut Vec, outputs: &mut Vec, cmd: &mut CmdRegister, trace: Option<&mut ExecutionTrace>, ) -> Result<(), String> { let mut pc = 0; let trace_cell = std::cell::RefCell::new(trace); let run_quotation = |quot: Value, stack: &mut Vec, outputs: &mut Vec, cmd: &mut CmdRegister| -> Result<(), String> { 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, cmd, trace_opt.as_deref_mut(), )?; *trace_cell.borrow_mut() = trace_opt; Ok(()) } _ => Err("expected quotation".into()), } }; let select_and_run = |selected: Value, stack: &mut Vec, outputs: &mut Vec, cmd: &mut CmdRegister| -> Result<(), String> { if let Some(span) = selected.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); } } if matches!(selected, Value::Quotation(..)) { run_quotation(selected, stack, outputs, cmd) } else { stack.push(selected); Ok(()) } }; let drain_select_run = |count: usize, idx: usize, stack: &mut Vec, outputs: &mut Vec, cmd: &mut CmdRegister| -> Result<(), String> { if stack.len() < count { return Err("stack underflow".into()); } let start = stack.len() - count; let values: Vec = stack.drain(start..).collect(); let selected = values[idx].clone(); 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 (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; let sound = 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)) }; while pc < ops.len() { match &ops[pc] { Op::PushInt(n, span) => stack.push(Value::Int(*n, *span)), Op::PushFloat(f, span) => stack.push(Value::Float(*f, *span)), Op::PushStr(s, span) => stack.push(Value::Str(s.clone(), *span)), Op::Dup => { let v = stack.last().ok_or("stack underflow")?.clone(); stack.push(v); } Op::Dupn => { let n = stack.pop().ok_or("stack underflow")?.as_int()?; let v = stack.pop().ok_or("stack underflow")?; for _ in 0..n { stack.push(v.clone()); } } Op::Drop => { stack.pop().ok_or("stack underflow")?; } Op::Swap => { let len = stack.len(); if len < 2 { return Err("stack underflow".into()); } stack.swap(len - 1, len - 2); } Op::Over => { let len = stack.len(); if len < 2 { return Err("stack underflow".into()); } let v = stack[len - 2].clone(); stack.push(v); } Op::Rot => { let len = stack.len(); if len < 3 { return Err("stack underflow".into()); } let v = stack.remove(len - 3); stack.push(v); } Op::Nip => { let len = stack.len(); if len < 2 { return Err("stack underflow".into()); } stack.remove(len - 2); } Op::Tuck => { let len = stack.len(); if len < 2 { return Err("stack underflow".into()); } let v = stack[len - 1].clone(); stack.insert(len - 2, v); } Op::Add => binary_op(stack, |a, b| a + b)?, Op::Sub => binary_op(stack, |a, b| a - b)?, Op::Mul => binary_op(stack, |a, b| a * b)?, Op::Div => { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; if b.as_float().map_or(true, |v| v == 0.0) { return Err("division by zero".into()); } stack.push(lift_binary(a, b, |x, y| x / y)?); } Op::Mod => { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; if b.as_float().map_or(true, |v| v == 0.0) { return Err("modulo by zero".into()); } let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?; stack.push(result); } Op::Neg => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| -x)?); } Op::Abs => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.abs())?); } Op::Floor => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.floor())?); } Op::Ceil => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.ceil())?); } Op::Round => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.round())?); } Op::Min => binary_op(stack, |a, b| a.min(b))?, Op::Max => binary_op(stack, |a, b| a.max(b))?, Op::Pow => binary_op(stack, |a, b| a.powf(b))?, Op::Sqrt => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.sqrt())?); } Op::Sin => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.sin())?); } Op::Cos => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.cos())?); } Op::Log => { let v = stack.pop().ok_or("stack underflow")?; stack.push(lift_unary(v, |x| x.ln())?); } Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?, Op::Ne => cmp_op(stack, |a, b| (a - b).abs() >= f64::EPSILON)?, Op::Lt => cmp_op(stack, |a, b| a < b)?, Op::Gt => cmp_op(stack, |a, b| a > b)?, Op::Le => cmp_op(stack, |a, b| a <= b)?, Op::Ge => cmp_op(stack, |a, b| a >= b)?, Op::And => { let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if a && b { 1 } else { 0 }, None)); } Op::Or => { let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if a || b { 1 } else { 0 }, None)); } Op::Not => { let v = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if v { 0 } else { 1 }, None)); } Op::Xor => { let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if a ^ b { 1 } else { 0 }, None)); } Op::Nand => { let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if !(a && b) { 1 } else { 0 }, None)); } Op::Nor => { let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = stack.pop().ok_or("stack underflow")?.is_truthy(); stack.push(Value::Int(if !(a || b) { 1 } else { 0 }, None)); } Op::BranchIfZero(offset, then_span, else_span) => { let v = stack.pop().ok_or("stack underflow")?; if !v.is_truthy() { if let Some(span) = else_span { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.executed_spans.push(*span); } } pc += offset; } else if let Some(span) = then_span { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.executed_spans.push(*span); } } } Op::Branch(offset) => { pc += offset; } Op::NewCmd => { let val = stack.pop().ok_or("stack underflow")?; cmd.set_sound(val); } Op::SetParam(param) => { let val = stack.pop().ok_or("stack underflow")?; cmd.set_param(param.clone(), val); } Op::Emit => { if let Some(sound_val) = emit_once(cmd, outputs)? { if let Some(span) = sound_val.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); } } } } Op::Get => { let name = stack.pop().ok_or("stack underflow")?; let name = name.as_str()?; let vars = self.vars.lock().unwrap(); let val = vars.get(name).cloned().unwrap_or(Value::Int(0, None)); stack.push(val); } Op::Set => { let name = stack.pop().ok_or("stack underflow")?; let name = name.as_str()?.to_string(); let val = stack.pop().ok_or("stack underflow")?; self.vars.lock().unwrap().insert(name, val); } Op::GetContext(name) => { let val = match name.as_str() { "step" => Value::Int(ctx.step as i64, None), "beat" => Value::Float(ctx.beat, None), "bank" => Value::Int(ctx.bank as i64, None), "pattern" => Value::Int(ctx.pattern as i64, None), "tempo" => Value::Float(ctx.tempo, None), "phase" => Value::Float(ctx.phase, None), "slot" => Value::Int(ctx.slot as i64, None), "runs" => Value::Int(ctx.runs as i64, None), "iter" => Value::Int(ctx.iter as i64, None), "speed" => Value::Float(ctx.speed, None), "stepdur" => Value::Float(ctx.step_duration(), None), "fill" => Value::Int(if ctx.fill { 1 } else { 0 }, None), _ => Value::Int(0, None), }; stack.push(val); } Op::Rand => { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; match (&a, &b) { (Value::Int(a_i, _), Value::Int(b_i, _)) => { let (lo, hi) = if a_i <= b_i { (*a_i, *b_i) } else { (*b_i, *a_i) }; let val = self.rng.lock().unwrap().gen_range(lo..=hi); stack.push(Value::Int(val, None)); } _ => { let a_f = a.as_float()?; let b_f = b.as_float()?; let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) }; let val = self.rng.lock().unwrap().gen_range(lo..hi); stack.push(Value::Float(val, None)); } } } Op::Seed => { let s = stack.pop().ok_or("stack underflow")?.as_int()?; *self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64); } Op::Cycle | Op::PCycle => { let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; if count == 0 { return Err("cycle count must be > 0".into()); } let idx = match &ops[pc] { Op::Cycle => ctx.runs, _ => ctx.iter, } % count; drain_select_run(count, idx, stack, outputs, cmd)?; } Op::Choose => { let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; if count == 0 { return Err("choose count must be > 0".into()); } let idx = self.rng.lock().unwrap().gen_range(0..count); drain_select_run(count, idx, stack, outputs, cmd)?; } Op::ChanceExec | Op::ProbExec => { let threshold = stack.pop().ok_or("stack underflow")?.as_float()?; let quot = stack.pop().ok_or("stack underflow")?; let val: f64 = self.rng.lock().unwrap().gen(); let limit = match &ops[pc] { Op::ChanceExec => threshold, _ => threshold / 100.0, }; if val < limit { run_quotation(quot, stack, outputs, cmd)?; } } Op::Coin => { let val: f64 = self.rng.lock().unwrap().gen(); stack.push(Value::Int(if val < 0.5 { 1 } else { 0 }, None)); } Op::Every => { let n = stack.pop().ok_or("stack underflow")?.as_int()?; if n <= 0 { return Err("every count must be > 0".into()); } let result = ctx.iter as i64 % n == 0; stack.push(Value::Int(if result { 1 } else { 0 }, None)); } Op::Quotation(quote_ops, body_span) => { stack.push(Value::Quotation(quote_ops.clone(), *body_span)); } Op::When | Op::Unless => { let cond = stack.pop().ok_or("stack underflow")?; let quot = stack.pop().ok_or("stack underflow")?; let should_run = match &ops[pc] { Op::When => cond.is_truthy(), _ => !cond.is_truthy(), }; if should_run { run_quotation(quot, stack, outputs, cmd)?; } } Op::IfElse => { let cond = stack.pop().ok_or("stack underflow")?; let false_quot = stack.pop().ok_or("stack underflow")?; let true_quot = stack.pop().ok_or("stack underflow")?; let quot = if cond.is_truthy() { true_quot } else { false_quot }; run_quotation(quot, stack, outputs, cmd)?; } Op::Pick => { let idx_i = stack.pop().ok_or("stack underflow")?.as_int()?; if idx_i < 0 { return Err(format!("pick index must be >= 0, got {idx_i}")); } let idx = idx_i as usize; let mut quots: Vec = Vec::new(); while let Some(val) = stack.pop() { match &val { Value::Quotation(_, _) => quots.push(val), _ => { stack.push(val); break; } } } quots.reverse(); if idx >= quots.len() { return Err(format!( "pick index {} out of range (have {} quotations)", idx, quots.len() )); } run_quotation(quots.swap_remove(idx), stack, outputs, cmd)?; } Op::Mtof => { let note = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0); stack.push(Value::Float(freq, None)); } Op::Ftom => { let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let note = 69.0 + 12.0 * (freq / 440.0).log2(); stack.push(Value::Float(note, None)); } Op::Degree(pattern) => { if pattern.is_empty() { return Err("empty scale pattern".into()); } let val = stack.pop().ok_or("stack underflow")?; let len = pattern.len() as i64; let result = lift_unary_int(val, |degree| { let octave_offset = degree.div_euclid(len); let idx = degree.rem_euclid(len) as usize; 60 + octave_offset * 12 + pattern[idx] })?; stack.push(result); } Op::Oct => { let shift = stack.pop().ok_or("stack underflow")?; let note = stack.pop().ok_or("stack underflow")?; let result = lift_binary(note, shift, |n, s| n + s * 12.0)?; stack.push(result); } Op::SetTempo => { let tempo = stack.pop().ok_or("stack underflow")?.as_float()?; let clamped = tempo.clamp(20.0, 300.0); self.vars .lock() .unwrap() .insert("__tempo__".to_string(), Value::Float(clamped, None)); } Op::SetSpeed => { let speed = stack.pop().ok_or("stack underflow")?.as_float()?; let clamped = speed.clamp(0.125, 8.0); let key = format!("__speed_{}_{}__", ctx.bank, ctx.pattern); self.vars .lock() .unwrap() .insert(key, Value::Float(clamped, None)); } Op::Chain => { let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1; let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1; if bank < 0 || pattern < 0 { return Err("chain: bank and pattern must be >= 1".into()); } if bank as usize == ctx.bank && pattern as usize == ctx.pattern { // chaining to self is a no-op } else { let key = format!("__chain_{}_{}__", ctx.bank, ctx.pattern); let val = format!("{bank}:{pattern}"); self.vars.lock().unwrap().insert(key, Value::Str(val, None)); } } Op::Loop => { let beats = stack.pop().ok_or("stack underflow")?.as_float()?; if ctx.tempo == 0.0 || ctx.speed == 0.0 { return Err("tempo and speed must be non-zero".into()); } let dur = beats * 60.0 / ctx.tempo / ctx.speed; cmd.set_param("fit".into(), Value::Float(dur, None)); 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; } 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, }; 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)?; } Op::Adsr => { let r = stack.pop().ok_or("stack underflow")?; let s = stack.pop().ok_or("stack underflow")?; let d = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; cmd.set_param("attack".into(), a); cmd.set_param("decay".into(), d); cmd.set_param("sustain".into(), s); cmd.set_param("release".into(), r); } Op::Ad => { let d = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; cmd.set_param("attack".into(), a); cmd.set_param("decay".into(), d); cmd.set_param("sustain".into(), Value::Int(0, None)); } Op::Apply => { let quot = stack.pop().ok_or("stack underflow")?; run_quotation(quot, stack, outputs, cmd)?; } Op::Ramp => { let curve = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let phase = (freq * ctx.beat).fract(); let phase = if phase < 0.0 { phase + 1.0 } else { phase }; let val = phase.powf(curve); stack.push(Value::Float(val, None)); } Op::Tri => { let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let phase = (freq * ctx.beat).fract(); let phase = if phase < 0.0 { phase + 1.0 } else { phase }; let val = 1.0 - (2.0 * phase - 1.0).abs(); stack.push(Value::Float(val, None)); } Op::Range => { let max = stack.pop().ok_or("stack underflow")?.as_float()?; let min = stack.pop().ok_or("stack underflow")?.as_float()?; let val = stack.pop().ok_or("stack underflow")?.as_float()?; stack.push(Value::Float(min + val * (max - min), None)); } Op::Perlin => { let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let val = perlin_noise_1d(freq * ctx.beat); stack.push(Value::Float(val, None)); } Op::ClearCmd => { cmd.clear(); } Op::EmitN => { let n = stack.pop().ok_or("stack underflow")?.as_int()?; if n < 0 { return Err("emit count must be >= 0".into()); } for _ in 0..n { emit_once(cmd, outputs)?; } } } pc += 1; } Ok(()) } } const TEMPO_SCALED_PARAMS: &[&str] = &[ "attack", "decay", "release", "lpa", "lpd", "lpr", "hpa", "hpd", "hpr", "bpa", "bpd", "bpr", "patt", "pdec", "prel", "fma", "fmd", "fmr", "glide", "verbdecay", "verbpredelay", "chorusdelay", "duration", ]; fn emit_output( sound: &str, params: &[(String, String)], step_duration: f64, nudge_secs: f64, outputs: &mut Vec, ) { let mut pairs = vec![("sound".into(), sound.to_string())]; pairs.extend(params.iter().cloned()); if nudge_secs > 0.0 { pairs.push(("delta".into(), nudge_secs.to_string())); } if !pairs.iter().any(|(k, _)| k == "dur") { pairs.push(("dur".into(), step_duration.to_string())); } if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") { let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0); pairs[idx].1 = (ratio * step_duration).to_string(); } else { pairs.push(("delaytime".into(), step_duration.to_string())); } for pair in &mut pairs { if TEMPO_SCALED_PARAMS.contains(&pair.0.as_str()) { if let Ok(val) = pair.1.parse::() { pair.1 = (val * step_duration).to_string(); } } } outputs.push(format_cmd(&pairs)); } fn perlin_grad(hash_input: i64) -> f64 { let mut h = (hash_input as u64) .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); h ^= h >> 33; h = h.wrapping_mul(0xff51afd7ed558ccd); h ^= h >> 33; // Convert to float in [-1, 1] range for varied gradients (h as i64 as f64) / (i64::MAX as f64) } fn perlin_noise_1d(x: f64) -> f64 { let x0 = x.floor() as i64; let t = x - x0 as f64; let s = t * t * (3.0 - 2.0 * t); let d0 = perlin_grad(x0) * t; let d1 = perlin_grad(x0 + 1) * (t - 1.0); (d0 + s * (d1 - d0)) * 0.5 + 0.5 } fn float_to_value(result: f64) -> Value { if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { Value::Int(result as i64, None) } else { Value::Float(result, None) } } fn lift_unary(val: Value, f: F) -> Result where F: Fn(f64) -> f64, { Ok(float_to_value(f(val.as_float()?))) } fn lift_unary_int(val: Value, f: F) -> Result where F: Fn(i64) -> i64, { Ok(Value::Int(f(val.as_int()?), None)) } fn lift_binary(a: Value, b: Value, f: F) -> Result where F: Fn(f64, f64) -> f64, { Ok(float_to_value(f(a.as_float()?, b.as_float()?))) } fn binary_op(stack: &mut Vec, f: F) -> Result<(), String> where F: Fn(f64, f64) -> f64 + Copy, { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; stack.push(lift_binary(a, b, f)?); Ok(()) } fn cmp_op(stack: &mut Vec, f: F) -> Result<(), String> where F: Fn(f64, f64) -> bool, { let b = stack.pop().ok_or("stack underflow")?; let a = stack.pop().ok_or("stack underflow")?; let result = if f(a.as_float()?, b.as_float()?) { 1 } else { 0 }; stack.push(Value::Int(result, None)); Ok(()) } fn format_cmd(pairs: &[(String, String)]) -> String { let parts: Vec = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); format!("/{}", parts.join("/")) }