@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.
- package/bin/tish +0 -0
- package/crates/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +2 -1
- package/crates/tish/src/cli_help.rs +15 -0
- package/crates/tish/src/main.rs +139 -12
- package/crates/tish/tests/integration_test.rs +65 -0
- package/crates/tish_ast/src/ast.rs +6 -2
- 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 +1 -1
- package/crates/tish_compile/src/codegen.rs +3133 -582
- package/crates/tish_compile/src/infer.rs +1950 -56
- 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 +17 -0
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +365 -9
- package/crates/tish_compile_js/src/lib.rs +2 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
- 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 +45 -1
- 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 +6 -0
- package/crates/tish_lsp/src/import_goto.rs +52 -7
- package/crates/tish_lsp/src/main.rs +856 -140
- 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 +15 -26
- package/crates/tish_resolve/src/lib.rs +188 -18
- 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};
|
|
@@ -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:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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, ¶ms.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,
|
|
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,
|
|
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(
|
|
765
|
+
md.push_str(no_binding_msg);
|
|
637
766
|
}
|
|
638
767
|
} else {
|
|
639
|
-
md.push_str(
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
829
|
-
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
|
+
}
|
|
830
978
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
)
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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.
|
|
934
|
-
|
|
1048
|
+
sym.name.clone(),
|
|
1049
|
+
sym.kind,
|
|
935
1050
|
None,
|
|
936
1051
|
Location {
|
|
937
|
-
uri: uri.clone(),
|
|
938
|
-
range:
|
|
1052
|
+
uri: cf.uri.clone(),
|
|
1053
|
+
range: sym.range,
|
|
939
1054
|
},
|
|
940
1055
|
None,
|
|
941
1056
|
));
|
|
942
1057
|
}
|
|
943
1058
|
}
|
|
944
|
-
|
|
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
|
-
|
|
1088
|
+
collect_file_symbols(inner, text, out);
|
|
947
1089
|
}
|
|
948
1090
|
}
|
|
949
|
-
|
|
950
|
-
| tishlang_ast::Statement::Multi { statements, .. } => {
|
|
1091
|
+
St::Block { statements, .. } | St::Multi { statements, .. } => {
|
|
951
1092
|
for x in statements {
|
|
952
|
-
|
|
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
|
-
|
|
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
|
}
|