mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-30 22:01:47 +00:00

## 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 } ```
321 lines
12 KiB
Rust
321 lines
12 KiB
Rust
//! Scheduling, I/O, and API endpoints.
|
|
|
|
use std::num::NonZeroUsize;
|
|
|
|
use lsp::Connection;
|
|
use lsp_server as lsp;
|
|
use lsp_types as types;
|
|
use types::ClientCapabilities;
|
|
use types::CodeActionKind;
|
|
use types::CodeActionOptions;
|
|
use types::DiagnosticOptions;
|
|
use types::DidChangeWatchedFilesRegistrationOptions;
|
|
use types::FileSystemWatcher;
|
|
use types::OneOf;
|
|
use types::TextDocumentSyncCapability;
|
|
use types::TextDocumentSyncKind;
|
|
use types::TextDocumentSyncOptions;
|
|
use types::WorkDoneProgressOptions;
|
|
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;
|
|
|
|
mod api;
|
|
mod client;
|
|
mod schedule;
|
|
|
|
pub(crate) type Result<T> = std::result::Result<T, api::Error>;
|
|
|
|
pub struct Server {
|
|
conn: lsp::Connection,
|
|
client_capabilities: ClientCapabilities,
|
|
threads: lsp::IoThreads,
|
|
worker_threads: NonZeroUsize,
|
|
session: Session,
|
|
}
|
|
|
|
impl Server {
|
|
pub fn new(worker_threads: NonZeroUsize) -> crate::Result<Self> {
|
|
let (conn, threads) = lsp::Connection::stdio();
|
|
|
|
let (id, params) = conn.initialize_start()?;
|
|
|
|
let init_params: types::InitializeParams = serde_json::from_value(params)?;
|
|
|
|
let client_capabilities = init_params.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| {
|
|
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...");
|
|
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.")
|
|
})?;
|
|
|
|
let initialize_data = serde_json::json!({
|
|
"capabilities": server_capabilities,
|
|
"serverInfo": {
|
|
"name": crate::SERVER_NAME,
|
|
"version": crate::version()
|
|
}
|
|
});
|
|
|
|
conn.initialize_finish(id, initialize_data)?;
|
|
|
|
Ok(Self {
|
|
conn,
|
|
threads,
|
|
worker_threads,
|
|
session: Session::new(
|
|
&client_capabilities,
|
|
position_encoding,
|
|
global_settings,
|
|
workspaces,
|
|
)?,
|
|
client_capabilities,
|
|
})
|
|
}
|
|
|
|
pub fn run(self) -> crate::Result<()> {
|
|
let result = event_loop_thread(move || {
|
|
Self::event_loop(
|
|
&self.conn,
|
|
&self.client_capabilities,
|
|
self.session,
|
|
self.worker_threads,
|
|
)
|
|
})?
|
|
.join();
|
|
self.threads.join()?;
|
|
result
|
|
}
|
|
|
|
#[allow(clippy::needless_pass_by_value)] // this is because we aren't using `next_request_id` yet.
|
|
fn event_loop(
|
|
connection: &Connection,
|
|
client_capabilities: &ClientCapabilities,
|
|
mut session: Session,
|
|
worker_threads: NonZeroUsize,
|
|
) -> crate::Result<()> {
|
|
let mut scheduler =
|
|
schedule::Scheduler::new(&mut session, worker_threads, &connection.sender);
|
|
|
|
Self::try_register_capabilities(client_capabilities, &mut scheduler);
|
|
for msg in &connection.receiver {
|
|
let task = match msg {
|
|
lsp::Message::Request(req) => {
|
|
if connection.handle_shutdown(&req)? {
|
|
return Ok(());
|
|
}
|
|
api::request(req)
|
|
}
|
|
lsp::Message::Notification(notification) => api::notification(notification),
|
|
lsp::Message::Response(response) => scheduler.response(response),
|
|
};
|
|
scheduler.dispatch(task);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn try_register_capabilities(
|
|
client_capabilities: &ClientCapabilities,
|
|
scheduler: &mut Scheduler,
|
|
) {
|
|
let dynamic_registration = client_capabilities
|
|
.workspace
|
|
.as_ref()
|
|
.and_then(|workspace| workspace.did_change_watched_files)
|
|
.and_then(|watched_files| watched_files.dynamic_registration)
|
|
.unwrap_or_default();
|
|
if dynamic_registration {
|
|
// Register all dynamic capabilities here
|
|
|
|
// `workspace/didChangeWatchedFiles`
|
|
// (this registers the configuration file watcher)
|
|
let params = lsp_types::RegistrationParams {
|
|
registrations: vec![lsp_types::Registration {
|
|
id: "ruff-server-watch".into(),
|
|
method: "workspace/didChangeWatchedFiles".into(),
|
|
register_options: Some(
|
|
serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
|
|
watchers: vec![
|
|
FileSystemWatcher {
|
|
glob_pattern: types::GlobPattern::String(
|
|
"**/.?ruff.toml".into(),
|
|
),
|
|
kind: None,
|
|
},
|
|
FileSystemWatcher {
|
|
glob_pattern: types::GlobPattern::String(
|
|
"**/pyproject.toml".into(),
|
|
),
|
|
kind: None,
|
|
},
|
|
],
|
|
})
|
|
.unwrap(),
|
|
),
|
|
}],
|
|
};
|
|
|
|
let response_handler = |()| {
|
|
tracing::info!("Configuration file watcher successfully registered");
|
|
Task::nothing()
|
|
};
|
|
|
|
if let Err(err) = scheduler
|
|
.request::<lsp_types::request::RegisterCapability>(params, response_handler)
|
|
{
|
|
tracing::error!("An error occurred when trying to register the configuration file watcher: {err}");
|
|
}
|
|
} else {
|
|
tracing::warn!("LSP client does not support dynamic capability registration - automatic configuration reloading will not be available.");
|
|
}
|
|
}
|
|
|
|
fn find_best_position_encoding(client_capabilities: &ClientCapabilities) -> PositionEncoding {
|
|
client_capabilities
|
|
.general
|
|
.as_ref()
|
|
.and_then(|general_capabilities| general_capabilities.position_encodings.as_ref())
|
|
.and_then(|encodings| {
|
|
encodings
|
|
.iter()
|
|
.filter_map(|encoding| PositionEncoding::try_from(encoding).ok())
|
|
.max() // this selects the highest priority position encoding
|
|
})
|
|
.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(
|
|
CodeActionOptions {
|
|
code_action_kinds: Some(
|
|
SupportedCodeAction::all()
|
|
.flat_map(|action| action.kinds().into_iter())
|
|
.collect(),
|
|
),
|
|
work_done_progress_options: WorkDoneProgressOptions {
|
|
work_done_progress: Some(true),
|
|
},
|
|
resolve_provider: Some(true),
|
|
},
|
|
)),
|
|
workspace: Some(types::WorkspaceServerCapabilities {
|
|
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
|
|
supported: Some(true),
|
|
change_notifications: Some(OneOf::Left(true)),
|
|
}),
|
|
file_operations: None,
|
|
}),
|
|
document_formatting_provider: Some(OneOf::Left(true)),
|
|
document_range_formatting_provider: Some(OneOf::Left(true)),
|
|
diagnostic_provider: Some(types::DiagnosticServerCapabilities::Options(
|
|
DiagnosticOptions {
|
|
identifier: Some(crate::DIAGNOSTIC_NAME.into()),
|
|
// multi-file analysis could change this
|
|
inter_file_dependencies: false,
|
|
workspace_diagnostics: false,
|
|
work_done_progress_options: WorkDoneProgressOptions {
|
|
work_done_progress: Some(true),
|
|
},
|
|
},
|
|
)),
|
|
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
|
TextDocumentSyncOptions {
|
|
open_close: Some(true),
|
|
change: Some(TextDocumentSyncKind::INCREMENTAL),
|
|
will_save: Some(false),
|
|
will_save_wait_until: Some(false),
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The code actions we support.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub(crate) enum SupportedCodeAction {
|
|
/// Maps to the `quickfix` code action kind. Quick fix code actions are shown under
|
|
/// their respective diagnostics. Quick fixes are only created where the fix applicability is
|
|
/// at least [`ruff_diagnostics::Applicability::Unsafe`].
|
|
QuickFix,
|
|
/// Maps to the `source.fixAll` and `source.fixAll.ruff` code action kinds.
|
|
/// This is a source action that applies all safe fixes to the currently open document.
|
|
SourceFixAll,
|
|
/// Maps to `source.organizeImports` and `source.organizeImports.ruff` code action kinds.
|
|
/// This is a source action that applies import sorting fixes to the currently open document.
|
|
#[allow(dead_code)] // TODO: remove
|
|
SourceOrganizeImports,
|
|
}
|
|
|
|
impl SupportedCodeAction {
|
|
/// Returns the possible LSP code action kind(s) that map to this code action.
|
|
fn kinds(self) -> Vec<CodeActionKind> {
|
|
match self {
|
|
Self::QuickFix => vec![CodeActionKind::QUICKFIX],
|
|
Self::SourceFixAll => vec![CodeActionKind::SOURCE_FIX_ALL, crate::SOURCE_FIX_ALL_RUFF],
|
|
Self::SourceOrganizeImports => vec![
|
|
CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
|
|
crate::SOURCE_ORGANIZE_IMPORTS_RUFF,
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Returns all code actions kinds that the server currently supports.
|
|
fn all() -> impl Iterator<Item = Self> {
|
|
[
|
|
Self::QuickFix,
|
|
Self::SourceFixAll,
|
|
Self::SourceOrganizeImports,
|
|
]
|
|
.into_iter()
|
|
}
|
|
}
|
|
|
|
impl TryFrom<CodeActionKind> for SupportedCodeAction {
|
|
type Error = ();
|
|
|
|
fn try_from(kind: CodeActionKind) -> std::result::Result<Self, Self::Error> {
|
|
for supported_kind in Self::all() {
|
|
if supported_kind.kinds().contains(&kind) {
|
|
return Ok(supported_kind);
|
|
}
|
|
}
|
|
Err(())
|
|
}
|
|
}
|