From 0fa388abebd7f3b5429ad25ef9e0f8ab9ee186c8 Mon Sep 17 00:00:00 2001 From: Duhan BALCI Date: Tue, 7 Apr 2026 15:16:19 +0300 Subject: [PATCH] 0.3.0 --- .gitignore | 2 + CLAUDE.md | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- docs/ast.md | 4 +- docs/editor.md | 6 +- docs/language_info.md | 3 +- docs/vm.md | 7 +- editor/bun.lock | 134 ++++++++ editor/dist/index.cjs | 15 +- editor/dist/index.d.cts | 2 +- editor/dist/index.d.ts | 2 +- editor/dist/index.js | 15 +- editor/package.json | 10 +- editor/src/completions.test.ts | 149 ++++++++ editor/src/completions.ts | 55 ++- justfile | 14 +- src/ast/value.rs | 51 ++- src/language_info.rs | 30 ++ src/vm/builtins.rs | 1 + src/vm/methods.rs | 255 +++++++++++++- src/vm/vm.rs | 38 +++ tests/integration_tests.rs | 605 +++++++++++++++++++++++++++++++++ wasm/Cargo.toml | 2 +- wasm/src/lib.rs | 3 + 25 files changed, 1370 insertions(+), 38 deletions(-) create mode 100644 editor/src/completions.test.ts diff --git a/.gitignore b/.gitignore index ea8c4bf..7160277 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +editor/dist +editor/node_modules diff --git a/CLAUDE.md b/CLAUDE.md index e6f4f05..037735d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index d938b56..35d0194 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,7 +304,7 @@ checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "dexpr" -version = "0.1.0" +version = "0.3.0" dependencies = [ "bumpalo", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 46bba34..afa3d37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dexpr" -version = "0.1.0" +version = "0.3.0" edition = "2021" description = "Embeddable expression evaluator and bytecode VM" license = "MIT" diff --git a/docs/ast.md b/docs/ast.md index 0c1c413..d6dae87 100644 --- a/docs/ast.md +++ b/docs/ast.md @@ -88,12 +88,13 @@ struct Spanned { node: T, span: Span } | `NumberList(Vec)` | `Vec` | Sayı listesi | | `StringList(Vec)` | `Vec` | String listesi | | `Object(IndexMap)` | `IndexMap` | Anahtar-değer nesnesi | +| `List(Vec)` | `Vec` | 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. diff --git a/docs/editor.md b/docs/editor.md index a3f6b60..1730f53 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -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ı | diff --git a/docs/language_info.md b/docs/language_info.md index c5ae710..c14f71b 100644 --- a/docs/language_info.md +++ b/docs/language_info.md @@ -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` | 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)` diff --git a/docs/vm.md b/docs/vm.md index d06119b..d822761 100644 --- a/docs/vm.md +++ b/docs/vm.md @@ -137,17 +137,18 @@ struct VM<'a> { ### String, Nesne ve Metodlar - **`handle_concat()`** — İki register'ı birleştir (karışık tip dönüşümü destekler: String, Number, Boolean otomatik olarak String'e dönüştürülür) -- **`handle_get_property()`** — Object register'ından alan oku, alan yoksa `Null` döndür +- **`handle_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) @@ -203,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"` --- diff --git a/editor/bun.lock b/editor/bun.lock index 527ca74..cc800a6 100644 --- a/editor/bun.lock +++ b/editor/bun.lock @@ -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=="], } } diff --git a/editor/dist/index.cjs b/editor/dist/index.cjs index 435024e..7c82259 100644 --- a/editor/dist/index.cjs +++ b/editor/dist/index.cjs @@ -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 { diff --git a/editor/dist/index.d.cts b/editor/dist/index.d.cts index 2c4ad07..ab31333 100644 --- a/editor/dist/index.d.cts +++ b/editor/dist/index.d.cts @@ -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; diff --git a/editor/dist/index.d.ts b/editor/dist/index.d.ts index 2c4ad07..ab31333 100644 --- a/editor/dist/index.d.ts +++ b/editor/dist/index.d.ts @@ -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; diff --git a/editor/dist/index.js b/editor/dist/index.js index 97918a7..15e913d 100644 --- a/editor/dist/index.js +++ b/editor/dist/index.js @@ -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 { diff --git a/editor/package.json b/editor/package.json index 9192a8f..190d3a2 100644 --- a/editor/package.json +++ b/editor/package.json @@ -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" } diff --git a/editor/src/completions.test.ts b/editor/src/completions.test.ts new file mode 100644 index 0000000..297c18d --- /dev/null +++ b/editor/src/completions.test.ts @@ -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"); + }); +}); diff --git a/editor/src/completions.ts b/editor/src/completions.ts index ad335e6..e37d82e 100644 --- a/editor/src/completions.ts +++ b/editor/src/completions.ts @@ -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(); 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 { diff --git a/justfile b/justfile index ddca66f..1ed6043 100644 --- a/justfile +++ b/justfile @@ -14,7 +14,7 @@ run: # --- Publish --- -# Publish to Gitea cargo registry +# Publish dexpr to Gitea cargo registry publish: cargo publish --registry gitea --allow-dirty @@ -22,11 +22,11 @@ publish: # 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 --- @@ -42,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) diff --git a/src/ast/value.rs b/src/ast/value.rs index 16e789e..924f7cc 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -15,6 +15,7 @@ pub enum Value { NumberList(Rc>), StringList(Rc>), Object(Rc>), + List(Rc>), } /// Type tag constants for serialization @@ -25,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 { @@ -63,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, "]") + } } } } @@ -78,6 +90,7 @@ impl Value { Value::NumberList(_) => TYPE_NUMBER_LIST, Value::StringList(_) => TYPE_STRING_LIST, Value::Object(_) => TYPE_OBJECT, + Value::List(_) => TYPE_LIST, } } @@ -91,6 +104,7 @@ impl Value { Value::NumberList(_) => "NumberList", Value::StringList(_) => "StringList", Value::Object(_) => "Object", + Value::List(_) => "List", } } @@ -150,6 +164,15 @@ impl Value { 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 @@ -226,6 +249,12 @@ impl From> for Value { } } +impl From> for Value { + fn from(v: Vec) -> Self { + Value::List(Rc::new(v)) + } +} + impl Value { /// Deserialize a value from bytes pub fn deserialize(bytes: &[u8]) -> Result<(Value, usize), String> { @@ -357,6 +386,22 @@ impl Value { 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)), } } @@ -426,7 +471,11 @@ impl Value { .collect(); 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) => { diff --git a/src/language_info.rs b/src/language_info.rs index 234e131..a6d66eb 100644 --- a/src/language_info.rs +++ b/src/language_info.rs @@ -121,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 { @@ -276,6 +289,23 @@ fn builtin_methods() -> Vec<(&'static str, Vec)> { 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 }, diff --git a/src/vm/builtins.rs b/src/vm/builtins.rs index d2e24b4..c4418c4 100644 --- a/src/vm/builtins.rs +++ b/src/vm/builtins.rs @@ -160,6 +160,7 @@ impl<'a> VM<'a> { 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 {}", diff --git a/src/vm/methods.rs b/src/vm/methods.rs index 397a801..e20d719 100644 --- a/src/vm/methods.rs +++ b/src/vm/methods.rs @@ -26,6 +26,7 @@ impl<'a> VM<'a> { 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(); @@ -452,9 +453,7 @@ impl<'a> VM<'a> { .collect(); Ok(Value::NumberList(Rc::new(numbers))) } else { - Err(VMError::RuntimeError( - "values() only works when all values are the same type (String or Number)".to_string(), - )) + Ok(Value::List(Rc::new(vals))) } } "length" | "len" => Ok(Value::Number(Decimal::from(map.len()))), @@ -497,4 +496,254 @@ impl<'a> VM<'a> { } } } + + fn dispatch_list_method_inner( + &self, + obj_val: &Value, + method: &str, + args: &[Value], + ) -> Result { + 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 = 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 = 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 = 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 = 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, 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), + }) + } + } + } + } } diff --git a/src/vm/vm.rs b/src/vm/vm.rs index ce8d5aa..0644e7d 100644 --- a/src/vm/vm.rs +++ b/src/vm/vm.rs @@ -597,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", @@ -684,6 +686,42 @@ impl<'a> VM<'a> { 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 = 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 = 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 = 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 {}", diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6dc64b8..8a10c33 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1334,4 +1334,609 @@ 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 = 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))); } \ No newline at end of file diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index dab5892..b5d7fde 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dexpr-wasm" -version = "0.1.0" +version = "0.3.0" edition = "2021" [lib] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index a3b6f6c..f48fa45 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -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()) + } } }