[ty] Added support for "document symbols" and "workspace symbols" (#19521)

This PR adds support for "document symbols" and "workspace symbols"
language server features. Most of the logic to implement these features
is shared.

The "document symbols" feature returns a list of all symbols within a
specified source file. Clients can specify whether they want a flat or
hierarchical list. Document symbols are typically presented by a client
in an "outline" form. Here's what this looks like in VS Code, for
example.

<img width="240" height="249" alt="image"
src="https://github.com/user-attachments/assets/82b11f4f-32ec-4165-ba01-d6496ad13bdf"
/>


The "workspace symbols" feature returns a list of all symbols across the
entire workspace that match some user-supplied query string. This allows
the user to quickly find and navigate to any symbol within their code.

<img width="450" height="134" alt="image"
src="https://github.com/user-attachments/assets/aac131e0-9464-4adf-8a6c-829da028c759"
/>

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
This commit is contained in:
UnboundVariable 2025-07-25 13:07:38 -07:00 committed by GitHub
parent 165091a31c
commit a0d8ff51dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1153 additions and 0 deletions

View file

@ -0,0 +1,383 @@
use crate::symbols::{SymbolInfo, SymbolsOptions, symbols_for_file};
use ruff_db::files::File;
use ty_project::Db;
/// Get all document symbols for a file with the given options.
pub fn document_symbols_with_options(
db: &dyn Db,
file: File,
options: &SymbolsOptions,
) -> Vec<SymbolInfo> {
symbols_for_file(db, file, options)
}
/// Get all document symbols for a file (hierarchical by default).
pub fn document_symbols(db: &dyn Db, file: File) -> Vec<SymbolInfo> {
let options = SymbolsOptions {
hierarchical: true,
global_only: false,
query_string: None,
};
document_symbols_with_options(db, file, &options)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{CursorTest, IntoDiagnostic, cursor_test};
use insta::assert_snapshot;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
SubDiagnosticSeverity,
};
#[test]
fn test_document_symbols_simple() {
let test = cursor_test(
"
def hello():
pass
class World:
def method(self):
pass
<CURSOR>",
);
assert_snapshot!(test.document_symbols(), @r"
info[document-symbols]: SymbolInfo
--> main.py:2:5
|
2 | def hello():
| ^^^^^
3 | pass
|
info: Function hello
info[document-symbols]: SymbolInfo
--> main.py:5:7
|
3 | pass
4 |
5 | class World:
| ^^^^^
6 | def method(self):
7 | pass
|
info: Class World
info[document-symbols]: SymbolInfo
--> main.py:6:9
|
5 | class World:
6 | def method(self):
| ^^^^^^
7 | pass
|
info: Method method
");
}
#[test]
fn test_document_symbols_complex() {
let test = cursor_test(
"
import os
from typing import List
CONSTANT = 42
variable = 'hello'
typed_global: str = 'typed'
annotated_only: int
class MyClass:
class_var = 100
typed_class_var: str = 'class_typed'
annotated_class_var: float
def __init__(self):
self.instance_var = 0
def public_method(self):
return self.instance_var
def _private_method(self):
pass
def standalone_function():
local_var = 10
return local_var
<CURSOR>",
);
assert_snapshot!(test.document_symbols(), @r"
info[document-symbols]: SymbolInfo
--> main.py:5:1
|
3 | from typing import List
4 |
5 | CONSTANT = 42
| ^^^^^^^^
6 | variable = 'hello'
7 | typed_global: str = 'typed'
|
info: Constant CONSTANT
info[document-symbols]: SymbolInfo
--> main.py:6:1
|
5 | CONSTANT = 42
6 | variable = 'hello'
| ^^^^^^^^
7 | typed_global: str = 'typed'
8 | annotated_only: int
|
info: Variable variable
info[document-symbols]: SymbolInfo
--> main.py:7:1
|
5 | CONSTANT = 42
6 | variable = 'hello'
7 | typed_global: str = 'typed'
| ^^^^^^^^^^^^
8 | annotated_only: int
|
info: Variable typed_global
info[document-symbols]: SymbolInfo
--> main.py:8:1
|
6 | variable = 'hello'
7 | typed_global: str = 'typed'
8 | annotated_only: int
| ^^^^^^^^^^^^^^
9 |
10 | class MyClass:
|
info: Variable annotated_only
info[document-symbols]: SymbolInfo
--> main.py:10:7
|
8 | annotated_only: int
9 |
10 | class MyClass:
| ^^^^^^^
11 | class_var = 100
12 | typed_class_var: str = 'class_typed'
|
info: Class MyClass
info[document-symbols]: SymbolInfo
--> main.py:11:5
|
10 | class MyClass:
11 | class_var = 100
| ^^^^^^^^^
12 | typed_class_var: str = 'class_typed'
13 | annotated_class_var: float
|
info: Field class_var
info[document-symbols]: SymbolInfo
--> main.py:12:5
|
10 | class MyClass:
11 | class_var = 100
12 | typed_class_var: str = 'class_typed'
| ^^^^^^^^^^^^^^^
13 | annotated_class_var: float
|
info: Field typed_class_var
info[document-symbols]: SymbolInfo
--> main.py:13:5
|
11 | class_var = 100
12 | typed_class_var: str = 'class_typed'
13 | annotated_class_var: float
| ^^^^^^^^^^^^^^^^^^^
14 |
15 | def __init__(self):
|
info: Field annotated_class_var
info[document-symbols]: SymbolInfo
--> main.py:15:9
|
13 | annotated_class_var: float
14 |
15 | def __init__(self):
| ^^^^^^^^
16 | self.instance_var = 0
|
info: Constructor __init__
info[document-symbols]: SymbolInfo
--> main.py:18:9
|
16 | self.instance_var = 0
17 |
18 | def public_method(self):
| ^^^^^^^^^^^^^
19 | return self.instance_var
|
info: Method public_method
info[document-symbols]: SymbolInfo
--> main.py:21:9
|
19 | return self.instance_var
20 |
21 | def _private_method(self):
| ^^^^^^^^^^^^^^^
22 | pass
|
info: Method _private_method
info[document-symbols]: SymbolInfo
--> main.py:24:5
|
22 | pass
23 |
24 | def standalone_function():
| ^^^^^^^^^^^^^^^^^^^
25 | local_var = 10
26 | return local_var
|
info: Function standalone_function
");
}
#[test]
fn test_document_symbols_nested() {
let test = cursor_test(
"
class OuterClass:
OUTER_CONSTANT = 100
def outer_method(self):
return self.OUTER_CONSTANT
class InnerClass:
def inner_method(self):
pass
<CURSOR>",
);
assert_snapshot!(test.document_symbols(), @r"
info[document-symbols]: SymbolInfo
--> main.py:2:7
|
2 | class OuterClass:
| ^^^^^^^^^^
3 | OUTER_CONSTANT = 100
|
info: Class OuterClass
info[document-symbols]: SymbolInfo
--> main.py:3:5
|
2 | class OuterClass:
3 | OUTER_CONSTANT = 100
| ^^^^^^^^^^^^^^
4 |
5 | def outer_method(self):
|
info: Constant OUTER_CONSTANT
info[document-symbols]: SymbolInfo
--> main.py:5:9
|
3 | OUTER_CONSTANT = 100
4 |
5 | def outer_method(self):
| ^^^^^^^^^^^^
6 | return self.OUTER_CONSTANT
|
info: Method outer_method
info[document-symbols]: SymbolInfo
--> main.py:8:11
|
6 | return self.OUTER_CONSTANT
7 |
8 | class InnerClass:
| ^^^^^^^^^^
9 | def inner_method(self):
10 | pass
|
info: Class InnerClass
info[document-symbols]: SymbolInfo
--> main.py:9:13
|
8 | class InnerClass:
9 | def inner_method(self):
| ^^^^^^^^^^^^
10 | pass
|
info: Method inner_method
");
}
impl CursorTest {
fn document_symbols(&self) -> String {
let symbols = document_symbols(&self.db, self.cursor.file);
if symbols.is_empty() {
return "No symbols found".to_string();
}
self.render_diagnostics(
symbols
.into_iter()
.flat_map(|symbol| symbol_to_diagnostics(symbol, self.cursor.file)),
)
}
}
fn symbol_to_diagnostics(symbol: SymbolInfo, file: File) -> Vec<DocumentSymbolDiagnostic> {
// Output the symbol and recursively output all child symbols
let mut diagnostics = vec![DocumentSymbolDiagnostic::new(symbol.clone(), file)];
for child in symbol.children {
diagnostics.extend(symbol_to_diagnostics(child, file));
}
diagnostics
}
struct DocumentSymbolDiagnostic {
symbol: SymbolInfo,
file: File,
}
impl DocumentSymbolDiagnostic {
fn new(symbol: SymbolInfo, file: File) -> Self {
Self { symbol, file }
}
}
impl IntoDiagnostic for DocumentSymbolDiagnostic {
fn into_diagnostic(self) -> Diagnostic {
let symbol_kind_str = self.symbol.kind.to_string();
let info_text = format!("{} {}", symbol_kind_str, self.symbol.name);
let sub = SubDiagnostic::new(SubDiagnosticSeverity::Info, info_text);
let mut main = Diagnostic::new(
DiagnosticId::Lint(LintName::of("document-symbols")),
Severity::Info,
"SymbolInfo".to_string(),
);
main.annotate(Annotation::primary(
Span::from(self.file).with_range(self.symbol.name_range),
));
main.sub(sub);
main
}
}
}

View file

@ -1,6 +1,7 @@
mod completion;
mod doc_highlights;
mod docstring;
mod document_symbols;
mod find_node;
mod goto;
mod goto_declaration;
@ -14,10 +15,13 @@ mod references;
mod semantic_tokens;
mod signature_help;
mod stub_mapping;
mod symbols;
mod workspace_symbols;
pub use completion::completion;
pub use doc_highlights::document_highlights;
pub use docstring::get_parameter_documentation;
pub use document_symbols::{document_symbols, document_symbols_with_options};
pub use goto::{goto_declaration, goto_definition, goto_type_definition};
pub use goto_references::goto_references;
pub use hover::hover;
@ -28,6 +32,8 @@ pub use semantic_tokens::{
SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, semantic_tokens,
};
pub use signature_help::{ParameterDetails, SignatureDetails, SignatureHelpInfo, signature_help};
pub use symbols::{SymbolInfo, SymbolKind, SymbolsOptions};
pub use workspace_symbols::{WorkspaceSymbolInfo, workspace_symbols};
use ruff_db::files::{File, FileRange};
use ruff_text_size::{Ranged, TextRange};

View file

@ -0,0 +1,309 @@
//! Implements logic used by the document symbol provider, workspace symbol
//! provider, and auto-import feature of the completion provider.
use ruff_db::files::File;
use ruff_db::parsed::parsed_module;
use ruff_python_ast::visitor::source_order::{self, SourceOrderVisitor};
use ruff_python_ast::{Expr, Stmt};
use ruff_text_size::{Ranged, TextRange};
use ty_project::Db;
/// Options that control which symbols are returned
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolsOptions {
/// Return a hierarchy of symbols or a flattened list?
pub hierarchical: bool,
/// Include only symbols in the global scope
pub global_only: bool,
/// Query string for filtering symbol names
pub query_string: Option<String>,
}
/// Symbol information for IDE features like document outline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolInfo {
/// The name of the symbol
pub name: String,
/// The kind of symbol (function, class, variable, etc.)
pub kind: SymbolKind,
/// The range of the symbol name
pub name_range: TextRange,
/// The full range of the symbol (including body)
pub full_range: TextRange,
/// Child symbols (e.g., methods in a class)
pub children: Vec<SymbolInfo>,
}
/// The kind of symbol
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SymbolKind {
Module,
Class,
Method,
Function,
Variable,
Constant,
Property,
Field,
Constructor,
Parameter,
TypeParameter,
Import,
}
impl SymbolKind {
/// Returns the string representation of the symbol kind.
pub fn to_string(self) -> &'static str {
match self {
SymbolKind::Module => "Module",
SymbolKind::Class => "Class",
SymbolKind::Method => "Method",
SymbolKind::Function => "Function",
SymbolKind::Variable => "Variable",
SymbolKind::Constant => "Constant",
SymbolKind::Property => "Property",
SymbolKind::Field => "Field",
SymbolKind::Constructor => "Constructor",
SymbolKind::Parameter => "Parameter",
SymbolKind::TypeParameter => "TypeParameter",
SymbolKind::Import => "Import",
}
}
}
pub(crate) fn symbols_for_file(
db: &dyn Db,
file: File,
options: &SymbolsOptions,
) -> Vec<SymbolInfo> {
assert!(
!options.hierarchical || options.query_string.is_none(),
"Cannot use hierarchical mode with a query string"
);
let parsed = parsed_module(db, file);
let module = parsed.load(db);
let mut visitor = SymbolVisitor::new(options);
visitor.visit_body(&module.syntax().body);
visitor.symbols
}
struct SymbolVisitor<'a> {
symbols: Vec<SymbolInfo>,
symbol_stack: Vec<SymbolInfo>,
/// Track if we're currently inside a function (to exclude local variables)
in_function: bool,
/// Options controlling symbol collection
options: &'a SymbolsOptions,
}
impl<'a> SymbolVisitor<'a> {
fn new(options: &'a SymbolsOptions) -> Self {
Self {
symbols: Vec::new(),
symbol_stack: Vec::new(),
in_function: false,
options,
}
}
fn visit_body(&mut self, body: &[Stmt]) {
for stmt in body {
self.visit_stmt(stmt);
}
}
fn add_symbol(&mut self, symbol: SymbolInfo) {
// Filter by query string if provided
if let Some(ref query) = self.options.query_string {
if !Self::is_pattern_in_symbol(query, &symbol.name) {
return;
}
}
if self.options.hierarchical {
if let Some(parent) = self.symbol_stack.last_mut() {
parent.children.push(symbol);
} else {
self.symbols.push(symbol);
}
} else {
self.symbols.push(symbol);
}
}
fn push_symbol(&mut self, symbol: SymbolInfo) {
if self.options.hierarchical {
self.symbol_stack.push(symbol);
} else {
self.add_symbol(symbol);
}
}
fn pop_symbol(&mut self) {
if self.options.hierarchical {
if let Some(symbol) = self.symbol_stack.pop() {
self.add_symbol(symbol);
}
}
}
fn is_constant_name(name: &str) -> bool {
name.chars().all(|c| c.is_ascii_uppercase() || c == '_')
}
/// Returns true if symbol name contains all characters in the query
/// string in order. The comparison is case insensitive.
fn is_pattern_in_symbol(query_string: &str, symbol_name: &str) -> bool {
let typed_lower = query_string.to_lowercase();
let symbol_lower = symbol_name.to_lowercase();
let typed_chars: Vec<char> = typed_lower.chars().collect();
let symbol_chars: Vec<char> = symbol_lower.chars().collect();
let mut typed_pos = 0;
let mut symbol_pos = 0;
while typed_pos < typed_chars.len() && symbol_pos < symbol_chars.len() {
if typed_chars[typed_pos] == symbol_chars[symbol_pos] {
typed_pos += 1;
}
symbol_pos += 1;
}
typed_pos == typed_chars.len()
}
}
impl SourceOrderVisitor<'_> for SymbolVisitor<'_> {
fn visit_stmt(&mut self, stmt: &Stmt) {
match stmt {
Stmt::FunctionDef(func_def) => {
let kind = if self
.symbol_stack
.iter()
.any(|s| s.kind == SymbolKind::Class)
{
if func_def.name.as_str() == "__init__" {
SymbolKind::Constructor
} else {
SymbolKind::Method
}
} else {
SymbolKind::Function
};
let symbol = SymbolInfo {
name: func_def.name.to_string(),
kind,
name_range: func_def.name.range(),
full_range: stmt.range(),
children: Vec::new(),
};
if self.options.global_only {
self.add_symbol(symbol);
// If global_only, don't walk function bodies
return;
}
self.push_symbol(symbol);
// Mark that we're entering a function scope
let was_in_function = self.in_function;
self.in_function = true;
source_order::walk_stmt(self, stmt);
// Restore the previous function scope state
self.in_function = was_in_function;
self.pop_symbol();
}
Stmt::ClassDef(class_def) => {
let symbol = SymbolInfo {
name: class_def.name.to_string(),
kind: SymbolKind::Class,
name_range: class_def.name.range(),
full_range: stmt.range(),
children: Vec::new(),
};
if self.options.global_only {
self.add_symbol(symbol);
// If global_only, don't walk class bodies
return;
}
self.push_symbol(symbol);
source_order::walk_stmt(self, stmt);
self.pop_symbol();
}
Stmt::Assign(assign) => {
// Include assignments only when we're in global or class scope
if !self.in_function {
for target in &assign.targets {
if let Expr::Name(name) = target {
let kind = if Self::is_constant_name(name.id.as_str()) {
SymbolKind::Constant
} else if self
.symbol_stack
.iter()
.any(|s| s.kind == SymbolKind::Class)
{
SymbolKind::Field
} else {
SymbolKind::Variable
};
let symbol = SymbolInfo {
name: name.id.to_string(),
kind,
name_range: name.range(),
full_range: stmt.range(),
children: Vec::new(),
};
self.add_symbol(symbol);
}
}
}
}
Stmt::AnnAssign(ann_assign) => {
// Include assignments only when we're in global or class scope
if !self.in_function {
if let Expr::Name(name) = &*ann_assign.target {
let kind = if Self::is_constant_name(name.id.as_str()) {
SymbolKind::Constant
} else if self
.symbol_stack
.iter()
.any(|s| s.kind == SymbolKind::Class)
{
SymbolKind::Field
} else {
SymbolKind::Variable
};
let symbol = SymbolInfo {
name: name.id.to_string(),
kind,
name_range: name.range(),
full_range: stmt.range(),
children: Vec::new(),
};
self.add_symbol(symbol);
}
}
}
_ => {
source_order::walk_stmt(self, stmt);
}
}
}
}

View file

@ -0,0 +1,166 @@
use crate::symbols::{SymbolInfo, SymbolsOptions, symbols_for_file};
use ruff_db::files::File;
use ty_project::Db;
/// Get all workspace symbols matching the query string.
/// Returns symbols from all files in the workspace, filtered by the query.
pub fn workspace_symbols(db: &dyn Db, query: &str) -> Vec<WorkspaceSymbolInfo> {
// If the query is empty, return immediately to avoid expensive file scanning
if query.is_empty() {
return Vec::new();
}
let mut results = Vec::new();
let project = db.project();
let options = SymbolsOptions {
hierarchical: false, // Workspace symbols are always flat
global_only: false,
query_string: Some(query.to_string()),
};
// Get all files in the project
let files = project.files(db);
// For each file, extract symbols and add them to results
for file in files.iter() {
let file_symbols = symbols_for_file(db, *file, &options);
for symbol in file_symbols {
results.push(WorkspaceSymbolInfo {
symbol,
file: *file,
});
}
}
results
}
/// A symbol found in the workspace, including the file it was found in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceSymbolInfo {
/// The symbol information
pub symbol: SymbolInfo,
/// The file containing the symbol
pub file: File,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::CursorTest;
use crate::tests::IntoDiagnostic;
use insta::assert_snapshot;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
SubDiagnosticSeverity,
};
#[test]
fn test_workspace_symbols_multi_file() {
let test = CursorTest::builder()
.source(
"utils.py",
"
def utility_function():
'''A helpful utility function'''
pass
",
)
.source(
"models.py",
"
class DataModel:
'''A data model class'''
def __init__(self):
pass
",
)
.source(
"constants.py",
"
API_BASE_URL = 'https://api.example.com'
<CURSOR>",
)
.build();
assert_snapshot!(test.workspace_symbols("ufunc"), @r"
info[workspace-symbols]: WorkspaceSymbolInfo
--> utils.py:2:5
|
2 | def utility_function():
| ^^^^^^^^^^^^^^^^
3 | '''A helpful utility function'''
4 | pass
|
info: Function utility_function
");
assert_snapshot!(test.workspace_symbols("data"), @r"
info[workspace-symbols]: WorkspaceSymbolInfo
--> models.py:2:7
|
2 | class DataModel:
| ^^^^^^^^^
3 | '''A data model class'''
4 | def __init__(self):
|
info: Class DataModel
");
assert_snapshot!(test.workspace_symbols("apibase"), @r"
info[workspace-symbols]: WorkspaceSymbolInfo
--> constants.py:2:1
|
2 | API_BASE_URL = 'https://api.example.com'
| ^^^^^^^^^^^^
|
info: Constant API_BASE_URL
");
}
impl CursorTest {
fn workspace_symbols(&self, query: &str) -> String {
let symbols = workspace_symbols(&self.db, query);
if symbols.is_empty() {
return "No symbols found".to_string();
}
self.render_diagnostics(symbols.into_iter().map(WorkspaceSymbolDiagnostic::new))
}
}
struct WorkspaceSymbolDiagnostic {
symbol_info: WorkspaceSymbolInfo,
}
impl WorkspaceSymbolDiagnostic {
fn new(symbol_info: WorkspaceSymbolInfo) -> Self {
Self { symbol_info }
}
}
impl IntoDiagnostic for WorkspaceSymbolDiagnostic {
fn into_diagnostic(self) -> Diagnostic {
let symbol_kind_str = self.symbol_info.symbol.kind.to_string();
let info_text = format!("{} {}", symbol_kind_str, self.symbol_info.symbol.name);
let sub = SubDiagnostic::new(SubDiagnosticSeverity::Info, info_text);
let mut main = Diagnostic::new(
DiagnosticId::Lint(LintName::of("workspace-symbols")),
Severity::Info,
"WorkspaceSymbolInfo".to_string(),
);
main.annotate(Annotation::primary(
Span::from(self.symbol_info.file).with_range(self.symbol_info.symbol.name_range),
));
main.sub(sub);
main
}
}
}

View file

@ -241,6 +241,8 @@ impl Server {
trigger_characters: Some(vec!['.'.to_string()]),
..Default::default()
}),
document_symbol_provider: Some(lsp_types::OneOf::Left(true)),
workspace_symbol_provider: Some(lsp_types::OneOf::Left(true)),
..Default::default()
}
}

View file

@ -12,6 +12,7 @@ mod diagnostics;
mod notifications;
mod requests;
mod semantic_tokens;
mod symbols;
mod traits;
use self::traits::{NotificationHandler, RequestHandler};
@ -83,6 +84,14 @@ pub(super) fn request(req: server::Request) -> Task {
>(
req, BackgroundSchedule::LatencySensitive
),
requests::DocumentSymbolRequestHandler::METHOD => background_document_request_task::<
requests::DocumentSymbolRequestHandler,
>(req, BackgroundSchedule::Worker),
requests::WorkspaceSymbolRequestHandler::METHOD => background_request_task::<
requests::WorkspaceSymbolRequestHandler,
>(
req, BackgroundSchedule::Worker
),
lsp_types::request::Shutdown::METHOD => sync_request_task::<requests::ShutdownHandler>(req),
method => {

View file

@ -1,6 +1,7 @@
mod completion;
mod diagnostic;
mod doc_highlights;
mod document_symbols;
mod goto_declaration;
mod goto_definition;
mod goto_references;
@ -12,10 +13,12 @@ mod semantic_tokens_range;
mod shutdown;
mod signature_help;
mod workspace_diagnostic;
mod workspace_symbols;
pub(super) use completion::CompletionRequestHandler;
pub(super) use diagnostic::DocumentDiagnosticRequestHandler;
pub(super) use doc_highlights::DocumentHighlightRequestHandler;
pub(super) use document_symbols::DocumentSymbolRequestHandler;
pub(super) use goto_declaration::GotoDeclarationRequestHandler;
pub(super) use goto_definition::GotoDefinitionRequestHandler;
pub(super) use goto_references::ReferencesRequestHandler;
@ -27,3 +30,4 @@ pub(super) use semantic_tokens_range::SemanticTokensRangeRequestHandler;
pub(super) use shutdown::ShutdownHandler;
pub(super) use signature_help::SignatureHelpRequestHandler;
pub(super) use workspace_diagnostic::WorkspaceDiagnosticRequestHandler;
pub(super) use workspace_symbols::WorkspaceSymbolRequestHandler;

View file

@ -0,0 +1,125 @@
use std::borrow::Cow;
use lsp_types::request::DocumentSymbolRequest;
use lsp_types::{DocumentSymbol, DocumentSymbolParams, SymbolInformation, Url};
use ruff_db::source::{line_index, source_text};
use ruff_source_file::LineIndex;
use ty_ide::{SymbolInfo, SymbolsOptions, document_symbols_with_options};
use ty_project::ProjectDatabase;
use crate::document::{PositionEncoding, ToRangeExt};
use crate::server::api::symbols::{convert_symbol_kind, convert_to_lsp_symbol_information};
use crate::server::api::traits::{
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
};
use crate::session::DocumentSnapshot;
use crate::session::client::Client;
pub(crate) struct DocumentSymbolRequestHandler;
impl RequestHandler for DocumentSymbolRequestHandler {
type RequestType = DocumentSymbolRequest;
}
impl BackgroundDocumentRequestHandler for DocumentSymbolRequestHandler {
fn document_url(params: &DocumentSymbolParams) -> Cow<Url> {
Cow::Borrowed(&params.text_document.uri)
}
fn run_with_snapshot(
db: &ProjectDatabase,
snapshot: DocumentSnapshot,
_client: &Client,
params: DocumentSymbolParams,
) -> crate::server::Result<Option<lsp_types::DocumentSymbolResponse>> {
if snapshot.client_settings().is_language_services_disabled() {
return Ok(None);
}
let Some(file) = snapshot.file(db) else {
return Ok(None);
};
let source = source_text(db, file);
let line_index = line_index(db, file);
// Check if the client supports hierarchical document symbols
let supports_hierarchical = snapshot
.resolved_client_capabilities()
.supports_hierarchical_document_symbols();
let options = SymbolsOptions {
hierarchical: supports_hierarchical,
global_only: false,
query_string: None,
};
let symbols = document_symbols_with_options(db, file, &options);
if symbols.is_empty() {
return Ok(None);
}
if supports_hierarchical {
// Return hierarchical symbols
let lsp_symbols: Vec<DocumentSymbol> = symbols
.into_iter()
.map(|symbol| {
convert_to_lsp_document_symbol(
symbol,
&source,
&line_index,
snapshot.encoding(),
)
})
.collect();
Ok(Some(lsp_types::DocumentSymbolResponse::Nested(lsp_symbols)))
} else {
// Return flattened symbols as SymbolInformation
let lsp_symbols: Vec<SymbolInformation> = symbols
.into_iter()
.map(|symbol| {
convert_to_lsp_symbol_information(
symbol,
&params.text_document.uri,
&source,
&line_index,
snapshot.encoding(),
)
})
.collect();
Ok(Some(lsp_types::DocumentSymbolResponse::Flat(lsp_symbols)))
}
}
}
impl RetriableRequestHandler for DocumentSymbolRequestHandler {}
fn convert_to_lsp_document_symbol(
symbol: SymbolInfo,
source: &str,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> DocumentSymbol {
let symbol_kind = convert_symbol_kind(symbol.kind);
DocumentSymbol {
name: symbol.name,
detail: None,
kind: symbol_kind,
tags: None,
#[allow(deprecated)]
deprecated: None,
range: symbol.full_range.to_lsp_range(source, line_index, encoding),
selection_range: symbol.name_range.to_lsp_range(source, line_index, encoding),
children: Some(
symbol
.children
.into_iter()
.map(|child| convert_to_lsp_document_symbol(child, source, line_index, encoding))
.collect(),
),
}
}

View file

@ -0,0 +1,77 @@
use std::panic::AssertUnwindSafe;
use lsp_types::request::WorkspaceSymbolRequest;
use lsp_types::{WorkspaceSymbolParams, WorkspaceSymbolResponse};
use ty_ide::{WorkspaceSymbolInfo, workspace_symbols};
use crate::server::api::symbols::convert_to_lsp_symbol_information;
use crate::server::api::traits::{
BackgroundRequestHandler, RequestHandler, RetriableRequestHandler,
};
use crate::session::SessionSnapshot;
use crate::session::client::Client;
use crate::system::file_to_url;
use ruff_db::source::{line_index, source_text};
pub(crate) struct WorkspaceSymbolRequestHandler;
impl RequestHandler for WorkspaceSymbolRequestHandler {
type RequestType = WorkspaceSymbolRequest;
}
impl BackgroundRequestHandler for WorkspaceSymbolRequestHandler {
fn run(
snapshot: AssertUnwindSafe<SessionSnapshot>,
_client: &Client,
params: WorkspaceSymbolParams,
) -> crate::server::Result<Option<WorkspaceSymbolResponse>> {
// Check if language services are disabled
if snapshot
.index()
.global_settings()
.is_language_services_disabled()
{
return Ok(None);
}
let query = &params.query;
let mut all_symbols = Vec::new();
// Iterate through all projects in the session
for db in snapshot.projects() {
// Get workspace symbols matching the query
let workspace_symbol_infos = workspace_symbols(db, query);
// Convert to LSP SymbolInformation
for workspace_symbol_info in workspace_symbol_infos {
let WorkspaceSymbolInfo { symbol, file } = workspace_symbol_info;
// Get file information for URL conversion
let source = source_text(db, file);
let line_index = line_index(db, file);
// Convert file to URL
let Some(url) = file_to_url(db, file) else {
tracing::debug!("Failed to convert file to URL at {}", file.path(db));
continue;
};
// Get position encoding from session
let encoding = snapshot.position_encoding();
let lsp_symbol =
convert_to_lsp_symbol_information(symbol, &url, &source, &line_index, encoding);
all_symbols.push(lsp_symbol);
}
}
if all_symbols.is_empty() {
Ok(None)
} else {
Ok(Some(WorkspaceSymbolResponse::Flat(all_symbols)))
}
}
}
impl RetriableRequestHandler for WorkspaceSymbolRequestHandler {}

View file

@ -0,0 +1,50 @@
//! Utility functions common to language server request handlers
//! that return symbol information.
use lsp_types::{SymbolInformation, SymbolKind, Url};
use ruff_source_file::LineIndex;
use ty_ide::SymbolInfo;
use crate::document::{PositionEncoding, ToRangeExt};
/// Convert `ty_ide` `SymbolKind` to LSP `SymbolKind`
pub(crate) fn convert_symbol_kind(kind: ty_ide::SymbolKind) -> SymbolKind {
match kind {
ty_ide::SymbolKind::Module => SymbolKind::MODULE,
ty_ide::SymbolKind::Class => SymbolKind::CLASS,
ty_ide::SymbolKind::Method => SymbolKind::METHOD,
ty_ide::SymbolKind::Function => SymbolKind::FUNCTION,
ty_ide::SymbolKind::Variable => SymbolKind::VARIABLE,
ty_ide::SymbolKind::Constant => SymbolKind::CONSTANT,
ty_ide::SymbolKind::Property => SymbolKind::PROPERTY,
ty_ide::SymbolKind::Field => SymbolKind::FIELD,
ty_ide::SymbolKind::Constructor => SymbolKind::CONSTRUCTOR,
ty_ide::SymbolKind::Parameter => SymbolKind::VARIABLE,
ty_ide::SymbolKind::TypeParameter => SymbolKind::TYPE_PARAMETER,
ty_ide::SymbolKind::Import => SymbolKind::MODULE,
}
}
/// Convert a `ty_ide` `SymbolInfo` to LSP `SymbolInformation`
pub(crate) fn convert_to_lsp_symbol_information(
symbol: SymbolInfo,
uri: &Url,
source: &str,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> SymbolInformation {
let symbol_kind = convert_symbol_kind(symbol.kind);
SymbolInformation {
name: symbol.name,
kind: symbol_kind,
tags: None,
#[allow(deprecated)]
deprecated: None,
location: lsp_types::Location {
uri: uri.clone(),
range: symbol.full_range.to_lsp_range(source, line_index, encoding),
},
container_name: None,
}
}

View file

@ -16,6 +16,7 @@ bitflags::bitflags! {
const MULTILINE_SEMANTIC_TOKENS = 1 << 7;
const SIGNATURE_LABEL_OFFSET_SUPPORT = 1 << 8;
const SIGNATURE_ACTIVE_PARAMETER_SUPPORT = 1 << 9;
const HIERARCHICAL_DOCUMENT_SYMBOL_SUPPORT = 1 << 10;
}
}
@ -70,6 +71,11 @@ impl ResolvedClientCapabilities {
self.contains(Self::SIGNATURE_ACTIVE_PARAMETER_SUPPORT)
}
/// Returns `true` if the client supports hierarchical document symbols.
pub(crate) const fn supports_hierarchical_document_symbols(self) -> bool {
self.contains(Self::HIERARCHICAL_DOCUMENT_SYMBOL_SUPPORT)
}
pub(super) fn new(client_capabilities: &ClientCapabilities) -> Self {
let mut flags = Self::empty();
@ -173,6 +179,18 @@ impl ResolvedClientCapabilities {
flags |= Self::SIGNATURE_ACTIVE_PARAMETER_SUPPORT;
}
if text_document
.and_then(|text_document| {
text_document
.document_symbol
.as_ref()?
.hierarchical_document_symbol_support
})
.unwrap_or_default()
{
flags |= Self::HIERARCHICAL_DOCUMENT_SYMBOL_SUPPORT;
}
flags
}
}

View file

@ -28,6 +28,8 @@ expression: initialization_result
"typeDefinitionProvider": true,
"referencesProvider": true,
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"declarationProvider": true,
"semanticTokensProvider": {
"legend": {

View file

@ -28,6 +28,8 @@ expression: initialization_result
"typeDefinitionProvider": true,
"referencesProvider": true,
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"declarationProvider": true,
"semanticTokensProvider": {
"legend": {