@tishlang/tish 2.2.5 → 2.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.
- package/bin/tish +0 -0
- package/crates/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +1 -1
- package/crates/tish/src/cli_help.rs +12 -0
- package/crates/tish/src/main.rs +69 -11
- package/crates/tish_ast/src/ast.rs +12 -5
- package/crates/tish_build_utils/src/lib.rs +37 -0
- package/crates/tish_builtins/src/array.rs +82 -1
- package/crates/tish_builtins/src/globals.rs +50 -16
- package/crates/tish_builtins/src/math.rs +63 -9
- package/crates/tish_builtins/src/number.rs +20 -1
- package/crates/tish_builtins/src/string.rs +68 -4
- package/crates/tish_builtins/src/typedarrays.rs +23 -16
- package/crates/tish_bytecode/src/compiler.rs +94 -28
- package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
- package/crates/tish_compile/src/check.rs +11 -10
- package/crates/tish_compile/src/codegen.rs +1386 -42
- package/crates/tish_compile/src/infer.rs +16 -16
- package/crates/tish_compile/src/lib.rs +38 -0
- package/crates/tish_compile/src/resolve.rs +3 -2
- package/crates/tish_compile/src/types.rs +26 -9
- package/crates/tish_compile_js/src/codegen.rs +55 -4
- package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
- package/crates/tish_core/Cargo.toml +2 -0
- package/crates/tish_core/src/json.rs +135 -34
- package/crates/tish_core/src/lib.rs +23 -1
- package/crates/tish_core/src/value.rs +64 -11
- package/crates/tish_eval/src/eval.rs +144 -197
- package/crates/tish_eval/src/natives.rs +45 -45
- package/crates/tish_eval/src/regex.rs +35 -27
- package/crates/tish_eval/src/value.rs +8 -0
- package/crates/tish_fmt/src/lib.rs +46 -2
- package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
- package/crates/tish_lint/src/lib.rs +197 -21
- package/crates/tish_lsp/Cargo.toml +7 -1
- package/crates/tish_lsp/src/import_goto.rs +52 -7
- package/crates/tish_lsp/src/main.rs +913 -145
- package/crates/tish_opt/src/lib.rs +5 -3
- package/crates/tish_parser/src/lib.rs +23 -5
- package/crates/tish_parser/src/parser.rs +35 -35
- package/crates/tish_resolve/src/lib.rs +567 -17
- package/crates/tish_runtime/src/lib.rs +58 -18
- package/crates/tish_ui/src/jsx.rs +2 -2
- package/crates/tish_vm/src/jit.rs +212 -10
- package/crates/tish_vm/src/vm.rs +39 -18
- package/crates/tish_wasm/src/lib.rs +116 -22
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- 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,
|
|
13
|
-
DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
|
|
14
|
-
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
|
|
15
|
-
HoverProviderCapability, InitializeParams, InitializeResult, Location,
|
|
16
|
-
MarkupKind, MessageType, NumberOrString, OneOf, Position, Range,
|
|
17
|
-
RenameOptions, RenameParams, ServerCapabilities, ServerInfo,
|
|
18
|
-
|
|
19
|
-
WorkDoneProgressOptions, WorkspaceEdit,
|
|
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};
|
|
@@ -29,11 +32,45 @@ mod import_goto;
|
|
|
29
32
|
struct Backend {
|
|
30
33
|
client: Client,
|
|
31
34
|
docs: Arc<RwLock<HashMap<Url, String>>>,
|
|
35
|
+
/// Monotonic per-document edit counter. did_change bumps it and a debounced task only
|
|
36
|
+
/// publishes diagnostics if its edit is still the latest — so rapid keystrokes coalesce and
|
|
37
|
+
/// superseded recomputes are dropped (the analysis pipeline is comparatively expensive).
|
|
38
|
+
edit_seq: Arc<RwLock<HashMap<Url, u64>>>,
|
|
32
39
|
roots: Arc<RwLock<Vec<PathBuf>>>,
|
|
33
40
|
/// `(project_root, cargo:spec)` → resolved dependency source root (for `cargo metadata` / registry).
|
|
34
41
|
cargo_src_cache: Arc<RwLock<HashMap<(PathBuf, String), PathBuf>>>,
|
|
35
42
|
/// Root of the `tishlang/tish` checkout (parent of `crates/`), for built-in / JSX goto-definition.
|
|
36
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>,
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
#[tokio::main]
|
|
@@ -44,9 +81,12 @@ async fn main() {
|
|
|
44
81
|
let (service, socket) = LspService::new(|client| Backend {
|
|
45
82
|
client,
|
|
46
83
|
docs: Arc::new(RwLock::new(HashMap::new())),
|
|
84
|
+
edit_seq: Arc::new(RwLock::new(HashMap::new())),
|
|
47
85
|
roots: Arc::new(RwLock::new(Vec::new())),
|
|
48
86
|
cargo_src_cache: Arc::new(RwLock::new(HashMap::new())),
|
|
49
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(())),
|
|
50
90
|
});
|
|
51
91
|
Server::new(stdin, stdout, socket).serve(service).await;
|
|
52
92
|
}
|
|
@@ -140,7 +180,23 @@ fn document_symbol(
|
|
|
140
180
|
}
|
|
141
181
|
}
|
|
142
182
|
|
|
143
|
-
async fn publish_parse_and_lint(client: &Client, uri: Url, text:
|
|
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> {
|
|
144
200
|
let mut diags = Vec::new();
|
|
145
201
|
match tishlang_parser::parse(text) {
|
|
146
202
|
Ok(program) => {
|
|
@@ -154,6 +210,7 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
|
|
|
154
210
|
severity: Some(sev),
|
|
155
211
|
code: Some(NumberOrString::String(d.code.to_string())),
|
|
156
212
|
message: d.message,
|
|
213
|
+
source: Some("tish".into()),
|
|
157
214
|
..Default::default()
|
|
158
215
|
});
|
|
159
216
|
}
|
|
@@ -163,6 +220,7 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
|
|
|
163
220
|
severity: Some(DiagnosticSeverity::ERROR),
|
|
164
221
|
code: Some(NumberOrString::String("tish-unresolved-name".into())),
|
|
165
222
|
message: format!("no binding in scope for `{}`", u.name),
|
|
223
|
+
source: Some("tish".into()),
|
|
166
224
|
..Default::default()
|
|
167
225
|
});
|
|
168
226
|
}
|
|
@@ -209,13 +267,30 @@ async fn publish_parse_and_lint(client: &Client, uri: Url, text: &str) {
|
|
|
209
267
|
range: diag_range(l, c, text),
|
|
210
268
|
severity: Some(DiagnosticSeverity::ERROR),
|
|
211
269
|
message: e,
|
|
270
|
+
source: Some("tish".into()),
|
|
212
271
|
..Default::default()
|
|
213
272
|
});
|
|
214
273
|
}
|
|
215
274
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
|
219
294
|
}
|
|
220
295
|
|
|
221
296
|
#[tower_lsp::async_trait]
|
|
@@ -277,6 +352,16 @@ impl LanguageServer for Backend {
|
|
|
277
352
|
document_formatting_provider: Some(OneOf::Left(true)),
|
|
278
353
|
document_symbol_provider: Some(OneOf::Left(true)),
|
|
279
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
|
+
}),
|
|
280
365
|
..Default::default()
|
|
281
366
|
},
|
|
282
367
|
server_info: Some(ServerInfo {
|
|
@@ -292,6 +377,19 @@ impl LanguageServer for Backend {
|
|
|
292
377
|
.await;
|
|
293
378
|
}
|
|
294
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, ¶ms.event);
|
|
387
|
+
}
|
|
388
|
+
self.client
|
|
389
|
+
.log_message(MessageType::INFO, "tish-lsp: workspace folders updated")
|
|
390
|
+
.await;
|
|
391
|
+
}
|
|
392
|
+
|
|
295
393
|
async fn shutdown(&self) -> Result<()> {
|
|
296
394
|
Ok(())
|
|
297
395
|
}
|
|
@@ -300,7 +398,7 @@ impl LanguageServer for Backend {
|
|
|
300
398
|
let uri = p.text_document.uri;
|
|
301
399
|
let text = p.text_document.text;
|
|
302
400
|
self.docs.write().unwrap().insert(uri.clone(), text.clone());
|
|
303
|
-
publish_parse_and_lint(&self.client, uri,
|
|
401
|
+
publish_parse_and_lint(&self.client, uri, text).await;
|
|
304
402
|
}
|
|
305
403
|
|
|
306
404
|
async fn did_change(&self, p: DidChangeTextDocumentParams) {
|
|
@@ -310,12 +408,35 @@ impl LanguageServer for Backend {
|
|
|
310
408
|
.write()
|
|
311
409
|
.unwrap()
|
|
312
410
|
.insert(uri.clone(), chg.text.clone());
|
|
313
|
-
|
|
411
|
+
// Bump this document's edit sequence and debounce the (expensive) analysis: only the
|
|
412
|
+
// task whose sequence is still current after the delay publishes, so a burst of
|
|
413
|
+
// keystrokes coalesces into one recompute instead of one-per-change.
|
|
414
|
+
let seq = {
|
|
415
|
+
let mut g = self.edit_seq.write().unwrap();
|
|
416
|
+
let n = g.entry(uri.clone()).or_insert(0);
|
|
417
|
+
*n += 1;
|
|
418
|
+
*n
|
|
419
|
+
};
|
|
420
|
+
let client = self.client.clone();
|
|
421
|
+
let docs = Arc::clone(&self.docs);
|
|
422
|
+
let edit_seq = Arc::clone(&self.edit_seq);
|
|
423
|
+
tokio::spawn(async move {
|
|
424
|
+
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
425
|
+
// Superseded by a newer edit while we waited — drop this stale recompute.
|
|
426
|
+
if edit_seq.read().unwrap().get(&uri).copied() != Some(seq) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
let text = docs.read().unwrap().get(&uri).cloned();
|
|
430
|
+
if let Some(text) = text {
|
|
431
|
+
publish_parse_and_lint(&client, uri, text).await;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
314
434
|
}
|
|
315
435
|
}
|
|
316
436
|
|
|
317
437
|
async fn did_close(&self, p: DidCloseTextDocumentParams) {
|
|
318
438
|
self.docs.write().unwrap().remove(&p.text_document.uri);
|
|
439
|
+
self.edit_seq.write().unwrap().remove(&p.text_document.uri);
|
|
319
440
|
self.client
|
|
320
441
|
.publish_diagnostics(p.text_document.uri, vec![], None)
|
|
321
442
|
.await;
|
|
@@ -332,6 +453,31 @@ impl LanguageServer for Backend {
|
|
|
332
453
|
return Ok(None);
|
|
333
454
|
};
|
|
334
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
|
+
|
|
335
481
|
let keywords = [
|
|
336
482
|
"fn", "async", "let", "const", "if", "else", "while", "for", "return", "break",
|
|
337
483
|
"continue", "switch", "case", "default", "try", "catch", "finally", "throw", "import",
|
|
@@ -362,14 +508,6 @@ impl LanguageServer for Backend {
|
|
|
362
508
|
}
|
|
363
509
|
}
|
|
364
510
|
|
|
365
|
-
if let Some(ctx) = params.context {
|
|
366
|
-
if matches!(ctx.trigger_kind, CompletionTriggerKind::TRIGGER_CHARACTER)
|
|
367
|
-
&& ctx.trigger_character.as_deref() == Some(".")
|
|
368
|
-
{
|
|
369
|
-
// After dot: could add member completion later
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
511
|
Ok(Some(CompletionResponse::Array(items)))
|
|
374
512
|
}
|
|
375
513
|
|
|
@@ -426,12 +564,14 @@ impl LanguageServer for Backend {
|
|
|
426
564
|
if let Ok(ref file_path) = uri.to_file_path() {
|
|
427
565
|
let word = word_at_position(&text, position);
|
|
428
566
|
let roots = self.roots.read().unwrap().clone();
|
|
567
|
+
let open_docs = self.docs.read().unwrap();
|
|
429
568
|
if let Some(loc) = import_goto::definition_for_import(
|
|
430
569
|
&program,
|
|
431
570
|
file_path,
|
|
432
571
|
word.as_str(),
|
|
433
572
|
&roots,
|
|
434
573
|
self.cargo_src_cache.as_ref(),
|
|
574
|
+
&open_docs,
|
|
435
575
|
) {
|
|
436
576
|
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
|
|
437
577
|
}
|
|
@@ -461,12 +601,14 @@ impl LanguageServer for Backend {
|
|
|
461
601
|
|
|
462
602
|
if let Ok(ref file_path) = uri.to_file_path() {
|
|
463
603
|
let roots = self.roots.read().unwrap().clone();
|
|
604
|
+
let open_docs = self.docs.read().unwrap();
|
|
464
605
|
if let Some(loc) = import_goto::definition_for_import(
|
|
465
606
|
&program,
|
|
466
607
|
file_path,
|
|
467
608
|
word.as_str(),
|
|
468
609
|
&roots,
|
|
469
610
|
self.cargo_src_cache.as_ref(),
|
|
611
|
+
&open_docs,
|
|
470
612
|
) {
|
|
471
613
|
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
|
|
472
614
|
}
|
|
@@ -479,6 +621,7 @@ impl LanguageServer for Backend {
|
|
|
479
621
|
position.line,
|
|
480
622
|
position.character,
|
|
481
623
|
word.as_str(),
|
|
624
|
+
&open_docs,
|
|
482
625
|
) {
|
|
483
626
|
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
|
|
484
627
|
}
|
|
@@ -577,10 +720,23 @@ impl LanguageServer for Backend {
|
|
|
577
720
|
}
|
|
578
721
|
} else {
|
|
579
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
|
+
};
|
|
580
735
|
if word.is_empty() {
|
|
581
736
|
md.push_str("\n\n_No binding in scope for this name._");
|
|
582
737
|
} else if let Ok(fp) = uri.to_file_path() {
|
|
583
738
|
let roots = self.roots.read().unwrap().clone();
|
|
739
|
+
let open_docs = self.docs.read().unwrap();
|
|
584
740
|
if let Some(nmd) = import_goto::native_member_definition(
|
|
585
741
|
&program,
|
|
586
742
|
&fp,
|
|
@@ -590,6 +746,7 @@ impl LanguageServer for Backend {
|
|
|
590
746
|
pos.line,
|
|
591
747
|
pos.character,
|
|
592
748
|
word.as_str(),
|
|
749
|
+
&open_docs,
|
|
593
750
|
) {
|
|
594
751
|
md.push_str(
|
|
595
752
|
"\n\n_Native host module member (e.g. `tish:macos`); implementation in Rust._",
|
|
@@ -605,10 +762,10 @@ impl LanguageServer for Backend {
|
|
|
605
762
|
"\n\n[Open Rust implementation]({href}#L{line_1})"
|
|
606
763
|
));
|
|
607
764
|
} else {
|
|
608
|
-
md.push_str(
|
|
765
|
+
md.push_str(no_binding_msg);
|
|
609
766
|
}
|
|
610
767
|
} else {
|
|
611
|
-
md.push_str(
|
|
768
|
+
md.push_str(no_binding_msg);
|
|
612
769
|
}
|
|
613
770
|
}
|
|
614
771
|
}
|
|
@@ -675,15 +832,15 @@ impl LanguageServer for Backend {
|
|
|
675
832
|
let Ok(program) = tishlang_parser::parse(&text) else {
|
|
676
833
|
return Ok(None);
|
|
677
834
|
};
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
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
|
+
}
|
|
687
844
|
}
|
|
688
845
|
|
|
689
846
|
async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
|
|
@@ -700,6 +857,30 @@ impl LanguageServer for Backend {
|
|
|
700
857
|
let Ok(program) = tishlang_parser::parse(&text) else {
|
|
701
858
|
return Ok(None);
|
|
702
859
|
};
|
|
860
|
+
// Type-alias rename: the value resolver can't see `: T` annotation uses (type names live
|
|
861
|
+
// in a separate namespace), so handle a cursor on a type-alias declaration/use here,
|
|
862
|
+
// editing the declaration and every annotation site together.
|
|
863
|
+
if let Some(spans) =
|
|
864
|
+
tishlang_resolve::type_alias_rename_spans(&program, &text, pos.line, pos.character)
|
|
865
|
+
{
|
|
866
|
+
let mut edits: Vec<TextEdit> = spans
|
|
867
|
+
.into_iter()
|
|
868
|
+
.map(|sp| TextEdit {
|
|
869
|
+
range: span_to_range(&sp, &text),
|
|
870
|
+
new_text: new_name.clone(),
|
|
871
|
+
})
|
|
872
|
+
.collect();
|
|
873
|
+
edits.sort_by(|a, b| {
|
|
874
|
+
(b.range.start.line, b.range.start.character)
|
|
875
|
+
.cmp(&(a.range.start.line, a.range.start.character))
|
|
876
|
+
});
|
|
877
|
+
let mut m = HashMap::new();
|
|
878
|
+
m.insert(uri.clone(), edits);
|
|
879
|
+
return Ok(Some(WorkspaceEdit {
|
|
880
|
+
changes: Some(m),
|
|
881
|
+
..Default::default()
|
|
882
|
+
}));
|
|
883
|
+
}
|
|
703
884
|
let Some(def) = tishlang_resolve::definition_span(&program, &text, pos.line, pos.character)
|
|
704
885
|
else {
|
|
705
886
|
return Ok(None);
|
|
@@ -773,131 +954,143 @@ impl LanguageServer for Backend {
|
|
|
773
954
|
if query.is_empty() {
|
|
774
955
|
return Ok(Some(vec![]));
|
|
775
956
|
}
|
|
776
|
-
let
|
|
777
|
-
let
|
|
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
|
+
}
|
|
778
978
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
let Ok(src) = std::fs::read_to_string(path) else {
|
|
787
|
-
continue;
|
|
788
|
-
};
|
|
789
|
-
let Ok(program) = tishlang_parser::parse(&src) else {
|
|
790
|
-
continue;
|
|
791
|
-
};
|
|
792
|
-
let Ok(uri) = Url::from_file_path(path) else {
|
|
793
|
-
continue;
|
|
794
|
-
};
|
|
795
|
-
for s in &program.statements {
|
|
796
|
-
collect_workspace_syms(s, &src, &uri, &query, &mut out);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
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;
|
|
801
986
|
}
|
|
987
|
+
let name = e.file_name().to_string_lossy();
|
|
988
|
+
name == "node_modules" || name == "target" || name.starts_with('.')
|
|
802
989
|
}
|
|
803
990
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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>>,
|
|
808
997
|
query: &str,
|
|
809
|
-
|
|
810
|
-
)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
uri: uri.clone(),
|
|
822
|
-
range: span_to_range(name_span, text),
|
|
823
|
-
},
|
|
824
|
-
None,
|
|
825
|
-
));
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
tishlang_ast::Statement::VarDecl {
|
|
829
|
-
name, name_span, ..
|
|
830
|
-
} => {
|
|
831
|
-
if name.to_lowercase().contains(query) {
|
|
832
|
-
out.push(symbol_information(
|
|
833
|
-
name.to_string(),
|
|
834
|
-
SymbolKind::VARIABLE,
|
|
835
|
-
None,
|
|
836
|
-
Location {
|
|
837
|
-
uri: uri.clone(),
|
|
838
|
-
range: span_to_range(name_span, text),
|
|
839
|
-
},
|
|
840
|
-
None,
|
|
841
|
-
));
|
|
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;
|
|
842
1010
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
Location {
|
|
853
|
-
uri: uri.clone(),
|
|
854
|
-
range: span_to_range(name_span, text),
|
|
855
|
-
},
|
|
856
|
-
None,
|
|
857
|
-
));
|
|
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;
|
|
858
1020
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
},
|
|
872
|
-
None,
|
|
873
|
-
));
|
|
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);
|
|
874
1033
|
}
|
|
1034
|
+
let mtime = mtime.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
1035
|
+
index.write().unwrap().insert(path, CachedFile { mtime, uri, symbols });
|
|
875
1036
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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) {
|
|
880
1047
|
out.push(symbol_information(
|
|
881
|
-
name.
|
|
882
|
-
|
|
1048
|
+
sym.name.clone(),
|
|
1049
|
+
sym.kind,
|
|
883
1050
|
None,
|
|
884
1051
|
Location {
|
|
885
|
-
uri: uri.clone(),
|
|
886
|
-
range:
|
|
1052
|
+
uri: cf.uri.clone(),
|
|
1053
|
+
range: sym.range,
|
|
887
1054
|
},
|
|
888
1055
|
None,
|
|
889
1056
|
));
|
|
890
1057
|
}
|
|
891
1058
|
}
|
|
892
|
-
|
|
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, .. } => {
|
|
893
1087
|
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
894
|
-
|
|
1088
|
+
collect_file_symbols(inner, text, out);
|
|
895
1089
|
}
|
|
896
1090
|
}
|
|
897
|
-
|
|
898
|
-
| tishlang_ast::Statement::Multi { statements, .. } => {
|
|
1091
|
+
St::Block { statements, .. } | St::Multi { statements, .. } => {
|
|
899
1092
|
for x in statements {
|
|
900
|
-
|
|
1093
|
+
collect_file_symbols(x, text, out);
|
|
901
1094
|
}
|
|
902
1095
|
}
|
|
903
1096
|
_ => {}
|
|
@@ -1012,6 +1205,25 @@ fn span_to_range(span: &tishlang_ast::Span, text: &str) -> Range {
|
|
|
1012
1205
|
}
|
|
1013
1206
|
}
|
|
1014
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
|
+
|
|
1015
1227
|
/// Whether `span` is the local-name span of an import specifier — i.e. `definition_span` resolved a
|
|
1016
1228
|
/// use to an `import { … }` line. Go-to-definition should follow such a result through to the source
|
|
1017
1229
|
/// module rather than jumping to the import line itself.
|
|
@@ -1040,7 +1252,22 @@ fn is_import_specifier_span(program: &tishlang_ast::Program, span: &tishlang_ast
|
|
|
1040
1252
|
fn word_at_position(text: &str, position: Position) -> String {
|
|
1041
1253
|
let line = text.lines().nth(position.line as usize).unwrap_or("");
|
|
1042
1254
|
let chars: Vec<(usize, char)> = line.char_indices().collect();
|
|
1043
|
-
|
|
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
|
+
};
|
|
1044
1271
|
// Pick the identifier the cursor is on. If the cursor sits just past a word's end
|
|
1045
1272
|
// (on whitespace/punct or EOL), fall back to the identifier immediately to its left.
|
|
1046
1273
|
let mut start = col;
|
|
@@ -1074,7 +1301,7 @@ fn is_ident_char(c: char) -> bool {
|
|
|
1074
1301
|
fn render_type(t: &tishlang_ast::TypeAnnotation) -> String {
|
|
1075
1302
|
use tishlang_ast::{TypeAnnotation as T, TypeLiteral as L};
|
|
1076
1303
|
match t {
|
|
1077
|
-
T::Simple(s) => s.to_string(),
|
|
1304
|
+
T::Simple(s, _) => s.to_string(),
|
|
1078
1305
|
T::Array(inner) => {
|
|
1079
1306
|
// Parenthesize composite element types so `(A | B)[]` reads unambiguously.
|
|
1080
1307
|
if matches!(
|
|
@@ -1128,7 +1355,7 @@ fn shallow_expr_type(e: &tishlang_ast::Expr) -> Option<tishlang_ast::TypeAnnotat
|
|
|
1128
1355
|
Literal::Bool(_) => "boolean",
|
|
1129
1356
|
Literal::Null => "null",
|
|
1130
1357
|
};
|
|
1131
|
-
Some(T::Simple(Arc::from(name)))
|
|
1358
|
+
Some(T::Simple(Arc::from(name), tishlang_ast::Span::default()))
|
|
1132
1359
|
} else {
|
|
1133
1360
|
None
|
|
1134
1361
|
}
|
|
@@ -1661,11 +1888,11 @@ mod hover_tests {
|
|
|
1661
1888
|
#[test]
|
|
1662
1889
|
fn composite_types_render() {
|
|
1663
1890
|
use tishlang_ast::{TypeAnnotation as T, TypeLiteral as L};
|
|
1664
|
-
let arr = T::Array(Box::new(T::Simple("number".into())));
|
|
1891
|
+
let arr = T::Array(Box::new(T::Simple("number".into(), tishlang_ast::Span::default())));
|
|
1665
1892
|
assert_eq!(render_type(&arr), "number[]");
|
|
1666
|
-
let tup = T::Tuple(vec![T::Simple("number".into()), T::Simple("string".into())]);
|
|
1893
|
+
let tup = T::Tuple(vec![T::Simple("number".into(), tishlang_ast::Span::default()), T::Simple("string".into(), tishlang_ast::Span::default())]);
|
|
1667
1894
|
assert_eq!(render_type(&tup), "[number, string]");
|
|
1668
|
-
let uni = T::Union(vec![T::Simple("number".into()), T::Simple("null".into())]);
|
|
1895
|
+
let uni = T::Union(vec![T::Simple("number".into(), tishlang_ast::Span::default()), T::Simple("null".into(), tishlang_ast::Span::default())]);
|
|
1669
1896
|
assert_eq!(render_type(&uni), "number | null");
|
|
1670
1897
|
assert_eq!(render_type(&T::Literal(L::Str("on".into()))), "\"on\"");
|
|
1671
1898
|
let arr_of_union = T::Array(Box::new(uni));
|
|
@@ -1754,4 +1981,545 @@ mod type_ref_tests {
|
|
|
1754
1981
|
// On punctuation between words → empty.
|
|
1755
1982
|
assert_eq!(word_at_position("a = b\n", Position { line: 0, character: 2 }), "");
|
|
1756
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
|
+
}
|
|
1757
2525
|
}
|