дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
eef24ea785
коміт
ac426c8881
12 files changed
+183
-10
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 分析器同一时间只运行一个。"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Заблокувати користувача