@tishlang/tish-lsp 2.43.8 → 3.1.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 (47) hide show
  1. package/bin/tish-lsp +0 -0
  2. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  3. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  4. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  5. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  6. package/crates/tish/tests/scope_merge.rs +31 -0
  7. package/crates/tish_build_utils/src/lib.rs +131 -6
  8. package/crates/tish_builtins/src/globals.rs +27 -0
  9. package/crates/tish_compile/src/codegen.rs +1498 -132
  10. package/crates/tish_compile/src/resolve.rs +65 -9
  11. package/crates/tish_compile/src/schemes.rs +60 -5
  12. package/crates/tish_compile/src/types.rs +64 -32
  13. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  14. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  15. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  16. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  17. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  18. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  19. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  20. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  21. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  22. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  23. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  24. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  25. package/crates/tish_core/src/console_style.rs +4 -0
  26. package/crates/tish_core/src/json.rs +8 -0
  27. package/crates/tish_core/src/shape.rs +28 -3
  28. package/crates/tish_core/src/value.rs +123 -0
  29. package/crates/tish_lsp/Cargo.toml +1 -1
  30. package/crates/tish_native/src/build.rs +4 -0
  31. package/crates/tish_runtime/Cargo.toml +20 -1
  32. package/crates/tish_runtime/src/crypto.rs +127 -0
  33. package/crates/tish_runtime/src/encoding.rs +157 -0
  34. package/crates/tish_runtime/src/fs_ext.rs +27 -0
  35. package/crates/tish_runtime/src/lib.rs +33 -0
  36. package/crates/tish_runtime/src/net.rs +482 -0
  37. package/crates/tish_runtime/src/process_spawn.rs +508 -0
  38. package/crates/tish_runtime/src/ws.rs +206 -23
  39. package/crates/tish_runtime/src/zip.rs +196 -0
  40. package/crates/tish_runtime_gba/src/lib.rs +188 -4
  41. package/crates/tish_vm/src/vm.rs +48 -0
  42. package/package.json +1 -1
  43. package/platform/darwin-arm64/tish-lsp +0 -0
  44. package/platform/darwin-x64/tish-lsp +0 -0
  45. package/platform/linux-arm64/tish-lsp +0 -0
  46. package/platform/linux-x64/tish-lsp +0 -0
  47. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -22,6 +22,11 @@ pub struct ResolvedNativeModule {
22
22
  pub export_fn: String,
23
23
  /// When false, omit `path = …` in the generated Cargo.toml (crate comes from `tish.rustDependencies` only).
24
24
  pub use_path_dependency: bool,
25
+ /// The crate's own SOURCE directory (where its `Cargo.toml`/`src` live), when known — distinct from
26
+ /// `crate_path` (the importing project root). Used to find a `tish.d.tish` the crate ships (typed
27
+ /// externs). For a `cargo:` dep it's the resolved `rustDependencies` path; for a `tish.module`
28
+ /// package it's the package dir; `None` for registry deps with no local path.
29
+ pub source_dir: Option<PathBuf>,
25
30
  }
26
31
 
27
32
  /// How codegen links a native import to Rust (`generateNativeWrapper` for `tish:*`; `cargo:*` always generated).
@@ -39,6 +44,18 @@ pub enum NativeModuleInit {
39
44
  },
40
45
  }
41
46
 
47
+ impl NativeModuleInit {
48
+ /// The Rust crate name whose functions back this module — the path for a direct typed call
49
+ /// (`<crate_name>::name_typed(..)`). Both variants carry it (Legacy directly, Generated as the
50
+ /// shim crate the generated namespace forwards to).
51
+ pub fn crate_name(&self) -> &str {
52
+ match self {
53
+ NativeModuleInit::Legacy { crate_name, .. } => crate_name,
54
+ NativeModuleInit::Generated { shim_crate, .. } => shim_crate,
55
+ }
56
+ }
57
+ }
58
+
42
59
  /// Extra native build inputs produced alongside Rust source (Cargo merge + optional wrapper).
43
60
  #[derive(Debug, Clone)]
44
61
  pub struct NativeBuildArtifacts {
@@ -60,6 +77,10 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
60
77
  ("ws", "tish:ws"),
61
78
  ("tty", "tish:tty"),
62
79
  ("pty", "tish:pty"),
80
+ ("net", "tish:net"),
81
+ ("encoding", "tish:encoding"),
82
+ ("crypto", "tish:crypto"),
83
+ ("zip", "tish:zip"),
63
84
  ];
64
85
 
65
86
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
@@ -89,9 +110,14 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
89
110
  | "tish:ws"
90
111
  | "tish:tty"
91
112
  | "tish:pty"
113
+ | "tish:net"
114
+ | "tish:encoding"
115
+ | "tish:crypto"
116
+ | "tish:zip"
92
117
  ) || matches!(
93
118
  spec,
94
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
119
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
120
+ | "encoding" | "crypto" | "zip"
95
121
  )
96
122
  }
97
123
 
@@ -383,6 +409,17 @@ fn resolve_cargo_native_module(
383
409
  let crate_path = project_root
384
410
  .canonicalize()
385
411
  .unwrap_or_else(|_| project_root.to_path_buf());
412
+ // The crate's own source dir, from a `rustDependencies` `path` (so we can find its `tish.d.tish`).
413
+ let source_dir = rust_deps
414
+ .get(&dep_key)
415
+ .and_then(|v| v.as_object())
416
+ .and_then(|o| o.get("path"))
417
+ .and_then(|p| p.as_str())
418
+ .map(|raw| {
419
+ let p = Path::new(raw);
420
+ let resolved = if p.is_absolute() { p.to_path_buf() } else { project_root.join(p) };
421
+ resolved.canonicalize().unwrap_or(resolved)
422
+ });
386
423
  Ok(ResolvedNativeModule {
387
424
  spec: spec.to_string(),
388
425
  package_name: dep_key.clone(),
@@ -390,6 +427,7 @@ fn resolve_cargo_native_module(
390
427
  crate_path,
391
428
  export_fn,
392
429
  use_path_dependency: false,
430
+ source_dir,
393
431
  })
394
432
  }
395
433
 
@@ -442,9 +480,11 @@ fn resolve_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNati
442
480
  spec: spec.to_string(),
443
481
  package_name: raw_crate.clone(),
444
482
  crate_name: raw_crate.replace('-', "_"),
445
- crate_path,
483
+ crate_path: crate_path.clone(),
446
484
  export_fn,
447
485
  use_path_dependency: true,
486
+ // A tish.module package IS its own source dir — that's where any `tish.d.tish` lives.
487
+ source_dir: Some(crate_path),
448
488
  })
449
489
  }
450
490
 
@@ -998,7 +1038,8 @@ pub fn is_native_import(spec: &str) -> bool {
998
1038
  || spec.starts_with("ffi:")
999
1039
  || matches!(
1000
1040
  spec,
1001
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
1041
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
1042
+ | "crypto" | "zip"
1002
1043
  )
1003
1044
  }
1004
1045
 
@@ -1126,11 +1167,14 @@ pub fn resolve_bare_spec(spec: &str, from_dir: &Path, _project_root: &Path) -> O
1126
1167
  /// directory (`from_dir`) to an absolute path that must exist — a missing file is a compile-time
1127
1168
  /// error, not a device panic — and the spec becomes `prefix:/abs/path` (the codegen feeds that
1128
1169
  /// absolute path to the scheme's include macro; agb's `resolve_path` takes absolute paths as-is, so
1129
- /// no asset-copy step is needed). A non-`resolve_file` scheme passes through unchanged.
1170
+ /// no asset-copy step is needed). A trailing `@N` on the path (e.g. `font:face.ttf@7`) is stripped
1171
+ /// before canonicalize and reattached on the canonical spec so `{size}` is available at emit. A
1172
+ /// bare `font:path` (no `@N`) defaults to [`crate::schemes::FONT_DEFAULT_SIZE`]. A non-`resolve_file`
1173
+ /// scheme passes through unchanged.
1130
1174
  fn resolve_scheme_spec(spec: &str, from_dir: &Path) -> Result<String, String> {
1131
- let (prefix, resolve_file) = crate::schemes::with_active(|reg| {
1175
+ let (scheme_name, prefix, resolve_file) = crate::schemes::with_active(|reg| {
1132
1176
  reg.matches(spec)
1133
- .map(|s| (s.prefix(), s.resolve_file))
1177
+ .map(|s| (s.name.clone(), s.prefix(), s.resolve_file))
1134
1178
  })
1135
1179
  .ok_or_else(|| format!("import '{}' is not a registered scheme", spec))?;
1136
1180
  let rest = spec.strip_prefix(&prefix).unwrap_or(spec);
@@ -1144,7 +1188,8 @@ fn resolve_scheme_spec(spec: &str, from_dir: &Path) -> Result<String, String> {
1144
1188
  prefix
1145
1189
  ));
1146
1190
  }
1147
- let path = Path::new(rest);
1191
+ let (file_rest, size_opt) = crate::schemes::split_path_size(rest);
1192
+ let path = Path::new(file_rest);
1148
1193
  let joined = if path.is_absolute() {
1149
1194
  path.to_path_buf()
1150
1195
  } else {
@@ -1155,12 +1200,23 @@ fn resolve_scheme_spec(spec: &str, from_dir: &Path) -> Result<String, String> {
1155
1200
  "{} import '{}': cannot find '{}' (resolved from {}): {}",
1156
1201
  prefix.trim_end_matches(':'),
1157
1202
  spec,
1158
- rest,
1203
+ file_rest,
1159
1204
  from_dir.display(),
1160
1205
  e
1161
1206
  )
1162
1207
  })?;
1163
- Ok(format!("{}{}", prefix, abs.to_string_lossy()))
1208
+ let mut out = format!("{}{}", prefix, abs.to_string_lossy());
1209
+ // `font:` always carries an explicit size in the canonical spec (explicit @N, or default 16).
1210
+ if scheme_name == "font" {
1211
+ let size = size_opt.unwrap_or(crate::schemes::FONT_DEFAULT_SIZE);
1212
+ out.push('@');
1213
+ out.push_str(&size.to_string());
1214
+ } else if let Some(size) = size_opt {
1215
+ // Other schemes may use `@N` + `{size}` later; preserve when present.
1216
+ out.push('@');
1217
+ out.push_str(&size.to_string());
1218
+ }
1219
+ Ok(out)
1164
1220
  }
1165
1221
 
1166
1222
  /// Resolve an import specifier (e.g. "./foo.tish", "../lib/utils", "lattish") to an absolute path.
@@ -13,7 +13,8 @@
13
13
  //! * `resolve_file` — resolve the rest as a project-relative file and validate it exists.
14
14
  //! * `targets` — per emit-target ("gba", …) code templates: the `use`s + body of a generated
15
15
  //! `mod`, an accessor, and a registration call. Templates substitute `{path}` (the resolved
16
- //! absolute path, as a Rust string literal) and `{mod}` (the generated module's identifier).
16
+ //! absolute path, as a Rust string literal), `{mod}` (the generated module's identifier),
17
+ //! `{charset}` (used non-ASCII chars), and optionally `{size}` (from a trailing `@N` on the path).
17
18
  //!
18
19
  //! Only *file-baking* schemes are expressible this way today — the family `asset:` belongs to
19
20
  //! (resolve a file → emit a macro + a registration + bind an i32 handle). Schemes needing
@@ -235,14 +236,38 @@ pub fn is_scheme_import(spec: &str) -> bool {
235
236
  with_active(|r| r.matches(spec).is_some())
236
237
  }
237
238
 
238
- /// Substitute `{path}` (the file as a Rust string literal), `{mod}` (the generated module ident), and
239
- /// `{charset}` (the program's used non-ASCII characters as a Rust string literal — for schemes that
240
- /// bake only the glyphs a program actually uses, e.g. `font<N>:`) in a template.
239
+ /// Default bake size when `font:path` omits `@N` (most common native grid: monogram/alagard/m3x6).
240
+ pub const FONT_DEFAULT_SIZE: u32 = 16;
241
+
242
+ /// Split a scheme path rest into `(file_path, size)` when it ends with `@` + digits
243
+ /// (`face.ttf@7` → `("face.ttf", Some(7))`). Non-digit suffixes after `@` are left intact
244
+ /// (`foo@2x.png` is not a size).
245
+ pub fn split_path_size(rest: &str) -> (&str, Option<u32>) {
246
+ let Some((path, size_str)) = rest.rsplit_once('@') else {
247
+ return (rest, None);
248
+ };
249
+ if path.is_empty() || size_str.is_empty() || !size_str.bytes().all(|b| b.is_ascii_digit()) {
250
+ return (rest, None);
251
+ }
252
+ match size_str.parse::<u32>() {
253
+ Ok(n) if n > 0 => (path, Some(n)),
254
+ _ => (rest, None),
255
+ }
256
+ }
257
+
258
+ /// Substitute `{path}` (the file as a Rust string literal), `{mod}` (the generated module ident),
259
+ /// `{charset}` (used non-ASCII chars as a Rust string literal), and `{size}` (plain integer from a
260
+ /// trailing `@N` on the path, e.g. `font:face.ttf@7`) in a template. `{path}` never includes `@N`.
241
261
  pub fn render_template(template: &str, abspath: &str, module_ident: &str, charset: &str) -> String {
262
+ let (path, size) = split_path_size(abspath);
263
+ let size_str = size
264
+ .map(|n| n.to_string())
265
+ .unwrap_or_else(|| FONT_DEFAULT_SIZE.to_string());
242
266
  template
243
- .replace("{path}", &format!("{:?}", abspath))
267
+ .replace("{path}", &format!("{:?}", path))
244
268
  .replace("{mod}", module_ident)
245
269
  .replace("{charset}", &format!("{:?}", charset))
270
+ .replace("{size}", &size_str)
246
271
  }
247
272
 
248
273
  #[cfg(test)]
@@ -275,4 +300,34 @@ mod tests {
275
300
  // The same def under a valid name parses.
276
301
  assert!(parse_scheme_def("sheet", &def).is_some());
277
302
  }
303
+
304
+ #[test]
305
+ fn split_path_size_parses_trailing_at_digits() {
306
+ assert_eq!(split_path_size("face.ttf@7"), ("face.ttf", Some(7)));
307
+ assert_eq!(split_path_size("/abs/face.ttf@16"), ("/abs/face.ttf", Some(16)));
308
+ assert_eq!(split_path_size("face.ttf"), ("face.ttf", None));
309
+ // Not a size — non-digit suffix after @.
310
+ assert_eq!(split_path_size("foo@2x.png"), ("foo@2x.png", None));
311
+ assert_eq!(split_path_size("@7"), ("@7", None));
312
+ assert_eq!(split_path_size("face.ttf@0"), ("face.ttf@0", None));
313
+ }
314
+
315
+ #[test]
316
+ fn render_template_substitutes_size_and_strips_at_from_path() {
317
+ let out = render_template(
318
+ "pack!({path}, {size}, {charset}); //{mod}",
319
+ "/abs/face.ttf@7",
320
+ "__scheme_font_0",
321
+ "你好",
322
+ );
323
+ assert!(out.contains("pack!(\"/abs/face.ttf\", 7, \"你好\")"), "got: {out}");
324
+ assert!(out.contains("//__scheme_font_0"));
325
+ assert!(!out.contains("face.ttf@7"));
326
+ }
327
+
328
+ #[test]
329
+ fn render_template_defaults_size_when_missing() {
330
+ let out = render_template("x({size})", "/abs/face.ttf", "m", "");
331
+ assert_eq!(out, format!("x({})", FONT_DEFAULT_SIZE));
332
+ }
278
333
  }
@@ -255,6 +255,17 @@ impl RustType {
255
255
  !matches!(self, RustType::Value)
256
256
  }
257
257
 
258
+ /// Whether the generated Rust type is `Copy` — the native numeric scalars, `Fixed`, and `Unit`.
259
+ /// A `Copy` field can be read out of a borrowed place (`arr[i].field`, `(*cell.borrow()).field`)
260
+ /// by value; a non-`Copy` one (`Value`/`String`/an aggregate) must be `.clone()`d there or it
261
+ /// moves out of the borrow — a use-after-move / cannot-move-out-of-index error (E0382 / E0507).
262
+ pub fn is_copy(&self) -> bool {
263
+ matches!(
264
+ self,
265
+ RustType::F64 | RustType::I32 | RustType::Bool | RustType::Fixed | RustType::Unit
266
+ ) || self.is_narrow_int()
267
+ }
268
+
258
269
  /// Check if this type is numeric (f64).
259
270
  pub fn is_numeric(&self) -> bool {
260
271
  matches!(self, RustType::F64)
@@ -533,11 +544,19 @@ impl RustType {
533
544
  // e.g. an assignment-as-expression `{ let _v = obj; lhs = <coerce _v>; _v }` reuses
534
545
  // `_v` for its value (tishlang/tish#486). `&(expr)` evaluates the source once and
535
546
  // (for a temporary) lifetime-extends it to this block, so it's still single-eval.
547
+ //
548
+ // FAST PATH (GBA/portable): if the source is a BY-REFERENCE boxed struct of exactly this
549
+ // type (`Value::Struct`, e.g. from a `Value::from_struct` return), `downcast_struct`
550
+ // clones it out directly — no per-field `get_prop`. Off-portable there is no struct
551
+ // variant, so `downcast_struct` is a `None` constant the optimiser removes, leaving the
552
+ // rebuild path byte-identical to before.
553
+ let struct_ident = named_struct_ident(name);
536
554
  format!(
537
- "{{ let _src = &({}); {} {{ {} }} }}",
538
- value_expr,
539
- named_struct_ident(name),
540
- field_assigns
555
+ "{{ let _src = &({}); match _src.downcast_struct::<{}>() {{ \
556
+ Some(_s) => _s, \
557
+ None => {} {{ {} }} \
558
+ }} }}",
559
+ value_expr, struct_ident, struct_ident, field_assigns
541
560
  )
542
561
  }
543
562
  RustType::ShapeUnion { .. } => {
@@ -602,34 +621,22 @@ impl RustType {
602
621
  native_expr, inner_to_value
603
622
  )
604
623
  }
605
- RustType::Named { fields, .. } => {
606
- // Build the boxed Value with an ORDERED PropMap via `object_from_pairs` (no
607
- // intermediate `AHashMap`), so key order == field-declaration order == JS
608
- // insertion order. The old `ObjectMap::default()` (`AHashMap`) + insert path
609
- // scrambled key order NON-DETERMINISTICALLY per run (ahash seed) — a shipped
610
- // `JSON.stringify` / `Object.keys` / `for..in` divergence from node whenever a
611
- // native struct crosses back into untyped Tish. This boundary is paid only on
612
- // that crossing (JSON.stringify, calling a Value::Function, etc.); direct
613
- // Rust-to-Rust paths between two Named values stay as plain struct moves.
614
- let pairs = fields
615
- .iter()
616
- .map(|(k, ty)| {
617
- let access = format!("{}.{}", native_expr, field_ident(k));
618
- // A `Value`-typed field (e.g. from a generic struct `Box<T>`) accessed
619
- // behind `&self` must be cloned — it isn't `Copy` and `to_value_expr(Value)`
620
- // is identity. Native field types clone/copy inside their own `to_value_expr`.
621
- let v_expr = if matches!(ty, RustType::Value) {
622
- format!("{}.clone()", access)
623
- } else {
624
- ty.to_value_expr(&access)
625
- };
626
- format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
627
- })
628
- .collect::<Vec<_>>()
629
- .join(", ");
630
- // `object_from_pairs::<N>` builds the `PropMap` in one ordered pass (N = field
631
- // count, a codegen-time constant) — no AHashMap, so key order is preserved.
632
- format!("Value::object_from_pairs([{}])", pairs)
624
+ RustType::Named { .. } => {
625
+ // Box the struct BY SHARED REFERENCE via `Value::from_struct_ref(&(..))`. Portable/GBA
626
+ // keeps it as a cheap `Value::Struct` (one `Rc` — the struct must be OWNED there, so it
627
+ // clones internally). Every other native backend serialises the borrow directly through
628
+ // `TishStruct::tish_to_object` (the ordered `object_from_pairs` boundary — see
629
+ // [`named_struct_to_object_expr`]) with NO whole-struct clone, byte-identical to the
630
+ // historical behaviour that read fields straight off the place (#565: the previous
631
+ // `from_struct((..).clone())` cloned the entire struct just to serialise it off-GBA).
632
+ // Property access on a boxed struct dispatches through the generated `impl TishStruct`
633
+ // (small match, no hash); materialisation is reached only on JSON.stringify / display /
634
+ // `Object.keys`-family / `for..in` / `in` / `Object.assign` / `{...s}` spread. `&(..)`
635
+ // borrows the place codegen already emits here (a value or a deref like `(*gs.borrow())`).
636
+ format!(
637
+ "tishlang_runtime::Value::from_struct_ref(&({}))",
638
+ native_expr
639
+ )
633
640
  }
634
641
  RustType::ShapeUnion { name, variants } => {
635
642
  // union → boxed Value::Object: one match arm per variant, delegating to that
@@ -673,6 +680,31 @@ pub fn named_struct_ident(tish_name: &str) -> String {
673
680
  format!("TishStruct_{}", tish_name)
674
681
  }
675
682
 
683
+ /// Build the `Value::object_from_pairs([...])` that materialises a Named struct at `native_expr` into
684
+ /// an ORDERED boxed object (field-declaration = JS insertion order — deterministic, unlike an AHashMap
685
+ /// insert). This was the body of `to_value_expr`'s `Named` arm; it now backs the generated
686
+ /// `TishStruct::tish_to_object` (the JSON / display / `Object.keys`-family / `for..in` / `in` /
687
+ /// `Object.assign` / `{...s}` spread materialisation, and the non-portable
688
+ /// `Value::from_struct` fallback), while the live boxing path emits the cheap by-ref `Value::from_struct`.
689
+ pub fn named_struct_to_object_expr(fields: &[(Arc<str>, RustType)], native_expr: &str) -> String {
690
+ let pairs = fields
691
+ .iter()
692
+ .map(|(k, ty)| {
693
+ let access = format!("{}.{}", native_expr, field_ident(k));
694
+ // A `Value`-typed field must be cloned (not `Copy`, and `to_value_expr(Value)` is identity);
695
+ // native field types clone/copy inside their own `to_value_expr`.
696
+ let v_expr = if matches!(ty, RustType::Value) {
697
+ format!("{}.clone()", access)
698
+ } else {
699
+ ty.to_value_expr(&access)
700
+ };
701
+ format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
702
+ })
703
+ .collect::<Vec<_>>()
704
+ .join(", ");
705
+ format!("Value::object_from_pairs([{}])", pairs)
706
+ }
707
+
676
708
  /// #179 Stage B: map a shape-union alias to the generated Rust enum identifier.
677
709
  pub fn shape_union_enum_ident(tish_name: &str) -> String {
678
710
  format!("TishUnion_{}", tish_name)
@@ -0,0 +1,40 @@
1
+ //! tishlang/tish#556 — a top-level MUTABLE var captured by a closure becomes a `VmRef` cell; if its
2
+ //! name matches an imported/other function's PARAMETER, a reference to the parameter inside that
3
+ //! function must resolve to the parameter, NOT a `vm_read` of the outer cell (which emits
4
+ //! `vm_read(&param)` — a `&Value` where a `&VmRef` is wanted, so the generated Rust won't compile).
5
+ //! The bug bit the GBA backend; guard both emit modes.
6
+ use std::collections::HashSet;
7
+ use std::path::PathBuf;
8
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
9
+
10
+ fn compile(rel: &str, mode: NativeEmitMode) -> String {
11
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
12
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
13
+ let caps: HashSet<String> = HashSet::new();
14
+ compile_project_full_emit(&path, path.parent(), &[], true, mode, Some(&caps))
15
+ .unwrap()
16
+ .0
17
+ }
18
+
19
+ #[test]
20
+ fn param_shadows_outer_cell_not_read_via_vm_read() {
21
+ let rel = "crates/tish_compile/tests/regression/name_collision_main.tish";
22
+ for mode in [NativeEmitMode::DesktopBin, NativeEmitMode::Gba] {
23
+ let rust = compile(rel, mode);
24
+ // The bug: inside `loadScene`, calling the `scene` PARAMETER (`scene(1)`) compiled the callee
25
+ // as `vm_read(&scene)` — a read of the outer `VmRef` cell, i.e. `&Value` where a `&VmRef` is
26
+ // wanted (won't compile). The param must be used directly instead. (A plain `vm_read(&scene)`
27
+ // still legitimately appears where `bump` mutates the real outer cell, so we target this
28
+ // exact param-as-callee shape.)
29
+ assert!(
30
+ !rust.contains("_callee = (tishlang_runtime::vm_read(&scene))"),
31
+ "#556 ({mode:?}): `loadScene`'s `scene` parameter must shadow the outer cell — a call to \
32
+ it must not read `vm_read(&scene)`\n{rust}"
33
+ );
34
+ // The outer cell itself is still legitimately a real `VmRef` where `bump` mutates it.
35
+ assert!(
36
+ rust.contains("scene.borrow_mut()"),
37
+ "#556 ({mode:?}): expected the outer captured `scene` to remain a real `VmRef` cell\n{rust}"
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,65 @@
1
+ //! tishlang/tish#558 — a typed `Vec<struct>` element field write (`arr[i].field = v`) must lower to
2
+ //! a NATIVE store into the Vec element, not a `set_prop` on a throwaway `Value::object_from_pairs`
3
+ //! copy of the element (which silently discards the write). Regressed the module-level array and the
4
+ //! local array; both are covered here.
5
+ use std::collections::HashSet;
6
+ use std::path::PathBuf;
7
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
8
+
9
+ fn compile(rel: &str) -> String {
10
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
11
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
12
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
13
+ }
14
+
15
+ fn compile_gba(rel: &str) -> String {
16
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
17
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
18
+ let caps: HashSet<String> = HashSet::new();
19
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, Some(&caps))
20
+ .unwrap()
21
+ .0
22
+ }
23
+
24
+ #[test]
25
+ fn gba_struct_write_is_a_fully_native_i32_store() {
26
+ // On the GBA target a `: i32` struct field is a native `i32`, so the write is a single-word store
27
+ // into the Vec element — the whole point of typed struct arrays for hot AI loops. Guard that the
28
+ // perf win is maximal there: no `Value`, no `set_prop`, no per-write allocation.
29
+ let rust = compile_gba("crates/tish_compile/tests/regression/struct_array_write.tish");
30
+ assert!(
31
+ rust.contains("pub x: i32"),
32
+ "expected the `: i32` field to be a native i32 on the GBA target\n{rust}"
33
+ );
34
+ assert!(
35
+ rust.contains("arr[(0_f64) as usize].x = (200_f64) as i32"),
36
+ "#558: expected a fully-native i32 element store on GBA\n{rust}"
37
+ );
38
+ assert!(
39
+ !rust.contains("set_prop(&(Value::object_from_pairs"),
40
+ "#558: the GBA struct write must not box + set_prop on a throwaway copy\n{rust}"
41
+ );
42
+ }
43
+
44
+ #[test]
45
+ fn struct_array_write_is_a_native_store_not_a_discarded_copy() {
46
+ let rust = compile("crates/tish_compile/tests/regression/struct_array_write.tish");
47
+
48
+ // The write must NOT build a throwaway object and set_prop on it — that mutates a copy and
49
+ // discards the store. This exact shape is what the bug emitted.
50
+ assert!(
51
+ !rust.contains("set_prop(&(Value::object_from_pairs"),
52
+ "#558: `arr[i].field = v` must not set_prop on a throwaway object_from_pairs copy\n{rust}"
53
+ );
54
+
55
+ // It must instead be a direct field store into the Vec element (module-level `arr` here).
56
+ assert!(
57
+ rust.contains("arr[(0_f64) as usize].x ="),
58
+ "#558: expected a native `arr[(0_f64) as usize].x = …` store into the Vec element\n{rust}"
59
+ );
60
+ // …and the local array `es` inside the function.
61
+ assert!(
62
+ rust.contains("es[(0_f64) as usize].x ="),
63
+ "#558: expected a native `es[(0_f64) as usize].x = …` store into the local Vec element\n{rust}"
64
+ );
65
+ }
@@ -0,0 +1,21 @@
1
+ //! tishlang/tish#558 residual — a struct-field read from a Vec element in a value/return position must
2
+ //! clone a non-Copy field instead of moving it out of the borrowed element (E0507).
3
+ use std::path::PathBuf;
4
+ use tishlang_compile::compile_project_full;
5
+
6
+ fn compile(rel: &str) -> String {
7
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
8
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
9
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
10
+ }
11
+
12
+ #[test]
13
+ fn struct_field_read_from_vec_element_clones_non_copy() {
14
+ let rust = compile("crates/tish_compile/tests/regression/struct_field_return_read.tish");
15
+ // Off-GBA the `: i32` field is a boxed `Value` (non-Copy); the read must clone it out of the
16
+ // borrowed `es[0]`, not move it.
17
+ assert!(
18
+ rust.contains("es[(0_f64) as usize]).x.clone()"),
19
+ "#558 residual: `es[0].x` read must clone the non-Copy field out of the borrowed element\n{rust}"
20
+ );
21
+ }
@@ -0,0 +1,64 @@
1
+ //! tishlang/tish#558 — a captured (refcell-wrapped) native struct `Vec` must mutate NATIVELY, not fall
2
+ //! to the boxed path. When a struct array is closure-captured-and-mutated it is wrapped in a `VmRef`;
3
+ //! the native fast paths used to bail on that (`!refcell_wrapped_vars`), so `arr[i].field = v` and
4
+ //! `arr.push(x)` fell to the generic boxed path, which cloned+`from_struct`-boxed the WHOLE Vec into a
5
+ //! throwaway `Value::Array` and ran `set_prop`/`array_push` on the discarded copy — O(n) per op, the
6
+ //! write lost, and (for a `Vec<struct>`) an E0282 on the boxing closure since the skipped native op
7
+ //! never pinned the element type. These assert the native `borrow_mut` forms are emitted instead.
8
+
9
+ use std::path::PathBuf;
10
+
11
+ use tishlang_compile::compile_project_full;
12
+
13
+ fn emit(src: &str, name: &str) -> String {
14
+ let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
15
+ std::fs::write(&path, src).unwrap();
16
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
17
+ }
18
+
19
+ // `arr` is captured AND mutated (field-assign) by `update`, so it is refcell-wrapped.
20
+ const WRAPPED_ASSIGN: &str = "\
21
+ interface E { x: number; y: number }
22
+ let arr: E[] = []
23
+ arr.push({ x: 0, y: 0 })
24
+ function update() { arr[0].x = 200 }
25
+ update()
26
+ console.log(arr[0].x)
27
+ ";
28
+
29
+ #[test]
30
+ fn wrapped_struct_element_field_assign_is_a_native_store() {
31
+ let rust = emit(WRAPPED_ASSIGN, "wrapped_assign.tish");
32
+ // Native element field store through the shared cell — not a `set_prop` on a boxed copy.
33
+ assert!(
34
+ rust.contains("borrow_mut())[_i]") || rust.contains(".borrow_mut())[") ,
35
+ "#558: wrapped `arr[i].field = v` must store natively through `(*arr.borrow_mut())[i]`.\n{rust}"
36
+ );
37
+ assert!(
38
+ !rust.contains("set_prop(&(tishlang_runtime::get_index(&Value::Array(VmRef::new"),
39
+ "#558: wrapped struct assign must NOT box the whole Vec and set_prop a throwaway copy.\n{rust}"
40
+ );
41
+ }
42
+
43
+ // `a` is captured AND mutated (push) by `f`, so it is refcell-wrapped.
44
+ const WRAPPED_PUSH: &str = "\
45
+ interface E { x: number; y: number }
46
+ let a: E[] = []
47
+ function f() { a.push({ x: 1, y: 2 }) }
48
+ f()
49
+ console.log(a[0].x)
50
+ ";
51
+
52
+ #[test]
53
+ fn wrapped_struct_push_is_a_native_push() {
54
+ let rust = emit(WRAPPED_PUSH, "wrapped_push.tish");
55
+ // Native push through the shared cell — not a boxed `array_push` on a whole-Vec copy.
56
+ assert!(
57
+ rust.contains("borrow_mut()).push("),
58
+ "#558: wrapped `a.push(struct)` must push natively through `(*a.borrow_mut()).push(..)`.\n{rust}"
59
+ );
60
+ assert!(
61
+ !rust.contains("array_push(&Value::Array(VmRef::new"),
62
+ "#558: wrapped struct push must NOT box the whole Vec into a throwaway array_push.\n{rust}"
63
+ );
64
+ }