Skip to content

Commit 4937dc6

Browse files
Copilotcoder3101
andauthored
Add support for workspace symbols (#95)
## Overview This PR implements workspace symbols support for the protols LSP server, enabling clients to search for symbols across all files in the workspace. This complements the existing document symbols feature by providing a global symbol search capability. ## Changes ### Server Capabilities - Added `workspace_symbol_provider` to the LSP server capabilities advertised during initialization - Registered the `WorkspaceSymbolRequest` handler in the server router ### Implementation The implementation adds a `workspace_symbol` method in `src/lsp.rs` that processes workspace symbol requests with query-based filtering. The method now includes full workspace parsing before symbol collection: 1. **Full Workspace Parsing**: Before collecting symbols, the handler parses all `.proto` files from all workspace folders (similar to references and rename capabilities), ensuring complete coverage 2. **`find_workspace_symbols`** in `src/state.rs`: Collects symbols from all parsed trees in the workspace, filters them based on the query string (case-insensitive substring match), and returns sorted results 3. **`collect_workspace_symbols`** in `src/state.rs`: Recursively traverses document symbols to extract workspace symbols, maintaining parent-child relationships via the `container_name` field 4. **`get_workspaces`** in `src/config/workspace.rs`: New helper method to retrieve all workspace folder URIs ### Features - **Full workspace coverage**: Parses all proto files in all workspace folders, not just opened files and their imports - **Cross-file symbol search**: Search for messages, enums, and other symbols across all proto files in the workspace - **Query filtering**: Case-insensitive substring matching on symbol names - **Nested symbol support**: Correctly handles and reports nested messages with their container names - **Consistent ordering**: Results are sorted alphabetically by name and then by URI for predictable behavior - **Progress reporting**: Supports LSP work done progress tokens for long-running workspace parsing operations ### Example Usage When a client sends a `workspace/symbol` request with query `"author"`: ```json { "query": "author" } ``` The server parses all workspace files and returns all symbols matching "author" across the workspace: ```json [ { "name": "Author", "kind": 23, "location": { "uri": "file:///path/to/b.proto", "range": { "start": { "line": 5, "character": 0 }, ... } } } ] ``` ### Testing Added comprehensive test coverage in `src/workspace/workspace_symbol.rs`: - Test with empty query (returns all symbols) - Test with specific queries ("author", "address") - Test that non-matching queries return empty results - Uses insta snapshot testing for regression protection All 29 tests pass successfully (28 existing + 1 new). Fixes #21 <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Add support for workspace symbols</issue_title> > <issue_description>We already support document symbols, it's time to support workspace symbols as well.</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > <comment_new><author>@coder3101</author><body> > can be re-opened if requested.</body></comment_new> > </comments> > </details> Fixes #21 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: coder3101 <[email protected]> Co-authored-by: Ashar <[email protected]>
1 parent 0de61b7 commit 4937dc6

16 files changed

+573
-12
lines changed

Cargo.lock

Lines changed: 122 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ pkg-config = "0.3"
3232
clap = { version = "4.5", features = ["derive"] }
3333

3434
[dev-dependencies]
35-
insta = { version = "1.43", features = ["yaml"] }
35+
insta = { version = "1.43", features = ["yaml", "redactions"] }

src/config/workspace.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ impl WorkspaceProtoConfigs {
131131
Some(ipath)
132132
}
133133

134+
pub fn get_workspaces(&self) -> Vec<&Url> {
135+
self.workspaces.iter().collect()
136+
}
137+
134138
pub fn no_workspace_mode(&mut self) {
135139
let wr = ProtolsConfig::default();
136140
let rp = if cfg!(target_os = "windows") {

src/lsp.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use async_lsp::lsp_types::{
1414
RenameParams, ServerCapabilities, ServerInfo, TextDocumentPositionParams,
1515
TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, WorkspaceEdit,
1616
WorkspaceFileOperationsServerCapabilities, WorkspaceFoldersServerCapabilities,
17-
WorkspaceServerCapabilities,
17+
WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkspaceSymbolResponse,
1818
};
1919
use async_lsp::{LanguageClient, ResponseError};
2020
use futures::future::BoxFuture;
@@ -113,6 +113,7 @@ impl ProtoLanguageServer {
113113
definition_provider: Some(OneOf::Left(true)),
114114
hover_provider: Some(HoverProviderCapability::Simple(true)),
115115
document_symbol_provider: Some(OneOf::Left(true)),
116+
workspace_symbol_provider: Some(OneOf::Left(true)),
116117
completion_provider: Some(CompletionOptions::default()),
117118
rename_provider: Some(rename_provider),
118119
document_formatting_provider: Some(OneOf::Left(true)),
@@ -379,6 +380,35 @@ impl ProtoLanguageServer {
379380
Box::pin(async move { Ok(Some(response)) })
380381
}
381382

383+
pub(super) fn workspace_symbol(
384+
&mut self,
385+
params: WorkspaceSymbolParams,
386+
) -> BoxFuture<'static, Result<Option<WorkspaceSymbolResponse>, ResponseError>> {
387+
let query = params.query.to_lowercase();
388+
let work_done_token = params.work_done_progress_params.work_done_token;
389+
390+
// Parse all files from all workspaces
391+
let workspaces = self.configs.get_workspaces();
392+
let progress_sender = work_done_token.map(|token| self.with_report_progress(token));
393+
394+
for workspace in workspaces {
395+
if let Ok(workspace_path) = workspace.to_file_path() {
396+
self.state
397+
.parse_all_from_workspace(workspace_path, progress_sender.clone());
398+
}
399+
}
400+
401+
let symbols = self.state.find_workspace_symbols(&query);
402+
403+
Box::pin(async move {
404+
if symbols.is_empty() {
405+
Ok(None)
406+
} else {
407+
Ok(Some(WorkspaceSymbolResponse::Nested(symbols)))
408+
}
409+
})
410+
}
411+
382412
pub(super) fn formatting(
383413
&mut self,
384414
params: DocumentFormattingParams,

src/server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use async_lsp::{
99
request::{
1010
Completion, DocumentSymbolRequest, Formatting, GotoDefinition, HoverRequest,
1111
Initialize, PrepareRenameRequest, RangeFormatting, References, Rename,
12+
WorkspaceSymbolRequest,
1213
},
1314
},
1415
router::Router,
@@ -59,6 +60,7 @@ impl ProtoLanguageServer {
5960
router.request::<References, _>(|st, params| st.references(params));
6061
router.request::<GotoDefinition, _>(|st, params| st.definition(params));
6162
router.request::<DocumentSymbolRequest, _>(|st, params| st.document_symbol(params));
63+
router.request::<WorkspaceSymbolRequest, _>(|st, params| st.workspace_symbol(params));
6264
router.request::<Formatting, _>(|st, params| st.formatting(params));
6365
router.request::<RangeFormatting, _>(|st, params| st.range_formatting(params));
6466

src/state.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ use std::{
55
};
66
use tracing::info;
77

8-
use async_lsp::lsp_types::ProgressParamsValue;
9-
use async_lsp::lsp_types::{CompletionItem, CompletionItemKind, PublishDiagnosticsParams, Url};
8+
use async_lsp::lsp_types::{
9+
CompletionItem, CompletionItemKind, Location, OneOf, PublishDiagnosticsParams, Url,
10+
WorkspaceSymbol,
11+
};
12+
use async_lsp::lsp_types::{DocumentSymbol, ProgressParamsValue};
1013
use std::sync::mpsc::Sender;
1114
use tree_sitter::Node;
1215
use walkdir::WalkDir;
@@ -73,6 +76,78 @@ impl ProtoLanguageState {
7376
.collect()
7477
}
7578

79+
pub fn find_workspace_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
80+
let mut symbols = Vec::new();
81+
82+
for tree in self.get_trees() {
83+
let content = self.get_content(&tree.uri);
84+
let doc_symbols = tree.find_document_locations(content.as_bytes());
85+
86+
for doc_symbol in doc_symbols {
87+
Self::find_workspace_symbols_impl(
88+
&doc_symbol,
89+
&tree.uri,
90+
query,
91+
None,
92+
&mut symbols,
93+
);
94+
}
95+
}
96+
97+
// Sort symbols by name and then by URI for consistent ordering
98+
symbols.sort_by(|a, b| {
99+
let name_cmp = a.name.cmp(&b.name);
100+
if name_cmp != std::cmp::Ordering::Equal {
101+
return name_cmp;
102+
}
103+
// Extract URI from location
104+
match (&a.location, &b.location) {
105+
(OneOf::Left(loc_a), OneOf::Left(loc_b)) => {
106+
loc_a.uri.as_str().cmp(loc_b.uri.as_str())
107+
}
108+
_ => std::cmp::Ordering::Equal,
109+
}
110+
});
111+
112+
symbols
113+
}
114+
115+
fn find_workspace_symbols_impl(
116+
doc_symbol: &DocumentSymbol,
117+
uri: &Url,
118+
query: &str,
119+
container_name: Option<String>,
120+
symbols: &mut Vec<WorkspaceSymbol>,
121+
) {
122+
let symbol_name_lower = doc_symbol.name.to_lowercase();
123+
124+
if query.is_empty() || symbol_name_lower.contains(query) {
125+
symbols.push(WorkspaceSymbol {
126+
name: doc_symbol.name.clone(),
127+
kind: doc_symbol.kind,
128+
tags: doc_symbol.tags.clone(),
129+
container_name: container_name.clone(),
130+
location: OneOf::Left(Location {
131+
uri: uri.clone(),
132+
range: doc_symbol.range,
133+
}),
134+
data: None,
135+
});
136+
}
137+
138+
if let Some(children) = &doc_symbol.children {
139+
for child in children {
140+
Self::find_workspace_symbols_impl(
141+
child,
142+
uri,
143+
query,
144+
Some(doc_symbol.name.clone()),
145+
symbols,
146+
);
147+
}
148+
}
149+
}
150+
76151
fn upsert_content_impl(
77152
&mut self,
78153
uri: &Url,

src/workspace/definition.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,9 @@ mod test {
107107
Jumpable::Import("c.proto".to_owned()),
108108
);
109109

110-
assert_eq!(loc.len(), 1);
111-
assert!(
112-
loc[0]
113-
.uri
114-
.to_file_path()
115-
.unwrap()
116-
.ends_with(ipath[0].join("c.proto"))
117-
)
110+
assert_yaml_snapshot!(loc, {"[0].uri" => insta::dynamic_redaction(|c, _| {
111+
assert!(c.as_str().unwrap().ends_with("c.proto"));
112+
"file://<redacted>/c.proto".to_string()
113+
})});
118114
}
119115
}

src/workspace/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
mod definition;
22
mod hover;
33
mod rename;
4+
mod workspace_symbol;

0 commit comments

Comments
 (0)