diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs
index f3103be8d..468d51c41 100644
--- a/crates/ide-language/src/registry.rs
+++ b/crates/ide-language/src/registry.rs
@@ -518,12 +518,14 @@ mod tests {
#[test]
fn alternative_servers_are_fallbacks() {
let reg = reg();
- for (lang, servers) in
- [("python", ["basedpyright", "pyright"]), ("typescript", ["vtsls", "typescript-language-server"])]
- {
+ for (lang, servers) in [
+ ("python", vec!["basedpyright", "pyright", "ty"]),
+ ("typescript", vec!["vtsls", "typescript-language-server"]),
+ ] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let got: Vec<(&str, bool)> = cfg.language_servers.iter().map(|s| (s.name.as_str(), s.fallback)).collect();
- assert_eq!(got, [(servers[0], false), (servers[1], true)]);
+ let expected: Vec<(&str, bool)> = servers.iter().enumerate().map(|(i, name)| (*name, i > 0)).collect();
+ assert_eq!(got, expected);
}
}
diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs
index b0ea17015..8386146e0 100644
--- a/crates/ide-lsp/tests/real_servers.rs
+++ b/crates/ide-lsp/tests/real_servers.rs
@@ -105,6 +105,44 @@ fn main() {
}
";
+#[tokio::test(flavor = "multi_thread")]
+#[ignore = "requires ty on PATH"]
+async fn ty_python_end_to_end() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dir.path().canonicalize().unwrap();
+ let path = root.join("example.py");
+ let source = "def greet(name: str) -> str:\n return name\n\nresult = greet('Ada')\nbad: int = 'wrong'\n";
+ std::fs::write(&path, source).unwrap();
+ let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
+ python.language_servers.retain(|server| server.name == "ty");
+ assert_eq!(python.language_servers.len(), 1);
+ let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
+ let doc = DocId::next();
+ mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
+ retry("ty ready", Duration::from_secs(30), || {
+ let status = last_status(&events, "ty");
+ if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
+ panic!("ty failed: {msg:?}");
+ }
+ async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
+ })
+ .await;
+ let defs = retry("ty definition", Duration::from_secs(30), || {
+ let mgr = mgr.clone();
+ async move { mgr.goto(doc, Pos::new(3, 10), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) }
+ })
+ .await;
+ assert_eq!(defs[0].path, path);
+ assert_eq!(defs[0].range.start.line, 0);
+ retry("ty diagnostics", Duration::from_secs(30), || {
+ let found = events.lock().iter().any(
+ |event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "ty" && !items.is_empty()),
+ );
+ async move { found.then_some(()) }
+ })
+ .await;
+}
+
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires rust-analyzer"]
async fn rust_analyzer_end_to_end() {
diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs
index 5d3c70cb1..b8b36fbb0 100644
--- a/crates/ide-sdk/src/apply.rs
+++ b/crates/ide-sdk/src/apply.rs
@@ -134,7 +134,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option {
}
}
-const PYTHON_SERVERS: [&str; 5] = ["basedpyright", "pyright", "pylsp", "jedi-language-server", "ruff"];
+const PYTHON_SERVERS: [&str; 6] = ["basedpyright", "pyright", "ty", "pylsp", "jedi-language-server", "ruff"];
fn merge(base: &mut Value, layer: Value) {
match (base, layer) {
diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs
index 7ca612b8b..591bb735d 100644
--- a/crates/ide-tools/src/registry.rs
+++ b/crates/ide-tools/src/registry.rs
@@ -167,6 +167,16 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://docs.basedpyright.com",
needs: &[],
},
+ ToolSpec {
+ name: "ty",
+ display: "ty (Python)",
+ kind: LanguageServer,
+ commands: &["ty"],
+ method: Method::PipVenv { package: "ty" },
+ version: ver("ty"),
+ homepage: "https://docs.astral.sh/ty/",
+ needs: &[],
+ },
ToolSpec {
name: "bash-language-server",
display: "Bash Language Server",
diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs
index b6b9b8a67..021753d31 100644
--- a/crates/ide-tools/src/tests.rs
+++ b/crates/ide-tools/src/tests.rs
@@ -67,6 +67,11 @@ fn everything_missing_on_an_empty_system() {
let vtsls = find(&list, "vtsls");
assert!(vtsls.languages.iter().any(|l| l == "TypeScript"));
assert!(vtsls.unavailable_reason.as_deref().unwrap().contains("npm"));
+ let ty = find(&list, "ty");
+ assert_eq!(ty.status, ToolStatus::Missing);
+ assert_eq!(ty.command, "ty");
+ assert_eq!(ty.install_method.as_deref(), Some("pip (virtualenv)"));
+ assert!(ty.languages.iter().any(|language| language == "Python"));
// Servers without an installer are listed with their manual hint.
let hls = find(&list, "haskell-language-server");
assert_eq!(hls.install_method, None);
diff --git a/languages/python.toml b/languages/python.toml
index 3dd270bc8..b450c3dd7 100644
--- a/languages/python.toml
+++ b/languages/python.toml
@@ -32,6 +32,14 @@ args = ["--stdio"]
root-markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]
install-hint = "npm i -g pyright (or: pip install pyright)"
+[[language-servers]]
+name = "ty"
+fallback = true
+command = "ty"
+args = ["server"]
+root-markers = ["ty.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]
+install-hint = "uv tool install ty (or: pip install ty)"
+
[debugger]
adapter = "debugpy"
command = "python3"
diff --git a/src/features/tools/LanguageServersPage.module.css b/src/features/tools/LanguageServersPage.module.css
index 1c1eff93e..926d001a4 100644
--- a/src/features/tools/LanguageServersPage.module.css
+++ b/src/features/tools/LanguageServersPage.module.css
@@ -11,6 +11,21 @@
max-width: 680px;
color: var(--fg-secondary);
}
+.pythonAnalyzer {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.pythonAnalyzerRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.pythonAnalyzerHint {
+ margin: 0;
+ color: var(--fg-secondary);
+ font-size: var(--fs-small);
+}
.body {
display: flex;
flex: 1;
diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx
index 70373bbff..509d3725e 100644
--- a/src/features/tools/LanguageServersPage.tsx
+++ b/src/features/tools/LanguageServersPage.tsx
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { ToolInfo } from "@/bindings";
-import { Badge, Button, EmptyState, IconButton, ProgressBar, SearchField, Spinner, T } from "@/ui";
+import { Badge, Button, EmptyState, IconButton, ProgressBar, SearchField, Select, Spinner, T } from "@/ui";
import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useSettingsForm } from "@/workbench/contributions";
@@ -28,6 +28,25 @@ import { tx } from "@/platform/i18n";
type Filter = "all" | "missing" | "installed";
+const PYTHON_ANALYZERS = ["basedpyright", "pyright", "ty"] as const;
+type PythonAnalyzer = (typeof PYTHON_ANALYZERS)[number] | "auto" | "none" | "custom";
+
+function selectedPythonAnalyzer(servers: Record): PythonAnalyzer {
+ const enabled = PYTHON_ANALYZERS.filter((name) => servers[name]?.enabled !== false);
+ if (enabled.length === PYTHON_ANALYZERS.length) return "auto";
+ if (enabled.length === 0) return "none";
+ return enabled.length === 1 ? enabled[0]! : "custom";
+}
+
+const PYTHON_ANALYZER_OPTIONS: ReadonlyArray<{ value: PythonAnalyzer; label: string; disabled?: boolean }> = [
+ { value: "auto", label: "Automatic (first available)" },
+ { value: "basedpyright", label: "basedpyright" },
+ { value: "pyright", label: "Pyright" },
+ { value: "ty", label: "ty" },
+ { value: "none", label: "None" },
+ { value: "custom", label: "Custom combination", disabled: true },
+];
+
const STATUS_TONE: Record = {
installed: "success",
onPath: "accent",
@@ -317,6 +336,29 @@ export function LanguageServersPage() {
"Language servers and debug adapters used by the IDE. Missing tools can be installed into the IDE's own tools folder; nothing is downloaded until you click Install.",
)}
+
+
+
+ Python language server
+
+
+
+ Only one of these built-in Python analyzers runs at a time.
+
+
{error && (
{error}
diff --git a/src/features/tools/__tests__/serverSettings.test.tsx b/src/features/tools/__tests__/serverSettings.test.tsx
index d7115024f..882a1d243 100644
--- a/src/features/tools/__tests__/serverSettings.test.tsx
+++ b/src/features/tools/__tests__/serverSettings.test.tsx
@@ -73,6 +73,46 @@ describe("Language Servers page settings", () => {
afterEach(() => setToolsBackend(null));
+ it("selects exactly one Python analyzer and restores automatic fallback without writing defaults", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ const picker = screen.getByRole("button", { name: "Python language server" });
+ expect(picker).toHaveTextContent("Automatic");
+ await user.click(picker);
+ await user.click(screen.getByRole("option", { name: "ty" }));
+ expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false);
+ expect(stagedFor("languageServers.pyright.enabled")).toBe(false);
+ expect(stagedFor("languageServers.ty.enabled")).toBeNull();
+ expect(picker).toHaveTextContent("ty");
+ await user.click(picker);
+ await user.click(screen.getByRole("option", { name: /Automatic/ }));
+ for (const name of ["basedpyright", "pyright", "ty"]) {
+ expect(stagedFor(`languageServers.${name}.enabled`)).toBeNull();
+ expect(useTestForm.getState().values).not.toHaveProperty(`languageServers.${name}.enabled`);
+ }
+ });
+
+ it("spells out the chosen Python analyzer in project settings", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const picker = screen.getByRole("button", { name: "Python language server" });
+ await user.click(picker);
+ await user.click(screen.getByRole("option", { name: "Pyright" }));
+ expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false);
+ expect(stagedFor("languageServers.pyright.enabled")).toBe(true);
+ expect(stagedFor("languageServers.ty.enabled")).toBe(false);
+ expect(picker).toHaveTextContent("Pyright");
+ await user.click(picker);
+ await user.click(screen.getByRole("option", { name: /Automatic/ }));
+ for (const name of ["basedpyright", "pyright", "ty"]) {
+ expect(stagedFor(`languageServers.${name}.enabled`)).toBe(true);
+ }
+ });
+
it("stages startup options, showing the defaults they replace", async () => {
const user = userEvent.setup();
renderPage();
diff --git a/src/features/tools/__tests__/settingsForm.tsx b/src/features/tools/__tests__/settingsForm.tsx
index 5682aea7c..5e46a6b5e 100644
--- a/src/features/tools/__tests__/settingsForm.tsx
+++ b/src/features/tools/__tests__/settingsForm.tsx
@@ -34,15 +34,21 @@ export function stagedFor(key: string): unknown {
return useTestForm.getState().staged.findLast(([k]) => k === key)?.[1];
}
-export function TestSettingsForm({ children }: { children: ReactNode }) {
+export function TestSettingsForm({
+ children,
+ scope = "global",
+}: {
+ children: ReactNode;
+ scope?: SettingsForm["scope"];
+}) {
const form = useMemo(
() => ({
- scope: "global",
+ scope,
useValue: (key) => useTestForm((s) => getAt(s.values, key)),
set: (key, value) =>
useTestForm.setState((s) => ({ values: putAt(s.values, key, value), staged: [...s.staged, [key, value]] })),
}),
- [],
+ [scope],
);
return {children};
}
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index 5a97da4a8..7848331f9 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -4473,5 +4473,9 @@
"Renamed": "已重命名",
"Unversioned": "未跟踪",
"Merge conflict": "合并冲突",
- "by {author}": "由 {author} 创建"
+ "by {author}": "由 {author} 创建",
+ "Automatic (first available)": "自动(第一个可用的)",
+ "Custom combination": "自定义组合",
+ "Python language server": "Python 语言服务器",
+ "Only one of these built-in Python analyzers runs at a time.": "这些内置 Python 分析器同一时间只运行一个。"
}
diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts
index 3025e8dc6..f51e8000c 100644
--- a/src/platform/locales/textCatalog.test.ts
+++ b/src/platform/locales/textCatalog.test.ts
@@ -88,6 +88,7 @@ const UNTRANSLATED = new Set([
// Products, tools and formats
"Amazon Aurora MySQL",
"Amazon Redshift",
+ "basedpyright",
"Cargo Check",
"Clippy",
"CockroachDB",
@@ -123,6 +124,7 @@ const UNTRANSLATED = new Set([
"npm",
"OpenIDEA",
"PostgreSQL",
+ "Pyright",
"Rust",
"Rustfmt",
"SingleStore",
@@ -131,6 +133,7 @@ const UNTRANSLATED = new Set([
"TiDB",
"TimescaleDB",
"TSV",
+ "ty",
"TypeScript",
"Vim",
"YugabyteDB",