1use anyhow::{anyhow, Result};
7use oxc_allocator::Allocator;
8use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement};
9use oxc_codegen::Codegen;
10use oxc_isolated_declarations::{IsolatedDeclarations, IsolatedDeclarationsOptions};
11use oxc_parser::Parser;
12use oxc_semantic::SemanticBuilder;
13use oxc_span::SourceType;
14use oxc_transformer::{TransformOptions, Transformer};
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18pub fn transpile_typescript(source: &str, filename: &str) -> Result<String> {
20 let allocator = Allocator::default();
21 let source_type = SourceType::from_path(filename).unwrap_or_default();
22
23 let parser_ret = Parser::new(&allocator, source, source_type).parse();
25 if !parser_ret.errors.is_empty() {
26 let errors: Vec<String> = parser_ret.errors.iter().map(|e| e.to_string()).collect();
27 return Err(anyhow!("TypeScript parse errors: {}", errors.join("; ")));
28 }
29
30 let mut program = parser_ret.program;
31
32 let semantic_ret = SemanticBuilder::new().build(&program);
34
35 if !semantic_ret.errors.is_empty() {
36 let errors: Vec<String> = semantic_ret.errors.iter().map(|e| e.to_string()).collect();
37 return Err(anyhow!("Semantic errors: {}", errors.join("; ")));
38 }
39
40 let scoping = semantic_ret.semantic.into_scoping();
42
43 let transform_options = TransformOptions::default();
45 let transformer_ret = Transformer::new(&allocator, Path::new(filename), &transform_options)
46 .build_with_scoping(scoping, &mut program);
47
48 if !transformer_ret.errors.is_empty() {
49 let errors: Vec<String> = transformer_ret
50 .errors
51 .iter()
52 .map(|e| e.to_string())
53 .collect();
54 return Err(anyhow!("Transform errors: {}", errors.join("; ")));
55 }
56
57 let codegen_ret = Codegen::new().build(&program);
59
60 Ok(codegen_ret.code)
61}
62
63pub fn emit_isolated_declarations(source: &str, filename: &str) -> Result<String> {
94 let allocator = Allocator::default();
95 let source_type = SourceType::from_path(filename)
96 .unwrap_or_default()
97 .with_module(true);
98
99 let module_marked;
100 let effective_source: &str = if has_es_module_syntax(source) {
101 source
102 } else {
103 module_marked = format!("{source}\nexport {{}};\n");
104 &module_marked
105 };
106
107 let parser_ret = Parser::new(&allocator, effective_source, source_type).parse();
108 if !parser_ret.errors.is_empty() {
109 let errors: Vec<String> = parser_ret.errors.iter().map(|e| e.to_string()).collect();
110 return Err(anyhow!(
111 "isolated-declarations parse errors in {}: {}",
112 filename,
113 errors.join("; ")
114 ));
115 }
116
117 let emit = IsolatedDeclarations::new(&allocator, IsolatedDeclarationsOptions::default())
118 .build(&parser_ret.program);
119
120 let codegen_ret = Codegen::new().build(&emit.program);
126 Ok(codegen_ret.code)
127}
128
129pub fn has_es_module_syntax(source: &str) -> bool {
132 let has_imports = source.contains("import ") && source.contains(" from ");
134 let has_exports = source.lines().any(|line| {
136 let trimmed = line.trim();
137 trimmed.starts_with("export ")
138 });
139 has_imports || has_exports
140}
141
142pub fn has_es_imports(source: &str) -> bool {
145 source.contains("import ") && source.contains(" from ")
146}
147
148pub fn extract_plugin_dependencies(source: &str) -> Vec<String> {
158 let prefix = "fresh:plugin/";
159 let mut deps = Vec::new();
160 let mut seen = HashSet::new();
161
162 for line in source.lines() {
163 let trimmed = line.trim();
164 if !trimmed.starts_with("import ") || !trimmed.contains(prefix) {
166 continue;
167 }
168 if let Some(from_idx) = trimmed.find(" from ") {
170 let after_from = &trimmed[from_idx + 6..]; let after_from = after_from.trim();
172 let quote_char = after_from.chars().next();
174 if let Some(q) = quote_char {
175 if q == '"' || q == '\'' {
176 if let Some(end) = after_from[1..].find(q) {
177 let module_path = &after_from[1..1 + end];
178 if let Some(plugin_name) = module_path.strip_prefix(prefix) {
179 if !plugin_name.is_empty() && seen.insert(plugin_name.to_string()) {
180 deps.push(plugin_name.to_string());
181 }
182 }
183 }
184 }
185 }
186 }
187 }
188
189 deps
190}
191
192pub fn topological_sort_plugins(
199 plugin_names: &[String],
200 dependencies: &std::collections::HashMap<String, Vec<String>>,
201) -> Result<Vec<String>> {
202 use std::collections::HashMap;
203
204 let mut in_degree: HashMap<&str, usize> = HashMap::new();
206 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
207
208 for name in plugin_names {
209 in_degree.entry(name.as_str()).or_insert(0);
210 }
211
212 for name in plugin_names {
213 if let Some(deps) = dependencies.get(name) {
214 for dep in deps {
215 if in_degree.contains_key(dep.as_str()) {
217 *in_degree.entry(name.as_str()).or_insert(0) += 1;
218 dependents
219 .entry(dep.as_str())
220 .or_default()
221 .push(name.as_str());
222 } else {
223 return Err(anyhow!(
224 "Plugin '{}' depends on '{}', which is not installed or not enabled",
225 name,
226 dep
227 ));
228 }
229 }
230 }
231 }
232
233 let mut queue: Vec<&str> = in_degree
235 .iter()
236 .filter(|(_, °)| deg == 0)
237 .map(|(&name, _)| name)
238 .collect();
239 queue.sort();
241
242 let mut result: Vec<String> = Vec::with_capacity(plugin_names.len());
243
244 while let Some(current) = queue.first().copied() {
245 queue.remove(0);
246 result.push(current.to_string());
247
248 if let Some(deps) = dependents.get(current) {
249 let mut newly_ready = Vec::new();
250 for &dependent in deps {
251 if let Some(deg) = in_degree.get_mut(dependent) {
252 *deg -= 1;
253 if *deg == 0 {
254 newly_ready.push(dependent);
255 }
256 }
257 }
258 newly_ready.sort();
260 queue.extend(newly_ready);
261 queue.sort(); }
263 }
264
265 if result.len() != plugin_names.len() {
266 let in_result: HashSet<&str> = result.iter().map(|s| s.as_str()).collect();
268 let cycle_plugins: Vec<String> = plugin_names
269 .iter()
270 .filter(|n| !in_result.contains(n.as_str()))
271 .cloned()
272 .collect();
273 return Err(anyhow!(
274 "Plugin dependency cycle detected among: {}. These plugins will not be loaded.",
275 cycle_plugins.join(", ")
276 ));
277 }
278
279 Ok(result)
280}
281
282#[derive(Debug, Clone)]
284struct ModuleMetadata {
285 path: PathBuf,
287 var_name: String,
289 imports: Vec<ImportBinding>,
291 exports: Vec<ExportBinding>,
293 reexports: Vec<ReexportBinding>,
295 code: String,
297}
298
299#[derive(Debug, Clone)]
300struct ImportBinding {
301 local_name: String,
303 imported_name: Option<String>,
305 source_path: String,
307 is_namespace: bool,
309}
310
311#[derive(Debug, Clone)]
312struct ExportBinding {
313 exported_name: String,
315 local_name: String,
317}
318
319#[derive(Debug, Clone)]
320struct ReexportBinding {
321 exported_name: Option<String>,
323 source_name: Option<String>,
325 source_path: String,
327}
328
329pub fn bundle_module(entry_path: &Path) -> Result<String> {
332 let mut modules: Vec<ModuleMetadata> = Vec::new();
333 let mut visited = HashSet::new();
334 let mut path_to_var: std::collections::HashMap<PathBuf, String> =
335 std::collections::HashMap::new();
336
337 collect_modules(entry_path, &mut visited, &mut modules, &mut path_to_var)?;
339
340 let mut output = String::new();
342
343 for (i, module) in modules.iter().enumerate() {
344 let is_entry = i == modules.len() - 1;
345 output.push_str(&generate_scoped_module(module, &path_to_var, is_entry)?);
346 output.push('\n');
347 }
348
349 Ok(output)
350}
351
352fn collect_modules(
354 path: &Path,
355 visited: &mut HashSet<PathBuf>,
356 modules: &mut Vec<ModuleMetadata>,
357 path_to_var: &mut std::collections::HashMap<PathBuf, String>,
358) -> Result<()> {
359 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
360 if visited.contains(&canonical) {
361 return Ok(()); }
363 visited.insert(canonical.clone());
364
365 let source = std::fs::read_to_string(path)
366 .map_err(|e| anyhow!("Failed to read {}: {}", path.display(), e))?;
367
368 let (imports, exports, reexports) = extract_module_bindings(&source);
370
371 let parent_dir = path.parent().unwrap_or(Path::new("."));
372
373 for import in &imports {
375 if import.source_path.starts_with("./") || import.source_path.starts_with("../") {
376 let resolved = resolve_import(&import.source_path, parent_dir)?;
377 collect_modules(&resolved, visited, modules, path_to_var)?;
378 }
379 }
380 for reexport in &reexports {
381 if reexport.source_path.starts_with("./") || reexport.source_path.starts_with("../") {
382 let resolved = resolve_import(&reexport.source_path, parent_dir)?;
383 collect_modules(&resolved, visited, modules, path_to_var)?;
384 }
385 }
386
387 let var_name = path_to_module_var(path);
389 path_to_var.insert(canonical.clone(), var_name.clone());
390
391 let stripped = strip_imports_and_exports(&source);
393 let filename = path.to_str().unwrap_or("unknown.ts");
394 let transpiled = transpile_typescript(&stripped, filename)?;
395
396 modules.push(ModuleMetadata {
397 path: canonical,
398 var_name,
399 imports,
400 exports,
401 reexports,
402 code: transpiled,
403 });
404
405 Ok(())
406}
407
408fn path_to_module_var(path: &Path) -> String {
410 let name = path
411 .file_stem()
412 .and_then(|s| s.to_str())
413 .unwrap_or("module");
414
415 let sanitized: String = name
417 .chars()
418 .map(|c| if c.is_alphanumeric() { c } else { '_' })
419 .collect();
420
421 use std::hash::{Hash, Hasher};
423 let mut hasher = std::collections::hash_map::DefaultHasher::new();
424 path.hash(&mut hasher);
425 let hash = hasher.finish();
426
427 format!("__mod_{}_{:x}", sanitized, hash & 0xFFFF)
428}
429
430fn generate_scoped_module(
432 module: &ModuleMetadata,
433 path_to_var: &std::collections::HashMap<PathBuf, String>,
434 is_entry: bool,
435) -> Result<String> {
436 let mut code = String::new();
437
438 if is_entry {
440 code.push_str("(function() {\n");
441 } else {
442 code.push_str(&format!("const {} = (function() {{\n", module.var_name));
443 }
444
445 for import in &module.imports {
447 if let Some(dep_var) = resolve_import_to_var(&import.source_path, &module.path, path_to_var)
448 {
449 if import.is_namespace {
450 code.push_str(&format!("const {} = {};\n", import.local_name, dep_var));
452 } else if let Some(ref imported_name) = import.imported_name {
453 if imported_name == "default" {
455 code.push_str(&format!(
456 "const {} = {}.default;\n",
457 import.local_name, dep_var
458 ));
459 } else if &import.local_name == imported_name {
460 code.push_str(&format!("const {{{}}} = {};\n", import.local_name, dep_var));
461 } else {
462 code.push_str(&format!(
463 "const {{{}: {}}} = {};\n",
464 imported_name, import.local_name, dep_var
465 ));
466 }
467 } else {
468 code.push_str(&format!(
470 "const {} = {}.default;\n",
471 import.local_name, dep_var
472 ));
473 }
474 }
475 }
476
477 code.push_str(&module.code);
479 code.push('\n');
480
481 if !is_entry {
483 code.push_str("return {");
484
485 let mut export_parts: Vec<String> = Vec::new();
486
487 for export in &module.exports {
489 if export.exported_name == export.local_name {
490 export_parts.push(export.exported_name.clone());
491 } else {
492 export_parts.push(format!("{}: {}", export.exported_name, export.local_name));
493 }
494 }
495
496 for reexport in &module.reexports {
498 if let Some(dep_var) =
499 resolve_import_to_var(&reexport.source_path, &module.path, path_to_var)
500 {
501 match (&reexport.exported_name, &reexport.source_name) {
502 (Some(exported), Some(source)) => {
503 export_parts.push(format!("{}: {}.{}", exported, dep_var, source));
505 }
506 (Some(exported), None) => {
507 export_parts.push(format!("{}: {}.{}", exported, dep_var, exported));
509 }
510 (None, None) => {
511 export_parts.push(format!("...{}", dep_var));
513 }
514 _ => {}
515 }
516 }
517 }
518
519 code.push_str(&export_parts.join(", "));
520 code.push_str("};\n");
521 }
522
523 code.push_str("})();\n");
525
526 Ok(code)
527}
528
529fn resolve_import_to_var(
531 source_path: &str,
532 importer_path: &Path,
533 path_to_var: &std::collections::HashMap<PathBuf, String>,
534) -> Option<String> {
535 if !source_path.starts_with("./") && !source_path.starts_with("../") {
536 return None; }
538
539 let parent_dir = importer_path.parent().unwrap_or(Path::new("."));
540 if let Ok(resolved) = resolve_import(source_path, parent_dir) {
541 let canonical = resolved.canonicalize().unwrap_or(resolved);
542 path_to_var.get(&canonical).cloned()
543 } else {
544 None
545 }
546}
547
548fn extract_module_bindings(
550 source: &str,
551) -> (Vec<ImportBinding>, Vec<ExportBinding>, Vec<ReexportBinding>) {
552 let allocator = Allocator::default();
553 let source_type = SourceType::default()
554 .with_module(true)
555 .with_typescript(true);
556
557 let parser_ret = Parser::new(&allocator, source, source_type).parse();
558 if !parser_ret.errors.is_empty() {
559 return (Vec::new(), Vec::new(), Vec::new());
560 }
561
562 let mut imports = Vec::new();
563 let mut exports = Vec::new();
564 let mut reexports = Vec::new();
565
566 for stmt in &parser_ret.program.body {
567 match stmt {
568 Statement::ImportDeclaration(import_decl) => {
569 let source_path = import_decl.source.value.to_string();
570
571 if let Some(specifiers) = &import_decl.specifiers {
573 for spec in specifiers {
574 match spec {
575 oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(s) => {
576 imports.push(ImportBinding {
577 local_name: s.local.name.to_string(),
578 imported_name: Some(s.imported.name().to_string()),
579 source_path: source_path.clone(),
580 is_namespace: false,
581 });
582 }
583 oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
584 imports.push(ImportBinding {
585 local_name: s.local.name.to_string(),
586 imported_name: None, source_path: source_path.clone(),
588 is_namespace: false,
589 });
590 }
591 oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(
592 s,
593 ) => {
594 imports.push(ImportBinding {
595 local_name: s.local.name.to_string(),
596 imported_name: None,
597 source_path: source_path.clone(),
598 is_namespace: true,
599 });
600 }
601 }
602 }
603 }
604 }
605
606 Statement::ExportNamedDeclaration(export_decl) => {
607 if let Some(ref source) = export_decl.source {
608 let source_path = source.value.to_string();
610 for spec in &export_decl.specifiers {
611 reexports.push(ReexportBinding {
612 exported_name: Some(spec.exported.name().to_string()),
613 source_name: Some(spec.local.name().to_string()),
614 source_path: source_path.clone(),
615 });
616 }
617 } else {
618 if let Some(ref decl) = export_decl.declaration {
620 for name in get_declaration_names(decl) {
622 exports.push(ExportBinding {
623 exported_name: name.clone(),
624 local_name: name,
625 });
626 }
627 }
628 for spec in &export_decl.specifiers {
630 exports.push(ExportBinding {
631 exported_name: spec.exported.name().to_string(),
632 local_name: spec.local.name().to_string(),
633 });
634 }
635 }
636 }
637
638 Statement::ExportDefaultDeclaration(export_default) => {
639 match &export_default.declaration {
641 ExportDefaultDeclarationKind::FunctionDeclaration(f) => {
642 if let Some(ref id) = f.id {
643 exports.push(ExportBinding {
644 exported_name: "default".to_string(),
645 local_name: id.name.to_string(),
646 });
647 }
648 }
649 ExportDefaultDeclarationKind::ClassDeclaration(c) => {
650 if let Some(ref id) = c.id {
651 exports.push(ExportBinding {
652 exported_name: "default".to_string(),
653 local_name: id.name.to_string(),
654 });
655 }
656 }
657 _ => {
658 exports.push(ExportBinding {
660 exported_name: "default".to_string(),
661 local_name: "__default__".to_string(),
662 });
663 }
664 }
665 }
666
667 Statement::ExportAllDeclaration(export_all) => {
668 reexports.push(ReexportBinding {
670 exported_name: None,
671 source_name: None,
672 source_path: export_all.source.value.to_string(),
673 });
674 }
675
676 _ => {}
677 }
678 }
679
680 (imports, exports, reexports)
681}
682
683fn get_declaration_names(decl: &Declaration<'_>) -> Vec<String> {
685 match decl {
686 Declaration::VariableDeclaration(var_decl) => var_decl
687 .declarations
688 .iter()
689 .filter_map(|d| d.id.get_binding_identifier().map(|id| id.name.to_string()))
690 .collect(),
691 Declaration::FunctionDeclaration(f) => {
692 f.id.as_ref()
693 .map(|id| vec![id.name.to_string()])
694 .unwrap_or_default()
695 }
696 Declaration::ClassDeclaration(c) => {
697 c.id.as_ref()
698 .map(|id| vec![id.name.to_string()])
699 .unwrap_or_default()
700 }
701 Declaration::TSEnumDeclaration(e) => {
702 vec![e.id.name.to_string()]
703 }
704 _ => Vec::new(),
705 }
706}
707
708fn resolve_import(import_path: &str, parent_dir: &Path) -> Result<PathBuf> {
710 let base = parent_dir.join(import_path);
711
712 if base.exists() {
714 return Ok(base);
715 }
716
717 let with_ts = base.with_extension("ts");
718 if with_ts.exists() {
719 return Ok(with_ts);
720 }
721
722 let with_js = base.with_extension("js");
723 if with_js.exists() {
724 return Ok(with_js);
725 }
726
727 let index_ts = base.join("index.ts");
729 if index_ts.exists() {
730 return Ok(index_ts);
731 }
732
733 let index_js = base.join("index.js");
734 if index_js.exists() {
735 return Ok(index_js);
736 }
737
738 Err(anyhow!(
739 "Cannot resolve import '{}' from {}",
740 import_path,
741 parent_dir.display()
742 ))
743}
744
745pub fn strip_imports_and_exports(source: &str) -> String {
748 let allocator = Allocator::default();
749 let source_type = SourceType::default()
751 .with_module(true)
752 .with_typescript(true);
753
754 let parser_ret = Parser::new(&allocator, source, source_type).parse();
755 if !parser_ret.errors.is_empty() {
756 return source.to_string();
758 }
759
760 let mut program = parser_ret.program;
761
762 strip_module_syntax_ast(&allocator, &mut program);
764
765 let codegen_ret = Codegen::new().build(&program);
767 codegen_ret.code
768}
769
770fn strip_module_syntax_ast<'a>(allocator: &'a Allocator, program: &mut oxc_ast::ast::Program<'a>) {
775 use oxc_allocator::Vec as OxcVec;
776
777 let mut new_body: OxcVec<'a, Statement<'a>> =
779 OxcVec::with_capacity_in(program.body.len(), allocator);
780
781 for stmt in program.body.drain(..) {
782 match stmt {
783 Statement::ImportDeclaration(_) => {
785 }
787
788 Statement::ExportNamedDeclaration(export_decl) => {
790 let inner = export_decl.unbox();
791 if let Some(decl) = inner.declaration {
792 let stmt = declaration_to_statement(decl);
795 new_body.push(stmt);
796 }
797 }
799
800 Statement::ExportDefaultDeclaration(export_default) => {
802 let inner = export_default.unbox();
803 match inner.declaration {
804 ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
805 new_body.push(Statement::FunctionDeclaration(func));
806 }
807 ExportDefaultDeclarationKind::ClassDeclaration(class) => {
808 new_body.push(Statement::ClassDeclaration(class));
809 }
810 ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => {
811 }
813 _ => {
814 }
816 }
817 }
818
819 Statement::ExportAllDeclaration(_) => {
821 }
823
824 other => {
826 new_body.push(other);
827 }
828 }
829 }
830
831 program.body = new_body;
832}
833
834fn declaration_to_statement(decl: Declaration<'_>) -> Statement<'_> {
836 match decl {
837 Declaration::VariableDeclaration(d) => Statement::VariableDeclaration(d),
838 Declaration::FunctionDeclaration(d) => Statement::FunctionDeclaration(d),
839 Declaration::ClassDeclaration(d) => Statement::ClassDeclaration(d),
840 Declaration::TSTypeAliasDeclaration(d) => Statement::TSTypeAliasDeclaration(d),
841 Declaration::TSInterfaceDeclaration(d) => Statement::TSInterfaceDeclaration(d),
842 Declaration::TSEnumDeclaration(d) => Statement::TSEnumDeclaration(d),
843 Declaration::TSModuleDeclaration(d) => Statement::TSModuleDeclaration(d),
844 Declaration::TSImportEqualsDeclaration(d) => Statement::TSImportEqualsDeclaration(d),
845 Declaration::TSGlobalDeclaration(d) => Statement::TSGlobalDeclaration(d),
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 #[test]
854 fn emit_isolated_declarations_script_hides_internals() {
855 let source = r#"
862 interface Internal { x: number; }
863 const internalConst: Internal = { x: 1 };
864 function internalFn(): void {}
865 "#;
866 let dts = emit_isolated_declarations(source, "script_plugin.ts").unwrap();
867 assert!(
868 !dts.contains("Internal"),
869 "non-exported interface leaked into .d.ts: {dts}"
870 );
871 assert!(
872 !dts.contains("internalConst"),
873 "non-exported const leaked into .d.ts: {dts}"
874 );
875 assert!(
876 !dts.contains("internalFn"),
877 "non-exported function leaked into .d.ts: {dts}"
878 );
879 }
880
881 #[test]
882 fn emit_isolated_declarations_keeps_exports_and_registry_augmentation() {
883 let source = r#"
888 export type FooApi = { doThing(): void };
889 declare global {
890 interface FreshPluginRegistry {
891 foo: FooApi;
892 }
893 }
894 const internal = 42;
895 "#;
896 let dts = emit_isolated_declarations(source, "foo.ts").unwrap();
897 assert!(dts.contains("FooApi"), "exported type missing: {dts}");
898 assert!(
899 dts.contains("FreshPluginRegistry"),
900 "registry augmentation missing: {dts}"
901 );
902 assert!(!dts.contains("internal"), "internal const leaked: {dts}");
903 }
904
905 #[test]
906 fn test_transpile_basic_typescript() {
907 let source = r#"
908 const x: number = 42;
909 function greet(name: string): string {
910 return `Hello, ${name}!`;
911 }
912 "#;
913
914 let result = transpile_typescript(source, "test.ts").unwrap();
915 assert!(result.contains("const x = 42"));
916 assert!(result.contains("function greet(name)"));
917 assert!(!result.contains(": number"));
918 assert!(!result.contains(": string"));
919 }
920
921 #[test]
922 fn test_transpile_interface() {
923 let source = r#"
924 interface User {
925 name: string;
926 age: number;
927 }
928 const user: User = { name: "Alice", age: 30 };
929 "#;
930
931 let result = transpile_typescript(source, "test.ts").unwrap();
932 assert!(!result.contains("interface"));
933 assert!(result.contains("const user = {"));
934 }
935
936 #[test]
937 fn test_transpile_type_alias() {
938 let source = r#"
939 type ID = number | string;
940 const id: ID = 123;
941 "#;
942
943 let result = transpile_typescript(source, "test.ts").unwrap();
944 assert!(!result.contains("type ID"));
945 assert!(result.contains("const id = 123"));
946 }
947
948 #[test]
949 fn test_has_es_imports() {
950 assert!(has_es_imports("import { foo } from './lib'"));
951 assert!(has_es_imports("import foo from 'bar'"));
952 assert!(!has_es_imports("const x = 1;"));
953 assert!(has_es_imports("// import foo from 'bar'")); }
957
958 #[test]
959 fn test_extract_module_bindings() {
960 let source = r#"
961 import { foo } from "./lib/utils";
962 import bar from "../shared/bar";
963 import external from "external-package";
964 export { PanelManager } from "./panel-manager.ts";
965 export * from "./types.ts";
966 export const API_VERSION = 1;
967 const x = 1;
968 "#;
969
970 let (imports, exports, reexports) = extract_module_bindings(source);
971
972 assert_eq!(imports.len(), 3);
974 assert!(imports
975 .iter()
976 .any(|i| i.source_path == "./lib/utils" && i.local_name == "foo"));
977 assert!(imports
978 .iter()
979 .any(|i| i.source_path == "../shared/bar" && i.local_name == "bar"));
980 assert!(imports.iter().any(|i| i.source_path == "external-package"));
981
982 assert_eq!(exports.len(), 1);
984 assert!(exports.iter().any(|e| e.exported_name == "API_VERSION"));
985
986 assert_eq!(reexports.len(), 2);
988 assert!(reexports
989 .iter()
990 .any(|r| r.source_path == "./panel-manager.ts"));
991 assert!(reexports
992 .iter()
993 .any(|r| r.source_path == "./types.ts" && r.exported_name.is_none()));
994 }
996
997 #[test]
998 fn test_extract_module_bindings_multiline() {
999 let source = r#"
1001export type {
1002 RGB,
1003 Location,
1004 PanelOptions,
1005} from "./types.ts";
1006
1007export {
1008 Finder,
1009 defaultFuzzyFilter,
1010} from "./finder.ts";
1011
1012import {
1013 something,
1014 somethingElse,
1015} from "./multiline-import.ts";
1016 "#;
1017
1018 let (imports, _exports, reexports) = extract_module_bindings(source);
1019
1020 assert_eq!(imports.len(), 2);
1022 assert!(imports.iter().any(|i| i.local_name == "something"));
1023 assert!(imports.iter().any(|i| i.local_name == "somethingElse"));
1024
1025 assert_eq!(reexports.len(), 5); assert!(reexports.iter().any(|r| r.source_path == "./types.ts"));
1028 assert!(reexports.iter().any(|r| r.source_path == "./finder.ts"));
1029 }
1030
1031 #[test]
1032 fn test_strip_imports_and_exports() {
1033 let source = r#"import { foo } from "./lib";
1034import bar from "../bar";
1035export const API_VERSION = 1;
1036export function greet() { return "hi"; }
1037export interface User { name: string; }
1038const x = foo() + bar();"#;
1039
1040 let stripped = strip_imports_and_exports(source);
1041 assert!(!stripped.contains("import { foo }"));
1043 assert!(!stripped.contains("import bar from"));
1044 assert!(!stripped.contains("export const"));
1046 assert!(!stripped.contains("export function"));
1047 assert!(!stripped.contains("export interface"));
1048 assert!(stripped.contains("const API_VERSION = 1"));
1050 assert!(stripped.contains("function greet()"));
1051 assert!(stripped.contains("interface User"));
1052 assert!(stripped.contains("const x = foo() + bar();"));
1053 }
1054
1055 #[test]
1056 fn test_extract_plugin_dependencies_basic() {
1057 let source = r#"
1058import type { SomeType } from "fresh:plugin/utility-plugin";
1059import { helper } from "fresh:plugin/core-lib";
1060const editor = getEditor();
1061"#;
1062 let deps = extract_plugin_dependencies(source);
1063 assert_eq!(deps, vec!["utility-plugin", "core-lib"]);
1064 }
1065
1066 #[test]
1067 fn test_extract_plugin_dependencies_various_import_forms() {
1068 let source = r#"
1069import type { A } from "fresh:plugin/plugin-a";
1070import { B } from "fresh:plugin/plugin-b";
1071import * as C from "fresh:plugin/plugin-c";
1072import D from "fresh:plugin/plugin-d";
1073import { E } from './local-file';
1074import { F } from "../other-file";
1075"#;
1076 let deps = extract_plugin_dependencies(source);
1077 assert_eq!(deps, vec!["plugin-a", "plugin-b", "plugin-c", "plugin-d"]);
1078 }
1079
1080 #[test]
1081 fn test_extract_plugin_dependencies_deduplicates() {
1082 let source = r#"
1083import type { A } from "fresh:plugin/shared";
1084import { B } from "fresh:plugin/shared";
1085"#;
1086 let deps = extract_plugin_dependencies(source);
1087 assert_eq!(deps, vec!["shared"]);
1088 }
1089
1090 #[test]
1091 fn test_extract_plugin_dependencies_single_quotes() {
1092 let source = r#"
1093import type { A } from 'fresh:plugin/single-quoted';
1094"#;
1095 let deps = extract_plugin_dependencies(source);
1096 assert_eq!(deps, vec!["single-quoted"]);
1097 }
1098
1099 #[test]
1100 fn test_extract_plugin_dependencies_no_deps() {
1101 let source = r#"
1102const editor = getEditor();
1103import { helper } from "./lib/utils";
1104"#;
1105 let deps = extract_plugin_dependencies(source);
1106 assert!(deps.is_empty());
1107 }
1108
1109 #[test]
1110 fn test_topological_sort_no_deps() {
1111 let names = vec!["c".to_string(), "a".to_string(), "b".to_string()];
1112 let deps = std::collections::HashMap::new();
1113 let result = topological_sort_plugins(&names, &deps).unwrap();
1114 assert_eq!(result, vec!["a", "b", "c"]);
1116 }
1117
1118 #[test]
1119 fn test_topological_sort_linear_chain() {
1120 let names = vec!["c".to_string(), "b".to_string(), "a".to_string()];
1121 let mut deps = std::collections::HashMap::new();
1122 deps.insert("b".to_string(), vec!["a".to_string()]);
1123 deps.insert("c".to_string(), vec!["b".to_string()]);
1124 let result = topological_sort_plugins(&names, &deps).unwrap();
1125 assert_eq!(result, vec!["a", "b", "c"]);
1126 }
1127
1128 #[test]
1129 fn test_topological_sort_diamond() {
1130 let names = vec![
1132 "d".to_string(),
1133 "c".to_string(),
1134 "b".to_string(),
1135 "a".to_string(),
1136 ];
1137 let mut deps = std::collections::HashMap::new();
1138 deps.insert("b".to_string(), vec!["a".to_string()]);
1139 deps.insert("c".to_string(), vec!["a".to_string()]);
1140 deps.insert("d".to_string(), vec!["b".to_string(), "c".to_string()]);
1141 let result = topological_sort_plugins(&names, &deps).unwrap();
1142 assert_eq!(result, vec!["a", "b", "c", "d"]);
1144 }
1145
1146 #[test]
1147 fn test_topological_sort_cycle_detection() {
1148 let names = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1149 let mut deps = std::collections::HashMap::new();
1150 deps.insert("a".to_string(), vec!["b".to_string()]);
1151 deps.insert("b".to_string(), vec!["c".to_string()]);
1152 deps.insert("c".to_string(), vec!["a".to_string()]);
1153 let result = topological_sort_plugins(&names, &deps);
1154 assert!(result.is_err());
1155 let err = result.unwrap_err().to_string();
1156 assert!(err.contains("cycle"), "Error should mention cycle: {}", err);
1157 }
1158
1159 #[test]
1160 fn test_topological_sort_missing_dependency() {
1161 let names = vec!["a".to_string()];
1162 let mut deps = std::collections::HashMap::new();
1163 deps.insert("a".to_string(), vec!["nonexistent".to_string()]);
1164 let result = topological_sort_plugins(&names, &deps);
1165 assert!(result.is_err());
1166 let err = result.unwrap_err().to_string();
1167 assert!(
1168 err.contains("not installed"),
1169 "Error should mention missing dep: {}",
1170 err
1171 );
1172 }
1173
1174 #[test]
1175 fn test_topological_sort_independent_plugins_alphabetical() {
1176 let names = vec![
1178 "zebra".to_string(),
1179 "alpha".to_string(),
1180 "beta".to_string(),
1181 "gamma".to_string(),
1182 ];
1183 let mut deps = std::collections::HashMap::new();
1184 deps.insert("gamma".to_string(), vec!["alpha".to_string()]);
1185 let result = topological_sort_plugins(&names, &deps).unwrap();
1186 let alpha_pos = result.iter().position(|s| s == "alpha").unwrap();
1188 let gamma_pos = result.iter().position(|s| s == "gamma").unwrap();
1189 assert!(alpha_pos < gamma_pos);
1190 }
1191}