@ape-egg/vibe 1.3.2 → 1.6.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/CHANGELOG.md +177 -0
- package/README.md +97 -0
- package/boot.js +0 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +719 -40
- package/compiler/src/Cargo.toml +11 -2
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +241 -0
- package/compiler/src/compiler/compile.rs +552 -175
- package/compiler/src/compiler/component_tagger.rs +234 -0
- package/compiler/src/compiler/iteration_optimizer.rs +351 -0
- package/compiler/src/compiler/js_analyzer.rs +572 -0
- package/compiler/src/compiler/manifest_builder.rs +251 -26
- package/compiler/src/compiler/mod.rs +5 -1
- package/compiler/src/compiler/state_extractor.rs +140 -25
- package/compiler/src/compiler/value_stamper.rs +579 -88
- package/compiler/src/compiler/watcher.rs +579 -0
- package/compiler/src/config.rs +51 -8
- package/compiler/src/main.rs +41 -28
- package/compiler/src/parser/html.rs +229 -118
- package/component.js +23 -11
- package/package.json +1 -1
- package/runtime/cleanup.js +4 -4
- package/runtime/component.js +98 -21
- package/runtime/conditionals.js +2 -2
- package/runtime/constants.js +2 -1
- package/runtime/index.js +152 -30
- package/runtime/iterate.js +27 -5
- package/runtime/parse.js +2 -1
- package/runtime/pre-compiled-iterations.js +153 -0
- package/runtime/{hyperspeed.js → pre-compiled-manifest.js} +204 -132
- package/runtime/utils.js +2 -1
- package/test-results/.last-run.json +4 -0
- package/vibe.css +19 -0
- package/runtime/component-state.js +0 -63
|
@@ -0,0 +1,572 @@
|
|
|
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
|
+
source_map: Lrc<SourceMap>,
|
|
13
|
+
base_path: PathBuf,
|
|
14
|
+
_module_cache: HashMap<PathBuf, Module>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
impl JsAnalyzer {
|
|
18
|
+
pub fn new(base_path: PathBuf) -> Self {
|
|
19
|
+
Self {
|
|
20
|
+
source_map: Lrc::new(SourceMap::default()),
|
|
21
|
+
base_path,
|
|
22
|
+
_module_cache: HashMap::new(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Extract state from a JavaScript/TypeScript script
|
|
27
|
+
pub fn extract_state(&mut self, script: &str) -> Option<Value> {
|
|
28
|
+
// eprintln!("[JsAnalyzer] extract_state called, script length: {}", script.len());
|
|
29
|
+
let module = self.parse_module(script)?;
|
|
30
|
+
// eprintln!("[JsAnalyzer] Module parsed");
|
|
31
|
+
|
|
32
|
+
// Build scope with import resolution
|
|
33
|
+
let mut scope_builder = ScopeBuilder::new(self.base_path.clone());
|
|
34
|
+
module.visit_with(&mut scope_builder);
|
|
35
|
+
// eprintln!("[JsAnalyzer] Found {} imports", scope_builder.imports.len());
|
|
36
|
+
|
|
37
|
+
// Resolve imports to actual Values
|
|
38
|
+
let mut resolved_imports: HashMap<String, Value> = HashMap::new();
|
|
39
|
+
for (import_name, import_path) in &scope_builder.imports {
|
|
40
|
+
if let Some(value) = self.resolve_import_value(&import_path, import_name) {
|
|
41
|
+
resolved_imports.insert(import_name.clone(), value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find vibe() calls and resolve their arguments
|
|
46
|
+
let mut state_extractor = StateExtractor::new(scope_builder.bindings);
|
|
47
|
+
state_extractor.resolved_imports = resolved_imports;
|
|
48
|
+
module.visit_with(&mut state_extractor);
|
|
49
|
+
|
|
50
|
+
state_extractor.merged_state
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Resolve an import and return the exported value (fully resolved)
|
|
54
|
+
fn resolve_import_value(&mut self, import_path: &str, import_name: &str) -> Option<Value> {
|
|
55
|
+
// eprintln!("[JsAnalyzer] Resolving import: '{}' name: '{}'", import_path, import_name);
|
|
56
|
+
// Resolve relative path
|
|
57
|
+
let resolved_path = self.resolve_path(import_path)?;
|
|
58
|
+
// eprintln!("[JsAnalyzer] Resolved to: {:?}", resolved_path);
|
|
59
|
+
|
|
60
|
+
// Read and parse the module
|
|
61
|
+
let code = fs::read_to_string(&resolved_path).ok()?;
|
|
62
|
+
let module = self.parse_module(&code)?;
|
|
63
|
+
|
|
64
|
+
// Build scope for the imported module
|
|
65
|
+
let mut scope_builder = ScopeBuilder::new(resolved_path.parent()?.to_path_buf());
|
|
66
|
+
module.visit_with(&mut scope_builder);
|
|
67
|
+
|
|
68
|
+
// Find the exported binding
|
|
69
|
+
let mut export_finder = ExportFinder::new(
|
|
70
|
+
import_name.to_string(),
|
|
71
|
+
scope_builder.bindings.clone(),
|
|
72
|
+
);
|
|
73
|
+
module.visit_with(&mut export_finder);
|
|
74
|
+
|
|
75
|
+
// Resolve the exported expression with the file's bindings
|
|
76
|
+
if let Some(expr) = export_finder.found_export {
|
|
77
|
+
let state_extractor = StateExtractor::new(scope_builder.bindings);
|
|
78
|
+
return state_extractor.resolve_expr(&expr);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
None
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Resolve import path relative to base_path
|
|
85
|
+
fn resolve_path(&self, import_path: &str) -> Option<PathBuf> {
|
|
86
|
+
// eprintln!("[JsAnalyzer] Resolving path: '{}' from base: '{:?}'", import_path, self.base_path);
|
|
87
|
+
let resolved = if import_path.starts_with('/') {
|
|
88
|
+
// Absolute path: treat as relative to base_path root
|
|
89
|
+
let clean_path = import_path.strip_prefix('/').unwrap_or(import_path);
|
|
90
|
+
let candidate = self.base_path.join(clean_path);
|
|
91
|
+
// eprintln!("[JsAnalyzer] Candidate absolute path: {:?}", candidate);
|
|
92
|
+
candidate
|
|
93
|
+
} else if import_path.starts_with("./") || import_path.starts_with("../") {
|
|
94
|
+
// Relative path
|
|
95
|
+
self.base_path.join(import_path)
|
|
96
|
+
} else {
|
|
97
|
+
// Neither absolute nor relative - treat as relative
|
|
98
|
+
self.base_path.join(import_path)
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Try with and without .js extension
|
|
102
|
+
if resolved.exists() {
|
|
103
|
+
// eprintln!("[JsAnalyzer] Path exists: {:?}", resolved);
|
|
104
|
+
Some(resolved)
|
|
105
|
+
} else {
|
|
106
|
+
// eprintln!("[JsAnalyzer] Path doesn't exist, trying with .js extension");
|
|
107
|
+
let with_js = resolved.with_extension("js");
|
|
108
|
+
if with_js.exists() {
|
|
109
|
+
// eprintln!("[JsAnalyzer] Path with .js exists: {:?}", with_js);
|
|
110
|
+
Some(with_js)
|
|
111
|
+
} else {
|
|
112
|
+
// eprintln!("[JsAnalyzer] Path not found: {:?}", resolved);
|
|
113
|
+
None
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Parse JavaScript/TypeScript module
|
|
119
|
+
fn parse_module(&self, code: &str) -> Option<Module> {
|
|
120
|
+
let fm = self.source_map.new_source_file(
|
|
121
|
+
Lrc::new(FileName::Anon),
|
|
122
|
+
code.to_string(),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
let syntax = Syntax::Es(EsSyntax {
|
|
126
|
+
jsx: false,
|
|
127
|
+
decorators: true,
|
|
128
|
+
..Default::default()
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let input = StringInput::new(
|
|
132
|
+
&fm.src,
|
|
133
|
+
fm.start_pos,
|
|
134
|
+
fm.end_pos,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let mut parser = Parser::new(
|
|
138
|
+
syntax,
|
|
139
|
+
input,
|
|
140
|
+
None,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
parser.parse_module().ok()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Builds a scope of variable bindings and tracks imports
|
|
148
|
+
struct ScopeBuilder {
|
|
149
|
+
bindings: HashMap<String, Expr>,
|
|
150
|
+
imports: HashMap<String, String>, // import_name -> import_path
|
|
151
|
+
_base_path: PathBuf,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
impl ScopeBuilder {
|
|
155
|
+
fn new(base_path: PathBuf) -> Self {
|
|
156
|
+
Self {
|
|
157
|
+
bindings: HashMap::new(),
|
|
158
|
+
imports: HashMap::new(),
|
|
159
|
+
_base_path: base_path,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
impl Visit for ScopeBuilder {
|
|
165
|
+
fn visit_import_decl(&mut self, import: &ImportDecl) {
|
|
166
|
+
let import_path = import.src.value.to_string();
|
|
167
|
+
|
|
168
|
+
for specifier in &import.specifiers {
|
|
169
|
+
match specifier {
|
|
170
|
+
// import { name } from './file.js'
|
|
171
|
+
ImportSpecifier::Named(named) => {
|
|
172
|
+
let _import_name = match &named.imported {
|
|
173
|
+
Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
|
|
174
|
+
_ => named.local.sym.to_string(),
|
|
175
|
+
};
|
|
176
|
+
self.imports.insert(named.local.sym.to_string(), import_path.clone());
|
|
177
|
+
}
|
|
178
|
+
// import name from './file.js' (default import)
|
|
179
|
+
ImportSpecifier::Default(default) => {
|
|
180
|
+
self.imports.insert(default.local.sym.to_string(), import_path.clone());
|
|
181
|
+
}
|
|
182
|
+
// import * as name from './file.js'
|
|
183
|
+
ImportSpecifier::Namespace(namespace) => {
|
|
184
|
+
self.imports.insert(namespace.local.sym.to_string(), import_path.clone());
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
import.visit_children_with(self);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn visit_export_decl(&mut self, export: &ExportDecl) {
|
|
193
|
+
// Handle: export const name = value
|
|
194
|
+
match &export.decl {
|
|
195
|
+
Decl::Var(var_decl) => {
|
|
196
|
+
self.visit_var_decl(var_decl);
|
|
197
|
+
}
|
|
198
|
+
_ => {}
|
|
199
|
+
}
|
|
200
|
+
export.visit_children_with(self);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fn visit_var_decl(&mut self, decl: &VarDecl) {
|
|
204
|
+
for declarator in &decl.decls {
|
|
205
|
+
if let Pat::Ident(ident) = &declarator.name {
|
|
206
|
+
if let Some(init) = &declarator.init {
|
|
207
|
+
self.bindings.insert(
|
|
208
|
+
ident.id.sym.to_string(),
|
|
209
|
+
(**init).clone(),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
decl.visit_children_with(self);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Finds exported bindings in a module
|
|
219
|
+
struct ExportFinder {
|
|
220
|
+
target_name: String,
|
|
221
|
+
bindings: HashMap<String, Expr>,
|
|
222
|
+
found_export: Option<Expr>,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
impl ExportFinder {
|
|
226
|
+
fn new(target_name: String, bindings: HashMap<String, Expr>) -> Self {
|
|
227
|
+
Self {
|
|
228
|
+
target_name,
|
|
229
|
+
bindings,
|
|
230
|
+
found_export: None,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
impl Visit for ExportFinder {
|
|
236
|
+
fn visit_export_decl(&mut self, export: &ExportDecl) {
|
|
237
|
+
match &export.decl {
|
|
238
|
+
Decl::Var(var_decl) => {
|
|
239
|
+
for declarator in &var_decl.decls {
|
|
240
|
+
if let Pat::Ident(ident) = &declarator.name {
|
|
241
|
+
if ident.id.sym.to_string() == self.target_name {
|
|
242
|
+
if let Some(init) = &declarator.init {
|
|
243
|
+
self.found_export = Some((**init).clone());
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
_ => {}
|
|
251
|
+
}
|
|
252
|
+
export.visit_children_with(self);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn visit_named_export(&mut self, export: &NamedExport) {
|
|
256
|
+
// Handle: export { name }
|
|
257
|
+
for specifier in &export.specifiers {
|
|
258
|
+
if let ExportSpecifier::Named(named) = specifier {
|
|
259
|
+
// Get the local name (what it's called in this file)
|
|
260
|
+
let local_name = if let ModuleExportName::Ident(ident) = &named.orig {
|
|
261
|
+
ident.sym.to_string()
|
|
262
|
+
} else {
|
|
263
|
+
continue;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Get the export name (what it's called when imported)
|
|
267
|
+
let export_name = match &named.exported {
|
|
268
|
+
Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
|
|
269
|
+
_ => local_name.clone(),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Check if this is the export we're looking for
|
|
273
|
+
if export_name == self.target_name {
|
|
274
|
+
// Look up the binding
|
|
275
|
+
if let Some(binding) = self.bindings.get(&local_name) {
|
|
276
|
+
self.found_export = Some(binding.clone());
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
export.visit_children_with(self);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Extracts state from vibe()/state()/component() calls
|
|
287
|
+
struct StateExtractor {
|
|
288
|
+
bindings: HashMap<String, Expr>,
|
|
289
|
+
resolved_imports: HashMap<String, Value>,
|
|
290
|
+
merged_state: Option<Value>,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
impl StateExtractor {
|
|
294
|
+
fn new(bindings: HashMap<String, Expr>) -> Self {
|
|
295
|
+
Self {
|
|
296
|
+
bindings,
|
|
297
|
+
resolved_imports: HashMap::new(),
|
|
298
|
+
merged_state: None,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// Resolve an expression to a JSON value
|
|
303
|
+
fn resolve_expr(&self, expr: &Expr) -> Option<Value> {
|
|
304
|
+
match expr {
|
|
305
|
+
// Object literal: { a: 1, b: 2 }
|
|
306
|
+
Expr::Object(obj) => self.resolve_object(obj),
|
|
307
|
+
|
|
308
|
+
// Array literal: [1, 2, 3]
|
|
309
|
+
Expr::Array(arr) => self.resolve_array(arr),
|
|
310
|
+
|
|
311
|
+
// String literal: "hello"
|
|
312
|
+
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
|
|
313
|
+
|
|
314
|
+
// Number literal: 42
|
|
315
|
+
Expr::Lit(Lit::Num(n)) => {
|
|
316
|
+
serde_json::Number::from_f64(n.value)
|
|
317
|
+
.map(Value::Number)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Boolean literal: true/false
|
|
321
|
+
Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
|
|
322
|
+
|
|
323
|
+
// Null literal
|
|
324
|
+
Expr::Lit(Lit::Null(_)) => Some(Value::Null),
|
|
325
|
+
|
|
326
|
+
// Identifier: resolve from imports first, then bindings
|
|
327
|
+
Expr::Ident(ident) => {
|
|
328
|
+
let name = ident.sym.to_string();
|
|
329
|
+
|
|
330
|
+
// Check resolved imports first (these are fully resolved Values)
|
|
331
|
+
if let Some(value) = self.resolved_imports.get(&name) {
|
|
332
|
+
return Some(value.clone());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Fall back to bindings (these are Exprs that need resolution)
|
|
336
|
+
self.bindings.get(&name)
|
|
337
|
+
.and_then(|expr| self.resolve_expr(expr))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Parenthesized expression: (expr)
|
|
341
|
+
Expr::Paren(paren) => self.resolve_expr(&paren.expr),
|
|
342
|
+
|
|
343
|
+
_ => None, // Can't statically resolve
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Resolve object literal with spread support
|
|
348
|
+
/// Uses partial resolution: resolves what it can, skips what it can't
|
|
349
|
+
fn resolve_object(&self, obj: &ObjectLit) -> Option<Value> {
|
|
350
|
+
let mut result = Map::new();
|
|
351
|
+
|
|
352
|
+
for prop in &obj.props {
|
|
353
|
+
match prop {
|
|
354
|
+
// Regular property: a: 1
|
|
355
|
+
PropOrSpread::Prop(prop) => {
|
|
356
|
+
if let Prop::KeyValue(kv) = &**prop {
|
|
357
|
+
if let Some(key) = self.get_prop_key(&kv.key) {
|
|
358
|
+
if let Some(value) = self.resolve_expr(&kv.value) {
|
|
359
|
+
result.insert(key, value);
|
|
360
|
+
}
|
|
361
|
+
// Skip properties that can't be resolved
|
|
362
|
+
}
|
|
363
|
+
} else if let Prop::Shorthand(ident) = &**prop {
|
|
364
|
+
// Shorthand: { a } where a is a variable
|
|
365
|
+
let key = ident.sym.to_string();
|
|
366
|
+
if let Some(expr) = self.bindings.get(&key) {
|
|
367
|
+
if let Some(value) = self.resolve_expr(expr) {
|
|
368
|
+
result.insert(key, value);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Skip if can't resolve
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Spread: ...other
|
|
376
|
+
PropOrSpread::Spread(spread) => {
|
|
377
|
+
// Resolve the spread expression
|
|
378
|
+
if let Some(Value::Object(spread_obj)) = self.resolve_expr(&spread.expr) {
|
|
379
|
+
// Merge spread properties
|
|
380
|
+
for (k, v) in spread_obj {
|
|
381
|
+
result.insert(k, v);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Skip spreads that can't be resolved
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Return partial object even if some properties couldn't be resolved
|
|
390
|
+
if result.is_empty() {
|
|
391
|
+
None
|
|
392
|
+
} else {
|
|
393
|
+
Some(Value::Object(result))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Resolve array literal
|
|
398
|
+
/// Uses partial resolution: includes elements that can be resolved, skips others
|
|
399
|
+
fn resolve_array(&self, arr: &ArrayLit) -> Option<Value> {
|
|
400
|
+
let mut result = Vec::new();
|
|
401
|
+
|
|
402
|
+
for elem in &arr.elems {
|
|
403
|
+
if let Some(elem) = elem {
|
|
404
|
+
match elem {
|
|
405
|
+
// Regular element
|
|
406
|
+
ExprOrSpread { spread: None, expr } => {
|
|
407
|
+
if let Some(value) = self.resolve_expr(expr) {
|
|
408
|
+
result.push(value);
|
|
409
|
+
}
|
|
410
|
+
// Skip elements that can't be resolved
|
|
411
|
+
}
|
|
412
|
+
// Spread element: ...arr
|
|
413
|
+
ExprOrSpread { spread: Some(_), expr } => {
|
|
414
|
+
if let Some(Value::Array(spread_arr)) = self.resolve_expr(expr) {
|
|
415
|
+
result.extend(spread_arr);
|
|
416
|
+
}
|
|
417
|
+
// Skip spreads that can't be resolved
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Return partial array even if some elements couldn't be resolved
|
|
424
|
+
if result.is_empty() {
|
|
425
|
+
None
|
|
426
|
+
} else {
|
|
427
|
+
Some(Value::Array(result))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/// Get string key from property name
|
|
432
|
+
fn get_prop_key(&self, key: &PropName) -> Option<String> {
|
|
433
|
+
match key {
|
|
434
|
+
PropName::Ident(ident) => Some(ident.sym.to_string()),
|
|
435
|
+
PropName::Str(s) => Some(s.value.to_string()),
|
|
436
|
+
_ => None,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/// Merge state from a vibe() call
|
|
441
|
+
fn merge_state(&mut self, state: Value) {
|
|
442
|
+
if let Value::Object(new_state) = state {
|
|
443
|
+
match &mut self.merged_state {
|
|
444
|
+
Some(Value::Object(existing)) => {
|
|
445
|
+
for (k, v) in new_state {
|
|
446
|
+
existing.insert(k, v);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
_ => {
|
|
450
|
+
self.merged_state = Some(Value::Object(new_state));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
impl Visit for StateExtractor {
|
|
458
|
+
fn visit_call_expr(&mut self, call: &CallExpr) {
|
|
459
|
+
// Check if this is a vibe() or state() call
|
|
460
|
+
if let Callee::Expr(expr) = &call.callee {
|
|
461
|
+
if let Expr::Ident(ident) = &**expr {
|
|
462
|
+
let name = ident.sym.to_string();
|
|
463
|
+
if name == "vibe" || name == "state" || name == "component" {
|
|
464
|
+
// Get first argument (the state object)
|
|
465
|
+
if let Some(arg) = call.args.first() {
|
|
466
|
+
if let Some(state) = self.resolve_expr(&arg.expr) {
|
|
467
|
+
self.merge_state(state);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
call.visit_children_with(self);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#[cfg(test)]
|
|
479
|
+
mod tests {
|
|
480
|
+
use super::*;
|
|
481
|
+
use serde_json::json;
|
|
482
|
+
|
|
483
|
+
#[test]
|
|
484
|
+
fn test_simple_object() {
|
|
485
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
486
|
+
let script = r#"
|
|
487
|
+
vibe({ count: 0, name: "test" });
|
|
488
|
+
"#;
|
|
489
|
+
|
|
490
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
491
|
+
assert_eq!(state, json!({ "count": 0, "name": "test" }));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[test]
|
|
495
|
+
fn test_variable_reference() {
|
|
496
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
497
|
+
let script = r#"
|
|
498
|
+
const items = [1, 2, 3];
|
|
499
|
+
vibe({ items });
|
|
500
|
+
"#;
|
|
501
|
+
|
|
502
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
503
|
+
assert_eq!(state, json!({ "items": [1, 2, 3] }));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#[test]
|
|
507
|
+
fn test_spread_operator() {
|
|
508
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
509
|
+
let script = r#"
|
|
510
|
+
const base = { a: 1, b: 2 };
|
|
511
|
+
vibe({ ...base, c: 3 });
|
|
512
|
+
"#;
|
|
513
|
+
|
|
514
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
515
|
+
assert_eq!(state, json!({ "a": 1, "b": 2, "c": 3 }));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#[test]
|
|
519
|
+
fn test_nested_objects() {
|
|
520
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
521
|
+
let script = r#"
|
|
522
|
+
const user = { name: "John", age: 30 };
|
|
523
|
+
vibe({ user, count: 0 });
|
|
524
|
+
"#;
|
|
525
|
+
|
|
526
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
527
|
+
assert_eq!(state, json!({ "user": { "name": "John", "age": 30 }, "count": 0 }));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[test]
|
|
531
|
+
fn test_array_spread() {
|
|
532
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
|
|
533
|
+
let script = r#"
|
|
534
|
+
const base = [1, 2];
|
|
535
|
+
const items = [...base, 3, 4];
|
|
536
|
+
vibe({ items });
|
|
537
|
+
"#;
|
|
538
|
+
|
|
539
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
540
|
+
assert_eq!(state, json!({ "items": [1, 2, 3, 4] }));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[test]
|
|
544
|
+
fn test_import_resolution() {
|
|
545
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
|
|
546
|
+
let script = r#"
|
|
547
|
+
import { data } from './test-import.js';
|
|
548
|
+
vibe({ ...data, c: 3 });
|
|
549
|
+
"#;
|
|
550
|
+
|
|
551
|
+
let state = analyzer.extract_state(script).unwrap();
|
|
552
|
+
assert_eq!(state["a"].as_f64().unwrap(), 1.0);
|
|
553
|
+
assert_eq!(state["b"].as_f64().unwrap(), 2.0);
|
|
554
|
+
assert_eq!(state["c"].as_f64().unwrap(), 3.0);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#[test]
|
|
558
|
+
fn test_absolute_path_import() {
|
|
559
|
+
let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
|
|
560
|
+
let script = r#"
|
|
561
|
+
import { menuState } from '/test-data.js';
|
|
562
|
+
vibe({ ...menuState, extra: "value" });
|
|
563
|
+
"#;
|
|
564
|
+
|
|
565
|
+
let state = analyzer.extract_state(script);
|
|
566
|
+
assert!(state.is_some(), "State should be extracted");
|
|
567
|
+
let state = state.unwrap();
|
|
568
|
+
assert!(state["menuSections"].is_array(), "menuSections should be an array");
|
|
569
|
+
assert_eq!(state["other"].as_str().unwrap(), "value");
|
|
570
|
+
assert_eq!(state["extra"].as_str().unwrap(), "value");
|
|
571
|
+
}
|
|
572
|
+
}
|