Compare commits

..

4 Commits

Author SHA1 Message Date
0fa388abeb 0.3.0 2026-04-07 15:16:19 +03:00
953b39d433 perf improvements 2026-04-06 02:51:42 +03:00
b0ea71e104 performance improvements 2026-04-06 00:04:40 +03:00
7582c5aee7 refactor 2026-04-05 23:05:31 +03:00
46 changed files with 3999 additions and 1308 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target
editor/dist
editor/node_modules

View File

@@ -72,6 +72,7 @@ The dexpr language supports:
- Line comments (`//`) and block comments (`/* */`)
- Lists: `NumberList` and `StringList` types with methods (`sum`, `avg`, `min`, `max`, `first`, `last`, `get`, `join`, `contains`, `indexOf`, `slice`, `reverse`, `sort`, `isEmpty`, etc.)
- Objects: `Object` type (provided externally via `set_global`) with property access (`obj.field`), nested access (`obj.a.b`), property assignment (`obj.field = value`), and methods (`keys()`, `values()`, `length()`, `contains(key)`, `get(key)`)
- Lists: `List` type for heterogeneous arrays (including array of objects), with methods (`length`, `isEmpty`, `first`, `last`, `get`, `contains`, `indexOf`, `slice`, `reverse`, `join`, `map(field)`, `filter(field, value?)`, `find(field, value?)`, `sort(field)`). Property projection: `kalemler.tutar` extracts field from each Object element, returning NumberList/StringList/List. `map("field")` also available as explicit alternative
## Detailed Module Documentation

3
Cargo.lock generated
View File

@@ -304,7 +304,7 @@ checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
[[package]]
name = "dexpr"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"bumpalo",
"criterion",
@@ -315,6 +315,7 @@ dependencies = [
"rust_decimal",
"rust_decimal_macros",
"rustc-hash",
"serde",
"serde_json",
"smallvec",
"smol_str",

View File

@@ -1,7 +1,10 @@
[package]
name = "dexpr"
version = "0.1.0"
version = "0.3.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"

View File

@@ -1,21 +1,39 @@
use criterion::{criterion_group, criterion_main, Criterion};
use dexpr::{ast::value::Value, compiler::Compiler, parser, vm::VM};
use indexmap::IndexMap;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use smol_str::SmolStr;
use std::rc::Rc;
/// Helper: compile source to bytecode
fn compile(source: &str) -> Vec<u8> {
let ast = parser::program(source).unwrap();
let mut compiler = Compiler::new();
compiler.compile(ast).unwrap()
}
/// Helper: build a sample object with N fields
fn sample_object(n: usize) -> Value {
let mut map = IndexMap::new();
for i in 0..n {
map.insert(SmolStr::new(format!("field{}", i)), Value::Number(Decimal::from(i)));
}
Value::Object(Rc::new(map))
}
pub fn criterion_benchmark(c: &mut Criterion) {
// 1. Parser Benchmark
// ── Existing benchmarks ──────────────────────────────────────────────
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();
})
});
// 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();
@@ -23,11 +41,8 @@ pub fn criterion_benchmark(c: &mut Criterion) {
})
});
// 3. VM Benchmarks
// 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();
@@ -38,9 +53,8 @@ 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();
@@ -50,12 +64,9 @@ pub fn criterion_benchmark(c: &mut Criterion) {
})
});
// Short code benchmark
c.bench_function("vm_short", |b| {
let input = "5.12 + test * 1.5";
let ast = parser::program(input).unwrap();
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).unwrap();
let bytecode = compile(input);
let test_val = dec!(100);
b.iter(|| {
let mut vm = VM::new(&bytecode);
@@ -63,6 +74,220 @@ pub fn criterion_benchmark(c: &mut Criterion) {
let _ = vm.execute().unwrap();
})
});
// ── #1: Method dispatch clone overhead ───────────────────────────────
// Object method — clone entire IndexMap per call
c.bench_function("vm_object_method_keys", |b| {
let bytecode = compile("obj.keys()");
let obj = sample_object(20);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("obj", obj.clone());
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_object_method_length", |b| {
let bytecode = compile("obj.length()");
let obj = sample_object(20);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("obj", obj.clone());
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_object_method_contains", |b| {
let bytecode = compile(r#"obj.contains("field10")"#);
let obj = sample_object(20);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("obj", obj.clone());
let _ = vm.execute().unwrap();
})
});
// StringList method — clone entire Vec per call
c.bench_function("vm_strlist_method_length", |b| {
let bytecode = compile("items.length()");
let items = Value::StringList(Rc::new((0..50).map(|i| SmolStr::new(format!("item{}", i))).collect()));
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("items", items.clone());
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_numlist_method_sum", |b| {
let bytecode = compile("nums.sum()");
let nums = Value::NumberList(Rc::new((0..100).map(Decimal::from).collect()));
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("nums", nums.clone());
let _ = vm.execute().unwrap();
})
});
// String method — lighter clone (SmolStr)
c.bench_function("vm_string_method_upper", |b| {
let bytecode = compile(r#"s.upper()"#);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("s", Value::String(SmolStr::new("hello world this is a test string")));
let _ = vm.execute().unwrap();
})
});
// ── #2 & #3: Vec alloc in method/external calls ─────────────────────
c.bench_function("vm_method_call_with_args", |b| {
let bytecode = compile(r#"s.replace("hello", "world")"#);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("s", Value::String(SmolStr::new("hello world hello")));
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_external_fn_call", |b| {
let bytecode = compile("getRate(a, b)");
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("a", Value::Number(dec!(10)));
vm.set_global("b", Value::Number(dec!(20)));
vm.register_function("getRate", |_args| Ok(Value::Number(dec!(34.5))));
let _ = vm.execute().unwrap();
})
});
// ── #4: Value enum size (cache pressure on register ops) ─────────────
c.bench_function("vm_arithmetic_chain", |b| {
// Pure arithmetic — measures register read/write cache performance
let bytecode = compile(
"a = 1.5\nb = 2.3\nc = a + b\nd = c * a\ne = d - b\nf = e / c\ng = f + d\ng * 2.0",
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_comparison_chain", |b| {
let bytecode = compile(
"a = 10\nb = 20\nc = a < b\nd = b >= a\ne = a == 10\nf = b != 15\nc && d && e && f",
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
// ── #5: SmolStr alloc per opcode (string table missing) ──────────────
c.bench_function("vm_global_read_heavy", |b| {
// Many LoadGlobal ops → SmolStr alloc per read (split to stay within register limit)
let bytecode = compile(
"r1 = x1 + x2 + x3\nr2 = x4 + x5 + x6\nr = r1 + r2\nr",
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
for i in 1..=6 {
vm.set_global(&format!("x{}", i), Value::Number(Decimal::from(i)));
}
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_global_write_heavy", |b| {
// Many StoreGlobal ops
let bytecode = compile(
"a = 1\nb = 2\nc = 3\nd = 4\ne = 5\nf = 6\nr = a + b + c\ns = d + e + f\nr + s",
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_property_access_chain", |b| {
// GetProperty → read_string per access
let bytecode = compile("obj.field0 + obj.field1 + obj.field2 + obj.field3 + obj.field4");
let obj = sample_object(10);
b.iter(|| {
let mut vm = VM::new(&bytecode);
vm.set_global("obj", obj.clone());
let _ = vm.execute().unwrap();
})
});
// ── #8: String concat with format! ───────────────────────────────────
c.bench_function("vm_string_concat", |b| {
let bytecode = compile(
r#"a = "hello" + " " + "world"
b = a + " " + "this"
c = b + " " + "test"
c"#,
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
c.bench_function("vm_string_number_coerce", |b| {
let bytecode = compile(
r#"a = "value: " + 42
b = a + " and " + 3.14
b"#,
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
// ── #6: Opcode dispatch (overall loop throughput) ────────────────────
c.bench_function("vm_opcode_throughput", |b| {
// Many simple ops to stress the dispatch loop
let bytecode = compile(
"a = 1\nb = 2\nc = a + b\nd = c * 2\ne = d - 1\nf = e / 3\n\
g = f + a\nh = g * b\ni = h - c\nj = i + d\n\
k = j * 2\nl = k - 1\nm = l + 3\nn = m / 2\n\
o = n + a\np = o * b\np",
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
// ── Combined: realistic expression with multiple issue areas ─────────
c.bench_function("vm_realistic_mixed", |b| {
let bytecode = compile(
r#"
price = 100.50
tax = 18
discount = 5.5
net = price * (1 + tax / 100) - discount
label = "Total: " + net
if net > 100 then
result = label + " (high)"
else
result = label + " (low)"
end
result
"#,
);
b.iter(|| {
let mut vm = VM::new(&bytecode);
let _ = vm.execute().unwrap();
})
});
}
criterion_group!(benches, criterion_benchmark);

View File

@@ -88,12 +88,13 @@ struct Spanned<T> { node: T, span: Span }
| `NumberList(Vec<Decimal>)` | `Vec<Decimal>` | Sayı listesi |
| `StringList(Vec<SmolStr>)` | `Vec<SmolStr>` | String listesi |
| `Object(IndexMap<SmolStr, Value>)` | `IndexMap<SmolStr, Value>` | Anahtar-değer nesnesi |
| `List(Vec<Value>)` | `Vec<Value>` | Genel liste (object array dahil) |
### Serileştirme
Her `Value` bytecode'a gömülebilir. Serileştirme formatı:
1. **Tip etiketi** (1 byte): `NULL=0x00`, `NUMBER=0x01`, `STRING=0x02`, `BOOLEAN=0x03`, `NUMBER_LIST=0x04`, `STRING_LIST=0x05`, `OBJECT=0x06`
1. **Tip etiketi** (1 byte): `NULL=0x00`, `NUMBER=0x01`, `STRING=0x02`, `BOOLEAN=0x03`, `NUMBER_LIST=0x04`, `STRING_LIST=0x05`, `OBJECT=0x06`, `LIST=0x07`
2. **Veri:**
- Number: 16 byte (Decimal serialization)
- String: 2-byte uzunluk + UTF-8 bytes
@@ -101,6 +102,7 @@ Her `Value` bytecode'a gömülebilir. Serileştirme formatı:
- NumberList: 2-byte count + her sayı için 16 byte
- StringList: 2-byte count + her string için (2-byte uzunluk + bytes)
- Object: 2-byte entry count + her girdi için (anahtar: 2-byte uzunluk + bytes, değer: rekürsif serialize)
- List: 2-byte count + her eleman için rekürsif serialize
`serialize()` ve `deserialize()` metodları bu dönüşümü gerçekleştirir.

View File

@@ -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

View File

@@ -155,7 +155,9 @@ Bu analiz her autocomplete tetiklendiğinde Lezer tree üzerinde yapılır. Bozu
| `x.` (assignment'tan `String` çıkarıldı) | String metodları |
| `items.` (config'de `StringList`) | StringList metodları |
| `scores.` (config'de `NumberList`) | NumberList metodları |
| `obj.` (config'de `Object`) | Object metodları |
| `obj.` (config'de `Object`) | Object field'ları + Object metodları |
| `kalemler.` (config'de `List`) | Element field'ları (property projection) + List metodları |
| `kalemler.tutar.` (List projection → `NumberList`) | NumberList metodları (sum, avg, min, max...) |
| `result.` (tip bilinmiyor) | Tüm metodlar |
| `42.` | Öneri yok |
@@ -260,7 +262,7 @@ Eğer host uygulama çalışma sırasında yeni fonksiyon/değişken eklerse, ed
| Tip | Açıklama |
|-----|----------|
| `DexprLanguageInfo` | Metadata arayüzü (JSON yapısı) |
| `DexprType` | `"String" \| "Number" \| "Boolean" \| "NumberList" \| "StringList" \| "Object"` |
| `DexprType` | `"String" \| "Number" \| "Boolean" \| "NumberList" \| "StringList" \| "Object" \| "List"` |
| `FunctionInfo` | Fonksiyon metadata'sı |
| `MethodInfo` | Metod metadata'sı |
| `VariableInfo` | Değişken metadata'sı |

View File

@@ -29,7 +29,7 @@ Editör entegrasyonu için dil metadata'sı üretir. Built-in fonksiyonlar, tipe
| Alan | Tip | Açıklama |
|------|-----|----------|
| `name` | `String` | Değişken adı |
| `type_name` | `String` | Tip adı: `String`, `Number`, `Boolean`, `NumberList`, `StringList`, `Object` |
| `type_name` | `String` | Tip adı: `String`, `Number`, `Boolean`, `NumberList`, `StringList`, `Object`, `List` |
| `doc` | `Option<String>` | Opsiyonel açıklama |
### LanguageInfo
@@ -62,6 +62,7 @@ Tüm built-in fonksiyon ve metodları içeren yeni bir `LanguageInfo` oluşturur
| `NumberList` | `length`, `len`, `isEmpty`, `first`, `last`, `get`, `contains`, `indexOf`, `slice`, `reverse`, `sort`, `sum`, `avg`, `min`, `max` |
| `StringList` | `length`, `len`, `isEmpty`, `first`, `last`, `get`, `contains`, `indexOf`, `slice`, `reverse`, `sort`, `join` |
| `Object` | `keys`, `values`, `length`, `len`, `contains`, `get` |
| `List` | `length`, `len`, `isEmpty`, `first`, `last`, `get`, `contains`, `indexOf`, `slice`, `reverse`, `join`, `map`, `filter`, `find`, `sort` |
### `add_function(name, signature, doc)`

View File

@@ -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.

View File

@@ -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,18 +136,19 @@ 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_get_property()`** — Object register'ından alan oku, alan yoksa `Null` döndür
- **`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. List register'ında property projection yapar: her Object elemanından ilgili alanı çıkarıp NumberList/StringList/List döndürür
- **`handle_set_property()`** — Object register'ında alan değerini ayarla
- **`handle_method_call()`** — Nesne register'ı, metod adı, argümanlar
- **String metodları:** `upper`, `lower`, `trim`, `trimStart`, `trimEnd`, `split(delimiter)`, `replace(old, new)`, `startsWith(prefix)`, `endsWith(suffix)`, `contains(substr)`, `length`, `charAt(index)`, `substring(start, end?)`
- **StringList metodları:** `length`/`len`, `isEmpty`, `first`, `last`, `get(index)`, `contains(value)`, `indexOf(value)`, `slice(start, end?)`, `reverse()`, `sort()`, `join(delimiter?)`
- **NumberList metodları:** `length`/`len`, `isEmpty`, `first`, `last`, `get(index)`, `contains(value)`, `indexOf(value)`, `slice(start, end?)`, `reverse()`, `sort()`, `sum`, `avg`, `min`, `max`
- **Object metodları:** `keys()`, `values()`, `length`/`len()`, `contains(key)`, `get(key)`
- **List metodları:** `length`/`len`, `isEmpty`, `first`, `last`, `get(index)`, `contains(value)`, `indexOf(value)`, `slice(start, end?)`, `reverse()`, `join(delim?)`, `map(field)`, `filter(field, value?)`, `find(field, value?)`, `sort(field)`
- **Harici metodlar:** Yukarıdaki built-in metodlar bulunamazsa `external_methods` HashMap'inde aranır
### Üyelik Testi
- **`handle_contains()`** — `in` operatörü: String in StringList, Number in NumberList, String in String (substring), String in Object (anahtar varlığı kontrolü)
- **`handle_contains()`** — `in` operatörü: String in StringList, Number in NumberList, String in String (substring), String in Object (anahtar varlığı kontrolü), Value in List
### Harici Fonksiyonlar ve Sonuç
- **`handle_call_external()`** — İsimle harici fonksiyon çağır (HashMap lookup)
@@ -153,6 +157,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.
---
@@ -188,7 +204,7 @@ vm.register_method("Number", "format", |this, args| {
});
```
**Tip isimleri:** `"Number"`, `"String"`, `"Boolean"`, `"NumberList"`, `"StringList"`, `"Object"`, `"Null"`
**Tip isimleri:** `"Number"`, `"String"`, `"Boolean"`, `"NumberList"`, `"StringList"`, `"Object"`, `"List"`, `"Null"`
---

View File

@@ -15,6 +15,7 @@
"codemirror": "^6.0.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^4.1.3",
},
"peerDependencies": {
"@codemirror/autocomplete": "^6.0.0",
@@ -22,6 +23,7 @@
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
},
},
},
@@ -40,6 +42,12 @@
"@codemirror/view": ["@codemirror/view@6.41.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
@@ -110,6 +118,42 @@
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@oxc-project/types": ["@oxc-project/types@0.123.0", "", {}, "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.13", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm" }, "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.13", "", { "os": "none", "cpu": "arm64" }, "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.13", "", { "dependencies": { "@emnapi/core": "1.9.1", "@emnapi/runtime": "1.9.1", "@napi-rs/wasm-runtime": "^1.1.2" }, "cpu": "none" }, "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "x64" }, "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.13", "", {}, "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
@@ -160,16 +204,42 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@vitest/expect": ["@vitest/expect@4.1.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.3", "", { "dependencies": { "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.3", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg=="],
"@vitest/runner": ["@vitest/runner@4.1.3", "", { "dependencies": { "@vitest/utils": "4.1.3", "pathe": "^2.0.3" } }, "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ=="],
"@vitest/spy": ["@vitest/spy@4.1.3", "", {}, "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw=="],
"@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
@@ -180,12 +250,22 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
@@ -194,6 +274,30 @@
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -208,8 +312,12 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -220,16 +328,28 @@
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"rolldown": ["rolldown@1.0.0-rc.13", "", { "dependencies": { "@oxc-project/types": "=0.123.0", "@rolldown/pluginutils": "1.0.0-rc.13" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-x64": "1.0.0-rc.13", "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
@@ -238,20 +358,34 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"vite": ["vite@8.0.6", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.13", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-jeOXoY6N8rOfit/mZADMd0misLqjRdWBB3/S23ZQNuPcbVsfMBJutWD8b4ftdczMOsNyMBnKro0Z1Kt0HIqq5Q=="],
"vitest": ["vitest@4.1.3", "", { "dependencies": { "@vitest/expect": "4.1.3", "@vitest/mocker": "4.1.3", "@vitest/pretty-format": "4.1.3", "@vitest/runner": "4.1.3", "@vitest/snapshot": "4.1.3", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.3", "@vitest/browser-preview": "4.1.3", "@vitest/browser-webdriverio": "4.1.3", "@vitest/coverage-istanbul": "4.1.3", "@vitest/coverage-v8": "4.1.3", "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"vitest/tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
}
}

15
editor/dist/index.cjs vendored
View File

@@ -285,6 +285,15 @@ function inferMethodReturnType(method) {
// depends on input type
case "join":
return "String";
// List methods
case "map":
return null;
// depends on field type (NumberList, StringList, or List)
case "filter":
return "List";
case "find":
return null;
// returns single element
default:
return null;
}
@@ -318,7 +327,7 @@ function dexprCompletion(info) {
}
const objectFieldCompletions = /* @__PURE__ */ new Map();
for (const v of info.variables ?? []) {
if (v.type === "Object" && v.fields) {
if ((v.type === "Object" || v.type === "List") && v.fields) {
const fieldItems = [];
for (const f of v.fields) {
configVarTypes.set(`${v.name}.${f.name}`, f.type);
@@ -392,6 +401,10 @@ function dexprCompletion(info) {
const fieldItems = objectFieldCompletions.get(rootVarName) ?? [];
const objMethods = methodsByType["Object"] ?? [];
options = [...fieldItems, ...objMethods];
} else if (finalType === "List") {
const rootVarName = path[0];
const listMethods = methodsByType["List"] ?? [];
options = [...listMethods];
} else if (finalType) {
options = methodsByType[finalType] ?? allMethods;
} else {

View File

@@ -2,7 +2,7 @@ import { Extension } from '@codemirror/state';
import { Completion } from '@codemirror/autocomplete';
import { LRLanguage, HighlightStyle } from '@codemirror/language';
type DexprType = "String" | "Number" | "Boolean" | "NumberList" | "StringList" | "Object";
type DexprType = "String" | "Number" | "Boolean" | "NumberList" | "StringList" | "Object" | "List";
interface FunctionInfo {
name: string;
signature: string;

View File

@@ -2,7 +2,7 @@ import { Extension } from '@codemirror/state';
import { Completion } from '@codemirror/autocomplete';
import { LRLanguage, HighlightStyle } from '@codemirror/language';
type DexprType = "String" | "Number" | "Boolean" | "NumberList" | "StringList" | "Object";
type DexprType = "String" | "Number" | "Boolean" | "NumberList" | "StringList" | "Object" | "List";
interface FunctionInfo {
name: string;
signature: string;

15
editor/dist/index.js vendored
View File

@@ -258,6 +258,15 @@ function inferMethodReturnType(method) {
// depends on input type
case "join":
return "String";
// List methods
case "map":
return null;
// depends on field type (NumberList, StringList, or List)
case "filter":
return "List";
case "find":
return null;
// returns single element
default:
return null;
}
@@ -291,7 +300,7 @@ function dexprCompletion(info) {
}
const objectFieldCompletions = /* @__PURE__ */ new Map();
for (const v of info.variables ?? []) {
if (v.type === "Object" && v.fields) {
if ((v.type === "Object" || v.type === "List") && v.fields) {
const fieldItems = [];
for (const f of v.fields) {
configVarTypes.set(`${v.name}.${f.name}`, f.type);
@@ -365,6 +374,10 @@ function dexprCompletion(info) {
const fieldItems = objectFieldCompletions.get(rootVarName) ?? [];
const objMethods = methodsByType["Object"] ?? [];
options = [...fieldItems, ...objMethods];
} else if (finalType === "List") {
const rootVarName = path[0];
const listMethods = methodsByType["List"] ?? [];
options = [...listMethods];
} else if (finalType) {
options = methodsByType[finalType] ?? allMethods;
} else {

View File

@@ -1,6 +1,6 @@
{
"name": "codemirror-lang-dexpr",
"version": "0.1.0",
"name": "@duhanbalci/codemirror-lang-dexpr",
"version": "0.3.0",
"description": "CodeMirror 6 language support for dexpr",
"type": "module",
"main": "dist/index.cjs",
@@ -17,7 +17,8 @@
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external @codemirror/language --external @codemirror/autocomplete --external @codemirror/state --external @codemirror/view --external @lezer/highlight --external @lezer/lr",
"demo": "tsup demo.ts --format iife --outDir dist --no-dts",
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
"test": "vitest run"
},
"peerDependencies": {
"@codemirror/autocomplete": "^6.0.0",
@@ -37,7 +38,8 @@
"@lezer/lr": "^1.4.8",
"codemirror": "^6.0.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"vitest": "^4.1.3"
},
"license": "MIT"
}

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { inferMethodReturnType, projectedListType } from "./completions";
import type { DexprType } from "./completions";
// ==================== inferMethodReturnType ====================
describe("inferMethodReturnType", () => {
// String methods → String
it.each(["upper", "lower", "trim", "trimStart", "trimEnd", "replace", "charAt", "substring"])(
"%s → String",
(method) => {
expect(inferMethodReturnType(method)).toBe("String");
}
);
// String/List methods → Boolean
it.each(["contains", "startsWith", "endsWith", "isEmpty"])(
"%s → Boolean",
(method) => {
expect(inferMethodReturnType(method)).toBe("Boolean");
}
);
// Methods → Number
it.each(["length", "len", "indexOf", "sum", "avg", "min", "max", "first", "last"])(
"%s → Number",
(method) => {
expect(inferMethodReturnType(method)).toBe("Number");
}
);
// split → StringList
it("split → StringList", () => {
expect(inferMethodReturnType("split")).toBe("StringList");
});
// join → String
it("join → String", () => {
expect(inferMethodReturnType("join")).toBe("String");
});
// filter → List
it("filter → List", () => {
expect(inferMethodReturnType("filter")).toBe("List");
});
// Methods that depend on input type → null
it.each(["reverse", "sort", "slice", "map", "find"])(
"%s → null (context-dependent)",
(method) => {
expect(inferMethodReturnType(method)).toBeNull();
}
);
// Unknown method → null
it("unknown method → null", () => {
expect(inferMethodReturnType("foobar")).toBeNull();
});
});
// ==================== projectedListType ====================
describe("projectedListType", () => {
it("Number field → NumberList", () => {
expect(projectedListType("Number")).toBe("NumberList");
});
it("String field → StringList", () => {
expect(projectedListType("String")).toBe("StringList");
});
it("Boolean field → List", () => {
expect(projectedListType("Boolean")).toBe("List");
});
it("Object field → List", () => {
expect(projectedListType("Object")).toBe("List");
});
it("List field → List", () => {
expect(projectedListType("List")).toBe("List");
});
it("null (unknown) field → List", () => {
expect(projectedListType(null)).toBe("List");
});
it("NumberList field → List (nested lists stay as List)", () => {
expect(projectedListType("NumberList")).toBe("List");
});
it("StringList field → List", () => {
expect(projectedListType("StringList")).toBe("List");
});
});
// ==================== Type flow scenarios ====================
describe("type flow scenarios", () => {
// Simulates what happens in the autocomplete pipeline
it("kalemler.tutar.sum() — List → NumberList → Number", () => {
// Step 1: kalemler is List, tutar field is Number
const projectedType = projectedListType("Number");
expect(projectedType).toBe("NumberList");
// Step 2: .sum() on NumberList returns Number
const resultType = inferMethodReturnType("sum");
expect(resultType).toBe("Number");
});
it("kalemler.adi.join() — List → StringList → String", () => {
const projectedType = projectedListType("String");
expect(projectedType).toBe("StringList");
const resultType = inferMethodReturnType("join");
expect(resultType).toBe("String");
});
it("kalemler.filter().tutar.sum() — List → List → NumberList → Number", () => {
// filter returns List
const afterFilter = inferMethodReturnType("filter");
expect(afterFilter).toBe("List");
// .tutar on List with Number field
const afterProjection = projectedListType("Number");
expect(afterProjection).toBe("NumberList");
// .sum() on NumberList
const result = inferMethodReturnType("sum");
expect(result).toBe("Number");
});
it("kalemler.tutar.max() — projection then aggregate", () => {
const projected = projectedListType("Number");
expect(projected).toBe("NumberList");
const result = inferMethodReturnType("max");
expect(result).toBe("Number");
});
it("kalemler.birim.contains() — StringList method", () => {
const projected = projectedListType("String");
expect(projected).toBe("StringList");
const result = inferMethodReturnType("contains");
expect(result).toBe("Boolean");
});
});

View File

@@ -17,7 +17,8 @@ export type DexprType =
| "Boolean"
| "NumberList"
| "StringList"
| "Object";
| "Object"
| "List";
export interface FunctionInfo {
name: string;
@@ -171,15 +172,15 @@ function inferExprType(
if (objNode.name === "VariableName") {
const varName = doc.sliceString(objNode.from, objNode.to);
const fieldName = doc.sliceString(propNode.from, propNode.to);
// Look up from objectFieldTypes via the global lookup
// (We use knownTypes to check if root is Object, then check field)
const rootType = knownTypes.get(varName);
if (rootType === "Object") {
// Field type needs to come from config — stored as "varName.fieldName" key
// We can't access objectFieldTypes here, so use the convention
// that knownTypes may contain "varName.fieldName" entries
return knownTypes.get(`${varName}.${fieldName}`) ?? null;
}
if (rootType === "List") {
// Property projection: list.field → typed list based on field type
const fieldType = knownTypes.get(`${varName}.${fieldName}`) ?? null;
return projectedListType(fieldType);
}
}
return null;
}
@@ -204,7 +205,7 @@ function findChild(
}
/** Infer return type from known method names */
function inferMethodReturnType(method: string): DexprType | null {
export function inferMethodReturnType(method: string): DexprType | null {
switch (method) {
// String -> String
case "upper":
@@ -245,11 +246,29 @@ function inferMethodReturnType(method: string): DexprType | null {
return null; // depends on input type
case "join":
return "String";
// List methods
case "map":
return null; // depends on field type (NumberList, StringList, or List)
case "filter":
return "List";
case "find":
return null; // returns single element
default:
return null;
}
}
/**
* Given a field type from an Object element within a List,
* return the projected list type after property access.
* e.g. List with Number field "tutar" → kalemler.tutar → NumberList
*/
export function projectedListType(fieldType: DexprType | null): DexprType {
if (fieldType === "Number") return "NumberList";
if (fieldType === "String") return "StringList";
return "List";
}
// --- Autocomplete ---
function dedup(items: Completion[]): Completion[] {
@@ -290,7 +309,7 @@ export function dexprCompletion(info: DexprLanguageInfo): Extension {
// and field completions per Object variable
const objectFieldCompletions = new Map<string, Completion[]>();
for (const v of info.variables ?? []) {
if (v.type === "Object" && v.fields) {
if ((v.type === "Object" || v.type === "List") && v.fields) {
const fieldItems: Completion[] = [];
for (const f of v.fields) {
// Store "customer.name" → "String" in configVarTypes for type inference
@@ -354,13 +373,17 @@ export function dexprCompletion(info: DexprLanguageInfo): Extension {
// e.g. path=["customer","name"] → look up "customer.name" in varTypes
let currentType = rootType;
for (let i = 1; i < path.length; i++) {
if (currentType !== "Object") {
if (currentType === "Object") {
const key = `${path[i - 1]}.${path[i]}`;
currentType = varTypes.get(key) ?? null;
} else if (currentType === "List") {
// Property projection: list.field → typed list
const key = `${path[0]}.${path[i]}`;
const fieldType = varTypes.get(key) ?? null;
currentType = projectedListType(fieldType);
} else {
return { type: currentType, path };
}
// "customer.name" key convention
const key = `${path[i - 1]}.${path[i]}`;
const fieldType = varTypes.get(key) ?? null;
currentType = fieldType;
}
return { type: currentType, path };
@@ -408,6 +431,12 @@ export function dexprCompletion(info: DexprLanguageInfo): Extension {
const fieldItems = objectFieldCompletions.get(rootVarName) ?? [];
const objMethods = methodsByType["Object"] ?? [];
options = [...fieldItems, ...objMethods];
} else if (finalType === "List") {
// Show field names (property projection) + List methods
const rootVarName = path[0];
const fieldItems = objectFieldCompletions.get(rootVarName) ?? [];
const listMethods = methodsByType["List"] ?? [];
options = [...fieldItems, ...listMethods];
} else if (finalType) {
options = methodsByType[finalType] ?? allMethods;
} else {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

74
gen.js
View File

@@ -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);

View File

@@ -12,15 +12,21 @@ bench:
run:
cargo run --release
# --- Publish ---
# Publish dexpr to Gitea cargo registry
publish:
cargo publish --registry gitea --allow-dirty
# --- WASM ---
# Build wasm package (web target)
wasm:
cd wasm && wasm-pack build --target web --release
cd wasm && wasm-pack build --target web --release --scope duhanbalci
# Build wasm package (bundler target, for npm)
wasm-bundler:
cd wasm && wasm-pack build --target bundler --release
cd wasm && wasm-pack build --target bundler --release --scope duhanbalci
# --- Editor ---
@@ -36,6 +42,14 @@ editor-build: editor-grammar
editor-demo: editor-build
cd editor && bun run demo
# Publish editor package to Gitea npm registry
editor-publish: editor-build
cd editor && npm publish
# Publish wasm package to Gitea npm registry
wasm-publish: wasm-bundler
cd wasm/pkg && npm publish
# --- Combined ---
# Build everything (wasm + editor)

Binary file not shown.

View File

@@ -2,6 +2,7 @@ use indexmap::IndexMap;
use rust_decimal::Decimal;
use smol_str::SmolStr;
use std::fmt;
use std::rc::Rc;
/// Value type for the dExpr language
#[derive(Debug, Clone, PartialEq, Default)]
@@ -11,9 +12,10 @@ pub enum Value {
Number(Decimal),
String(SmolStr),
Boolean(bool),
NumberList(Vec<Decimal>),
StringList(Vec<SmolStr>),
Object(IndexMap<SmolStr, Value>),
NumberList(Rc<Vec<Decimal>>),
StringList(Rc<Vec<SmolStr>>),
Object(Rc<IndexMap<SmolStr, Value>>),
List(Rc<Vec<Value>>),
}
/// Type tag constants for serialization
@@ -24,6 +26,7 @@ pub const TYPE_BOOLEAN: u8 = 0x03;
pub const TYPE_NUMBER_LIST: u8 = 0x04;
pub const TYPE_STRING_LIST: u8 = 0x05;
pub const TYPE_OBJECT: u8 = 0x06;
pub const TYPE_LIST: u8 = 0x07;
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -62,6 +65,16 @@ impl fmt::Display for Value {
}
write!(f, "}}")
}
Value::List(list) => {
write!(f, "[")?;
for (i, val) in list.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}", val)?;
}
write!(f, "]")
}
}
}
}
@@ -77,6 +90,7 @@ impl Value {
Value::NumberList(_) => TYPE_NUMBER_LIST,
Value::StringList(_) => TYPE_STRING_LIST,
Value::Object(_) => TYPE_OBJECT,
Value::List(_) => TYPE_LIST,
}
}
@@ -90,6 +104,7 @@ impl Value {
Value::NumberList(_) => "NumberList",
Value::StringList(_) => "StringList",
Value::Object(_) => "Object",
Value::List(_) => "List",
}
}
@@ -120,7 +135,7 @@ impl Value {
bytes.push((list.len() >> 8) as u8);
bytes.push(list.len() as u8);
// List items
for n in list {
for n in list.iter() {
bytes.extend_from_slice(&n.serialize());
}
}
@@ -129,7 +144,7 @@ impl Value {
bytes.push((list.len() >> 8) as u8);
bytes.push(list.len() as u8);
// List items
for s in list {
for s in list.iter() {
// String length (2 bytes)
bytes.push((s.len() >> 8) as u8);
bytes.push(s.len() as u8);
@@ -142,13 +157,22 @@ impl Value {
bytes.push((map.len() >> 8) as u8);
bytes.push(map.len() as u8);
// Entries: key (string) + value (recursive)
for (key, val) in map {
for (key, val) in map.iter() {
bytes.push((key.len() >> 8) as u8);
bytes.push(key.len() as u8);
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(&val.serialize());
}
}
Value::List(list) => {
// List length (2 bytes)
bytes.push((list.len() >> 8) as u8);
bytes.push(list.len() as u8);
// List items (recursive serialization)
for val in list.iter() {
bytes.extend_from_slice(&val.serialize());
}
}
}
bytes
@@ -209,19 +233,25 @@ impl From<SmolStr> for Value {
impl From<Vec<Decimal>> for Value {
fn from(v: Vec<Decimal>) -> Self {
Value::NumberList(v)
Value::NumberList(Rc::new(v))
}
}
impl From<Vec<SmolStr>> for Value {
fn from(v: Vec<SmolStr>) -> Self {
Value::StringList(v)
Value::StringList(Rc::new(v))
}
}
impl From<IndexMap<SmolStr, Value>> for Value {
fn from(m: IndexMap<SmolStr, Value>) -> Self {
Value::Object(m)
Value::Object(Rc::new(m))
}
}
impl From<Vec<Value>> for Value {
fn from(v: Vec<Value>) -> Self {
Value::List(Rc::new(v))
}
}
@@ -292,7 +322,7 @@ impl Value {
list.push(Decimal::deserialize(decimal_bytes));
}
Ok((Value::NumberList(list), pos))
Ok((Value::NumberList(Rc::new(list)), pos))
}
TYPE_STRING_LIST => {
if bytes.len() < pos + 2 {
@@ -321,7 +351,7 @@ impl Value {
list.push(s.into());
}
Ok((Value::StringList(list), pos))
Ok((Value::StringList(Rc::new(list)), pos))
}
TYPE_OBJECT => {
if bytes.len() < pos + 2 {
@@ -354,7 +384,23 @@ impl Value {
map.insert(key.into(), val);
}
Ok((Value::Object(map), pos))
Ok((Value::Object(Rc::new(map)), pos))
}
TYPE_LIST => {
if bytes.len() < pos + 2 {
return Err("Insufficient bytes for List length".to_string());
}
let len = u16::from_be_bytes([bytes[pos], bytes[pos + 1]]) as usize;
pos += 2;
let mut list = Vec::with_capacity(len);
for _ in 0..len {
let (val, val_bytes) = Value::deserialize(&bytes[pos..])?;
pos += val_bytes;
list.push(val);
}
Ok((Value::List(Rc::new(list)), pos))
}
_ => Err(format!("Unknown type tag: {}", type_tag)),
}
@@ -407,7 +453,7 @@ impl Value {
serde_json::Value::String(s) => Ok(Value::String(SmolStr::new(s))),
serde_json::Value::Array(arr) => {
if arr.is_empty() {
return Ok(Value::StringList(Vec::new()));
return Ok(Value::StringList(Rc::new(Vec::new())));
}
// Check if all elements are the same type
let first = &arr[0];
@@ -418,14 +464,18 @@ impl Value {
nums.push(n);
}
}
Ok(Value::NumberList(nums))
Ok(Value::NumberList(Rc::new(nums)))
} else if first.is_string() && arr.iter().all(|v| v.is_string()) {
let strings: Vec<SmolStr> = arr.iter()
.filter_map(|v| v.as_str().map(SmolStr::new))
.collect();
Ok(Value::StringList(strings))
Ok(Value::StringList(Rc::new(strings)))
} else {
Err("Arrays must contain all numbers or all strings".to_string())
let mut items = Vec::with_capacity(arr.len());
for item in arr {
items.push(Self::from_json_value(item)?);
}
Ok(Value::List(Rc::new(items)))
}
}
serde_json::Value::Object(obj) => {
@@ -433,7 +483,7 @@ impl Value {
for (k, v) in obj {
map.insert(SmolStr::new(k), Self::from_json_value(v)?);
}
Ok(Value::Object(map))
Ok(Value::Object(Rc::new(map)))
}
}
}

View File

@@ -126,9 +126,9 @@ impl<'a> BytecodeReader<'a> {
self.read_byte()
}
/// Read a string
/// Read a string as a borrowed slice (zero-copy from bytecode)
#[inline(always)]
pub fn read_string(&mut self) -> Result<SmolStr, String> {
pub fn read_str(&mut self) -> Result<&'a str, String> {
let length = self.read_u16()? as usize;
if self.position + length > self.bytecode.len() {
@@ -139,7 +139,13 @@ impl<'a> BytecodeReader<'a> {
.map_err(|_| "Invalid UTF-8 string".to_string())?;
self.position += length;
Ok(s.into())
Ok(s)
}
/// Read a string as SmolStr (allocating — use read_str when possible)
#[inline(always)]
pub fn read_string(&mut self) -> Result<SmolStr, String> {
self.read_str().map(SmolStr::from)
}
/// Read a value

View File

@@ -223,6 +223,7 @@ pub fn disassemble_bytecode(bytecode: &[u8]) -> Vec<String> {
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),
};

View File

@@ -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<u8, CompileError> {
let operand_reg = self.compile_expr(operand)?;

View File

@@ -11,6 +11,7 @@
//! use indexmap::IndexMap;
//! use smol_str::SmolStr;
//! use rust_decimal_macros::dec;
//! use std::rc::Rc;
//!
//! let mut info = LanguageInfo::builtin();
//!
@@ -24,7 +25,7 @@
//! let mut customer = IndexMap::new();
//! customer.insert(SmolStr::new("name"), Value::String("Alice".into()));
//! customer.insert(SmolStr::new("age"), Value::Number(dec!(30)));
//! info.add_value("customer", &Value::Object(customer), None);
//! info.add_value("customer", &Value::Object(Rc::new(customer)), None);
//! info.add_value("price", &Value::Number(dec!(100)), None);
//!
//! let json = info.to_json();
@@ -120,6 +121,19 @@ impl LanguageInfo {
type_name: v.type_name().to_string(),
}).collect())
}
Value::List(list) => {
// For List of Objects, derive fields from the first Object element
list.iter().find_map(|item| {
if let Value::Object(map) = item {
Some(map.iter().map(|(k, v)| FieldInfo {
name: k.to_string(),
type_name: v.type_name().to_string(),
}).collect())
} else {
None
}
})
}
_ => None,
};
self.variables.push(VariableInfo {
@@ -275,6 +289,23 @@ fn builtin_methods() -> Vec<(&'static str, Vec<MethodInfo>)> {
MethodInfo { name: "contains", signature: "(key: String) -> Boolean", doc: Some("Check if key exists") },
MethodInfo { name: "get", signature: "(key: String) -> any", doc: Some("Get value by key") },
]),
("List", vec![
MethodInfo { name: "length", signature: "() -> Number", doc: Some("Number of elements") },
MethodInfo { name: "len", signature: "() -> Number", doc: None },
MethodInfo { name: "isEmpty", signature: "() -> Boolean", doc: None },
MethodInfo { name: "first", signature: "() -> any", doc: Some("First element") },
MethodInfo { name: "last", signature: "() -> any", doc: Some("Last element") },
MethodInfo { name: "get", signature: "(index: Number) -> any", doc: None },
MethodInfo { name: "contains", signature: "(value: any) -> Boolean", doc: None },
MethodInfo { name: "indexOf", signature: "(value: any) -> Number", doc: None },
MethodInfo { name: "slice", signature: "(start: Number, end?: Number) -> List", doc: None },
MethodInfo { name: "reverse", signature: "() -> List", doc: None },
MethodInfo { name: "join", signature: "(delim?: String) -> String", doc: None },
MethodInfo { name: "map", signature: "(field: String) -> NumberList | StringList | List", doc: Some("Extract field from each Object element") },
MethodInfo { name: "filter", signature: "(field: String, value?: any) -> List", doc: Some("Filter by field value or truthy field") },
MethodInfo { name: "find", signature: "(field: String, value?: any) -> any", doc: Some("Find first element matching field condition") },
MethodInfo { name: "sort", signature: "(field: String) -> List", doc: Some("Sort by field value") },
]),
("StringList", vec![
MethodInfo { name: "length", signature: "() -> Number", doc: None },
MethodInfo { name: "len", signature: "() -> Number", doc: None },

View File

@@ -2,7 +2,7 @@ use dexpr::{ast::value::Value, compiler::Compiler, parser, vm::VM};
use rust_decimal_macros::dec;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = include_str!("basic_long.dexpr");
let input = include_str!("../examples/basic_long.dexpr");
let ast = parser::program(input)?;

View File

@@ -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 <EFBFBD><EFBFBD><EFBFBD> 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> {
@@ -78,6 +99,7 @@ pub enum OpCodeByte {
// Result
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",
}
}

View File

@@ -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

230
src/vm/builtins.rs Normal file
View File

@@ -0,0 +1,230 @@
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 &reg 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 &reg 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()),
Value::List(l) => Decimal::from(l.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::<Decimal>().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<Decimal, VMError> {
match val {
Value::Number(n) => Ok(*n),
other => Err(VMError::RuntimeError(format!(
"{}() requires a number argument, got {}",
name,
other.type_name()
))),
}
}

749
src/vm/methods.rs Normal file
View File

@@ -0,0 +1,749 @@
use crate::ast::value::Value;
use rust_decimal::{prelude::ToPrimitive, Decimal};
use smol_str::{SmolStr, StrExt};
use std::rc::Rc;
use super::error::VMError;
use super::vm::VM;
impl<'a> VM<'a> {
/// Dispatch a method call on a value.
///
/// Uses `std::mem::take` to temporarily move the value out of the register,
/// avoiding clones when dispatching to type-specific handlers.
pub(super) fn dispatch_method(
&mut self,
dest: usize,
obj: usize,
method: &str,
args: &[Value],
) -> Result<(), VMError> {
// Take the value out to avoid borrow conflicts (register read + write).
let obj_val = std::mem::take(&mut self.registers[obj]);
let result = match &obj_val {
Value::String(_) => self.dispatch_string_method_inner(&obj_val, method, args),
Value::StringList(_) => self.dispatch_string_list_method_inner(&obj_val, method, args),
Value::NumberList(_) => self.dispatch_number_list_method_inner(&obj_val, method, args),
Value::Object(_) => self.dispatch_object_method_inner(&obj_val, method, args),
Value::List(_) => self.dispatch_list_method_inner(&obj_val, method, args),
_ => {
// Try external methods for any type
let type_name: SmolStr = obj_val.type_name().into();
let key = (type_name, SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(&obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: obj_val.type_name(),
method: SmolStr::from(method),
})
}
}
};
// Put the object back, then set dest (if dest == obj, result overwrites it — that's fine).
self.registers[obj] = obj_val;
self.registers[dest] = result?;
Ok(())
}
fn dispatch_string_method_inner(
&self,
obj_val: &Value,
method: &str,
args: &[Value],
) -> Result<Value, VMError> {
let s = match obj_val {
Value::String(s) => s,
_ => unreachable!(),
};
match method {
"upper" => Ok(Value::String(s.to_uppercase_smolstr())),
"lower" => Ok(Value::String(s.to_lowercase_smolstr())),
"trim" => Ok(Value::String(SmolStr::new(s.trim()))),
"trimStart" => Ok(Value::String(SmolStr::new(s.trim_start()))),
"trimEnd" => Ok(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<SmolStr> = s.split(delim.as_str()).map(SmolStr::new).collect();
Ok(Value::StringList(Rc::new(parts)))
}
_ => 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)) => {
Ok(Value::String(SmolStr::new(s.replace(old.as_str(), new.as_str()))))
}
_ => 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) => Ok(Value::Boolean(s.starts_with(prefix.as_str()))),
_ => 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) => Ok(Value::Boolean(s.ends_with(suffix.as_str()))),
_ => 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) => Ok(Value::Boolean(s.contains(substr.as_str()))),
_ => Err(VMError::RuntimeError(
"contains() requires a string substring".to_string(),
)),
}
}
"length" => Ok(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) => Ok(Value::String(SmolStr::new(c.to_string()))),
None => Ok(Value::Null),
}
}
_ => 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<char> = 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 {
Ok(Value::String(SmolStr::new("")))
} else {
let end = end.min(chars.len());
let result: String = chars[start..end].iter().collect();
Ok(Value::String(SmolStr::new(result)))
}
}
_ => Err(VMError::RuntimeError(
"substring() requires a number start index".to_string(),
)),
}
}
_ => {
let key = (SmolStr::new_static("String"), SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: "String",
method: SmolStr::from(method),
})
}
}
}
}
fn dispatch_string_list_method_inner(
&self,
obj_val: &Value,
method: &str,
args: &[Value],
) -> Result<Value, VMError> {
let list = match obj_val {
Value::StringList(l) => l,
_ => unreachable!(),
};
match method {
"length" | "len" => Ok(Value::Number(Decimal::from(list.len()))),
"isEmpty" => Ok(Value::Boolean(list.is_empty())),
"first" => Ok(list.first().map(|s| Value::String(s.clone())).unwrap_or(Value::Null)),
"last" => Ok(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);
Ok(list.get(index).map(|s| Value::String(s.clone())).unwrap_or(Value::Null))
}
_ => 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) => Ok(Value::Boolean(list.contains(s))),
_ => 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);
Ok(idx.map(|i| Value::Number(Decimal::from(i))).unwrap_or(Value::Number(Decimal::from(-1))))
}
_ => 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()
};
Ok(Value::StringList(Rc::new(list[start..end].to_vec())))
}
_ => Err(VMError::RuntimeError("slice() requires a number index".to_string())),
}
}
"reverse" => {
let mut reversed = list.to_vec();
reversed.reverse();
Ok(Value::StringList(Rc::new(reversed)))
}
"sort" => {
let mut sorted = list.to_vec();
sorted.sort();
Ok(Value::StringList(Rc::new(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::<Vec<_>>().join(delim);
Ok(Value::String(SmolStr::new(result)))
}
_ => {
let key = (SmolStr::new_static("StringList"), SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: "StringList",
method: SmolStr::from(method),
})
}
}
}
}
fn dispatch_number_list_method_inner(
&self,
obj_val: &Value,
method: &str,
args: &[Value],
) -> Result<Value, VMError> {
let list = match obj_val {
Value::NumberList(l) => l,
_ => unreachable!(),
};
match method {
"length" | "len" => Ok(Value::Number(Decimal::from(list.len()))),
"isEmpty" => Ok(Value::Boolean(list.is_empty())),
"first" => Ok(list.first().map(|n| Value::Number(*n)).unwrap_or(Value::Null)),
"last" => Ok(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);
Ok(list.get(index).map(|n| Value::Number(*n)).unwrap_or(Value::Null))
}
_ => 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) => Ok(Value::Boolean(list.contains(n))),
_ => 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);
Ok(idx.map(|i| Value::Number(Decimal::from(i))).unwrap_or(Value::Number(Decimal::from(-1))))
}
_ => 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()
};
Ok(Value::NumberList(Rc::new(list[start..end].to_vec())))
}
_ => Err(VMError::RuntimeError("slice() requires a number index".to_string())),
}
}
"reverse" => {
let mut reversed = list.to_vec();
reversed.reverse();
Ok(Value::NumberList(Rc::new(reversed)))
}
"sort" => {
let mut sorted = list.to_vec();
sorted.sort();
Ok(Value::NumberList(Rc::new(sorted)))
}
"sum" => {
let sum: Decimal = list.iter().sum();
Ok(Value::Number(sum))
}
"avg" => {
if list.is_empty() {
Ok(Value::Null)
} else {
let sum: Decimal = list.iter().sum();
let avg = sum / Decimal::from(list.len());
Ok(Value::Number(avg))
}
}
"min" => Ok(list.iter().min().map(|n| Value::Number(*n)).unwrap_or(Value::Null)),
"max" => Ok(list.iter().max().map(|n| Value::Number(*n)).unwrap_or(Value::Null)),
_ => {
let key = (SmolStr::new_static("NumberList"), SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: "NumberList",
method: SmolStr::from(method),
})
}
}
}
}
fn dispatch_object_method_inner(
&self,
obj_val: &Value,
method: &str,
args: &[Value],
) -> Result<Value, VMError> {
let map = match obj_val {
Value::Object(m) => m,
_ => unreachable!(),
};
match method {
"keys" => {
let keys: Vec<SmolStr> = map.keys().cloned().collect();
Ok(Value::StringList(Rc::new(keys)))
}
"values" => {
let vals: Vec<Value> = map.values().cloned().collect();
if vals.is_empty() {
Ok(Value::StringList(Rc::new(Vec::new())))
} else if vals.iter().all(|v| matches!(v, Value::String(_))) {
let strings: Vec<SmolStr> = vals
.into_iter()
.map(|v| match v {
Value::String(s) => s,
_ => unreachable!(),
})
.collect();
Ok(Value::StringList(Rc::new(strings)))
} else if vals.iter().all(|v| matches!(v, Value::Number(_))) {
let numbers: Vec<Decimal> = vals
.into_iter()
.map(|v| match v {
Value::Number(n) => n,
_ => unreachable!(),
})
.collect();
Ok(Value::NumberList(Rc::new(numbers)))
} else {
Ok(Value::List(Rc::new(vals)))
}
}
"length" | "len" => Ok(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) => Ok(Value::Boolean(map.contains_key(key))),
_ => 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) => Ok(map.get(key).cloned().unwrap_or(Value::Null)),
_ => Err(VMError::RuntimeError(
"get() requires a string key".to_string(),
)),
}
}
_ => {
let key = (SmolStr::new_static("Object"), SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: "Object",
method: SmolStr::from(method),
})
}
}
}
}
fn dispatch_list_method_inner(
&self,
obj_val: &Value,
method: &str,
args: &[Value],
) -> Result<Value, VMError> {
let list = match obj_val {
Value::List(l) => l,
_ => unreachable!(),
};
match method {
"length" | "len" => Ok(Value::Number(Decimal::from(list.len()))),
"isEmpty" => Ok(Value::Boolean(list.is_empty())),
"first" => Ok(list.first().cloned().unwrap_or(Value::Null)),
"last" => Ok(list.last().cloned().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);
Ok(list.get(index).cloned().unwrap_or(Value::Null))
}
_ => 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()));
}
Ok(Value::Boolean(list.contains(&args[0])))
}
"indexOf" => {
if args.is_empty() {
return Err(VMError::RuntimeError("indexOf() requires an argument".to_string()));
}
let idx = list.iter().position(|item| item == &args[0]);
Ok(idx.map(|i| Value::Number(Decimal::from(i))).unwrap_or(Value::Number(Decimal::from(-1))))
}
"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()
};
Ok(Value::List(Rc::new(list[start..end].to_vec())))
}
_ => Err(VMError::RuntimeError("slice() requires a number index".to_string())),
}
}
"reverse" => {
let mut reversed = list.to_vec();
reversed.reverse();
Ok(Value::List(Rc::new(reversed)))
}
"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 strings: Vec<String> = list.iter().map(|v| format!("{}", v)).collect();
Ok(Value::String(SmolStr::new(strings.join(delim))))
}
"map" => {
// Property shorthand: list.map("fieldName") extracts a field from each Object element
if args.is_empty() {
return Err(VMError::RuntimeError("map() requires a field name argument".to_string()));
}
match &args[0] {
Value::String(field) => {
let mut values: Vec<Value> = Vec::with_capacity(list.len());
for item in list.iter() {
match item {
Value::Object(map) => {
values.push(map.get(field.as_str()).cloned().unwrap_or(Value::Null));
}
_ => {
return Err(VMError::RuntimeError(format!(
"map() requires all elements to be Objects, got {}",
item.type_name()
)));
}
}
}
// Smart return type: if all values are the same primitive type, return typed list
if values.is_empty() {
return Ok(Value::List(Rc::new(values)));
}
if values.iter().all(|v| matches!(v, Value::Number(_))) {
let nums: Vec<Decimal> = values
.into_iter()
.map(|v| match v {
Value::Number(n) => n,
_ => unreachable!(),
})
.collect();
Ok(Value::NumberList(Rc::new(nums)))
} else if values.iter().all(|v| matches!(v, Value::String(_))) {
let strings: Vec<SmolStr> = values
.into_iter()
.map(|v| match v {
Value::String(s) => s,
_ => unreachable!(),
})
.collect();
Ok(Value::StringList(Rc::new(strings)))
} else {
Ok(Value::List(Rc::new(values)))
}
}
_ => Err(VMError::RuntimeError("map() requires a string field name".to_string())),
}
}
"filter" => {
// Property shorthand:
// list.filter("active") — filter by truthy boolean field
// list.filter("field", value) — filter where field == value
if args.is_empty() {
return Err(VMError::RuntimeError("filter() requires a field name argument".to_string()));
}
match &args[0] {
Value::String(field) => {
let filtered: Result<Vec<Value>, VMError> = list
.iter()
.filter_map(|item| {
match item {
Value::Object(map) => {
let field_val = map.get(field.as_str()).cloned().unwrap_or(Value::Null);
if args.len() > 1 {
// filter("field", value) — equality check
if field_val == args[1] {
Some(Ok(item.clone()))
} else {
None
}
} else {
// filter("field") — truthy check
match &field_val {
Value::Boolean(b) => if *b { Some(Ok(item.clone())) } else { None },
Value::Null => None,
_ => Some(Ok(item.clone())),
}
}
}
_ => Some(Err(VMError::RuntimeError(format!(
"filter() requires all elements to be Objects, got {}",
item.type_name()
)))),
}
})
.collect();
Ok(Value::List(Rc::new(filtered?)))
}
_ => Err(VMError::RuntimeError("filter() requires a string field name".to_string())),
}
}
"find" => {
// Property shorthand: list.find("field", value) — first element where field == value
if args.is_empty() {
return Err(VMError::RuntimeError("find() requires a field name argument".to_string()));
}
match &args[0] {
Value::String(field) => {
for item in list.iter() {
match item {
Value::Object(map) => {
let field_val = map.get(field.as_str()).cloned().unwrap_or(Value::Null);
if args.len() > 1 {
if field_val == args[1] {
return Ok(item.clone());
}
} else {
// find("field") — first with truthy field
match &field_val {
Value::Boolean(b) => if *b { return Ok(item.clone()); },
Value::Null => {},
_ => return Ok(item.clone()),
}
}
}
_ => {
return Err(VMError::RuntimeError(format!(
"find() requires all elements to be Objects, got {}",
item.type_name()
)));
}
}
}
Ok(Value::Null)
}
_ => Err(VMError::RuntimeError("find() requires a string field name".to_string())),
}
}
"sort" => {
// Property shorthand: list.sort("field") — sort by field value
if args.is_empty() {
return Err(VMError::RuntimeError("sort() on List requires a field name argument".to_string()));
}
match &args[0] {
Value::String(field) => {
let mut sorted = list.to_vec();
sorted.sort_by(|a, b| {
let a_val = match a {
Value::Object(map) => map.get(field.as_str()).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
let b_val = match b {
Value::Object(map) => map.get(field.as_str()).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
match (&a_val, &b_val) {
(Value::Number(a), Value::Number(b)) => a.cmp(b),
(Value::String(a), Value::String(b)) => a.cmp(b),
_ => std::cmp::Ordering::Equal,
}
});
Ok(Value::List(Rc::new(sorted)))
}
_ => Err(VMError::RuntimeError("sort() requires a string field name".to_string())),
}
}
_ => {
let key = (SmolStr::new_static("List"), SmolStr::from(method));
if let Some(ext_method) = self.external_methods.as_ref().and_then(|m| m.get(&key)) {
ext_method(obj_val, args).map_err(VMError::RuntimeError)
} else {
Err(VMError::MethodNotFound {
type_name: "List",
method: SmolStr::from(method),
})
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
mod builtins;
mod debug_info;
pub mod error;
mod methods;
mod vm;
pub use debug_info::DebugInfo;

View File

@@ -1,9 +1,10 @@
use crate::{ast::value::Value, bytecode::BytecodeReader, opcodes::OpCodeByte};
use bumpalo::Bump;
use std::rc::Rc;
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 smallvec::SmallVec;
use smol_str::SmolStr;
/// Type alias for external (host) functions
pub type ExternalFn = Box<dyn Fn(&[Value]) -> Result<Value, String>>;
@@ -33,36 +34,25 @@ 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<SmolStr, Value, 64>,
// Last expression result (returned by execute)
last_result: Value,
// External (host) functions — lazily allocated
external_functions: Option<FxHashMap<SmolStr, ExternalFn>>,
pub(super) external_functions: Option<FxHashMap<SmolStr, ExternalFn>>,
// External (host) methods per type — lazily allocated
external_methods: Option<FxHashMap<(SmolStr, SmolStr), ExternalMethod>>,
pub(super) external_methods: Option<FxHashMap<(SmolStr, SmolStr), ExternalMethod>>,
// 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],
}
@@ -79,7 +69,6 @@ impl<'a> VM<'a> {
last_result: Value::Null,
external_functions: None,
external_methods: None,
heap: Bump::new(),
debug_info: None,
#[cfg(debug_assertions)]
debug: false,
@@ -153,7 +142,6 @@ impl<'a> VM<'a> {
self.registers = [const { Value::Null }; MAX_REGISTERS];
self.last_result = Value::Null;
// Preserve globals
self.heap = Bump::new();
}
/// Execute the bytecode program and return the last expression result
@@ -195,7 +183,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 +228,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)]
@@ -406,12 +399,12 @@ impl<'a> VM<'a> {
#[inline]
fn handle_load_global(&mut self) -> Result<(), VMError> {
let reg = self.read_register_checked()?;
let name = self.reader.read_string().map_err(VMError::BytecodeError)?;
let name = self.reader.read_str().map_err(VMError::BytecodeError)?;
let value = self
.globals
.get(&name)
.ok_or_else(|| VMError::UndefinedVariable(name.clone()))?;
.get(name)
.ok_or_else(|| VMError::UndefinedVariable(SmolStr::from(name)))?;
self.registers[reg] = value.clone();
log_debug!(self,
@@ -424,12 +417,12 @@ impl<'a> VM<'a> {
/// Handle StoreGlobal opcode - store register to global variable
#[inline]
fn handle_store_global(&mut self) -> Result<(), VMError> {
let name = self.reader.read_string().map_err(VMError::BytecodeError)?;
let name = self.reader.read_str().map_err(VMError::BytecodeError)?;
let reg = self.read_register_checked()?;
self
.globals
.insert(name.clone(), self.registers[reg].clone());
.insert(SmolStr::from(name), self.registers[reg].clone());
log_debug!(self,
"StoreGlobal global.{} = r{} ({})",
@@ -464,6 +457,54 @@ 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 mut result = String::with_capacity(a_str.len() + b_str.len());
result.push_str(a_str);
result.push_str(b_str);
self.registers[dest] = Value::String(result.into());
}
(Value::String(a_str), other) => {
let b_cow = value_to_string(other);
let mut result = String::with_capacity(a_str.len() + b_cow.len());
result.push_str(a_str);
result.push_str(&b_cow);
self.registers[dest] = Value::String(result.into());
}
(other, Value::String(b_str)) => {
let a_cow = value_to_string(other);
let mut result = String::with_capacity(a_cow.len() + b_str.len());
result.push_str(&a_cow);
result.push_str(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
// ============================================================================
@@ -556,6 +597,8 @@ impl<'a> VM<'a> {
}
// String in Object (key check)
(Value::String(key), Value::Object(map)) => map.contains_key(key),
// Value in List
(needle_val, Value::List(list)) => list.contains(needle_val),
(needle_val, haystack_val) => {
return Err(VMError::InvalidOperation {
operation: "in",
@@ -614,26 +657,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);
let a_cow = value_to_string(&self.registers[a]);
let b_cow = value_to_string(&self.registers[b]);
let mut result = String::with_capacity(a_cow.len() + b_cow.len());
result.push_str(&a_cow);
result.push_str(&b_cow);
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(),
});
}
}
log_debug!(self, "Concat r{} = r{} + r{}", dest, a, b);
Ok(())
@@ -643,13 +679,49 @@ impl<'a> VM<'a> {
fn handle_get_property(&mut self) -> Result<(), VMError> {
let dest = self.read_register_checked()?;
let obj = self.read_register_checked()?;
let prop = self.reader.read_string().map_err(VMError::BytecodeError)?;
let prop = self.reader.read_str().map_err(VMError::BytecodeError)?;
match &self.registers[obj] {
Value::Object(map) => {
let value = map.get(&prop).cloned().unwrap_or(Value::Null);
let value = map.get(prop).cloned().unwrap_or(Value::Null);
self.registers[dest] = value;
}
Value::List(list) => {
// Property projection: list.field → extract field from each Object element
let mut values: Vec<Value> = Vec::with_capacity(list.len());
for item in list.iter() {
match item {
Value::Object(map) => {
values.push(map.get(prop).cloned().unwrap_or(Value::Null));
}
_ => {
return Err(VMError::RuntimeError(format!(
"Cannot access property '{}' on non-Object element in List (got {})",
prop,
item.type_name()
)));
}
}
}
// Smart return type: NumberList/StringList when homogeneous
if values.is_empty() {
self.registers[dest] = Value::List(Rc::new(values));
} else if values.iter().all(|v| matches!(v, Value::Number(_))) {
let nums: Vec<Decimal> = values.into_iter().map(|v| match v {
Value::Number(n) => n,
_ => unreachable!(),
}).collect();
self.registers[dest] = Value::NumberList(Rc::new(nums));
} else if values.iter().all(|v| matches!(v, Value::String(_))) {
let strings: Vec<SmolStr> = values.into_iter().map(|v| match v {
Value::String(s) => s,
_ => unreachable!(),
}).collect();
self.registers[dest] = Value::StringList(Rc::new(strings));
} else {
self.registers[dest] = Value::List(Rc::new(values));
}
}
other => {
return Err(VMError::RuntimeError(format!(
"Cannot access property '{}' on type {}",
@@ -665,13 +737,13 @@ impl<'a> VM<'a> {
/// Handle SetProperty opcode - set a field on an Object (in-place on register)
fn handle_set_property(&mut self) -> Result<(), VMError> {
let obj = self.read_register_checked()?;
let prop = self.reader.read_string().map_err(VMError::BytecodeError)?;
let prop = self.reader.read_str().map_err(VMError::BytecodeError)?;
let val = self.read_register_checked()?;
let value = self.registers[val].clone();
match &mut self.registers[obj] {
Value::Object(map) => {
map.insert(prop.clone(), value);
Rc::make_mut(map).insert(SmolStr::from(prop), value);
}
other => {
return Err(VMError::RuntimeError(format!(
@@ -689,538 +761,21 @@ impl<'a> VM<'a> {
fn handle_method_call(&mut self) -> Result<(), VMError> {
let dest = self.read_register_checked()?;
let obj = self.read_register_checked()?;
let method = self.reader.read_string().map_err(VMError::BytecodeError)?;
let method = self.reader.read_str().map_err(VMError::BytecodeError)?;
// Read argument registers
let arg_count = self
.reader
.read_byte()
.map_err(VMError::BytecodeError)? as usize;
let mut args = Vec::with_capacity(arg_count);
let mut args: SmallVec<[Value; 4]> = SmallVec::with_capacity(arg_count);
for _ in 0..arg_count {
let reg = self.read_register_checked()?;
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<SmolStr> = 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<char> = 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::<Vec<_>>().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<SmolStr> = 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<Value> = 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<SmolStr> = 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<Decimal> = 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,48 +787,16 @@ 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;
let mut arg_regs = Vec::with_capacity(arg_count);
let mut arg_regs: SmallVec<[usize; 4]> = SmallVec::with_capacity(arg_count);
for _ in 0..arg_count {
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(())
@@ -1282,20 +805,21 @@ impl<'a> VM<'a> {
/// Handle CallExternal opcode - call host function by name
fn handle_call_external(&mut self) -> Result<(), VMError> {
let dest = self.read_register_checked()?;
let name = self.reader.read_string().map_err(VMError::BytecodeError)?;
let name = self.reader.read_str().map_err(VMError::BytecodeError)?;
let arg_count = self.reader.read_byte().map_err(VMError::BytecodeError)? as usize;
let mut args = Vec::with_capacity(arg_count);
let mut args: SmallVec<[Value; 4]> = SmallVec::with_capacity(arg_count);
for _ in 0..arg_count {
let reg = self.read_register_checked()?;
args.push(self.registers[reg].clone());
}
let name_key = SmolStr::from(name);
let func = self
.external_functions
.as_ref()
.and_then(|m| m.get(&name))
.ok_or_else(|| VMError::RuntimeError(format!("Undefined function: {}", name)))?;
.and_then(|m| m.get(&name_key))
.ok_or_else(|| VMError::RuntimeError(format!("Undefined function: {}", name_key)))?;
let result = func(&args).map_err(VMError::RuntimeError)?;
self.registers[dest] = result;
@@ -1415,3 +939,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)),
}
}

144
tests/data_driven_tests.rs Normal file
View File

@@ -0,0 +1,144 @@
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::rc::Rc;
use std::str::FromStr;
#[derive(serde::Deserialize)]
struct TestCase {
name: String,
code: String,
#[serde(default)]
globals: HashMap<String, ValueDef>,
expected: ValueDef,
}
#[derive(serde::Deserialize)]
struct ValueDef {
#[serde(rename = "type")]
typ: String,
#[serde(default)]
value: Option<serde_json::Value>,
}
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<String, serde_json::Value>) -> 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(Rc::new(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<TestCase> =
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.");
}

View File

@@ -3,6 +3,7 @@ use indexmap::IndexMap;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal_macros::dec;
use smol_str::SmolStr;
use std::rc::Rc;
/// Helper to run code and get the value of "result" variable
fn run_and_get_result(code: &str) -> Value {
@@ -628,7 +629,7 @@ fn test_numberlist_contains() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![dec!(1), dec!(2), dec!(3), dec!(4)]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![dec!(1), dec!(2), dec!(3), dec!(4)])));
vm.execute().expect("execute");
assert_eq!(vm.get_global("result").unwrap(), &Value::Boolean(true));
}
@@ -639,7 +640,7 @@ fn test_numberlist_indexof() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![dec!(1), dec!(2), dec!(3)]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![dec!(1), dec!(2), dec!(3)])));
vm.execute().expect("execute");
assert_eq!(vm.get_global("result").unwrap(), &Value::Number(dec!(2)));
}
@@ -650,7 +651,7 @@ fn test_numberlist_slice() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![dec!(10), dec!(20), dec!(30), dec!(40)]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![dec!(10), dec!(20), dec!(30), dec!(40)])));
vm.execute().expect("execute");
// slice(1,3) = [20, 30], sum = 50
assert_eq!(vm.get_global("result").unwrap(), &Value::Number(dec!(50)));
@@ -662,7 +663,7 @@ fn test_numberlist_reverse() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![dec!(1), dec!(2), dec!(3)]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![dec!(1), dec!(2), dec!(3)])));
vm.execute().expect("execute");
assert_eq!(vm.get_global("result").unwrap(), &Value::Number(dec!(3)));
}
@@ -673,7 +674,7 @@ fn test_numberlist_sort() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![dec!(30), dec!(10), dec!(20)]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![dec!(30), dec!(10), dec!(20)])));
vm.execute().expect("execute");
assert_eq!(vm.get_global("result").unwrap(), &Value::Number(dec!(10)));
}
@@ -684,7 +685,7 @@ fn test_numberlist_isempty() {
let mut compiler = Compiler::new();
let bytecode = compiler.compile(ast).expect("compile");
let mut vm = VM::new(&bytecode);
vm.set_global("nums", Value::NumberList(vec![]));
vm.set_global("nums", Value::NumberList(Rc::new(vec![])));
vm.execute().expect("execute");
assert_eq!(vm.get_global("result").unwrap(), &Value::Boolean(true));
}
@@ -959,27 +960,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]
@@ -1036,7 +1029,7 @@ fn make_object(entries: Vec<(&str, Value)>) -> Value {
for (k, v) in entries {
map.insert(SmolStr::new(k), v);
}
Value::Object(map)
Value::Object(Rc::new(map))
}
#[test]
@@ -1121,7 +1114,7 @@ fn test_object_method_keys() {
let result = run_expr_with_globals("person.keys()", vec![("person", obj)]);
assert_eq!(
result,
Value::StringList(vec![SmolStr::new("name"), SmolStr::new("age")])
Value::StringList(Rc::new(vec![SmolStr::new("name"), SmolStr::new("age")]))
);
}
@@ -1185,7 +1178,7 @@ fn test_object_method_values_strings() {
let result = run_expr_with_globals("obj.values()", vec![("obj", obj)]);
assert_eq!(
result,
Value::StringList(vec![SmolStr::new("x"), SmolStr::new("y")])
Value::StringList(Rc::new(vec![SmolStr::new("x"), SmolStr::new("y")]))
);
}
@@ -1196,7 +1189,7 @@ fn test_object_method_values_numbers() {
("b", Value::Number(dec!(2))),
]);
let result = run_expr_with_globals("obj.values()", vec![("obj", obj)]);
assert_eq!(result, Value::NumberList(vec![dec!(1), dec!(2)]));
assert_eq!(result, Value::NumberList(Rc::new(vec![dec!(1), dec!(2)])));
}
#[test]
@@ -1342,3 +1335,608 @@ fn test_from_json_full_workflow() {
let r = run_and_get_result_with_globals(code, vec![("customer", customer)]);
assert_eq!(r, Value::String("premium".into()));
}
// ==================== LIST (ARRAY OF OBJECTS) TESTS ====================
/// Build the invoice items test data
fn kalemler() -> Value {
let items: Vec<Value> = vec![
make_kalem(1, "Web Uygulama Gelistirme", 1, "Adet", dec!(45000), dec!(45000)),
make_kalem(2, "Mobil Uygulama Gelistirme", 1, "Adet", dec!(35000), dec!(35000)),
make_kalem(3, "UI/UX Tasarim Hizmeti", 40, "Saat", dec!(750), dec!(30000)),
make_kalem(4, "Sunucu Bakim Sozlesmesi (Yillik)", 1, "Adet", dec!(12000), dec!(12000)),
make_kalem(5, "SSL Sertifikasi", 3, "Adet", dec!(500), dec!(1500)),
make_kalem(6, "Veritabani Yonetimi", 12, "Ay", dec!(2000), dec!(24000)),
make_kalem(7, "API Entegrasyon Hizmeti", 1, "Adet", dec!(18000), dec!(18000)),
make_kalem(8, "Bulut Altyapi Kurulumu", 1, "Adet", dec!(8000), dec!(8000)),
make_kalem(9, "Siber Guvenlik Danismanligi", 20, "Saat", dec!(900), dec!(18000)),
make_kalem(10, "E-posta Sunucu Yapilandirmasi", 1, "Adet", dec!(3500), dec!(3500)),
make_kalem(11, "Yedekleme Sistemi Kurulumu", 1, "Adet", dec!(5000), dec!(5000)),
make_kalem(12, "SEO Optimizasyonu", 1, "Adet", dec!(7500), dec!(7500)),
make_kalem(13, "Egitim ve Dokumantasyon", 8, "Saat", dec!(600), dec!(4800)),
make_kalem(14, "Performans Testi ve Raporlama", 1, "Adet", dec!(6000), dec!(6000)),
make_kalem(15, "Teknik Destek Paketi (6 Ay)", 1, "Adet", dec!(9000), dec!(9000)),
];
Value::List(Rc::new(items))
}
fn make_kalem(sira_no: i64, adi: &str, miktar: i64, birim: &str, birim_fiyat: rust_decimal::Decimal, tutar: rust_decimal::Decimal) -> Value {
let mut map = IndexMap::new();
map.insert(SmolStr::new("siraNo"), Value::Number(rust_decimal::Decimal::from(sira_no)));
map.insert(SmolStr::new("adi"), Value::String(SmolStr::new(adi)));
map.insert(SmolStr::new("miktar"), Value::Number(rust_decimal::Decimal::from(miktar)));
map.insert(SmolStr::new("birim"), Value::String(SmolStr::new(birim)));
map.insert(SmolStr::new("birimFiyat"), Value::Number(birim_fiyat));
map.insert(SmolStr::new("tutar"), Value::Number(tutar));
Value::Object(Rc::new(map))
}
// --- List: length / len ---
#[test]
fn test_list_length() {
let result = run_expr_with_globals("kalemler.length()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(15)));
}
#[test]
fn test_list_len() {
let result = run_expr_with_globals("kalemler.len()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(15)));
}
// --- List: isEmpty ---
#[test]
fn test_list_is_empty_false() {
let result = run_expr_with_globals("kalemler.isEmpty()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Boolean(false));
}
#[test]
fn test_list_is_empty_true() {
let empty = Value::List(Rc::new(vec![]));
let result = run_expr_with_globals("items.isEmpty()", vec![("items", empty)]);
assert_eq!(result, Value::Boolean(true));
}
// --- List: first / last ---
#[test]
fn test_list_first() {
let result = run_expr_with_globals("kalemler.first().adi", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("Web Uygulama Gelistirme".into()));
}
#[test]
fn test_list_last() {
let result = run_expr_with_globals("kalemler.last().adi", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("Teknik Destek Paketi (6 Ay)".into()));
}
#[test]
fn test_list_first_empty() {
let empty = Value::List(Rc::new(vec![]));
let result = run_expr_with_globals("items.first()", vec![("items", empty)]);
assert_eq!(result, Value::Null);
}
#[test]
fn test_list_last_empty() {
let empty = Value::List(Rc::new(vec![]));
let result = run_expr_with_globals("items.last()", vec![("items", empty)]);
assert_eq!(result, Value::Null);
}
// --- List: get ---
#[test]
fn test_list_get() {
let result = run_expr_with_globals("kalemler.get(2).adi", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("UI/UX Tasarim Hizmeti".into()));
}
#[test]
fn test_list_get_out_of_bounds() {
let result = run_expr_with_globals("kalemler.get(100)", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Null);
}
// --- List: contains ---
#[test]
fn test_list_contains() {
let items = Value::List(Rc::new(vec![
Value::Number(dec!(1)),
Value::Number(dec!(2)),
Value::Number(dec!(3)),
]));
let result = run_expr_with_globals("items.contains(2)", vec![("items", items)]);
assert_eq!(result, Value::Boolean(true));
}
#[test]
fn test_list_contains_false() {
let items = Value::List(Rc::new(vec![
Value::Number(dec!(1)),
Value::Number(dec!(2)),
]));
let result = run_expr_with_globals("items.contains(5)", vec![("items", items)]);
assert_eq!(result, Value::Boolean(false));
}
// --- List: indexOf ---
#[test]
fn test_list_index_of() {
let items = Value::List(Rc::new(vec![
Value::String("a".into()),
Value::String("b".into()),
Value::String("c".into()),
]));
let result = run_expr_with_globals(r#"items.indexOf("b")"#, vec![("items", items)]);
assert_eq!(result, Value::Number(dec!(1)));
}
#[test]
fn test_list_index_of_not_found() {
let items = Value::List(Rc::new(vec![
Value::String("a".into()),
]));
let result = run_expr_with_globals(r#"items.indexOf("z")"#, vec![("items", items)]);
assert_eq!(result, Value::Number(dec!(-1)));
}
// --- List: slice ---
#[test]
fn test_list_slice() {
let result = run_expr_with_globals("kalemler.slice(0, 3).length()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(3)));
}
#[test]
fn test_list_slice_to_end() {
let result = run_expr_with_globals("kalemler.slice(13).length()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(2)));
}
// --- List: reverse ---
#[test]
fn test_list_reverse() {
let result = run_expr_with_globals("kalemler.reverse().first().adi", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("Teknik Destek Paketi (6 Ay)".into()));
}
// --- List: join ---
#[test]
fn test_list_join() {
let items = Value::List(Rc::new(vec![
Value::Number(dec!(1)),
Value::Number(dec!(2)),
Value::Number(dec!(3)),
]));
let result = run_expr_with_globals(r#"items.join(", ")"#, vec![("items", items)]);
assert_eq!(result, Value::String("1, 2, 3".into()));
}
// --- List: map (property shorthand) ---
#[test]
fn test_list_map_number_field() {
// map("tutar") should return NumberList when all values are Number
let result = run_expr_with_globals(r#"kalemler.map("tutar").sum()"#, vec![("kalemler", kalemler())]);
// 45000+35000+30000+12000+1500+24000+18000+8000+18000+3500+5000+7500+4800+6000+9000 = 227300
assert_eq!(result, Value::Number(dec!(227300)));
}
#[test]
fn test_list_map_string_field() {
// map("birim") should return StringList when all values are String
let result = run_expr_with_globals(r#"kalemler.map("birim").contains("Saat")"#, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Boolean(true));
}
#[test]
fn test_list_map_string_field_join() {
let items = Value::List(Rc::new(vec![
make_kalem(1, "A", 1, "X", dec!(10), dec!(10)),
make_kalem(2, "B", 1, "Y", dec!(20), dec!(20)),
]));
let result = run_expr_with_globals(r#"items.map("adi").join(", ")"#, vec![("items", items)]);
assert_eq!(result, Value::String("A, B".into()));
}
// --- The main use case: kalemler.map("tutar").sum() * kdvOrani ---
#[test]
fn test_list_map_sum_multiply() {
let code = r#"kalemler.map("tutar").sum() * kdvOrani"#;
let result = run_expr_with_globals(code, vec![
("kalemler", kalemler()),
("kdvOrani", Value::Number(dec!(0.20))),
]);
// 227300 * 0.20 = 45460
assert_eq!(result, Value::Number(dec!(45460.00)));
}
#[test]
fn test_list_map_avg() {
let result = run_expr_with_globals(r#"kalemler.map("tutar").avg()"#, vec![("kalemler", kalemler())]);
// 227300 / 15 = 15153.333...
let avg = result.clone();
match avg {
Value::Number(n) => {
let rounded = n.round_dp(2);
assert_eq!(rounded, dec!(15153.33));
}
_ => panic!("Expected Number, got {:?}", avg),
}
}
#[test]
fn test_list_map_min() {
let result = run_expr_with_globals(r#"kalemler.map("tutar").min()"#, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(1500)));
}
#[test]
fn test_list_map_max() {
let result = run_expr_with_globals(r#"kalemler.map("tutar").max()"#, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(45000)));
}
// --- List: filter ---
#[test]
fn test_list_filter_by_value() {
let result = run_expr_with_globals(
r#"kalemler.filter("birim", "Saat").length()"#,
vec![("kalemler", kalemler())],
);
// Items 3, 9, 13 have birim="Saat"
assert_eq!(result, Value::Number(dec!(3)));
}
#[test]
fn test_list_filter_by_value_sum() {
let result = run_expr_with_globals(
r#"kalemler.filter("birim", "Saat").map("tutar").sum()"#,
vec![("kalemler", kalemler())],
);
// 30000 + 18000 + 4800 = 52800
assert_eq!(result, Value::Number(dec!(52800)));
}
#[test]
fn test_list_filter_by_boolean_field() {
let items = Value::List(Rc::new(vec![
{
let mut m = IndexMap::new();
m.insert(SmolStr::new("name"), Value::String("A".into()));
m.insert(SmolStr::new("active"), Value::Boolean(true));
Value::Object(Rc::new(m))
},
{
let mut m = IndexMap::new();
m.insert(SmolStr::new("name"), Value::String("B".into()));
m.insert(SmolStr::new("active"), Value::Boolean(false));
Value::Object(Rc::new(m))
},
{
let mut m = IndexMap::new();
m.insert(SmolStr::new("name"), Value::String("C".into()));
m.insert(SmolStr::new("active"), Value::Boolean(true));
Value::Object(Rc::new(m))
},
]));
let result = run_expr_with_globals(r#"items.filter("active").length()"#, vec![("items", items)]);
assert_eq!(result, Value::Number(dec!(2)));
}
// --- List: find ---
#[test]
fn test_list_find_by_value() {
let result = run_expr_with_globals(
r#"kalemler.find("siraNo", 5).adi"#,
vec![("kalemler", kalemler())],
);
assert_eq!(result, Value::String("SSL Sertifikasi".into()));
}
#[test]
fn test_list_find_not_found() {
let result = run_expr_with_globals(
r#"kalemler.find("siraNo", 99)"#,
vec![("kalemler", kalemler())],
);
assert_eq!(result, Value::Null);
}
// --- List: sort ---
#[test]
fn test_list_sort_by_field() {
let result = run_expr_with_globals(
r#"kalemler.sort("tutar").first().adi"#,
vec![("kalemler", kalemler())],
);
// Smallest tutar is 1500 (SSL Sertifikasi)
assert_eq!(result, Value::String("SSL Sertifikasi".into()));
}
#[test]
fn test_list_sort_by_field_last() {
let result = run_expr_with_globals(
r#"kalemler.sort("tutar").last().adi"#,
vec![("kalemler", kalemler())],
);
// Largest tutar is 45000 (Web Uygulama Gelistirme)
assert_eq!(result, Value::String("Web Uygulama Gelistirme".into()));
}
// --- List: in operator ---
#[test]
fn test_list_in_operator() {
let items = Value::List(Rc::new(vec![
Value::String("a".into()),
Value::String("b".into()),
Value::String("c".into()),
]));
let result = run_expr_with_globals(r#""b" in items"#, vec![("items", items)]);
assert_eq!(result, Value::Boolean(true));
}
#[test]
fn test_list_in_operator_false() {
let items = Value::List(Rc::new(vec![
Value::Number(dec!(1)),
Value::Number(dec!(2)),
]));
let result = run_expr_with_globals("5 in items", vec![("items", items)]);
assert_eq!(result, Value::Boolean(false));
}
// --- List: len() builtin function ---
#[test]
fn test_list_builtin_len() {
let result = run_expr_with_globals("len(kalemler)", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(15)));
}
// --- List: from_json ---
#[test]
fn test_list_from_json() {
let json = r#"[{"a": 1}, {"a": 2}]"#;
let val = Value::from_json(json).unwrap();
match &val {
Value::List(list) => {
assert_eq!(list.len(), 2);
match &list[0] {
Value::Object(map) => assert_eq!(map.get("a"), Some(&Value::Number(dec!(1)))),
other => panic!("Expected Object, got {:?}", other),
}
}
other => panic!("Expected List, got {:?}", other),
}
}
#[test]
fn test_list_from_json_mixed() {
// Mixed types in array → List
let json = r#"[1, "hello", true]"#;
let val = Value::from_json(json).unwrap();
match &val {
Value::List(list) => {
assert_eq!(list.len(), 3);
assert_eq!(list[0], Value::Number(dec!(1)));
assert_eq!(list[1], Value::String("hello".into()));
assert_eq!(list[2], Value::Boolean(true));
}
other => panic!("Expected List, got {:?}", other),
}
}
// --- List: serialization round-trip ---
#[test]
fn test_list_serialize_deserialize() {
let original = Value::List(Rc::new(vec![
Value::Number(dec!(42)),
Value::String("hello".into()),
Value::Boolean(true),
]));
let bytes = original.serialize();
let (deserialized, _) = Value::deserialize(&bytes).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_list_serialize_deserialize_objects() {
let original = kalemler();
let bytes = original.serialize();
let (deserialized, _) = Value::deserialize(&bytes).unwrap();
assert_eq!(original, deserialized);
}
// --- List: chained operations ---
#[test]
fn test_list_chain_filter_map_sum() {
// Filter only "Adet" items, then sum their tutars
let code = r#"kalemler.filter("birim", "Adet").map("tutar").sum()"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
// Adet items: 45000+35000+12000+1500+18000+8000+3500+5000+7500+6000+9000 = 150500
assert_eq!(result, Value::Number(dec!(150500)));
}
#[test]
fn test_list_chain_filter_length() {
let code = r#"kalemler.filter("birim", "Ay").length()"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
// Only item 6 (Veritabani Yonetimi) has birim="Ay"
assert_eq!(result, Value::Number(dec!(1)));
}
#[test]
fn test_list_chain_sort_slice_map() {
// Sort by tutar, take top 3, get names
let code = r#"kalemler.sort("tutar").reverse().slice(0, 3).map("adi").join(", ")"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("Web Uygulama Gelistirme, Mobil Uygulama Gelistirme, UI/UX Tasarim Hizmeti".into()));
}
// --- Object values() now returns List for mixed types ---
#[test]
fn test_object_values_mixed_returns_list() {
let mut map = IndexMap::new();
map.insert(SmolStr::new("name"), Value::String("Alice".into()));
map.insert(SmolStr::new("age"), Value::Number(dec!(30)));
let obj = Value::Object(Rc::new(map));
let result = run_expr_with_globals("item.values().length()", vec![("item", obj)]);
assert_eq!(result, Value::Number(dec!(2)));
}
// --- List: Display ---
#[test]
fn test_list_display() {
let list = Value::List(Rc::new(vec![
Value::Number(dec!(1)),
Value::String("hello".into()),
Value::Boolean(true),
]));
assert_eq!(format!("{}", list), r#"[1, "hello", true]"#);
}
// --- List: complex invoice scenario ---
#[test]
fn test_invoice_total_with_kdv() {
let code = r#"
araToplam = kalemler.map("tutar").sum()
kdv = araToplam * kdvOrani
result = araToplam + kdv
"#;
let result = run_and_get_result_with_globals(code, vec![
("kalemler", kalemler()),
("kdvOrani", Value::Number(dec!(0.20))),
]);
// 227300 + 45460 = 272760
assert_eq!(result, Value::Number(dec!(272760.00)));
}
#[test]
fn test_invoice_item_count_by_birim() {
let code = r#"
adetSayisi = kalemler.filter("birim", "Adet").length()
saatSayisi = kalemler.filter("birim", "Saat").length()
result = adetSayisi + saatSayisi
"#;
let result = run_and_get_result_with_globals(code, vec![("kalemler", kalemler())]);
// 11 Adet + 3 Saat = 14
assert_eq!(result, Value::Number(dec!(14)));
}
#[test]
fn test_invoice_find_and_access_property() {
let code = r#"kalemler.find("adi", "SSL Sertifikasi").birimFiyat"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(500)));
}
// ==================== LIST PROPERTY PROJECTION TESTS ====================
#[test]
fn test_list_property_projection_number() {
// kalemler.tutar → NumberList
let result = run_expr_with_globals("kalemler.tutar.sum()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(227300)));
}
#[test]
fn test_list_property_projection_string() {
// kalemler.adi → StringList
let result = run_expr_with_globals(r#"kalemler.birim.contains("Saat")"#, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Boolean(true));
}
#[test]
fn test_list_property_projection_sum_multiply() {
// The main use case: kalemler.tutar.sum() * kdvOrani
let code = "kalemler.tutar.sum() * kdvOrani";
let result = run_expr_with_globals(code, vec![
("kalemler", kalemler()),
("kdvOrani", Value::Number(dec!(0.20))),
]);
assert_eq!(result, Value::Number(dec!(45460.00)));
}
#[test]
fn test_list_property_projection_min_max() {
let result = run_expr_with_globals("kalemler.tutar.min()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(1500)));
let result = run_expr_with_globals("kalemler.tutar.max()", vec![("kalemler", kalemler())]);
assert_eq!(result, Value::Number(dec!(45000)));
}
#[test]
fn test_list_property_projection_join() {
let items = Value::List(Rc::new(vec![
make_kalem(1, "A", 1, "X", dec!(10), dec!(10)),
make_kalem(2, "B", 1, "Y", dec!(20), dec!(20)),
]));
let result = run_expr_with_globals(r#"items.adi.join(", ")"#, vec![("items", items)]);
assert_eq!(result, Value::String("A, B".into()));
}
#[test]
fn test_list_property_projection_chained_with_filter() {
// filter → projection: kalemler.filter("birim", "Saat").tutar.sum()
let code = r#"kalemler.filter("birim", "Saat").tutar.sum()"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
// Saat items: 30000 + 18000 + 4800 = 52800
assert_eq!(result, Value::Number(dec!(52800)));
}
#[test]
fn test_list_property_projection_empty() {
let empty = Value::List(Rc::new(vec![]));
let result = run_expr_with_globals("items.tutar", vec![("items", empty)]);
match result {
Value::List(l) => assert!(l.is_empty()),
_ => panic!("Expected empty List, got {:?}", result),
}
}
#[test]
fn test_list_property_projection_with_sort() {
// sort → projection
let code = r#"kalemler.sort("tutar").adi.first()"#;
let result = run_expr_with_globals(code, vec![("kalemler", kalemler())]);
assert_eq!(result, Value::String("SSL Sertifikasi".into()));
}
#[test]
fn test_invoice_full_scenario() {
let code = r#"
araToplam = kalemler.tutar.sum()
kdv = araToplam * kdvOrani
genelToplam = araToplam + kdv
saatlikHizmetler = kalemler.filter("birim", "Saat").tutar.sum()
result = genelToplam
"#;
let result = run_and_get_result_with_globals(code, vec![
("kalemler", kalemler()),
("kdvOrani", Value::Number(dec!(0.20))),
]);
assert_eq!(result, Value::Number(dec!(272760.00)));
}

1288
tests/test_cases.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "dexpr-wasm"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
[lib]

View File

@@ -37,6 +37,9 @@ fn value_to_json(val: &Value) -> serde_json::Value {
.collect();
serde_json::Value::Object(obj)
}
Value::List(list) => {
serde_json::Value::Array(list.iter().map(|v| value_to_json(v)).collect())
}
}
}