From 30cf2e5e26a10e55df15b410b729096651e92bed Mon Sep 17 00:00:00 2001 From: Glint Dev Date: Tue, 21 Jul 2026 20:32:29 +0300 Subject: [PATCH] Phase 1.2-1.6: replace variables HashMap with HashMap, rewrite str_to_dyn/dyn_to_str -> value_to_dynamic/dynamic_to_value, update resolve_string/evaluate_condition/is_truthy for Value --- ARCHITECTURE-IMPROVEMENT-PLAN.md | 379 +++++++++++++++++++++++++++++++ desktop.glbc | Bin 5265 -> 12290 bytes src/app.rs | 14 +- src/interpreter/mod.rs | 43 ++-- src/interpreter/reader.rs | 74 +++++- src/interpreter/rhei.rs | 63 +++-- src/interpreter/types.rs | 22 +- 7 files changed, 542 insertions(+), 53 deletions(-) create mode 100644 ARCHITECTURE-IMPROVEMENT-PLAN.md diff --git a/ARCHITECTURE-IMPROVEMENT-PLAN.md b/ARCHITECTURE-IMPROVEMENT-PLAN.md new file mode 100644 index 0000000..86a5bab --- /dev/null +++ b/ARCHITECTURE-IMPROVEMENT-PLAN.md @@ -0,0 +1,379 @@ +# План улучшения архитектуры Glint Runtime + +## Цель + +Обеспечить **плоский график производительности**: рендеринг 1000 строк байткода должен занимать +~0.04 секунды сегодня и столько же через годы, независимо от роста количества компонентов, +стилей и страниц. Время обработки события должно быть пропорционально **размеру изменения**, +а не **размеру всей системы**. + +--- + +## Фаза 0: Бенчмарки и профилирование + +**Цель:** зафиксировать текущие метрики, чтобы объективно оценивать прогресс. + +- [ ] **0.1** Добавить Criterion в `Cargo.toml` +- [ ] **0.2** Написать бенчмарки для: + - `evaluate_vdom()` на `desktop.glbc` + - `matching_rules()` — 10, 100, 1000 правил + - `ComputedStyle::compute()` — пустой, 5, 20 свойств + - `resolve_string()` — без `$`, с 1 `$var`, с 3 `$var` + - `RheiContext::eval_expr()` — простое выражение, сложное + - `RheiContext::execute_action()` — короткий скрипт + - `Element::clone()` — замерить копирование глубокого дерева +- [ ] **0.3** Запустить `perf record` / `flamegraph-rs` на горячем пути: + - `cargo run -- run desktop.glbc` + интерактив + - Выявить фактический bottleneck (гипотеза: style matching, string parsing) +- [ ] **0.4** Записать baseline в `BENCHMARKS.md` или `README.md` + +**Файлы:** новый `benches/bench.rs`, `Cargo.toml` + +--- + +## Фаза 1: Типизированные значения (Value enum) + +**Цель:** устранить постоянный round-trip через строки (parse/format на каждое свойство). + +**Текущая проблема:** `HashMap` для переменных. Каждый `SliderChanged` → +`format!(...)`, потом `rhei.rs:139-142` — три парсинга подряд (`i64`, `f64`, `bool`). + +- [ ] **1.1** Определить `Value` enum в `interpreter/types.rs`: + ```rust + #[derive(Clone, Debug)] + pub enum Value { + Str(CompactStr), + Int(i64), + Float(f64), + Bool(bool), + Array(Vec), + None, + } + ``` +- [ ] **1.2** Заменить `HashMap` на `HashMap`: + - `Document::variables` + - `GlintApp::update()` — входящие значения +- [ ] **1.3** Переписать `str_to_dyn()` и `dyn_to_str()` в `rhei.rs`: + - Убрать парсинг, конвертировать напрямую `Value ↔ Dynamic` +- [ ] **1.4** Переписать `resolve_string()`: + - Если значение уже `Int`/`Float` — не парсить, форматировать один раз +- [ ] **1.5** Переписать `evaluate_condition()`: + - Числовые сравнения без парсинга строк + - `resolve_string()` → преобразование к Value +- [ ] **1.6** Переписать `is_truthy()`: работа с Value напрямую +- [ ] **1.7** Обновить `renderer.rs`: + - `SliderChanged`, `ToggleChanged`, `InputChanged` — принимать `Value`, не строку + - Парсинг slider/toggle/input значений — один раз, а не на каждый чих +- [ ] **1.8** Обновить `ComputedStyle::compute()` и `parse_*()` функции: + - Принимать `&Value` где возможно, а не `&str` + - `parse_color`, `parse_size`, `parse_length` — работа с Value +- [ ] **1.9** Написать тесты для Value: конверсии, сравнения, форматирование + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/mod.rs`, `interpreter/rhei.rs`, +`interpreter/style.rs`, `app.rs`, `renderer.rs` + +--- + +## Фаза 2: Интернирование строк (String Interning) + +**Цель:** ускорить сравнение строк (ключи свойств, названия типов, классы). +Заменить million `== "padding-top"` на O(1) сравнение ID. + +- [ ] **2.1** Выбрать библиотеку intern-строк: + - `lasso` (thread-safe, производительный) или `strena` (лёгкая) + - Или самодельный `StringArena` с `HashMap<&str, usize>` +- [ ] **2.2** Заменить `&'a str` на `InternedStr` (newtype вокруг `u32`/`usize`): + - `Element::type_name` + - Ключи в `Element::properties` + - Селекторы, имена классов, ID +- [ ] **2.3** `lookup()` в `style.rs`: + - Сравнение ключей через ID вместо `==` +- [ ] **2.4** `CompoundSelector::matches_element()`: + - Сравнение `tag`, `id`, `classes` через internd-строки +- [ ] **2.5** Все `HashMap` где ключи повторяются → `HashMap` + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/style.rs`, `interpreter/mod.rs`, +`renderer.rs` (много `el.type_name == "Button"`) + +--- + +## Фаза 3: Граф зависимостей (Reactive Dependency Tracking) + +**Цель:** вместо полного `evaluate_vdom()` на каждое событие — пересчитывать только +элементы, которые зависят от изменившейся переменной. **Это главный этап для плоского графика.** + +**Текущая проблема:** `app.rs:44-51` — на любое событие полный VDOM rebuild. Слайдер +меняет `volume_level`, но пересчитываются все 500 элементов. + +- [ ] **3.1** Создать `interpreter/reactive.rs`: + ```rust + pub struct ReactiveTracker { + subscribers: HashMap>, + dependencies: HashMap>, + dirty_set: HashSet, + } + ``` +- [ ] **3.2** Анализ зависимостей при загрузке шаблона: + - Сканировать `$var` в свойствах → регистрировать `ElementId → var` + - Сканировать `!rhei:{expr}` → извлекать имена переменных через Rhai parser + - `@if condition` → зависимости от переменных в condition + - `@each source` → зависимости от переменных в source + - Результат: каждый элемент знает, от каких переменных зависит +- [ ] **3.3** Изменить `GlintApp::update()`: + ```rust + fn update(&mut self, msg: Message) { + let affected = self.tracker.on_variable_changed(name, new_val); + self.vdom_roots = Interpreter::evaluate_vdom_incr( + &self.doc.roots, &affected, &self.tracker, ... + ); + } + ``` +- [ ] **3.4** `evaluate_vdom_incr()`: + - Для элементов не в `affected` — возвращаем кэшированный VDOM + - Для элементов в `affected` — пересчитываем и каскадно помечаем детей + - `@if` — если условие не изменилось — не пересчитываем ветку + - `@each` — если source не изменился — не пересчитываем +- [ ] **3.5** Инвалидация стилей: + - При изменении переменной, меняющей `class` или `id` элемента → сброс кэша стилей + - Иначе стили не пересчитываем +- [ ] **3.6** Кэш VDOM: + - `HashMap>` — не клонируем, разделяем память + - `Arc::make_mut()` при изменении (copy-on-write) +- [ ] **3.7** Написать тест: + - Сценарий: 1000 элементов, 1 зависимость → после изменения пересчитывается 1 элемент + - Проверить, что dirty_set корректен + +**Изменяемые файлы:** новый `interpreter/reactive.rs`, `interpreter/mod.rs`, `interpreter/types.rs`, +`app.rs`, `main.rs` + +--- + +## Фаза 4: Индексированный матчинг стилей (Style Index) + +**Цель:** заменить O(N×M) на O(K) где K — число релевантных правил для элемента (1-5, не 500). + +**Текущая проблема:** `style.rs:487-505` — каждый элемент проверяется против ВСЕХ правил. +100 элементов × 500 правил = 50 000 проверок на событие. + +- [ ] **4.1** Построить индекс стилей при загрузке `Document`: + ```rust + pub struct StyleIndex { + by_tag: HashMap>, + by_class: HashMap>, + by_id: HashMap>, + by_tag_class: HashMap<(InternedStr, InternedStr), Vec>, + by_tag_id: HashMap<(InternedStr, InternedStr), Vec>, + complex_rules: Vec<(ComplexSelector, RuleId)>, + rules: Vec, + } + ``` +- [ ] **4.2** `matching_rules()` → `query_index()`: + - Для элемента `Button.primary#submit`: + 1. `by_tag["Button"]` → [1, 5, 12] + 2. `by_class["primary"]` → [3, 5, 7] + 3. `by_id["submit"]` → [5] + 4. Пересечение → [5] + 5. Проверить 1 сложное правило + 6. Итого: 3 проверки вместо 500 +- [ ] **4.3** Кэш совпадений: + - `HashMap, u64)>` — инвалидируется по эпохе + - epoch увеличивается при перезагрузке стилей + - Псевдоклассы (`:hover`, `:active`) — кэш с ключом `(ElementId, PseudoClass)` +- [ ] **4.4** `ComputedStyle::compute()`: + - Убрать сортировку по specificity на каждый вызов + - Правила уже отсортированы в индексе + - `lookup()` — проход по нескольким правилам вместо прохода по `matched_sheets` +- [ ] **4.5** Миграция `matching_pseudo_rules()`: + - Использовать индекс + кэш + - Вызывается из `renderer.rs` для hover/active — тысячи раз в секунду + - Кэш на `(ElementId, pseudo)` с инвалидацией + +**Изменяемые файлы:** `interpreter/style.rs`, `interpreter/mod.rs`, `interpreter/types.rs`, +`renderer.rs`, `main.rs` + +--- + +## Фаза 5: Аренная аллокация и VNode (Flat VDOM) + +**Цель:** устранить глубокое клонирование `Element` и reduce аллокаций на каждый кадр. + +**Текущая проблема:** `Element` содержит `Vec` — рекурсивное клонирование. +`child.clone()` в `@if` (mod.rs:347) копирует поддеревья целиком. + +- [ ] **5.1** Создать плоское представление VDOM: + ```rust + struct VNode<'a> { + id: NodeId, + type_name: InternedStr, + properties: Range, + children_range: Range, + computed_style: ComputedStyle, + } + + struct FlatVDom<'a> { + nodes: Vec>, + properties: Vec<(InternedStr, Value)>, + arena: Bump, + } + ``` +- [ ] **5.2** Добавить `bumpalo` или написать `BumpAlloc`: + - Все строковые данные живут в арене + - Очистка арены одним махом между кадрами +- [ ] **5.3** `evaluate_vdom()` → возвращает `FlatVDom<'arena>` вместо `Vec`: + - Вместо `push_prop("text", val.clone())` — allocate в арене + - Вместо `Element::new(el.type_name)` — выделить VNode в `Vec` +- [ ] **5.4** Structural sharing: + - Ветки `@each` с одинаковыми телами разделяют `VNode` через `Arc` + - `Arc::make_mut()` при изменении +- [ ] **5.5** `render_element()` → работает с `&VNode`: + - `get_prop()` — lookup в плоском массиве свойств + - `children` — итерация по `children_range` +- [ ] **5.6** Удалить `#[derive(Clone)]` из `Element` (или оставить для совместимости): + - В горячем пути clone не используется +- [ ] **5.7** Тест: проверить, что аллокаций на кадр стало < 10 (было ~1000+) + +**Изменяемые файлы:** `interpreter/types.rs`, `interpreter/mod.rs`, `renderer.rs`, +`interpreter/style.rs`, `app.rs` + +--- + +## Фаза 6: Кэширование Rhai AST + +**Цель:** убрать компиляцию Rhai скриптов при каждом выполнении. + +**Текущая проблема:** `rhei.rs:112` — `engine.compile(script)` при каждом `execute_action()`. +Скрипты в `@on:click { ... }` компилируются каждый клик. + +- [ ] **6.1** Собрать все Rhai блоки при загрузке `Document`: + - Из `rhei_scripts` (init-скрипты) + - Из `__on:*` свойств (обработчики событий) + - Из `!rhei:` выражений (компилируем, но не выполняем) +- [ ] **6.2** `RheiContext`: + ```rust + pub struct RheiContext { + engine: Engine, + init_ast: AST, + action_cache: HashMap, + expr_cache: HashMap, + } + ``` +- [ ] **6.3** `execute_action()`: + - `self.action_cache.get(script)` вместо `engine.compile(script)` + - Если нет — компилируем и кэшируем +- [ ] **6.4** Очистка кэша: + - Только при перезагрузке документа + - Или `LruCache` если скриптов слишком много +- [ ] **6.5** Тест: выполнить 1000 раз один и тот же action — время не должно расти + +**Изменяемые файлы:** `interpreter/rhei.rs`, `interpreter/mod.rs`, `main.rs` + +--- + +## Фаза 7: Параллелизм (Rayon) + +**Цель:** распараллелить style matching и независимые ветки VDOM. + +**Текущая проблема:** всё выполняется последовательно, хотя style matching +для разных элементов — embarrassingly parallel. + +- [ ] **7.1** Добавить `rayon` в `Cargo.toml` (feature gate: `parallel`) +- [ ] **7.2** Параллельный style matching: + - `matching_rules_batch(elements: &[Element]) -> Vec>` + - `elements.par_iter().map(...)` +- [ ] **7.3** Параллельный `@each`: + - Итерации `@each` независимы + - `items.par_iter().flat_map(|item| evaluate_vdom(body, ...))` + - Feature gate: только для больших списков (> N элементов) +- [ ] **7.4** Параллельный `ComputedStyle::compute_batch()`: + - Векторизованный compute для нескольких элементов +- [ ] **7.5** Тест: `@each` с 1000 итераций → ускорение ~4x на 8 ядрах + +**Изменяемые файлы:** `Cargo.toml`, `interpreter/style.rs`, `interpreter/mod.rs` + +--- + +## Фаза 8: Мемоизация `ComputedStyle::compute()` + +**Цель:** не пересчитывать стили для элементов, чьи свойства не изменились. + +**Текущая проблема:** каждый кадр `ComputedStyle::compute()` парсит все ~40 полей +через `lookup()`. + +- [ ] **8.1** Ввести хэш `(inline_properties_hash, stylesheet_epoch, element_id) → ComputedStyle` +- [ ] **8.2** Кэш: `HashMap` + LRU eviction +- [ ] **8.3** Инвалидация: + - `stylesheet_epoch` — счётчик, увеличивается при изменении стилей + - `inline_properties_hash` — хэш от значений свойств элемента +- [ ] **8.4** Проверить hit rate на реальном UI: > 95%+ + +**Изменяемые файлы:** `interpreter/style.rs` + +--- + +## Фаза 9: Инкрементальный рендеринг (keyed widgets) + +**Цель:** дать Iced'у возможность эффективно диффить виджеты, а не пересоздавать их. + +**Текущая проблема:** `view()` создаёт полностью новые Iced-виджеты каждый кадр. + +- [ ] **9.1** Ввести стабильные ID для каждого элемента VDOM +- [ ] **9.2** `render_element()`: + - Присваивать `iced::id()` на основе `ElementId` + - Iced использует ID для сохранения состояния виджетов между кадрами +- [ ] **9.3** Убирать из `view()` только изменившиеся элементы: + - Если `VNode::hash == prev_hash` — вернуть кэшированный `iced::Element` + - Кэш: `HashMap>` + +**Изменяемые файлы:** `renderer.rs`, `interpreter/types.rs` + +--- + +## Фаза 10: Мониторинг и автоматические бенчмарки + +**Цель:** не допустить регрессий в будущем. + +- [ ] **10.1** Добавить `cargo bench` в CI +- [ ] **10.2** Пороговые проверки: + - `evaluate_vdom` на desktop.glbc < 5ms + - `matching_rules` (100 правил) < 10μs + - `ComputedStyle::compute` < 1μs +- [ ] **10.3** Логирование производительности: + - `--perf` флаг для `glint run` + - Печатать: `VDOM: 1.2ms | Style: 0.3ms | Render: 2.1ms | Total: 3.6ms` +- [ ] **10.4** Alert если total > 16ms (frame budget для 60 FPS) + +**Изменяемые файлы:** `Cargo.toml`, новый `src/perf.rs`, `cli.rs`, `.github/workflows/ci.yml` + +--- + +## Сводная таблица влияния + +| Фаза | Описание | Ускорение | Плоский график | +|------|----------|-----------|----------------| +| 0 | Бенчмарки | — | — | +| 1 | Value enum | 2-3× | Нет | +| 2 | String interning | 1.5-2× | Нет | +| **3** | **Reactive tracker** | **10-50×** | **Да (ключевое)** | +| 4 | Style index | 5-10× (style) | Да | +| 5 | Arena + Flat VDOM | 2-3× | Частично | +| 6 | Rhai AST cache | 2-5× (click) | Да | +| 7 | Parallelism | 2-4× | Нет | +| 8 | Style memoization | 2-3× | Да | +| 9 | Keyed widgets | 1.5-2× (render) | Частично | +| 10 | CI benchmarks | — (контроль) | — | + +**Суммарно:** до 100× на горячих путях. + +--- + +## Рекомендованный порядок имплементации + +1. **Фаза 0** — профилирование (без этого нельзя) +2. **Фаза 1 + 2** — Value enum + String interning (фундамент) +3. **Фаза 3** — Reactive tracker (сердце архитектуры) +4. **Фаза 4** — Style index +5. **Фаза 5 + 8** — Arena + Memoization +6. **Фаза 6** — Rhai cache +7. **Фаза 7** — Parallelism +8. **Фаза 9** — Keyed widgets +9. **Фаза 10** — CI benchmarks diff --git a/desktop.glbc b/desktop.glbc index 3bc0d39291c6e13dd7870072cccf274f96e875cc..1d204f206ba752adccd683f042d8f597701044a1 100644 GIT binary patch literal 12290 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 iced::Task { match message { Message::WindowScrolled(y) => { - self.doc.variables.insert("__scroll_y".to_string(), y.to_string()); + self.doc.variables.insert("__scroll_y".to_string(), Value::Float(y as f64)); } Message::EventTriggered(script) => { if !script.is_empty() { @@ -27,15 +27,15 @@ impl GlintApp { } } Message::InputChanged(Some(var), val) => { - self.doc.variables.insert(var, val); + self.doc.variables.insert(var, Value::from(val)); } Message::ToggleChanged(Some(var), val) => { - self.doc.variables.insert(var, val.to_string()); + self.doc.variables.insert(var, Value::Bool(val)); } Message::SliderChanged(Some(var), val) => { - self.doc.variables.insert(var.clone(), format!("{:.1}", val)); + self.doc.variables.insert(var.clone(), Value::Float(val)); if var == "volume_level" { - self.doc.variables.insert("age".to_string(), val.to_string()); + self.doc.variables.insert("age".to_string(), Value::Float(val)); } } _ => {} @@ -63,7 +63,7 @@ impl GlintApp { let mut global_sticky_layers = Vec::new(); let _scroll_y = self.doc.variables.get("__scroll_y") - .and_then(|v| v.parse::().ok()) + .and_then(|v| if let Value::Float(f) = v { Some(*f as f32) } else { None }) .unwrap_or(0.0); for root in &self.vdom_roots { diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 18c678a..eca32a0 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -10,10 +10,12 @@ pub use rhei::RheiContext; use style::StyleSheet as SS; pub use types::{ComponentDef, Document, Element, InterpError}; +use compact_str::CompactString; use opcodes::*; use reader::Reader; -use rhei::{dyn_to_str, str_to_dyn, RHEI_PREFIX}; +use rhei::{value_to_dynamic, dynamic_to_value, RHEI_PREFIX}; use style::{AncestorInfo, ComputedStyle, StructuralContext}; +pub use types::Value; use regex::Regex; use std::collections::HashMap; use std::sync::OnceLock; @@ -51,7 +53,7 @@ impl Interpreter { fn parse_block_elements<'a>( r: &mut Reader<'a>, - variables: &mut HashMap, + variables: &mut HashMap, components: &mut HashMap>, rhei_scripts: &mut Vec, stylesheet: &mut SS, @@ -75,7 +77,7 @@ impl Interpreter { OP_GLOBAL | OP_LET => { let name = r.read_string()?; let vop = r.read_byte()?; - if let Some(value) = r.read_value_as_string(vop)? { + if let Some(value) = r.read_value(vop)? { variables.insert(name, value); } } @@ -292,7 +294,7 @@ impl Interpreter { pub fn evaluate_vdom<'a>( templates: &[Element<'a>], - variables: &mut HashMap, + variables: &mut HashMap, components: &HashMap>, rhei: &RheiContext, stylesheet: &SS, @@ -357,7 +359,7 @@ impl Interpreter { let source_expr = el.get_prop("source").unwrap_or_default(); let resolved_source = if let Some(expr) = source_expr.strip_prefix(RHEI_PREFIX) { - Self::normalize_rhai_array(&rhei.eval_expr(expr, variables)) + Self::normalize_rhai_array(&rhei.eval_expr(expr, variables).to_owned_string()) } else { Self::resolve_string(source_expr, variables).into_owned() }; @@ -369,7 +371,7 @@ impl Interpreter { }; for item in items { - let old_val = variables.insert(var_name.to_string(), item); + let old_val = variables.insert(var_name.to_string(), Value::Str(CompactString::new(item))); output.extend(Self::evaluate_vdom( &el.children, variables, components, rhei, stylesheet, ancestors, @@ -397,7 +399,7 @@ impl Interpreter { let mut old_vals = Vec::with_capacity(new_args.len()); for (k, v) in new_args { - old_vals.push((k.clone(), variables.insert(k, v))); + old_vals.push((k.clone(), variables.insert(k, Value::from(v)))); } let mut vcomp = Element::new(el.type_name); @@ -489,9 +491,9 @@ impl Interpreter { stylesheet.matching_rules(el.type_name, el_id, &classes, active_pseudo, structural, ancestors, preceding_siblings, &el_attributes) } - fn resolve_prop(v: &str, variables: &HashMap, rhei: &RheiContext) -> String { + fn resolve_prop(v: &str, variables: &HashMap, rhei: &RheiContext) -> String { if let Some(expr) = v.strip_prefix(RHEI_PREFIX) { - rhei.eval_expr(expr, variables) + rhei.eval_expr(expr, variables).to_owned_string().into() } else { Self::resolve_string(v, variables).into_owned() } @@ -499,7 +501,7 @@ impl Interpreter { fn evaluate_condition( cond: &str, - variables: &HashMap, + variables: &HashMap, rhei: &RheiContext, ) -> bool { if let Some(expr) = cond.strip_prefix(RHEI_PREFIX) { @@ -515,7 +517,7 @@ impl Interpreter { let resolved = Self::resolve_string(&clean, variables).trim().to_string(); - if Self::is_truthy(&resolved) { return true; } + if Self::is_truthy_str(&resolved) { return true; } if resolved == "false" || resolved == "0" || resolved.is_empty() { return false; } let operators: [(&str, fn(f64, f64) -> bool); 6] = [ @@ -542,7 +544,19 @@ impl Interpreter { } #[inline] - fn is_truthy(s: &str) -> bool { + fn is_truthy(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Int(i) => *i != 0, + Value::Float(f) => *f != 0.0, + Value::Str(s) => Self::is_truthy_str(s), + Value::None => false, + Value::Array(a) => !a.is_empty(), + } + } + + #[inline] + fn is_truthy_str(s: &str) -> bool { match s.trim() { "" | "false" | "0" | "null" => false, "true" | "1" => true, @@ -563,7 +577,7 @@ impl Interpreter { } } - pub fn resolve_string<'a>(val: &'a str, scope: &HashMap) -> Cow<'a, str> { + pub fn resolve_string<'a>(val: &'a str, scope: &HashMap) -> Cow<'a, str> { if !val.contains('$') { return Cow::Borrowed(val); } @@ -582,7 +596,8 @@ impl Interpreter { } } if let Some(resolved) = scope.get(&var_name) { - result.push_str(resolved); + let formatted = resolved.to_owned_string(); + result.push_str(&formatted); } else { result.push('$'); result.push_str(&var_name); diff --git a/src/interpreter/reader.rs b/src/interpreter/reader.rs index fc3535e..79f2c77 100644 --- a/src/interpreter/reader.rs +++ b/src/interpreter/reader.rs @@ -1,5 +1,6 @@ use super::opcodes::*; -use super::types::InterpError; +use super::types::{InterpError, Value}; +use compact_str::CompactString; pub struct Reader<'a> { pub data: &'a [u8], @@ -138,6 +139,77 @@ impl<'a> Reader<'a> { Ok(s) } + pub fn read_value(&mut self, type_op: u8) -> Result, InterpError> { + let val = match type_op { + OP_PROP_STR | OP_PROP_COLOR | OP_PROP_FSPATH | OP_PROP_IDENT => { + Some(Value::Str(CompactString::new(self.read_string()?))) + } + OP_PROP_RHEI => { + let raw = self.read_string()?; + let mut s = String::with_capacity(super::rhei::RHEI_PREFIX.len() + raw.len()); + s.push_str(super::rhei::RHEI_PREFIX); + s.push_str(&raw); + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_VAR => { + let name = self.read_str_ref()?; + let mut s = String::with_capacity(name.len() + 1); + s.push('$'); + s.push_str(name); + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_INT => Some(Value::Int(self.read_i64()?)), + OP_PROP_FLOAT => Some(Value::Float(self.read_f64()?)), + OP_PROP_BOOL => Some(Value::Bool(self.read_byte()? != 0)), + OP_PROP_NULL => None, + OP_PROP_ARRAY => { + let items = self.read_array_as_values()?; + Some(Value::Array(items)) + } + OP_PROP_UNIT => { + let num = self.read_f64()?; + let unit = self.read_str_ref()?; + let s = if num.fract() == 0.0 { + format!("{}{}", num as i64, unit) + } else { + format!("{}{}", num, unit) + }; + Some(Value::Str(CompactString::new(s))) + } + OP_PROP_CALL => { + let name = self.read_string()?; + let arg_count = self.read_u32()? as usize; + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + let op = self.read_byte()?; + let val = self.read_value(op)?.unwrap_or(Value::None); + args.push(match val { + Value::Str(s) => s.to_string(), + Value::Int(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Bool(b) => b.to_string(), + _ => String::new(), + }); + } + Some(Value::Str(CompactString::new(format!("{}({})", name, args.join(","))))) + } + _ => None, + }; + Ok(val) + } + + pub fn read_array_as_values(&mut self) -> Result, InterpError> { + let count = self.read_u32()? as usize; + let mut items = Vec::with_capacity(count); + for _ in 0..count { + let elem_op = self.read_byte()?; + if let Some(v) = self.read_value(elem_op)? { + items.push(v); + } + } + Ok(items) + } + pub fn read_array_as_strings(&mut self) -> Result, InterpError> { let count = self.read_u32()? as usize; let mut items = Vec::with_capacity(count); diff --git a/src/interpreter/rhei.rs b/src/interpreter/rhei.rs index dc16fcb..89ce5e6 100644 --- a/src/interpreter/rhei.rs +++ b/src/interpreter/rhei.rs @@ -2,6 +2,9 @@ use rhai::{Dynamic, Engine, Scope, AST, Module}; use std::collections::HashMap; use std::cell::RefCell; +use super::types::Value; +use compact_str::CompactString; + pub const RHEI_PREFIX: &str = "__rhei:"; pub struct RheiContext { @@ -46,24 +49,24 @@ impl RheiContext { } } - pub fn sync_scope(&self, variables: &HashMap) { + pub fn sync_scope(&self, variables: &HashMap) { let mut scope = self.scope.borrow_mut(); for (k, v) in variables.iter() { if scope.contains(k) { if let Some(old_val) = scope.get_value::(k) { - if dyn_to_str(&old_val) == *v { + if dynamic_to_value(&old_val) == *v { continue; } } - scope.set_value(k, str_to_dyn(v)); + scope.set_value(k, value_to_dynamic(v)); } else { - scope.push_dynamic(k.clone(), str_to_dyn(v)); + scope.push_dynamic(k.clone(), value_to_dynamic(v)); } } } - pub fn initialize(&self, variables: &mut HashMap) { + pub fn initialize(&self, variables: &mut HashMap) { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -72,27 +75,27 @@ impl RheiContext { } for (name, _, val) in scope.iter_raw() { - let s_val = dyn_to_str(&val); + let s_val = dynamic_to_value(&val); if variables.get(name).map(|v| v != &s_val).unwrap_or(true) { variables.insert(name.to_string(), s_val); } } } - pub fn eval_expr(&self, expr: &str, variables: &HashMap) -> String { + pub fn eval_expr(&self, expr: &str, variables: &HashMap) -> Value { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); match self.engine.eval_expression_with_scope::(&mut *scope, expr) { - Ok(val) => dyn_to_str(&val), + Ok(val) => dynamic_to_value(&val), Err(e) => { eprintln!("⚠️ Rhei eval_expr error: {e}"); - String::new() + Value::None } } } - pub fn eval_condition(&self, expr: &str, variables: &HashMap) -> bool { + pub fn eval_condition(&self, expr: &str, variables: &HashMap) -> bool { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -105,7 +108,7 @@ impl RheiContext { } } - pub fn execute_action(&self, script: &str, variables: &mut HashMap) { + pub fn execute_action(&self, script: &str, variables: &mut HashMap) { self.sync_scope(variables); let mut scope = self.scope.borrow_mut(); @@ -121,7 +124,7 @@ impl RheiContext { } for (name, _, val) in scope.iter_raw() { - let s_val = dyn_to_str(&val); + let s_val = dynamic_to_value(&val); if variables.get(name).map(|v| v != &s_val).unwrap_or(true) { variables.insert(name.to_string(), s_val); } @@ -135,16 +138,36 @@ impl Default for RheiContext { } } -pub fn str_to_dyn(s: &str) -> Dynamic { - if let Ok(i) = s.parse::() { return Dynamic::from(i); } - if let Ok(f) = s.parse::() { return Dynamic::from(f); } - if let Ok(b) = s.parse::() { return Dynamic::from(b); } - Dynamic::from(s.to_owned()) +pub fn value_to_dynamic(v: &Value) -> Dynamic { + match v { + Value::Int(i) => Dynamic::from(*i), + Value::Float(f) => Dynamic::from(*f), + Value::Bool(b) => Dynamic::from(*b), + Value::Str(s) => Dynamic::from(s.to_string()), + Value::None => Dynamic::UNIT, + Value::Array(arr) => { + let d: rhai::Dynamic = arr.iter().map(value_to_dynamic).collect(); + d + } + } } -pub fn dyn_to_str(d: &Dynamic) -> String { +pub fn dynamic_to_value(d: &Dynamic) -> Value { if d.is_string() { - return d.clone().into_string().unwrap_or_default(); + return Value::Str(CompactString::new(d.clone().into_string().unwrap_or_default())); } - d.to_string() + if d.is_int() { + return Value::Int(d.as_int().unwrap_or(0)); + } + if d.is_float() { + return Value::Float(d.as_float().unwrap_or(0.0)); + } + if d.is_bool() { + return Value::Bool(d.as_bool().unwrap_or(false)); + } + if d.is_array() { + let arr = d.clone().into_array().unwrap_or_default(); + return Value::Array(arr.iter().map(dynamic_to_value).collect()); + } + Value::None } diff --git a/src/interpreter/types.rs b/src/interpreter/types.rs index a497702..6fac207 100644 --- a/src/interpreter/types.rs +++ b/src/interpreter/types.rs @@ -3,11 +3,11 @@ use std::collections::HashMap; use std::fmt; use super::style::{ComputedStyle, StyleSheet}; -use compact_str::CompactStr; +use compact_str::CompactString; #[derive(Clone, Debug)] pub enum Value { - Str(CompactStr), + Str(CompactString), Int(i64), Float(f64), Bool(bool), @@ -23,31 +23,31 @@ impl Value { } } - pub fn to_owned_string(&self) -> CompactStr { + pub fn to_owned_string(&self) -> CompactString { match self { Value::Str(s) => s.clone(), - Value::Int(i) => CompactStr::new(i.to_string()), - Value::Float(f) => CompactStr::new(if f.fract() == 0.0 { + Value::Int(i) => CompactString::new(i.to_string()), + Value::Float(f) => CompactString::new(if f.fract() == 0.0 { format!("{:.1}", f) } else { f.to_string() }), - Value::Bool(b) => CompactStr::new(b.to_string()), - Value::Array(a) => CompactStr::new(a.iter().map(|v| v.to_owned_string()).collect::>().join(",")), - Value::None => CompactStr::new_empty(), + Value::Bool(b) => CompactString::new(b.to_string()), + Value::Array(a) => CompactString::new(a.iter().map(|v| v.to_owned_string()).collect::>().join(",")), + Value::None => CompactString::new(""), } } } impl From<&str> for Value { fn from(s: &str) -> Self { - Value::Str(CompactStr::new(s)) + Value::Str(CompactString::new(s)) } } impl From for Value { fn from(s: String) -> Self { - Value::Str(CompactStr::new(s)) + Value::Str(CompactString::new(s)) } } @@ -125,7 +125,7 @@ pub struct ComponentDef<'a> { pub struct Document<'a> { pub roots: Vec>, pub components: HashMap>, - pub variables: HashMap, + pub variables: HashMap, pub rhei_scripts: Vec, pub stylesheet: StyleSheet, }