@tishlang/tish 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/tish +0 -0
  2. package/crates/tish/Cargo.toml +2 -2
  3. package/crates/tish/src/cli_help.rs +504 -0
  4. package/crates/tish/src/main.rs +76 -90
  5. package/crates/tish/src/repl_completion.rs +1 -1
  6. package/crates/tish/tests/cargo_example_compile.rs +65 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/Cargo.toml +3 -0
  8. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/Cargo.toml +11 -0
  9. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +12 -0
  10. package/crates/tish/tests/fixtures/cargo_example_project/package.json +10 -0
  11. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +3 -0
  12. package/crates/tish/tests/integration_test.rs +48 -0
  13. package/crates/tish_build_utils/src/lib.rs +204 -1
  14. package/crates/tish_builtins/src/string.rs +248 -0
  15. package/crates/tish_bytecode/Cargo.toml +1 -0
  16. package/crates/tish_bytecode/src/compiler.rs +289 -66
  17. package/crates/tish_bytecode/src/opcode.rs +13 -3
  18. package/crates/tish_bytecode/src/peephole.rs +21 -16
  19. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +44 -0
  20. package/crates/tish_compile/Cargo.toml +1 -0
  21. package/crates/tish_compile/src/codegen.rs +277 -93
  22. package/crates/tish_compile/src/lib.rs +7 -4
  23. package/crates/tish_compile/src/resolve.rs +418 -40
  24. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  25. package/crates/tish_core/src/value.rs +1 -0
  26. package/crates/tish_eval/src/eval.rs +49 -1
  27. package/crates/tish_eval/src/lib.rs +1 -1
  28. package/crates/tish_native/src/build.rs +86 -17
  29. package/crates/tish_native/src/lib.rs +36 -16
  30. package/crates/tish_runtime/src/lib.rs +4 -0
  31. package/crates/tish_vm/src/lib.rs +1 -1
  32. package/crates/tish_vm/src/vm.rs +165 -19
  33. package/crates/tish_vm/tests/lexical_scope_declare.rs +34 -0
  34. package/package.json +1 -1
  35. package/platform/darwin-arm64/tish +0 -0
  36. package/platform/darwin-x64/tish +0 -0
  37. package/platform/linux-arm64/tish +0 -0
  38. package/platform/linux-x64/tish +0 -0
  39. package/platform/win32-x64/tish.exe +0 -0
@@ -13,9 +13,12 @@ pub use codegen::{
13
13
  };
14
14
  pub use codegen::CompileError;
15
15
  pub use resolve::{
16
- detect_cycles, extract_native_import_features, has_external_native_imports, has_native_imports,
17
- is_builtin_native_spec, merge_modules, resolve_native_modules, resolve_project,
18
- resolve_project_from_stdin, ResolvedNativeModule,
16
+ cargo_export_fn_name, compute_native_build_artifacts, detect_cycles, export_name_to_rust_ident,
17
+ extract_native_import_features, format_rust_dependencies_toml, generate_native_wrapper_rs,
18
+ has_external_native_imports, has_native_imports, infer_native_module_exports,
19
+ is_builtin_native_spec, is_cargo_native_spec, merge_modules, read_project_tish_config,
20
+ resolve_native_modules, resolve_project, resolve_project_from_stdin, NativeBuildArtifacts,
21
+ NativeModuleInit, ResolvedNativeModule,
19
22
  };
20
23
  pub use types::{RustType, TypeContext};
21
24
 
@@ -108,7 +111,7 @@ fn factory() {
108
111
  .into_iter()
109
112
  .map(String::from)
110
113
  .collect::<Vec<_>>();
111
- let (rust, _) = compile_project_full(&bench, bench.parent(), &features, true).unwrap();
114
+ let (rust, _, _, _) = compile_project_full(&bench, bench.parent(), &features, true).unwrap();
112
115
  // outerVar = 42 is inferred as f64; f64 is Copy so no .clone() is emitted.
113
116
  assert!(
114
117
  rust.contains("let mut outerVar: f64"),
@@ -1,5 +1,5 @@
1
1
  //! Module resolver: resolves relative imports, builds dependency graph, detects cycles.
2
- //! Supports native imports: tish:egui, tish:polars, @scope/pkg (via package.json).
2
+ //! Supports native imports: `tish:…`, `cargo:…`, `@scope/pkg` (via package.json).
3
3
 
4
4
  use std::collections::{HashMap, HashSet};
5
5
  use std::path::{Path, PathBuf};
@@ -16,6 +16,33 @@ pub struct ResolvedNativeModule {
16
16
  pub crate_name: String,
17
17
  pub crate_path: PathBuf,
18
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>,
19
46
  }
20
47
 
21
48
  /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.).
@@ -45,6 +72,7 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
45
72
 
46
73
  /// Resolve all native imports in a merged program via package.json lookup.
47
74
  /// Built-in modules (tish:fs, tish:http, tish:process) are skipped - they use tishlang_runtime directly.
75
+ /// Handles both lowered `NativeModuleLoad` (merged modules) and raw `import { … } from 'tish:…'`.
48
76
  pub fn resolve_native_modules(program: &Program, project_root: &Path) -> Result<Vec<ResolvedNativeModule>, String> {
49
77
  let root_canon = project_root
50
78
  .canonicalize()
@@ -52,25 +80,93 @@ pub fn resolve_native_modules(program: &Program, project_root: &Path) -> Result<
52
80
  let mut seen = HashSet::new();
53
81
  let mut modules = Vec::new();
54
82
  for stmt in &program.statements {
55
- if let Statement::VarDecl {
56
- init: Some(Expr::NativeModuleLoad { spec, .. }),
57
- ..
58
- } = stmt
59
- {
60
- let s = spec.as_ref();
61
- if is_builtin_native_spec(s) {
62
- continue; // Built-ins use tishlang_runtime, no package.json lookup
83
+ let specs: Vec<String> = match stmt {
84
+ Statement::VarDecl {
85
+ init: Some(Expr::NativeModuleLoad { spec, .. }),
86
+ ..
87
+ } => vec![spec.as_ref().to_string()],
88
+ Statement::Import { from, .. } if is_native_import(from.as_ref()) => {
89
+ vec![normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string())]
90
+ }
91
+ _ => continue,
92
+ };
93
+ for s in specs {
94
+ if is_builtin_native_spec(&s) {
95
+ continue;
63
96
  }
64
- if !seen.insert(s.to_string()) {
97
+ if !seen.insert(s.clone()) {
65
98
  continue;
66
99
  }
67
- let m = resolve_native_module(s, &root_canon)?;
100
+ let m = if s.starts_with("cargo:") {
101
+ resolve_cargo_native_module(&s, &root_canon)?
102
+ } else {
103
+ resolve_native_module(&s, &root_canon)?
104
+ };
68
105
  modules.push(m);
69
106
  }
70
107
  }
71
108
  Ok(modules)
72
109
  }
73
110
 
111
+ /// True for `cargo:…` specs (Cargo-backed imports; Rust native backend only).
112
+ pub fn is_cargo_native_spec(spec: &str) -> bool {
113
+ spec.starts_with("cargo:")
114
+ }
115
+
116
+ /// Stable Rust symbol for the generated namespace function, e.g. `cargo:my-crate` → `cargo_native_my_crate_object`.
117
+ pub fn cargo_export_fn_name(spec: &str) -> String {
118
+ let tail = spec.strip_prefix("cargo:").unwrap_or(spec);
119
+ let mut out = String::from("cargo_native_");
120
+ for c in tail.chars() {
121
+ if c.is_ascii_alphanumeric() {
122
+ out.push(c);
123
+ } else {
124
+ out.push('_');
125
+ }
126
+ }
127
+ if out == "cargo_native_" {
128
+ out.push_str("unnamed");
129
+ }
130
+ out.push_str("_object");
131
+ out
132
+ }
133
+
134
+ fn resolve_cargo_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNativeModule, String> {
135
+ let tail = spec
136
+ .strip_prefix("cargo:")
137
+ .ok_or_else(|| format!("Invalid cargo native spec: {}", spec))?;
138
+ if tail.is_empty() {
139
+ return Err("cargo: import needs a dependency name, e.g. import { x } from 'cargo:serde_json'".into());
140
+ }
141
+ let dep_key = tail.to_string();
142
+ let tish = read_project_tish_config(project_root);
143
+ let rust_deps = tish.get("rustDependencies").and_then(|v| v.as_object()).ok_or_else(|| {
144
+ format!(
145
+ "cargo:{} requires package.json \"tish\": {{ \"rustDependencies\": {{ \"{}\": \"…\" }} }}",
146
+ tail, dep_key
147
+ )
148
+ })?;
149
+ if !rust_deps.contains_key(&dep_key) {
150
+ return Err(format!(
151
+ "cargo:{}: add \"{}\" to tish.rustDependencies in package.json (version string or inline table)",
152
+ tail, dep_key
153
+ ));
154
+ }
155
+ let crate_name = dep_key.replace('-', "_");
156
+ let export_fn = cargo_export_fn_name(spec);
157
+ let crate_path = project_root
158
+ .canonicalize()
159
+ .unwrap_or_else(|_| project_root.to_path_buf());
160
+ Ok(ResolvedNativeModule {
161
+ spec: spec.to_string(),
162
+ package_name: dep_key.clone(),
163
+ crate_name,
164
+ crate_path,
165
+ export_fn,
166
+ use_path_dependency: false,
167
+ })
168
+ }
169
+
74
170
  fn resolve_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNativeModule, String> {
75
171
  let package_name = if spec.starts_with("tish:") {
76
172
  format!("tish-{}", spec.strip_prefix("tish:").unwrap_or(spec))
@@ -110,6 +206,236 @@ fn resolve_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNati
110
206
  crate_name: raw_crate.replace('-', "_"),
111
207
  crate_path,
112
208
  export_fn,
209
+ use_path_dependency: true,
210
+ })
211
+ }
212
+
213
+ /// Read the `tish` object from the project root `package.json` (empty JSON object if missing).
214
+ pub fn read_project_tish_config(project_root: &Path) -> serde_json::Value {
215
+ let path = project_root.join("package.json");
216
+ let Ok(content) = std::fs::read_to_string(&path) else {
217
+ return serde_json::json!({});
218
+ };
219
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
220
+ return serde_json::json!({});
221
+ };
222
+ json.get("tish").cloned().unwrap_or_else(|| serde_json::json!({}))
223
+ }
224
+
225
+ fn resolve_cargo_path_for_toml(project_root: &Path, raw: &str) -> String {
226
+ let p = Path::new(raw);
227
+ let resolved = if p.is_absolute() {
228
+ p.to_path_buf()
229
+ } else {
230
+ project_root.join(p)
231
+ };
232
+ let resolved = resolved.canonicalize().unwrap_or(resolved);
233
+ resolved.display().to_string().replace('\\', "/")
234
+ }
235
+
236
+ fn json_to_cargo_inline_value(v: &serde_json::Value, project_root: &Path) -> Result<String, String> {
237
+ match v {
238
+ serde_json::Value::String(s) => Ok(format!("{:?}", s.as_str())),
239
+ serde_json::Value::Bool(b) => Ok(b.to_string()),
240
+ serde_json::Value::Number(n) => Ok(n.to_string()),
241
+ serde_json::Value::Array(arr) => {
242
+ let mut inner = Vec::new();
243
+ for item in arr {
244
+ inner.push(json_to_cargo_inline_value(item, project_root)?);
245
+ }
246
+ Ok(format!("[{}]", inner.join(", ")))
247
+ }
248
+ serde_json::Value::Object(map) => {
249
+ let mut parts = Vec::new();
250
+ for (k, v) in map {
251
+ let rhs = if k == "path" && v.as_str().is_some() {
252
+ let s = v.as_str().unwrap();
253
+ format!("{:?}", resolve_cargo_path_for_toml(project_root, s))
254
+ } else {
255
+ json_to_cargo_inline_value(v, project_root)?
256
+ };
257
+ parts.push(format!("{} = {}", k, rhs));
258
+ }
259
+ Ok(format!("{{ {} }}", parts.join(", ")))
260
+ }
261
+ serde_json::Value::Null => Err("null is not valid in a Cargo dependency value".to_string()),
262
+ }
263
+ }
264
+
265
+ /// Serialize `tish.rustDependencies` from project `package.json` into Cargo.toml `[dependencies]` lines.
266
+ /// Relative `path = "…"` entries in inline tables are resolved against `project_root` so the temp build crate can find them.
267
+ pub fn format_rust_dependencies_toml(tish: &serde_json::Value, project_root: &Path) -> Result<String, String> {
268
+ let Some(obj) = tish.get("rustDependencies").and_then(|v| v.as_object()) else {
269
+ return Ok(String::new());
270
+ };
271
+ let mut out = String::new();
272
+ for (name, val) in obj {
273
+ match val {
274
+ serde_json::Value::String(_) | serde_json::Value::Object(_) => {
275
+ out.push_str(&format!(
276
+ "{} = {}\n",
277
+ name,
278
+ json_to_cargo_inline_value(val, project_root)?
279
+ ));
280
+ }
281
+ _ => {
282
+ return Err(format!(
283
+ "tish.rustDependencies.{} must be a string (version) or object (inline table)",
284
+ name
285
+ ));
286
+ }
287
+ }
288
+ }
289
+ Ok(out)
290
+ }
291
+
292
+ /// Map a Tish export name to a Rust identifier (e.g. `readFile` → `read_file`) for shim crate symbols.
293
+ pub fn export_name_to_rust_ident(export_name: &str) -> String {
294
+ let mut out = String::new();
295
+ for (i, c) in export_name.chars().enumerate() {
296
+ if c.is_uppercase() && i > 0 {
297
+ out.push('_');
298
+ }
299
+ for lower in c.to_lowercase() {
300
+ out.push(lower);
301
+ }
302
+ }
303
+ if out.is_empty() {
304
+ "native_export".to_string()
305
+ } else {
306
+ out
307
+ }
308
+ }
309
+
310
+ /// Collect `(spec, export_name)` for every non-builtin native import in the program.
311
+ pub fn infer_native_module_exports(program: &Program) -> HashMap<String, HashSet<String>> {
312
+ let mut map: HashMap<String, HashSet<String>> = HashMap::new();
313
+ for stmt in &program.statements {
314
+ match stmt {
315
+ Statement::VarDecl {
316
+ init: Some(Expr::NativeModuleLoad { spec, export_name, .. }),
317
+ ..
318
+ } => {
319
+ let s = spec.as_ref();
320
+ if is_builtin_native_spec(s) {
321
+ continue;
322
+ }
323
+ map.entry(s.to_string())
324
+ .or_default()
325
+ .insert(export_name.to_string());
326
+ }
327
+ Statement::Import { specifiers, from, .. } if is_native_import(from.as_ref()) => {
328
+ let spec = normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string());
329
+ if is_builtin_native_spec(&spec) {
330
+ continue;
331
+ }
332
+ for sp in specifiers {
333
+ if let ImportSpecifier::Named { name, .. } = sp {
334
+ map.entry(spec.clone())
335
+ .or_default()
336
+ .insert(name.to_string());
337
+ }
338
+ }
339
+ }
340
+ _ => {}
341
+ }
342
+ }
343
+ map
344
+ }
345
+
346
+ /// Emit `generated_native.rs` for [`NativeModuleInit::Generated`] modules.
347
+ pub fn generate_native_wrapper_rs(
348
+ modules: &[ResolvedNativeModule],
349
+ inferred: &HashMap<String, HashSet<String>>,
350
+ init_by_spec: &HashMap<String, NativeModuleInit>,
351
+ ) -> String {
352
+ let mut file = String::from(
353
+ "//! Generated by `tish build` — do not edit.\n\
354
+ use std::cell::RefCell;\n\
355
+ use std::rc::Rc;\n\
356
+ use std::sync::Arc;\n\
357
+ use tishlang_runtime::{ObjectMap, Value};\n\n",
358
+ );
359
+ let mut any = false;
360
+ for m in modules {
361
+ let Some(NativeModuleInit::Generated { shim_crate, export_fn }) = init_by_spec.get(&m.spec) else {
362
+ continue;
363
+ };
364
+ let Some(names) = inferred.get(&m.spec) else {
365
+ continue;
366
+ };
367
+ if names.is_empty() {
368
+ continue;
369
+ }
370
+ any = true;
371
+ let mut keys: Vec<_> = names.iter().cloned().collect();
372
+ keys.sort();
373
+ file.push_str(&format!("pub fn {}() -> Value {{\n", export_fn));
374
+ file.push_str(" let mut m = ObjectMap::default();\n");
375
+ for export_name in keys {
376
+ let rust_fn = export_name_to_rust_ident(&export_name);
377
+ let key_lit = format!("{:?}", export_name);
378
+ file.push_str(&format!(
379
+ " m.insert(Arc::from({}), Value::Function(Rc::new(|args: &[Value]| {{\n {}::{}(args)\n }})));\n",
380
+ key_lit, shim_crate, rust_fn
381
+ ));
382
+ }
383
+ file.push_str(" Value::Object(Rc::new(RefCell::new(m)))\n}\n\n");
384
+ }
385
+ if !any {
386
+ return String::new();
387
+ }
388
+ file
389
+ }
390
+
391
+ /// Combine project `package.json`, inferred exports, and resolved native modules into build artifacts.
392
+ pub fn compute_native_build_artifacts(
393
+ program: &Program,
394
+ project_root: &Path,
395
+ native_modules: &[ResolvedNativeModule],
396
+ ) -> Result<NativeBuildArtifacts, String> {
397
+ let tish = read_project_tish_config(project_root);
398
+ let rust_dependencies_toml = format_rust_dependencies_toml(&tish, project_root)?;
399
+ let inferred = infer_native_module_exports(program);
400
+ let gen_tish = tish
401
+ .get("generateNativeWrapper")
402
+ .and_then(|v| v.as_bool())
403
+ .unwrap_or(false);
404
+
405
+ let mut native_init: HashMap<String, NativeModuleInit> = HashMap::new();
406
+ for m in native_modules {
407
+ let use_gen = if is_cargo_native_spec(&m.spec) {
408
+ inferred.get(&m.spec).map(|s| !s.is_empty()).unwrap_or(false)
409
+ } else {
410
+ gen_tish && inferred.get(&m.spec).map(|s| !s.is_empty()).unwrap_or(false)
411
+ };
412
+ let init = if use_gen {
413
+ NativeModuleInit::Generated {
414
+ shim_crate: m.crate_name.clone(),
415
+ export_fn: m.export_fn.clone(),
416
+ }
417
+ } else {
418
+ NativeModuleInit::Legacy {
419
+ crate_name: m.crate_name.clone(),
420
+ export_fn: m.export_fn.clone(),
421
+ }
422
+ };
423
+ native_init.insert(m.spec.clone(), init);
424
+ }
425
+
426
+ let generated_native_rs = {
427
+ let s = generate_native_wrapper_rs(native_modules, &inferred, &native_init);
428
+ if s.trim().is_empty() {
429
+ None
430
+ } else {
431
+ Some(s)
432
+ }
433
+ };
434
+
435
+ Ok(NativeBuildArtifacts {
436
+ rust_dependencies_toml,
437
+ generated_native_rs,
438
+ native_init,
113
439
  })
114
440
  }
115
441
 
@@ -151,16 +477,25 @@ fn read_package_name(pkg_path: &Path) -> Option<String> {
151
477
  json.get("name").and_then(|v| v.as_str()).map(String::from)
152
478
  }
153
479
 
480
+ fn stmt_native_specs(stmt: &Statement) -> Vec<String> {
481
+ match stmt {
482
+ Statement::VarDecl {
483
+ init: Some(Expr::NativeModuleLoad { spec, .. }),
484
+ ..
485
+ } => vec![spec.to_string()],
486
+ Statement::Import { from, .. } if is_native_import(from.as_ref()) => {
487
+ vec![normalize_builtin_spec(from.as_ref()).unwrap_or_else(|| from.to_string())]
488
+ }
489
+ _ => vec![],
490
+ }
491
+ }
492
+
154
493
  /// Extract Cargo feature names from native imports in a merged program.
155
494
  /// Used to enable tishlang_runtime features based on `import { x } from 'tish:egui'` etc.
156
495
  pub fn extract_native_import_features(program: &Program) -> Vec<String> {
157
496
  let mut features = std::collections::HashSet::new();
158
497
  for stmt in &program.statements {
159
- if let Statement::VarDecl {
160
- init: Some(Expr::NativeModuleLoad { spec, .. }),
161
- ..
162
- } = stmt
163
- {
498
+ for spec in stmt_native_specs(stmt) {
164
499
  if let Some(f) = native_spec_to_feature(spec.as_ref()) {
165
500
  features.insert(f);
166
501
  }
@@ -171,27 +506,17 @@ pub fn extract_native_import_features(program: &Program) -> Vec<String> {
171
506
 
172
507
  /// Returns true if the merged program contains native imports (tish:*, @scope/pkg).
173
508
  pub fn has_native_imports(program: &Program) -> bool {
174
- for stmt in &program.statements {
175
- if let Statement::VarDecl {
176
- init: Some(Expr::NativeModuleLoad { .. }),
177
- ..
178
- } = stmt
179
- {
180
- return true;
181
- }
182
- }
183
- false
509
+ program
510
+ .statements
511
+ .iter()
512
+ .any(|stmt| !stmt_native_specs(stmt).is_empty())
184
513
  }
185
514
 
186
515
  /// Returns true if the merged program contains external native imports (not built-in tish:fs/http/process).
187
516
  /// Cranelift/LLVM reject these; bytecode VM supports built-ins only.
188
517
  pub fn has_external_native_imports(program: &Program) -> bool {
189
518
  for stmt in &program.statements {
190
- if let Statement::VarDecl {
191
- init: Some(Expr::NativeModuleLoad { spec, .. }),
192
- ..
193
- } = stmt
194
- {
519
+ for spec in stmt_native_specs(stmt) {
195
520
  if !is_builtin_native_spec(spec.as_ref()) {
196
521
  return true;
197
522
  }
@@ -267,10 +592,10 @@ pub fn resolve_project_from_stdin(
267
592
 
268
593
  for stmt in &program.statements {
269
594
  if let Statement::Import { from, .. } = stmt {
270
- if is_native_import(from) {
595
+ if is_native_import(from.as_ref()) {
271
596
  continue;
272
597
  }
273
- let dep_path = resolve_import_path(from, from_dir, &root_canon)?;
598
+ let dep_path = resolve_import_path(from.as_ref(), from_dir, &root_canon)?;
274
599
  if !path_to_module.contains_key(&dep_path) {
275
600
  load_module_recursive(
276
601
  &dep_path,
@@ -320,10 +645,10 @@ fn load_module_recursive(
320
645
  let dir = canonical.parent().unwrap_or(Path::new("."));
321
646
  for stmt in &program.statements {
322
647
  if let Statement::Import { from, .. } = stmt {
323
- if is_native_import(from) {
648
+ if is_native_import(from.as_ref()) {
324
649
  continue; // Native imports don't load files
325
650
  }
326
- let dep_path = resolve_import_path(from, dir, project_root)?;
651
+ let dep_path = resolve_import_path(from.as_ref(), dir, project_root)?;
327
652
  if !path_to_module.contains_key(&dep_path) {
328
653
  load_module_recursive(
329
654
  &dep_path,
@@ -344,9 +669,11 @@ fn load_module_recursive(
344
669
  /// Returns true for native module imports that don't resolve to files.
345
670
  /// - fs, http, process, ws (Node-compatible aliases for tish:fs, tish:http, tish:process, tish:ws)
346
671
  /// - tish:egui, tish:polars, etc.
672
+ /// - cargo:… (Cargo `rustDependencies` + generated wrapper; Rust native backend)
347
673
  /// - @scope/package (npm-style)
348
674
  pub fn is_native_import(spec: &str) -> bool {
349
675
  spec.starts_with("tish:")
676
+ || spec.starts_with("cargo:")
350
677
  || spec.starts_with('@')
351
678
  || matches!(spec, "fs" | "http" | "process" | "ws")
352
679
  }
@@ -476,10 +803,10 @@ fn has_cycle_from(
476
803
  ) -> Result<bool, String> {
477
804
  for stmt in &program.statements {
478
805
  if let Statement::Import { from, .. } = stmt {
479
- if is_native_import(from) {
806
+ if is_native_import(from.as_ref()) {
480
807
  continue;
481
808
  }
482
- let dep_path = resolve_import_path(from, from_dir, Path::new("."))?;
809
+ let dep_path = resolve_import_path(from.as_ref(), from_dir, Path::new("."))?;
483
810
  if let Some(&dep_idx) = path_to_idx.get(&dep_path) {
484
811
  if stack.contains(&dep_idx) {
485
812
  stack.push(dep_idx);
@@ -548,10 +875,11 @@ pub fn merge_modules(modules: Vec<ResolvedModule>) -> Result<Program, String> {
548
875
  for stmt in &module.program.statements {
549
876
  match stmt {
550
877
  Statement::Import { specifiers, from, span } => {
551
- if is_native_import(from) {
878
+ if is_native_import(from.as_ref()) {
552
879
  // Normalize fs/http/process -> tish:fs etc. for Node compatibility
553
880
  let canonical_spec =
554
- normalize_builtin_spec(from).unwrap_or_else(|| from.to_string());
881
+ normalize_builtin_spec(from.as_ref())
882
+ .unwrap_or_else(|| from.to_string());
555
883
  // Emit VarDecl with NativeModuleLoad for each specifier
556
884
  for spec in specifiers {
557
885
  match spec {
@@ -589,7 +917,7 @@ pub fn merge_modules(modules: Vec<ResolvedModule>) -> Result<Program, String> {
589
917
  }
590
918
  continue;
591
919
  }
592
- let dep_path = resolve_import_path(from, dir, Path::new("."))?;
920
+ let dep_path = resolve_import_path(from.as_ref(), dir, Path::new("."))?;
593
921
  let dep_path = dep_path
594
922
  .canonicalize()
595
923
  .unwrap_or(dep_path);
@@ -682,3 +1010,53 @@ pub fn merge_modules(modules: Vec<ResolvedModule>) -> Result<Program, String> {
682
1010
  }
683
1011
  Ok(Program { statements })
684
1012
  }
1013
+
1014
+ #[cfg(test)]
1015
+ mod cargo_spec_tests {
1016
+ use std::sync::Arc;
1017
+
1018
+ use super::cargo_export_fn_name;
1019
+ use super::is_native_import;
1020
+
1021
+ #[test]
1022
+ fn is_native_import_accepts_arc_str_ref() {
1023
+ let from: &Arc<str> = &Arc::from("cargo:demo_shim");
1024
+ assert!(is_native_import(from));
1025
+ }
1026
+
1027
+ #[test]
1028
+ fn detect_cycles_skips_cargo_import() {
1029
+ use super::{detect_cycles, resolve_project};
1030
+ let dir = tempfile::tempdir().expect("tempdir");
1031
+ let p = dir.path().join("main.tish");
1032
+ let src = "import { greet } from 'cargo:demo_shim'\nconsole.log(1)\n";
1033
+ std::fs::write(&p, src).unwrap();
1034
+ let root = dir.path();
1035
+ let modules = resolve_project(&p, Some(root)).unwrap();
1036
+ detect_cycles(&modules).unwrap();
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn merge_modules_skips_cargo_import() {
1041
+ use super::{merge_modules, resolve_project};
1042
+ let dir = tempfile::tempdir().expect("tempdir");
1043
+ let p = dir.path().join("main.tish");
1044
+ let src = "import { greet } from 'cargo:demo_shim'\nconsole.log(1)\n";
1045
+ std::fs::write(&p, src).unwrap();
1046
+ let root = dir.path();
1047
+ let modules = resolve_project(&p, Some(root)).unwrap();
1048
+ merge_modules(modules).unwrap();
1049
+ }
1050
+
1051
+ #[test]
1052
+ fn cargo_export_fn_name_sanitizes() {
1053
+ assert_eq!(
1054
+ cargo_export_fn_name("cargo:serde_json"),
1055
+ "cargo_native_serde_json_object"
1056
+ );
1057
+ assert_eq!(
1058
+ cargo_export_fn_name("cargo:my-crate"),
1059
+ "cargo_native_my_crate_object"
1060
+ );
1061
+ }
1062
+ }
@@ -33,6 +33,7 @@ fn normalize_builtin_spec(spec: &str) -> Option<String> {
33
33
 
34
34
  fn is_native_import(spec: &str) -> bool {
35
35
  spec.starts_with("tish:")
36
+ || spec.starts_with("cargo:")
36
37
  || spec.starts_with('@')
37
38
  || matches!(spec, "fs" | "http" | "process" | "ws")
38
39
  }
@@ -363,6 +363,7 @@ impl Value {
363
363
  "endsWith".into(),
364
364
  "includes".into(),
365
365
  "indexOf".into(),
366
+ "lastIndexOf".into(),
366
367
  "padEnd".into(),
367
368
  "padStart".into(),
368
369
  "repeat".into(),