Skip to content

Commit

Permalink
Implement client setting initialization and resolution for `ruff serv…
Browse files Browse the repository at this point in the history
…er` (#10764)

## Summary

When a language server initializes, it is passed a serialized JSON
object, which is known as its "initialization options". Until now, `ruff
server` has ignored those initialization options, meaning that
user-provided settings haven't worked. This PR is the first step for
supporting settings from the LSP client. It implements procedures to
deserialize initialization options into a settings object, and then
resolve those settings objects into concrete settings for each
workspace.

One of the goals for user settings implementation in `ruff server` is
backwards compatibility with `ruff-lsp`'s settings. We won't support all
settings that `ruff-lsp` had, but the ones that we do support should
work the same and use the same schema as `ruff-lsp`.

These are the existing settings from `ruff-lsp` that we will continue to
support, and which are part of the settings schema in this PR:

| Setting | Default Value | Description |

|----------------------------------------|---------------|----------------------------------------------------------------------------------------|
| `codeAction.disableRuleComment.enable` | `true` | Whether to display
Quick Fix actions to disable rules via `noqa` suppression comments. |
| `codeAction.fixViolation.enable` | `true` | Whether to display Quick
Fix actions to autofix violations. |
| `fixAll` | `true` | Whether to register Ruff as capable of handling
`source.fixAll` actions. |
| `lint.enable` | `true` | Whether to enable linting. Set to `false` to
use Ruff exclusively as a formatter. |
| `organizeImports` | `true` | Whether to register Ruff as capable of
handling `source.organizeImports` actions. |

To be clear: this PR does not implement 'support' for these settings,
individually. Rather, it constructs a framework for these settings to be
used by the server in the future.

Notably, we are choosing *not* to support `lint.args` and `format.args`
as settings for `ruff server`. This is because we're now interfacing
with Ruff at a lower level than its CLI, and converting CLI arguments
back into configuration is too involved.

We will have support for linter and formatter specific settings in
follow-up PRs. We will also 'hook up' user settings to work with the
server in follow up PRs.

## Test Plan

### Snapshot Tests

Tests have been created in
`crates/ruff_server/src/session/settings/tests.rs` to ensure that
deserialization and settings resolution works as expected.

### Manual Testing

Since we aren't using the resolved settings anywhere yet, we'll have to
add a few printing statements.

We want to capture what the resolved settings look like when sent as
part of a snapshot, so modify `Session::take_snapshot` to be the
following:

```rust
    pub(crate) fn take_snapshot(&self, url: &Url) -> Option<DocumentSnapshot> {
        let resolved_settings = self.workspaces.client_settings(url, &self.global_settings);
        tracing::info!("Resolved settings for document {url}: {resolved_settings:?}");
        Some(DocumentSnapshot {
            configuration: self.workspaces.configuration(url)?.clone(),
            resolved_client_capabilities: self.resolved_client_capabilities.clone(),
            client_settings: resolved_settings,
            document_ref: self.workspaces.snapshot(url)?,
            position_encoding: self.position_encoding,
            url: url.clone(),
        })
    }
```

Once you've done that, build the server and start up your extension
testing environment.

1. Set up a workspace in VS Code with two workspace folders, each one
having some variant of Ruff file-based configuration (`pyproject.toml`,
`ruff.toml`, etc.). We'll call these folders `folder_a` and `folder_b`.
2. In each folder, open up `.vscode/settings.json`.
3. In folder A, use these settings:
```json
{
    "ruff.codeAction.disableRuleComment": {
        "enable": true
    }
}
```
4. In folder B, use these settings:
```json
{
    
    "ruff.codeAction.disableRuleComment": {
        "enable": false
    }
}
```
5. Finally, open up your VS Code User Settings and un-check the `Ruff >
Code Action: Disable Rule Comment` setting.
6. When opening files in `folder_a`, you should see logs that look like
this:
```
Resolved settings for document <file>: ResolvedClientSettings { fix_all: true, organize_imports: true, lint_enable: true, disable_rule_comment_enable: true, fix_violation_enable: true }
```
7. When opening files in `folder_b`, you should see logs that look like
this:
```
Resolved settings for document <file>: ResolvedClientSettings { fix_all: true, organize_imports: true, lint_enable: true, disable_rule_comment_enable: false, fix_violation_enable: true }
```
8. To test invalid configuration, change `.vscode/settings.json` in
either folder to be this:
```json
{
    "ruff.codeAction.disableRuleComment": {
        "enable": "invalid"
    },
}
```
10. You should now see these error logs:
```
<time> [info]    <duration> ERROR ruff_server::session::settings Failed to deserialize initialization options: data did not match any variant of untagged enum InitializationOptions. Falling back to default client settings...

<time> [info]    <duration> WARN ruff_server::server No workspace settings found for file:///Users/jane/testbed/pandas
   <duration> WARN ruff_server::server No workspace settings found for file:///Users/jane/foss/scipy
```
11. Opening files in either folder should now print the following
configuration:
```
Resolved settings for document <file>: ResolvedClientSettings { fix_all: true, organize_imports: true, lint_enable: true, disable_rule_comment_enable: true, fix_violation_enable: true }
```
  • Loading branch information
snowsignal authored Apr 5, 2024
1 parent a4ee9c1 commit a184dc6
Show file tree
Hide file tree
Showing 7 changed files with 680 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"settings": {
"codeAction": {
"disableRuleComment": {
"enable": false
}
},
"lint": {
"run": "onSave"
},
"fixAll": false,
"logLevel": "warn"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{
"settings": [
{
"experimentalServer": true,
"cwd": "/Users/test/projects/pandas",
"workspace": "file:///Users/test/projects/pandas",
"path": [],
"ignoreStandardLibrary": true,
"interpreter": [
"/Users/test/projects/pandas/.venv/bin/python"
],
"importStrategy": "fromEnvironment",
"codeAction": {
"fixViolation": {
"enable": false
},
"disableRuleComment": {
"enable": false
}
},
"lint": {
"enable": true,
"run": "onType",
"args": [
"--preview"
]
},
"format": {
"args": []
},
"enable": true,
"organizeImports": true,
"fixAll": true,
"showNotifications": "off"
},
{
"experimentalServer": true,
"cwd": "/Users/test/projects/scipy",
"workspace": "file:///Users/test/projects/scipy",
"path": [],
"ignoreStandardLibrary": true,
"interpreter": [
"/Users/test/projects/scipy/.venv/bin/python"
],
"importStrategy": "fromEnvironment",
"codeAction": {
"fixViolation": {
"enable": false
},
"disableRuleComment": {
"enable": true
}
},
"lint": {
"enable": true,
"run": "onType",
"args": [
"--preview"
]
},
"format": {
"args": []
},
"enable": true,
"organizeImports": true,
"fixAll": true,
"showNotifications": "off"
}
],
"globalSettings": {
"experimentalServer": true,
"cwd": "/",
"workspace": "/",
"path": [],
"ignoreStandardLibrary": true,
"interpreter": [],
"importStrategy": "fromEnvironment",
"codeAction": {
"fixViolation": {
"enable": false
},
"disableRuleComment": {
"enable": false
}
},
"lint": {
"enable": true,
"run": "onType",
"args": [
"--preview"
]
},
"format": {
"args": []
},
"enable": true,
"organizeImports": true,
"fixAll": false,
"showNotifications": "off"
}
}
44 changes: 37 additions & 7 deletions crates/ruff_server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use types::WorkspaceFoldersServerCapabilities;
use self::schedule::event_loop_thread;
use self::schedule::Scheduler;
use self::schedule::Task;
use crate::session::AllSettings;
use crate::session::ClientSettings;
use crate::session::Session;
use crate::PositionEncoding;

Expand All @@ -47,14 +49,34 @@ impl Server {
let init_params: types::InitializeParams = serde_json::from_value(params)?;

let client_capabilities = init_params.capabilities;
let server_capabilities = Self::server_capabilities(&client_capabilities);
let position_encoding = Self::find_best_position_encoding(&client_capabilities);
let server_capabilities = Self::server_capabilities(position_encoding);

let AllSettings {
global_settings,
mut workspace_settings,
} = AllSettings::from_value(init_params.initialization_options.unwrap_or_default());

let mut workspace_for_uri = |uri| {
let Some(workspace_settings) = workspace_settings.as_mut() else {
return (uri, ClientSettings::default());
};
let settings = workspace_settings.remove(&uri).unwrap_or_else(|| {
tracing::warn!("No workspace settings found for {uri}");
ClientSettings::default()
});
(uri, settings)
};

let workspaces = init_params
.workspace_folders
.map(|folders| folders.into_iter().map(|folder| folder.uri).collect())
.map(|folders| folders.into_iter().map(|folder| {
workspace_for_uri(folder.uri)
}).collect())
.or_else(|| {
tracing::debug!("No workspace(s) were provided during initialization. Using the current working directory as a default workspace...");
Some(vec![types::Url::from_file_path(std::env::current_dir().ok()?).ok()?])
let uri = types::Url::from_file_path(std::env::current_dir().ok()?).ok()?;
Some(vec![workspace_for_uri(uri)])
})
.ok_or_else(|| {
anyhow::anyhow!("Failed to get the current working directory while creating a default workspace.")
Expand All @@ -74,7 +96,12 @@ impl Server {
conn,
threads,
worker_threads,
session: Session::new(&client_capabilities, &server_capabilities, &workspaces)?,
session: Session::new(
&client_capabilities,
position_encoding,
global_settings,
workspaces,
)?,
client_capabilities,
})
}
Expand Down Expand Up @@ -176,8 +203,8 @@ impl Server {
}
}

fn server_capabilities(client_capabilities: &ClientCapabilities) -> types::ServerCapabilities {
let position_encoding = client_capabilities
fn find_best_position_encoding(client_capabilities: &ClientCapabilities) -> PositionEncoding {
client_capabilities
.general
.as_ref()
.and_then(|general_capabilities| general_capabilities.position_encodings.as_ref())
Expand All @@ -187,7 +214,10 @@ impl Server {
.filter_map(|encoding| PositionEncoding::try_from(encoding).ok())
.max() // this selects the highest priority position encoding
})
.unwrap_or_default();
.unwrap_or_default()
}

fn server_capabilities(position_encoding: PositionEncoding) -> types::ServerCapabilities {
types::ServerCapabilities {
position_encoding: Some(position_encoding.into()),
code_action_provider: Some(types::CodeActionProviderCapability::Options(
Expand Down
58 changes: 44 additions & 14 deletions crates/ruff_server/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
//! Data model, state management, and configuration resolution.

mod capabilities;
mod settings;

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::{ops::Deref, sync::Arc};

use anyhow::anyhow;
use lsp_types::{ClientCapabilities, ServerCapabilities, Url};
use lsp_types::{ClientCapabilities, Url};
use ruff_workspace::resolver::{ConfigurationTransformer, Relativity};
use rustc_hash::FxHashMap;

use crate::edit::{Document, DocumentVersion};
use crate::PositionEncoding;

use self::settings::ResolvedClientCapabilities;
use self::capabilities::ResolvedClientCapabilities;
use self::settings::ResolvedClientSettings;
pub(crate) use self::settings::{AllSettings, ClientSettings};

/// The global state for the LSP
pub(crate) struct Session {
/// Workspace folders in the current session, which contain the state of all open files.
workspaces: Workspaces,
/// The global position encoding, negotiated during LSP initialization.
position_encoding: PositionEncoding,
/// Global settings provided by the client.
global_settings: ClientSettings,
/// Tracks what LSP features the client supports and doesn't support.
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
}
Expand All @@ -31,6 +36,8 @@ pub(crate) struct Session {
pub(crate) struct DocumentSnapshot {
configuration: Arc<RuffConfiguration>,
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
#[allow(dead_code)]
client_settings: settings::ResolvedClientSettings,
document_ref: DocumentRef,
position_encoding: PositionEncoding,
url: Url,
Expand All @@ -50,6 +57,7 @@ pub(crate) struct Workspaces(BTreeMap<PathBuf, Workspace>);
pub(crate) struct Workspace {
open_documents: OpenDocuments,
configuration: Arc<RuffConfiguration>,
settings: ClientSettings,
}

#[derive(Default)]
Expand All @@ -73,15 +81,13 @@ pub(crate) struct DocumentRef {
impl Session {
pub(crate) fn new(
client_capabilities: &ClientCapabilities,
server_capabilities: &ServerCapabilities,
workspaces: &[Url],
position_encoding: PositionEncoding,
global_settings: ClientSettings,
workspaces: Vec<(Url, ClientSettings)>,
) -> crate::Result<Self> {
Ok(Self {
position_encoding: server_capabilities
.position_encoding
.as_ref()
.and_then(|encoding| encoding.try_into().ok())
.unwrap_or_default(),
position_encoding,
global_settings,
resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new(
client_capabilities,
)),
Expand All @@ -90,9 +96,12 @@ impl Session {
}

pub(crate) fn take_snapshot(&self, url: &Url) -> Option<DocumentSnapshot> {
let resolved_settings = self.workspaces.client_settings(url, &self.global_settings);
tracing::info!("Resolved settings for document {url}: {resolved_settings:?}");
Some(DocumentSnapshot {
configuration: self.workspaces.configuration(url)?.clone(),
resolved_client_capabilities: self.resolved_client_capabilities.clone(),
client_settings: resolved_settings,
document_ref: self.workspaces.snapshot(url)?,
position_encoding: self.position_encoding,
url: url.clone(),
Expand Down Expand Up @@ -220,16 +229,18 @@ impl DocumentSnapshot {
}

impl Workspaces {
fn new(urls: &[Url]) -> crate::Result<Self> {
fn new(workspaces: Vec<(Url, ClientSettings)>) -> crate::Result<Self> {
Ok(Self(
urls.iter()
.map(Workspace::new)
workspaces
.into_iter()
.map(|(url, settings)| Workspace::new(&url, settings))
.collect::<crate::Result<_>>()?,
))
}

fn open_workspace_folder(&mut self, folder_url: &Url) -> crate::Result<()> {
let (path, workspace) = Workspace::new(folder_url)?;
// TODO(jane): find a way to allow for workspace settings to be updated dynamically
let (path, workspace) = Workspace::new(folder_url, ClientSettings::default())?;
self.0.insert(path, workspace);
Ok(())
}
Expand Down Expand Up @@ -281,6 +292,24 @@ impl Workspaces {
.close(url)
}

fn client_settings(
&self,
url: &Url,
global_settings: &ClientSettings,
) -> ResolvedClientSettings {
self.workspace_for_url(url).map_or_else(
|| {
tracing::warn!(
"Workspace not found for {url}. Global settings will be used for this document"
);
ResolvedClientSettings::global(global_settings)
},
|workspace| {
ResolvedClientSettings::with_workspace(&workspace.settings, global_settings)
},
)
}

fn workspace_for_url(&self, url: &Url) -> Option<&Workspace> {
Some(self.entry_for_url(url)?.1)
}
Expand All @@ -307,7 +336,7 @@ impl Workspaces {
}

impl Workspace {
pub(crate) fn new(root: &Url) -> crate::Result<(PathBuf, Self)> {
pub(crate) fn new(root: &Url, settings: ClientSettings) -> crate::Result<(PathBuf, Self)> {
let path = root
.to_file_path()
.map_err(|()| anyhow!("workspace URL was not a file path!"))?;
Expand All @@ -319,6 +348,7 @@ impl Workspace {
Self {
open_documents: OpenDocuments::default(),
configuration: Arc::new(configuration),
settings,
},
))
}
Expand Down
25 changes: 25 additions & 0 deletions crates/ruff_server/src/session/capabilities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use lsp_types::ClientCapabilities;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ResolvedClientCapabilities {
pub(crate) code_action_deferred_edit_resolution: bool,
}

impl ResolvedClientCapabilities {
pub(super) fn new(client_capabilities: &ClientCapabilities) -> Self {
let code_action_settings = client_capabilities
.text_document
.as_ref()
.and_then(|doc_settings| doc_settings.code_action.as_ref());
let code_action_data_support = code_action_settings
.and_then(|code_action_settings| code_action_settings.data_support)
.unwrap_or_default();
let code_action_edit_resolution = code_action_settings
.and_then(|code_action_settings| code_action_settings.resolve_support.as_ref())
.is_some_and(|resolve_support| resolve_support.properties.contains(&"edit".into()));
Self {
code_action_deferred_edit_resolution: code_action_data_support
&& code_action_edit_resolution,
}
}
}
Loading

0 comments on commit a184dc6

Please sign in to comment.