@tishlang/tish-lint 3.0.0 → 3.2.2

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 (32) hide show
  1. package/bin/tish-lint +0 -0
  2. package/crates/tish/src/cli_help.rs +4 -0
  3. package/crates/tish/src/main.rs +13 -2
  4. package/crates/tish/tests/rust_lib_crate.rs +140 -0
  5. package/crates/tish/tests/scope_merge.rs +31 -0
  6. package/crates/tish_build_utils/src/lib.rs +131 -6
  7. package/crates/tish_compile/src/codegen.rs +517 -19
  8. package/crates/tish_compile/src/lib.rs +14 -1
  9. package/crates/tish_compile/src/resolve.rs +12 -2
  10. package/crates/tish_compile/tests/regr_andor_rhs_move.rs +85 -0
  11. package/crates/tish_compile/tests/regr_rust_lib_emit.rs +150 -0
  12. package/crates/tish_core/src/shape.rs +28 -3
  13. package/crates/tish_fmt/src/lib.rs +54 -1
  14. package/crates/tish_lint/Cargo.toml +1 -1
  15. package/crates/tish_native/src/build.rs +131 -0
  16. package/crates/tish_native/src/config.rs +14 -0
  17. package/crates/tish_runtime/Cargo.toml +20 -1
  18. package/crates/tish_runtime/src/crypto.rs +127 -0
  19. package/crates/tish_runtime/src/encoding.rs +157 -0
  20. package/crates/tish_runtime/src/fs_ext.rs +27 -0
  21. package/crates/tish_runtime/src/lib.rs +32 -0
  22. package/crates/tish_runtime/src/net.rs +482 -0
  23. package/crates/tish_runtime/src/process_spawn.rs +508 -0
  24. package/crates/tish_runtime/src/ws.rs +206 -23
  25. package/crates/tish_runtime/src/zip.rs +196 -0
  26. package/crates/tish_runtime_gba/src/lib.rs +17 -0
  27. package/package.json +1 -1
  28. package/platform/darwin-arm64/tish-lint +0 -0
  29. package/platform/darwin-x64/tish-lint +0 -0
  30. package/platform/linux-arm64/tish-lint +0 -0
  31. package/platform/linux-x64/tish-lint +0 -0
  32. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -40,6 +40,19 @@ pub enum NativeEmitMode {
40
40
  DesktopBin,
41
41
  /// `[lib] crate-type = ["staticlib"]` — no `fn main()`, host calls `tish_ios_launch`.
42
42
  EmbeddedLib,
43
+ /// `[lib] crate-type = ["rlib"]` — a Rust *library* crate whose public API is the entry
44
+ /// module's `export fn`s, so a Tish library can be consumed from Rust the way `--target js`
45
+ /// lets it be consumed from JS. No `fn main()`, no `tish_ios_launch`.
46
+ ///
47
+ /// The module body still lowers to the ordinary whole-program `run()`; what differs is that
48
+ /// `run()` is invoked lazily behind an `ensure_init()` and its exported bindings are parked in
49
+ /// a `thread_local!` so they outlive the call. That keeps module-level state alive between
50
+ /// `pub fn` calls with the same semantics an ESM module has — including reassignment, since
51
+ /// top-level `let`s are captured by the emitted closures as `VmRef` cells.
52
+ ///
53
+ /// `thread_local!` (rather than a global) is forced by `Value`: `send-values` is off unless
54
+ /// `http` is enabled, so `VmRef` is `Rc<RefCell<_>>` and `Value` is `!Send`.
55
+ RustLib,
43
56
  /// Game Boy Advance ROM: `#![no_std]`, `#[agb::entry] fn agb_main(gba)`, links the
44
57
  /// `tishlang_runtime_gba` facade. Numbers/async lowering diverge; see codegen `emit_program`.
45
58
  Gba,
@@ -49,7 +62,7 @@ pub use codegen::CompileError;
49
62
  pub use codegen::{
50
63
  compile, compile_project, compile_project_full, compile_project_full_emit,
51
64
  compile_with_features, compile_with_native_modules, compile_with_native_modules_emit,
52
- compile_with_project_root,
65
+ compile_with_native_modules_emit_exports, compile_with_project_root,
53
66
  };
54
67
  pub use platform_resolve::{
55
68
  apply_resolve_env, parse_platform, parse_surface, platform_suffixes, platform_virtual_keys,
@@ -77,6 +77,10 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
77
77
  ("ws", "tish:ws"),
78
78
  ("tty", "tish:tty"),
79
79
  ("pty", "tish:pty"),
80
+ ("net", "tish:net"),
81
+ ("encoding", "tish:encoding"),
82
+ ("crypto", "tish:crypto"),
83
+ ("zip", "tish:zip"),
80
84
  ];
81
85
 
82
86
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
@@ -106,9 +110,14 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
106
110
  | "tish:ws"
107
111
  | "tish:tty"
108
112
  | "tish:pty"
113
+ | "tish:net"
114
+ | "tish:encoding"
115
+ | "tish:crypto"
116
+ | "tish:zip"
109
117
  ) || matches!(
110
118
  spec,
111
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
119
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
120
+ | "encoding" | "crypto" | "zip"
112
121
  )
113
122
  }
114
123
 
@@ -1029,7 +1038,8 @@ pub fn is_native_import(spec: &str) -> bool {
1029
1038
  || spec.starts_with("ffi:")
1030
1039
  || matches!(
1031
1040
  spec,
1032
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
1041
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
1042
+ | "crypto" | "zip"
1033
1043
  )
1034
1044
  }
1035
1045
 
@@ -0,0 +1,85 @@
1
+ //! Regression: `&&` / `||` yield the deciding OPERAND (JS semantics, #240). #328 fixed the left
2
+ //! operand to bind by `.clone()` rather than by move; the right operand had the identical hazard.
3
+ //!
4
+ //! When the right operand lowers to a bare local — `inGenBlock && currentTrack` — yielding it moved
5
+ //! that local out of scope, so any later read was `E0382: use of moved value`. It only bites when
6
+ //! the operand is a plain non-`Copy` `Value` local that is read again (typically the next iteration
7
+ //! of the enclosing loop), which is why it survived: the common right operand is a call or an
8
+ //! already-cloned expression, which are temporaries.
9
+ //!
10
+ //! Found compiling `@spacedevin/deck`'s `Parser.tish`, whose statement loop does exactly this. The
11
+ //! source runs fine under `--target js` and the interpreter, so the divergence was Rust-backend-only.
12
+ use tishlang_compile::compile;
13
+ use tishlang_parser::parse;
14
+
15
+ fn rust_for(src: &str) -> String {
16
+ compile(&parse(src).unwrap()).unwrap()
17
+ }
18
+
19
+ #[test]
20
+ fn bare_ident_right_operand_is_cloned_not_moved() {
21
+ let rust = rust_for(
22
+ r#"
23
+ fn f() {
24
+ let cur = null
25
+ let flag = true
26
+ let i = 0
27
+ while (i < 3) {
28
+ if (flag && cur) { return 1 }
29
+ if (flag && cur) { return 2 }
30
+ i = i + 1
31
+ }
32
+ return 0
33
+ }
34
+ "#,
35
+ );
36
+ assert!(
37
+ rust.contains("if __l.is_truthy() { cur.clone() }"),
38
+ "a bare-ident right operand must be cloned out, not moved:\n{rust}"
39
+ );
40
+ assert!(
41
+ !rust.contains("if __l.is_truthy() { cur }"),
42
+ "found the moving form, which is E0382 on the second read:\n{rust}"
43
+ );
44
+ }
45
+
46
+ #[test]
47
+ fn or_right_operand_gets_the_same_treatment() {
48
+ let rust = rust_for(
49
+ r#"
50
+ fn f() {
51
+ let a = null
52
+ let b = null
53
+ let i = 0
54
+ while (i < 3) {
55
+ let x = a || b
56
+ let y = a || b
57
+ i = i + 1
58
+ }
59
+ return 0
60
+ }
61
+ "#,
62
+ );
63
+ assert!(
64
+ rust.contains("else { b.clone() }"),
65
+ "`||` must clone a bare-ident right operand too:\n{rust}"
66
+ );
67
+ }
68
+
69
+ #[test]
70
+ fn non_ident_right_operands_are_not_given_a_redundant_clone() {
71
+ // A call result is already a temporary — cloning it would be pure overhead on a hot path.
72
+ let rust = rust_for(
73
+ r#"
74
+ fn g() { return 1 }
75
+ fn f() {
76
+ let flag = true
77
+ return flag && g()
78
+ }
79
+ "#,
80
+ );
81
+ assert!(
82
+ !rust.contains("))).clone().clone()"),
83
+ "an already-temporary right operand must not gain a second clone:\n{rust}"
84
+ );
85
+ }
@@ -0,0 +1,150 @@
1
+ //! `--target rust-lib` (`NativeEmitMode::RustLib`): a Tish library compiled to a Rust library crate
2
+ //! whose public API is the entry module's `export fn`s — the Rust counterpart of `--target js`.
3
+ //!
4
+ //! The module body still lowers to the ordinary whole-program `run()`. What the library emit adds is
5
+ //! (a) one `pub fn` per export and (b) keeping the exported bindings alive past `run()` in a
6
+ //! thread-local, so module-level state survives between calls. These tests pin that emitted shape;
7
+ //! the crate layout and the behavioural end (state persists, reassignment is observable, init runs
8
+ //! once) are covered in `crates/tish/tests/rust_lib_crate.rs`.
9
+ use std::path::PathBuf;
10
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
11
+
12
+ fn fixture(rel: &str) -> PathBuf {
13
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
14
+ manifest.join("../..").join(rel).canonicalize().unwrap()
15
+ }
16
+
17
+ fn compile_lib(rel: &str) -> String {
18
+ let path = fixture(rel);
19
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::RustLib, None)
20
+ .expect("rust-lib emit")
21
+ .0
22
+ }
23
+
24
+ #[test]
25
+ fn every_export_becomes_an_arity_matched_pub_fn() {
26
+ let rust = compile_lib("tests/regression/rust_lib_emit.tish");
27
+ for sig in [
28
+ "pub fn register(a0: Value, a1: Value) -> Value",
29
+ "pub fn lookup(a0: Value) -> Value",
30
+ "pub fn clearAll() -> Value",
31
+ "pub fn initRuns() -> Value",
32
+ "pub fn add(a0: Value, a1: Value) -> Value",
33
+ ] {
34
+ assert!(
35
+ rust.contains(sig),
36
+ "missing `{sig}` in the emitted library surface:\n{rust}"
37
+ );
38
+ }
39
+ }
40
+
41
+ #[test]
42
+ fn library_crate_has_no_binary_entry_point() {
43
+ let rust = compile_lib("tests/regression/rust_lib_emit.tish");
44
+ // A `fn main` (or the iOS C entry) would make this an executable/staticlib, not an rlib.
45
+ assert!(
46
+ !rust.contains("fn main()"),
47
+ "a library crate must not emit a binary entry point:\n{rust}"
48
+ );
49
+ assert!(
50
+ !rust.contains("tish_ios_launch"),
51
+ "the iOS staticlib entry belongs to EmbeddedLib, not RustLib:\n{rust}"
52
+ );
53
+ }
54
+
55
+ #[test]
56
+ fn exports_are_published_out_of_run_so_module_state_outlives_it() {
57
+ let rust = compile_lib("tests/regression/rust_lib_emit.tish");
58
+ // Without this call the closures — and the `VmRef` cells they captured — drop with `run()`'s
59
+ // frame, and every `pub fn` would see a fresh module on each call.
60
+ assert!(
61
+ rust.contains("__tish_publish_exports(::std::vec!["),
62
+ "run() must hand its exported bindings to the module table:\n{rust}"
63
+ );
64
+ assert!(
65
+ rust.contains("(\"clearAll\", clearAll.clone())"),
66
+ "each export must be published under its exported name:\n{rust}"
67
+ );
68
+ // `Value` is `!Send` unless `send-values` (which follows `http`) is on, so a `static` would not
69
+ // compile for the common configuration.
70
+ assert!(
71
+ rust.contains("thread_local! {") && rust.contains("static __TISH_MODULE"),
72
+ "module storage must be thread-local:\n{rust}"
73
+ );
74
+ }
75
+
76
+ #[test]
77
+ fn runtime_prelude_is_re_exported_so_consumers_cannot_version_skew_value() {
78
+ let rust = compile_lib("tests/regression/rust_lib_emit.tish");
79
+ assert!(
80
+ rust.contains("pub use tishlang_runtime::{console_debug"),
81
+ "RustLib must re-export the runtime prelude (so `Value` comes from THIS crate):\n{rust}"
82
+ );
83
+ assert!(
84
+ rust.contains("pub use tishlang_runtime as runtime;"),
85
+ "RustLib must re-export the runtime module:\n{rust}"
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn other_emit_modes_keep_the_prelude_private_and_gain_no_surface() {
91
+ let path = fixture("tests/regression/rust_lib_emit.tish");
92
+ let rust = compile_project_full(&path, path.parent(), &[], true)
93
+ .expect("desktop emit")
94
+ .0;
95
+ assert!(
96
+ !rust.contains("__TISH_MODULE") && !rust.contains("pub fn clearAll"),
97
+ "the library surface must be RustLib-only:\n{rust}"
98
+ );
99
+ assert!(
100
+ rust.contains("use tishlang_runtime::{console_debug")
101
+ && !rust.contains("pub use tishlang_runtime::{console_debug"),
102
+ "a binary must keep the runtime prelude private:\n{rust}"
103
+ );
104
+ }
105
+
106
+ #[test]
107
+ fn a_value_export_is_rejected_rather_than_silently_returning_null() {
108
+ // A value export has no arity and its Rust local need not even be a `Value` (a `const` string
109
+ // lowers to `&str`), so it cannot be published like a closure. Fail loudly instead.
110
+ let dir = std::env::temp_dir().join("tish_rustlib_value_export");
111
+ std::fs::create_dir_all(&dir).unwrap();
112
+ let path = dir.join("index.tish");
113
+ std::fs::write(&path, "export let answer = 42\nexport fn get() { return answer }\n").unwrap();
114
+ let err = compile_project_full_emit(
115
+ &path,
116
+ Some(dir.as_path()),
117
+ &[],
118
+ true,
119
+ NativeEmitMode::RustLib,
120
+ None,
121
+ )
122
+ .expect_err("a value export must be rejected");
123
+ assert!(
124
+ err.message.contains("only `export fn`") && err.message.contains("answer"),
125
+ "the error should name the offending export: {}",
126
+ err.message
127
+ );
128
+ }
129
+
130
+ #[test]
131
+ fn an_entry_module_with_no_exports_is_rejected() {
132
+ let dir = std::env::temp_dir().join("tish_rustlib_no_exports");
133
+ std::fs::create_dir_all(&dir).unwrap();
134
+ let path = dir.join("index.tish");
135
+ std::fs::write(&path, "fn unused() { return 1 }\n").unwrap();
136
+ let err = compile_project_full_emit(
137
+ &path,
138
+ Some(dir.as_path()),
139
+ &[],
140
+ true,
141
+ NativeEmitMode::RustLib,
142
+ None,
143
+ )
144
+ .expect_err("a crate with no public API must be rejected");
145
+ assert!(
146
+ err.message.contains("exports nothing"),
147
+ "unexpected error: {}",
148
+ err.message
149
+ );
150
+ }
@@ -12,9 +12,9 @@
12
12
  //! `PropMap` on a cache miss). Phase 1b will attach the ordered key list to each shape so objects can
13
13
  //! drop per-object key storage entirely (the butterfly representation).
14
14
 
15
- use crate::compat::{AHashMap, Arc, OnceLock, RwLock};
16
- #[cfg(feature = "portable")]
17
- use alloc::{vec, vec::Vec};
15
+ use crate::compat::Arc;
16
+ #[cfg(not(feature = "portable"))]
17
+ use crate::compat::{AHashMap, OnceLock, RwLock};
18
18
 
19
19
  /// Identity of an object's ordered key-set.
20
20
  pub type ShapeId = u32;
@@ -31,15 +31,18 @@ pub const DICT_SHAPE: ShapeId = u32::MAX;
31
31
  /// Edges are keyed by an `ahash` map, not the default SipHash one: `transition` is on the hot path of
32
32
  /// every object construction (and `JSON.parse` — profiled as ~16% of parse, almost all SipHash), and
33
33
  /// ahash is ~2–3× faster on short string keys. Random-seeded per process, so no HashDoS regression.
34
+ #[cfg(not(feature = "portable"))]
34
35
  #[derive(Default)]
35
36
  struct ShapeNode {
36
37
  transitions: AHashMap<Arc<str>, ShapeId>,
37
38
  }
38
39
 
40
+ #[cfg(not(feature = "portable"))]
39
41
  struct Registry {
40
42
  nodes: Vec<ShapeNode>,
41
43
  }
42
44
 
45
+ #[cfg(not(feature = "portable"))]
43
46
  fn registry() -> &'static RwLock<Registry> {
44
47
  static REG: OnceLock<RwLock<Registry>> = OnceLock::new();
45
48
  REG.get_or_init(|| {
@@ -56,6 +59,28 @@ fn registry() -> &'static RwLock<Registry> {
56
59
  /// object with the same construction path reuses it. Cheap on the hot path (a read-lock + one hashmap
57
60
  /// lookup once the edge exists). A `DICT_SHAPE` input (or shape-space exhaustion) stays `DICT_SHAPE`.
58
61
  pub fn transition(from: ShapeId, key: &Arc<str>) -> ShapeId {
62
+ // On `portable` (the embedded/GBA link) nothing ever READS a shape id: the only consumers are
63
+ // the bytecode VM (`tish_vm`) and the std runtime's inline caches (`tish_runtime`), and neither
64
+ // is linked there — the GBA facade's `get_prop` goes straight to `PropMap`. Maintaining the
65
+ // registry anyway cost a lock plus an ahash lookup on every new-key insert AND — since this
66
+ // file has no eviction of any kind — a permanent `ShapeNode` plus an `Arc::clone` of the key
67
+ // for every distinct construction path, for the life of the program, on a 136 KB heap.
68
+ // Opt out via the sentinel the file already defines for exactly this case: `DICT_SHAPE` never
69
+ // matches an inline cache, so any future reader degrades to the slow path rather than trusting
70
+ // a slot index that was never recorded.
71
+ #[cfg(feature = "portable")]
72
+ {
73
+ let _ = (from, key);
74
+ DICT_SHAPE
75
+ }
76
+ #[cfg(not(feature = "portable"))]
77
+ {
78
+ transition_tracked(from, key)
79
+ }
80
+ }
81
+
82
+ #[cfg(not(feature = "portable"))]
83
+ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
59
84
  if from == DICT_SHAPE {
60
85
  return DICT_SHAPE;
61
86
  }
@@ -1706,6 +1706,12 @@ impl Scanner {
1706
1706
  self.chars.get(self.i + 1).copied()
1707
1707
  }
1708
1708
 
1709
+ /// True when the char immediately before the cursor is `:`, i.e. the `//` about to
1710
+ /// be scanned is the scheme separator of a URL (`http://`) rather than a comment.
1711
+ fn prev_is_colon(&self) -> bool {
1712
+ self.i > 0 && self.chars.get(self.i - 1) == Some(&':')
1713
+ }
1714
+
1709
1715
  /// Consume one char, tracking line/col like the lexer (col resets after `\n`).
1710
1716
  fn bump(&mut self) -> Option<char> {
1711
1717
  let c = *self.chars.get(self.i)?;
@@ -1765,7 +1771,23 @@ impl Scanner {
1765
1771
  self.seen_nonws = true;
1766
1772
  self.scan_template();
1767
1773
  }
1768
- '/' if self.peek2() == Some('/') => self.scan_line_comment(),
1774
+ // `://` is a URL scheme separator, never a comment. This scanner has no JSX
1775
+ // awareness, so JSX TEXT — which, unlike a string or an attribute value, is bare
1776
+ // source — used to have its URLs read as comments:
1777
+ //
1778
+ // <div>Redirect URI: http://127.0.0.1/callback</div>
1779
+ //
1780
+ // scanned `//127.0.0.1/callback</div>` as a line comment, and the printer then
1781
+ // re-emitted it after the element. Worse, the re-emitted text is a REAL comment, so
1782
+ // every subsequent run found one more and the file grew without converging — and
1783
+ // `--check` could never pass on such a file. See tishlang/tish#578.
1784
+ //
1785
+ // Guarding on the preceding `:` fixes the whole class (http://, file://, dune://)
1786
+ // without teaching this scanner to parse JSX, which would mean distinguishing
1787
+ // `<div>` from `a < b` and risks breaking comment scanning in ordinary code.
1788
+ '/' if self.peek2() == Some('/') && !self.prev_is_colon() => {
1789
+ self.scan_line_comment()
1790
+ }
1769
1791
  '/' if self.peek2() == Some('*') => self.scan_block_comment(),
1770
1792
  ' ' | '\t' | '\r' | '\n' => {
1771
1793
  self.bump();
@@ -1884,6 +1906,37 @@ mod tests {
1884
1906
  let _ = tishlang_parser::parse(&out).unwrap();
1885
1907
  }
1886
1908
 
1909
+ /// tishlang/tish#578 — a URL in JSX TEXT had its `//` scanned as a line comment, so the
1910
+ /// printer re-emitted everything after it as a trailing comment. The re-emitted text was
1911
+ /// itself a real comment, so each run appended another copy and the file never converged.
1912
+ #[test]
1913
+ fn url_in_jsx_text_is_not_a_comment() {
1914
+ let src = "/** @jsxImportSource lattish */\n\nexport fn Demo() {\n return <div>Redirect URI: http://127.0.0.1/callback for details</div>\n}\n";
1915
+ let once = format_source(src).unwrap();
1916
+ // Not `!contains("//127.0.0.1")` — that substring is part of the legitimate URL. The
1917
+ // corruption signature is the text appearing TWICE: once in the JSX, once hoisted out.
1918
+ assert_eq!(
1919
+ once.matches("127.0.0.1").count(),
1920
+ 1,
1921
+ "URL text was duplicated into a comment:\n{once}"
1922
+ );
1923
+ // Idempotence is the property that actually broke: formatting must reach a fixed point,
1924
+ // otherwise `--check` can never pass no matter how many times the file is formatted.
1925
+ let twice = format_source(&once).unwrap();
1926
+ assert_eq!(once, twice, "formatting is not idempotent");
1927
+ let thrice = format_source(&twice).unwrap();
1928
+ assert_eq!(twice, thrice, "formatting is not idempotent");
1929
+ }
1930
+
1931
+ /// The guard keys off the `:` immediately before `//`, so ordinary trailing comments —
1932
+ /// including ones right after a colon-bearing construct — must still be recognised.
1933
+ #[test]
1934
+ fn ordinary_comments_still_scanned() {
1935
+ let src = "fn f() {\n let o = { a: 1 } // trailing\n return o\n}\n";
1936
+ let out = format_source(src).unwrap();
1937
+ assert!(out.contains("// trailing"), "comment was dropped:\n{out}");
1938
+ }
1939
+
1887
1940
  /// Debug-render the parsed AST with all `Span { … }` contents removed, so two programs compare
1888
1941
  /// equal iff they are structurally identical (reformatting legitimately changes spans).
1889
1942
  fn structure(src: &str) -> String {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "3.0.0"
3
+ version = "3.2.2"
4
4
  edition = "2021"
5
5
  description = "AST-based linter for Tish"
6
6
  license-file = { workspace = true }
@@ -18,6 +18,10 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
18
18
  "ws",
19
19
  "tty",
20
20
  "pty",
21
+ "net",
22
+ "encoding",
23
+ "crypto",
24
+ "zip",
21
25
  ];
22
26
 
23
27
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -145,6 +149,17 @@ pub fn build_via_cargo_with_config(
145
149
  project_root,
146
150
  );
147
151
  }
152
+ if build_config.artifact == NativeArtifact::RustLib {
153
+ return emit_rust_lib_crate(
154
+ rust_code,
155
+ native_modules,
156
+ output_path,
157
+ features,
158
+ extra_dependencies_toml,
159
+ generated_native_rs,
160
+ project_root,
161
+ );
162
+ }
148
163
 
149
164
  let out_stem = output_path
150
165
  .file_stem()
@@ -344,6 +359,122 @@ fn read_facade_agb_version(facade_path: &Path) -> Option<String> {
344
359
  /// Runs its own `cargo build` (NOT `run_cargo_build`) so the `.cargo/config.toml`
345
360
  /// rustflags (`-Tgba.ld`, `-Ctarget-cpu=arm7tdmi`) apply — `run_cargo_build` sets a
346
361
  /// `RUSTFLAGS` env that would shadow them.
362
+ /// Read `version = "…"` from the `[package]` section of a crate's `Cargo.toml`.
363
+ fn read_crate_version(crate_dir: &Path) -> Option<String> {
364
+ let toml = fs::read_to_string(crate_dir.join("Cargo.toml")).ok()?;
365
+ for line in toml.lines() {
366
+ let line = line.trim();
367
+ if let Some(rest) = line.strip_prefix("version") {
368
+ let rest = rest.trim_start();
369
+ if let Some(rest) = rest.strip_prefix('=') {
370
+ return Some(rest.trim().trim_matches('"').to_string());
371
+ }
372
+ }
373
+ // Only the first (package) table — stop before dependency versions.
374
+ if line.starts_with('[') && !line.starts_with("[package]") {
375
+ break;
376
+ }
377
+ }
378
+ None
379
+ }
380
+
381
+ /// `--target rust --emit lib`: write a Cargo **library source crate** to `output_path` and stop.
382
+ ///
383
+ /// This is the one artifact that does not run cargo. The consumer decides what to build it for and
384
+ /// how to depend on it, exactly as they would with any other crate — which is the point: it lets a
385
+ /// Tish library be consumed from Rust the way `--target js` lets it be consumed from JS.
386
+ ///
387
+ /// The `tishlang_runtime` dependency carries **both** `path` and `version`. Cargo prefers the path
388
+ /// locally and falls back to the registry version when the crate is published, so the emitted crate
389
+ /// is publishable without a post-processing step.
390
+ fn emit_rust_lib_crate(
391
+ rust_code: &str,
392
+ native_modules: Vec<ResolvedNativeModule>,
393
+ output_path: &Path,
394
+ features: &[String],
395
+ extra_dependencies_toml: &str,
396
+ generated_native_rs: Option<&str>,
397
+ project_root: Option<&Path>,
398
+ ) -> Result<(), String> {
399
+ let crate_name = tishlang_build_utils::cargo_target_name(
400
+ output_path
401
+ .file_name()
402
+ .and_then(|s| s.to_str())
403
+ .unwrap_or("tish_lib"),
404
+ );
405
+ let runtime_path = tishlang_build_utils::find_runtime_path_for_project(project_root)?;
406
+ let runtime_version = read_crate_version(Path::new(&runtime_path))
407
+ .map(|v| format!(", version = {:?}", v))
408
+ .unwrap_or_default();
409
+
410
+ let runtime_features = runtime_features_for_cargo(features);
411
+ let runtime_refs: Vec<&str> = runtime_features.iter().map(String::as_str).collect();
412
+ let features_str = if runtime_refs.is_empty() {
413
+ String::new()
414
+ } else {
415
+ format!(", features = {:?}", runtime_refs)
416
+ };
417
+
418
+ let native_deps: String = native_modules
419
+ .iter()
420
+ .filter(|m| m.use_path_dependency)
421
+ .map(|m| {
422
+ let path = m.crate_path.display().to_string().replace('\\', "/");
423
+ format!("{} = {{ path = {:?} }}\n", m.package_name, path)
424
+ })
425
+ .collect();
426
+
427
+ let mut more_deps = String::new();
428
+ if !native_deps.is_empty() {
429
+ more_deps.push_str(&format!("\n{}", native_deps));
430
+ }
431
+ if !extra_dependencies_toml.trim().is_empty() {
432
+ more_deps.push_str(&format!("\n{}", extra_dependencies_toml));
433
+ }
434
+
435
+ let lib_rs = if generated_native_rs.is_some() {
436
+ inject_generated_native_mod(rust_code)
437
+ } else {
438
+ rust_code.to_string()
439
+ };
440
+
441
+ let cargo_toml = format!(
442
+ r#"[package]
443
+ name = "{name}"
444
+ version = "0.1.0"
445
+ edition = "2021"
446
+
447
+ [lib]
448
+ name = "{name}"
449
+ crate-type = ["rlib"]
450
+ path = "src/lib.rs"
451
+
452
+ [dependencies]
453
+ tishlang_runtime = {{ path = {runtime:?}{version}{features} }}
454
+ {more}"#,
455
+ name = crate_name,
456
+ runtime = runtime_path,
457
+ version = runtime_version,
458
+ features = features_str,
459
+ more = more_deps,
460
+ );
461
+
462
+ let src_dir = output_path.join("src");
463
+ fs::create_dir_all(&src_dir)
464
+ .map_err(|e| format!("Cannot create {}: {}", src_dir.display(), e))?;
465
+ fs::write(output_path.join("Cargo.toml"), cargo_toml)
466
+ .map_err(|e| format!("Cannot write Cargo.toml: {}", e))?;
467
+ if let Some(gen) = generated_native_rs {
468
+ fs::write(src_dir.join("generated_native.rs"), gen)
469
+ .map_err(|e| format!("Cannot write generated_native.rs: {}", e))?;
470
+ }
471
+ fs::write(src_dir.join("lib.rs"), lib_rs)
472
+ .map_err(|e| format!("Cannot write lib.rs: {}", e))?;
473
+
474
+ println!("Emitted Rust library crate: {}", output_path.display());
475
+ Ok(())
476
+ }
477
+
347
478
  fn build_gba_rom(
348
479
  rust_code: &str,
349
480
  native_modules: Vec<ResolvedNativeModule>,
@@ -11,6 +11,10 @@ pub enum NativeArtifact {
11
11
  /// A GBA ROM: cargo builds an ELF for `thumbv4t-none-eabi`, then `agb-gbafix`
12
12
  /// converts it to a `.gba`.
13
13
  GbaRom,
14
+ /// A Rust *library source crate* (`crate-type = ["rlib"]`) whose public API is the entry
15
+ /// module's `export fn`s. Unlike every other artifact, cargo is **not** run — the emitted
16
+ /// crate directory is itself the output, for the consumer to add as a path/registry dependency.
17
+ RustLib,
14
18
  }
15
19
 
16
20
  /// Options passed from the CLI into nested `cargo build`.
@@ -45,6 +49,16 @@ impl NativeBuildConfig {
45
49
  }
46
50
  }
47
51
 
52
+ /// `tish build --target rust --emit lib`: emit a Cargo library crate and stop. Host target —
53
+ /// the consumer decides what to build it for.
54
+ pub fn rust_lib() -> Self {
55
+ Self {
56
+ artifact: NativeArtifact::RustLib,
57
+ cargo_target: None,
58
+ emit_mode: NativeEmitMode::RustLib,
59
+ }
60
+ }
61
+
48
62
  pub fn is_cross_compile(&self) -> bool {
49
63
  self.cargo_target.is_some()
50
64
  }
@@ -56,6 +56,15 @@ http-hyper = [
56
56
  # `mkdtemp` needs a random suffix (predictable/timestamp names are a temp-dir security smell).
57
57
  fs = ["promise", "dep:rand"]
58
58
  process = []
59
+ # Raw TCP sockets: DAP TCP transports, OAuth loopback listener, connect probes.
60
+ net = []
61
+ # Base64 encode/decode (tish:encoding) — bytes <-> string, for file-bytes-over-JSON (extension host
62
+ # vscode.workspace.fs, broker_read_file_base64) and PKCE (URL-safe, no-pad).
63
+ encoding = ["dep:base64"]
64
+ # SHA-256 + secure random (tish:crypto) — PKCE code_challenge/verifier, checkpoint dir hashing.
65
+ crypto = ["dep:sha2", "dep:rand"]
66
+ # Zip extraction (tish:zip) — VSIX / archive extract with `..`-traversal guard + unix-mode preserved.
67
+ zip = ["dep:zip"]
59
68
  # Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
60
69
  tty = ["dep:crossterm"]
61
70
  # Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
@@ -71,6 +80,7 @@ ws = [
71
80
  "tokio/net",
72
81
  "tokio/io-util",
73
82
  "dep:tokio-tungstenite",
83
+ "dep:rustls",
74
84
  "dep:futures-util",
75
85
  "dep:lazy_static",
76
86
  "send-values",
@@ -84,6 +94,9 @@ tishlang_builtins = { path = "../tish_builtins", version = ">=0.1" }
84
94
  tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "time", "sync"], optional = true }
85
95
  reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "stream", "multipart"], optional = true }
86
96
  futures = { version = "0.3.32", optional = true }
97
+ base64 = { version = "0.22", optional = true }
98
+ sha2 = { version = "0.10", optional = true }
99
+ zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
87
100
  bytes = { version = "1", optional = true }
88
101
  tiny_http = { version = "0.12.0", optional = true }
89
102
  # fs feature only: random suffix for `mkdtemp`.
@@ -100,7 +113,13 @@ hyper = { version = "1", default-features = false, features = ["server", "http1"
100
113
  hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
101
114
  http-body-util = { version = "0.1", optional = true }
102
115
  core_affinity = { version = "0.8", optional = true }
103
- tokio-tungstenite = { version = "0.29", optional = true }
116
+ # rustls-tls-native-roots gives `wss://` support (system cert store, no OpenSSL — matches reqwest's
117
+ # rustls backend). Without a TLS feature, connect_async can only do plain `ws://` and rejects wss.
118
+ tokio-tungstenite = { version = "0.29", optional = true, features = ["rustls-tls-native-roots"] }
119
+ # Direct rustls handle so ws.rs can install a process-default CryptoProvider: both aws-lc-rs and ring
120
+ # are in the tree (reqwest + transitive), so rustls 0.23 can't auto-select one, and connect_async's
121
+ # wss path needs the process default. aws_lc_rs matches reqwest's provider.
122
+ rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"], optional = true }
104
123
  futures-util = { version = "0.3", optional = true }
105
124
  lazy_static = { version = "1", optional = true }
106
125