ruff/crates/ty_server/src/session/options.rs

386 lines
14 KiB
Rust

use std::collections::HashMap;
use lsp_types::Url;
use ruff_db::system::SystemPathBuf;
use ruff_macros::Combine;
use ruff_python_ast::PythonVersion;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use ty_combine::Combine;
use ty_ide::{CompletionSettings, InlayHintSettings};
use ty_project::metadata::Options as TyOptions;
use ty_project::metadata::options::ProjectOptionsOverrides;
use ty_project::metadata::value::{RangedValue, RelativePathBuf};
use crate::logging::LogLevel;
use super::settings::{ExperimentalSettings, GlobalSettings, WorkspaceSettings};
/// Initialization options that are set once at server startup that never change.
///
/// There are two sets of options that are defined here:
/// 1. Options that are static, set once and are required at server startup. Any changes to these
/// options require a server restart to take effect.
/// 2. Options that are dynamic and can change during the runtime of the server, such as the
/// diagnostic mode.
///
/// The dynamic options are also accepted during the initialization phase, so that we can support
/// clients that do not support the `workspace/didChangeConfiguration` notification.
///
/// Note that this structure has a limitation in that there's no way to specify different options
/// for different workspaces in the initialization options which means that the server will not
/// support multiple workspaces for clients that do not implement the `workspace/configuration`
/// endpoint. Most editors support this endpoint, so this is not a significant limitation.
#[derive(Clone, Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InitializationOptions {
/// The log level for the language server.
pub(crate) log_level: Option<LogLevel>,
/// Path to the log file, defaults to stderr if not set.
///
/// Tildes (`~`) and environment variables (e.g., `$HOME`) are expanded.
pub(crate) log_file: Option<SystemPathBuf>,
/// The remaining options that are dynamic and can change during the runtime of the server.
#[serde(flatten)]
pub(crate) options: ClientOptions,
}
impl InitializationOptions {
/// Create the initialization options from the given JSON value that corresponds to the
/// initialization options sent by the client.
///
/// It returns a tuple of the initialization options and an optional error if the JSON value
/// could not be deserialized into the initialization options. In case of an error, the default
/// initialization options are returned.
pub(crate) fn from_value(
options: Option<Value>,
) -> (InitializationOptions, Option<serde_json::Error>) {
let Some(options) = options else {
return (InitializationOptions::default(), None);
};
match serde_json::from_value(options) {
Ok(options) => (options, None),
Err(err) => (InitializationOptions::default(), Some(err)),
}
}
}
/// Options that configure the behavior of the language server.
///
/// This is the direct representation of the options that the client sends to the server when
/// asking for workspace configuration. These options are dynamic and can change during the runtime
/// of the server via the `workspace/didChangeConfiguration` notification.
///
/// The representation of the options is split into two parts:
/// 1. Global options contains options that are global to the language server. They are applied to
/// all workspaces managed by the language server.
/// 2. Workspace options contains options that are specific to a workspace. They are applied to the
/// workspace these options are associated with.
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ClientOptions {
#[serde(flatten)]
pub(crate) global: GlobalOptions,
#[serde(flatten)]
pub(crate) workspace: WorkspaceOptions,
/// Additional options that aren't valid as per the schema but we accept it to provide better
/// error message to the user.
#[serde(flatten)]
pub(crate) unknown: HashMap<String, Value>,
}
impl ClientOptions {
#[must_use]
pub fn with_diagnostic_mode(mut self, diagnostic_mode: DiagnosticMode) -> Self {
self.global.diagnostic_mode = Some(diagnostic_mode);
self
}
#[must_use]
pub fn with_disable_language_services(mut self, disable_language_services: bool) -> Self {
self.workspace.disable_language_services = Some(disable_language_services);
self
}
#[must_use]
pub fn with_variable_types_inlay_hints(mut self, variable_types: bool) -> Self {
self.workspace
.inlay_hints
.get_or_insert_default()
.variable_types = Some(variable_types);
self
}
#[must_use]
pub fn with_experimental_rename(mut self, enabled: bool) -> Self {
self.global.experimental.get_or_insert_default().rename = Some(enabled);
self
}
#[must_use]
pub fn with_auto_import(mut self, enabled: bool) -> Self {
self.workspace
.completions
.get_or_insert_default()
.auto_import = Some(enabled);
self
}
#[must_use]
pub fn with_unknown(mut self, unknown: HashMap<String, Value>) -> Self {
self.unknown = unknown;
self
}
}
/// Options that are global to the language server.
///
/// These are the dynamic options that are applied to all workspaces managed by the language
/// server.
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GlobalOptions {
/// Diagnostic mode for the language server.
diagnostic_mode: Option<DiagnosticMode>,
/// Experimental features that the server provides on an opt-in basis.
pub(crate) experimental: Option<Experimental>,
}
impl GlobalOptions {
pub(crate) fn into_settings(self) -> GlobalSettings {
let experimental = self
.experimental
.map(|experimental| ExperimentalSettings {
rename: experimental.rename.unwrap_or(false),
})
.unwrap_or_default();
GlobalSettings {
diagnostic_mode: self.diagnostic_mode.unwrap_or_default(),
experimental,
}
}
}
/// Options that are specific to a workspace.
///
/// These are the dynamic options that are applied to a specific workspace.
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspaceOptions {
/// Whether to disable language services like code completions, hover, etc.
disable_language_services: Option<bool>,
/// Options to configure inlay hints.
inlay_hints: Option<InlayHintOptions>,
/// Options to configure completions.
completions: Option<CompletionOptions>,
/// Information about the currently active Python environment in the VS Code Python extension.
///
/// This is relevant only for VS Code and is populated by the ty VS Code extension.
python_extension: Option<PythonExtension>,
}
impl WorkspaceOptions {
pub(crate) fn into_settings(self) -> WorkspaceSettings {
let overrides = self.python_extension.and_then(|extension| {
let active_environment = extension.active_environment?;
let mut overrides = ProjectOptionsOverrides::new(None, TyOptions::default());
overrides.fallback_python = if let Some(environment) = &active_environment.environment {
environment.folder_uri.to_file_path().ok().and_then(|path| {
Some(RelativePathBuf::python_extension(
SystemPathBuf::from_path_buf(path).ok()?,
))
})
} else {
Some(RelativePathBuf::python_extension(
active_environment.executable.sys_prefix.clone(),
))
};
overrides.fallback_python_version =
active_environment.version.as_ref().and_then(|version| {
Some(RangedValue::python_extension(PythonVersion::from((
u8::try_from(version.major).ok()?,
u8::try_from(version.minor).ok()?,
))))
});
if let Some(python) = &overrides.fallback_python {
tracing::debug!(
"Using the Python environment selected in your editor \
in case the configuration doesn't specify a Python environment: {python}",
python = python.path()
);
}
if let Some(version) = &overrides.fallback_python_version {
tracing::debug!(
"Using the Python version selected in your editor: {version} \
in case the configuration doesn't specify a Python version",
);
}
Some(overrides)
});
WorkspaceSettings {
disable_language_services: self.disable_language_services.unwrap_or_default(),
inlay_hints: self
.inlay_hints
.map(InlayHintOptions::into_settings)
.unwrap_or_default(),
completions: self
.completions
.map(CompletionOptions::into_settings)
.unwrap_or_default(),
overrides,
}
}
}
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct InlayHintOptions {
variable_types: Option<bool>,
call_argument_names: Option<bool>,
}
impl InlayHintOptions {
fn into_settings(self) -> InlayHintSettings {
InlayHintSettings {
variable_types: self.variable_types.unwrap_or(true),
call_argument_names: self.call_argument_names.unwrap_or(true),
}
}
}
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CompletionOptions {
auto_import: Option<bool>,
}
impl CompletionOptions {
// N.B. It's important for the defaults here to
// match the defaults for `CompletionSettings`.
// This is because `WorkspaceSettings::default()`
// uses `CompletionSettings::default()`. But
// `WorkspaceOptions::default().into_settings()` will use this
// definition.
fn into_settings(self) -> CompletionSettings {
CompletionSettings {
auto_import: self.auto_import.unwrap_or(true),
}
}
}
/// Diagnostic mode for the language server.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DiagnosticMode {
/// Check only currently open files.
#[default]
OpenFilesOnly,
/// Check all files in the workspace.
Workspace,
}
impl DiagnosticMode {
/// Returns `true` if the diagnostic mode is set to check all files in the workspace.
pub(crate) const fn is_workspace(self) -> bool {
matches!(self, DiagnosticMode::Workspace)
}
/// Returns `true` if the diagnostic mode is set to check only currently open files.
pub(crate) const fn is_open_files_only(self) -> bool {
matches!(self, DiagnosticMode::OpenFilesOnly)
}
}
impl Combine for DiagnosticMode {
fn combine_with(&mut self, other: Self) {
// Diagnostic mode is a global option but as it can be updated without a server restart,
// it is part of the dynamic option set. But, there's no easy way to enforce the fact that
// this option should not be set for individual workspaces. The ty VS Code extension
// enforces this but we're not in control of other clients.
//
// So, this is a workaround to ensure that if the diagnostic mode is set to `workspace` in
// either an initialization options or one of the workspace options, it is always set to
// `workspace` in the global options.
if other.is_workspace() {
*self = DiagnosticMode::Workspace;
}
}
}
#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Experimental {
/// Whether to enable the experimental symbol rename feature.
pub(crate) rename: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PythonExtension {
active_environment: Option<ActiveEnvironment>,
}
impl Combine for PythonExtension {
fn combine_with(&mut self, _other: Self) {
panic!(
"`python_extension` is not expected to be combined with the initialization options as \
it's only set by the ty VS Code extension in the `workspace/configuration` request."
);
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ActiveEnvironment {
pub(crate) executable: PythonExecutable,
pub(crate) environment: Option<PythonEnvironment>,
pub(crate) version: Option<EnvironmentVersion>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EnvironmentVersion {
pub(crate) major: i64,
pub(crate) minor: i64,
#[allow(dead_code)]
pub(crate) patch: i64,
#[allow(dead_code)]
pub(crate) sys_version: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PythonEnvironment {
pub(crate) folder_uri: Url,
#[allow(dead_code)]
#[serde(rename = "type")]
pub(crate) kind: String,
#[allow(dead_code)]
pub(crate) name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PythonExecutable {
#[allow(dead_code)]
pub(crate) uri: Url,
pub(crate) sys_prefix: SystemPathBuf,
}