diff --git a/Cargo.lock b/Cargo.lock
index f13fff1..d938b56 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -315,6 +315,7 @@ dependencies = [
"rust_decimal",
"rust_decimal_macros",
"rustc-hash",
+ "serde",
"serde_json",
"smallvec",
"smol_str",
diff --git a/Cargo.toml b/Cargo.toml
index 441b207..46bba34 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,9 @@
name = "dexpr"
version = "0.1.0"
edition = "2021"
+description = "Embeddable expression evaluator and bytecode VM"
+license = "MIT"
+exclude = ["editor/", "wasm/", "docs/", ".vscode/", "benches/", "scripts/", "CLAUDE.md", "flamegraph.svg", "profile.json.gz", "gen.js", "*.dexpr", "*.txt", "src/main.rs"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -35,6 +38,7 @@ serde_json = "1"
[dev-dependencies]
criterion = { version = "0.8.2", features = ["html_reports"] }
+serde = { version = "1", features = ["derive"] }
[[bench]]
name = "my_benchmark"
diff --git a/benches/my_benchmark.rs b/benches/my_benchmark.rs
index 26d6cc1..4c857f0 100644
--- a/benches/my_benchmark.rs
+++ b/benches/my_benchmark.rs
@@ -7,7 +7,7 @@ use rust_decimal_macros::dec;
pub fn criterion_benchmark(c: &mut Criterion) {
// 1. Parser Benchmark
c.bench_function("parser_long", |b| {
- let input = include_str!("../src/bench_long.dexpr");
+ let input = include_str!("../examples/bench_long.dexpr");
b.iter(|| {
let _ = parser::program(input).unwrap();
})
@@ -15,7 +15,7 @@ pub fn criterion_benchmark(c: &mut Criterion) {
// 2. Compiler Benchmark
c.bench_function("compiler_long", |b| {
- let input = include_str!("../src/bench_long.dexpr");
+ let input = include_str!("../examples/bench_long.dexpr");
let ast = parser::program(input).unwrap();
b.iter(|| {
let mut compiler = Compiler::new();
@@ -27,7 +27,7 @@ pub fn criterion_benchmark(c: &mut Criterion) {
// basic_long.dexpr benchmark
c.bench_function("vm_basic_long", |b| {
- let input = include_str!("../src/basic_long.dexpr");
+ let input = include_str!("../examples/basic_long.dexpr");
let ast = parser::program(input).unwrap();
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).unwrap();
@@ -40,7 +40,7 @@ pub fn criterion_benchmark(c: &mut Criterion) {
// Long code benchmark (using bench_long.dexpr)
c.bench_function("vm_long", |b| {
- let input = include_str!("../src/bench_long.dexpr");
+ let input = include_str!("../examples/bench_long.dexpr");
let ast = parser::program(input).unwrap();
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).unwrap();
diff --git a/docs/compiler.md b/docs/compiler.md
index eb68a1b..a7e4250 100644
--- a/docs/compiler.md
+++ b/docs/compiler.md
@@ -96,8 +96,9 @@ Parse ile birlikte pozisyon bilgisi de toplar ve `DebugInfo` üretir.
2. Sağ operandı register'a derle
3. Sonuç register'ı ayır
4. Uygun opcode'u emit et (Add, Sub, Mul, vs.)
-5. **Özel durum:** String + String → `Concat` kullanılır
-6. Operand register'ları serbest bırak
+5. Operand register'ları serbest bırak
+
+> **Not:** String birleştirme derleme zamanında ayırt edilmez. `Op::Add` her zaman `OpCodeByte::Add` emit eder; string birleştirme ve otomatik tip dönüşümü VM tarafından çalışma zamanında (runtime) ele alınır.
### UnaryOp (Tekli Operasyon)
1. Operandı register'a derle
diff --git a/docs/opcodes.md b/docs/opcodes.md
index e2584d5..fd041df 100644
--- a/docs/opcodes.md
+++ b/docs/opcodes.md
@@ -85,6 +85,25 @@ Bytecode komut setini (instruction set) tanımlar. Her opcode bir `u8` değerine
---
+## Built-in Fonksiyon ID'leri
+
+`default_fn` modülü, built-in fonksiyonlar için sabit ID'ler tanımlar. `CallExternal` opcode'u bu ID'leri kullanarak built-in fonksiyonları çağırır.
+
+| Sabit | ID | Fonksiyon |
+|-------|----|-----------|
+| `ABS` | `1` | Mutlak değer |
+| `MIN` | `2` | Minimum değer |
+| `MAX` | `3` | Maksimum değer |
+| `FLOOR` | `4` | Aşağı yuvarlama |
+| `CEIL` | `5` | Yukarı yuvarlama |
+| `ROUND` | `6` | Yuvarlama |
+| `SQRT` | `7` | Karekök |
+| `LEN` | `8` | Uzunluk |
+| `TO_STRING` | `9` | String'e dönüştür |
+| `TO_NUMBER` | `10` | Sayıya dönüştür |
+
+---
+
## Hızlı Lookup Tablosu
`LOOKUP[256]` statik dizisi, O(1) karmaşıklıkta byte-to-opcode dönüşümü sağlar. `from_byte(u8)` metodu bu tabloyu kullanır.
diff --git a/docs/vm.md b/docs/vm.md
index f2faf3e..d06119b 100644
--- a/docs/vm.md
+++ b/docs/vm.md
@@ -11,7 +11,9 @@ Register tabanlı sanal makine. Bytecode'u çalıştırır, 8 register ve global
| Dosya | İçerik |
|-------|--------|
| `vm/mod.rs` | Modül export'ları |
-| `vm/vm.rs` | Ana VM implementasyonu |
+| `vm/vm.rs` | Ana VM implementasyonu (core çalıştırma döngüsü) |
+| `vm/methods.rs` | Metod dispatch (String, StringList, NumberList, Object metodları) |
+| `vm/builtins.rs` | Built-in fonksiyon implementasyonları (abs, min, max, floor, ceil, round, sqrt, len, toString, toNumber) |
| `vm/error.rs` | Hata türleri (VMError) |
| `vm/debug_info.rs` | Bytecode offset → kaynak konum eşleştirme |
@@ -120,6 +122,7 @@ struct VM<'a> {
### Aritmetik
- **`binary_op(f, name)`** — Genel handler: iki operand register'ı oku, fonksiyonu uygula, sonucu kaydet
- Sıfıra bölme kontrolü yapılır
+- **`Add` opcode:** Sayısal toplama yanında string birleştirmeyi de destekler. Otomatik tip dönüşümü (auto-coercion) yapılır: String+String, String+Number, Number+String, String+Boolean kombinasyonları birleştirme olarak çalışır
- **`handle_neg()`** — Sadece Number tipinde tekli negatif
### Karşılaştırma
@@ -133,7 +136,7 @@ struct VM<'a> {
- **`handle_jump_if_false()`** — Register `Boolean(false)` ise atla
### String, Nesne ve Metodlar
-- **`handle_concat()`** — İki String register'ını birleştir
+- **`handle_concat()`** — İki register'ı birleştir (karışık tip dönüşümü destekler: String, Number, Boolean otomatik olarak String'e dönüştürülür)
- **`handle_get_property()`** — Object register'ından alan oku, alan yoksa `Null` döndür
- **`handle_set_property()`** — Object register'ında alan değerini ayarla
- **`handle_method_call()`** — Nesne register'ı, metod adı, argümanlar
@@ -153,6 +156,18 @@ struct VM<'a> {
### Built-in
- **`handle_log()`** — Register değerini stdout'a yazdır
- **`rand(min, max)`** — min ile max arasında rastgele tamsayı üret (varsayılan harici fonksiyon)
+- **`abs(n)`** — Mutlak değer
+- **`min(a, b, ...)`** — Verilen değerlerin minimumu
+- **`max(a, b, ...)`** — Verilen değerlerin maksimumu
+- **`floor(n)`** — Aşağı yuvarlama
+- **`ceil(n)`** — Yukarı yuvarlama
+- **`round(n[, places])`** — Yuvarlama (opsiyonel ondalık basamak sayısı)
+- **`sqrt(n)`** — Karekök
+- **`len(v)`** — Değerin uzunluğu (String, List, Object)
+- **`toString(v)`** — Değeri String'e dönüştür
+- **`toNumber(v)`** — Değeri Number'a dönüştür
+
+> **Not:** Built-in fonksiyon implementasyonları `vm/builtins.rs` dosyasında, sabit ID tanımları `src/opcodes.rs` içindeki `default_fn` modülündedir.
---
diff --git a/src/basic_long.dexpr b/examples/basic_long.dexpr
similarity index 100%
rename from src/basic_long.dexpr
rename to examples/basic_long.dexpr
diff --git a/src/bench_long.dexpr b/examples/bench_long.dexpr
similarity index 100%
rename from src/bench_long.dexpr
rename to examples/bench_long.dexpr
diff --git a/src/bench_sample.dexpr b/examples/bench_sample.dexpr
similarity index 100%
rename from src/bench_sample.dexpr
rename to examples/bench_sample.dexpr
diff --git a/src/bench_sample2.dexpr b/examples/bench_sample2.dexpr
similarity index 100%
rename from src/bench_sample2.dexpr
rename to examples/bench_sample2.dexpr
diff --git a/src/sample.dexpr b/examples/sample.dexpr
similarity index 100%
rename from src/sample.dexpr
rename to examples/sample.dexpr
diff --git a/src/sample_test.dexpr b/examples/sample_test.dexpr
similarity index 100%
rename from src/sample_test.dexpr
rename to examples/sample_test.dexpr
diff --git a/flamegraph.svg b/flamegraph.svg
deleted file mode 100644
index fb38284..0000000
--- a/flamegraph.svg
+++ /dev/null
@@ -1,491 +0,0 @@
-
\ No newline at end of file
diff --git a/gen.js b/gen.js
deleted file mode 100644
index 6f1c83b..0000000
--- a/gen.js
+++ /dev/null
@@ -1,74 +0,0 @@
-function randomBetween(min, max) {
- min = Math.round(min);
- max = Math.round(max);
- return Math.floor(Math.random() * (max - min + 1) + min);
-}
-
-function shuffleArray(array) {
- for (let i = array.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j], array[i]];
- }
-}
-
-function generateRandomArithmeticExpression() {
- const operators = ['+', '-', '*', '/']; // Array of arithmetic operators
- const maxDecimalPlaces = 5;
- const numOperands = randomBetween(10, 20);
- const numParentheses = randomBetween(4, 8);
-
- // Generate random numbers with two decimal places
- let nums = [];
- for (let i = 0; i < numOperands; i++) {
- nums.push((Math.random() * 100).toFixed(randomBetween(1, maxDecimalPlaces)));
- }
-
- let res = [];
-
- shuffleArray(nums);
-
- let numRemainingOperands = nums.length;
- let openParentheses = 0;
-
- for (let i = 0; i < nums.length; i++) {
- // Open a parenthesis if there are enough operands remaining
- if (openParentheses < numParentheses && numRemainingOperands > 1 && Math.random() < 0.5) {
- res.push('(');
- openParentheses++;
- }
-
- res.push(nums[i]);
-
- // Close a parenthesis if there are enough operands preceding it
- if (openParentheses > 0 && numRemainingOperands > 2 && Math.random() < 0.5) {
- res.push(')');
- openParentheses--;
- }
-
- if (i < nums.length - 1) {
- res.push(operators[randomBetween(0, 3)]);
- }
-
- numRemainingOperands--;
- }
-
- // Close any remaining open parentheses
- while (openParentheses > 0) {
- res.push(')');
- openParentheses--;
- }
-
- return res.join('');
-}
-
-let res = '';
-
-for (let i = 0; i < 100; i++) {
- let expr = generateRandomArithmeticExpression();
- let val = eval(expr);
-
- // res += `("${expr}", "${val}"),\n`;
- res += `"${expr}",\n`;
-}
-
-console.log(res);
diff --git a/justfile b/justfile
index 449a090..ddca66f 100644
--- a/justfile
+++ b/justfile
@@ -12,6 +12,12 @@ bench:
run:
cargo run --release
+# --- Publish ---
+
+# Publish to Gitea cargo registry
+publish:
+ cargo publish --registry gitea --allow-dirty
+
# --- WASM ---
# Build wasm package (web target)
diff --git a/profile.json.gz b/profile.json.gz
deleted file mode 100644
index 7f7cad8..0000000
Binary files a/profile.json.gz and /dev/null differ
diff --git a/src/bytecode_dump.rs b/src/bytecode_dump.rs
index 9a5a176..afe0ece 100644
--- a/src/bytecode_dump.rs
+++ b/src/bytecode_dump.rs
@@ -223,6 +223,7 @@ pub fn disassemble_bytecode(bytecode: &[u8]) -> Vec {
Ok(reg) => format!("{:04x}: SetResult r{}", start_position, reg),
Err(_) => format!("{:04x}: SetResult (truncated)", start_position),
},
+ OpCodeByte::ClearResult => format!("{:04x}: ClearResult", start_position),
OpCodeByte::End => format!("{:04x}: End", start_position),
};
diff --git a/src/compiler.rs b/src/compiler.rs
index 0b34712..585c826 100644
--- a/src/compiler.rs
+++ b/src/compiler.rs
@@ -170,6 +170,7 @@ impl Compiler {
let expr_reg = self.compile_expr(expr)?;
self.emit_store_global(name, expr_reg);
self.free_register(expr_reg);
+ self.emit_byte(OpCodeByte::ClearResult.to_byte());
Ok(())
}
@@ -229,6 +230,7 @@ impl Compiler {
// Store root back to global
self.emit_store_global(root, root_reg);
self.free_register(root_reg);
+ self.emit_byte(OpCodeByte::ClearResult.to_byte());
Ok(())
}
@@ -319,13 +321,7 @@ impl Compiler {
let result_reg = self.allocate_register()?;
let opcode = match op {
- Op::Add => {
- if self.is_string_concatenation(left, right) {
- OpCodeByte::Concat
- } else {
- OpCodeByte::Add
- }
- }
+ Op::Add => OpCodeByte::Add,
Op::Sub => OpCodeByte::Sub,
Op::Mul => OpCodeByte::Mul,
Op::Div => OpCodeByte::Div,
@@ -359,12 +355,6 @@ impl Compiler {
Ok(result_reg)
}
- /// Check if binary operation is string concatenation
- fn is_string_concatenation(&self, left: &Expr, right: &Expr) -> bool {
- matches!(left, Expr::Value(Value::String(_)))
- || matches!(right, Expr::Value(Value::String(_)))
- }
-
/// Compile a unary operation
fn compile_unary_op(&mut self, op: &Op, operand: &Expr) -> Result {
let operand_reg = self.compile_expr(operand)?;
diff --git a/src/main.rs b/src/main.rs
index 4ccf930..41ede8b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,7 @@ use dexpr::{ast::value::Value, compiler::Compiler, parser, vm::VM};
use rust_decimal_macros::dec;
fn main() -> Result<(), Box> {
- let input = include_str!("basic_long.dexpr");
+ let input = include_str!("../examples/basic_long.dexpr");
let ast = parser::program(input)?;
diff --git a/src/opcodes.rs b/src/opcodes.rs
index 27b184c..0a2e717 100644
--- a/src/opcodes.rs
+++ b/src/opcodes.rs
@@ -5,10 +5,31 @@ pub struct Register(pub u8);
/// Default (built-in) function IDs for CallDefault opcode
pub mod default_fn {
pub const RAND: u8 = 0;
- // Future: ABS = 1, MIN = 2, MAX = 3, FLOOR = 4, CEIL = 5, ROUND = 6, ...
+ pub const ABS: u8 = 1;
+ pub const MIN: u8 = 2;
+ pub const MAX: u8 = 3;
+ pub const FLOOR: u8 = 4;
+ pub const CEIL: u8 = 5;
+ pub const ROUND: u8 = 6;
+ pub const SQRT: u8 = 7;
+ pub const LEN: u8 = 8;
+ pub const TO_STRING: u8 = 9;
+ pub const TO_NUMBER: u8 = 10;
- /// Lookup table: function name → ID
- pub const NAMES: &[(&str, u8)] = &[("rand", RAND)];
+ /// Lookup table: function name ��� ID
+ pub const NAMES: &[(&str, u8)] = &[
+ ("rand", RAND),
+ ("abs", ABS),
+ ("min", MIN),
+ ("max", MAX),
+ ("floor", FLOOR),
+ ("ceil", CEIL),
+ ("round", ROUND),
+ ("sqrt", SQRT),
+ ("len", LEN),
+ ("toString", TO_STRING),
+ ("toNumber", TO_NUMBER),
+ ];
/// Get function name by ID
pub fn name(id: u8) -> Option<&'static str> {
@@ -77,7 +98,8 @@ pub enum OpCodeByte {
CallDefault = 0xA2, // Call default (built-in) function by ID
// Result
- SetResult = 0xB0, // Set expression result (for return value)
+ SetResult = 0xB0, // Set expression result (for return value)
+ ClearResult = 0xB1, // Clear expression result (assignment resets last result)
// End marker
End = 0xFF, // End of program
@@ -128,6 +150,7 @@ impl OpCodeByte {
0xA1 => Some(OpCodeByte::CallExternal),
0xA2 => Some(OpCodeByte::CallDefault),
0xB0 => Some(OpCodeByte::SetResult),
+ 0xB1 => Some(OpCodeByte::ClearResult),
0xFF => Some(OpCodeByte::End),
_ => None,
};
@@ -178,6 +201,7 @@ impl OpCodeByte {
OpCodeByte::CallExternal => "CallExternal",
OpCodeByte::CallDefault => "Rand",
OpCodeByte::SetResult => "SetResult",
+ OpCodeByte::ClearResult => "ClearResult",
OpCodeByte::End => "End",
}
}
diff --git a/src/sample_test_asm.txt b/src/sample_test_asm.txt
deleted file mode 100644
index 4a634e7..0000000
--- a/src/sample_test_asm.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-fib:
- push rbp
- movrr rbp, rsp
- movsr rbp, r1
- cmpsi rbp, 0
- jg .L0
- movri eax, 0
- ret
-.L0:
- cmpsi rbp, 2
- jg .L1
- movri eax, 1
- ret
-.L1:
- movrs r1, rbp
-
-
-
-main:
- add rsp, 16
- movsi 16(byte) rbp, 10
- movrs r1, rbp
- call fib
\ No newline at end of file
diff --git a/src/vm/builtins.rs b/src/vm/builtins.rs
new file mode 100644
index 0000000..d2e24b4
--- /dev/null
+++ b/src/vm/builtins.rs
@@ -0,0 +1,229 @@
+use crate::ast::value::Value;
+use crate::opcodes::default_fn;
+use rust_decimal::{prelude::ToPrimitive, Decimal, MathematicalOps};
+
+use super::error::VMError;
+use super::vm::VM;
+
+impl<'a> VM<'a> {
+ /// Dispatch a built-in (default) function call by ID
+ pub(super) fn dispatch_builtin(
+ &mut self,
+ dest: usize,
+ fn_id: u8,
+ arg_regs: &[usize],
+ ) -> Result<(), VMError> {
+ match fn_id {
+ default_fn::RAND => self.builtin_rand(dest, arg_regs),
+ default_fn::ABS => self.builtin_abs(dest, arg_regs),
+ default_fn::MIN => self.builtin_min(dest, arg_regs),
+ default_fn::MAX => self.builtin_max(dest, arg_regs),
+ default_fn::FLOOR => self.builtin_floor(dest, arg_regs),
+ default_fn::CEIL => self.builtin_ceil(dest, arg_regs),
+ default_fn::ROUND => self.builtin_round(dest, arg_regs),
+ default_fn::SQRT => self.builtin_sqrt(dest, arg_regs),
+ default_fn::LEN => self.builtin_len(dest, arg_regs),
+ default_fn::TO_STRING => self.builtin_to_string(dest, arg_regs),
+ default_fn::TO_NUMBER => self.builtin_to_number(dest, arg_regs),
+ _ => {
+ let name = default_fn::name(fn_id)
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| format!("unknown({})", fn_id));
+ Err(VMError::RuntimeError(format!(
+ "Unknown default function: {}",
+ name
+ )))
+ }
+ }
+ }
+
+ fn builtin_rand(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ use rand::RngExt;
+ if arg_regs.len() < 2 {
+ return Err(VMError::RuntimeError(
+ "rand() requires two arguments (min, max)".to_string(),
+ ));
+ }
+ match (&self.registers[arg_regs[0]], &self.registers[arg_regs[1]]) {
+ (Value::Number(min), Value::Number(max)) => {
+ let min_i64 = min.to_i64().ok_or_else(|| {
+ VMError::RuntimeError("rand() min must be an integer".to_string())
+ })?;
+ let max_i64 = max.to_i64().ok_or_else(|| {
+ VMError::RuntimeError("rand() max must be an integer".to_string())
+ })?;
+ if min_i64 > max_i64 {
+ return Err(VMError::RuntimeError(
+ "rand() min must be <= max".to_string(),
+ ));
+ }
+ let mut rng = rand::rng();
+ let result = rng.random_range(min_i64..=max_i64);
+ self.registers[dest] = Value::Number(Decimal::from(result));
+ Ok(())
+ }
+ _ => Err(VMError::RuntimeError(
+ "rand() requires number arguments".to_string(),
+ )),
+ }
+ }
+
+ fn builtin_abs(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("abs", arg_regs, 1)?;
+ let n = extract_number("abs", &self.registers[arg_regs[0]])?;
+ self.registers[dest] = Value::Number(n.abs());
+ Ok(())
+ }
+
+ fn builtin_min(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ if arg_regs.is_empty() {
+ return Err(VMError::RuntimeError(
+ "min() requires at least one argument".to_string(),
+ ));
+ }
+ let mut result = extract_number("min", &self.registers[arg_regs[0]])?;
+ for ® in &arg_regs[1..] {
+ let n = extract_number("min", &self.registers[reg])?;
+ if n < result {
+ result = n;
+ }
+ }
+ self.registers[dest] = Value::Number(result);
+ Ok(())
+ }
+
+ fn builtin_max(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ if arg_regs.is_empty() {
+ return Err(VMError::RuntimeError(
+ "max() requires at least one argument".to_string(),
+ ));
+ }
+ let mut result = extract_number("max", &self.registers[arg_regs[0]])?;
+ for ® in &arg_regs[1..] {
+ let n = extract_number("max", &self.registers[reg])?;
+ if n > result {
+ result = n;
+ }
+ }
+ self.registers[dest] = Value::Number(result);
+ Ok(())
+ }
+
+ fn builtin_floor(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("floor", arg_regs, 1)?;
+ let n = extract_number("floor", &self.registers[arg_regs[0]])?;
+ self.registers[dest] = Value::Number(n.floor());
+ Ok(())
+ }
+
+ fn builtin_ceil(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("ceil", arg_regs, 1)?;
+ let n = extract_number("ceil", &self.registers[arg_regs[0]])?;
+ self.registers[dest] = Value::Number(n.ceil());
+ Ok(())
+ }
+
+ fn builtin_round(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("round", arg_regs, 1)?;
+ let n = extract_number("round", &self.registers[arg_regs[0]])?;
+ // Optional second argument: decimal places (default 0)
+ let places = if arg_regs.len() > 1 {
+ extract_number("round", &self.registers[arg_regs[1]])?
+ .to_u32()
+ .unwrap_or(0)
+ } else {
+ 0
+ };
+ self.registers[dest] = Value::Number(n.round_dp(places));
+ Ok(())
+ }
+
+ fn builtin_sqrt(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("sqrt", arg_regs, 1)?;
+ let n = extract_number("sqrt", &self.registers[arg_regs[0]])?;
+ if n.is_sign_negative() {
+ return Err(VMError::RuntimeError(
+ "sqrt() argument must be non-negative".to_string(),
+ ));
+ }
+ let result = n.sqrt().ok_or_else(|| {
+ VMError::RuntimeError("sqrt() failed to compute".to_string())
+ })?;
+ self.registers[dest] = Value::Number(result);
+ Ok(())
+ }
+
+ fn builtin_len(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("len", arg_regs, 1)?;
+ let len = match &self.registers[arg_regs[0]] {
+ Value::String(s) => Decimal::from(s.len()),
+ Value::StringList(l) => Decimal::from(l.len()),
+ Value::NumberList(l) => Decimal::from(l.len()),
+ Value::Object(m) => Decimal::from(m.len()),
+ other => {
+ return Err(VMError::RuntimeError(format!(
+ "len() not supported for type {}",
+ other.type_name()
+ )));
+ }
+ };
+ self.registers[dest] = Value::Number(len);
+ Ok(())
+ }
+
+ fn builtin_to_string(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("toString", arg_regs, 1)?;
+ let s = super::vm::value_to_string(&self.registers[arg_regs[0]]);
+ self.registers[dest] = Value::String(s.into_owned().into());
+ Ok(())
+ }
+
+ fn builtin_to_number(&mut self, dest: usize, arg_regs: &[usize]) -> Result<(), VMError> {
+ require_args("toNumber", arg_regs, 1)?;
+ let result = match &self.registers[arg_regs[0]] {
+ Value::Number(n) => *n,
+ Value::String(s) => s.parse::().map_err(|_| {
+ VMError::RuntimeError(format!("toNumber() cannot parse '{}'", s))
+ })?,
+ Value::Boolean(b) => {
+ if *b {
+ Decimal::from(1)
+ } else {
+ Decimal::from(0)
+ }
+ }
+ other => {
+ return Err(VMError::RuntimeError(format!(
+ "toNumber() not supported for type {}",
+ other.type_name()
+ )));
+ }
+ };
+ self.registers[dest] = Value::Number(result);
+ Ok(())
+ }
+}
+
+/// Helper: check minimum argument count
+fn require_args(name: &str, arg_regs: &[usize], min: usize) -> Result<(), VMError> {
+ if arg_regs.len() < min {
+ Err(VMError::RuntimeError(format!(
+ "{}() requires at least {} argument(s)",
+ name, min
+ )))
+ } else {
+ Ok(())
+ }
+}
+
+/// Helper: extract a Decimal from a Value or return an error
+fn extract_number(name: &str, val: &Value) -> Result {
+ match val {
+ Value::Number(n) => Ok(*n),
+ other => Err(VMError::RuntimeError(format!(
+ "{}() requires a number argument, got {}",
+ name,
+ other.type_name()
+ ))),
+ }
+}
diff --git a/src/vm/methods.rs b/src/vm/methods.rs
new file mode 100644
index 0000000..495ad04
--- /dev/null
+++ b/src/vm/methods.rs
@@ -0,0 +1,608 @@
+use crate::ast::value::Value;
+use rust_decimal::{prelude::ToPrimitive, Decimal};
+use smol_str::{SmolStr, StrExt};
+
+use super::error::VMError;
+use super::vm::VM;
+
+impl<'a> VM<'a> {
+ /// Dispatch a method call on a value
+ pub(super) fn dispatch_method(
+ &mut self,
+ dest: usize,
+ obj: usize,
+ method: &SmolStr,
+ args: &[Value],
+ ) -> Result<(), VMError> {
+ match &self.registers[obj] {
+ Value::String(_) => self.dispatch_string_method(dest, obj, method, args),
+ Value::StringList(_) => self.dispatch_string_list_method(dest, obj, method, args),
+ Value::NumberList(_) => self.dispatch_number_list_method(dest, obj, method, args),
+ Value::Object(_) => self.dispatch_object_method(dest, obj, method, args),
+ _ => {
+ // Try external methods for any type
+ let obj_val = &self.registers[obj];
+ let type_name: SmolStr = obj_val.type_name().into();
+ let key = (type_name.clone(), method.clone());
+ if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
+ let result = ext_method(obj_val, args).map_err(VMError::RuntimeError)?;
+ self.registers[dest] = result;
+ Ok(())
+ } else {
+ Err(VMError::MethodNotFound {
+ type_name: obj_val.type_name(),
+ method: method.clone(),
+ })
+ }
+ }
+ }
+ }
+
+ fn dispatch_string_method(
+ &mut self,
+ dest: usize,
+ obj: usize,
+ method: &SmolStr,
+ args: &[Value],
+ ) -> Result<(), VMError> {
+ let s = match &self.registers[obj] {
+ Value::String(s) => s.clone(),
+ _ => unreachable!(),
+ };
+
+ match method.as_str() {
+ "upper" => {
+ self.registers[dest] = Value::String(s.to_uppercase_smolstr());
+ }
+ "lower" => {
+ self.registers[dest] = Value::String(s.to_lowercase_smolstr());
+ }
+ "trim" => {
+ self.registers[dest] = Value::String(SmolStr::new(s.trim()));
+ }
+ "trimStart" => {
+ self.registers[dest] = Value::String(SmolStr::new(s.trim_start()));
+ }
+ "trimEnd" => {
+ self.registers[dest] = Value::String(SmolStr::new(s.trim_end()));
+ }
+ "split" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "split() requires a delimiter argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(delim) => {
+ let parts: Vec = s.split(delim.as_str()).map(SmolStr::new).collect();
+ self.registers[dest] = Value::StringList(parts);
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "split() requires a string delimiter".to_string(),
+ ));
+ }
+ }
+ }
+ "replace" => {
+ if args.len() < 2 {
+ return Err(VMError::RuntimeError(
+ "replace() requires two arguments (old, new)".to_string(),
+ ));
+ }
+ match (&args[0], &args[1]) {
+ (Value::String(old), Value::String(new)) => {
+ let result = SmolStr::new(s.replace(old.as_str(), new.as_str()));
+ self.registers[dest] = Value::String(result);
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "replace() requires string arguments".to_string(),
+ ));
+ }
+ }
+ }
+ "startsWith" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "startsWith() requires a prefix argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(prefix) => {
+ self.registers[dest] = Value::Boolean(s.starts_with(prefix.as_str()));
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "startsWith() requires a string prefix".to_string(),
+ ));
+ }
+ }
+ }
+ "endsWith" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "endsWith() requires a suffix argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(suffix) => {
+ self.registers[dest] = Value::Boolean(s.ends_with(suffix.as_str()));
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "endsWith() requires a string suffix".to_string(),
+ ));
+ }
+ }
+ }
+ "contains" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "contains() requires a substring argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(substr) => {
+ self.registers[dest] = Value::Boolean(s.contains(substr.as_str()));
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "contains() requires a string substring".to_string(),
+ ));
+ }
+ }
+ }
+ "length" => {
+ self.registers[dest] = Value::Number(Decimal::from(s.len()));
+ }
+ "charAt" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "charAt() requires an index argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::Number(idx) => {
+ let index = idx.to_u64().unwrap_or(u64::MAX) as usize;
+ match s.chars().nth(index) {
+ Some(c) => {
+ self.registers[dest] = Value::String(SmolStr::new(c.to_string()));
+ }
+ None => {
+ self.registers[dest] = Value::Null;
+ }
+ }
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "charAt() requires a number index".to_string(),
+ ));
+ }
+ }
+ }
+ "substring" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "substring() requires at least a start index".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::Number(start_idx) => {
+ let start = start_idx.to_usize().unwrap_or(0);
+ let chars: Vec = s.chars().collect();
+ let end = if args.len() > 1 {
+ match &args[1] {
+ Value::Number(end_idx) => end_idx.to_usize().unwrap_or(chars.len()),
+ _ => chars.len(),
+ }
+ } else {
+ chars.len()
+ };
+
+ if start >= chars.len() || start >= end {
+ self.registers[dest] = Value::String(SmolStr::new(""));
+ } else {
+ let end = end.min(chars.len());
+ let result: String = chars[start..end].iter().collect();
+ self.registers[dest] = Value::String(SmolStr::new(result));
+ }
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "substring() requires a number start index".to_string(),
+ ));
+ }
+ }
+ }
+ _ => {
+ let key = (SmolStr::new_static("String"), method.clone());
+ if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
+ let obj_val = &self.registers[obj];
+ let result = ext_method(obj_val, args).map_err(VMError::RuntimeError)?;
+ self.registers[dest] = result;
+ } else {
+ return Err(VMError::MethodNotFound {
+ type_name: "String",
+ method: method.clone(),
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn dispatch_string_list_method(
+ &mut self,
+ dest: usize,
+ obj: usize,
+ method: &SmolStr,
+ args: &[Value],
+ ) -> Result<(), VMError> {
+ let list = match &self.registers[obj] {
+ Value::StringList(l) => l.clone(),
+ _ => unreachable!(),
+ };
+
+ match method.as_str() {
+ "length" | "len" => {
+ self.registers[dest] = Value::Number(Decimal::from(list.len()));
+ }
+ "isEmpty" => {
+ self.registers[dest] = Value::Boolean(list.is_empty());
+ }
+ "first" => {
+ self.registers[dest] = list
+ .first()
+ .map(|s| Value::String(s.clone()))
+ .unwrap_or(Value::Null);
+ }
+ "last" => {
+ self.registers[dest] = list
+ .last()
+ .map(|s| Value::String(s.clone()))
+ .unwrap_or(Value::Null);
+ }
+ "get" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("get() requires an index".to_string()));
+ }
+ match &args[0] {
+ Value::Number(idx) => {
+ let index = idx.to_usize().unwrap_or(usize::MAX);
+ self.registers[dest] = list
+ .get(index)
+ .map(|s| Value::String(s.clone()))
+ .unwrap_or(Value::Null);
+ }
+ _ => return Err(VMError::RuntimeError("get() requires a number index".to_string())),
+ }
+ }
+ "contains" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("contains() requires an argument".to_string()));
+ }
+ match &args[0] {
+ Value::String(s) => {
+ self.registers[dest] = Value::Boolean(list.contains(s));
+ }
+ _ => return Err(VMError::RuntimeError("contains() requires a string argument".to_string())),
+ }
+ }
+ "indexOf" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("indexOf() requires an argument".to_string()));
+ }
+ match &args[0] {
+ Value::String(s) => {
+ let idx = list.iter().position(|item| item == s);
+ self.registers[dest] = idx
+ .map(|i| Value::Number(Decimal::from(i)))
+ .unwrap_or(Value::Number(Decimal::from(-1)));
+ }
+ _ => return Err(VMError::RuntimeError("indexOf() requires a string argument".to_string())),
+ }
+ }
+ "slice" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("slice() requires at least a start index".to_string()));
+ }
+ match &args[0] {
+ Value::Number(start_idx) => {
+ let start = start_idx.to_usize().unwrap_or(0).min(list.len());
+ let end = if args.len() > 1 {
+ match &args[1] {
+ Value::Number(end_idx) => end_idx.to_usize().unwrap_or(list.len()).min(list.len()),
+ _ => list.len(),
+ }
+ } else {
+ list.len()
+ };
+ self.registers[dest] = Value::StringList(list[start..end].to_vec());
+ }
+ _ => return Err(VMError::RuntimeError("slice() requires a number index".to_string())),
+ }
+ }
+ "reverse" => {
+ let mut reversed = list;
+ reversed.reverse();
+ self.registers[dest] = Value::StringList(reversed);
+ }
+ "sort" => {
+ let mut sorted = list;
+ sorted.sort();
+ self.registers[dest] = Value::StringList(sorted);
+ }
+ "join" => {
+ let delim = if args.is_empty() {
+ ""
+ } else {
+ match &args[0] {
+ Value::String(s) => s.as_str(),
+ _ => return Err(VMError::RuntimeError("join() requires a string delimiter".to_string())),
+ }
+ };
+ let result: String = list.iter().map(|s| s.as_str()).collect::>().join(delim);
+ self.registers[dest] = Value::String(SmolStr::new(result));
+ }
+ _ => {
+ let key = (SmolStr::new_static("StringList"), method.clone());
+ if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
+ let obj_val = &self.registers[obj];
+ let result = ext_method(obj_val, args).map_err(VMError::RuntimeError)?;
+ self.registers[dest] = result;
+ } else {
+ return Err(VMError::MethodNotFound {
+ type_name: "StringList",
+ method: method.clone(),
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn dispatch_number_list_method(
+ &mut self,
+ dest: usize,
+ obj: usize,
+ method: &SmolStr,
+ args: &[Value],
+ ) -> Result<(), VMError> {
+ let list = match &self.registers[obj] {
+ Value::NumberList(l) => l.clone(),
+ _ => unreachable!(),
+ };
+
+ match method.as_str() {
+ "length" | "len" => {
+ self.registers[dest] = Value::Number(Decimal::from(list.len()));
+ }
+ "isEmpty" => {
+ self.registers[dest] = Value::Boolean(list.is_empty());
+ }
+ "first" => {
+ self.registers[dest] = list
+ .first()
+ .map(|n| Value::Number(*n))
+ .unwrap_or(Value::Null);
+ }
+ "last" => {
+ self.registers[dest] = list
+ .last()
+ .map(|n| Value::Number(*n))
+ .unwrap_or(Value::Null);
+ }
+ "get" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("get() requires an index".to_string()));
+ }
+ match &args[0] {
+ Value::Number(idx) => {
+ let index = idx.to_usize().unwrap_or(usize::MAX);
+ self.registers[dest] = list
+ .get(index)
+ .map(|n| Value::Number(*n))
+ .unwrap_or(Value::Null);
+ }
+ _ => return Err(VMError::RuntimeError("get() requires a number index".to_string())),
+ }
+ }
+ "contains" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("contains() requires an argument".to_string()));
+ }
+ match &args[0] {
+ Value::Number(n) => {
+ self.registers[dest] = Value::Boolean(list.contains(n));
+ }
+ _ => return Err(VMError::RuntimeError("contains() requires a number argument".to_string())),
+ }
+ }
+ "indexOf" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("indexOf() requires an argument".to_string()));
+ }
+ match &args[0] {
+ Value::Number(n) => {
+ let idx = list.iter().position(|item| item == n);
+ self.registers[dest] = idx
+ .map(|i| Value::Number(Decimal::from(i)))
+ .unwrap_or(Value::Number(Decimal::from(-1)));
+ }
+ _ => return Err(VMError::RuntimeError("indexOf() requires a number argument".to_string())),
+ }
+ }
+ "slice" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError("slice() requires at least a start index".to_string()));
+ }
+ match &args[0] {
+ Value::Number(start_idx) => {
+ let start = start_idx.to_usize().unwrap_or(0).min(list.len());
+ let end = if args.len() > 1 {
+ match &args[1] {
+ Value::Number(end_idx) => end_idx.to_usize().unwrap_or(list.len()).min(list.len()),
+ _ => list.len(),
+ }
+ } else {
+ list.len()
+ };
+ self.registers[dest] = Value::NumberList(list[start..end].to_vec());
+ }
+ _ => return Err(VMError::RuntimeError("slice() requires a number index".to_string())),
+ }
+ }
+ "reverse" => {
+ let mut reversed = list;
+ reversed.reverse();
+ self.registers[dest] = Value::NumberList(reversed);
+ }
+ "sort" => {
+ let mut sorted = list;
+ sorted.sort();
+ self.registers[dest] = Value::NumberList(sorted);
+ }
+ "sum" => {
+ let sum: Decimal = list.iter().sum();
+ self.registers[dest] = Value::Number(sum);
+ }
+ "avg" => {
+ if list.is_empty() {
+ self.registers[dest] = Value::Null;
+ } else {
+ let sum: Decimal = list.iter().sum();
+ let avg = sum / Decimal::from(list.len());
+ self.registers[dest] = Value::Number(avg);
+ }
+ }
+ "min" => {
+ self.registers[dest] = list
+ .iter()
+ .min()
+ .map(|n| Value::Number(*n))
+ .unwrap_or(Value::Null);
+ }
+ "max" => {
+ self.registers[dest] = list
+ .iter()
+ .max()
+ .map(|n| Value::Number(*n))
+ .unwrap_or(Value::Null);
+ }
+ _ => {
+ let key = (SmolStr::new_static("NumberList"), method.clone());
+ if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
+ let obj_val = &self.registers[obj];
+ let result = ext_method(obj_val, args).map_err(VMError::RuntimeError)?;
+ self.registers[dest] = result;
+ } else {
+ return Err(VMError::MethodNotFound {
+ type_name: "NumberList",
+ method: method.clone(),
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn dispatch_object_method(
+ &mut self,
+ dest: usize,
+ obj: usize,
+ method: &SmolStr,
+ args: &[Value],
+ ) -> Result<(), VMError> {
+ let map = match &self.registers[obj] {
+ Value::Object(m) => m.clone(),
+ _ => unreachable!(),
+ };
+
+ match method.as_str() {
+ "keys" => {
+ let keys: Vec = map.keys().cloned().collect();
+ self.registers[dest] = Value::StringList(keys);
+ }
+ "values" => {
+ let vals: Vec = map.values().cloned().collect();
+ if vals.is_empty() {
+ self.registers[dest] = Value::StringList(Vec::new());
+ } else if vals.iter().all(|v| matches!(v, Value::String(_))) {
+ let strings: Vec = vals
+ .into_iter()
+ .map(|v| match v {
+ Value::String(s) => s,
+ _ => unreachable!(),
+ })
+ .collect();
+ self.registers[dest] = Value::StringList(strings);
+ } else if vals.iter().all(|v| matches!(v, Value::Number(_))) {
+ let numbers: Vec = vals
+ .into_iter()
+ .map(|v| match v {
+ Value::Number(n) => n,
+ _ => unreachable!(),
+ })
+ .collect();
+ self.registers[dest] = Value::NumberList(numbers);
+ } else {
+ return Err(VMError::RuntimeError(
+ "values() only works when all values are the same type (String or Number)".to_string(),
+ ));
+ }
+ }
+ "length" | "len" => {
+ self.registers[dest] = Value::Number(Decimal::from(map.len()));
+ }
+ "contains" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "contains() requires a key argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(key) => {
+ self.registers[dest] = Value::Boolean(map.contains_key(key));
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "contains() requires a string key".to_string(),
+ ))
+ }
+ }
+ }
+ "get" => {
+ if args.is_empty() {
+ return Err(VMError::RuntimeError(
+ "get() requires a key argument".to_string(),
+ ));
+ }
+ match &args[0] {
+ Value::String(key) => {
+ self.registers[dest] = map.get(key).cloned().unwrap_or(Value::Null);
+ }
+ _ => {
+ return Err(VMError::RuntimeError(
+ "get() requires a string key".to_string(),
+ ))
+ }
+ }
+ }
+ _ => {
+ let key = (SmolStr::new_static("Object"), method.clone());
+ if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
+ let obj_val = &self.registers[obj];
+ let result = ext_method(obj_val, args).map_err(VMError::RuntimeError)?;
+ self.registers[dest] = result;
+ } else {
+ return Err(VMError::MethodNotFound {
+ type_name: "Object",
+ method: method.clone(),
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/src/vm/mod.rs b/src/vm/mod.rs
index c987283..c768592 100644
--- a/src/vm/mod.rs
+++ b/src/vm/mod.rs
@@ -1,5 +1,7 @@
+mod builtins;
mod debug_info;
pub mod error;
+mod methods;
mod vm;
pub use debug_info::DebugInfo;
diff --git a/src/vm/vm.rs b/src/vm/vm.rs
index 33f49b0..5268ac7 100644
--- a/src/vm/vm.rs
+++ b/src/vm/vm.rs
@@ -1,9 +1,9 @@
use crate::{ast::value::Value, bytecode::BytecodeReader, opcodes::OpCodeByte};
use bumpalo::Bump;
use micromap::Map;
-use rust_decimal::{prelude::ToPrimitive, Decimal, MathematicalOps};
+use rust_decimal::{Decimal, MathematicalOps};
use rustc_hash::FxHashMap;
-use smol_str::{SmolStr, StrExt};
+use smol_str::SmolStr;
/// Type alias for external (host) functions
pub type ExternalFn = Box Result>;
@@ -33,36 +33,27 @@ macro_rules! log_debug {
/// Virtual Machine for executing dExpr bytecode
pub struct VM<'a> {
- bytecode: &'a [u8], // Bytecode to execute
- reader: BytecodeReader<'a>, // Bytecode reader
- pc: usize, // Program counter
+ bytecode: &'a [u8],
+ pub(super) reader: BytecodeReader<'a>,
+ pc: usize,
- // Registers for computation
- registers: [Value; MAX_REGISTERS],
+ pub(super) registers: [Value; MAX_REGISTERS],
- // Global variables
globals: Map,
- // Last expression result (returned by execute)
last_result: Value,
- // External (host) functions — lazily allocated
- external_functions: Option>,
+ pub(super) external_functions: Option>,
- // External (host) methods per type — lazily allocated
- external_methods: Option>,
+ pub(super) external_methods: Option>,
- // Heap for complex data types
heap: Bump,
- // Debug info for error messages
debug_info: Option<&'a DebugInfo>,
- // Debug flag
#[cfg(debug_assertions)]
- debug: bool,
+ pub(super) debug: bool,
- // Profiling counts
#[cfg(debug_assertions)]
opcode_counts: [usize; 256],
}
@@ -195,7 +186,7 @@ impl<'a> VM<'a> {
OpCodeByte::StoreLocal => self.handle_store_local(),
OpCodeByte::LoadGlobal => self.handle_load_global(),
OpCodeByte::StoreGlobal => self.handle_store_global(),
- OpCodeByte::Add => self.binary_op(|a, b| Ok(a + b), "add"),
+ OpCodeByte::Add => self.handle_add(),
OpCodeByte::Sub => self.binary_op(|a, b| Ok(a - b), "subtract"),
OpCodeByte::Mul => self.binary_op(|a, b| Ok(a * b), "multiply"),
OpCodeByte::Div => self.binary_op(
@@ -240,6 +231,11 @@ impl<'a> VM<'a> {
OpCodeByte::CallExternal => self.handle_call_external(),
OpCodeByte::CallDefault => self.handle_call_default(),
OpCodeByte::SetResult => self.handle_set_result(),
+ OpCodeByte::ClearResult => {
+ self.last_result = Value::Null;
+ log_debug!(self, "ClearResult");
+ Ok(())
+ }
OpCodeByte::End => {
log_debug!(self, "End of program");
#[cfg(debug_assertions)]
@@ -464,6 +460,46 @@ impl<'a> VM<'a> {
Ok(())
}
+ // ============================================================================
+ // Opcode Handlers - Add (Number + String coercion)
+ // ============================================================================
+
+ /// Handle Add opcode - number addition or string concatenation with auto-coercion
+ #[inline]
+ fn handle_add(&mut self) -> Result<(), VMError> {
+ let dest = self.read_register_checked()?;
+ let a = self.read_register_checked()?;
+ let b = self.read_register_checked()?;
+
+ match (&self.registers[a], &self.registers[b]) {
+ (Value::Number(a_num), Value::Number(b_num)) => {
+ self.registers[dest] = Value::Number(*a_num + *b_num);
+ }
+ (Value::String(a_str), Value::String(b_str)) => {
+ let result = format!("{}{}", a_str, b_str);
+ self.registers[dest] = Value::String(result.into());
+ }
+ (Value::String(a_str), other) => {
+ let result = format!("{}{}", a_str, value_to_string(other));
+ self.registers[dest] = Value::String(result.into());
+ }
+ (other, Value::String(b_str)) => {
+ let result = format!("{}{}", value_to_string(other), b_str);
+ self.registers[dest] = Value::String(result.into());
+ }
+ (a_val, b_val) => {
+ return Err(VMError::InvalidOperation {
+ operation: "add",
+ left_type: a_val.type_name(),
+ right_type: b_val.type_name(),
+ });
+ }
+ }
+
+ log_debug!(self, "Add r{} = r{} + r{}", dest, a, b);
+ Ok(())
+ }
+
// ============================================================================
// Opcode Handlers - Boolean Operations
// ============================================================================
@@ -614,26 +650,19 @@ impl<'a> VM<'a> {
// Opcode Handlers - String Operations
// ============================================================================
- /// Handle Concat opcode - string concatenation
+ /// Handle Concat opcode - string concatenation with auto-coercion
#[inline]
fn handle_concat(&mut self) -> Result<(), VMError> {
let dest = self.read_register_checked()?;
let a = self.read_register_checked()?;
let b = self.read_register_checked()?;
- match (&self.registers[a], &self.registers[b]) {
- (Value::String(a_str), Value::String(b_str)) => {
- let result = format!("{}{}", a_str, b_str);
- self.registers[dest] = Value::String(result.into());
- }
- (a_val, b_val) => {
- return Err(VMError::InvalidOperation {
- operation: "concat",
- left_type: a_val.type_name(),
- right_type: b_val.type_name(),
- });
- }
- }
+ let result = format!(
+ "{}{}",
+ value_to_string(&self.registers[a]),
+ value_to_string(&self.registers[b])
+ );
+ self.registers[dest] = Value::String(result.into());
log_debug!(self, "Concat r{} = r{} + r{}", dest, a, b);
Ok(())
@@ -703,524 +732,7 @@ impl<'a> VM<'a> {
args.push(self.registers[reg].clone());
}
- // Dispatch method call
- match &self.registers[obj] {
- Value::String(s) => match method.as_str() {
- "upper" => {
- let result = s.to_uppercase_smolstr();
- self.registers[dest] = Value::String(result);
- }
- "lower" => {
- let result = s.to_lowercase_smolstr();
- self.registers[dest] = Value::String(result);
- }
- "trim" => {
- let result = SmolStr::new(s.trim());
- self.registers[dest] = Value::String(result);
- }
- "trimStart" => {
- let result = SmolStr::new(s.trim_start());
- self.registers[dest] = Value::String(result);
- }
- "trimEnd" => {
- let result = SmolStr::new(s.trim_end());
- self.registers[dest] = Value::String(result);
- }
- "split" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "split() requires a delimiter argument".to_string(),
- ));
- }
- match &args[0] {
- Value::String(delim) => {
- let parts: Vec = s.split(delim.as_str()).map(SmolStr::new).collect();
- self.registers[dest] = Value::StringList(parts);
- }
- _ => {
- return Err(VMError::RuntimeError(
- "split() requires a string delimiter".to_string(),
- ));
- }
- }
- }
- "replace" => {
- if args.len() < 2 {
- return Err(VMError::RuntimeError(
- "replace() requires two arguments (old, new)".to_string(),
- ));
- }
- match (&args[0], &args[1]) {
- (Value::String(old), Value::String(new)) => {
- let result = SmolStr::new(s.replace(old.as_str(), new.as_str()));
- self.registers[dest] = Value::String(result);
- }
- _ => {
- return Err(VMError::RuntimeError(
- "replace() requires string arguments".to_string(),
- ));
- }
- }
- }
- "startsWith" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "startsWith() requires a prefix argument".to_string(),
- ));
- }
- match &args[0] {
- Value::String(prefix) => {
- let result = s.starts_with(prefix.as_str());
- self.registers[dest] = Value::Boolean(result);
- }
- _ => {
- return Err(VMError::RuntimeError(
- "startsWith() requires a string prefix".to_string(),
- ));
- }
- }
- }
- "endsWith" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "endsWith() requires a suffix argument".to_string(),
- ));
- }
- match &args[0] {
- Value::String(suffix) => {
- let result = s.ends_with(suffix.as_str());
- self.registers[dest] = Value::Boolean(result);
- }
- _ => {
- return Err(VMError::RuntimeError(
- "endsWith() requires a string suffix".to_string(),
- ));
- }
- }
- }
- "contains" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "contains() requires a substring argument".to_string(),
- ));
- }
- match &args[0] {
- Value::String(substr) => {
- let result = s.contains(substr.as_str());
- self.registers[dest] = Value::Boolean(result);
- }
- _ => {
- return Err(VMError::RuntimeError(
- "contains() requires a string substring".to_string(),
- ));
- }
- }
- }
- "length" => {
- let len = Decimal::from(s.len());
- self.registers[dest] = Value::Number(len);
- }
- "charAt" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "charAt() requires an index argument".to_string(),
- ));
- }
- match &args[0] {
- Value::Number(idx) => {
- let index = idx.to_u64().unwrap_or(u64::MAX) as usize;
- match s.chars().nth(index) {
- Some(c) => {
- self.registers[dest] = Value::String(SmolStr::new(c.to_string()));
- }
- None => {
- self.registers[dest] = Value::Null;
- }
- }
- }
- _ => {
- return Err(VMError::RuntimeError(
- "charAt() requires a number index".to_string(),
- ));
- }
- }
- }
- "substring" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError(
- "substring() requires at least a start index".to_string(),
- ));
- }
- match &args[0] {
- Value::Number(start_idx) => {
- let start = start_idx.to_usize().unwrap_or(0);
- let chars: Vec = s.chars().collect();
- let end = if args.len() > 1 {
- match &args[1] {
- Value::Number(end_idx) => end_idx.to_usize().unwrap_or(chars.len()),
- _ => chars.len(),
- }
- } else {
- chars.len()
- };
-
- if start >= chars.len() || start >= end {
- self.registers[dest] = Value::String(SmolStr::new(""));
- } else {
- let end = end.min(chars.len());
- let result: String = chars[start..end].iter().collect();
- self.registers[dest] = Value::String(SmolStr::new(result));
- }
- }
- _ => {
- return Err(VMError::RuntimeError(
- "substring() requires a number start index".to_string(),
- ));
- }
- }
- }
- _ => {
- let key = (SmolStr::new_static("String"), method.clone());
- if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
- let obj_val = &self.registers[obj];
- let result = ext_method(obj_val, &args).map_err(VMError::RuntimeError)?;
- self.registers[dest] = result;
- } else {
- return Err(VMError::MethodNotFound {
- type_name: "String",
- method,
- });
- }
- }
- },
- Value::StringList(list) => match method.as_str() {
- "length" | "len" => {
- self.registers[dest] = Value::Number(Decimal::from(list.len()));
- }
- "isEmpty" => {
- self.registers[dest] = Value::Boolean(list.is_empty());
- }
- "first" => {
- self.registers[dest] = list
- .first()
- .map(|s| Value::String(s.clone()))
- .unwrap_or(Value::Null);
- }
- "last" => {
- self.registers[dest] = list
- .last()
- .map(|s| Value::String(s.clone()))
- .unwrap_or(Value::Null);
- }
- "get" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("get() requires an index".to_string()));
- }
- match &args[0] {
- Value::Number(idx) => {
- let index = idx.to_usize().unwrap_or(usize::MAX);
- self.registers[dest] = list
- .get(index)
- .map(|s| Value::String(s.clone()))
- .unwrap_or(Value::Null);
- }
- _ => return Err(VMError::RuntimeError("get() requires a number index".to_string())),
- }
- }
- "contains" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("contains() requires an argument".to_string()));
- }
- match &args[0] {
- Value::String(s) => {
- self.registers[dest] = Value::Boolean(list.contains(s));
- }
- _ => return Err(VMError::RuntimeError("contains() requires a string argument".to_string())),
- }
- }
- "indexOf" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("indexOf() requires an argument".to_string()));
- }
- match &args[0] {
- Value::String(s) => {
- let idx = list.iter().position(|item| item == s);
- self.registers[dest] = idx
- .map(|i| Value::Number(Decimal::from(i)))
- .unwrap_or(Value::Number(Decimal::from(-1)));
- }
- _ => return Err(VMError::RuntimeError("indexOf() requires a string argument".to_string())),
- }
- }
- "slice" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("slice() requires at least a start index".to_string()));
- }
- match &args[0] {
- Value::Number(start_idx) => {
- let start = start_idx.to_usize().unwrap_or(0).min(list.len());
- let end = if args.len() > 1 {
- match &args[1] {
- Value::Number(end_idx) => end_idx.to_usize().unwrap_or(list.len()).min(list.len()),
- _ => list.len(),
- }
- } else {
- list.len()
- };
- self.registers[dest] = Value::StringList(list[start..end].to_vec());
- }
- _ => return Err(VMError::RuntimeError("slice() requires a number index".to_string())),
- }
- }
- "reverse" => {
- let mut reversed = list.clone();
- reversed.reverse();
- self.registers[dest] = Value::StringList(reversed);
- }
- "sort" => {
- let mut sorted = list.clone();
- sorted.sort();
- self.registers[dest] = Value::StringList(sorted);
- }
- "join" => {
- let delim = if args.is_empty() {
- ""
- } else {
- match &args[0] {
- Value::String(s) => s.as_str(),
- _ => return Err(VMError::RuntimeError("join() requires a string delimiter".to_string())),
- }
- };
- let result: String = list.iter().map(|s| s.as_str()).collect::>().join(delim);
- self.registers[dest] = Value::String(SmolStr::new(result));
- }
- _ => {
- let key = (SmolStr::new_static("StringList"), method.clone());
- if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
- let obj_val = &self.registers[obj];
- let result = ext_method(obj_val, &args).map_err(VMError::RuntimeError)?;
- self.registers[dest] = result;
- } else {
- return Err(VMError::MethodNotFound {
- type_name: "StringList",
- method,
- });
- }
- }
- },
- Value::NumberList(list) => match method.as_str() {
- "length" | "len" => {
- self.registers[dest] = Value::Number(Decimal::from(list.len()));
- }
- "isEmpty" => {
- self.registers[dest] = Value::Boolean(list.is_empty());
- }
- "first" => {
- self.registers[dest] = list
- .first()
- .map(|n| Value::Number(*n))
- .unwrap_or(Value::Null);
- }
- "last" => {
- self.registers[dest] = list
- .last()
- .map(|n| Value::Number(*n))
- .unwrap_or(Value::Null);
- }
- "get" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("get() requires an index".to_string()));
- }
- match &args[0] {
- Value::Number(idx) => {
- let index = idx.to_usize().unwrap_or(usize::MAX);
- self.registers[dest] = list
- .get(index)
- .map(|n| Value::Number(*n))
- .unwrap_or(Value::Null);
- }
- _ => return Err(VMError::RuntimeError("get() requires a number index".to_string())),
- }
- }
- "contains" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("contains() requires an argument".to_string()));
- }
- match &args[0] {
- Value::Number(n) => {
- self.registers[dest] = Value::Boolean(list.contains(n));
- }
- _ => return Err(VMError::RuntimeError("contains() requires a number argument".to_string())),
- }
- }
- "indexOf" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("indexOf() requires an argument".to_string()));
- }
- match &args[0] {
- Value::Number(n) => {
- let idx = list.iter().position(|item| item == n);
- self.registers[dest] = idx
- .map(|i| Value::Number(Decimal::from(i)))
- .unwrap_or(Value::Number(Decimal::from(-1)));
- }
- _ => return Err(VMError::RuntimeError("indexOf() requires a number argument".to_string())),
- }
- }
- "slice" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("slice() requires at least a start index".to_string()));
- }
- match &args[0] {
- Value::Number(start_idx) => {
- let start = start_idx.to_usize().unwrap_or(0).min(list.len());
- let end = if args.len() > 1 {
- match &args[1] {
- Value::Number(end_idx) => end_idx.to_usize().unwrap_or(list.len()).min(list.len()),
- _ => list.len(),
- }
- } else {
- list.len()
- };
- self.registers[dest] = Value::NumberList(list[start..end].to_vec());
- }
- _ => return Err(VMError::RuntimeError("slice() requires a number index".to_string())),
- }
- }
- "reverse" => {
- let mut reversed = list.clone();
- reversed.reverse();
- self.registers[dest] = Value::NumberList(reversed);
- }
- "sort" => {
- let mut sorted = list.clone();
- sorted.sort();
- self.registers[dest] = Value::NumberList(sorted);
- }
- "sum" => {
- let sum: Decimal = list.iter().sum();
- self.registers[dest] = Value::Number(sum);
- }
- "avg" => {
- if list.is_empty() {
- self.registers[dest] = Value::Null;
- } else {
- let sum: Decimal = list.iter().sum();
- let avg = sum / Decimal::from(list.len());
- self.registers[dest] = Value::Number(avg);
- }
- }
- "min" => {
- self.registers[dest] = list
- .iter()
- .min()
- .map(|n| Value::Number(*n))
- .unwrap_or(Value::Null);
- }
- "max" => {
- self.registers[dest] = list
- .iter()
- .max()
- .map(|n| Value::Number(*n))
- .unwrap_or(Value::Null);
- }
- _ => {
- let key = (SmolStr::new_static("NumberList"), method.clone());
- if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
- let obj_val = &self.registers[obj];
- let result = ext_method(obj_val, &args).map_err(VMError::RuntimeError)?;
- self.registers[dest] = result;
- } else {
- return Err(VMError::MethodNotFound {
- type_name: "NumberList",
- method,
- });
- }
- }
- },
- Value::Object(map) => match method.as_str() {
- "keys" => {
- let keys: Vec = map.keys().cloned().collect();
- self.registers[dest] = Value::StringList(keys);
- }
- "values" => {
- // Returns a StringList if all values are strings, NumberList if all numbers, otherwise error
- let vals: Vec = map.values().cloned().collect();
- if vals.is_empty() {
- self.registers[dest] = Value::StringList(Vec::new());
- } else if vals.iter().all(|v| matches!(v, Value::String(_))) {
- let strings: Vec = vals.into_iter().map(|v| match v {
- Value::String(s) => s,
- _ => unreachable!(),
- }).collect();
- self.registers[dest] = Value::StringList(strings);
- } else if vals.iter().all(|v| matches!(v, Value::Number(_))) {
- let numbers: Vec = vals.into_iter().map(|v| match v {
- Value::Number(n) => n,
- _ => unreachable!(),
- }).collect();
- self.registers[dest] = Value::NumberList(numbers);
- } else {
- return Err(VMError::RuntimeError(
- "values() only works when all values are the same type (String or Number)".to_string(),
- ));
- }
- }
- "length" | "len" => {
- self.registers[dest] = Value::Number(Decimal::from(map.len()));
- }
- "contains" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("contains() requires a key argument".to_string()));
- }
- match &args[0] {
- Value::String(key) => {
- self.registers[dest] = Value::Boolean(map.contains_key(key));
- }
- _ => return Err(VMError::RuntimeError("contains() requires a string key".to_string())),
- }
- }
- "get" => {
- if args.is_empty() {
- return Err(VMError::RuntimeError("get() requires a key argument".to_string()));
- }
- match &args[0] {
- Value::String(key) => {
- self.registers[dest] = map.get(key).cloned().unwrap_or(Value::Null);
- }
- _ => return Err(VMError::RuntimeError("get() requires a string key".to_string())),
- }
- }
- _ => {
- let key = (SmolStr::new_static("Object"), method.clone());
- if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
- let obj_val = &self.registers[obj];
- let result = ext_method(obj_val, &args).map_err(VMError::RuntimeError)?;
- self.registers[dest] = result;
- } else {
- return Err(VMError::MethodNotFound {
- type_name: "Object",
- method,
- });
- }
- }
- },
- _ => {
- // Try external methods for any type
- let obj_val = &self.registers[obj];
- let type_name: SmolStr = obj_val.type_name().into();
- let key = (type_name.clone(), method.clone());
- if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
- let result = ext_method(obj_val, &args).map_err(VMError::RuntimeError)?;
- self.registers[dest] = result;
- } else {
- return Err(VMError::MethodNotFound {
- type_name: obj_val.type_name(),
- method,
- });
- }
- }
- }
+ self.dispatch_method(dest, obj, &method, &args)?;
log_debug!(self, "MethodCall r{} = r{}.{}(...)", dest, obj, method);
Ok(())
@@ -1232,8 +744,6 @@ impl<'a> VM<'a> {
/// Handle CallDefault opcode - call a default (built-in) function by ID
fn handle_call_default(&mut self) -> Result<(), VMError> {
- use crate::opcodes::default_fn;
-
let dest = self.read_register_checked()?;
let fn_id = self.reader.read_byte().map_err(VMError::BytecodeError)?;
let arg_count = self.reader.read_byte().map_err(VMError::BytecodeError)? as usize;
@@ -1243,37 +753,7 @@ impl<'a> VM<'a> {
arg_regs.push(self.read_register_checked()?);
}
- match fn_id {
- default_fn::RAND => {
- use rand::RngExt;
- if arg_regs.len() < 2 {
- return Err(VMError::RuntimeError("rand() requires two arguments (min, max)".to_string()));
- }
- match (&self.registers[arg_regs[0]], &self.registers[arg_regs[1]]) {
- (Value::Number(min), Value::Number(max)) => {
- let min_i64 = min.to_i64().ok_or_else(|| {
- VMError::RuntimeError("rand() min must be an integer".to_string())
- })?;
- let max_i64 = max.to_i64().ok_or_else(|| {
- VMError::RuntimeError("rand() max must be an integer".to_string())
- })?;
- if min_i64 > max_i64 {
- return Err(VMError::RuntimeError("rand() min must be <= max".to_string()));
- }
- let mut rng = rand::rng();
- let result = rng.random_range(min_i64..=max_i64);
- self.registers[dest] = Value::Number(Decimal::from(result));
- }
- _ => return Err(VMError::RuntimeError("rand() requires number arguments".to_string())),
- }
- }
- _ => {
- let name = default_fn::name(fn_id)
- .map(|s| s.to_string())
- .unwrap_or_else(|| format!("unknown({})", fn_id));
- return Err(VMError::RuntimeError(format!("Unknown default function: {}", name)));
- }
- }
+ self.dispatch_builtin(dest, fn_id, &arg_regs)?;
log_debug!(self, "CallDefault r{} = fn#{}(...)", dest, fn_id);
Ok(())
@@ -1415,3 +895,14 @@ impl<'a> VM<'a> {
Ok(())
}
}
+
+/// Convert a Value to its string representation for concatenation (no quotes around strings)
+pub(super) fn value_to_string(val: &Value) -> std::borrow::Cow<'_, str> {
+ match val {
+ Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
+ Value::Number(n) => std::borrow::Cow::Owned(n.to_string()),
+ Value::Boolean(b) => std::borrow::Cow::Borrowed(if *b { "true" } else { "false" }),
+ Value::Null => std::borrow::Cow::Borrowed("null"),
+ other => std::borrow::Cow::Owned(format!("{}", other)),
+ }
+}
diff --git a/tests/data_driven_tests.rs b/tests/data_driven_tests.rs
new file mode 100644
index 0000000..ef61a6f
--- /dev/null
+++ b/tests/data_driven_tests.rs
@@ -0,0 +1,143 @@
+use dexpr::{ast::value::Value, compiler::Compiler, parser, vm::VM};
+use indexmap::IndexMap;
+use rust_decimal::Decimal;
+use smol_str::SmolStr;
+use std::collections::HashMap;
+use std::str::FromStr;
+
+#[derive(serde::Deserialize)]
+struct TestCase {
+ name: String,
+ code: String,
+ #[serde(default)]
+ globals: HashMap,
+ expected: ValueDef,
+}
+
+#[derive(serde::Deserialize)]
+struct ValueDef {
+ #[serde(rename = "type")]
+ typ: String,
+ #[serde(default)]
+ value: Option,
+}
+
+fn value_def_to_value(def: &ValueDef) -> Value {
+ match def.typ.as_str() {
+ "null" => Value::Null,
+ "number" => {
+ let s = def.value.as_ref().unwrap().as_str().unwrap();
+ Value::Number(Decimal::from_str(s).unwrap())
+ }
+ "string" => {
+ let s = def.value.as_ref().unwrap().as_str().unwrap();
+ Value::String(s.into())
+ }
+ "boolean" => {
+ let b = def.value.as_ref().unwrap().as_bool().unwrap();
+ Value::Boolean(b)
+ }
+ "object" => {
+ fn json_obj_to_value(obj: &serde_json::Map) -> Value {
+ let mut map = IndexMap::new();
+ for (k, v) in obj {
+ let val = match v {
+ serde_json::Value::String(s) => {
+ if let Ok(d) = Decimal::from_str(s) {
+ Value::Number(d)
+ } else {
+ Value::String(SmolStr::from(s.as_str()))
+ }
+ }
+ serde_json::Value::Bool(b) => Value::Boolean(*b),
+ serde_json::Value::Object(nested) => json_obj_to_value(nested),
+ _ => Value::String(SmolStr::from(v.to_string())),
+ };
+ map.insert(SmolStr::from(k.as_str()), val);
+ }
+ Value::Object(map)
+ }
+ let obj = def.value.as_ref().unwrap().as_object().unwrap();
+ json_obj_to_value(obj)
+ }
+ other => panic!("Unknown type: {other}"),
+ }
+}
+
+#[test]
+fn test_all_cases() {
+ let json = include_str!("test_cases.json");
+ let cases: Vec =
+ serde_json::from_str(json).expect("Failed to parse test_cases.json");
+
+ let mut failures = Vec::new();
+ let total = cases.len();
+
+ for case in &cases {
+ let ast = match parser::program(&case.code) {
+ Ok(ast) => ast,
+ Err(e) => {
+ failures.push(format!(
+ "FAIL: {}\n code: {}\n error: parse failed: {e}",
+ case.name,
+ case.code.replace('\n', "\\n"),
+ ));
+ continue;
+ }
+ };
+
+ let mut compiler = Compiler::new();
+ let bytecode = match compiler.compile(ast) {
+ Ok(bc) => bc,
+ Err(e) => {
+ failures.push(format!(
+ "FAIL: {}\n code: {}\n error: compile failed: {e}",
+ case.name,
+ case.code.replace('\n', "\\n"),
+ ));
+ continue;
+ }
+ };
+
+ let mut vm = VM::new(&bytecode);
+
+ for (name, def) in &case.globals {
+ vm.set_global(name, value_def_to_value(def));
+ }
+
+ let result = match vm.execute() {
+ Ok(v) => v,
+ Err(e) => {
+ failures.push(format!(
+ "FAIL: {}\n code: {}\n error: execute failed: {e}",
+ case.name,
+ case.code.replace('\n', "\\n"),
+ ));
+ continue;
+ }
+ };
+
+ let expected = value_def_to_value(&case.expected);
+
+ if result != expected {
+ failures.push(format!(
+ "FAIL: {}\n code: {}\n expected: {:?}\n got: {:?}",
+ case.name,
+ case.code.replace('\n', "\\n"),
+ expected,
+ result
+ ));
+ }
+ }
+
+ if !failures.is_empty() {
+ panic!(
+ "\n{} / {} test cases failed:\n\n{}\n",
+ failures.len(),
+ total,
+ failures.join("\n\n")
+ );
+ }
+
+ eprintln!("All {total} test cases passed.");
+}
diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs
index 9b37de2..ecf275d 100644
--- a/tests/integration_tests.rs
+++ b/tests/integration_tests.rs
@@ -959,27 +959,19 @@ result = x % y"#;
}
#[test]
-fn test_error_location_type_mismatch() {
+fn test_string_number_auto_coercion() {
+ // string + number now auto-coerces to string concatenation
let code = r#"x = "hello"
y = 5
-result = x + y"#;
+x + y"#;
+ let ast = parser::program(code).unwrap();
let mut compiler = Compiler::new();
- let (bytecode, debug_info) = compiler
- .compile_from_source(code)
- .expect("Failed to compile");
-
+ let bytecode = compiler.compile(ast).unwrap();
let mut vm = VM::new(&bytecode);
- vm.set_debug_info(&debug_info);
+ let result = vm.execute().unwrap();
- let err = vm.execute().unwrap_err();
- let err_msg = err.to_string();
-
- assert!(
- err_msg.contains("line 3"),
- "Error should contain line 3, got: {}",
- err_msg
- );
+ assert_eq!(result, Value::String("hello5".into()));
}
#[test]
diff --git a/tests/test_cases.json b/tests/test_cases.json
new file mode 100644
index 0000000..e0df2f2
--- /dev/null
+++ b/tests/test_cases.json
@@ -0,0 +1,1288 @@
+[
+ {
+ "name": "single number literal",
+ "code": "42",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "single string literal",
+ "code": "\"hello\"",
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "single boolean literal",
+ "code": "true",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "false literal",
+ "code": "false",
+ "expected": { "type": "boolean", "value": false }
+ },
+
+ {
+ "name": "last result: only assignments returns null",
+ "code": "x = 10\ny = 20",
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "last result: bare expression at end",
+ "code": "x = 10\ny = 20\nx + y",
+ "expected": { "type": "number", "value": "30" }
+ },
+ {
+ "name": "last result: multiple bare expressions, last wins",
+ "code": "1\n2\n3",
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "last result: expression then assignment returns null",
+ "code": "42\nx = 10",
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "last result: assignment then bare variable",
+ "code": "x = 99\nx",
+ "expected": { "type": "number", "value": "99" }
+ },
+ {
+ "name": "last result: string expression at end",
+ "code": "x = \"hello\"\nx + \" world\"",
+ "expected": { "type": "string", "value": "hello world" }
+ },
+ {
+ "name": "last result: boolean expression at end",
+ "code": "x = 10\nx > 5",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "last result: if/else returns value",
+ "code": "if true then\n \"yes\"\nelse\n \"no\"\nend",
+ "expected": { "type": "string", "value": "yes" }
+ },
+ {
+ "name": "last result: if/else false branch",
+ "code": "if false then\n \"yes\"\nelse\n \"no\"\nend",
+ "expected": { "type": "string", "value": "no" }
+ },
+ {
+ "name": "last result: if/else with number",
+ "code": "x = 10\nif x > 5 then\n x * 2\nelse\n x * 3\nend",
+ "expected": { "type": "number", "value": "20" }
+ },
+ {
+ "name": "last result: nested if/else",
+ "code": "x = 2\nif x == 1 then\n \"one\"\nelse if x == 2 then\n \"two\"\nelse\n \"other\"\nend",
+ "expected": { "type": "string", "value": "two" }
+ },
+ {
+ "name": "last result: if/else followed by expression",
+ "code": "if true then\n x = 10\nelse\n x = 20\nend\nx + 5",
+ "expected": { "type": "number", "value": "15" }
+ },
+ {
+ "name": "last result: assignment after if/else clears result",
+ "code": "if true then\n 100\nend\ny = 5",
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "last result: complex multi-step, last expression wins",
+ "code": "a = 10\nb = 20\nc = a + b\nd = c * 2\nd - 1",
+ "expected": { "type": "number", "value": "59" }
+ },
+ {
+ "name": "last result: log does not affect last result",
+ "code": "log(\"hello\")\n42",
+ "expected": { "type": "number", "value": "42" }
+ },
+
+ {
+ "name": "arithmetic: addition",
+ "code": "10 + 20",
+ "expected": { "type": "number", "value": "30" }
+ },
+ {
+ "name": "arithmetic: subtraction",
+ "code": "50 - 17",
+ "expected": { "type": "number", "value": "33" }
+ },
+ {
+ "name": "arithmetic: multiplication",
+ "code": "6 * 7",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "arithmetic: division",
+ "code": "100 / 4",
+ "expected": { "type": "number", "value": "25" }
+ },
+ {
+ "name": "arithmetic: modulo",
+ "code": "10 % 3",
+ "expected": { "type": "number", "value": "1" }
+ },
+ {
+ "name": "arithmetic: power",
+ "code": "2 ** 10",
+ "expected": { "type": "number", "value": "1024" }
+ },
+ {
+ "name": "arithmetic: negative number",
+ "code": "-5 + 3",
+ "expected": { "type": "number", "value": "-2" }
+ },
+ {
+ "name": "arithmetic: decimal",
+ "code": "10.5 + 0.5",
+ "expected": { "type": "number", "value": "11.0" }
+ },
+ {
+ "name": "arithmetic: precedence mul before add",
+ "code": "2 + 3 * 4",
+ "expected": { "type": "number", "value": "14" }
+ },
+ {
+ "name": "arithmetic: parentheses override precedence",
+ "code": "(2 + 3) * 4",
+ "expected": { "type": "number", "value": "20" }
+ },
+ {
+ "name": "arithmetic: complex expression",
+ "code": "(3 + 3) * 2 / 3",
+ "expected": { "type": "number", "value": "4" }
+ },
+
+ {
+ "name": "comparison: greater than true",
+ "code": "10 > 5",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: greater than false",
+ "code": "3 > 5",
+ "expected": { "type": "boolean", "value": false }
+ },
+ {
+ "name": "comparison: less than",
+ "code": "3 < 5",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: greater equal",
+ "code": "5 >= 5",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: less equal",
+ "code": "5 <= 5",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: equal numbers",
+ "code": "42 == 42",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: not equal",
+ "code": "42 != 43",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "comparison: equal numbers exact",
+ "code": "100 == 100",
+ "expected": { "type": "boolean", "value": true }
+ },
+
+ {
+ "name": "logical: and true",
+ "code": "true && true",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "logical: and false",
+ "code": "true && false",
+ "expected": { "type": "boolean", "value": false }
+ },
+ {
+ "name": "logical: or",
+ "code": "false || true",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "logical: not",
+ "code": "!false",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "logical: complex",
+ "code": "2 > 1 && 2 >= 2 && !false",
+ "expected": { "type": "boolean", "value": true }
+ },
+
+ {
+ "name": "string: concatenation",
+ "code": "\"hello\" + \" world\"",
+ "expected": { "type": "string", "value": "hello world" }
+ },
+ {
+ "name": "string: upper",
+ "code": "\"hello\".upper()",
+ "expected": { "type": "string", "value": "HELLO" }
+ },
+ {
+ "name": "string: lower",
+ "code": "\"HELLO\".lower()",
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "string: trim",
+ "code": "\" hello \".trim()",
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "string: trimStart",
+ "code": "\" hello \".trimStart()",
+ "expected": { "type": "string", "value": "hello " }
+ },
+ {
+ "name": "string: trimEnd",
+ "code": "\" hello \".trimEnd()",
+ "expected": { "type": "string", "value": " hello" }
+ },
+ {
+ "name": "string: contains true",
+ "code": "\"hello world\".contains(\"world\")",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "string: contains false",
+ "code": "\"hello world\".contains(\"xyz\")",
+ "expected": { "type": "boolean", "value": false }
+ },
+ {
+ "name": "string: startsWith",
+ "code": "\"hello world\".startsWith(\"hello\")",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "string: endsWith",
+ "code": "\"hello world\".endsWith(\"world\")",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "string: replace",
+ "code": "\"hello world\".replace(\"world\", \"rust\")",
+ "expected": { "type": "string", "value": "hello rust" }
+ },
+ {
+ "name": "string: length method",
+ "code": "\"hello\".length()",
+ "expected": { "type": "number", "value": "5" }
+ },
+ {
+ "name": "string: charAt",
+ "code": "\"hello\".charAt(1)",
+ "expected": { "type": "string", "value": "e" }
+ },
+ {
+ "name": "string: substring",
+ "code": "\"hello world\".substring(0, 5)",
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "string: concat with method result",
+ "code": "\"Merhaba\" + \" duhan\".upper()",
+ "expected": { "type": "string", "value": "Merhaba DUHAN" }
+ },
+
+ {
+ "name": "in operator: string in string",
+ "code": "\"hello\" in \"hello world\"",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "in operator: string not in string",
+ "code": "\"xyz\" in \"hello world\"",
+ "expected": { "type": "boolean", "value": false }
+ },
+
+ {
+ "name": "compound: plus equals",
+ "code": "x = 10\nx += 5\nx",
+ "expected": { "type": "number", "value": "15" }
+ },
+ {
+ "name": "compound: minus equals",
+ "code": "x = 10\nx -= 3\nx",
+ "expected": { "type": "number", "value": "7" }
+ },
+ {
+ "name": "compound: times equals",
+ "code": "x = 10\nx *= 3\nx",
+ "expected": { "type": "number", "value": "30" }
+ },
+ {
+ "name": "compound: divide equals",
+ "code": "x = 10\nx /= 2\nx",
+ "expected": { "type": "number", "value": "5" }
+ },
+ {
+ "name": "compound: modulo equals",
+ "code": "x = 10\nx %= 3\nx",
+ "expected": { "type": "number", "value": "1" }
+ },
+
+ {
+ "name": "if/else: simple true",
+ "code": "if true then \"yes\" else \"no\" end",
+ "expected": { "type": "string", "value": "yes" }
+ },
+ {
+ "name": "if/else: simple false",
+ "code": "if false then \"yes\" else \"no\" end",
+ "expected": { "type": "string", "value": "no" }
+ },
+ {
+ "name": "if/else: else if chain",
+ "code": "x = 3\nif x == 1 then\n \"one\"\nelse if x == 2 then\n \"two\"\nelse if x == 3 then\n \"three\"\nelse\n \"other\"\nend",
+ "expected": { "type": "string", "value": "three" }
+ },
+ {
+ "name": "if/else: with computed condition",
+ "code": "score = 85\nif score >= 90 then\n \"A\"\nelse if score >= 80 then\n \"B\"\nelse if score >= 70 then\n \"C\"\nelse\n \"F\"\nend",
+ "expected": { "type": "string", "value": "B" }
+ },
+ {
+ "name": "if/else: nested if",
+ "code": "x = 5\ny = 10\nif x > 3 then\n if y > 8 then\n \"both\"\n else\n \"only x\"\n end\nelse\n \"neither\"\nend",
+ "expected": { "type": "string", "value": "both" }
+ },
+
+ {
+ "name": "comments: line comment ignored",
+ "code": "// this is a comment\n42",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "comments: block comment ignored",
+ "code": "/* block comment */ 42",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "comments: inline comment after expression",
+ "code": "10 + 20 // should be 30",
+ "expected": { "type": "number", "value": "30" }
+ },
+
+ {
+ "name": "split: basic split",
+ "code": "\"a,b,c\".split(\",\").length()",
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "split: join back",
+ "code": "\"a,b,c\".split(\",\").join(\"-\")",
+ "expected": { "type": "string", "value": "a-b-c" }
+ },
+ {
+ "name": "split: first element",
+ "code": "\"hello world\".split(\" \").first()",
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "split: last element",
+ "code": "\"hello world\".split(\" \").last()",
+ "expected": { "type": "string", "value": "world" }
+ },
+ {
+ "name": "split: contains",
+ "code": "\"a,b,c\".split(\",\").contains(\"b\")",
+ "expected": { "type": "boolean", "value": true }
+ },
+
+ {
+ "name": "last result: expression between assignments returns null",
+ "code": "x = 10\n42\ny = 20",
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "last result: compound assignment does not set result",
+ "code": "x = 10\nx += 5",
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "last result: if without else, true branch",
+ "code": "if true then\n 99\nend",
+ "expected": { "type": "number", "value": "99" }
+ },
+ {
+ "name": "last result: chained operations then result",
+ "code": "a = 1\nb = 2\nc = 3\na + b + c",
+ "expected": { "type": "number", "value": "6" }
+ },
+ {
+ "name": "last result: string method as last expr",
+ "code": "name = \"duhan\"\nname.upper()",
+ "expected": { "type": "string", "value": "DUHAN" }
+ },
+ {
+ "name": "last result: comparison as last expr",
+ "code": "x = 10\ny = 20\nx < y",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "last result: logical as last expr",
+ "code": "a = true\nb = false\na && !b",
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "last result: multiple if/else, last one wins",
+ "code": "if true then\n \"first\"\nend\nif true then\n \"second\"\nend",
+ "expected": { "type": "string", "value": "second" }
+ },
+ {
+ "name": "last result: complex formula",
+ "code": "base = 1000\nrate = 18\nbase * rate / 100",
+ "expected": { "type": "number", "value": "180" }
+ },
+
+ {
+ "name": "globals: simple number",
+ "code": "x * 2",
+ "globals": { "x": { "type": "number", "value": "21" } },
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "globals: string global method",
+ "code": "name.upper()",
+ "globals": {
+ "name": { "type": "string", "value": "duhan" }
+ },
+ "expected": { "type": "string", "value": "DUHAN" }
+ },
+ {
+ "name": "globals: formula with external values",
+ "code": "toplamTutar * kdv / 100",
+ "globals": {
+ "toplamTutar": { "type": "number", "value": "1000" },
+ "kdv": { "type": "number", "value": "18" }
+ },
+ "expected": { "type": "number", "value": "180" }
+ },
+ {
+ "name": "globals: boolean global in condition",
+ "code": "if active then \"yes\" else \"no\" end",
+ "globals": { "active": { "type": "boolean", "value": true } },
+ "expected": { "type": "string", "value": "yes" }
+ },
+ {
+ "name": "globals: override global with assignment",
+ "code": "x = x + 10\nx",
+ "globals": { "x": { "type": "number", "value": "5" } },
+ "expected": { "type": "number", "value": "15" }
+ },
+ {
+ "name": "globals: compound assignment on global",
+ "code": "x += 100\nx",
+ "globals": { "x": { "type": "number", "value": "50" } },
+ "expected": { "type": "number", "value": "150" }
+ },
+ {
+ "name": "globals: string method on global",
+ "code": "name.upper()",
+ "globals": { "name": { "type": "string", "value": "duhan" } },
+ "expected": { "type": "string", "value": "DUHAN" }
+ },
+ {
+ "name": "globals: comparison with global",
+ "code": "if price > 100 then\n price * discount / 100\nelse\n 0\nend",
+ "globals": {
+ "price": { "type": "number", "value": "200" },
+ "discount": { "type": "number", "value": "10" }
+ },
+ "expected": { "type": "number", "value": "20" }
+ },
+ {
+ "name": "globals: multiple globals in complex expression",
+ "code": "subtotal = quantity * unitPrice\ntax = subtotal * taxRate / 100\nsubtotal + tax",
+ "globals": {
+ "quantity": { "type": "number", "value": "5" },
+ "unitPrice": { "type": "number", "value": "100" },
+ "taxRate": { "type": "number", "value": "18" }
+ },
+ "expected": { "type": "number", "value": "590" }
+ },
+ {
+ "name": "globals: in operator with global string",
+ "code": "keyword in text",
+ "globals": {
+ "keyword": { "type": "string", "value": "finans" },
+ "text": { "type": "string", "value": "bu bir finans raporu" }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: nested if with number global",
+ "code": "if level == 1 then\n \"low\"\nelse if level == 2 then\n \"mid\"\nelse\n \"high\"\nend",
+ "globals": { "level": { "type": "number", "value": "2" } },
+ "expected": { "type": "string", "value": "mid" }
+ },
+ {
+ "name": "globals: last result with globals, assignment only returns null",
+ "code": "result = x + y",
+ "globals": {
+ "x": { "type": "number", "value": "10" },
+ "y": { "type": "number", "value": "20" }
+ },
+ "expected": { "type": "null" }
+ },
+ {
+ "name": "globals: last result with globals, expression returns value",
+ "code": "result = x + y\nresult",
+ "globals": {
+ "x": { "type": "number", "value": "10" },
+ "y": { "type": "number", "value": "20" }
+ },
+ "expected": { "type": "number", "value": "30" }
+ },
+ {
+ "name": "globals: pricing formula",
+ "code": "base = basePrice * (1 + margin / 100)\nif base > maxPrice then\n maxPrice\nelse\n base\nend",
+ "globals": {
+ "basePrice": { "type": "number", "value": "100" },
+ "margin": { "type": "number", "value": "20" },
+ "maxPrice": { "type": "number", "value": "150" }
+ },
+ "expected": { "type": "number", "value": "120" }
+ },
+ {
+ "name": "globals: pricing formula exceeds max",
+ "code": "base = basePrice * (1 + margin / 100)\nif base > maxPrice then\n maxPrice\nelse\n base\nend",
+ "globals": {
+ "basePrice": { "type": "number", "value": "200" },
+ "margin": { "type": "number", "value": "50" },
+ "maxPrice": { "type": "number", "value": "250" }
+ },
+ "expected": { "type": "number", "value": "250" }
+ },
+ {
+ "name": "globals: object property access",
+ "code": "user.name",
+ "globals": {
+ "user": { "type": "object", "value": { "name": "Duhan", "age": "30" } }
+ },
+ "expected": { "type": "string", "value": "Duhan" }
+ },
+ {
+ "name": "globals: object in condition",
+ "code": "if user.age > 18 then \"adult\" else \"minor\" end",
+ "globals": {
+ "user": { "type": "object", "value": { "name": "Duhan", "age": "30" } }
+ },
+ "expected": { "type": "string", "value": "adult" }
+ },
+ {
+ "name": "globals: nested object property access",
+ "code": "order.customer.name",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "id": "1001",
+ "customer": {
+ "name": "Duhan",
+ "email": "duhan@test.com"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "Duhan" }
+ },
+ {
+ "name": "globals: deeply nested object",
+ "code": "config.db.connection.port",
+ "globals": {
+ "config": {
+ "type": "object",
+ "value": {
+ "db": {
+ "connection": {
+ "host": "localhost",
+ "port": "5432"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "5432" }
+ },
+ {
+ "name": "globals: nested object in formula",
+ "code": "item.price * item.quantity",
+ "globals": {
+ "item": {
+ "type": "object",
+ "value": {
+ "name": "Widget",
+ "price": "25",
+ "quantity": "4"
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "100" }
+ },
+ {
+ "name": "globals: nested object in condition",
+ "code": "if order.payment.method == 1 then\n order.total * 0.95\nelse\n order.total\nend",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "total": "200",
+ "payment": {
+ "method": "1",
+ "status": "paid"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "190.00" }
+ },
+ {
+ "name": "globals: multiple nested objects",
+ "code": "if customer.tier == 1 then\n product.price * 0.9\nelse\n product.price\nend",
+ "globals": {
+ "customer": {
+ "type": "object",
+ "value": { "name": "Duhan", "tier": "1" }
+ },
+ "product": {
+ "type": "object",
+ "value": { "name": "Laptop", "price": "1000" }
+ }
+ },
+ "expected": { "type": "number", "value": "900.0" }
+ },
+ {
+ "name": "globals: nested object with string method",
+ "code": "user.email.upper()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": {
+ "name": "Duhan",
+ "email": "duhan@test.com"
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "DUHAN@TEST.COM" }
+ },
+ {
+ "name": "globals: object keys method",
+ "code": "user.keys().length()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": { "name": "Duhan", "age": "30", "city": "Istanbul" }
+ }
+ },
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "globals: object contains key",
+ "code": "user.contains(\"email\")",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": { "name": "Duhan", "email": "duhan@test.com" }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: object contains missing key",
+ "code": "user.contains(\"phone\")",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": { "name": "Duhan", "email": "duhan@test.com" }
+ }
+ },
+ "expected": { "type": "boolean", "value": false }
+ },
+ {
+ "name": "globals: in operator with object",
+ "code": "\"name\" in user",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": { "name": "Duhan", "age": "30" }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: nested object assign to field",
+ "code": "order.status = \"shipped\"\norder.status",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": { "id": "1001", "status": "pending" }
+ }
+ },
+ "expected": { "type": "string", "value": "shipped" }
+ },
+ {
+ "name": "globals: complex pricing with nested objects",
+ "code": "basePrice = product.price * order.quantity\ndiscountRate = customer.discount\nfinalPrice = basePrice * (1 - discountRate / 100)\nfinalPrice",
+ "globals": {
+ "product": {
+ "type": "object",
+ "value": { "name": "Widget", "price": "50" }
+ },
+ "order": {
+ "type": "object",
+ "value": { "quantity": "10", "urgent": "false" }
+ },
+ "customer": {
+ "type": "object",
+ "value": { "name": "Acme Corp", "discount": "15" }
+ }
+ },
+ "expected": { "type": "number", "value": "425.0" }
+ },
+
+ {
+ "name": "globals: deep path + string method",
+ "code": "order.customer.name.upper()",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "id": "1001",
+ "customer": {
+ "name": "duhan",
+ "email": "duhan@test.com"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "DUHAN" }
+ },
+ {
+ "name": "globals: deep path + lower method",
+ "code": "config.app.title.lower()",
+ "globals": {
+ "config": {
+ "type": "object",
+ "value": {
+ "app": {
+ "title": "MY APPLICATION",
+ "version": "1"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "my application" }
+ },
+ {
+ "name": "globals: deep path + trim method",
+ "code": "data.record.value.trim()",
+ "globals": {
+ "data": {
+ "type": "object",
+ "value": {
+ "record": {
+ "value": " hello "
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "hello" }
+ },
+ {
+ "name": "globals: deep path + contains method",
+ "code": "order.customer.email.contains(\"@\")",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "id": "1001",
+ "customer": {
+ "name": "Duhan",
+ "email": "duhan@test.com"
+ }
+ }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: deep path + startsWith method",
+ "code": "company.address.city.startsWith(\"Ist\")",
+ "globals": {
+ "company": {
+ "type": "object",
+ "value": {
+ "name": "Acme",
+ "address": {
+ "city": "Istanbul",
+ "country": "TR"
+ }
+ }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: deep path + replace method",
+ "code": "config.db.host.replace(\"localhost\", \"production.db\")",
+ "globals": {
+ "config": {
+ "type": "object",
+ "value": {
+ "db": {
+ "host": "localhost",
+ "port": "5432"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "production.db" }
+ },
+ {
+ "name": "globals: deep path + split + first",
+ "code": "user.profile.fullName.split(\" \").first()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": {
+ "id": "42",
+ "profile": {
+ "fullName": "Duhan Balci",
+ "age": "30"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "Duhan" }
+ },
+ {
+ "name": "globals: deep path + split + last",
+ "code": "user.profile.fullName.split(\" \").last()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": {
+ "id": "42",
+ "profile": {
+ "fullName": "Duhan Balci",
+ "age": "30"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "Balci" }
+ },
+ {
+ "name": "globals: deep path + split + join chain",
+ "code": "data.csv.row.split(\",\").join(\" | \")",
+ "globals": {
+ "data": {
+ "type": "object",
+ "value": {
+ "csv": {
+ "row": "a,b,c,d"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "a | b | c | d" }
+ },
+ {
+ "name": "globals: deep path + split + length",
+ "code": "data.csv.row.split(\",\").length()",
+ "globals": {
+ "data": {
+ "type": "object",
+ "value": {
+ "csv": {
+ "row": "a,b,c,d"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "4" }
+ },
+ {
+ "name": "globals: deep path + split + contains",
+ "code": "data.tags.raw.split(\",\").contains(\"finans\")",
+ "globals": {
+ "data": {
+ "type": "object",
+ "value": {
+ "tags": {
+ "raw": "tech,finans,health"
+ }
+ }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: deep path + substring method",
+ "code": "order.ref.code.substring(0, 3)",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "ref": {
+ "code": "ORD-12345"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "ORD" }
+ },
+ {
+ "name": "globals: deep path + charAt method",
+ "code": "config.app.code.charAt(0)",
+ "globals": {
+ "config": {
+ "type": "object",
+ "value": {
+ "app": {
+ "code": "XYZ",
+ "version": "1"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "X" }
+ },
+ {
+ "name": "globals: deep path + length method on string",
+ "code": "user.profile.bio.length()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": {
+ "profile": {
+ "bio": "hello world"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "11" }
+ },
+ {
+ "name": "globals: deep path arithmetic",
+ "code": "invoice.line.qty * invoice.line.unitPrice",
+ "globals": {
+ "invoice": {
+ "type": "object",
+ "value": {
+ "id": "INV-001",
+ "line": {
+ "product": "Widget",
+ "qty": "10",
+ "unitPrice": "25"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "250" }
+ },
+ {
+ "name": "globals: deep path in condition",
+ "code": "if order.customer.tier == 1 then\n order.total * 0.9\nelse\n order.total\nend",
+ "globals": {
+ "order": {
+ "type": "object",
+ "value": {
+ "total": "500",
+ "customer": {
+ "name": "Acme",
+ "tier": "1"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "450.0" }
+ },
+ {
+ "name": "globals: deep path + upper in concat",
+ "code": "\"Dear \" + user.info.name.upper()",
+ "globals": {
+ "user": {
+ "type": "object",
+ "value": {
+ "info": {
+ "name": "duhan",
+ "role": "admin"
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "Dear DUHAN" }
+ },
+ {
+ "name": "globals: 4-level deep path",
+ "code": "a.b.c.d",
+ "globals": {
+ "a": {
+ "type": "object",
+ "value": {
+ "b": {
+ "c": {
+ "d": "42"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "globals: 4-level deep path + method",
+ "code": "a.b.c.d.upper()",
+ "globals": {
+ "a": {
+ "type": "object",
+ "value": {
+ "b": {
+ "c": {
+ "d": "hello"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "string", "value": "HELLO" }
+ },
+ {
+ "name": "globals: deep path object keys",
+ "code": "org.department.team.keys().length()",
+ "globals": {
+ "org": {
+ "type": "object",
+ "value": {
+ "department": {
+ "team": {
+ "lead": "Ali",
+ "dev1": "Veli",
+ "dev2": "Ayse"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "globals: deep path object contains",
+ "code": "org.department.team.contains(\"lead\")",
+ "globals": {
+ "org": {
+ "type": "object",
+ "value": {
+ "department": {
+ "team": {
+ "lead": "Ali",
+ "dev1": "Veli"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: deep path + in operator on nested object",
+ "code": "\"dev1\" in org.department.team",
+ "globals": {
+ "org": {
+ "type": "object",
+ "value": {
+ "department": {
+ "team": {
+ "lead": "Ali",
+ "dev1": "Veli"
+ }
+ }
+ }
+ }
+ },
+ "expected": { "type": "boolean", "value": true }
+ },
+ {
+ "name": "globals: multiple deep paths from different roots",
+ "code": "customer.address.city.upper().length() + order.shipping.cost",
+ "globals": {
+ "customer": {
+ "type": "object",
+ "value": {
+ "address": {
+ "city": "istanbul"
+ }
+ }
+ },
+ "order": {
+ "type": "object",
+ "value": {
+ "shipping": {
+ "cost": "50",
+ "method": "express"
+ }
+ }
+ }
+ },
+ "expected": { "type": "number", "value": "58" }
+ },
+
+ {
+ "name": "string concat: variable + variable",
+ "code": "a = \"hello\"\nb = \" world\"\na + b",
+ "expected": { "type": "string", "value": "hello world" }
+ },
+ {
+ "name": "string concat: string + number coercion",
+ "code": "\"count: \" + 42",
+ "expected": { "type": "string", "value": "count: 42" }
+ },
+ {
+ "name": "string concat: number + string coercion",
+ "code": "100 + \" items\"",
+ "expected": { "type": "string", "value": "100 items" }
+ },
+ {
+ "name": "string concat: string + boolean coercion",
+ "code": "\"active: \" + true",
+ "expected": { "type": "string", "value": "active: true" }
+ },
+ {
+ "name": "string concat: global string variables",
+ "code": "first + \" \" + last",
+ "globals": {
+ "first": { "type": "string", "value": "Duhan" },
+ "last": { "type": "string", "value": "Balci" }
+ },
+ "expected": { "type": "string", "value": "Duhan Balci" }
+ },
+ {
+ "name": "string concat: string + decimal coercion",
+ "code": "\"price: \" + 19.99",
+ "expected": { "type": "string", "value": "price: 19.99" }
+ },
+
+ {
+ "name": "builtin: abs positive",
+ "code": "abs(42)",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "builtin: abs negative",
+ "code": "abs(-42)",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "builtin: min two args",
+ "code": "min(10, 3)",
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "builtin: min three args",
+ "code": "min(10, 3, 7)",
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "builtin: max two args",
+ "code": "max(10, 3)",
+ "expected": { "type": "number", "value": "10" }
+ },
+ {
+ "name": "builtin: max three args",
+ "code": "max(10, 3, 7)",
+ "expected": { "type": "number", "value": "10" }
+ },
+ {
+ "name": "builtin: floor",
+ "code": "floor(3.7)",
+ "expected": { "type": "number", "value": "3" }
+ },
+ {
+ "name": "builtin: floor negative",
+ "code": "floor(-2.3)",
+ "expected": { "type": "number", "value": "-3" }
+ },
+ {
+ "name": "builtin: ceil",
+ "code": "ceil(3.2)",
+ "expected": { "type": "number", "value": "4" }
+ },
+ {
+ "name": "builtin: ceil negative",
+ "code": "ceil(-2.7)",
+ "expected": { "type": "number", "value": "-2" }
+ },
+ {
+ "name": "builtin: round default",
+ "code": "round(3.5)",
+ "expected": { "type": "number", "value": "4" }
+ },
+ {
+ "name": "builtin: round with decimal places",
+ "code": "round(3.14159, 2)",
+ "expected": { "type": "number", "value": "3.14" }
+ },
+ {
+ "name": "builtin: sqrt",
+ "code": "sqrt(16)",
+ "expected": { "type": "number", "value": "4" }
+ },
+ {
+ "name": "builtin: len string",
+ "code": "len(\"hello\")",
+ "expected": { "type": "number", "value": "5" }
+ },
+ {
+ "name": "builtin: toString number",
+ "code": "toString(42)",
+ "expected": { "type": "string", "value": "42" }
+ },
+ {
+ "name": "builtin: toString boolean",
+ "code": "toString(true)",
+ "expected": { "type": "string", "value": "true" }
+ },
+ {
+ "name": "builtin: toNumber string",
+ "code": "toNumber(\"42\")",
+ "expected": { "type": "number", "value": "42" }
+ },
+ {
+ "name": "builtin: toNumber boolean true",
+ "code": "toNumber(true)",
+ "expected": { "type": "number", "value": "1" }
+ },
+ {
+ "name": "builtin: abs in formula",
+ "code": "x = -50\nabs(x) + 10",
+ "expected": { "type": "number", "value": "60" }
+ },
+ {
+ "name": "builtin: round in pricing",
+ "code": "price = 99.999\nround(price, 2)",
+ "expected": { "type": "number", "value": "100.00" }
+ },
+ {
+ "name": "builtin: min/max with globals",
+ "code": "min(max(price, minPrice), maxPrice)",
+ "globals": {
+ "price": { "type": "number", "value": "250" },
+ "minPrice": { "type": "number", "value": "100" },
+ "maxPrice": { "type": "number", "value": "200" }
+ },
+ "expected": { "type": "number", "value": "200" }
+ }
+]