Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions extensions/vscode/src/interpreters/fsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions extensions/vscode/src/interpreters/fsUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
164 changes: 164 additions & 0 deletions extensions/vscode/src/interpreters/index.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
52 changes: 52 additions & 0 deletions extensions/vscode/src/interpreters/index.ts
Original file line number Diff line number Diff line change
@@ -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<InterpreterDetectionResult> {
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,
};
}
Loading
Loading