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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 更新日志

## 0.2.7

- 修复「一键配置模块代码提示」在标准 mcpp 安装(install.sh / AUR)下无法发现 mcpp 内置
xlings 的问题:xlings 发现以 `mcpp self env` 为权威来源(项目级契约),路径探测仅作回退;
为 `mcpp self env` 调用增加超时保护,并补齐测试(PR #11)。

## 0.2.6

- 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd
Expand Down
2 changes: 1 addition & 1 deletion src/cliController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export class McppCliController {
return false;
}

private mcppExecutable(project: McppProjectDiscovery | undefined): string {
public mcppExecutable(project: McppProjectDiscovery | undefined): string {
const uri = project === undefined
? vscode.workspace.workspaceFolders?.[0]?.uri
: vscode.Uri.file(project.root);
Expand Down
6 changes: 4 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type McppProjectDiscovery,
} from "./discovery";
import {
findXlingsExecutable,
resolveXlingsExecutable,
llvmToolsVersionSpec,
xlingsInstallArgs,
} from "./llvmTools";
Expand Down Expand Up @@ -763,7 +763,9 @@ async function autoConfigureModulesWizard(
: { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" };
}

const xlingsPath = findXlingsExecutable();
const xlingsPath = await resolveXlingsExecutable(
cliController.mcppExecutable(currentContext.project),
);
const compilerPath = currentContext.analysis.compilerPath;
if (xlingsPath === undefined || compilerPath === undefined) {
return {
Expand Down
75 changes: 68 additions & 7 deletions src/llvmTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import path from "node:path";
import process from "node:process";

import type { ToolIdentity } from "./analysis";
import { runProcess, type ProcessResult } from "./process";
import {
runProcess,
type ProcessResult,
type ProcessRunner,
} from "./process";

export function llvmToolsVersionSpec(identity: ToolIdentity): string {
return `${identity.major}.${identity.minor}.${identity.patch}`;
Expand Down Expand Up @@ -60,8 +64,16 @@ export function xlingsInstallArgs(version?: string): string[] {
return ["update", "llvm-tools"];
}

export function findXlingsExecutable(): string | undefined {
const home = os.homedir();
export interface FindXlingsOptions {
/** Override for tests: base home directory instead of os.homedir(). */
home?: string;
/** Override for tests: environment instead of process.env. */
env?: NodeJS.ProcessEnv;
}

export function findXlingsExecutable(options?: FindXlingsOptions): string | undefined {
const home = options?.home ?? os.homedir();
const env = options?.env ?? process.env;
const knownPaths = [
path.join(home, ".xlings", "subos", "current", "bin", "xlings"),
path.join(home, ".xlings", "bin", "xlings"),
Expand All @@ -72,6 +84,26 @@ export function findXlingsExecutable(): string | undefined {
);
}

// mcpp (install.sh / AUR / mcpp-m) bundles xlings inside its own registry
// sandbox instead of installing to ~/.xlings. The AUR launcher pins the
// path via MCPP_VENDORED_XLINGS; otherwise it lives at
// $MCPP_HOME/registry/bin/xlings. Without probing both, the one-click
// module setup can never auto-install llvm-tools after a standard install.
const vendored = env.MCPP_VENDORED_XLINGS?.trim();
if (vendored !== undefined && vendored.length > 0) {
knownPaths.push(vendored);
}
const mcppHome = env.MCPP_HOME?.trim();
const extension = process.platform === "win32" ? ".exe" : "";
knownPaths.push(
path.join(
mcppHome !== undefined && mcppHome.length > 0 ? mcppHome : path.join(home, ".mcpp"),
"registry",
"bin",
`xlings${extension}`,
),
);

// Check known install paths first
for (const candidate of knownPaths) {
if (existsSync(candidate)) {
Expand All @@ -82,15 +114,15 @@ export function findXlingsExecutable(): string | undefined {
// Fall back to PATH, but only when "xlings" actually resolves there. Always
// returning "xlings" hid the not-installed case, so callers could never show
// the "xlings 未安装" guidance.
return xlingsResolvableOnPath() ? "xlings" : undefined;
return xlingsResolvableOnPath(env.PATH) ? "xlings" : undefined;
}

function xlingsResolvableOnPath(): boolean {
function xlingsResolvableOnPath(pathValue?: string): boolean {
const names = process.platform === "win32"
? ["xlings.exe", "xlings.cmd", "xlings.bat"]
: ["xlings"];
const pathValue = process.env.PATH ?? "";
for (const dir of pathValue.split(path.delimiter)) {
const pathEnv = pathValue ?? "";
for (const dir of pathEnv.split(path.delimiter)) {
if (dir.length === 0) {
continue;
}
Expand All @@ -103,6 +135,35 @@ function xlingsResolvableOnPath(): boolean {
return false;
}

const XLINGS_BINARY_LINE = /^\s*xlings binary\s*=\s*(.+?)\s*$/im;

// Source of truth is mcpp itself, not the filesystem or PATH: `mcpp self env`
// reports the exact xlings bundled with THIS mcpp (mcpp is a project-level
// environment; it owns its tool paths). Works for install.sh, AUR and any
// custom MCPP_PREFIX layout. Falls back to the historical path heuristics for
// standalone ~/.xlings installs and for mcpp versions without the line.
//
// The subprocess is bounded by MCPP_SELF_ENV_TIMEOUT_MS: the wizard reaches
// this step only after mcpp is initialized (toolchain list / build already
// ran), so 60s is generous while still guarding against an extreme hang.
const MCPP_SELF_ENV_TIMEOUT_MS = 60_000;

export async function resolveXlingsExecutable(
mcppExecutable: string,
runner: ProcessRunner = runProcess,
options?: FindXlingsOptions,
): Promise<string | undefined> {
const result = await runner(mcppExecutable, ["self", "env"], undefined, {
timeoutMs: MCPP_SELF_ENV_TIMEOUT_MS,
});
const match = `${result.stdout}\n${result.stderr}`.match(XLINGS_BINARY_LINE);
const reported = match?.[1]?.trim();
if (reported !== undefined && reported.length > 0 && existsSync(reported)) {
return reported;
}
return findXlingsExecutable(options);
}

export async function runXlingsCommand(
xlingsPath: string,
args: string[],
Expand Down
119 changes: 119 additions & 0 deletions test/llvmTools.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import {
Expand All @@ -7,8 +10,12 @@ import {
xlingsInstallArgs,
deriveInstalledClangdPath,
findXlingsExecutable,
resolveXlingsExecutable,
} from "../src/llvmTools";

// `xlings` on POSIX, `xlings.exe` on Windows — mirrors mcpp's exe_suffix.
const xlingsBinaryName = process.platform === "win32" ? "xlings.exe" : "xlings";

test("extracts version string from ToolIdentity", () => {
assert.equal(
llvmToolsVersionSpec({ major: 22, minor: 1, patch: 8, revision: "abc1234" }),
Expand Down Expand Up @@ -68,3 +75,115 @@ test("findXlingsExecutable returns a string or undefined", () => {
// Returns string (PATH fallback or known path) or undefined if xlings not found
assert.ok(result === undefined || typeof result === "string");
});

test("findXlingsExecutable finds the xlings bundled in $MCPP_HOME/registry/bin", () => {
const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-mcpp-home-"));
const registryBin = path.join(home, "registry", "bin");
const xlingsPath = path.join(registryBin, xlingsBinaryName);
mkdirSync(registryBin, { recursive: true });
writeFileSync(xlingsPath, "#!/bin/sh\n");
try {
assert.equal(
findXlingsExecutable({ home, env: { MCPP_HOME: home } }),
xlingsPath,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("findXlingsExecutable falls back to $HOME/.mcpp/registry/bin when MCPP_HOME is unset", () => {
const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-home-"));
const registryBin = path.join(home, ".mcpp", "registry", "bin");
const xlingsPath = path.join(registryBin, xlingsBinaryName);
mkdirSync(registryBin, { recursive: true });
writeFileSync(xlingsPath, "#!/bin/sh\n");
try {
assert.equal(
findXlingsExecutable({ home, env: {} }),
xlingsPath,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("findXlingsExecutable honors MCPP_VENDORED_XLINGS", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-vendored-"));
const vendored = path.join(root, "opt-mcpp", "registry", "bin", xlingsBinaryName);
mkdirSync(path.dirname(vendored), { recursive: true });
writeFileSync(vendored, "#!/bin/sh\n");
try {
assert.equal(
findXlingsExecutable({ home: root, env: { MCPP_VENDORED_XLINGS: vendored } }),
vendored,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("resolveXlingsExecutable reads the xlings binary from `mcpp self env`", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-selfenv-"));
const xlingsPath = path.join(root, "registry", "bin", xlingsBinaryName);
mkdirSync(path.dirname(xlingsPath), { recursive: true });
writeFileSync(xlingsPath, "#!/bin/sh\n");
const runner = async () => ({
exitCode: 0,
stdout: `MCPP_HOME = ${root}\nxlings binary = ${xlingsPath}\nxlings pinned = 2026.8.8.1\n`,
stderr: "",
});
try {
assert.equal(
await resolveXlingsExecutable("/tools/mcpp", runner),
xlingsPath,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("resolveXlingsExecutable passes a timeout to `mcpp self env`", async () => {
let captured: { timeoutMs?: number } | undefined;
const runner = async (
_executable: string,
_args: string[],
_cwd?: string,
options?: { timeoutMs?: number },
) => {
captured = options;
return { exitCode: 0, stdout: "", stderr: "" };
};
await resolveXlingsExecutable("/tools/mcpp", runner);
assert.equal(captured?.timeoutMs, 60_000);
});

test("resolveXlingsExecutable falls back to path probing when the reported path does not exist", async () => {
const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-missing-"));
const runner = async () => ({
exitCode: 0,
stdout: "xlings binary = /no/such/xlings\n",
stderr: "",
});
try {
assert.equal(
await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }),
undefined,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("resolveXlingsExecutable falls back to path probing when `mcpp self env` fails", async () => {
const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-fail-"));
const runner = async () => ({ exitCode: 1, stdout: "", stderr: "boom\n" });
try {
assert.equal(
await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }),
undefined,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
Loading