@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
@@ -344,6 +344,8 @@ impl LanguageServer for Backend {
344
344
  }
345
345
 
346
346
  let mut src_root: Option<PathBuf> = None;
347
+ let mut init_platform: Option<String> = None;
348
+ let mut init_surface: Option<String> = None;
347
349
  if let Some(opts) = &params.initialization_options {
348
350
  if let Some(s) = opts
349
351
  .get("tishlangSourceRoot")
@@ -354,7 +356,23 @@ impl LanguageServer for Backend {
354
356
  src_root = Some(PathBuf::from(s));
355
357
  }
356
358
  }
359
+ // Platform file cascade for imports (same as `tish resolve-id` / Vite).
360
+ // settings.json: "tish.platform" / "tish.surface", or env TISH_PLATFORM / TISH_SURFACE.
361
+ if let Some(p) = opts.get("platform").and_then(|v| v.as_str()) {
362
+ init_platform = Some(p.to_string());
363
+ } else if let Some(p) = opts.get("tishPlatform").and_then(|v| v.as_str()) {
364
+ init_platform = Some(p.to_string());
365
+ }
366
+ if let Some(s) = opts.get("surface").and_then(|v| v.as_str()) {
367
+ init_surface = Some(s.to_string());
368
+ } else if let Some(s) = opts.get("tishSurface").and_then(|v| v.as_str()) {
369
+ init_surface = Some(s.to_string());
370
+ }
357
371
  }
372
+ let _ = tishlang_compile::apply_resolve_env(
373
+ init_platform.as_deref(),
374
+ init_surface.as_deref(),
375
+ );
358
376
  if src_root.is_none() {
359
377
  if let Ok(s) = std::env::var("TISHLANG_SOURCE_ROOT") {
360
378
  let t = s.trim();
@@ -135,6 +135,17 @@ pub fn build_via_cargo_with_config(
135
135
  project_root: Option<&Path>,
136
136
  build_config: &NativeBuildConfig,
137
137
  ) -> Result<(), String> {
138
+ if build_config.artifact == NativeArtifact::GbaRom {
139
+ return build_gba_rom(
140
+ rust_code,
141
+ native_modules,
142
+ output_path,
143
+ extra_dependencies_toml,
144
+ generated_native_rs,
145
+ project_root,
146
+ );
147
+ }
148
+
138
149
  let out_stem = output_path
139
150
  .file_stem()
140
151
  .and_then(|s| s.to_str())
@@ -299,6 +310,241 @@ tishlang_runtime = {{ path = {:?}{} }}
299
310
  Ok(())
300
311
  }
301
312
 
313
+ /// Read the agb version the facade pins, from `<facade>/Cargo.toml`. This keeps the generated ROM
314
+ /// crate's agb in lockstep with the facade's (the single source of truth) — see [`build_gba_rom`].
315
+ /// Matches an `agb = "X"` or `agb = { version = "X", … }` line; `None` if absent (caller falls back).
316
+ fn read_facade_agb_version(facade_path: &Path) -> Option<String> {
317
+ let toml = fs::read_to_string(facade_path.join("Cargo.toml")).ok()?;
318
+ for line in toml.lines() {
319
+ // Require the key to be exactly `agb` (not `agb_foo`): after `agb`, the next non-space
320
+ // token must be `=`.
321
+ let Some(after_key) = line.trim_start().strip_prefix("agb") else {
322
+ continue;
323
+ };
324
+ let Some(after_eq) = after_key.trim_start().strip_prefix('=') else {
325
+ continue;
326
+ };
327
+ // Take the first double-quoted string (the version, in either the bare or table form).
328
+ let rest = after_eq.trim_start();
329
+ if let Some(start) = rest.find('"') {
330
+ if let Some(len) = rest[start + 1..].find('"') {
331
+ return Some(rest[start + 1..start + 1 + len].to_string());
332
+ }
333
+ }
334
+ }
335
+ None
336
+ }
337
+
338
+ /// Build a Game Boy Advance ROM from generated `#![no_std]` Rust.
339
+ ///
340
+ /// Emits an agb-style cargo project (nightly + build-std + `thumbv4t-none-eabi` +
341
+ /// `gba.ld`) that links the `tishlang_runtime_gba` facade under the `tishlang_runtime`
342
+ /// name (the `package =` rename), builds it, then runs `agb-gbafix` on the ELF.
343
+ ///
344
+ /// Runs its own `cargo build` (NOT `run_cargo_build`) so the `.cargo/config.toml`
345
+ /// rustflags (`-Tgba.ld`, `-Ctarget-cpu=arm7tdmi`) apply — `run_cargo_build` sets a
346
+ /// `RUSTFLAGS` env that would shadow them.
347
+ fn build_gba_rom(
348
+ rust_code: &str,
349
+ native_modules: Vec<ResolvedNativeModule>,
350
+ output_path: &Path,
351
+ extra_dependencies_toml: &str,
352
+ generated_native_rs: Option<&str>,
353
+ project_root: Option<&Path>,
354
+ ) -> Result<(), String> {
355
+ use std::process::Command;
356
+
357
+ // Locate the facade crate (sibling of tishlang_runtime in the tish workspace).
358
+ let runtime_path = tishlang_build_utils::find_runtime_path_for_project(project_root)?;
359
+ let facade_path = Path::new(&runtime_path)
360
+ .parent()
361
+ .ok_or_else(|| "invalid tishlang_runtime path (no parent)".to_string())?
362
+ .join("tish_runtime_gba");
363
+ if !facade_path.exists() {
364
+ return Err(format!(
365
+ "GBA runtime facade not found at {} (expected the tishlang_runtime_gba crate)",
366
+ facade_path.display()
367
+ ));
368
+ }
369
+
370
+ // Single-source the agb version from the facade's own Cargo.toml — the facade is the one crate
371
+ // that actually links agb (its `Fixed`, entry-point, and asset arenas are agb types), so it is
372
+ // the source of truth. The generated ROM crate must depend on the SAME agb: a skew would pull in
373
+ // two agb crates and mismatch types at the sprite-registration boundary. Falls back to a known
374
+ // pin only if the line can't be read (never blocks the build on a parse miss).
375
+ let agb_version =
376
+ read_facade_agb_version(&facade_path).unwrap_or_else(|| "0.25.0".to_string());
377
+
378
+ let out_stem = output_path
379
+ .file_stem()
380
+ .and_then(|s| s.to_str())
381
+ .unwrap_or("tish_out");
382
+ let cargo_name = tishlang_build_utils::cargo_target_name(out_stem);
383
+
384
+ // Persistent build dir under the project so the (expensive) build-std recompile
385
+ // is cached across runs; the generated Rust stays inspectable.
386
+ let base = project_root
387
+ .map(|p| p.to_path_buf())
388
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
389
+ let build_dir = base.join(".tish").join("gba").join(out_stem);
390
+ fs::create_dir_all(build_dir.join("src"))
391
+ .map_err(|e| format!("Cannot create GBA build dir: {}", e))?;
392
+ fs::create_dir_all(build_dir.join(".cargo"))
393
+ .map_err(|e| format!("Cannot create .cargo dir: {}", e))?;
394
+
395
+ // Path dependencies for any `cargo:` native modules the program imported.
396
+ let native_deps: String = native_modules
397
+ .iter()
398
+ .filter(|m| m.use_path_dependency)
399
+ .map(|m| {
400
+ let path = m.crate_path.display().to_string().replace('\\', "/");
401
+ format!("{} = {{ path = {:?} }}\n", m.package_name, path)
402
+ })
403
+ .collect();
404
+
405
+ // `cargo:` module path deps arrive via `extra_dependencies_toml`
406
+ // (`tish.rustDependencies`); `native_modules` covers any that use a path dep
407
+ // resolved another way. Both are appended to `[dependencies]`.
408
+ let mut more_deps = native_deps;
409
+ if !extra_dependencies_toml.trim().is_empty() {
410
+ more_deps.push('\n');
411
+ more_deps.push_str(extra_dependencies_toml);
412
+ }
413
+
414
+ let facade = facade_path.display().to_string().replace('\\', "/");
415
+ let cargo_toml = format!(
416
+ r#"[package]
417
+ name = "tish_output"
418
+ version = "0.1.0"
419
+ edition = "2021"
420
+
421
+ # Standalone workspace so a parent Cargo workspace (e.g. the tish-gba repo, when
422
+ # the build dir lives under it) doesn't try to absorb this generated crate.
423
+ [workspace]
424
+
425
+ [[bin]]
426
+ name = "{cargo_name}"
427
+ path = "src/main.rs"
428
+
429
+ [dependencies]
430
+ tishlang_runtime = {{ package = "tishlang_runtime_gba", path = {facade:?} }}
431
+ agb = "{agb_version}"
432
+ {more_deps}
433
+ [profile.dev]
434
+ opt-level = 3
435
+ debug = true
436
+
437
+ [profile.dev.build-override]
438
+ opt-level = 3
439
+
440
+ [profile.release]
441
+ opt-level = 3
442
+ lto = "fat"
443
+ debug = true
444
+
445
+ [profile.release.build-override]
446
+ opt-level = 3
447
+ "#,
448
+ );
449
+ fs::write(build_dir.join("Cargo.toml"), cargo_toml)
450
+ .map_err(|e| format!("Cannot write Cargo.toml: {}", e))?;
451
+
452
+ fs::write(
453
+ build_dir.join("rust-toolchain.toml"),
454
+ "[toolchain]\nchannel = \"nightly\"\ncomponents = [\"rust-src\"]\n",
455
+ )
456
+ .map_err(|e| format!("Cannot write rust-toolchain.toml: {}", e))?;
457
+
458
+ // build-std + target + gba.ld rustflags (agb ships gba.ld via its build.rs).
459
+ fs::write(
460
+ build_dir.join(".cargo").join("config.toml"),
461
+ r#"[unstable]
462
+ build-std = ["core", "alloc"]
463
+ build-std-features = ["compiler-builtins-mem"]
464
+
465
+ [build]
466
+ target = "thumbv4t-none-eabi"
467
+
468
+ [target.thumbv4t-none-eabi]
469
+ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
470
+ "#,
471
+ )
472
+ .map_err(|e| format!("Cannot write .cargo/config.toml: {}", e))?;
473
+
474
+ let rust_main = if generated_native_rs.is_some() {
475
+ inject_generated_native_mod(rust_code)
476
+ } else {
477
+ rust_code.to_string()
478
+ };
479
+ if let Some(gen) = generated_native_rs {
480
+ // The `cargo:` wrapper emits the std header (`std::cell/rc/sync`); map it
481
+ // to the no_std facade equivalents (`alloc` is crate-wide via main.rs's
482
+ // `extern crate alloc`). The module body is otherwise Value-only.
483
+ let gen = gen
484
+ .replace("use std::cell::RefCell;", "use core::cell::RefCell;")
485
+ .replace("use std::rc::Rc;", "use alloc::rc::Rc;")
486
+ .replace("use std::sync::Arc;", "use tishlang_runtime::Arc;");
487
+ fs::write(build_dir.join("src").join("generated_native.rs"), gen)
488
+ .map_err(|e| format!("Cannot write generated_native.rs: {}", e))?;
489
+ }
490
+ fs::write(build_dir.join("src").join("main.rs"), rust_main)
491
+ .map_err(|e| format!("Cannot write main.rs: {}", e))?;
492
+
493
+ // Pass the GBA linker/target rustflags via the RUSTFLAGS env (which REPLACES,
494
+ // not joins, any `[target.*].rustflags` from config files). This matters when
495
+ // the build dir sits inside a project that already has a `.cargo/config.toml`
496
+ // with `-Tgba.ld` — cargo *joins* rustflags arrays across config files, so a
497
+ // config-file approach would pass `-Tgba.ld` twice ("region already defined").
498
+ // build-std still comes from the generated `[unstable]` config (not rustflags).
499
+ let status = Command::new("cargo")
500
+ .arg("build")
501
+ .arg("--release")
502
+ .current_dir(&build_dir)
503
+ .env(
504
+ "RUSTFLAGS",
505
+ "-Clink-arg=-Tgba.ld -Ctarget-cpu=arm7tdmi -Cforce-frame-pointers=yes",
506
+ )
507
+ .env("CARGO_TERM_COLOR", "always")
508
+ .status()
509
+ .map_err(|e| format!("Failed to run cargo for GBA build: {}", e))?;
510
+ if !status.success() {
511
+ return Err(format!(
512
+ "GBA cargo build failed (see output above). Build dir: {}",
513
+ build_dir.display()
514
+ ));
515
+ }
516
+
517
+ // ELF → .gba via agb-gbafix.
518
+ let elf = build_dir
519
+ .join("target")
520
+ .join("thumbv4t-none-eabi")
521
+ .join("release")
522
+ .join(&cargo_name);
523
+ if !elf.exists() {
524
+ return Err(format!("GBA ELF not found at {}", elf.display()));
525
+ }
526
+ let rom = if output_path.extension().is_some_and(|e| e == "gba") {
527
+ output_path.to_path_buf()
528
+ } else {
529
+ output_path.with_extension("gba")
530
+ };
531
+ let gbafix = Command::new("agb-gbafix")
532
+ .arg(&elf)
533
+ .arg("-o")
534
+ .arg(&rom)
535
+ .status()
536
+ .map_err(|e| {
537
+ format!(
538
+ "Failed to run agb-gbafix ({}). Install it with: cargo install agb-gbafix",
539
+ e
540
+ )
541
+ })?;
542
+ if !gbafix.success() {
543
+ return Err("agb-gbafix failed to produce the ROM".to_string());
544
+ }
545
+ Ok(())
546
+ }
547
+
302
548
  /// Remove a finished build's per-PID source directory (#384). Called only on SUCCESS, after the
303
549
  /// binary is copied out — the dir is then disposable (it holds only the generated crate source;
304
550
  /// compiled artifacts live in the shared workspace `target/`). Left in place on any earlier error so a
@@ -8,6 +8,9 @@ pub enum NativeArtifact {
8
8
  #[default]
9
9
  Bin,
10
10
  StaticLib,
11
+ /// A GBA ROM: cargo builds an ELF for `thumbv4t-none-eabi`, then `agb-gbafix`
12
+ /// converts it to a `.gba`.
13
+ GbaRom,
11
14
  }
12
15
 
13
16
  /// Options passed from the CLI into nested `cargo build`.
@@ -32,11 +35,28 @@ impl NativeBuildConfig {
32
35
  }
33
36
  }
34
37
 
38
+ /// Game Boy Advance ROM build (`tish build --target gba`): cross-compile to
39
+ /// `thumbv4t-none-eabi` with the `Gba` emit mode.
40
+ pub fn gba() -> Self {
41
+ Self {
42
+ artifact: NativeArtifact::GbaRom,
43
+ cargo_target: Some("thumbv4t-none-eabi".to_string()),
44
+ emit_mode: NativeEmitMode::Gba,
45
+ }
46
+ }
47
+
35
48
  pub fn is_cross_compile(&self) -> bool {
36
49
  self.cargo_target.is_some()
37
50
  }
38
51
  }
39
52
 
53
+ /// Feature cap for GBA builds: no host-only runtime capabilities (http/fs/process/
54
+ /// timers/ws/tty/pty). The facade `compile_error!`-stubs them, but capping keeps
55
+ /// the emitted prelude from importing names that don't exist on GBA.
56
+ pub fn gba_runtime_features(_features: &[String]) -> Vec<String> {
57
+ Vec::new()
58
+ }
59
+
40
60
  /// Filter runtime features for iOS sandbox builds.
41
61
  pub fn ios_runtime_features(features: &[String]) -> Vec<String> {
42
62
  const ALLOW: &[&str] = &["http", "http-hyper", "regex", "timers"];
@@ -97,13 +97,23 @@ pub fn compile_to_native_with_config(
97
97
 
98
98
  match backend {
99
99
  Backend::Rust => {
100
- let ios_cap = build_config.cargo_target.as_ref().map(|_| {
101
- ios_runtime_features(features)
100
+ // Cross-target builds cap runtime features to what the target supports:
101
+ // GBA gets none (no OS), iOS gets its sandbox allow-list.
102
+ let is_gba = build_config.emit_mode == tishlang_compile::NativeEmitMode::Gba;
103
+ let cap = |f: &[String]| -> Vec<String> {
104
+ if is_gba {
105
+ crate::config::gba_runtime_features(f)
106
+ } else {
107
+ ios_runtime_features(f)
108
+ }
109
+ };
110
+ let cross_cap = build_config.cargo_target.as_ref().map(|_| {
111
+ cap(features)
102
112
  .into_iter()
103
113
  .collect::<std::collections::HashSet<_>>()
104
114
  });
105
- let compile_features = if ios_cap.is_some() {
106
- ios_runtime_features(features)
115
+ let compile_features = if cross_cap.is_some() {
116
+ cap(features)
107
117
  } else {
108
118
  features.to_vec()
109
119
  };
@@ -114,14 +124,14 @@ pub fn compile_to_native_with_config(
114
124
  &compile_features,
115
125
  optimize,
116
126
  build_config.emit_mode,
117
- ios_cap.as_ref(),
127
+ cross_cap.as_ref(),
118
128
  )
119
129
  .map_err(|e| NativeError {
120
130
  message: e.to_string(),
121
131
  })?;
122
132
 
123
133
  let features_for_cargo = if build_config.cargo_target.is_some() {
124
- ios_runtime_features(&effective_features)
134
+ cap(&effective_features)
125
135
  } else {
126
136
  effective_features
127
137
  };
@@ -101,6 +101,34 @@ mod tests {
101
101
  assert!(parse("let x = async 5").is_err());
102
102
  }
103
103
 
104
+ /// #428 (function-expression half): `async function (…) {…}` / named `async function f(…) {…}`
105
+ /// in expression position parse as async ArrowFunctions — anonymous, named, object-property,
106
+ /// and IIFE-operand forms. Non-async function expressions keep `async_: false` (#464).
107
+ #[test]
108
+ fn async_function_expressions_parse() {
109
+ use tishlang_ast::Expr;
110
+ let fn_expr_async = |src: &str| -> bool {
111
+ let p = parse(src).expect("parse");
112
+ let Some(Statement::VarDecl { init: Some(e), .. }) = p.statements.first() else {
113
+ panic!("expected `let x = <fn expr>`");
114
+ };
115
+ match e {
116
+ Expr::ArrowFunction { async_, .. } => *async_,
117
+ other => panic!("expected ArrowFunction, got {other:?}"),
118
+ }
119
+ };
120
+ assert!(fn_expr_async("let f = async function() { return 1 }"), "anonymous");
121
+ assert!(fn_expr_async("let f = async function named(n) { return n }"), "named");
122
+ assert!(!fn_expr_async("let f = function() { return 1 }"), "plain fn expr is not async");
123
+ // Object-property and IIFE-operand positions.
124
+ assert!(parse("let o = { m: async function() { return 1 } }").is_ok());
125
+ assert!(parse("let p = (async function() { return 1 })()").is_ok());
126
+ assert!(parse("let q = (async () => { return 1 })()").is_ok());
127
+ // `async fn => …` stays a single-param arrow with param `fn` (#55 ident-compat) —
128
+ // the function-expression branch requires `(` or an identifier after `function`.
129
+ assert!(parse("let r = async fn => fn").is_ok());
130
+ }
131
+
104
132
  #[test]
105
133
  fn test_async_fn_parse() {
106
134
  let program = parse("async fn foo() { }").expect("parse async fn");
@@ -197,6 +197,11 @@ impl<'a> Parser<'a> {
197
197
  self.peek().map(|t| t.kind)
198
198
  }
199
199
 
200
+ /// Kind of the token `n` positions past the current one (`peek_ahead_kind(0)` == `peek_kind`).
201
+ fn peek_ahead_kind(&self, n: usize) -> Option<TokenKind> {
202
+ self.tokens.get(self.pos + n).map(|t| t.kind)
203
+ }
204
+
200
205
  fn advance(&mut self) -> Option<&Token> {
201
206
  let t = self.tokens.get(self.pos);
202
207
  if t.is_some() {
@@ -1060,7 +1065,8 @@ impl<'a> Parser<'a> {
1060
1065
 
1061
1066
  /// `interface Name { k: T, ... }` — desugared to `type Name = { ... }` so the checker
1062
1067
  /// (structural matching) and codegen (native `TishStruct_*`) treat it exactly like an
1063
- /// object-type alias. (`extends` is not yet supported.)
1068
+ /// object-type alias. `interface Name extends P1, P2 { ... }` desugars to an intersection
1069
+ /// of the parents with the body (see below), so inherited fields participate too.
1064
1070
  fn parse_interface(&mut self) -> Result<Statement, String> {
1065
1071
  let span_start = self.expect(TokenKind::Interface)?.span.start;
1066
1072
  let name_tok = self.expect(TokenKind::Ident)?;
@@ -2464,9 +2470,11 @@ impl<'a> Parser<'a> {
2464
2470
  value: Literal::Null,
2465
2471
  span,
2466
2472
  }),
2467
- // `async` in expression position (#428): an async arrow — `async () => …` or
2468
- // `async x => …`. The body runs in async context (`await` allowed). Async function
2469
- // expressions / method shorthand need function-expression support, which tish lacks.
2473
+ // `async` in expression position (#428): an async arrow — `async () => …` /
2474
+ // `async x => …` — or an async function EXPRESSION — `async function (…) {…}` /
2475
+ // named `async function f(…) {…}` (desugars to an async ArrowFunction exactly like
2476
+ // the non-async `function` expression form, #464). The body runs in async context
2477
+ // (`await` allowed).
2470
2478
  TokenKind::Async => {
2471
2479
  if matches!(self.peek_kind(), Some(TokenKind::LParen)) {
2472
2480
  self.advance(); // consume `(`
@@ -2475,6 +2483,19 @@ impl<'a> Parser<'a> {
2475
2483
  }
2476
2484
  return Err("Expected an arrow function after `async (`".to_string());
2477
2485
  }
2486
+ // `async function (…) {…}` / `async function f(…) {…}`: `function` (`Fn`)
2487
+ // followed by `(` or an identifier is a function expression, mirroring the
2488
+ // non-async arm below. Checked BEFORE the single-param-arrow branch, which
2489
+ // would otherwise consume `function` as a parameter name and demand `=>`.
2490
+ if matches!(self.peek_kind(), Some(TokenKind::Fn))
2491
+ && matches!(
2492
+ self.peek_ahead_kind(1),
2493
+ Some(TokenKind::LParen | TokenKind::Ident)
2494
+ )
2495
+ {
2496
+ self.advance(); // consume `function`
2497
+ return self.parse_function_expr_tail(&span, true);
2498
+ }
2478
2499
  if matches!(
2479
2500
  self.peek_kind(),
2480
2501
  Some(TokenKind::Ident | TokenKind::Fn | TokenKind::Type)
@@ -289,6 +289,9 @@ fn worker_thread(
289
289
  // headers slower than this instead of pinning the worker task.
290
290
  .header_read_timeout(std::time::Duration::from_secs(30))
291
291
  .serve_connection(io, svc)
292
+ // Required for `hyper::upgrade::on` to hand off the socket after a 101 — without
293
+ // this, a WebSocket upgrade sends the 101 but the upgrade future never resolves.
294
+ .with_upgrades()
292
295
  .await
293
296
  {
294
297
  // Chatty connection errors (resets, client timeouts) are
@@ -314,6 +317,13 @@ async fn handle_request(
314
317
  return Ok(static_route_response(route));
315
318
  }
316
319
 
320
+ // WebSocket upgrade: complete the handshake here and hand the socket to `ws.rs` — the VM handler
321
+ // never sees it. The program picks the connection up via `wsAccept`. (Single-port WS, issue #495.)
322
+ #[cfg(feature = "ws")]
323
+ if is_ws_upgrade(&req) {
324
+ return Ok(handle_ws_upgrade(req));
325
+ }
326
+
317
327
  // Slow path: cross the mpsc to the VM thread.
318
328
  let (method, url, path, query, headers, body_bytes) = extract_request(req).await;
319
329
  // Lossy UTF-8 decode to match the tiny_http path (`http.rs`): a body with invalid UTF-8 becomes
@@ -455,6 +465,66 @@ fn simple_error_response(status: StatusCode, msg: &str) -> Response<Full<Bytes>>
455
465
  .unwrap()
456
466
  }
457
467
 
468
+ /// A well-formed WebSocket upgrade handshake request (RFC 6455): `Upgrade: websocket`,
469
+ /// `Connection: Upgrade`, and a `Sec-WebSocket-Key`.
470
+ #[cfg(feature = "ws")]
471
+ fn is_ws_upgrade(req: &Request<hyper::body::Incoming>) -> bool {
472
+ let h = req.headers();
473
+ let upgrade_ws = h
474
+ .get(hyper::header::UPGRADE)
475
+ .and_then(|v| v.to_str().ok())
476
+ .map(|s| s.eq_ignore_ascii_case("websocket"))
477
+ .unwrap_or(false);
478
+ let conn_upgrade = h
479
+ .get(hyper::header::CONNECTION)
480
+ .and_then(|v| v.to_str().ok())
481
+ .map(|s| s.to_ascii_lowercase().split(',').any(|p| p.trim() == "upgrade"))
482
+ .unwrap_or(false);
483
+ upgrade_ws && conn_upgrade && h.contains_key(hyper::header::SEC_WEBSOCKET_KEY)
484
+ }
485
+
486
+ /// Complete a WebSocket handshake on the HTTP port: reply `101` with the derived accept key, then
487
+ /// (after the response flushes) take over the connection, wrap it as a server-role WebSocket, and
488
+ /// register it with `ws.rs` so the program can pick it up via `wsAccept`.
489
+ #[cfg(feature = "ws")]
490
+ fn handle_ws_upgrade(mut req: Request<hyper::body::Incoming>) -> Response<Full<Bytes>> {
491
+ use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
492
+ use tokio_tungstenite::tungstenite::protocol::Role;
493
+
494
+ let accept = match req.headers().get(hyper::header::SEC_WEBSOCKET_KEY) {
495
+ Some(k) => derive_accept_key(k.as_bytes()),
496
+ None => return simple_error_response(StatusCode::BAD_REQUEST, "missing Sec-WebSocket-Key"),
497
+ };
498
+
499
+ let on_upgrade = hyper::upgrade::on(&mut req);
500
+ tokio::spawn(async move {
501
+ match on_upgrade.await {
502
+ Ok(upgraded) => {
503
+ let io = TokioIo::new(upgraded);
504
+ let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(io, Role::Server, None)
505
+ .await;
506
+ let id = crate::ws::register_ws_stream(ws);
507
+ crate::ws::enqueue_upgraded_conn(id);
508
+ }
509
+ Err(e) => {
510
+ if std::env::var_os("TISH_HTTP_DEBUG").is_some() {
511
+ eprintln!("[tish http/hyper] ws upgrade failed: {}", e);
512
+ }
513
+ }
514
+ }
515
+ });
516
+
517
+ Response::builder()
518
+ .status(StatusCode::SWITCHING_PROTOCOLS)
519
+ .header(hyper::header::UPGRADE, "websocket")
520
+ .header(hyper::header::CONNECTION, "Upgrade")
521
+ .header(hyper::header::SEC_WEBSOCKET_ACCEPT, accept)
522
+ .body(Full::new(Bytes::new()))
523
+ .unwrap_or_else(|_| {
524
+ simple_error_response(StatusCode::INTERNAL_SERVER_ERROR, "ws upgrade build failed")
525
+ })
526
+ }
527
+
458
528
  /// Marker used by external callers (like the bench) to gate the backend
459
529
  /// choice. Returns true when `TISH_HTTP_BACKEND=hyper` is in the env.
460
530
  pub fn is_enabled_via_env() -> bool {