@ape-egg/vibe 2.1.4 → 2.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +48 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +137 -2
- package/compiler/src/compiler/component_tagger.rs +80 -28
- package/compiler/src/compiler/js_analyzer.rs +122 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +81 -3
- package/package.json +1 -1
- package/runtime/component.js +30 -3
- package/runtime/conditionals.js +8 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
//! Reassignment analysis: decide which global `$` state keys are compile-time
|
|
2
|
+
//! constants (safe to value-stamp into pre-rendered HTML) versus runtime-dynamic
|
|
3
|
+
//! (must be left as live `@[...]` bindings for the runtime to fill).
|
|
4
|
+
//!
|
|
5
|
+
//! Soundness rule: a wrong "constant" bakes stale content into production HTML
|
|
6
|
+
//! (a correctness bug), while a wrong "dynamic" merely forgoes the optimization
|
|
7
|
+
//! (a brief FOUC, same as today). So every uncertain case resolves to **dynamic**.
|
|
8
|
+
//!
|
|
9
|
+
//! A key is reported constant only when ALL of these hold:
|
|
10
|
+
//! 1. Its initial value is a primitive (string / number / bool / null). Objects
|
|
11
|
+
//! and arrays are never constant — their contents can be mutated through a
|
|
12
|
+
//! value alias (`const a = $.items; a.push(x)`) that a `$.key` scan cannot
|
|
13
|
+
//! see, so they are conservatively excluded.
|
|
14
|
+
//! 2. No write to `$.key` anywhere across the scanned sources — assignment,
|
|
15
|
+
//! compound assignment, `++`/`--`, `delete`, or a nested `$.key.x = …`.
|
|
16
|
+
//!
|
|
17
|
+
//! Any of these "escape" signals discards the whole optimization (every key is
|
|
18
|
+
//! treated dynamic), because they could write a key we cannot pin down:
|
|
19
|
+
//! - aliasing `$` itself (`const x = $` / `x = $`),
|
|
20
|
+
//! - reflective writes (`Object.assign($, …)` / `Object.defineProperty($, …)`),
|
|
21
|
+
//! - a source that fails to parse (we cannot prove it is write-free).
|
|
22
|
+
//!
|
|
23
|
+
//! The `$` access split mirrors Vibe's own convention: `$.key` and `$['key']`
|
|
24
|
+
//! (string literal) address GLOBAL state, while `$[expr]` with a non-literal key
|
|
25
|
+
//! is the component-state-by-id pattern (`const id = component(s); $[id].x = …`)
|
|
26
|
+
//! whose key is a generated component id, never a global key — so a non-literal
|
|
27
|
+
//! computed write is ignored, not a bail.
|
|
28
|
+
//!
|
|
29
|
+
//! Passing `$` as a plain argument (`derive($, …)`) is treated as a read, not an
|
|
30
|
+
//! escape — the vibe convention is to write state through direct `$.key = …`
|
|
31
|
+
//! member assignments, and bailing on every `f($)` would disable the optimization
|
|
32
|
+
//! for any real app.
|
|
33
|
+
|
|
34
|
+
use swc_common::{sync::Lrc, FileName, SourceMap};
|
|
35
|
+
use swc_ecma_ast::*;
|
|
36
|
+
use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
|
|
37
|
+
use swc_ecma_visit::{Visit, VisitWith};
|
|
38
|
+
|
|
39
|
+
use serde_json::{Map, Value};
|
|
40
|
+
use std::collections::HashSet;
|
|
41
|
+
|
|
42
|
+
/// Classify which `initial_state` keys are safe to treat as compile-time
|
|
43
|
+
/// constants, given every JS source in the app (module files, inline `<script>`
|
|
44
|
+
/// bodies, and HTML `on*` handler bodies). See module docs for the rules.
|
|
45
|
+
pub fn classify_constant_keys(
|
|
46
|
+
initial_state: &Map<String, Value>,
|
|
47
|
+
sources: &[String],
|
|
48
|
+
) -> HashSet<String> {
|
|
49
|
+
let mut written: HashSet<String> = HashSet::new();
|
|
50
|
+
|
|
51
|
+
for src in sources {
|
|
52
|
+
let module = match parse(src) {
|
|
53
|
+
Some(m) => m,
|
|
54
|
+
// Cannot prove a source is write-free → forfeit the whole optimization.
|
|
55
|
+
None => return HashSet::new(),
|
|
56
|
+
};
|
|
57
|
+
let mut visitor = WriteVisitor::default();
|
|
58
|
+
module.visit_with(&mut visitor);
|
|
59
|
+
if visitor.bail {
|
|
60
|
+
return HashSet::new();
|
|
61
|
+
}
|
|
62
|
+
written.extend(visitor.written);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
initial_state
|
|
66
|
+
.iter()
|
|
67
|
+
.filter(|(key, value)| is_primitive(value) && !written.contains(key.as_str()))
|
|
68
|
+
.map(|(key, _)| key.clone())
|
|
69
|
+
.collect()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// True if `src` parses as JavaScript. Used to drop heuristically-extracted
|
|
73
|
+
/// HTML attribute values that aren't actually event-handler code, so that genuine
|
|
74
|
+
/// unparseable JS sources can still trigger the conservative bail.
|
|
75
|
+
pub fn parses_as_js(src: &str) -> bool {
|
|
76
|
+
parse(src).is_some()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn is_primitive(value: &Value) -> bool {
|
|
80
|
+
matches!(
|
|
81
|
+
value,
|
|
82
|
+
Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Parse a source as a module. Handler bodies can contain top-level `return`
|
|
87
|
+
/// (`onclick="if (x) return; f()"`), which is illegal at module top level, so a
|
|
88
|
+
/// failed parse is retried wrapped in a function before giving up — writes are
|
|
89
|
+
/// still found inside the wrapper, while genuinely broken sources fall through.
|
|
90
|
+
fn parse(src: &str) -> Option<Module> {
|
|
91
|
+
parse_module(src).or_else(|| parse_module(&format!("function __vibe_wrap__(){{\n{}\n}}", src)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn parse_module(code: &str) -> Option<Module> {
|
|
95
|
+
let cm: Lrc<SourceMap> = Default::default();
|
|
96
|
+
let fm = cm.new_source_file(Lrc::new(FileName::Anon), code.to_string());
|
|
97
|
+
let syntax = Syntax::Es(EsSyntax {
|
|
98
|
+
jsx: false,
|
|
99
|
+
decorators: true,
|
|
100
|
+
..Default::default()
|
|
101
|
+
});
|
|
102
|
+
let input = StringInput::new(&fm.src, fm.start_pos, fm.end_pos);
|
|
103
|
+
let mut parser = Parser::new(syntax, input, None);
|
|
104
|
+
parser.parse_module().ok()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn is_dollar(expr: &Expr) -> bool {
|
|
108
|
+
matches!(expr, Expr::Ident(id) if id.sym.to_string() == "$")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// What a write target rooted at `$` resolves to.
|
|
112
|
+
enum DollarTarget {
|
|
113
|
+
/// `$.key`, `$.key.x…`, or `$['key']` — the top-level GLOBAL state key touched.
|
|
114
|
+
Key(String),
|
|
115
|
+
/// `$[expr]` with a non-literal key — Vibe's component-state-by-id access,
|
|
116
|
+
/// never a global key, so irrelevant to global-constant analysis.
|
|
117
|
+
ComponentState,
|
|
118
|
+
/// Not rooted at `$`.
|
|
119
|
+
None,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Walk a member chain to its base. If the base is the `$` identifier, return
|
|
123
|
+
/// the FIRST property after it (the top-level state key). `$.a.b.c` → `a`.
|
|
124
|
+
fn dollar_target(member: &MemberExpr) -> DollarTarget {
|
|
125
|
+
match &*member.obj {
|
|
126
|
+
Expr::Ident(id) if id.sym.to_string() == "$" => match &member.prop {
|
|
127
|
+
MemberProp::Ident(name) => DollarTarget::Key(name.sym.to_string()),
|
|
128
|
+
MemberProp::Computed(c) => match &*c.expr {
|
|
129
|
+
Expr::Lit(Lit::Str(s)) => DollarTarget::Key(s.value.to_string()),
|
|
130
|
+
_ => DollarTarget::ComponentState,
|
|
131
|
+
},
|
|
132
|
+
MemberProp::PrivateName(_) => DollarTarget::None,
|
|
133
|
+
},
|
|
134
|
+
Expr::Member(inner) => dollar_target(inner),
|
|
135
|
+
Expr::Paren(p) => match &*p.expr {
|
|
136
|
+
Expr::Member(inner) => dollar_target(inner),
|
|
137
|
+
_ => DollarTarget::None,
|
|
138
|
+
},
|
|
139
|
+
_ => DollarTarget::None,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[derive(Default)]
|
|
144
|
+
struct WriteVisitor {
|
|
145
|
+
written: HashSet<String>,
|
|
146
|
+
bail: bool,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
impl WriteVisitor {
|
|
150
|
+
/// Record a write whose target is (or descends from) `$`.
|
|
151
|
+
fn record_member_write(&mut self, member: &MemberExpr) {
|
|
152
|
+
match dollar_target(member) {
|
|
153
|
+
DollarTarget::Key(k) => {
|
|
154
|
+
self.written.insert(k);
|
|
155
|
+
}
|
|
156
|
+
// Component-state-by-id write — irrelevant to global keys.
|
|
157
|
+
DollarTarget::ComponentState => {}
|
|
158
|
+
DollarTarget::None => {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Detects any use of the bare `$` identifier within a subtree.
|
|
164
|
+
#[derive(Default)]
|
|
165
|
+
struct DollarUseFinder {
|
|
166
|
+
found: bool,
|
|
167
|
+
}
|
|
168
|
+
impl Visit for DollarUseFinder {
|
|
169
|
+
fn visit_ident(&mut self, id: &Ident) {
|
|
170
|
+
if id.sym.to_string() == "$" {
|
|
171
|
+
self.found = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
impl Visit for WriteVisitor {
|
|
177
|
+
fn visit_assign_expr(&mut self, n: &AssignExpr) {
|
|
178
|
+
match &n.left {
|
|
179
|
+
AssignTarget::Simple(SimpleAssignTarget::Member(m)) => self.record_member_write(m),
|
|
180
|
+
// `($.k) = …`
|
|
181
|
+
AssignTarget::Simple(SimpleAssignTarget::Paren(p)) => {
|
|
182
|
+
if let Expr::Member(m) = &*p.expr {
|
|
183
|
+
self.record_member_write(m);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Reassigning `$` itself (`$ = …`) replaces the whole state object.
|
|
187
|
+
AssignTarget::Simple(SimpleAssignTarget::Ident(id)) => {
|
|
188
|
+
if id.id.sym.to_string() == "$" {
|
|
189
|
+
self.bail = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Destructuring assignment that writes through `$` (`[$.k] = …`).
|
|
193
|
+
AssignTarget::Pat(pat) => {
|
|
194
|
+
let mut finder = DollarUseFinder::default();
|
|
195
|
+
pat.visit_with(&mut finder);
|
|
196
|
+
if finder.found {
|
|
197
|
+
self.bail = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
_ => {}
|
|
201
|
+
}
|
|
202
|
+
// Aliasing: `x = $` lets later `x.key = …` writes escape our scan.
|
|
203
|
+
if is_dollar(&n.right) {
|
|
204
|
+
self.bail = true;
|
|
205
|
+
}
|
|
206
|
+
n.visit_children_with(self);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fn visit_var_declarator(&mut self, n: &VarDeclarator) {
|
|
210
|
+
// `const x = $` — same aliasing escape as `x = $`.
|
|
211
|
+
if let Some(init) = &n.init {
|
|
212
|
+
if is_dollar(init) {
|
|
213
|
+
self.bail = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
n.visit_children_with(self);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn visit_update_expr(&mut self, n: &UpdateExpr) {
|
|
220
|
+
// `$.key++`, `--$.key`
|
|
221
|
+
if let Expr::Member(m) = &*n.arg {
|
|
222
|
+
self.record_member_write(m);
|
|
223
|
+
}
|
|
224
|
+
n.visit_children_with(self);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fn visit_unary_expr(&mut self, n: &UnaryExpr) {
|
|
228
|
+
// `delete $.key`
|
|
229
|
+
if matches!(n.op, UnaryOp::Delete) {
|
|
230
|
+
if let Expr::Member(m) = &*n.arg {
|
|
231
|
+
self.record_member_write(m);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
n.visit_children_with(self);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn visit_call_expr(&mut self, n: &CallExpr) {
|
|
238
|
+
// Reflective writes: Object.assign($, …) / Object.defineProperty($, …) /
|
|
239
|
+
// Reflect.set($, …) and friends could write any key.
|
|
240
|
+
if let Callee::Expr(callee) = &n.callee {
|
|
241
|
+
if let Expr::Member(m) = &**callee {
|
|
242
|
+
if let Expr::Ident(obj) = &*m.obj {
|
|
243
|
+
let obj = obj.sym.to_string();
|
|
244
|
+
if let MemberProp::Ident(method) = &m.prop {
|
|
245
|
+
let method = method.sym.to_string();
|
|
246
|
+
let reflective_write = matches!(
|
|
247
|
+
(obj.as_str(), method.as_str()),
|
|
248
|
+
("Object", "assign")
|
|
249
|
+
| ("Object", "defineProperty")
|
|
250
|
+
| ("Object", "defineProperties")
|
|
251
|
+
| ("Object", "setPrototypeOf")
|
|
252
|
+
| ("Reflect", "set")
|
|
253
|
+
| ("Reflect", "defineProperty")
|
|
254
|
+
| ("Reflect", "deleteProperty")
|
|
255
|
+
| ("Reflect", "setPrototypeOf")
|
|
256
|
+
);
|
|
257
|
+
if reflective_write {
|
|
258
|
+
if let Some(first) = n.args.first() {
|
|
259
|
+
if is_dollar(&first.expr) {
|
|
260
|
+
self.bail = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
n.visit_children_with(self);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[cfg(test)]
|
|
273
|
+
mod tests {
|
|
274
|
+
use super::*;
|
|
275
|
+
use serde_json::json;
|
|
276
|
+
|
|
277
|
+
fn state(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
|
278
|
+
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn primitive_never_written_is_constant() {
|
|
283
|
+
let s = state(&[("version", json!("0.1.5"))]);
|
|
284
|
+
let got = classify_constant_keys(&s, &["console.log($.version);".to_string()]);
|
|
285
|
+
assert!(got.contains("version"));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn direct_reassignment_is_dynamic() {
|
|
290
|
+
let s = state(&[("coins", json!(400)), ("version", json!("0.1.5"))]);
|
|
291
|
+
let got = classify_constant_keys(&s, &["$.coins = 500;".to_string()]);
|
|
292
|
+
assert!(!got.contains("coins"));
|
|
293
|
+
assert!(got.contains("version"));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#[test]
|
|
297
|
+
fn compound_and_update_are_dynamic() {
|
|
298
|
+
let s = state(&[("a", json!(1)), ("b", json!(1)), ("c", json!(1))]);
|
|
299
|
+
let got = classify_constant_keys(&s, &["$.a += 1; $.b++; --$.c;".to_string()]);
|
|
300
|
+
assert!(got.is_empty());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[test]
|
|
304
|
+
fn computed_string_literal_write_is_dynamic() {
|
|
305
|
+
let s = state(&[("coins", json!(400)), ("version", json!("v"))]);
|
|
306
|
+
let got = classify_constant_keys(&s, &["$['coins'] = 1;".to_string()]);
|
|
307
|
+
assert!(!got.contains("coins"));
|
|
308
|
+
assert!(got.contains("version"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn delete_is_dynamic() {
|
|
313
|
+
let s = state(&[("x", json!(1))]);
|
|
314
|
+
let got = classify_constant_keys(&s, &["delete $.x;".to_string()]);
|
|
315
|
+
assert!(!got.contains("x"));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#[test]
|
|
319
|
+
fn nested_property_write_marks_top_key_dynamic() {
|
|
320
|
+
let s = state(&[("settings", json!({ "a": 1 })), ("version", json!("v"))]);
|
|
321
|
+
let got = classify_constant_keys(&s, &["$.settings.theme = 'dark';".to_string()]);
|
|
322
|
+
assert!(!got.contains("settings"));
|
|
323
|
+
assert!(got.contains("version"));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#[test]
|
|
327
|
+
fn objects_and_arrays_are_never_constant() {
|
|
328
|
+
let s = state(&[
|
|
329
|
+
("characters", json!([])),
|
|
330
|
+
("clock", json!({ "now": 0 })),
|
|
331
|
+
("version", json!("v")),
|
|
332
|
+
]);
|
|
333
|
+
let got = classify_constant_keys(&s, &["// no writes".to_string()]);
|
|
334
|
+
assert!(!got.contains("characters"));
|
|
335
|
+
assert!(!got.contains("clock"));
|
|
336
|
+
assert!(got.contains("version"));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn aliasing_dollar_bails_everything() {
|
|
341
|
+
let s = state(&[("version", json!("v")), ("coins", json!(1))]);
|
|
342
|
+
let got = classify_constant_keys(&s, &["const x = $; x.coins = 5;".to_string()]);
|
|
343
|
+
assert!(got.is_empty());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn reassigning_dollar_bails_everything() {
|
|
348
|
+
let s = state(&[("version", json!("v"))]);
|
|
349
|
+
let got = classify_constant_keys(&s, &["let y; y = $;".to_string()]);
|
|
350
|
+
assert!(got.is_empty());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#[test]
|
|
354
|
+
fn computed_component_state_write_is_ignored() {
|
|
355
|
+
// `$[id]` / `$[id].x = …` is Vibe's component-state-by-id pattern (id is a
|
|
356
|
+
// generated component id, never a global key), so it must not bail.
|
|
357
|
+
let s = state(&[("version", json!("v"))]);
|
|
358
|
+
let got = classify_constant_keys(
|
|
359
|
+
&s,
|
|
360
|
+
&["const id = component({ open: false }); $[id].open = true;".to_string()],
|
|
361
|
+
);
|
|
362
|
+
assert!(got.contains("version"));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn computed_string_literal_global_write_is_dynamic() {
|
|
367
|
+
// A *literal* computed key IS a global-key write and must mark it dynamic.
|
|
368
|
+
let s = state(&[("coins", json!(1)), ("version", json!("v"))]);
|
|
369
|
+
let got = classify_constant_keys(&s, &["$['coins'] = 5;".to_string()]);
|
|
370
|
+
assert!(!got.contains("coins"));
|
|
371
|
+
assert!(got.contains("version"));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn object_assign_on_dollar_bails_everything() {
|
|
376
|
+
let s = state(&[("version", json!("v"))]);
|
|
377
|
+
let got = classify_constant_keys(&s, &["Object.assign($, { coins: 5 });".to_string()]);
|
|
378
|
+
assert!(got.is_empty());
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#[test]
|
|
382
|
+
fn write_in_nested_scope_is_detected() {
|
|
383
|
+
let s = state(&[("token", json!(null)), ("version", json!("v"))]);
|
|
384
|
+
let src = "function f(){ if (true) { $.token = undefined; } } const g = () => $.version;";
|
|
385
|
+
let got = classify_constant_keys(&s, &[src.to_string()]);
|
|
386
|
+
assert!(!got.contains("token"));
|
|
387
|
+
assert!(got.contains("version"));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#[test]
|
|
391
|
+
fn write_across_multiple_sources() {
|
|
392
|
+
let s = state(&[("a", json!(1)), ("b", json!(2))]);
|
|
393
|
+
let srcs = ["$.a = 1;".to_string(), "console.log($.b);".to_string()];
|
|
394
|
+
let got = classify_constant_keys(&s, &srcs);
|
|
395
|
+
assert!(!got.contains("a"));
|
|
396
|
+
assert!(got.contains("b"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn reads_do_not_mark_dynamic() {
|
|
401
|
+
let s = state(&[("version", json!("v"))]);
|
|
402
|
+
let src = "const x = $.version; if ($.version === 'v') foo($.version);";
|
|
403
|
+
let got = classify_constant_keys(&s, &[src.to_string()]);
|
|
404
|
+
assert!(got.contains("version"));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#[test]
|
|
408
|
+
fn passing_dollar_as_argument_does_not_bail() {
|
|
409
|
+
let s = state(&[("version", json!("v"))]);
|
|
410
|
+
let src = "dataInspectorState($, paths); render($);";
|
|
411
|
+
let got = classify_constant_keys(&s, &[src.to_string()]);
|
|
412
|
+
assert!(got.contains("version"));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#[test]
|
|
416
|
+
fn unparseable_source_bails_everything() {
|
|
417
|
+
let s = state(&[("version", json!("v"))]);
|
|
418
|
+
let got = classify_constant_keys(&s, &["this is (((not valid @@@".to_string()]);
|
|
419
|
+
assert!(got.is_empty());
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn handler_body_write_is_detected() {
|
|
424
|
+
let s = state(&[("overlay", json!({})), ("version", json!("v"))]);
|
|
425
|
+
let got = classify_constant_keys(
|
|
426
|
+
&s,
|
|
427
|
+
&["event.preventDefault(); $.overlay = { name: 'x' };".to_string()],
|
|
428
|
+
);
|
|
429
|
+
assert!(got.contains("version"));
|
|
430
|
+
assert!(!got.contains("overlay"));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#[test]
|
|
434
|
+
fn realistic_app_state_subset() {
|
|
435
|
+
// Mirrors the battle-brawlers global state: only `version` survives.
|
|
436
|
+
let s = state(&[
|
|
437
|
+
("version", json!("0.1.5")),
|
|
438
|
+
("coins", json!(400)),
|
|
439
|
+
("characters", json!([])),
|
|
440
|
+
("token", json!(null)),
|
|
441
|
+
("settings", json!({ "darkMode": false })),
|
|
442
|
+
]);
|
|
443
|
+
let srcs = [
|
|
444
|
+
"if (email) $.email = email; $.canTest = false;".to_string(),
|
|
445
|
+
"if (gameState.characters) $.characters = gameState.characters;".to_string(),
|
|
446
|
+
"if (gameState.coins !== undefined) $.coins = gameState.coins;".to_string(),
|
|
447
|
+
"$.token = undefined; const v = $.version;".to_string(),
|
|
448
|
+
];
|
|
449
|
+
let got = classify_constant_keys(&s, &srcs);
|
|
450
|
+
assert!(got.contains("version"));
|
|
451
|
+
assert!(!got.contains("coins"));
|
|
452
|
+
assert!(!got.contains("characters"));
|
|
453
|
+
assert!(!got.contains("token"));
|
|
454
|
+
assert!(!got.contains("settings"));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
use regex::{Regex, Captures};
|
|
2
|
-
use serde_json::Value;
|
|
2
|
+
use serde_json::{Map, Value};
|
|
3
3
|
use rquickjs::{Context, Runtime};
|
|
4
|
-
use std::collections::HashSet;
|
|
4
|
+
use std::collections::{HashMap, HashSet};
|
|
5
|
+
|
|
6
|
+
/// Render a primitive state value the way an evaluated `@[expr]` would print it:
|
|
7
|
+
/// strings/numbers/bools as their text, null as empty. Mirrors `eval_with_state`.
|
|
8
|
+
fn primitive_to_text(value: &Value) -> String {
|
|
9
|
+
match value {
|
|
10
|
+
Value::String(s) => s.clone(),
|
|
11
|
+
Value::Number(n) => n.to_string(),
|
|
12
|
+
Value::Bool(b) => b.to_string(),
|
|
13
|
+
Value::Null => String::new(),
|
|
14
|
+
// Constants are primitive-only (see reassignment_analyzer); fall back safely.
|
|
15
|
+
other => other.to_string(),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
// Elements that should not have reactive bindings processed
|
|
7
20
|
// Matches runtime/constants.js NON_REACTIVE_ELEMENTS
|
|
@@ -53,10 +66,23 @@ pub struct ValueStamper<'a> {
|
|
|
53
66
|
_runtime: Runtime, // Must be kept alive for context to work
|
|
54
67
|
context: Context,
|
|
55
68
|
components_as_is: bool,
|
|
69
|
+
/// Global state keys proven constant by reassignment analysis, mapped to the
|
|
70
|
+
/// text they stamp to. Only an `@[key]` binding whose expression is EXACTLY
|
|
71
|
+
/// one of these keys is stamped from here — never a compound expression — so
|
|
72
|
+
/// there is no risk of a dynamic operand leaking into the value.
|
|
73
|
+
constant_texts: HashMap<String, String>,
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
impl<'a> ValueStamper<'a> {
|
|
59
77
|
pub fn new(state: &'a Value, components_as_is: bool) -> Result<Self, String> {
|
|
78
|
+
Self::with_constants(state, components_as_is, &Map::new())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pub fn with_constants(
|
|
82
|
+
state: &'a Value,
|
|
83
|
+
components_as_is: bool,
|
|
84
|
+
constants: &Map<String, Value>,
|
|
85
|
+
) -> Result<Self, String> {
|
|
60
86
|
let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
|
|
61
87
|
let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
|
|
62
88
|
|
|
@@ -76,12 +102,18 @@ impl<'a> ValueStamper<'a> {
|
|
|
76
102
|
Ok(())
|
|
77
103
|
})?;
|
|
78
104
|
|
|
105
|
+
let constant_texts = constants
|
|
106
|
+
.iter()
|
|
107
|
+
.map(|(key, value)| (key.clone(), primitive_to_text(value)))
|
|
108
|
+
.collect();
|
|
109
|
+
|
|
79
110
|
Ok(Self {
|
|
80
111
|
state,
|
|
81
112
|
binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
|
|
82
113
|
_runtime: runtime,
|
|
83
114
|
context,
|
|
84
115
|
components_as_is,
|
|
116
|
+
constant_texts,
|
|
85
117
|
})
|
|
86
118
|
}
|
|
87
119
|
|
|
@@ -164,9 +196,17 @@ impl<'a> ValueStamper<'a> {
|
|
|
164
196
|
// Stamp the binding
|
|
165
197
|
let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
|
|
166
198
|
let expr = &caps[1];
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
// A bare reference to a proven-constant global key (`@[version]`)
|
|
200
|
+
// stamps its build-time value. Any compound expression falls
|
|
201
|
+
// through to normal evaluation, where dynamic globals stay absent
|
|
202
|
+
// and the binding is left live for the runtime.
|
|
203
|
+
if let Some(text) = self.constant_texts.get(expr.trim()) {
|
|
204
|
+
result.push_str(text);
|
|
205
|
+
} else {
|
|
206
|
+
let stamped = self.eval_expression(expr)
|
|
207
|
+
.unwrap_or_else(|| binding_match.as_str().to_string());
|
|
208
|
+
result.push_str(&stamped);
|
|
209
|
+
}
|
|
170
210
|
}
|
|
171
211
|
|
|
172
212
|
last_pos = match_end;
|
|
@@ -226,7 +266,11 @@ impl<'a> ValueStamper<'a> {
|
|
|
226
266
|
}
|
|
227
267
|
|
|
228
268
|
fn render_iterations_recursive(&self, html: String) -> Result<String, String> {
|
|
229
|
-
|
|
269
|
+
// Matches every `<!-- each PATH as ITEM[, INDEX][ (KEY)] -->` form: the
|
|
270
|
+
// array PATH may be any expression (a call like `filter(...)`, not just a
|
|
271
|
+
// dotted path) and the optional `(KEY)` keyed-iteration suffix is captured
|
|
272
|
+
// so it can be preserved on the rebuilt marker.
|
|
273
|
+
let each_start = Regex::new(r"<!--\s*each\s+(.+?)\s+as\s+(\w+)(?:\s*,\s*(\w+))?(?:\s*\(([^)]*)\))?\s*-->").unwrap();
|
|
230
274
|
|
|
231
275
|
let mut result = html.clone();
|
|
232
276
|
let mut search_pos = 0;
|
|
@@ -241,20 +285,19 @@ impl<'a> ValueStamper<'a> {
|
|
|
241
285
|
let item_alias = captures.get(2).unwrap().as_str().to_string();
|
|
242
286
|
let index_alias = captures.get(3).map(|m| m.as_str().to_string()).unwrap_or_else(|| "index".to_string());
|
|
243
287
|
let has_index = captures.get(3).is_some();
|
|
288
|
+
let key_part = captures.get(4).map(|m| format!(" ({})", m.as_str())).unwrap_or_default();
|
|
244
289
|
|
|
245
290
|
// Find matching <!-- /each --> using depth counting
|
|
246
291
|
if let Some((end_pos, end_after)) = self.find_matching_end(&result, template_start) {
|
|
247
292
|
let template = result[template_start..end_pos].to_string();
|
|
248
293
|
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
};
|
|
294
|
+
// Resolve the array from the stamp state. An array we can't resolve
|
|
295
|
+
// (a dynamic global like `notifications`, empty at compile time) is
|
|
296
|
+
// treated as empty: render zero items so the raw `@[item.x]`
|
|
297
|
+
// template body is dropped from the painted DOM. The runtime
|
|
298
|
+
// restores the template from the manifest (built pre-stamp) and
|
|
299
|
+
// fills it in when the live array gets data.
|
|
300
|
+
let array = self.eval_array_path(&array_path).unwrap_or_default();
|
|
258
301
|
|
|
259
302
|
// Render each item
|
|
260
303
|
let mut rendered_items = Vec::new();
|
|
@@ -298,10 +341,11 @@ impl<'a> ValueStamper<'a> {
|
|
|
298
341
|
};
|
|
299
342
|
|
|
300
343
|
let replacement = format!(
|
|
301
|
-
"<!-- each {} as {}{} -->{}<!-- /each -->",
|
|
344
|
+
"<!-- each {} as {}{}{} -->{}<!-- /each -->",
|
|
302
345
|
array_path,
|
|
303
346
|
item_alias,
|
|
304
347
|
index_part,
|
|
348
|
+
key_part,
|
|
305
349
|
rendered_items.join("")
|
|
306
350
|
);
|
|
307
351
|
|
|
@@ -767,6 +811,38 @@ mod tests {
|
|
|
767
811
|
assert_eq!(result, r#"<!-- each items as item, idx --><span>[0] a</span><span>[1] b</span><span>[2] c</span><!-- /each -->"#);
|
|
768
812
|
}
|
|
769
813
|
|
|
814
|
+
#[test]
|
|
815
|
+
fn unresolvable_iteration_body_is_stripped() {
|
|
816
|
+
// `notifications` is a dynamic global, absent from the stamp state. The
|
|
817
|
+
// each renders empty so no raw `@[notification.title]` paints, while the
|
|
818
|
+
// keyed marker is preserved for runtime restoration from the manifest.
|
|
819
|
+
let state = json!({});
|
|
820
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
821
|
+
let html = String::from(
|
|
822
|
+
"<toasts><!-- each notifications as notification (notification.id) --><toast>@[notification.title]</toast><!-- /each --></toasts>",
|
|
823
|
+
);
|
|
824
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
825
|
+
assert!(!result.contains("@["), "raw binding left behind: {result}");
|
|
826
|
+
assert!(
|
|
827
|
+
result.contains("<!-- each notifications as notification (notification.id) -->"),
|
|
828
|
+
"keyed marker dropped: {result}"
|
|
829
|
+
);
|
|
830
|
+
assert!(result.contains("<!-- /each -->"));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#[test]
|
|
834
|
+
fn unresolvable_complex_path_iteration_is_stripped() {
|
|
835
|
+
// The array path is a function call, not a dotted path — still handled.
|
|
836
|
+
let state = json!({});
|
|
837
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
838
|
+
let html = String::from(
|
|
839
|
+
"<!-- each dataInspectorState($, x).rows as row (row.path) --><r>@[row.label]</r><!-- /each -->",
|
|
840
|
+
);
|
|
841
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
842
|
+
assert!(!result.contains("@["), "raw binding left behind: {result}");
|
|
843
|
+
assert!(result.contains("<!-- each dataInspectorState($, x).rows as row (row.path) -->"));
|
|
844
|
+
}
|
|
845
|
+
|
|
770
846
|
#[test]
|
|
771
847
|
fn cleanup_boolean_attributes_false() {
|
|
772
848
|
let state = json!({ "buttonDisabled": false });
|
|
@@ -806,4 +882,40 @@ mod tests {
|
|
|
806
882
|
// disabled and required should be removed, readonly should be present with empty value
|
|
807
883
|
assert_eq!(result, r#"<input readonly="">"#);
|
|
808
884
|
}
|
|
885
|
+
|
|
886
|
+
fn constants(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
|
887
|
+
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
#[test]
|
|
891
|
+
fn constant_key_binding_is_stamped() {
|
|
892
|
+
let state = json!({});
|
|
893
|
+
let consts = constants(&[("version", json!("0.1.5"))]);
|
|
894
|
+
let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
|
|
895
|
+
let result = stamper
|
|
896
|
+
.stamp_html("<version-tag>BETA v@[version]</version-tag>".to_string())
|
|
897
|
+
.unwrap();
|
|
898
|
+
assert_eq!(result, "<version-tag>BETA v0.1.5</version-tag>");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
#[test]
|
|
902
|
+
fn non_constant_binding_stays_raw() {
|
|
903
|
+
let state = json!({});
|
|
904
|
+
let consts = constants(&[("version", json!("0.1.5"))]);
|
|
905
|
+
let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
|
|
906
|
+
// `coins` is dynamic (not a constant, not in stamp state) → left for runtime.
|
|
907
|
+
let result = stamper.stamp_html("<coin-stack>@[coins]</coin-stack>".to_string()).unwrap();
|
|
908
|
+
assert_eq!(result, "<coin-stack>@[coins]</coin-stack>");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
#[test]
|
|
912
|
+
fn constant_never_leaks_into_compound_expression() {
|
|
913
|
+
// Only the EXACT `@[version]` binding stamps; `@[version + …]` must not
|
|
914
|
+
// pull the constant in (no `0.1.5…` garbage), guarding mixed expressions.
|
|
915
|
+
let state = json!({});
|
|
916
|
+
let consts = constants(&[("version", json!("0.1.5"))]);
|
|
917
|
+
let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
|
|
918
|
+
let result = stamper.stamp_html("<x>@[version + suffix]</x>".to_string()).unwrap();
|
|
919
|
+
assert!(!result.contains("0.1.5"), "constant leaked into compound: {result}");
|
|
920
|
+
}
|
|
809
921
|
}
|