@tishlang/tish 1.0.28 → 1.0.33

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 (65) hide show
  1. package/Cargo.toml +1 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +15 -6
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/src/main.rs +8 -55
  5. package/crates/tish/tests/integration_test.rs +4 -3
  6. package/crates/tish_ast/src/ast.rs +65 -2
  7. package/crates/tish_build_utils/src/lib.rs +10 -2
  8. package/crates/tish_builtins/src/construct.rs +177 -0
  9. package/crates/tish_builtins/src/globals.rs +3 -5
  10. package/crates/tish_builtins/src/helpers.rs +2 -3
  11. package/crates/tish_builtins/src/lib.rs +1 -0
  12. package/crates/tish_builtins/src/object.rs +3 -4
  13. package/crates/tish_bytecode/src/compiler.rs +85 -11
  14. package/crates/tish_bytecode/src/opcode.rs +7 -3
  15. package/crates/tish_compile/Cargo.toml +1 -0
  16. package/crates/tish_compile/src/codegen.rs +233 -71
  17. package/crates/tish_compile/src/lib.rs +35 -0
  18. package/crates/tish_compile_js/Cargo.toml +1 -1
  19. package/crates/tish_compile_js/src/codegen.rs +43 -147
  20. package/crates/tish_compile_js/src/lib.rs +4 -7
  21. package/crates/tish_compile_js/src/tests_jsx.rs +89 -19
  22. package/crates/tish_compiler_wasm/src/lib.rs +2 -3
  23. package/crates/tish_core/Cargo.toml +4 -0
  24. package/crates/tish_core/src/console_style.rs +7 -1
  25. package/crates/tish_core/src/json.rs +1 -2
  26. package/crates/tish_core/src/macros.rs +2 -3
  27. package/crates/tish_core/src/value.rs +10 -5
  28. package/crates/tish_eval/Cargo.toml +2 -0
  29. package/crates/tish_eval/src/eval.rs +149 -72
  30. package/crates/tish_eval/src/http.rs +3 -4
  31. package/crates/tish_eval/src/regex.rs +3 -2
  32. package/crates/tish_eval/src/value.rs +11 -13
  33. package/crates/tish_eval/src/value_convert.rs +4 -8
  34. package/crates/tish_fmt/src/lib.rs +49 -10
  35. package/crates/tish_jsx_web/Cargo.toml +1 -1
  36. package/crates/tish_jsx_web/README.md +3 -16
  37. package/crates/tish_jsx_web/src/lib.rs +2 -157
  38. package/crates/tish_lexer/src/token.rs +2 -0
  39. package/crates/tish_lint/src/lib.rs +9 -0
  40. package/crates/tish_lsp/README.md +1 -1
  41. package/crates/tish_native/src/build.rs +16 -2
  42. package/crates/tish_opt/src/lib.rs +15 -0
  43. package/crates/tish_parser/src/lib.rs +101 -1
  44. package/crates/tish_parser/src/parser.rs +161 -50
  45. package/crates/tish_runtime/src/http.rs +4 -5
  46. package/crates/tish_runtime/src/http_fetch.rs +9 -10
  47. package/crates/tish_runtime/src/lib.rs +9 -2
  48. package/crates/tish_runtime/src/promise.rs +2 -3
  49. package/crates/tish_runtime/src/promise_io.rs +2 -3
  50. package/crates/tish_runtime/src/ws.rs +7 -7
  51. package/crates/tish_ui/Cargo.toml +17 -0
  52. package/crates/tish_ui/src/jsx.rs +390 -0
  53. package/crates/tish_ui/src/lib.rs +16 -0
  54. package/crates/tish_ui/src/runtime/hooks.rs +122 -0
  55. package/crates/tish_ui/src/runtime/mod.rs +173 -0
  56. package/crates/tish_vm/src/vm.rs +121 -27
  57. package/justfile +3 -3
  58. package/package.json +1 -1
  59. package/platform/darwin-arm64/tish +0 -0
  60. package/platform/darwin-x64/tish +0 -0
  61. package/platform/linux-arm64/tish +0 -0
  62. package/platform/linux-x64/tish +0 -0
  63. package/platform/win32-x64/tish.exe +0 -0
  64. package/crates/tish_compile_js/src/js_intrinsics.rs +0 -82
  65. package/crates/tish_jsx_web/vendor/Lattish.tish +0 -362
@@ -0,0 +1,390 @@
1
+ //! Shared JSX lowering: emit `h(tag, props, children)` as JavaScript or Rust (`Value`) source.
2
+
3
+ use tishlang_ast::{
4
+ ArrayElement, Expr, JsxAttrValue, JsxChild, JsxProp, Literal, ObjectProp,
5
+ };
6
+
7
+ /// Escape a Tish identifier for Rust output (matches `tishlang_compile` conventions).
8
+ pub fn escape_ident_rust(s: &str) -> String {
9
+ if s == "await" || s == "default" {
10
+ format!("_{}", s)
11
+ } else {
12
+ s.to_string()
13
+ }
14
+ }
15
+
16
+ /// Emit JSX expression as JavaScript (same rules as legacy `tishlang_compile_js`).
17
+ pub fn emit_jsx_js<F, E>(expr: &Expr, emit_expr: &mut F) -> Result<String, E>
18
+ where
19
+ F: FnMut(&Expr) -> Result<String, E>,
20
+ E: From<String>,
21
+ {
22
+ match expr {
23
+ Expr::JsxElement {
24
+ tag,
25
+ props,
26
+ children,
27
+ ..
28
+ } => {
29
+ let tag_str = if tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
30
+ tag.as_ref().to_string()
31
+ } else {
32
+ format!("{:?}", tag.as_ref())
33
+ };
34
+ let props_str = emit_jsx_props_js(props, emit_expr)?;
35
+ let children_strs: Result<Vec<_>, _> =
36
+ children.iter().map(|c| emit_jsx_child_js(c, emit_expr)).collect();
37
+ let children_str = children_strs?.join(", ");
38
+ Ok(format!("h({}, {}, [{}])", tag_str, props_str, children_str))
39
+ }
40
+ Expr::JsxFragment { children, .. } => {
41
+ let children_strs: Result<Vec<_>, _> =
42
+ children.iter().map(|c| emit_jsx_child_js(c, emit_expr)).collect();
43
+ let children_str = children_strs?.join(", ");
44
+ Ok(format!("h(Fragment, null, [{}])", children_str))
45
+ }
46
+ _ => Err(emit_err("emit_jsx_js: not a JSX expression")),
47
+ }
48
+ }
49
+
50
+ fn emit_err<E>(msg: &str) -> E
51
+ where
52
+ E: From<String>,
53
+ {
54
+ E::from(msg.to_string())
55
+ }
56
+
57
+ fn emit_jsx_props_js<F, E>(props: &[JsxProp], emit_expr: &mut F) -> Result<String, E>
58
+ where
59
+ F: FnMut(&Expr) -> Result<String, E>,
60
+ {
61
+ if props.is_empty() {
62
+ return Ok("null".to_string());
63
+ }
64
+ let parts: Result<Vec<_>, _> = props
65
+ .iter()
66
+ .map(|p| match p {
67
+ JsxProp::Attr { name, value } => {
68
+ let val = match value {
69
+ JsxAttrValue::String(s) => format!("{:?}", s.as_ref()),
70
+ JsxAttrValue::Expr(e) => emit_expr(e)?,
71
+ JsxAttrValue::ImplicitTrue => "true".to_string(),
72
+ };
73
+ let key = name.as_ref();
74
+ Ok(if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
75
+ format!("{}: {}", key, val)
76
+ } else {
77
+ format!("{:?}: {}", key, val)
78
+ })
79
+ }
80
+ JsxProp::Spread(e) => Ok(format!("...{}", emit_expr(e)?)),
81
+ })
82
+ .collect();
83
+ Ok(format!("{{ {} }}", parts?.join(", ")))
84
+ }
85
+
86
+ fn emit_jsx_child_js<F, E>(child: &JsxChild, emit_expr: &mut F) -> Result<String, E>
87
+ where
88
+ F: FnMut(&Expr) -> Result<String, E>,
89
+ {
90
+ match child {
91
+ JsxChild::Text(s) => Ok(format!("{:?}", s.as_ref())),
92
+ JsxChild::Expr(e) => {
93
+ let inner = emit_expr(e)?;
94
+ let needs_string = matches!(
95
+ e,
96
+ Expr::Literal {
97
+ value: Literal::Number(_) | Literal::Bool(_) | Literal::Null,
98
+ ..
99
+ }
100
+ );
101
+ Ok(if needs_string {
102
+ format!("String({})", inner)
103
+ } else {
104
+ inner
105
+ })
106
+ }
107
+ }
108
+ }
109
+
110
+ /// Emit JSX as Rust `Value` by calling `tishlang_ui::ui_h` directly (no closure capture of a local `h` binding).
111
+ pub fn emit_jsx_rust<F, E>(expr: &Expr, emit_expr: &mut F) -> Result<String, E>
112
+ where
113
+ F: FnMut(&Expr) -> Result<String, E>,
114
+ E: From<String>,
115
+ {
116
+ match expr {
117
+ Expr::JsxElement {
118
+ tag,
119
+ props,
120
+ children,
121
+ ..
122
+ } => {
123
+ let is_component = tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
124
+ let tag_rust = if is_component {
125
+ escape_ident_rust(tag.as_ref())
126
+ } else {
127
+ format!("Value::String({:?}.into())", tag.as_ref())
128
+ };
129
+ let props_rust = emit_jsx_props_rust(props, emit_expr)?;
130
+ let child_parts: Result<Vec<_>, _> = children
131
+ .iter()
132
+ .map(|c| emit_jsx_child_rust(c, emit_expr))
133
+ .collect();
134
+ let children_rust = format!(
135
+ "Value::Array(Rc::new(RefCell::new(vec![{}])))",
136
+ child_parts?.join(", ")
137
+ );
138
+ Ok(wrap_h_call_rust(&tag_rust, &props_rust, &children_rust))
139
+ }
140
+ Expr::JsxFragment { children, .. } => {
141
+ let child_parts: Result<Vec<_>, _> = children
142
+ .iter()
143
+ .map(|c| emit_jsx_child_rust(c, emit_expr))
144
+ .collect();
145
+ let children_rust = format!(
146
+ "Value::Array(Rc::new(RefCell::new(vec![{}])))",
147
+ child_parts?.join(", ")
148
+ );
149
+ Ok(wrap_h_call_rust(
150
+ "Fragment",
151
+ "Value::Null",
152
+ &children_rust,
153
+ ))
154
+ }
155
+ _ => Err(E::from("emit_jsx_rust: not a JSX expression".to_string())),
156
+ }
157
+ }
158
+
159
+ fn wrap_h_call_rust(tag: &str, props: &str, children: &str) -> String {
160
+ format!(
161
+ "tishlang_ui::ui_h(&[({}).clone(), ({}).clone(), ({}).clone()])",
162
+ tag, props, children
163
+ )
164
+ }
165
+
166
+ fn emit_jsx_props_rust<F, E>(props: &[JsxProp], emit_expr: &mut F) -> Result<String, E>
167
+ where
168
+ F: FnMut(&Expr) -> Result<String, E>,
169
+ E: From<String>,
170
+ {
171
+ if props.is_empty() {
172
+ return Ok("Value::Null".to_string());
173
+ }
174
+ let has_spread = props.iter().any(|p| matches!(p, JsxProp::Spread(_)));
175
+ if has_spread {
176
+ let mut parts = Vec::new();
177
+ for prop in props {
178
+ match prop {
179
+ JsxProp::Attr { name, value } => {
180
+ let val = match value {
181
+ JsxAttrValue::String(s) => {
182
+ format!("Value::String({:?}.into())", s.as_ref())
183
+ }
184
+ JsxAttrValue::Expr(e) => emit_expr(e)?,
185
+ JsxAttrValue::ImplicitTrue => "Value::Bool(true)".to_string(),
186
+ };
187
+ parts.push(format!(
188
+ "_obj.insert(Arc::from({:?}), ({}).clone());",
189
+ name.as_ref(),
190
+ val
191
+ ));
192
+ }
193
+ JsxProp::Spread(e) => {
194
+ let val = emit_expr(e)?;
195
+ parts.push(format!(
196
+ "if let Value::Object(ref _spread) = {} {{ for (k, v) in _spread.borrow().iter() {{ _obj.insert(Arc::clone(k), v.clone()); }} }}",
197
+ val
198
+ ));
199
+ }
200
+ }
201
+ }
202
+ Ok(format!(
203
+ "{{ let mut _obj: ObjectMap = ObjectMap::default(); {} Value::Object(Rc::new(RefCell::new(_obj))) }}",
204
+ parts.join(" ")
205
+ ))
206
+ } else {
207
+ let mut kv = Vec::new();
208
+ for prop in props {
209
+ if let JsxProp::Attr { name, value } = prop {
210
+ let val = match value {
211
+ JsxAttrValue::String(s) => {
212
+ format!("Value::String({:?}.into())", s.as_ref())
213
+ }
214
+ JsxAttrValue::Expr(e) => emit_expr(e)?,
215
+ JsxAttrValue::ImplicitTrue => "Value::Bool(true)".to_string(),
216
+ };
217
+ kv.push(format!(
218
+ "(Arc::from({:?}), ({}).clone())",
219
+ name.as_ref(),
220
+ val
221
+ ));
222
+ }
223
+ }
224
+ Ok(format!(
225
+ "Value::Object(Rc::new(RefCell::new(ObjectMap::from([{}]))))",
226
+ kv.join(", ")
227
+ ))
228
+ }
229
+ }
230
+
231
+ fn emit_jsx_child_rust<F, E>(child: &JsxChild, emit_expr: &mut F) -> Result<String, E>
232
+ where
233
+ F: FnMut(&Expr) -> Result<String, E>,
234
+ E: From<String>,
235
+ {
236
+ match child {
237
+ JsxChild::Text(s) => Ok(format!("Value::String({:?}.into())", s.as_ref())),
238
+ JsxChild::Expr(e) => {
239
+ let inner = emit_expr(e)?;
240
+ let needs_string = matches!(
241
+ e,
242
+ Expr::Literal {
243
+ value: Literal::Number(_) | Literal::Bool(_) | Literal::Null,
244
+ ..
245
+ }
246
+ );
247
+ Ok(if needs_string {
248
+ format!("Value::String(({}).to_display_string().into())", inner)
249
+ } else {
250
+ format!("({}).clone()", inner)
251
+ })
252
+ }
253
+ }
254
+ }
255
+
256
+ /// Whether the program contains any JSX syntax (for conditional native UI globals).
257
+ pub fn program_contains_jsx(program: &tishlang_ast::Program) -> bool {
258
+ program.statements.iter().any(stmt_contains_jsx)
259
+ }
260
+
261
+ fn stmt_contains_jsx(stmt: &tishlang_ast::Statement) -> bool {
262
+ use tishlang_ast::{ExportDeclaration, Statement};
263
+ match stmt {
264
+ Statement::Block { statements, .. } => statements.iter().any(stmt_contains_jsx),
265
+ Statement::VarDecl { init, .. } => init.as_ref().is_some_and(expr_contains_jsx),
266
+ Statement::VarDeclDestructure { init, .. } => expr_contains_jsx(init),
267
+ Statement::ExprStmt { expr, .. } => expr_contains_jsx(expr),
268
+ Statement::Return { value, .. } => value.as_ref().is_some_and(expr_contains_jsx),
269
+ Statement::If {
270
+ cond,
271
+ then_branch,
272
+ else_branch,
273
+ ..
274
+ } => {
275
+ expr_contains_jsx(cond)
276
+ || stmt_contains_jsx(then_branch)
277
+ || else_branch.as_ref().is_some_and(|s| stmt_contains_jsx(s))
278
+ }
279
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
280
+ expr_contains_jsx(cond) || stmt_contains_jsx(body)
281
+ }
282
+ Statement::For { init, cond, update, body, .. } => {
283
+ init.as_ref().is_some_and(|s| stmt_contains_jsx(s))
284
+ || cond.as_ref().is_some_and(expr_contains_jsx)
285
+ || update.as_ref().is_some_and(expr_contains_jsx)
286
+ || stmt_contains_jsx(body)
287
+ }
288
+ Statement::ForOf { iterable, body, .. } => {
289
+ expr_contains_jsx(iterable) || stmt_contains_jsx(body)
290
+ }
291
+ Statement::Switch { expr, cases, default_body, .. } => {
292
+ expr_contains_jsx(expr)
293
+ || cases.iter().any(|(e, ss)| {
294
+ e.as_ref().is_some_and(expr_contains_jsx) || ss.iter().any(stmt_contains_jsx)
295
+ })
296
+ || default_body
297
+ .as_ref()
298
+ .is_some_and(|ss| ss.iter().any(stmt_contains_jsx))
299
+ }
300
+ Statement::Try {
301
+ body,
302
+ catch_body,
303
+ finally_body,
304
+ ..
305
+ } => {
306
+ stmt_contains_jsx(body)
307
+ || catch_body.as_ref().is_some_and(|s| stmt_contains_jsx(s))
308
+ || finally_body.as_ref().is_some_and(|s| stmt_contains_jsx(s))
309
+ }
310
+ Statement::FunDecl { body, .. } => stmt_contains_jsx(body),
311
+ Statement::Throw { value, .. } => expr_contains_jsx(value),
312
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
313
+ ExportDeclaration::Named(inner) => stmt_contains_jsx(inner),
314
+ ExportDeclaration::Default(e) => expr_contains_jsx(e),
315
+ },
316
+ Statement::Import { .. } | Statement::Break { .. } | Statement::Continue { .. } => false,
317
+ }
318
+ }
319
+
320
+ fn expr_contains_jsx(expr: &Expr) -> bool {
321
+ match expr {
322
+ Expr::JsxElement { .. } | Expr::JsxFragment { .. } => true,
323
+ Expr::Binary { left, right, .. } => expr_contains_jsx(left) || expr_contains_jsx(right),
324
+ Expr::Unary { operand, .. } => expr_contains_jsx(operand),
325
+ Expr::Assign { value, .. } => expr_contains_jsx(value),
326
+ Expr::Call { callee, args, .. } => {
327
+ expr_contains_jsx(callee)
328
+ || args.iter().any(|a| match a {
329
+ tishlang_ast::CallArg::Expr(e) | tishlang_ast::CallArg::Spread(e) => {
330
+ expr_contains_jsx(e)
331
+ }
332
+ })
333
+ }
334
+ Expr::Member { object, prop, .. } => {
335
+ expr_contains_jsx(object)
336
+ || matches!(prop, tishlang_ast::MemberProp::Expr(e) if expr_contains_jsx(e))
337
+ }
338
+ Expr::Index { object, index, .. } => expr_contains_jsx(object) || expr_contains_jsx(index),
339
+ Expr::Conditional {
340
+ cond,
341
+ then_branch,
342
+ else_branch,
343
+ ..
344
+ } => {
345
+ expr_contains_jsx(cond)
346
+ || expr_contains_jsx(then_branch)
347
+ || expr_contains_jsx(else_branch)
348
+ }
349
+ Expr::Array { elements, .. } => elements.iter().any(|el| match el {
350
+ ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_contains_jsx(e),
351
+ }),
352
+ Expr::Object { props, .. } => props.iter().any(|p| match p {
353
+ ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_contains_jsx(e),
354
+ }),
355
+ Expr::ArrowFunction { body, .. } => match body {
356
+ tishlang_ast::ArrowBody::Expr(e) => expr_contains_jsx(e),
357
+ tishlang_ast::ArrowBody::Block(s) => stmt_contains_jsx(s),
358
+ },
359
+ Expr::NullishCoalesce { left, right, .. } => {
360
+ expr_contains_jsx(left) || expr_contains_jsx(right)
361
+ }
362
+ Expr::TemplateLiteral { exprs, .. } => exprs.iter().any(expr_contains_jsx),
363
+ Expr::Await { operand, .. } => expr_contains_jsx(operand),
364
+ Expr::TypeOf { operand, .. } => expr_contains_jsx(operand),
365
+ Expr::PostfixInc { .. }
366
+ | Expr::PrefixInc { .. }
367
+ | Expr::PostfixDec { .. }
368
+ | Expr::PrefixDec { .. } => false,
369
+ Expr::CompoundAssign { value, .. } | Expr::LogicalAssign { value, .. } => {
370
+ expr_contains_jsx(value)
371
+ }
372
+ Expr::MemberAssign { object, value, .. } => {
373
+ expr_contains_jsx(object) || expr_contains_jsx(value)
374
+ }
375
+ Expr::IndexAssign { object, index, value, .. } => {
376
+ expr_contains_jsx(object) || expr_contains_jsx(index) || expr_contains_jsx(value)
377
+ }
378
+ Expr::New { callee, args, .. } => {
379
+ expr_contains_jsx(callee)
380
+ || args.iter().any(|a| match a {
381
+ tishlang_ast::CallArg::Expr(e) | tishlang_ast::CallArg::Spread(e) => {
382
+ expr_contains_jsx(e)
383
+ }
384
+ })
385
+ }
386
+ Expr::Literal { .. }
387
+ | Expr::Ident { .. }
388
+ | Expr::NativeModuleLoad { .. } => false,
389
+ }
390
+ }
@@ -0,0 +1,16 @@
1
+ //! JSX lowering (compiler) and UI runtime (vnode + hooks + host) for cross-target Tish UI.
2
+ //!
3
+ //! - Feature **`compiler`**: AST → JS / Rust `h(...)` emission helpers (depends on `tishlang_ast`).
4
+ //! - Feature **`runtime`**: `Value`-based `h`, `Fragment`, hooks, and [`Host`] (depends on `tishlang_core`).
5
+
6
+ #[cfg(feature = "compiler")]
7
+ pub mod jsx;
8
+
9
+ #[cfg(feature = "runtime")]
10
+ pub mod runtime;
11
+
12
+ #[cfg(feature = "runtime")]
13
+ pub use runtime::{
14
+ fragment_value, install_thread_local_host, native_create_root, native_use_state, ui_h,
15
+ ui_text, with_thread_local_host, Host, HeadlessHost, FRAGMENT_SENTINEL,
16
+ };
@@ -0,0 +1,122 @@
1
+ //! Minimal hook state: `useState` + render flush (Lattish-style cursor reset).
2
+
3
+ use std::cell::{Cell, RefCell};
4
+ use std::rc::Rc;
5
+
6
+ use tishlang_core::{ObjectMap, Value};
7
+
8
+ use super::ACTIVE_HOST;
9
+
10
+ thread_local! {
11
+ pub static HOOK: RefCell<HookState> = RefCell::new(HookState::default());
12
+ static IN_FLUSH: Cell<bool> = Cell::new(false);
13
+ }
14
+
15
+ #[derive(Default)]
16
+ pub struct HookState {
17
+ pub state_slots: Rc<RefCell<Vec<Value>>>,
18
+ pub cursor: usize,
19
+ pub root_app: Option<Value>,
20
+ pub root_vnode: Option<Value>,
21
+ pub flush_scheduled: bool,
22
+ }
23
+
24
+ impl HookState {
25
+ pub fn reset_for_new_root(&mut self) {
26
+ self.state_slots.borrow_mut().clear();
27
+ self.cursor = 0;
28
+ self.root_vnode = None;
29
+ self.flush_scheduled = false;
30
+ }
31
+ }
32
+
33
+ /// `useState(initial)` → `[state, setState]` as a Tish array.
34
+ pub fn native_use_state(args: &[Value]) -> Value {
35
+ let initial = args.first().cloned().unwrap_or(Value::Null);
36
+ HOOK.with(|h| {
37
+ let mut st = h.borrow_mut();
38
+ let i = st.cursor;
39
+ st.cursor += 1;
40
+ let slots = Rc::clone(&st.state_slots);
41
+ while i >= slots.borrow().len() {
42
+ slots.borrow_mut().push(initial.clone());
43
+ }
44
+ let current = slots.borrow()[i].clone();
45
+ let idx = i;
46
+ let setter = Value::Function(Rc::new(move |a: &[Value]| {
47
+ let new_v = a.first().cloned().unwrap_or(Value::Null);
48
+ slots.borrow_mut()[idx] = new_v;
49
+ schedule_flush();
50
+ Value::Null
51
+ }));
52
+ Value::Array(Rc::new(RefCell::new(vec![current, setter])))
53
+ })
54
+ }
55
+
56
+ /// `createRoot(container)` → `{ render: (App) => { ... } }` (container ignored for headless native).
57
+ pub fn native_create_root(args: &[Value]) -> Value {
58
+ let _container = args.first();
59
+ let render_fn = Value::Function(Rc::new(|app_args: &[Value]| {
60
+ let app = app_args.first().cloned().unwrap_or(Value::Null);
61
+ HOOK.with(|h| {
62
+ let mut st = h.borrow_mut();
63
+ st.reset_for_new_root();
64
+ st.root_app = Some(app);
65
+ st.flush_scheduled = true;
66
+ });
67
+ drain_flush_queue();
68
+ Value::Null
69
+ }));
70
+ Value::Object(Rc::new(RefCell::new(ObjectMap::from([(
71
+ std::sync::Arc::from("render"),
72
+ render_fn,
73
+ )]))))
74
+ }
75
+
76
+ /// Request a re-render (coalesced; safe if called during flush).
77
+ pub fn schedule_flush() {
78
+ HOOK.with(|h| {
79
+ h.borrow_mut().flush_scheduled = true;
80
+ });
81
+ if IN_FLUSH.get() {
82
+ return;
83
+ }
84
+ drain_flush_queue();
85
+ }
86
+
87
+ fn drain_flush_queue() {
88
+ loop {
89
+ let run = HOOK.with(|h| {
90
+ let mut st = h.borrow_mut();
91
+ if st.flush_scheduled {
92
+ st.flush_scheduled = false;
93
+ true
94
+ } else {
95
+ false
96
+ }
97
+ });
98
+ if !run {
99
+ break;
100
+ }
101
+ IN_FLUSH.set(true);
102
+ HOOK.with(|h| {
103
+ let mut st = h.borrow_mut();
104
+ st.cursor = 0;
105
+ let Some(app) = st.root_app.clone() else {
106
+ return;
107
+ };
108
+ let Value::Function(f) = app else {
109
+ return;
110
+ };
111
+ let tree = f(&[]);
112
+ st.root_vnode = Some(tree.clone());
113
+ ACTIVE_HOST.with(|host_cell| {
114
+ let mut host_opt = host_cell.borrow_mut();
115
+ if let Some(host) = host_opt.as_deref_mut() {
116
+ host.commit_root(&tree);
117
+ }
118
+ });
119
+ });
120
+ IN_FLUSH.set(false);
121
+ }
122
+ }
@@ -0,0 +1,173 @@
1
+ //! UI runtime: `h`, `Fragment`, vnode shapes compatible with Lattish, minimal hooks, and [`Host`].
2
+
3
+ mod hooks;
4
+
5
+ use std::cell::RefCell;
6
+ use std::rc::Rc;
7
+ use std::sync::Arc;
8
+
9
+ pub use hooks::{native_create_root, native_use_state, schedule_flush, HookState, HOOK};
10
+
11
+ use tishlang_core::{ObjectMap, Value};
12
+
13
+ /// Sentinel string for `Fragment` (native). JS/Lattish uses `Symbol`; hosts compare via equality.
14
+ pub const FRAGMENT_SENTINEL: &str = "__tish_ui_Fragment__";
15
+
16
+ /// `Fragment` marker value for `h(Fragment, null, children)`.
17
+ pub fn fragment_value() -> Value {
18
+ Value::String(FRAGMENT_SENTINEL.into())
19
+ }
20
+
21
+ /// Returns true if `tag` refers to [`fragment_value`].
22
+ pub fn is_fragment_tag(tag: &Value) -> bool {
23
+ matches!(tag, Value::String(s) if s.as_ref() == FRAGMENT_SENTINEL)
24
+ }
25
+
26
+ /// `text(s)` helper — returns string as `Value::String` for JSX text nodes.
27
+ pub fn ui_text(args: &[Value]) -> Value {
28
+ let s = args
29
+ .first()
30
+ .map(|v| v.to_display_string())
31
+ .unwrap_or_default();
32
+ Value::String(s.into())
33
+ }
34
+
35
+ /// Vnode factory: `h(tag, props, children)` (Lattish-compatible shape).
36
+ pub fn ui_h(args: &[Value]) -> Value {
37
+ let tag = args.get(0).cloned().unwrap_or(Value::Null);
38
+ let props = args.get(1).cloned().unwrap_or(Value::Null);
39
+ let children_arg = args.get(2).cloned().unwrap_or(Value::Null);
40
+
41
+ let children_vec = normalize_children_list(children_arg);
42
+
43
+ if let Value::Function(f) = &tag {
44
+ let mut merged = if matches!(props, Value::Null) {
45
+ ObjectMap::default()
46
+ } else if let Value::Object(obj) = props {
47
+ obj.borrow().clone()
48
+ } else {
49
+ ObjectMap::default()
50
+ };
51
+ if !children_vec.is_empty() {
52
+ merged.insert(
53
+ Arc::from("children"),
54
+ Value::Array(Rc::new(RefCell::new(children_vec.clone()))),
55
+ );
56
+ }
57
+ return f(&[Value::Object(Rc::new(RefCell::new(merged)))]);
58
+ }
59
+
60
+ if is_fragment_tag(&tag) {
61
+ return vnode_fragment(children_vec);
62
+ }
63
+
64
+ let tag_str: Arc<str> = match tag {
65
+ Value::String(s) => s,
66
+ _ => return Value::Null,
67
+ };
68
+
69
+ vnode_element(tag_str, props, children_vec)
70
+ }
71
+
72
+ fn normalize_children_list(children_arg: Value) -> Vec<Value> {
73
+ match children_arg {
74
+ Value::Null => vec![],
75
+ Value::Array(a) => a.borrow().clone(),
76
+ other => vec![other],
77
+ }
78
+ }
79
+
80
+ fn vnode_element(tag: Arc<str>, props: Value, children: Vec<Value>) -> Value {
81
+ let mut m = ObjectMap::default();
82
+ m.insert(Arc::from("tag"), Value::String(tag));
83
+ m.insert(
84
+ Arc::from("props"),
85
+ if matches!(props, Value::Null) {
86
+ Value::Null
87
+ } else {
88
+ props
89
+ },
90
+ );
91
+ m.insert(
92
+ Arc::from("children"),
93
+ Value::Array(Rc::new(RefCell::new(children))),
94
+ );
95
+ m.insert(Arc::from("_el"), Value::Null);
96
+ Value::Object(Rc::new(RefCell::new(m)))
97
+ }
98
+
99
+ fn vnode_fragment(children: Vec<Value>) -> Value {
100
+ let mut m = ObjectMap::default();
101
+ m.insert(Arc::from("tag"), fragment_value());
102
+ m.insert(Arc::from("props"), Value::Null);
103
+ m.insert(
104
+ Arc::from("children"),
105
+ Value::Array(Rc::new(RefCell::new(children))),
106
+ );
107
+ m.insert(Arc::from("_el"), Value::Null);
108
+ Value::Object(Rc::new(RefCell::new(m)))
109
+ }
110
+
111
+ /// Pluggable UI backend (Floem, DOM, SwiftUI, …). Main-thread / single-threaded by default.
112
+ pub trait Host {
113
+ /// Apply a new root vnode (after each render flush).
114
+ fn commit_root(&mut self, vnode: &Value);
115
+ }
116
+
117
+ /// No-op / test host that only stores the last committed tree.
118
+ pub struct HeadlessHost {
119
+ pub last: Option<Value>,
120
+ }
121
+
122
+ impl Default for HeadlessHost {
123
+ fn default() -> Self {
124
+ Self { last: None }
125
+ }
126
+ }
127
+
128
+ impl Host for HeadlessHost {
129
+ fn commit_root(&mut self, vnode: &Value) {
130
+ self.last = Some(vnode.clone());
131
+ }
132
+ }
133
+
134
+ thread_local! {
135
+ static ACTIVE_HOST: RefCell<Option<Box<dyn Host>>> = RefCell::new(None);
136
+ }
137
+
138
+ /// Install the thread-local host used by [`schedule_flush`] / `createRoot`.
139
+ pub fn install_thread_local_host(host: Box<dyn Host>) {
140
+ ACTIVE_HOST.with(|c| {
141
+ *c.borrow_mut() = Some(host);
142
+ });
143
+ }
144
+
145
+ pub fn with_thread_local_host<R>(f: impl FnOnce(&mut dyn Host) -> R) -> Option<R> {
146
+ ACTIVE_HOST.with(|c| {
147
+ let mut opt = c.borrow_mut();
148
+ match opt.as_deref_mut() {
149
+ Some(host) => Some(f(host)),
150
+ None => None,
151
+ }
152
+ })
153
+ }
154
+
155
+ /// Tag registry hook for future host-specific intrinsic mapping (HTML tag → component kind).
156
+ #[derive(Default)]
157
+ pub struct TagRegistry;
158
+
159
+ impl TagRegistry {
160
+ pub fn new() -> Self {
161
+ Self
162
+ }
163
+ }
164
+
165
+ /// Placeholder for subset CSS / style object interpretation.
166
+ #[derive(Default)]
167
+ pub struct StyleInterpreter;
168
+
169
+ impl StyleInterpreter {
170
+ pub fn new() -> Self {
171
+ Self
172
+ }
173
+ }