mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-29 13:24:57 +00:00

## Summary
This PR implements support for providing LSP client settings.
The complementary PR in the ty VS Code extension:
astral-sh/ty-vscode#106.
Notes for the previous iteration of this PR is in
https://github.com/astral-sh/ruff/pull/19614#issuecomment-3136477864
(click on "Details").
Specifically, this PR splits the client settings into 3 distinct groups.
Keep in mind that these groups are not visible to the user, they're
merely an implementation detail. The groups are:
1. `GlobalOptions` - these are the options that are global to the
language server and will be the same for all the workspaces that are
handled by the server
2. `WorkspaceOptions` - these are the options that are specific to a
workspace and will be applied only when running any logic for that
workspace
3. `InitializationOptions` - these are the options that can be specified
during initialization
The initialization options are a superset that contains both the global
and workspace options flattened into a 1-dimensional structure. This
means that the user can specify any and all fields present in
`GlobalOptions` and `WorkspaceOptions` in the initialization options in
addition to the fields that are _specific_ to initialization options.
From the current set of available settings, following are only available
during initialization because they are required at that time, are static
during the runtime of the server and changing their values require a
restart to take effect:
- `logLevel`
- `logFile`
And, following are available under `GlobalOptions`:
- `diagnosticMode`
And, following under `WorkspaceOptions`:
- `disableLanguageServices`
- `pythonExtension` (Python environment information that is populated by
the ty VS Code extension)
### `workspace/configuration`
This request allows server to ask the client for configuration to a
specific workspace. But, this is only supported by the client that has
the `workspace.configuration` client capability set to `true`. What to
do for clients that don't support pulling configurations?
In that case, the settings needs to be provided in the initialization
options and updating the values of those settings can only be done by
restarting the server. With the way this is implemented, this means that
if the client does not support pulling workspace configuration then
there's no way to specify settings specific to a workspace. Earlier,
this would've been possible by providing an array of client options with
an additional field which specifies which workspace the options belong
to but that adds complexity and clients that actually do not support
`workspace/configuration` would usually not support multiple workspaces
either.
Now, for the clients that do support this, the server will initiate the
request to get the configuration for all the workspaces at the start of
the server. Once the server receives these options, it will resolve them
for each workspace as follows:
1. Combine the client options sent during initialization with the
options specific to the workspace creating the final client options
that's specific to this workspace
2. Create a global options by combining the global options from (1) for
all workspaces which in turn will also combine the global options sent
during initialization
The global options are resolved into the global settings and are
available on the `Session` which is initialized with the default global
settings. The workspace options are resolved into the workspace settings
and are available on the respective `Workspace`.
The `SessionSnapshot` contains the global settings while the document
snapshot contains the workspace settings. We could add the global
settings to the document snapshot but that's currently not needed.
### Document diagnostic dynamic registration
Currently, the document diagnostic server capability is created based on
the `diagnosticMode` sent during initialization. But, that wouldn't
provide us with the complete picture. This means the server needs to
defer registering the document diagnostic capability at a later point
once the settings have been resolved.
This is done using dynamic registration for clients that support it. For
clients that do not support dynamic registration for document diagnostic
capability, the server advertises itself as always supporting workspace
diagnostics and work done progress token.
This dynamic registration now allows us to change the server capability
for workspace diagnostics based on the resolved `diagnosticMode` value.
In the future, once `workspace/didChangeConfiguration` is supported, we
can avoid the server restart when users have changed any client
settings.
## Test Plan
Add integration tests and recorded videos on the user experience in
various editors:
### VS Code
For VS Code users, the settings experience is unchanged because the
extension defines it's own interface on how the user can specify the
server setting. This means everything is under the `ty.*` namespace as
usual.
https://github.com/user-attachments/assets/c2e5ba5c-7617-406e-a09d-e397ce9c3b93
### Zed
For Zed, the settings experience has changed. Users can specify settings
during initialization:
```json
{
"lsp": {
"ty": {
"initialization_options": {
"logLevel": "debug",
"logFile": "~/.cache/ty.log",
"diagnosticMode": "workspace",
"disableLanguageServices": true
}
},
}
}
```
Or, can specify the options under the `settings` key:
```json
{
"lsp": {
"ty": {
"settings": {
"ty": {
"diagnosticMode": "openFilesOnly",
"disableLanguageServices": true
}
},
"initialization_options": {
"logLevel": "debug",
"logFile": "~/.cache/ty.log"
}
},
}
}
```
The `logLevel` and `logFile` setting still needs to go under the
initialization options because they're required by the server during
initialization.
We can remove the nesting of the settings under the "ty" namespace by
updating the return type of
db9ea0cdfd/src/tychecker.rs (L45-L49)
to be wrapped inside `ty` directly so that users can avoid doing the
double nesting.
There's one issue here which is that if the `diagnosticMode` is
specified in both the initialization option and settings key, then the
resolution is a bit different - if either of them is set to be
`workspace`, then it wins which means that in the following
configuration, the diagnostic mode is `workspace`:
```json
{
"lsp": {
"ty": {
"settings": {
"ty": {
"diagnosticMode": "openFilesOnly"
}
},
"initialization_options": {
"diagnosticMode": "workspace"
}
},
}
}
```
This behavior is mainly a result of combining global options from
various workspace configuration results. Users should not be able to
provide global options in multiple workspaces but that restriction
cannot be done on the server side. The ty VS Code extension restricts
these global settings to only be set in the user settings and not in
workspace settings but we do not control extensions in other editors.
https://github.com/user-attachments/assets/8e2d6c09-18e6-49e5-ab78-6cf942fe1255
### Neovim
Same as in Zed.
### Other
Other editors that do not support `workspace/configuration`, the users
would need to provide the server settings during initialization.
987 lines
35 KiB
Rust
987 lines
35 KiB
Rust
use anyhow::Result;
|
|
use insta::{assert_compact_debug_snapshot, assert_debug_snapshot};
|
|
use lsp_server::RequestId;
|
|
use lsp_types::request::WorkspaceDiagnosticRequest;
|
|
use lsp_types::{
|
|
NumberOrString, PartialResultParams, PreviousResultId, Url, WorkDoneProgressParams,
|
|
WorkspaceDiagnosticParams, WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport,
|
|
};
|
|
use ruff_db::system::SystemPath;
|
|
use ty_server::{ClientOptions, DiagnosticMode, PartialWorkspaceProgress};
|
|
|
|
use crate::{TestServer, TestServerBuilder, TestServerError};
|
|
|
|
#[test]
|
|
fn on_did_open() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let foo = SystemPath::new("src/foo.py");
|
|
let foo_content = "\
|
|
def foo() -> str:
|
|
return 42
|
|
";
|
|
|
|
let mut server = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_file(foo, foo_content)?
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
server.open_text_document(foo, &foo_content, 1);
|
|
let diagnostics = server.document_diagnostic_request(foo, None)?;
|
|
|
|
assert_debug_snapshot!(diagnostics);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn document_diagnostic_caching_unchanged() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let foo = SystemPath::new("src/foo.py");
|
|
let foo_content = "\
|
|
def foo() -> str:
|
|
return 42
|
|
";
|
|
|
|
let mut server = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_file(foo, foo_content)?
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
server.open_text_document(foo, &foo_content, 1);
|
|
|
|
// First request with no previous result ID
|
|
let first_response = server.document_diagnostic_request(foo, None)?;
|
|
|
|
// Extract result ID from first response
|
|
let result_id = match &first_response {
|
|
lsp_types::DocumentDiagnosticReportResult::Report(
|
|
lsp_types::DocumentDiagnosticReport::Full(report),
|
|
) => report
|
|
.full_document_diagnostic_report
|
|
.result_id
|
|
.as_ref()
|
|
.expect("First response should have a result ID")
|
|
.clone(),
|
|
_ => panic!("First response should be a full report"),
|
|
};
|
|
|
|
// Second request with the previous result ID - should return Unchanged
|
|
let second_response = server.document_diagnostic_request(foo, Some(result_id))?;
|
|
|
|
// Verify it's an unchanged report
|
|
match second_response {
|
|
lsp_types::DocumentDiagnosticReportResult::Report(
|
|
lsp_types::DocumentDiagnosticReport::Unchanged(_),
|
|
) => {
|
|
// Success - got unchanged report as expected
|
|
}
|
|
_ => panic!("Expected an unchanged report when diagnostics haven't changed"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn document_diagnostic_caching_changed() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let foo = SystemPath::new("src/foo.py");
|
|
let foo_content_v1 = "\
|
|
def foo() -> str:
|
|
return 42
|
|
";
|
|
let foo_content_v2 = "\
|
|
def foo() -> str:
|
|
return \"fixed\"
|
|
";
|
|
|
|
let mut server = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_file(foo, foo_content_v1)?
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
server.open_text_document(foo, &foo_content_v1, 1);
|
|
|
|
// First request with no previous result ID
|
|
let first_response = server.document_diagnostic_request(foo, None)?;
|
|
|
|
// Extract result ID from first response
|
|
let result_id = match &first_response {
|
|
lsp_types::DocumentDiagnosticReportResult::Report(
|
|
lsp_types::DocumentDiagnosticReport::Full(report),
|
|
) => report
|
|
.full_document_diagnostic_report
|
|
.result_id
|
|
.as_ref()
|
|
.expect("First response should have a result ID")
|
|
.clone(),
|
|
_ => panic!("First response should be a full report"),
|
|
};
|
|
|
|
// Change the document to fix the error
|
|
server.change_text_document(
|
|
foo,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: foo_content_v2.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// Second request with the previous result ID - should return a new full report
|
|
let second_response = server.document_diagnostic_request(foo, Some(result_id))?;
|
|
|
|
// Verify it's a full report (not unchanged)
|
|
match second_response {
|
|
lsp_types::DocumentDiagnosticReportResult::Report(
|
|
lsp_types::DocumentDiagnosticReport::Full(report),
|
|
) => {
|
|
// Should have no diagnostics now
|
|
assert_eq!(report.full_document_diagnostic_report.items.len(), 0);
|
|
}
|
|
_ => panic!("Expected a full report when diagnostics have changed"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_diagnostic_caching() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
|
|
// File A: Will have an unchanged diagnostic
|
|
let file_a = SystemPath::new("src/unchanged.py");
|
|
let file_a_content = "\
|
|
def foo() -> str:
|
|
return 42 # This error will remain the same
|
|
";
|
|
|
|
// File B: Initially no error, will get a new error (added diagnostic)
|
|
let file_b = SystemPath::new("src/new_error.py");
|
|
let file_b_content_v1 = "\
|
|
def foo() -> int:
|
|
return 42 # No error initially
|
|
";
|
|
let file_b_content_v2 = "\
|
|
def foo() -> str:
|
|
return 42 # Error appears
|
|
";
|
|
|
|
// File C: Initially has error, will be fixed (removed diagnostic)
|
|
let file_c = SystemPath::new("src/fixed_error.py");
|
|
let file_c_content_v1 = "\
|
|
def foo() -> str:
|
|
return 42 # Error initially
|
|
";
|
|
let file_c_content_v2 = "\
|
|
def foo() -> str:
|
|
return \"fixed\" # Error removed
|
|
";
|
|
|
|
// File D: Has error that changes content (changed diagnostic)
|
|
let file_d = SystemPath::new("src/changed_error.py");
|
|
let file_d_content_v1 = "\
|
|
def foo() -> str:
|
|
return 42 # First error: expected str, got int
|
|
";
|
|
let file_d_content_v2 = "\
|
|
def foo() -> int:
|
|
return \"hello\" # Different error: expected int, got str
|
|
";
|
|
|
|
// File E: Modified but same diagnostic (e.g., new function added but original error remains)
|
|
let file_e = SystemPath::new("src/modified_same_error.py");
|
|
let file_e_content_v1 = "\
|
|
def foo() -> str:
|
|
return 42 # Error: expected str, got int
|
|
";
|
|
let file_e_content_v2 = "\
|
|
def bar() -> int:
|
|
return 100 # New function added at the top
|
|
|
|
def foo() -> str:
|
|
return 42 # Same error: expected str, got int
|
|
";
|
|
|
|
let mut server = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_initialization_options(
|
|
ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace),
|
|
)
|
|
.with_file(file_a, file_a_content)?
|
|
.with_file(file_b, file_b_content_v1)?
|
|
.with_file(file_c, file_c_content_v1)?
|
|
.with_file(file_d, file_d_content_v1)?
|
|
.with_file(file_e, file_e_content_v1)?
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
server.open_text_document(file_a, &file_a_content, 1);
|
|
|
|
// First request with no previous result IDs
|
|
let mut first_response = server.workspace_diagnostic_request(
|
|
Some(NumberOrString::String("progress-1".to_string())),
|
|
None,
|
|
)?;
|
|
sort_workspace_diagnostic_response(&mut first_response);
|
|
|
|
assert_debug_snapshot!("workspace_diagnostic_initial_state", first_response);
|
|
|
|
// Consume all progress notifications sent during workspace diagnostics
|
|
consume_all_progress_notifications(&mut server)?;
|
|
|
|
// Extract result IDs from the first response
|
|
let previous_result_ids = extract_result_ids_from_response(&first_response);
|
|
|
|
// Make changes to files B, C, D, and E (leave A unchanged)
|
|
// Need to open files before changing them
|
|
server.open_text_document(file_b, &file_b_content_v1, 1);
|
|
server.open_text_document(file_c, &file_c_content_v1, 1);
|
|
server.open_text_document(file_d, &file_d_content_v1, 1);
|
|
server.open_text_document(file_e, &file_e_content_v1, 1);
|
|
|
|
// File B: Add a new error
|
|
server.change_text_document(
|
|
file_b,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_b_content_v2.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// File C: Fix the error
|
|
server.change_text_document(
|
|
file_c,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_c_content_v2.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// File D: Change the error
|
|
server.change_text_document(
|
|
file_d,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_d_content_v2.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// File E: Modify the file but keep the same diagnostic
|
|
server.change_text_document(
|
|
file_e,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_e_content_v2.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// Second request with previous result IDs
|
|
// Expected results:
|
|
// - File A: Unchanged report (diagnostic hasn't changed)
|
|
// - File B: Full report (new diagnostic appeared)
|
|
// - File C: Full report with empty diagnostics (diagnostic was removed)
|
|
// - File D: Full report (diagnostic content changed)
|
|
// - File E: Full report (the range changes)
|
|
let mut second_response = server.workspace_diagnostic_request(
|
|
Some(NumberOrString::String("progress-2".to_string())),
|
|
Some(previous_result_ids),
|
|
)?;
|
|
sort_workspace_diagnostic_response(&mut second_response);
|
|
|
|
// Consume all progress notifications sent during the second workspace diagnostics
|
|
consume_all_progress_notifications(&mut server)?;
|
|
|
|
assert_debug_snapshot!("workspace_diagnostic_after_changes", second_response);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn workspace_diagnostic_caching_unchanged_with_colon_in_path() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("astral:test");
|
|
let foo = SystemPath::new("astral:test/test.py");
|
|
let foo_content = "\
|
|
def foo() -> str:
|
|
return 42
|
|
";
|
|
|
|
let mut server = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_file(foo, foo_content)?
|
|
.with_initialization_options(
|
|
ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace),
|
|
)
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
let first_response = server.workspace_diagnostic_request(None, None).unwrap();
|
|
|
|
// Extract result IDs from the first response
|
|
let mut previous_result_ids = extract_result_ids_from_response(&first_response);
|
|
|
|
for previous_id in &mut previous_result_ids {
|
|
// VS Code URL encodes paths, so that `:` is encoded as `%3A`.
|
|
previous_id
|
|
.uri
|
|
.set_path(&previous_id.uri.path().replace(':', "%3A"));
|
|
}
|
|
|
|
let workspace_request_id =
|
|
server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
|
|
identifier: None,
|
|
previous_result_ids,
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
});
|
|
|
|
// The URL mismatch shouldn't result in a full document report.
|
|
// The server needs to match the previous result IDs by the path, not the URL.
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &workspace_request_id);
|
|
|
|
let second_response = shutdown_and_await_workspace_diagnostic(server, &workspace_request_id)?;
|
|
|
|
assert_compact_debug_snapshot!(second_response, @"Report(WorkspaceDiagnosticReport { items: [] })");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Redact result_id values since they are hash-based and non-deterministic
|
|
fn filter_result_id() -> insta::internals::SettingsBindDropGuard {
|
|
let mut settings = insta::Settings::clone_current();
|
|
settings.add_filter(r#""[a-f0-9]{16}""#, r#""[RESULT_ID]""#);
|
|
settings.bind_to_scope()
|
|
}
|
|
|
|
fn consume_all_progress_notifications(server: &mut TestServer) -> Result<()> {
|
|
// Always consume Begin
|
|
let begin_params = server.await_notification::<lsp_types::notification::Progress>()?;
|
|
|
|
// The params are already the ProgressParams type
|
|
let lsp_types::ProgressParamsValue::WorkDone(lsp_types::WorkDoneProgress::Begin(_)) =
|
|
begin_params.value
|
|
else {
|
|
return Err(anyhow::anyhow!("Expected Begin progress notification"));
|
|
};
|
|
|
|
// Consume Report notifications - there may be multiple based on number of files
|
|
// Keep consuming until we hit the End notification
|
|
loop {
|
|
let params = server.await_notification::<lsp_types::notification::Progress>()?;
|
|
|
|
if let lsp_types::ProgressParamsValue::WorkDone(lsp_types::WorkDoneProgress::End(_)) =
|
|
params.value
|
|
{
|
|
// Found the End notification, we're done
|
|
break;
|
|
}
|
|
// Otherwise it's a Report notification, continue
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tests that the server sends partial results for workspace diagnostics
|
|
/// if a client sets the `partial_result_token` in the request.
|
|
///
|
|
/// Note: In production, the server throttles the partial results to one every 50ms. However,
|
|
/// this behavior makes testing very hard. That's why the server, in tests, sends a partial response
|
|
/// as soon as it batched at least 2 diagnostics together.
|
|
#[test]
|
|
fn workspace_diagnostic_streaming() -> Result<()> {
|
|
const NUM_FILES: usize = 5;
|
|
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
|
|
// Create 60 files with the same error to trigger streaming batching (server batches at 50 files)
|
|
let error_content = "\
|
|
def foo() -> str:
|
|
return 42 # Type error: expected str, got int
|
|
";
|
|
|
|
let mut builder = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_initialization_options(
|
|
ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace),
|
|
);
|
|
|
|
for i in 0..NUM_FILES {
|
|
let file_path_string = format!("src/file_{i:03}.py");
|
|
let file_path = SystemPath::new(&file_path_string);
|
|
builder = builder.with_file(file_path, error_content)?;
|
|
}
|
|
|
|
let mut server = builder
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
let partial_token = lsp_types::ProgressToken::String("streaming-diagnostics".to_string());
|
|
let request_id = server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
|
|
identifier: None,
|
|
previous_result_ids: Vec::new(),
|
|
work_done_progress_params: WorkDoneProgressParams {
|
|
work_done_token: None,
|
|
},
|
|
partial_result_params: PartialResultParams {
|
|
partial_result_token: Some(partial_token.clone()),
|
|
},
|
|
});
|
|
|
|
let mut received_results = 0usize;
|
|
|
|
// First, read the response of the workspace diagnostic request.
|
|
// Note: This response comes after the progress notifications but it simplifies the test to read it first.
|
|
let final_response = server.await_response::<WorkspaceDiagnosticRequest>(&request_id)?;
|
|
|
|
// Process the final report.
|
|
// This should always be a partial report. However, the type definition in the LSP specification
|
|
// is broken in the sense that both `Report` and `Partial` have the exact same shape
|
|
// and deserializing a previously serialized `Partial` result will yield a `Report` type.
|
|
let response_items = match final_response {
|
|
WorkspaceDiagnosticReportResult::Report(report) => report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => partial.items,
|
|
};
|
|
|
|
// The last batch should contain 1 item because the server sends a partial result with
|
|
// 2 items each.
|
|
assert_eq!(response_items.len(), 1);
|
|
received_results += response_items.len();
|
|
|
|
// Collect any partial results sent via progress notifications
|
|
while let Ok(params) = server.await_notification::<PartialWorkspaceProgress>() {
|
|
if params.token == partial_token {
|
|
let streamed_items = match params.value {
|
|
// Ideally we'd assert that only the first response is a full report
|
|
// However, the type definition in the LSP specification is broken
|
|
// in the sense that both `Report` and `Partial` have the exact same structure
|
|
// but it also doesn't use a tag to tell them apart...
|
|
// That means, a client can never tell if it's a full report or a partial report
|
|
WorkspaceDiagnosticReportResult::Report(report) => report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => partial.items,
|
|
};
|
|
|
|
// All streamed batches should contain 2 items (test behavior).
|
|
assert_eq!(streamed_items.len(), 2);
|
|
received_results += streamed_items.len();
|
|
|
|
if received_results == NUM_FILES {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert_eq!(received_results, NUM_FILES);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tests that the server's diagnostic streaming (partial results) work correctly
|
|
/// with result ids.
|
|
#[test]
|
|
fn workspace_diagnostic_streaming_with_caching() -> Result<()> {
|
|
const NUM_FILES: usize = 7;
|
|
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let error_content = "def foo() -> str:\n return 42 # Error";
|
|
let changed_content = "def foo() -> str:\n return true # Error";
|
|
|
|
let mut builder = TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_initialization_options(
|
|
ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace),
|
|
);
|
|
|
|
for i in 0..NUM_FILES {
|
|
let file_path_string = format!("src/error_{i}.py");
|
|
let file_path = SystemPath::new(&file_path_string);
|
|
builder = builder.with_file(file_path, error_content)?; // All files have errors initially
|
|
}
|
|
|
|
let mut server = builder
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()?;
|
|
|
|
server.open_text_document(SystemPath::new("src/error_0.py"), &error_content, 1);
|
|
server.open_text_document(SystemPath::new("src/error_1.py"), &error_content, 1);
|
|
server.open_text_document(SystemPath::new("src/error_2.py"), &error_content, 1);
|
|
|
|
// First request to get result IDs (non-streaming for simplicity)
|
|
let first_response = server.workspace_diagnostic_request(None, None)?;
|
|
|
|
let result_ids = extract_result_ids_from_response(&first_response);
|
|
|
|
assert_eq!(result_ids.len(), NUM_FILES);
|
|
|
|
// Fix three errors
|
|
server.change_text_document(
|
|
SystemPath::new("src/error_0.py"),
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: changed_content.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
server.change_text_document(
|
|
SystemPath::new("src/error_1.py"),
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: changed_content.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
server.change_text_document(
|
|
SystemPath::new("src/error_2.py"),
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: changed_content.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// Second request with caching - use streaming to test the caching behavior
|
|
let partial_token = lsp_types::ProgressToken::String("streaming-diagnostics".to_string());
|
|
let request2_id =
|
|
server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
|
|
identifier: None,
|
|
previous_result_ids: result_ids,
|
|
work_done_progress_params: WorkDoneProgressParams {
|
|
work_done_token: None,
|
|
},
|
|
partial_result_params: PartialResultParams {
|
|
partial_result_token: Some(partial_token.clone()),
|
|
},
|
|
});
|
|
|
|
let final_response2 = server.await_response::<WorkspaceDiagnosticRequest>(&request2_id)?;
|
|
|
|
let mut all_items = Vec::new();
|
|
|
|
// The final response should contain one fixed file and all unchanged files
|
|
let items = match final_response2 {
|
|
WorkspaceDiagnosticReportResult::Report(report) => report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => partial.items,
|
|
};
|
|
|
|
assert_eq!(items.len(), NUM_FILES - 3 + 1); // 3 fixed, 4 unchanged, 1 full report for fixed file
|
|
|
|
all_items.extend(items);
|
|
|
|
// Collect any partial results sent via progress notifications
|
|
while let Ok(params) = server.await_notification::<PartialWorkspaceProgress>() {
|
|
if params.token == partial_token {
|
|
let streamed_items = match params.value {
|
|
// Ideally we'd assert that only the first response is a full report
|
|
// However, the type definition in the LSP specification is broken
|
|
// in the sense that both `Report` and `Partial` have the exact same structure
|
|
// but it also doesn't use a tag to tell them apart...
|
|
// That means, a client can never tell if it's a full report or a partial report
|
|
WorkspaceDiagnosticReportResult::Report(report) => report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => partial.items,
|
|
};
|
|
|
|
// All streamed batches should contain 2 items.
|
|
assert_eq!(streamed_items.len(), 2);
|
|
all_items.extend(streamed_items);
|
|
|
|
if all_items.len() == NUM_FILES {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
sort_workspace_report_items(&mut all_items);
|
|
|
|
assert_debug_snapshot!(all_items);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn sort_workspace_diagnostic_response(response: &mut WorkspaceDiagnosticReportResult) {
|
|
let items = match response {
|
|
WorkspaceDiagnosticReportResult::Report(report) => &mut report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => &mut partial.items,
|
|
};
|
|
|
|
sort_workspace_report_items(items);
|
|
}
|
|
|
|
fn sort_workspace_report_items(items: &mut [WorkspaceDocumentDiagnosticReport]) {
|
|
fn item_uri(item: &WorkspaceDocumentDiagnosticReport) -> &Url {
|
|
match item {
|
|
WorkspaceDocumentDiagnosticReport::Full(full_report) => &full_report.uri,
|
|
WorkspaceDocumentDiagnosticReport::Unchanged(unchanged_report) => &unchanged_report.uri,
|
|
}
|
|
}
|
|
|
|
items.sort_unstable_by(|a, b| item_uri(a).cmp(item_uri(b)));
|
|
}
|
|
|
|
/// The LSP specification requires that the server sends a response for every request.
|
|
///
|
|
/// This test verifies that the server responds to a long-polling workspace diagnostic request
|
|
/// when the server is shut down.
|
|
#[test]
|
|
fn workspace_diagnostic_long_polling_responds_on_shutdown() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let file_path = SystemPath::new("src/test.py");
|
|
let file_content = "\
|
|
def hello() -> str:
|
|
return \"world\"
|
|
";
|
|
|
|
// Create a project with one file but no diagnostics
|
|
let mut server = create_workspace_server_with_file(workspace_root, file_path, file_content)?;
|
|
|
|
// Make a workspace diagnostic request to a project with one file but no diagnostics
|
|
// This should trigger long-polling since the project has no diagnostics
|
|
let request_id = send_workspace_diagnostic_request(&mut server);
|
|
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id);
|
|
|
|
// The workspace diagnostic request should now respond with an empty report
|
|
let workspace_response = shutdown_and_await_workspace_diagnostic(server, &request_id)?;
|
|
|
|
// Verify we got an empty report (default response during shutdown)
|
|
assert_debug_snapshot!(
|
|
"workspace_diagnostic_long_polling_shutdown_response",
|
|
workspace_response
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tests that the server responds to a long-polling workspace diagnostic request
|
|
/// after a change introduced a new diagnostic.
|
|
#[test]
|
|
fn workspace_diagnostic_long_polling_responds_on_change() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let file_path = SystemPath::new("src/test.py");
|
|
let file_content_no_error = "\
|
|
def hello() -> str:
|
|
return \"world\"
|
|
";
|
|
let file_content_with_error = "\
|
|
def hello() -> str:
|
|
return 42 # Type error: expected str, got int
|
|
";
|
|
|
|
// Create a project with one file but no diagnostics
|
|
let mut server =
|
|
create_workspace_server_with_file(workspace_root, file_path, file_content_no_error)?;
|
|
|
|
// Open the file first
|
|
server.open_text_document(file_path, &file_content_no_error, 1);
|
|
|
|
// Make a workspace diagnostic request to a project with one file but no diagnostics
|
|
// This should trigger long-polling since the project has no diagnostics
|
|
let request_id = send_workspace_diagnostic_request(&mut server);
|
|
|
|
// Verify the request doesn't complete immediately (should be long-polling)
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id);
|
|
|
|
// Now introduce an error to the file - this should trigger the long-polling request to complete
|
|
server.change_text_document(
|
|
file_path,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_content_with_error.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// The workspace diagnostic request should now complete with the new diagnostic
|
|
let workspace_response = server.await_response::<WorkspaceDiagnosticRequest>(&request_id)?;
|
|
|
|
// Verify we got a report with one file containing the new diagnostic
|
|
assert_debug_snapshot!(
|
|
"workspace_diagnostic_long_polling_change_response",
|
|
workspace_response
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The LSP specification requires that the server responds to each request with exactly one response.
|
|
///
|
|
/// This test verifies that the server sends one response (and not two) if a long polling workspace diagnostic request
|
|
/// is cancelled.
|
|
#[test]
|
|
fn workspace_diagnostic_long_polling_responds_on_cancellation() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let file_path = SystemPath::new("src/test.py");
|
|
let file_content = "\
|
|
def hello() -> str:
|
|
return \"world\"
|
|
";
|
|
|
|
// Create a project with one file but no diagnostics
|
|
let mut server = create_workspace_server_with_file(workspace_root, file_path, file_content)?;
|
|
|
|
// Make a workspace diagnostic request to a project with one file but no diagnostics
|
|
// This should trigger long-polling since the project has no diagnostics
|
|
let request_id = send_workspace_diagnostic_request(&mut server);
|
|
|
|
// Verify the request doesn't complete immediately (should be long-polling)
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id);
|
|
|
|
// Send a cancel request notification for the suspended request
|
|
// The request_id from send_request should match the ID that the server expects
|
|
// Based on logs, the server shows request id=2, so let's try using that directly
|
|
server.cancel(&request_id);
|
|
|
|
// The workspace diagnostic request should now respond with a cancellation response (Err).
|
|
let result = server.await_response::<WorkspaceDiagnosticRequest>(&request_id);
|
|
assert_debug_snapshot!(
|
|
"workspace_diagnostic_long_polling_cancellation_result",
|
|
result
|
|
);
|
|
|
|
// The test server's drop implementation asserts that we aren't sending the response twice.
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// This test verifies an entire workspace diagnostic cycle with long-polling:
|
|
/// * Initial suspend with no diagnostics
|
|
/// * Change the file to introduce a diagnostic, server should respond with the new diagnostics
|
|
/// * Send a second workspace diagnostic request, which should suspend again because the diagnostics haven't changed
|
|
/// * Change the file again to fix the diagnostic, server should respond with no diagnostics
|
|
#[test]
|
|
fn workspace_diagnostic_long_polling_suspend_change_suspend_cycle() -> Result<()> {
|
|
let _filter = filter_result_id();
|
|
|
|
let workspace_root = SystemPath::new("src");
|
|
let file_path = SystemPath::new("src/test.py");
|
|
let file_content_no_error = "\
|
|
def hello() -> str:
|
|
return \"world\"
|
|
";
|
|
let file_content_with_error = "\
|
|
def hello() -> str:
|
|
return 42 # Type error: expected str, got int
|
|
";
|
|
let file_content_fixed = "\
|
|
def hello() -> str:
|
|
return \"fixed\"
|
|
";
|
|
|
|
// Create a project with one file but no diagnostics
|
|
let mut server =
|
|
create_workspace_server_with_file(workspace_root, file_path, file_content_no_error)?;
|
|
|
|
// Open the file first
|
|
server.open_text_document(file_path, &file_content_no_error, 1);
|
|
|
|
// PHASE 1: Initial suspend (no diagnostics)
|
|
let request_id_1 = send_workspace_diagnostic_request(&mut server);
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id_1);
|
|
|
|
// PHASE 2: Introduce error to trigger response
|
|
server.change_text_document(
|
|
file_path,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_content_with_error.to_string(),
|
|
}],
|
|
2,
|
|
);
|
|
|
|
// First request should complete with diagnostics
|
|
let first_response = server.await_response::<WorkspaceDiagnosticRequest>(&request_id_1)?;
|
|
|
|
// Extract result IDs from the first response for the second request
|
|
let previous_result_ids = extract_result_ids_from_response(&first_response);
|
|
|
|
// PHASE 3: Second request with previous result IDs - should suspend again since diagnostics unchanged
|
|
let request_id_2 =
|
|
server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
|
|
identifier: None,
|
|
previous_result_ids,
|
|
work_done_progress_params: WorkDoneProgressParams {
|
|
work_done_token: None,
|
|
},
|
|
partial_result_params: PartialResultParams {
|
|
partial_result_token: None,
|
|
},
|
|
});
|
|
|
|
// Second request should suspend since diagnostics haven't changed
|
|
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id_2);
|
|
|
|
// PHASE 4: Fix the error to trigger the second response
|
|
server.change_text_document(
|
|
file_path,
|
|
vec![lsp_types::TextDocumentContentChangeEvent {
|
|
range: None,
|
|
range_length: None,
|
|
text: file_content_fixed.to_string(),
|
|
}],
|
|
3,
|
|
);
|
|
|
|
// Second request should complete with the fix (no diagnostics)
|
|
let second_response = server.await_response::<WorkspaceDiagnosticRequest>(&request_id_2)?;
|
|
|
|
// Snapshot both responses to verify the full cycle
|
|
assert_debug_snapshot!(
|
|
"workspace_diagnostic_suspend_change_suspend_first_response",
|
|
first_response
|
|
);
|
|
assert_debug_snapshot!(
|
|
"workspace_diagnostic_suspend_change_suspend_second_response",
|
|
second_response
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Helper functions for long-polling tests
|
|
fn create_workspace_server_with_file(
|
|
workspace_root: &SystemPath,
|
|
file_path: &SystemPath,
|
|
file_content: &str,
|
|
) -> Result<TestServer> {
|
|
TestServerBuilder::new()?
|
|
.with_workspace(workspace_root, None)?
|
|
.with_file(file_path, file_content)?
|
|
.with_initialization_options(
|
|
ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace),
|
|
)
|
|
.enable_pull_diagnostics(true)
|
|
.build()?
|
|
.wait_until_workspaces_are_initialized()
|
|
}
|
|
|
|
/// Sends a workspace diagnostic request to the server.
|
|
///
|
|
/// Unlike [`TestServer::workspace_diagnostic_request`], this function does not wait for the response.
|
|
fn send_workspace_diagnostic_request(server: &mut TestServer) -> lsp_server::RequestId {
|
|
server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
|
|
identifier: None,
|
|
previous_result_ids: Vec::new(),
|
|
work_done_progress_params: WorkDoneProgressParams {
|
|
work_done_token: None,
|
|
},
|
|
partial_result_params: PartialResultParams {
|
|
partial_result_token: None,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn shutdown_and_await_workspace_diagnostic(
|
|
mut server: TestServer,
|
|
request_id: &RequestId,
|
|
) -> Result<WorkspaceDiagnosticReportResult> {
|
|
// Send shutdown request - this should cause the suspended workspace diagnostic request to respond
|
|
let shutdown_id = server.send_request::<lsp_types::request::Shutdown>(());
|
|
|
|
// The workspace diagnostic request should now respond with an empty report
|
|
let workspace_response = server.await_response::<WorkspaceDiagnosticRequest>(request_id);
|
|
|
|
// Complete shutdown sequence
|
|
server.await_response::<lsp_types::request::Shutdown>(&shutdown_id)?;
|
|
server.send_notification::<lsp_types::notification::Exit>(());
|
|
|
|
workspace_response
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_workspace_diagnostics_suspends_for_long_polling(
|
|
server: &mut TestServer,
|
|
request_id: &lsp_server::RequestId,
|
|
) {
|
|
match server.await_response::<WorkspaceDiagnosticRequest>(request_id) {
|
|
Ok(_) => {
|
|
panic!("Expected workspace diagnostic request to suspend for long-polling.");
|
|
}
|
|
Err(error) => {
|
|
if let Some(test_error) = error.downcast_ref::<TestServerError>() {
|
|
assert!(
|
|
matches!(test_error, TestServerError::RecvTimeoutError(_)),
|
|
"Response should time out because the request is suspended for long-polling"
|
|
);
|
|
} else {
|
|
panic!("Unexpected error type: {error:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_result_ids_from_response(
|
|
response: &WorkspaceDiagnosticReportResult,
|
|
) -> Vec<PreviousResultId> {
|
|
let items = match response {
|
|
WorkspaceDiagnosticReportResult::Report(report) => &report.items,
|
|
WorkspaceDiagnosticReportResult::Partial(partial) => {
|
|
// For partial results, extract from items the same way
|
|
&partial.items
|
|
}
|
|
};
|
|
|
|
items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
WorkspaceDocumentDiagnosticReport::Full(full_report) => {
|
|
let result_id = full_report
|
|
.full_document_diagnostic_report
|
|
.result_id
|
|
.as_ref()?;
|
|
|
|
Some(PreviousResultId {
|
|
uri: full_report.uri.clone(),
|
|
value: result_id.clone(),
|
|
})
|
|
}
|
|
WorkspaceDocumentDiagnosticReport::Unchanged(_) => {
|
|
// Unchanged reports don't provide new result IDs
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|