@ape-egg/vibe 2.1.4 → 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,17 +9,20 @@ use std::fs;
9
9
 
10
10
  /// Analyzes JavaScript code to extract state from vibe() calls
11
11
  pub struct JsAnalyzer {
12
- source_map: Lrc<SourceMap>,
13
12
  base_path: PathBuf,
14
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>>,
15
18
  }
16
19
 
17
20
  impl JsAnalyzer {
18
21
  pub fn new(base_path: PathBuf) -> Self {
19
22
  Self {
20
- source_map: Lrc::new(SourceMap::default()),
21
23
  base_path,
22
24
  _module_cache: HashMap::new(),
25
+ import_cache: HashMap::new(),
23
26
  }
24
27
  }
25
28
 
@@ -34,11 +37,11 @@ impl JsAnalyzer {
34
37
  module.visit_with(&mut scope_builder);
35
38
  // eprintln!("[JsAnalyzer] Found {} imports", scope_builder.imports.len());
36
39
 
37
- // Resolve imports to actual Values
40
+ // Resolve imports to actual Values (keyed by the LOCAL name used in code)
38
41
  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
+ 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);
42
45
  }
43
46
  }
44
47
 
@@ -50,35 +53,73 @@ impl JsAnalyzer {
50
53
  state_extractor.merged_state
51
54
  }
52
55
 
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
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
+
57
76
  let resolved_path = self.resolve_path(import_path)?;
58
- // eprintln!("[JsAnalyzer] Resolved to: {:?}", resolved_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
+ }
59
89
 
60
- // Read and parse the module
61
- let code = fs::read_to_string(&resolved_path).ok()?;
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()?;
62
97
  let module = self.parse_module(&code)?;
63
98
 
64
- // Build scope for the imported module
99
+ // Build scope for the imported module.
65
100
  let mut scope_builder = ScopeBuilder::new(resolved_path.parent()?.to_path_buf());
66
101
  module.visit_with(&mut scope_builder);
67
102
 
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);
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
+ }
79
112
  }
80
113
 
81
- None
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)
82
123
  }
83
124
 
84
125
  /// Resolve import path relative to base_path
@@ -115,9 +156,12 @@ impl JsAnalyzer {
115
156
  }
116
157
  }
117
158
 
118
- /// Parse JavaScript/TypeScript module
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).
119
162
  fn parse_module(&self, code: &str) -> Option<Module> {
120
- let fm = self.source_map.new_source_file(
163
+ let source_map: Lrc<SourceMap> = Default::default();
164
+ let fm = source_map.new_source_file(
121
165
  Lrc::new(FileName::Anon),
122
166
  code.to_string(),
123
167
  );
@@ -147,7 +191,9 @@ impl JsAnalyzer {
147
191
  /// Builds a scope of variable bindings and tracks imports
148
192
  struct ScopeBuilder {
149
193
  bindings: HashMap<String, Expr>,
150
- imports: HashMap<String, String>, // import_name -> import_path
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)>,
151
197
  _base_path: PathBuf,
152
198
  }
153
199
 
@@ -167,21 +213,29 @@ impl Visit for ScopeBuilder {
167
213
 
168
214
  for specifier in &import.specifiers {
169
215
  match specifier {
170
- // import { name } from './file.js'
216
+ // import { orig as local } from './file.js'
171
217
  ImportSpecifier::Named(named) => {
172
- let _import_name = match &named.imported {
218
+ let imported = match &named.imported {
173
219
  Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
174
- _ => named.local.sym.to_string(),
220
+ Some(ModuleExportName::Str(s)) => s.value.to_string(),
221
+ None => named.local.sym.to_string(),
175
222
  };
176
- self.imports.insert(named.local.sym.to_string(), import_path.clone());
223
+ self.imports
224
+ .insert(named.local.sym.to_string(), (import_path.clone(), imported));
177
225
  }
178
226
  // import name from './file.js' (default import)
179
227
  ImportSpecifier::Default(default) => {
180
- self.imports.insert(default.local.sym.to_string(), import_path.clone());
228
+ self.imports.insert(
229
+ default.local.sym.to_string(),
230
+ (import_path.clone(), "default".to_string()),
231
+ );
181
232
  }
182
- // import * as name from './file.js'
233
+ // import * as name from './file.js' (namespace import)
183
234
  ImportSpecifier::Namespace(namespace) => {
184
- self.imports.insert(namespace.local.sym.to_string(), import_path.clone());
235
+ self.imports.insert(
236
+ namespace.local.sym.to_string(),
237
+ (import_path.clone(), "*".to_string()),
238
+ );
185
239
  }
186
240
  }
187
241
  }
@@ -233,6 +287,13 @@ impl ExportFinder {
233
287
  }
234
288
 
235
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
+
236
297
  fn visit_export_decl(&mut self, export: &ExportDecl) {
237
298
  match &export.decl {
238
299
  Decl::Var(var_decl) => {
@@ -626,4 +687,29 @@ mod tests {
626
687
  assert_eq!(state["other"].as_str().unwrap(), "value");
627
688
  assert_eq!(state["extra"].as_str().unwrap(), "value");
628
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
+ }
629
715
  }
@@ -5,6 +5,7 @@ mod value_stamper;
5
5
  mod iteration_optimizer;
6
6
  mod js_analyzer;
7
7
  mod component_tagger;
8
+ mod reassignment_analyzer;
8
9
  pub mod watcher;
9
10
 
10
11
  pub use compile::Compiler;