@tishlang/tish 2.12.0 → 2.16.13

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 (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -2,24 +2,29 @@
2
2
 
3
3
  use std::collections::{HashMap, HashSet};
4
4
  use std::path::PathBuf;
5
+ use std::sync::atomic::{AtomicBool, Ordering};
5
6
  use std::sync::{Arc, Mutex, RwLock};
6
7
  use std::time::SystemTime;
7
8
 
8
9
  use regex::Regex;
9
10
  use tower_lsp::jsonrpc::Result;
11
+ use tower_lsp::lsp_types::notification::Progress;
10
12
  use tower_lsp::lsp_types::{
11
13
  CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse,
12
14
  CompletionTriggerKind, Diagnostic, DiagnosticSeverity, DiagnosticTag,
13
- DidChangeTextDocumentParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
14
- DidOpenTextDocumentParams, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
15
- DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
16
- HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, Location,
17
- MarkupContent, MarkupKind, MessageType, NumberOrString, OneOf, Position, Range,
18
- ReferenceParams, RenameOptions, RenameParams, ServerCapabilities, ServerInfo,
19
- SymbolInformation, SymbolKind, SymbolTag, TextDocumentPositionParams,
20
- TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit,
21
- WorkspaceFoldersChangeEvent, WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities,
22
- WorkspaceSymbolParams,
15
+ DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
16
+ DidChangeWatchedFilesRegistrationOptions, DidChangeWorkspaceFoldersParams,
17
+ DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams,
18
+ DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, FileSystemWatcher, GlobPattern,
19
+ GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
20
+ HoverProviderCapability, InitializeParams, InitializeResult, Location, MarkupContent,
21
+ MarkupKind, MessageType, NumberOrString, OneOf, Position, ProgressParams, ProgressParamsValue,
22
+ Range, ReferenceParams, Registration, RenameOptions, RenameParams,
23
+ ServerCapabilities, ServerInfo, SymbolInformation, SymbolKind, SymbolTag,
24
+ TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, Url,
25
+ WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions,
26
+ WorkspaceEdit, WorkspaceFoldersChangeEvent, WorkspaceFoldersServerCapabilities,
27
+ WorkspaceServerCapabilities, WorkspaceSymbolOptions, WorkspaceSymbolParams,
23
28
  };
24
29
  use tower_lsp::lsp_types::{PrepareRenameResponse, TextEdit};
25
30
  use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -51,6 +56,33 @@ struct Backend {
51
56
  /// based on divergent root snapshots. Held only across the (blocking) refresh, off the async
52
57
  /// driver (#162).
53
58
  symbol_refresh: Arc<Mutex<()>>,
59
+ /// Whether the client advertised `window.workDoneProgress` at `initialize`. Only then may the
60
+ /// server emit `$/progress` work-done notifications for long requests; otherwise a compliant
61
+ /// client would reject them (#164).
62
+ client_work_done_progress: Arc<RwLock<bool>>,
63
+ }
64
+
65
+ /// RAII cancel flag for a long-running request. tower-lsp aborts the request handler future on
66
+ /// `$/cancelRequest` (or when the client otherwise drops interest), which drops everything the
67
+ /// handler holds. Dropping this guard flips the shared flag, so blocking work handed to
68
+ /// `spawn_blocking` — which the runtime cannot itself abort — observes the cancellation and bails
69
+ /// early instead of running the whole walk and publishing a now-stale result (#164).
70
+ struct CancelGuard(Arc<AtomicBool>);
71
+
72
+ impl CancelGuard {
73
+ fn new() -> Self {
74
+ CancelGuard(Arc::new(AtomicBool::new(false)))
75
+ }
76
+ /// A handle the blocking work polls; stays live after the guard drops.
77
+ fn flag(&self) -> Arc<AtomicBool> {
78
+ Arc::clone(&self.0)
79
+ }
80
+ }
81
+
82
+ impl Drop for CancelGuard {
83
+ fn drop(&mut self) {
84
+ self.0.store(true, Ordering::Relaxed);
85
+ }
54
86
  }
55
87
 
56
88
  /// One symbol harvested from a workspace file, with its position pre-resolved so answering a query
@@ -87,6 +119,7 @@ async fn main() {
87
119
  tishlang_source_root: Arc::new(RwLock::new(None)),
88
120
  symbol_index: Arc::new(RwLock::new(HashMap::new())),
89
121
  symbol_refresh: Arc::new(Mutex::new(())),
122
+ client_work_done_progress: Arc::new(RwLock::new(false)),
90
123
  });
91
124
  Server::new(stdin, stdout, socket).serve(service).await;
92
125
  }
@@ -332,6 +365,18 @@ impl LanguageServer for Backend {
332
365
  }
333
366
  let mut g = self.tishlang_source_root.write().unwrap();
334
367
  *g = src_root.filter(|p| p.is_dir());
368
+ drop(g);
369
+
370
+ // Remember whether the client can display work-done progress. Only then does `symbol` emit
371
+ // `$/progress` begin/report/end for the (potentially slow) workspace crawl — a client that
372
+ // did not advertise it would reject the notifications (#164).
373
+ let supports_progress = params
374
+ .capabilities
375
+ .window
376
+ .as_ref()
377
+ .and_then(|w| w.work_done_progress)
378
+ .unwrap_or(false);
379
+ *self.client_work_done_progress.write().unwrap() = supports_progress;
335
380
 
336
381
  Ok(InitializeResult {
337
382
  capabilities: ServerCapabilities {
@@ -351,7 +396,15 @@ impl LanguageServer for Backend {
351
396
  })),
352
397
  document_formatting_provider: Some(OneOf::Left(true)),
353
398
  document_symbol_provider: Some(OneOf::Left(true)),
354
- workspace_symbol_provider: Some(OneOf::Left(true)),
399
+ // Advertise work-done progress on workspace/symbol so a capable client sends a
400
+ // `workDoneToken`; the handler reports crawl progress against it and honors
401
+ // `$/cancelRequest` mid-walk (#164).
402
+ workspace_symbol_provider: Some(OneOf::Right(WorkspaceSymbolOptions {
403
+ work_done_progress_options: WorkDoneProgressOptions {
404
+ work_done_progress: Some(true),
405
+ },
406
+ resolve_provider: None,
407
+ })),
355
408
  // Ask the client to send didChangeWorkspaceFolders so adding/removing a folder in a
356
409
  // multi-root workspace keeps `roots` (and thus the workspace-symbol index) accurate
357
410
  // instead of being frozen at the set captured by `initialize` (#162).
@@ -372,6 +425,35 @@ impl LanguageServer for Backend {
372
425
  }
373
426
 
374
427
  async fn initialized(&self, _: tower_lsp::lsp_types::InitializedParams) {
428
+ // Dynamically register for `workspace/didChangeWatchedFiles` over `.tish` and `.d.tish`
429
+ // files so an externally edited declaration (`.d.tish`) or source file refreshes the
430
+ // workspace-symbol index without a server restart (#161). The client only delivers these
431
+ // events for globs the server registers; a `.tish` glob does NOT cover `.d.tish` (it has a
432
+ // compound extension), so both are registered explicitly.
433
+ let watchers = |glob: &str| FileSystemWatcher {
434
+ glob_pattern: GlobPattern::String(glob.to_string()),
435
+ kind: None, // create | change | delete
436
+ };
437
+ let reg = Registration {
438
+ id: "tish-watch-d-tish".to_string(),
439
+ method: "workspace/didChangeWatchedFiles".to_string(),
440
+ register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
441
+ watchers: vec![watchers("**/*.tish"), watchers("**/*.d.tish")],
442
+ })
443
+ .ok(),
444
+ };
445
+ // Best-effort: a client that does not support dynamic watched-file registration returns an
446
+ // error here, which we log and move on from — the mtime-keyed index still self-heals on the
447
+ // next `workspace/symbol` query.
448
+ if let Err(e) = self.client.register_capability(vec![reg]).await {
449
+ self.client
450
+ .log_message(
451
+ MessageType::INFO,
452
+ format!("tish-lsp: watched-files registration skipped: {e}"),
453
+ )
454
+ .await;
455
+ }
456
+
375
457
  self.client
376
458
  .log_message(MessageType::INFO, "tish-lsp ready")
377
459
  .await;
@@ -390,6 +472,48 @@ impl LanguageServer for Backend {
390
472
  .await;
391
473
  }
392
474
 
475
+ async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
476
+ // React to on-disk `.tish` / `.d.tish` changes the editor is not buffering (created,
477
+ // externally edited, or deleted) so cross-file declarations refresh without a restart
478
+ // (#161). Drop the changed path from the mtime-keyed symbol index: a deleted file is
479
+ // evicted immediately, and a created/changed one is force-reparsed on the next
480
+ // `workspace/symbol` walk (its cache entry is gone, so the mtime shortcut can't reuse a
481
+ // stale parse). `.d.tish` files carry the `tish` extension, so both are handled here.
482
+ let mut invalidated = 0usize;
483
+ {
484
+ let mut idx = self.symbol_index.write().unwrap();
485
+ for change in &params.changes {
486
+ if let Ok(path) = change.uri.to_file_path() {
487
+ if path.extension().map(|x| x == "tish") == Some(true)
488
+ && idx.remove(&path).is_some()
489
+ {
490
+ invalidated += 1;
491
+ }
492
+ }
493
+ }
494
+ }
495
+ // Re-lint any OPEN buffer whose declarations may now have changed. A `.d.tish` supplies
496
+ // ambient `declare` bindings other buffers depend on, so a change there can flip
497
+ // unresolved-name diagnostics; recompute open docs against the new on-disk state.
498
+ let open: Vec<(Url, String)> = {
499
+ let docs = self.docs.read().unwrap();
500
+ docs.iter().map(|(u, t)| (u.clone(), t.clone())).collect()
501
+ };
502
+ for (uri, text) in open {
503
+ publish_parse_and_lint(&self.client, uri, text).await;
504
+ }
505
+ self.client
506
+ .log_message(
507
+ MessageType::INFO,
508
+ format!(
509
+ "tish-lsp: watched files changed ({} index entr{} invalidated)",
510
+ invalidated,
511
+ if invalidated == 1 { "y" } else { "ies" }
512
+ ),
513
+ )
514
+ .await;
515
+ }
516
+
393
517
  async fn shutdown(&self) -> Result<()> {
394
518
  Ok(())
395
519
  }
@@ -422,13 +546,27 @@ impl LanguageServer for Backend {
422
546
  let edit_seq = Arc::clone(&self.edit_seq);
423
547
  tokio::spawn(async move {
424
548
  tokio::time::sleep(std::time::Duration::from_millis(200)).await;
549
+ // Still the latest edit? (superseded-while-debouncing check.)
550
+ let is_current = |uri: &Url, seq: u64| {
551
+ edit_seq.read().unwrap().get(uri).copied() == Some(seq)
552
+ };
425
553
  // Superseded by a newer edit while we waited — drop this stale recompute.
426
- if edit_seq.read().unwrap().get(&uri).copied() != Some(seq) {
554
+ if !is_current(&uri, seq) {
427
555
  return;
428
556
  }
429
557
  let text = docs.read().unwrap().get(&uri).cloned();
430
558
  if let Some(text) = text {
431
- publish_parse_and_lint(&client, uri, text).await;
559
+ // Compute off the async driver, then re-check the sequence: analysis is O(file
560
+ // size) and a keystroke can land while it runs, so publishing unconditionally
561
+ // would show diagnostics for text the user has already moved past (#164). Bail
562
+ // before publishing if this recompute is no longer the latest.
563
+ let diags = tokio::task::spawn_blocking(move || compute_diagnostics(&text))
564
+ .await
565
+ .unwrap_or_default();
566
+ if !is_current(&uri, seq) {
567
+ return;
568
+ }
569
+ client.publish_diagnostics(uri, diags, None).await;
432
570
  }
433
571
  });
434
572
  }
@@ -957,22 +1095,88 @@ impl LanguageServer for Backend {
957
1095
  let roots_handle = Arc::clone(&self.roots);
958
1096
  let index = self.symbol_index.clone();
959
1097
  let refresh = Arc::clone(&self.symbol_refresh);
1098
+
1099
+ // Cancellation (#164). tower-lsp aborts THIS future on `$/cancelRequest`, dropping the guard
1100
+ // and flipping its flag; the flag lives on into the (un-abortable) blocking walk below, which
1101
+ // polls it and bails so we stop burning a blocking thread on a result nobody will read. The
1102
+ // clone kept in this future is what the drop-on-abort sets.
1103
+ let cancel = CancelGuard::new();
1104
+ let cancel_flag = cancel.flag();
1105
+
1106
+ // Work-done progress (#164): only when the client advertised support AND handed us a token to
1107
+ // report against. Bracket the blocking walk with begin/end `$/progress` notifications.
1108
+ let progress_token = params.work_done_progress_params.work_done_token;
1109
+ let show_progress = *self.client_work_done_progress.read().unwrap();
1110
+ let progress_token = progress_token.filter(|_| show_progress);
1111
+ if let Some(ref token) = progress_token {
1112
+ self.send_work_done(
1113
+ token.clone(),
1114
+ WorkDoneProgress::Begin(WorkDoneProgressBegin {
1115
+ title: "Searching workspace symbols".to_string(),
1116
+ cancellable: Some(true),
1117
+ message: Some(format!("query: {query}")),
1118
+ percentage: None,
1119
+ }),
1120
+ )
1121
+ .await;
1122
+ }
1123
+
960
1124
  // The crawl + fs-read + parse is CPU/IO-blocking; running it inline stalls tower-lsp's shared
961
1125
  // request driver (it multiplexes requests without a per-request spawn), so hover/completion
962
1126
  // in flight would freeze for its duration. Hand it to a blocking thread, and answer from the
963
1127
  // mtime-keyed index so repeated keystrokes don't re-walk and re-parse the tree (#135).
964
- let syms = tokio::task::spawn_blocking(move || {
1128
+ let walk_flag = Arc::clone(&cancel_flag);
1129
+ let result = tokio::task::spawn_blocking(move || {
965
1130
  // Serialize refreshes and read `roots` fresh under that lock (#162): folders can now
966
1131
  // change mid-session, and the index's global retain would otherwise let a query carrying
967
1132
  // a stale/smaller root snapshot evict another concurrent query's still-valid entries.
968
1133
  // One walk at a time, each over the current roots, makes that impossible.
969
1134
  let _refresh_guard = refresh.lock().unwrap_or_else(|e| e.into_inner());
970
1135
  let roots = roots_handle.read().unwrap().clone();
971
- refresh_and_query_symbols(&roots, &index, &query)
1136
+ refresh_and_query_symbols(&roots, &index, &query, &walk_flag)
972
1137
  })
973
1138
  .await
974
- .unwrap_or_default();
975
- Ok(Some(syms))
1139
+ .unwrap_or(WalkOutcome::Cancelled);
1140
+
1141
+ if let Some(ref token) = progress_token {
1142
+ self.send_work_done(
1143
+ token.clone(),
1144
+ WorkDoneProgress::End(WorkDoneProgressEnd {
1145
+ message: Some(match &result {
1146
+ WalkOutcome::Completed(s) => format!("{} symbol(s)", s.len()),
1147
+ WalkOutcome::Cancelled => "cancelled".to_string(),
1148
+ }),
1149
+ }),
1150
+ )
1151
+ .await;
1152
+ }
1153
+
1154
+ // Explicitly consume the guard here so it lives across the whole request (not dropped early
1155
+ // by NLL); if we were aborted, this line never runs and the guard's Drop already fired.
1156
+ drop(cancel);
1157
+
1158
+ match result {
1159
+ WalkOutcome::Completed(syms) => Ok(Some(syms)),
1160
+ // The walk saw the cancel flag and stopped early: report cancellation rather than an
1161
+ // empty/partial list the client might cache as authoritative (#164).
1162
+ WalkOutcome::Cancelled => Err(tower_lsp::jsonrpc::Error::request_cancelled()),
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ impl Backend {
1168
+ /// Send a single `$/progress` work-done notification against a client-provided token.
1169
+ async fn send_work_done(
1170
+ &self,
1171
+ token: tower_lsp::lsp_types::ProgressToken,
1172
+ value: WorkDoneProgress,
1173
+ ) {
1174
+ self.client
1175
+ .send_notification::<Progress>(ProgressParams {
1176
+ token,
1177
+ value: ProgressParamsValue::WorkDone(value),
1178
+ })
1179
+ .await;
976
1180
  }
977
1181
  }
978
1182
 
@@ -988,14 +1192,29 @@ fn ws_prune_dir(e: &walkdir::DirEntry) -> bool {
988
1192
  name == "node_modules" || name == "target" || name.starts_with('.')
989
1193
  }
990
1194
 
1195
+ /// Outcome of a workspace-symbol walk: the completed result set, or an early cancellation when the
1196
+ /// request was aborted mid-crawl (#164). Kept distinct so `symbol` can answer a cancelled walk with
1197
+ /// `RequestCancelled` rather than a partial list the client might treat as authoritative.
1198
+ #[derive(Debug)]
1199
+ enum WalkOutcome {
1200
+ Completed(Vec<SymbolInformation>),
1201
+ Cancelled,
1202
+ }
1203
+
991
1204
  /// Refresh the mtime-keyed symbol index for `roots` (re-parsing only changed/new files, evicting
992
1205
  /// deleted ones) and return the symbols whose lowercased name contains `query`. Runs on a blocking
993
1206
  /// thread. Factored out as a free function so it is unit-testable without a live `Backend` (#135).
1207
+ ///
1208
+ /// `cancel` is polled at each directory entry and before the (comparatively cheap) final scan; when
1209
+ /// it flips (the request was cancelled — see [`CancelGuard`]) the walk stops early and returns
1210
+ /// [`WalkOutcome::Cancelled`] instead of running the whole tree and building a stale result (#164).
1211
+ /// Any files already re-parsed before the abort stay cached, so the work is not wasted.
994
1212
  fn refresh_and_query_symbols(
995
1213
  roots: &[PathBuf],
996
1214
  index: &RwLock<HashMap<PathBuf, CachedFile>>,
997
1215
  query: &str,
998
- ) -> Vec<SymbolInformation> {
1216
+ cancel: &AtomicBool,
1217
+ ) -> WalkOutcome {
999
1218
  let mut seen: HashSet<PathBuf> = HashSet::new();
1000
1219
  for root in roots {
1001
1220
  for e in WalkDir::new(root)
@@ -1003,6 +1222,11 @@ fn refresh_and_query_symbols(
1003
1222
  .filter_entry(|e| !ws_prune_dir(e))
1004
1223
  .filter_map(|e| e.ok())
1005
1224
  {
1225
+ // Bail as soon as the client cancels: on a large tree the remaining walk+parse is pure
1226
+ // waste once nobody is waiting for the answer.
1227
+ if cancel.load(Ordering::Relaxed) {
1228
+ return WalkOutcome::Cancelled;
1229
+ }
1006
1230
  if !e.file_type().is_file()
1007
1231
  || e.path().extension().map(|x| x == "tish") != Some(true)
1008
1232
  {
@@ -1035,7 +1259,14 @@ fn refresh_and_query_symbols(
1035
1259
  index.write().unwrap().insert(path, CachedFile { mtime, uri, symbols });
1036
1260
  }
1037
1261
  }
1038
- // Evict files that vanished from the tree so deleted symbols don't linger in results.
1262
+ // A cancel that lands right as the walk finishes must not run the eviction below: `seen` is only
1263
+ // complete because every root was fully visited, but the caller no longer wants a result, so
1264
+ // stop before mutating the shared index on its behalf.
1265
+ if cancel.load(Ordering::Relaxed) {
1266
+ return WalkOutcome::Cancelled;
1267
+ }
1268
+ // Evict files that vanished from the tree so deleted symbols don't linger in results. Only safe
1269
+ // because the loop above ran to completion (no early cancel), so `seen` is the full file set.
1039
1270
  index.write().unwrap().retain(|p, _| seen.contains(p));
1040
1271
 
1041
1272
  // Answer the query from the now-fresh index.
@@ -1057,7 +1288,7 @@ fn refresh_and_query_symbols(
1057
1288
  }
1058
1289
  }
1059
1290
  }
1060
- out
1291
+ WalkOutcome::Completed(out)
1061
1292
  }
1062
1293
 
1063
1294
  /// Harvest every top-level (and exported / block-nested) named declaration from a statement into the
@@ -1617,6 +1848,9 @@ fn value_completion_kind_stmt(
1617
1848
  value_completion_kind_stmt(inner, name)
1618
1849
  }
1619
1850
  tishlang_ast::ExportDeclaration::Default(_) => None,
1851
+ // A re-exported name (`export { x } from "./m"` / `export * from`) is bound from another
1852
+ // module, not declared here, so there is no local completion-kind to report.
1853
+ tishlang_ast::ExportDeclaration::ReExport { .. } => None,
1620
1854
  },
1621
1855
  _ => None,
1622
1856
  }
@@ -2083,6 +2317,7 @@ mod jsonrpc_integration_tests {
2083
2317
  tishlang_source_root: Arc::new(RwLock::new(None)),
2084
2318
  symbol_index: Arc::new(RwLock::new(HashMap::new())),
2085
2319
  symbol_refresh: Arc::new(Mutex::new(())),
2320
+ client_work_done_progress: Arc::new(RwLock::new(false)),
2086
2321
  });
2087
2322
  service
2088
2323
  }
@@ -2337,6 +2572,83 @@ mod jsonrpc_integration_tests {
2337
2572
 
2338
2573
  std::fs::remove_dir_all(&dir).ok();
2339
2574
  }
2575
+
2576
+ fn did_change_watched_files(uris: &[&str]) -> Request {
2577
+ // FileChangeType::CHANGED == 2 in the LSP wire encoding.
2578
+ let changes: Vec<serde_json::Value> = uris
2579
+ .iter()
2580
+ .map(|u| serde_json::json!({ "uri": u, "type": 2 }))
2581
+ .collect();
2582
+ Request::build("workspace/didChangeWatchedFiles")
2583
+ .params(serde_json::json!({ "changes": changes }))
2584
+ .finish()
2585
+ }
2586
+
2587
+ // #164: with `window.workDoneProgress` advertised, the server must offer work-done progress on
2588
+ // workspace/symbol so the client sends a `workDoneToken` the handler can report/cancel against.
2589
+ #[tokio::test]
2590
+ async fn initialize_advertises_workspace_symbol_work_done_progress() {
2591
+ let mut service = new_service();
2592
+ let init = Request::build("initialize")
2593
+ .id(1)
2594
+ .params(serde_json::json!({
2595
+ "capabilities": { "window": { "workDoneProgress": true } }
2596
+ }))
2597
+ .finish();
2598
+ let result = call(&mut service, init).await.expect("initialize must return a result");
2599
+ assert_eq!(
2600
+ result["capabilities"]["workspaceSymbolProvider"]["workDoneProgress"],
2601
+ serde_json::json!(true),
2602
+ "workspace/symbol must advertise workDoneProgress, got {}",
2603
+ result["capabilities"]["workspaceSymbolProvider"]
2604
+ );
2605
+ }
2606
+
2607
+ // #161 end-to-end: after a `.d.tish` is indexed, an on-disk edit delivered via
2608
+ // didChangeWatchedFiles must be reflected on the next workspace/symbol query — the handler
2609
+ // invalidates the cached parse so a fresh one is picked up without a restart. `.d.tish` carries
2610
+ // the `tish` extension, so this also proves declaration files are walked at all.
2611
+ #[tokio::test]
2612
+ async fn did_change_watched_files_refreshes_d_tish_declaration() {
2613
+ let mut service = new_service();
2614
+ initialize(&mut service).await;
2615
+
2616
+ let dir = crate::test_fs::unique_temp_dir("dtish_watch");
2617
+ let decl = dir.join("ambient.d.tish");
2618
+ std::fs::write(&decl, "declare fn oldAmbient(): void\n").unwrap();
2619
+ let folder_uri = Url::from_file_path(&dir).unwrap().to_string();
2620
+ let file_uri = Url::from_file_path(&decl).unwrap().to_string();
2621
+
2622
+ // Register the folder and index the initial declaration.
2623
+ let _ = call(&mut service, did_change_workspace_folders(&[&folder_uri], &[])).await;
2624
+ let before = call(&mut service, symbol_request("oldAmbient")).await.expect("symbol result");
2625
+ assert_eq!(
2626
+ before.as_array().unwrap().len(),
2627
+ 1,
2628
+ "the declaration in the .d.tish is indexed, got {before}"
2629
+ );
2630
+
2631
+ // Rewrite the declaration file on disk (distinct mtime) and notify via the watcher.
2632
+ std::thread::sleep(std::time::Duration::from_millis(20));
2633
+ std::fs::write(&decl, "declare fn newAmbient(): void\n").unwrap();
2634
+ let _ = call(&mut service, did_change_watched_files(&[&file_uri])).await;
2635
+
2636
+ // The new declaration is now searchable and the old one is gone — proof the watched-file
2637
+ // change forced a re-parse rather than serving the stale cached symbols.
2638
+ let new_hit = call(&mut service, symbol_request("newAmbient")).await.expect("symbol result");
2639
+ assert_eq!(
2640
+ new_hit.as_array().unwrap().len(),
2641
+ 1,
2642
+ "the changed .d.tish declaration is re-indexed, got {new_hit}"
2643
+ );
2644
+ let old_gone = call(&mut service, symbol_request("oldAmbient")).await.expect("symbol result");
2645
+ assert!(
2646
+ old_gone.as_array().unwrap().is_empty(),
2647
+ "the stale declaration must be gone after the watched-file change, got {old_gone}"
2648
+ );
2649
+
2650
+ std::fs::remove_dir_all(&dir).ok();
2651
+ }
2340
2652
  }
2341
2653
 
2342
2654
  #[cfg(test)]
@@ -2428,6 +2740,20 @@ mod workspace_symbol_tests {
2428
2740
  syms.iter().map(|s| s.name.as_str()).collect()
2429
2741
  }
2430
2742
 
2743
+ /// Run a (never-cancelled) walk and unwrap the completed symbol list. Panics if the walk somehow
2744
+ /// reports cancellation, which cannot happen with an always-false flag.
2745
+ fn query(
2746
+ roots: &[PathBuf],
2747
+ index: &RwLock<HashMap<PathBuf, CachedFile>>,
2748
+ q: &str,
2749
+ ) -> Vec<SymbolInformation> {
2750
+ let never = AtomicBool::new(false);
2751
+ match refresh_and_query_symbols(roots, index, q, &never) {
2752
+ WalkOutcome::Completed(s) => s,
2753
+ WalkOutcome::Cancelled => panic!("uncancelled walk must complete"),
2754
+ }
2755
+ }
2756
+
2431
2757
  #[test]
2432
2758
  fn indexes_queries_and_prunes_heavy_dirs() {
2433
2759
  let dir = unique_temp_dir("idx");
@@ -2441,11 +2767,11 @@ mod workspace_symbol_tests {
2441
2767
  let index = RwLock::new(HashMap::new());
2442
2768
  let roots = [dir.clone()];
2443
2769
 
2444
- let alpha = refresh_and_query_symbols(&roots, &index, "alpha");
2770
+ let alpha = query(&roots, &index, "alpha");
2445
2771
  assert_eq!(names(&alpha), ["alphaFn"], "alphaFn matched; alphaDep pruned under node_modules");
2446
2772
  // case-insensitive substring across kinds
2447
- assert_eq!(names(&refresh_and_query_symbols(&roots, &index, "beta")), ["betaVar"]);
2448
- assert_eq!(names(&refresh_and_query_symbols(&roots, &index, "gamma")), ["GammaType"]);
2773
+ assert_eq!(names(&query(&roots, &index, "beta")), ["betaVar"]);
2774
+ assert_eq!(names(&query(&roots, &index, "gamma")), ["GammaType"]);
2449
2775
  // The cache holds exactly the two real workspace files, not the node_modules one.
2450
2776
  assert_eq!(index.read().unwrap().len(), 2, "two .tish files indexed, node_modules pruned");
2451
2777
 
@@ -2460,23 +2786,47 @@ mod workspace_symbol_tests {
2460
2786
  let index = RwLock::new(HashMap::new());
2461
2787
  let roots = [dir.clone()];
2462
2788
 
2463
- assert_eq!(refresh_and_query_symbols(&roots, &index, "first").len(), 1);
2464
- assert_eq!(refresh_and_query_symbols(&roots, &index, "second").len(), 0);
2789
+ assert_eq!(query(&roots, &index, "first").len(), 1);
2790
+ assert_eq!(query(&roots, &index, "second").len(), 0);
2465
2791
 
2466
2792
  // Rewrite with new content; the newer mtime must invalidate the cached parse. A short sleep
2467
2793
  // guarantees a distinct mtime even on coarse-resolution clocks.
2468
2794
  std::thread::sleep(std::time::Duration::from_millis(20));
2469
2795
  std::fs::write(&f, "fn second() {}\n").unwrap();
2470
- assert_eq!(refresh_and_query_symbols(&roots, &index, "second").len(), 1, "re-parsed after edit");
2471
- assert_eq!(refresh_and_query_symbols(&roots, &index, "first").len(), 0, "stale symbol gone");
2796
+ assert_eq!(query(&roots, &index, "second").len(), 1, "re-parsed after edit");
2797
+ assert_eq!(query(&roots, &index, "first").len(), 0, "stale symbol gone");
2472
2798
 
2473
2799
  // Deleting the file must evict it from the index.
2474
2800
  std::fs::remove_file(&f).unwrap();
2475
- assert_eq!(refresh_and_query_symbols(&roots, &index, "second").len(), 0, "deleted file evicted");
2801
+ assert_eq!(query(&roots, &index, "second").len(), 0, "deleted file evicted");
2476
2802
  assert!(index.read().unwrap().is_empty(), "index empty after the only file is removed");
2477
2803
 
2478
2804
  std::fs::remove_dir_all(&dir).ok();
2479
2805
  }
2806
+
2807
+ // #164: a pre-set cancel flag makes the walk bail before it scans/evicts, returning
2808
+ // `Cancelled` — the signal `symbol` turns into a `RequestCancelled` response instead of a
2809
+ // partial list, and (proven here) it leaves the shared index untouched.
2810
+ #[test]
2811
+ fn cancelled_walk_bails_without_mutating_index() {
2812
+ let dir = unique_temp_dir("cancel");
2813
+ std::fs::write(dir.join("c.tish"), "fn deltaSym() { return 1 }\n").unwrap();
2814
+ let index = RwLock::new(HashMap::new());
2815
+ let roots = [dir.clone()];
2816
+
2817
+ let cancelled = AtomicBool::new(true);
2818
+ let outcome = refresh_and_query_symbols(&roots, &index, "delta", &cancelled);
2819
+ assert!(
2820
+ matches!(outcome, WalkOutcome::Cancelled),
2821
+ "a pre-cancelled walk must report Cancelled"
2822
+ );
2823
+ assert!(
2824
+ index.read().unwrap().is_empty(),
2825
+ "a cancelled walk must not populate the index"
2826
+ );
2827
+
2828
+ std::fs::remove_dir_all(&dir).ok();
2829
+ }
2480
2830
  }
2481
2831
 
2482
2832
  #[cfg(test)]
@@ -294,9 +294,22 @@ tishlang_runtime = {{ path = {:?}{} }}
294
294
  };
295
295
  tishlang_build_utils::copy_binary_to_output(&artifact, &target)?;
296
296
 
297
+ cleanup_build_dir(&build_dir);
297
298
  Ok(())
298
299
  }
299
300
 
301
+ /// Remove a finished build's per-PID source directory (#384). Called only on SUCCESS, after the
302
+ /// binary is copied out — the dir is then disposable (it holds only the generated crate source;
303
+ /// compiled artifacts live in the shared workspace `target/`). Left in place on any earlier error so a
304
+ /// failed build can be inspected, and preserved entirely when `TISH_KEEP_BUILD_DIR=1` (e.g. to read the
305
+ /// generated `main.rs`). Best-effort: a failed removal is ignored.
306
+ pub(crate) fn cleanup_build_dir(build_dir: &Path) {
307
+ if std::env::var("TISH_KEEP_BUILD_DIR").as_deref() == Ok("1") {
308
+ return;
309
+ }
310
+ let _ = std::fs::remove_dir_all(build_dir);
311
+ }
312
+
300
313
  /// Build several native binaries in **one** nested Cargo project (shared `tishlang_runtime` compile).
301
314
  ///
302
315
  /// `bins` order must match `outputs`: each `(stem, rust_code, generated_native_rs)` pairs with
@@ -454,6 +467,7 @@ tishlang_runtime = {{ path = {:?}{} }}
454
467
  tishlang_build_utils::copy_binary_to_output(&binary, &target)?;
455
468
  }
456
469
 
470
+ cleanup_build_dir(&build_dir);
457
471
  Ok(())
458
472
  }
459
473
 
@@ -461,6 +475,33 @@ tishlang_runtime = {{ path = {:?}{} }}
461
475
  mod tests {
462
476
  use super::runtime_features_for_cargo;
463
477
 
478
+ /// #384: `cleanup_build_dir` removes a finished build's dir by default, and preserves it under
479
+ /// `TISH_KEEP_BUILD_DIR=1`.
480
+ #[test]
481
+ fn cleanup_build_dir_removes_by_default_and_keeps_on_flag() {
482
+ use std::path::PathBuf;
483
+ // Scratch under the workspace target/ (never /tmp), unique per case.
484
+ let base: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
485
+ .join("../../target/tmp_cleanup_test");
486
+ let removed = base.join("to_remove");
487
+ let kept = base.join("to_keep");
488
+ for d in [&removed, &kept] {
489
+ std::fs::create_dir_all(d.join("src")).unwrap();
490
+ std::fs::write(d.join("src/main.rs"), "fn main(){}").unwrap();
491
+ }
492
+
493
+ std::env::remove_var("TISH_KEEP_BUILD_DIR");
494
+ super::cleanup_build_dir(&removed);
495
+ assert!(!removed.exists(), "default should remove the build dir");
496
+
497
+ std::env::set_var("TISH_KEEP_BUILD_DIR", "1");
498
+ super::cleanup_build_dir(&kept);
499
+ assert!(kept.exists(), "TISH_KEEP_BUILD_DIR=1 should preserve the build dir");
500
+ std::env::remove_var("TISH_KEEP_BUILD_DIR");
501
+
502
+ let _ = std::fs::remove_dir_all(&base);
503
+ }
504
+
464
505
  #[test]
465
506
  fn runtime_features_full_expands() {
466
507
  let f = runtime_features_for_cargo(&["full".to_string()]);
@@ -9,3 +9,7 @@ repository = { workspace = true }
9
9
  [dependencies]
10
10
  tishlang_lexer = { path = "../tish_lexer", version = ">=0.1" }
11
11
  tishlang_ast = { path = "../tish_ast", version = ">=0.1" }
12
+ # #381: grow the stack on demand during the recursive descent so deeply-nested source can't overflow
13
+ # the fixed OS thread stack before the MAX_PARSE_DEPTH bound converts it into a catchable error.
14
+ # (No-op shim on wasm32, matching tish_eval / tish_vm.)
15
+ stacker = "0.1"