use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use regex::Regex;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::notification::Progress;
use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse,
CompletionTriggerKind, Diagnostic, DiagnosticSeverity, DiagnosticTag,
DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
DidChangeWatchedFilesRegistrationOptions, DidChangeWorkspaceFoldersParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams,
DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, FileSystemWatcher, GlobPattern,
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
HoverProviderCapability, InitializeParams, InitializeResult, Location, MarkupContent,
MarkupKind, MessageType, NumberOrString, OneOf, Position, ProgressParams, ProgressParamsValue,
Range, ReferenceParams, Registration, RenameOptions, RenameParams,
ServerCapabilities, ServerInfo, SymbolInformation, SymbolKind, SymbolTag,
TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, Url,
WorkDoneProgress, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressOptions,
WorkspaceEdit, WorkspaceFoldersChangeEvent, WorkspaceFoldersServerCapabilities,
WorkspaceServerCapabilities, WorkspaceSymbolOptions, WorkspaceSymbolParams,
};
use tower_lsp::lsp_types::{PrepareRenameResponse, TextEdit};
use tower_lsp::{Client, LanguageServer, LspService, Server};
use walkdir::WalkDir;
mod builtin_goto;
mod import_goto;
#[derive(Debug)]
struct Backend {
client: Client,
docs: Arc<RwLock<HashMap<Url, String>>>,
edit_seq: Arc<RwLock<HashMap<Url, u64>>>,
roots: Arc<RwLock<Vec<PathBuf>>>,
cargo_src_cache: Arc<RwLock<HashMap<(PathBuf, String), PathBuf>>>,
tishlang_source_root: Arc<RwLock<Option<PathBuf>>>,
symbol_index: Arc<RwLock<HashMap<PathBuf, CachedFile>>>,
symbol_refresh: Arc<Mutex<()>>,
client_work_done_progress: Arc<RwLock<bool>>,
}
struct CancelGuard(Arc<AtomicBool>);
impl CancelGuard {
fn new() -> Self {
CancelGuard(Arc::new(AtomicBool::new(false)))
}
fn flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.0)
}
}
impl Drop for CancelGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
#[derive(Clone, Debug)]
struct CachedSymbol {
name: String,
name_lower: String,
kind: SymbolKind,
range: Range,
}
#[derive(Debug)]
struct CachedFile {
mtime: SystemTime,
uri: Url,
symbols: Vec<CachedSymbol>,
}
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend {
client,
docs: Arc::new(RwLock::new(HashMap::new())),
edit_seq: Arc::new(RwLock::new(HashMap::new())),
roots: Arc::new(RwLock::new(Vec::new())),
cargo_src_cache: Arc::new(RwLock::new(HashMap::new())),
tishlang_source_root: Arc::new(RwLock::new(None)),
symbol_index: Arc::new(RwLock::new(HashMap::new())),
symbol_refresh: Arc::new(Mutex::new(())),
client_work_done_progress: Arc::new(RwLock::new(false)),
});
Server::new(stdin, stdout, socket).serve(service).await;
}
fn parse_error_pos(err: &str) -> (u32, u32) {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"start: \((\d+), (\d+)\)").unwrap());
if let Some(c) = re.captures(err) {
let line: u32 = c.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(1);
let col: u32 = c.get(2).and_then(|m| m.as_str().parse().ok()).unwrap_or(1);
return (line.saturating_sub(1), col.saturating_sub(1));
}
(0, 0)
}
fn pos(line: u32, col: u32) -> Position {
Position {
line,
character: col,
}
}
fn full_doc_end(text: &str) -> (u32, u32) {
let line = text.matches('\n').count() as u32;
let last_seg = text.rsplit('\n').next().unwrap_or("");
let col = last_seg.encode_utf16().count() as u32;
(line, col)
}
fn diag_range(line: u32, col: u32, text: &str) -> Range {
let line_str = text.lines().nth(line as usize).unwrap_or("");
let end_char = line_str.len().max(col as usize + 1) as u32;
Range {
start: pos(line, col),
end: pos(line, end_char.min(col + 80)),
}
}
#[allow(deprecated)]
fn symbol_information(
name: String,
kind: SymbolKind,
tags: Option<Vec<SymbolTag>>,
location: Location,
container_name: Option<String>,
) -> SymbolInformation {
SymbolInformation {
name,
kind,
tags,
deprecated: None,
location,
container_name,
}
}
#[allow(deprecated)]
fn document_symbol(
name: String,
detail: Option<String>,
kind: SymbolKind,
tags: Option<Vec<SymbolTag>>,
range: Range,
selection_range: Range,
children: Option<Vec<DocumentSymbol>>,
) -> DocumentSymbol {
let contains = (range.start.line, range.start.character)
<= (selection_range.start.line, selection_range.start.character)
&& (selection_range.end.line, selection_range.end.character)
<= (range.end.line, range.end.character);
let range = if contains { range } else { selection_range };
DocumentSymbol {
name,
detail,
kind,
tags,
deprecated: None,
range,
selection_range,
children,
}
}
async fn publish_parse_and_lint(client: &Client, uri: Url, text: String) {
let diags = tokio::task::spawn_blocking(move || compute_diagnostics(&text))
.await
.unwrap_or_default();
client.publish_diagnostics(uri, diags, None).await;
}
fn compute_diagnostics(text: &str) -> Vec<Diagnostic> {
let mut diags = Vec::new();
match tishlang_parser::parse(text) {
Ok(program) => {
for d in tishlang_lint::lint_program(&program) {
let sev = match d.severity {
tishlang_lint::Severity::Error => DiagnosticSeverity::ERROR,
tishlang_lint::Severity::Warning => DiagnosticSeverity::WARNING,
};
diags.push(Diagnostic {
range: diag_range(d.line.saturating_sub(1), d.col.saturating_sub(1), text),
severity: Some(sev),
code: Some(NumberOrString::String(d.code.to_string())),
message: d.message,
source: Some("tish".into()),
..Default::default()
});
}
for u in tishlang_resolve::collect_unresolved_identifiers(&program) {
diags.push(Diagnostic {
range: span_to_range(&u.span, text),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("tish-unresolved-name".into())),
message: format!("no binding in scope for `{}`", u.name),
source: Some("tish".into()),
..Default::default()
});
}
for ub in tishlang_resolve::collect_unused_bindings(&program, text) {
let (message, code) = match ub.kind {
tishlang_resolve::UnusedBindingKind::Import => (
format!("`{}` is imported but never used", ub.name),
"tish-unused-import",
),
tishlang_resolve::UnusedBindingKind::Parameter => (
format!("`{}` is declared but never read", ub.name),
"tish-unused-parameter",
),
tishlang_resolve::UnusedBindingKind::Variable => (
format!("`{}` is declared but its value is never read", ub.name),
"tish-unused-variable",
),
};
diags.push(Diagnostic {
range: span_to_range(&ub.span, text),
severity: Some(DiagnosticSeverity::HINT),
code: Some(NumberOrString::String(code.into())),
message,
tags: Some(vec![DiagnosticTag::UNNECESSARY]),
source: Some("tish".into()),
..Default::default()
});
}
for d in tishlang_compile::check_program(&program) {
diags.push(Diagnostic {
range: span_to_range(&d.span, text),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("tish-type".into())),
message: d.message,
source: Some("tish".into()),
..Default::default()
});
}
}
Err(e) => {
let (l, c) = parse_error_pos(&e);
diags.push(Diagnostic {
range: diag_range(l, c, text),
severity: Some(DiagnosticSeverity::ERROR),
message: e,
source: Some("tish".into()),
..Default::default()
});
}
}
diags
}
fn apply_workspace_folder_changes(roots: &mut Vec<PathBuf>, event: &WorkspaceFoldersChangeEvent) {
for removed in &event.removed {
if let Ok(p) = removed.uri.to_file_path() {
roots.retain(|r| r != &p);
}
}
for added in &event.added {
if let Ok(p) = added.uri.to_file_path() {
if !roots.contains(&p) {
roots.push(p);
}
}
}
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
let mut roots = self.roots.write().unwrap();
roots.clear();
if let Some(folders) = params.workspace_folders {
for f in folders {
if let Ok(p) = f.uri.to_file_path() {
roots.push(p);
}
}
} else if let Some(uri) = params.root_uri {
if let Ok(p) = uri.to_file_path() {
roots.push(p);
}
}
let mut src_root: Option<PathBuf> = None;
let mut init_platform: Option<String> = None;
let mut init_surface: Option<String> = None;
if let Some(opts) = ¶ms.initialization_options {
if let Some(s) = opts
.get("tishlangSourceRoot")
.and_then(|v| v.as_str())
.map(str::trim)
{
if !s.is_empty() {
src_root = Some(PathBuf::from(s));
}
}
if let Some(p) = opts.get("platform").and_then(|v| v.as_str()) {
init_platform = Some(p.to_string());
} else if let Some(p) = opts.get("tishPlatform").and_then(|v| v.as_str()) {
init_platform = Some(p.to_string());
}
if let Some(s) = opts.get("surface").and_then(|v| v.as_str()) {
init_surface = Some(s.to_string());
} else if let Some(s) = opts.get("tishSurface").and_then(|v| v.as_str()) {
init_surface = Some(s.to_string());
}
}
let _ = tishlang_compile::apply_resolve_env(
init_platform.as_deref(),
init_surface.as_deref(),
);
if src_root.is_none() {
if let Ok(s) = std::env::var("TISHLANG_SOURCE_ROOT") {
let t = s.trim();
if !t.is_empty() {
src_root = Some(PathBuf::from(t));
}
}
}
let mut g = self.tishlang_source_root.write().unwrap();
*g = src_root.filter(|p| p.is_dir());
drop(g);
let supports_progress = params
.capabilities
.window
.as_ref()
.and_then(|w| w.work_done_progress)
.unwrap_or(false);
*self.client_work_done_progress.write().unwrap() = supports_progress;
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
completion_provider: Some(tower_lsp::lsp_types::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
..Default::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
document_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_provider: Some(OneOf::Right(WorkspaceSymbolOptions {
work_done_progress_options: WorkDoneProgressOptions {
work_done_progress: Some(true),
},
resolve_provider: None,
})),
workspace: Some(WorkspaceServerCapabilities {
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: Some(OneOf::Left(true)),
}),
file_operations: None,
}),
..Default::default()
},
server_info: Some(ServerInfo {
name: "tish-lsp".into(),
version: Some(env!("CARGO_PKG_VERSION").into()),
}),
})
}
async fn initialized(&self, _: tower_lsp::lsp_types::InitializedParams) {
let watchers = |glob: &str| FileSystemWatcher {
glob_pattern: GlobPattern::String(glob.to_string()),
kind: None, };
let reg = Registration {
id: "tish-watch-d-tish".to_string(),
method: "workspace/didChangeWatchedFiles".to_string(),
register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
watchers: vec![watchers("**/*.tish"), watchers("**/*.d.tish")],
})
.ok(),
};
if let Err(e) = self.client.register_capability(vec![reg]).await {
self.client
.log_message(
MessageType::INFO,
format!("tish-lsp: watched-files registration skipped: {e}"),
)
.await;
}
self.client
.log_message(MessageType::INFO, "tish-lsp ready")
.await;
}
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
{
let mut roots = self.roots.write().unwrap();
apply_workspace_folder_changes(&mut roots, ¶ms.event);
}
self.client
.log_message(MessageType::INFO, "tish-lsp: workspace folders updated")
.await;
}
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
let mut invalidated = 0usize;
{
let mut idx = self.symbol_index.write().unwrap();
for change in ¶ms.changes {
if let Ok(path) = change.uri.to_file_path() {
if path.extension().map(|x| x == "tish") == Some(true)
&& idx.remove(&path).is_some()
{
invalidated += 1;
}
}
}
}
let open: Vec<(Url, String)> = {
let docs = self.docs.read().unwrap();
docs.iter().map(|(u, t)| (u.clone(), t.clone())).collect()
};
for (uri, text) in open {
publish_parse_and_lint(&self.client, uri, text).await;
}
self.client
.log_message(
MessageType::INFO,
format!(
"tish-lsp: watched files changed ({} index entr{} invalidated)",
invalidated,
if invalidated == 1 { "y" } else { "ies" }
),
)
.await;
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
async fn did_open(&self, p: DidOpenTextDocumentParams) {
let uri = p.text_document.uri;
let text = p.text_document.text;
self.docs.write().unwrap().insert(uri.clone(), text.clone());
publish_parse_and_lint(&self.client, uri, text).await;
}
async fn did_change(&self, p: DidChangeTextDocumentParams) {
let uri = p.text_document.uri;
if let Some(chg) = p.content_changes.into_iter().last() {
self.docs
.write()
.unwrap()
.insert(uri.clone(), chg.text.clone());
let seq = {
let mut g = self.edit_seq.write().unwrap();
let n = g.entry(uri.clone()).or_insert(0);
*n += 1;
*n
};
let client = self.client.clone();
let docs = Arc::clone(&self.docs);
let edit_seq = Arc::clone(&self.edit_seq);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let is_current = |uri: &Url, seq: u64| {
edit_seq.read().unwrap().get(uri).copied() == Some(seq)
};
if !is_current(&uri, seq) {
return;
}
let text = docs.read().unwrap().get(&uri).cloned();
if let Some(text) = text {
let diags = tokio::task::spawn_blocking(move || compute_diagnostics(&text))
.await
.unwrap_or_default();
if !is_current(&uri, seq) {
return;
}
client.publish_diagnostics(uri, diags, None).await;
}
});
}
}
async fn did_close(&self, p: DidCloseTextDocumentParams) {
self.docs.write().unwrap().remove(&p.text_document.uri);
self.edit_seq.write().unwrap().remove(&p.text_document.uri);
self.client
.publish_diagnostics(p.text_document.uri, vec![], None)
.await;
}
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
let uri = params.text_document_position.text_document.uri.clone();
let pos = params.text_document_position.position;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let dot_trigger = params
.context
.as_ref()
.map(|c| {
matches!(c.trigger_kind, CompletionTriggerKind::TRIGGER_CHARACTER)
&& c.trigger_character.as_deref() == Some(".")
})
.unwrap_or(false);
let after_dot = dot_trigger
|| text
.lines()
.nth(pos.line as usize)
.map(|line| {
let upto: String = line.chars().take(pos.character as usize).collect();
upto.trim_end().ends_with('.')
})
.unwrap_or(false);
if after_dot {
return Ok(Some(CompletionResponse::Array(vec![])));
}
let keywords = [
"fn", "async", "let", "const", "if", "else", "while", "for", "return", "break",
"continue", "switch", "case", "default", "try", "catch", "finally", "throw", "import",
"export", "from", "typeof", "void", "await", "of", "in", "true", "false", "null",
"function", "do",
];
let mut items: Vec<CompletionItem> = keywords
.iter()
.map(|k| CompletionItem {
label: (*k).to_string(),
kind: Some(CompletionItemKind::KEYWORD),
..Default::default()
})
.collect();
if let Ok(program) = tishlang_parser::parse(&text) {
for name in tishlang_resolve::completion_value_names_at_cursor(
&program,
&text,
pos.line,
pos.character,
) {
items.push(CompletionItem {
label: name.to_string(),
kind: Some(value_completion_kind(&program, name.as_ref())),
..Default::default()
});
}
}
Ok(Some(CompletionResponse::Array(items)))
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
let uri = params.text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
let mut syms: Vec<DocumentSymbol> = Vec::new();
for s in &program.statements {
doc_symbol_stmt(s, &text, &mut syms);
}
Ok(Some(DocumentSymbolResponse::Nested(syms)))
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> Result<Option<GotoDefinitionResponse>> {
let TextDocumentPositionParams {
text_document,
position,
} = params.text_document_position_params;
let uri = text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
if let Some(def) =
tishlang_resolve::definition_span(&program, &text, position.line, position.character)
{
if is_import_specifier_span(&program, &def) {
if let Ok(ref file_path) = uri.to_file_path() {
let word = word_at_position(&text, position);
let roots = self.roots.read().unwrap().clone();
let open_docs = self.docs.read().unwrap();
if let Some(loc) = import_goto::definition_for_import(
&program,
file_path,
word.as_str(),
&roots,
self.cargo_src_cache.as_ref(),
&open_docs,
) {
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
}
}
}
let range = span_to_range(&def, &text);
return Ok(Some(GotoDefinitionResponse::Scalar(Location {
uri: uri.clone(),
range,
})));
}
let word = word_at_position(&text, position);
if word.is_empty() {
return Ok(None);
}
if let Some(sp) = type_decl_span(&program, word.as_str()) {
return Ok(Some(GotoDefinitionResponse::Scalar(Location {
uri: uri.clone(),
range: span_to_range(&sp, &text),
})));
}
if let Ok(ref file_path) = uri.to_file_path() {
let roots = self.roots.read().unwrap().clone();
let open_docs = self.docs.read().unwrap();
if let Some(loc) = import_goto::definition_for_import(
&program,
file_path,
word.as_str(),
&roots,
self.cargo_src_cache.as_ref(),
&open_docs,
) {
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
}
if let Some(loc) = import_goto::definition_for_native_receiver_member(
&program,
file_path,
&text,
&roots,
self.cargo_src_cache.as_ref(),
position.line,
position.character,
word.as_str(),
&open_docs,
) {
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
}
}
if let Some(root) = self.tishlang_source_root.read().unwrap().clone() {
if let Some(bdef) = builtin_goto::definition_for_builtin(
&text,
position.line,
position.character,
word.as_str(),
) {
if let Some(loc) = builtin_goto::to_file_location(&root, &bdef) {
return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
}
}
}
Ok(None)
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let pos = params.text_document_position_params.position;
let uri = params.text_document_position_params.text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
let Some(use_site) =
tishlang_resolve::name_at_cursor(&program, &text, pos.line, pos.character)
else {
let word = word_at_position(&text, pos);
if let Some(ty) = type_alias_body(&program, &word) {
let value = format!("**`{}`**{}", word, code_hint(&format!("type {} = {}", word, ty)));
return Ok(Some(Hover {
range: None,
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
}));
}
return Ok(None);
};
let def = tishlang_resolve::definition_span(&program, &text, pos.line, pos.character);
let mut md = format!("**`{}`**", use_site.name);
if let Some(ref dspan) = def {
if let Some(hint) = type_hint_at_def(&program, dspan) {
md.push_str(&hint);
}
}
match def {
Some(def) if def.start == use_site.span.start && def.end == use_site.span.end => {
md.push_str("\n\n_(binding site)_");
}
Some(def) => {
md.push_str(&format!(
"\n\nDefined at line {} col {}",
def.start.0, def.start.1
));
}
None => {
if tishlang_resolve::is_runtime_global_ident(use_site.name.as_ref()) {
md.push_str(
"\n\n_Interpreter root global (no lexical declaration in this file)._",
);
let word = word_at_position(&text, pos);
if !word.is_empty() {
if let Some(root) = self.tishlang_source_root.read().unwrap().clone() {
if let Some(bdef) = builtin_goto::definition_for_builtin(
&text,
pos.line,
pos.character,
word.as_str(),
) {
if let Some(loc) = builtin_goto::to_file_location(&root, &bdef) {
let line_1 = bdef.line.saturating_add(1);
let href = loc.uri.as_str();
md.push_str(&format!(
"\n\n[Open in Tish sources]({href}#L{line_1}) (`{}`)",
bdef.rel_path
));
}
}
}
}
} else {
let word = word_at_position(&text, pos);
let on_member_prop = tishlang_resolve::member_access_chain_at_cursor(
&program, &text, pos.line, pos.character,
)
.is_some();
let no_binding_msg = if on_member_prop {
"\n\n_Object property._"
} else {
"\n\n_No binding in scope for this name._"
};
if word.is_empty() {
md.push_str("\n\n_No binding in scope for this name._");
} else if let Ok(fp) = uri.to_file_path() {
let roots = self.roots.read().unwrap().clone();
let open_docs = self.docs.read().unwrap();
if let Some(nmd) = import_goto::native_member_definition(
&program,
&fp,
&text,
&roots,
self.cargo_src_cache.as_ref(),
pos.line,
pos.character,
word.as_str(),
&open_docs,
) {
md.push_str(
"\n\n_Native host module member (e.g. `tish:macos`); implementation in Rust._",
);
if let Some(ref d) = nmd.doc {
md.push_str("\n\n");
md.push_str(d);
}
let loc = nmd.location;
let line_1 = loc.range.start.line.saturating_add(1);
let href = loc.uri.as_str();
md.push_str(&format!(
"\n\n[Open Rust implementation]({href}#L{line_1})"
));
} else {
md.push_str(no_binding_msg);
}
} else {
md.push_str(no_binding_msg);
}
}
}
}
Ok(Some(Hover {
range: Some(span_to_range(&use_site.span, &text)),
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: md,
}),
}))
}
async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
let pos = params.text_document_position.position;
let uri = params.text_document_position.text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
let Some(def) = tishlang_resolve::definition_span(&program, &text, pos.line, pos.character)
else {
return Ok(None);
};
let Some(nu) = tishlang_resolve::name_at_cursor(&program, &text, pos.line, pos.character)
else {
return Ok(None);
};
let spans =
tishlang_resolve::reference_spans_for_def(&program, &text, nu.name.as_ref(), def);
let include_decl = params.context.include_declaration;
let locs: Vec<Location> = spans
.into_iter()
.filter(|sp| include_decl || *sp != def)
.map(|sp| Location {
uri: uri.clone(),
range: span_to_range(&sp, &text),
})
.collect();
Ok(Some(locs))
}
async fn prepare_rename(
&self,
params: TextDocumentPositionParams,
) -> Result<Option<PrepareRenameResponse>> {
let pos = params.position;
let uri = params.text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
match rename_target(&program, &text, pos.line, pos.character) {
Some((range, placeholder)) => Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
range,
placeholder,
})),
None => Ok(None),
}
}
async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
let pos = params.text_document_position.position;
let uri = params.text_document_position.text_document.uri;
let new_name = params.new_name;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
let Ok(program) = tishlang_parser::parse(&text) else {
return Ok(None);
};
if let Some(spans) =
tishlang_resolve::type_alias_rename_spans(&program, &text, pos.line, pos.character)
{
let mut edits: Vec<TextEdit> = spans
.into_iter()
.map(|sp| TextEdit {
range: span_to_range(&sp, &text),
new_text: new_name.clone(),
})
.collect();
edits.sort_by(|a, b| {
(b.range.start.line, b.range.start.character)
.cmp(&(a.range.start.line, a.range.start.character))
});
let mut m = HashMap::new();
m.insert(uri.clone(), edits);
return Ok(Some(WorkspaceEdit {
changes: Some(m),
..Default::default()
}));
}
let Some(def) = tishlang_resolve::definition_span(&program, &text, pos.line, pos.character)
else {
return Ok(None);
};
let Some(nu) = tishlang_resolve::name_at_cursor(&program, &text, pos.line, pos.character)
else {
return Ok(None);
};
let spans =
tishlang_resolve::reference_spans_for_def(&program, &text, nu.name.as_ref(), def);
let mut edits: Vec<TextEdit> = spans
.into_iter()
.map(|sp| TextEdit {
range: span_to_range(&sp, &text),
new_text: new_name.clone(),
})
.collect();
edits.sort_by(|a, b| {
(b.range.start.line, b.range.start.character)
.cmp(&(a.range.start.line, a.range.start.character))
});
let mut m = HashMap::new();
m.insert(uri, edits);
Ok(Some(WorkspaceEdit {
changes: Some(m),
..Default::default()
}))
}
async fn formatting(
&self,
params: DocumentFormattingParams,
) -> Result<Option<Vec<tower_lsp::lsp_types::TextEdit>>> {
let uri = params.text_document.uri;
let text = {
let g = self.docs.read().unwrap();
g.get(&uri).cloned()
};
let Some(text) = text else {
return Ok(None);
};
match tishlang_fmt::format_source(&text) {
Ok(formatted) => {
let (end_line, end_char) = full_doc_end(&text);
Ok(Some(vec![tower_lsp::lsp_types::TextEdit {
range: Range {
start: pos(0, 0),
end: pos(end_line, end_char),
},
new_text: formatted,
}]))
}
Err(e) => {
self.client
.show_message(MessageType::ERROR, format!("tish-fmt (formatter): {}", e))
.await;
Ok(None)
}
}
}
async fn symbol(
&self,
params: WorkspaceSymbolParams,
) -> Result<Option<Vec<SymbolInformation>>> {
let query = params.query.to_lowercase();
if query.is_empty() {
return Ok(Some(vec![]));
}
let roots_handle = Arc::clone(&self.roots);
let index = self.symbol_index.clone();
let refresh = Arc::clone(&self.symbol_refresh);
let cancel = CancelGuard::new();
let cancel_flag = cancel.flag();
let progress_token = params.work_done_progress_params.work_done_token;
let show_progress = *self.client_work_done_progress.read().unwrap();
let progress_token = progress_token.filter(|_| show_progress);
if let Some(ref token) = progress_token {
self.send_work_done(
token.clone(),
WorkDoneProgress::Begin(WorkDoneProgressBegin {
title: "Searching workspace symbols".to_string(),
cancellable: Some(true),
message: Some(format!("query: {query}")),
percentage: None,
}),
)
.await;
}
let walk_flag = Arc::clone(&cancel_flag);
let result = tokio::task::spawn_blocking(move || {
let _refresh_guard = refresh.lock().unwrap_or_else(|e| e.into_inner());
let roots = roots_handle.read().unwrap().clone();
refresh_and_query_symbols(&roots, &index, &query, &walk_flag)
})
.await
.unwrap_or(WalkOutcome::Cancelled);
if let Some(ref token) = progress_token {
self.send_work_done(
token.clone(),
WorkDoneProgress::End(WorkDoneProgressEnd {
message: Some(match &result {
WalkOutcome::Completed(s) => format!("{} symbol(s)", s.len()),
WalkOutcome::Cancelled => "cancelled".to_string(),
}),
}),
)
.await;
}
drop(cancel);
match result {
WalkOutcome::Completed(syms) => Ok(Some(syms)),
WalkOutcome::Cancelled => Err(tower_lsp::jsonrpc::Error::request_cancelled()),
}
}
}
impl Backend {
async fn send_work_done(
&self,
token: tower_lsp::lsp_types::ProgressToken,
value: WorkDoneProgress,
) {
self.client
.send_notification::<Progress>(ProgressParams {
token,
value: ProgressParamsValue::WorkDone(value),
})
.await;
}
}
fn ws_prune_dir(e: &walkdir::DirEntry) -> bool {
if e.depth() == 0 || !e.file_type().is_dir() {
return false;
}
let name = e.file_name().to_string_lossy();
name == "node_modules" || name == "target" || name.starts_with('.')
}
#[derive(Debug)]
enum WalkOutcome {
Completed(Vec<SymbolInformation>),
Cancelled,
}
fn refresh_and_query_symbols(
roots: &[PathBuf],
index: &RwLock<HashMap<PathBuf, CachedFile>>,
query: &str,
cancel: &AtomicBool,
) -> WalkOutcome {
let mut seen: HashSet<PathBuf> = HashSet::new();
for root in roots {
for e in WalkDir::new(root)
.into_iter()
.filter_entry(|e| !ws_prune_dir(e))
.filter_map(|e| e.ok())
{
if cancel.load(Ordering::Relaxed) {
return WalkOutcome::Cancelled;
}
if !e.file_type().is_file()
|| e.path().extension().map(|x| x == "tish") != Some(true)
{
continue;
}
let path = e.path().to_path_buf();
let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
seen.insert(path.clone());
let fresh = mtime.is_some_and(|mt| {
index.read().unwrap().get(&path).is_some_and(|cf| cf.mtime == mt)
});
if fresh {
continue;
}
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(program) = tishlang_parser::parse(&src) else {
continue;
};
let Ok(uri) = Url::from_file_path(&path) else {
continue;
};
let mut symbols = Vec::new();
for s in &program.statements {
collect_file_symbols(s, &src, &mut symbols);
}
let mtime = mtime.unwrap_or(SystemTime::UNIX_EPOCH);
index.write().unwrap().insert(path, CachedFile { mtime, uri, symbols });
}
}
if cancel.load(Ordering::Relaxed) {
return WalkOutcome::Cancelled;
}
index.write().unwrap().retain(|p, _| seen.contains(p));
let g = index.read().unwrap();
let mut out = Vec::new();
for cf in g.values() {
for sym in &cf.symbols {
if sym.name_lower.contains(query) {
out.push(symbol_information(
sym.name.clone(),
sym.kind,
None,
Location {
uri: cf.uri.clone(),
range: sym.range,
},
None,
));
}
}
}
WalkOutcome::Completed(out)
}
fn collect_file_symbols(s: &tishlang_ast::Statement, text: &str, out: &mut Vec<CachedSymbol>) {
use tishlang_ast::Statement as St;
let named: Option<(&str, SymbolKind, &tishlang_ast::Span)> = match s {
St::FunDecl { name, name_span, .. } => Some((name, SymbolKind::FUNCTION, name_span)),
St::VarDecl { name, name_span, .. } => Some((name, SymbolKind::VARIABLE, name_span)),
St::TypeAlias { name, name_span, .. } => Some((name, SymbolKind::INTERFACE, name_span)),
St::DeclareFun { name, name_span, .. } => Some((name, SymbolKind::FUNCTION, name_span)),
St::DeclareVar { name, name_span, .. } => Some((name, SymbolKind::VARIABLE, name_span)),
_ => None,
};
if let Some((name, kind, name_span)) = named {
out.push(CachedSymbol {
name: name.to_string(),
name_lower: name.to_lowercase(),
kind,
range: span_to_range(name_span, text),
});
return;
}
match s {
St::Export { declaration, .. } => {
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
collect_file_symbols(inner, text, out);
}
}
St::Block { statements, .. } | St::Multi { statements, .. } => {
for x in statements {
collect_file_symbols(x, text, out);
}
}
_ => {}
}
}
pub(crate) fn find_export(
program: &tishlang_ast::Program,
name: &str,
uri: &Url,
text: &str,
) -> Option<Location> {
for s in &program.statements {
match s {
tishlang_ast::Statement::FunDecl {
name: n, name_span, ..
} if n.as_ref() == name => {
return Some(Location {
uri: uri.clone(),
range: span_to_range(name_span, text),
});
}
tishlang_ast::Statement::VarDecl {
name: n, name_span, ..
} if n.as_ref() == name => {
return Some(Location {
uri: uri.clone(),
range: span_to_range(name_span, text),
});
}
tishlang_ast::Statement::Export { declaration, .. } => if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
if let Some(loc) = find_decl_in_stmt(inner, name, uri, text) {
return Some(loc);
}
},
_ => {}
}
}
None
}
pub(crate) fn find_default_export(
program: &tishlang_ast::Program,
uri: &Url,
text: &str,
) -> Option<Location> {
for s in &program.statements {
if let tishlang_ast::Statement::Export { declaration, span } = s {
if matches!(
declaration.as_ref(),
tishlang_ast::ExportDeclaration::Default(_)
) {
return Some(Location {
uri: uri.clone(),
range: span_to_range(span, text),
});
}
}
}
None
}
fn find_decl_in_stmt(
s: &tishlang_ast::Statement,
word: &str,
uri: &Url,
text: &str,
) -> Option<Location> {
match s {
tishlang_ast::Statement::FunDecl {
name, name_span, ..
} if name.as_ref() == word => Some(Location {
uri: uri.clone(),
range: span_to_range(name_span, text),
}),
tishlang_ast::Statement::VarDecl {
name, name_span, ..
} if name.as_ref() == word => Some(Location {
uri: uri.clone(),
range: span_to_range(name_span, text),
}),
tishlang_ast::Statement::Block { statements, .. } => {
for x in statements {
if let Some(l) = find_decl_in_stmt(x, word, uri, text) {
return Some(l);
}
}
None
}
_ => None,
}
}
fn span_to_range(span: &tishlang_ast::Span, text: &str) -> Range {
if let Some(((sl, sc), (el, ec))) = tishlang_resolve::span_to_lsp_range_exclusive(text, span) {
Range {
start: pos(sl, sc),
end: pos(el, ec),
}
} else {
Range {
start: pos(
span.start.0.saturating_sub(1) as u32,
span.start.1.saturating_sub(1) as u32,
),
end: pos(
span.end.0.saturating_sub(1) as u32,
span.end.1.saturating_sub(1) as u32,
),
}
}
}
fn rename_target(
program: &tishlang_ast::Program,
text: &str,
line: u32,
character: u32,
) -> Option<(Range, String)> {
let nu = tishlang_resolve::name_at_cursor(program, text, line, character)?;
let renameable = tishlang_resolve::definition_span(program, text, line, character).is_some()
|| tishlang_resolve::type_alias_rename_spans(program, text, line, character).is_some();
if !renameable {
return None;
}
Some((span_to_range(&nu.span, text), nu.name.to_string()))
}
fn is_import_specifier_span(program: &tishlang_ast::Program, span: &tishlang_ast::Span) -> bool {
use tishlang_ast::{ImportSpecifier, Statement};
program.statements.iter().any(|s| {
if let Statement::Import { specifiers, .. } = s {
specifiers.iter().any(|sp| {
let local = match sp {
ImportSpecifier::Named {
name_span,
alias_span,
..
} => alias_span.as_ref().unwrap_or(name_span),
ImportSpecifier::Namespace { name_span, .. }
| ImportSpecifier::Default { name_span, .. } => name_span,
};
local == span
})
} else {
false
}
})
}
fn word_at_position(text: &str, position: Position) -> String {
let line = text.lines().nth(position.line as usize).unwrap_or("");
let chars: Vec<(usize, char)> = line.char_indices().collect();
let target_u16 = position.character as usize;
let col = {
let mut idx = 0usize;
let mut acc = 0usize;
for (_, c) in &chars {
if acc >= target_u16 {
break;
}
acc += c.len_utf16();
idx += 1;
}
idx.min(chars.len())
};
let mut start = col;
if start >= chars.len() || !is_ident_char(chars[start].1) {
if start == 0 || !is_ident_char(chars[start - 1].1) {
return String::new();
}
start -= 1;
}
while start > 0 && is_ident_char(chars[start - 1].1) {
start -= 1;
}
let mut end = start;
while end < chars.len() && is_ident_char(chars[end].1) {
end += 1;
}
let s = chars[start].0;
let e = chars.get(end).map(|(p, _)| *p).unwrap_or(line.len());
line[s..e].to_string()
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn render_type(t: &tishlang_ast::TypeAnnotation) -> String {
use tishlang_ast::{TypeAnnotation as T, TypeLiteral as L};
match t {
T::Simple(s, _) => s.to_string(),
T::Array(inner) => {
if matches!(
inner.as_ref(),
T::Union(_) | T::Intersection(_) | T::Function { .. }
) {
format!("({})[]", render_type(inner))
} else {
format!("{}[]", render_type(inner))
}
}
T::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(k, v)| format!("{}: {}", k, render_type(v)))
.collect::<Vec<_>>()
.join(", ")
),
T::Function { params, returns } => format!(
"({}) => {}",
params.iter().map(render_type).collect::<Vec<_>>().join(", "),
render_type(returns)
),
T::Union(ts) => ts.iter().map(render_type).collect::<Vec<_>>().join(" | "),
T::Tuple(ts) => format!(
"[{}]",
ts.iter().map(render_type).collect::<Vec<_>>().join(", ")
),
T::Intersection(ts) => ts.iter().map(render_type).collect::<Vec<_>>().join(" & "),
T::Literal(L::Str(s)) => format!("\"{}\"", s),
T::Literal(L::Num(n)) => {
if n.fract() == 0.0 && n.is_finite() {
format!("{}", *n as i64)
} else {
n.to_string()
}
}
T::Literal(L::Bool(b)) => b.to_string(),
}
}
fn shallow_expr_type(e: &tishlang_ast::Expr) -> Option<tishlang_ast::TypeAnnotation> {
use tishlang_ast::{Expr, Literal, TypeAnnotation as T};
if let Expr::Literal { value, .. } = e {
let name = match value {
Literal::Number(_) => "number",
Literal::String(_) => "string",
Literal::Bool(_) => "boolean",
Literal::Null => "null",
};
Some(T::Simple(Arc::from(name), tishlang_ast::Span::default()))
} else {
None
}
}
fn render_param(p: &tishlang_ast::FunParam) -> String {
use tishlang_ast::FunParam;
match p {
FunParam::Simple(tp) => match &tp.type_ann {
Some(t) => format!("{}: {}", tp.name, render_type(t)),
None => tp.name.to_string(),
},
FunParam::Destructure { type_ann, .. } => match type_ann {
Some(t) => format!("{{…}}: {}", render_type(t)),
None => "{…}".to_string(),
},
}
}
fn fn_signature(
name: &str,
params: &[tishlang_ast::FunParam],
rest: &Option<tishlang_ast::TypedParam>,
ret: &Option<tishlang_ast::TypeAnnotation>,
) -> String {
let mut ps: Vec<String> = params.iter().map(render_param).collect();
if let Some(r) = rest {
let t = r
.type_ann
.as_ref()
.map(|t| format!(": {}", render_type(t)))
.unwrap_or_default();
ps.push(format!("...{}{}", r.name, t));
}
let ret_s = ret
.as_ref()
.map(render_type)
.unwrap_or_else(|| "void".to_string());
format!("fn {}({}): {}", name, ps.join(", "), ret_s)
}
fn same_start(a: &tishlang_ast::Span, b: &tishlang_ast::Span) -> bool {
a.start == b.start
}
fn code_hint(line: &str) -> String {
format!("\n\n```tish\n{}\n```", line)
}
fn type_hint_at_def(program: &tishlang_ast::Program, def: &tishlang_ast::Span) -> Option<String> {
program.statements.iter().find_map(|s| hint_in_stmt(s, def))
}
fn type_decl_span(program: &tishlang_ast::Program, name: &str) -> Option<tishlang_ast::Span> {
program.statements.iter().find_map(|s| match s {
tishlang_ast::Statement::TypeAlias {
name: n, name_span, ..
} if n.as_ref() == name => Some(*name_span),
_ => None,
})
}
fn type_alias_body(program: &tishlang_ast::Program, name: &str) -> Option<String> {
program.statements.iter().find_map(|s| match s {
tishlang_ast::Statement::TypeAlias { name: n, ty, .. } if n.as_ref() == name => {
Some(render_type(ty))
}
_ => None,
})
}
fn hint_in_stmt(s: &tishlang_ast::Statement, def: &tishlang_ast::Span) -> Option<String> {
use tishlang_ast::{FunParam, Statement as St};
match s {
St::VarDecl {
name,
name_span,
mutable,
type_ann,
init,
..
} => {
if same_start(name_span, def) {
let ty = type_ann
.clone()
.or_else(|| init.as_ref().and_then(shallow_expr_type))?;
let kw = if *mutable { "let" } else { "const" };
return Some(code_hint(&format!(
"{} {}: {}",
kw,
name,
render_type(&ty)
)));
}
None
}
St::FunDecl {
name,
name_span,
params,
rest_param,
return_type,
body,
..
} => {
if same_start(name_span, def) {
return Some(code_hint(&fn_signature(
name,
params,
rest_param,
return_type,
)));
}
for p in params {
if let FunParam::Simple(tp) = p {
if same_start(&tp.name_span, def) {
let ty = tp
.type_ann
.as_ref()
.map(render_type)
.unwrap_or_else(|| "any".to_string());
return Some(code_hint(&format!("(parameter) {}: {}", tp.name, ty)));
}
}
}
if let Some(r) = rest_param {
if same_start(&r.name_span, def) {
let ty = r
.type_ann
.as_ref()
.map(render_type)
.unwrap_or_else(|| "any[]".to_string());
return Some(code_hint(&format!("(parameter) ...{}: {}", r.name, ty)));
}
}
hint_in_stmt(body, def)
}
St::Block { statements, .. } | St::Multi { statements, .. } => {
statements.iter().find_map(|s| hint_in_stmt(s, def))
}
St::If {
then_branch,
else_branch,
..
} => hint_in_stmt(then_branch, def)
.or_else(|| else_branch.as_ref().and_then(|e| hint_in_stmt(e, def))),
St::For { init, body, .. } => init
.as_ref()
.and_then(|i| hint_in_stmt(i, def))
.or_else(|| hint_in_stmt(body, def)),
St::While { body, .. } | St::DoWhile { body, .. } | St::ForOf { body, .. } => {
hint_in_stmt(body, def)
}
St::Try { body, .. } => hint_in_stmt(body, def),
_ => None,
}
}
fn value_completion_kind(program: &tishlang_ast::Program, name: &str) -> CompletionItemKind {
for s in &program.statements {
if let Some(k) = value_completion_kind_stmt(s, name) {
return k;
}
}
CompletionItemKind::VARIABLE
}
fn value_completion_kind_stmt(
s: &tishlang_ast::Statement,
name: &str,
) -> Option<CompletionItemKind> {
match s {
tishlang_ast::Statement::FunDecl { name: n, .. } if n.as_ref() == name => {
Some(CompletionItemKind::FUNCTION)
}
tishlang_ast::Statement::VarDecl { name: n, .. } if n.as_ref() == name => {
Some(CompletionItemKind::VARIABLE)
}
tishlang_ast::Statement::Import { specifiers, .. } => {
for sp in specifiers {
let local = match sp {
tishlang_ast::ImportSpecifier::Named { name: n, alias, .. } => {
alias.as_ref().map(|a| a.as_ref()).unwrap_or(n.as_ref())
}
tishlang_ast::ImportSpecifier::Default { name: n, .. } => n.as_ref(),
tishlang_ast::ImportSpecifier::Namespace { name: n, .. } => n.as_ref(),
};
if local == name {
return Some(CompletionItemKind::VARIABLE);
}
}
None
}
tishlang_ast::Statement::Block { statements, .. } => statements
.iter()
.find_map(|x| value_completion_kind_stmt(x, name)),
tishlang_ast::Statement::If {
then_branch,
else_branch,
..
} => value_completion_kind_stmt(then_branch, name).or_else(|| {
else_branch
.as_ref()
.and_then(|b| value_completion_kind_stmt(b, name))
}),
tishlang_ast::Statement::While { body, .. }
| tishlang_ast::Statement::ForOf { body, .. }
| tishlang_ast::Statement::DoWhile { body, .. } => value_completion_kind_stmt(body, name),
tishlang_ast::Statement::For { init, body, .. } => init
.as_ref()
.and_then(|i| value_completion_kind_stmt(i, name))
.or_else(|| value_completion_kind_stmt(body, name)),
tishlang_ast::Statement::Try {
body,
catch_body,
finally_body,
..
} => value_completion_kind_stmt(body, name)
.or_else(|| {
catch_body
.as_ref()
.and_then(|b| value_completion_kind_stmt(b, name))
})
.or_else(|| {
finally_body
.as_ref()
.and_then(|b| value_completion_kind_stmt(b, name))
}),
tishlang_ast::Statement::Switch {
cases,
default_body,
..
} => {
for (_e, stmts) in cases {
if let Some(k) = stmts
.iter()
.find_map(|st| value_completion_kind_stmt(st, name))
{
return Some(k);
}
}
default_body.as_ref().and_then(|stmts| {
stmts
.iter()
.find_map(|st| value_completion_kind_stmt(st, name))
})
}
tishlang_ast::Statement::Export { declaration, .. } => match declaration.as_ref() {
tishlang_ast::ExportDeclaration::Named(inner) => {
value_completion_kind_stmt(inner, name)
}
tishlang_ast::ExportDeclaration::Default(_) => None,
tishlang_ast::ExportDeclaration::ReExport { .. } => None,
},
_ => None,
}
}
fn doc_symbol_stmt(
s: &tishlang_ast::Statement,
text: &str,
out: &mut Vec<DocumentSymbol>,
) {
match s {
tishlang_ast::Statement::FunDecl {
name,
name_span,
span,
body,
..
} => {
let mut children = Vec::new();
collect_child_syms(body, text, &mut children);
out.push(document_symbol(
name.to_string(),
None,
SymbolKind::FUNCTION,
None,
span_to_range(span, text),
span_to_range(name_span, text),
if children.is_empty() {
None
} else {
Some(children)
},
));
}
tishlang_ast::Statement::VarDecl {
name,
name_span,
span,
..
} => {
out.push(document_symbol(
name.to_string(),
None,
SymbolKind::VARIABLE,
None,
span_to_range(span, text),
span_to_range(name_span, text),
None,
));
}
tishlang_ast::Statement::TypeAlias {
name,
name_span,
span,
..
} => {
out.push(document_symbol(
name.to_string(),
None,
SymbolKind::INTERFACE,
None,
span_to_range(span, text),
span_to_range(name_span, text),
None,
));
}
tishlang_ast::Statement::DeclareFun {
name,
name_span,
span,
..
} => {
out.push(document_symbol(
name.to_string(),
None,
SymbolKind::FUNCTION,
None,
span_to_range(span, text),
span_to_range(name_span, text),
None,
));
}
tishlang_ast::Statement::DeclareVar {
name,
name_span,
span,
..
} => {
out.push(document_symbol(
name.to_string(),
None,
SymbolKind::VARIABLE,
None,
span_to_range(span, text),
span_to_range(name_span, text),
None,
));
}
tishlang_ast::Statement::Export { declaration, .. } => {
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
doc_symbol_stmt(inner, text, out);
}
}
tishlang_ast::Statement::Block { statements, .. }
| tishlang_ast::Statement::Multi { statements, .. } => {
for x in statements {
doc_symbol_stmt(x, text, out);
}
}
_ => {}
}
}
fn collect_child_syms(
s: &tishlang_ast::Statement,
text: &str,
out: &mut Vec<DocumentSymbol>,
) {
match s {
tishlang_ast::Statement::Block { statements, .. } => {
for x in statements {
doc_symbol_stmt(x, text, out);
}
}
_ => doc_symbol_stmt(s, text, out),
}
}
#[cfg(test)]
mod hover_tests {
use super::*;
use tishlang_ast::{FunParam, Span, Statement};
fn parse(src: &str) -> tishlang_ast::Program {
tishlang_parser::parse(src).expect("parse")
}
fn decl_span(s: &Statement, name: &str) -> Option<Span> {
match s {
Statement::VarDecl { name: n, name_span, .. } if n.as_ref() == name => Some(*name_span),
Statement::FunDecl { name: n, name_span, body, .. } => {
if n.as_ref() == name {
Some(*name_span)
} else {
decl_span(body, name)
}
}
Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
statements.iter().find_map(|x| decl_span(x, name))
}
Statement::If { then_branch, else_branch, .. } => decl_span(then_branch, name)
.or_else(|| else_branch.as_ref().and_then(|e| decl_span(e, name))),
Statement::For { body, .. }
| Statement::While { body, .. }
| Statement::DoWhile { body, .. }
| Statement::ForOf { body, .. } => decl_span(body, name),
_ => None,
}
}
fn span_of(p: &tishlang_ast::Program, name: &str) -> Span {
p.statements
.iter()
.find_map(|s| decl_span(s, name))
.unwrap_or_else(|| panic!("decl `{name}` not found"))
}
fn param_span(p: &tishlang_ast::Program, fname: &str, pname: &str) -> Span {
for s in &p.statements {
if let Statement::FunDecl { name, params, .. } = s {
if name.as_ref() == fname {
for fp in params {
if let FunParam::Simple(tp) = fp {
if tp.name.as_ref() == pname {
return tp.name_span;
}
}
}
}
}
}
panic!("param `{fname}.{pname}` not found")
}
fn hint(p: &tishlang_ast::Program, span: &Span) -> String {
type_hint_at_def(p, span).expect("expected a type hint")
}
#[test]
fn document_symbols_include_exported_type_and_comma_decls() {
let src = "export fn foo() {}\ntype Status = number\nlet a = 1, b = 2\ndeclare fn ext(): void\nlet plain = 3\n";
let program = tishlang_parser::parse(src).unwrap();
let mut syms = Vec::new();
for s in &program.statements {
doc_symbol_stmt(s, src, &mut syms);
}
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
for expected in ["foo", "Status", "a", "b", "ext", "plain"] {
assert!(names.contains(&expected), "outline missing `{expected}`: {names:?}");
}
}
#[test]
fn is_import_specifier_span_detects_imports() {
let src = "import { foo } from \"./m\"\nfoo()\nlet x = 1\nx\n";
let program = tishlang_parser::parse(src).unwrap();
let foo_def = tishlang_resolve::definition_span(&program, src, 1, 0).expect("foo resolves");
assert!(
is_import_specifier_span(&program, &foo_def),
"foo resolves to an import specifier"
);
let x_def = tishlang_resolve::definition_span(&program, src, 3, 0).expect("x resolves");
assert!(
!is_import_specifier_span(&program, &x_def),
"x is a local binding, not an import"
);
}
#[test]
fn find_default_export_locates_export_default() {
let src = "export fn foo() {}\nexport default 42\n";
let program = tishlang_parser::parse(src).unwrap();
let uri = Url::parse("file:///m.tish").unwrap();
let loc = find_default_export(&program, &uri, src).expect("default export found");
assert_eq!(loc.range.start.line, 1, "export default is on line 1");
let none_src = "export fn bar() {}\n";
let p2 = tishlang_parser::parse(none_src).unwrap();
assert!(find_default_export(&p2, &uri, none_src).is_none());
}
#[test]
fn annotated_var() {
let p = parse("let count: number = 0\n");
assert!(hint(&p, &span_of(&p, "count")).contains("let count: number"));
}
#[test]
fn inferred_var_and_const() {
let p = parse("let x = 42\nconst label = \"hi\"\nlet ok = true\n");
assert!(hint(&p, &span_of(&p, "x")).contains("let x: number"));
assert!(hint(&p, &span_of(&p, "label")).contains("const label: string"));
assert!(hint(&p, &span_of(&p, "ok")).contains("let ok: boolean"));
}
#[test]
fn function_signature() {
let p = parse("fn add(a: number, b: number): number { return a + b }\n");
assert!(hint(&p, &span_of(&p, "add")).contains("fn add(a: number, b: number): number"));
}
#[test]
fn parameter_hover() {
let p = parse("fn f(p: string) { return p }\n");
assert!(hint(&p, ¶m_span(&p, "f", "p")).contains("(parameter) p: string"));
}
#[test]
fn nested_decl_resolves() {
let p = parse("fn g() {\n let inner: boolean = true\n return inner\n}\n");
assert!(hint(&p, &span_of(&p, "inner")).contains("let inner: boolean"));
}
#[test]
fn composite_types_render() {
use tishlang_ast::{TypeAnnotation as T, TypeLiteral as L};
let arr = T::Array(Box::new(T::Simple("number".into(), tishlang_ast::Span::default())));
assert_eq!(render_type(&arr), "number[]");
let tup = T::Tuple(vec![T::Simple("number".into(), tishlang_ast::Span::default()), T::Simple("string".into(), tishlang_ast::Span::default())]);
assert_eq!(render_type(&tup), "[number, string]");
let uni = T::Union(vec![T::Simple("number".into(), tishlang_ast::Span::default()), T::Simple("null".into(), tishlang_ast::Span::default())]);
assert_eq!(render_type(&uni), "number | null");
assert_eq!(render_type(&T::Literal(L::Str("on".into()))), "\"on\"");
let arr_of_union = T::Array(Box::new(uni));
assert_eq!(render_type(&arr_of_union), "(number | null)[]");
}
#[test]
fn full_doc_end_reaches_past_trailing_newline_in_utf16() {
assert_eq!(full_doc_end("a\nb\n"), (2, 0)); assert_eq!(full_doc_end("a\nb"), (1, 1)); assert_eq!(full_doc_end("x\n"), (1, 0));
assert_eq!(full_doc_end(""), (0, 0));
assert_eq!(full_doc_end("café"), (0, 4)); }
#[test]
fn doc_symbols_satisfy_lsp_selection_containment() {
use tower_lsp::lsp_types::DocumentSymbol;
fn check(syms: &[DocumentSymbol], src: &str) {
for s in syms {
let (r, sel) = (&s.range, &s.selection_range);
let contained = (r.start.line, r.start.character)
<= (sel.start.line, sel.start.character)
&& (sel.end.line, sel.end.character) <= (r.end.line, r.end.character);
assert!(
contained,
"selectionRange {sel:?} not contained in range {r:?} for `{}` in:\n{src}",
s.name
);
if let Some(children) = &s.children {
check(children, src);
}
}
}
let sources = [
"fn f(x) { return x }\n",
"let a = 1\n",
"let a = 1, b = 2\n",
"export fn g() { return 1 }\n",
"export let x = 1\n",
"type T = number\n",
"declare fn h(): void\n",
"declare let y: number\n",
"fn outer() {\n fn inner() { return 1 }\n return inner\n}\n",
"export type Opts = { a: number }\n",
];
for src in sources {
let p = parse(src);
let mut syms = Vec::new();
for s in &p.statements {
doc_symbol_stmt(s, src, &mut syms);
}
check(&syms, src);
}
}
}
#[cfg(test)]
mod type_ref_tests {
use super::*;
const SRC: &str =
"interface Point { x: number, y: number }\ntype Status = \"on\" | \"off\"\nlet p: Point = { x: 1, y: 2 }\n";
#[test]
fn type_decl_lookup_and_body() {
let p = tishlang_parser::parse(SRC).expect("parse");
assert!(type_decl_span(&p, "Point").is_some());
assert!(type_decl_span(&p, "Status").is_some());
assert_eq!(type_alias_body(&p, "Point").as_deref(), Some("{ x: number, y: number }"));
assert_eq!(type_alias_body(&p, "Status").as_deref(), Some("\"on\" | \"off\""));
assert!(type_decl_span(&p, "Nope").is_none());
}
#[test]
fn word_at_position_finds_whole_word() {
assert_eq!(word_at_position(SRC, Position { line: 2, character: 8 }), "Point");
assert_eq!(word_at_position(SRC, Position { line: 2, character: 7 }), "Point");
assert_eq!(word_at_position(SRC, Position { line: 2, character: 12 }), "Point");
assert_eq!(word_at_position("a = b\n", Position { line: 0, character: 2 }), "");
}
#[test]
fn word_at_position_handles_astral_chars() {
assert_eq!(word_at_position("😀😀😀w foo", Position { line: 0, character: 6 }), "w");
}
}
#[cfg(test)]
mod rename_target_tests {
use super::*;
fn parse(src: &str) -> tishlang_ast::Program {
tishlang_parser::parse(src).expect("parse")
}
#[test]
fn member_property_not_offered() {
let src = "let obj = { foo: 1 }\nlet z = obj.foo\n";
let p = parse(src);
assert!(
rename_target(&p, src, 1, 12).is_none(),
"member property `foo` must not be offered for rename"
);
}
#[test]
fn value_binding_offered() {
let src = "let count = 1\nlet z = count\n";
let p = parse(src);
let t = rename_target(&p, src, 1, 8); assert!(t.is_some(), "a value binding use must be renameable");
assert_eq!(t.unwrap().1, "count");
}
#[test]
fn type_alias_offered() {
let src = "type T = number\nfn f(x: T) { return x }\nf(1)\n";
let p = parse(src);
assert!(
rename_target(&p, src, 0, 5).is_some(),
"a type alias declaration must be renameable"
);
}
}
#[cfg(test)]
mod test_fs {
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
pub fn scratch_root() -> PathBuf {
for key in ["TMPDIR", "TEMP", "TMP"] {
if let Some(v) = std::env::var_os(key).filter(|v| !v.is_empty()) {
return PathBuf::from(v);
}
}
PathBuf::from("/tmp")
}
pub fn unique_temp_dir(tag: &str) -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let d = scratch_root().join(format!("tish_lsp_test_{tag}_{}_{n}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
}
#[cfg(test)]
mod jsonrpc_integration_tests {
use super::*;
use tower::{Service, ServiceExt};
use tower_lsp::jsonrpc::Request;
fn new_service() -> LspService<Backend> {
let (service, _socket) = LspService::new(|client| Backend {
client,
docs: Arc::new(RwLock::new(HashMap::new())),
edit_seq: Arc::new(RwLock::new(HashMap::new())),
roots: Arc::new(RwLock::new(Vec::new())),
cargo_src_cache: Arc::new(RwLock::new(HashMap::new())),
tishlang_source_root: Arc::new(RwLock::new(None)),
symbol_index: Arc::new(RwLock::new(HashMap::new())),
symbol_refresh: Arc::new(Mutex::new(())),
client_work_done_progress: Arc::new(RwLock::new(false)),
});
service
}
async fn call(service: &mut LspService<Backend>, req: Request) -> Option<serde_json::Value> {
let resp = service.ready().await.unwrap().call(req).await.unwrap()?;
let (_id, result) = resp.into_parts();
Some(result.expect("server returned a JSON-RPC error"))
}
async fn initialize(service: &mut LspService<Backend>) {
let init = Request::build("initialize")
.id(1)
.params(serde_json::json!({ "capabilities": {} }))
.finish();
call(service, init).await.expect("initialize must return a result");
let initialized = Request::build("initialized").params(serde_json::json!({})).finish();
let _ = call(service, initialized).await; }
async fn did_open(service: &mut LspService<Backend>, uri: &str, text: &str) {
let req = Request::build("textDocument/didOpen")
.params(serde_json::json!({
"textDocument": { "uri": uri, "languageId": "tish", "version": 1, "text": text }
}))
.finish();
let _ = call(service, req).await; }
fn formatting_request(uri: &str) -> Request {
Request::build("textDocument/formatting")
.id(2)
.params(serde_json::json!({
"textDocument": { "uri": uri },
"options": { "tabSize": 2, "insertSpaces": true }
}))
.finish()
}
fn symbol_request(query: &str) -> Request {
Request::build("workspace/symbol")
.id(3)
.params(serde_json::json!({ "query": query }))
.finish()
}
fn did_change_workspace_folders(added: &[&str], removed: &[&str]) -> Request {
let folders = |uris: &[&str]| -> Vec<serde_json::Value> {
uris.iter()
.map(|u| serde_json::json!({ "uri": u, "name": "ws" }))
.collect()
};
Request::build("workspace/didChangeWorkspaceFolders")
.params(serde_json::json!({
"event": { "added": folders(added), "removed": folders(removed) }
}))
.finish()
}
#[tokio::test]
async fn formatting_round_trip_replaces_whole_document() {
let mut service = new_service();
initialize(&mut service).await;
let uri = "file:///round_trip.tish";
did_open(&mut service, uri, "let x=1\n").await;
let result = call(&mut service, formatting_request(uri))
.await
.expect("formatting must return a result");
let edits = result.as_array().expect("formatting result is an array of edits");
assert_eq!(edits.len(), 1, "a single whole-document edit");
let edit = &edits[0];
assert_eq!(edit["newText"], "let x = 1\n", "reformatted source");
assert_eq!(edit["range"]["start"], serde_json::json!({ "line": 0, "character": 0 }));
assert_eq!(
edit["range"]["end"],
serde_json::json!({ "line": 1, "character": 0 }),
"the edit must reach past the trailing newline, not stop at (0, N)"
);
}
#[tokio::test]
async fn formatting_unknown_document_yields_no_edit() {
let mut service = new_service();
initialize(&mut service).await;
let result = call(&mut service, formatting_request("file:///never_opened.tish"))
.await
.expect("formatting must return a result");
assert!(result.is_null(), "unknown document must yield no edits, got {result}");
}
fn completion_request(uri: &str, line: u32, ch: u32, trigger: Option<&str>) -> Request {
let context = match trigger {
Some(t) => serde_json::json!({ "triggerKind": 2, "triggerCharacter": t }),
None => serde_json::json!({ "triggerKind": 1 }),
};
Request::build("textDocument/completion")
.id(7)
.params(serde_json::json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": ch },
"context": context,
}))
.finish()
}
#[tokio::test]
async fn completion_after_dot_offers_no_keywords() {
let mut service = new_service();
initialize(&mut service).await;
let uri = "file:///complete.tish";
did_open(&mut service, uri, "let obj = 1\nobj.\n").await;
let after_dot = call(&mut service, completion_request(uri, 1, 4, Some(".")))
.await
.expect("completion result");
let items = after_dot.as_array().expect("completion is an array");
assert!(items.is_empty(), "no completions after a dot, got {after_dot}");
let top = call(&mut service, completion_request(uri, 0, 0, None))
.await
.expect("completion result");
let labels: Vec<&str> = top
.as_array()
.unwrap()
.iter()
.filter_map(|i| i["label"].as_str())
.collect();
assert!(labels.contains(&"let"), "top-level completion has keywords, got {top}");
}
#[tokio::test]
async fn did_change_updates_the_stored_document() {
let mut service = new_service();
initialize(&mut service).await;
let uri = "file:///mutated.tish";
did_open(&mut service, uri, "let a=1\n").await;
let change = Request::build("textDocument/didChange")
.params(serde_json::json!({
"textDocument": { "uri": uri, "version": 2 },
"contentChanges": [ { "text": "let b=2\n" } ]
}))
.finish();
let _ = call(&mut service, change).await;
let result = call(&mut service, formatting_request(uri))
.await
.expect("formatting must return a result");
let edits = result.as_array().expect("edits array");
assert_eq!(edits[0]["newText"], "let b = 2\n", "formatting must see the changed text");
}
#[tokio::test]
async fn initialize_advertises_workspace_folder_change_notifications() {
let mut service = new_service();
let init = Request::build("initialize")
.id(1)
.params(serde_json::json!({ "capabilities": {} }))
.finish();
let result = call(&mut service, init).await.expect("initialize must return a result");
let wf = &result["capabilities"]["workspace"]["workspaceFolders"];
assert_eq!(wf["supported"], serde_json::json!(true), "advertises workspace-folder support");
assert_eq!(
wf["changeNotifications"],
serde_json::json!(true),
"requests change notifications"
);
}
#[tokio::test]
async fn workspace_folder_add_then_remove_tracks_symbols() {
let mut service = new_service();
initialize(&mut service).await;
let dir = crate::test_fs::unique_temp_dir("wsfolder");
std::fs::write(dir.join("z.tish"), "fn zetaSym() { return 1 }\n").unwrap();
let uri = Url::from_file_path(&dir).unwrap().to_string();
let before = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
assert!(
before.as_array().expect("symbol result is an array").is_empty(),
"no roots yet → no symbols, got {before}"
);
let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
let found = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
let arr = found.as_array().expect("array");
assert_eq!(arr.len(), 1, "added folder's symbol is indexed, got {found}");
assert_eq!(arr[0]["name"], "zetaSym");
let _ = call(&mut service, did_change_workspace_folders(&[], &[&uri])).await;
let after = call(&mut service, symbol_request("zeta")).await.expect("symbol result");
assert!(
after.as_array().expect("symbol result is an array").is_empty(),
"removed folder's symbols evicted, got {after}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn double_add_is_idempotent_and_untracked_remove_is_a_no_op() {
let mut service = new_service();
initialize(&mut service).await;
let dir = crate::test_fs::unique_temp_dir("wsfolder_edge");
std::fs::write(dir.join("o.tish"), "fn omegaSym() { return 1 }\n").unwrap();
let uri = Url::from_file_path(&dir).unwrap().to_string();
let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
let found = call(&mut service, symbol_request("omega")).await.expect("symbol result");
assert_eq!(found.as_array().unwrap().len(), 1, "double-add stays one match, got {found}");
let _ = call(
&mut service,
did_change_workspace_folders(&[], &["file:///definitely/not/added"]),
)
.await;
let still = call(&mut service, symbol_request("omega")).await.expect("symbol result");
assert_eq!(
still.as_array().unwrap().len(),
1,
"untracked removal preserved the tracked root, got {still}"
);
std::fs::remove_dir_all(&dir).ok();
}
fn did_change_watched_files(uris: &[&str]) -> Request {
let changes: Vec<serde_json::Value> = uris
.iter()
.map(|u| serde_json::json!({ "uri": u, "type": 2 }))
.collect();
Request::build("workspace/didChangeWatchedFiles")
.params(serde_json::json!({ "changes": changes }))
.finish()
}
#[tokio::test]
async fn initialize_advertises_workspace_symbol_work_done_progress() {
let mut service = new_service();
let init = Request::build("initialize")
.id(1)
.params(serde_json::json!({
"capabilities": { "window": { "workDoneProgress": true } }
}))
.finish();
let result = call(&mut service, init).await.expect("initialize must return a result");
assert_eq!(
result["capabilities"]["workspaceSymbolProvider"]["workDoneProgress"],
serde_json::json!(true),
"workspace/symbol must advertise workDoneProgress, got {}",
result["capabilities"]["workspaceSymbolProvider"]
);
}
#[tokio::test]
async fn did_change_watched_files_refreshes_d_tish_declaration() {
let mut service = new_service();
initialize(&mut service).await;
let dir = crate::test_fs::unique_temp_dir("dtish_watch");
let decl = dir.join("ambient.d.tish");
std::fs::write(&decl, "declare fn oldAmbient(): void\n").unwrap();
let folder_uri = Url::from_file_path(&dir).unwrap().to_string();
let file_uri = Url::from_file_path(&decl).unwrap().to_string();
let _ = call(&mut service, did_change_workspace_folders(&[&folder_uri], &[])).await;
let before = call(&mut service, symbol_request("oldAmbient")).await.expect("symbol result");
assert_eq!(
before.as_array().unwrap().len(),
1,
"the declaration in the .d.tish is indexed, got {before}"
);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&decl, "declare fn newAmbient(): void\n").unwrap();
let _ = call(&mut service, did_change_watched_files(&[&file_uri])).await;
let new_hit = call(&mut service, symbol_request("newAmbient")).await.expect("symbol result");
assert_eq!(
new_hit.as_array().unwrap().len(),
1,
"the changed .d.tish declaration is re-indexed, got {new_hit}"
);
let old_gone = call(&mut service, symbol_request("oldAmbient")).await.expect("symbol result");
assert!(
old_gone.as_array().unwrap().is_empty(),
"the stale declaration must be gone after the watched-file change, got {old_gone}"
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(test)]
mod workspace_folder_tests {
use super::*;
use std::path::Path;
use tower_lsp::lsp_types::WorkspaceFolder;
fn folder(path: &Path) -> WorkspaceFolder {
WorkspaceFolder {
uri: Url::from_file_path(path).expect("absolute path"),
name: path.file_name().unwrap().to_string_lossy().into_owned(),
}
}
fn ev(added: &[&Path], removed: &[&Path]) -> WorkspaceFoldersChangeEvent {
WorkspaceFoldersChangeEvent {
added: added.iter().map(|p| folder(p)).collect(),
removed: removed.iter().map(|p| folder(p)).collect(),
}
}
#[test]
fn add_remove_and_dedup_roots() {
let base = std::env::current_dir().unwrap();
let (a, b, c) = (base.join("ws_a"), base.join("ws_b"), base.join("ws_c"));
let mut roots = vec![a.clone()];
apply_workspace_folder_changes(&mut roots, &ev(&[&a, &b, &c], &[]));
assert_eq!(roots, vec![a.clone(), b.clone(), c.clone()], "added new, deduped existing");
apply_workspace_folder_changes(&mut roots, &ev(&[], &[&b, &base.join("ghost")]));
assert_eq!(roots, vec![a, c], "removed b; untracked removal was a no-op");
}
#[test]
fn remove_and_readd_in_one_event_nets_to_present_once() {
let base = std::env::current_dir().unwrap();
let a = base.join("ws_a");
let mut roots = vec![a.clone()];
apply_workspace_folder_changes(&mut roots, &ev(&[&a], &[&a]));
assert_eq!(roots, vec![a], "remove-then-add nets to present once");
}
#[test]
fn non_file_uris_are_ignored() {
let base = std::env::current_dir().unwrap();
let a = base.join("ws_a");
let virt = WorkspaceFolder {
uri: Url::parse("vscode-vfs://host/project").unwrap(),
name: "virtual".into(),
};
let mut roots = vec![a.clone()];
apply_workspace_folder_changes(
&mut roots,
&WorkspaceFoldersChangeEvent { added: vec![virt.clone()], removed: vec![] },
);
assert_eq!(roots, vec![a.clone()], "non-file added URI ignored");
apply_workspace_folder_changes(
&mut roots,
&WorkspaceFoldersChangeEvent { added: vec![], removed: vec![virt] },
);
assert_eq!(roots, vec![a], "non-file removed URI is a no-op");
}
}
#[cfg(test)]
mod workspace_symbol_tests {
use super::*;
use crate::test_fs::unique_temp_dir;
fn names(syms: &[SymbolInformation]) -> Vec<&str> {
syms.iter().map(|s| s.name.as_str()).collect()
}
fn query(
roots: &[PathBuf],
index: &RwLock<HashMap<PathBuf, CachedFile>>,
q: &str,
) -> Vec<SymbolInformation> {
let never = AtomicBool::new(false);
match refresh_and_query_symbols(roots, index, q, &never) {
WalkOutcome::Completed(s) => s,
WalkOutcome::Cancelled => panic!("uncancelled walk must complete"),
}
}
#[test]
fn indexes_queries_and_prunes_heavy_dirs() {
let dir = unique_temp_dir("idx");
std::fs::write(dir.join("a.tish"), "fn alphaFn() { return 1 }\nlet betaVar = 2\n").unwrap();
std::fs::write(dir.join("b.tish"), "type GammaType = number\n").unwrap();
let nm = dir.join("node_modules");
std::fs::create_dir_all(&nm).unwrap();
std::fs::write(nm.join("dep.tish"), "fn alphaDep() {}\n").unwrap();
let index = RwLock::new(HashMap::new());
let roots = [dir.clone()];
let alpha = query(&roots, &index, "alpha");
assert_eq!(names(&alpha), ["alphaFn"], "alphaFn matched; alphaDep pruned under node_modules");
assert_eq!(names(&query(&roots, &index, "beta")), ["betaVar"]);
assert_eq!(names(&query(&roots, &index, "gamma")), ["GammaType"]);
assert_eq!(index.read().unwrap().len(), 2, "two .tish files indexed, node_modules pruned");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn reparses_on_edit_and_evicts_deleted_files() {
let dir = unique_temp_dir("mtime");
let f = dir.join("m.tish");
std::fs::write(&f, "fn first() {}\n").unwrap();
let index = RwLock::new(HashMap::new());
let roots = [dir.clone()];
assert_eq!(query(&roots, &index, "first").len(), 1);
assert_eq!(query(&roots, &index, "second").len(), 0);
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&f, "fn second() {}\n").unwrap();
assert_eq!(query(&roots, &index, "second").len(), 1, "re-parsed after edit");
assert_eq!(query(&roots, &index, "first").len(), 0, "stale symbol gone");
std::fs::remove_file(&f).unwrap();
assert_eq!(query(&roots, &index, "second").len(), 0, "deleted file evicted");
assert!(index.read().unwrap().is_empty(), "index empty after the only file is removed");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cancelled_walk_bails_without_mutating_index() {
let dir = unique_temp_dir("cancel");
std::fs::write(dir.join("c.tish"), "fn deltaSym() { return 1 }\n").unwrap();
let index = RwLock::new(HashMap::new());
let roots = [dir.clone()];
let cancelled = AtomicBool::new(true);
let outcome = refresh_and_query_symbols(&roots, &index, "delta", &cancelled);
assert!(
matches!(outcome, WalkOutcome::Cancelled),
"a pre-cancelled walk must report Cancelled"
);
assert!(
index.read().unwrap().is_empty(),
"a cancelled walk must not populate the index"
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(test)]
mod diagnostics_tests {
use super::*;
fn codes(diags: &[Diagnostic]) -> Vec<&str> {
diags
.iter()
.filter_map(|d| match &d.code {
Some(NumberOrString::String(s)) => Some(s.as_str()),
_ => None,
})
.collect()
}
#[test]
fn reports_parse_errors() {
let d = compute_diagnostics("let x = \n");
assert!(
d.iter().any(|x| x.severity == Some(DiagnosticSeverity::ERROR)),
"a parse error must surface an ERROR diagnostic, got {d:?}"
);
}
#[test]
fn runs_lint_and_resolve_in_one_pass() {
let d = compute_diagnostics("let o = { a: 1, a: 2 }\nbar()\n");
let c = codes(&d);
assert!(c.contains(&"tish-duplicate-key"), "lint stage ran: {c:?}");
assert!(c.contains(&"tish-unresolved-name"), "resolve stage ran: {c:?}");
}
#[test]
fn clean_program_has_no_errors() {
let d = compute_diagnostics("export fn add(a, b) { return a + b }\n");
assert!(
d.iter().all(|x| x.severity != Some(DiagnosticSeverity::ERROR)),
"a clean, exported, fully-used function must not error, got {d:?}"
);
}
}