diff --git a/extensions/vscode/src/interpreters/fsUtils.test.ts b/extensions/vscode/src/interpreters/fsUtils.test.ts new file mode 100644 index 000000000..e66970dc8 --- /dev/null +++ b/extensions/vscode/src/interpreters/fsUtils.test.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, test, vi } from "vitest"; +import { readFileText, fileExistsAt } from "./fsUtils"; + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn((filePath: string) => { + if (filePath === "/exists.txt") { + return Promise.resolve("hello world"); + } + return Promise.reject( + Object.assign(new Error("ENOENT"), { code: "ENOENT" }), + ); + }), + access: vi.fn((filePath: string) => { + if (filePath === "/exists.txt") { + return Promise.resolve(); + } + return Promise.reject( + Object.assign(new Error("ENOENT"), { code: "ENOENT" }), + ); + }), +})); + +describe("readFileText", () => { + test("returns file content as string when file exists", async () => { + const result = await readFileText("/exists.txt"); + expect(result).toBe("hello world"); + }); + + test("returns null when file does not exist", async () => { + const result = await readFileText("/missing.txt"); + expect(result).toBeNull(); + }); +}); + +describe("fileExistsAt", () => { + test("returns true when file exists", async () => { + const result = await fileExistsAt("/exists.txt"); + expect(result).toBe(true); + }); + + test("returns false when file does not exist", async () => { + const result = await fileExistsAt("/missing.txt"); + expect(result).toBe(false); + }); +}); diff --git a/extensions/vscode/src/interpreters/fsUtils.ts b/extensions/vscode/src/interpreters/fsUtils.ts new file mode 100644 index 000000000..82e057b9d --- /dev/null +++ b/extensions/vscode/src/interpreters/fsUtils.ts @@ -0,0 +1,27 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { access, readFile } from "node:fs/promises"; + +/** + * Read a file as UTF-8 text, returning null if the file doesn't exist + * or can't be read. + */ +export async function readFileText(filePath: string): Promise { + try { + return await readFile(filePath, "utf-8"); + } catch { + return null; + } +} + +/** + * Check whether a file exists at the given path. + */ +export async function fileExistsAt(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} diff --git a/extensions/vscode/src/interpreters/index.test.ts b/extensions/vscode/src/interpreters/index.test.ts new file mode 100644 index 000000000..54d48a560 --- /dev/null +++ b/extensions/vscode/src/interpreters/index.test.ts @@ -0,0 +1,164 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getInterpreterDefaults } from "./index"; + +const { mockDetectPython, mockDetectR } = vi.hoisted(() => ({ + mockDetectPython: vi.fn(), + mockDetectR: vi.fn(), +})); + +vi.mock("./pythonInterpreter", () => ({ + detectPythonInterpreter: mockDetectPython, +})); + +vi.mock("./rInterpreter", () => ({ + detectRInterpreter: mockDetectR, +})); + +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", + }, + 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/index.ts b/extensions/vscode/src/interpreters/index.ts new file mode 100644 index 000000000..0f85f42fa --- /dev/null +++ b/extensions/vscode/src/interpreters/index.ts @@ -0,0 +1,52 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { + detectPythonInterpreter, + PythonInterpreterConfig, +} from "./pythonInterpreter"; +import { detectRInterpreter, RInterpreterConfig } from "./rInterpreter"; + +export interface InterpreterDetectionResult { + python: PythonInterpreterConfig; + preferredPythonPath: string; + r: RInterpreterConfig; + preferredRPath: string; +} + +/** + * 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 000000000..e004cbd5e --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.test.ts @@ -0,0 +1,260 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + detectPythonInterpreter, + clearPythonVersionCache, +} from "./pythonInterpreter"; + +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execFile: mockExecFile, +})); + +let mockFileExistsResult = false; +vi.mock("./fsUtils", () => ({ + fileExistsAt: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +// Mock getPythonRequires so it doesn't try to read real files +vi.mock("./pythonRequires", () => ({ + getPythonRequires: vi.fn(() => Promise.resolve("")), +})); + +describe("detectPythonInterpreter", () => { + beforeEach(() => { + clearPythonVersionCache(); + mockFileExistsResult = false; + mockExecFile.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided and PATH lookup fails", 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"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("falls back to python3 on PATH when no preferred path given", async () => { + mockExecFile.mockImplementation( + ( + cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + if (cmd === "python3") { + cb(null, "3.12.0\n"); + } else { + cb(new Error("not found"), ""); + } + }, + ); + + const result = await detectPythonInterpreter("/project"); + expect(result.config.version).toBe("3.12.0"); + expect(result.preferredPath).toBe("python3"); + }); + + test("falls back to python on PATH when python3 is not found", async () => { + mockExecFile.mockImplementation( + ( + cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + if (cmd === "python") { + cb(null, "3.9.7\n"); + } else { + cb(new Error("not found"), ""); + } + }, + ); + + const result = await detectPythonInterpreter("/project"); + expect(result.config.version).toBe("3.9.7"); + expect(result.preferredPath).toBe("python"); + }); + + 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("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( + ( + _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 000000000..92961904f --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonInterpreter.ts @@ -0,0 +1,117 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { execFile } from "child_process"; +import path from "node:path"; +import { fileExistsAt } from "./fsUtils"; +import { getPythonRequires } from "./pythonRequires"; + +const REQUIREMENTS_TXT = "requirements.txt"; +const PYTHON_PATH_FALLBACKS = ["python3", "python"]; + +const pythonVersionCache = new Map(); + +export interface PythonInterpreterConfig { + version: string; + packageFile: string; + packageManager: string; + requiresPython?: string; +} + +export interface PythonDetectionResult { + config: PythonInterpreterConfig; + 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 || "", + }; + + // Try preferred path first, then fall back to PATH lookup + let resolvedPath = preferredPath || ""; + let version = ""; + + if (preferredPath) { + version = await getPythonVersionFromExecutable(preferredPath, projectDir); + } + + if (!version) { + for (const candidate of PYTHON_PATH_FALLBACKS) { + version = await getPythonVersionFromExecutable(candidate, projectDir); + if (version) { + resolvedPath = candidate; + break; + } + } + } + + if (!version) { + return empty; + } + + // Check for requirements.txt + const hasRequirements = await fileExistsAt( + path.join(projectDir, REQUIREMENTS_TXT), + ); + const packageFile = hasRequirements ? REQUIREMENTS_TXT : ""; + + // Read Python version requirements from project metadata + const requiresPython = await getPythonRequires(projectDir); + + return { + config: { + version, + packageFile, + packageManager: "auto", + requiresPython: requiresPython || undefined, + }, + preferredPath: resolvedPath, + }; +} + +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 000000000..8ac0088de --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.test.ts @@ -0,0 +1,109 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import path from "node:path"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getPythonRequires } from "./pythonRequires"; + +const mockFiles: Record = {}; + +vi.mock("./fsUtils", () => ({ + readFileText: vi.fn((filePath: string) => { + const content = mockFiles[filePath]; + if (content === undefined) { + return Promise.resolve(null); + } + return Promise.resolve(content); + }), +})); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[path.join(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 000000000..f09f999f6 --- /dev/null +++ b/extensions/vscode/src/interpreters/pythonRequires.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import path from "node:path"; +import { readFileText } from "./fsUtils"; +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 fromVersionFile = await readPythonVersionFile(projectDir); + if (fromVersionFile) { + return fromVersionFile; + } + + const fromPyProject = await readPyProjectToml(projectDir); + if (fromPyProject) { + return fromPyProject; + } + + const fromSetupCfg = await readSetupCfg(projectDir); + if (fromSetupCfg) { + return fromSetupCfg; + } + + return ""; +} + +/** + * Read .python-version file. Plain text, possibly comma-separated versions. + * Each part is adapted through adaptPythonRequires(). + */ +async function readPythonVersionFile( + projectDir: string, +): Promise { + const content = await readFileText(path.join(projectDir, ".python-version")); + 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( + projectDir: string, +): Promise { + const content = await readFileText(path.join(projectDir, "pyproject.toml")); + 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(projectDir: string): Promise { + const content = await readFileText(path.join(projectDir, "setup.cfg")); + 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 000000000..aaf9c1fc5 --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.test.ts @@ -0,0 +1,344 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { detectRInterpreter } from "./rInterpreter"; + +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execFile: mockExecFile, +})); + +let mockFileExistsResult = false; +vi.mock("./fsUtils", () => ({ + fileExistsAt: vi.fn(() => Promise.resolve(mockFileExistsResult)), +})); + +// Mock getRRequires so it doesn't try to read real files +vi.mock("./rRequires", () => ({ + getRRequires: vi.fn(() => Promise.resolve("")), +})); + +describe("detectRInterpreter", () => { + beforeEach(() => { + mockFileExistsResult = false; + mockExecFile.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns empty config when no path provided and PATH lookup fails", 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"); + expect(result.config.version).toBe(""); + expect(result.config.packageFile).toBe(""); + expect(result.config.packageManager).toBe(""); + expect(result.preferredPath).toBe(""); + }); + + test("falls back to R on PATH when no preferred path given", async () => { + 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)\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"); + expect(result.config.version).toBe("4.3.2"); + expect(result.preferredPath).toBe("R"); + }); + + 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("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( + ( + _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 000000000..1b96ab529 --- /dev/null +++ b/extensions/vscode/src/interpreters/rInterpreter.ts @@ -0,0 +1,165 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { execFile } from "child_process"; +import path from "node:path"; +import { fileExistsAt } from "./fsUtils"; +import { getRRequires } from "./rRequires"; + +const DEFAULT_RENV_LOCKFILE = "renv.lock"; +const R_PATH_FALLBACKS = ["R"]; +const R_VERSION_TIMEOUT = 15000; +const RENV_LOCKFILE_TIMEOUT = 15000; + +const rVersionRe = /^R version (\d+\.\d+\.\d+)/; +const renvLockPathRe = /^\[1\] "(.*)"/; + +export interface RInterpreterConfig { + version: string; + packageFile: string; + packageManager: string; + requiresR?: string; +} + +export interface RDetectionResult { + config: RInterpreterConfig; + 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 || "", + }; + + // Try preferred path first, then fall back to PATH lookup + let resolvedPath = preferredPath || ""; + let version = ""; + + if (preferredPath) { + version = await getRVersionFromExecutable(preferredPath); + } + + if (!version) { + for (const candidate of R_PATH_FALLBACKS) { + version = await getRVersionFromExecutable(candidate); + if (version) { + resolvedPath = candidate; + break; + } + } + } + + if (!version) { + return empty; + } + + // Resolve the renv lockfile path + const lockfilePath = await resolveRenvLockfile(resolvedPath, projectDir); + const lockfilePresent = await fileExistsAt( + path.join(projectDir, lockfilePath), + ); + const packageFile = lockfilePresent ? lockfilePath : ""; + + // Read R version requirements from project metadata + const requiresR = await getRRequires(projectDir); + + return { + config: { + version, + packageFile, + packageManager: "renv", + requiresR: requiresR || undefined, + }, + preferredPath: resolvedPath, + }; +} + +/** + * 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 000000000..00f25d8cf --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.test.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import path from "node:path"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getRRequires } from "./rRequires"; + +const mockFiles: Record = {}; + +vi.mock("./fsUtils", () => ({ + readFileText: vi.fn((filePath: string) => { + const content = mockFiles[filePath]; + if (content === undefined) { + return Promise.resolve(null); + } + return Promise.resolve(content); + }), +})); + +function setFile(projectDir: string, filename: string, content: string) { + mockFiles[path.join(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 000000000..ac08d4efb --- /dev/null +++ b/extensions/vscode/src/interpreters/rRequires.ts @@ -0,0 +1,86 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import path from "node:path"; +import { readFileText } from "./fsUtils"; +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 fromDescription = await readDescriptionFile(projectDir); + if (fromDescription) { + return fromDescription; + } + + const fromRenvLock = await readRenvLock(projectDir); + if (fromRenvLock) { + return fromRenvLock; + } + + return ""; +} + +/** + * Read DESCRIPTION file and look for R version in the Depends: section. + * Matches patterns like "R (>= 3.5.0)". + */ +async function readDescriptionFile( + projectDir: string, +): Promise { + const content = await readFileText(path.join(projectDir, "DESCRIPTION")); + 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(projectDir: string): Promise { + const content = await readFileText(path.join(projectDir, "renv.lock")); + 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 000000000..496c10ba2 --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.test.ts @@ -0,0 +1,62 @@ +// Copyright (C) 2026 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 000000000..1d379ee72 --- /dev/null +++ b/extensions/vscode/src/interpreters/versionConstraints.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2026 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 null 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`; +}