use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use lsp_types as types;
use sha2::{Digest, Sha256};
use shuck_discover::{DiscoveredFile, DiscoveryOptions, FileKind, discover_files};
use shuck_indexer::LineIndex;
use shuck_linter::ShellDialect;
use shuck_semantic::{EditorDocumentSymbol, EditorSymbolKind};
use crate::PositionEncoding;
use crate::TextDocument;
use crate::edit::DocumentVersion;
use crate::session::{
Client, ClientOptions, DocumentSnapshot, ShuckSettings, WorkspaceSettingsSnapshot,
WorkspaceSymbolFeatureOptions,
};
pub(crate) type DocumentSymbolResponse = Option<types::DocumentSymbolResponse>;
pub(crate) type WorkspaceSymbolResponse = Option<types::WorkspaceSymbolResponse>;
#[derive(Clone)]
pub(crate) struct WorkspaceSymbolContext {
pub(crate) index: Arc<WorkspaceSymbolIndex>,
pub(crate) options: WorkspaceSymbolFeatureOptions,
pub(crate) global_options: ClientOptions,
pub(crate) workspace_settings: Vec<WorkspaceSettingsSnapshot>,
pub(crate) workspace_roots: Vec<PathBuf>,
pub(crate) settings_workspace_roots: Vec<PathBuf>,
pub(crate) open_documents: Vec<WorkspaceOpenDocument>,
pub(crate) encoding: PositionEncoding,
}
#[derive(Clone)]
pub(crate) struct WorkspaceOpenDocument {
uri: types::Url,
document: Arc<TextDocument>,
}
pub(crate) struct WorkspaceSymbolIndex {
cache: Mutex<WorkspaceSymbolCache>,
rebuild_finished: Condvar,
}
#[derive(Debug)]
struct WorkspaceSymbolCache {
summaries: BTreeMap<String, WorkspaceSymbolSummary>,
partial: bool,
dirty: bool,
rebuilding: bool,
generation: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceSymbolSummary {
uri: types::Url,
version: Option<DocumentVersion>,
content_hash: [u8; 32],
symbols: Vec<WorkspaceSymbolEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceSymbolEntry {
name: String,
kind: types::SymbolKind,
container_name: Option<String>,
range: types::Range,
selection_range: types::Range,
}
#[derive(Clone)]
struct WorkspaceSymbolCandidate {
uri: types::Url,
symbol: WorkspaceSymbolEntry,
}
struct ScoredWorkspaceSymbolCandidate {
score: SymbolQueryScore,
name_folded: String,
candidate: WorkspaceSymbolCandidate,
}
struct WorkspaceSymbolRebuildGuard<'a> {
index: &'a WorkspaceSymbolIndex,
active: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct SymbolQueryScore {
rank: u8,
penalty: usize,
}
impl Default for WorkspaceSymbolCache {
fn default() -> Self {
Self {
summaries: BTreeMap::new(),
partial: false,
dirty: true,
rebuilding: false,
generation: 0,
}
}
}
impl Default for WorkspaceSymbolIndex {
fn default() -> Self {
Self {
cache: Mutex::new(WorkspaceSymbolCache::default()),
rebuild_finished: Condvar::new(),
}
}
}
impl WorkspaceOpenDocument {
pub(crate) fn new(uri: types::Url, document: Arc<TextDocument>) -> Self {
Self { uri, document }
}
}
impl WorkspaceSymbolIndex {
pub(crate) fn invalidate_all(&self) {
let mut cache = lock_or_recover(&self.cache);
cache.summaries.clear();
cache.partial = false;
cache.dirty = true;
cache.generation = cache.generation.wrapping_add(1);
drop(cache);
self.rebuild_finished.notify_all();
}
pub(crate) fn invalidate_file_events(&self, changes: &[types::FileEvent]) {
let changed_uris = changes
.iter()
.map(|change| change.uri.as_str().to_owned())
.collect::<Vec<_>>();
if changed_uris.is_empty() {
return;
}
let mut cache = lock_or_recover(&self.cache);
for uri in changed_uris {
cache.summaries.remove(&uri);
}
cache.dirty = true;
cache.generation = cache.generation.wrapping_add(1);
drop(cache);
self.rebuild_finished.notify_all();
}
pub(crate) fn invalidate_uri(&self, uri: &types::Url) {
self.invalidate_uri_key(uri.as_str());
}
fn invalidate_uri_key(&self, uri: &str) {
let mut cache = lock_or_recover(&self.cache);
cache.summaries.remove(uri);
cache.dirty = true;
cache.generation = cache.generation.wrapping_add(1);
drop(cache);
self.rebuild_finished.notify_all();
}
fn closed_summaries(
&self,
context: &WorkspaceSymbolContext,
client: &Client,
) -> crate::server::Result<(Vec<WorkspaceSymbolSummary>, bool)> {
loop {
let generation = {
let mut cache = lock_or_recover(&self.cache);
while cache.dirty && cache.rebuilding {
cache = wait_or_recover(&self.rebuild_finished, cache);
}
if !cache.dirty {
return Ok(clone_cached_summaries(&cache));
}
cache.rebuilding = true;
cache.generation
};
let mut rebuild_guard = WorkspaceSymbolRebuildGuard::new(self);
let rebuilt = rebuild_closed_workspace_symbols(context)?;
let mut cache = lock_or_recover(&self.cache);
cache.rebuilding = false;
if cache.generation != generation {
drop(cache);
self.rebuild_finished.notify_all();
rebuild_guard.disarm();
continue;
}
cache.summaries = rebuilt
.summaries
.into_iter()
.map(|summary| (summary.uri.as_str().to_owned(), summary))
.collect();
cache.partial = rebuilt.partial;
cache.dirty = false;
let partial = cache.partial;
let summaries = clone_cached_summaries(&cache);
drop(cache);
self.rebuild_finished.notify_all();
rebuild_guard.disarm();
if partial {
let _ = client.log_message(
format!(
"Shuck workspace symbol index is partial; indexed the first {} closed shell files",
context.options.max_files
),
types::MessageType::INFO,
);
}
return Ok(summaries);
}
}
}
impl<'a> WorkspaceSymbolRebuildGuard<'a> {
fn new(index: &'a WorkspaceSymbolIndex) -> Self {
Self {
index,
active: true,
}
}
fn disarm(&mut self) {
self.active = false;
}
}
impl Drop for WorkspaceSymbolRebuildGuard<'_> {
fn drop(&mut self) {
if !self.active {
return;
}
let mut cache = lock_or_recover(&self.index.cache);
cache.rebuilding = false;
drop(cache);
self.index.rebuild_finished.notify_all();
}
}
pub(crate) fn document_symbols(
snapshot: DocumentSnapshot,
_client: &Client,
_params: types::DocumentSymbolParams,
) -> crate::server::Result<DocumentSymbolResponse> {
let Some(analysis) = snapshot.analysis() else {
return Ok(None);
};
let query = snapshot.query();
let source = analysis.source();
let editor_symbols = analysis.semantic().editor_query().document_symbols();
let render = SymbolRenderContext {
uri: query.file_url(),
source,
line_index: analysis.line_index(),
encoding: snapshot.encoding(),
};
if snapshot
.resolved_client_capabilities()
.hierarchical_document_symbols
{
let symbols = editor_symbols
.iter()
.map(|symbol| to_lsp_document_symbol(symbol, &render))
.collect();
Ok(Some(types::DocumentSymbolResponse::Nested(symbols)))
} else {
let symbols = editor_symbols
.iter()
.flat_map(|symbol| to_lsp_symbol_information(symbol, &render, None))
.collect();
Ok(Some(types::DocumentSymbolResponse::Flat(symbols)))
}
}
pub(crate) fn workspace_symbols(
context: WorkspaceSymbolContext,
client: &Client,
params: types::WorkspaceSymbolParams,
) -> crate::server::Result<WorkspaceSymbolResponse> {
if !workspace_symbols_have_enabled_scope(&context) {
return Ok(Some(types::WorkspaceSymbolResponse::Nested(Vec::new())));
}
let mut summaries = context
.open_documents
.iter()
.filter(|document| workspace_symbol_document_in_workspace(&context, document))
.filter(|document| workspace_symbol_options_for_uri(&context, &document.uri).enabled)
.filter_map(|document| workspace_symbol_summary_from_open_document(&context, document))
.collect::<Vec<_>>();
let open_uris = context
.open_documents
.iter()
.filter(|document| workspace_symbol_document_in_workspace(&context, document))
.filter(|document| workspace_symbol_options_for_uri(&context, &document.uri).enabled)
.flat_map(|document| workspace_uri_keys(&document.uri))
.collect::<std::collections::BTreeSet<_>>();
let (closed_summaries, _partial) = context.index.closed_summaries(&context, client)?;
summaries.extend(
closed_summaries
.into_iter()
.filter(|summary| !open_uris.contains(summary.uri.as_str())),
);
let symbols = workspace_symbol_candidates(&summaries, ¶ms.query)
.into_iter()
.map(to_lsp_workspace_symbol)
.collect();
Ok(Some(types::WorkspaceSymbolResponse::Nested(symbols)))
}
#[allow(deprecated)]
struct SymbolRenderContext<'a> {
uri: &'a types::Url,
source: &'a str,
line_index: &'a LineIndex,
encoding: PositionEncoding,
}
#[allow(deprecated)]
fn to_lsp_document_symbol(
symbol: &EditorDocumentSymbol,
render: &SymbolRenderContext<'_>,
) -> types::DocumentSymbol {
let children = (!symbol.children.is_empty()).then(|| {
symbol
.children
.iter()
.map(|child| to_lsp_document_symbol(child, render))
.collect()
});
types::DocumentSymbol {
name: symbol.name.to_string(),
detail: None,
kind: to_lsp_symbol_kind(symbol.kind),
tags: None,
deprecated: None,
range: crate::edit::to_lsp_range(
symbol.range.to_range(),
render.source,
render.line_index,
render.encoding,
),
selection_range: crate::edit::to_lsp_range(
symbol.selection_span.to_range(),
render.source,
render.line_index,
render.encoding,
),
children,
}
}
#[allow(deprecated)]
fn to_lsp_symbol_information(
symbol: &EditorDocumentSymbol,
render: &SymbolRenderContext<'_>,
container_name: Option<&str>,
) -> Vec<types::SymbolInformation> {
let mut symbols = vec![types::SymbolInformation {
name: symbol.name.to_string(),
kind: to_lsp_symbol_kind(symbol.kind),
tags: None,
deprecated: None,
location: types::Location::new(
render.uri.clone(),
crate::edit::to_lsp_range(
symbol.selection_span.to_range(),
render.source,
render.line_index,
render.encoding,
),
),
container_name: container_name.map(str::to_owned),
}];
symbols.extend(
symbol
.children
.iter()
.flat_map(|child| to_lsp_symbol_information(child, render, Some(symbol.name.as_str()))),
);
symbols
}
fn to_lsp_symbol_kind(kind: EditorSymbolKind) -> types::SymbolKind {
match kind {
EditorSymbolKind::Function => types::SymbolKind::FUNCTION,
EditorSymbolKind::Array | EditorSymbolKind::AssociativeArray => types::SymbolKind::ARRAY,
EditorSymbolKind::Variable
| EditorSymbolKind::Declaration
| EditorSymbolKind::RuntimeName => types::SymbolKind::VARIABLE,
}
}
fn editor_document_symbols(
source: &str,
path: Option<&Path>,
shell: ShellDialect,
) -> Vec<EditorDocumentSymbol> {
crate::editor::analyze_editor_document(source, path, shell)
.editor_query()
.document_symbols()
}
fn workspace_symbol_summary_from_open_document(
context: &WorkspaceSymbolContext,
open_document: &WorkspaceOpenDocument,
) -> Option<WorkspaceSymbolSummary> {
let path = open_document.uri.to_file_path().ok();
let settings = shuck_settings_for_document(context, path.as_deref());
let source = open_document.document.contents();
let shell = crate::lint::infer_document_shell_from_parts(
&settings,
open_document.document.language_id(),
source,
path.as_deref(),
)?;
let content_hash = content_hash(source);
let symbols = editor_document_symbols(source, path.as_deref(), shell);
let render = SymbolRenderContext {
uri: &open_document.uri,
source,
line_index: open_document.document.index(),
encoding: context.encoding,
};
Some(workspace_symbol_summary_from_editor_symbols(
open_document.uri.clone(),
Some(open_document.document.version()),
content_hash,
&symbols,
&render,
))
}
fn workspace_symbol_summary_from_source(
uri: types::Url,
source: &str,
path: &Path,
shell: ShellDialect,
encoding: PositionEncoding,
) -> WorkspaceSymbolSummary {
let line_index = LineIndex::new(source);
let symbols = editor_document_symbols(source, Some(path), shell);
let render = SymbolRenderContext {
uri: &uri,
source,
line_index: &line_index,
encoding,
};
workspace_symbol_summary_from_editor_symbols(
uri.clone(),
None,
content_hash(source),
&symbols,
&render,
)
}
fn workspace_symbol_summary_from_editor_symbols(
uri: types::Url,
version: Option<DocumentVersion>,
content_hash: [u8; 32],
symbols: &[EditorDocumentSymbol],
render: &SymbolRenderContext<'_>,
) -> WorkspaceSymbolSummary {
let mut entries = Vec::new();
flatten_workspace_symbols(symbols, render, None, &mut entries);
WorkspaceSymbolSummary {
uri,
version,
content_hash,
symbols: entries,
}
}
fn flatten_workspace_symbols(
symbols: &[EditorDocumentSymbol],
render: &SymbolRenderContext<'_>,
container_name: Option<&str>,
entries: &mut Vec<WorkspaceSymbolEntry>,
) {
for symbol in symbols {
let range = crate::edit::to_lsp_range(
symbol.range.to_range(),
render.source,
render.line_index,
render.encoding,
);
let selection_range = crate::edit::to_lsp_range(
symbol.selection_span.to_range(),
render.source,
render.line_index,
render.encoding,
);
entries.push(WorkspaceSymbolEntry {
name: symbol.name.to_string(),
kind: to_lsp_symbol_kind(symbol.kind),
container_name: container_name.map(str::to_owned),
range,
selection_range,
});
flatten_workspace_symbols(
&symbol.children,
render,
Some(symbol.name.as_str()),
entries,
);
}
}
struct ClosedWorkspaceSymbolBuild {
summaries: Vec<WorkspaceSymbolSummary>,
partial: bool,
}
fn rebuild_closed_workspace_symbols(
context: &WorkspaceSymbolContext,
) -> crate::server::Result<ClosedWorkspaceSymbolBuild> {
if context.workspace_roots.is_empty() {
return Ok(ClosedWorkspaceSymbolBuild {
summaries: Vec::new(),
partial: false,
});
}
let mut files = BTreeMap::new();
let mut partial = false;
let open_paths = open_document_path_keys(context);
for root in &context.workspace_roots {
let options = workspace_symbol_options_for_path(context, Some(root));
if !options.enabled {
continue;
}
let discovered = match discover_files(
std::slice::from_ref(root),
root,
&DiscoveryOptions {
respect_gitignore: true,
parallel: true,
use_config_roots: true,
..DiscoveryOptions::default()
},
) {
Ok(files) => files,
Err(error) => {
tracing::warn!(
"Failed to discover workspace symbol files in {}: {error}",
root.display()
);
partial = true;
continue;
}
};
let mut root_files = discovered
.into_iter()
.filter(|file| file.kind == FileKind::Shell)
.filter(|file| workspace_path_owned_by_root(context, &file.absolute_path, root))
.filter(|file| !open_paths.contains(&file.absolute_path))
.collect::<Vec<_>>();
partial |= root_files.len() > options.max_files;
root_files.truncate(options.max_files);
for file in root_files {
files.entry(file.absolute_path.clone()).or_insert(file);
}
}
partial |= files.len() > context.options.max_files;
let summaries = files
.values()
.take(context.options.max_files)
.filter_map(|file| closed_workspace_symbol_summary(context, file))
.collect();
Ok(ClosedWorkspaceSymbolBuild { summaries, partial })
}
fn closed_workspace_symbol_summary(
context: &WorkspaceSymbolContext,
file: &DiscoveredFile,
) -> Option<WorkspaceSymbolSummary> {
let source = match std::fs::read_to_string(&file.absolute_path) {
Ok(source) => source,
Err(error) => {
tracing::warn!(
"Failed to read workspace symbol file {}: {error}",
file.absolute_path.display()
);
return None;
}
};
let uri = match types::Url::from_file_path(&file.absolute_path) {
Ok(uri) => uri,
Err(()) => {
tracing::warn!(
"Failed to convert workspace symbol path to URI: {}",
file.absolute_path.display()
);
return None;
}
};
let settings = shuck_settings_for_document(context, Some(&file.absolute_path));
let shell = crate::lint::infer_document_shell_from_parts(
&settings,
None,
&source,
Some(&file.absolute_path),
)?;
Some(workspace_symbol_summary_from_source(
uri,
&source,
&file.absolute_path,
shell,
context.encoding,
))
}
fn shuck_settings_for_document(
context: &WorkspaceSymbolContext,
path: Option<&Path>,
) -> ShuckSettings {
let workspace_options = path.and_then(|path| {
context
.workspace_settings
.iter()
.filter_map(|workspace| {
workspace_root_match_len(path, workspace).map(|len| (workspace, len))
})
.max_by_key(|(_, len)| *len)
.and_then(|(workspace, _)| workspace.options.as_ref())
});
if let Some(workspace_options) = workspace_options {
return ShuckSettings::resolve(
path,
&context.settings_workspace_roots,
&[&context.global_options, workspace_options],
);
}
ShuckSettings::resolve(
path,
&context.settings_workspace_roots,
&[&context.global_options],
)
}
fn workspace_root_match_len(path: &Path, workspace: &WorkspaceSettingsSnapshot) -> Option<usize> {
[Some(&workspace.root), workspace.canonical_root.as_ref()]
.into_iter()
.flatten()
.filter(|root| path.starts_with(root))
.map(|root| root.components().count())
.max()
}
fn workspace_symbol_options_for_uri(
context: &WorkspaceSymbolContext,
uri: &types::Url,
) -> WorkspaceSymbolFeatureOptions {
let path = uri.to_file_path().ok();
workspace_symbol_options_for_path(context, path.as_deref())
}
fn workspace_symbol_options_for_path(
context: &WorkspaceSymbolContext,
path: Option<&Path>,
) -> WorkspaceSymbolFeatureOptions {
path.and_then(|path| workspace_settings_for_path(context, path))
.and_then(|workspace| workspace.options.as_ref())
.map(|options| {
options
.server
.workspace_symbols_layered_over(context.options)
})
.unwrap_or(context.options)
}
fn workspace_symbol_document_in_workspace(
context: &WorkspaceSymbolContext,
document: &WorkspaceOpenDocument,
) -> bool {
let Ok(path) = document.uri.to_file_path() else {
return false;
};
workspace_path_in_workspace(context, &path)
}
fn workspace_path_in_workspace(context: &WorkspaceSymbolContext, path: &Path) -> bool {
workspace_settings_for_path(context, path).is_some()
|| context
.workspace_roots
.iter()
.any(|root| path.starts_with(root))
}
fn workspace_path_owned_by_root(
context: &WorkspaceSymbolContext,
path: &Path,
root: &Path,
) -> bool {
workspace_settings_for_path(context, path)
.map(|workspace| workspace.root == root)
.unwrap_or(true)
}
fn workspace_settings_for_path<'a>(
context: &'a WorkspaceSymbolContext,
path: &Path,
) -> Option<&'a WorkspaceSettingsSnapshot> {
context
.workspace_settings
.iter()
.filter_map(|workspace| {
workspace_root_match_len(path, workspace).map(|len| (workspace, len))
})
.max_by_key(|(_, len)| *len)
.map(|(workspace, _)| workspace)
}
fn workspace_symbols_have_enabled_scope(context: &WorkspaceSymbolContext) -> bool {
context.open_documents.iter().any(|document| {
workspace_symbol_document_in_workspace(context, document)
&& workspace_symbol_options_for_uri(context, &document.uri).enabled
}) || context
.workspace_roots
.iter()
.any(|root| workspace_symbol_options_for_path(context, Some(root)).enabled)
}
fn workspace_symbol_candidates(
summaries: &[WorkspaceSymbolSummary],
query: &str,
) -> Vec<WorkspaceSymbolCandidate> {
let query_folded = query.trim().to_lowercase();
let query = query_folded.as_str();
let mut candidates = summaries
.iter()
.flat_map(|summary| {
summary.symbols.iter().filter_map(move |symbol| {
let name_folded = symbol.name.to_lowercase();
symbol_query_score(&name_folded, query).map(|score| {
ScoredWorkspaceSymbolCandidate {
score,
name_folded,
candidate: WorkspaceSymbolCandidate {
uri: summary.uri.clone(),
symbol: symbol.clone(),
},
}
})
})
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
left.score
.cmp(&right.score)
.then_with(|| left.name_folded.cmp(&right.name_folded))
.then_with(|| left.candidate.symbol.name.cmp(&right.candidate.symbol.name))
.then_with(|| {
left.candidate
.uri
.as_str()
.cmp(right.candidate.uri.as_str())
})
.then_with(|| {
range_sort_key(left.candidate.symbol.selection_range)
.cmp(&range_sort_key(right.candidate.symbol.selection_range))
})
});
candidates
.into_iter()
.map(|candidate| candidate.candidate)
.collect()
}
fn workspace_uri_keys(uri: &types::Url) -> Vec<String> {
let mut keys = vec![uri.as_str().to_owned()];
if let Ok(path) = uri.to_file_path()
&& let Ok(canonical) = std::fs::canonicalize(path)
&& let Ok(uri) = types::Url::from_file_path(canonical)
{
keys.push(uri.as_str().to_owned());
}
keys
}
fn open_document_path_keys(
context: &WorkspaceSymbolContext,
) -> std::collections::BTreeSet<PathBuf> {
let mut paths = std::collections::BTreeSet::new();
for document in &context.open_documents {
let Ok(path) = document.uri.to_file_path() else {
continue;
};
if let Ok(canonical) = std::fs::canonicalize(&path) {
paths.insert(canonical);
}
paths.insert(path);
}
paths
}
fn symbol_query_score(name: &str, query: &str) -> Option<SymbolQueryScore> {
if query.is_empty() {
return Some(SymbolQueryScore {
rank: 0,
penalty: 0,
});
}
if name == query {
return Some(SymbolQueryScore {
rank: 0,
penalty: 0,
});
}
if name.starts_with(query) {
return Some(SymbolQueryScore {
rank: 1,
penalty: name.len().saturating_sub(query.len()),
});
}
if let Some(index) = name.find(query) {
return Some(SymbolQueryScore {
rank: 2,
penalty: index,
});
}
subsequence_penalty(name, query).map(|penalty| SymbolQueryScore { rank: 3, penalty })
}
fn subsequence_penalty(name: &str, query: &str) -> Option<usize> {
let mut chars = name.char_indices();
let mut first = None;
let mut last = 0;
for needle in query.chars() {
let (index, _) = chars.find(|(_, candidate)| *candidate == needle)?;
first.get_or_insert(index);
last = index;
}
Some(
last.saturating_sub(first.unwrap_or(0))
.saturating_sub(query.len()),
)
}
fn to_lsp_workspace_symbol(candidate: WorkspaceSymbolCandidate) -> types::WorkspaceSymbol {
types::WorkspaceSymbol {
name: candidate.symbol.name,
kind: candidate.symbol.kind,
tags: None,
container_name: candidate.symbol.container_name,
location: lsp_types::OneOf::Left(types::Location::new(
candidate.uri,
candidate.symbol.selection_range,
)),
data: None,
}
}
fn content_hash(source: &str) -> [u8; 32] {
Sha256::digest(source.as_bytes()).into()
}
fn range_sort_key(range: types::Range) -> (u32, u32, u32, u32) {
(
range.start.line,
range.start.character,
range.end.line,
range.end.character,
)
}
fn clone_cached_summaries(cache: &WorkspaceSymbolCache) -> (Vec<WorkspaceSymbolSummary>, bool) {
(cache.summaries.values().cloned().collect(), cache.partial)
}
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn wait_or_recover<'a, T>(condvar: &Condvar, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> {
match condvar.wait(guard) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
mod tests {
use crossbeam::channel;
use lsp_types::{
ClientCapabilities, DocumentSymbolClientCapabilities, DocumentSymbolParams,
DocumentSymbolResponse, FileChangeType, FileEvent, PartialResultParams,
PositionEncodingKind, TextDocumentClientCapabilities, TextDocumentIdentifier, Url,
WorkDoneProgressParams, WorkspaceSymbolParams,
};
use super::*;
use crate::{
Client, GlobalOptions, PositionEncoding, Session, TextDocument, Workspace, Workspaces,
};
fn position_encoding_kind(encoding: PositionEncoding) -> PositionEncodingKind {
match encoding {
PositionEncoding::UTF8 => PositionEncodingKind::UTF8,
PositionEncoding::UTF16 => PositionEncodingKind::UTF16,
PositionEncoding::UTF32 => PositionEncodingKind::UTF32,
}
}
fn make_snapshot(
source: &str,
encoding: PositionEncoding,
hierarchical_document_symbols: bool,
) -> (DocumentSnapshot, Client, Url) {
let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
let (client_sender, _client_receiver) = channel::unbounded();
let client = Client::new(main_loop_sender, client_sender);
let workspace_root = std::env::temp_dir().join("shuck-server-symbol-tests");
let workspace_uri =
Url::from_file_path(&workspace_root).expect("workspace path should convert to a URL");
let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
let global = GlobalOptions::default().into_settings(client.clone());
let mut session = Session::new(
&ClientCapabilities {
general: Some(lsp_types::GeneralClientCapabilities {
position_encodings: Some(vec![position_encoding_kind(encoding)]),
..Default::default()
}),
text_document: Some(TextDocumentClientCapabilities {
document_symbol: Some(DocumentSymbolClientCapabilities {
hierarchical_document_symbol_support: Some(hierarchical_document_symbols),
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
encoding,
global,
&workspaces,
&client,
)
.expect("test session should initialize");
let uri = Url::from_file_path(workspace_root.join("script.sh"))
.expect("script path should convert to a URL");
session.open_text_document(
uri.clone(),
TextDocument::new(source.to_owned(), 1).with_language_id("shellscript"),
);
(
session
.take_snapshot(uri.clone())
.expect("test document should produce a snapshot"),
client,
uri,
)
}
fn make_session(
workspace_root: &std::path::Path,
encoding: PositionEncoding,
) -> (Session, Client) {
let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
let (client_sender, _client_receiver) = channel::unbounded();
let client = Client::new(main_loop_sender, client_sender);
let workspace_uri =
Url::from_file_path(workspace_root).expect("workspace path should convert to a URL");
let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
let global = GlobalOptions::default().into_settings(client.clone());
let session = Session::new(
&ClientCapabilities {
general: Some(lsp_types::GeneralClientCapabilities {
position_encodings: Some(vec![position_encoding_kind(encoding)]),
..Default::default()
}),
..Default::default()
},
encoding,
global,
&workspaces,
&client,
)
.expect("test session should initialize");
(session, client)
}
fn workspace_symbol_params(query: &str) -> WorkspaceSymbolParams {
WorkspaceSymbolParams {
query: query.to_owned(),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
}
}
fn workspace_symbol_response_names(response: types::WorkspaceSymbolResponse) -> Vec<String> {
let types::WorkspaceSymbolResponse::Nested(symbols) = response else {
panic!("expected nested workspace symbols");
};
symbols.into_iter().map(|symbol| symbol.name).collect()
}
fn workspace_symbol_response(
response: types::WorkspaceSymbolResponse,
) -> Vec<types::WorkspaceSymbol> {
let types::WorkspaceSymbolResponse::Nested(symbols) = response else {
panic!("expected nested workspace symbols");
};
symbols
}
fn summary_with_names(uri: &str, names: &[&str]) -> WorkspaceSymbolSummary {
WorkspaceSymbolSummary {
uri: Url::parse(uri).expect("test URI should parse"),
version: None,
content_hash: [0; 32],
symbols: names
.iter()
.map(|name| WorkspaceSymbolEntry {
name: (*name).to_owned(),
kind: types::SymbolKind::FUNCTION,
container_name: None,
range: types::Range::new(
types::Position::new(0, 0),
types::Position::new(0, 0),
),
selection_range: types::Range::new(
types::Position::new(0, 0),
types::Position::new(0, 0),
),
})
.collect(),
}
}
#[test]
fn document_symbols_return_nested_lsp_symbols() {
let source = "\
#!/bin/bash
VERSION=1
build() {
local artifact
}
";
let (snapshot, client, uri) = make_snapshot(source, PositionEncoding::UTF16, true);
let response = document_symbols(
snapshot,
&client,
DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
},
)
.expect("document symbol request should succeed")
.expect("document symbol response should be present");
let DocumentSymbolResponse::Nested(symbols) = response else {
panic!("expected nested document symbols");
};
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0].name, "VERSION");
assert_eq!(symbols[0].kind, types::SymbolKind::VARIABLE);
assert_eq!(symbols[0].selection_range.start.line, 1);
assert_eq!(symbols[0].selection_range.start.character, 0);
assert_eq!(symbols[0].selection_range.end.character, 7);
assert_eq!(symbols[1].name, "build");
assert_eq!(symbols[1].kind, types::SymbolKind::FUNCTION);
assert_eq!(symbols[1].selection_range.start.line, 2);
assert_eq!(symbols[1].selection_range.start.character, 0);
assert_eq!(symbols[1].selection_range.end.character, 5);
let children = symbols[1]
.children
.as_ref()
.expect("function symbol should have children");
assert_eq!(children.len(), 1);
assert_eq!(children[0].name, "artifact");
assert_eq!(children[0].kind, types::SymbolKind::VARIABLE);
assert_eq!(children[0].selection_range.start.line, 3);
assert_eq!(children[0].selection_range.start.character, 8);
}
#[test]
fn document_symbols_fall_back_to_flat_response_without_hierarchical_client_support() {
let source = "\
#!/bin/bash
VERSION=1
build() {
local artifact
}
";
let (snapshot, client, uri) = make_snapshot(source, PositionEncoding::UTF16, false);
let response = document_symbols(
snapshot,
&client,
DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
},
)
.expect("document symbol request should succeed")
.expect("document symbol response should be present");
let DocumentSymbolResponse::Flat(symbols) = response else {
panic!("expected flat document symbols");
};
assert_eq!(
symbols
.iter()
.map(|symbol| symbol.name.as_str())
.collect::<Vec<_>>(),
["VERSION", "build", "artifact"]
);
assert_eq!(symbols[0].container_name, None);
assert_eq!(symbols[2].container_name.as_deref(), Some("build"));
}
#[test]
fn document_symbol_ranges_use_negotiated_position_encoding() {
let source = "build() { echo \"é\"; local cafe; }\n";
let (snapshot, client, uri) = make_snapshot(source, PositionEncoding::UTF16, true);
let utf16_response = document_symbols(
snapshot,
&client,
DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
},
)
.expect("document symbol request should succeed")
.expect("document symbol response should be present");
let DocumentSymbolResponse::Nested(utf16_symbols) = utf16_response else {
panic!("expected nested document symbols");
};
let utf16_child = &utf16_symbols[0]
.children
.as_ref()
.expect("function should have children")[0];
assert_eq!(utf16_child.name, "cafe");
assert_eq!(utf16_child.selection_range.start.character, 26);
assert_eq!(utf16_child.selection_range.end.character, 30);
let (snapshot, client, uri) = make_snapshot(source, PositionEncoding::UTF8, true);
let utf8_response = document_symbols(
snapshot,
&client,
DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
},
)
.expect("document symbol request should succeed")
.expect("document symbol response should be present");
let DocumentSymbolResponse::Nested(utf8_symbols) = utf8_response else {
panic!("expected nested document symbols");
};
let utf8_child = &utf8_symbols[0]
.children
.as_ref()
.expect("function should have children")[0];
assert_eq!(utf8_child.name, "cafe");
assert_eq!(utf8_child.selection_range.start.character, 27);
assert_eq!(utf8_child.selection_range.end.character, 31);
}
#[test]
fn workspace_symbol_query_ranks_exact_prefix_contains_and_subsequence_matches() {
let summaries = vec![
summary_with_names(
"file:///tmp/one.sh",
&["prebuild", "build", "builder", "bxxuild"],
),
summary_with_names("file:///tmp/two.sh", &["other"]),
];
let candidates = workspace_symbol_candidates(&summaries, "build");
assert_eq!(
candidates
.iter()
.map(|candidate| candidate.symbol.name.as_str())
.collect::<Vec<_>>(),
["build", "builder", "prebuild", "bxxuild"]
);
}
#[test]
fn workspace_symbol_queries_are_case_insensitive_and_empty_queries_are_deterministic() {
let summaries = vec![
summary_with_names("file:///tmp/z.sh", &["shared", "gamma"]),
summary_with_names("file:///tmp/a.sh", &["shared", "Alpha"]),
];
let exact = workspace_symbol_candidates(&summaries, "alpha");
assert_eq!(
exact
.iter()
.map(|candidate| candidate.symbol.name.as_str())
.collect::<Vec<_>>(),
["Alpha"]
);
let empty = workspace_symbol_candidates(&summaries, "");
assert_eq!(
empty
.iter()
.map(|candidate| (candidate.symbol.name.as_str(), candidate.uri.as_str()))
.collect::<Vec<_>>(),
[
("Alpha", "file:///tmp/a.sh"),
("gamma", "file:///tmp/z.sh"),
("shared", "file:///tmp/a.sh"),
("shared", "file:///tmp/z.sh"),
]
);
}
#[test]
fn workspace_symbol_ranges_use_negotiated_position_encoding() {
let source = "build() { echo \"é\"; local cafe; }\n";
let path = std::path::Path::new("/tmp/script.sh");
let uri = Url::parse("file:///tmp/script.sh").expect("test URI should parse");
let utf16 = workspace_symbol_summary_from_source(
uri.clone(),
source,
path,
ShellDialect::Bash,
PositionEncoding::UTF16,
);
let utf16_child = utf16
.symbols
.iter()
.find(|symbol| symbol.name == "cafe")
.expect("expected local symbol");
assert_eq!(utf16_child.selection_range.start.character, 26);
assert_eq!(utf16_child.selection_range.end.character, 30);
let utf8 = workspace_symbol_summary_from_source(
uri,
source,
path,
ShellDialect::Bash,
PositionEncoding::UTF8,
);
let utf8_child = utf8
.symbols
.iter()
.find(|symbol| symbol.name == "cafe")
.expect("expected local symbol");
assert_eq!(utf8_child.selection_range.start.character, 27);
assert_eq!(utf8_child.selection_range.end.character, 31);
}
#[test]
fn workspace_symbols_can_be_disabled_without_rebuilding_closed_index() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("script.sh"), "hidden_symbol() { :; }\n")
.expect("fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.enabled = false;
session.update_client_options(options);
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(workspace_symbol_response_names(response).is_empty());
let cache = lock_or_recover(&context.index.cache);
assert!(cache.dirty);
assert!(cache.summaries.is_empty());
}
#[test]
fn workspace_symbols_honor_workspace_level_disable() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("script.sh"), "hidden_symbol() { :; }\n")
.expect("fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let workspace_uri =
Url::from_file_path(tempdir.path()).expect("workspace URI should convert");
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.enabled = false;
let mut workspace_options = crate::session::WorkspaceOptionsMap::default();
workspace_options.insert(workspace_uri, options);
session.update_configuration(crate::ClientOptions::default(), Some(workspace_options));
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(workspace_symbol_response_names(response).is_empty());
let cache = lock_or_recover(&context.index.cache);
assert!(cache.dirty);
assert!(cache.summaries.is_empty());
}
#[test]
fn workspace_symbols_preserve_global_disable_when_workspace_symbols_are_omitted() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("script.sh"), "hidden_symbol() { :; }\n")
.expect("fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let workspace_uri =
Url::from_file_path(tempdir.path()).expect("workspace URI should convert");
let mut global_options = crate::ClientOptions::default();
global_options.server.workspace_symbols.enabled = false;
let workspace_options = serde_json::from_value::<crate::ClientOptions>(serde_json::json!({
"showSyntaxErrors": false
}))
.expect("workspace options should deserialize");
let mut workspace_options_map = crate::session::WorkspaceOptionsMap::default();
workspace_options_map.insert(workspace_uri, workspace_options);
session.update_configuration(global_options, Some(workspace_options_map));
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("hidden"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(workspace_symbol_response_names(response).is_empty());
}
#[test]
fn workspace_symbols_preserve_global_max_files_when_workspace_symbols_are_omitted() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("a.sh"), "alpha_symbol() { :; }\n")
.expect("alpha fixture should be written");
std::fs::write(tempdir.path().join("b.sh"), "beta_symbol() { :; }\n")
.expect("beta fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let workspace_uri =
Url::from_file_path(tempdir.path()).expect("workspace URI should convert");
let mut global_options = crate::ClientOptions::default();
global_options.server.workspace_symbols.max_files = 1;
let workspace_options = serde_json::from_value::<crate::ClientOptions>(serde_json::json!({
"showSyntaxErrors": false
}))
.expect("workspace options should deserialize");
let mut workspace_options_map = crate::session::WorkspaceOptionsMap::default();
workspace_options_map.insert(workspace_uri, workspace_options);
session.update_configuration(global_options, Some(workspace_options_map));
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["alpha_symbol"]);
assert!(lock_or_recover(&context.index.cache).partial);
}
#[test]
fn workspace_symbols_include_language_id_only_open_buffers() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_uri = Url::from_file_path(tempdir.path().join("scratch.txt"))
.expect("scratch URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("language_only_symbol() { :; }\n".to_owned(), 3)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("language_only"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["language_only_symbol"]
);
}
#[test]
fn workspace_symbols_exclude_open_buffers_outside_workspace_roots() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let workspace_root = tempdir.path().join("workspace");
let external_root = tempdir.path().join("external");
std::fs::create_dir(&workspace_root).expect("workspace root should be created");
std::fs::create_dir(&external_root).expect("external root should be created");
let (mut session, client) = make_session(&workspace_root, PositionEncoding::UTF16);
let workspace_uri = Url::from_file_path(workspace_root.join("scratch.sh"))
.expect("workspace buffer URI should convert");
session.open_text_document(
workspace_uri,
TextDocument::new("workspace_open_symbol() { :; }\n".to_owned(), 3)
.with_language_id("shellscript"),
);
let external_uri = Url::from_file_path(external_root.join("script.sh"))
.expect("external buffer URI should convert");
session.open_text_document(
external_uri,
TextDocument::new("external_open_symbol() { :; }\n".to_owned(), 4)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("open_symbol"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["workspace_open_symbol"]
);
}
#[test]
fn workspace_symbols_return_concrete_locations_and_container_names() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_path = tempdir.path().join("script.sh");
let open_uri = Url::from_file_path(&open_path).expect("open URI should convert");
session.open_text_document(
open_uri.clone(),
TextDocument::new("build() {\n local artifact\n}\n".to_owned(), 4)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("artifact"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
let symbols = workspace_symbol_response(response);
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "artifact");
assert_eq!(symbols[0].container_name.as_deref(), Some("build"));
let lsp_types::OneOf::Left(location) = &symbols[0].location else {
panic!("workspace symbols should use concrete locations");
};
assert_eq!(location.uri, open_uri);
assert_eq!(location.range.start.line, 1);
assert_eq!(location.range.start.character, 8);
assert_eq!(location.range.end.character, 16);
}
#[test]
fn workspace_symbols_prefer_unsaved_open_buffers_over_disk_summaries() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let open_path = tempdir.path().join("open.sh");
let closed_path = tempdir.path().join("closed.sh");
std::fs::write(&open_path, "disk_workspace_symbol() { :; }\n")
.expect("open disk fixture should be written");
std::fs::write(&closed_path, "closed_workspace_symbol() { :; }\n")
.expect("closed fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_uri = Url::from_file_path(&open_path).expect("open URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("buffer_workspace_symbol() { :; }\n".to_owned(), 7)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("workspace_symbol"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
let names = workspace_symbol_response_names(response);
assert!(names.contains(&"buffer_workspace_symbol".to_owned()));
assert!(names.contains(&"closed_workspace_symbol".to_owned()));
assert!(!names.contains(&"disk_workspace_symbol".to_owned()));
}
#[test]
fn workspace_symbols_shadow_closed_summaries_for_unclassified_open_buffers() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let open_path = tempdir.path().join("script");
std::fs::write(&open_path, "#!/bin/sh\ndisk_shadow_symbol() { :; }\n")
.expect("open disk fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_uri = Url::from_file_path(&open_path).expect("open URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("echo no shell hint\n".to_owned(), 8),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("disk_shadow"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(workspace_symbol_response_names(response).is_empty());
}
#[cfg(unix)]
#[test]
fn workspace_symbols_shadow_closed_summaries_when_open_uri_is_symlink() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let real_path = tempdir.path().join("real.sh");
let symlink_path = tempdir.path().join("link.sh");
std::fs::write(&real_path, "disk_symlink_symbol() { :; }\n")
.expect("real fixture should be written");
std::os::unix::fs::symlink(&real_path, &symlink_path)
.expect("symlink fixture should be created");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_uri = Url::from_file_path(&symlink_path).expect("symlink URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("buffer_symlink_symbol() { :; }\n".to_owned(), 5)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("symlink_symbol"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
let names = workspace_symbol_response_names(response);
assert_eq!(names, ["buffer_symlink_symbol"]);
}
#[cfg(unix)]
#[test]
fn workspace_symbols_apply_workspace_options_for_symlinked_workspace_root() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let real_root = tempdir.path().join("real");
let symlink_root = tempdir.path().join("link");
std::fs::create_dir(&real_root).expect("real workspace should be created");
std::os::unix::fs::symlink(&real_root, &symlink_root)
.expect("workspace symlink should be created");
std::fs::write(
real_root.join("script.sh"),
"function zsh_target zsh_alias() { :; }\n",
)
.expect("fixture should be written");
let (mut session, client) = make_session(&symlink_root, PositionEncoding::UTF16);
let workspace_uri =
Url::from_file_path(&symlink_root).expect("workspace URI should convert");
let mut workspace_options = crate::session::WorkspaceOptionsMap::default();
workspace_options.insert(
workspace_uri,
crate::ClientOptions {
lint: Some(shuck_config::LintConfig {
per_file_shell: Some(std::collections::BTreeMap::from([(
"*.sh".to_owned(),
"zsh".to_owned(),
)])),
..Default::default()
}),
..Default::default()
},
);
session.update_configuration(crate::ClientOptions::default(), Some(workspace_options));
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("zsh_alias"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["zsh_alias"]);
}
#[test]
fn workspace_symbols_use_source_declared_shell_for_closed_files() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(
tempdir.path().join("script.sh"),
"# shellcheck shell=zsh\nfunction declared_header declared_alias() { :; }\n",
)
.expect("fixture should be written");
let (session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("declared_alias"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["declared_alias"]
);
}
#[test]
fn workspace_symbols_respect_max_files_and_mark_partial_indexes() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("a.sh"), "alpha_symbol() { :; }\n")
.expect("alpha fixture should be written");
std::fs::write(tempdir.path().join("b.sh"), "beta_symbol() { :; }\n")
.expect("beta fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.max_files = 1;
session.update_client_options(options);
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
let names = workspace_symbol_response_names(response);
assert_eq!(names, ["alpha_symbol"]);
let cache = lock_or_recover(&context.index.cache);
assert!(cache.partial);
assert!(!cache.dirty);
}
#[test]
fn workspace_symbols_honor_workspace_level_max_files() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
std::fs::write(tempdir.path().join("a.sh"), "alpha_symbol() { :; }\n")
.expect("alpha fixture should be written");
std::fs::write(tempdir.path().join("b.sh"), "beta_symbol() { :; }\n")
.expect("beta fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let workspace_uri =
Url::from_file_path(tempdir.path()).expect("workspace URI should convert");
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.max_files = 1;
let mut workspace_options = crate::session::WorkspaceOptionsMap::default();
workspace_options.insert(workspace_uri, options);
session.update_configuration(crate::ClientOptions::default(), Some(workspace_options));
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["alpha_symbol"]);
let cache = lock_or_recover(&context.index.cache);
assert!(cache.partial);
assert!(!cache.dirty);
}
#[test]
fn workspace_symbols_enforce_max_files_across_workspace_roots() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let first_root = tempdir.path().join("a-root");
let second_root = tempdir.path().join("b-root");
std::fs::create_dir(&first_root).expect("first root should be created");
std::fs::create_dir(&second_root).expect("second root should be created");
std::fs::write(first_root.join("a.sh"), "alpha_symbol() { :; }\n")
.expect("alpha fixture should be written");
std::fs::write(second_root.join("b.sh"), "beta_symbol() { :; }\n")
.expect("beta fixture should be written");
let (mut session, client) = make_session(&first_root, PositionEncoding::UTF16);
let second_uri =
Url::from_file_path(&second_root).expect("second workspace URI should convert");
session
.open_workspace_folder(second_uri, &client)
.expect("second workspace should open");
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.max_files = 1;
session.update_client_options(options);
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["alpha_symbol"]);
let cache = lock_or_recover(&context.index.cache);
assert!(cache.partial);
assert!(!cache.dirty);
}
#[test]
fn workspace_symbols_do_not_index_nested_workspace_files_from_parent_root() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let parent_root = tempdir.path().join("parent");
let nested_root = parent_root.join("nested");
std::fs::create_dir_all(&nested_root).expect("nested root should be created");
std::fs::write(parent_root.join("a.sh"), "parent_symbol() { :; }\n")
.expect("parent fixture should be written");
std::fs::write(nested_root.join("b.sh"), "nested_symbol() { :; }\n")
.expect("nested fixture should be written");
let (mut session, client) = make_session(&parent_root, PositionEncoding::UTF16);
let nested_uri =
Url::from_file_path(&nested_root).expect("nested workspace URI should convert");
session
.open_workspace_folder(nested_uri.clone(), &client)
.expect("nested workspace should open");
let mut nested_options = crate::ClientOptions::default();
nested_options.server.workspace_symbols.enabled = false;
let mut workspace_options = crate::session::WorkspaceOptionsMap::default();
workspace_options.insert(nested_uri, nested_options);
session.update_configuration(crate::ClientOptions::default(), Some(workspace_options));
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("symbol"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["parent_symbol"]);
}
#[test]
fn workspace_symbols_tolerate_discovery_failure_for_one_root() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let healthy_root = tempdir.path().join("healthy");
let missing_root = tempdir.path().join("missing");
std::fs::create_dir(&healthy_root).expect("healthy root should be created");
std::fs::create_dir(&missing_root).expect("missing root should initially exist");
std::fs::write(healthy_root.join("a.sh"), "healthy_symbol() { :; }\n")
.expect("healthy fixture should be written");
let (mut session, client) = make_session(&healthy_root, PositionEncoding::UTF16);
let missing_uri =
Url::from_file_path(&missing_root).expect("missing workspace URI should convert");
session
.open_workspace_folder(missing_uri, &client)
.expect("missing workspace should open");
let open_uri = Url::from_file_path(missing_root.join("scratch.sh"))
.expect("open buffer URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("open_buffer_symbol() { :; }\n".to_owned(), 3)
.with_language_id("shellscript"),
);
std::fs::remove_dir(&missing_root).expect("missing root should be removed");
let context = session.workspace_symbol_context();
let response = workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should tolerate one bad root")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["healthy_symbol", "open_buffer_symbol"]
);
let cache = lock_or_recover(&context.index.cache);
assert!(cache.partial);
assert!(!cache.dirty);
}
#[test]
fn workspace_symbols_apply_max_files_after_excluding_open_documents() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let open_path = tempdir.path().join("a.sh");
let closed_path = tempdir.path().join("b.sh");
std::fs::write(&open_path, "disk_alpha_symbol() { :; }\n")
.expect("alpha fixture should be written");
std::fs::write(&closed_path, "beta_symbol() { :; }\n")
.expect("beta fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let open_uri = Url::from_file_path(&open_path).expect("open URI should convert");
session.open_text_document(
open_uri,
TextDocument::new("buffer_alpha_symbol() { :; }\n".to_owned(), 9)
.with_language_id("shellscript"),
);
let mut options = crate::ClientOptions::default();
options.server.workspace_symbols.max_files = 1;
session.update_client_options(options);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("beta_symbol"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(workspace_symbol_response_names(response), ["beta_symbol"]);
}
#[test]
fn workspace_symbols_rebuild_disk_summary_after_open_document_closes() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let script = tempdir.path().join("script.sh");
std::fs::write(&script, "old_closed_symbol() { :; }\n")
.expect("script fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("old_closed"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["old_closed_symbol"]
);
std::fs::write(&script, "new_closed_symbol() { :; }\n")
.expect("script fixture should be updated");
let uri = Url::from_file_path(&script).expect("script URI should convert");
session.open_text_document(
uri.clone(),
TextDocument::new("new_closed_symbol() { :; }\n".to_owned(), 2)
.with_language_id("shellscript"),
);
let response = workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params("new_closed"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(response),
["new_closed_symbol"]
);
let key = session.key_from_url(uri);
session
.close_document(&key)
.expect("document close should succeed");
let context = session.workspace_symbol_context();
assert!(lock_or_recover(&context.index.cache).dirty);
let stale = workspace_symbols(
context.clone(),
&client,
workspace_symbol_params("old_closed"),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(workspace_symbol_response_names(stale).is_empty());
let fresh = workspace_symbols(context, &client, workspace_symbol_params("new_closed"))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert_eq!(
workspace_symbol_response_names(fresh),
["new_closed_symbol"]
);
}
#[test]
fn workspace_symbol_index_invalidates_for_file_config_and_workspace_changes() {
let tempdir = tempfile::tempdir().expect("workspace should be created");
let script = tempdir.path().join("script.sh");
std::fs::write(&script, "cached_symbol() { :; }\n")
.expect("script fixture should be written");
let (mut session, client) = make_session(tempdir.path(), PositionEncoding::UTF16);
let context = session.workspace_symbol_context();
workspace_symbols(context.clone(), &client, workspace_symbol_params(""))
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(!lock_or_recover(&context.index.cache).dirty);
session.reload_settings(
&[FileEvent {
uri: Url::from_file_path(&script).expect("script URI should convert"),
typ: FileChangeType::CHANGED,
}],
&client,
);
assert!(lock_or_recover(&context.index.cache).dirty);
workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params(""),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(!lock_or_recover(&context.index.cache).dirty);
session.update_configuration(crate::ClientOptions::default(), None);
assert!(lock_or_recover(&context.index.cache).dirty);
workspace_symbols(
session.workspace_symbol_context(),
&client,
workspace_symbol_params(""),
)
.expect("workspace symbol request should succeed")
.expect("workspace symbol response should be present");
assert!(!lock_or_recover(&context.index.cache).dirty);
let workspace_uri =
Url::from_file_path(tempdir.path()).expect("workspace URI should convert");
session
.close_workspace_folder(&workspace_uri)
.expect("workspace close should succeed");
assert!(lock_or_recover(&context.index.cache).dirty);
}
#[test]
fn workspace_symbol_index_waits_for_an_active_rebuild_commit() {
let index = Arc::new(WorkspaceSymbolIndex::default());
let summary = summary_with_names("file:///tmp/cached.sh", &["cached_symbol"]);
{
let mut cache = lock_or_recover(&index.cache);
cache
.summaries
.insert(summary.uri.as_str().to_owned(), summary);
cache.dirty = true;
cache.rebuilding = true;
}
let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
let (client_sender, _client_receiver) = channel::unbounded();
let client = Client::new(main_loop_sender, client_sender);
let context = WorkspaceSymbolContext {
index: index.clone(),
options: WorkspaceSymbolFeatureOptions::default(),
global_options: crate::ClientOptions::default(),
workspace_settings: Vec::new(),
workspace_roots: Vec::new(),
settings_workspace_roots: Vec::new(),
open_documents: Vec::new(),
encoding: PositionEncoding::UTF16,
};
let index_for_thread = index.clone();
let (started_sender, started_receiver) = channel::bounded(1);
let handle = std::thread::spawn(move || {
started_sender
.send(())
.expect("test thread should signal start");
index_for_thread
.closed_summaries(&context, &client)
.expect("closed summary wait should succeed")
});
started_receiver.recv().expect("test thread should start");
{
let mut cache = lock_or_recover(&index.cache);
cache.dirty = false;
cache.rebuilding = false;
}
index.rebuild_finished.notify_all();
let (summaries, partial) = handle.join().expect("test thread should finish");
assert!(!partial);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].symbols[0].name, "cached_symbol");
}
}