[ty] Use python version and path from Python extension (#19012)

This commit is contained in:
Micha Reiser 2025-07-14 11:47:27 +02:00 committed by GitHub
parent 26f736bc46
commit 90026047f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 344 additions and 99 deletions

View file

@ -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);

View file

@ -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();
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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();

View file

@ -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();

View file

@ -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![] },
));

View file

@ -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<SystemPathBuf, ProjectDatabase>,
default_project: ProjectDatabase,
/// The project to use for files outside any workspace. For example, if the user
/// opens the project `<home>/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<ChangeEvent>,
) -> 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<Item = &'_ mut ProjectDatabase> + '_ {
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<Url, Workspace>,
workspaces: BTreeMap<SystemPathBuf, Workspace>,
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<SystemPath>) -> Option<&Workspace> {
self.workspaces
.range(..=path.as_ref().to_path_buf())
.next_back()
.map(|(_, db)| db)
}
pub(crate) fn urls(&self) -> impl Iterator<Item = &Url> + '_ {
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<ProjectDatabase>);
impl DefaultProject {
pub(crate) fn new() -> Self {
DefaultProject(std::sync::OnceLock::new())
}
pub(crate) fn get(&self, index: Option<&Arc<Index>>) -> &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<Index>>) -> &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()
}
}

View file

@ -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<Url, ClientOptions>;
@ -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<Python>,
/// Diagnostic mode for the language server.
diagnostic_mode: Option<DiagnosticMode>,
python_extension: Option<PythonExtension>,
}
/// 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<Ty>,
}
#[derive(Debug, Deserialize, Default)]
#[derive(Clone, Debug, Deserialize, Default)]
#[cfg_attr(test, derive(PartialEq, Eq))]
#[serde(rename_all = "camelCase")]
struct PythonExtension {
active_environment: Option<ActiveEnvironment>,
}
#[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<PythonEnvironment>,
pub(crate) version: Option<EnvironmentVersion>,
}
#[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<String>,
}
#[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 {

View file

@ -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<ProjectOptionsOverrides>,
}
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()
}
}