@tishlang/tish 1.6.0 → 1.8.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 (113) hide show
  1. package/Cargo.toml +2 -0
  2. package/README.md +2 -0
  3. package/bin/tish +0 -0
  4. package/crates/js_to_tish/src/error.rs +2 -8
  5. package/crates/js_to_tish/src/transform/expr.rs +128 -137
  6. package/crates/js_to_tish/src/transform/stmt.rs +62 -32
  7. package/crates/tish/Cargo.toml +15 -5
  8. package/crates/tish/src/cargo_native_registry.rs +29 -0
  9. package/crates/tish/src/cli_help.rs +92 -39
  10. package/crates/tish/src/main.rs +172 -86
  11. package/crates/tish/src/repl_completion.rs +3 -3
  12. package/crates/tish/tests/cargo_example_compile.rs +4 -2
  13. package/crates/tish/tests/integration_test.rs +216 -54
  14. package/crates/tish/tests/run_optimize_stdout_parity.rs +3 -7
  15. package/crates/tish/tests/shortcircuit.rs +20 -5
  16. package/crates/tish_ast/src/ast.rs +92 -23
  17. package/crates/tish_build_utils/Cargo.toml +4 -0
  18. package/crates/tish_build_utils/src/lib.rs +136 -8
  19. package/crates/tish_builtins/Cargo.toml +5 -1
  20. package/crates/tish_builtins/src/array.rs +65 -33
  21. package/crates/tish_builtins/src/construct.rs +34 -39
  22. package/crates/tish_builtins/src/globals.rs +42 -26
  23. package/crates/tish_builtins/src/helpers.rs +2 -1
  24. package/crates/tish_builtins/src/lib.rs +5 -5
  25. package/crates/tish_builtins/src/math.rs +5 -3
  26. package/crates/tish_builtins/src/object.rs +3 -2
  27. package/crates/tish_builtins/src/string.rs +144 -22
  28. package/crates/tish_bytecode/src/chunk.rs +0 -1
  29. package/crates/tish_bytecode/src/compiler.rs +173 -71
  30. package/crates/tish_bytecode/src/opcode.rs +24 -6
  31. package/crates/tish_bytecode/src/peephole.rs +2 -2
  32. package/crates/tish_compile/Cargo.toml +1 -0
  33. package/crates/tish_compile/src/codegen.rs +1621 -453
  34. package/crates/tish_compile/src/infer.rs +75 -19
  35. package/crates/tish_compile/src/lib.rs +19 -8
  36. package/crates/tish_compile/src/resolve.rs +278 -137
  37. package/crates/tish_compile/src/types.rs +184 -24
  38. package/crates/tish_compile_js/Cargo.toml +1 -0
  39. package/crates/tish_compile_js/src/codegen.rs +181 -37
  40. package/crates/tish_compile_js/src/lib.rs +3 -1
  41. package/crates/tish_compile_js/src/tests_jsx.rs +30 -6
  42. package/crates/tish_compiler_wasm/src/lib.rs +16 -13
  43. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +69 -59
  44. package/crates/tish_core/Cargo.toml +8 -0
  45. package/crates/tish_core/src/json.rs +107 -56
  46. package/crates/tish_core/src/lib.rs +4 -2
  47. package/crates/tish_core/src/macros.rs +5 -5
  48. package/crates/tish_core/src/uri.rs +9 -6
  49. package/crates/tish_core/src/value.rs +145 -43
  50. package/crates/tish_core/src/vmref.rs +178 -0
  51. package/crates/tish_cranelift/src/link.rs +6 -9
  52. package/crates/tish_cranelift/src/lower.rs +14 -8
  53. package/crates/tish_eval/Cargo.toml +17 -2
  54. package/crates/tish_eval/src/eval.rs +474 -165
  55. package/crates/tish_eval/src/http.rs +61 -0
  56. package/crates/tish_eval/src/lib.rs +12 -8
  57. package/crates/tish_eval/src/natives.rs +136 -38
  58. package/crates/tish_eval/src/promise.rs +14 -8
  59. package/crates/tish_eval/src/timers.rs +28 -19
  60. package/crates/tish_eval/src/value.rs +17 -6
  61. package/crates/tish_eval/src/value_convert.rs +13 -5
  62. package/crates/tish_fmt/src/lib.rs +149 -43
  63. package/crates/tish_lexer/src/lib.rs +232 -63
  64. package/crates/tish_lexer/src/token.rs +10 -6
  65. package/crates/tish_llvm/src/lib.rs +17 -8
  66. package/crates/tish_lsp/Cargo.toml +4 -1
  67. package/crates/tish_lsp/README.md +1 -1
  68. package/crates/tish_lsp/src/builtin_goto.rs +261 -0
  69. package/crates/tish_lsp/src/import_goto.rs +549 -0
  70. package/crates/tish_lsp/src/main.rs +504 -106
  71. package/crates/tish_native/src/build.rs +4 -8
  72. package/crates/tish_native/src/lib.rs +54 -21
  73. package/crates/tish_opt/src/lib.rs +84 -52
  74. package/crates/tish_parser/src/lib.rs +45 -13
  75. package/crates/tish_parser/src/parser.rs +505 -130
  76. package/crates/tish_resolve/Cargo.toml +13 -0
  77. package/crates/tish_resolve/src/lib.rs +3436 -0
  78. package/crates/tish_resolve/src/pos.rs +133 -0
  79. package/crates/tish_runtime/Cargo.toml +68 -3
  80. package/crates/tish_runtime/src/http.rs +1136 -145
  81. package/crates/tish_runtime/src/http_fetch.rs +38 -27
  82. package/crates/tish_runtime/src/http_hyper.rs +418 -0
  83. package/crates/tish_runtime/src/http_prefork.rs +189 -0
  84. package/crates/tish_runtime/src/lib.rs +375 -189
  85. package/crates/tish_runtime/src/promise.rs +199 -40
  86. package/crates/tish_runtime/src/promise_io.rs +2 -1
  87. package/crates/tish_runtime/src/timers.rs +37 -1
  88. package/crates/tish_runtime/src/ws.rs +65 -42
  89. package/crates/tish_runtime/tests/fetch_readable_stream.rs +5 -4
  90. package/crates/tish_ui/src/jsx.rs +317 -27
  91. package/crates/tish_ui/src/lib.rs +5 -2
  92. package/crates/tish_ui/src/runtime/hooks.rs +406 -45
  93. package/crates/tish_ui/src/runtime/mod.rs +36 -9
  94. package/crates/tish_vm/Cargo.toml +15 -5
  95. package/crates/tish_vm/src/vm.rs +725 -281
  96. package/crates/tish_vm/tests/peephole_jump_chain_logical_or.rs +11 -4
  97. package/crates/tish_wasm/src/lib.rs +55 -42
  98. package/crates/tish_wasm_runtime/Cargo.toml +2 -1
  99. package/crates/tish_wasm_runtime/src/lib.rs +1 -1
  100. package/crates/tishlang_cargo_bindgen/Cargo.toml +26 -0
  101. package/crates/tishlang_cargo_bindgen/src/classify.rs +265 -0
  102. package/crates/tishlang_cargo_bindgen/src/discover.rs +120 -0
  103. package/crates/tishlang_cargo_bindgen/src/infer.rs +372 -0
  104. package/crates/tishlang_cargo_bindgen/src/lib.rs +350 -0
  105. package/crates/tishlang_cargo_bindgen/src/main.rs +164 -0
  106. package/crates/tishlang_cargo_bindgen/src/metadata.rs +114 -0
  107. package/justfile +8 -0
  108. package/package.json +1 -1
  109. package/platform/darwin-arm64/tish +0 -0
  110. package/platform/darwin-x64/tish +0 -0
  111. package/platform/linux-arm64/tish +0 -0
  112. package/platform/linux-x64/tish +0 -0
  113. package/platform/win32-x64/tish.exe +0 -0
@@ -7,7 +7,9 @@ mod error;
7
7
  #[cfg(test)]
8
8
  mod tests_jsx;
9
9
 
10
- pub use codegen::{compile_project_with_jsx, compile_with_jsx};
10
+ pub use codegen::{
11
+ compile_project_with_jsx, compile_project_with_jsx_and_source_map, compile_with_jsx, JsBundle,
12
+ };
11
13
  pub use error::CompileError;
12
14
 
13
15
  /// JSX lowers to `h` / `Fragment`; merge the `lattish` runtime for hooks and DOM.
@@ -11,7 +11,11 @@ mod tests {
11
11
  let src = r#"fn X() { return <div class="a">{"hi"}</div> }"#;
12
12
  let program = parse(src).unwrap();
13
13
  let js = compile_with_jsx(&program, false).unwrap();
14
- assert!(js.contains("h(\"div\", { class: \"a\" }, [\"hi\"])"), "{}", js);
14
+ assert!(
15
+ js.contains("h(\"div\", { class: \"a\" }, [\"hi\"])"),
16
+ "{}",
17
+ js
18
+ );
15
19
  assert!(!js.contains("function __h("));
16
20
  }
17
21
 
@@ -57,7 +61,11 @@ mod tests {
57
61
  let src = r#"fn X() { return <p>work!</p> }"#;
58
62
  let program = parse(src).unwrap();
59
63
  let js = compile_with_jsx(&program, false).unwrap();
60
- assert!(js.contains(r#""work!""#), "expected 'work!', got: {}", &js[..400.min(js.len())]);
64
+ assert!(
65
+ js.contains(r#""work!""#),
66
+ "expected 'work!', got: {}",
67
+ &js[..400.min(js.len())]
68
+ );
61
69
  }
62
70
 
63
71
  #[test]
@@ -65,7 +73,11 @@ mod tests {
65
73
  let src = r#"fn X() { return <p>hello 😔</p> }"#;
66
74
  let program = parse(src).unwrap();
67
75
  let js = compile_with_jsx(&program, false).unwrap();
68
- assert!(js.contains("😔"), "expected emoji, got: {}", &js[..400.min(js.len())]);
76
+ assert!(
77
+ js.contains("😔"),
78
+ "expected emoji, got: {}",
79
+ &js[..400.min(js.len())]
80
+ );
69
81
  }
70
82
 
71
83
  #[test]
@@ -93,10 +105,22 @@ mod tests {
93
105
  let src = r#"fn X() { return <div class="x">{"a"}</div> }"#;
94
106
  let program = parse(src).unwrap();
95
107
  let js = compile_with_jsx(&program, false).unwrap();
96
- assert!(js.contains("h(\"div\", { class: \"x\" }"), "{}", &js[..500.min(js.len())]);
108
+ assert!(
109
+ js.contains("h(\"div\", { class: \"x\" }"),
110
+ "{}",
111
+ &js[..500.min(js.len())]
112
+ );
97
113
  assert!(!js.contains("__vdom_h"), "{}", &js[..600.min(js.len())]);
98
- assert!(!js.contains("window.__LATTISH_JSX_VDOM"), "{}", &js[..600.min(js.len())]);
99
- assert!(!js.contains("__lattishVdomPatch"), "{}", &js[..600.min(js.len())]);
114
+ assert!(
115
+ !js.contains("window.__LATTISH_JSX_VDOM"),
116
+ "{}",
117
+ &js[..600.min(js.len())]
118
+ );
119
+ assert!(
120
+ !js.contains("__lattishVdomPatch"),
121
+ "{}",
122
+ &js[..600.min(js.len())]
123
+ );
100
124
  }
101
125
 
102
126
  /// Component calls like {Panel()} return DOM elements. Wrapping in String() produces [object HTMLDivElement].
@@ -14,29 +14,34 @@ use wasm_bindgen::prelude::*;
14
14
 
15
15
  #[wasm_bindgen]
16
16
  pub fn compile_to_bytecode(source: &str) -> Result<String, JsValue> {
17
- let program = tishlang_parser::parse(source.trim()).map_err(|e| JsValue::from_str(&e.to_string()))?;
17
+ let program =
18
+ tishlang_parser::parse(source.trim()).map_err(|e| JsValue::from_str(&e.to_string()))?;
18
19
  let program = tishlang_opt::optimize(&program);
19
- let chunk = tishlang_bytecode::compile(&program).map_err(|e| JsValue::from_str(&e.to_string()))?;
20
+ let chunk =
21
+ tishlang_bytecode::compile(&program).map_err(|e| JsValue::from_str(&e.to_string()))?;
20
22
  Ok(base64::engine::general_purpose::STANDARD.encode(tishlang_bytecode::serialize(&chunk)))
21
23
  }
22
24
 
23
25
  #[wasm_bindgen]
24
26
  pub fn compile_to_js(source: &str) -> Result<String, JsValue> {
25
- let program = tishlang_parser::parse(source.trim()).map_err(|e| JsValue::from_str(&e.to_string()))?;
26
- tishlang_compile_js::compile_with_jsx(&program, true)
27
- .map_err(|e| JsValue::from_str(&e.message))
27
+ let program =
28
+ tishlang_parser::parse(source.trim()).map_err(|e| JsValue::from_str(&e.to_string()))?;
29
+ tishlang_compile_js::compile_with_jsx(&program, true).map_err(|e| JsValue::from_str(&e.message))
28
30
  }
29
31
 
30
32
  #[wasm_bindgen]
31
- pub fn compile_to_bytecode_with_imports(entry_path: &str, files_json: &str) -> Result<String, JsValue> {
33
+ pub fn compile_to_bytecode_with_imports(
34
+ entry_path: &str,
35
+ files_json: &str,
36
+ ) -> Result<String, JsValue> {
32
37
  let files: HashMap<String, String> = serde_json::from_str(files_json)
33
38
  .map_err(|e| JsValue::from_str(&format!("Invalid files JSON: {}", e)))?;
34
- let modules = resolve_virtual(entry_path, &files)
35
- .map_err(|e| JsValue::from_str(&e))?;
39
+ let modules = resolve_virtual(entry_path, &files).map_err(|e| JsValue::from_str(&e))?;
36
40
  detect_cycles_virtual(&modules).map_err(|e| JsValue::from_str(&e))?;
37
41
  let program = merge_modules_virtual(modules).map_err(|e| JsValue::from_str(&e))?;
38
42
  let program = tishlang_opt::optimize(&program);
39
- let chunk = tishlang_bytecode::compile(&program).map_err(|e| JsValue::from_str(&e.to_string()))?;
43
+ let chunk =
44
+ tishlang_bytecode::compile(&program).map_err(|e| JsValue::from_str(&e.to_string()))?;
40
45
  Ok(base64::engine::general_purpose::STANDARD.encode(tishlang_bytecode::serialize(&chunk)))
41
46
  }
42
47
 
@@ -44,11 +49,9 @@ pub fn compile_to_bytecode_with_imports(entry_path: &str, files_json: &str) -> R
44
49
  pub fn compile_to_js_with_imports(entry_path: &str, files_json: &str) -> Result<String, JsValue> {
45
50
  let files: HashMap<String, String> = serde_json::from_str(files_json)
46
51
  .map_err(|e| JsValue::from_str(&format!("Invalid files JSON: {}", e)))?;
47
- let modules = resolve_virtual(entry_path, &files)
48
- .map_err(|e| JsValue::from_str(&e))?;
52
+ let modules = resolve_virtual(entry_path, &files).map_err(|e| JsValue::from_str(&e))?;
49
53
  detect_cycles_virtual(&modules).map_err(|e| JsValue::from_str(&e))?;
50
54
  let program = merge_modules_virtual(modules).map_err(|e| JsValue::from_str(&e))?;
51
55
  let program = tishlang_opt::optimize(&program);
52
- tishlang_compile_js::compile_with_jsx(&program, true)
53
- .map_err(|e| JsValue::from_str(&e.message))
56
+ tishlang_compile_js::compile_with_jsx(&program, true).map_err(|e| JsValue::from_str(&e.message))
54
57
  }
@@ -17,6 +17,7 @@ pub struct VirtualModule {
17
17
  const BUILTIN_ALIASES: &[(&str, &str)] = &[
18
18
  ("fs", "tish:fs"),
19
19
  ("http", "tish:http"),
20
+ ("timers", "tish:timers"),
20
21
  ("process", "tish:process"),
21
22
  ("ws", "tish:ws"),
22
23
  ];
@@ -35,7 +36,7 @@ fn is_native_import(spec: &str) -> bool {
35
36
  spec.starts_with("tish:")
36
37
  || spec.starts_with("cargo:")
37
38
  || spec.starts_with('@')
38
- || matches!(spec, "fs" | "http" | "process" | "ws")
39
+ || matches!(spec, "fs" | "http" | "timers" | "process" | "ws")
39
40
  }
40
41
 
41
42
  /// Normalize a virtual path: resolve . and .. components.
@@ -109,10 +110,16 @@ pub fn resolve_virtual(
109
110
  if files.contains_key(&with_ext) {
110
111
  with_ext
111
112
  } else {
112
- return Err(format!("Entry file '{}' not in virtual file map", entry_path));
113
+ return Err(format!(
114
+ "Entry file '{}' not in virtual file map",
115
+ entry_path
116
+ ));
113
117
  }
114
118
  } else {
115
- return Err(format!("Entry file '{}' not in virtual file map", entry_path));
119
+ return Err(format!(
120
+ "Entry file '{}' not in virtual file map",
121
+ entry_path
122
+ ));
116
123
  };
117
124
 
118
125
  let mut visited = HashSet::new();
@@ -148,9 +155,9 @@ fn load_module_recursive(
148
155
  }
149
156
  visited.insert(module_path.to_string());
150
157
 
151
- let source = files.get(module_path).ok_or_else(|| {
152
- format!("Module '{}' not in virtual file map", module_path)
153
- })?;
158
+ let source = files
159
+ .get(module_path)
160
+ .ok_or_else(|| format!("Module '{}' not in virtual file map", module_path))?;
154
161
  let program = tishlang_parser::parse(source.trim())
155
162
  .map_err(|e| format!("Parse error in {}: {}", module_path, e))?;
156
163
 
@@ -162,13 +169,7 @@ fn load_module_recursive(
162
169
  }
163
170
  let dep_key = resolve_import_to_key(from, from_dir, files)?;
164
171
  if !path_to_module.contains_key(&dep_key) {
165
- load_module_recursive(
166
- &dep_key,
167
- files,
168
- visited,
169
- path_to_module,
170
- load_order,
171
- )?;
172
+ load_module_recursive(&dep_key, files, visited, path_to_module, load_order)?;
172
173
  }
173
174
  }
174
175
  }
@@ -197,11 +198,11 @@ pub fn detect_cycles_virtual(modules: &[VirtualModule]) -> Result<(), String> {
197
198
  &mut stack,
198
199
  &mut HashSet::new(),
199
200
  )? {
200
- let path_names: Vec<_> = stack
201
- .iter()
202
- .map(|&i| modules[i].path.clone())
203
- .collect();
204
- return Err(format!("Circular import detected: {}", path_names.join(" -> ")));
201
+ let path_names: Vec<_> = stack.iter().map(|&i| modules[i].path.clone()).collect();
202
+ return Err(format!(
203
+ "Circular import detected: {}",
204
+ path_names.join(" -> ")
205
+ ));
205
206
  }
206
207
  }
207
208
  Ok(())
@@ -231,14 +232,8 @@ fn has_cycle_from(
231
232
  stack.push(dep_idx);
232
233
  let dep = &modules[dep_idx];
233
234
  let dep_dir = parent_dir(&dep.path);
234
- if has_cycle_from(
235
- dep_dir,
236
- &dep.program,
237
- path_to_idx,
238
- modules,
239
- stack,
240
- visiting,
241
- )? {
235
+ if has_cycle_from(dep_dir, &dep.program, path_to_idx, modules, stack, visiting)?
236
+ {
242
237
  return Ok(true);
243
238
  }
244
239
  stack.pop();
@@ -307,14 +302,24 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
307
302
  let dir = parent_dir(&module.path);
308
303
  for stmt in &module.program.statements {
309
304
  match stmt {
310
- Statement::Import { specifiers, from, span } => {
305
+ Statement::Import {
306
+ specifiers,
307
+ from,
308
+ span,
309
+ } => {
311
310
  if is_native_import(from) {
312
311
  let canonical_spec =
313
312
  normalize_builtin_spec(from).unwrap_or_else(|| from.to_string());
314
313
  for spec in specifiers {
315
314
  match spec {
316
- ImportSpecifier::Named { name, alias } => {
315
+ ImportSpecifier::Named {
316
+ name,
317
+ name_span,
318
+ alias,
319
+ alias_span,
320
+ } => {
317
321
  let bind = alias.as_deref().unwrap_or(name.as_ref());
322
+ let decl_name_span = alias_span.as_ref().unwrap_or(name_span);
318
323
  let init = Expr::NativeModuleLoad {
319
324
  spec: Arc::from(canonical_spec.clone()),
320
325
  export_name: name.clone(),
@@ -322,23 +327,24 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
322
327
  };
323
328
  statements.push(Statement::VarDecl {
324
329
  name: Arc::from(bind),
330
+ name_span: *decl_name_span,
325
331
  mutable: false,
326
332
  type_ann: None,
327
333
  init: Some(init),
328
334
  span: *span,
329
335
  });
330
336
  }
331
- ImportSpecifier::Namespace(ns) => {
337
+ ImportSpecifier::Namespace { name, .. } => {
332
338
  return Err(format!(
333
339
  "Namespace import (* as {}) not supported for native module '{}'",
334
- ns.as_ref(),
340
+ name.as_ref(),
335
341
  from.as_ref()
336
342
  ));
337
343
  }
338
- ImportSpecifier::Default(bind) => {
344
+ ImportSpecifier::Default { name, .. } => {
339
345
  return Err(format!(
340
346
  "Default import '{}' not supported for native module '{}'. Use named import.",
341
- bind.as_ref(),
347
+ name.as_ref(),
342
348
  from.as_ref()
343
349
  ));
344
350
  }
@@ -353,15 +359,22 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
353
359
  let dep_exports = &module_exports[dep_idx];
354
360
  for spec in specifiers {
355
361
  match spec {
356
- ImportSpecifier::Named { name, alias } => {
362
+ ImportSpecifier::Named {
363
+ name,
364
+ name_span,
365
+ alias,
366
+ alias_span,
367
+ } => {
357
368
  let source = dep_exports
358
369
  .get(name.as_ref())
359
370
  .cloned()
360
371
  .unwrap_or_else(|| name.to_string());
361
372
  let bind = alias.as_deref().unwrap_or(name.as_ref());
362
373
  if bind != source {
374
+ let decl_name_span = alias_span.as_ref().unwrap_or(name_span);
363
375
  statements.push(Statement::VarDecl {
364
376
  name: Arc::from(bind),
377
+ name_span: *decl_name_span,
365
378
  mutable: false,
366
379
  type_ann: None,
367
380
  init: Some(Expr::Ident {
@@ -372,7 +385,7 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
372
385
  });
373
386
  }
374
387
  }
375
- ImportSpecifier::Namespace(ns) => {
388
+ ImportSpecifier::Namespace { name, name_span } => {
376
389
  let mut props = Vec::new();
377
390
  for (k, v) in dep_exports {
378
391
  props.push(tishlang_ast::ObjectProp::KeyValue(
@@ -384,25 +397,22 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
384
397
  ));
385
398
  }
386
399
  statements.push(Statement::VarDecl {
387
- name: ns.clone(),
400
+ name: name.clone(),
401
+ name_span: *name_span,
388
402
  mutable: false,
389
403
  type_ann: None,
390
- init: Some(Expr::Object {
391
- props,
392
- span: *span,
393
- }),
404
+ init: Some(Expr::Object { props, span: *span }),
394
405
  span: *span,
395
406
  });
396
407
  }
397
- ImportSpecifier::Default(bind) => {
398
- let source = dep_exports
399
- .get("default")
400
- .cloned()
401
- .ok_or_else(|| {
408
+ ImportSpecifier::Default { name, name_span } => {
409
+ let source =
410
+ dep_exports.get("default").cloned().ok_or_else(|| {
402
411
  format!("Module '{}' has no default export", from)
403
412
  })?;
404
413
  statements.push(Statement::VarDecl {
405
- name: bind.clone(),
414
+ name: name.clone(),
415
+ name_span: *name_span,
406
416
  mutable: false,
407
417
  type_ann: None,
408
418
  init: Some(Expr::Ident {
@@ -415,21 +425,21 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
415
425
  }
416
426
  }
417
427
  }
418
- Statement::Export { declaration, .. } => {
419
- match declaration.as_ref() {
420
- ExportDeclaration::Named(s) => statements.push(*s.clone()),
421
- ExportDeclaration::Default(e) => {
422
- let default_name = format!("__default_{}", idx);
423
- statements.push(Statement::VarDecl {
424
- name: Arc::from(default_name),
425
- mutable: false,
426
- type_ann: None,
427
- init: Some((*e).clone()),
428
- span: e.span(),
429
- });
430
- }
428
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
429
+ ExportDeclaration::Named(s) => statements.push(*s.clone()),
430
+ ExportDeclaration::Default(e) => {
431
+ let default_name = format!("__default_{}", idx);
432
+ let espan = e.span();
433
+ statements.push(Statement::VarDecl {
434
+ name: Arc::from(default_name),
435
+ name_span: espan,
436
+ mutable: false,
437
+ type_ann: None,
438
+ init: Some((*e).clone()),
439
+ span: espan,
440
+ });
431
441
  }
432
- }
442
+ },
433
443
  _ => statements.push(stmt.clone()),
434
444
  }
435
445
  }
@@ -9,6 +9,14 @@ repository = { workspace = true }
9
9
  [features]
10
10
  default = []
11
11
  regex = ["dep:fancy-regex"]
12
+ # Make `Value` (and its array / object / regex payloads) `Send + Sync` by
13
+ # switching the interior `Rc<RefCell<T>>` to `Arc<Mutex<T>>` and the native
14
+ # function type from `Rc<dyn Fn>` to `Arc<dyn Fn + Send + Sync>`. Enabled
15
+ # transitively by any crate that needs to pass `Value`s across threads —
16
+ # most notably `tishlang_runtime/http`, which dispatches HTTP handlers
17
+ # across a worker pool. Off by default so wasm / wasi / cranelift / llvm /
18
+ # interpreter builds pay no atomic-ref-count or mutex overhead.
19
+ send-values = []
12
20
 
13
21
  [dependencies]
14
22
  ahash = "0.8.11"
@@ -1,8 +1,6 @@
1
1
  //! JSON parsing and stringification for Tish values.
2
2
 
3
- use crate::Value;
4
- use std::cell::RefCell;
5
- use std::rc::Rc;
3
+ use crate::{Value, VmRef};
6
4
  use std::sync::Arc;
7
5
 
8
6
  /// Parse JSON string into a Value.
@@ -19,75 +17,126 @@ pub fn json_parse(json: &str) -> Result<Value, String> {
19
17
  }
20
18
 
21
19
  /// Stringify a Value to JSON.
20
+ ///
21
+ /// Single-buffer write strategy: all nested values append into one
22
+ /// `String` via [`json_stringify_into`], so we never allocate a transient
23
+ /// per-node `String` only to copy + drop it on the way back up. For a
24
+ /// 20-row TFB `/queries` response (~40 numbers, 2 keys × 20 = ~80 string
25
+ /// ops) that saves dozens of small allocations per request.
22
26
  pub fn json_stringify(value: &Value) -> String {
27
+ // 256 B is enough for typical TFB responses (`/db` is 31 B,
28
+ // `/queries=20` is ~700 B). Larger payloads reallocate normally.
29
+ let mut buf = String::with_capacity(256);
30
+ json_stringify_into(&mut buf, value);
31
+ buf
32
+ }
33
+
34
+ /// Append a JSON-stringified `value` to `buf`. Used by JSON.stringify for
35
+ /// the recursive case so we don't pay for an intermediate `String` per
36
+ /// node.
37
+ pub fn json_stringify_into(buf: &mut String, value: &Value) {
23
38
  match value {
24
- Value::Null => "null".to_string(),
25
- Value::Bool(b) => b.to_string(),
39
+ Value::Null => buf.push_str("null"),
40
+ Value::Bool(true) => buf.push_str("true"),
41
+ Value::Bool(false) => buf.push_str("false"),
26
42
  Value::Number(n) => {
27
43
  if n.is_nan() || n.is_infinite() {
28
- "null".to_string()
44
+ buf.push_str("null");
29
45
  } else {
30
- n.to_string()
46
+ // `write!` avoids the heap allocation that `to_string`
47
+ // produces. The f64 → decimal formatter is the same
48
+ // either way (`std::fmt::Display`).
49
+ use std::fmt::Write;
50
+ let _ = write!(buf, "{}", n);
31
51
  }
32
52
  }
33
- Value::String(s) => format!("\"{}\"", escape_json_string(s)),
53
+ Value::String(s) => {
54
+ buf.push('"');
55
+ escape_json_string_into(buf, s);
56
+ buf.push('"');
57
+ }
34
58
  Value::Array(arr) => {
35
59
  let borrowed = arr.borrow();
36
- let mut result = String::with_capacity(borrowed.len() * 8 + 2);
37
- result.push('[');
38
- let mut first = true;
39
- for item in borrowed.iter() {
40
- if !first {
41
- result.push(',');
60
+ buf.push('[');
61
+ for (i, item) in borrowed.iter().enumerate() {
62
+ if i > 0 {
63
+ buf.push(',');
42
64
  }
43
- first = false;
44
- result.push_str(&json_stringify(item));
65
+ json_stringify_into(buf, item);
45
66
  }
46
- result.push(']');
47
- result
67
+ buf.push(']');
48
68
  }
49
69
  Value::Object(obj) => {
50
70
  let borrowed = obj.borrow();
51
- let mut keys: Vec<_> = borrowed.keys().collect();
52
- keys.sort();
53
- let mut result = String::with_capacity(borrowed.len() * 16 + 2);
54
- result.push('{');
55
- let mut first = true;
56
- for key in keys {
57
- if !first {
58
- result.push(',');
71
+ // Sort keys for deterministic output. Pre-allocate to avoid
72
+ // a fresh `Vec` realloc inside `keys().collect()`.
73
+ let mut keys: Vec<&Arc<str>> = Vec::with_capacity(borrowed.len());
74
+ keys.extend(borrowed.keys());
75
+ keys.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
76
+ buf.push('{');
77
+ for (i, key) in keys.into_iter().enumerate() {
78
+ if i > 0 {
79
+ buf.push(',');
59
80
  }
60
- first = false;
61
- result.push('"');
62
- result.push_str(&escape_json_string(key));
63
- result.push_str("\":");
64
- result.push_str(&json_stringify(borrowed.get(key).unwrap()));
81
+ buf.push('"');
82
+ escape_json_string_into(buf, key);
83
+ buf.push_str("\":");
84
+ json_stringify_into(buf, borrowed.get(key).unwrap());
65
85
  }
66
- result.push('}');
67
- result
86
+ buf.push('}');
68
87
  }
69
- Value::Function(_) => "null".to_string(),
70
- Value::Promise(_) => "null".to_string(),
71
- Value::Opaque(_) => "null".to_string(),
88
+ Value::Function(_) | Value::Promise(_) | Value::Opaque(_) => buf.push_str("null"),
72
89
  #[cfg(feature = "regex")]
73
- Value::RegExp(_) => "null".to_string(),
90
+ Value::RegExp(_) => buf.push_str("null"),
74
91
  }
75
92
  }
76
93
 
77
- fn escape_json_string(s: &str) -> String {
78
- let mut result = String::with_capacity(s.len());
79
- for c in s.chars() {
80
- match c {
81
- '"' => result.push_str("\\\""),
82
- '\\' => result.push_str("\\\\"),
83
- '\n' => result.push_str("\\n"),
84
- '\r' => result.push_str("\\r"),
85
- '\t' => result.push_str("\\t"),
86
- c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
87
- c => result.push(c),
94
+ /// Append an escaped JSON string body (without the surrounding quotes)
95
+ /// to `buf`. Optimised for the common case where the input is ASCII and
96
+ /// contains no characters that need escaping — we fast-pass the bytes
97
+ /// straight through, only falling into the per-char path on a hit.
98
+ fn escape_json_string_into(buf: &mut String, s: &str) {
99
+ let bytes = s.as_bytes();
100
+ let mut start = 0usize;
101
+ for (i, &b) in bytes.iter().enumerate() {
102
+ // Anything < 0x20 is a JSON control char that must be escaped;
103
+ // 0x22 (`"`) and 0x5C (`\`) also need an explicit escape; bytes
104
+ // 0x80 are the start of a multi-byte UTF-8 sequence, which is
105
+ // valid JSON as-is.
106
+ if b < 0x20 || b == b'"' || b == b'\\' {
107
+ // Flush the run of clean bytes before this one in one push.
108
+ if start < i {
109
+ // SAFETY: `s` is `&str`, every byte in `start..i` was a
110
+ // single-byte ASCII char (we only stop on ASCII triggers
111
+ // below 0x80), so the slice is a valid `&str`.
112
+ buf.push_str(&s[start..i]);
113
+ }
114
+ match b {
115
+ b'"' => buf.push_str("\\\""),
116
+ b'\\' => buf.push_str("\\\\"),
117
+ b'\n' => buf.push_str("\\n"),
118
+ b'\r' => buf.push_str("\\r"),
119
+ b'\t' => buf.push_str("\\t"),
120
+ b'\x08' => buf.push_str("\\b"),
121
+ b'\x0c' => buf.push_str("\\f"),
122
+ _ => {
123
+ use std::fmt::Write;
124
+ let _ = write!(buf, "\\u{:04x}", b as u32);
125
+ }
126
+ }
127
+ start = i + 1;
88
128
  }
89
129
  }
90
- result
130
+ if start < bytes.len() {
131
+ buf.push_str(&s[start..]);
132
+ }
133
+ }
134
+
135
+ #[allow(dead_code)]
136
+ fn escape_json_string(s: &str) -> String {
137
+ let mut buf = String::with_capacity(s.len());
138
+ escape_json_string_into(&mut buf, s);
139
+ buf
91
140
  }
92
141
 
93
142
  fn parse_value(input: &str) -> Result<(Value, &str), String> {
@@ -228,7 +277,7 @@ fn parse_number(input: &str) -> Result<(Value, &str), String> {
228
277
 
229
278
  let num_str: String = chars[..end].iter().collect();
230
279
  let byte_len: usize = chars[..end].iter().map(|c| c.len_utf8()).sum();
231
-
280
+
232
281
  num_str
233
282
  .parse::<f64>()
234
283
  .map(|n| (Value::Number(n), &input[byte_len..]))
@@ -241,7 +290,7 @@ fn parse_array(input: &str) -> Result<(Value, &str), String> {
241
290
 
242
291
  input = input.trim_start();
243
292
  if let Some(rest) = input.strip_prefix(']') {
244
- return Ok((Value::Array(Rc::new(RefCell::new(items))), rest));
293
+ return Ok((Value::Array(VmRef::new(items)), rest));
245
294
  }
246
295
 
247
296
  loop {
@@ -251,7 +300,7 @@ fn parse_array(input: &str) -> Result<(Value, &str), String> {
251
300
 
252
301
  match input.chars().next() {
253
302
  Some(',') => input = &input[1..],
254
- Some(']') => return Ok((Value::Array(Rc::new(RefCell::new(items))), &input[1..])),
303
+ Some(']') => return Ok((Value::Array(VmRef::new(items)), &input[1..])),
255
304
  _ => return Err("Expected ',' or ']' in array".to_string()),
256
305
  }
257
306
  }
@@ -263,7 +312,7 @@ fn parse_object(input: &str) -> Result<(Value, &str), String> {
263
312
 
264
313
  input = input.trim_start();
265
314
  if let Some(rest) = input.strip_prefix('}') {
266
- return Ok((Value::Object(Rc::new(RefCell::new(map))), rest));
315
+ return Ok((Value::Object(VmRef::new(map)), rest));
267
316
  }
268
317
 
269
318
  loop {
@@ -290,7 +339,7 @@ fn parse_object(input: &str) -> Result<(Value, &str), String> {
290
339
 
291
340
  match input.chars().next() {
292
341
  Some(',') => input = &input[1..],
293
- Some('}') => return Ok((Value::Object(Rc::new(RefCell::new(map))), &input[1..])),
342
+ Some('}') => return Ok((Value::Object(VmRef::new(map)), &input[1..])),
294
343
  _ => return Err("Expected ',' or '}' in object".to_string()),
295
344
  }
296
345
  }
@@ -306,7 +355,9 @@ mod tests {
306
355
  assert!(matches!(json_parse("true").unwrap(), Value::Bool(true)));
307
356
  assert!(matches!(json_parse("false").unwrap(), Value::Bool(false)));
308
357
  assert!(matches!(json_parse("42").unwrap(), Value::Number(n) if n == 42.0));
309
- assert!(matches!(json_parse("\"hello\"").unwrap(), Value::String(s) if s.as_ref() == "hello"));
358
+ assert!(
359
+ matches!(json_parse("\"hello\"").unwrap(), Value::String(s) if s.as_ref() == "hello")
360
+ );
310
361
  }
311
362
 
312
363
  #[test]
@@ -315,7 +366,7 @@ mod tests {
315
366
  let value = json_parse(original).unwrap();
316
367
  let stringified = json_stringify(&value);
317
368
  let reparsed = json_parse(&stringified).unwrap();
318
-
369
+
319
370
  match (&value, &reparsed) {
320
371
  (Value::Object(a), Value::Object(b)) => {
321
372
  assert_eq!(a.borrow().len(), b.borrow().len());