diff --git a/crates/ruff_server/resources/test/fixtures/settings/empty.json b/crates/ruff_server/resources/test/fixtures/settings/empty.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/crates/ruff_server/resources/test/fixtures/settings/empty.json @@ -0,0 +1 @@ +{} diff --git a/crates/ruff_server/resources/test/fixtures/settings/global_only.json b/crates/ruff_server/resources/test/fixtures/settings/global_only.json new file mode 100644 index 0000000000..674c756a2a --- /dev/null +++ b/crates/ruff_server/resources/test/fixtures/settings/global_only.json @@ -0,0 +1,14 @@ +{ + "settings": { + "codeAction": { + "disableRuleComment": { + "enable": false + } + }, + "lint": { + "run": "onSave" + }, + "fixAll": false, + "logLevel": "warn" + } +} diff --git a/crates/ruff_server/resources/test/fixtures/settings/vs_code_initialization_options.json b/crates/ruff_server/resources/test/fixtures/settings/vs_code_initialization_options.json new file mode 100644 index 0000000000..d7e7c1c7b7 --- /dev/null +++ b/crates/ruff_server/resources/test/fixtures/settings/vs_code_initialization_options.json @@ -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" + } +} diff --git a/crates/ruff_server/src/server.rs b/crates/ruff_server/src/server.rs index 4f28dbd81a..dfdea1e252 100644 --- a/crates/ruff_server/src/server.rs +++ b/crates/ruff_server/src/server.rs @@ -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; @@ -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.") @@ -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, }) } @@ -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()) @@ -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( diff --git a/crates/ruff_server/src/session.rs b/crates/ruff_server/src/session.rs index 22f1ed50ce..208ad923fd 100644 --- a/crates/ruff_server/src/session.rs +++ b/crates/ruff_server/src/session.rs @@ -1,5 +1,6 @@ //! Data model, state management, and configuration resolution. +mod capabilities; mod settings; use std::collections::BTreeMap; @@ -7,14 +8,16 @@ 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 { @@ -22,6 +25,8 @@ pub(crate) struct Session { 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, } @@ -31,6 +36,8 @@ pub(crate) struct Session { pub(crate) struct DocumentSnapshot { configuration: Arc, resolved_client_capabilities: Arc, + #[allow(dead_code)] + client_settings: settings::ResolvedClientSettings, document_ref: DocumentRef, position_encoding: PositionEncoding, url: Url, @@ -50,6 +57,7 @@ pub(crate) struct Workspaces(BTreeMap); pub(crate) struct Workspace { open_documents: OpenDocuments, configuration: Arc, + settings: ClientSettings, } #[derive(Default)] @@ -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 { 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, )), @@ -90,9 +96,12 @@ impl Session { } pub(crate) fn take_snapshot(&self, url: &Url) -> Option { + 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(), @@ -220,16 +229,18 @@ impl DocumentSnapshot { } impl Workspaces { - fn new(urls: &[Url]) -> crate::Result { + fn new(workspaces: Vec<(Url, ClientSettings)>) -> crate::Result { Ok(Self( - urls.iter() - .map(Workspace::new) + workspaces + .into_iter() + .map(|(url, settings)| Workspace::new(&url, settings)) .collect::>()?, )) } 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(()) } @@ -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) } @@ -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!"))?; @@ -319,6 +348,7 @@ impl Workspace { Self { open_documents: OpenDocuments::default(), configuration: Arc::new(configuration), + settings, }, )) } diff --git a/crates/ruff_server/src/session/capabilities.rs b/crates/ruff_server/src/session/capabilities.rs new file mode 100644 index 0000000000..f415aa7541 --- /dev/null +++ b/crates/ruff_server/src/session/capabilities.rs @@ -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, + } + } +} diff --git a/crates/ruff_server/src/session/settings.rs b/crates/ruff_server/src/session/settings.rs index f415aa7541..b6e831b1d0 100644 --- a/crates/ruff_server/src/session/settings.rs +++ b/crates/ruff_server/src/session/settings.rs @@ -1,25 +1,462 @@ -use lsp_types::ClientCapabilities; +use std::ops::Deref; -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) struct ResolvedClientCapabilities { - pub(crate) code_action_deferred_edit_resolution: bool, +use lsp_types::Url; +use rustc_hash::FxHashMap; +use serde::Deserialize; + +/// Maps a workspace URI to its associated client settings. Used during server initialization. +pub(crate) type WorkspaceSettingsMap = FxHashMap; + +/// Resolved client settings for a specific document. These settings are meant to be +/// used directly by the server, and are *not* a 1:1 representation with how the client +/// sends them. +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq, Eq))] +// TODO(jane): Remove dead code warning +#[allow(dead_code, clippy::struct_excessive_bools)] +pub(crate) struct ResolvedClientSettings { + fix_all: bool, + organize_imports: bool, + lint_enable: bool, + disable_rule_comment_enable: bool, + fix_violation_enable: 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())); +/// This is a direct representation of the settings schema sent by the client. +#[derive(Debug, Deserialize, Default)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientSettings { + fix_all: Option, + organize_imports: Option, + lint: Option, + code_action: Option, +} + +/// This is a direct representation of the workspace settings schema, +/// which inherits the schema of [`ClientSettings`] and adds extra fields +/// to describe the workspace it applies to. +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +struct WorkspaceSettings { + #[serde(flatten)] + settings: ClientSettings, + workspace: Url, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +struct Lint { + enable: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +struct CodeAction { + disable_rule_comment: Option, + fix_violation: Option, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +struct CodeActionSettings { + enable: Option, +} + +/// This is the exact schema for initialization options sent in by the client +/// during initialization. +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(untagged)] +enum InitializationOptions { + #[serde(rename_all = "camelCase")] + HasWorkspaces { + global_settings: ClientSettings, + #[serde(rename = "settings")] + workspace_settings: Vec, + }, + GlobalOnly { + settings: Option, + }, +} + +/// Built from the initialization options provided by the client. +pub(crate) struct AllSettings { + pub(crate) global_settings: ClientSettings, + /// If this is `None`, the client only passed in global settings. + pub(crate) workspace_settings: Option, +} + +impl AllSettings { + /// Initializes the controller from the serialized initialization options. + /// This fails if `options` are not valid initialization options. + pub(crate) fn from_value(options: serde_json::Value) -> Self { + Self::from_init_options( + serde_json::from_value(options) + .map_err(|err| { + tracing::error!("Failed to deserialize initialization options: {err}. Falling back to default client settings..."); + }) + .unwrap_or_default(), + ) + } + + fn from_init_options(options: InitializationOptions) -> Self { + let (global_settings, workspace_settings) = match options { + InitializationOptions::GlobalOnly { settings } => (settings.unwrap_or_default(), None), + InitializationOptions::HasWorkspaces { + global_settings, + workspace_settings, + } => (global_settings, Some(workspace_settings)), + }; + Self { - code_action_deferred_edit_resolution: code_action_data_support - && code_action_edit_resolution, + global_settings, + workspace_settings: workspace_settings.map(|workspace_settings| { + workspace_settings + .into_iter() + .map(|settings| (settings.workspace, settings.settings)) + .collect() + }), } } } + +impl ResolvedClientSettings { + /// Resolves a series of client settings, prioritizing workspace settings over global settings. + /// Any fields not specified by either are set to their defaults. + pub(super) fn with_workspace( + workspace_settings: &ClientSettings, + global_settings: &ClientSettings, + ) -> Self { + Self::new_impl(&[workspace_settings, global_settings]) + } + + /// Resolves global settings only. + pub(super) fn global(global_settings: &ClientSettings) -> Self { + Self::new_impl(&[global_settings]) + } + + fn new_impl(all_settings: &[&ClientSettings]) -> Self { + Self { + fix_all: Self::resolve_or(all_settings, |settings| settings.fix_all, true), + organize_imports: Self::resolve_or( + all_settings, + |settings| settings.organize_imports, + true, + ), + lint_enable: Self::resolve_or( + all_settings, + |settings| settings.lint.as_ref()?.enable, + true, + ), + disable_rule_comment_enable: Self::resolve_or( + all_settings, + |settings| { + settings + .code_action + .as_ref()? + .disable_rule_comment + .as_ref()? + .enable + }, + true, + ), + fix_violation_enable: Self::resolve_or( + all_settings, + |settings| { + settings + .code_action + .as_ref()? + .fix_violation + .as_ref()? + .enable + }, + true, + ), + } + } + + /// Attempts to resolve a setting using a list of available client settings as sources. + /// Client settings that come earlier in the list take priority. `default` will be returned + /// if none of the settings specify the requested setting. + fn resolve_or( + all_settings: &[&ClientSettings], + get: impl Fn(&ClientSettings) -> Option, + default: T, + ) -> T { + all_settings + .iter() + .map(Deref::deref) + .find_map(get) + .unwrap_or(default) + } +} + +impl Default for InitializationOptions { + fn default() -> Self { + Self::GlobalOnly { settings: None } + } +} + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + use serde::de::DeserializeOwned; + + use super::*; + + const VS_CODE_INIT_OPTIONS_FIXTURE: &str = + include_str!("../../resources/test/fixtures/settings/vs_code_initialization_options.json"); + const GLOBAL_ONLY_INIT_OPTIONS_FIXTURE: &str = + include_str!("../../resources/test/fixtures/settings/global_only.json"); + const EMPTY_INIT_OPTIONS_FIXTURE: &str = + include_str!("../../resources/test/fixtures/settings/empty.json"); + + fn deserialize_fixture(content: &str) -> T { + serde_json::from_str(content).expect("test fixture JSON should deserialize") + } + + #[test] + fn test_vs_code_init_options_deserialize() { + let options: InitializationOptions = deserialize_fixture(VS_CODE_INIT_OPTIONS_FIXTURE); + + assert_debug_snapshot!(options, @r###" + HasWorkspaces { + global_settings: ClientSettings { + fix_all: Some( + false, + ), + organize_imports: Some( + true, + ), + lint: Some( + Lint { + enable: Some( + true, + ), + }, + ), + code_action: Some( + CodeAction { + disable_rule_comment: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + fix_violation: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + }, + ), + }, + workspace_settings: [ + WorkspaceSettings { + settings: ClientSettings { + fix_all: Some( + true, + ), + organize_imports: Some( + true, + ), + lint: Some( + Lint { + enable: Some( + true, + ), + }, + ), + code_action: Some( + CodeAction { + disable_rule_comment: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + fix_violation: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + }, + ), + }, + workspace: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/Users/test/projects/pandas", + query: None, + fragment: None, + }, + }, + WorkspaceSettings { + settings: ClientSettings { + fix_all: Some( + true, + ), + organize_imports: Some( + true, + ), + lint: Some( + Lint { + enable: Some( + true, + ), + }, + ), + code_action: Some( + CodeAction { + disable_rule_comment: Some( + CodeActionSettings { + enable: Some( + true, + ), + }, + ), + fix_violation: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + }, + ), + }, + workspace: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/Users/test/projects/scipy", + query: None, + fragment: None, + }, + }, + ], + } + "###); + } + + #[test] + fn test_vs_code_workspace_settings_resolve() { + let options = deserialize_fixture(VS_CODE_INIT_OPTIONS_FIXTURE); + let AllSettings { + global_settings, + workspace_settings, + } = AllSettings::from_init_options(options); + let url = Url::parse("file:///Users/test/projects/pandas").expect("url should parse"); + let workspace_settings = workspace_settings.expect("workspace settings should exist"); + assert_eq!( + ResolvedClientSettings::with_workspace( + workspace_settings + .get(&url) + .expect("workspace setting should exist"), + &global_settings + ), + ResolvedClientSettings { + fix_all: true, + organize_imports: true, + lint_enable: true, + disable_rule_comment_enable: false, + fix_violation_enable: false, + } + ); + let url = Url::parse("file:///Users/test/projects/scipy").expect("url should parse"); + assert_eq!( + ResolvedClientSettings::with_workspace( + workspace_settings + .get(&url) + .expect("workspace setting should exist"), + &global_settings + ), + ResolvedClientSettings { + fix_all: true, + organize_imports: true, + lint_enable: true, + disable_rule_comment_enable: true, + fix_violation_enable: false, + } + ); + } + + #[test] + fn test_global_only_init_options_deserialize() { + let options: InitializationOptions = deserialize_fixture(GLOBAL_ONLY_INIT_OPTIONS_FIXTURE); + + assert_debug_snapshot!(options, @r###" + GlobalOnly { + settings: Some( + ClientSettings { + fix_all: Some( + false, + ), + organize_imports: None, + lint: Some( + Lint { + enable: None, + }, + ), + code_action: Some( + CodeAction { + disable_rule_comment: Some( + CodeActionSettings { + enable: Some( + false, + ), + }, + ), + fix_violation: None, + }, + ), + }, + ), + } + "###); + } + + #[test] + fn test_global_only_resolves_correctly() { + let options = deserialize_fixture(GLOBAL_ONLY_INIT_OPTIONS_FIXTURE); + + let AllSettings { + global_settings, .. + } = AllSettings::from_init_options(options); + assert_eq!( + ResolvedClientSettings::global(&global_settings), + ResolvedClientSettings { + fix_all: false, + organize_imports: true, + lint_enable: true, + disable_rule_comment_enable: false, + fix_violation_enable: true, + } + ); + } + + #[test] + fn test_empty_init_options_deserialize() { + let options: InitializationOptions = deserialize_fixture(EMPTY_INIT_OPTIONS_FIXTURE); + + assert_eq!(options, InitializationOptions::default()); + } +}