Skip to content

Commit feaedb3

Browse files
committed
fix: #225 support relative paths and ${workspaceFolder} in mise.binPath
1 parent 3e3135b commit feaedb3

6 files changed

Lines changed: 248 additions & 2 deletions

File tree

docs/src/content/docs/reference/Settings.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Path to the mise binary (automatically detected on startup).
3838

3939
If set to `mise` (default), it will use `mise` available in `PATH`.
4040

41+
Relative paths (e.g. `./bin/mise`) and `${workspaceFolder}` variables are resolved against the workspace folders.
42+
4143
See https://mise.jdx.dev/getting-started.html to install mise.
4244

4345
---

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@
169169
"mise.binPath": {
170170
"order": 2,
171171
"type": "string",
172-
"markdownDescription": "Path to the mise binary (automatically detected on startup).\n\nIf set to `mise` (default), it will use `mise` available in `PATH`.\n\nSee https://mise.jdx.dev/getting-started.html to install mise.",
172+
"markdownDescription": "Path to the mise binary (automatically detected on startup).\n\nIf set to `mise` (default), it will use `mise` available in `PATH`.\n\nRelative paths (e.g. `./bin/mise`) and `${workspaceFolder}` variables are resolved against the workspace folders.\n\nSee https://mise.jdx.dev/getting-started.html to install mise.",
173173
"default": "mise"
174174
},
175175
"mise.miseEnv": {

src/configuration.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isDeepStrictEqual } from "node:util";
22
import { deepMerge } from "@std/collections";
33
import * as vscode from "vscode";
4+
import { resolveConfiguredBinPath } from "./utils/fileUtils";
45
import { logger } from "./utils/logger";
56

67
export const CONFIGURATION_FLAGS = {
@@ -114,7 +115,21 @@ export const getMiseEnv = (): string | undefined => {
114115
};
115116

116117
export const getConfiguredBinPath = (): string | undefined => {
117-
return getExtensionConfig().get<string>(CONFIGURATION_FLAGS.binPath)?.trim();
118+
const configuredPath = getExtensionConfig()
119+
.get<string>(CONFIGURATION_FLAGS.binPath)
120+
?.trim();
121+
122+
if (!configuredPath) {
123+
return configuredPath;
124+
}
125+
126+
return resolveConfiguredBinPath(
127+
configuredPath,
128+
vscode.workspace.workspaceFolders?.map((folder) => ({
129+
name: folder.name,
130+
fsPath: folder.uri.fsPath,
131+
})) ?? [],
132+
);
118133
};
119134

120135
export const updateBinPath = async (binPath: string) => {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as assert from "node:assert";
2+
import { execFile } from "node:child_process";
3+
import { mkdir, rm, symlink } from "node:fs/promises";
4+
import * as path from "node:path";
5+
import { promisify } from "node:util";
6+
import * as vscode from "vscode";
7+
import { MiseService } from "../miseService";
8+
9+
const execFileAsync = promisify(execFile);
10+
11+
suite("Relative binPath Test Suite", function () {
12+
this.timeout(20_000);
13+
14+
let workspaceRoot: string;
15+
let binDir: string;
16+
let miseService: MiseService;
17+
let originalBinPath: string | undefined;
18+
19+
// initializeMisePath only reads workspaceState from the context
20+
const fakeContext = {
21+
workspaceState: { get: () => undefined },
22+
} as unknown as vscode.ExtensionContext;
23+
24+
const getGlobalBinPathValue = () =>
25+
vscode.workspace.getConfiguration("mise").inspect<string>("binPath")
26+
?.globalValue;
27+
28+
const updateBinPath = (value: string | undefined) =>
29+
vscode.workspace
30+
.getConfiguration("mise")
31+
.update("binPath", value, vscode.ConfigurationTarget.Global);
32+
33+
setup(async () => {
34+
workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "";
35+
assert.ok(workspaceRoot, "Workspace root should be available");
36+
37+
originalBinPath = getGlobalBinPathValue();
38+
39+
const { stdout } = await execFileAsync("which", ["mise"]);
40+
const miseBin = stdout.trim();
41+
assert.ok(miseBin, "mise should be available in PATH");
42+
43+
binDir = path.join(workspaceRoot, "bin");
44+
await rm(binDir, { recursive: true, force: true });
45+
await mkdir(binDir, { recursive: true });
46+
await symlink(miseBin, path.join(binDir, "mise"));
47+
48+
miseService = new MiseService(fakeContext);
49+
});
50+
51+
teardown(async () => {
52+
await updateBinPath(originalBinPath);
53+
await rm(binDir, { recursive: true, force: true });
54+
});
55+
56+
test("a relative binPath is resolved against the workspace and not overwritten", async () => {
57+
await updateBinPath("./bin/mise");
58+
59+
await miseService.initializeMisePath();
60+
61+
assert.equal(
62+
miseService.getMiseBinaryPath(),
63+
path.join(workspaceRoot, "bin", "mise"),
64+
);
65+
// the configured relative path must survive activation instead of
66+
// being rewritten to the resolved absolute path
67+
assert.equal(getGlobalBinPathValue(), "./bin/mise");
68+
69+
const { stdout } = await miseService.execMiseCommand("version");
70+
assert.ok(stdout.trim().length > 0, "mise version should produce output");
71+
});
72+
});

src/utils/fileUtils.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
compareSourcePaths,
44
getShebangFileExtension,
55
getSourceProximityRank,
6+
resolveConfiguredBinPath,
67
} from "./fileUtils";
78

89
describe("getSourceProximityRank", () => {
@@ -103,3 +104,102 @@ describe("getShebangFileExtension", () => {
103104
expect(getShebangFileExtension("#!/usr/bin/env made-up\n")).toBeUndefined();
104105
});
105106
});
107+
108+
describe("resolveConfiguredBinPath", () => {
109+
const folders = [
110+
{ name: "frontend", fsPath: "/repo/frontend" },
111+
{ name: "backend", fsPath: "/repo/backend" },
112+
];
113+
const existsIn = (...paths: string[]) => {
114+
return (filePath: string) => paths.includes(filePath);
115+
};
116+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal VS Code variable
117+
const workspaceFolderVar = "${workspaceFolder}";
118+
119+
it("leaves bare command names untouched for PATH lookup", () => {
120+
expect(resolveConfiguredBinPath("mise", folders, existsIn())).toBe("mise");
121+
expect(resolveConfiguredBinPath("mise.exe", folders, existsIn())).toBe(
122+
"mise.exe",
123+
);
124+
});
125+
126+
it("leaves absolute paths untouched", () => {
127+
expect(
128+
resolveConfiguredBinPath("/usr/local/bin/mise", folders, existsIn()),
129+
).toBe("/usr/local/bin/mise");
130+
});
131+
132+
it("resolves relative paths against the first workspace folder containing the file", () => {
133+
expect(
134+
resolveConfiguredBinPath(
135+
"./bin/mise",
136+
folders,
137+
existsIn("/repo/backend/bin/mise"),
138+
),
139+
).toBe("/repo/backend/bin/mise");
140+
expect(
141+
resolveConfiguredBinPath(
142+
"bin/mise",
143+
folders,
144+
existsIn("/repo/frontend/bin/mise", "/repo/backend/bin/mise"),
145+
),
146+
).toBe("/repo/frontend/bin/mise");
147+
});
148+
149+
it("falls back to the first workspace folder when the file does not exist", () => {
150+
expect(resolveConfiguredBinPath("./bin/mise", folders, existsIn())).toBe(
151+
"/repo/frontend/bin/mise",
152+
);
153+
});
154+
155+
it("resolves workspaceFolder variables", () => {
156+
expect(
157+
resolveConfiguredBinPath(
158+
`${workspaceFolderVar}/bin/mise`,
159+
folders,
160+
existsIn("/repo/backend/bin/mise"),
161+
),
162+
).toBe("/repo/backend/bin/mise");
163+
expect(
164+
resolveConfiguredBinPath(
165+
`${workspaceFolderVar}/bin/mise`,
166+
folders,
167+
existsIn(),
168+
),
169+
).toBe("/repo/frontend/bin/mise");
170+
});
171+
172+
it("resolves named workspaceFolder variables against the named folder", () => {
173+
expect(
174+
resolveConfiguredBinPath(
175+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal VS Code variable
176+
"${workspaceFolder:backend}/bin/mise",
177+
folders,
178+
existsIn(),
179+
),
180+
).toBe("/repo/backend/bin/mise");
181+
});
182+
183+
it("expands the home directory prefix", () => {
184+
const resolved = resolveConfiguredBinPath(
185+
"~/bin/mise",
186+
folders,
187+
existsIn(),
188+
);
189+
expect(resolved.endsWith("bin/mise")).toBe(true);
190+
expect(resolved.startsWith("~")).toBe(false);
191+
});
192+
193+
it("returns the configured path unchanged without workspace folders", () => {
194+
expect(resolveConfiguredBinPath("./bin/mise", [], existsIn())).toBe(
195+
"./bin/mise",
196+
);
197+
expect(
198+
resolveConfiguredBinPath(
199+
`${workspaceFolderVar}/bin/mise`,
200+
[],
201+
existsIn(),
202+
),
203+
).toBe(`${workspaceFolderVar}/bin/mise`);
204+
});
205+
});

src/utils/fileUtils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { existsSync } from "node:fs";
12
import * as fs from "node:fs/promises";
23
import * as os from "node:os";
34
import * as path from "node:path";
@@ -15,6 +16,62 @@ export function expandPath(filePath: string): string {
1516
return res;
1617
}
1718

19+
const WORKSPACE_FOLDER_VARIABLE = /^\$\{workspaceFolder(?::([^}]+))?\}[/\\]?/;
20+
21+
/**
22+
* Resolve a configured binary path (`mise.binPath`) that may reference the
23+
* workspace: `${workspaceFolder}`/`${workspaceFolder:folderName}` variables
24+
* and relative paths like `./bin/mise` are resolved against the workspace
25+
* folders (the first folder where the file exists wins). Bare command names
26+
* without a path separator (e.g. `mise`) are left untouched so they are
27+
* looked up in `PATH`.
28+
*/
29+
export function resolveConfiguredBinPath(
30+
configuredPath: string,
31+
workspaceFolders: readonly { name: string; fsPath: string }[],
32+
exists: (filePath: string) => boolean = existsSync,
33+
): string {
34+
const variableMatch = configuredPath.match(WORKSPACE_FOLDER_VARIABLE);
35+
36+
if (variableMatch) {
37+
const [prefix, folderName] = variableMatch;
38+
const folders = folderName
39+
? workspaceFolders.filter((folder) => folder.name === folderName)
40+
: workspaceFolders;
41+
42+
const candidates = folders.map((folder) =>
43+
path.join(folder.fsPath, configuredPath.slice(prefix.length)),
44+
);
45+
46+
return (
47+
candidates.find((candidate) => exists(candidate)) ??
48+
candidates[0] ??
49+
configuredPath
50+
);
51+
}
52+
53+
if (configuredPath.startsWith("~/") || configuredPath.startsWith("~\\")) {
54+
return path.join(os.homedir(), configuredPath.slice(2));
55+
}
56+
57+
if (
58+
path.isAbsolute(configuredPath) ||
59+
(!configuredPath.includes("/") && !configuredPath.includes("\\"))
60+
) {
61+
return configuredPath;
62+
}
63+
64+
const candidates = workspaceFolders.map((folder) =>
65+
path.resolve(folder.fsPath, configuredPath),
66+
);
67+
68+
return (
69+
candidates.find((candidate) => exists(candidate)) ??
70+
candidates[0] ??
71+
configuredPath
72+
);
73+
}
74+
1875
const SHEBANG_EXTENSIONS: Record<string, string> = {
1976
bash: "sh",
2077
sh: "sh",

0 commit comments

Comments
 (0)