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 + +