From b55c398fb5f784751fcffc778a4d00a4a49f1343 Mon Sep 17 00:00:00 2001 From: Piettro <2058578+pcastro93@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:45 -0500 Subject: [PATCH] feat(python): add Pyrefly language server (#911) Pyrefly (`pyrefly lsp`) joins basedpyright, Pyright and ty as a fallback Python language server. It is installable from Tools as a managed virtualenv tool and is listed among the interchangeable type checkers, before Ruff, so Ruff still runs after them. Pyrefly only reports type errors in projects with a Pyrefly configuration (`pyrefly.toml` or `[tool.pyrefly]`); hover, completion and navigation work without one. A real-server test (ignored by default, needs `pyrefly` on PATH) covers startup, go-to-definition and diagnostics. Co-authored-by: pcastro93 <2058578+pcastro93@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 4 +- crates/ide-lsp/tests/real_servers.rs | 40 +++++++++++++++++++ crates/ide-sdk/src/apply.rs | 2 +- crates/ide-tools/src/registry.rs | 11 +++++ crates/ide-tools/src/tests.rs | 7 +++- languages/python.toml | 8 ++++ .../tools/__tests__/serverSettings.test.tsx | 7 ++-- src/features/tools/backend.ts | 13 ++++-- src/platform/locales/textCatalog.test.ts | 1 + 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index d777ba37..ee48ab61 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -553,7 +553,7 @@ mod tests { fn alternative_servers_are_fallbacks() { let reg = reg(); for (lang, servers) in [ - ("python", vec!["basedpyright", "pyright", "ty"]), + ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -610,7 +610,7 @@ mod tests { let cfg = reg.get(&LanguageId::new("python")).unwrap(); let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); // After the fallbacks: hover, rename and symbols stay with the type checker. - assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]); + assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]); let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); assert!(ruff.require_root_marker && !ruff.fallback); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index ace06401..4ed35010 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -250,6 +250,46 @@ async fn ruff_next_to_basedpyright_end_to_end() { tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires pyrefly on PATH"] +async fn pyrefly_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(); + // Pyrefly only reports type errors in projects with a Pyrefly configuration. + std::fs::write(root.join("pyrefly.toml"), "").unwrap(); + let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap(); + python.language_servers.retain(|server| server.name == "pyrefly"); + 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("pyrefly ready", Duration::from_secs(30), || { + let status = last_status(&events, "pyrefly"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("pyrefly failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let defs = retry("pyrefly 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("pyrefly diagnostics", Duration::from_secs(30), || { + let found = events.lock().iter().any( + |event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "pyrefly" && !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 ec173c95..3f368d46 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -204,7 +204,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option { } } -const PYTHON_SERVERS: [&str; 6] = ["basedpyright", "pyright", "ty", "pylsp", "jedi-language-server", "ruff"]; +const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "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 755da795..6602f809 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -233,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://docs.astral.sh/ty/", needs: &[], }, + ToolSpec { + name: "pyrefly", + display: "Pyrefly (Python)", + kind: LanguageServer, + commands: &["pyrefly"], + method: Method::PipVenv { package: "pyrefly" }, + fallback: None, + version: ver("pyrefly"), + homepage: "https://pyrefly.org", + needs: &[], + }, ToolSpec { name: "ruff", display: "Ruff", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 40112196..7b15ba32 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -130,6 +130,11 @@ fn everything_missing_on_an_empty_system() { assert_eq!(ty.command, "ty"); assert_eq!(ty.install_method.as_deref(), Some("pip (virtualenv)")); assert!(ty.languages.iter().any(|language| language == "Python")); + let pyrefly = find(&list, "pyrefly"); + assert_eq!(pyrefly.status, ToolStatus::Missing); + assert_eq!(pyrefly.command, "pyrefly"); + assert_eq!(pyrefly.install_method.as_deref(), Some("pip (virtualenv)")); + assert!(pyrefly.languages.iter().any(|language| language == "Python")); let angular = find(&list, "angular-language-server"); assert_eq!(angular.command, "ngserver"); assert_eq!(angular.install_method.as_deref(), Some("npm")); @@ -150,7 +155,7 @@ fn everything_missing_on_an_empty_system() { fn interchangeable_servers_list_their_alternatives() { let env = Env::new(); let list = env.tools().list(&langs()); - let python = ["basedpyright", "pyright", "ty"]; + let python = ["basedpyright", "pyright", "ty", "pyrefly"]; for name in python { assert_eq!(find(&list, name).alternatives, python); } diff --git a/languages/python.toml b/languages/python.toml index 1d3b9504..a9b4ea92 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -40,6 +40,14 @@ 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)" +[[language-servers]] +name = "pyrefly" +fallback = true +command = "pyrefly" +args = ["lsp"] +root-markers = ["pyrefly.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] +install-hint = "uv tool install pyrefly (or: pip install pyrefly)" + # Lint, format, organize imports and fix all next to the type checker, in # projects configured for Ruff (formatting is Ruff's alone: basedpyright has none). [[language-servers]] diff --git a/src/features/tools/__tests__/serverSettings.test.tsx b/src/features/tools/__tests__/serverSettings.test.tsx index 2f96403d..a176a994 100644 --- a/src/features/tools/__tests__/serverSettings.test.tsx +++ b/src/features/tools/__tests__/serverSettings.test.tsx @@ -89,7 +89,7 @@ describe("Language Servers page settings", () => { const ty = details("ty (Python)"); expect(within(ty).getByRole("button", { name: "Language server for Python" })).toHaveTextContent("Automatic"); // The language is in the label already, so names drop a "(Python)" suffix. - expect(within(ty).getByText(/basedpyright → Pyright → ty\.$/)).toBeInTheDocument(); + expect(within(ty).getByText(/basedpyright → Pyright → ty → Pyrefly\.$/)).toBeInTheDocument(); await user.click(listItem("gopls")!); expect(within(details("gopls")).getByRole("checkbox", { name: /Use gopls/ })).toBeInTheDocument(); expect(within(details("gopls")).queryByRole("button", { name: /^Language server for/ })).toBeNull(); @@ -108,7 +108,7 @@ describe("Language Servers page settings", () => { expect(picker).toHaveTextContent("ty"); await user.click(picker); await user.click(screen.getByRole("option", { name: /Automatic/ })); - for (const name of ["basedpyright", "pyright", "ty"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBeNull(); expect(useTestForm.getState().values).not.toHaveProperty(`languageServers.${name}.enabled`); } @@ -127,10 +127,11 @@ describe("Language Servers page settings", () => { expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false); expect(stagedFor("languageServers.pyright.enabled")).toBe(true); expect(stagedFor("languageServers.ty.enabled")).toBe(false); + expect(stagedFor("languageServers.pyrefly.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"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBe(true); } }); diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index f472418a..f9dded63 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -94,13 +94,20 @@ export function sampleTools(): ToolInfo[] { version: "4.3.3", path: "/opt/homebrew/bin/typescript-language-server", }), - ...["basedpyright", "pyright", "ty"].map((name) => + ...["basedpyright", "pyright", "ty", "pyrefly"].map((name) => tool({ name, - displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, + displayName: + name === "pyright" + ? "Pyright" + : name === "ty" + ? "ty (Python)" + : name === "pyrefly" + ? "Pyrefly (Python)" + : name, languages: ["Python"], installMethod: "uv (virtualenv)", - alternatives: ["basedpyright", "pyright", "ty"], + alternatives: ["basedpyright", "pyright", "ty", "pyrefly"], }), ), tool({ name: "gopls", languages: ["Go"], installMethod: "go install" }), diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 1bba83ce..380a72fa 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -149,6 +149,7 @@ const UNTRANSLATED = new Set([ "OpenIDEA", "Spring Boot", "PostgreSQL", + "Pyrefly", "Pyright", "pytest", "Rust",