diff --git a/Cargo.lock b/Cargo.lock index 2d4dc26626..7111dced54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,6 +4307,7 @@ dependencies = [ "lsp-types", "ruff_db", "ruff_notebook", + "ruff_python_ast", "ruff_source_file", "ruff_text_size", "rustc-hash", diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index b9d7cbd8f9..f3a0bdaf26 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -121,17 +121,17 @@ fn run_check(args: CheckCommand) -> anyhow::Result { None => ProjectMetadata::discover(&project_path, &system)?, }; - let options = args.into_options(); - project_metadata.apply_options(options.clone()); project_metadata.apply_configuration_files(&system)?; + let project_options_overrides = ProjectOptionsOverrides::new(config_file, args.into_options()); + project_metadata.apply_overrides(&project_options_overrides); + let mut db = ProjectDatabase::new(project_metadata, system)?; if !check_paths.is_empty() { db.project().set_included_paths(&mut db, check_paths); } - let project_options_overrides = ProjectOptionsOverrides::new(config_file, options); let (main_loop, main_loop_cancellation_token) = MainLoop::new(project_options_overrides, printer); diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 76f001b3b8..b0e85a42b4 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -3,6 +3,7 @@ use std::panic::{AssertUnwindSafe, RefUnwindSafe}; use std::sync::Arc; use std::{cmp, fmt}; +pub use self::changes::ChangeResult; use crate::metadata::settings::file_settings; use crate::{DEFAULT_LINT_REGISTRY, DummyReporter}; use crate::{ProgressReporter, Project, ProjectMetadata}; diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index f1b203c299..d9eb457097 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -41,8 +41,6 @@ impl ProjectDatabase { let project_root = project.root(self).to_path_buf(); let config_file_override = project_options_overrides.and_then(|options| options.config_file_override.clone()); - let options = - project_options_overrides.map(|project_options| project_options.options.clone()); let program = Program::get(self); let custom_stdlib_versions_path = program .custom_stdlib_search_path(self) @@ -218,16 +216,16 @@ impl ProjectDatabase { }; match new_project_metadata { Ok(mut metadata) => { - if let Some(cli_options) = options { - metadata.apply_options(cli_options); - } - if let Err(error) = metadata.apply_configuration_files(self.system()) { tracing::error!( "Failed to apply configuration files, continuing without applying them: {error}" ); } + if let Some(overrides) = project_options_overrides { + metadata.apply_overrides(overrides); + } + match metadata.to_program_settings(self.system(), self.vendored()) { Ok(program_settings) => { let program = Program::get(self); diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index ffff0204f5..377b3d1276 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -1,7 +1,7 @@ use crate::glob::{GlobFilterCheckMode, IncludeResult}; use crate::metadata::options::{OptionDiagnostic, ToSettingsError}; use crate::walk::{ProjectFilesFilter, ProjectFilesWalker}; -pub use db::{CheckMode, Db, ProjectDatabase, SalsaMemoryDump}; +pub use db::{ChangeResult, CheckMode, Db, ProjectDatabase, SalsaMemoryDump}; use files::{Index, Indexed, IndexedFiles}; use metadata::settings::Settings; pub use metadata::{ProjectMetadata, ProjectMetadataError}; diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index f97cd44318..443b54f0e2 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -7,6 +7,7 @@ use thiserror::Error; use ty_python_semantic::ProgramSettings; use crate::combine::Combine; +use crate::metadata::options::ProjectOptionsOverrides; use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError}; use crate::metadata::value::ValueSource; pub use options::Options; @@ -276,6 +277,10 @@ impl ProjectMetadata { .to_program_settings(self.root(), self.name(), system, vendored) } + pub fn apply_overrides(&mut self, overrides: &ProjectOptionsOverrides) { + self.options = overrides.apply_to(std::mem::take(&mut self.options)); + } + /// Combine the project options with the CLI options where the CLI options take precedence. pub fn apply_options(&mut self, options: Options) { self.options = options.combine(std::mem::take(&mut self.options)); diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 5b2e824825..e627e01718 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -121,6 +121,9 @@ impl Options { ValueSource::File(path) => PythonVersionSource::ConfigFile( PythonVersionFileSource::new(path.clone(), ranged_version.range()), ), + ValueSource::PythonVSCodeExtension => { + PythonVersionSource::PythonVSCodeExtension + } }, }); @@ -140,6 +143,7 @@ impl Options { ValueSource::File(path) => { SysPrefixPathOrigin::ConfigFileSetting(path.clone(), python_path.range()) } + ValueSource::PythonVSCodeExtension => SysPrefixPathOrigin::PythonVSCodeExtension, }; Some(PythonEnvironment::new( @@ -702,6 +706,10 @@ impl Rules { let lint_source = match source { ValueSource::File(_) => LintSource::File, ValueSource::Cli => LintSource::Cli, + + ValueSource::PythonVSCodeExtension => { + unreachable!("Can't configure rules from the Python VSCode extension") + } }; if let Ok(severity) = Severity::try_from(**level) { selection.enable(lint, severity, lint_source); @@ -854,6 +862,7 @@ fn build_include_filter( Severity::Info, "The pattern was specified on the CLI", )), + ValueSource::PythonVSCodeExtension => unreachable!("Can't configure includes from the Python VSCode extension"), } })?; } @@ -936,6 +945,9 @@ fn build_exclude_filter( Severity::Info, "The pattern was specified on the CLI", )), + ValueSource::PythonVSCodeExtension => unreachable!( + "Can't configure excludes from the Python VSCode extension" + ) } })?; } @@ -1497,8 +1509,11 @@ impl OptionDiagnostic { /// This is a wrapper for options that actually get loaded from configuration files /// and the CLI, which also includes a `config_file_override` option that overrides /// default configuration discovery with an explicitly-provided path to a configuration file +#[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct ProjectOptionsOverrides { pub config_file_override: Option, + pub fallback_python_version: Option>, + pub fallback_python: Option, pub options: Options, } @@ -1507,8 +1522,22 @@ impl ProjectOptionsOverrides { Self { config_file_override, options, + ..Self::default() } } + + pub fn apply_to(&self, options: Options) -> Options { + let mut combined = self.options.clone().combine(options); + + // Set the fallback python version and path if set + combined.environment.combine_with(Some(EnvironmentOptions { + python_version: self.fallback_python_version.clone(), + python: self.fallback_python.clone(), + ..EnvironmentOptions::default() + })); + + combined + } } trait OrDefault { diff --git a/crates/ty_project/src/metadata/value.rs b/crates/ty_project/src/metadata/value.rs index 09d457d875..c07257d412 100644 --- a/crates/ty_project/src/metadata/value.rs +++ b/crates/ty_project/src/metadata/value.rs @@ -27,6 +27,9 @@ pub enum ValueSource { /// The value comes from a CLI argument, while it's left open if specified using a short argument, /// long argument (`--extra-paths`) or `--config key=value`. Cli, + + /// The value comes from an LSP client configuration. + PythonVSCodeExtension, } impl ValueSource { @@ -34,6 +37,7 @@ impl ValueSource { match self { ValueSource::File(path) => Some(&**path), ValueSource::Cli => None, + ValueSource::PythonVSCodeExtension => None, } } @@ -105,6 +109,14 @@ impl RangedValue { Self::with_range(value, ValueSource::Cli, TextRange::default()) } + pub fn python_extension(value: T) -> Self { + Self::with_range( + value, + ValueSource::PythonVSCodeExtension, + TextRange::default(), + ) + } + pub fn with_range(value: T, source: ValueSource, range: TextRange) -> Self { Self { value, @@ -327,6 +339,10 @@ impl RelativePathBuf { Self::new(path, ValueSource::Cli) } + pub fn python_extension(path: impl AsRef) -> Self { + Self::new(path, ValueSource::PythonVSCodeExtension) + } + /// Returns the relative path as specified by the user. pub fn path(&self) -> &SystemPath { &self.0 @@ -354,7 +370,7 @@ impl RelativePathBuf { pub fn absolute(&self, project_root: &SystemPath, system: &dyn System) -> SystemPathBuf { let relative_to = match &self.0.source { ValueSource::File(_) => project_root, - ValueSource::Cli => system.current_directory(), + ValueSource::Cli | ValueSource::PythonVSCodeExtension => system.current_directory(), }; SystemPath::absolute(&self.0, relative_to) @@ -409,7 +425,7 @@ impl RelativeGlobPattern { ) -> Result { let relative_to = match &self.0.source { ValueSource::File(_) => project_root, - ValueSource::Cli => system.current_directory(), + ValueSource::Cli | ValueSource::PythonVSCodeExtension => system.current_directory(), }; let pattern = PortableGlobPattern::parse(&self.0, kind)?; diff --git a/crates/ty_python_semantic/src/program.rs b/crates/ty_python_semantic/src/program.rs index 64c4252261..e674926a20 100644 --- a/crates/ty_python_semantic/src/program.rs +++ b/crates/ty_python_semantic/src/program.rs @@ -113,6 +113,9 @@ pub enum PythonVersionSource { /// long argument (`--extra-paths`) or `--config key=value`. Cli, + /// The value comes from the Python VS Code extension (the selected interpreter). + PythonVSCodeExtension, + /// We fell back to a default value because the value was not specified via the CLI or a config file. #[default] Default, diff --git a/crates/ty_python_semantic/src/site_packages.rs b/crates/ty_python_semantic/src/site_packages.rs index a6b2b8214c..d69e8896d0 100644 --- a/crates/ty_python_semantic/src/site_packages.rs +++ b/crates/ty_python_semantic/src/site_packages.rs @@ -1065,6 +1065,8 @@ pub enum SysPrefixPathOrigin { ConfigFileSetting(Arc, Option), /// The `sys.prefix` path came from a `--python` CLI flag PythonCliFlag, + /// The selected interpreter in the VS Code's Python extension. + PythonVSCodeExtension, /// The `sys.prefix` path came from the `VIRTUAL_ENV` environment variable VirtualEnvVar, /// The `sys.prefix` path came from the `CONDA_PREFIX` environment variable @@ -1086,6 +1088,7 @@ impl SysPrefixPathOrigin { Self::LocalVenv | Self::VirtualEnvVar => true, Self::ConfigFileSetting(..) | Self::PythonCliFlag + | Self::PythonVSCodeExtension | Self::DerivedFromPyvenvCfg | Self::CondaPrefixVar => false, } @@ -1097,7 +1100,9 @@ impl SysPrefixPathOrigin { /// the `sys.prefix` directory, e.g. the `--python` CLI flag. pub(crate) const fn must_point_directly_to_sys_prefix(&self) -> bool { match self { - Self::PythonCliFlag | Self::ConfigFileSetting(..) => false, + Self::PythonCliFlag | Self::ConfigFileSetting(..) | Self::PythonVSCodeExtension => { + false + } Self::VirtualEnvVar | Self::CondaPrefixVar | Self::DerivedFromPyvenvCfg @@ -1115,6 +1120,9 @@ impl std::fmt::Display for SysPrefixPathOrigin { Self::CondaPrefixVar => f.write_str("`CONDA_PREFIX` environment variable"), Self::DerivedFromPyvenvCfg => f.write_str("derived `sys.prefix` path"), Self::LocalVenv => f.write_str("local virtual environment"), + Self::PythonVSCodeExtension => { + f.write_str("selected interpreter in the VS Code Python extension") + } } } } diff --git a/crates/ty_python_semantic/src/util/diagnostics.rs b/crates/ty_python_semantic/src/util/diagnostics.rs index ca39583cbb..5639f4d511 100644 --- a/crates/ty_python_semantic/src/util/diagnostics.rs +++ b/crates/ty_python_semantic/src/util/diagnostics.rs @@ -62,6 +62,12 @@ pub fn add_inferred_python_version_hint_to_diagnostic( or in a configuration file", ); } + crate::PythonVersionSource::PythonVSCodeExtension => { + diagnostic.info(format_args!( + "Python {version} was assumed when {action} \ + because it's the version of the selected Python interpreter in the VS Code Python extension", + )); + } crate::PythonVersionSource::InstallationDirectoryLayout { site_packages_parent_dir, } => { diff --git a/crates/ty_server/Cargo.toml b/crates/ty_server/Cargo.toml index 3a1df33622..b85f0aacc1 100644 --- a/crates/ty_server/Cargo.toml +++ b/crates/ty_server/Cargo.toml @@ -13,6 +13,7 @@ license = { workspace = true } [dependencies] ruff_db = { workspace = true, features = ["os"] } ruff_notebook = { workspace = true } +ruff_python_ast = { workspace = true } ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } diff --git a/crates/ty_server/src/server/api/notifications/did_change.rs b/crates/ty_server/src/server/api/notifications/did_change.rs index 318228be5b..e9042da999 100644 --- a/crates/ty_server/src/server/api/notifications/did_change.rs +++ b/crates/ty_server/src/server/api/notifications/did_change.rs @@ -40,22 +40,16 @@ impl SyncNotificationHandler for DidChangeTextDocumentHandler { .update_text_document(&key, content_changes, version) .with_failure_code(ErrorCode::InternalError)?; - match key.path() { + let changes = match key.path() { AnySystemPath::System(path) => { - let db = match session.project_db_for_path_mut(path) { - Some(db) => db, - None => session.default_project_db_mut(), - }; - db.apply_changes(vec![ChangeEvent::file_content_changed(path.clone())], None); + vec![ChangeEvent::file_content_changed(path.clone())] } AnySystemPath::SystemVirtual(virtual_path) => { - let db = session.default_project_db_mut(); - db.apply_changes( - vec![ChangeEvent::ChangedVirtual(virtual_path.clone())], - None, - ); + vec![ChangeEvent::ChangedVirtual(virtual_path.clone())] } - } + }; + + session.apply_changes(key.path(), changes); publish_diagnostics(session, &key, client); diff --git a/crates/ty_server/src/server/api/notifications/did_change_watched_files.rs b/crates/ty_server/src/server/api/notifications/did_change_watched_files.rs index a0e4df2a62..2cdaa1e3c6 100644 --- a/crates/ty_server/src/server/api/notifications/did_change_watched_files.rs +++ b/crates/ty_server/src/server/api/notifications/did_change_watched_files.rs @@ -88,12 +88,7 @@ impl SyncNotificationHandler for DidChangeWatchedFiles { for (root, changes) in events_by_db { tracing::debug!("Applying changes to `{root}`"); - // SAFETY: Only paths that are part of the workspace are registered for file watching. - // So, virtual paths and paths that are outside of a workspace does not trigger this - // notification. - let db = session.project_db_for_path_mut(&*root).unwrap(); - - let result = db.apply_changes(changes, None); + let result = session.apply_changes(&AnySystemPath::System(root), changes); project_changed |= result.project_changed(); } diff --git a/crates/ty_server/src/server/api/notifications/did_close.rs b/crates/ty_server/src/server/api/notifications/did_close.rs index 936d73e81a..3c38607b00 100644 --- a/crates/ty_server/src/server/api/notifications/did_close.rs +++ b/crates/ty_server/src/server/api/notifications/did_close.rs @@ -39,10 +39,9 @@ impl SyncNotificationHandler for DidCloseTextDocumentHandler { .with_failure_code(ErrorCode::InternalError)?; if let AnySystemPath::SystemVirtual(virtual_path) = key.path() { - let db = session.default_project_db_mut(); - db.apply_changes( + session.apply_changes( + key.path(), vec![ChangeEvent::DeletedVirtual(virtual_path.clone())], - None, ); } diff --git a/crates/ty_server/src/server/api/notifications/did_close_notebook.rs b/crates/ty_server/src/server/api/notifications/did_close_notebook.rs index b0563e79c3..f934f6832e 100644 --- a/crates/ty_server/src/server/api/notifications/did_close_notebook.rs +++ b/crates/ty_server/src/server/api/notifications/did_close_notebook.rs @@ -39,10 +39,9 @@ impl SyncNotificationHandler for DidCloseNotebookHandler { .with_failure_code(lsp_server::ErrorCode::InternalError)?; if let AnySystemPath::SystemVirtual(virtual_path) = key.path() { - let db = session.default_project_db_mut(); - db.apply_changes( + session.apply_changes( + key.path(), vec![ChangeEvent::DeletedVirtual(virtual_path.clone())], - None, ); } diff --git a/crates/ty_server/src/server/api/notifications/did_open.rs b/crates/ty_server/src/server/api/notifications/did_open.rs index 210d3c669d..6ef920b535 100644 --- a/crates/ty_server/src/server/api/notifications/did_open.rs +++ b/crates/ty_server/src/server/api/notifications/did_open.rs @@ -46,11 +46,7 @@ impl SyncNotificationHandler for DidOpenTextDocumentHandler { match key.path() { AnySystemPath::System(system_path) => { - let db = match session.project_db_for_path_mut(system_path) { - Some(db) => db, - None => session.default_project_db_mut(), - }; - db.apply_changes(vec![ChangeEvent::Opened(system_path.clone())], None); + session.apply_changes(key.path(), vec![ChangeEvent::Opened(system_path.clone())]); } AnySystemPath::SystemVirtual(virtual_path) => { let db = session.default_project_db_mut(); diff --git a/crates/ty_server/src/server/api/notifications/did_open_notebook.rs b/crates/ty_server/src/server/api/notifications/did_open_notebook.rs index 644046a072..c3e4fff5cd 100644 --- a/crates/ty_server/src/server/api/notifications/did_open_notebook.rs +++ b/crates/ty_server/src/server/api/notifications/did_open_notebook.rs @@ -40,11 +40,7 @@ impl SyncNotificationHandler for DidOpenNotebookHandler { match &path { AnySystemPath::System(system_path) => { - let db = match session.project_db_for_path_mut(system_path) { - Some(db) => db, - None => session.default_project_db_mut(), - }; - db.apply_changes(vec![ChangeEvent::Opened(system_path.clone())], None); + session.apply_changes(&path, vec![ChangeEvent::Opened(system_path.clone())]); } AnySystemPath::SystemVirtual(virtual_path) => { let db = session.default_project_db_mut(); diff --git a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs index 68f7812c2a..060bd71491 100644 --- a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs +++ b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs @@ -33,7 +33,7 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { let index = snapshot.index(); if !index.global_settings().diagnostic_mode().is_workspace() { - tracing::debug!("Workspace diagnostics is disabled; returning empty report"); + tracing::trace!("Workspace diagnostics is disabled; returning empty report"); return Ok(WorkspaceDiagnosticReportResult::Report( WorkspaceDiagnosticReport { items: vec![] }, )); diff --git a/crates/ty_server/src/session.rs b/crates/ty_server/src/session.rs index 66bd183ae6..e4d66bb396 100644 --- a/crates/ty_server/src/session.rs +++ b/crates/ty_server/src/session.rs @@ -13,7 +13,8 @@ use ruff_db::Db; use ruff_db::files::File; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ty_project::metadata::Options; -use ty_project::{ProjectDatabase, ProjectMetadata}; +use ty_project::watch::ChangeEvent; +use ty_project::{ChangeResult, ProjectDatabase, ProjectMetadata}; pub(crate) use self::capabilities::ResolvedClientCapabilities; pub(crate) use self::index::DocumentQuery; @@ -49,7 +50,12 @@ pub(crate) struct Session { /// The projects across all workspaces. projects: BTreeMap, - default_project: ProjectDatabase, + /// The project to use for files outside any workspace. For example, if the user + /// opens the project `/my_project` in VS code but they then opens a Python file from their Desktop. + /// This file isn't part of the active workspace, nor is it part of any project. But we still want + /// to provide some basic functionality like navigation, completions, syntax highlighting, etc. + /// That's what we use the default project for. + default_project: DefaultProject, /// The global position encoding, negotiated during LSP initialization. position_encoding: PositionEncoding, @@ -77,26 +83,15 @@ impl Session { let mut workspaces = Workspaces::default(); for (url, options) in workspace_folders { - workspaces.register(url, options)?; + workspaces.register(url, options.into_settings())?; } - let default_project = { - let system = LSPSystem::new(index.clone()); - let metadata = ProjectMetadata::from_options( - Options::default(), - system.current_directory().to_path_buf(), - None, - ) - .unwrap(); - ProjectDatabase::new(metadata, system).unwrap() - }; - Ok(Self { position_encoding, workspaces, deferred_messages: VecDeque::new(), index: Some(index), - default_project, + default_project: DefaultProject::new(), projects: BTreeMap::new(), resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new( client_capabilities, @@ -109,7 +104,6 @@ impl Session { pub(crate) fn request_queue(&self) -> &RequestQueue { &self.request_queue } - pub(crate) fn request_queue_mut(&mut self) -> &mut RequestQueue { &mut self.request_queue } @@ -204,21 +198,46 @@ impl Session { .map(|(_, db)| db) } - /// Returns a reference to the default project [`ProjectDatabase`]. The default project is the - /// minimum root path in the project map. + pub(crate) fn apply_changes( + &mut self, + path: &AnySystemPath, + changes: Vec, + ) -> ChangeResult { + let overrides = path.as_system().and_then(|root| { + self.workspaces() + .for_path(root)? + .settings() + .project_options_overrides() + .cloned() + }); + + let db = match path { + AnySystemPath::System(path) => match self.project_db_for_path_mut(path) { + Some(db) => db, + None => self.default_project_db_mut(), + }, + AnySystemPath::SystemVirtual(_) => self.default_project_db_mut(), + }; + + db.apply_changes(changes, overrides.as_ref()) + } + + /// Returns a reference to the default project [`ProjectDatabase`]. pub(crate) fn default_project_db(&self) -> &ProjectDatabase { - &self.default_project + self.default_project.get(self.index.as_ref()) } /// Returns a mutable reference to the default project [`ProjectDatabase`]. pub(crate) fn default_project_db_mut(&mut self) -> &mut ProjectDatabase { - &mut self.default_project + self.default_project.get_mut(self.index.as_ref()) } + /// Returns a mutable iterator over all project databases that have been initialized to this point. + /// + /// This iterator will only yield the default project database if it has been used. fn projects_mut(&mut self) -> impl Iterator + '_ { - self.projects - .values_mut() - .chain(std::iter::once(&mut self.default_project)) + let default_project = self.default_project.try_get_mut(); + self.projects.values_mut().chain(default_project) } /// Returns the [`DocumentKey`] for the given URL. @@ -232,16 +251,18 @@ impl Session { assert!(!self.workspaces.all_initialized()); for (url, options) in workspace_settings { - let Some(workspace) = self.workspaces.initialize(&url, options) else { + tracing::debug!("Initializing workspace `{url}`"); + + let settings = options.into_settings(); + let Some((root, workspace)) = self.workspaces.initialize(&url, settings) else { continue; }; + // For now, create one project database per workspace. // In the future, index the workspace directories to find all projects // and create a project database for each. let system = LSPSystem::new(self.index.as_ref().unwrap().clone()); - let system_path = workspace.root(); - let root = system_path.to_path_buf(); let project = ProjectMetadata::discover(&root, &system) .context("Failed to find project configuration") .and_then(|mut metadata| { @@ -249,6 +270,11 @@ impl Session { metadata .apply_configuration_files(&system) .context("Failed to apply configuration files")?; + + if let Some(overrides) = workspace.settings.project_options_overrides() { + metadata.apply_overrides(overrides); + } + ProjectDatabase::new(metadata, system) .context("Failed to create project database") }); @@ -516,12 +542,19 @@ impl SessionSnapshot { #[derive(Debug, Default)] pub(crate) struct Workspaces { - workspaces: BTreeMap, + workspaces: BTreeMap, uninitialized: usize, } impl Workspaces { - pub(crate) fn register(&mut self, url: Url, options: ClientOptions) -> anyhow::Result<()> { + /// Registers a new workspace with the given URL and default settings for the workspace. + /// + /// It's the caller's responsibility to later call [`initialize`] with the resolved settings + /// for this workspace. Registering and initializing a workspace is a two-step process because + /// the workspace are announced to the server during the `initialize` request, but the + /// resolved settings are only available after the client has responded to the `workspace/configuration` + /// request. + pub(crate) fn register(&mut self, url: Url, settings: ClientSettings) -> anyhow::Result<()> { let path = url .to_file_path() .map_err(|()| anyhow!("Workspace URL is not a file or directory: {url:?}"))?; @@ -530,35 +563,47 @@ impl Workspaces { let system_path = SystemPathBuf::from_path_buf(path) .map_err(|_| anyhow!("Workspace URL is not valid UTF8"))?; - self.workspaces.insert( - url, - Workspace { - options, - root: system_path, - }, - ); + self.workspaces + .insert(system_path, Workspace { url, settings }); self.uninitialized += 1; Ok(()) } + /// Initializes the workspace with the resolved client settings for the workspace. + /// + /// ## Returns + /// + /// `None` if URL doesn't map to a valid path or if the workspace is not registered. pub(crate) fn initialize( &mut self, url: &Url, - options: ClientOptions, - ) -> Option<&mut Workspace> { - if let Some(workspace) = self.workspaces.get_mut(url) { - workspace.options = options; + settings: ClientSettings, + ) -> Option<(SystemPathBuf, &mut Workspace)> { + let path = url.to_file_path().ok()?; + + // Realistically I don't think this can fail because we got the path from a Url + let system_path = SystemPathBuf::from_path_buf(path).ok()?; + + if let Some(workspace) = self.workspaces.get_mut(&system_path) { + workspace.settings = settings; self.uninitialized -= 1; - Some(workspace) + Some((system_path, workspace)) } else { None } } + pub(crate) fn for_path(&self, path: impl AsRef) -> Option<&Workspace> { + self.workspaces + .range(..=path.as_ref().to_path_buf()) + .next_back() + .map(|(_, db)| db) + } + pub(crate) fn urls(&self) -> impl Iterator + '_ { - self.workspaces.keys() + self.workspaces.values().map(Workspace::url) } pub(crate) fn all_initialized(&self) -> bool { @@ -567,8 +612,8 @@ impl Workspaces { } impl<'a> IntoIterator for &'a Workspaces { - type Item = (&'a Url, &'a Workspace); - type IntoIter = std::collections::btree_map::Iter<'a, Url, Workspace>; + type Item = (&'a SystemPathBuf, &'a Workspace); + type IntoIter = std::collections::btree_map::Iter<'a, SystemPathBuf, Workspace>; fn into_iter(self) -> Self::IntoIter { self.workspaces.iter() @@ -577,12 +622,61 @@ impl<'a> IntoIterator for &'a Workspaces { #[derive(Debug)] pub(crate) struct Workspace { - root: SystemPathBuf, - options: ClientOptions, + /// The workspace root URL as sent by the client during initialization. + url: Url, + settings: ClientSettings, } impl Workspace { - pub(crate) fn root(&self) -> &SystemPath { - &self.root + pub(crate) fn url(&self) -> &Url { + &self.url + } + + pub(crate) fn settings(&self) -> &ClientSettings { + &self.settings + } +} + +/// Thin wrapper around the default project database that ensures it only gets initialized +/// when it's first accessed. +/// +/// There are a few advantages to this: +/// +/// 1. Salsa has a fast-path for query lookups for the first created database. +/// We really want that to be the actual project database and not our fallback database. +/// 2. The logs when the server starts can be confusing if it once shows it uses Python X (for the default db) +/// but then has another log that it uses Python Y (for the actual project db). +struct DefaultProject(std::sync::OnceLock); + +impl DefaultProject { + pub(crate) fn new() -> Self { + DefaultProject(std::sync::OnceLock::new()) + } + + pub(crate) fn get(&self, index: Option<&Arc>) -> &ProjectDatabase { + self.0.get_or_init(|| { + tracing::info!("Initialize default project"); + + let system = LSPSystem::new(index.unwrap().clone()); + let metadata = ProjectMetadata::from_options( + Options::default(), + system.current_directory().to_path_buf(), + None, + ) + .unwrap(); + ProjectDatabase::new(metadata, system).unwrap() + }) + } + + pub(crate) fn get_mut(&mut self, index: Option<&Arc>) -> &mut ProjectDatabase { + let _ = self.get(index); + + // SAFETY: The `OnceLock` is guaranteed to be initialized at this point because + // we called `get` above, which initializes it if it wasn't already. + self.0.get_mut().unwrap() + } + + pub(crate) fn try_get_mut(&mut self) -> Option<&mut ProjectDatabase> { + self.0.get_mut() } } diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index e73f49a11b..d4a0ed2ccb 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -1,10 +1,14 @@ use lsp_types::Url; use ruff_db::system::SystemPathBuf; +use ruff_python_ast::PythonVersion; use rustc_hash::FxHashMap; use serde::Deserialize; +use ty_project::metadata::Options; +use ty_project::metadata::options::ProjectOptionsOverrides; +use ty_project::metadata::value::{RangedValue, RelativePathBuf}; use crate::logging::LogLevel; -use crate::session::settings::ClientSettings; +use crate::session::ClientSettings; pub(crate) type WorkspaceOptionsMap = FxHashMap; @@ -43,7 +47,7 @@ struct WorkspaceOptions { } /// This is a direct representation of the settings schema sent by the client. -#[derive(Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] #[serde(rename_all = "camelCase")] pub(crate) struct ClientOptions { @@ -52,6 +56,8 @@ pub(crate) struct ClientOptions { python: Option, /// Diagnostic mode for the language server. diagnostic_mode: Option, + + python_extension: Option, } /// Diagnostic mode for the language server. @@ -75,6 +81,47 @@ impl DiagnosticMode { impl ClientOptions { /// Returns the client settings that are relevant to the language server. pub(crate) fn into_settings(self) -> ClientSettings { + let overrides = self.python_extension.and_then(|extension| { + let active_environment = extension.active_environment?; + + let mut overrides = ProjectOptionsOverrides::new(None, Options::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 the VS Code Python extension 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 the VS Code Python extension: {version} in case the configuration doesn't specify a Python version", + ); + } + + Some(overrides) + }); + ClientSettings { disable_language_services: self .python @@ -82,6 +129,7 @@ impl ClientOptions { .and_then(|ty| ty.disable_language_services) .unwrap_or_default(), diagnostic_mode: self.diagnostic_mode.unwrap_or_default(), + overrides, } } } @@ -90,14 +138,63 @@ impl ClientOptions { // would be useful to instead use `workspace/configuration` instead. This would be then used to get // all settings and not just the ones in "python.*". -#[derive(Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] #[serde(rename_all = "camelCase")] struct Python { ty: Option, } -#[derive(Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +struct PythonExtension { + active_environment: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActiveEnvironment { + pub(crate) executable: PythonExecutable, + pub(crate) environment: Option, + pub(crate) version: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[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, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[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, +} + +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[serde(rename_all = "camelCase")] +pub(crate) struct PythonExecutable { + #[allow(dead_code)] + pub(crate) uri: Url, + pub(crate) sys_prefix: SystemPathBuf, +} + +#[derive(Clone, Debug, Deserialize, Default)] #[cfg_attr(test, derive(PartialEq, Eq))] #[serde(rename_all = "camelCase")] struct Ty { diff --git a/crates/ty_server/src/session/settings.rs b/crates/ty_server/src/session/settings.rs index f24cd92350..3d8899be65 100644 --- a/crates/ty_server/src/session/settings.rs +++ b/crates/ty_server/src/session/settings.rs @@ -1,5 +1,7 @@ use super::options::DiagnosticMode; +use ty_project::metadata::options::ProjectOptionsOverrides; + /// 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. @@ -8,6 +10,7 @@ use super::options::DiagnosticMode; pub(crate) struct ClientSettings { pub(super) disable_language_services: bool, pub(super) diagnostic_mode: DiagnosticMode, + pub(super) overrides: Option, } impl ClientSettings { @@ -18,4 +21,8 @@ impl ClientSettings { pub(crate) fn diagnostic_mode(&self) -> DiagnosticMode { self.diagnostic_mode } + + pub(crate) fn project_options_overrides(&self) -> Option<&ProjectOptionsOverrides> { + self.overrides.as_ref() + } }