@tishlang/tish-lsp 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 (89) 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/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_lsp/Cargo.toml +3 -1
  69. package/crates/tish_lsp/src/import_goto.rs +41 -14
  70. package/crates/tish_lsp/src/main.rs +18 -0
  71. package/crates/tish_native/src/build.rs +246 -0
  72. package/crates/tish_native/src/config.rs +20 -0
  73. package/crates/tish_native/src/lib.rs +16 -6
  74. package/crates/tish_parser/src/lib.rs +28 -0
  75. package/crates/tish_parser/src/parser.rs +25 -4
  76. package/crates/tish_runtime/src/lib.rs +55 -9
  77. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  78. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  79. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  80. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  81. package/crates/tish_vm/src/jit.rs +1446 -164
  82. package/crates/tish_vm/src/vm.rs +583 -173
  83. package/justfile +9 -0
  84. package/package.json +1 -1
  85. package/platform/darwin-arm64/tish-lsp +0 -0
  86. package/platform/darwin-x64/tish-lsp +0 -0
  87. package/platform/linux-arm64/tish-lsp +0 -0
  88. package/platform/linux-x64/tish-lsp +0 -0
  89. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -229,7 +229,7 @@ pub fn infer_expr_type(expr: &Expr, ctx: &InferCtx) -> Option<TypeAnnotation> {
229
229
  /// Run inference over a program, returning a modified Program with additional
230
230
  /// type annotations filled in on `VarDecl` nodes.
231
231
  pub fn infer_program(program: &Program) -> Program {
232
- // M4 (opt-in via TISH_PARAM_INFER) runs FIRST: give unannotated params used PURELY
232
+ // M4 (default-on via native_opts_enabled; TISH_NATIVE_OPT=0 opts out) runs FIRST: give unannotated params used PURELY
233
233
  // numerically a synthetic `: number`. Doing this *before* local/struct inference is what
234
234
  // lets derived numeric locals (`let x0 = (px / w) * 3`) be proven numeric off the now-known
235
235
  // param types — otherwise they fall back to boxed `Value` and the whole hot loop boxes with
@@ -245,7 +245,7 @@ pub fn infer_program(program: &Program) -> Program {
245
245
  let p = Program {
246
246
  statements: infer_statements(&p.statements, &mut ctx),
247
247
  };
248
- // Automatic struct inference (opt-in via TISH_STRUCT_INFER until proven):
248
+ // Automatic struct inference (default-on via native_opts_enabled):
249
249
  // give unannotated object literals a concrete struct type so the Rust
250
250
  // backend emits unboxed structs with direct field access. Conservative —
251
251
  // only applies when every use of the binding is a literal-key field read,
@@ -255,17 +255,18 @@ pub fn infer_program(program: &Program) -> Program {
255
255
  } else {
256
256
  p
257
257
  };
258
- // S-0..S-C aggregate (interprocedural monomorphic struct) inference — issue #177, opt-in via
259
- // TISH_AGGREGATE_INFER, OFF by default. Front-end of the nbody unboxing lever: it
258
+ // S-0..S-F aggregate (interprocedural monomorphic struct) inference — issue #177, default-on
259
+ // via native_opts_enabled. Front-end of the nbody unboxing lever: it
260
260
  // S-0: types params used ONLY as object-literal field values (`body(x,…)` → `: number`),
261
261
  // S-A: registers the return-shape struct alias for an all-f64 object-literal-returning fn,
262
262
  // S-B: propagates a call's return type to `let p = body(…)` / `let bs = makeBodies()`,
263
263
  // S-C: types `[ident,…]` array literals from those struct-typed locals.
264
- // The full lever also needs S-D (write-permitting param-shape) + S-E/S-F (the typed-fn ABI
265
- // tier in codegen) before the annotations can be *consumed* without a boxed-edge miscompile;
266
- // until those land this pass only emits the annotations the existing codegen backs SOUNDLY
267
- // (the S-0 scalar `: number` params, identical to the M4 mechanism) plus the inert struct
268
- // alias decls. See `aggregate_infer_program`.
264
+ // S-0 scalar params are always stamped. The S-A/S-B/S-C struct/array shapes are stamped only
265
+ // when the S-D whole-program candidacy predicate holds (`unbox_alias` — monomorphic all-f64
266
+ // shape, no `===`/escape/reshape), in which case codegen's S-E/S-F typed-fn ABI tier
267
+ // (`setup_aggregate_fns`) consumes them, de-virtualizing the group into native free fns over
268
+ // `Vec<TishStruct_alias>`; any unlowerable use disables the whole path (boxed fallback,
269
+ // byte-identical). See `aggregate_infer_program` / `stamp_aggregate`.
269
270
  if crate::native_opts_enabled() {
270
271
  aggregate_infer_program(p)
271
272
  } else {
@@ -274,7 +275,7 @@ pub fn infer_program(program: &Program) -> Program {
274
275
  }
275
276
 
276
277
  // ---------------------------------------------------------------------------
277
- // M4: parameter type inference (conservative, sound, opt-in)
278
+ // M4: parameter type inference (conservative, sound, default-on)
278
279
  // ---------------------------------------------------------------------------
279
280
 
280
281
  fn param_infer_program(program: Program) -> Program {
@@ -1469,7 +1470,7 @@ pub(crate) fn pi_mentions(e: &Expr, name: &str) -> bool {
1469
1470
  }
1470
1471
 
1471
1472
  // ---------------------------------------------------------------------------
1472
- // Automatic struct inference (conservative, sound, opt-in)
1473
+ // Automatic struct inference (conservative, sound, default-on)
1473
1474
  // ---------------------------------------------------------------------------
1474
1475
 
1475
1476
  /// One emitted `type` decl: alias name plus its field list.
@@ -2319,7 +2320,7 @@ fn inline_single_call_factories(stmts: Vec<Statement>) -> Vec<Statement> {
2319
2320
  // (`return [a, b]`), a call return, or a discarded `f()` is left intact — those are the #177
2320
2321
  // aggregate machinery's territory (nbody's `makeBodies` returns `[sun, jupiter, …]`, and
2321
2322
  // inlining it removes the function the aggregate ABI expects → nbody BUILD-FAIL under
2322
- // TISH_AGGREGATE_INFER). This keeps the inliner to exactly the Stage A/B bridge target.
2323
+ // the aggregate path). This keeps the inliner to exactly the Stage A/B bridge target.
2323
2324
  let return_ident = match &ret_expr {
2324
2325
  Expr::Ident { name, .. } if leaked.iter().any(|l| l.as_ref() == name.as_ref()) => {
2325
2326
  name.clone()
@@ -3987,8 +3988,8 @@ fn _uses_arrow_body(_: &ArrowBody) {}
3987
3988
  // S-0..S-C: aggregate (interprocedural monomorphic struct) inference — issue #177
3988
3989
  // ===========================================================================
3989
3990
  //
3990
- // This is the front-end of the nbody-unboxing lever. It runs ONLY under
3991
- // `TISH_AGGREGATE_INFER` (OFF by default). The four sub-passes are pure analysis
3991
+ // This is the front-end of the nbody-unboxing lever. It runs by default under
3992
+ // `native_opts_enabled()` (`TISH_NATIVE_OPT=0` opts out). The four sub-passes are pure analysis
3992
3993
  // over the (already base/param/struct-inferred) `Program`:
3993
3994
  //
3994
3995
  // S-0 param-numeric-as-field-value: a param used ONLY as an object-literal
@@ -4004,27 +4005,22 @@ fn _uses_arrow_body(_: &ArrowBody) {}
4004
4005
  //
4005
4006
  // SOUNDNESS / WHAT IS ACTUALLY WRITTEN BACK
4006
4007
  // -----------------------------------------
4007
- // The struct types S-A/S-B/S-C compute cannot yet be *consumed* by codegen
4008
- // without the S-D write-permitting param predicate and the S-E/S-F typed-fn ABI
4009
- // tier (a de-virtualized `fn advance(bodies: &VmRef<Vec<TishStruct_Body>>, …)`).
4010
- // Until those land, writing a `Named`/`Array(Named)` annotation onto a fn param
4011
- // or a call-initialised local would MISCOMPILE: `collect_annotated_types`
4012
- // (codegen.rs) records the param/local as a native struct while the actual
4013
- // binding is still a boxed `Value` (the call returns boxed `Value` — there is no
4014
- // FnSigTable / struct-returning emission), so a `p.x` field read or `bodies[i].vx
4015
- // = …` write would be lowered against a boxed value. The boxed-edge `===`/escape
4016
- // hazards in the design's candidacy predicate are the same class of problem.
4017
- //
4018
- // Therefore `aggregate_infer_program` writes back ONLY the annotations the
4019
- // EXISTING codegen backs soundly:
4020
- // * S-0's scalar `: number` params (identical to the M4 param-infer mechanism,
4021
- // already consumed soundly), and
4022
- // * the inert struct `type` alias decls (unreferenced aliases are dropped by
4023
- // codegen — they change nothing on their own).
4024
- // The S-A/S-B/S-C struct *shapes* are computed and exposed via
4025
- // `analyze_aggregate` for unit tests and for the future S-D..S-F consumers, but
4026
- // are NOT yet stamped onto params / call-locals. This keeps the ON path free of
4027
- // any checksum divergence while the inference logic is validated independently.
4008
+ // Stamping a struct/array annotation is only sound when codegen can back it end
4009
+ // to end — a `Named`/`Array(Named)` annotation on a param or call-initialised
4010
+ // local whose actual binding stays a boxed `Value` would MISCOMPILE (`p.x` /
4011
+ // `bodies[i].vx = …` lowered against a boxed value). So write-back is two-tier:
4012
+ // * S-0's scalar `: number` params are ALWAYS stamped (identical to the M4
4013
+ // param-infer mechanism, already consumed soundly), plus the struct `type`
4014
+ // alias decls (inert on their own — unreferenced aliases are dropped).
4015
+ // * The S-A/S-B/S-C struct/array shapes are stamped ONLY when the S-D
4016
+ // whole-program candidacy predicate holds (`compute_unbox_candidacy` →
4017
+ // `unbox_alias`: one monomorphic all-f64 shape, no `===`/escape/reshape).
4018
+ // Codegen's S-E/S-F typed-fn ABI tier (`setup_aggregate_fns`) then consumes
4019
+ // them, de-virtualizing the factory/array/operator fns into native free fns
4020
+ // over `Vec<TishStruct_alias>` threaded by reference. All-or-nothing with
4021
+ // scratch-buffer rollback: any unlowerable use disables the whole path and
4022
+ // the boxed closures are emitted unchanged (byte-identical fallback).
4023
+ // `analyze_aggregate` stays exposed for unit tests over the raw analysis.
4028
4024
 
4029
4025
  /// The result of the aggregate analysis: the registered struct shapes plus, per
4030
4026
  /// function, the inferred return shape and the set of params S-0 typed numeric.
@@ -5003,12 +4999,12 @@ fn infer_aggregate_expr(
5003
4999
  }
5004
5000
  }
5005
5001
 
5006
- /// Aggregate inference writeback. Runs the S-0..S-C analysis, then stamps onto the
5007
- /// program ONLY the annotations the existing codegen backs soundly (see the module
5008
- /// note above): the S-0 numeric `: number` params, plus the inert struct alias
5009
- /// `type` decls. The S-A/S-B/S-C struct shapes are computed and available via
5010
- /// `analyze_aggregate` but are NOT written onto params / call-locals until the
5011
- /// S-D..S-F typed-fn ABI tier exists to consume them without a boxed-edge miscompile.
5002
+ /// Aggregate inference writeback (two-tier — see the module note above). Always
5003
+ /// stamps the S-0 numeric `: number` params plus the struct alias `type` decls;
5004
+ /// when the S-D candidacy predicate holds (`unbox_alias` is `Some`) it ALSO stamps
5005
+ /// the S-A/S-B/S-C struct/array shapes (factory returns, group-fn array params,
5006
+ /// top-level array locals), which codegen's S-E/S-F typed-fn ABI tier
5007
+ /// (`setup_aggregate_fns`) consumes with scratch-buffer rollback.
5012
5008
  fn aggregate_infer_program(program: Program) -> Program {
5013
5009
  let analysis = analyze_aggregate(&program);
5014
5010
 
@@ -5,9 +5,13 @@
5
5
  mod check;
6
6
  mod codegen;
7
7
  mod infer;
8
+ mod platform_resolve;
8
9
  mod resolve;
10
+ mod schemes;
9
11
  mod types;
10
12
 
13
+ pub use schemes::{set_active as set_scheme_registry, SchemeRegistry};
14
+
11
15
  pub use check::{check_program, TypeDiagnostic};
12
16
 
13
17
  /// The native typed-codegen optimizations — numeric param inference, struct/aggregate inference,
@@ -29,13 +33,16 @@ pub(crate) fn native_recur_guard_enabled() -> bool {
29
33
  std::env::var("TISH_NATIVE_RECUR_GUARD").map(|v| v != "0").unwrap_or(true)
30
34
  }
31
35
 
32
- /// How generated Rust is linked (desktop binary vs embedded iOS staticlib).
36
+ /// How generated Rust is linked (desktop binary vs embedded iOS staticlib vs GBA ROM).
33
37
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34
38
  pub enum NativeEmitMode {
35
39
  #[default]
36
40
  DesktopBin,
37
41
  /// `[lib] crate-type = ["staticlib"]` — no `fn main()`, host calls `tish_ios_launch`.
38
42
  EmbeddedLib,
43
+ /// Game Boy Advance ROM: `#![no_std]`, `#[agb::entry] fn agb_main(gba)`, links the
44
+ /// `tishlang_runtime_gba` facade. Numbers/async lowering diverge; see codegen `emit_program`.
45
+ Gba,
39
46
  }
40
47
 
41
48
  pub use codegen::CompileError;
@@ -44,6 +51,11 @@ pub use codegen::{
44
51
  compile_with_features, compile_with_native_modules, compile_with_native_modules_emit,
45
52
  compile_with_project_root,
46
53
  };
54
+ pub use platform_resolve::{
55
+ apply_resolve_env, parse_platform, parse_surface, platform_suffixes, platform_virtual_keys,
56
+ resolve_context, resolve_id_public, resolve_with_platform, set_resolve_context, Platform,
57
+ ResolveContext, Surface,
58
+ };
47
59
  pub use resolve::{
48
60
  cargo_export_fn_name, compute_native_build_artifacts, detect_cycles, ensure_tish_canvas_module,
49
61
  export_name_to_rust_ident, extract_native_import_features, format_rust_dependencies_toml,
@@ -0,0 +1,361 @@
1
+ //! React Native–style platform/surface file resolution for `.tish` imports.
2
+ //!
3
+ //! See docs/LANGUAGE.md (platform extensions). Context is process-wide so existing
4
+ //! `resolve_project` callers pick up `--platform` / `--surface` / env without signature churn.
5
+
6
+ use std::path::{Path, PathBuf};
7
+ use std::sync::{Mutex, OnceLock};
8
+
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10
+ pub enum Platform {
11
+ #[default]
12
+ Unknown,
13
+ Macos,
14
+ Ios,
15
+ Android,
16
+ Windows,
17
+ Linux,
18
+ Web,
19
+ }
20
+
21
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22
+ pub enum Surface {
23
+ #[default]
24
+ Unknown,
25
+ Native,
26
+ Webview,
27
+ Web,
28
+ }
29
+
30
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31
+ pub struct ResolveContext {
32
+ pub platform: Platform,
33
+ pub surface: Surface,
34
+ }
35
+
36
+ fn resolve_ctx_lock() -> &'static Mutex<ResolveContext> {
37
+ static RESOLVE_CTX: OnceLock<Mutex<ResolveContext>> = OnceLock::new();
38
+ RESOLVE_CTX.get_or_init(|| Mutex::new(ResolveContext::default()))
39
+ }
40
+
41
+ pub fn set_resolve_context(ctx: ResolveContext) {
42
+ *resolve_ctx_lock().lock().unwrap_or_else(|e| e.into_inner()) = ctx;
43
+ }
44
+
45
+ pub fn resolve_context() -> ResolveContext {
46
+ *resolve_ctx_lock().lock().unwrap_or_else(|e| e.into_inner())
47
+ }
48
+
49
+ pub fn parse_platform(s: &str) -> Option<Platform> {
50
+ match s.trim().to_ascii_lowercase().as_str() {
51
+ "macos" | "darwin" | "osx" => Some(Platform::Macos),
52
+ "ios" => Some(Platform::Ios),
53
+ "android" => Some(Platform::Android),
54
+ "windows" | "win32" | "win" => Some(Platform::Windows),
55
+ "linux" => Some(Platform::Linux),
56
+ "web" => Some(Platform::Web),
57
+ "" | "unknown" | "default" => Some(Platform::Unknown),
58
+ _ => None,
59
+ }
60
+ }
61
+
62
+ pub fn parse_surface(s: &str) -> Option<Surface> {
63
+ match s.trim().to_ascii_lowercase().as_str() {
64
+ "native" => Some(Surface::Native),
65
+ "webview" => Some(Surface::Webview),
66
+ "web" => Some(Surface::Web),
67
+ "" | "unknown" | "default" => Some(Surface::Unknown),
68
+ _ => None,
69
+ }
70
+ }
71
+
72
+ /// Apply CLI/env into the process resolve context.
73
+ /// `TISH_PLATFORM` / `TISH_SURFACE` fill gaps when args are omitted.
74
+ pub fn apply_resolve_env(platform: Option<&str>, surface: Option<&str>) -> Result<(), String> {
75
+ let mut ctx = resolve_context();
76
+ if let Some(p) = platform {
77
+ ctx.platform = parse_platform(p).ok_or_else(|| format!("unknown --platform '{p}'"))?;
78
+ } else if let Ok(p) = std::env::var("TISH_PLATFORM") {
79
+ if let Some(parsed) = parse_platform(&p) {
80
+ ctx.platform = parsed;
81
+ }
82
+ }
83
+ if let Some(s) = surface {
84
+ ctx.surface = parse_surface(s).ok_or_else(|| format!("unknown --surface '{s}'"))?;
85
+ } else if let Ok(s) = std::env::var("TISH_SURFACE") {
86
+ if let Some(parsed) = parse_surface(&s) {
87
+ ctx.surface = parsed;
88
+ }
89
+ }
90
+ // `--platform web` implies surface web when surface unset
91
+ if ctx.platform == Platform::Web && ctx.surface == Surface::Unknown {
92
+ ctx.surface = Surface::Web;
93
+ }
94
+ set_resolve_context(ctx);
95
+ Ok(())
96
+ }
97
+
98
+ /// Suffix tokens to try (before bare `.tish`), most specific first.
99
+ pub fn platform_suffixes(ctx: ResolveContext) -> Vec<&'static str> {
100
+ let mut out = Vec::new();
101
+ match (ctx.platform, ctx.surface) {
102
+ (_, Surface::Web) | (Platform::Web, _) => {
103
+ out.push("web");
104
+ }
105
+ (Platform::Macos, Surface::Native) | (Platform::Macos, Surface::Unknown) => {
106
+ out.extend(["macos", "desktop", "native"]);
107
+ }
108
+ (Platform::Ios, Surface::Native) | (Platform::Ios, Surface::Unknown) => {
109
+ out.extend(["ios", "mobile", "native"]);
110
+ }
111
+ (Platform::Android, Surface::Native) | (Platform::Android, Surface::Unknown) => {
112
+ out.extend(["android", "mobile", "native"]);
113
+ }
114
+ (Platform::Windows, Surface::Native) | (Platform::Windows, Surface::Unknown) => {
115
+ out.extend(["windows", "desktop", "native"]);
116
+ }
117
+ (Platform::Linux, Surface::Native) | (Platform::Linux, Surface::Unknown) => {
118
+ out.extend(["linux", "desktop", "native"]);
119
+ }
120
+ (Platform::Macos, Surface::Webview)
121
+ | (Platform::Windows, Surface::Webview)
122
+ | (Platform::Linux, Surface::Webview)
123
+ | (Platform::Unknown, Surface::Webview) => {
124
+ out.extend(["webview", "web", "desktop"]);
125
+ }
126
+ (Platform::Ios, Surface::Webview) | (Platform::Android, Surface::Webview) => {
127
+ out.extend(["webview", "web", "mobile"]);
128
+ }
129
+ _ => {}
130
+ }
131
+ out
132
+ }
133
+
134
+ /// After normalizing a virtual path to a stem (no `.tish`), list cascade keys to probe.
135
+ /// `normalized_stem` is e.g. `app/Button` (no extension).
136
+ pub fn platform_virtual_keys(normalized_stem: &str, ctx: ResolveContext) -> Vec<String> {
137
+ let parent = Path::new(normalized_stem)
138
+ .parent()
139
+ .map(|p| p.to_string_lossy().to_string())
140
+ .filter(|p| !p.is_empty() && p != ".");
141
+ let file_stem = Path::new(normalized_stem)
142
+ .file_name()
143
+ .and_then(|s| s.to_str())
144
+ .unwrap_or("index");
145
+ let mut keys = Vec::new();
146
+ for suffix in platform_suffixes(ctx) {
147
+ keys.push(match &parent {
148
+ Some(p) => format!("{p}/{file_stem}.{suffix}.tish"),
149
+ None => format!("{file_stem}.{suffix}.tish"),
150
+ });
151
+ }
152
+ keys.push(match &parent {
153
+ Some(p) => format!("{p}/{file_stem}.tish"),
154
+ None => format!("{file_stem}.tish"),
155
+ });
156
+ keys
157
+ }
158
+
159
+ /// Resolve a relative import specifier to an on-disk path, trying platform variants.
160
+ ///
161
+ /// `spec` is e.g. `./Button` or `./Button.tish`. Never picks native-only suffixes for web surface.
162
+ pub fn resolve_with_platform(
163
+ spec: &str,
164
+ from_dir: &Path,
165
+ ctx: ResolveContext,
166
+ ) -> Option<PathBuf> {
167
+ let joined = from_dir.join(spec);
168
+ let (stem_path, had_tish_ext) = if joined.extension().and_then(|e| e.to_str()) == Some("tish")
169
+ {
170
+ (joined.with_extension(""), true)
171
+ } else if joined.extension().is_none() {
172
+ (joined, false)
173
+ } else {
174
+ // Other extensions (.js, .css): leave to caller / no platform remap
175
+ return if joined.exists() {
176
+ joined.canonicalize().ok()
177
+ } else {
178
+ None
179
+ };
180
+ };
181
+
182
+ let parent = stem_path.parent().unwrap_or(from_dir);
183
+ let file_stem = stem_path
184
+ .file_name()
185
+ .and_then(|s| s.to_str())
186
+ .unwrap_or("index");
187
+
188
+ for suffix in platform_suffixes(ctx) {
189
+ let candidate = parent.join(format!("{file_stem}.{suffix}.tish"));
190
+ if candidate.exists() {
191
+ return candidate.canonicalize().ok();
192
+ }
193
+ }
194
+
195
+ let base = parent.join(format!("{file_stem}.tish"));
196
+ if base.exists() {
197
+ return base.canonicalize().ok();
198
+ }
199
+
200
+ // Explicit path without remap that already exists (edge)
201
+ if had_tish_ext {
202
+ let explicit = from_dir.join(spec);
203
+ if explicit.exists() {
204
+ return explicit.canonicalize().ok();
205
+ }
206
+ }
207
+
208
+ None
209
+ }
210
+
211
+ /// Public one-shot for CLI / Vite: resolve `importer` + `source` → absolute path string.
212
+ pub fn resolve_id_public(
213
+ source: &str,
214
+ importer: Option<&str>,
215
+ platform: Option<&str>,
216
+ surface: Option<&str>,
217
+ ) -> Result<String, String> {
218
+ apply_resolve_env(platform, surface)?;
219
+ let ctx = resolve_context();
220
+ let from_dir = if let Some(imp) = importer {
221
+ Path::new(imp)
222
+ .parent()
223
+ .map(Path::to_path_buf)
224
+ .unwrap_or_else(|| PathBuf::from("."))
225
+ } else {
226
+ std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
227
+ };
228
+
229
+ if !source.starts_with("./") && !source.starts_with("../") {
230
+ return Err(format!(
231
+ "resolve-id only handles relative imports (got '{source}')"
232
+ ));
233
+ }
234
+
235
+ resolve_with_platform(source, &from_dir, ctx)
236
+ .map(|p| p.display().to_string())
237
+ .ok_or_else(|| {
238
+ let tried: Vec<_> = platform_suffixes(ctx)
239
+ .into_iter()
240
+ .map(|s| format!("*.{s}.tish"))
241
+ .collect();
242
+ format!(
243
+ "Cannot resolve '{source}' from {} (tried {} then *.tish)",
244
+ from_dir.display(),
245
+ if tried.is_empty() {
246
+ "(no platform suffixes)".into()
247
+ } else {
248
+ tried.join(", ")
249
+ }
250
+ )
251
+ })
252
+ }
253
+
254
+ #[cfg(test)]
255
+ mod tests {
256
+ use super::*;
257
+ use std::fs;
258
+ use tempfile::tempdir;
259
+
260
+ #[test]
261
+ fn macos_native_prefers_macos_then_desktop() {
262
+ let dir = tempdir().unwrap();
263
+ let root = dir.path();
264
+ fs::write(root.join("Button.desktop.tish"), "").unwrap();
265
+ fs::write(root.join("Button.macos.tish"), "").unwrap();
266
+ fs::write(root.join("Button.tish"), "").unwrap();
267
+ let ctx = ResolveContext {
268
+ platform: Platform::Macos,
269
+ surface: Surface::Native,
270
+ };
271
+ let p = resolve_with_platform("./Button", root, ctx).unwrap();
272
+ assert!(p.ends_with("Button.macos.tish"));
273
+ }
274
+
275
+ #[test]
276
+ fn webview_reuses_web_not_macos() {
277
+ let dir = tempdir().unwrap();
278
+ let root = dir.path();
279
+ fs::write(root.join("Button.macos.tish"), "").unwrap();
280
+ fs::write(root.join("Button.web.tish"), "").unwrap();
281
+ let ctx = ResolveContext {
282
+ platform: Platform::Macos,
283
+ surface: Surface::Webview,
284
+ };
285
+ let p = resolve_with_platform("./Button", root, ctx).unwrap();
286
+ assert!(p.ends_with("Button.web.tish"));
287
+ }
288
+
289
+ #[test]
290
+ fn web_surface_only_web_and_base() {
291
+ let dir = tempdir().unwrap();
292
+ let root = dir.path();
293
+ fs::write(root.join("Button.macos.tish"), "").unwrap();
294
+ fs::write(root.join("Button.web.tish"), "").unwrap();
295
+ let ctx = ResolveContext {
296
+ platform: Platform::Web,
297
+ surface: Surface::Web,
298
+ };
299
+ let p = resolve_with_platform("./Button.tish", root, ctx).unwrap();
300
+ assert!(p.ends_with("Button.web.tish"));
301
+ }
302
+
303
+ #[test]
304
+ fn android_native_prefers_android_then_mobile() {
305
+ let dir = tempdir().unwrap();
306
+ let root = dir.path();
307
+ fs::write(root.join("Button.mobile.tish"), "").unwrap();
308
+ fs::write(root.join("Button.android.tish"), "").unwrap();
309
+ fs::write(root.join("Button.tish"), "").unwrap();
310
+ let ctx = ResolveContext {
311
+ platform: Platform::Android,
312
+ surface: Surface::Native,
313
+ };
314
+ let p = resolve_with_platform("./Button", root, ctx).unwrap();
315
+ assert!(p.ends_with("Button.android.tish"));
316
+ }
317
+
318
+ #[test]
319
+ fn ios_native_falls_back_to_mobile() {
320
+ let dir = tempdir().unwrap();
321
+ let root = dir.path();
322
+ fs::write(root.join("Button.mobile.tish"), "").unwrap();
323
+ fs::write(root.join("Button.tish"), "").unwrap();
324
+ let ctx = ResolveContext {
325
+ platform: Platform::Ios,
326
+ surface: Surface::Native,
327
+ };
328
+ let p = resolve_with_platform("./Button", root, ctx).unwrap();
329
+ assert!(p.ends_with("Button.mobile.tish"));
330
+ }
331
+
332
+ #[test]
333
+ fn virtual_keys_cascade_with_and_without_parent() {
334
+ // nested path keeps the parent dir; macos/native → macos, desktop, native, then base
335
+ let macos = platform_virtual_keys(
336
+ "app/Button",
337
+ ResolveContext {
338
+ platform: Platform::Macos,
339
+ surface: Surface::Native,
340
+ },
341
+ );
342
+ assert_eq!(
343
+ macos,
344
+ vec![
345
+ "app/Button.macos.tish",
346
+ "app/Button.desktop.tish",
347
+ "app/Button.native.tish",
348
+ "app/Button.tish",
349
+ ]
350
+ );
351
+ // bare stem (no parent) + web surface → web, then base
352
+ let web = platform_virtual_keys(
353
+ "Button",
354
+ ResolveContext {
355
+ platform: Platform::Web,
356
+ surface: Surface::Web,
357
+ },
358
+ );
359
+ assert_eq!(web, vec!["Button.web.tish", "Button.tish"]);
360
+ }
361
+ }