From 7582c5aee7f975ce4efff41f94a6c9ff7120e541 Mon Sep 17 00:00:00 2001 From: Duhan BALCI Date: Sun, 5 Apr 2026 23:05:31 +0300 Subject: [PATCH] refactor --- Cargo.lock | 1 + Cargo.toml | 4 + benches/my_benchmark.rs | 8 +- docs/compiler.md | 5 +- docs/opcodes.md | 19 + docs/vm.md | 19 +- {src => examples}/basic_long.dexpr | 0 {src => examples}/bench_long.dexpr | 0 {src => examples}/bench_sample.dexpr | 0 {src => examples}/bench_sample2.dexpr | 0 {src => examples}/sample.dexpr | 0 {src => examples}/sample_test.dexpr | 0 flamegraph.svg | 491 ---------- gen.js | 74 -- justfile | 6 + profile.json.gz | Bin 6408 -> 0 bytes src/bytecode_dump.rs | 1 + src/compiler.rs | 16 +- src/main.rs | 2 +- src/opcodes.rs | 32 +- src/sample_test_asm.txt | 23 - src/vm/builtins.rs | 229 +++++ src/vm/methods.rs | 608 ++++++++++++ src/vm/mod.rs | 2 + src/vm/vm.rs | 659 ++----------- tests/data_driven_tests.rs | 143 +++ tests/integration_tests.rs | 22 +- tests/test_cases.json | 1288 +++++++++++++++++++++++++ 28 files changed, 2439 insertions(+), 1213 deletions(-) rename {src => examples}/basic_long.dexpr (100%) rename {src => examples}/bench_long.dexpr (100%) rename {src => examples}/bench_sample.dexpr (100%) rename {src => examples}/bench_sample2.dexpr (100%) rename {src => examples}/sample.dexpr (100%) rename {src => examples}/sample_test.dexpr (100%) delete mode 100644 flamegraph.svg delete mode 100644 gen.js delete mode 100644 profile.json.gz delete mode 100644 src/sample_test_asm.txt create mode 100644 src/vm/builtins.rs create mode 100644 src/vm/methods.rs create mode 100644 tests/data_driven_tests.rs create mode 100644 tests/test_cases.json 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 @@ -Flame Graph Reset ZoomSearch dyld4::prepare(dyld4::APIs&, mach_o::Header const*) (1 samples, 0.38%)dyld4::APIs::runAllInitializersForMain() (1 samples, 0.38%)dyld4::PrebuiltLoader::runInitializers(dyld4::RuntimeState&) const (1 samples, 0.38%)dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const (1 samples, 0.38%)dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const (1 samples, 0.38%)mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const (1 samples, 0.38%)mach_o::Header::forEachLoadCommand(void (load_command const*, bool&) block_pointer) const (1 samples, 0.38%)invocation function for block in mach_o::Header::forEachSection(void (mach_o::Header::SectionInfo const&, bool&) block_pointer) const (1 samples, 0.38%)invocation function for block in dyld3::MachOAnalyzer::forEachInitializer(Diagnostics&, dyld3::MachOAnalyzer::VMAddrConverter const&, void (unsigned int) block_pointer, void const*) const (1 samples, 0.38%)invocation function for block in dyld4::Loader::findAndRunAllInitializers(dyld4::RuntimeState&) const (1 samples, 0.38%)libSystem_initializer (1 samples, 0.38%)_sanitizers_init (1 samples, 0.38%)void config::env::Parser::unsetEnv<18ul>(char const**, char const (&) [18ul]) (1 samples, 0.38%)<core::iter::adapters::cloned::Cloned<I> as core::iter::traits::unchecked_iterator::UncheckedIterator>::next_unchecked (1 samples, 0.38%)<dexpr::ast::value::Value as core::clone::Clone>::clone (1 samples, 0.38%)<core::ops::try_trait::NeverShortCircuit<T> as core::ops::try_trait::Try>::branch (2 samples, 0.75%)<core::result::Result<T,E> as core::ops::try_trait::Try>::branch (1 samples, 0.38%)_free (8 samples, 3.02%)_fr.._platform_memmove (4 samples, 1.51%)_xzm_free (4 samples, 1.51%)<alloc::string::String as core::fmt::Write>::write_str (4 samples, 1.51%)<core::ptr::non_null::NonNull<T> as core::cmp::PartialEq>::eq (2 samples, 0.75%)<str as core::fmt::Debug>::fmt (1 samples, 0.38%)<alloc::string::String as core::fmt::Write>::write_str (3 samples, 1.13%)<core::fmt::Formatter as core::fmt::Write>::write_char (3 samples, 1.13%)alloc::raw_vec::RawVecInner<A>::capacity (1 samples, 0.38%)<core::slice::iter::Iter<T> as core::iter::traits::iterator::Iterator>::position (9 samples, 3.40%)<co..<dexpr::ast::value::Value as core::fmt::Display>::fmt (1 samples, 0.38%)<core::slice::iter::Iter<T> as core::iter::traits::iterator::Iterator>::all (1 samples, 0.38%)_platform_memmove (1 samples, 0.38%)<rust_decimal::decimal::Decimal as core::fmt::Display>::fmt (3 samples, 1.13%)rust_decimal::str::to_str_internal (1 samples, 0.38%)<rust_decimal::decimal::Decimal as core::fmt::Debug>::fmt (4 samples, 1.51%)arrayvec::array_string::ArrayString<_>::len (1 samples, 0.38%)<str as core::fmt::Debug>::fmt (2 samples, 0.75%)<core::ptr::non_null::NonNull<T> as core::cmp::PartialEq>::eq (1 samples, 0.38%)core::fmt::Formatter::pad_integral (1 samples, 0.38%)core::fmt::Formatter::pad_integral::write_prefix (1 samples, 0.38%)core::fmt::Formatter::pad_integral (3 samples, 1.13%)core::fmt::Formatter::pad_integral::write_prefix (3 samples, 1.13%)core::ptr::copy_nonoverlapping (1 samples, 0.38%)DYLD-STUB$$memcpy (1 samples, 0.38%)rust_decimal::decimal::Decimal::mantissa_array3 (1 samples, 0.38%)<rust_decimal::decimal::Decimal as core::fmt::Display>::fmt (9 samples, 3.40%)<ru..rust_decimal::str::to_str_internal (2 samples, 0.75%)core::fmt::Formatter::write_fmt (11 samples, 4.15%)core:..core::fmt::rt::Argument::fmt (10 samples, 3.77%)core..core::fmt::Formatter::pad_integral (1 samples, 0.38%)<alloc::string::String as core::fmt::Write>::write_str (1 samples, 0.38%)_platform_memmove (5 samples, 1.89%)_..alloc::raw_vec::RawVecInner<A>::capacity (1 samples, 0.38%)<core::result::Result<T,E> as core::ops::try_trait::Try>::branch (1 samples, 0.38%)<deduplicated_symbol> (3 samples, 1.13%)_platform_memmove (1 samples, 0.38%)_xzm_free (1 samples, 0.38%)_xzm_xzone_malloc (1 samples, 0.38%)_xzm_xzone_malloc_tiny (4 samples, 1.51%)_malloc_zone_realloc (16 samples, 6.04%)_malloc_..xzm_realloc (6 samples, 2.26%)x..mach_absolute_time (3 samples, 1.13%)xzm_malloc_zone_size (4 samples, 1.51%)alloc::raw_vec::RawVecInner<A>::reserve (22 samples, 8.30%)alloc::raw_v..alloc::raw_vec::RawVecInner<A>::grow_amortized (21 samples, 7.92%)alloc::raw_..std::sys::alloc::unix::_<impl core::alloc::global::GlobalAlloc for std::alloc::System>::realloc (21 samples, 7.92%)std::sys::a.._realloc (21 samples, 7.92%)_reallocxzm_realloc (1 samples, 0.38%)core::fmt::Formatter::write_str (31 samples, 11.70%)core::fmt::Format..core::ptr::copy_nonoverlapping (1 samples, 0.38%)DYLD-STUB$$memcpy (1 samples, 0.38%)core::fmt::builders::DebugList::entry::_{{closure}} (1 samples, 0.38%)core::fmt::Formatter::pad_integral (1 samples, 0.38%)<core::result::Result<T,E> as core::ops::try_trait::Try>::branch (1 samples, 0.38%)_platform_memmove (1 samples, 0.38%)alloc::raw_vec::RawVecInner<A>::capacity (1 samples, 0.38%)alloc::vec::Vec<T,A>::append_elements (1 samples, 0.38%)core::fmt::Formatter::pad_integral (5 samples, 1.89%)c..core::fmt::Formatter::pad_integral::write_prefix (1 samples, 0.38%)core::fmt::num::imp::_<impl core::fmt::Display for u64>::fmt (13 samples, 4.91%)core::..core::fmt::Formatter::pad_integral (2 samples, 0.75%)core::fmt::Formatter::pad_integral::write_prefix (2 samples, 0.75%)core::fmt::rt::Argument::fmt (79 samples, 29.81%)core::fmt::rt::Argument::fmtcore::str::traits::_<impl core::slice::index::SliceIndex<str> for core::ops::range::Range<usize>>::get (1 samples, 0.38%)_platform_memmove (10 samples, 3.77%)_pla..alloc::raw_vec::RawVecInner<A>::capacity (2 samples, 0.75%)alloc::vec::Vec<T,A>::append_elements (3 samples, 1.13%)<&mut W as core::fmt::Write::write_fmt::SpecWriteFmt>::spec_write_fmt (111 samples, 41.89%)<&mut W as core::fmt::Write::write_fmt::SpecWriteFmt>::spec_write_fmtcore::fmt::write (25 samples, 9.43%)core::fmt::wr..core::ptr::copy_nonoverlapping (4 samples, 1.51%)DYLD-STUB$$memcpy (4 samples, 1.51%)<deduplicated_symbol> (4 samples, 1.51%)_malloc_zone_malloc (7 samples, 2.64%)_m.._xzm_xzone_malloc (2 samples, 0.75%)_xzm_xzone_malloc_tiny (17 samples, 6.42%)_xzm_xzo..alloc::fmt::format::format_inner (2 samples, 0.75%)alloc::raw_vec::RawVecInner<A>::try_allocate_in (1 samples, 0.38%)core::fmt::Arguments::estimated_capacity (1 samples, 0.38%)malloc (1 samples, 0.38%)alloc::fmt::format::_{{closure}} (147 samples, 55.47%)alloc::fmt::format::_{{closure}}xzm_malloc_zone_malloc_type_malloc (1 samples, 0.38%)alloc::fmt::format::format_inner (4 samples, 1.51%)alloc::raw_vec::RawVecInner<A>::current_memory (1 samples, 0.38%)alloc::vec::Vec<T,A>::push_mut (1 samples, 0.38%)core::hint::must_use (2 samples, 0.75%)dexpr::ast::value::Value::deserialize (1 samples, 0.38%)dexpr::bytecode::BytecodeReader::read_byte (4 samples, 1.51%)dexpr::opcodes::OpCodeByte::name (3 samples, 1.13%)dexpr::vm::vm::VM::compare_op (1 samples, 0.38%)DYLD-STUB$$_platform_bzero (1 samples, 0.38%)DYLD-STUB$$mach_absolute_time (1 samples, 0.38%)_platform_memset (4 samples, 1.51%)_xzm_free (8 samples, 3.02%)_xz..dexpr::vm::vm::VM::execute (28 samples, 10.57%)dexpr::vm::vm::..mach_absolute_time (11 samples, 4.15%)mach_..dexpr::vm::vm::VM::handle_load_const (2 samples, 0.75%)_platform_memmove (1 samples, 0.38%)core::ptr::drop_in_place<[dexpr::ast::value::Value (2 samples, 0.75%) 16]> (2 samples, 0.75%)core::ptr::drop_in_place<dexpr::ast::value::Value> (2 samples, 0.75%)dexpr::vm::vm::VM::handle_return (4 samples, 1.51%)core::ptr::drop_in_place<dexpr::ast::value::Value> (1 samples, 0.38%)dexpr::vm::vm::VM::read_jump_address (1 samples, 0.38%)dexpr::bytecode::BytecodeReader::read_u32 (1 samples, 0.38%)rust_decimal::ops::add::add_impl (1 samples, 0.38%)rust_decimal::ops::add::add_sub_internal (1 samples, 0.38%)rust_decimal::ops::add::sub_impl (1 samples, 0.38%)rust_decimal::ops::add::add_sub_internal (1 samples, 0.38%)DYLD-STUB$$free (1 samples, 0.38%)__bzero (2 samples, 0.75%)_platform_memset (4 samples, 1.51%)_xzm_free (15 samples, 5.66%)_xzm_fr..all (265 samples, 100%)start (265 samples, 100.00%)startmain (264 samples, 99.62%)maincore::ops::function::FnOnce::call_once (264 samples, 99.62%)core::ops::function::FnOnce::call_oncedexpr::main (264 samples, 99.62%)dexpr::mainstd::sys::alloc::unix::_<impl core::alloc::global::GlobalAlloc for std::alloc::System>::dealloc (43 samples, 16.23%)std::sys::alloc::unix::_<..mach_absolute_time (21 samples, 7.92%)mach_absolu.. \ 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 7f7cad8d85cbab3023c66408ca56eb012fb473cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6408 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 Wb&EfGf^?A6U;Y<<*OvIzWdH!VGCeH- 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" } + } +]