feat(python): Add ty and select the Python language server (#121)

ty (`ty server`) joins basedpyright and Pyright as a Python language server (fallback, gets the project venv like the others, installable from Tools). Settings › Language Servers gets a Python language server picker: Automatic (first available), basedpyright, Pyright, ty or None, written through the per-server enabled settings (null globally / true in a project for enabled, false for disabled); other combinations show as Custom combination. Picker text is translated; the hint sits on its own line.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: zzxzz12345 <[email protected]>
Цей коміт міститься в:
胡翀таzzxzz12345 authored and GitHub committed 2026-10-28 14:57:34 +08:00
1 джерело eef24ea785
коміт ac426c8881
12 files changed
+183 -10

No files matched your search

+6 -4
Переглянути файл
@@ -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);
}
}
+38
Переглянути файл
@@ -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() {
+1 -1
Переглянути файл
@@ -134,7 +134,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
}
}
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) {
+10
Переглянути файл
@@ -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",
+5
Переглянути файл
@@ -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);
+8
Переглянути файл
@@ -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"
+15
Переглянути файл
@@ -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;
+43 -1
Переглянути файл
@@ -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<string, ServerEntry>): 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<ToolInfo["status"], "success" | "accent" | "warning"> = {
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.",
)}
</p>
<div className={styles.pythonAnalyzer}>
<div className={styles.pythonAnalyzerRow}>
<span>
<T>Python language server</T>
</span>
<Select
aria-label="Python language server"
value={selectedPythonAnalyzer(servers)}
options={PYTHON_ANALYZER_OPTIONS}
onChange={(selected) => {
if (selected === "custom") return;
for (const name of PYTHON_ANALYZERS) {
const on = selected === "auto" || selected === name;
// Same as each server's own checkbox: on is the default, so only a project spells it out.
form.set(serverKey(name, "enabled"), on ? (form.scope === "project" ? true : null) : false);
}
}}
/>
</div>
<p className={styles.pythonAnalyzerHint}>
<T>Only one of these built-in Python analyzers runs at a time.</T>
</p>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
+40
Переглянути файл
@@ -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(
<TestSettingsForm scope="project">
<LanguageServersPage />
</TestSettingsForm>,
);
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();
+9 -3
Переглянути файл
@@ -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<SettingsForm>(
() => ({
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 <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
}
+5 -1
Переглянути файл
@@ -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 分析器同一时间只运行一个。"
}
+3
Переглянути файл
@@ -88,6 +88,7 @@ const UNTRANSLATED = new Set<string>([
// Products, tools and formats
"Amazon Aurora MySQL",
"Amazon Redshift",
"basedpyright",
"Cargo Check",
"Clippy",
"CockroachDB",
@@ -123,6 +124,7 @@ const UNTRANSLATED = new Set<string>([
"npm",
"OpenIDEA",
"PostgreSQL",
"Pyright",
"Rust",
"Rustfmt",
"SingleStore",
@@ -131,6 +133,7 @@ const UNTRANSLATED = new Set<string>([
"TiDB",
"TimescaleDB",
"TSV",
"ty",
"TypeScript",
"Vim",
"YugabyteDB",