@tishlang/tish-lint 2.43.8 → 3.1.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 (47) hide show
  1. package/bin/tish-lint +0 -0
  2. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  3. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  4. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  5. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  6. package/crates/tish/tests/scope_merge.rs +31 -0
  7. package/crates/tish_build_utils/src/lib.rs +131 -6
  8. package/crates/tish_builtins/src/globals.rs +27 -0
  9. package/crates/tish_compile/src/codegen.rs +1498 -132
  10. package/crates/tish_compile/src/resolve.rs +65 -9
  11. package/crates/tish_compile/src/schemes.rs +60 -5
  12. package/crates/tish_compile/src/types.rs +64 -32
  13. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  14. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  15. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  16. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  17. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  18. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  19. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  20. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  21. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  22. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  23. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  24. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  25. package/crates/tish_core/src/console_style.rs +4 -0
  26. package/crates/tish_core/src/json.rs +8 -0
  27. package/crates/tish_core/src/shape.rs +28 -3
  28. package/crates/tish_core/src/value.rs +123 -0
  29. package/crates/tish_lint/Cargo.toml +1 -1
  30. package/crates/tish_native/src/build.rs +4 -0
  31. package/crates/tish_runtime/Cargo.toml +20 -1
  32. package/crates/tish_runtime/src/crypto.rs +127 -0
  33. package/crates/tish_runtime/src/encoding.rs +157 -0
  34. package/crates/tish_runtime/src/fs_ext.rs +27 -0
  35. package/crates/tish_runtime/src/lib.rs +33 -0
  36. package/crates/tish_runtime/src/net.rs +482 -0
  37. package/crates/tish_runtime/src/process_spawn.rs +508 -0
  38. package/crates/tish_runtime/src/ws.rs +206 -23
  39. package/crates/tish_runtime/src/zip.rs +196 -0
  40. package/crates/tish_runtime_gba/src/lib.rs +188 -4
  41. package/crates/tish_vm/src/vm.rs +48 -0
  42. package/package.json +1 -1
  43. package/platform/darwin-arm64/tish-lint +0 -0
  44. package/platform/darwin-x64/tish-lint +0 -0
  45. package/platform/linux-arm64/tish-lint +0 -0
  46. package/platform/linux-x64/tish-lint +0 -0
  47. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -165,6 +165,35 @@ pub trait TishOpaque {
165
165
  fn as_any(&self) -> &dyn core::any::Any;
166
166
  }
167
167
 
168
+ /// A typed native struct (from an `interface` / typed object literal in native codegen) carried inside
169
+ /// a [`Value::Struct`] WITHOUT serialising to a hashmap object. Each generated `TishStruct_*` implements
170
+ /// it: a boxed function that returns/passes a typed struct keeps it by reference, property access
171
+ /// dispatches here (a small `match` on the field name — no string hash, no allocation), and it
172
+ /// materialises to a real `Value::Object` only when a boxed `Value::Struct` (a struct whose static
173
+ /// type was erased) crosses into genuinely dynamic territory (`JSON.stringify`, display,
174
+ /// `Object.keys`/`values`/`entries`, `for..in`, `in`, `Object.assign`, object-literal spread `{...s}`).
175
+ /// This is the RUNTIME fallback: when the struct's static type IS known at a reflection site, GBA
176
+ /// codegen lowers `Object.keys`/`values`/`entries`/`in`/`{...s}` to native field reads instead — the
177
+ /// struct is never boxed and this materialisation never runs. No `Send + Sync` bound: the `Value::Struct` variant is
178
+ /// `cfg(portable)` and `portable` excludes `send-values` (they are mutually exclusive), so the ref is
179
+ /// single-threaded `Rc` — mirroring the single-threaded [`TishOpaque`].
180
+ pub trait TishStruct {
181
+ /// Read a field as a boxed `Value` (`Null` if `key` is not a field). Backs `get_prop`.
182
+ fn tish_get(&self, key: &str) -> Value;
183
+ /// Write a field from a boxed `Value` (no-op if `key` is not a field). Backs `set_prop`.
184
+ fn tish_set(&mut self, key: &str, value: Value);
185
+ /// Materialise an ordered `Value::Object` copy (field-declaration order, JS insertion order). Used
186
+ /// for JSON / display / `Object`-interop, and as the non-portable `Value::from_struct` fallback so
187
+ /// off-GBA backends behave EXACTLY as the historical `object_from_pairs` boundary did.
188
+ fn tish_to_object(&self) -> Value;
189
+ /// `typeof` result — always "object".
190
+ fn tish_type_name(&self) -> &'static str {
191
+ "object"
192
+ }
193
+ /// Downcast hook — recover the concrete `TishStruct_*` (e.g. for a future typed fast path).
194
+ fn as_any(&self) -> &dyn core::any::Any;
195
+ }
196
+
168
197
  /// Trait for Promise-like values that can be awaited (block until settled).
169
198
  /// Implemented by the runtime for native compile; interpreter uses its own Promise.
170
199
  pub trait TishPromise: Send + Sync {
@@ -467,8 +496,24 @@ pub enum Value {
467
496
  Promise(Arc<dyn TishPromise>),
468
497
  /// Opaque handle to a native Rust type (e.g. Polars DataFrame).
469
498
  Opaque(Arc<dyn TishOpaque>),
499
+ /// A typed native struct carried BY REFERENCE (portable / GBA native codegen). Lets a boxed
500
+ /// function return or pass a typed struct without serialising it to a hashmap `Object` —
501
+ /// property access dispatches through [`TishStruct`] (a small match, no hash lookup / alloc), and
502
+ /// it materialises to an `Object` only on JSON / display / `Object.keys`-family / `for..in` /
503
+ /// `in` / `Object.assign` / `{...s}` spread. Built via
504
+ /// [`Value::from_struct`]. Portable-only: the ref is a single-threaded `Rc<RefCell<dyn …>>` and
505
+ /// `portable` excludes `send-values`, so this never violates `Send` (which off-portable `Value`
506
+ /// may need). A fat pointer is 16 B, so the 24 B `Value` size guard still holds.
507
+ #[cfg(feature = "portable")]
508
+ Struct(StructRef),
470
509
  }
471
510
 
511
+ /// Reference to a boxed typed struct: `Rc<RefCell<dyn TishStruct>>`. Portable-only (see [`TishStruct`]
512
+ /// / [`Value::Struct`]). `RefCell` is OUTSIDE the trait object so `set_prop` can borrow it mutably; the
513
+ /// `Rc<RefCell<Concrete>> → Rc<RefCell<dyn TishStruct>>` unsizing is a stable coercion (no nightly).
514
+ #[cfg(feature = "portable")]
515
+ pub type StructRef = alloc::rc::Rc<core::cell::RefCell<dyn TishStruct>>;
516
+
472
517
  // Size guard. `Value` is 24 bytes: `String` is thin (`ArcStr`, 8B), but `Function`/`Promise`/`Opaque`
473
518
  // are fat `Arc<dyn …>` (data+vtable, 16B) ⇒ 16B payload + discriminant = 24.
474
519
  //
@@ -513,6 +558,8 @@ pub enum ValueTag {
513
558
  RegExp,
514
559
  Promise,
515
560
  Opaque,
561
+ #[cfg(feature = "portable")]
562
+ Struct,
516
563
  }
517
564
 
518
565
  /// Borrowed view of a [`Value`]'s payload, mirroring the enum variants. Lets call sites
@@ -533,6 +580,8 @@ pub enum ValueRef<'a> {
533
580
  RegExp(&'a VmRef<TishRegExp>),
534
581
  Promise(&'a Arc<dyn TishPromise>),
535
582
  Opaque(&'a Arc<dyn TishOpaque>),
583
+ #[cfg(feature = "portable")]
584
+ Struct(&'a StructRef),
536
585
  }
537
586
 
538
587
  impl Value {
@@ -578,6 +627,8 @@ impl Value {
578
627
  Value::RegExp(r) => ValueRef::RegExp(r),
579
628
  Value::Promise(p) => ValueRef::Promise(p),
580
629
  Value::Opaque(o) => ValueRef::Opaque(o),
630
+ #[cfg(feature = "portable")]
631
+ Value::Struct(s) => ValueRef::Struct(s),
581
632
  }
582
633
  }
583
634
 
@@ -598,6 +649,8 @@ impl Value {
598
649
  Value::RegExp(_) => ValueTag::RegExp,
599
650
  Value::Promise(_) => ValueTag::Promise,
600
651
  Value::Opaque(_) => ValueTag::Opaque,
652
+ #[cfg(feature = "portable")]
653
+ Value::Struct(_) => ValueTag::Struct,
601
654
  }
602
655
  }
603
656
 
@@ -1256,6 +1309,8 @@ impl core::fmt::Debug for Value {
1256
1309
  ),
1257
1310
  Value::Promise(_) => write!(f, "Promise"),
1258
1311
  Value::Opaque(o) => write!(f, "{}(opaque)", o.type_name()),
1312
+ #[cfg(feature = "portable")]
1313
+ Value::Struct(s) => write!(f, "Struct({})", s.borrow().tish_type_name()),
1259
1314
  }
1260
1315
  }
1261
1316
  }
@@ -1466,6 +1521,9 @@ impl Value {
1466
1521
  Value::Function(_) => "[Function]".to_string(),
1467
1522
  Value::Promise(_) => "[object Promise]".to_string(),
1468
1523
  Value::Opaque(o) => format!("[object {}]", o.type_name()),
1524
+ // Display a boxed struct like the object it is (materialise once, then reuse the Object path).
1525
+ #[cfg(feature = "portable")]
1526
+ Value::Struct(s) => s.borrow().tish_to_object().to_display_string_guarded(ancestors),
1469
1527
  #[cfg(feature = "regex")]
1470
1528
  Value::RegExp(re) => {
1471
1529
  let re = re.borrow();
@@ -1517,6 +1575,8 @@ impl Value {
1517
1575
  .collect::<Vec<_>>()
1518
1576
  .join(","),
1519
1577
  Value::Object(_) => "[object Object]".to_string(),
1578
+ #[cfg(feature = "portable")]
1579
+ Value::Struct(_) => "[object Object]".to_string(),
1520
1580
  // ECMAScript ToString of a number (drops `-0`'s sign), distinct from the inspect form
1521
1581
  // that `to_display_string` would give for `-0`. (#247)
1522
1582
  Value::Number(n) => js_number_to_string(*n),
@@ -1561,6 +1621,10 @@ impl Value {
1561
1621
  (Value::Promise(a), Value::Promise(b)) => Arc::ptr_eq(a, b),
1562
1622
  (Value::Opaque(a), Value::Opaque(b)) => Arc::ptr_eq(a, b),
1563
1623
  (Value::Symbol(a), Value::Symbol(b)) => Arc::ptr_eq(a, b),
1624
+ // Struct-Value identity is `Rc` pointer equality, exactly like `Object` — two distinct
1625
+ // boxings compare unequal, but a struct aliased through one `Value` is `=== ` itself.
1626
+ #[cfg(feature = "portable")]
1627
+ (Value::Struct(a), Value::Struct(b)) => alloc::rc::Rc::ptr_eq(a, b),
1564
1628
  _ => false,
1565
1629
  }
1566
1630
  }
@@ -1613,6 +1677,63 @@ impl Value {
1613
1677
  }))
1614
1678
  }
1615
1679
 
1680
+ /// Box a typed native struct (a generated `TishStruct_*`). On portable (GBA) it is kept BY
1681
+ /// REFERENCE as a cheap [`Value::Struct`] — one `Rc` allocation, no per-field string keys / hashmap;
1682
+ /// property access later dispatches through [`TishStruct`]. On every other backend it serialises to
1683
+ /// an ordered `Value::Object` (via [`TishStruct::tish_to_object`]) — byte-identical to the historical
1684
+ /// `object_from_pairs` boundary, so off-GBA behaviour is unchanged. Codegen's struct-to-`Value`
1685
+ /// boxing emits a single `Value::from_struct(..)` call for all native targets.
1686
+ #[cfg(feature = "portable")]
1687
+ #[inline]
1688
+ pub fn from_struct<T: TishStruct + 'static>(v: T) -> Value {
1689
+ Value::Struct(alloc::rc::Rc::new(core::cell::RefCell::new(v)))
1690
+ }
1691
+ /// See the portable variant above — off-GBA this is exactly the old `object_from_pairs` boundary.
1692
+ #[cfg(not(feature = "portable"))]
1693
+ #[inline]
1694
+ pub fn from_struct<T: TishStruct>(v: T) -> Value {
1695
+ v.tish_to_object()
1696
+ }
1697
+
1698
+ /// Box a typed native struct BY SHARED REFERENCE — the boxing path codegen actually emits, so the
1699
+ /// whole-struct clone is paid ONLY where it's genuinely needed. On portable (GBA) the struct must
1700
+ /// be OWNED by the `Rc`, so it clones (identical to `from_struct((v).clone())`). Off-GBA it
1701
+ /// serialises the borrow directly via [`TishStruct::tish_to_object`] with NO whole-struct clone —
1702
+ /// byte-identical to the historical `object_from_pairs` boundary that read fields straight off the
1703
+ /// place (#565: the old `from_struct((v).clone())` cloned the entire struct just to serialise it).
1704
+ #[cfg(feature = "portable")]
1705
+ #[inline]
1706
+ pub fn from_struct_ref<T: TishStruct + Clone + 'static>(v: &T) -> Value {
1707
+ Value::Struct(alloc::rc::Rc::new(core::cell::RefCell::new(v.clone())))
1708
+ }
1709
+ /// See the portable variant — off-GBA this serialises the borrow with no whole-struct clone.
1710
+ #[cfg(not(feature = "portable"))]
1711
+ #[inline]
1712
+ pub fn from_struct_ref<T: TishStruct>(v: &T) -> Value {
1713
+ v.tish_to_object()
1714
+ }
1715
+
1716
+ /// If this value is a by-reference boxed struct of concrete type `T` (a `Value::Struct`), return a
1717
+ /// CLONE of it — the native fast path for `let x: T = <boxed struct return>`, so subsequent field
1718
+ /// reads are plain Rust loads instead of `get_prop`. Portable only: off-GBA there is no `Value::Struct`
1719
+ /// variant, so this is always `None` and codegen's `from_value_expr` falls back to the field-by-field
1720
+ /// `get_prop` rebuild (which also handles a genuine `Value::Object`). Returns `None` for any non-struct
1721
+ /// value or a struct of a different concrete type.
1722
+ #[cfg(feature = "portable")]
1723
+ #[inline]
1724
+ pub fn downcast_struct<T: TishStruct + Clone + 'static>(&self) -> Option<T> {
1725
+ match self {
1726
+ Value::Struct(s) => s.borrow().as_any().downcast_ref::<T>().cloned(),
1727
+ _ => None,
1728
+ }
1729
+ }
1730
+ /// See the portable variant — off-GBA there is no struct variant, so always `None`.
1731
+ #[cfg(not(feature = "portable"))]
1732
+ #[inline]
1733
+ pub fn downcast_struct<T: TishStruct + Clone + 'static>(&self) -> Option<T> {
1734
+ None
1735
+ }
1736
+
1616
1737
  /// Create an empty array Value.
1617
1738
  pub fn empty_array() -> Self {
1618
1739
  Value::Array(VmRef::new(Vec::new()))
@@ -1700,6 +1821,8 @@ impl Value {
1700
1821
  Value::Promise(_) => "object",
1701
1822
  Value::Opaque(o) => o.type_name(),
1702
1823
  Value::Symbol(_) => "symbol",
1824
+ #[cfg(feature = "portable")]
1825
+ Value::Struct(_) => "object",
1703
1826
  }
1704
1827
  }
1705
1828
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "2.43.8"
3
+ version = "3.1.0"
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.
@@ -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
 
@@ -0,0 +1,127 @@
1
+ //! Cryptographic hashing + secure random for Tish (`tish:crypto`), behind the `crypto` feature.
2
+ //!
3
+ //! import { sha256, sha256Hex, randomBytes } from 'tish:crypto'
4
+ //!
5
+ //! - `sha256(bytesOrString) -> number[]` (the 32-byte digest as a byte array)
6
+ //! - `sha256Hex(bytesOrString) -> string` (64 lowercase hex chars)
7
+ //! - `randomBytes(n) -> number[]` (n cryptographically-secure random bytes; n clamped to 1 MiB)
8
+ //!
9
+ //! Bytes are the `tish:fs` / `tish:encoding` representation (Numbers 0-255). A string argument is
10
+ //! hashed as its UTF-8 bytes. For PKCE: `base64UrlEncode(sha256(verifier))` (see `tish:encoding`).
11
+
12
+ use sha2::{Digest, Sha256};
13
+ use tishlang_core::{Value, VmRef};
14
+
15
+ /// Coerce arg 0 to bytes: a byte array (Numbers 0-255) or a string's UTF-8 bytes.
16
+ fn arg_bytes(args: &[Value]) -> Option<Vec<u8>> {
17
+ match args.first() {
18
+ Some(Value::Array(a)) => Some(
19
+ a.borrow()
20
+ .iter()
21
+ .map(|v| if let Value::Number(n) = v { *n as u8 } else { 0 })
22
+ .collect(),
23
+ ),
24
+ Some(Value::String(s)) => Some(s.as_bytes().to_vec()),
25
+ _ => None,
26
+ }
27
+ }
28
+
29
+ fn bytes_to_value(bytes: &[u8]) -> Value {
30
+ Value::Array(VmRef::new(
31
+ bytes.iter().map(|b| Value::Number(*b as f64)).collect(),
32
+ ))
33
+ }
34
+
35
+ fn digest_of(args: &[Value]) -> Option<[u8; 32]> {
36
+ let b = arg_bytes(args)?;
37
+ let mut h = Sha256::new();
38
+ h.update(&b);
39
+ Some(h.finalize().into())
40
+ }
41
+
42
+ pub fn sha256(args: &[Value]) -> Value {
43
+ match digest_of(args) {
44
+ Some(d) => bytes_to_value(&d),
45
+ None => Value::Null,
46
+ }
47
+ }
48
+
49
+ pub fn sha256_hex(args: &[Value]) -> Value {
50
+ match digest_of(args) {
51
+ Some(d) => {
52
+ let mut s = String::with_capacity(64);
53
+ for byte in d.iter() {
54
+ s.push_str(&format!("{byte:02x}"));
55
+ }
56
+ Value::String(s.into())
57
+ }
58
+ None => Value::Null,
59
+ }
60
+ }
61
+
62
+ pub fn random_bytes(args: &[Value]) -> Value {
63
+ let n = match args.first() {
64
+ Some(Value::Number(x)) if x.is_finite() && *x >= 0.0 => (*x as usize).min(1_048_576),
65
+ _ => return Value::Null,
66
+ };
67
+ // rand::random draws from the thread CSPRNG (ChaCha, OS-seeded) — same source fs_ext uses.
68
+ let bytes: Vec<u8> = (0..n).map(|_| rand::random::<u8>()).collect();
69
+ bytes_to_value(&bytes)
70
+ }
71
+
72
+ #[cfg(test)]
73
+ mod tests {
74
+ use super::*;
75
+
76
+ fn to_bytes(v: Value) -> Vec<u8> {
77
+ let Value::Array(a) = v else { panic!("not an array") };
78
+ let g = a.borrow();
79
+ let out: Vec<u8> = g
80
+ .iter()
81
+ .map(|x| if let Value::Number(n) = x { *n as u8 } else { 0 })
82
+ .collect();
83
+ out
84
+ }
85
+
86
+ #[test]
87
+ fn sha256_hex_known_vector() {
88
+ // The canonical NIST vector: SHA-256("abc").
89
+ let Value::String(s) = sha256_hex(&[Value::String("abc".into())]) else { panic!() };
90
+ assert_eq!(
91
+ s.to_string(),
92
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
93
+ );
94
+ }
95
+
96
+ #[test]
97
+ fn sha256_returns_32_bytes_matching_hex() {
98
+ let bytes = to_bytes(sha256(&[Value::String("abc".into())]));
99
+ assert_eq!(bytes.len(), 32);
100
+ assert_eq!(bytes[0], 0xba);
101
+ assert_eq!(bytes[31], 0xad);
102
+ }
103
+
104
+ #[test]
105
+ fn sha256_accepts_byte_array() {
106
+ // "abc" as a byte array must hash identically to the string.
107
+ let arr = Value::Array(VmRef::new(vec![
108
+ Value::Number(97.0),
109
+ Value::Number(98.0),
110
+ Value::Number(99.0),
111
+ ]));
112
+ let Value::String(s) = sha256_hex(&[arr]) else { panic!() };
113
+ assert_eq!(
114
+ s.to_string(),
115
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
116
+ );
117
+ }
118
+
119
+ #[test]
120
+ fn random_bytes_length_and_variance() {
121
+ assert_eq!(to_bytes(random_bytes(&[Value::Number(0.0)])).len(), 0);
122
+ let a = to_bytes(random_bytes(&[Value::Number(32.0)]));
123
+ let b = to_bytes(random_bytes(&[Value::Number(32.0)]));
124
+ assert_eq!(a.len(), 32);
125
+ assert_ne!(a, b, "two 32-byte draws must (almost surely) differ");
126
+ }
127
+ }
@@ -0,0 +1,157 @@
1
+ //! Base64 encoding for Tish (`tish:encoding`), behind the `encoding` feature.
2
+ //!
3
+ //! import { base64Encode, base64Decode, base64UrlEncode, base64UrlDecode } from 'tish:encoding'
4
+ //!
5
+ //! - `base64Encode(bytesOrString) -> string` (standard alphabet, padded)
6
+ //! - `base64Decode(string) -> number[] | null` (bytes 0-255; null on invalid input)
7
+ //! - `base64UrlEncode(bytesOrString) -> string` (URL-safe alphabet, NO padding — PKCE etc.)
8
+ //! - `base64UrlDecode(string) -> number[] | null`
9
+ //!
10
+ //! Bytes are the same representation `tish:fs` uses: an array of Numbers 0-255 (`readFileBytes`
11
+ //! returns this; `writeFile` accepts it). A string argument is encoded as its UTF-8 bytes, so
12
+ //! `base64Encode("hi")` works without a byte round-trip.
13
+
14
+ use base64::Engine;
15
+ use tishlang_core::{Value, VmRef};
16
+
17
+ /// Coerce arg 0 to bytes: a byte array (Numbers 0-255) or a string's UTF-8 bytes.
18
+ fn arg_bytes(args: &[Value]) -> Option<Vec<u8>> {
19
+ match args.first() {
20
+ Some(Value::Array(a)) => Some(
21
+ a.borrow()
22
+ .iter()
23
+ .map(|v| if let Value::Number(n) = v { *n as u8 } else { 0 })
24
+ .collect(),
25
+ ),
26
+ Some(Value::String(s)) => Some(s.as_bytes().to_vec()),
27
+ _ => None,
28
+ }
29
+ }
30
+
31
+ fn bytes_to_value(bytes: Vec<u8>) -> Value {
32
+ Value::Array(VmRef::new(
33
+ bytes.into_iter().map(|b| Value::Number(b as f64)).collect(),
34
+ ))
35
+ }
36
+
37
+ pub fn base64_encode(args: &[Value]) -> Value {
38
+ match arg_bytes(args) {
39
+ Some(b) => Value::String(base64::engine::general_purpose::STANDARD.encode(&b).into()),
40
+ None => Value::Null,
41
+ }
42
+ }
43
+
44
+ pub fn base64_decode(args: &[Value]) -> Value {
45
+ let s = match args.first() {
46
+ Some(Value::String(s)) => s.to_string(),
47
+ _ => return Value::Null,
48
+ };
49
+ match base64::engine::general_purpose::STANDARD.decode(s.as_bytes()) {
50
+ Ok(b) => bytes_to_value(b),
51
+ Err(_) => Value::Null,
52
+ }
53
+ }
54
+
55
+ pub fn base64_url_encode(args: &[Value]) -> Value {
56
+ match arg_bytes(args) {
57
+ Some(b) => Value::String(
58
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
59
+ .encode(&b)
60
+ .into(),
61
+ ),
62
+ None => Value::Null,
63
+ }
64
+ }
65
+
66
+ pub fn base64_url_decode(args: &[Value]) -> Value {
67
+ let s = match args.first() {
68
+ Some(Value::String(s)) => s.to_string(),
69
+ _ => return Value::Null,
70
+ };
71
+ match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.as_bytes()) {
72
+ Ok(b) => bytes_to_value(b),
73
+ Err(_) => Value::Null,
74
+ }
75
+ }
76
+
77
+ /// `utf8Decode(byteArray) -> string` — decode bytes as UTF-8 (lossy: invalid sequences become the
78
+ /// replacement char). The counterpart to `readFileBytes` / a streaming `reader.read()` chunk, whose
79
+ /// `value` is a byte array. Split streamed bytes on a newline BEFORE decoding a line so a multi-byte
80
+ /// sequence is never cut mid-decode (a `\n`=0x0A byte never occurs inside a UTF-8 multi-byte char).
81
+ pub fn utf8_decode(args: &[Value]) -> Value {
82
+ match arg_bytes(args) {
83
+ Some(b) => Value::String(String::from_utf8_lossy(&b).into_owned().into()),
84
+ None => Value::Null,
85
+ }
86
+ }
87
+
88
+ /// `utf8Encode(string) -> number[]` — the string's UTF-8 bytes as a byte array. (A byte-array
89
+ /// argument is passed through, so `utf8Encode(utf8Decode(x))` is a no-op round trip.)
90
+ pub fn utf8_encode(args: &[Value]) -> Value {
91
+ match arg_bytes(args) {
92
+ Some(b) => bytes_to_value(b),
93
+ None => Value::Null,
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use super::*;
100
+
101
+ fn to_bytes(v: Value) -> Vec<u8> {
102
+ let Value::Array(a) = v else { panic!("not an array") };
103
+ let g = a.borrow();
104
+ let out: Vec<u8> = g
105
+ .iter()
106
+ .map(|x| if let Value::Number(n) = x { *n as u8 } else { 0 })
107
+ .collect();
108
+ out
109
+ }
110
+
111
+ #[test]
112
+ fn encode_string_matches_known_vector() {
113
+ let enc = base64_encode(&[Value::String("hello world".into())]);
114
+ let Value::String(s) = &enc else { panic!() };
115
+ assert_eq!(s.to_string(), "aGVsbG8gd29ybGQ=");
116
+ }
117
+
118
+ #[test]
119
+ fn roundtrip_all_byte_values() {
120
+ let arr = Value::Array(VmRef::new(
121
+ (0u8..=255).map(|b| Value::Number(b as f64)).collect(),
122
+ ));
123
+ let enc = base64_encode(&[arr]);
124
+ assert_eq!(to_bytes(base64_decode(&[enc])), (0u8..=255).collect::<Vec<u8>>());
125
+ }
126
+
127
+ #[test]
128
+ fn url_safe_has_no_pad_plus_or_slash() {
129
+ // 0xFB,0xFF,0xBF encodes to +/ in standard; url-safe must use -_ and drop padding.
130
+ let arr = Value::Array(VmRef::new(vec![
131
+ Value::Number(251.0),
132
+ Value::Number(255.0),
133
+ Value::Number(191.0),
134
+ ]));
135
+ let Value::String(sv) = base64_url_encode(&[arr]) else { panic!() };
136
+ let s = sv.to_string();
137
+ assert!(!s.contains('=') && !s.contains('+') && !s.contains('/'), "got {s}");
138
+ // and it round-trips
139
+ assert_eq!(to_bytes(base64_url_decode(&[Value::String(sv)])), vec![251u8, 255, 191]);
140
+ }
141
+
142
+ #[test]
143
+ fn decode_invalid_is_null() {
144
+ assert!(matches!(
145
+ base64_decode(&[Value::String("!!! not base64 !!!".into())]),
146
+ Value::Null
147
+ ));
148
+ }
149
+
150
+ #[test]
151
+ fn utf8_roundtrip_multibyte() {
152
+ // "héllo 🚀" exercises 2-byte (é) and 4-byte (🚀) sequences.
153
+ let bytes = utf8_encode(&[Value::String("héllo 🚀".into())]);
154
+ let Value::String(s) = utf8_decode(&[bytes]) else { panic!() };
155
+ assert_eq!(s.to_string(), "héllo 🚀");
156
+ }
157
+ }
@@ -230,6 +230,32 @@ pub fn truncate_core(args: &[Value]) -> Result<Value, Value> {
230
230
  .map_err(io_err)
231
231
  }
232
232
 
233
+ /// `chmod(path, mode)` — set the file mode (a number, e.g. `0o600` = 384). Unix only; a no-op that
234
+ /// succeeds on other platforms. Needed to write a secrets file 0600.
235
+ pub fn chmod_core(args: &[Value]) -> Result<Value, Value> {
236
+ let mode = match args.get(1) {
237
+ Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => *n as u32,
238
+ _ => {
239
+ return Err(io_err(std::io::Error::new(
240
+ std::io::ErrorKind::InvalidInput,
241
+ "chmod: mode must be a number",
242
+ )))
243
+ }
244
+ };
245
+ #[cfg(unix)]
246
+ {
247
+ use std::os::unix::fs::PermissionsExt;
248
+ std::fs::set_permissions(path_arg(args, 0), std::fs::Permissions::from_mode(mode))
249
+ .map(|_| Value::Null)
250
+ .map_err(io_err)
251
+ }
252
+ #[cfg(not(unix))]
253
+ {
254
+ let _ = mode;
255
+ Ok(Value::Null)
256
+ }
257
+ }
258
+
233
259
  /// `mkdtemp(prefix)` — create a uniquely-named temp dir and return its path. Node appends 6 RANDOM
234
260
  /// characters; a timestamp suffix is both predictable (a temp-dir security smell) and collision-prone
235
261
  /// under rapid calls. Use a random suffix and rely on `create_dir`'s exclusive semantics, retrying on
@@ -319,6 +345,7 @@ fs_method!(copy_file, copy_file_promise, copy_file_core);
319
345
  fs_method!(realpath, realpath_promise, realpath_core);
320
346
  fs_method!(readlink, readlink_promise, readlink_core);
321
347
  fs_method!(truncate, truncate_promise, truncate_core);
348
+ fs_method!(chmod, chmod_promise, chmod_core);
322
349
  fs_method!(mkdtemp, mkdtemp_promise, mkdtemp_core);
323
350
  fs_method!(cp, cp_promise, cp_core);
324
351
 
@@ -16,6 +16,7 @@ pub use tishlang_core::ObjectMap;
16
16
  // pass) instead of materializing an intermediate `AHashMap` and rebuilding from it.
17
17
  pub use tishlang_core::{ObjectData, PropMap};
18
18
  pub use tishlang_core::Value;
19
+ pub use tishlang_core::TishStruct;
19
20
  pub use tishlang_core::NumArrayBacking;
20
21
  pub use tishlang_core::ArcStr;
21
22
  /// Used by native codegen for `f()` / `obj()` dispatch (`Value::Function` or `__call` on objects).
@@ -1777,6 +1778,38 @@ pub mod pty;
1777
1778
  #[cfg(feature = "pty")]
1778
1779
  pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
1779
1780
 
1781
+ #[cfg(feature = "process")]
1782
+ pub mod process_spawn;
1783
+ #[cfg(feature = "process")]
1784
+ pub use process_spawn::{
1785
+ process_close_stdin, process_kill, process_pid, process_read_stderr, process_read_stdout,
1786
+ process_spawn, process_wait, process_write_stdin,
1787
+ };
1788
+
1789
+ #[cfg(feature = "net")]
1790
+ pub mod net;
1791
+ #[cfg(feature = "net")]
1792
+ pub use net::{
1793
+ net_accept, net_close, net_connect, net_listen, net_probe, net_read, net_sleep, net_write,
1794
+ };
1795
+
1796
+ #[cfg(feature = "encoding")]
1797
+ pub mod encoding;
1798
+ #[cfg(feature = "encoding")]
1799
+ pub use encoding::{
1800
+ base64_decode, base64_encode, base64_url_decode, base64_url_encode, utf8_decode, utf8_encode,
1801
+ };
1802
+
1803
+ #[cfg(feature = "crypto")]
1804
+ pub mod crypto;
1805
+ #[cfg(feature = "crypto")]
1806
+ pub use crypto::{random_bytes, sha256, sha256_hex};
1807
+
1808
+ #[cfg(feature = "zip")]
1809
+ pub mod zip;
1810
+ #[cfg(feature = "zip")]
1811
+ pub use zip::{zip_entries, zip_extract};
1812
+
1780
1813
  #[cfg(feature = "ws")]
1781
1814
  pub use ws::{
1782
1815
  web_socket_client, web_socket_server_accept, web_socket_server_construct,