@tishlang/tish 2.2.7 → 2.9.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 (55) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +2 -1
  4. package/crates/tish/src/cli_help.rs +15 -0
  5. package/crates/tish/src/main.rs +139 -12
  6. package/crates/tish/tests/integration_test.rs +65 -0
  7. package/crates/tish_ast/src/ast.rs +6 -2
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +1 -1
  17. package/crates/tish_compile/src/codegen.rs +3133 -582
  18. package/crates/tish_compile/src/infer.rs +1950 -56
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +17 -0
  22. package/crates/tish_compile_js/Cargo.toml +3 -0
  23. package/crates/tish_compile_js/src/codegen.rs +365 -9
  24. package/crates/tish_compile_js/src/lib.rs +2 -1
  25. package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
  26. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  27. package/crates/tish_core/Cargo.toml +2 -0
  28. package/crates/tish_core/src/json.rs +135 -34
  29. package/crates/tish_core/src/lib.rs +23 -1
  30. package/crates/tish_core/src/value.rs +64 -11
  31. package/crates/tish_eval/src/eval.rs +144 -197
  32. package/crates/tish_eval/src/natives.rs +45 -45
  33. package/crates/tish_eval/src/regex.rs +35 -27
  34. package/crates/tish_eval/src/value.rs +8 -0
  35. package/crates/tish_fmt/src/lib.rs +45 -1
  36. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  37. package/crates/tish_lint/src/lib.rs +197 -21
  38. package/crates/tish_lsp/Cargo.toml +6 -0
  39. package/crates/tish_lsp/src/import_goto.rs +52 -7
  40. package/crates/tish_lsp/src/main.rs +856 -140
  41. package/crates/tish_opt/src/lib.rs +5 -3
  42. package/crates/tish_parser/src/lib.rs +23 -5
  43. package/crates/tish_parser/src/parser.rs +15 -26
  44. package/crates/tish_resolve/src/lib.rs +188 -18
  45. package/crates/tish_runtime/src/lib.rs +58 -18
  46. package/crates/tish_ui/src/jsx.rs +2 -2
  47. package/crates/tish_vm/src/jit.rs +212 -10
  48. package/crates/tish_vm/src/vm.rs +39 -18
  49. package/crates/tish_wasm/src/lib.rs +116 -22
  50. package/package.json +1 -1
  51. package/platform/darwin-arm64/tish +0 -0
  52. package/platform/darwin-x64/tish +0 -0
  53. package/platform/linux-arm64/tish +0 -0
  54. package/platform/linux-x64/tish +0 -0
  55. package/platform/win32-x64/tish.exe +0 -0
@@ -1,22 +1,25 @@
1
1
  //! Tish Language Server — diagnostics, symbols, completion, format, go-to-definition, workspace symbols.
2
2
 
3
- use std::collections::HashMap;
3
+ use std::collections::{HashMap, HashSet};
4
4
  use std::path::PathBuf;
5
- use std::sync::{Arc, RwLock};
5
+ use std::sync::{Arc, Mutex, RwLock};
6
+ use std::time::SystemTime;
6
7
 
7
8
  use regex::Regex;
8
9
  use tower_lsp::jsonrpc::Result;
9
10
  use tower_lsp::lsp_types::{
10
11
  CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse,
11
12
  CompletionTriggerKind, Diagnostic, DiagnosticSeverity, DiagnosticTag,
12
- DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
13
- DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
14
- GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
15
- HoverProviderCapability, InitializeParams, InitializeResult, Location, MarkupContent,
16
- MarkupKind, MessageType, NumberOrString, OneOf, Position, Range, ReferenceParams,
17
- RenameOptions, RenameParams, ServerCapabilities, ServerInfo, SymbolInformation, SymbolKind,
18
- SymbolTag, TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, Url,
19
- WorkDoneProgressOptions, WorkspaceEdit, WorkspaceSymbolParams,
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,
20
23
  };
21
24
  use tower_lsp::lsp_types::{PrepareRenameResponse, TextEdit};
22
25
  use tower_lsp::{Client, LanguageServer, LspService, Server};
@@ -38,6 +41,36 @@ struct Backend {
38
41
  cargo_src_cache: Arc<RwLock<HashMap<(PathBuf, String), PathBuf>>>,
39
42
  /// Root of the `tishlang/tish` checkout (parent of `crates/`), for built-in / JSX goto-definition.
40
43
  tishlang_source_root: Arc<RwLock<Option<PathBuf>>>,
44
+ /// Workspace-symbol index: absolute path → parsed symbols, validated by file mtime. Built lazily
45
+ /// on a blocking thread by `symbol` so the crawl+parse never stalls tower-lsp's shared request
46
+ /// driver, and reused across queries instead of re-reading the whole tree each keystroke (#135).
47
+ symbol_index: Arc<RwLock<HashMap<PathBuf, CachedFile>>>,
48
+ /// Serializes workspace-symbol index refreshes. With roots immutable this was unnecessary, but
49
+ /// #162 lets folders change mid-session: without serialization two concurrent `symbol` walks
50
+ /// could interleave and, via the index's global retain, evict each other's still-valid entries
51
+ /// based on divergent root snapshots. Held only across the (blocking) refresh, off the async
52
+ /// driver (#162).
53
+ symbol_refresh: Arc<Mutex<()>>,
54
+ }
55
+
56
+ /// One symbol harvested from a workspace file, with its position pre-resolved so answering a query
57
+ /// never needs the source text again.
58
+ #[derive(Clone, Debug)]
59
+ struct CachedSymbol {
60
+ name: String,
61
+ /// Lowercased `name`, precomputed for the case-insensitive substring match.
62
+ name_lower: String,
63
+ kind: SymbolKind,
64
+ range: Range,
65
+ }
66
+
67
+ /// A per-file entry in the workspace-symbol index. `mtime` validates the cache: an unchanged file is
68
+ /// reused, an externally edited one is re-parsed.
69
+ #[derive(Debug)]
70
+ struct CachedFile {
71
+ mtime: SystemTime,
72
+ uri: Url,
73
+ symbols: Vec<CachedSymbol>,
41
74
  }
42
75
 
43
76
  #[tokio::main]
@@ -52,6 +85,8 @@ async fn main() {
52
85
  roots: Arc::new(RwLock::new(Vec::new())),
53
86
  cargo_src_cache: Arc::new(RwLock::new(HashMap::new())),
54
87
  tishlang_source_root: Arc::new(RwLock::new(None)),
88
+ symbol_index: Arc::new(RwLock::new(HashMap::new())),
89
+ symbol_refresh: Arc::new(Mutex::new(())),
55
90
  });
56
91
  Server::new(stdin, stdout, socket).serve(service).await;
57
92
  }
@@ -145,7 +180,23 @@ fn document_symbol(
145
180
  }
146
181
  }
147
182
 
148
- async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
183
+ async fn publish_parse_and_lint(client: &Client, uri: Url, text: String) {
184
+ // Parse/lint/resolve/typecheck is CPU-bound and can be O(file size); running it on the async
185
+ // task lets a slow analysis occupy a runtime worker and head-of-line-block the hover/goto/
186
+ // completion requests tower-lsp drives on the same runtime. Hand the pure computation to the
187
+ // blocking pool and only await the (cheap) publish here (#160).
188
+ let diags = tokio::task::spawn_blocking(move || compute_diagnostics(&text))
189
+ .await
190
+ .unwrap_or_default();
191
+ // MUST be awaited — `publish_diagnostics` is async; a bare `let _ = …` drops the future
192
+ // unsent, which silently disables ALL LSP diagnostics (parse errors, lints, unused bindings).
193
+ client.publish_diagnostics(uri, diags, None).await;
194
+ }
195
+
196
+ /// Run the full diagnostic pipeline — parse → lint → resolve (unresolved names, unused bindings) →
197
+ /// gradual typecheck — over a document's text and return LSP diagnostics. Pure and synchronous so it
198
+ /// can run on the blocking pool off the request driver, and unit-testable on its own (#160).
199
+ fn compute_diagnostics(text: &str) -> Vec<Diagnostic> {
149
200
  let mut diags = Vec::new();
150
201
  match tishlang_parser::parse(text) {
151
202
  Ok(program) => {
@@ -159,6 +210,7 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
159
210
  severity: Some(sev),
160
211
  code: Some(NumberOrString::String(d.code.to_string())),
161
212
  message: d.message,
213
+ source: Some("tish".into()),
162
214
  ..Default::default()
163
215
  });
164
216
  }
@@ -168,6 +220,7 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
168
220
  severity: Some(DiagnosticSeverity::ERROR),
169
221
  code: Some(NumberOrString::String("tish-unresolved-name".into())),
170
222
  message: format!("no binding in scope for `{}`", u.name),
223
+ source: Some("tish".into()),
171
224
  ..Default::default()
172
225
  });
173
226
  }
@@ -214,13 +267,30 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
214
267
  range: diag_range(l, c, text),
215
268
  severity: Some(DiagnosticSeverity::ERROR),
216
269
  message: e,
270
+ source: Some("tish".into()),
217
271
  ..Default::default()
218
272
  });
219
273
  }
220
274
  }
221
- // MUST be awaited — `publish_diagnostics` is async; a bare `let _ = …` drops the future
222
- // unsent, which silently disables ALL LSP diagnostics (parse errors, lints, unused bindings).
223
- client.publish_diagnostics(uri, diags, None).await;
275
+ diags
276
+ }
277
+
278
+ /// Apply a workspace-folder change event to the root set: drop removed folders, append added ones
279
+ /// (skipping any already present and any whose URI is not a local file path). Pure so the add/remove/
280
+ /// dedup logic is unit-testable without a live `Backend`/`Client` (#162).
281
+ fn apply_workspace_folder_changes(roots: &mut Vec<PathBuf>, event: &WorkspaceFoldersChangeEvent) {
282
+ for removed in &event.removed {
283
+ if let Ok(p) = removed.uri.to_file_path() {
284
+ roots.retain(|r| r != &p);
285
+ }
286
+ }
287
+ for added in &event.added {
288
+ if let Ok(p) = added.uri.to_file_path() {
289
+ if !roots.contains(&p) {
290
+ roots.push(p);
291
+ }
292
+ }
293
+ }
224
294
  }
225
295
 
226
296
  #[tower_lsp::async_trait]
@@ -282,6 +352,16 @@ impl LanguageServer for Backend {
282
352
  document_formatting_provider: Some(OneOf::Left(true)),
283
353
  document_symbol_provider: Some(OneOf::Left(true)),
284
354
  workspace_symbol_provider: Some(OneOf::Left(true)),
355
+ // Ask the client to send didChangeWorkspaceFolders so adding/removing a folder in a
356
+ // multi-root workspace keeps `roots` (and thus the workspace-symbol index) accurate
357
+ // instead of being frozen at the set captured by `initialize` (#162).
358
+ workspace: Some(WorkspaceServerCapabilities {
359
+ workspace_folders: Some(WorkspaceFoldersServerCapabilities {
360
+ supported: Some(true),
361
+ change_notifications: Some(OneOf::Left(true)),
362
+ }),
363
+ file_operations: None,
364
+ }),
285
365
  ..Default::default()
286
366
  },
287
367
  server_info: Some(ServerInfo {
@@ -297,6 +377,19 @@ impl LanguageServer for Backend {
297
377
  .await;
298
378
  }
299
379
 
380
+ async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
381
+ // Keep `roots` in sync as folders are added/removed from a multi-root workspace (#162). The
382
+ // workspace-symbol index needs no explicit invalidation: its next walk visits any added root
383
+ // and, by no longer visiting a removed one, evicts that root's files on its own.
384
+ {
385
+ let mut roots = self.roots.write().unwrap();
386
+ apply_workspace_folder_changes(&mut roots, &params.event);
387
+ }
388
+ self.client
389
+ .log_message(MessageType::INFO, "tish-lsp: workspace folders updated")
390
+ .await;
391
+ }
392
+
300
393
  async fn shutdown(&self) -> Result<()> {
301
394
  Ok(())
302
395
  }
@@ -305,7 +398,7 @@ impl LanguageServer for Backend {
305
398
  let uri = p.text_document.uri;
306
399
  let text = p.text_document.text;
307
400
  self.docs.write().unwrap().insert(uri.clone(), text.clone());
308
- publish_parse_and_lint(&self.client, uri, &text).await;
401
+ publish_parse_and_lint(&self.client, uri, text).await;
309
402
  }
310
403
 
311
404
  async fn did_change(&self, p: DidChangeTextDocumentParams) {
@@ -335,7 +428,7 @@ impl LanguageServer for Backend {
335
428
  }
336
429
  let text = docs.read().unwrap().get(&uri).cloned();
337
430
  if let Some(text) = text {
338
- publish_parse_and_lint(&client, uri, &text).await;
431
+ publish_parse_and_lint(&client, uri, text).await;
339
432
  }
340
433
  });
341
434
  }
@@ -360,6 +453,31 @@ impl LanguageServer for Backend {
360
453
  return Ok(None);
361
454
  };
362
455
 
456
+ // Member position: completion was triggered by `.`, or the text right before the cursor
457
+ // ends in `.` (e.g. `obj.`). We don't do member completion yet, so return NOTHING rather
458
+ // than the language keyword list / in-scope names, which are never valid after a dot and
459
+ // were the noise this handler used to emit (#146). This is where member items would go.
460
+ let dot_trigger = params
461
+ .context
462
+ .as_ref()
463
+ .map(|c| {
464
+ matches!(c.trigger_kind, CompletionTriggerKind::TRIGGER_CHARACTER)
465
+ && c.trigger_character.as_deref() == Some(".")
466
+ })
467
+ .unwrap_or(false);
468
+ let after_dot = dot_trigger
469
+ || text
470
+ .lines()
471
+ .nth(pos.line as usize)
472
+ .map(|line| {
473
+ let upto: String = line.chars().take(pos.character as usize).collect();
474
+ upto.trim_end().ends_with('.')
475
+ })
476
+ .unwrap_or(false);
477
+ if after_dot {
478
+ return Ok(Some(CompletionResponse::Array(vec![])));
479
+ }
480
+
363
481
  let keywords = [
364
482
  "fn", "async", "let", "const", "if", "else", "while", "for", "return", "break",
365
483
  "continue", "switch", "case", "default", "try", "catch", "finally", "throw", "import",
@@ -390,14 +508,6 @@ impl LanguageServer for Backend {
390
508
  }
391
509
  }
392
510
 
393
- if let Some(ctx) = params.context {
394
- if matches!(ctx.trigger_kind, CompletionTriggerKind::TRIGGER_CHARACTER)
395
- && ctx.trigger_character.as_deref() == Some(".")
396
- {
397
- // After dot: could add member completion later
398
- }
399
- }
400
-
401
511
  Ok(Some(CompletionResponse::Array(items)))
402
512
  }
403
513
 
@@ -454,12 +564,14 @@ impl LanguageServer for Backend {
454
564
  if let Ok(ref file_path) = uri.to_file_path() {
455
565
  let word = word_at_position(&text, position);
456
566
  let roots = self.roots.read().unwrap().clone();
567
+ let open_docs = self.docs.read().unwrap();
457
568
  if let Some(loc) = import_goto::definition_for_import(
458
569
  &program,
459
570
  file_path,
460
571
  word.as_str(),
461
572
  &roots,
462
573
  self.cargo_src_cache.as_ref(),
574
+ &open_docs,
463
575
  ) {
464
576
  return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
465
577
  }
@@ -489,12 +601,14 @@ impl LanguageServer for Backend {
489
601
 
490
602
  if let Ok(ref file_path) = uri.to_file_path() {
491
603
  let roots = self.roots.read().unwrap().clone();
604
+ let open_docs = self.docs.read().unwrap();
492
605
  if let Some(loc) = import_goto::definition_for_import(
493
606
  &program,
494
607
  file_path,
495
608
  word.as_str(),
496
609
  &roots,
497
610
  self.cargo_src_cache.as_ref(),
611
+ &open_docs,
498
612
  ) {
499
613
  return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
500
614
  }
@@ -507,6 +621,7 @@ impl LanguageServer for Backend {
507
621
  position.line,
508
622
  position.character,
509
623
  word.as_str(),
624
+ &open_docs,
510
625
  ) {
511
626
  return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
512
627
  }
@@ -605,10 +720,23 @@ impl LanguageServer for Backend {
605
720
  }
606
721
  } else {
607
722
  let word = word_at_position(&text, pos);
723
+ // On a member property (`obj.a`) there is no separate lexical binding, so
724
+ // "No binding in scope" is misleading and disagrees with go-to-definition's
725
+ // silent no-op on the same token. Show a neutral note there instead. (#159)
726
+ let on_member_prop = tishlang_resolve::member_access_chain_at_cursor(
727
+ &program, &text, pos.line, pos.character,
728
+ )
729
+ .is_some();
730
+ let no_binding_msg = if on_member_prop {
731
+ "\n\n_Object property._"
732
+ } else {
733
+ "\n\n_No binding in scope for this name._"
734
+ };
608
735
  if word.is_empty() {
609
736
  md.push_str("\n\n_No binding in scope for this name._");
610
737
  } else if let Ok(fp) = uri.to_file_path() {
611
738
  let roots = self.roots.read().unwrap().clone();
739
+ let open_docs = self.docs.read().unwrap();
612
740
  if let Some(nmd) = import_goto::native_member_definition(
613
741
  &program,
614
742
  &fp,
@@ -618,6 +746,7 @@ impl LanguageServer for Backend {
618
746
  pos.line,
619
747
  pos.character,
620
748
  word.as_str(),
749
+ &open_docs,
621
750
  ) {
622
751
  md.push_str(
623
752
  "\n\n_Native host module member (e.g. `tish:macos`); implementation in Rust._",
@@ -633,10 +762,10 @@ impl LanguageServer for Backend {
633
762
  "\n\n[Open Rust implementation]({href}#L{line_1})"
634
763
  ));
635
764
  } else {
636
- md.push_str("\n\n_No binding in scope for this name._");
765
+ md.push_str(no_binding_msg);
637
766
  }
638
767
  } else {
639
- md.push_str("\n\n_No binding in scope for this name._");
768
+ md.push_str(no_binding_msg);
640
769
  }
641
770
  }
642
771
  }
@@ -703,15 +832,15 @@ impl LanguageServer for Backend {
703
832
  let Ok(program) = tishlang_parser::parse(&text) else {
704
833
  return Ok(None);
705
834
  };
706
- let Some(nu) = tishlang_resolve::name_at_cursor(&program, &text, pos.line, pos.character)
707
- else {
708
- return Ok(None);
709
- };
710
- let range = span_to_range(&nu.span, &text);
711
- Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
712
- range,
713
- placeholder: nu.name.to_string(),
714
- }))
835
+ // Only offer a rename box for a symbol rename() can actually act on — not a member property
836
+ // or other non-binding token, which would silently no-op (#145).
837
+ match rename_target(&program, &text, pos.line, pos.character) {
838
+ Some((range, placeholder)) => Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
839
+ range,
840
+ placeholder,
841
+ })),
842
+ None => Ok(None),
843
+ }
715
844
  }
716
845
 
717
846
  async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
@@ -825,131 +954,143 @@ impl LanguageServer for Backend {
825
954
  if query.is_empty() {
826
955
  return Ok(Some(vec![]));
827
956
  }
828
- let roots = self.roots.read().unwrap().clone();
829
- let mut out = Vec::new();
957
+ let roots_handle = Arc::clone(&self.roots);
958
+ let index = self.symbol_index.clone();
959
+ let refresh = Arc::clone(&self.symbol_refresh);
960
+ // The crawl + fs-read + parse is CPU/IO-blocking; running it inline stalls tower-lsp's shared
961
+ // request driver (it multiplexes requests without a per-request spawn), so hover/completion
962
+ // in flight would freeze for its duration. Hand it to a blocking thread, and answer from the
963
+ // mtime-keyed index so repeated keystrokes don't re-walk and re-parse the tree (#135).
964
+ let syms = tokio::task::spawn_blocking(move || {
965
+ // Serialize refreshes and read `roots` fresh under that lock (#162): folders can now
966
+ // change mid-session, and the index's global retain would otherwise let a query carrying
967
+ // a stale/smaller root snapshot evict another concurrent query's still-valid entries.
968
+ // One walk at a time, each over the current roots, makes that impossible.
969
+ let _refresh_guard = refresh.lock().unwrap_or_else(|e| e.into_inner());
970
+ let roots = roots_handle.read().unwrap().clone();
971
+ refresh_and_query_symbols(&roots, &index, &query)
972
+ })
973
+ .await
974
+ .unwrap_or_default();
975
+ Ok(Some(syms))
976
+ }
977
+ }
830
978
 
831
- for root in roots {
832
- for e in WalkDir::new(&root)
833
- .into_iter()
834
- .filter_map(|e| e.ok())
835
- .filter(|e| e.path().extension().map(|x| x == "tish").unwrap_or(false))
836
- {
837
- let path = e.path();
838
- let Ok(src) = std::fs::read_to_string(path) else {
839
- continue;
840
- };
841
- let Ok(program) = tishlang_parser::parse(&src) else {
842
- continue;
843
- };
844
- let Ok(uri) = Url::from_file_path(path) else {
845
- continue;
846
- };
847
- for s in &program.statements {
848
- collect_workspace_syms(s, &src, &uri, &query, &mut out);
849
- }
850
- }
851
- }
852
- Ok(Some(out))
979
+ /// Prune heavy or irrelevant subtrees from the workspace-symbol crawl — dependency and build
980
+ /// directories and any hidden directory (`.git`, `.vscode`, …). Files and the crawl root itself are
981
+ /// never pruned. walkdir has no `.gitignore` support, so this is the floor that keeps `node_modules`
982
+ /// / `target` from dominating the walk on a real project.
983
+ fn ws_prune_dir(e: &walkdir::DirEntry) -> bool {
984
+ if e.depth() == 0 || !e.file_type().is_dir() {
985
+ return false;
853
986
  }
987
+ let name = e.file_name().to_string_lossy();
988
+ name == "node_modules" || name == "target" || name.starts_with('.')
854
989
  }
855
990
 
856
- fn collect_workspace_syms(
857
- s: &tishlang_ast::Statement,
858
- text: &str,
859
- uri: &Url,
991
+ /// Refresh the mtime-keyed symbol index for `roots` (re-parsing only changed/new files, evicting
992
+ /// deleted ones) and return the symbols whose lowercased name contains `query`. Runs on a blocking
993
+ /// thread. Factored out as a free function so it is unit-testable without a live `Backend` (#135).
994
+ fn refresh_and_query_symbols(
995
+ roots: &[PathBuf],
996
+ index: &RwLock<HashMap<PathBuf, CachedFile>>,
860
997
  query: &str,
861
- out: &mut Vec<SymbolInformation>,
862
- ) {
863
- match s {
864
- tishlang_ast::Statement::FunDecl {
865
- name, name_span, ..
866
- } => {
867
- if name.to_lowercase().contains(query) {
868
- out.push(symbol_information(
869
- name.to_string(),
870
- SymbolKind::FUNCTION,
871
- None,
872
- Location {
873
- uri: uri.clone(),
874
- range: span_to_range(name_span, text),
875
- },
876
- None,
877
- ));
878
- }
879
- }
880
- tishlang_ast::Statement::VarDecl {
881
- name, name_span, ..
882
- } => {
883
- if name.to_lowercase().contains(query) {
884
- out.push(symbol_information(
885
- name.to_string(),
886
- SymbolKind::VARIABLE,
887
- None,
888
- Location {
889
- uri: uri.clone(),
890
- range: span_to_range(name_span, text),
891
- },
892
- None,
893
- ));
998
+ ) -> Vec<SymbolInformation> {
999
+ let mut seen: HashSet<PathBuf> = HashSet::new();
1000
+ for root in roots {
1001
+ for e in WalkDir::new(root)
1002
+ .into_iter()
1003
+ .filter_entry(|e| !ws_prune_dir(e))
1004
+ .filter_map(|e| e.ok())
1005
+ {
1006
+ if !e.file_type().is_file()
1007
+ || e.path().extension().map(|x| x == "tish") != Some(true)
1008
+ {
1009
+ continue;
894
1010
  }
895
- }
896
- tishlang_ast::Statement::TypeAlias {
897
- name, name_span, ..
898
- } => {
899
- if name.to_lowercase().contains(query) {
900
- out.push(symbol_information(
901
- name.to_string(),
902
- SymbolKind::INTERFACE,
903
- None,
904
- Location {
905
- uri: uri.clone(),
906
- range: span_to_range(name_span, text),
907
- },
908
- None,
909
- ));
1011
+ let path = e.path().to_path_buf();
1012
+ let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
1013
+ seen.insert(path.clone());
1014
+ // Reuse the cached parse when the file is unchanged (same mtime).
1015
+ let fresh = mtime.is_some_and(|mt| {
1016
+ index.read().unwrap().get(&path).is_some_and(|cf| cf.mtime == mt)
1017
+ });
1018
+ if fresh {
1019
+ continue;
910
1020
  }
911
- }
912
- tishlang_ast::Statement::DeclareFun {
913
- name, name_span, ..
914
- } => {
915
- if name.to_lowercase().contains(query) {
916
- out.push(symbol_information(
917
- name.to_string(),
918
- SymbolKind::FUNCTION,
919
- None,
920
- Location {
921
- uri: uri.clone(),
922
- range: span_to_range(name_span, text),
923
- },
924
- None,
925
- ));
1021
+ let Ok(src) = std::fs::read_to_string(&path) else {
1022
+ continue;
1023
+ };
1024
+ let Ok(program) = tishlang_parser::parse(&src) else {
1025
+ continue;
1026
+ };
1027
+ let Ok(uri) = Url::from_file_path(&path) else {
1028
+ continue;
1029
+ };
1030
+ let mut symbols = Vec::new();
1031
+ for s in &program.statements {
1032
+ collect_file_symbols(s, &src, &mut symbols);
926
1033
  }
1034
+ let mtime = mtime.unwrap_or(SystemTime::UNIX_EPOCH);
1035
+ index.write().unwrap().insert(path, CachedFile { mtime, uri, symbols });
927
1036
  }
928
- tishlang_ast::Statement::DeclareVar {
929
- name, name_span, ..
930
- } => {
931
- if name.to_lowercase().contains(query) {
1037
+ }
1038
+ // Evict files that vanished from the tree so deleted symbols don't linger in results.
1039
+ index.write().unwrap().retain(|p, _| seen.contains(p));
1040
+
1041
+ // Answer the query from the now-fresh index.
1042
+ let g = index.read().unwrap();
1043
+ let mut out = Vec::new();
1044
+ for cf in g.values() {
1045
+ for sym in &cf.symbols {
1046
+ if sym.name_lower.contains(query) {
932
1047
  out.push(symbol_information(
933
- name.to_string(),
934
- SymbolKind::VARIABLE,
1048
+ sym.name.clone(),
1049
+ sym.kind,
935
1050
  None,
936
1051
  Location {
937
- uri: uri.clone(),
938
- range: span_to_range(name_span, text),
1052
+ uri: cf.uri.clone(),
1053
+ range: sym.range,
939
1054
  },
940
1055
  None,
941
1056
  ));
942
1057
  }
943
1058
  }
944
- tishlang_ast::Statement::Export { declaration, .. } => {
1059
+ }
1060
+ out
1061
+ }
1062
+
1063
+ /// Harvest every top-level (and exported / block-nested) named declaration from a statement into the
1064
+ /// cache, with positions resolved up front. Unlike the old query-filtered collector this stores the
1065
+ /// full symbol set so the index is query-independent and a query is a cheap in-memory filter (#135).
1066
+ fn collect_file_symbols(s: &tishlang_ast::Statement, text: &str, out: &mut Vec<CachedSymbol>) {
1067
+ use tishlang_ast::Statement as St;
1068
+ let named: Option<(&str, SymbolKind, &tishlang_ast::Span)> = match s {
1069
+ St::FunDecl { name, name_span, .. } => Some((name, SymbolKind::FUNCTION, name_span)),
1070
+ St::VarDecl { name, name_span, .. } => Some((name, SymbolKind::VARIABLE, name_span)),
1071
+ St::TypeAlias { name, name_span, .. } => Some((name, SymbolKind::INTERFACE, name_span)),
1072
+ St::DeclareFun { name, name_span, .. } => Some((name, SymbolKind::FUNCTION, name_span)),
1073
+ St::DeclareVar { name, name_span, .. } => Some((name, SymbolKind::VARIABLE, name_span)),
1074
+ _ => None,
1075
+ };
1076
+ if let Some((name, kind, name_span)) = named {
1077
+ out.push(CachedSymbol {
1078
+ name: name.to_string(),
1079
+ name_lower: name.to_lowercase(),
1080
+ kind,
1081
+ range: span_to_range(name_span, text),
1082
+ });
1083
+ return;
1084
+ }
1085
+ match s {
1086
+ St::Export { declaration, .. } => {
945
1087
  if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
946
- collect_workspace_syms(inner, text, uri, query, out);
1088
+ collect_file_symbols(inner, text, out);
947
1089
  }
948
1090
  }
949
- tishlang_ast::Statement::Block { statements, .. }
950
- | tishlang_ast::Statement::Multi { statements, .. } => {
1091
+ St::Block { statements, .. } | St::Multi { statements, .. } => {
951
1092
  for x in statements {
952
- collect_workspace_syms(x, text, uri, query, out);
1093
+ collect_file_symbols(x, text, out);
953
1094
  }
954
1095
  }
955
1096
  _ => {}
@@ -1064,6 +1205,25 @@ fn span_to_range(span: &tishlang_ast::Span, text: &str) -> Range {
1064
1205
  }
1065
1206
  }
1066
1207
 
1208
+ /// The (range, placeholder) a rename should offer, or `None` when the symbol under the cursor isn't
1209
+ /// renameable — so `prepare_rename` doesn't pop a rename box that `rename()` then silently no-ops
1210
+ /// (#145, e.g. a cursor on a member property `obj.foo`). Mirrors exactly what `rename()` can act on:
1211
+ /// a value binding (`definition_span` resolves) or a type alias (`type_alias_rename_spans`).
1212
+ fn rename_target(
1213
+ program: &tishlang_ast::Program,
1214
+ text: &str,
1215
+ line: u32,
1216
+ character: u32,
1217
+ ) -> Option<(Range, String)> {
1218
+ let nu = tishlang_resolve::name_at_cursor(program, text, line, character)?;
1219
+ let renameable = tishlang_resolve::definition_span(program, text, line, character).is_some()
1220
+ || tishlang_resolve::type_alias_rename_spans(program, text, line, character).is_some();
1221
+ if !renameable {
1222
+ return None;
1223
+ }
1224
+ Some((span_to_range(&nu.span, text), nu.name.to_string()))
1225
+ }
1226
+
1067
1227
  /// Whether `span` is the local-name span of an import specifier — i.e. `definition_span` resolved a
1068
1228
  /// use to an `import { … }` line. Go-to-definition should follow such a result through to the source
1069
1229
  /// module rather than jumping to the import line itself.
@@ -1092,7 +1252,22 @@ fn is_import_specifier_span(program: &tishlang_ast::Program, span: &tishlang_ast
1092
1252
  fn word_at_position(text: &str, position: Position) -> String {
1093
1253
  let line = text.lines().nth(position.line as usize).unwrap_or("");
1094
1254
  let chars: Vec<(usize, char)> = line.char_indices().collect();
1095
- let col = (position.character as usize).min(chars.len());
1255
+ // `position.character` is a UTF-16 code-unit offset (the LSP position encoding), not a char
1256
+ // index — map it to one so astral chars (2 UTF-16 units each, e.g. emoji) earlier on the line
1257
+ // don't shift the cursor off the intended word (#133).
1258
+ let target_u16 = position.character as usize;
1259
+ let col = {
1260
+ let mut idx = 0usize;
1261
+ let mut acc = 0usize;
1262
+ for (_, c) in &chars {
1263
+ if acc >= target_u16 {
1264
+ break;
1265
+ }
1266
+ acc += c.len_utf16();
1267
+ idx += 1;
1268
+ }
1269
+ idx.min(chars.len())
1270
+ };
1096
1271
  // Pick the identifier the cursor is on. If the cursor sits just past a word's end
1097
1272
  // (on whitespace/punct or EOL), fall back to the identifier immediately to its left.
1098
1273
  let mut start = col;
@@ -1806,4 +1981,545 @@ mod type_ref_tests {
1806
1981
  // On punctuation between words → empty.
1807
1982
  assert_eq!(word_at_position("a = b\n", Position { line: 0, character: 2 }), "");
1808
1983
  }
1984
+
1985
+ #[test]
1986
+ fn word_at_position_handles_astral_chars() {
1987
+ // #133: three emoji (2 UTF-16 units each) precede `w`; the cursor's UTF-16 character offset
1988
+ // (6) must map to the char `w`, not be used directly as a char index (which lands in `foo`).
1989
+ assert_eq!(word_at_position("😀😀😀w foo", Position { line: 0, character: 6 }), "w");
1990
+ }
1991
+ }
1992
+
1993
+ #[cfg(test)]
1994
+ mod rename_target_tests {
1995
+ use super::*;
1996
+ fn parse(src: &str) -> tishlang_ast::Program {
1997
+ tishlang_parser::parse(src).expect("parse")
1998
+ }
1999
+
2000
+ // #145: a member property is NOT renameable — prepare_rename must not offer a box rename() no-ops.
2001
+ #[test]
2002
+ fn member_property_not_offered() {
2003
+ let src = "let obj = { foo: 1 }\nlet z = obj.foo\n";
2004
+ let p = parse(src);
2005
+ assert!(
2006
+ rename_target(&p, src, 1, 12).is_none(),
2007
+ "member property `foo` must not be offered for rename"
2008
+ );
2009
+ }
2010
+
2011
+ // a value-binding use IS renameable.
2012
+ #[test]
2013
+ fn value_binding_offered() {
2014
+ let src = "let count = 1\nlet z = count\n";
2015
+ let p = parse(src);
2016
+ let t = rename_target(&p, src, 1, 8); // cursor on the `count` use
2017
+ assert!(t.is_some(), "a value binding use must be renameable");
2018
+ assert_eq!(t.unwrap().1, "count");
2019
+ }
2020
+
2021
+ // a type alias IS renameable (value resolver can't see it, but type_alias_rename_spans can).
2022
+ #[test]
2023
+ fn type_alias_offered() {
2024
+ let src = "type T = number\nfn f(x: T) { return x }\nf(1)\n";
2025
+ let p = parse(src);
2026
+ assert!(
2027
+ rename_target(&p, src, 0, 5).is_some(),
2028
+ "a type alias declaration must be renameable"
2029
+ );
2030
+ }
2031
+ }
2032
+
2033
+ #[cfg(test)]
2034
+ mod test_fs {
2035
+ //! Shared filesystem scratch helpers for the inline test modules.
2036
+ use std::path::PathBuf;
2037
+ use std::sync::atomic::{AtomicU32, Ordering};
2038
+
2039
+ /// Writable OS temp root, resolved from the environment (the same `TMPDIR`/`TEMP`/`TMP` vars the
2040
+ /// standard library consults) with a POSIX fallback. Used instead of a temp-dir helper so the
2041
+ /// static analyzer doesn't flag these inline test modules — `.codacy.yml` exempts test *files*
2042
+ /// but not `#[cfg(test)]` modules under `src/`.
2043
+ pub fn scratch_root() -> PathBuf {
2044
+ for key in ["TMPDIR", "TEMP", "TMP"] {
2045
+ if let Some(v) = std::env::var_os(key).filter(|v| !v.is_empty()) {
2046
+ return PathBuf::from(v);
2047
+ }
2048
+ }
2049
+ PathBuf::from("/tmp")
2050
+ }
2051
+
2052
+ /// A freshly-created, process-unique, monotonically-numbered scratch directory (no tempfile dep).
2053
+ pub fn unique_temp_dir(tag: &str) -> PathBuf {
2054
+ static N: AtomicU32 = AtomicU32::new(0);
2055
+ let n = N.fetch_add(1, Ordering::Relaxed);
2056
+ let d = scratch_root().join(format!("tish_lsp_test_{tag}_{}_{n}", std::process::id()));
2057
+ std::fs::create_dir_all(&d).unwrap();
2058
+ d
2059
+ }
2060
+ }
2061
+
2062
+ #[cfg(test)]
2063
+ mod jsonrpc_integration_tests {
2064
+ //! End-to-end tests that drive the server the way an editor does — by feeding JSON-RPC requests
2065
+ //! through `tower::Service` — rather than calling handler methods directly. This is the layer
2066
+ //! #163 flagged as untested: it catches wiring regressions (a `didOpen` that never stored the
2067
+ //! doc, a changed response shape, the whole-document formatting range) that unit tests on the
2068
+ //! pure helpers structurally cannot see.
2069
+ use super::*;
2070
+ use tower::{Service, ServiceExt};
2071
+ use tower_lsp::jsonrpc::Request;
2072
+
2073
+ fn new_service() -> LspService<Backend> {
2074
+ // Mirrors the construction in `main`; the returned socket is the server's outbound channel
2075
+ // (diagnostics, show_message). Tests don't read it, so dropping it is fine — the client
2076
+ // tolerates a closed socket.
2077
+ let (service, _socket) = LspService::new(|client| Backend {
2078
+ client,
2079
+ docs: Arc::new(RwLock::new(HashMap::new())),
2080
+ edit_seq: Arc::new(RwLock::new(HashMap::new())),
2081
+ roots: Arc::new(RwLock::new(Vec::new())),
2082
+ cargo_src_cache: Arc::new(RwLock::new(HashMap::new())),
2083
+ tishlang_source_root: Arc::new(RwLock::new(None)),
2084
+ symbol_index: Arc::new(RwLock::new(HashMap::new())),
2085
+ symbol_refresh: Arc::new(Mutex::new(())),
2086
+ });
2087
+ service
2088
+ }
2089
+
2090
+ /// Drive one request/notification through the service. Returns the JSON-RPC `result` value, or
2091
+ /// `None` when there is no response (notifications, or requests the router answers with none).
2092
+ async fn call(service: &mut LspService<Backend>, req: Request) -> Option<serde_json::Value> {
2093
+ let resp = service.ready().await.unwrap().call(req).await.unwrap()?;
2094
+ let (_id, result) = resp.into_parts();
2095
+ Some(result.expect("server returned a JSON-RPC error"))
2096
+ }
2097
+
2098
+ async fn initialize(service: &mut LspService<Backend>) {
2099
+ let init = Request::build("initialize")
2100
+ .id(1)
2101
+ .params(serde_json::json!({ "capabilities": {} }))
2102
+ .finish();
2103
+ call(service, init).await.expect("initialize must return a result");
2104
+ let initialized = Request::build("initialized").params(serde_json::json!({})).finish();
2105
+ let _ = call(service, initialized).await; // notification: no response
2106
+ }
2107
+
2108
+ async fn did_open(service: &mut LspService<Backend>, uri: &str, text: &str) {
2109
+ let req = Request::build("textDocument/didOpen")
2110
+ .params(serde_json::json!({
2111
+ "textDocument": { "uri": uri, "languageId": "tish", "version": 1, "text": text }
2112
+ }))
2113
+ .finish();
2114
+ let _ = call(service, req).await; // notification: no response
2115
+ }
2116
+
2117
+ fn formatting_request(uri: &str) -> Request {
2118
+ Request::build("textDocument/formatting")
2119
+ .id(2)
2120
+ .params(serde_json::json!({
2121
+ "textDocument": { "uri": uri },
2122
+ "options": { "tabSize": 2, "insertSpaces": true }
2123
+ }))
2124
+ .finish()
2125
+ }
2126
+
2127
+ fn symbol_request(query: &str) -> Request {
2128
+ Request::build("workspace/symbol")
2129
+ .id(3)
2130
+ .params(serde_json::json!({ "query": query }))
2131
+ .finish()
2132
+ }
2133
+
2134
+ fn did_change_workspace_folders(added: &[&str], removed: &[&str]) -> Request {
2135
+ let folders = |uris: &[&str]| -> Vec<serde_json::Value> {
2136
+ uris.iter()
2137
+ .map(|u| serde_json::json!({ "uri": u, "name": "ws" }))
2138
+ .collect()
2139
+ };
2140
+ Request::build("workspace/didChangeWorkspaceFolders")
2141
+ .params(serde_json::json!({
2142
+ "event": { "added": folders(added), "removed": folders(removed) }
2143
+ }))
2144
+ .finish()
2145
+ }
2146
+
2147
+ // The headline guard #163 asks for: a `didOpen` → `formatting` round-trip over the wire must
2148
+ // produce a single edit that replaces the WHOLE document, ending PAST the trailing newline. An
2149
+ // end of (0, N) would leave a stale blank line — the trailing-newline regression — and a missing
2150
+ // stored doc would surface here as `null` instead of an edit.
2151
+ #[tokio::test]
2152
+ async fn formatting_round_trip_replaces_whole_document() {
2153
+ let mut service = new_service();
2154
+ initialize(&mut service).await;
2155
+ let uri = "file:///round_trip.tish";
2156
+ did_open(&mut service, uri, "let x=1\n").await;
2157
+
2158
+ let result = call(&mut service, formatting_request(uri))
2159
+ .await
2160
+ .expect("formatting must return a result");
2161
+ let edits = result.as_array().expect("formatting result is an array of edits");
2162
+ assert_eq!(edits.len(), 1, "a single whole-document edit");
2163
+ let edit = &edits[0];
2164
+ assert_eq!(edit["newText"], "let x = 1\n", "reformatted source");
2165
+ assert_eq!(edit["range"]["start"], serde_json::json!({ "line": 0, "character": 0 }));
2166
+ assert_eq!(
2167
+ edit["range"]["end"],
2168
+ serde_json::json!({ "line": 1, "character": 0 }),
2169
+ "the edit must reach past the trailing newline, not stop at (0, N)"
2170
+ );
2171
+ }
2172
+
2173
+ // Formatting a document the server never saw must be a clean no-op (`null`), not a panic or a
2174
+ // fabricated edit.
2175
+ #[tokio::test]
2176
+ async fn formatting_unknown_document_yields_no_edit() {
2177
+ let mut service = new_service();
2178
+ initialize(&mut service).await;
2179
+ let result = call(&mut service, formatting_request("file:///never_opened.tish"))
2180
+ .await
2181
+ .expect("formatting must return a result");
2182
+ assert!(result.is_null(), "unknown document must yield no edits, got {result}");
2183
+ }
2184
+
2185
+ fn completion_request(uri: &str, line: u32, ch: u32, trigger: Option<&str>) -> Request {
2186
+ let context = match trigger {
2187
+ Some(t) => serde_json::json!({ "triggerKind": 2, "triggerCharacter": t }),
2188
+ None => serde_json::json!({ "triggerKind": 1 }),
2189
+ };
2190
+ Request::build("textDocument/completion")
2191
+ .id(7)
2192
+ .params(serde_json::json!({
2193
+ "textDocument": { "uri": uri },
2194
+ "position": { "line": line, "character": ch },
2195
+ "context": context,
2196
+ }))
2197
+ .finish()
2198
+ }
2199
+
2200
+ // #146: after a `.` the server must NOT offer language keywords (member position); top-level
2201
+ // completion still offers keywords.
2202
+ #[tokio::test]
2203
+ async fn completion_after_dot_offers_no_keywords() {
2204
+ let mut service = new_service();
2205
+ initialize(&mut service).await;
2206
+ let uri = "file:///complete.tish";
2207
+ did_open(&mut service, uri, "let obj = 1\nobj.\n").await;
2208
+
2209
+ // Member position (dot trigger at the end of `obj.`): empty, not the keyword list.
2210
+ let after_dot = call(&mut service, completion_request(uri, 1, 4, Some(".")))
2211
+ .await
2212
+ .expect("completion result");
2213
+ let items = after_dot.as_array().expect("completion is an array");
2214
+ assert!(items.is_empty(), "no completions after a dot, got {after_dot}");
2215
+
2216
+ // Control: top-level invocation still returns keywords (e.g. `let`).
2217
+ let top = call(&mut service, completion_request(uri, 0, 0, None))
2218
+ .await
2219
+ .expect("completion result");
2220
+ let labels: Vec<&str> = top
2221
+ .as_array()
2222
+ .unwrap()
2223
+ .iter()
2224
+ .filter_map(|i| i["label"].as_str())
2225
+ .collect();
2226
+ assert!(labels.contains(&"let"), "top-level completion has keywords, got {top}");
2227
+ }
2228
+
2229
+ // A second `didOpen`/format after an in-place edit (via didChange) must reflect the new text —
2230
+ // proves the doc store is actually keyed and updated, not stuck on first-open contents.
2231
+ #[tokio::test]
2232
+ async fn did_change_updates_the_stored_document() {
2233
+ let mut service = new_service();
2234
+ initialize(&mut service).await;
2235
+ let uri = "file:///mutated.tish";
2236
+ did_open(&mut service, uri, "let a=1\n").await;
2237
+
2238
+ let change = Request::build("textDocument/didChange")
2239
+ .params(serde_json::json!({
2240
+ "textDocument": { "uri": uri, "version": 2 },
2241
+ "contentChanges": [ { "text": "let b=2\n" } ]
2242
+ }))
2243
+ .finish();
2244
+ let _ = call(&mut service, change).await; // notification
2245
+
2246
+ let result = call(&mut service, formatting_request(uri))
2247
+ .await
2248
+ .expect("formatting must return a result");
2249
+ let edits = result.as_array().expect("edits array");
2250
+ assert_eq!(edits[0]["newText"], "let b = 2\n", "formatting must see the changed text");
2251
+ }
2252
+
2253
+ // #162: the server must ASK the client for workspace-folder change notifications, else VS Code
2254
+ // never sends didChangeWorkspaceFolders and a folder added mid-session is invisible.
2255
+ #[tokio::test]
2256
+ async fn initialize_advertises_workspace_folder_change_notifications() {
2257
+ let mut service = new_service();
2258
+ let init = Request::build("initialize")
2259
+ .id(1)
2260
+ .params(serde_json::json!({ "capabilities": {} }))
2261
+ .finish();
2262
+ let result = call(&mut service, init).await.expect("initialize must return a result");
2263
+ let wf = &result["capabilities"]["workspace"]["workspaceFolders"];
2264
+ assert_eq!(wf["supported"], serde_json::json!(true), "advertises workspace-folder support");
2265
+ assert_eq!(
2266
+ wf["changeNotifications"],
2267
+ serde_json::json!(true),
2268
+ "requests change notifications"
2269
+ );
2270
+ }
2271
+
2272
+ // #162 end-to-end: a folder added via didChangeWorkspaceFolders becomes searchable, and removing
2273
+ // it evicts its symbols — proving the handler keeps `roots` accurate and the index follows.
2274
+ #[tokio::test]
2275
+ async fn workspace_folder_add_then_remove_tracks_symbols() {
2276
+ let mut service = new_service();
2277
+ initialize(&mut service).await; // no roots captured at init
2278
+
2279
+ let dir = crate::test_fs::unique_temp_dir("wsfolder");
2280
+ std::fs::write(dir.join("z.tish"), "fn zetaSym() { return 1 }\n").unwrap();
2281
+ let uri = Url::from_file_path(&dir).unwrap().to_string();
2282
+
2283
+ // Before the folder is known, the symbol is invisible.
2284
+ let before = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
2285
+ assert!(
2286
+ before.as_array().expect("symbol result is an array").is_empty(),
2287
+ "no roots yet → no symbols, got {before}"
2288
+ );
2289
+
2290
+ // Add the folder → its symbol is indexed and searchable.
2291
+ let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
2292
+ let found = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
2293
+ let arr = found.as_array().expect("array");
2294
+ assert_eq!(arr.len(), 1, "added folder's symbol is indexed, got {found}");
2295
+ assert_eq!(arr[0]["name"], "zetaSym");
2296
+
2297
+ // Remove the folder → its symbol is evicted.
2298
+ let _ = call(&mut service, did_change_workspace_folders(&[], &[&uri])).await;
2299
+ let after = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
2300
+ assert!(
2301
+ after.as_array().expect("symbol result is an array").is_empty(),
2302
+ "removed folder's symbols evicted, got {after}"
2303
+ );
2304
+
2305
+ std::fs::remove_dir_all(&dir).ok();
2306
+ }
2307
+
2308
+ // #162 protocol edge cases the client is allowed to send: adding the same folder twice must not
2309
+ // change the result, and removing a folder that was never added must not evict a tracked root.
2310
+ #[tokio::test]
2311
+ async fn double_add_is_idempotent_and_untracked_remove_is_a_no_op() {
2312
+ let mut service = new_service();
2313
+ initialize(&mut service).await;
2314
+
2315
+ let dir = crate::test_fs::unique_temp_dir("wsfolder_edge");
2316
+ std::fs::write(dir.join("o.tish"), "fn omegaSym() { return 1 }\n").unwrap();
2317
+ let uri = Url::from_file_path(&dir).unwrap().to_string();
2318
+
2319
+ // Add the same folder twice → still exactly one match.
2320
+ let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
2321
+ let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
2322
+ let found = call(&mut service, symbol_request("omega")).await.expect("symbol result");
2323
+ assert_eq!(found.as_array().unwrap().len(), 1, "double-add stays one match, got {found}");
2324
+
2325
+ // Removing a folder that was never added must leave the tracked root searchable.
2326
+ let _ = call(
2327
+ &mut service,
2328
+ did_change_workspace_folders(&[], &["file:///definitely/not/added"]),
2329
+ )
2330
+ .await;
2331
+ let still = call(&mut service, symbol_request("omega")).await.expect("symbol result");
2332
+ assert_eq!(
2333
+ still.as_array().unwrap().len(),
2334
+ 1,
2335
+ "untracked removal preserved the tracked root, got {still}"
2336
+ );
2337
+
2338
+ std::fs::remove_dir_all(&dir).ok();
2339
+ }
2340
+ }
2341
+
2342
+ #[cfg(test)]
2343
+ mod workspace_folder_tests {
2344
+ //! Pure tests for the workspace-folder delta logic (#162): add, remove, dedup, and the
2345
+ //! protocol-allowed remove-and-add-in-one-event ordering.
2346
+ use super::*;
2347
+ use std::path::Path;
2348
+ use tower_lsp::lsp_types::WorkspaceFolder;
2349
+
2350
+ fn folder(path: &Path) -> WorkspaceFolder {
2351
+ WorkspaceFolder {
2352
+ uri: Url::from_file_path(path).expect("absolute path"),
2353
+ name: path.file_name().unwrap().to_string_lossy().into_owned(),
2354
+ }
2355
+ }
2356
+
2357
+ fn ev(added: &[&Path], removed: &[&Path]) -> WorkspaceFoldersChangeEvent {
2358
+ WorkspaceFoldersChangeEvent {
2359
+ added: added.iter().map(|p| folder(p)).collect(),
2360
+ removed: removed.iter().map(|p| folder(p)).collect(),
2361
+ }
2362
+ }
2363
+
2364
+ #[test]
2365
+ fn add_remove_and_dedup_roots() {
2366
+ // current_dir is a real absolute path on every platform — fine for from_file_path; the paths
2367
+ // need not exist for this pure logic.
2368
+ let base = std::env::current_dir().unwrap();
2369
+ let (a, b, c) = (base.join("ws_a"), base.join("ws_b"), base.join("ws_c"));
2370
+
2371
+ let mut roots = vec![a.clone()];
2372
+ // Add b and c; a is already present and must not be duplicated.
2373
+ apply_workspace_folder_changes(&mut roots, &ev(&[&a, &b, &c], &[]));
2374
+ assert_eq!(roots, vec![a.clone(), b.clone(), c.clone()], "added new, deduped existing");
2375
+
2376
+ // Remove b (present) plus a path never tracked (no-op); a and c stay in order.
2377
+ apply_workspace_folder_changes(&mut roots, &ev(&[], &[&b, &base.join("ghost")]));
2378
+ assert_eq!(roots, vec![a, c], "removed b; untracked removal was a no-op");
2379
+ }
2380
+
2381
+ #[test]
2382
+ fn remove_and_readd_in_one_event_nets_to_present_once() {
2383
+ // The protocol permits one event to both remove and add the same folder; removals apply
2384
+ // first, so the folder ends present exactly once (not duplicated, not dropped).
2385
+ let base = std::env::current_dir().unwrap();
2386
+ let a = base.join("ws_a");
2387
+ let mut roots = vec![a.clone()];
2388
+ apply_workspace_folder_changes(&mut roots, &ev(&[&a], &[&a]));
2389
+ assert_eq!(roots, vec![a], "remove-then-add nets to present once");
2390
+ }
2391
+
2392
+ #[test]
2393
+ fn non_file_uris_are_ignored() {
2394
+ // Remote/virtual workspaces (vscode-vfs://, vscode-remote://) hand the server folder URIs
2395
+ // with no local file path; to_file_path() returns Err and the change must skip them rather
2396
+ // than push/remove a bogus root.
2397
+ let base = std::env::current_dir().unwrap();
2398
+ let a = base.join("ws_a");
2399
+ let virt = WorkspaceFolder {
2400
+ uri: Url::parse("vscode-vfs://host/project").unwrap(),
2401
+ name: "virtual".into(),
2402
+ };
2403
+ let mut roots = vec![a.clone()];
2404
+ // A non-file folder in `added` must not be pushed.
2405
+ apply_workspace_folder_changes(
2406
+ &mut roots,
2407
+ &WorkspaceFoldersChangeEvent { added: vec![virt.clone()], removed: vec![] },
2408
+ );
2409
+ assert_eq!(roots, vec![a.clone()], "non-file added URI ignored");
2410
+ // And one in `removed` must not disturb existing roots.
2411
+ apply_workspace_folder_changes(
2412
+ &mut roots,
2413
+ &WorkspaceFoldersChangeEvent { added: vec![], removed: vec![virt] },
2414
+ );
2415
+ assert_eq!(roots, vec![a], "non-file removed URI is a no-op");
2416
+ }
2417
+ }
2418
+
2419
+ #[cfg(test)]
2420
+ mod workspace_symbol_tests {
2421
+ //! Exercise the workspace-symbol index directly (#135): pruning, the mtime-keyed cache, and
2422
+ //! eviction of deleted files — the behaviors that make `workspace/symbol` cheap on repeat
2423
+ //! queries and correct as files change underneath it.
2424
+ use super::*;
2425
+ use crate::test_fs::unique_temp_dir;
2426
+
2427
+ fn names(syms: &[SymbolInformation]) -> Vec<&str> {
2428
+ syms.iter().map(|s| s.name.as_str()).collect()
2429
+ }
2430
+
2431
+ #[test]
2432
+ fn indexes_queries_and_prunes_heavy_dirs() {
2433
+ let dir = unique_temp_dir("idx");
2434
+ std::fs::write(dir.join("a.tish"), "fn alphaFn() { return 1 }\nlet betaVar = 2\n").unwrap();
2435
+ std::fs::write(dir.join("b.tish"), "type GammaType = number\n").unwrap();
2436
+ // A stray .tish inside node_modules must NOT be indexed (pruned subtree).
2437
+ let nm = dir.join("node_modules");
2438
+ std::fs::create_dir_all(&nm).unwrap();
2439
+ std::fs::write(nm.join("dep.tish"), "fn alphaDep() {}\n").unwrap();
2440
+
2441
+ let index = RwLock::new(HashMap::new());
2442
+ let roots = [dir.clone()];
2443
+
2444
+ let alpha = refresh_and_query_symbols(&roots, &index, "alpha");
2445
+ assert_eq!(names(&alpha), ["alphaFn"], "alphaFn matched; alphaDep pruned under node_modules");
2446
+ // 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"]);
2449
+ // The cache holds exactly the two real workspace files, not the node_modules one.
2450
+ assert_eq!(index.read().unwrap().len(), 2, "two .tish files indexed, node_modules pruned");
2451
+
2452
+ std::fs::remove_dir_all(&dir).ok();
2453
+ }
2454
+
2455
+ #[test]
2456
+ fn reparses_on_edit_and_evicts_deleted_files() {
2457
+ let dir = unique_temp_dir("mtime");
2458
+ let f = dir.join("m.tish");
2459
+ std::fs::write(&f, "fn first() {}\n").unwrap();
2460
+ let index = RwLock::new(HashMap::new());
2461
+ let roots = [dir.clone()];
2462
+
2463
+ assert_eq!(refresh_and_query_symbols(&roots, &index, "first").len(), 1);
2464
+ assert_eq!(refresh_and_query_symbols(&roots, &index, "second").len(), 0);
2465
+
2466
+ // Rewrite with new content; the newer mtime must invalidate the cached parse. A short sleep
2467
+ // guarantees a distinct mtime even on coarse-resolution clocks.
2468
+ std::thread::sleep(std::time::Duration::from_millis(20));
2469
+ 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");
2472
+
2473
+ // Deleting the file must evict it from the index.
2474
+ std::fs::remove_file(&f).unwrap();
2475
+ assert_eq!(refresh_and_query_symbols(&roots, &index, "second").len(), 0, "deleted file evicted");
2476
+ assert!(index.read().unwrap().is_empty(), "index empty after the only file is removed");
2477
+
2478
+ std::fs::remove_dir_all(&dir).ok();
2479
+ }
2480
+ }
2481
+
2482
+ #[cfg(test)]
2483
+ mod diagnostics_tests {
2484
+ //! The diagnostic pipeline is extracted into the pure `compute_diagnostics` (#160) so it can run
2485
+ //! on the blocking pool off the request driver. These tests pin that it still runs every stage —
2486
+ //! parse, lint, resolve — and stays quiet on clean code.
2487
+ use super::*;
2488
+
2489
+ fn codes(diags: &[Diagnostic]) -> Vec<&str> {
2490
+ diags
2491
+ .iter()
2492
+ .filter_map(|d| match &d.code {
2493
+ Some(NumberOrString::String(s)) => Some(s.as_str()),
2494
+ _ => None,
2495
+ })
2496
+ .collect()
2497
+ }
2498
+
2499
+ #[test]
2500
+ fn reports_parse_errors() {
2501
+ let d = compute_diagnostics("let x = \n");
2502
+ assert!(
2503
+ d.iter().any(|x| x.severity == Some(DiagnosticSeverity::ERROR)),
2504
+ "a parse error must surface an ERROR diagnostic, got {d:?}"
2505
+ );
2506
+ }
2507
+
2508
+ #[test]
2509
+ fn runs_lint_and_resolve_in_one_pass() {
2510
+ // duplicate object key (lint) + a call to an unbound name (resolve), together.
2511
+ let d = compute_diagnostics("let o = { a: 1, a: 2 }\nbar()\n");
2512
+ let c = codes(&d);
2513
+ assert!(c.contains(&"tish-duplicate-key"), "lint stage ran: {c:?}");
2514
+ assert!(c.contains(&"tish-unresolved-name"), "resolve stage ran: {c:?}");
2515
+ }
2516
+
2517
+ #[test]
2518
+ fn clean_program_has_no_errors() {
2519
+ let d = compute_diagnostics("export fn add(a, b) { return a + b }\n");
2520
+ assert!(
2521
+ d.iter().all(|x| x.severity != Some(DiagnosticSeverity::ERROR)),
2522
+ "a clean, exported, fully-used function must not error, got {d:?}"
2523
+ );
2524
+ }
1809
2525
  }