From 60901e6f4c08dbe737537e7a398d5a29465999d9 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:46:53 -0400 Subject: [PATCH] Add TypeScript interpreter detection module Add a self-contained TypeScript module for detecting Python and R interpreters, replacing the Go backend's /api/interpreters endpoint. The module includes: - Version constraint parsing (requires-python/requires-r from metadata) - Python interpreter detection via sys.executable and version probing - R interpreter detection via R --version parsing - Shared filesystem utilities for reading files and checking existence All source files use only Node.js builtins (no vscode dependency), making the module testable in isolation with vitest. The module defines its own local types (PythonInterpreterConfig, RInterpreterConfig, InterpreterDetectionResult) so no existing files are modified. Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/interpreters/fsUtils.test.ts | 47 +++ extensions/vscode/src/interpreters/fsUtils.ts | 27 ++ .../vscode/src/interpreters/index.test.ts | 164 +++++++++ extensions/vscode/src/interpreters/index.ts | 52 +++ .../interpreters/pythonInterpreter.test.ts | 260 +++++++++++++ .../src/interpreters/pythonInterpreter.ts | 117 ++++++ .../src/interpreters/pythonRequires.test.ts | 109 ++++++ .../vscode/src/interpreters/pythonRequires.ts | 121 ++++++ .../src/interpreters/rInterpreter.test.ts | 344 ++++++++++++++++++ .../vscode/src/interpreters/rInterpreter.ts | 165 +++++++++ .../vscode/src/interpreters/rRequires.test.ts | 121 ++++++ .../vscode/src/interpreters/rRequires.ts | 86 +++++ .../interpreters/versionConstraints.test.ts | 62 ++++ .../src/interpreters/versionConstraints.ts | 65 ++++ 14 files changed, 1740 insertions(+) create mode 100644 extensions/vscode/src/interpreters/fsUtils.test.ts create mode 100644 extensions/vscode/src/interpreters/fsUtils.ts create mode 100644 extensions/vscode/src/interpreters/index.test.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 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`; +}