python: Add built-in support for Ruff (#37804)

Release Notes:

- python: The Ruff native language server is now available without
installing an extension.

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
This commit is contained in:
Cole Miller
2025-09-11 20:52:07 +00:00
committed by GitHub
co-authored by Piotr Osiewicz
parent 7655e22ff5
commit d5d30b5c44
3 changed files with 246 additions and 3 deletions
+5
View File
@@ -1692,6 +1692,11 @@
"allow_rewrap": "anywhere"
},
"Python": {
"formatter": {
"language_server": {
"name": "ruff"
}
},
"debuggers": ["Debugpy"]
},
"Ruby": {
+6 -2
View File
@@ -12,7 +12,10 @@ use util::{ResultExt, asset_str};
pub use language::*;
use crate::{json::JsonTaskProvider, python::BasedPyrightLspAdapter};
use crate::{
json::JsonTaskProvider,
python::{BasedPyrightLspAdapter, RuffLspAdapter},
};
mod bash;
mod c;
@@ -91,6 +94,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
let python_context_provider = Arc::new(python::PythonContextProvider);
let python_lsp_adapter = Arc::new(python::PythonLspAdapter::new(node.clone()));
let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new());
let ruff_lsp_adapter = Arc::new(RuffLspAdapter::new(fs.clone()));
let python_toolchain_provider = Arc::new(python::PythonToolchainProvider);
let rust_context_provider = Arc::new(rust::RustContextProvider);
let rust_lsp_adapter = Arc::new(rust::RustLspAdapter);
@@ -171,7 +175,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
},
LanguageInfo {
name: "python",
adapters: vec![basedpyright_lsp_adapter],
adapters: vec![basedpyright_lsp_adapter, ruff_lsp_adapter],
context: Some(python_context_provider),
toolchain: Some(python_toolchain_provider),
manifest_name: Some(SharedString::new_static("pyproject.toml").into()),
+235 -1
View File
@@ -2,9 +2,12 @@ use anyhow::{Context as _, ensure};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use collections::HashMap;
use futures::AsyncBufReadExt;
use dap::adapters::latest_github_release;
use futures::{AsyncBufReadExt, StreamExt as _};
use gpui::{App, Task};
use gpui::{AsyncApp, SharedString};
use http_client::github::AssetKind;
use http_client::github::GitHubLspBinaryVersion;
use language::ToolchainList;
use language::ToolchainLister;
use language::language_settings::language_settings;
@@ -23,6 +26,8 @@ use project::lsp_store::language_server_settings;
use serde_json::{Value, json};
use smol::lock::OnceCell;
use std::cmp::Ordering;
use std::env::consts;
use util::fs::{make_file_executable, remove_matching};
use parking_lot::Mutex;
use std::str::FromStr;
@@ -37,6 +42,8 @@ use std::{
use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
use util::{ResultExt, maybe};
use crate::github_download::{GithubBinaryMetadata, download_server_binary};
pub(crate) struct PyprojectTomlManifestProvider;
impl ManifestProvider for PyprojectTomlManifestProvider {
@@ -1653,6 +1660,233 @@ impl LspAdapter for BasedPyrightLspAdapter {
}
}
pub(crate) struct RuffLspAdapter {
fs: Arc<dyn Fs>,
}
#[cfg(target_os = "macos")]
impl RuffLspAdapter {
const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
const ARCH_SERVER_NAME: &str = "apple-darwin";
}
#[cfg(target_os = "linux")]
impl RuffLspAdapter {
const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
const ARCH_SERVER_NAME: &str = "unknown-linux-gnu";
}
#[cfg(target_os = "freebsd")]
impl RuffLspAdapter {
const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
const ARCH_SERVER_NAME: &str = "unknown-freebsd";
}
#[cfg(target_os = "windows")]
impl RuffLspAdapter {
const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
const ARCH_SERVER_NAME: &str = "pc-windows-msvc";
}
impl RuffLspAdapter {
const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ruff");
pub fn new(fs: Arc<dyn Fs>) -> RuffLspAdapter {
RuffLspAdapter { fs }
}
fn build_asset_name() -> Result<(String, String)> {
let arch = match consts::ARCH {
"x86" => "i686",
_ => consts::ARCH,
};
let os = Self::ARCH_SERVER_NAME;
let suffix = match consts::OS {
"windows" => "zip",
_ => "tar.gz",
};
let asset_name = format!("ruff-{arch}-{os}.{suffix}");
let asset_stem = format!("ruff-{arch}-{os}");
Ok((asset_stem, asset_name))
}
}
#[async_trait(?Send)]
impl LspAdapter for RuffLspAdapter {
fn name(&self) -> LanguageServerName {
Self::SERVER_NAME
}
async fn check_if_user_installed(
&self,
delegate: &dyn LspAdapterDelegate,
toolchain: Option<Toolchain>,
_: &AsyncApp,
) -> Option<LanguageServerBinary> {
let ruff_in_venv = if let Some(toolchain) = toolchain
&& toolchain.language_name.as_ref() == "Python"
{
Path::new(toolchain.path.as_str())
.parent()
.map(|path| path.join("ruff"))
} else {
None
};
for path in ruff_in_venv.into_iter().chain(["ruff".into()]) {
if let Some(ruff_bin) = delegate.which(path.as_os_str()).await {
let env = delegate.shell_env().await;
return Some(LanguageServerBinary {
path: ruff_bin,
env: Some(env),
arguments: vec!["server".into()],
});
}
}
None
}
async fn fetch_latest_server_version(
&self,
delegate: &dyn LspAdapterDelegate,
_: &AsyncApp,
) -> Result<Box<dyn 'static + Send + Any>> {
let release =
latest_github_release("astral-sh/ruff", true, false, delegate.http_client()).await?;
let (_, asset_name) = Self::build_asset_name()?;
let asset = release
.assets
.into_iter()
.find(|asset| asset.name == asset_name)
.with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
Ok(Box::new(GitHubLspBinaryVersion {
name: release.tag_name,
url: asset.browser_download_url,
digest: asset.digest,
}))
}
async fn fetch_server_binary(
&self,
latest_version: Box<dyn 'static + Send + Any>,
container_dir: PathBuf,
delegate: &dyn LspAdapterDelegate,
) -> Result<LanguageServerBinary> {
let GitHubLspBinaryVersion {
name,
url,
digest: expected_digest,
} = *latest_version.downcast::<GitHubLspBinaryVersion>().unwrap();
let destination_path = container_dir.join(format!("ruff-{name}"));
let server_path = match Self::GITHUB_ASSET_KIND {
AssetKind::TarGz | AssetKind::Gz => destination_path
.join(Self::build_asset_name()?.0)
.join("ruff"),
AssetKind::Zip => destination_path.clone().join("ruff.exe"),
};
let binary = LanguageServerBinary {
path: server_path.clone(),
env: None,
arguments: vec!["server".into()],
};
let metadata_path = destination_path.with_extension("metadata");
let metadata = GithubBinaryMetadata::read_from_file(&metadata_path)
.await
.ok();
if let Some(metadata) = metadata {
let validity_check = async || {
delegate
.try_exec(LanguageServerBinary {
path: server_path.clone(),
arguments: vec!["--version".into()],
env: None,
})
.await
.inspect_err(|err| {
log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",)
})
};
if let (Some(actual_digest), Some(expected_digest)) =
(&metadata.digest, &expected_digest)
{
if actual_digest == expected_digest {
if validity_check().await.is_ok() {
return Ok(binary);
}
} else {
log::info!(
"SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}"
);
}
} else if validity_check().await.is_ok() {
return Ok(binary);
}
}
download_server_binary(
delegate,
&url,
expected_digest.as_deref(),
&destination_path,
Self::GITHUB_ASSET_KIND,
)
.await?;
make_file_executable(&server_path).await?;
remove_matching(&container_dir, |path| path != destination_path).await;
GithubBinaryMetadata::write_to_file(
&GithubBinaryMetadata {
metadata_version: 1,
digest: expected_digest,
},
&metadata_path,
)
.await?;
Ok(LanguageServerBinary {
path: server_path,
env: None,
arguments: vec!["server".into()],
})
}
async fn cached_server_binary(
&self,
container_dir: PathBuf,
_: &dyn LspAdapterDelegate,
) -> Option<LanguageServerBinary> {
maybe!(async {
let mut last = None;
let mut entries = self.fs.read_dir(&container_dir).await?;
while let Some(entry) = entries.next().await {
let path = entry?;
if path.extension().is_some_and(|ext| ext == "metadata") {
continue;
}
last = Some(path);
}
let path = last.context("no cached binary")?;
let path = match Self::GITHUB_ASSET_KIND {
AssetKind::TarGz | AssetKind::Gz => {
path.join(Self::build_asset_name()?.0).join("ruff")
}
AssetKind::Zip => path.join("ruff.exe"),
};
anyhow::Ok(LanguageServerBinary {
path,
env: None,
arguments: vec!["server".into()],
})
})
.await
.log_err()
}
}
#[cfg(test)]
mod tests {
use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};