@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,715 +0,0 @@
|
|
|
1
|
-
use swc_common::{sync::Lrc, SourceMap, FileName};
|
|
2
|
-
use swc_ecma_parser::{Parser, StringInput, Syntax, EsSyntax};
|
|
3
|
-
use swc_ecma_ast::*;
|
|
4
|
-
use swc_ecma_visit::{Visit, VisitWith};
|
|
5
|
-
use serde_json::{Value, Map};
|
|
6
|
-
use std::collections::HashMap;
|
|
7
|
-
use std::path::PathBuf;
|
|
8
|
-
use std::fs;
|
|
9
|
-
|
|
10
|
-
/// Analyzes JavaScript code to extract state from vibe() calls
|
|
11
|
-
pub struct JsAnalyzer {
|
|
12
|
-
base_path: PathBuf,
|
|
13
|
-
_module_cache: HashMap<PathBuf, Module>,
|
|
14
|
-
/// Memoizes resolved imports by (file, export name). Dedupes diamond imports
|
|
15
|
-
/// (without it, recursive resolution re-parses shared modules exponentially)
|
|
16
|
-
/// and breaks cycles (an in-progress entry is seeded `None`).
|
|
17
|
-
import_cache: HashMap<(PathBuf, String), Option<Value>>,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
impl JsAnalyzer {
|
|
21
|
-
pub fn new(base_path: PathBuf) -> Self {
|
|
22
|
-
Self {
|
|
23
|
-
base_path,
|
|
24
|
-
_module_cache: HashMap::new(),
|
|
25
|
-
import_cache: HashMap::new(),
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/// Extract state from a JavaScript/TypeScript script
|
|
30
|
-
pub fn extract_state(&mut self, script: &str) -> Option<Value> {
|
|
31
|
-
// eprintln!("[JsAnalyzer] extract_state called, script length: {}", script.len());
|
|
32
|
-
let module = self.parse_module(script)?;
|
|
33
|
-
// eprintln!("[JsAnalyzer] Module parsed");
|
|
34
|
-
|
|
35
|
-
// Build scope with import resolution
|
|
36
|
-
let mut scope_builder = ScopeBuilder::new(self.base_path.clone());
|
|
37
|
-
module.visit_with(&mut scope_builder);
|
|
38
|
-
// eprintln!("[JsAnalyzer] Found {} imports", scope_builder.imports.len());
|
|
39
|
-
|
|
40
|
-
// Resolve imports to actual Values (keyed by the LOCAL name used in code)
|
|
41
|
-
let mut resolved_imports: HashMap<String, Value> = HashMap::new();
|
|
42
|
-
for (local_name, (import_path, imported_name)) in scope_builder.imports.clone() {
|
|
43
|
-
if let Some(value) = self.resolve_import_value(&import_path, &imported_name) {
|
|
44
|
-
resolved_imports.insert(local_name, value);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Find vibe() calls and resolve their arguments
|
|
49
|
-
let mut state_extractor = StateExtractor::new(scope_builder.bindings);
|
|
50
|
-
state_extractor.resolved_imports = resolved_imports;
|
|
51
|
-
module.visit_with(&mut state_extractor);
|
|
52
|
-
|
|
53
|
-
state_extractor.merged_state
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/// Resolve an import and return the exported value (fully resolved).
|
|
57
|
-
/// `imported_name` is the EXPORT name to look up ("default" for a default
|
|
58
|
-
/// import). The imported module's OWN imports are resolved too, so a chain
|
|
59
|
-
/// like `import appState` → `export default { version: VERSION }` →
|
|
60
|
-
/// `import VERSION from './version.js'` resolves all the way down.
|
|
61
|
-
fn resolve_import_value(&mut self, import_path: &str, imported_name: &str) -> Option<Value> {
|
|
62
|
-
self.resolve_import_value_depth(import_path, imported_name, 0)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
fn resolve_import_value_depth(
|
|
66
|
-
&mut self,
|
|
67
|
-
import_path: &str,
|
|
68
|
-
imported_name: &str,
|
|
69
|
-
depth: usize,
|
|
70
|
-
) -> Option<Value> {
|
|
71
|
-
// Guard against pathological depth.
|
|
72
|
-
if depth > 16 {
|
|
73
|
-
return None;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
let resolved_path = self.resolve_path(import_path)?;
|
|
77
|
-
let cache_key = (resolved_path.clone(), imported_name.to_string());
|
|
78
|
-
if let Some(cached) = self.import_cache.get(&cache_key) {
|
|
79
|
-
return cached.clone();
|
|
80
|
-
}
|
|
81
|
-
// Seed the in-progress entry so an import cycle resolves to None instead
|
|
82
|
-
// of recursing forever.
|
|
83
|
-
self.import_cache.insert(cache_key.clone(), None);
|
|
84
|
-
|
|
85
|
-
let result = self.resolve_export_from_file(&resolved_path, imported_name, depth);
|
|
86
|
-
self.import_cache.insert(cache_key, result.clone());
|
|
87
|
-
result
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
fn resolve_export_from_file(
|
|
91
|
-
&mut self,
|
|
92
|
-
resolved_path: &PathBuf,
|
|
93
|
-
imported_name: &str,
|
|
94
|
-
depth: usize,
|
|
95
|
-
) -> Option<Value> {
|
|
96
|
-
let code = fs::read_to_string(resolved_path).ok()?;
|
|
97
|
-
let module = self.parse_module(&code)?;
|
|
98
|
-
|
|
99
|
-
// Build scope for the imported module.
|
|
100
|
-
let mut scope_builder = ScopeBuilder::new(resolved_path.parent()?.to_path_buf());
|
|
101
|
-
module.visit_with(&mut scope_builder);
|
|
102
|
-
|
|
103
|
-
// Recursively resolve THIS module's own imports so a property like
|
|
104
|
-
// `version: VERSION` (itself a default import) can be resolved. Import
|
|
105
|
-
// paths in this codebase are absolute (`/js/...`), resolved against the
|
|
106
|
-
// analyzer's fixed base, so no per-module base swap is needed.
|
|
107
|
-
let mut resolved_imports: HashMap<String, Value> = HashMap::new();
|
|
108
|
-
for (local_name, (path, name)) in scope_builder.imports.clone() {
|
|
109
|
-
if let Some(value) = self.resolve_import_value_depth(&path, &name, depth + 1) {
|
|
110
|
-
resolved_imports.insert(local_name, value);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Find the requested export.
|
|
115
|
-
let mut export_finder =
|
|
116
|
-
ExportFinder::new(imported_name.to_string(), scope_builder.bindings.clone());
|
|
117
|
-
module.visit_with(&mut export_finder);
|
|
118
|
-
|
|
119
|
-
let expr = export_finder.found_export?;
|
|
120
|
-
let mut state_extractor = StateExtractor::new(scope_builder.bindings);
|
|
121
|
-
state_extractor.resolved_imports = resolved_imports;
|
|
122
|
-
state_extractor.resolve_expr(&expr)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/// Resolve import path relative to base_path
|
|
126
|
-
fn resolve_path(&self, import_path: &str) -> Option<PathBuf> {
|
|
127
|
-
// eprintln!("[JsAnalyzer] Resolving path: '{}' from base: '{:?}'", import_path, self.base_path);
|
|
128
|
-
let resolved = if import_path.starts_with('/') {
|
|
129
|
-
// Absolute path: treat as relative to base_path root
|
|
130
|
-
let clean_path = import_path.strip_prefix('/').unwrap_or(import_path);
|
|
131
|
-
let candidate = self.base_path.join(clean_path);
|
|
132
|
-
// eprintln!("[JsAnalyzer] Candidate absolute path: {:?}", candidate);
|
|
133
|
-
candidate
|
|
134
|
-
} else if import_path.starts_with("./") || import_path.starts_with("../") {
|
|
135
|
-
// Relative path
|
|
136
|
-
self.base_path.join(import_path)
|
|
137
|
-
} else {
|
|
138
|
-
// Neither absolute nor relative - treat as relative
|
|
139
|
-
self.base_path.join(import_path)
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
// Try with and without .js extension
|
|
143
|
-
if resolved.exists() {
|
|
144
|
-
// eprintln!("[JsAnalyzer] Path exists: {:?}", resolved);
|
|
145
|
-
Some(resolved)
|
|
146
|
-
} else {
|
|
147
|
-
// eprintln!("[JsAnalyzer] Path doesn't exist, trying with .js extension");
|
|
148
|
-
let with_js = resolved.with_extension("js");
|
|
149
|
-
if with_js.exists() {
|
|
150
|
-
// eprintln!("[JsAnalyzer] Path with .js exists: {:?}", with_js);
|
|
151
|
-
Some(with_js)
|
|
152
|
-
} else {
|
|
153
|
-
// eprintln!("[JsAnalyzer] Path not found: {:?}", resolved);
|
|
154
|
-
None
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/// Parse JavaScript/TypeScript module. Uses a FRESH source map per call —
|
|
160
|
-
/// recursive import resolution parses many files, and a shared map would
|
|
161
|
-
/// accumulate byte positions until they overflow (`start <= end` panic).
|
|
162
|
-
fn parse_module(&self, code: &str) -> Option<Module> {
|
|
163
|
-
let source_map: Lrc<SourceMap> = Default::default();
|
|
164
|
-
let fm = source_map.new_source_file(
|
|
165
|
-
Lrc::new(FileName::Anon),
|
|
166
|
-
code.to_string(),
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
let syntax = Syntax::Es(EsSyntax {
|
|
170
|
-
jsx: false,
|
|
171
|
-
decorators: true,
|
|
172
|
-
..Default::default()
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
let input = StringInput::new(
|
|
176
|
-
&fm.src,
|
|
177
|
-
fm.start_pos,
|
|
178
|
-
fm.end_pos,
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
let mut parser = Parser::new(
|
|
182
|
-
syntax,
|
|
183
|
-
input,
|
|
184
|
-
None,
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
parser.parse_module().ok()
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/// Builds a scope of variable bindings and tracks imports
|
|
192
|
-
struct ScopeBuilder {
|
|
193
|
-
bindings: HashMap<String, Expr>,
|
|
194
|
-
// local binding name -> (import path, imported export name; "default" for a
|
|
195
|
-
// default import, "*" for a namespace import)
|
|
196
|
-
imports: HashMap<String, (String, String)>,
|
|
197
|
-
_base_path: PathBuf,
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
impl ScopeBuilder {
|
|
201
|
-
fn new(base_path: PathBuf) -> Self {
|
|
202
|
-
Self {
|
|
203
|
-
bindings: HashMap::new(),
|
|
204
|
-
imports: HashMap::new(),
|
|
205
|
-
_base_path: base_path,
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
impl Visit for ScopeBuilder {
|
|
211
|
-
fn visit_import_decl(&mut self, import: &ImportDecl) {
|
|
212
|
-
let import_path = import.src.value.to_string();
|
|
213
|
-
|
|
214
|
-
for specifier in &import.specifiers {
|
|
215
|
-
match specifier {
|
|
216
|
-
// import { orig as local } from './file.js'
|
|
217
|
-
ImportSpecifier::Named(named) => {
|
|
218
|
-
let imported = match &named.imported {
|
|
219
|
-
Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
|
|
220
|
-
Some(ModuleExportName::Str(s)) => s.value.to_string(),
|
|
221
|
-
None => named.local.sym.to_string(),
|
|
222
|
-
};
|
|
223
|
-
self.imports
|
|
224
|
-
.insert(named.local.sym.to_string(), (import_path.clone(), imported));
|
|
225
|
-
}
|
|
226
|
-
// import name from './file.js' (default import)
|
|
227
|
-
ImportSpecifier::Default(default) => {
|
|
228
|
-
self.imports.insert(
|
|
229
|
-
default.local.sym.to_string(),
|
|
230
|
-
(import_path.clone(), "default".to_string()),
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
// import * as name from './file.js' (namespace import)
|
|
234
|
-
ImportSpecifier::Namespace(namespace) => {
|
|
235
|
-
self.imports.insert(
|
|
236
|
-
namespace.local.sym.to_string(),
|
|
237
|
-
(import_path.clone(), "*".to_string()),
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
import.visit_children_with(self);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
fn visit_export_decl(&mut self, export: &ExportDecl) {
|
|
247
|
-
// Handle: export const name = value
|
|
248
|
-
match &export.decl {
|
|
249
|
-
Decl::Var(var_decl) => {
|
|
250
|
-
self.visit_var_decl(var_decl);
|
|
251
|
-
}
|
|
252
|
-
_ => {}
|
|
253
|
-
}
|
|
254
|
-
export.visit_children_with(self);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
fn visit_var_decl(&mut self, decl: &VarDecl) {
|
|
258
|
-
for declarator in &decl.decls {
|
|
259
|
-
if let Pat::Ident(ident) = &declarator.name {
|
|
260
|
-
if let Some(init) = &declarator.init {
|
|
261
|
-
self.bindings.insert(
|
|
262
|
-
ident.id.sym.to_string(),
|
|
263
|
-
(**init).clone(),
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
decl.visit_children_with(self);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/// Finds exported bindings in a module
|
|
273
|
-
struct ExportFinder {
|
|
274
|
-
target_name: String,
|
|
275
|
-
bindings: HashMap<String, Expr>,
|
|
276
|
-
found_export: Option<Expr>,
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
impl ExportFinder {
|
|
280
|
-
fn new(target_name: String, bindings: HashMap<String, Expr>) -> Self {
|
|
281
|
-
Self {
|
|
282
|
-
target_name,
|
|
283
|
-
bindings,
|
|
284
|
-
found_export: None,
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
impl Visit for ExportFinder {
|
|
290
|
-
// `export default <expr>` — matched when the consumer used a default import.
|
|
291
|
-
fn visit_export_default_expr(&mut self, export: &ExportDefaultExpr) {
|
|
292
|
-
if self.target_name == "default" {
|
|
293
|
-
self.found_export = Some((*export.expr).clone());
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
fn visit_export_decl(&mut self, export: &ExportDecl) {
|
|
298
|
-
match &export.decl {
|
|
299
|
-
Decl::Var(var_decl) => {
|
|
300
|
-
for declarator in &var_decl.decls {
|
|
301
|
-
if let Pat::Ident(ident) = &declarator.name {
|
|
302
|
-
if ident.id.sym.to_string() == self.target_name {
|
|
303
|
-
if let Some(init) = &declarator.init {
|
|
304
|
-
self.found_export = Some((**init).clone());
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
_ => {}
|
|
312
|
-
}
|
|
313
|
-
export.visit_children_with(self);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
fn visit_named_export(&mut self, export: &NamedExport) {
|
|
317
|
-
// Handle: export { name }
|
|
318
|
-
for specifier in &export.specifiers {
|
|
319
|
-
if let ExportSpecifier::Named(named) = specifier {
|
|
320
|
-
// Get the local name (what it's called in this file)
|
|
321
|
-
let local_name = if let ModuleExportName::Ident(ident) = &named.orig {
|
|
322
|
-
ident.sym.to_string()
|
|
323
|
-
} else {
|
|
324
|
-
continue;
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
// Get the export name (what it's called when imported)
|
|
328
|
-
let export_name = match &named.exported {
|
|
329
|
-
Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
|
|
330
|
-
_ => local_name.clone(),
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
// Check if this is the export we're looking for
|
|
334
|
-
if export_name == self.target_name {
|
|
335
|
-
// Look up the binding
|
|
336
|
-
if let Some(binding) = self.bindings.get(&local_name) {
|
|
337
|
-
self.found_export = Some(binding.clone());
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
export.visit_children_with(self);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/// Extracts state from vibe()/state()/component() calls
|
|
348
|
-
struct StateExtractor {
|
|
349
|
-
bindings: HashMap<String, Expr>,
|
|
350
|
-
resolved_imports: HashMap<String, Value>,
|
|
351
|
-
merged_state: Option<Value>,
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
impl StateExtractor {
|
|
355
|
-
fn new(bindings: HashMap<String, Expr>) -> Self {
|
|
356
|
-
Self {
|
|
357
|
-
bindings,
|
|
358
|
-
resolved_imports: HashMap::new(),
|
|
359
|
-
merged_state: None,
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/// Resolve an expression to a JSON value
|
|
364
|
-
fn resolve_expr(&self, expr: &Expr) -> Option<Value> {
|
|
365
|
-
match expr {
|
|
366
|
-
// Object literal: { a: 1, b: 2 }
|
|
367
|
-
Expr::Object(obj) => self.resolve_object(obj),
|
|
368
|
-
|
|
369
|
-
// Array literal: [1, 2, 3]
|
|
370
|
-
Expr::Array(arr) => self.resolve_array(arr),
|
|
371
|
-
|
|
372
|
-
// String literal: "hello"
|
|
373
|
-
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
|
|
374
|
-
|
|
375
|
-
// Number literal: 42
|
|
376
|
-
Expr::Lit(Lit::Num(n)) => {
|
|
377
|
-
serde_json::Number::from_f64(n.value)
|
|
378
|
-
.map(Value::Number)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Boolean literal: true/false
|
|
382
|
-
Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
|
|
383
|
-
|
|
384
|
-
// Null literal
|
|
385
|
-
Expr::Lit(Lit::Null(_)) => Some(Value::Null),
|
|
386
|
-
|
|
387
|
-
// Identifier: resolve from imports first, then bindings
|
|
388
|
-
Expr::Ident(ident) => {
|
|
389
|
-
let name = ident.sym.to_string();
|
|
390
|
-
|
|
391
|
-
// Check resolved imports first (these are fully resolved Values)
|
|
392
|
-
if let Some(value) = self.resolved_imports.get(&name) {
|
|
393
|
-
return Some(value.clone());
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Fall back to bindings (these are Exprs that need resolution)
|
|
397
|
-
self.bindings.get(&name)
|
|
398
|
-
.and_then(|expr| self.resolve_expr(expr))
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Parenthesized expression: (expr)
|
|
402
|
-
Expr::Paren(paren) => self.resolve_expr(&paren.expr),
|
|
403
|
-
|
|
404
|
-
_ => None, // Can't statically resolve
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/// Resolve object literal with spread support
|
|
409
|
-
/// Uses partial resolution: resolves what it can, skips what it can't
|
|
410
|
-
fn resolve_object(&self, obj: &ObjectLit) -> Option<Value> {
|
|
411
|
-
let mut result = Map::new();
|
|
412
|
-
|
|
413
|
-
for prop in &obj.props {
|
|
414
|
-
match prop {
|
|
415
|
-
// Regular property: a: 1
|
|
416
|
-
PropOrSpread::Prop(prop) => {
|
|
417
|
-
if let Prop::KeyValue(kv) = &**prop {
|
|
418
|
-
if let Some(key) = self.get_prop_key(&kv.key) {
|
|
419
|
-
if let Some(value) = self.resolve_expr(&kv.value) {
|
|
420
|
-
result.insert(key, value);
|
|
421
|
-
}
|
|
422
|
-
// Skip properties that can't be resolved
|
|
423
|
-
}
|
|
424
|
-
} else if let Prop::Shorthand(ident) = &**prop {
|
|
425
|
-
// Shorthand: { a } where a is a variable
|
|
426
|
-
let key = ident.sym.to_string();
|
|
427
|
-
if let Some(expr) = self.bindings.get(&key) {
|
|
428
|
-
if let Some(value) = self.resolve_expr(expr) {
|
|
429
|
-
result.insert(key, value);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
// Skip if can't resolve
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Spread: ...other
|
|
437
|
-
PropOrSpread::Spread(spread) => {
|
|
438
|
-
// Resolve the spread expression
|
|
439
|
-
if let Some(Value::Object(spread_obj)) = self.resolve_expr(&spread.expr) {
|
|
440
|
-
// Merge spread properties
|
|
441
|
-
for (k, v) in spread_obj {
|
|
442
|
-
result.insert(k, v);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
// Skip spreads that can't be resolved
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Return partial object even if some properties couldn't be resolved
|
|
451
|
-
if result.is_empty() {
|
|
452
|
-
None
|
|
453
|
-
} else {
|
|
454
|
-
Some(Value::Object(result))
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/// Resolve array literal
|
|
459
|
-
/// Uses partial resolution: includes elements that can be resolved, skips others
|
|
460
|
-
fn resolve_array(&self, arr: &ArrayLit) -> Option<Value> {
|
|
461
|
-
let mut result = Vec::new();
|
|
462
|
-
|
|
463
|
-
for elem in &arr.elems {
|
|
464
|
-
if let Some(elem) = elem {
|
|
465
|
-
match elem {
|
|
466
|
-
// Regular element
|
|
467
|
-
ExprOrSpread { spread: None, expr } => {
|
|
468
|
-
if let Some(value) = self.resolve_expr(expr) {
|
|
469
|
-
result.push(value);
|
|
470
|
-
}
|
|
471
|
-
// Skip elements that can't be resolved
|
|
472
|
-
}
|
|
473
|
-
// Spread element: ...arr
|
|
474
|
-
ExprOrSpread { spread: Some(_), expr } => {
|
|
475
|
-
if let Some(Value::Array(spread_arr)) = self.resolve_expr(expr) {
|
|
476
|
-
result.extend(spread_arr);
|
|
477
|
-
}
|
|
478
|
-
// Skip spreads that can't be resolved
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Return partial array even if some elements couldn't be resolved
|
|
485
|
-
if result.is_empty() {
|
|
486
|
-
None
|
|
487
|
-
} else {
|
|
488
|
-
Some(Value::Array(result))
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/// Get string key from property name
|
|
493
|
-
fn get_prop_key(&self, key: &PropName) -> Option<String> {
|
|
494
|
-
match key {
|
|
495
|
-
PropName::Ident(ident) => Some(ident.sym.to_string()),
|
|
496
|
-
PropName::Str(s) => Some(s.value.to_string()),
|
|
497
|
-
_ => None,
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/// Merge state from a vibe() call
|
|
502
|
-
fn merge_state(&mut self, state: Value) {
|
|
503
|
-
if let Value::Object(new_state) = state {
|
|
504
|
-
match &mut self.merged_state {
|
|
505
|
-
Some(Value::Object(existing)) => {
|
|
506
|
-
for (k, v) in new_state {
|
|
507
|
-
existing.insert(k, v);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
_ => {
|
|
511
|
-
self.merged_state = Some(Value::Object(new_state));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
impl Visit for StateExtractor {
|
|
519
|
-
fn visit_call_expr(&mut self, call: &CallExpr) {
|
|
520
|
-
// Check if this is a vibe() or state() call
|
|
521
|
-
if let Callee::Expr(expr) = &call.callee {
|
|
522
|
-
if let Expr::Ident(ident) = &**expr {
|
|
523
|
-
let name = ident.sym.to_string();
|
|
524
|
-
if name == "vibe" || name == "state" || name == "component" {
|
|
525
|
-
// Get first argument (the state object)
|
|
526
|
-
if let Some(arg) = call.args.first() {
|
|
527
|
-
if let Some(state) = self.resolve_expr(&arg.expr) {
|
|
528
|
-
self.merge_state(state);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
call.visit_children_with(self);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
#[cfg(test)]
|
|
540
|
-
mod tests {
|
|
541
|
-
use super::*;
|
|
542
|
-
use serde_json::json;
|
|
543
|
-
|
|
544
|
-
#[test]
|
|
545
|
-
fn test_simple_object() {
|
|
546
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
547
|
-
let script = r#"
|
|
548
|
-
vibe({ count: 0, name: "test" });
|
|
549
|
-
"#;
|
|
550
|
-
|
|
551
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
552
|
-
assert_eq!(state, json!({ "count": 0, "name": "test" }));
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
#[test]
|
|
556
|
-
fn test_variable_reference() {
|
|
557
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
558
|
-
let script = r#"
|
|
559
|
-
const items = [1, 2, 3];
|
|
560
|
-
vibe({ items });
|
|
561
|
-
"#;
|
|
562
|
-
|
|
563
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
564
|
-
assert_eq!(state, json!({ "items": [1, 2, 3] }));
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
#[test]
|
|
568
|
-
fn test_spread_operator() {
|
|
569
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
570
|
-
let script = r#"
|
|
571
|
-
const base = { a: 1, b: 2 };
|
|
572
|
-
vibe({ ...base, c: 3 });
|
|
573
|
-
"#;
|
|
574
|
-
|
|
575
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
576
|
-
assert_eq!(state, json!({ "a": 1, "b": 2, "c": 3 }));
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
#[test]
|
|
580
|
-
fn test_nested_objects() {
|
|
581
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
582
|
-
let script = r#"
|
|
583
|
-
const user = { name: "John", age: 30 };
|
|
584
|
-
vibe({ user, count: 0 });
|
|
585
|
-
"#;
|
|
586
|
-
|
|
587
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
588
|
-
assert_eq!(state, json!({ "user": { "name": "John", "age": 30 }, "count": 0 }));
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
#[test]
|
|
592
|
-
fn test_array_spread() {
|
|
593
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
594
|
-
let script = r#"
|
|
595
|
-
const base = [1, 2];
|
|
596
|
-
const items = [...base, 3, 4];
|
|
597
|
-
vibe({ items });
|
|
598
|
-
"#;
|
|
599
|
-
|
|
600
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
601
|
-
assert_eq!(state, json!({ "items": [1, 2, 3, 4] }));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
#[test]
|
|
605
|
-
fn test_import_resolution() {
|
|
606
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
|
|
607
|
-
let script = r#"
|
|
608
|
-
import { data } from './test-import.js';
|
|
609
|
-
vibe({ ...data, c: 3 });
|
|
610
|
-
"#;
|
|
611
|
-
|
|
612
|
-
let state = analyzer.extract_state(script).unwrap();
|
|
613
|
-
assert_eq!(state["a"].as_f64().unwrap(), 1.0);
|
|
614
|
-
assert_eq!(state["b"].as_f64().unwrap(), 2.0);
|
|
615
|
-
assert_eq!(state["c"].as_f64().unwrap(), 3.0);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
#[test]
|
|
619
|
-
fn test_component_called_with_variable() {
|
|
620
|
-
// component(state) — the whole argument is a variable bound to an object
|
|
621
|
-
// literal above (BrawlerDetailContent does exactly this). The extractor
|
|
622
|
-
// must resolve the variable; otherwise the component wrapper is never
|
|
623
|
-
// tagged and its `this.` conditionals can't resolve at runtime.
|
|
624
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
625
|
-
let script = r#"
|
|
626
|
-
const state = {
|
|
627
|
-
currentCharacter: null,
|
|
628
|
-
currentSlots: [],
|
|
629
|
-
currentLastTickEnd: 0,
|
|
630
|
-
};
|
|
631
|
-
setupCharacter(state);
|
|
632
|
-
const id = component(state);
|
|
633
|
-
"#;
|
|
634
|
-
|
|
635
|
-
let state = analyzer.extract_state(script).expect("state should be extracted");
|
|
636
|
-
assert!(state.get("currentCharacter").is_some(), "got: {state}");
|
|
637
|
-
assert_eq!(state["currentLastTickEnd"].as_f64().unwrap(), 0.0);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
#[test]
|
|
641
|
-
fn test_component_var_amid_realistic_script() {
|
|
642
|
-
// Mirrors BrawlerDetailContent's script shape: top-level imports, a
|
|
643
|
-
// dynamic import().then(), an empty `catch {}`, optional chaining, and
|
|
644
|
-
// `component(state)`. A parse failure on any of these makes extract_state
|
|
645
|
-
// return None and the component goes untagged.
|
|
646
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
647
|
-
let script = r#"
|
|
648
|
-
import CHARACTERS from '/js/constants/CHARACTERS.js';
|
|
649
|
-
import { buildEquipmentSlots, unequip } from '/js/equipment.js';
|
|
650
|
-
|
|
651
|
-
const backParam = new URLSearchParams(location.search).get('back') || '';
|
|
652
|
-
const state = {
|
|
653
|
-
currentCharacter: null,
|
|
654
|
-
currentSlots: [],
|
|
655
|
-
currentLastTickEnd: 0,
|
|
656
|
-
backUrl: backParam.startsWith('/') ? backParam : '',
|
|
657
|
-
};
|
|
658
|
-
|
|
659
|
-
const setupCharacter = (target) => {
|
|
660
|
-
const ref = $.characters?.[0];
|
|
661
|
-
try { ref.foo(); } catch {}
|
|
662
|
-
target.currentCharacter = ref;
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
setupCharacter(state);
|
|
666
|
-
const id = component(state);
|
|
667
|
-
|
|
668
|
-
import('/js/dnd.js').then(({ default: dnd }) => { dnd.init(); });
|
|
669
|
-
"#;
|
|
670
|
-
|
|
671
|
-
let state = analyzer.extract_state(script).expect("state should be extracted");
|
|
672
|
-
assert!(state.get("currentCharacter").is_some(), "got: {state}");
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
#[test]
|
|
676
|
-
fn test_absolute_path_import() {
|
|
677
|
-
let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
|
|
678
|
-
let script = r#"
|
|
679
|
-
import { menuState } from '/test-data.js';
|
|
680
|
-
vibe({ ...menuState, extra: "value" });
|
|
681
|
-
"#;
|
|
682
|
-
|
|
683
|
-
let state = analyzer.extract_state(script);
|
|
684
|
-
assert!(state.is_some(), "State should be extracted");
|
|
685
|
-
let state = state.unwrap();
|
|
686
|
-
assert!(state["menuSections"].is_array(), "menuSections should be an array");
|
|
687
|
-
assert_eq!(state["other"].as_str().unwrap(), "value");
|
|
688
|
-
assert_eq!(state["extra"].as_str().unwrap(), "value");
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
#[test]
|
|
692
|
-
fn resolves_default_import_chain() {
|
|
693
|
-
// Two nested DEFAULT imports, mirroring boot.js → app.js → version.js:
|
|
694
|
-
// `import appState` → `export default { version: VERSION }` → `import VERSION`.
|
|
695
|
-
let dir = std::env::temp_dir().join("vibe_default_import_chain_test");
|
|
696
|
-
let _ = fs::remove_dir_all(&dir);
|
|
697
|
-
fs::create_dir_all(&dir).unwrap();
|
|
698
|
-
fs::write(dir.join("version.js"), "export default '0.1.5';").unwrap();
|
|
699
|
-
fs::write(
|
|
700
|
-
dir.join("app.js"),
|
|
701
|
-
"import VERSION from '/version.js';\nexport default { version: VERSION, coins: 400 };",
|
|
702
|
-
)
|
|
703
|
-
.unwrap();
|
|
704
|
-
|
|
705
|
-
let mut analyzer = JsAnalyzer::new(dir.clone());
|
|
706
|
-
let state = analyzer
|
|
707
|
-
.extract_state("import appState from '/app.js';\nvibe({ ...appState });")
|
|
708
|
-
.unwrap();
|
|
709
|
-
|
|
710
|
-
assert_eq!(state["version"], "0.1.5");
|
|
711
|
-
assert_eq!(state["coins"].as_f64(), Some(400.0));
|
|
712
|
-
|
|
713
|
-
let _ = fs::remove_dir_all(&dir);
|
|
714
|
-
}
|
|
715
|
-
}
|