@tishlang/tish-lsp 2.38.0 → 2.43.8

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 (75) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-lsp +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/scope_merge.rs +79 -0
  6. package/crates/tish_build_utils/src/lib.rs +27 -1
  7. package/crates/tish_builtins/Cargo.toml +14 -6
  8. package/crates/tish_builtins/src/array.rs +130 -54
  9. package/crates/tish_builtins/src/collections.rs +9 -5
  10. package/crates/tish_builtins/src/construct.rs +7 -1
  11. package/crates/tish_builtins/src/date.rs +15 -1
  12. package/crates/tish_builtins/src/globals.rs +38 -3
  13. package/crates/tish_builtins/src/helpers.rs +7 -3
  14. package/crates/tish_builtins/src/iterator.rs +5 -1
  15. package/crates/tish_builtins/src/lib.rs +3 -0
  16. package/crates/tish_builtins/src/math.rs +14 -0
  17. package/crates/tish_builtins/src/number.rs +72 -6
  18. package/crates/tish_builtins/src/object.rs +6 -1
  19. package/crates/tish_builtins/src/string.rs +13 -2
  20. package/crates/tish_builtins/src/symbol.rs +17 -17
  21. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  22. package/crates/tish_bytecode/src/compiler.rs +76 -2
  23. package/crates/tish_bytecode/src/lib.rs +1 -1
  24. package/crates/tish_bytecode/src/opcode.rs +77 -2
  25. package/crates/tish_compile/src/codegen.rs +812 -47
  26. package/crates/tish_compile/src/infer.rs +37 -41
  27. package/crates/tish_compile/src/lib.rs +13 -1
  28. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  29. package/crates/tish_compile/src/resolve.rs +148 -10
  30. package/crates/tish_compile/src/schemes.rs +278 -0
  31. package/crates/tish_compile/src/types.rs +141 -2
  32. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  33. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  34. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  35. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  36. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  37. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  38. package/crates/tish_core/Cargo.toml +39 -4
  39. package/crates/tish_core/src/compat.rs +506 -0
  40. package/crates/tish_core/src/console_style.rs +19 -1
  41. package/crates/tish_core/src/json.rs +34 -10
  42. package/crates/tish_core/src/lib.rs +121 -25
  43. package/crates/tish_core/src/macros.rs +1 -1
  44. package/crates/tish_core/src/shape.rs +4 -2
  45. package/crates/tish_core/src/uri.rs +8 -1
  46. package/crates/tish_core/src/value.rs +182 -54
  47. package/crates/tish_core/src/vmref.rs +17 -5
  48. package/crates/tish_eval/Cargo.toml +2 -0
  49. package/crates/tish_eval/src/eval.rs +90 -3
  50. package/crates/tish_eval/src/value.rs +8 -0
  51. package/crates/tish_eval/src/value_convert.rs +316 -21
  52. package/crates/tish_lsp/Cargo.toml +3 -1
  53. package/crates/tish_lsp/src/import_goto.rs +41 -14
  54. package/crates/tish_lsp/src/main.rs +18 -0
  55. package/crates/tish_native/src/build.rs +246 -0
  56. package/crates/tish_native/src/config.rs +20 -0
  57. package/crates/tish_native/src/lib.rs +16 -6
  58. package/crates/tish_parser/src/lib.rs +28 -0
  59. package/crates/tish_parser/src/parser.rs +25 -4
  60. package/crates/tish_runtime/src/http_hyper.rs +70 -0
  61. package/crates/tish_runtime/src/lib.rs +55 -10
  62. package/crates/tish_runtime/src/ws.rs +94 -21
  63. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  64. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  65. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  66. package/crates/tish_runtime_gba/src/lib.rs +620 -0
  67. package/crates/tish_vm/src/jit.rs +1446 -164
  68. package/crates/tish_vm/src/vm.rs +535 -173
  69. package/justfile +9 -0
  70. package/package.json +1 -1
  71. package/platform/darwin-arm64/tish-lsp +0 -0
  72. package/platform/darwin-x64/tish-lsp +0 -0
  73. package/platform/linux-arm64/tish-lsp +0 -0
  74. package/platform/linux-x64/tish-lsp +0 -0
  75. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -122,6 +122,11 @@ pub fn resolve_native_modules(
122
122
  if is_builtin_native_spec(&s) {
123
123
  continue;
124
124
  }
125
+ // `asset:` loads are baked into the ROM by the GBA codegen, not linked as native
126
+ // modules — they have no crate to resolve and no Value wrapper to generate.
127
+ if crate::schemes::is_scheme_import(&s) {
128
+ continue;
129
+ }
125
130
  if !seen.insert(s.clone()) {
126
131
  continue;
127
132
  }
@@ -561,7 +566,7 @@ pub fn infer_native_module_exports(program: &Program) -> HashMap<String, HashSet
561
566
  ..
562
567
  } => {
563
568
  let s = spec.as_ref();
564
- if is_builtin_native_spec(s) {
569
+ if is_builtin_native_spec(s) || crate::schemes::is_scheme_import(s) {
565
570
  continue;
566
571
  }
567
572
  map.entry(s.to_string())
@@ -709,6 +714,13 @@ fn find_package_dir(package_name: &str, project_root: &Path) -> Result<PathBuf,
709
714
  {
710
715
  return Ok(node_mod);
711
716
  }
717
+ let apple_crate = search.join("tish-apple").join("crates").join(package_name);
718
+ if apple_crate.join("package.json").exists()
719
+ && read_package_name(&apple_crate.join("package.json"))
720
+ == Some(package_name.to_string())
721
+ {
722
+ return Ok(apple_crate);
723
+ }
712
724
  let sibling = search.join(package_name);
713
725
  if sibling.join("package.json").exists()
714
726
  && read_package_name(&sibling.join("package.json")) == Some(package_name.to_string())
@@ -876,7 +888,7 @@ pub fn resolve_project_from_stdin(
876
888
 
877
889
  for stmt in &program.statements {
878
890
  if let Some(from) = stmt_module_dep(stmt) {
879
- if is_native_import(from.as_ref()) {
891
+ 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
892
  continue;
881
893
  }
882
894
  let dep_path = resolve_import_path(from.as_ref(), from_dir, &root_canon)?;
@@ -944,7 +956,7 @@ fn load_module_recursive(
944
956
  let dir = canonical.parent().unwrap_or(Path::new("."));
945
957
  for stmt in &program.statements {
946
958
  if let Some(from) = stmt_module_dep(stmt) {
947
- if is_native_import(from.as_ref()) {
959
+ if is_native_import(from.as_ref()) || is_scoped_native_import(from.as_ref(), dir) || crate::schemes::is_scheme_import(from.as_ref()) {
948
960
  continue; // Native imports don't load files
949
961
  }
950
962
  let dep_path = resolve_import_path(from.as_ref(), dir, project_root)?;
@@ -971,7 +983,13 @@ fn load_module_recursive(
971
983
  /// - cargo:… (Cargo `rustDependencies` + generated wrapper; Rust native backend)
972
984
  /// - ffi:… (a C-ABI cdylib loaded via `tish_ffi::load_module` — portable across backends)
973
985
  ///
974
- /// Scoped npm packages (`@scope/pkg`) are merged as Tish source unless imported via `tish:…`.
986
+ /// Scoped npm packages (`@scope/pkg`) are **merged as Tish source** by default — they are NOT
987
+ /// native imports and fall through to the module-merge resolver (`exports.tish` / `tish.module`
988
+ /// path / `tish.source` / `index.tish`). A scoped package is only treated as a native Rust crate
989
+ /// when it opts in via `tish.crate` / `tish.rustDependencies` in its package.json (the tish-apple
990
+ /// crates). This split is why `@scope` is intentionally absent below (#38): matching the ecosystem's
991
+ /// resolution for `import … from "@scope/pkg"` is interop, and the native-vs-merged axis is the one
992
+ /// piece tish adds on top, keyed off the native markers rather than mere presence of `tish.module`.
975
993
  pub fn is_native_import(spec: &str) -> bool {
976
994
  // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
977
995
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
@@ -984,6 +1002,39 @@ pub fn is_native_import(spec: &str) -> bool {
984
1002
  )
985
1003
  }
986
1004
 
1005
+ // Import schemes (`asset:` and any project-declared prefix) are recognized via the active
1006
+ // [`crate::schemes`] registry — `crate::schemes::is_scheme_import(spec)`. Like native imports, a
1007
+ // scheme spec is NOT resolved as a `.tish` module or npm package; unlike them it links no crate and
1008
+ // generates no Value wrapper — the codegen injects the scheme's include macro and binds each name to
1009
+ // an i32 handle. See [`resolve_scheme_spec`] for the file-resolution half.
1010
+
1011
+ /// The `@scope/pkg` native opt-in described above (#38): true only when the package resolves and
1012
+ /// its package.json carries the native markers — `tish.module == true` (the native-module flag, a
1013
+ /// bool; a string is a merged-source entry path) AND `tish.crate` / `tish.rustDependencies` (the
1014
+ /// Rust-crate opt-in). Anything else — unresolvable, plain `tish.module: true` with no crate
1015
+ /// (#38), or a `.tish` source package like `@tishlang/pg` — falls through to the module-merge
1016
+ /// resolver. Package resolution walks up from `from_dir` (the importing module's directory), the
1017
+ /// same walk [`resolve_native_module`] later repeats from the project root.
1018
+ pub fn is_scoped_native_import(spec: &str, from_dir: &Path) -> bool {
1019
+ if !spec.starts_with('@') {
1020
+ return false;
1021
+ }
1022
+ let Ok(pkg_dir) = find_package_dir(spec, from_dir) else {
1023
+ return false;
1024
+ };
1025
+ let Ok(content) = std::fs::read_to_string(pkg_dir.join("package.json")) else {
1026
+ return false;
1027
+ };
1028
+ let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1029
+ return false;
1030
+ };
1031
+ let Some(tish) = json.get("tish") else {
1032
+ return false;
1033
+ };
1034
+ tish.get("module").and_then(|v| v.as_bool()).unwrap_or(false)
1035
+ && (tish.get("crate").is_some() || tish.get("rustDependencies").is_some())
1036
+ }
1037
+
987
1038
  /// True for `ffi:…` specs (portable C-ABI cdylib extensions, loadable on every backend). The
988
1039
  /// path after `ffi:` is resolved relative to the importing program and loaded with
989
1040
  /// `tish_ffi::load_module`. Unlike `cargo:` (rust-AOT only), `ffi:` is allowed everywhere.
@@ -1070,6 +1121,48 @@ pub fn resolve_bare_spec(spec: &str, from_dir: &Path, _project_root: &Path) -> O
1070
1121
  None
1071
1122
  }
1072
1123
 
1124
+ /// Resolve a scheme import (`asset:relpath`, or any project-declared scheme) to a canonical spec.
1125
+ /// For a `resolve_file` scheme, the part after the colon is resolved against the importing module's
1126
+ /// directory (`from_dir`) to an absolute path that must exist — a missing file is a compile-time
1127
+ /// error, not a device panic — and the spec becomes `prefix:/abs/path` (the codegen feeds that
1128
+ /// 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.
1130
+ fn resolve_scheme_spec(spec: &str, from_dir: &Path) -> Result<String, String> {
1131
+ let (prefix, resolve_file) = crate::schemes::with_active(|reg| {
1132
+ reg.matches(spec)
1133
+ .map(|s| (s.prefix(), s.resolve_file))
1134
+ })
1135
+ .ok_or_else(|| format!("import '{}' is not a registered scheme", spec))?;
1136
+ let rest = spec.strip_prefix(&prefix).unwrap_or(spec);
1137
+ if !resolve_file {
1138
+ return Ok(spec.to_string());
1139
+ }
1140
+ if rest.is_empty() {
1141
+ return Err(format!(
1142
+ "{} import needs a path, e.g. import {{ player }} from '{}gfx/player.png'",
1143
+ prefix.trim_end_matches(':'),
1144
+ prefix
1145
+ ));
1146
+ }
1147
+ let path = Path::new(rest);
1148
+ let joined = if path.is_absolute() {
1149
+ path.to_path_buf()
1150
+ } else {
1151
+ from_dir.join(path)
1152
+ };
1153
+ let abs = joined.canonicalize().map_err(|e| {
1154
+ format!(
1155
+ "{} import '{}': cannot find '{}' (resolved from {}): {}",
1156
+ prefix.trim_end_matches(':'),
1157
+ spec,
1158
+ rest,
1159
+ from_dir.display(),
1160
+ e
1161
+ )
1162
+ })?;
1163
+ Ok(format!("{}{}", prefix, abs.to_string_lossy()))
1164
+ }
1165
+
1073
1166
  /// Resolve an import specifier (e.g. "./foo.tish", "../lib/utils", "lattish") to an absolute path.
1074
1167
  fn resolve_import_path(
1075
1168
  spec: &str,
@@ -1091,8 +1184,12 @@ fn resolve_import_path(
1091
1184
  spec, spec
1092
1185
  ));
1093
1186
  }
1187
+ let ctx = crate::platform_resolve::resolve_context();
1188
+ if let Some(path) = crate::platform_resolve::resolve_with_platform(spec, from_dir, ctx) {
1189
+ return Ok(path);
1190
+ }
1094
1191
  let base = from_dir.join(spec);
1095
- // Try with .tish extension if the path has no extension
1192
+ // Fallback: legacy single .tish probe when platform context is empty / file missing
1096
1193
  let path = if base.extension().is_none() {
1097
1194
  let with_ext = base.with_extension("tish");
1098
1195
  if with_ext.exists() {
@@ -1155,7 +1252,7 @@ fn has_cycle_from(
1155
1252
  ) -> Result<bool, String> {
1156
1253
  for stmt in &program.statements {
1157
1254
  if let Some(from) = stmt_module_dep(stmt) {
1158
- if is_native_import(from.as_ref()) {
1255
+ 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
1256
  continue;
1160
1257
  }
1161
1258
  let dep_path = resolve_import_path(from.as_ref(), from_dir, Path::new("."))?;
@@ -1837,10 +1934,20 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1837
1934
  from,
1838
1935
  span,
1839
1936
  } => {
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());
1937
+ if is_native_import(from.as_ref())
1938
+ || is_scoped_native_import(from.as_ref(), dir)
1939
+ || crate::schemes::is_scheme_import(from.as_ref())
1940
+ {
1941
+ // Normalize fs/http/process -> tish:fs etc. for Node compatibility.
1942
+ // For `asset:` imports, resolve the (relative) file path to an absolute
1943
+ // one now, while we still know the importing module's directory — the GBA
1944
+ // codegen emits `include_aseprite_inner!("<abspath>")` from that spec.
1945
+ let canonical_spec = if crate::schemes::is_scheme_import(from.as_ref()) {
1946
+ resolve_scheme_spec(from.as_ref(), dir)?
1947
+ } else {
1948
+ normalize_builtin_spec(from.as_ref())
1949
+ .unwrap_or_else(|| from.to_string())
1950
+ };
1844
1951
  // Emit VarDecl with NativeModuleLoad for each specifier
1845
1952
  for spec in specifiers {
1846
1953
  match spec {
@@ -2090,3 +2197,34 @@ mod cargo_spec_tests {
2090
2197
  );
2091
2198
  }
2092
2199
  }
2200
+
2201
+ #[cfg(test)]
2202
+ mod find_package_dir_tests {
2203
+ use super::find_package_dir;
2204
+
2205
+ #[test]
2206
+ fn prefers_tish_apple_crates_over_sibling() {
2207
+ let dir = tempfile::tempdir().expect("tempdir");
2208
+ let root = dir.path();
2209
+ // sibling decoy
2210
+ let sibling = root.join("tish-macos");
2211
+ std::fs::create_dir_all(&sibling).unwrap();
2212
+ std::fs::write(
2213
+ sibling.join("package.json"),
2214
+ r#"{"name":"tish-macos","tish":{"module":true,"crate":"tishlang_macos","export":"macos_object"}}"#,
2215
+ )
2216
+ .unwrap();
2217
+ // preferred apple path
2218
+ let apple = root.join("tish-apple").join("crates").join("tish-macos");
2219
+ std::fs::create_dir_all(&apple).unwrap();
2220
+ std::fs::write(
2221
+ apple.join("package.json"),
2222
+ r#"{"name":"tish-macos","tish":{"module":true,"crate":"tish-macos","export":"macos_object"}}"#,
2223
+ )
2224
+ .unwrap();
2225
+ let project = root.join("app");
2226
+ std::fs::create_dir_all(&project).unwrap();
2227
+ let hit = find_package_dir("tish-macos", &project).expect("resolve");
2228
+ assert_eq!(hit.canonicalize().unwrap(), apple.canonicalize().unwrap());
2229
+ }
2230
+ }
@@ -0,0 +1,278 @@
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) and `{mod}` (the generated module's identifier).
17
+ //!
18
+ //! Only *file-baking* schemes are expressible this way today — the family `asset:` belongs to
19
+ //! (resolve a file → emit a macro + a registration + bind an i32 handle). Schemes needing
20
+ //! compile-time logic (e.g. parsing aseprite tags) will want a code-plugin surface later.
21
+ //!
22
+ //! The active registry is compile-scoped: [`set_active`] installs it at the start of a build and
23
+ //! the resolve/codegen predicates read it via [`with_active`]. It defaults to [`builtin`] so any
24
+ //! path that doesn't install one still sees the built-in schemes (matching prior behaviour).
25
+
26
+ use std::cell::RefCell;
27
+ use std::collections::HashMap;
28
+ use std::path::Path;
29
+
30
+ /// What an imported name binds to. Only `Handle` (an `i32` index into a runtime arena) exists
31
+ /// today; kept as an enum so richer bindings (typed structs, const data) can be added later.
32
+ #[derive(Clone, Debug, PartialEq)]
33
+ pub enum BindKind {
34
+ /// The imported name is an `i32` handle — its index among the scheme's registered files.
35
+ Handle,
36
+ }
37
+
38
+ /// One emit target's code templates for a scheme (e.g. the `gba` recipe for `asset:`).
39
+ #[derive(Clone, Debug)]
40
+ pub struct TargetEmit {
41
+ /// `use` lines placed at the top of the generated `mod`.
42
+ pub uses: Vec<String>,
43
+ /// The module body — typically the include macro. `{path}` → the file as a Rust string literal.
44
+ pub module_body: String,
45
+ /// A public accessor emitted inside the same `mod` (so it can see the macro's private statics).
46
+ pub accessor: String,
47
+ /// Registration call emitted in the entry before `run()`. `{mod}` → the generated module ident.
48
+ pub register: String,
49
+ /// What each imported name binds to.
50
+ pub bind: BindKind,
51
+ }
52
+
53
+ /// A declaratively-described import scheme.
54
+ #[derive(Clone, Debug)]
55
+ pub struct SchemeDef {
56
+ /// The prefix WITHOUT the trailing colon (`asset`); matches specs starting `asset:`.
57
+ pub name: String,
58
+ /// Resolve the part after the colon as a project-relative file (canonicalize + validate exists).
59
+ pub resolve_file: bool,
60
+ /// Per emit-target code templates.
61
+ pub targets: HashMap<String, TargetEmit>,
62
+ }
63
+
64
+ impl SchemeDef {
65
+ /// The spec prefix including the colon (`asset:`).
66
+ pub fn prefix(&self) -> String {
67
+ format!("{}:", self.name)
68
+ }
69
+ }
70
+
71
+ /// The set of active import schemes for a build.
72
+ #[derive(Clone, Debug, Default)]
73
+ pub struct SchemeRegistry {
74
+ pub schemes: Vec<SchemeDef>,
75
+ }
76
+
77
+ impl SchemeRegistry {
78
+ /// The schemes tish core ships with — **none**. tish core is target-agnostic and knows nothing
79
+ /// about agb/GBA; concrete schemes (like `asset:`) are contributed by the crates that implement
80
+ /// their runtime (see [`from_project`](Self::from_project)). Kept as the seam for any future
81
+ /// genuinely-generic built-in scheme.
82
+ pub fn builtin() -> Self {
83
+ SchemeRegistry {
84
+ schemes: Vec::new(),
85
+ }
86
+ }
87
+
88
+ /// The active registry for a build: schemes contributed by cargo dependencies, then the
89
+ /// project's own, merged in that order (project overrides a dependency of the same name).
90
+ ///
91
+ /// * **Dependency-contributed** — each `tish.rustDependencies` entry with a `path` may ship a
92
+ /// `tish.schemes.json` (`{ "schemes": { … } }`) in its crate dir. This is how tish-agb
93
+ /// provides `asset:` without any tish-core edits: a game that depends on tish-agb (as every
94
+ /// sprite game already does) gets the scheme for free, no per-game config.
95
+ /// * **Project-local** — `package.json` → `tish.schemes`, for one-off or overriding schemes.
96
+ ///
97
+ /// A malformed entry is skipped, not fatal.
98
+ pub fn from_project(project_root: &Path) -> Self {
99
+ let mut reg = Self::builtin();
100
+ let tish = crate::resolve::read_project_tish_config(project_root);
101
+ // 1. Schemes contributed by cargo path-dependencies.
102
+ if let Some(deps) = tish.get("rustDependencies").and_then(|v| v.as_object()) {
103
+ for val in deps.values() {
104
+ let Some(path) = val
105
+ .as_object()
106
+ .and_then(|o| o.get("path"))
107
+ .and_then(|p| p.as_str())
108
+ else {
109
+ continue;
110
+ };
111
+ let p = Path::new(path);
112
+ let dep_dir = if p.is_absolute() {
113
+ p.to_path_buf()
114
+ } else {
115
+ project_root.join(p)
116
+ };
117
+ let manifest = dep_dir.join("tish.schemes.json");
118
+ if let Ok(content) = std::fs::read_to_string(&manifest) {
119
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
120
+ reg.merge_schemes(json.get("schemes"));
121
+ }
122
+ }
123
+ }
124
+ }
125
+ // 2. Project-local schemes (override dependency-contributed ones of the same name).
126
+ reg.merge_schemes(tish.get("schemes"));
127
+ reg
128
+ }
129
+
130
+ /// Merge a JSON `{ name: def, … }` schemes object into the registry; a same-named scheme
131
+ /// replaces the existing one.
132
+ fn merge_schemes(&mut self, schemes: Option<&serde_json::Value>) {
133
+ let Some(obj) = schemes.and_then(|v| v.as_object()) else {
134
+ return;
135
+ };
136
+ for (name, def) in obj {
137
+ if let Some(parsed) = parse_scheme_def(name, def) {
138
+ self.schemes.retain(|s| s.name != parsed.name);
139
+ self.schemes.push(parsed);
140
+ }
141
+ }
142
+ }
143
+
144
+ /// The scheme whose prefix `spec` starts with, if any.
145
+ pub fn matches(&self, spec: &str) -> Option<&SchemeDef> {
146
+ self.schemes.iter().find(|s| spec.starts_with(&s.prefix()))
147
+ }
148
+ }
149
+
150
+ /// Parse one `tish.schemes["name"]` JSON entry into a [`SchemeDef`]. Returns `None` (skip) on a
151
+ /// shape that doesn't describe a valid scheme.
152
+ fn parse_scheme_def(name: &str, def: &serde_json::Value) -> Option<SchemeDef> {
153
+ // The name is interpolated into a generated module identifier (`mod __scheme_<name>_<j>`) and
154
+ // used as the `<name>:` import prefix, so it must be a valid Rust identifier. A name like
155
+ // `sprite-sheet` would emit un-compilable Rust (a raw rustc error far from its cause); skip it
156
+ // instead, consistent with "a malformed entry is skipped, not fatal".
157
+ if !is_valid_scheme_name(name) {
158
+ return None;
159
+ }
160
+ let obj = def.as_object()?;
161
+ let resolve_file = obj.get("file").and_then(|v| v.as_bool()).unwrap_or(true);
162
+ let mut targets = HashMap::new();
163
+ if let Some(target_map) = obj.get("targets").and_then(|v| v.as_object()) {
164
+ for (target, spec) in target_map {
165
+ let s = spec.as_object()?;
166
+ let uses = s
167
+ .get("uses")
168
+ .and_then(|v| v.as_array())
169
+ .map(|a| {
170
+ a.iter()
171
+ .filter_map(|x| x.as_str().map(|s| s.to_string()))
172
+ .collect()
173
+ })
174
+ .unwrap_or_default();
175
+ let module_body = s.get("module").and_then(|v| v.as_str())?.to_string();
176
+ let accessor = s
177
+ .get("accessor")
178
+ .and_then(|v| v.as_str())
179
+ .unwrap_or("")
180
+ .to_string();
181
+ let register = s
182
+ .get("register")
183
+ .and_then(|v| v.as_str())
184
+ .unwrap_or("")
185
+ .to_string();
186
+ targets.insert(
187
+ target.clone(),
188
+ TargetEmit {
189
+ uses,
190
+ module_body,
191
+ accessor,
192
+ register,
193
+ bind: BindKind::Handle,
194
+ },
195
+ );
196
+ }
197
+ }
198
+ Some(SchemeDef {
199
+ name: name.to_string(),
200
+ resolve_file,
201
+ targets,
202
+ })
203
+ }
204
+
205
+ /// A scheme name must be a valid Rust identifier: it becomes part of a generated module ident
206
+ /// (`__scheme_<name>_<j>`) and the `<name>:` import prefix. ASCII letter/underscore, then
207
+ /// letters/digits/underscores. (The same shape `types::is_struct_field_safe` enforces for native
208
+ /// struct field keys, kept local so `schemes` stays self-contained.)
209
+ fn is_valid_scheme_name(name: &str) -> bool {
210
+ let mut chars = name.chars();
211
+ match chars.next() {
212
+ Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
213
+ _ => return false,
214
+ }
215
+ chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
216
+ }
217
+
218
+ thread_local! {
219
+ static ACTIVE: RefCell<SchemeRegistry> = RefCell::new(SchemeRegistry::builtin());
220
+ }
221
+
222
+ /// Install the registry for the current build. Call once at the start of a compile, before
223
+ /// resolution — every scheme predicate reads whatever is installed here.
224
+ pub fn set_active(registry: SchemeRegistry) {
225
+ ACTIVE.with(|c| *c.borrow_mut() = registry);
226
+ }
227
+
228
+ /// Read from the active registry.
229
+ pub fn with_active<R>(f: impl FnOnce(&SchemeRegistry) -> R) -> R {
230
+ ACTIVE.with(|c| f(&c.borrow()))
231
+ }
232
+
233
+ /// True if `spec` is a registered import scheme (built-in or project-declared).
234
+ pub fn is_scheme_import(spec: &str) -> bool {
235
+ with_active(|r| r.matches(spec).is_some())
236
+ }
237
+
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.
241
+ pub fn render_template(template: &str, abspath: &str, module_ident: &str, charset: &str) -> String {
242
+ template
243
+ .replace("{path}", &format!("{:?}", abspath))
244
+ .replace("{mod}", module_ident)
245
+ .replace("{charset}", &format!("{:?}", charset))
246
+ }
247
+
248
+ #[cfg(test)]
249
+ mod tests {
250
+ use super::*;
251
+
252
+ #[test]
253
+ fn scheme_name_must_be_a_rust_identifier() {
254
+ // Valid identifiers pass.
255
+ assert!(is_valid_scheme_name("asset"));
256
+ assert!(is_valid_scheme_name("sprite_sheet"));
257
+ assert!(is_valid_scheme_name("_x"));
258
+ assert!(is_valid_scheme_name("bg2"));
259
+ // Anything that would break the generated `mod __scheme_<name>_<j>` is rejected.
260
+ assert!(!is_valid_scheme_name("sprite-sheet")); // hyphen
261
+ assert!(!is_valid_scheme_name("")); // empty
262
+ assert!(!is_valid_scheme_name("2d")); // leading digit
263
+ assert!(!is_valid_scheme_name("a:b")); // colon
264
+ assert!(!is_valid_scheme_name("a b")); // space
265
+ }
266
+
267
+ #[test]
268
+ fn parse_scheme_def_skips_invalid_name() {
269
+ let def = serde_json::json!({
270
+ "file": true,
271
+ "targets": { "gba": { "module": "// x", "register": "" } }
272
+ });
273
+ // A hyphenated name is skipped (returns None) rather than emitting un-compilable Rust.
274
+ assert!(parse_scheme_def("sprite-sheet", &def).is_none());
275
+ // The same def under a valid name parses.
276
+ assert!(parse_scheme_def("sheet", &def).is_some());
277
+ }
278
+ }