@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
@@ -41,7 +41,10 @@ fn string_strict_eq_logical_or_inside_ternary_repl_last_expr() {
41
41
  let opt = tishlang_opt::optimize(&tishlang_parser::parse(src).expect("parse"));
42
42
  let v_peep = run_chunk(&compile_for_repl(&opt).expect("compile repl"));
43
43
  let v_unopt = run_chunk(&compile_for_repl_unoptimized(&opt).expect("compile repl unopt"));
44
- assert!(v_peep.strict_eq(&v_unopt), "peep={v_peep:?} unopt={v_unopt:?}");
44
+ assert!(
45
+ v_peep.strict_eq(&v_unopt),
46
+ "peep={v_peep:?} unopt={v_unopt:?}"
47
+ );
45
48
  assert!(
46
49
  matches!(&v_peep, Value::Number(n) if *n == 1.0),
47
50
  "expected 1, got {v_peep:?}"
@@ -62,7 +65,8 @@ fn logical_or_strict_eq_peephole_matches_unoptimized() {
62
65
  );
63
66
 
64
67
  let v_peep_repl = run_chunk(&compile_for_repl(&program).expect("compile repl"));
65
- let v_raw_repl = run_chunk(&compile_for_repl_unoptimized(&program).expect("compile repl unopt"));
68
+ let v_raw_repl =
69
+ run_chunk(&compile_for_repl_unoptimized(&program).expect("compile repl unopt"));
66
70
  assert!(
67
71
  v_peep_repl.strict_eq(&v_raw_repl),
68
72
  "repl: peep={v_peep_repl:?} raw={v_raw_repl:?}"
@@ -111,11 +115,14 @@ fn string_strict_eq_logical_or_peephole_matches_unoptimized() {
111
115
  /// `tish run path/to/file.tish` uses merge_modules; ensure that matches plain parse for the fixture.
112
116
  #[test]
113
117
  fn merged_module_program_bytecode_matches_parse_for_string_or_fixture() {
114
- let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/or_string_cmd.tish");
118
+ let fixture =
119
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/or_string_cmd.tish");
115
120
  let src = std::fs::read_to_string(&fixture).expect("read fixture");
116
121
  let modules = tishlang_compile::resolve_project(&fixture, Some(fixture.parent().unwrap()))
117
122
  .expect("resolve");
118
- let merged = tishlang_compile::merge_modules(modules).expect("merge");
123
+ let merged = tishlang_compile::merge_modules(modules)
124
+ .expect("merge")
125
+ .program;
119
126
  let flat = tishlang_parser::parse(&src).expect("parse");
120
127
  let m_opt = tishlang_opt::optimize(&merged);
121
128
  let f_opt = tishlang_opt::optimize(&flat);
@@ -3,6 +3,7 @@
3
3
  //! Compiles Tish to bytecode, then produces a .wasm VM binary + loader.
4
4
  //! The VM runs in the browser; your program runs as serialized bytecode.
5
5
 
6
+ use std::collections::BTreeSet;
6
7
  use std::path::Path;
7
8
  use std::process::Command;
8
9
 
@@ -43,7 +44,9 @@ fn resolve_and_compile_to_chunk(
43
44
  message: e.to_string(),
44
45
  })?;
45
46
  let program = {
46
- let prog = merge_modules(modules).map_err(|e| WasmError {
47
+ let prog = merge_modules(modules)
48
+ .map(|m| m.program)
49
+ .map_err(|e| WasmError {
47
50
  message: e.to_string(),
48
51
  })?;
49
52
  if optimize {
@@ -107,27 +110,33 @@ fn emit_wasm_from_chunk(chunk: &Chunk, output_path: &Path) -> Result<(), WasmErr
107
110
  std::fs::create_dir_all(&out_dir_abs).map_err(|e| WasmError {
108
111
  message: format!("Cannot create output directory: {}", e),
109
112
  })?;
110
- let workspace_root = tishlang_build_utils::find_workspace_root().map_err(|e| WasmError {
111
- message: e,
112
- })?;
113
+ let workspace_root =
114
+ tishlang_build_utils::find_workspace_root().map_err(|e| WasmError { message: e })?;
113
115
  let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
114
116
  let build_status = Command::new(&cargo)
115
117
  .current_dir(&workspace_root)
116
118
  .args([
117
- "build", "-p", "tishlang_wasm_runtime",
118
- "--target", "wasm32-unknown-unknown",
119
- "--release", "--features", "browser",
119
+ "build",
120
+ "-p",
121
+ "tishlang_wasm_runtime",
122
+ "--target",
123
+ "wasm32-unknown-unknown",
124
+ "--release",
125
+ "--features",
126
+ "browser",
120
127
  ])
121
128
  .status()
122
- .map_err(|e| WasmError { message: format!("Failed to run cargo: {}", e) })?;
129
+ .map_err(|e| WasmError {
130
+ message: format!("Failed to run cargo: {}", e),
131
+ })?;
123
132
  if !build_status.success() {
124
133
  return Err(WasmError {
125
134
  message: "Failed to build wasm runtime. Run: rustup target add wasm32-unknown-unknown"
126
135
  .to_string(),
127
136
  });
128
137
  }
129
- let wasm_artifact = workspace_root
130
- .join("target/wasm32-unknown-unknown/release/tishlang_wasm_runtime.wasm");
138
+ let wasm_artifact =
139
+ workspace_root.join("target/wasm32-unknown-unknown/release/tishlang_wasm_runtime.wasm");
131
140
  if !wasm_artifact.exists() {
132
141
  return Err(WasmError {
133
142
  message: format!("Wasm artifact not found: {}", wasm_artifact.display()),
@@ -137,17 +146,25 @@ fn emit_wasm_from_chunk(chunk: &Chunk, output_path: &Path) -> Result<(), WasmErr
137
146
  let out_name = stem.to_string();
138
147
  let bindgen_status = Command::new(&wasm_bindgen)
139
148
  .args([
140
- "--target", "web",
141
- "--out-dir", out_dir_abs.to_str().unwrap(),
142
- "--out-name", &out_name,
149
+ "--target",
150
+ "web",
151
+ "--out-dir",
152
+ out_dir_abs.to_str().unwrap(),
153
+ "--out-name",
154
+ &out_name,
143
155
  wasm_artifact.to_str().unwrap(),
144
156
  ])
145
157
  .status()
146
158
  .map_err(|e| WasmError {
147
- message: format!("Failed to run wasm-bindgen: {}. Install with: cargo install wasm-bindgen-cli", e),
159
+ message: format!(
160
+ "Failed to run wasm-bindgen: {}. Install with: cargo install wasm-bindgen-cli",
161
+ e
162
+ ),
148
163
  })?;
149
164
  if !bindgen_status.success() {
150
- return Err(WasmError { message: "wasm-bindgen failed".to_string() });
165
+ return Err(WasmError {
166
+ message: "wasm-bindgen failed".to_string(),
167
+ });
151
168
  }
152
169
  let js_name = format!("{}.js", stem);
153
170
  let html = format!(
@@ -171,7 +188,12 @@ run(chunk);
171
188
  std::fs::write(&html_path, html).map_err(|e| WasmError {
172
189
  message: format!("Cannot write {}: {}", html_path.display(), e),
173
190
  })?;
174
- println!("Built: {}_bg.wasm, {}.js, {}", stem, stem, html_path.display());
191
+ println!(
192
+ "Built: {}_bg.wasm, {}.js, {}",
193
+ stem,
194
+ stem,
195
+ html_path.display()
196
+ );
175
197
  Ok(())
176
198
  }
177
199
 
@@ -208,7 +230,7 @@ pub fn compile_to_wasi(
208
230
  let (chunk, program) = resolve_and_compile_to_chunk(entry_path, project_root, optimize)?;
209
231
  if has_external_native_imports(&program) {
210
232
  return Err(WasmError {
211
- message: "WASI backend does not support external native imports (tish:egui, @scope/pkg). Built-in tish:fs, tish:http, tish:process are supported.".to_string(),
233
+ message: "WASI backend does not support external native imports (tish:egui, @scope/pkg). Built-in tish:fs, tish:http, tish:process, tish:timers are supported.".to_string(),
212
234
  });
213
235
  }
214
236
  let wasi_features = extract_native_import_features(&program);
@@ -233,9 +255,8 @@ pub fn compile_to_wasi(
233
255
  message: format!("Cannot create output directory: {}", e),
234
256
  })?;
235
257
 
236
- let workspace_root = tishlang_build_utils::find_workspace_root().map_err(|e| WasmError {
237
- message: e,
238
- })?;
258
+ let workspace_root =
259
+ tishlang_build_utils::find_workspace_root().map_err(|e| WasmError { message: e })?;
239
260
 
240
261
  // Create generated project: wasi_build/{stem}/
241
262
  let build_dir = out_dir_abs.join("wasi_build").join(stem);
@@ -249,27 +270,25 @@ pub fn compile_to_wasi(
249
270
  })?;
250
271
 
251
272
  // Cargo.toml - path to tishlang_wasm_runtime (crate in crates/tish_wasm_runtime)
252
- let runtime_path = workspace_root
253
- .join("crates")
254
- .join("tish_wasm_runtime");
273
+ let runtime_path = workspace_root.join("crates").join("tish_wasm_runtime");
255
274
  let runtime_path_str = runtime_path
256
275
  .canonicalize()
257
276
  .unwrap_or(runtime_path)
258
277
  .to_string_lossy()
259
278
  .replace('\\', "/");
260
279
 
261
- let features_str = if wasi_features.is_empty() {
262
- String::new()
263
- } else {
264
- format!(
265
- ", features = [{}]",
266
- wasi_features
267
- .iter()
268
- .map(|f| format!("{:?}", f))
269
- .collect::<Vec<_>>()
270
- .join(", ")
271
- )
272
- };
280
+ // Bundled perf (`tests/main.tish`) and many scripts use global setTimeout without a top-level
281
+ // `import` (so `extract_native_import_features` is empty). Always link timers for WASI VM.
282
+ let mut wasi_feature_set: BTreeSet<String> = wasi_features.into_iter().collect();
283
+ wasi_feature_set.insert("timers".to_string());
284
+ let features_str = format!(
285
+ ", features = [{}]",
286
+ wasi_feature_set
287
+ .iter()
288
+ .map(|f| format!("{:?}", f))
289
+ .collect::<Vec<_>>()
290
+ .join(", ")
291
+ );
273
292
  let cargo_toml = format!(
274
293
  r#"[package]
275
294
  name = "tish_wasi_{stem}"
@@ -314,12 +333,7 @@ fn main() {
314
333
  let build_status = Command::new(&cargo)
315
334
  .current_dir(&build_dir)
316
335
  .env("CARGO_TARGET_DIR", &target_dir)
317
- .args([
318
- "build",
319
- "--target",
320
- "wasm32-wasip1",
321
- "--release",
322
- ])
336
+ .args(["build", "--target", "wasm32-wasip1", "--release"])
323
337
  .status()
324
338
  .map_err(|e| WasmError {
325
339
  message: format!("Failed to run cargo: {}", e),
@@ -355,4 +369,3 @@ fn main() {
355
369
  );
356
370
  Ok(())
357
371
  }
358
-
@@ -12,10 +12,11 @@ crate-type = ["cdylib", "rlib"]
12
12
  [features]
13
13
  # For wasm32-unknown-unknown (browser): wasm-bindgen, console output
14
14
  browser = ["dep:wasm-bindgen", "tishlang_vm/wasm"]
15
- # Built-in modules for WASI (wasm32-wasip1): file I/O, process, http
15
+ # Built-in modules for WASI (wasm32-wasip1): file I/O, process, http, timers
16
16
  fs = ["tishlang_vm/fs"]
17
17
  process = ["tishlang_vm/process"]
18
18
  http = ["tishlang_vm/http"]
19
+ timers = ["tishlang_vm/timers"]
19
20
 
20
21
  [dependencies]
21
22
  tishlang_bytecode = { path = "../tish_bytecode", version = ">=0.1" }
@@ -2,7 +2,7 @@
2
2
  //!
3
3
  //! Two targets:
4
4
  //! - **Browser** (wasm32-unknown-unknown): use `--features browser`, wasm-bindgen, console output
5
- //! - **WASI/Wasmtime** (wasm32-wasi): no features, println! via WASI, run with `wasmtime app.wasm`
5
+ //! - **WASI/Wasmtime** (wasm32-wasip1): optional `timers` / `http` / … via Cargo features; `compile_to_wasi` enables `timers` by default.
6
6
 
7
7
  use tishlang_bytecode::deserialize;
8
8
  use tishlang_vm::Vm;
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "tishlang_cargo_bindgen"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Generate Rust glue for Tish cargo: imports (bindgen-style, pre-commit friendly)"
6
+ license-file = { workspace = true }
7
+ repository = { workspace = true }
8
+
9
+ [[bin]]
10
+ name = "tishlang-cargo-bindgen"
11
+ path = "src/main.rs"
12
+
13
+ [lib]
14
+ name = "tishlang_cargo_bindgen"
15
+ path = "src/lib.rs"
16
+
17
+ [dependencies]
18
+ cargo_metadata = "0.19"
19
+ clap = { version = "4.6", features = ["derive", "color"] }
20
+ pathdiff = "0.2"
21
+ proc-macro2 = { version = "1.0", features = ["span-locations"] }
22
+ serde_json = "1"
23
+ syn = { version = "2.0", features = ["full", "extra-traits"] }
24
+ tempfile = "3"
25
+ toml = "0.8"
26
+ walkdir = "2"
@@ -0,0 +1,265 @@
1
+ //! Classify a `pub fn` from syn for glue emission (driven by signature shape, not crate name).
2
+
3
+ use syn::{
4
+ FnArg, GenericArgument, ItemFn, PathArguments, ReturnType, Type, TypeReference,
5
+ };
6
+
7
+ #[derive(Debug, Clone, PartialEq, Eq)]
8
+ pub enum SignatureClass {
9
+ /// `fn foo(args: &[Value]) -> Value` (or `tishlang_runtime::Value`).
10
+ TishValueAbi,
11
+ /// First parameter `&T` (or `&mut T`), `T: Serialize` (or `?Sized + Serialize`), returns `Result<String, _>`-like.
12
+ SerializeRefToResultString,
13
+ /// First parameter `&str` (or `& 'a str`), returns `Result<_, _>`, and has `Deserialize` bound on a type param.
14
+ DeserializeStrToResult,
15
+ }
16
+
17
+ pub fn classify_public_fn(item: &ItemFn) -> Option<SignatureClass> {
18
+ if matches!(classify_tish_abi(item), Some(SignatureClass::TishValueAbi)) {
19
+ return Some(SignatureClass::TishValueAbi);
20
+ }
21
+ if is_deserialize_str_result(item) {
22
+ return Some(SignatureClass::DeserializeStrToResult);
23
+ }
24
+ if is_serialize_ref_to_result_string(item) {
25
+ return Some(SignatureClass::SerializeRefToResultString);
26
+ }
27
+ None
28
+ }
29
+
30
+ fn classify_tish_abi(item: &ItemFn) -> Option<SignatureClass> {
31
+ let sig = &item.sig;
32
+ let mut value_args = 0;
33
+ for arg in &sig.inputs {
34
+ let FnArg::Typed(t) = arg else {
35
+ continue;
36
+ };
37
+ if is_slice_value(&t.ty) {
38
+ value_args += 1;
39
+ }
40
+ }
41
+ if value_args != 1 || sig.inputs.len() != 1 {
42
+ return None;
43
+ }
44
+ let Some(ret_ty) = return_type_inner(&sig.output) else {
45
+ return None;
46
+ };
47
+ if !is_value_type(ret_ty) {
48
+ return None;
49
+ }
50
+ Some(SignatureClass::TishValueAbi)
51
+ }
52
+
53
+ fn return_type_inner(ret: &ReturnType) -> Option<&Type> {
54
+ match ret {
55
+ ReturnType::Default => None,
56
+ ReturnType::Type(_, ty) => Some(ty),
57
+ }
58
+ }
59
+
60
+ fn is_slice_value(ty: &Type) -> bool {
61
+ let Some(inner) = strip_reference(ty) else {
62
+ return false;
63
+ };
64
+ let Type::Slice(s) = inner else {
65
+ return false;
66
+ };
67
+ is_value_type(&s.elem)
68
+ }
69
+
70
+ fn strip_reference(ty: &Type) -> Option<&Type> {
71
+ match ty {
72
+ Type::Reference(TypeReference { elem, .. }) => Some(elem.as_ref()),
73
+ _ => None,
74
+ }
75
+ }
76
+
77
+ fn is_value_type(ty: &Type) -> bool {
78
+ let Type::Path(p) = ty else {
79
+ return false;
80
+ };
81
+ let seg = p.path.segments.last();
82
+ let Some(seg) = seg else {
83
+ return false;
84
+ };
85
+ if seg.ident != "Value" {
86
+ return false;
87
+ }
88
+ // Accept `Value`, `tishlang_runtime::Value`, `tishlang_core::Value`
89
+ if p.path.segments.len() == 1 {
90
+ return true;
91
+ }
92
+ let prev = &p.path.segments[p.path.segments.len() - 2];
93
+ prev.ident == "tishlang_runtime" || prev.ident == "tishlang_core"
94
+ }
95
+
96
+ fn is_str_ref(ty: &Type) -> bool {
97
+ match ty {
98
+ Type::Reference(TypeReference { elem, .. }) => matches!(
99
+ elem.as_ref(),
100
+ Type::Path(p) if p.path.is_ident("str")
101
+ ),
102
+ _ => false,
103
+ }
104
+ }
105
+
106
+ fn is_deserialize_str_result(item: &ItemFn) -> bool {
107
+ let sig = &item.sig;
108
+ if sig.inputs.len() != 1 {
109
+ return false;
110
+ }
111
+ let FnArg::Typed(arg) = sig.inputs.first().unwrap() else {
112
+ return false;
113
+ };
114
+ if !is_str_ref(&arg.ty) {
115
+ return false;
116
+ }
117
+ let Some(ret) = return_type_inner(&sig.output) else {
118
+ return false;
119
+ };
120
+ if result_ok_type(ret).is_none() {
121
+ return false;
122
+ }
123
+ has_deserialize_bound(item)
124
+ }
125
+
126
+ fn has_deserialize_bound(item: &ItemFn) -> bool {
127
+ for p in &item.sig.generics.params {
128
+ if let syn::GenericParam::Type(t) = p {
129
+ for b in &t.bounds {
130
+ if bound_name_is(b, "Deserialize") {
131
+ return true;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ if let Some(wc) = &item.sig.generics.where_clause {
137
+ for pred in &wc.predicates {
138
+ if let syn::WherePredicate::Type(t) = pred {
139
+ for b in &t.bounds {
140
+ if bound_name_is(b, "Deserialize") {
141
+ return true;
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ false
148
+ }
149
+
150
+ fn bound_name_is(b: &syn::TypeParamBound, want: &str) -> bool {
151
+ let syn::TypeParamBound::Trait(t) = b else {
152
+ return false;
153
+ };
154
+ let path = &t.path;
155
+ path.segments.last().is_some_and(|s| s.ident == want)
156
+ }
157
+
158
+ fn is_serialize_ref_to_result_string(item: &ItemFn) -> bool {
159
+ let sig = &item.sig;
160
+ if sig.inputs.len() != 1 {
161
+ return false;
162
+ }
163
+ let FnArg::Typed(arg) = sig.inputs.first().unwrap() else {
164
+ return false;
165
+ };
166
+ if strip_reference(&arg.ty).is_none() {
167
+ return false;
168
+ }
169
+ let Some(ret) = return_type_inner(&sig.output) else {
170
+ return false;
171
+ };
172
+ let Some(ok_ty) = result_ok_type(ret) else {
173
+ return false;
174
+ };
175
+ if !type_is_string_or_str(ok_ty) {
176
+ return false;
177
+ }
178
+ has_serialize_bound(item)
179
+ }
180
+
181
+ fn has_serialize_bound(item: &ItemFn) -> bool {
182
+ for p in &item.sig.generics.params {
183
+ if let syn::GenericParam::Type(t) = p {
184
+ for b in &t.bounds {
185
+ if bound_name_is(b, "Serialize") {
186
+ return true;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ if let Some(wc) = &item.sig.generics.where_clause {
192
+ for pred in &wc.predicates {
193
+ if let syn::WherePredicate::Type(t) = pred {
194
+ for b in &t.bounds {
195
+ if bound_name_is(b, "Serialize") {
196
+ return true;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ false
203
+ }
204
+
205
+ fn result_ok_type(ty: &Type) -> Option<&Type> {
206
+ let Type::Path(p) = ty else {
207
+ return None;
208
+ };
209
+ let seg = p.path.segments.last()?;
210
+ if seg.ident != "Result" {
211
+ return None;
212
+ }
213
+ let PathArguments::AngleBracketed(ab) = &seg.arguments else {
214
+ return None;
215
+ };
216
+ let first = ab.args.first()?;
217
+ let GenericArgument::Type(t) = first else {
218
+ return None;
219
+ };
220
+ Some(t)
221
+ }
222
+
223
+ fn type_is_string_or_str(ty: &Type) -> bool {
224
+ match ty {
225
+ Type::Path(p) => {
226
+ if p.path.is_ident("String") {
227
+ return true;
228
+ }
229
+ p.path.segments.len() == 1 && p.path.segments[0].ident == "str"
230
+ }
231
+ _ => false,
232
+ }
233
+ }
234
+
235
+ #[cfg(test)]
236
+ mod tests {
237
+ use super::*;
238
+ use syn::parse_quote;
239
+
240
+ #[test]
241
+ fn classify_serde_to_string_shape() {
242
+ let item: ItemFn = parse_quote! {
243
+ pub fn to_string<T: ?Sized + Serialize>(value: &T) -> Result<String, ()> {
244
+ unimplemented!()
245
+ }
246
+ };
247
+ assert_eq!(
248
+ classify_public_fn(&item),
249
+ Some(SignatureClass::SerializeRefToResultString)
250
+ );
251
+ }
252
+
253
+ #[test]
254
+ fn classify_from_str_shape() {
255
+ let item: ItemFn = parse_quote! {
256
+ pub fn from_str<'a, T: Deserialize<'a>>(s: &'a str) -> Result<T, ()> {
257
+ unimplemented!()
258
+ }
259
+ };
260
+ assert_eq!(
261
+ classify_public_fn(&item),
262
+ Some(SignatureClass::DeserializeStrToResult)
263
+ );
264
+ }
265
+ }
@@ -0,0 +1,120 @@
1
+ //! Walk dependency `src/**/*.rs` and collect `pub fn` items by name.
2
+
3
+ use std::collections::HashMap;
4
+ use std::fs;
5
+ use std::path::{Path, PathBuf};
6
+
7
+ use syn::{Item, ItemFn, Visibility};
8
+ use walkdir::WalkDir;
9
+
10
+ fn is_pub(vis: &Visibility) -> bool {
11
+ matches!(vis, Visibility::Public(_))
12
+ }
13
+
14
+ /// Map export name (Rust ident) to the function AST (must be unique).
15
+ pub fn discover_public_functions(crate_root: &Path) -> Result<HashMap<String, ItemFn>, String> {
16
+ let src = crate_root.join("src");
17
+ if !src.is_dir() {
18
+ return Err(format!("no src/ under {}", crate_root.display()));
19
+ }
20
+
21
+ let mut map: HashMap<String, (PathBuf, ItemFn)> = HashMap::new();
22
+
23
+ for entry in WalkDir::new(&src)
24
+ .into_iter()
25
+ .filter_map(|e| e.ok())
26
+ .filter(|e| e.path().extension().map(|x| x == "rs").unwrap_or(false))
27
+ {
28
+ let path = entry.path();
29
+ let text = fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?;
30
+ let file = syn::parse_file(&text).map_err(|e| format!("parse {}: {}", path.display(), e))?;
31
+
32
+ for item in file.items {
33
+ if let Item::Fn(f) = item {
34
+ if !is_pub(&f.vis) {
35
+ continue;
36
+ }
37
+ let name = f.sig.ident.to_string();
38
+ if let Some((prev_path, _)) = map.get(&name) {
39
+ return Err(format!(
40
+ "ambiguous public fn `{}`: found in {} and {}",
41
+ name,
42
+ prev_path.display(),
43
+ path.display()
44
+ ));
45
+ }
46
+ map.insert(name, (path.to_path_buf(), f));
47
+ }
48
+ }
49
+ }
50
+
51
+ Ok(map.into_iter().map(|(k, (_, v))| (k, v)).collect())
52
+ }
53
+
54
+ /// On-disk location of a top-level `pub fn {fn_name}` under `crate_root/src` (LSP line/column, 0-based).
55
+ ///
56
+ /// Requires `proc-macro2` built with `span-locations` (this crate enables it) so spans from
57
+ /// `syn::parse_file` carry line/column.
58
+ pub fn rust_public_fn_location(
59
+ crate_root: &Path,
60
+ fn_name: &str,
61
+ ) -> Result<(PathBuf, u32, u32), String> {
62
+ let src = crate_root.join("src");
63
+ if !src.is_dir() {
64
+ return Err(format!("no src/ under {}", crate_root.display()));
65
+ }
66
+
67
+ for entry in WalkDir::new(&src)
68
+ .into_iter()
69
+ .filter_map(|e| e.ok())
70
+ .filter(|e| e.path().extension().map(|x| x == "rs").unwrap_or(false))
71
+ {
72
+ let path = entry.path();
73
+ let text = fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?;
74
+ let file = syn::parse_file(&text).map_err(|e| format!("parse {}: {}", path.display(), e))?;
75
+
76
+ for item in file.items {
77
+ if let Item::Fn(f) = item {
78
+ if !is_pub(&f.vis) {
79
+ continue;
80
+ }
81
+ if f.sig.ident != fn_name {
82
+ continue;
83
+ }
84
+ let lc = f.sig.ident.span().start();
85
+ let line = u32::try_from(lc.line)
86
+ .map_err(|_| "span line out of range".to_string())?
87
+ .saturating_sub(1);
88
+ let col = u32::try_from(lc.column).map_err(|_| "span column out of range".to_string())?;
89
+ return Ok((path.to_path_buf(), line, col));
90
+ }
91
+ }
92
+ }
93
+
94
+ Err(format!(
95
+ "no public fn `{}` found under {}/src",
96
+ fn_name,
97
+ crate_root.display()
98
+ ))
99
+ }
100
+
101
+ #[cfg(test)]
102
+ mod tests {
103
+ use super::*;
104
+ use tempfile::tempdir;
105
+
106
+ #[test]
107
+ fn rust_public_fn_location_finds_fn() {
108
+ let tmp = tempdir().unwrap();
109
+ let src = tmp.path().join("src");
110
+ fs::create_dir_all(&src).unwrap();
111
+ fs::write(
112
+ src.join("lib.rs"),
113
+ "// comment\npub fn hello_tish_export() -> i32 { 0 }\n",
114
+ )
115
+ .unwrap();
116
+ let (path, line, _col) = rust_public_fn_location(tmp.path(), "hello_tish_export").unwrap();
117
+ assert!(path.ends_with("lib.rs"));
118
+ assert_eq!(line, 1);
119
+ }
120
+ }