@ape-egg/vibe 1.3.2 → 1.6.1

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.
@@ -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
+ }