@tishlang/tish-format 1.0.12 → 1.0.13

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.
Files changed (164) hide show
  1. package/Cargo.toml +49 -0
  2. package/LICENSE +13 -0
  3. package/README.md +138 -0
  4. package/bin/tish-format +0 -0
  5. package/crates/js_to_tish/Cargo.toml +11 -0
  6. package/crates/js_to_tish/README.md +18 -0
  7. package/crates/js_to_tish/src/error.rs +55 -0
  8. package/crates/js_to_tish/src/lib.rs +11 -0
  9. package/crates/js_to_tish/src/span_util.rs +35 -0
  10. package/crates/js_to_tish/src/transform/expr.rs +610 -0
  11. package/crates/js_to_tish/src/transform/stmt.rs +503 -0
  12. package/crates/js_to_tish/src/transform.rs +60 -0
  13. package/crates/tish/Cargo.toml +54 -0
  14. package/crates/tish/src/cargo_native_registry.rs +32 -0
  15. package/crates/tish/src/cli_help.rs +565 -0
  16. package/crates/tish/src/main.rs +781 -0
  17. package/crates/tish/src/repl_completion.rs +200 -0
  18. package/crates/tish/tests/cargo_example_compile.rs +67 -0
  19. package/crates/tish/tests/fixtures/cargo_example_project/Cargo.toml +3 -0
  20. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/Cargo.toml +11 -0
  21. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +12 -0
  22. package/crates/tish/tests/fixtures/cargo_example_project/package.json +10 -0
  23. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +3 -0
  24. package/crates/tish/tests/integration_test.rs +1095 -0
  25. package/crates/tish/tests/run_optimize_stdout_parity.rs +50 -0
  26. package/crates/tish/tests/shortcircuit.rs +65 -0
  27. package/crates/tish_ast/Cargo.toml +9 -0
  28. package/crates/tish_ast/src/ast.rs +620 -0
  29. package/crates/tish_ast/src/lib.rs +5 -0
  30. package/crates/tish_build_utils/Cargo.toml +11 -0
  31. package/crates/tish_build_utils/src/lib.rs +577 -0
  32. package/crates/tish_builtins/Cargo.toml +20 -0
  33. package/crates/tish_builtins/src/array.rs +441 -0
  34. package/crates/tish_builtins/src/construct.rs +159 -0
  35. package/crates/tish_builtins/src/globals.rs +213 -0
  36. package/crates/tish_builtins/src/helpers.rs +35 -0
  37. package/crates/tish_builtins/src/lib.rs +16 -0
  38. package/crates/tish_builtins/src/math.rs +89 -0
  39. package/crates/tish_builtins/src/object.rs +36 -0
  40. package/crates/tish_builtins/src/string.rs +647 -0
  41. package/crates/tish_builtins/src/symbol.rs +83 -0
  42. package/crates/tish_bytecode/Cargo.toml +17 -0
  43. package/crates/tish_bytecode/src/chunk.rs +96 -0
  44. package/crates/tish_bytecode/src/compiler.rs +1760 -0
  45. package/crates/tish_bytecode/src/encoding.rs +100 -0
  46. package/crates/tish_bytecode/src/lib.rs +19 -0
  47. package/crates/tish_bytecode/src/opcode.rs +142 -0
  48. package/crates/tish_bytecode/src/peephole.rs +189 -0
  49. package/crates/tish_bytecode/src/serialize.rs +163 -0
  50. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +44 -0
  51. package/crates/tish_bytecode/tests/constant_folding.rs +84 -0
  52. package/crates/tish_bytecode/tests/sort_optimization.rs +31 -0
  53. package/crates/tish_compile/Cargo.toml +26 -0
  54. package/crates/tish_compile/src/codegen.rs +5332 -0
  55. package/crates/tish_compile/src/infer.rs +292 -0
  56. package/crates/tish_compile/src/lib.rs +164 -0
  57. package/crates/tish_compile/src/resolve.rs +1388 -0
  58. package/crates/tish_compile/src/types.rs +501 -0
  59. package/crates/tish_compile_js/Cargo.toml +18 -0
  60. package/crates/tish_compile_js/examples/jsx_vdom_smoke.tish +8 -0
  61. package/crates/tish_compile_js/src/codegen.rs +871 -0
  62. package/crates/tish_compile_js/src/error.rs +20 -0
  63. package/crates/tish_compile_js/src/lib.rs +26 -0
  64. package/crates/tish_compile_js/src/tests_jsx.rs +350 -0
  65. package/crates/tish_compiler_wasm/Cargo.toml +21 -0
  66. package/crates/tish_compiler_wasm/src/lib.rs +57 -0
  67. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +473 -0
  68. package/crates/tish_core/Cargo.toml +26 -0
  69. package/crates/tish_core/src/console_style.rs +160 -0
  70. package/crates/tish_core/src/json.rs +387 -0
  71. package/crates/tish_core/src/lib.rs +17 -0
  72. package/crates/tish_core/src/macros.rs +36 -0
  73. package/crates/tish_core/src/uri.rs +118 -0
  74. package/crates/tish_core/src/value.rs +696 -0
  75. package/crates/tish_core/src/vmref.rs +178 -0
  76. package/crates/tish_cranelift/Cargo.toml +19 -0
  77. package/crates/tish_cranelift/src/lib.rs +43 -0
  78. package/crates/tish_cranelift/src/link.rs +117 -0
  79. package/crates/tish_cranelift/src/lower.rs +85 -0
  80. package/crates/tish_cranelift_runtime/Cargo.toml +25 -0
  81. package/crates/tish_cranelift_runtime/src/lib.rs +45 -0
  82. package/crates/tish_eval/Cargo.toml +45 -0
  83. package/crates/tish_eval/src/eval.rs +3717 -0
  84. package/crates/tish_eval/src/http.rs +188 -0
  85. package/crates/tish_eval/src/lib.rs +99 -0
  86. package/crates/tish_eval/src/natives.rs +399 -0
  87. package/crates/tish_eval/src/promise.rs +179 -0
  88. package/crates/tish_eval/src/regex.rs +299 -0
  89. package/crates/tish_eval/src/timers.rs +120 -0
  90. package/crates/tish_eval/src/value.rs +318 -0
  91. package/crates/tish_eval/src/value_convert.rs +111 -0
  92. package/crates/tish_fmt/Cargo.toml +16 -0
  93. package/crates/tish_fmt/src/bin/tish-fmt.rs +41 -0
  94. package/crates/tish_fmt/src/lib.rs +2101 -0
  95. package/crates/tish_jsx_web/Cargo.toml +9 -0
  96. package/crates/tish_jsx_web/README.md +5 -0
  97. package/crates/tish_jsx_web/src/lib.rs +2 -0
  98. package/crates/tish_lexer/Cargo.toml +9 -0
  99. package/crates/tish_lexer/src/lib.rs +716 -0
  100. package/crates/tish_lexer/src/token.rs +163 -0
  101. package/crates/tish_lint/Cargo.toml +18 -0
  102. package/crates/tish_lint/src/bin/tish-lint.rs +195 -0
  103. package/crates/tish_lint/src/lib.rs +289 -0
  104. package/crates/tish_llvm/Cargo.toml +13 -0
  105. package/crates/tish_llvm/src/lib.rs +115 -0
  106. package/crates/tish_lsp/Cargo.toml +25 -0
  107. package/crates/tish_lsp/README.md +26 -0
  108. package/crates/tish_lsp/src/builtin_goto.rs +362 -0
  109. package/crates/tish_lsp/src/import_goto.rs +562 -0
  110. package/crates/tish_lsp/src/main.rs +1046 -0
  111. package/crates/tish_native/Cargo.toml +16 -0
  112. package/crates/tish_native/src/build.rs +427 -0
  113. package/crates/tish_native/src/config.rs +48 -0
  114. package/crates/tish_native/src/lib.rs +416 -0
  115. package/crates/tish_opt/Cargo.toml +13 -0
  116. package/crates/tish_opt/src/lib.rs +943 -0
  117. package/crates/tish_parser/Cargo.toml +11 -0
  118. package/crates/tish_parser/src/lib.rs +332 -0
  119. package/crates/tish_parser/src/parser.rs +2304 -0
  120. package/crates/tish_pg/Cargo.toml +34 -0
  121. package/crates/tish_pg/README.md +38 -0
  122. package/crates/tish_pg/src/error.rs +52 -0
  123. package/crates/tish_pg/src/lib.rs +955 -0
  124. package/crates/tish_resolve/Cargo.toml +13 -0
  125. package/crates/tish_resolve/src/lib.rs +3561 -0
  126. package/crates/tish_resolve/src/pos.rs +141 -0
  127. package/crates/tish_runtime/Cargo.toml +96 -0
  128. package/crates/tish_runtime/src/http.rs +1298 -0
  129. package/crates/tish_runtime/src/http_fetch.rs +471 -0
  130. package/crates/tish_runtime/src/http_hyper.rs +418 -0
  131. package/crates/tish_runtime/src/http_prefork.rs +189 -0
  132. package/crates/tish_runtime/src/lib.rs +1192 -0
  133. package/crates/tish_runtime/src/native_promise.rs +15 -0
  134. package/crates/tish_runtime/src/promise.rs +248 -0
  135. package/crates/tish_runtime/src/promise_io.rs +38 -0
  136. package/crates/tish_runtime/src/timers.rs +166 -0
  137. package/crates/tish_runtime/src/ws.rs +761 -0
  138. package/crates/tish_runtime/tests/fetch_readable_stream.rs +102 -0
  139. package/crates/tish_ui/Cargo.toml +17 -0
  140. package/crates/tish_ui/src/jsx.rs +682 -0
  141. package/crates/tish_ui/src/lib.rs +20 -0
  142. package/crates/tish_ui/src/runtime/hooks.rs +569 -0
  143. package/crates/tish_ui/src/runtime/mod.rs +180 -0
  144. package/crates/tish_vm/Cargo.toml +47 -0
  145. package/crates/tish_vm/src/lib.rs +39 -0
  146. package/crates/tish_vm/src/vm.rs +2192 -0
  147. package/crates/tish_vm/tests/fixtures/or_string_cmd.tish +2 -0
  148. package/crates/tish_vm/tests/lexical_scope_declare.rs +34 -0
  149. package/crates/tish_vm/tests/peephole_jump_chain_logical_or.rs +150 -0
  150. package/crates/tish_wasm/Cargo.toml +15 -0
  151. package/crates/tish_wasm/src/lib.rs +424 -0
  152. package/crates/tish_wasm_runtime/Cargo.toml +37 -0
  153. package/crates/tish_wasm_runtime/src/gpu.rs +413 -0
  154. package/crates/tish_wasm_runtime/src/lib.rs +42 -0
  155. package/crates/tishlang_cargo_bindgen/Cargo.toml +26 -0
  156. package/crates/tishlang_cargo_bindgen/src/classify.rs +263 -0
  157. package/crates/tishlang_cargo_bindgen/src/discover.rs +125 -0
  158. package/crates/tishlang_cargo_bindgen/src/infer.rs +382 -0
  159. package/crates/tishlang_cargo_bindgen/src/lib.rs +349 -0
  160. package/crates/tishlang_cargo_bindgen/src/main.rs +167 -0
  161. package/crates/tishlang_cargo_bindgen/src/metadata.rs +117 -0
  162. package/justfile +268 -0
  163. package/package.json +1 -1
  164. package/platform/darwin-arm64/tish-fmt +0 -0
@@ -0,0 +1,1388 @@
1
+ //! Module resolver: resolves relative imports, builds dependency graph, detects cycles.
2
+ //! Supports native imports: `tish:…`, `cargo:…`, `@scope/pkg` (via package.json).
3
+
4
+ use std::collections::{HashMap, HashSet};
5
+ use std::path::{Path, PathBuf};
6
+ use std::sync::Arc;
7
+ use tishlang_ast::{ExportDeclaration, Expr, ImportSpecifier, MemberProp, Program, Statement, CallArg};
8
+
9
+ /// Resolved native module: crate path and init expression.
10
+ #[derive(Debug, Clone)]
11
+ pub struct ResolvedNativeModule {
12
+ pub spec: String,
13
+ /// Cargo package name (e.g. tish-egui) for [dependencies]
14
+ pub package_name: String,
15
+ /// Rust crate name with underscores (e.g. tish_egui) for use in generated code
16
+ pub crate_name: String,
17
+ pub crate_path: PathBuf,
18
+ pub export_fn: String,
19
+ /// When false, omit `path = …` in the generated Cargo.toml (crate comes from `tish.rustDependencies` only).
20
+ pub use_path_dependency: bool,
21
+ }
22
+
23
+ /// How codegen links a native import to Rust (`generateNativeWrapper` for `tish:*`; `cargo:*` always generated).
24
+ #[derive(Debug, Clone)]
25
+ pub enum NativeModuleInit {
26
+ /// Call `external_crate::export_fn()` and read named exports from the returned object.
27
+ Legacy {
28
+ crate_name: String,
29
+ export_fn: String,
30
+ },
31
+ /// Call `crate::generated_native::export_fn()` — object built from per-export fns on `shim_crate`.
32
+ Generated {
33
+ shim_crate: String,
34
+ export_fn: String,
35
+ },
36
+ }
37
+
38
+ /// Extra native build inputs produced alongside Rust source (Cargo merge + optional wrapper).
39
+ #[derive(Debug, Clone)]
40
+ pub struct NativeBuildArtifacts {
41
+ /// Extra `[dependencies]` lines from `tish.rustDependencies`.
42
+ pub rust_dependencies_toml: String,
43
+ /// Generated `generated_native.rs` when using [`NativeModuleInit::Generated`].
44
+ pub generated_native_rs: Option<String>,
45
+ pub native_init: std::collections::HashMap<String, NativeModuleInit>,
46
+ }
47
+
48
+ /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.).
49
+ const BUILTIN_ALIASES: &[(&str, &str)] = &[
50
+ ("fs", "tish:fs"),
51
+ ("http", "tish:http"),
52
+ ("timers", "tish:timers"),
53
+ ("process", "tish:process"),
54
+ ("ws", "tish:ws"),
55
+ ];
56
+
57
+ /// Normalize built-in spec to canonical form. E.g. "fs" -> "tish:fs".
58
+ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
59
+ if spec.starts_with("tish:") {
60
+ return Some(spec.to_string());
61
+ }
62
+ BUILTIN_ALIASES
63
+ .iter()
64
+ .find(|(alias, _)| *alias == spec)
65
+ .map(|(_, canonical)| (*canonical).to_string())
66
+ }
67
+
68
+ /// Built-in modules that come from tishlang_runtime, not from package.json.
69
+ pub fn is_builtin_native_spec(spec: &str) -> bool {
70
+ matches!(
71
+ spec,
72
+ "tish:fs" | "tish:http" | "tish:timers" | "tish:process" | "tish:ws"
73
+ ) || matches!(spec, "fs" | "http" | "timers" | "process" | "ws")
74
+ }
75
+
76
+ /// Resolve all native imports in a merged program via package.json lookup.
77
+ /// Built-in modules (tish:fs, tish:http, tish:process) are skipped - they use tishlang_runtime directly.
78
+ /// Handles both lowered `NativeModuleLoad` (merged modules) and raw `import { … } from 'tish:…'`.
79
+ pub fn resolve_native_modules(
80
+ program: &Program,
81
+ project_root: &Path,
82
+ ) -> Result<Vec<ResolvedNativeModule>, String> {
83
+ let root_canon = project_root
84
+ .canonicalize()
85
+ .map_err(|e| format!("Cannot canonicalize project root: {}", e))?;
86
+ let mut seen = HashSet::new();
87
+ let mut modules = Vec::new();
88
+ for stmt in &program.statements {
89
+ let specs: Vec<String> = match stmt {
90
+ Statement::VarDecl {
91
+ init: Some(Expr::NativeModuleLoad { spec, .. }),
92
+ ..
93
+ } => vec![spec.as_ref().to_string()],
94
+ Statement::Import { from, .. } if is_native_import(from.as_ref()) => {
95
+ vec![normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string())]
96
+ }
97
+ _ => continue,
98
+ };
99
+ for s in specs {
100
+ if is_builtin_native_spec(&s) {
101
+ continue;
102
+ }
103
+ if !seen.insert(s.clone()) {
104
+ continue;
105
+ }
106
+ let m = if s.starts_with("cargo:") {
107
+ resolve_cargo_native_module(&s, &root_canon)?
108
+ } else {
109
+ resolve_native_module(&s, &root_canon)?
110
+ };
111
+ modules.push(m);
112
+ }
113
+ }
114
+ Ok(modules)
115
+ }
116
+
117
+ /// True when merged Tish source references the browser global `document` (e.g. juke-cards).
118
+ pub fn program_uses_document(program: &Program) -> bool {
119
+ use tishlang_ast::{ArrayElement, ArrowBody, JsxAttrValue, JsxChild, JsxProp, ObjectProp};
120
+
121
+ fn expr_uses_document(e: &Expr) -> bool {
122
+ match e {
123
+ Expr::Ident { name, .. } => name.as_ref() == "document",
124
+ Expr::Literal { .. } | Expr::NativeModuleLoad { .. } => false,
125
+ Expr::Binary { left, right, .. } => {
126
+ expr_uses_document(left) || expr_uses_document(right)
127
+ }
128
+ Expr::Unary { operand, .. } | Expr::TypeOf { operand, .. } => {
129
+ expr_uses_document(operand)
130
+ }
131
+ Expr::Call { callee, args, .. } => {
132
+ expr_uses_document(callee)
133
+ || args.iter().any(|a| match a {
134
+ CallArg::Expr(e) | CallArg::Spread(e) => expr_uses_document(e),
135
+ })
136
+ }
137
+ Expr::New { callee, args, .. } => {
138
+ expr_uses_document(callee)
139
+ || args.iter().any(|a| match a {
140
+ CallArg::Expr(e) | CallArg::Spread(e) => expr_uses_document(e),
141
+ })
142
+ }
143
+ Expr::Member { object, prop, .. } => {
144
+ expr_uses_document(object)
145
+ || if let MemberProp::Expr(e) = prop {
146
+ expr_uses_document(e)
147
+ } else {
148
+ false
149
+ }
150
+ }
151
+ Expr::Index { object, index, .. } => {
152
+ expr_uses_document(object) || expr_uses_document(index)
153
+ }
154
+ Expr::Conditional {
155
+ cond,
156
+ then_branch,
157
+ else_branch,
158
+ ..
159
+ } => {
160
+ expr_uses_document(cond)
161
+ || expr_uses_document(then_branch)
162
+ || expr_uses_document(else_branch)
163
+ }
164
+ Expr::NullishCoalesce { left, right, .. } => {
165
+ expr_uses_document(left) || expr_uses_document(right)
166
+ }
167
+ Expr::Array { elements, .. } => elements.iter().any(|el| match el {
168
+ ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_uses_document(e),
169
+ }),
170
+ Expr::Object { props, .. } => props.iter().any(|p| match p {
171
+ ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_uses_document(e),
172
+ }),
173
+ Expr::Assign { value, .. }
174
+ | Expr::CompoundAssign { value, .. }
175
+ | Expr::LogicalAssign { value, .. }
176
+ | Expr::MemberAssign { value, .. }
177
+ | Expr::IndexAssign { value, .. } => expr_uses_document(value),
178
+ Expr::PostfixInc { .. }
179
+ | Expr::PostfixDec { .. }
180
+ | Expr::PrefixInc { .. }
181
+ | Expr::PrefixDec { .. } => false,
182
+ Expr::ArrowFunction { body, .. } => match body {
183
+ ArrowBody::Expr(e) => expr_uses_document(e),
184
+ ArrowBody::Block(s) => stmt_uses_document(s),
185
+ },
186
+ Expr::TemplateLiteral { exprs, .. } => exprs.iter().any(expr_uses_document),
187
+ Expr::Await { operand, .. } => expr_uses_document(operand),
188
+ Expr::JsxElement { props, children, .. } => {
189
+ props.iter().any(|p| match p {
190
+ JsxProp::Attr { value, .. } => match value {
191
+ JsxAttrValue::Expr(e) => expr_uses_document(e),
192
+ JsxAttrValue::String(_) | JsxAttrValue::ImplicitTrue => false,
193
+ },
194
+ JsxProp::Spread(e) => expr_uses_document(e),
195
+ }) || children.iter().any(|c| match c {
196
+ JsxChild::Expr(e) => expr_uses_document(e),
197
+ JsxChild::Text(_) => false,
198
+ })
199
+ }
200
+ Expr::JsxFragment { children, .. } => children.iter().any(|c| match c {
201
+ JsxChild::Expr(e) => expr_uses_document(e),
202
+ JsxChild::Text(_) => false,
203
+ }),
204
+ }
205
+ }
206
+
207
+ fn stmt_uses_document(s: &Statement) -> bool {
208
+ match s {
209
+ Statement::VarDecl { init, .. } => init.as_ref().is_some_and(|e| expr_uses_document(e)),
210
+ Statement::VarDeclDestructure { init, .. } => expr_uses_document(init),
211
+ Statement::ExprStmt { expr, .. } => expr_uses_document(expr),
212
+ Statement::Return { value, .. } => value.as_ref().is_some_and(|e| expr_uses_document(e)),
213
+ Statement::Throw { value, .. } => expr_uses_document(value),
214
+ Statement::If {
215
+ cond,
216
+ then_branch,
217
+ else_branch,
218
+ ..
219
+ } => {
220
+ expr_uses_document(cond)
221
+ || stmt_uses_document(then_branch)
222
+ || else_branch
223
+ .as_ref()
224
+ .is_some_and(|b| stmt_uses_document(b.as_ref()))
225
+ }
226
+ Statement::While { cond, body, .. }
227
+ | Statement::DoWhile { cond, body, .. } => {
228
+ expr_uses_document(cond) || stmt_uses_document(body)
229
+ }
230
+ Statement::For { init, cond, update, body, .. } => {
231
+ init.as_ref().is_some_and(|s| stmt_uses_document(s.as_ref()))
232
+ || cond.as_ref().is_some_and(|e| expr_uses_document(e))
233
+ || update.as_ref().is_some_and(|e| expr_uses_document(e))
234
+ || stmt_uses_document(body)
235
+ }
236
+ Statement::ForOf { iterable, body, .. } => {
237
+ expr_uses_document(iterable) || stmt_uses_document(body)
238
+ }
239
+ Statement::Switch {
240
+ expr,
241
+ cases,
242
+ default_body,
243
+ ..
244
+ } => {
245
+ expr_uses_document(expr)
246
+ || cases.iter().any(|(e, stmts)| {
247
+ e.as_ref().is_some_and(|e| expr_uses_document(e))
248
+ || stmts.iter().any(stmt_uses_document)
249
+ })
250
+ || default_body
251
+ .as_ref()
252
+ .is_some_and(|stmts| stmts.iter().any(stmt_uses_document))
253
+ }
254
+ Statement::Block { statements, .. } => statements.iter().any(stmt_uses_document),
255
+ Statement::FunDecl { body, .. } => stmt_uses_document(body),
256
+ Statement::Try {
257
+ body,
258
+ catch_body,
259
+ finally_body,
260
+ ..
261
+ } => {
262
+ stmt_uses_document(body)
263
+ || catch_body
264
+ .as_ref()
265
+ .is_some_and(|b| stmt_uses_document(b.as_ref()))
266
+ || finally_body
267
+ .as_ref()
268
+ .is_some_and(|b| stmt_uses_document(b.as_ref()))
269
+ }
270
+ Statement::Import { .. }
271
+ | Statement::Export { .. }
272
+ | Statement::Break { .. }
273
+ | Statement::Continue { .. }
274
+ | Statement::TypeAlias { .. }
275
+ | Statement::DeclareVar { .. }
276
+ | Statement::DeclareFun { .. } => false,
277
+ }
278
+ }
279
+
280
+ program.statements.iter().any(stmt_uses_document)
281
+ }
282
+
283
+ /// When Tish uses bare `document`, link `tish-canvas` even without `import from 'tish:canvas'`.
284
+ pub fn ensure_tish_canvas_module(
285
+ native_modules: &mut Vec<ResolvedNativeModule>,
286
+ project_root: &Path,
287
+ ) -> Result<(), String> {
288
+ if native_modules
289
+ .iter()
290
+ .any(|m| m.crate_name == "tish_canvas" || m.package_name == "tish-canvas")
291
+ {
292
+ return Ok(());
293
+ }
294
+ let m = resolve_native_module("tish:canvas", project_root)?;
295
+ native_modules.push(m);
296
+ Ok(())
297
+ }
298
+
299
+ /// True for `cargo:…` specs (Cargo-backed imports; Rust native backend only).
300
+ pub fn is_cargo_native_spec(spec: &str) -> bool {
301
+ spec.starts_with("cargo:")
302
+ }
303
+
304
+ /// Stable Rust symbol for the generated namespace function, e.g. `cargo:my-crate` → `cargo_native_my_crate_object`.
305
+ pub fn cargo_export_fn_name(spec: &str) -> String {
306
+ let tail = spec.strip_prefix("cargo:").unwrap_or(spec);
307
+ let mut out = String::from("cargo_native_");
308
+ for c in tail.chars() {
309
+ if c.is_ascii_alphanumeric() {
310
+ out.push(c);
311
+ } else {
312
+ out.push('_');
313
+ }
314
+ }
315
+ if out == "cargo_native_" {
316
+ out.push_str("unnamed");
317
+ }
318
+ out.push_str("_object");
319
+ out
320
+ }
321
+
322
+ fn resolve_cargo_native_module(
323
+ spec: &str,
324
+ project_root: &Path,
325
+ ) -> Result<ResolvedNativeModule, String> {
326
+ let tail = spec
327
+ .strip_prefix("cargo:")
328
+ .ok_or_else(|| format!("Invalid cargo native spec: {}", spec))?;
329
+ if tail.is_empty() {
330
+ return Err(
331
+ "cargo: import needs a dependency name, e.g. import { x } from 'cargo:my_crate'".into(),
332
+ );
333
+ }
334
+ let dep_key = tail.to_string();
335
+ let tish = read_project_tish_config(project_root);
336
+ let rust_deps = tish.get("rustDependencies").and_then(|v| v.as_object()).ok_or_else(|| {
337
+ format!(
338
+ "cargo:{} requires package.json \"tish\": {{ \"rustDependencies\": {{ \"{}\": \"…\" }} }}",
339
+ tail, dep_key
340
+ )
341
+ })?;
342
+ if !rust_deps.contains_key(&dep_key) {
343
+ return Err(format!(
344
+ "cargo:{}: add \"{}\" to tish.rustDependencies in package.json (version string or inline table)",
345
+ tail, dep_key
346
+ ));
347
+ }
348
+ let crate_name = dep_key.replace('-', "_");
349
+ let export_fn = cargo_export_fn_name(spec);
350
+ let crate_path = project_root
351
+ .canonicalize()
352
+ .unwrap_or_else(|_| project_root.to_path_buf());
353
+ Ok(ResolvedNativeModule {
354
+ spec: spec.to_string(),
355
+ package_name: dep_key.clone(),
356
+ crate_name,
357
+ crate_path,
358
+ export_fn,
359
+ use_path_dependency: false,
360
+ })
361
+ }
362
+
363
+ fn resolve_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNativeModule, String> {
364
+ let package_name = if spec.starts_with("tish:") {
365
+ format!("tish-{}", spec.strip_prefix("tish:").unwrap_or(spec))
366
+ } else if spec.starts_with('@') {
367
+ spec.to_string()
368
+ } else {
369
+ return Err(format!("Unsupported native import spec: {}", spec));
370
+ };
371
+ let pkg_dir = find_package_dir(&package_name, project_root)?;
372
+ let pkg_json = pkg_dir.join("package.json");
373
+ let content = std::fs::read_to_string(&pkg_json)
374
+ .map_err(|e| format!("Cannot read {}: {}", pkg_json.display(), e))?;
375
+ let json: serde_json::Value = serde_json::from_str(&content)
376
+ .map_err(|e| format!("Invalid JSON in {}: {}", pkg_json.display(), e))?;
377
+ let tish = json
378
+ .get("tish")
379
+ .and_then(|v| v.as_object())
380
+ .ok_or_else(|| {
381
+ format!(
382
+ "Package {} has no \"tish\" config in package.json",
383
+ package_name
384
+ )
385
+ })?;
386
+ if !tish
387
+ .get("module")
388
+ .and_then(|v| v.as_bool())
389
+ .unwrap_or(false)
390
+ {
391
+ return Err(format!(
392
+ "Package {} is not a Tish native module (tish.module must be true)",
393
+ package_name
394
+ ));
395
+ }
396
+ let raw_crate = tish
397
+ .get("crate")
398
+ .and_then(|v| v.as_str())
399
+ .unwrap_or(&package_name)
400
+ .to_string();
401
+ let module_part = spec.strip_prefix("tish:").unwrap_or(spec);
402
+ let export_fn = tish
403
+ .get("export")
404
+ .and_then(|v| v.as_str())
405
+ .map(String::from)
406
+ .unwrap_or_else(|| format!("{}_object", str::replace(module_part, "-", "_")));
407
+ let crate_path = pkg_dir.canonicalize().unwrap_or(pkg_dir);
408
+ Ok(ResolvedNativeModule {
409
+ spec: spec.to_string(),
410
+ package_name: raw_crate.clone(),
411
+ crate_name: raw_crate.replace('-', "_"),
412
+ crate_path,
413
+ export_fn,
414
+ use_path_dependency: true,
415
+ })
416
+ }
417
+
418
+ /// Read the `tish` object from the project root `package.json` (empty JSON object if missing).
419
+ pub fn read_project_tish_config(project_root: &Path) -> serde_json::Value {
420
+ let path = project_root.join("package.json");
421
+ let Ok(content) = std::fs::read_to_string(&path) else {
422
+ return serde_json::json!({});
423
+ };
424
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
425
+ return serde_json::json!({});
426
+ };
427
+ json.get("tish")
428
+ .cloned()
429
+ .unwrap_or_else(|| serde_json::json!({}))
430
+ }
431
+
432
+ fn resolve_cargo_path_for_toml(project_root: &Path, raw: &str) -> String {
433
+ let p = Path::new(raw);
434
+ let resolved = if p.is_absolute() {
435
+ p.to_path_buf()
436
+ } else {
437
+ project_root.join(p)
438
+ };
439
+ let resolved = resolved.canonicalize().unwrap_or(resolved);
440
+ resolved.display().to_string().replace('\\', "/")
441
+ }
442
+
443
+ fn json_to_cargo_inline_value(
444
+ v: &serde_json::Value,
445
+ project_root: &Path,
446
+ ) -> Result<String, String> {
447
+ match v {
448
+ serde_json::Value::String(s) => Ok(format!("{:?}", s.as_str())),
449
+ serde_json::Value::Bool(b) => Ok(b.to_string()),
450
+ serde_json::Value::Number(n) => Ok(n.to_string()),
451
+ serde_json::Value::Array(arr) => {
452
+ let mut inner = Vec::new();
453
+ for item in arr {
454
+ inner.push(json_to_cargo_inline_value(item, project_root)?);
455
+ }
456
+ Ok(format!("[{}]", inner.join(", ")))
457
+ }
458
+ serde_json::Value::Object(map) => {
459
+ let mut parts = Vec::new();
460
+ for (k, v) in map {
461
+ let rhs = if k == "path" && v.as_str().is_some() {
462
+ let s = v.as_str().unwrap();
463
+ format!("{:?}", resolve_cargo_path_for_toml(project_root, s))
464
+ } else {
465
+ json_to_cargo_inline_value(v, project_root)?
466
+ };
467
+ parts.push(format!("{} = {}", k, rhs));
468
+ }
469
+ Ok(format!("{{ {} }}", parts.join(", ")))
470
+ }
471
+ serde_json::Value::Null => Err("null is not valid in a Cargo dependency value".to_string()),
472
+ }
473
+ }
474
+
475
+ /// Serialize `tish.rustDependencies` from project `package.json` into Cargo.toml `[dependencies]` lines.
476
+ /// Relative `path = "…"` entries in inline tables are resolved against `project_root` so the temp build crate can find them.
477
+ pub fn format_rust_dependencies_toml(
478
+ tish: &serde_json::Value,
479
+ project_root: &Path,
480
+ ) -> Result<String, String> {
481
+ let Some(obj) = tish.get("rustDependencies").and_then(|v| v.as_object()) else {
482
+ return Ok(String::new());
483
+ };
484
+ let mut out = String::new();
485
+ for (name, val) in obj {
486
+ match val {
487
+ serde_json::Value::String(_) | serde_json::Value::Object(_) => {
488
+ out.push_str(&format!(
489
+ "{} = {}\n",
490
+ name,
491
+ json_to_cargo_inline_value(val, project_root)?
492
+ ));
493
+ }
494
+ _ => {
495
+ return Err(format!(
496
+ "tish.rustDependencies.{} must be a string (version) or object (inline table)",
497
+ name
498
+ ));
499
+ }
500
+ }
501
+ }
502
+ Ok(out)
503
+ }
504
+
505
+ /// Map a Tish export name to a Rust identifier (e.g. `readFile` → `read_file`) for shim crate symbols.
506
+ pub fn export_name_to_rust_ident(export_name: &str) -> String {
507
+ let mut out = String::new();
508
+ for (i, c) in export_name.chars().enumerate() {
509
+ if c.is_uppercase() && i > 0 {
510
+ out.push('_');
511
+ }
512
+ for lower in c.to_lowercase() {
513
+ out.push(lower);
514
+ }
515
+ }
516
+ if out.is_empty() {
517
+ "native_export".to_string()
518
+ } else {
519
+ out
520
+ }
521
+ }
522
+
523
+ /// Collect `(spec, export_name)` for every non-builtin native import in the program.
524
+ pub fn infer_native_module_exports(program: &Program) -> HashMap<String, HashSet<String>> {
525
+ let mut map: HashMap<String, HashSet<String>> = HashMap::new();
526
+ for stmt in &program.statements {
527
+ match stmt {
528
+ Statement::VarDecl {
529
+ init:
530
+ Some(Expr::NativeModuleLoad {
531
+ spec, export_name, ..
532
+ }),
533
+ ..
534
+ } => {
535
+ let s = spec.as_ref();
536
+ if is_builtin_native_spec(s) {
537
+ continue;
538
+ }
539
+ map.entry(s.to_string())
540
+ .or_default()
541
+ .insert(export_name.to_string());
542
+ }
543
+ Statement::Import {
544
+ specifiers, from, ..
545
+ } if is_native_import(from.as_ref()) => {
546
+ let spec =
547
+ normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string());
548
+ if is_builtin_native_spec(&spec) {
549
+ continue;
550
+ }
551
+ for sp in specifiers {
552
+ if let ImportSpecifier::Named { name, .. } = sp {
553
+ map.entry(spec.clone())
554
+ .or_default()
555
+ .insert(name.to_string());
556
+ }
557
+ }
558
+ }
559
+ _ => {}
560
+ }
561
+ }
562
+ map
563
+ }
564
+
565
+ /// Emit `generated_native.rs` for [`NativeModuleInit::Generated`] modules.
566
+ pub fn generate_native_wrapper_rs(
567
+ modules: &[ResolvedNativeModule],
568
+ inferred: &HashMap<String, HashSet<String>>,
569
+ init_by_spec: &HashMap<String, NativeModuleInit>,
570
+ ) -> String {
571
+ let mut file = String::from(
572
+ "//! Generated by `tish build` — do not edit.\n\
573
+ use std::cell::RefCell;\n\
574
+ use std::rc::Rc;\n\
575
+ use std::sync::Arc;\n\
576
+ use tishlang_runtime::{ObjectMap, Value, VmRef};\n\n",
577
+ );
578
+ let mut any = false;
579
+ for m in modules {
580
+ let Some(NativeModuleInit::Generated {
581
+ shim_crate,
582
+ export_fn,
583
+ }) = init_by_spec.get(&m.spec)
584
+ else {
585
+ continue;
586
+ };
587
+ let Some(names) = inferred.get(&m.spec) else {
588
+ continue;
589
+ };
590
+ if names.is_empty() {
591
+ continue;
592
+ }
593
+ any = true;
594
+ let mut keys: Vec<_> = names.iter().cloned().collect();
595
+ keys.sort();
596
+ file.push_str(&format!("pub fn {}() -> Value {{\n", export_fn));
597
+ file.push_str(" let mut m = ObjectMap::default();\n");
598
+ for export_name in keys {
599
+ let rust_fn = export_name_to_rust_ident(&export_name);
600
+ let key_lit = format!("{:?}", export_name);
601
+ file.push_str(&format!(
602
+ " m.insert(Arc::from({}), Value::native(|args: &[Value]| {{\n {}::{}(args)\n }}));\n",
603
+ key_lit, shim_crate, rust_fn
604
+ ));
605
+ }
606
+ file.push_str(" Value::Object(VmRef::new(m))\n}\n\n");
607
+ }
608
+ if !any {
609
+ return String::new();
610
+ }
611
+ file
612
+ }
613
+
614
+ /// Combine project `package.json`, inferred exports, and resolved native modules into build artifacts.
615
+ pub fn compute_native_build_artifacts(
616
+ program: &Program,
617
+ project_root: &Path,
618
+ native_modules: &[ResolvedNativeModule],
619
+ ) -> Result<NativeBuildArtifacts, String> {
620
+ let tish = read_project_tish_config(project_root);
621
+ let rust_dependencies_toml = format_rust_dependencies_toml(&tish, project_root)?;
622
+ let inferred = infer_native_module_exports(program);
623
+ let gen_tish = tish
624
+ .get("generateNativeWrapper")
625
+ .and_then(|v| v.as_bool())
626
+ .unwrap_or(false);
627
+
628
+ let mut native_init: HashMap<String, NativeModuleInit> = HashMap::new();
629
+ for m in native_modules {
630
+ let use_gen = if is_cargo_native_spec(&m.spec) {
631
+ inferred
632
+ .get(&m.spec)
633
+ .map(|s| !s.is_empty())
634
+ .unwrap_or(false)
635
+ } else {
636
+ gen_tish
637
+ && inferred
638
+ .get(&m.spec)
639
+ .map(|s| !s.is_empty())
640
+ .unwrap_or(false)
641
+ };
642
+ let init = if use_gen {
643
+ NativeModuleInit::Generated {
644
+ shim_crate: m.crate_name.clone(),
645
+ export_fn: m.export_fn.clone(),
646
+ }
647
+ } else {
648
+ NativeModuleInit::Legacy {
649
+ crate_name: m.crate_name.clone(),
650
+ export_fn: m.export_fn.clone(),
651
+ }
652
+ };
653
+ native_init.insert(m.spec.clone(), init);
654
+ }
655
+
656
+ let generated_native_rs = {
657
+ let s = generate_native_wrapper_rs(native_modules, &inferred, &native_init);
658
+ if s.trim().is_empty() {
659
+ None
660
+ } else {
661
+ Some(s)
662
+ }
663
+ };
664
+
665
+ Ok(NativeBuildArtifacts {
666
+ rust_dependencies_toml,
667
+ generated_native_rs,
668
+ native_init,
669
+ })
670
+ }
671
+
672
+ fn find_package_dir(package_name: &str, project_root: &Path) -> Result<PathBuf, String> {
673
+ let mut search = project_root.to_path_buf();
674
+ loop {
675
+ let node_mod = search.join("node_modules").join(package_name);
676
+ if node_mod.join("package.json").exists()
677
+ && read_package_name(&node_mod.join("package.json")) == Some(package_name.to_string())
678
+ {
679
+ return Ok(node_mod);
680
+ }
681
+ let sibling = search.join(package_name);
682
+ if sibling.join("package.json").exists()
683
+ && read_package_name(&sibling.join("package.json")) == Some(package_name.to_string())
684
+ {
685
+ return Ok(sibling);
686
+ }
687
+ if search.join("package.json").exists()
688
+ && read_package_name(&search.join("package.json")) == Some(package_name.to_string())
689
+ {
690
+ return Ok(search);
691
+ }
692
+ if let Some(parent) = search.parent() {
693
+ search = parent.to_path_buf();
694
+ } else {
695
+ break;
696
+ }
697
+ }
698
+ Err(format!(
699
+ "Native module {} not found. Add it as a dependency or place it in node_modules/ or as a sibling directory.",
700
+ package_name
701
+ ))
702
+ }
703
+
704
+ fn read_package_name(pkg_path: &Path) -> Option<String> {
705
+ let content = std::fs::read_to_string(pkg_path).ok()?;
706
+ let json: serde_json::Value = serde_json::from_str(&content).ok()?;
707
+ json.get("name").and_then(|v| v.as_str()).map(String::from)
708
+ }
709
+
710
+ fn stmt_native_specs(stmt: &Statement) -> Vec<String> {
711
+ match stmt {
712
+ Statement::VarDecl {
713
+ init: Some(Expr::NativeModuleLoad { spec, .. }),
714
+ ..
715
+ } => vec![spec.to_string()],
716
+ Statement::Import { from, .. } if is_native_import(from.as_ref()) => {
717
+ vec![normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string())]
718
+ }
719
+ _ => vec![],
720
+ }
721
+ }
722
+
723
+ /// Extract Cargo feature names from native imports in a merged program.
724
+ /// Used to enable tishlang_runtime features based on `import { x } from 'tish:egui'` etc.
725
+ pub fn extract_native_import_features(program: &Program) -> Vec<String> {
726
+ let mut features = std::collections::HashSet::new();
727
+ for stmt in &program.statements {
728
+ for spec in stmt_native_specs(stmt) {
729
+ if let Some(f) = native_spec_to_feature(spec.as_ref()) {
730
+ features.insert(f);
731
+ }
732
+ }
733
+ }
734
+ features.into_iter().collect()
735
+ }
736
+
737
+ /// Returns true if the merged program contains native imports (tish:*, @scope/pkg).
738
+ pub fn has_native_imports(program: &Program) -> bool {
739
+ program
740
+ .statements
741
+ .iter()
742
+ .any(|stmt| !stmt_native_specs(stmt).is_empty())
743
+ }
744
+
745
+ /// Returns true if the merged program contains external native imports (not built-in tish:fs/http/process).
746
+ /// Cranelift/LLVM reject these; bytecode VM supports built-ins only.
747
+ pub fn has_external_native_imports(program: &Program) -> bool {
748
+ for stmt in &program.statements {
749
+ for spec in stmt_native_specs(stmt) {
750
+ if !is_builtin_native_spec(spec.as_ref()) {
751
+ return true;
752
+ }
753
+ }
754
+ }
755
+ false
756
+ }
757
+
758
+ /// A resolved module: path and its parsed program.
759
+ #[derive(Debug, Clone)]
760
+ pub struct ResolvedModule {
761
+ pub path: PathBuf,
762
+ pub program: Program,
763
+ }
764
+
765
+ /// Resolve all modules starting from the entry file. Returns modules in dependency order
766
+ /// (dependencies first, then dependents). Entry module is last.
767
+ pub fn resolve_project(
768
+ entry_path: &Path,
769
+ project_root: Option<&Path>,
770
+ ) -> Result<Vec<ResolvedModule>, String> {
771
+ let project_root =
772
+ project_root.unwrap_or_else(|| entry_path.parent().unwrap_or(Path::new(".")));
773
+ let entry_canon = entry_path
774
+ .canonicalize()
775
+ .map_err(|e| format!("Cannot canonicalize entry {}: {}", entry_path.display(), e))?;
776
+ let root_canon = project_root.canonicalize().map_err(|e| {
777
+ format!(
778
+ "Cannot canonicalize project root {}: {}",
779
+ project_root.display(),
780
+ e
781
+ )
782
+ })?;
783
+
784
+ let mut visited = HashSet::new();
785
+ let mut path_to_module: HashMap<PathBuf, Program> = HashMap::new();
786
+ let mut load_order: Vec<PathBuf> = Vec::new();
787
+
788
+ load_module_recursive(
789
+ &entry_canon,
790
+ &root_canon,
791
+ &mut visited,
792
+ &mut path_to_module,
793
+ &mut load_order,
794
+ )?;
795
+
796
+ Ok(load_order
797
+ .into_iter()
798
+ .map(|p| {
799
+ let program = path_to_module.remove(&p).unwrap();
800
+ ResolvedModule { path: p, program }
801
+ })
802
+ .collect())
803
+ }
804
+
805
+ /// Resolve modules when the entry program is read from stdin (`tish run -`).
806
+ /// Relative file imports resolve from `project_root` (typically [`std::env::current_dir()`]).
807
+ /// The synthetic entry path `<stdin>` is not a real file; dependencies load from disk as usual.
808
+ pub fn resolve_project_from_stdin(
809
+ source: &str,
810
+ project_root: &Path,
811
+ ) -> Result<Vec<ResolvedModule>, String> {
812
+ let root_canon = project_root.canonicalize().map_err(|e| {
813
+ format!(
814
+ "Cannot canonicalize project root {}: {}",
815
+ project_root.display(),
816
+ e
817
+ )
818
+ })?;
819
+
820
+ let stdin_path = root_canon.join("<stdin>");
821
+ let program =
822
+ tishlang_parser::parse(source).map_err(|e| format!("Parse error (stdin): {}", e))?;
823
+
824
+ let mut visited = HashSet::new();
825
+ let mut path_to_module: HashMap<PathBuf, Program> = HashMap::new();
826
+ let mut load_order: Vec<PathBuf> = Vec::new();
827
+
828
+ let from_dir = stdin_path.parent().unwrap_or_else(|| Path::new("."));
829
+
830
+ for stmt in &program.statements {
831
+ if let Statement::Import { from, .. } = stmt {
832
+ if is_native_import(from.as_ref()) {
833
+ continue;
834
+ }
835
+ let dep_path = resolve_import_path(from.as_ref(), from_dir, &root_canon)?;
836
+ if !path_to_module.contains_key(&dep_path) {
837
+ load_module_recursive(
838
+ &dep_path,
839
+ &root_canon,
840
+ &mut visited,
841
+ &mut path_to_module,
842
+ &mut load_order,
843
+ )?;
844
+ }
845
+ }
846
+ }
847
+
848
+ path_to_module.insert(stdin_path.clone(), program);
849
+ load_order.push(stdin_path);
850
+
851
+ Ok(load_order
852
+ .into_iter()
853
+ .map(|p| {
854
+ let program = path_to_module.remove(&p).unwrap();
855
+ ResolvedModule { path: p, program }
856
+ })
857
+ .collect())
858
+ }
859
+
860
+ fn load_module_recursive(
861
+ module_path: &Path,
862
+ project_root: &Path,
863
+ visited: &mut HashSet<PathBuf>,
864
+ path_to_module: &mut HashMap<PathBuf, Program>,
865
+ load_order: &mut Vec<PathBuf>,
866
+ ) -> Result<(), String> {
867
+ let canonical = module_path
868
+ .canonicalize()
869
+ .map_err(|e| format!("Cannot read {}: {}", module_path.display(), e))?;
870
+
871
+ if visited.contains(&canonical) {
872
+ return Ok(());
873
+ }
874
+ visited.insert(canonical.clone());
875
+
876
+ let source = std::fs::read_to_string(&canonical)
877
+ .map_err(|e| format!("Cannot read {}: {}", canonical.display(), e))?;
878
+ let program = tishlang_parser::parse(&source)
879
+ .map_err(|e| format!("Parse error in {}: {}", canonical.display(), e))?;
880
+
881
+ // Collect imports and load dependencies first (skip native imports)
882
+ let dir = canonical.parent().unwrap_or(Path::new("."));
883
+ for stmt in &program.statements {
884
+ if let Statement::Import { from, .. } = stmt {
885
+ if is_native_import(from.as_ref()) {
886
+ continue; // Native imports don't load files
887
+ }
888
+ let dep_path = resolve_import_path(from.as_ref(), dir, project_root)?;
889
+ if !path_to_module.contains_key(&dep_path) {
890
+ load_module_recursive(
891
+ &dep_path,
892
+ project_root,
893
+ visited,
894
+ path_to_module,
895
+ load_order,
896
+ )?;
897
+ }
898
+ }
899
+ }
900
+
901
+ path_to_module.insert(canonical.clone(), program);
902
+ load_order.push(canonical);
903
+ Ok(())
904
+ }
905
+
906
+ /// Returns true for native module imports that don't resolve to files.
907
+ /// - fs, http, timers, process, ws (Node-compatible aliases for tish:*)
908
+ /// - tish:egui, tish:polars, etc.
909
+ /// - cargo:… (Cargo `rustDependencies` + generated wrapper; Rust native backend)
910
+ ///
911
+ /// Scoped npm packages (`@scope/pkg`) are merged as Tish source unless imported via `tish:…`.
912
+ pub fn is_native_import(spec: &str) -> bool {
913
+ spec.starts_with("tish:")
914
+ || spec.starts_with("cargo:")
915
+ || matches!(spec, "fs" | "http" | "timers" | "process" | "ws")
916
+ }
917
+
918
+ /// Map native spec to Cargo feature name for built-in tish:* modules.
919
+ pub fn native_spec_to_feature(spec: &str) -> Option<String> {
920
+ let canonical = normalize_builtin_spec(spec)?;
921
+ canonical.strip_prefix("tish:").map(|s| s.to_string())
922
+ }
923
+
924
+ /// Resolve `package.json` at `pkg_root` to the package's main `.tish` entry, if `name` matches `spec`.
925
+ fn resolve_package_root_to_entry(pkg_root: &Path, spec: &str) -> Option<PathBuf> {
926
+ let pkg_json = pkg_root.join("package.json");
927
+ if !pkg_json.exists() {
928
+ return None;
929
+ }
930
+ if read_package_name(&pkg_json).as_deref() != Some(spec) {
931
+ return None;
932
+ }
933
+ let content = std::fs::read_to_string(&pkg_json).ok()?;
934
+ let json: serde_json::Value = serde_json::from_str(&content).ok()?;
935
+ let entry = json
936
+ .get("tish")
937
+ .and_then(|t| t.get("module"))
938
+ .and_then(|m| m.as_str())
939
+ .or_else(|| json.get("main").and_then(|m| m.as_str()))
940
+ .unwrap_or("index.tish");
941
+ let entry_clean = entry.trim_start_matches("./");
942
+ let resolved = pkg_root.join(entry_clean);
943
+ if !resolved.exists() {
944
+ return None;
945
+ }
946
+ match resolved.canonicalize() {
947
+ Ok(p) => Some(p),
948
+ Err(_) => Some(resolved),
949
+ }
950
+ }
951
+
952
+ /// Resolve a bare specifier (e.g. "lattish") to the package entry `.tish` file.
953
+ ///
954
+ /// Walks upward from `from_dir` and, at each level, checks (same order as native [`find_package_dir`]):
955
+ /// - `node_modules/<spec>/`
956
+ /// - `<spec>/` as a sibling directory (monorepo: `…/tish/tish-candle` next to `…/tish/tish-hub`)
957
+ /// - the search directory itself if its `package.json` name matches `spec`
958
+ pub fn resolve_bare_spec(spec: &str, from_dir: &Path, _project_root: &Path) -> Option<PathBuf> {
959
+ let mut search = from_dir.to_path_buf();
960
+ loop {
961
+ if let Some(p) =
962
+ resolve_package_root_to_entry(&search.join("node_modules").join(spec), spec)
963
+ {
964
+ return Some(p);
965
+ }
966
+ if let Some(p) = resolve_package_root_to_entry(&search.join(spec), spec) {
967
+ return Some(p);
968
+ }
969
+ if let Some(p) = resolve_package_root_to_entry(&search, spec) {
970
+ return Some(p);
971
+ }
972
+ if let Some(parent) = search.parent() {
973
+ if parent == search {
974
+ break;
975
+ }
976
+ search = parent.to_path_buf();
977
+ } else {
978
+ break;
979
+ }
980
+ }
981
+ None
982
+ }
983
+
984
+ /// Resolve an import specifier (e.g. "./foo.tish", "../lib/utils", "lattish") to an absolute path.
985
+ fn resolve_import_path(
986
+ spec: &str,
987
+ from_dir: &Path,
988
+ project_root: &Path,
989
+ ) -> Result<PathBuf, String> {
990
+ if is_native_import(spec) {
991
+ return Err(format!(
992
+ "resolve_import_path called for native import (use merge_modules native branch): {}",
993
+ spec
994
+ ));
995
+ }
996
+ if !spec.starts_with("./") && !spec.starts_with("../") {
997
+ if let Some(path) = resolve_bare_spec(spec, from_dir, project_root) {
998
+ return Ok(path);
999
+ }
1000
+ return Err(format!(
1001
+ "Package '{}' not found. Install with `npm install {}`, or place the package under node_modules/ or as a sibling directory (same layout as native `find_package_dir`).",
1002
+ spec, spec
1003
+ ));
1004
+ }
1005
+ let base = from_dir.join(spec);
1006
+ // Try with .tish extension if the path has no extension
1007
+ let path = if base.extension().is_none() {
1008
+ let with_ext = base.with_extension("tish");
1009
+ if with_ext.exists() {
1010
+ with_ext
1011
+ } else {
1012
+ base
1013
+ }
1014
+ } else {
1015
+ base
1016
+ };
1017
+ path.canonicalize().map_err(|e| {
1018
+ format!(
1019
+ "Cannot resolve import '{}' from {}: {}",
1020
+ spec,
1021
+ from_dir.display(),
1022
+ e
1023
+ )
1024
+ })
1025
+ }
1026
+
1027
+ /// Check for cyclic imports. Returns Err if a cycle is detected.
1028
+ pub fn detect_cycles(modules: &[ResolvedModule]) -> Result<(), String> {
1029
+ let path_to_idx: HashMap<_, _> = modules
1030
+ .iter()
1031
+ .enumerate()
1032
+ .map(|(i, m)| (m.path.clone(), i))
1033
+ .collect();
1034
+
1035
+ for (idx, module) in modules.iter().enumerate() {
1036
+ let dir = module.path.parent().unwrap_or(Path::new("."));
1037
+ let mut stack = vec![idx];
1038
+ if has_cycle_from(
1039
+ dir,
1040
+ &module.program,
1041
+ &path_to_idx,
1042
+ modules,
1043
+ &mut stack,
1044
+ &mut HashSet::new(),
1045
+ )? {
1046
+ let path_names: Vec<_> = stack
1047
+ .iter()
1048
+ .map(|&i| modules[i].path.display().to_string())
1049
+ .collect();
1050
+ return Err(format!(
1051
+ "Circular import detected: {}",
1052
+ path_names.join(" -> ")
1053
+ ));
1054
+ }
1055
+ }
1056
+ Ok(())
1057
+ }
1058
+
1059
+ fn has_cycle_from(
1060
+ from_dir: &Path,
1061
+ program: &Program,
1062
+ path_to_idx: &HashMap<PathBuf, usize>,
1063
+ modules: &[ResolvedModule],
1064
+ stack: &mut Vec<usize>,
1065
+ visiting: &mut HashSet<usize>,
1066
+ ) -> Result<bool, String> {
1067
+ for stmt in &program.statements {
1068
+ if let Statement::Import { from, .. } = stmt {
1069
+ if is_native_import(from.as_ref()) {
1070
+ continue;
1071
+ }
1072
+ let dep_path = resolve_import_path(from.as_ref(), from_dir, Path::new("."))?;
1073
+ if let Some(&dep_idx) = path_to_idx.get(&dep_path) {
1074
+ if stack.contains(&dep_idx) {
1075
+ stack.push(dep_idx);
1076
+ return Ok(true);
1077
+ }
1078
+ if !visiting.contains(&dep_idx) {
1079
+ visiting.insert(dep_idx);
1080
+ stack.push(dep_idx);
1081
+ let dep = &modules[dep_idx];
1082
+ let dep_dir = dep.path.parent().unwrap_or(Path::new("."));
1083
+ if has_cycle_from(dep_dir, &dep.program, path_to_idx, modules, stack, visiting)?
1084
+ {
1085
+ return Ok(true);
1086
+ }
1087
+ stack.pop();
1088
+ visiting.remove(&dep_idx);
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ Ok(false)
1094
+ }
1095
+
1096
+ /// Result of [`merge_modules`]: merged AST plus, per top-level statement, the originating `.tish` file.
1097
+ #[derive(Debug)]
1098
+ pub struct MergedProgram {
1099
+ pub program: Program,
1100
+ pub statement_sources: Vec<PathBuf>,
1101
+ }
1102
+
1103
+ fn merge_push(
1104
+ statements: &mut Vec<Statement>,
1105
+ statement_sources: &mut Vec<PathBuf>,
1106
+ stmt: Statement,
1107
+ source: PathBuf,
1108
+ ) {
1109
+ statements.push(stmt);
1110
+ statement_sources.push(source);
1111
+ }
1112
+
1113
+ /// Merge all resolved modules into a single program. Dependencies are emitted first.
1114
+ /// Import statements are rewritten as bindings from already-emitted dep exports.
1115
+ /// Export statements are unwrapped (the inner declaration is emitted).
1116
+ pub fn merge_modules(modules: Vec<ResolvedModule>) -> Result<MergedProgram, String> {
1117
+ let path_to_idx: HashMap<PathBuf, usize> = modules
1118
+ .iter()
1119
+ .enumerate()
1120
+ .map(|(i, m)| (m.path.canonicalize().unwrap_or(m.path.clone()), i))
1121
+ .collect();
1122
+
1123
+ let mut module_exports: Vec<HashMap<String, String>> = vec![HashMap::new(); modules.len()];
1124
+ for (idx, module) in modules.iter().enumerate() {
1125
+ for stmt in &module.program.statements {
1126
+ if let Statement::Export { declaration, .. } = stmt {
1127
+ match declaration.as_ref() {
1128
+ ExportDeclaration::Named(s) => {
1129
+ let name = match s.as_ref() {
1130
+ Statement::VarDecl { name, .. } | Statement::FunDecl { name, .. } => {
1131
+ name.to_string()
1132
+ }
1133
+ _ => continue,
1134
+ };
1135
+ module_exports[idx].insert(name.clone(), name);
1136
+ }
1137
+ ExportDeclaration::Default(_) => {
1138
+ let default_name = format!("__default_{}", idx);
1139
+ module_exports[idx].insert("default".to_string(), default_name);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ let mut statements = Vec::new();
1147
+ let mut statement_sources = Vec::new();
1148
+ for (idx, module) in modules.iter().enumerate() {
1149
+ let src_path = module.path.clone();
1150
+ let dir = module.path.parent().unwrap_or(Path::new("."));
1151
+ for stmt in &module.program.statements {
1152
+ match stmt {
1153
+ Statement::Import {
1154
+ specifiers,
1155
+ from,
1156
+ span,
1157
+ } => {
1158
+ if is_native_import(from.as_ref()) {
1159
+ // Normalize fs/http/process -> tish:fs etc. for Node compatibility
1160
+ let canonical_spec = normalize_builtin_spec(from.as_ref())
1161
+ .unwrap_or_else(|| from.to_string());
1162
+ // Emit VarDecl with NativeModuleLoad for each specifier
1163
+ for spec in specifiers {
1164
+ match spec {
1165
+ ImportSpecifier::Named {
1166
+ name,
1167
+ name_span,
1168
+ alias,
1169
+ alias_span,
1170
+ } => {
1171
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1172
+ let decl_name_span = alias_span.as_ref().unwrap_or(name_span);
1173
+ let init = Expr::NativeModuleLoad {
1174
+ spec: Arc::from(canonical_spec.clone()),
1175
+ export_name: name.clone(),
1176
+ span: *span,
1177
+ };
1178
+ merge_push(
1179
+ &mut statements,
1180
+ &mut statement_sources,
1181
+ Statement::VarDecl {
1182
+ name: Arc::from(bind),
1183
+ name_span: *decl_name_span,
1184
+ mutable: false,
1185
+ type_ann: None,
1186
+ init: Some(init),
1187
+ span: *span,
1188
+ },
1189
+ src_path.clone(),
1190
+ );
1191
+ }
1192
+ ImportSpecifier::Namespace { name, .. } => {
1193
+ return Err(format!(
1194
+ "Namespace import (* as {}) not supported for native module '{}'",
1195
+ name.as_ref(),
1196
+ from.as_ref()
1197
+ ));
1198
+ }
1199
+ ImportSpecifier::Default { name, .. } => {
1200
+ return Err(format!(
1201
+ "Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
1202
+ name.as_ref(),
1203
+ from.as_ref(),
1204
+ from.as_ref()
1205
+ ));
1206
+ }
1207
+ }
1208
+ }
1209
+ continue;
1210
+ }
1211
+ let dep_path = resolve_import_path(from.as_ref(), dir, Path::new("."))?;
1212
+ let dep_path = dep_path.canonicalize().unwrap_or(dep_path);
1213
+ let dep_idx = *path_to_idx
1214
+ .get(&dep_path)
1215
+ .ok_or_else(|| format!("Resolved import '{}' not in module list", from))?;
1216
+ let dep_exports = &module_exports[dep_idx];
1217
+ for spec in specifiers {
1218
+ match spec {
1219
+ ImportSpecifier::Named {
1220
+ name,
1221
+ name_span,
1222
+ alias,
1223
+ alias_span,
1224
+ } => {
1225
+ let source = dep_exports
1226
+ .get(name.as_ref())
1227
+ .cloned()
1228
+ .unwrap_or_else(|| name.to_string());
1229
+ let bind = alias.as_deref().unwrap_or(name.as_ref());
1230
+ if bind != source {
1231
+ let decl_name_span = alias_span.as_ref().unwrap_or(name_span);
1232
+ merge_push(
1233
+ &mut statements,
1234
+ &mut statement_sources,
1235
+ Statement::VarDecl {
1236
+ name: Arc::from(bind),
1237
+ name_span: *decl_name_span,
1238
+ mutable: false,
1239
+ type_ann: None,
1240
+ init: Some(Expr::Ident {
1241
+ name: Arc::from(source),
1242
+ span: *span,
1243
+ }),
1244
+ span: *span,
1245
+ },
1246
+ src_path.clone(),
1247
+ );
1248
+ }
1249
+ }
1250
+ ImportSpecifier::Namespace { name, name_span } => {
1251
+ let mut props = Vec::new();
1252
+ for (k, v) in dep_exports {
1253
+ props.push(tishlang_ast::ObjectProp::KeyValue(
1254
+ Arc::from(k.clone()),
1255
+ Expr::Ident {
1256
+ name: Arc::from(v.clone()),
1257
+ span: *span,
1258
+ },
1259
+ ));
1260
+ }
1261
+ merge_push(
1262
+ &mut statements,
1263
+ &mut statement_sources,
1264
+ Statement::VarDecl {
1265
+ name: name.clone(),
1266
+ name_span: *name_span,
1267
+ mutable: false,
1268
+ type_ann: None,
1269
+ init: Some(Expr::Object { props, span: *span }),
1270
+ span: *span,
1271
+ },
1272
+ src_path.clone(),
1273
+ );
1274
+ }
1275
+ ImportSpecifier::Default { name, name_span } => {
1276
+ let source =
1277
+ dep_exports.get("default").cloned().ok_or_else(|| {
1278
+ format!("Module '{}' has no default export", from)
1279
+ })?;
1280
+ merge_push(
1281
+ &mut statements,
1282
+ &mut statement_sources,
1283
+ Statement::VarDecl {
1284
+ name: name.clone(),
1285
+ name_span: *name_span,
1286
+ mutable: false,
1287
+ type_ann: None,
1288
+ init: Some(Expr::Ident {
1289
+ name: Arc::from(source),
1290
+ span: *span,
1291
+ }),
1292
+ span: *span,
1293
+ },
1294
+ src_path.clone(),
1295
+ );
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
1301
+ ExportDeclaration::Named(s) => merge_push(
1302
+ &mut statements,
1303
+ &mut statement_sources,
1304
+ *s.clone(),
1305
+ src_path.clone(),
1306
+ ),
1307
+ ExportDeclaration::Default(e) => {
1308
+ let default_name = format!("__default_{}", idx);
1309
+ let espan = e.span();
1310
+ merge_push(
1311
+ &mut statements,
1312
+ &mut statement_sources,
1313
+ Statement::VarDecl {
1314
+ name: Arc::from(default_name),
1315
+ name_span: espan,
1316
+ mutable: false,
1317
+ type_ann: None,
1318
+ init: Some((*e).clone()),
1319
+ span: espan,
1320
+ },
1321
+ src_path.clone(),
1322
+ );
1323
+ }
1324
+ },
1325
+ _ => merge_push(
1326
+ &mut statements,
1327
+ &mut statement_sources,
1328
+ stmt.clone(),
1329
+ src_path.clone(),
1330
+ ),
1331
+ }
1332
+ }
1333
+ }
1334
+ Ok(MergedProgram {
1335
+ program: Program { statements },
1336
+ statement_sources,
1337
+ })
1338
+ }
1339
+
1340
+ #[cfg(test)]
1341
+ mod cargo_spec_tests {
1342
+ use std::sync::Arc;
1343
+
1344
+ use super::cargo_export_fn_name;
1345
+ use super::is_native_import;
1346
+
1347
+ #[test]
1348
+ fn is_native_import_accepts_arc_str_ref() {
1349
+ let from: &Arc<str> = &Arc::from("cargo:demo_shim");
1350
+ assert!(is_native_import(from));
1351
+ }
1352
+
1353
+ #[test]
1354
+ fn detect_cycles_skips_cargo_import() {
1355
+ use super::{detect_cycles, resolve_project};
1356
+ let dir = tempfile::tempdir().expect("tempdir");
1357
+ let p = dir.path().join("main.tish");
1358
+ let src = "import { greet } from 'cargo:demo_shim'\nconsole.log(1)\n";
1359
+ std::fs::write(&p, src).unwrap();
1360
+ let root = dir.path();
1361
+ let modules = resolve_project(&p, Some(root)).unwrap();
1362
+ detect_cycles(&modules).unwrap();
1363
+ }
1364
+
1365
+ #[test]
1366
+ fn merge_modules_skips_cargo_import() {
1367
+ use super::{merge_modules, resolve_project};
1368
+ let dir = tempfile::tempdir().expect("tempdir");
1369
+ let p = dir.path().join("main.tish");
1370
+ let src = "import { greet } from 'cargo:demo_shim'\nconsole.log(1)\n";
1371
+ std::fs::write(&p, src).unwrap();
1372
+ let root = dir.path();
1373
+ let modules = resolve_project(&p, Some(root)).unwrap();
1374
+ let _ = merge_modules(modules).unwrap();
1375
+ }
1376
+
1377
+ #[test]
1378
+ fn cargo_export_fn_name_sanitizes() {
1379
+ assert_eq!(
1380
+ cargo_export_fn_name("cargo:tish_serde_json"),
1381
+ "cargo_native_tish_serde_json_object"
1382
+ );
1383
+ assert_eq!(
1384
+ cargo_export_fn_name("cargo:my-crate"),
1385
+ "cargo_native_my_crate_object"
1386
+ );
1387
+ }
1388
+ }