From ef9623ecd2281198fcba2f29ad648fa8b27e2121 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:19:31 -0400 Subject: [PATCH 1/8] Migrate /api/interpreters endpoint from Go to TypeScript Move interpreter detection (Python/R version, lockfiles, package managers) from the Go backend API into a new TypeScript module in the VSCode extension. This eliminates the HTTP round-trip to the Go process for logic that can run directly in TypeScript using child_process. - Add src/interpreters/ module with Python and R detection, version constraint parsing, and project metadata reading (.python-version, pyproject.toml, setup.cfg, DESCRIPTION, renv.lock) - Replace api.interpreters.get() calls in state.ts with getInterpreterDefaults() - Remove GET /api/interpreters Go endpoint and route registration - Remove Interpreters API resource class from TypeScript client - Add 62 unit tests covering all new modules Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/api/client.ts | 3 - .../vscode/src/api/resources/Interpreters.ts | 31 --- extensions/vscode/src/interpreters/index.ts | 37 +++ .../interpreters/pythonInterpreter.test.ts | 179 ++++++++++++++ .../src/interpreters/pythonInterpreter.ts | 91 +++++++ .../src/interpreters/pythonRequires.test.ts | 137 +++++++++++ .../vscode/src/interpreters/pythonRequires.ts | 133 ++++++++++ .../src/interpreters/rInterpreter.test.ts | 211 ++++++++++++++++ .../vscode/src/interpreters/rInterpreter.ts | 139 +++++++++++ .../vscode/src/interpreters/rRequires.test.ts | 137 +++++++++++ .../vscode/src/interpreters/rRequires.ts | 96 ++++++++ .../interpreters/versionConstraints.test.ts | 62 +++++ .../src/interpreters/versionConstraints.ts | 65 +++++ extensions/vscode/src/state.test.ts | 21 +- extensions/vscode/src/state.ts | 17 +- internal/services/api/api_service.go | 4 - internal/services/api/get_interpreters.go | 70 ------ .../services/api/get_interpreters_test.go | 230 ------------------ 18 files changed, 1307 insertions(+), 356 deletions(-) delete mode 100644 extensions/vscode/src/api/resources/Interpreters.ts create mode 100644 extensions/vscode/src/interpreters/index.ts create mode 100644 extensions/vscode/src/interpreters/pythonInterpreter.test.ts create mode 100644 extensions/vscode/src/interpreters/pythonInterpreter.ts create mode 100644 extensions/vscode/src/interpreters/pythonRequires.test.ts create mode 100644 extensions/vscode/src/interpreters/pythonRequires.ts create mode 100644 extensions/vscode/src/interpreters/rInterpreter.test.ts create mode 100644 extensions/vscode/src/interpreters/rInterpreter.ts create mode 100644 extensions/vscode/src/interpreters/rRequires.test.ts create mode 100644 extensions/vscode/src/interpreters/rRequires.ts create mode 100644 extensions/vscode/src/interpreters/versionConstraints.test.ts create mode 100644 extensions/vscode/src/interpreters/versionConstraints.ts delete mode 100644 internal/services/api/get_interpreters.go delete mode 100644 internal/services/api/get_interpreters_test.go diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index d655e2c8a0..029dedb0df 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -6,7 +6,6 @@ import { Credentials } from "./resources/Credentials"; import { ContentRecords } from "./resources/ContentRecords"; import { Configurations } from "./resources/Configurations"; import { Files } from "./resources/Files"; -import { Interpreters } from "./resources/Interpreters"; import { Packages } from "./resources/Packages"; import { Secrets } from "./resources/Secrets"; import { SnowflakeConnections } from "./resources/SnowflakeConnections"; @@ -20,7 +19,6 @@ class PublishingClientApi { private client; configurations: Configurations; - interpreters: Interpreters; credentials: Credentials; contentRecords: ContentRecords; files: Files; @@ -57,7 +55,6 @@ class PublishingClientApi { this.credentials = new Credentials(this.client); this.contentRecords = new ContentRecords(this.client); this.files = new Files(this.client); - this.interpreters = new Interpreters(this.client); this.packages = new Packages(this.client); this.secrets = new Secrets(this.client); this.integrationRequests = new IntegrationRequests(this.client); diff --git a/extensions/vscode/src/api/resources/Interpreters.ts b/extensions/vscode/src/api/resources/Interpreters.ts deleted file mode 100644 index 57f90a6029..0000000000 --- a/extensions/vscode/src/api/resources/Interpreters.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) 2025 by Posit Software, PBC. - -import { AxiosInstance } from "axios"; - -import { PythonExecutable, RExecutable } from "../../types/shared"; -import { InterpreterDefaults } from "../types/interpreters"; - -export class Interpreters { - private client: AxiosInstance; - - constructor(client: AxiosInstance) { - this.client = client; - } - - // Returns: - // 200 - success - // 500 - internal server error - get( - dir: string, - r: RExecutable | undefined, - python: PythonExecutable | undefined, - ) { - return this.client.get(`/interpreters`, { - params: { - dir, - r: r !== undefined ? r.rPath : "", - python: python !== undefined ? python.pythonPath : "", - }, - }); - } -} diff --git a/extensions/vscode/src/interpreters/index.ts b/extensions/vscode/src/interpreters/index.ts new file mode 100644 index 0000000000..0b338c256f --- /dev/null +++ b/extensions/vscode/src/interpreters/index.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { InterpreterDefaults } from "src/api/types/interpreters"; +import { detectPythonInterpreter } from "./pythonInterpreter"; +import { detectRInterpreter } from "./rInterpreter"; + +/** + * Detect Python and R interpreter defaults for a project directory. + * Runs both detections concurrently via Promise.allSettled. + */ +export async function getInterpreterDefaults( + projectDir: string, + pythonPath?: string, + rPath?: string, +): Promise { + const [pythonResult, rResult] = await Promise.allSettled([ + detectPythonInterpreter(projectDir, pythonPath), + detectRInterpreter(projectDir, rPath), + ]); + + const python = + pythonResult.status === "fulfilled" + ? pythonResult.value + : { config: { version: "", packageFile: "", packageManager: "" }, preferredPath: pythonPath || "" }; + + const r = + rResult.status === "fulfilled" + ? rResult.value + : { config: { version: "", packageFile: "", packageManager: "" }, preferredPath: rPath || "" }; + + return { + python: python.config, + preferredPythonPath: python.preferredPath, + r: r.config, + preferredRPath: r.preferredPath, + }; +} diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts new file mode 100644 index 0000000000..35487c91a5 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -0,0 +1,179 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + detectPythonInterpreter, + clearPythonVersionCache, +} from "./pythonInterpreter"; + +// Track calls to execFile +let mockExecFile: ReturnType; + +vi.mock("child_process", () => ({ + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + stat: vi.fn(), + }, + }, +})); + +// Mock fileExists +let mockFileExistsResult = false; +vi.mock("src/utils/files", () => ({ + fileExists: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +describe("detectPythonInterpreter", () => { + beforeEach(() => { + clearPythonVersionCache(); + mockFileExistsResult = false; + mockExecFile = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided", async () => { + const result = await detectPythonInterpreter("/project"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("returns empty config when python fails to execute", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(new Error("not found"), ""); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + + test("detects version from python executable", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.11.5\n"); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe("3.11.5"); + expect(result.config.packageManager).toBe("auto"); + expect(result.preferredPath).toBe("/usr/bin/python3"); + }); + + test("returns requirements.txt as packageFile when present", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.10.0\n"); + }, + ); + mockFileExistsResult = true; + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.packageFile).toBe("requirements.txt"); + }); + + test("returns empty packageFile when requirements.txt not present", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "3.10.0\n"); + }, + ); + mockFileExistsResult = false; + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.packageFile).toBe(""); + }); + + test("caches version for non-shim paths", async () => { + let callCount = 0; + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + callCount++; + cb(null, "3.10.0\n"); + }, + ); + + await detectPythonInterpreter("/project", "/usr/bin/python3"); + await detectPythonInterpreter("/project", "/usr/bin/python3"); + expect(callCount).toBe(1); + }); + + test("does not cache version for pyenv shims", async () => { + let callCount = 0; + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + callCount++; + cb(null, "3.10.0\n"); + }, + ); + + await detectPythonInterpreter( + "/project", + "/home/user/.pyenv/shims/python3", + ); + await detectPythonInterpreter( + "/project", + "/home/user/.pyenv/shims/python3", + ); + expect(callCount).toBe(2); + }); +}); diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.ts b/extensions/vscode/src/interpreters/pythonInterpreter.ts new file mode 100644 index 0000000000..90e78e8d29 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { execFile } from "child_process"; +import { Uri } from "vscode"; +import { PythonConfig } from "src/api/types/configurations"; +import { fileExists } from "src/utils/files"; + +const REQUIREMENTS_TXT = "requirements.txt"; + +const pythonVersionCache = new Map(); + +export interface PythonDetectionResult { + config: PythonConfig; + preferredPath: string; +} + +/** + * Detect Python interpreter info for a project directory. + * Runs the interpreter to get its version, checks for requirements.txt, + * and reads Python version requirements from project metadata. + */ +export async function detectPythonInterpreter( + projectDir: string, + preferredPath?: string, +): Promise { + const empty: PythonDetectionResult = { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: preferredPath || "", + }; + + let version = ""; + if (preferredPath) { + version = await getPythonVersionFromExecutable(preferredPath, projectDir); + } + + if (!version) { + return empty; + } + + // Check for requirements.txt + const reqUri = Uri.joinPath(Uri.file(projectDir), REQUIREMENTS_TXT); + const hasRequirements = await fileExists(reqUri); + const packageFile = hasRequirements ? REQUIREMENTS_TXT : ""; + + return { + config: { + version, + packageFile, + packageManager: "auto", + }, + preferredPath: preferredPath || "", + }; +} + +function getPythonVersionFromExecutable( + pythonPath: string, + cwd: string, +): Promise { + // Skip cache for pyenv shims (where the real interpreter may vary) + if (!pythonPath.includes("shims")) { + const cached = pythonVersionCache.get(pythonPath); + if (cached) { + return Promise.resolve(cached); + } + } + + return new Promise((resolve) => { + const args = [ + "-E", + "-c", + 'import sys; v = sys.version_info; print("%d.%d.%d" % (v[0], v[1], v[2]))', + ]; + + execFile(pythonPath, args, { cwd, timeout: 15000 }, (error, stdout) => { + if (error) { + resolve(""); + return; + } + const version = stdout.trim(); + if (version && !pythonPath.includes("shims")) { + pythonVersionCache.set(pythonPath, version); + } + resolve(version); + }); + }); +} + +// Exported for testing +export function clearPythonVersionCache() { + pythonVersionCache.clear(); +} diff --git a/extensions/vscode/src/interpreters/pythonRequires.test.ts b/extensions/vscode/src/interpreters/pythonRequires.test.ts new file mode 100644 index 0000000000..e3117d6511 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.test.ts @@ -0,0 +1,137 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getPythonRequires } from "./pythonRequires"; + +const mockFiles: Record = {}; + +vi.mock("vscode", () => { + return { + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + readFile: vi.fn((uri: { path: string }) => { + const content = mockFiles[uri.path]; + if (content === undefined) { + throw new Error(`File not found: ${uri.path}`); + } + return new TextEncoder().encode(content); + }), + }, + }, + }; +}); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[`${projectDir}/${filename}`] = content; +} + +describe("getPythonRequires", () => { + beforeEach(() => { + // Clear all mock files + for (const key of Object.keys(mockFiles)) { + delete mockFiles[key]; + } + }); + + test("returns empty string when no files exist", async () => { + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + describe(".python-version", () => { + test("reads a bare version and adapts it", async () => { + setFile("/project", ".python-version", "3.9.17"); + const result = await getPythonRequires("/project"); + expect(result).toBe("~=3.9.0"); + }); + + test("reads comma-separated versions", async () => { + setFile("/project", ".python-version", ">=3.8, <3.12"); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.8,<3.12"); + }); + + test("returns empty string for invalid version", async () => { + setFile("/project", ".python-version", "3.10rc1"); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over pyproject.toml", async () => { + setFile("/project", ".python-version", "3.11"); + setFile( + "/project", + "pyproject.toml", + '[project]\nrequires-python = ">=3.8"', + ); + const result = await getPythonRequires("/project"); + expect(result).toBe("~=3.11.0"); + }); + }); + + describe("pyproject.toml", () => { + test("reads requires-python from [project] section", async () => { + setFile( + "/project", + "pyproject.toml", + '[project]\nname = "myproject"\nrequires-python = ">=3.8"\n', + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.8"); + }); + + test("returns empty string when requires-python is absent", async () => { + setFile( + "/project", + "pyproject.toml", + '[project]\nname = "myproject"\n', + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over setup.cfg", async () => { + setFile( + "/project", + "pyproject.toml", + '[project]\nrequires-python = ">=3.9"', + ); + setFile( + "/project", + "setup.cfg", + "[options]\npython_requires = >=3.7\n", + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.9"); + }); + }); + + describe("setup.cfg", () => { + test("reads python_requires from [options] section", async () => { + setFile( + "/project", + "setup.cfg", + "[options]\npython_requires = >=3.9\n", + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(">=3.9"); + }); + + test("ignores python_requires in wrong section", async () => { + setFile( + "/project", + "setup.cfg", + "[metadata]\npython_requires = >=3.9\n", + ); + const result = await getPythonRequires("/project"); + expect(result).toBe(""); + }); + }); +}); diff --git a/extensions/vscode/src/interpreters/pythonRequires.ts b/extensions/vscode/src/interpreters/pythonRequires.ts new file mode 100644 index 0000000000..93f2c6a110 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.ts @@ -0,0 +1,133 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { Uri, workspace } from "vscode"; +import { adaptPythonRequires } from "./versionConstraints"; + +/** + * Find the Python version requested by the project, checking in order: + * 1. .python-version + * 2. pyproject.toml + * 3. setup.cfg + * + * Returns the PEP 440 version specification, or empty string if not found. + */ +export async function getPythonRequires(projectDir: string): Promise { + const baseUri = Uri.file(projectDir); + + const fromVersionFile = await readPythonVersionFile(baseUri); + if (fromVersionFile) { + return fromVersionFile; + } + + const fromPyProject = await readPyProjectToml(baseUri); + if (fromPyProject) { + return fromPyProject; + } + + const fromSetupCfg = await readSetupCfg(baseUri); + if (fromSetupCfg) { + return fromSetupCfg; + } + + return ""; +} + +async function readFileText(uri: Uri): Promise { + try { + const data = await workspace.fs.readFile(uri); + return new TextDecoder().decode(data); + } catch { + return null; + } +} + +/** + * Read .python-version file. Plain text, possibly comma-separated versions. + * Each part is adapted through adaptPythonRequires(). + */ +async function readPythonVersionFile( + baseUri: Uri, +): Promise { + const fileUri = Uri.joinPath(baseUri, ".python-version"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const parts = content.split(","); + const adapted: string[] = []; + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + const result = adaptPythonRequires(trimmed); + if (result === null) { + return ""; + } + adapted.push(result); + } + + return adapted.join(",") || undefined; +} + +const requiresPythonRe = + /^\s*requires-python\s*=\s*["']([^"']+)["']\s*$/m; + +/** + * Read pyproject.toml and extract requires-python from [project] section. + * Uses regex extraction instead of a full TOML parser. + */ +async function readPyProjectToml(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "pyproject.toml"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const match = requiresPythonRe.exec(content); + if (match && match[1]) { + return match[1]; + } + + return undefined; +} + +/** + * Read setup.cfg and extract python_requires from [options] section. + * Simple line-by-line INI parsing. + */ +async function readSetupCfg(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "setup.cfg"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const lines = content.split("\n"); + let inOptionsSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Section header + if (trimmed.startsWith("[")) { + inOptionsSection = trimmed.toLowerCase() === "[options]"; + continue; + } + + if (inOptionsSection) { + // Match key = value within the [options] section + const eqIdx = trimmed.indexOf("="); + if (eqIdx !== -1) { + const key = trimmed.substring(0, eqIdx).trim(); + if (key === "python_requires") { + return trimmed.substring(eqIdx + 1).trim(); + } + } + } + } + + return undefined; +} diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts new file mode 100644 index 0000000000..b82f8dd485 --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -0,0 +1,211 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { detectRInterpreter } from "./rInterpreter"; + +let mockExecFile: ReturnType; + +vi.mock("child_process", () => ({ + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + stat: vi.fn(), + }, + }, +})); + +let mockFileExistsResult = false; +vi.mock("src/utils/files", () => ({ + fileExists: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +describe("detectRInterpreter", () => { + beforeEach(() => { + mockFileExistsResult = false; + mockExecFile = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided", async () => { + const result = await detectRInterpreter("/project"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("returns empty config when R fails to execute", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("not found"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe(""); + }); + + test("detects version from R --version stdout", async () => { + // First call: R --version + // Second call: renv::paths$lockfile() + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb( + null, + "R version 4.3.2 (2023-10-31) -- \"Eye Holes\"\n", + "", + ); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.3.2"); + expect(result.config.packageManager).toBe("renv"); + expect(result.preferredPath).toBe("/usr/bin/R"); + }); + + test("detects version from R --version stderr", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "", "R version 4.2.1 (2022-06-23)\n"); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.2.1"); + }); + + test("uses renv lockfile path from renv::paths$lockfile()", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, '[1] "/project/custom/renv.lock"\n', ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("custom/renv.lock"); + }); + + test("falls back to default renv.lock when renv fails", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("renv.lock"); + }); + + test("returns empty packageFile when lockfile does not exist", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + mockFileExistsResult = false; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe(""); + }); +}); diff --git a/extensions/vscode/src/interpreters/rInterpreter.ts b/extensions/vscode/src/interpreters/rInterpreter.ts new file mode 100644 index 0000000000..94294ba08f --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.ts @@ -0,0 +1,139 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { execFile } from "child_process"; +import { Uri } from "vscode"; +import { RConfig } from "src/api/types/configurations"; +import { fileExists } from "src/utils/files"; + +const DEFAULT_RENV_LOCKFILE = "renv.lock"; +const R_VERSION_TIMEOUT = 15000; +const RENV_LOCKFILE_TIMEOUT = 15000; + +const rVersionRe = /^R version (\d+\.\d+\.\d+)/; +const renvLockPathRe = /^\[1\] "(.*)"/; + +export interface RDetectionResult { + config: RConfig; + preferredPath: string; +} + +/** + * Detect R interpreter info for a project directory. + * Runs `R --version` to get the version, resolves the renv lockfile path, + * and checks for lockfile existence. + */ +export async function detectRInterpreter( + projectDir: string, + preferredPath?: string, +): Promise { + const empty: RDetectionResult = { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: preferredPath || "", + }; + + let version = ""; + if (preferredPath) { + version = await getRVersionFromExecutable(preferredPath); + } + + if (!version) { + return empty; + } + + // Resolve the renv lockfile path + const lockfilePath = await resolveRenvLockfile(preferredPath!, projectDir); + const lockfileUri = Uri.joinPath(Uri.file(projectDir), lockfilePath); + const lockfilePresent = await fileExists(lockfileUri); + const packageFile = lockfilePresent ? lockfilePath : ""; + + return { + config: { + version, + packageFile, + packageManager: "renv", + }, + preferredPath: preferredPath || "", + }; +} + +/** + * Get R version by running `R --version` and parsing the output. + * R may output the version on stdout or stderr, so we check both. + */ +function getRVersionFromExecutable(rPath: string): Promise { + return new Promise((resolve) => { + execFile( + rPath, + ["--version"], + { timeout: R_VERSION_TIMEOUT }, + (_error, stdout, stderr) => { + // R --version may return non-zero on some platforms, but still + // outputs the version. Check output even on error. + const combined = (stdout || "") + (stderr || ""); + const lines = combined.split("\n"); + for (const line of lines) { + const match = rVersionRe.exec(line); + if (match && match[1]) { + resolve(match[1]); + return; + } + } + resolve(""); + }, + ); + }); +} + +/** + * Resolve the renv lockfile path by trying renv::paths$lockfile() first, + * falling back to the default "renv.lock". + */ +async function resolveRenvLockfile( + rPath: string, + projectDir: string, +): Promise { + const lockfilePath = await getRenvLockfileFromR(rPath, projectDir); + if (lockfilePath) { + // The renv lockfile path is absolute; make it relative to projectDir + if (lockfilePath.startsWith(projectDir)) { + const relative = lockfilePath.substring(projectDir.length); + // Remove leading path separator + return relative.replace(/^[/\\]/, ""); + } + return lockfilePath; + } + return DEFAULT_RENV_LOCKFILE; +} + +/** + * Get the renv lockfile path from R by running renv::paths$lockfile(). + * Returns the absolute path or empty string on failure. + */ +function getRenvLockfileFromR( + rPath: string, + cwd: string, +): Promise { + return new Promise((resolve) => { + execFile( + rPath, + ["-s", "-e", "renv::paths$lockfile()"], + { cwd, timeout: RENV_LOCKFILE_TIMEOUT }, + (error, stdout, stderr) => { + if (error) { + resolve(undefined); + return; + } + const combined = (stdout || "") + (stderr || ""); + const lines = combined.split("\n"); + for (const line of lines) { + const match = renvLockPathRe.exec(line); + if (match && match[1]) { + resolve(match[1]); + return; + } + } + resolve(undefined); + }, + ); + }); +} diff --git a/extensions/vscode/src/interpreters/rRequires.test.ts b/extensions/vscode/src/interpreters/rRequires.test.ts new file mode 100644 index 0000000000..4693253724 --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.test.ts @@ -0,0 +1,137 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getRRequires } from "./rRequires"; + +const mockFiles: Record = {}; + +vi.mock("vscode", () => { + return { + Uri: { + file: (path: string) => ({ fsPath: path, path }), + joinPath: (base: { path: string }, ...segments: string[]) => { + const joined = [base.path, ...segments].join("/"); + return { fsPath: joined, path: joined }; + }, + }, + workspace: { + fs: { + readFile: vi.fn((uri: { path: string }) => { + const content = mockFiles[uri.path]; + if (content === undefined) { + throw new Error(`File not found: ${uri.path}`); + } + return new TextEncoder().encode(content); + }), + }, + }, + }; +}); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[`${projectDir}/${filename}`] = content; +} + +describe("getRRequires", () => { + beforeEach(() => { + for (const key of Object.keys(mockFiles)) { + delete mockFiles[key]; + } + }); + + test("returns empty string when no files exist", async () => { + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + describe("DESCRIPTION", () => { + test("reads R version from Depends line", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1, R (>= 3.5.0), package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">= 3.5.0"); + }); + + test("reads R version from continuation lines", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n R (>3.5)\n package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">3.5"); + }); + + test("reads R version from tab-indented continuation", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n\tR (>7.3)\n\tpackage3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(">7.3"); + }); + + test("returns empty when no R dependency", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1, package2, package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("does not match tinyR", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: package1\n tinyR (<3.5)\n package3", + ); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("takes priority over renv.lock", async () => { + setFile( + "/project", + "DESCRIPTION", + "Depends: R (>= 4.0.0)", + ); + setFile( + "/project", + "renv.lock", + JSON.stringify({ R: { Version: "4.3.2" } }), + ); + const result = await getRRequires("/project"); + expect(result).toBe(">= 4.0.0"); + }); + }); + + describe("renv.lock", () => { + test("reads R version and adapts to compatible constraint", async () => { + setFile( + "/project", + "renv.lock", + JSON.stringify({ R: { Version: "4.3.2" } }), + ); + const result = await getRRequires("/project"); + expect(result).toBe("~=4.3.0"); + }); + + test("returns empty string for missing R section", async () => { + setFile("/project", "renv.lock", JSON.stringify({ Packages: {} })); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + + test("returns empty string for invalid JSON", async () => { + setFile("/project", "renv.lock", "not json"); + const result = await getRRequires("/project"); + expect(result).toBe(""); + }); + }); +}); diff --git a/extensions/vscode/src/interpreters/rRequires.ts b/extensions/vscode/src/interpreters/rRequires.ts new file mode 100644 index 0000000000..8c8a269f40 --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.ts @@ -0,0 +1,96 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { Uri, workspace } from "vscode"; +import { adaptToCompatibleConstraint } from "./versionConstraints"; + +/** + * Find the R version requested by the project, checking in order: + * 1. DESCRIPTION file (Depends: R (>= x.y.z)) + * 2. renv.lock (R.Version) + * + * Returns the version specification, or empty string if not found. + */ +export async function getRRequires(projectDir: string): Promise { + const baseUri = Uri.file(projectDir); + + const fromDescription = await readDescriptionFile(baseUri); + if (fromDescription) { + return fromDescription; + } + + const fromRenvLock = await readRenvLock(baseUri); + if (fromRenvLock) { + return fromRenvLock; + } + + return ""; +} + +async function readFileText(uri: Uri): Promise { + try { + const data = await workspace.fs.readFile(uri); + return new TextDecoder().decode(data); + } catch { + return null; + } +} + +/** + * Read DESCRIPTION file and look for R version in the Depends: section. + * Matches patterns like "R (>= 3.5.0)". + */ +async function readDescriptionFile(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "DESCRIPTION"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + const lines = content.split("\n"); + const deps: string[] = []; + let found = false; + + for (const line of lines) { + if (line.startsWith("Depends:")) { + deps.push(line.substring("Depends:".length)); + found = true; + } else if (found && (line.startsWith(" ") || line.startsWith("\t"))) { + deps.push(line.trim()); + } else if (found) { + break; + } + } + + const all = deps.join(" "); + const re = /\bR\s*\(([^)]+)\)/; + const match = re.exec(all); + if (match && match[1]) { + return match[1]; + } + + return undefined; +} + +/** + * Read renv.lock (JSON) and extract R.Version, converting it to a + * compatible constraint via adaptToCompatibleConstraint. + */ +async function readRenvLock(baseUri: Uri): Promise { + const fileUri = Uri.joinPath(baseUri, "renv.lock"); + const content = await readFileText(fileUri); + if (content === null) { + return undefined; + } + + try { + const parsed = JSON.parse(content); + const version = parsed?.R?.Version; + if (typeof version === "string" && version) { + return adaptToCompatibleConstraint(version); + } + } catch { + // Invalid JSON, ignore + } + + return undefined; +} diff --git a/extensions/vscode/src/interpreters/versionConstraints.test.ts b/extensions/vscode/src/interpreters/versionConstraints.test.ts new file mode 100644 index 0000000000..ff742cacd5 --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.test.ts @@ -0,0 +1,62 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { describe, expect, test } from "vitest"; +import { + adaptPythonRequires, + adaptToCompatibleConstraint, +} from "./versionConstraints"; + +describe("adaptPythonRequires", () => { + const cases: Array<{ input: string; expected: string | null }> = [ + { input: "3.9.17", expected: "~=3.9.0" }, + { input: ">=3.7", expected: ">=3.7" }, + { input: "==3.11.*", expected: "==3.11.*" }, + { input: "3.8.0", expected: "~=3.8.0" }, + { input: "3.11.10", expected: "~=3.11.0" }, + { input: "3.11", expected: "~=3.11.0" }, + { input: "3.11.0", expected: "~=3.11.0" }, + { input: "3", expected: "~=3.0" }, + { input: "3.0", expected: "~=3.0.0" }, + { input: "3.0.0", expected: "~=3.0.0" }, + { input: "3.8.*", expected: "==3.8.*" }, + { input: " 3.9.0 ", expected: "~=3.9.0" }, + { input: "~=3.10", expected: "~=3.10" }, + { input: "< 4.0", expected: "< 4.0" }, + // Pre-release and special implementations are rejected + { input: "3.10rc1", expected: null }, + { input: "3.11b2", expected: null }, + { input: "3.8a1", expected: null }, + { input: "cpython-3.8", expected: null }, + { input: "3.9/pypy", expected: null }, + { input: "3.10@foo", expected: null }, + // Invalid versions + { input: "", expected: null }, + { input: "abc", expected: null }, + { input: "3..8", expected: null }, + { input: "3.8.1.*", expected: null }, + ]; + + cases.forEach(({ input, expected }) => { + test(`"${input}" -> ${expected === null ? "null" : `"${expected}"`}`, () => { + expect(adaptPythonRequires(input)).toBe(expected); + }); + }); +}); + +describe("adaptToCompatibleConstraint", () => { + test("major only", () => { + expect(adaptToCompatibleConstraint("3")).toBe("~=3.0"); + }); + + test("major.minor", () => { + expect(adaptToCompatibleConstraint("3.8")).toBe("~=3.8.0"); + }); + + test("major.minor.patch", () => { + expect(adaptToCompatibleConstraint("3.8.11")).toBe("~=3.8.0"); + }); + + test("major.minor.patch (zeroes)", () => { + expect(adaptToCompatibleConstraint("4.3.0")).toBe("~=4.3.0"); + }); +}); diff --git a/extensions/vscode/src/interpreters/versionConstraints.ts b/extensions/vscode/src/interpreters/versionConstraints.ts new file mode 100644 index 0000000000..591bc2c043 --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +const pep440Operators = /(==|!=|<=|>=|~=|<|>)/; +const validVersion = /^\d+(\.\d+)*(\.\*)?$/; + +/** + * Adapts a raw Python version string from `.python-version` into a PEP 440 + * constraint suitable for deployment. Returns the adapted constraint string + * or an empty string if the input is invalid. + */ +export function adaptPythonRequires(raw: string): string | null { + const constraint = raw.trim(); + + if (!constraint) { + return null; + } + + if (/[-/@]/.test(constraint)) { + return null; + } + if ( + constraint.includes("rc") || + constraint.includes("b") || + constraint.includes("a") + ) { + return null; + } + + const dotCount = (constraint.match(/\./g) || []).length; + if (dotCount > 2) { + return null; + } + + // If it's already a PEP 440 constraint, return it as is + if (pep440Operators.test(constraint)) { + return constraint; + } + + // Otherwise it should be a version string + if (!validVersion.test(constraint)) { + return null; + } + + // If the version has a wildcard, use equivalence + // e.g. 3.8.* -> ==3.8.* + if (constraint.includes("*")) { + return "==" + constraint; + } + + return adaptToCompatibleConstraint(constraint); +} + +/** + * Converts a bare version string into a compatible release constraint (~=). + * - "3" -> "~=3.0" + * - "3.8" -> "~=3.8.0" + * - "3.8.11" -> "~=3.8.0" + */ +export function adaptToCompatibleConstraint(constraint: string): string { + const parts = constraint.split("."); + if (parts.length === 1) { + return `~=${parts[0]}.0`; + } + return `~=${parts[0]}.${parts[1]}.0`; +} diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index c5de95e464..cc409308e6 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -30,18 +30,6 @@ class mockApiClient { list: vi.fn(), reset: vi.fn(), }; - - readonly interpreters = { - get: vi.fn(() => { - return { - data: { - dir: "/usr/proj", - r: "/usr/bin/r", - python: "/usr/bin/python", - }, - }; - }), - }; } const mockClient = new mockApiClient(); @@ -75,6 +63,15 @@ vi.mock("src/utils/vscode", () => ({ getRInterpreterPath: vi.fn(), })); +vi.mock("src/interpreters", () => ({ + getInterpreterDefaults: vi.fn(async () => ({ + python: { version: "", packageFile: "", packageManager: "" }, + preferredPythonPath: "", + r: { version: "", packageFile: "", packageManager: "" }, + preferredRPath: "", + })), +})); + const mockSyncAllCredentials = vi.fn(); vi.mock("src/credentialSecretStorage", () => ({ syncAllCredentials: (...args: unknown[]) => mockSyncAllCredentials(...args), diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index fc9ffe3ca0..90bb380820 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -25,6 +25,7 @@ import { useApi, } from "src/api"; import { normalizeURL } from "src/utils/url"; +import { getInterpreterDefaults } from "src/interpreters"; import { showProgress } from "src/utils/progress"; import { getStatusFromError, @@ -241,12 +242,12 @@ export class PublisherState implements Disposable { contentRecord.configurationName, contentRecord.projectDir, ); - const defaults = await api.interpreters.get( + const defaults = await getInterpreterDefaults( contentRecord.projectDir, - r, - python, + python?.pythonPath, + r?.rPath, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const cfg = UpdateConfigWithDefaults(response.data, defaults); // its not foolproof, but it may help if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { this.configurations.push(cfg); @@ -315,10 +316,14 @@ export class PublisherState implements Disposable { const response = await api.configurations.getAll(".", { recursive: true, }); - const defaults = await api.interpreters.get(".", r, python); + const defaults = await getInterpreterDefaults( + ".", + python?.pythonPath, + r?.rPath, + ); this.configurations = UpdateAllConfigsWithDefaults( response.data, - defaults.data, + defaults, ); }, ); diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 207f84fcd7..289d515f7d 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -168,10 +168,6 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("deployments", "{name}", "environment"), GetDeploymentEnvironmentHandlerFunc(base, log, lister)). Methods(http.MethodGet) - // GET /api/interpreters - r.Handle(ToPath("interpreters"), GetActiveInterpretersHandlerFunc(base, log)). - Methods(http.MethodGet) - // POST /api/packages/python/scan r.Handle(ToPath("packages", "python", "scan"), NewPostPackagesPythonScanHandler(base, log)). Methods(http.MethodPost) diff --git a/internal/services/api/get_interpreters.go b/internal/services/api/get_interpreters.go deleted file mode 100644 index 3df34fb74c..0000000000 --- a/internal/services/api/get_interpreters.go +++ /dev/null @@ -1,70 +0,0 @@ -package api - -// Copyright (C) 2025 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" -) - -var interpretersFromRequest = InterpretersFromRequest - -// getInterpreterResponse is the format of returned interpreter data. -// It represents the defaults of the active interpreters, passed in -// the request. -type getInterpreterResponse struct { - Python *config.Python `json:"python,omitempty"` - PreferredPythonPath string `json:"preferredPythonPath,omitempty"` - R *config.R `json:"r,omitempty"` - PreferredRPath string `json:"preferredRPath,omitempty"` -} - -// toGetInterpreterResponse converts interpreter objects -// to the DTO type we return from the API. -func toGetInterpreterResponse(rInterpreter interpreters.RInterpreter, pythonInterpreter interpreters.PythonInterpreter) getInterpreterResponse { - - rConfig := &config.R{} - rConfig.FillDefaults(rInterpreter) - preferredRPath := "" - if rInterpreter != nil { - preferredRPath = (rInterpreter).GetPreferredPath() - } - - pythonConfig := &config.Python{} - pythonConfig.FillDefaults(pythonInterpreter) - preferredPythonPath := "" - if pythonInterpreter != nil { - preferredPythonPath = (pythonInterpreter).GetPreferredPath() - } - - return getInterpreterResponse{ - R: rConfig, - PreferredRPath: preferredRPath, - Python: pythonConfig, - PreferredPythonPath: preferredPythonPath, - } -} - -func GetActiveInterpretersHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - projectDir, _, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - rInterpreter, pythonInterpreter, err := interpretersFromRequest(projectDir, w, req, log) - if err != nil { - // Response already returned by InterpretersFromRequest - return - } - - response := toGetInterpreterResponse(rInterpreter, pythonInterpreter) - w.Header().Set("content-type", "application/json") - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/get_interpreters_test.go b/internal/services/api/get_interpreters_test.go deleted file mode 100644 index 4232c9e809..0000000000 --- a/internal/services/api/get_interpreters_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/interpreters" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" -) - -type GetInterpretersSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetInterpretersSuite(t *testing.T) { - suite.Run(t, new(GetInterpretersSuite)) -} - -func (s *GetInterpretersSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetInterpretersSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetInterpretersSuite) createMockRInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(true) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("R", s.cwd.Fs()), nil) - iMock.On("GetRVersion").Return("3.4.5", nil) - relPath := util.NewRelativePath("renv.lock", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, true, nil) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - iMock.On("GetRRequires").Return(">=3.1.1") - return iMock -} - -func (s *GetInterpretersSuite) createMockRMissingInterpreter() interpreters.RInterpreter { - iMock := interpreters.NewMockRInterpreter() - missingError := types.NewAgentError(types.ErrorRExecNotFound, errors.New("no r"), nil) - iMock.On("Init").Return(nil) - iMock.On("IsRExecutableValid").Return(false) - iMock.On("GetRExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetRVersion").Return("", missingError) - relPath := util.NewRelativePath("", s.cwd.Fs()) - iMock.On("GetLockFilePath").Return(relPath, false, missingError) - iMock.On("GetPackageManager").Return("renv") - iMock.On("GetPreferredPath").Return("bin/my_r") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - iMock.On("IsPythonExecutableValid").Return(true) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("/bin/python", s.cwd.Fs()), nil) - iMock.On("GetPythonVersion").Return("1.2.3", nil) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return(">=1.1.3") - iMock.On("GetLockFilePath").Return("requirements.txt", true, nil) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) createMockPythonMissingInterpreter() interpreters.PythonInterpreter { - iMock := interpreters.NewMockPythonInterpreter() - missingError := types.NewAgentError(types.ErrorPythonExecNotFound, errors.New("no python"), nil) - iMock.On("IsPythonExecutableValid").Return(false) - iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError) - iMock.On("GetPythonVersion").Return("", missingError) - iMock.On("GetPackageManager").Return("pip") - iMock.On("GetPythonRequires").Return("") - iMock.On("GetLockFilePath").Return("", false, missingError) - iMock.On("GetPreferredPath").Return("bin/my_python") - return iMock -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenPassedIn() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRInterpreter() - python := s.createMockPythonInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "1.2.3", - PackageFile: "requirements.txt", - PackageManager: "pip", - RequiresPythonVersion: ">=1.1.3", - } - expectedR := &config.R{ - Version: "3.4.5", - PackageFile: "renv.lock", - PackageManager: "renv", - RequiresRVersion: ">=3.1.1", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -} - -func (s *GetInterpretersSuite) TestGetInterpretersWhenNoneFound() { - - h := GetActiveInterpretersHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - - // Base URL - baseURL := "/api/interpreters" - - // Create a url.URL struct - parsedURL, err := url.Parse(baseURL) - if err != nil { - panic(err) - } - - // Create a url.Values to hold query parameters - queryParams := url.Values{} - queryParams.Add("dir", ".") - queryParams.Add("r", "bin/my_r") - queryParams.Add("python", "bin/my_python") - - // Encode query parameters and set them to the URL - parsedURL.RawQuery = queryParams.Encode() - - req, err := http.NewRequest("GET", parsedURL.String(), nil) - s.NoError(err) - - interpretersFromRequest = func( - util.AbsolutePath, - http.ResponseWriter, - *http.Request, - logging.Logger, - ) (interpreters.RInterpreter, interpreters.PythonInterpreter, error) { - r := s.createMockRMissingInterpreter() - python := s.createMockPythonMissingInterpreter() - - return r, python, nil - } - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := getInterpreterResponse{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - expectedPython := &config.Python{ - Version: "", - PackageFile: "", - PackageManager: "", - } - expectedR := &config.R{ - Version: "", - PackageFile: "", - PackageManager: "", - } - - s.Equal(expectedPython, res.Python) - s.Equal("bin/my_r", res.PreferredRPath) - s.Equal(expectedR, res.R) - s.Equal("bin/my_python", res.PreferredPythonPath) -} From 8383deb47e3c170f13dc8501bdd2e7d01049da0b Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:22:56 -0400 Subject: [PATCH 2/8] Update copyright headers to 2026 in new interpreter files Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/interpreters/index.ts | 2 +- extensions/vscode/src/interpreters/pythonInterpreter.test.ts | 2 +- extensions/vscode/src/interpreters/pythonInterpreter.ts | 2 +- extensions/vscode/src/interpreters/pythonRequires.test.ts | 2 +- extensions/vscode/src/interpreters/pythonRequires.ts | 2 +- extensions/vscode/src/interpreters/rInterpreter.test.ts | 2 +- extensions/vscode/src/interpreters/rInterpreter.ts | 2 +- extensions/vscode/src/interpreters/rRequires.test.ts | 2 +- extensions/vscode/src/interpreters/rRequires.ts | 2 +- extensions/vscode/src/interpreters/versionConstraints.test.ts | 2 +- extensions/vscode/src/interpreters/versionConstraints.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/extensions/vscode/src/interpreters/index.ts b/extensions/vscode/src/interpreters/index.ts index 0b338c256f..2fcbab6f91 100644 --- a/extensions/vscode/src/interpreters/index.ts +++ b/extensions/vscode/src/interpreters/index.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { InterpreterDefaults } from "src/api/types/interpreters"; import { detectPythonInterpreter } from "./pythonInterpreter"; diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts index 35487c91a5..d254b3f30a 100644 --- a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.ts b/extensions/vscode/src/interpreters/pythonInterpreter.ts index 90e78e8d29..c22dee9abe 100644 --- a/extensions/vscode/src/interpreters/pythonInterpreter.ts +++ b/extensions/vscode/src/interpreters/pythonInterpreter.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { execFile } from "child_process"; import { Uri } from "vscode"; diff --git a/extensions/vscode/src/interpreters/pythonRequires.test.ts b/extensions/vscode/src/interpreters/pythonRequires.test.ts index e3117d6511..787b9eeac8 100644 --- a/extensions/vscode/src/interpreters/pythonRequires.test.ts +++ b/extensions/vscode/src/interpreters/pythonRequires.test.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { beforeEach, describe, expect, test, vi } from "vitest"; import { getPythonRequires } from "./pythonRequires"; diff --git a/extensions/vscode/src/interpreters/pythonRequires.ts b/extensions/vscode/src/interpreters/pythonRequires.ts index 93f2c6a110..0f668ab0e2 100644 --- a/extensions/vscode/src/interpreters/pythonRequires.ts +++ b/extensions/vscode/src/interpreters/pythonRequires.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { Uri, workspace } from "vscode"; import { adaptPythonRequires } from "./versionConstraints"; diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts index b82f8dd485..28c9e44814 100644 --- a/extensions/vscode/src/interpreters/rInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { detectRInterpreter } from "./rInterpreter"; diff --git a/extensions/vscode/src/interpreters/rInterpreter.ts b/extensions/vscode/src/interpreters/rInterpreter.ts index 94294ba08f..18ea66084f 100644 --- a/extensions/vscode/src/interpreters/rInterpreter.ts +++ b/extensions/vscode/src/interpreters/rInterpreter.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { execFile } from "child_process"; import { Uri } from "vscode"; diff --git a/extensions/vscode/src/interpreters/rRequires.test.ts b/extensions/vscode/src/interpreters/rRequires.test.ts index 4693253724..0d6f66a376 100644 --- a/extensions/vscode/src/interpreters/rRequires.test.ts +++ b/extensions/vscode/src/interpreters/rRequires.test.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { beforeEach, describe, expect, test, vi } from "vitest"; import { getRRequires } from "./rRequires"; diff --git a/extensions/vscode/src/interpreters/rRequires.ts b/extensions/vscode/src/interpreters/rRequires.ts index 8c8a269f40..cbacb28912 100644 --- a/extensions/vscode/src/interpreters/rRequires.ts +++ b/extensions/vscode/src/interpreters/rRequires.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { Uri, workspace } from "vscode"; import { adaptToCompatibleConstraint } from "./versionConstraints"; diff --git a/extensions/vscode/src/interpreters/versionConstraints.test.ts b/extensions/vscode/src/interpreters/versionConstraints.test.ts index ff742cacd5..496c10ba2b 100644 --- a/extensions/vscode/src/interpreters/versionConstraints.test.ts +++ b/extensions/vscode/src/interpreters/versionConstraints.test.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. import { describe, expect, test } from "vitest"; import { diff --git a/extensions/vscode/src/interpreters/versionConstraints.ts b/extensions/vscode/src/interpreters/versionConstraints.ts index 591bc2c043..1e894475d4 100644 --- a/extensions/vscode/src/interpreters/versionConstraints.ts +++ b/extensions/vscode/src/interpreters/versionConstraints.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2025 by Posit Software, PBC. +// Copyright (C) 2026 by Posit Software, PBC. const pep440Operators = /(==|!=|<=|>=|~=|<|>)/; const validVersion = /^\d+(\.\d+)*(\.\*)?$/; From 64f7e6366035c1afc5eda27e78c3a9917d6a123c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:30:01 -0400 Subject: [PATCH 3/8] Add test coverage for interpreter detection gaps - Add index.test.ts: 6 tests covering getInterpreterDefaults() including rejection fallbacks for Python/R and undefined path handling - Add rInterpreter.test.ts: R --version with non-zero exit code, lockfile path outside projectDir, unparseable renv output, unrecognized R output - Add pythonInterpreter.test.ts: empty and whitespace-only stdout handling Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/interpreters/index.test.ts | 125 ++++++++++++++++++ .../interpreters/pythonInterpreter.test.ts | 38 ++++++ .../src/interpreters/rInterpreter.test.ts | 106 +++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 extensions/vscode/src/interpreters/index.test.ts diff --git a/extensions/vscode/src/interpreters/index.test.ts b/extensions/vscode/src/interpreters/index.test.ts new file mode 100644 index 0000000000..187e59f271 --- /dev/null +++ b/extensions/vscode/src/interpreters/index.test.ts @@ -0,0 +1,125 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, test, vi } from "vitest"; +import { getInterpreterDefaults } from "./index"; + +const mockDetectPython = vi.fn(); +const mockDetectR = vi.fn(); + +vi.mock("./pythonInterpreter", () => ({ + detectPythonInterpreter: (...args: unknown[]) => mockDetectPython(...args), +})); + +vi.mock("./rInterpreter", () => ({ + detectRInterpreter: (...args: unknown[]) => mockDetectR(...args), +})); + +describe("getInterpreterDefaults", () => { + test("returns results when both detections succeed", async () => { + mockDetectPython.mockResolvedValue({ + config: { version: "3.11.5", packageFile: "requirements.txt", packageManager: "auto" }, + preferredPath: "/usr/bin/python3", + }); + mockDetectR.mockResolvedValue({ + config: { version: "4.3.2", packageFile: "renv.lock", packageManager: "renv" }, + preferredPath: "/usr/bin/R", + }); + + const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + + expect(result.python).toEqual({ + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }); + expect(result.preferredPythonPath).toBe("/usr/bin/python3"); + expect(result.r).toEqual({ + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }); + expect(result.preferredRPath).toBe("/usr/bin/R"); + }); + + test("returns empty Python config when Python detection rejects", async () => { + mockDetectPython.mockRejectedValue(new Error("python exploded")); + mockDetectR.mockResolvedValue({ + config: { version: "4.3.2", packageFile: "renv.lock", packageManager: "renv" }, + preferredPath: "/usr/bin/R", + }); + + const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + + expect(result.python).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.preferredPythonPath).toBe("/usr/bin/python3"); + // R still succeeds + expect(result.r.version).toBe("4.3.2"); + }); + + test("returns empty R config when R detection rejects", async () => { + mockDetectPython.mockResolvedValue({ + config: { version: "3.11.5", packageFile: "requirements.txt", packageManager: "auto" }, + preferredPath: "/usr/bin/python3", + }); + mockDetectR.mockRejectedValue(new Error("R exploded")); + + const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + + // Python still succeeds + expect(result.python.version).toBe("3.11.5"); + expect(result.r).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.preferredRPath).toBe("/usr/bin/R"); + }); + + test("returns empty configs when both detections reject", async () => { + mockDetectPython.mockRejectedValue(new Error("python exploded")); + mockDetectR.mockRejectedValue(new Error("R exploded")); + + const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + + expect(result.python).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + expect(result.r).toEqual({ + version: "", + packageFile: "", + packageManager: "", + }); + }); + + test("handles undefined paths in rejection fallback", async () => { + mockDetectPython.mockRejectedValue(new Error("fail")); + mockDetectR.mockRejectedValue(new Error("fail")); + + const result = await getInterpreterDefaults("/project"); + + expect(result.preferredPythonPath).toBe(""); + expect(result.preferredRPath).toBe(""); + }); + + test("passes paths through to detectors", async () => { + mockDetectPython.mockResolvedValue({ + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: "", + }); + mockDetectR.mockResolvedValue({ + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: "", + }); + + await getInterpreterDefaults("/project", "/my/python", "/my/R"); + + expect(mockDetectPython).toHaveBeenCalledWith("/project", "/my/python"); + expect(mockDetectR).toHaveBeenCalledWith("/project", "/my/R"); + }); +}); diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts index d254b3f30a..405065f158 100644 --- a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -133,6 +133,44 @@ describe("detectPythonInterpreter", () => { expect(result.config.packageFile).toBe(""); }); + test("returns empty config when python outputs empty stdout", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, ""); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + + test("returns empty config when python outputs only whitespace", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, " \n "); + }, + ); + + const result = await detectPythonInterpreter( + "/project", + "/usr/bin/python3", + ); + expect(result.config.version).toBe(""); + }); + test("caches version for non-shim paths", async () => { let callCount = 0; mockExecFile.mockImplementation( diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts index 28c9e44814..49f0e1feeb 100644 --- a/extensions/vscode/src/interpreters/rInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -181,6 +181,112 @@ describe("detectRInterpreter", () => { expect(result.config.packageFile).toBe("renv.lock"); }); + test("detects version when R --version returns non-zero exit with valid output", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // Some platforms return non-zero but still output the version + cb( + new Error("exit code 1"), + "", + "R version 4.1.3 (2022-03-10) -- \"One Push-Up\"\n", + ); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(new Error("renv not installed"), "", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe("4.1.3"); + }); + + test("returns empty version when R output does not match version regex", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "some unexpected output\n", ""); + }, + ); + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.version).toBe(""); + }); + + test("returns absolute lockfile path when outside projectDir", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // renv returns a path outside the project directory + cb(null, '[1] "/other/location/renv.lock"\n', ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("/other/location/renv.lock"); + }); + + test("falls back to default lockfile when renv output is unparseable", async () => { + mockExecFile + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + cb(null, "R version 4.3.0 (2023-04-21)\n", ""); + }, + ) + .mockImplementationOnce( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + // renv succeeds but output doesn't match expected format + cb(null, "Warning: some renv message\n", ""); + }, + ); + mockFileExistsResult = true; + + const result = await detectRInterpreter("/project", "/usr/bin/R"); + expect(result.config.packageFile).toBe("renv.lock"); + }); + test("returns empty packageFile when lockfile does not exist", async () => { mockExecFile .mockImplementationOnce( From e5ea23ba3048c641c0a0cd776055e7784a35a24c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:41:22 -0400 Subject: [PATCH 4/8] Fix mock typing for Vitest v4 compatibility Use const vi.fn() with mockReset() instead of let with reassignment to avoid TS2348 errors with Vitest v4's Mock type. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/interpreters/index.test.ts | 13 ++++++++++--- .../src/interpreters/pythonInterpreter.test.ts | 5 ++--- .../vscode/src/interpreters/rInterpreter.test.ts | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/extensions/vscode/src/interpreters/index.test.ts b/extensions/vscode/src/interpreters/index.test.ts index 187e59f271..d85079552f 100644 --- a/extensions/vscode/src/interpreters/index.test.ts +++ b/extensions/vscode/src/interpreters/index.test.ts @@ -1,20 +1,27 @@ // Copyright (C) 2026 by Posit Software, PBC. -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { getInterpreterDefaults } from "./index"; const mockDetectPython = vi.fn(); const mockDetectR = vi.fn(); vi.mock("./pythonInterpreter", () => ({ - detectPythonInterpreter: (...args: unknown[]) => mockDetectPython(...args), + detectPythonInterpreter: (...args: unknown[]) => + mockDetectPython(...args), })); vi.mock("./rInterpreter", () => ({ - detectRInterpreter: (...args: unknown[]) => mockDetectR(...args), + detectRInterpreter: (...args: unknown[]) => + mockDetectR(...args), })); describe("getInterpreterDefaults", () => { + beforeEach(() => { + mockDetectPython.mockReset(); + mockDetectR.mockReset(); + }); + test("returns results when both detections succeed", async () => { mockDetectPython.mockResolvedValue({ config: { version: "3.11.5", packageFile: "requirements.txt", packageManager: "auto" }, diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts index 405065f158..0bd20934c2 100644 --- a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -6,8 +6,7 @@ import { clearPythonVersionCache, } from "./pythonInterpreter"; -// Track calls to execFile -let mockExecFile: ReturnType; +const mockExecFile = vi.fn(); vi.mock("child_process", () => ({ execFile: (...args: unknown[]) => mockExecFile(...args), @@ -38,7 +37,7 @@ describe("detectPythonInterpreter", () => { beforeEach(() => { clearPythonVersionCache(); mockFileExistsResult = false; - mockExecFile = vi.fn(); + mockExecFile.mockReset(); }); afterEach(() => { diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts index 49f0e1feeb..6f4a9865c5 100644 --- a/extensions/vscode/src/interpreters/rInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { detectRInterpreter } from "./rInterpreter"; -let mockExecFile: ReturnType; +const mockExecFile = vi.fn(); vi.mock("child_process", () => ({ execFile: (...args: unknown[]) => mockExecFile(...args), @@ -32,7 +32,7 @@ vi.mock("src/utils/files", () => ({ describe("detectRInterpreter", () => { beforeEach(() => { mockFileExistsResult = false; - mockExecFile = vi.fn(); + mockExecFile.mockReset(); }); afterEach(() => { From f888f71935346513c87ecee9cc00d7ae4c9fb561 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:54:51 -0400 Subject: [PATCH 5/8] Fix test mocks for Vitest v4 hoisting and formatting Use vi.hoisted() to declare mocks before vi.mock() hoisting, pass mock fns directly instead of wrapping in lambdas (fixes TS2348), and run prettier on all new files. Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/interpreters/index.test.ts | 60 ++++++++++++++----- extensions/vscode/src/interpreters/index.ts | 10 +++- .../interpreters/pythonInterpreter.test.ts | 6 +- .../src/interpreters/pythonRequires.test.ts | 24 ++------ .../vscode/src/interpreters/pythonRequires.ts | 3 +- .../src/interpreters/rInterpreter.test.ts | 14 ++--- .../vscode/src/interpreters/rRequires.test.ts | 6 +- 7 files changed, 70 insertions(+), 53 deletions(-) diff --git a/extensions/vscode/src/interpreters/index.test.ts b/extensions/vscode/src/interpreters/index.test.ts index d85079552f..54d48a5606 100644 --- a/extensions/vscode/src/interpreters/index.test.ts +++ b/extensions/vscode/src/interpreters/index.test.ts @@ -3,17 +3,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { getInterpreterDefaults } from "./index"; -const mockDetectPython = vi.fn(); -const mockDetectR = vi.fn(); +const { mockDetectPython, mockDetectR } = vi.hoisted(() => ({ + mockDetectPython: vi.fn(), + mockDetectR: vi.fn(), +})); vi.mock("./pythonInterpreter", () => ({ - detectPythonInterpreter: (...args: unknown[]) => - mockDetectPython(...args), + detectPythonInterpreter: mockDetectPython, })); vi.mock("./rInterpreter", () => ({ - detectRInterpreter: (...args: unknown[]) => - mockDetectR(...args), + detectRInterpreter: mockDetectR, })); describe("getInterpreterDefaults", () => { @@ -24,15 +24,27 @@ describe("getInterpreterDefaults", () => { test("returns results when both detections succeed", async () => { mockDetectPython.mockResolvedValue({ - config: { version: "3.11.5", packageFile: "requirements.txt", packageManager: "auto" }, + config: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }, preferredPath: "/usr/bin/python3", }); mockDetectR.mockResolvedValue({ - config: { version: "4.3.2", packageFile: "renv.lock", packageManager: "renv" }, + config: { + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }, preferredPath: "/usr/bin/R", }); - const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); expect(result.python).toEqual({ version: "3.11.5", @@ -51,11 +63,19 @@ describe("getInterpreterDefaults", () => { test("returns empty Python config when Python detection rejects", async () => { mockDetectPython.mockRejectedValue(new Error("python exploded")); mockDetectR.mockResolvedValue({ - config: { version: "4.3.2", packageFile: "renv.lock", packageManager: "renv" }, + config: { + version: "4.3.2", + packageFile: "renv.lock", + packageManager: "renv", + }, preferredPath: "/usr/bin/R", }); - const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); expect(result.python).toEqual({ version: "", @@ -69,12 +89,20 @@ describe("getInterpreterDefaults", () => { test("returns empty R config when R detection rejects", async () => { mockDetectPython.mockResolvedValue({ - config: { version: "3.11.5", packageFile: "requirements.txt", packageManager: "auto" }, + config: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "auto", + }, preferredPath: "/usr/bin/python3", }); mockDetectR.mockRejectedValue(new Error("R exploded")); - const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); // Python still succeeds expect(result.python.version).toBe("3.11.5"); @@ -90,7 +118,11 @@ describe("getInterpreterDefaults", () => { mockDetectPython.mockRejectedValue(new Error("python exploded")); mockDetectR.mockRejectedValue(new Error("R exploded")); - const result = await getInterpreterDefaults("/project", "/usr/bin/python3", "/usr/bin/R"); + const result = await getInterpreterDefaults( + "/project", + "/usr/bin/python3", + "/usr/bin/R", + ); expect(result.python).toEqual({ version: "", diff --git a/extensions/vscode/src/interpreters/index.ts b/extensions/vscode/src/interpreters/index.ts index 2fcbab6f91..bcb2d61131 100644 --- a/extensions/vscode/src/interpreters/index.ts +++ b/extensions/vscode/src/interpreters/index.ts @@ -21,12 +21,18 @@ export async function getInterpreterDefaults( const python = pythonResult.status === "fulfilled" ? pythonResult.value - : { config: { version: "", packageFile: "", packageManager: "" }, preferredPath: pythonPath || "" }; + : { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: pythonPath || "", + }; const r = rResult.status === "fulfilled" ? rResult.value - : { config: { version: "", packageFile: "", packageManager: "" }, preferredPath: rPath || "" }; + : { + config: { version: "", packageFile: "", packageManager: "" }, + preferredPath: rPath || "", + }; return { python: python.config, diff --git a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts index 0bd20934c2..ba320ecb5f 100644 --- a/extensions/vscode/src/interpreters/pythonInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -6,10 +6,12 @@ import { clearPythonVersionCache, } from "./pythonInterpreter"; -const mockExecFile = vi.fn(); +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); vi.mock("child_process", () => ({ - execFile: (...args: unknown[]) => mockExecFile(...args), + execFile: mockExecFile, })); vi.mock("vscode", () => ({ diff --git a/extensions/vscode/src/interpreters/pythonRequires.test.ts b/extensions/vscode/src/interpreters/pythonRequires.test.ts index 787b9eeac8..32a1008cf0 100644 --- a/extensions/vscode/src/interpreters/pythonRequires.test.ts +++ b/extensions/vscode/src/interpreters/pythonRequires.test.ts @@ -88,11 +88,7 @@ describe("getPythonRequires", () => { }); test("returns empty string when requires-python is absent", async () => { - setFile( - "/project", - "pyproject.toml", - '[project]\nname = "myproject"\n', - ); + setFile("/project", "pyproject.toml", '[project]\nname = "myproject"\n'); const result = await getPythonRequires("/project"); expect(result).toBe(""); }); @@ -103,11 +99,7 @@ describe("getPythonRequires", () => { "pyproject.toml", '[project]\nrequires-python = ">=3.9"', ); - setFile( - "/project", - "setup.cfg", - "[options]\npython_requires = >=3.7\n", - ); + setFile("/project", "setup.cfg", "[options]\npython_requires = >=3.7\n"); const result = await getPythonRequires("/project"); expect(result).toBe(">=3.9"); }); @@ -115,21 +107,13 @@ describe("getPythonRequires", () => { describe("setup.cfg", () => { test("reads python_requires from [options] section", async () => { - setFile( - "/project", - "setup.cfg", - "[options]\npython_requires = >=3.9\n", - ); + setFile("/project", "setup.cfg", "[options]\npython_requires = >=3.9\n"); const result = await getPythonRequires("/project"); expect(result).toBe(">=3.9"); }); test("ignores python_requires in wrong section", async () => { - setFile( - "/project", - "setup.cfg", - "[metadata]\npython_requires = >=3.9\n", - ); + setFile("/project", "setup.cfg", "[metadata]\npython_requires = >=3.9\n"); const result = await getPythonRequires("/project"); expect(result).toBe(""); }); diff --git a/extensions/vscode/src/interpreters/pythonRequires.ts b/extensions/vscode/src/interpreters/pythonRequires.ts index 0f668ab0e2..60399885b7 100644 --- a/extensions/vscode/src/interpreters/pythonRequires.ts +++ b/extensions/vscode/src/interpreters/pythonRequires.ts @@ -72,8 +72,7 @@ async function readPythonVersionFile( return adapted.join(",") || undefined; } -const requiresPythonRe = - /^\s*requires-python\s*=\s*["']([^"']+)["']\s*$/m; +const requiresPythonRe = /^\s*requires-python\s*=\s*["']([^"']+)["']\s*$/m; /** * Read pyproject.toml and extract requires-python from [project] section. diff --git a/extensions/vscode/src/interpreters/rInterpreter.test.ts b/extensions/vscode/src/interpreters/rInterpreter.test.ts index 6f4a9865c5..ad537078cf 100644 --- a/extensions/vscode/src/interpreters/rInterpreter.test.ts +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { detectRInterpreter } from "./rInterpreter"; -const mockExecFile = vi.fn(); +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); vi.mock("child_process", () => ({ - execFile: (...args: unknown[]) => mockExecFile(...args), + execFile: mockExecFile, })); vi.mock("vscode", () => ({ @@ -74,11 +76,7 @@ describe("detectRInterpreter", () => { _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void, ) => { - cb( - null, - "R version 4.3.2 (2023-10-31) -- \"Eye Holes\"\n", - "", - ); + cb(null, 'R version 4.3.2 (2023-10-31) -- "Eye Holes"\n', ""); }, ) .mockImplementationOnce( @@ -194,7 +192,7 @@ describe("detectRInterpreter", () => { cb( new Error("exit code 1"), "", - "R version 4.1.3 (2022-03-10) -- \"One Push-Up\"\n", + 'R version 4.1.3 (2022-03-10) -- "One Push-Up"\n', ); }, ) diff --git a/extensions/vscode/src/interpreters/rRequires.test.ts b/extensions/vscode/src/interpreters/rRequires.test.ts index 0d6f66a376..725544cf16 100644 --- a/extensions/vscode/src/interpreters/rRequires.test.ts +++ b/extensions/vscode/src/interpreters/rRequires.test.ts @@ -96,11 +96,7 @@ describe("getRRequires", () => { }); test("takes priority over renv.lock", async () => { - setFile( - "/project", - "DESCRIPTION", - "Depends: R (>= 4.0.0)", - ); + setFile("/project", "DESCRIPTION", "Depends: R (>= 4.0.0)"); setFile( "/project", "renv.lock", From 52dd8bb6dcdfc8627682ac13f261c6056c8e4b6f Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:06:46 -0400 Subject: [PATCH 6/8] Fix doc comment for adaptPythonRequires return type Returns null on invalid input, not empty string. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/interpreters/versionConstraints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/src/interpreters/versionConstraints.ts b/extensions/vscode/src/interpreters/versionConstraints.ts index 1e894475d4..1d379ee729 100644 --- a/extensions/vscode/src/interpreters/versionConstraints.ts +++ b/extensions/vscode/src/interpreters/versionConstraints.ts @@ -6,7 +6,7 @@ const validVersion = /^\d+(\.\d+)*(\.\*)?$/; /** * Adapts a raw Python version string from `.python-version` into a PEP 440 * constraint suitable for deployment. Returns the adapted constraint string - * or an empty string if the input is invalid. + * or null if the input is invalid. */ export function adaptPythonRequires(raw: string): string | null { const constraint = raw.trim(); From 451c89b230917a9f5419aecf00151790cbd4fc9b Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:14:11 -0400 Subject: [PATCH 7/8] Fix require-await lint error in state.test.ts mock Use Promise.resolve() instead of async arrow function. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/state.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index cc409308e6..853495fc06 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -64,7 +64,7 @@ vi.mock("src/utils/vscode", () => ({ })); vi.mock("src/interpreters", () => ({ - getInterpreterDefaults: vi.fn(async () => ({ + getInterpreterDefaults: vi.fn(() => Promise.resolve({ python: { version: "", packageFile: "", packageManager: "" }, preferredPythonPath: "", r: { version: "", packageFile: "", packageManager: "" }, From 9ec84f7231292fa27618a8dace0fcd42ef7110cb Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:17:43 -0400 Subject: [PATCH 8/8] Fix prettier formatting in state.test.ts Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/state.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 853495fc06..e45e3be361 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -64,12 +64,14 @@ vi.mock("src/utils/vscode", () => ({ })); vi.mock("src/interpreters", () => ({ - getInterpreterDefaults: vi.fn(() => Promise.resolve({ - python: { version: "", packageFile: "", packageManager: "" }, - preferredPythonPath: "", - r: { version: "", packageFile: "", packageManager: "" }, - preferredRPath: "", - })), + getInterpreterDefaults: vi.fn(() => + Promise.resolve({ + python: { version: "", packageFile: "", packageManager: "" }, + preferredPythonPath: "", + r: { version: "", packageFile: "", packageManager: "" }, + preferredRPath: "", + }), + ), })); const mockSyncAllCredentials = vi.fn();