@ape-egg/vibe 2.1.22 → 3.0.0
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/README.md +112 -5
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +26 -17
- package/llms.txt +36 -5
- package/package.json +20 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +360 -98
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +277 -110
- package/runtime/index.js +189 -65
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +81 -11
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +206 -0
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1159
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2522
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -15
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1147
- package/compiler/src/config.rs +0 -239
- package/compiler/src/main.rs +0 -347
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- package/test-results/.last-run.json +0 -4
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
pub mod compile;
|
|
2
|
-
mod state_extractor;
|
|
3
|
-
mod manifest_builder;
|
|
4
|
-
mod value_stamper;
|
|
5
|
-
mod iteration_optimizer;
|
|
6
|
-
mod js_analyzer;
|
|
7
|
-
mod component_tagger;
|
|
8
|
-
mod binding_case;
|
|
9
|
-
mod name_binding_protect;
|
|
10
|
-
mod reassignment_analyzer;
|
|
11
|
-
pub mod watcher;
|
|
12
|
-
|
|
13
|
-
pub use compile::Compiler;
|
|
14
|
-
#[allow(unused_imports)]
|
|
15
|
-
pub use compile::{CompileStats, ManifestStats};
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
use regex::Regex;
|
|
2
|
-
use std::sync::OnceLock;
|
|
3
|
-
|
|
4
|
-
/// The attribute a relocated name-binding is parked in. Mirrors the runtime
|
|
5
|
-
/// constant of the same name (runtime/constants.js) — the manifest builder reads
|
|
6
|
-
/// the binding back out of it, and the runtime consumes + strips it on hydrate.
|
|
7
|
-
pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
|
|
8
|
-
|
|
9
|
-
/// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
|
|
10
|
-
/// split an attribute token on whitespace, so once a prop is inlined into one and the
|
|
11
|
-
/// expression gains spaces — `<icon @[element]>` becoming `@[EQUIPMENT(item, true).element]`
|
|
12
|
-
/// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
|
|
13
|
-
///
|
|
14
|
-
/// We relocate every whitespace-bearing name-binding into a single verbatim
|
|
15
|
-
/// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
|
|
16
|
-
/// byte-for-byte through parsing, so the manifest builder recovers the exact expression
|
|
17
|
-
/// and the runtime evaluates it through the same path it uses in non-compiled mode —
|
|
18
|
-
/// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
|
|
19
|
-
/// survive as-is and are left untouched.
|
|
20
|
-
pub fn protect(html: &str) -> String {
|
|
21
|
-
// <script>/<style> bodies are raw text — a `<` inside them must not be read as a
|
|
22
|
-
// tag. Park them behind comment placeholders for the tag scan, restore after.
|
|
23
|
-
let (masked, raw_blocks) = mask_raw_text(html);
|
|
24
|
-
|
|
25
|
-
let out = tag_open_regex()
|
|
26
|
-
.replace_all(&masked, |caps: ®ex::Captures| {
|
|
27
|
-
let name = &caps[1];
|
|
28
|
-
let attrs = &caps[2];
|
|
29
|
-
let (kept, relocated) = relocate_unsafe(attrs);
|
|
30
|
-
if relocated.is_empty() {
|
|
31
|
-
return caps[0].to_string();
|
|
32
|
-
}
|
|
33
|
-
// Preserve a self-closing slash as the final token.
|
|
34
|
-
let kept_end_trimmed = kept.trim_end();
|
|
35
|
-
let (body, self_close) = match kept_end_trimmed.strip_suffix('/') {
|
|
36
|
-
Some(b) => (b.trim_end(), " /"),
|
|
37
|
-
None => (kept_end_trimmed, ""),
|
|
38
|
-
};
|
|
39
|
-
format!(
|
|
40
|
-
"<{}{} {}=\"{}\"{}>",
|
|
41
|
-
name, body, NAME_BIND_ATTR, relocated, self_close
|
|
42
|
-
)
|
|
43
|
-
})
|
|
44
|
-
.to_string();
|
|
45
|
-
|
|
46
|
-
restore_raw_text(&out, &raw_blocks)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/// Matches an opening tag and captures (name, attribute-section). Quoted values may
|
|
50
|
-
/// hold anything but their own quote; bare attribute chars are anything but `<>"'`.
|
|
51
|
-
fn tag_open_regex() -> &'static Regex {
|
|
52
|
-
static RE: OnceLock<Regex> = OnceLock::new();
|
|
53
|
-
RE.get_or_init(|| {
|
|
54
|
-
Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
|
|
55
|
-
})
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/// Walk a tag's attribute section, moving each whitespace-bearing name-binding out of
|
|
59
|
-
/// the kept attributes and into the concatenated `relocated` string. Quoted attribute
|
|
60
|
-
/// values (and string literals inside an expression) are skipped so value-bindings and
|
|
61
|
-
/// `@[fn('a b')]`-style literals are never mistaken for a name-binding.
|
|
62
|
-
fn relocate_unsafe(attrs: &str) -> (String, String) {
|
|
63
|
-
let bytes = attrs.as_bytes();
|
|
64
|
-
let n = bytes.len();
|
|
65
|
-
let mut kept = String::with_capacity(n);
|
|
66
|
-
let mut relocated = String::new();
|
|
67
|
-
let mut i = 0;
|
|
68
|
-
let mut seg = 0;
|
|
69
|
-
|
|
70
|
-
while i < n {
|
|
71
|
-
let b = bytes[i];
|
|
72
|
-
|
|
73
|
-
// Skip a quoted attribute value verbatim (it may legally contain `@[…]`).
|
|
74
|
-
if b == b'"' || b == b'\'' {
|
|
75
|
-
i += 1;
|
|
76
|
-
while i < n && bytes[i] != b {
|
|
77
|
-
i += 1;
|
|
78
|
-
}
|
|
79
|
-
if i < n {
|
|
80
|
-
i += 1; // consume closing quote
|
|
81
|
-
}
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// A bare name-binding in attribute-name position.
|
|
86
|
-
if b == b'@' && i + 1 < n && bytes[i + 1] == b'[' {
|
|
87
|
-
kept.push_str(&attrs[seg..i]);
|
|
88
|
-
let start = i;
|
|
89
|
-
i += 2;
|
|
90
|
-
let mut depth = 1;
|
|
91
|
-
while i < n && depth > 0 {
|
|
92
|
-
let c = bytes[i];
|
|
93
|
-
if c == b'\'' || c == b'"' {
|
|
94
|
-
i += 1;
|
|
95
|
-
while i < n && bytes[i] != c {
|
|
96
|
-
i += 1;
|
|
97
|
-
}
|
|
98
|
-
if i < n {
|
|
99
|
-
i += 1;
|
|
100
|
-
}
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
match c {
|
|
104
|
-
b'[' => depth += 1,
|
|
105
|
-
b']' => depth -= 1,
|
|
106
|
-
_ => {}
|
|
107
|
-
}
|
|
108
|
-
i += 1;
|
|
109
|
-
}
|
|
110
|
-
let binding = &attrs[start..i];
|
|
111
|
-
let inner = &binding[2..binding.len() - 1];
|
|
112
|
-
if inner.bytes().any(|x| x.is_ascii_whitespace()) {
|
|
113
|
-
relocated.push_str(binding);
|
|
114
|
-
} else {
|
|
115
|
-
kept.push_str(binding);
|
|
116
|
-
}
|
|
117
|
-
seg = i;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
i += 1;
|
|
122
|
-
}
|
|
123
|
-
kept.push_str(&attrs[seg..]);
|
|
124
|
-
(kept, relocated)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
fn raw_text_regex() -> &'static Regex {
|
|
128
|
-
static RE: OnceLock<Regex> = OnceLock::new();
|
|
129
|
-
RE.get_or_init(|| Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</(?:script|style)>").unwrap())
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
fn mask_raw_text(html: &str) -> (String, Vec<String>) {
|
|
133
|
-
let mut blocks = Vec::new();
|
|
134
|
-
let mut out = String::with_capacity(html.len());
|
|
135
|
-
let mut last = 0;
|
|
136
|
-
for mat in raw_text_regex().find_iter(html) {
|
|
137
|
-
out.push_str(&html[last..mat.start()]);
|
|
138
|
-
out.push_str(&format!("<!--VIBE_RAWTEXT_{}-->", blocks.len()));
|
|
139
|
-
blocks.push(html[mat.start()..mat.end()].to_string());
|
|
140
|
-
last = mat.end();
|
|
141
|
-
}
|
|
142
|
-
out.push_str(&html[last..]);
|
|
143
|
-
(out, blocks)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
fn restore_raw_text(html: &str, blocks: &[String]) -> String {
|
|
147
|
-
let mut out = html.to_string();
|
|
148
|
-
for (i, block) in blocks.iter().enumerate() {
|
|
149
|
-
out = out.replace(&format!("<!--VIBE_RAWTEXT_{}-->", i), block);
|
|
150
|
-
}
|
|
151
|
-
out
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
#[cfg(test)]
|
|
155
|
-
mod tests {
|
|
156
|
-
use super::*;
|
|
157
|
-
|
|
158
|
-
#[test]
|
|
159
|
-
fn relocates_whitespace_name_binding() {
|
|
160
|
-
let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
|
|
161
|
-
let out = protect(html);
|
|
162
|
-
assert!(
|
|
163
|
-
out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
164
|
-
"got: {}",
|
|
165
|
-
out
|
|
166
|
-
);
|
|
167
|
-
// The raw whitespace binding must no longer sit in attribute-name position.
|
|
168
|
-
assert!(!out.contains("@[EQUIPMENT(item, true).element]>"));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
#[test]
|
|
172
|
-
fn leaves_whitespace_free_name_binding_untouched() {
|
|
173
|
-
let html = r#"<icon @[(selectedEquipProps(uuid)).element]></icon>"#;
|
|
174
|
-
assert_eq!(protect(html), html);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
#[test]
|
|
178
|
-
fn leaves_value_binding_untouched() {
|
|
179
|
-
let html = r#"<card tinted="@[fn(a, b).element]"></card>"#;
|
|
180
|
-
assert_eq!(protect(html), html);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
#[test]
|
|
184
|
-
fn leaves_text_binding_untouched() {
|
|
185
|
-
let html = r#"<label>@[fn(a, b)]</label>"#;
|
|
186
|
-
assert_eq!(protect(html), html);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
#[test]
|
|
190
|
-
fn ignores_at_bracket_inside_script() {
|
|
191
|
-
let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
|
|
192
|
-
let out = protect(html);
|
|
193
|
-
assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
|
|
194
|
-
assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
#[test]
|
|
198
|
-
fn relocates_multiple_on_one_element() {
|
|
199
|
-
let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
|
|
200
|
-
let out = protect(html);
|
|
201
|
-
assert!(
|
|
202
|
-
out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
|
|
203
|
-
"got: {}",
|
|
204
|
-
out
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
@@ -1,456 +0,0 @@
|
|
|
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
|
-
}
|