@tishlang/tish-format 2.39.0 → 3.0.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 (90) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/src/cli_help.rs +28 -0
  4. package/crates/tish/src/main.rs +41 -5
  5. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  6. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  8. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  9. package/crates/tish/tests/scope_merge.rs +79 -0
  10. package/crates/tish_build_utils/src/lib.rs +27 -1
  11. package/crates/tish_builtins/Cargo.toml +14 -6
  12. package/crates/tish_builtins/src/array.rs +130 -54
  13. package/crates/tish_builtins/src/collections.rs +9 -5
  14. package/crates/tish_builtins/src/construct.rs +7 -1
  15. package/crates/tish_builtins/src/date.rs +15 -1
  16. package/crates/tish_builtins/src/globals.rs +65 -3
  17. package/crates/tish_builtins/src/helpers.rs +7 -3
  18. package/crates/tish_builtins/src/iterator.rs +5 -1
  19. package/crates/tish_builtins/src/lib.rs +3 -0
  20. package/crates/tish_builtins/src/math.rs +14 -0
  21. package/crates/tish_builtins/src/number.rs +72 -6
  22. package/crates/tish_builtins/src/object.rs +6 -1
  23. package/crates/tish_builtins/src/string.rs +13 -2
  24. package/crates/tish_builtins/src/symbol.rs +17 -17
  25. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  26. package/crates/tish_bytecode/src/compiler.rs +76 -2
  27. package/crates/tish_bytecode/src/lib.rs +1 -1
  28. package/crates/tish_bytecode/src/opcode.rs +77 -2
  29. package/crates/tish_compile/src/codegen.rs +2154 -225
  30. package/crates/tish_compile/src/infer.rs +37 -41
  31. package/crates/tish_compile/src/lib.rs +13 -1
  32. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  33. package/crates/tish_compile/src/resolve.rs +195 -11
  34. package/crates/tish_compile/src/schemes.rs +333 -0
  35. package/crates/tish_compile/src/types.rs +205 -34
  36. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  37. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  38. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  39. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  40. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  41. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  42. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  43. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  44. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  45. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  46. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  47. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  48. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  49. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  50. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  51. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  53. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  54. package/crates/tish_core/Cargo.toml +39 -4
  55. package/crates/tish_core/src/compat.rs +506 -0
  56. package/crates/tish_core/src/console_style.rs +23 -1
  57. package/crates/tish_core/src/json.rs +42 -10
  58. package/crates/tish_core/src/lib.rs +121 -25
  59. package/crates/tish_core/src/macros.rs +1 -1
  60. package/crates/tish_core/src/shape.rs +4 -2
  61. package/crates/tish_core/src/uri.rs +8 -1
  62. package/crates/tish_core/src/value.rs +305 -54
  63. package/crates/tish_core/src/vmref.rs +17 -5
  64. package/crates/tish_eval/Cargo.toml +2 -0
  65. package/crates/tish_eval/src/eval.rs +90 -3
  66. package/crates/tish_eval/src/value.rs +8 -0
  67. package/crates/tish_eval/src/value_convert.rs +316 -21
  68. package/crates/tish_fmt/Cargo.toml +1 -1
  69. package/crates/tish_lsp/Cargo.toml +2 -0
  70. package/crates/tish_lsp/src/import_goto.rs +41 -14
  71. package/crates/tish_lsp/src/main.rs +18 -0
  72. package/crates/tish_native/src/build.rs +246 -0
  73. package/crates/tish_native/src/config.rs +20 -0
  74. package/crates/tish_native/src/lib.rs +16 -6
  75. package/crates/tish_parser/src/lib.rs +28 -0
  76. package/crates/tish_parser/src/parser.rs +25 -4
  77. package/crates/tish_runtime/src/lib.rs +55 -9
  78. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  79. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  80. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  81. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  82. package/crates/tish_vm/src/jit.rs +1446 -164
  83. package/crates/tish_vm/src/vm.rs +583 -173
  84. package/justfile +9 -0
  85. package/package.json +1 -1
  86. package/platform/darwin-arm64/tish-fmt +0 -0
  87. package/platform/darwin-x64/tish-fmt +0 -0
  88. package/platform/linux-arm64/tish-fmt +0 -0
  89. package/platform/linux-x64/tish-fmt +0 -0
  90. package/platform/win32-x64/tish-fmt.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 {
@@ -122,6 +139,11 @@ pub fn resolve_native_modules(
122
139
  if is_builtin_native_spec(&s) {
123
140
  continue;
124
141
  }
142
+ // `asset:` loads are baked into the ROM by the GBA codegen, not linked as native
143
+ // modules — they have no crate to resolve and no Value wrapper to generate.
144
+ if crate::schemes::is_scheme_import(&s) {
145
+ continue;
146
+ }
125
147
  if !seen.insert(s.clone()) {
126
148
  continue;
127
149
  }
@@ -378,6 +400,17 @@ fn resolve_cargo_native_module(
378
400
  let crate_path = project_root
379
401
  .canonicalize()
380
402
  .unwrap_or_else(|_| project_root.to_path_buf());
403
+ // The crate's own source dir, from a `rustDependencies` `path` (so we can find its `tish.d.tish`).
404
+ let source_dir = rust_deps
405
+ .get(&dep_key)
406
+ .and_then(|v| v.as_object())
407
+ .and_then(|o| o.get("path"))
408
+ .and_then(|p| p.as_str())
409
+ .map(|raw| {
410
+ let p = Path::new(raw);
411
+ let resolved = if p.is_absolute() { p.to_path_buf() } else { project_root.join(p) };
412
+ resolved.canonicalize().unwrap_or(resolved)
413
+ });
381
414
  Ok(ResolvedNativeModule {
382
415
  spec: spec.to_string(),
383
416
  package_name: dep_key.clone(),
@@ -385,6 +418,7 @@ fn resolve_cargo_native_module(
385
418
  crate_path,
386
419
  export_fn,
387
420
  use_path_dependency: false,
421
+ source_dir,
388
422
  })
389
423
  }
390
424
 
@@ -437,9 +471,11 @@ fn resolve_native_module(spec: &str, project_root: &Path) -> Result<ResolvedNati
437
471
  spec: spec.to_string(),
438
472
  package_name: raw_crate.clone(),
439
473
  crate_name: raw_crate.replace('-', "_"),
440
- crate_path,
474
+ crate_path: crate_path.clone(),
441
475
  export_fn,
442
476
  use_path_dependency: true,
477
+ // A tish.module package IS its own source dir — that's where any `tish.d.tish` lives.
478
+ source_dir: Some(crate_path),
443
479
  })
444
480
  }
445
481
 
@@ -561,7 +597,7 @@ pub fn infer_native_module_exports(program: &Program) -> HashMap<String, HashSet
561
597
  ..
562
598
  } => {
563
599
  let s = spec.as_ref();
564
- if is_builtin_native_spec(s) {
600
+ if is_builtin_native_spec(s) || crate::schemes::is_scheme_import(s) {
565
601
  continue;
566
602
  }
567
603
  map.entry(s.to_string())
@@ -709,6 +745,13 @@ fn find_package_dir(package_name: &str, project_root: &Path) -> Result<PathBuf,
709
745
  {
710
746
  return Ok(node_mod);
711
747
  }
748
+ let apple_crate = search.join("tish-apple").join("crates").join(package_name);
749
+ if apple_crate.join("package.json").exists()
750
+ && read_package_name(&apple_crate.join("package.json"))
751
+ == Some(package_name.to_string())
752
+ {
753
+ return Ok(apple_crate);
754
+ }
712
755
  let sibling = search.join(package_name);
713
756
  if sibling.join("package.json").exists()
714
757
  && read_package_name(&sibling.join("package.json")) == Some(package_name.to_string())
@@ -876,7 +919,7 @@ pub fn resolve_project_from_stdin(
876
919
 
877
920
  for stmt in &program.statements {
878
921
  if let Some(from) = stmt_module_dep(stmt) {
879
- if is_native_import(from.as_ref()) {
922
+ if is_native_import(from.as_ref()) || is_scoped_native_import(from.as_ref(), from_dir) || crate::schemes::is_scheme_import(from.as_ref()) {
880
923
  continue;
881
924
  }
882
925
  let dep_path = resolve_import_path(from.as_ref(), from_dir, &root_canon)?;
@@ -944,7 +987,7 @@ fn load_module_recursive(
944
987
  let dir = canonical.parent().unwrap_or(Path::new("."));
945
988
  for stmt in &program.statements {
946
989
  if let Some(from) = stmt_module_dep(stmt) {
947
- if is_native_import(from.as_ref()) {
990
+ if is_native_import(from.as_ref()) || is_scoped_native_import(from.as_ref(), dir) || crate::schemes::is_scheme_import(from.as_ref()) {
948
991
  continue; // Native imports don't load files
949
992
  }
950
993
  let dep_path = resolve_import_path(from.as_ref(), dir, project_root)?;
@@ -971,7 +1014,13 @@ fn load_module_recursive(
971
1014
  /// - cargo:… (Cargo `rustDependencies` + generated wrapper; Rust native backend)
972
1015
  /// - ffi:… (a C-ABI cdylib loaded via `tish_ffi::load_module` — portable across backends)
973
1016
  ///
974
- /// Scoped npm packages (`@scope/pkg`) are merged as Tish source unless imported via `tish:…`.
1017
+ /// Scoped npm packages (`@scope/pkg`) are **merged as Tish source** by default — they are NOT
1018
+ /// native imports and fall through to the module-merge resolver (`exports.tish` / `tish.module`
1019
+ /// path / `tish.source` / `index.tish`). A scoped package is only treated as a native Rust crate
1020
+ /// when it opts in via `tish.crate` / `tish.rustDependencies` in its package.json (the tish-apple
1021
+ /// crates). This split is why `@scope` is intentionally absent below (#38): matching the ecosystem's
1022
+ /// resolution for `import … from "@scope/pkg"` is interop, and the native-vs-merged axis is the one
1023
+ /// piece tish adds on top, keyed off the native markers rather than mere presence of `tish.module`.
975
1024
  pub fn is_native_import(spec: &str) -> bool {
976
1025
  // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
977
1026
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
@@ -984,6 +1033,39 @@ pub fn is_native_import(spec: &str) -> bool {
984
1033
  )
985
1034
  }
986
1035
 
1036
+ // Import schemes (`asset:` and any project-declared prefix) are recognized via the active
1037
+ // [`crate::schemes`] registry — `crate::schemes::is_scheme_import(spec)`. Like native imports, a
1038
+ // scheme spec is NOT resolved as a `.tish` module or npm package; unlike them it links no crate and
1039
+ // generates no Value wrapper — the codegen injects the scheme's include macro and binds each name to
1040
+ // an i32 handle. See [`resolve_scheme_spec`] for the file-resolution half.
1041
+
1042
+ /// The `@scope/pkg` native opt-in described above (#38): true only when the package resolves and
1043
+ /// its package.json carries the native markers — `tish.module == true` (the native-module flag, a
1044
+ /// bool; a string is a merged-source entry path) AND `tish.crate` / `tish.rustDependencies` (the
1045
+ /// Rust-crate opt-in). Anything else — unresolvable, plain `tish.module: true` with no crate
1046
+ /// (#38), or a `.tish` source package like `@tishlang/pg` — falls through to the module-merge
1047
+ /// resolver. Package resolution walks up from `from_dir` (the importing module's directory), the
1048
+ /// same walk [`resolve_native_module`] later repeats from the project root.
1049
+ pub fn is_scoped_native_import(spec: &str, from_dir: &Path) -> bool {
1050
+ if !spec.starts_with('@') {
1051
+ return false;
1052
+ }
1053
+ let Ok(pkg_dir) = find_package_dir(spec, from_dir) else {
1054
+ return false;
1055
+ };
1056
+ let Ok(content) = std::fs::read_to_string(pkg_dir.join("package.json")) else {
1057
+ return false;
1058
+ };
1059
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1060
+ return false;
1061
+ };
1062
+ let Some(tish) = json.get("tish") else {
1063
+ return false;
1064
+ };
1065
+ tish.get("module").and_then(|v| v.as_bool()).unwrap_or(false)
1066
+ && (tish.get("crate").is_some() || tish.get("rustDependencies").is_some())
1067
+ }
1068
+
987
1069
  /// True for `ffi:…` specs (portable C-ABI cdylib extensions, loadable on every backend). The
988
1070
  /// path after `ffi:` is resolved relative to the importing program and loaded with
989
1071
  /// `tish_ffi::load_module`. Unlike `cargo:` (rust-AOT only), `ffi:` is allowed everywhere.
@@ -1070,6 +1152,63 @@ pub fn resolve_bare_spec(spec: &str, from_dir: &Path, _project_root: &Path) -> O
1070
1152
  None
1071
1153
  }
1072
1154
 
1155
+ /// Resolve a scheme import (`asset:relpath`, or any project-declared scheme) to a canonical spec.
1156
+ /// For a `resolve_file` scheme, the part after the colon is resolved against the importing module's
1157
+ /// directory (`from_dir`) to an absolute path that must exist — a missing file is a compile-time
1158
+ /// error, not a device panic — and the spec becomes `prefix:/abs/path` (the codegen feeds that
1159
+ /// absolute path to the scheme's include macro; agb's `resolve_path` takes absolute paths as-is, so
1160
+ /// no asset-copy step is needed). A trailing `@N` on the path (e.g. `font:face.ttf@7`) is stripped
1161
+ /// before canonicalize and reattached on the canonical spec so `{size}` is available at emit. A
1162
+ /// bare `font:path` (no `@N`) defaults to [`crate::schemes::FONT_DEFAULT_SIZE`]. A non-`resolve_file`
1163
+ /// scheme passes through unchanged.
1164
+ fn resolve_scheme_spec(spec: &str, from_dir: &Path) -> Result<String, String> {
1165
+ let (scheme_name, prefix, resolve_file) = crate::schemes::with_active(|reg| {
1166
+ reg.matches(spec)
1167
+ .map(|s| (s.name.clone(), s.prefix(), s.resolve_file))
1168
+ })
1169
+ .ok_or_else(|| format!("import '{}' is not a registered scheme", spec))?;
1170
+ let rest = spec.strip_prefix(&prefix).unwrap_or(spec);
1171
+ if !resolve_file {
1172
+ return Ok(spec.to_string());
1173
+ }
1174
+ if rest.is_empty() {
1175
+ return Err(format!(
1176
+ "{} import needs a path, e.g. import {{ player }} from '{}gfx/player.png'",
1177
+ prefix.trim_end_matches(':'),
1178
+ prefix
1179
+ ));
1180
+ }
1181
+ let (file_rest, size_opt) = crate::schemes::split_path_size(rest);
1182
+ let path = Path::new(file_rest);
1183
+ let joined = if path.is_absolute() {
1184
+ path.to_path_buf()
1185
+ } else {
1186
+ from_dir.join(path)
1187
+ };
1188
+ let abs = joined.canonicalize().map_err(|e| {
1189
+ format!(
1190
+ "{} import '{}': cannot find '{}' (resolved from {}): {}",
1191
+ prefix.trim_end_matches(':'),
1192
+ spec,
1193
+ file_rest,
1194
+ from_dir.display(),
1195
+ e
1196
+ )
1197
+ })?;
1198
+ let mut out = format!("{}{}", prefix, abs.to_string_lossy());
1199
+ // `font:` always carries an explicit size in the canonical spec (explicit @N, or default 16).
1200
+ if scheme_name == "font" {
1201
+ let size = size_opt.unwrap_or(crate::schemes::FONT_DEFAULT_SIZE);
1202
+ out.push('@');
1203
+ out.push_str(&size.to_string());
1204
+ } else if let Some(size) = size_opt {
1205
+ // Other schemes may use `@N` + `{size}` later; preserve when present.
1206
+ out.push('@');
1207
+ out.push_str(&size.to_string());
1208
+ }
1209
+ Ok(out)
1210
+ }
1211
+
1073
1212
  /// Resolve an import specifier (e.g. "./foo.tish", "../lib/utils", "lattish") to an absolute path.
1074
1213
  fn resolve_import_path(
1075
1214
  spec: &str,
@@ -1091,8 +1230,12 @@ fn resolve_import_path(
1091
1230
  spec, spec
1092
1231
  ));
1093
1232
  }
1233
+ let ctx = crate::platform_resolve::resolve_context();
1234
+ if let Some(path) = crate::platform_resolve::resolve_with_platform(spec, from_dir, ctx) {
1235
+ return Ok(path);
1236
+ }
1094
1237
  let base = from_dir.join(spec);
1095
- // Try with .tish extension if the path has no extension
1238
+ // Fallback: legacy single .tish probe when platform context is empty / file missing
1096
1239
  let path = if base.extension().is_none() {
1097
1240
  let with_ext = base.with_extension("tish");
1098
1241
  if with_ext.exists() {
@@ -1155,7 +1298,7 @@ fn has_cycle_from(
1155
1298
  ) -> Result<bool, String> {
1156
1299
  for stmt in &program.statements {
1157
1300
  if let Some(from) = stmt_module_dep(stmt) {
1158
- if is_native_import(from.as_ref()) {
1301
+ if is_native_import(from.as_ref()) || is_scoped_native_import(from.as_ref(), from_dir) || crate::schemes::is_scheme_import(from.as_ref()) {
1159
1302
  continue;
1160
1303
  }
1161
1304
  let dep_path = resolve_import_path(from.as_ref(), from_dir, Path::new("."))?;
@@ -1837,10 +1980,20 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1837
1980
  from,
1838
1981
  span,
1839
1982
  } => {
1840
- if is_native_import(from.as_ref()) {
1841
- // Normalize fs/http/process -> tish:fs etc. for Node compatibility
1842
- let canonical_spec = normalize_builtin_spec(from.as_ref())
1843
- .unwrap_or_else(|| from.to_string());
1983
+ if is_native_import(from.as_ref())
1984
+ || is_scoped_native_import(from.as_ref(), dir)
1985
+ || crate::schemes::is_scheme_import(from.as_ref())
1986
+ {
1987
+ // Normalize fs/http/process -> tish:fs etc. for Node compatibility.
1988
+ // For `asset:` imports, resolve the (relative) file path to an absolute
1989
+ // one now, while we still know the importing module's directory — the GBA
1990
+ // codegen emits `include_aseprite_inner!("<abspath>")` from that spec.
1991
+ let canonical_spec = if crate::schemes::is_scheme_import(from.as_ref()) {
1992
+ resolve_scheme_spec(from.as_ref(), dir)?
1993
+ } else {
1994
+ normalize_builtin_spec(from.as_ref())
1995
+ .unwrap_or_else(|| from.to_string())
1996
+ };
1844
1997
  // Emit VarDecl with NativeModuleLoad for each specifier
1845
1998
  for spec in specifiers {
1846
1999
  match spec {
@@ -2090,3 +2243,34 @@ mod cargo_spec_tests {
2090
2243
  );
2091
2244
  }
2092
2245
  }
2246
+
2247
+ #[cfg(test)]
2248
+ mod find_package_dir_tests {
2249
+ use super::find_package_dir;
2250
+
2251
+ #[test]
2252
+ fn prefers_tish_apple_crates_over_sibling() {
2253
+ let dir = tempfile::tempdir().expect("tempdir");
2254
+ let root = dir.path();
2255
+ // sibling decoy
2256
+ let sibling = root.join("tish-macos");
2257
+ std::fs::create_dir_all(&sibling).unwrap();
2258
+ std::fs::write(
2259
+ sibling.join("package.json"),
2260
+ r#"{"name":"tish-macos","tish":{"module":true,"crate":"tishlang_macos","export":"macos_object"}}"#,
2261
+ )
2262
+ .unwrap();
2263
+ // preferred apple path
2264
+ let apple = root.join("tish-apple").join("crates").join("tish-macos");
2265
+ std::fs::create_dir_all(&apple).unwrap();
2266
+ std::fs::write(
2267
+ apple.join("package.json"),
2268
+ r#"{"name":"tish-macos","tish":{"module":true,"crate":"tish-macos","export":"macos_object"}}"#,
2269
+ )
2270
+ .unwrap();
2271
+ let project = root.join("app");
2272
+ std::fs::create_dir_all(&project).unwrap();
2273
+ let hit = find_package_dir("tish-macos", &project).expect("resolve");
2274
+ assert_eq!(hit.canonicalize().unwrap(), apple.canonicalize().unwrap());
2275
+ }
2276
+ }
@@ -0,0 +1,333 @@
1
+ //! Import-scheme registry — the extension seam for custom import prefixes.
2
+ //!
3
+ //! An *import scheme* is a prefix like `asset:` that means "don't resolve this as a `.tish` module
4
+ //! or npm package; instead resolve it my way and inject my code." tish core ships **no** built-in
5
+ //! schemes — it is target-agnostic (see [`SchemeRegistry::builtin`]). Schemes are contributed by
6
+ //! cargo path-dependencies that ship a `tish.schemes.json` (this is how `tish-agb` provides
7
+ //! `asset:`/`sheet:`/`background:`/`wav:`/`map:` for free to any game that depends on it) and by a
8
+ //! project's own `package.json` → `tish.schemes`, with no changes to the compiler. This is the same
9
+ //! philosophy as `cargo:` imports (config-declared, zero core edits per crate), generalized.
10
+ //!
11
+ //! A scheme is described declaratively:
12
+ //! * `prefix` — the scheme name (`asset` → matches `asset:...`).
13
+ //! * `resolve_file` — resolve the rest as a project-relative file and validate it exists.
14
+ //! * `targets` — per emit-target ("gba", …) code templates: the `use`s + body of a generated
15
+ //! `mod`, an accessor, and a registration call. Templates substitute `{path}` (the resolved
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).
18
+ //!
19
+ //! Only *file-baking* schemes are expressible this way today — the family `asset:` belongs to
20
+ //! (resolve a file → emit a macro + a registration + bind an i32 handle). Schemes needing
21
+ //! compile-time logic (e.g. parsing aseprite tags) will want a code-plugin surface later.
22
+ //!
23
+ //! The active registry is compile-scoped: [`set_active`] installs it at the start of a build and
24
+ //! the resolve/codegen predicates read it via [`with_active`]. It defaults to [`builtin`] so any
25
+ //! path that doesn't install one still sees the built-in schemes (matching prior behaviour).
26
+
27
+ use std::cell::RefCell;
28
+ use std::collections::HashMap;
29
+ use std::path::Path;
30
+
31
+ /// What an imported name binds to. Only `Handle` (an `i32` index into a runtime arena) exists
32
+ /// today; kept as an enum so richer bindings (typed structs, const data) can be added later.
33
+ #[derive(Clone, Debug, PartialEq)]
34
+ pub enum BindKind {
35
+ /// The imported name is an `i32` handle — its index among the scheme's registered files.
36
+ Handle,
37
+ }
38
+
39
+ /// One emit target's code templates for a scheme (e.g. the `gba` recipe for `asset:`).
40
+ #[derive(Clone, Debug)]
41
+ pub struct TargetEmit {
42
+ /// `use` lines placed at the top of the generated `mod`.
43
+ pub uses: Vec<String>,
44
+ /// The module body — typically the include macro. `{path}` → the file as a Rust string literal.
45
+ pub module_body: String,
46
+ /// A public accessor emitted inside the same `mod` (so it can see the macro's private statics).
47
+ pub accessor: String,
48
+ /// Registration call emitted in the entry before `run()`. `{mod}` → the generated module ident.
49
+ pub register: String,
50
+ /// What each imported name binds to.
51
+ pub bind: BindKind,
52
+ }
53
+
54
+ /// A declaratively-described import scheme.
55
+ #[derive(Clone, Debug)]
56
+ pub struct SchemeDef {
57
+ /// The prefix WITHOUT the trailing colon (`asset`); matches specs starting `asset:`.
58
+ pub name: String,
59
+ /// Resolve the part after the colon as a project-relative file (canonicalize + validate exists).
60
+ pub resolve_file: bool,
61
+ /// Per emit-target code templates.
62
+ pub targets: HashMap<String, TargetEmit>,
63
+ }
64
+
65
+ impl SchemeDef {
66
+ /// The spec prefix including the colon (`asset:`).
67
+ pub fn prefix(&self) -> String {
68
+ format!("{}:", self.name)
69
+ }
70
+ }
71
+
72
+ /// The set of active import schemes for a build.
73
+ #[derive(Clone, Debug, Default)]
74
+ pub struct SchemeRegistry {
75
+ pub schemes: Vec<SchemeDef>,
76
+ }
77
+
78
+ impl SchemeRegistry {
79
+ /// The schemes tish core ships with — **none**. tish core is target-agnostic and knows nothing
80
+ /// about agb/GBA; concrete schemes (like `asset:`) are contributed by the crates that implement
81
+ /// their runtime (see [`from_project`](Self::from_project)). Kept as the seam for any future
82
+ /// genuinely-generic built-in scheme.
83
+ pub fn builtin() -> Self {
84
+ SchemeRegistry {
85
+ schemes: Vec::new(),
86
+ }
87
+ }
88
+
89
+ /// The active registry for a build: schemes contributed by cargo dependencies, then the
90
+ /// project's own, merged in that order (project overrides a dependency of the same name).
91
+ ///
92
+ /// * **Dependency-contributed** — each `tish.rustDependencies` entry with a `path` may ship a
93
+ /// `tish.schemes.json` (`{ "schemes": { … } }`) in its crate dir. This is how tish-agb
94
+ /// provides `asset:` without any tish-core edits: a game that depends on tish-agb (as every
95
+ /// sprite game already does) gets the scheme for free, no per-game config.
96
+ /// * **Project-local** — `package.json` → `tish.schemes`, for one-off or overriding schemes.
97
+ ///
98
+ /// A malformed entry is skipped, not fatal.
99
+ pub fn from_project(project_root: &Path) -> Self {
100
+ let mut reg = Self::builtin();
101
+ let tish = crate::resolve::read_project_tish_config(project_root);
102
+ // 1. Schemes contributed by cargo path-dependencies.
103
+ if let Some(deps) = tish.get("rustDependencies").and_then(|v| v.as_object()) {
104
+ for val in deps.values() {
105
+ let Some(path) = val
106
+ .as_object()
107
+ .and_then(|o| o.get("path"))
108
+ .and_then(|p| p.as_str())
109
+ else {
110
+ continue;
111
+ };
112
+ let p = Path::new(path);
113
+ let dep_dir = if p.is_absolute() {
114
+ p.to_path_buf()
115
+ } else {
116
+ project_root.join(p)
117
+ };
118
+ let manifest = dep_dir.join("tish.schemes.json");
119
+ if let Ok(content) = std::fs::read_to_string(&manifest) {
120
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
121
+ reg.merge_schemes(json.get("schemes"));
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // 2. Project-local schemes (override dependency-contributed ones of the same name).
127
+ reg.merge_schemes(tish.get("schemes"));
128
+ reg
129
+ }
130
+
131
+ /// Merge a JSON `{ name: def, … }` schemes object into the registry; a same-named scheme
132
+ /// replaces the existing one.
133
+ fn merge_schemes(&mut self, schemes: Option<&serde_json::Value>) {
134
+ let Some(obj) = schemes.and_then(|v| v.as_object()) else {
135
+ return;
136
+ };
137
+ for (name, def) in obj {
138
+ if let Some(parsed) = parse_scheme_def(name, def) {
139
+ self.schemes.retain(|s| s.name != parsed.name);
140
+ self.schemes.push(parsed);
141
+ }
142
+ }
143
+ }
144
+
145
+ /// The scheme whose prefix `spec` starts with, if any.
146
+ pub fn matches(&self, spec: &str) -> Option<&SchemeDef> {
147
+ self.schemes.iter().find(|s| spec.starts_with(&s.prefix()))
148
+ }
149
+ }
150
+
151
+ /// Parse one `tish.schemes["name"]` JSON entry into a [`SchemeDef`]. Returns `None` (skip) on a
152
+ /// shape that doesn't describe a valid scheme.
153
+ fn parse_scheme_def(name: &str, def: &serde_json::Value) -> Option<SchemeDef> {
154
+ // The name is interpolated into a generated module identifier (`mod __scheme_<name>_<j>`) and
155
+ // used as the `<name>:` import prefix, so it must be a valid Rust identifier. A name like
156
+ // `sprite-sheet` would emit un-compilable Rust (a raw rustc error far from its cause); skip it
157
+ // instead, consistent with "a malformed entry is skipped, not fatal".
158
+ if !is_valid_scheme_name(name) {
159
+ return None;
160
+ }
161
+ let obj = def.as_object()?;
162
+ let resolve_file = obj.get("file").and_then(|v| v.as_bool()).unwrap_or(true);
163
+ let mut targets = HashMap::new();
164
+ if let Some(target_map) = obj.get("targets").and_then(|v| v.as_object()) {
165
+ for (target, spec) in target_map {
166
+ let s = spec.as_object()?;
167
+ let uses = s
168
+ .get("uses")
169
+ .and_then(|v| v.as_array())
170
+ .map(|a| {
171
+ a.iter()
172
+ .filter_map(|x| x.as_str().map(|s| s.to_string()))
173
+ .collect()
174
+ })
175
+ .unwrap_or_default();
176
+ let module_body = s.get("module").and_then(|v| v.as_str())?.to_string();
177
+ let accessor = s
178
+ .get("accessor")
179
+ .and_then(|v| v.as_str())
180
+ .unwrap_or("")
181
+ .to_string();
182
+ let register = s
183
+ .get("register")
184
+ .and_then(|v| v.as_str())
185
+ .unwrap_or("")
186
+ .to_string();
187
+ targets.insert(
188
+ target.clone(),
189
+ TargetEmit {
190
+ uses,
191
+ module_body,
192
+ accessor,
193
+ register,
194
+ bind: BindKind::Handle,
195
+ },
196
+ );
197
+ }
198
+ }
199
+ Some(SchemeDef {
200
+ name: name.to_string(),
201
+ resolve_file,
202
+ targets,
203
+ })
204
+ }
205
+
206
+ /// A scheme name must be a valid Rust identifier: it becomes part of a generated module ident
207
+ /// (`__scheme_<name>_<j>`) and the `<name>:` import prefix. ASCII letter/underscore, then
208
+ /// letters/digits/underscores. (The same shape `types::is_struct_field_safe` enforces for native
209
+ /// struct field keys, kept local so `schemes` stays self-contained.)
210
+ fn is_valid_scheme_name(name: &str) -> bool {
211
+ let mut chars = name.chars();
212
+ match chars.next() {
213
+ Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
214
+ _ => return false,
215
+ }
216
+ chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
217
+ }
218
+
219
+ thread_local! {
220
+ static ACTIVE: RefCell<SchemeRegistry> = RefCell::new(SchemeRegistry::builtin());
221
+ }
222
+
223
+ /// Install the registry for the current build. Call once at the start of a compile, before
224
+ /// resolution — every scheme predicate reads whatever is installed here.
225
+ pub fn set_active(registry: SchemeRegistry) {
226
+ ACTIVE.with(|c| *c.borrow_mut() = registry);
227
+ }
228
+
229
+ /// Read from the active registry.
230
+ pub fn with_active<R>(f: impl FnOnce(&SchemeRegistry) -> R) -> R {
231
+ ACTIVE.with(|c| f(&c.borrow()))
232
+ }
233
+
234
+ /// True if `spec` is a registered import scheme (built-in or project-declared).
235
+ pub fn is_scheme_import(spec: &str) -> bool {
236
+ with_active(|r| r.matches(spec).is_some())
237
+ }
238
+
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`.
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());
266
+ template
267
+ .replace("{path}", &format!("{:?}", path))
268
+ .replace("{mod}", module_ident)
269
+ .replace("{charset}", &format!("{:?}", charset))
270
+ .replace("{size}", &size_str)
271
+ }
272
+
273
+ #[cfg(test)]
274
+ mod tests {
275
+ use super::*;
276
+
277
+ #[test]
278
+ fn scheme_name_must_be_a_rust_identifier() {
279
+ // Valid identifiers pass.
280
+ assert!(is_valid_scheme_name("asset"));
281
+ assert!(is_valid_scheme_name("sprite_sheet"));
282
+ assert!(is_valid_scheme_name("_x"));
283
+ assert!(is_valid_scheme_name("bg2"));
284
+ // Anything that would break the generated `mod __scheme_<name>_<j>` is rejected.
285
+ assert!(!is_valid_scheme_name("sprite-sheet")); // hyphen
286
+ assert!(!is_valid_scheme_name("")); // empty
287
+ assert!(!is_valid_scheme_name("2d")); // leading digit
288
+ assert!(!is_valid_scheme_name("a:b")); // colon
289
+ assert!(!is_valid_scheme_name("a b")); // space
290
+ }
291
+
292
+ #[test]
293
+ fn parse_scheme_def_skips_invalid_name() {
294
+ let def = serde_json::json!({
295
+ "file": true,
296
+ "targets": { "gba": { "module": "// x", "register": "" } }
297
+ });
298
+ // A hyphenated name is skipped (returns None) rather than emitting un-compilable Rust.
299
+ assert!(parse_scheme_def("sprite-sheet", &def).is_none());
300
+ // The same def under a valid name parses.
301
+ assert!(parse_scheme_def("sheet", &def).is_some());
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
+ }
333
+ }