diff --git a/src/analysis.ts b/src/analysis.ts index 921beb3..365c144 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -18,6 +18,8 @@ export interface CompilationDatabaseAnalysis { directory?: string; arguments?: string[]; hasPrebuiltModules?: boolean; + modulePcmSourceDirectories?: string[]; + modulePcmConsumerDirectories?: string[]; reason: string; } @@ -56,6 +58,13 @@ interface CompilationCommand { command?: unknown; } +interface CompilationCandidate extends CompilationDatabaseAnalysis { + moduleInterface: boolean; + projectSource: boolean; + moduleCompatibilityKey: string; + prebuiltModuleDirectory?: string; +} + function unavailable(reason: string): CompilationDatabaseAnalysis { return { kind: "unknown", @@ -145,6 +154,37 @@ function compilerKind(compilerPath: string): CompilerKind { return "unknown"; } +function prebuiltModuleDirectory(arguments_: readonly string[]): string | undefined { + const flag = arguments_.find((argument) => argument.startsWith("-fprebuilt-module-path=")); + if (flag === undefined) { + return undefined; + } + const value = flag.slice("-fprebuilt-module-path=".length); + return value.length >= 2 && value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1) + : value; +} + +function moduleCompatibilityKey(arguments_: readonly string[]): string { + const result: string[] = []; + for (let index = 1; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "-c" || argument === "-o" || argument === "-I") { + index += 1; + continue; + } + if ( + argument.startsWith("-I") + || argument.startsWith("-fmodule-file=") + || argument.startsWith("-fprebuilt-module-path=") + ) { + continue; + } + result.push(argument); + } + return result.join("\0"); +} + export function analyzeCompilationDatabase(contents: string): CompilationDatabaseAnalysis { let parsed: unknown; try { @@ -157,7 +197,7 @@ export function analyzeCompilationDatabase(contents: string): CompilationDatabas return unavailable("compile_commands.json 至少需要包含一条编译命令"); } - const candidates: CompilationDatabaseAnalysis[] = []; + const candidates: CompilationCandidate[] = []; for (const value of parsed) { if (value === null || typeof value !== "object") { @@ -183,6 +223,10 @@ export function analyzeCompilationDatabase(contents: string): CompilationDatabas const sourceFile = typeof command.file === "string" ? resolveCompilationSourceFile(directory, command.file) : undefined; + const moduleInterface = /\.(?:cppm|ixx|mpp|ccm)$/i.test(sourceFile ?? ""); + const projectSource = directory !== undefined + && sourceFile !== undefined + && isProjectSource(directory, sourceFile); candidates.push({ kind, capability: kind === "llvm" ? "full" : "syntax-only", @@ -191,6 +235,10 @@ export function analyzeCompilationDatabase(contents: string): CompilationDatabas directory, arguments: args, hasPrebuiltModules, + moduleInterface, + projectSource, + moduleCompatibilityKey: moduleCompatibilityKey(args), + prebuiltModuleDirectory: prebuiltModuleDirectory(args), reason: kind === "llvm" ? "Clang 编译命令可以由 clangd 使用" : `${kind.toUpperCase()} 模块产物不能由 clangd 使用`, @@ -201,17 +249,41 @@ export function analyzeCompilationDatabase(contents: string): CompilationDatabas return unavailable("compile_commands.json 不包含受支持的编译器命令"); } - const score = (candidate: CompilationDatabaseAnalysis): number => { + const score = (candidate: CompilationCandidate): number => { const sourceFile = candidate.sourceFile ?? ""; - const moduleInterface = /\.(?:cppm|ixx|mpp|ccm)$/i.test(sourceFile); const inProject = candidate.directory !== undefined && isWithinDirectory(candidate.directory, sourceFile); return (inProject ? 200 : 0) - + (candidate.directory !== undefined && isProjectSource(candidate.directory, sourceFile) ? 200 : 0) - + (moduleInterface ? 100 : 0) + + (candidate.projectSource ? 200 : 0) + + (candidate.moduleInterface ? 0 : 100) + (candidate.hasPrebuiltModules ? 10 : 0); }; - return candidates.reduce((best, candidate) => (score(candidate) > score(best) ? candidate : best)); + const selected = candidates.reduce( + (best, candidate) => (score(candidate) > score(best) ? candidate : best), + ); + const uniqueDirectories = (moduleInterface: boolean): string[] => [...new Set( + candidates + .filter((candidate) => ( + candidate.kind === "llvm" + && candidate.projectSource + && candidate.moduleInterface === moduleInterface + && candidate.compilerPath === selected.compilerPath + && candidate.moduleCompatibilityKey === selected.moduleCompatibilityKey + )) + .flatMap((candidate) => candidate.prebuiltModuleDirectory ?? []), + )]; + const { + moduleInterface: _moduleInterface, + projectSource: _projectSource, + moduleCompatibilityKey: _moduleCompatibilityKey, + prebuiltModuleDirectory: _prebuiltModuleDirectory, + ...analysis + } = selected; + return { + ...analysis, + modulePcmSourceDirectories: uniqueDirectories(true), + modulePcmConsumerDirectories: uniqueDirectories(false), + }; } function isWithinDirectory(directory: string, file: string): boolean { diff --git a/src/extension.ts b/src/extension.ts index 0d26e3d..db7efa2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -61,6 +61,7 @@ import { type ModuleSetupBlockedReason, type ModuleSetupStepResult, } from "./moduleSetup"; +import { stageAvailableProjectPcms } from "./pcm"; const COMMAND_CONFIGURE = "mcpp.configureClangd"; const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; @@ -187,6 +188,22 @@ function hasUsableCompilationDatabase(project: McppProjectDiscovery): boolean { return loadProjectContext(project)?.analysis.capability !== "unavailable"; } +function stageProjectPcms(context: ProjectContext, output: vscode.OutputChannel): boolean { + try { + const copied = stageAvailableProjectPcms(context.analysis); + if (copied > 0) { + appendOutputLine(output, `[PCM] 已为模块消费者暂存 ${copied} 个现有 PCM。`); + } + return copied > 0; + } catch (error) { + appendOutputLine( + output, + `[PCM] 暂存现有 PCM 失败:${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} + function moduleSetupBlockedMessage(reason: ModuleSetupBlockedReason): string { switch (reason) { case "project-toolchain-override": @@ -578,6 +595,10 @@ async function runModuleSupportCheck( return moduleStatus; } + if (stageProjectPcms(context, output)) { + await restartClangd(output); + } + const checkToken = moduleCheckOperations.begin(context.project.root); moduleStatusByProject.delete(context.project.root); if (shouldRenderProjectStatus(findCurrentProject()?.root, context.project.root)) { @@ -800,6 +821,7 @@ async function autoConfigureModulesWizard( return { stage: "reload", state: "failed", detail: "无法重新加载 mcpp 工程。" }; } currentContext = refreshed; + stageProjectPcms(currentContext, output); updateStatusBar(status, currentContext); if (currentContext.analysis.capability !== "full" || currentContext.analysis.kind !== "llvm") { return { @@ -1032,6 +1054,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi } } + if (workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { + forceRestart ||= stageProjectPcms(context, output); + } updateStatusBar(status, context); const configured = await configureClangd( diff --git a/src/pcm.ts b/src/pcm.ts new file mode 100644 index 0000000..c0a1d27 --- /dev/null +++ b/src/pcm.ts @@ -0,0 +1,44 @@ +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import type { CompilationDatabaseAnalysis } from "./analysis"; + +function sameDirectory(left: string, right: string): boolean { + const normalize = (value: string): string => { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; + }; + return normalize(left) === normalize(right); +} + +export function stageAvailableProjectPcms(analysis: CompilationDatabaseAnalysis): number { + const sources = analysis.modulePcmSourceDirectories ?? []; + const destinations = analysis.modulePcmConsumerDirectories ?? []; + let copied = 0; + + for (const sourceDirectory of sources) { + if (!existsSync(sourceDirectory)) { + continue; + } + const pcms = readdirSync(sourceDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".pcm")); + for (const destinationDirectory of destinations) { + if (sameDirectory(sourceDirectory, destinationDirectory) || pcms.length === 0) { + continue; + } + mkdirSync(destinationDirectory, { recursive: true }); + for (const pcm of pcms) { + const source = path.join(sourceDirectory, pcm.name); + const destination = path.join(destinationDirectory, pcm.name); + if (existsSync(destination) && statSync(source).mtimeMs <= statSync(destination).mtimeMs) { + continue; + } + copyFileSync(source, destination); + copied += 1; + } + } + } + + return copied; +} diff --git a/test/analysis.test.ts b/test/analysis.test.ts index 521b429..6b230b0 100644 --- a/test/analysis.test.ts +++ b/test/analysis.test.ts @@ -57,6 +57,51 @@ test("prefers a project module interface over an earlier external command", () = assert.equal(result.hasPrebuiltModules, true); }); +test("checks a project module consumer and records its separate PCM directory", () => { + const result = analyzeCompilationDatabase(JSON.stringify([ + { + directory: "/work/app", + file: "/work/app/src/demo.cppm", + arguments: [ + "/tools/clang++", + "-std=c++23", + "-O0", + "-fprebuilt-module-path=/work/app/target/build/pcm.cache", + "-c", + "/work/app/src/demo.cppm", + ], + }, + { + directory: "/work/app", + file: "/work/app/tests/demo_test.cpp", + arguments: [ + "/tools/clang++", + "-std=c++23", + "-O0", + "-fprebuilt-module-path=/work/app/target/test/pcm.cache", + "-c", + "/work/app/tests/demo_test.cpp", + ], + }, + { + directory: "/work/app", + file: "/work/app/tests/release_test.cpp", + arguments: [ + "/tools/clang++", + "-std=c++23", + "-O2", + "-fprebuilt-module-path=/work/app/target/release/pcm.cache", + "-c", + "/work/app/tests/release_test.cpp", + ], + }, + ])); + + assert.equal(result.sourceFile, "/work/app/tests/demo_test.cpp"); + assert.deepEqual(result.modulePcmSourceDirectories, ["/work/app/target/build/pcm.cache"]); + assert.deepEqual(result.modulePcmConsumerDirectories, ["/work/app/target/test/pcm.cache"]); +}); + test("prefers a project source over an external module interface", () => { const result = analyzeCompilationDatabase(JSON.stringify([ { diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index bf5baba..87b4f17 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -154,6 +154,15 @@ test("IDE commands expose progress and bound clangd restart waits", () => { assert.match(source, /output\.show\(true\)/); }); +test("module checks restore available PCM before invoking clangd", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + const start = source.indexOf("async function runModuleSupportCheck"); + const end = source.indexOf("async function updateModuleSupportForContext", start); + const method = source.slice(start, end); + assert.ok(method.indexOf("stageProjectPcms(context, output)") < method.indexOf("runClangdCheck(")); + assert.match(method, /stageProjectPcms\(context, output\)[\s\S]*await restartClangd\(output\)/); +}); + test("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; const associations = manifest.contributes?.configurationDefaults?.["files.associations"] as diff --git a/test/pcm.test.ts b/test/pcm.test.ts new file mode 100644 index 0000000..ba2141f --- /dev/null +++ b/test/pcm.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { stageAvailableProjectPcms } from "../src/pcm"; + +test("stages missing and newer module PCMs for a consumer", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-pcm-")); + const source = path.join(root, "build", "pcm.cache"); + const destination = path.join(root, "test", "pcm.cache"); + try { + mkdirSync(source, { recursive: true }); + mkdirSync(destination, { recursive: true }); + writeFileSync(path.join(source, "mcpplibs.demo.pcm"), "module"); + writeFileSync(path.join(source, "std.pcm"), "std"); + writeFileSync(path.join(destination, "std.pcm"), "existing"); + + const copied = stageAvailableProjectPcms({ + kind: "llvm", + capability: "full", + reason: "test", + modulePcmSourceDirectories: [source], + modulePcmConsumerDirectories: [destination], + }); + + assert.equal(copied, 1); + assert.equal(existsSync(path.join(destination, "mcpplibs.demo.pcm")), true); + assert.equal(readFileSync(path.join(destination, "std.pcm"), "utf8"), "existing"); + + writeFileSync(path.join(source, "std.pcm"), "updated"); + const newer = new Date(Date.now() + 1_000); + utimesSync(path.join(source, "std.pcm"), newer, newer); + assert.equal(stageAvailableProjectPcms({ + kind: "llvm", + capability: "full", + reason: "test", + modulePcmSourceDirectories: [source], + modulePcmConsumerDirectories: [destination], + }), 1); + assert.equal(readFileSync(path.join(destination, "std.pcm"), "utf8"), "updated"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});