diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4177e..0015cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cliController.ts b/src/cliController.ts index ef77e6a..2416b72 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -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); diff --git a/src/extension.ts b/src/extension.ts index f16d760..23fb2de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,7 +19,7 @@ import { type McppProjectDiscovery, } from "./discovery"; import { - findXlingsExecutable, + resolveXlingsExecutable, llvmToolsVersionSpec, xlingsInstallArgs, } from "./llvmTools"; @@ -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 { diff --git a/src/llvmTools.ts b/src/llvmTools.ts index be064f9..f573f05 100644 --- a/src/llvmTools.ts +++ b/src/llvmTools.ts @@ -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}`; @@ -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"), @@ -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)) { @@ -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; } @@ -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 { + 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[], diff --git a/test/llvmTools.test.ts b/test/llvmTools.test.ts index b6348f5..8e967d7 100644 --- a/test/llvmTools.test.ts +++ b/test/llvmTools.test.ts @@ -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 { @@ -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" }), @@ -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 }); + } +});