From 0003e8ef3484e05a2df0f7cc24b056861b05a65d Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Wed, 11 Nov 2026 02:47:30 -0400 Subject: [PATCH] feat(linux): use native code's module maps and CMake builds A module.modulemap in src/ or include/ is passed to the generator as is; sources are built as the library its first `link` names. A CMakeLists.txt replaces the built-in compile: its shared libraries are bundled and the generator takes its flags from compile_commands.json. --- lib/services/linux-native-code.ts | 355 ++++++++++++++++++++++++++--- test/services/linux-native-code.ts | 281 ++++++++++++++++++++++- 2 files changed, 601 insertions(+), 35 deletions(-) diff --git a/lib/services/linux-native-code.ts b/lib/services/linux-native-code.ts index 5db5f72410..1b2484e30a 100644 --- a/lib/services/linux-native-code.ts +++ b/lib/services/linux-native-code.ts @@ -10,10 +10,14 @@ import { IChildProcess, ISpawnResult } from "../common/declarations"; * include/ public headers; when present only these are exposed to JS * lib// prebuilt .so / .node (bundled in lib/) and .a (linked into lib.so) * native.json { "pkg-config": [], "cflags": [], "libs": [] } + * CMakeLists.txt builds the unit instead of src/; its shared libraries go in lib/ * * Headers are run through the metadata generator into `metadata/.bin`, * which the runtime loads next to the SDK metadata. * + * A `module.modulemap` in src/ or include/ is used as is, like on iOS. Without + * one, the unit is a module named after it. + * * Metadata filters (`native-api-usage.json`, written by prepare as * whitelist.mdg / blacklist.mdg) shrink the SDK metadata; units are always * kept but honor the blacklist. @@ -48,8 +52,17 @@ interface NativeConfig { const SOURCE = /\.(c|cc|cpp|cxx)$/; const CXX_SOURCE = /\.(cc|cpp|cxx)$/; const HEADER = /\.(h|hh|hpp)$/; +const MODULE_MAP = /(^|[\\/])module\.modulemap$/; /** App_Resources/Linux entries that are build inputs, not resources */ -export const NATIVE_ENTRIES = ["src", "include", "lib", "native.json"]; +export const NATIVE_ENTRIES = [ + "src", + "include", + "lib", + "native.json", + "CMakeLists.txt", +]; +const CMAKE_LISTS = "CMakeLists.txt"; +const SHARED_LIBRARY = /\.(so(\.\d+)*|node)$/; /** `@scope/my-plugin` -> `scope_my_plugin` */ export function nativeUnitName(name: string): string { @@ -75,6 +88,146 @@ function walk(dir: string, filter: RegExp): string[] { .sort(); } +export interface ModuleMapInfo { + file: string; + modules: string[]; + headers: string[]; + links: string[]; +} + +// clang parses the map itself, this only needs the headers for the umbrella +export function parseModuleMap(file: string): ModuleMapInfo { + const dir = path.dirname(file); + const text = fs + .readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/\/\/[^\n]*/g, " "); + const tokens = text.match(/"(?:[^"\\]|\\.)*"|[{}]|[A-Za-z_][\w.]*|\*/g) ?? []; + const unquote = (t?: string) => + t && t.startsWith('"') ? t.slice(1, -1) : undefined; + const info: ModuleMapInfo = { file, modules: [], headers: [], links: [] }; + let depth = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === "{") { + depth++; + } else if (token === "}") { + depth--; + } else if ( + token === "module" && + depth === 0 && + tokens[i - 1] !== "extern" + ) { + const name = tokens[i + 1]; + if (name && /^[A-Za-z_]\w*$/.test(name)) { + info.modules.push(name); + } + } else if (token === "header") { + const header = unquote(tokens[i + 1]); + const qualifiers = [tokens[i - 1], tokens[i - 2]]; + if ( + header && + !qualifiers.includes("exclude") && + !qualifiers.includes("textual") + ) { + info.headers.push(path.resolve(dir, header)); + } + } else if (token === "umbrella") { + const umbrella = unquote(tokens[i + 1]); + if (umbrella) { + info.headers.push(...walk(path.resolve(dir, umbrella), HEADER)); + } + } else if (token === "link") { + const name = unquote( + tokens[i + 1] === "framework" ? tokens[i + 2] : tokens[i + 1], + ); + if (name) { + info.links.push(name); + } + } + } + return info; +} + +function shellSplit(command: string): string[] { + const args: string[] = []; + let current = ""; + let started = false; + let quote: string | null = null; + for (let i = 0; i < command.length; i++) { + const c = command[i]; + if (quote) { + if (c === quote) { + quote = null; + } else if (c === "\\" && quote === '"' && i + 1 < command.length) { + current += command[++i]; + } else { + current += c; + } + } else if (c === "'" || c === '"') { + quote = c; + started = true; + } else if (c === "\\" && i + 1 < command.length) { + current += command[++i]; + started = true; + } else if (/\s/.test(c)) { + if (started) { + args.push(current); + } + current = ""; + started = false; + } else { + current += c; + started = true; + } + } + if (started) { + args.push(current); + } + return args; +} + +export function compileCommandFlags(file: string): string[] { + if (!fs.existsSync(file)) { + return []; + } + const entries: { + directory: string; + command?: string; + arguments?: string[]; + }[] = JSON.parse(fs.readFileSync(file, "utf8")); + const flags = new Map(); + const add = (...flag: string[]) => flags.set(flag.join("\0"), flag); + for (const entry of entries) { + const args = entry.arguments ?? shellSplit(entry.command ?? ""); + const resolve = (dir: string) => path.resolve(entry.directory, dir); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const option = ["-I", "-isystem", "-iquote", "-idirafter"].find((o) => + arg.startsWith(o), + ); + if (option) { + const dir = + arg.length > option.length ? arg.slice(option.length) : args[++i]; + if (dir) { + if (option === "-I") { + add(`-I${resolve(dir)}`); + } else { + add(option, resolve(dir)); + } + } + } else if (arg === "-D" || arg === "-U") { + if (args[i + 1]) { + add(`${arg}${args[++i]}`); + } + } else if (arg.startsWith("-D") || arg.startsWith("-U")) { + add(arg); + } + } + } + return [...flags.values()].flat(); +} + /** * `index.d.ts` for an app's `typings/linux`: the SDK typings in `sdk/` and * the native code typings (`c!.d.ts`) next to it. @@ -120,38 +273,67 @@ export class LinuxNativeCodeBuilder { ? fs.readdirSync(prebuiltDir).sort() : []; // Node-API addons (.node) are shared libraries too, loaded by path from lib/ - const shared = prebuilt.filter((f) => /\.(so(\.\d+)*|node)$/.test(f)); + const bundled = new Map( + prebuilt + .filter((f) => SHARED_LIBRARY.test(f)) + .map((f) => [f, path.join(prebuiltDir, f)]), + ); + const cmake = fs.existsSync(path.join(unit.dir, CMAKE_LISTS)); const statics = prebuilt .filter((f) => f.endsWith(".a")) .map((f) => path.join(prebuiltDir, f)); - const sources = walk(path.join(unit.dir, "src"), SOURCE); + const sources = cmake ? [] : walk(path.join(unit.dir, "src"), SOURCE); const publicDir = ["include", "src"] .map((d) => path.join(unit.dir, d)) .find((d) => fs.existsSync(d)); - const headers = publicDir ? walk(publicDir, HEADER) : []; + const maps = ["include", "src"] + .flatMap((d) => walk(path.join(unit.dir, d), MODULE_MAP)) + .map(parseModuleMap); + const headers = maps.length + ? [...new Set(maps.flatMap((m) => m.headers))] + : publicDir + ? walk(publicDir, HEADER) + : []; if ( + !cmake && !sources.length && !statics.length && - !shared.length && + !bundled.size && !headers.length ) { return; } + const cmakeBuild = cmake + ? await this.cmake(unit, options) + : { libraries: [], cflags: [] }; + for (const library of cmakeBuild.libraries) { + bundled.set(path.basename(library), library); + } + const shared = [...bundled.keys()].sort(); + const stageLib = path.join(options.stage, "lib"); fs.mkdirSync(stageLib, { recursive: true }); - for (const file of shared) { - fs.cpSync(path.join(prebuiltDir, file), path.join(stageLib, file), { + for (const [file, from] of bundled) { + fs.cpSync(from, path.join(stageLib, file), { verbatimSymlinks: true, }); } const work = path.join(options.workDir, unit.name); - const library = `lib${unit.name}.so`; + const compiles = !cmake && (sources.length > 0 || statics.length > 0); + const library = compiles + ? this.libraryName(unit, maps, shared) + : `lib${unit.name}.so`; const metadata = `${unit.name}.bin`; - const compiles = sources.length > 0 || statics.length > 0; const stamp = path.join(work, "stamp"); - const hash = this.inputsHash(unit.dir, options); + // headers a module map exposes from outside the unit + const hash = this.inputsHash(unit.dir, options, [ + ...maps.flatMap((m) => m.headers), + library, + ...shared, + ...cmakeBuild.cflags, + ]); if (!fs.existsSync(stamp) || fs.readFileSync(stamp, "utf8") !== hash) { fs.rmSync(work, { recursive: true, force: true }); fs.mkdirSync(work, { recursive: true }); @@ -182,14 +364,20 @@ export class LinuxNativeCodeBuilder { const links = compiles ? [library] : shared.filter( - (f) => !fs.lstatSync(path.join(prebuiltDir, f)).isSymbolicLink(), + (f) => !fs.lstatSync(bundled.get(f)).isSymbolicLink(), ); await this.generate( unit, publicDir, + maps, headers, links, - [...includes, ...pkgCflags, ...(config.cflags ?? [])], + [ + ...includes, + ...cmakeBuild.cflags, + ...pkgCflags, + ...(config.cflags ?? []), + ], work, options, ); @@ -215,6 +403,53 @@ export class LinuxNativeCodeBuilder { } } + private async cmake( + unit: LinuxNativeUnit, + options: LinuxNativeBuildOptions, + ): Promise<{ libraries: string[]; cflags: string[] }> { + // outside the work folder, which is wiped when inputs change, so CMake stays incremental + const build = path.join( + options.workDir, + `${unit.name}.cmake`, + options.release ? "release" : "debug", + ); + const output = path.join(build, "ns-lib"); + this.$logger.info(`Building native code with CMake: ${unit.name}`); + await this.run( + "cmake", + [ + "-S", + unit.dir, + "-B", + build, + `-DCMAKE_BUILD_TYPE=${options.release ? "Release" : "Debug"}`, + `-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${output}`, + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + // bundled libraries sit next to each other in lib/ + "-DCMAKE_BUILD_RPATH=$ORIGIN", + `-DNS_ARCH=${options.arch}`, + ], + unit.dir, + ); + await this.run("cmake", ["--build", build, "--parallel"], unit.dir); + const libraries = fs.existsSync(output) + ? fs + .readdirSync(output) + .filter((f) => SHARED_LIBRARY.test(f)) + .sort() + .map((f) => path.join(output, f)) + : []; + if (!libraries.length) { + this.$logger.warn( + `${path.join(unit.dir, CMAKE_LISTS)} built no shared library (add_library( SHARED ...)), ${unit.name}'s C functions won't load.`, + ); + } + return { + libraries, + cflags: compileCommandFlags(path.join(build, "compile_commands.json")), + }; + } + private config(dir: string): NativeConfig { const file = path.join(dir, "native.json"); if (!fs.existsSync(file)) { @@ -227,8 +462,44 @@ export class LinuxNativeCodeBuilder { } } + // the runtime only loads libraries a module links, so sources are built as one of them + private libraryName( + unit: LinuxNativeUnit, + maps: ModuleMapInfo[], + bundled: string[], + ): string { + const fallback = `lib${unit.name}.so`; + if (!maps.length) { + return fallback; + } + const isBundled = (link: string) => + bundled.some( + (f) => + f === link || f === `lib${link}.so` || f.startsWith(`lib${link}.so.`), + ); + for (const link of maps.flatMap((m) => m.links)) { + if (isBundled(link)) { + continue; + } + if (/^lib[^/]+\.so$/.test(link)) { + return link; + } + if (/^[\w+-]+$/.test(link)) { + return `lib${link}.so`; + } + } + this.$logger.warn( + `${maps[0].file} doesn't link a library for ${unit.name}'s sources, so their functions won't load. Add \`link "${unit.name}"\` to the module.`, + ); + return fallback; + } + /** Changes to any input (or the generator / build type) rebuild the unit. */ - private inputsHash(dir: string, options: LinuxNativeBuildOptions): string { + private inputsHash( + dir: string, + options: LinuxNativeBuildOptions, + extra: string[] = [], + ): string { const hash = crypto.createHash("sha1"); hash.update(`${options.arch}|${options.release}\n`); hash.update(readIfExists(options.blacklist)); @@ -236,6 +507,7 @@ export class LinuxNativeCodeBuilder { for (const file of [ generator, ...NATIVE_ENTRIES.flatMap((e) => this.files(path.join(dir, e))), + ...extra, ]) { const stat = fs.existsSync(file) && fs.statSync(file); hash.update(`${file}|${stat ? `${stat.size}|${stat.mtimeMs}` : "-"}\n`); @@ -296,6 +568,7 @@ export class LinuxNativeCodeBuilder { private async generate( unit: LinuxNativeUnit, publicDir: string, + maps: ModuleMapInfo[], headers: string[], links: string[], cflags: string[], @@ -311,17 +584,23 @@ export class LinuxNativeCodeBuilder { } const gen = path.join(work, "gen"); fs.mkdirSync(gen, { recursive: true }); - const modulemap = path.join(gen, "module.modulemap"); - fs.writeFileSync( - modulemap, - [ - `module ${unit.name} {`, - ` umbrella "${publicDir}"`, - ...links.map((l) => ` link "${l}"`), - "}", - "", - ].join("\n"), - ); + let modulemaps = maps.map((m) => m.file); + let modules = maps.flatMap((m) => m.modules); + if (!maps.length) { + const modulemap = path.join(gen, "module.modulemap"); + fs.writeFileSync( + modulemap, + [ + `module ${unit.name} {`, + ` umbrella "${publicDir}"`, + ...links.map((l) => ` link "${l}"`), + "}", + "", + ].join("\n"), + ); + modulemaps = [modulemap]; + modules = [unit.name]; + } const umbrella = path.join(gen, "umbrella.h"); fs.writeFileSync( umbrella, @@ -329,7 +608,7 @@ export class LinuxNativeCodeBuilder { ); // only this unit's declarations, not the system headers it includes const whitelist = path.join(gen, "whitelist.txt"); - fs.writeFileSync(whitelist, `${unit.name}\n`); + fs.writeFileSync(whitelist, modules.map((m) => `${m}\n${m}.*\n`).join("")); const blacklist = options.blacklist && fs.existsSync(options.blacklist) ? ["-blacklist-modules-file", options.blacklist] @@ -348,7 +627,7 @@ export class LinuxNativeCodeBuilder { "-output-typescript", path.join(work, "typings"), "Xclang", - `-fmodule-map-file=${modulemap}`, + ...modulemaps.map((m) => `-fmodule-map-file=${m}`), ...cflags, ], options.generatorDir, @@ -417,13 +696,23 @@ export class LinuxNativeCodeBuilder { args: string[], cwd: string, ): Promise { - const result = await this.$childProcess.spawnFromEvent( - command, - args, - "close", - { cwd }, - { throwError: false }, - ); + let result: ISpawnResult; + try { + result = await this.$childProcess.spawnFromEvent( + command, + args, + "close", + { cwd }, + { throwError: false }, + ); + } catch (e) { + if (e?.code === "ENOENT") { + throw new Error( + `${command} was not found, install it to build Linux native code (sudo apt install ${command === "cmake" ? "cmake" : "build-essential"}).`, + ); + } + throw e; + } if (result.exitCode !== 0) { throw new Error( `${command} failed (exit code ${result.exitCode}):\n${result.stderr || result.stdout}`, diff --git a/test/services/linux-native-code.ts b/test/services/linux-native-code.ts index 8b561c5380..f3cfed9d53 100644 --- a/test/services/linux-native-code.ts +++ b/test/services/linux-native-code.ts @@ -6,12 +6,21 @@ import { LinuxNativeCodeBuilder, nativeUnitName, hasNativeCode, + parseModuleMap, + compileCommandFlags, } from "../../lib/services/linux-native-code"; import * as stubs from "../stubs"; -/** Records commands and fakes their outputs. */ -function fakeProcess() { +/** + * Records commands and fakes their outputs. A CMake build makes `cmakeLibraries` + * and a compile_commands.json compiling with `cmakeFlags`. + */ +function fakeProcess( + cmakeLibraries: string[] = [], + cmakeFlags = "-I../inc -DMASON_API=1", +) { const calls: { command: string; args: string[]; cwd: string }[] = []; + const cmakeOutputs = new Map(); return { calls, spawnFromEvent: async ( @@ -32,6 +41,34 @@ function fakeProcess() { stderr: "", }; } + if (command === "cmake") { + if (args[0] === "-S") { + const build = output("-B"); + const define = "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY="; + cmakeOutputs.set( + build, + args.find((a) => a.startsWith(define)).slice(define.length), + ); + fs.mkdirSync(build, { recursive: true }); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify([ + { + directory: build, + command: `cc ${cmakeFlags} -c mason.c`, + file: "mason.c", + }, + ]), + ); + } else { + const out = cmakeOutputs.get(args[1]); + fs.mkdirSync(out, { recursive: true }); + for (const library of cmakeLibraries) { + fs.writeFileSync(path.join(out, library), "elf"); + } + } + return { exitCode: 0, stdout: "", stderr: "" }; + } if (command.endsWith("ns-metadata-filter")) { fs.writeFileSync(args[1], "filtered"); return { exitCode: 0, stdout: "1 of 2 declarations", stderr: "" }; @@ -214,4 +251,244 @@ describe("linuxNativeCode", () => { assert.include(generator.args, "-blacklist-modules-file"); assert.include(generator.args, blacklist); }); + + it("parses the parts of a module map the generator needs", () => { + const outside = path.join(root, "outside.h"); + const app = unit({ + "include/module.modulemap": ` + // comment: module ignored { header "no.h" } + module mason [system] { + umbrella header "mason.h" + private header "detail/private.h" + textual header "textual.h" + exclude header "excluded.h" + header "${outside}" + link "mason" + explicit module ui { header "ui.h" link framework "x" } + } + extern module other "other.modulemap" + module extra { umbrella "extra" } + `, + "include/extra/a.h": "", + "include/extra/b/c.h": "", + }); + const info = parseModuleMap(path.join(app.dir, "include/module.modulemap")); + const include = path.join(app.dir, "include"); + assert.deepEqual(info.modules, ["mason", "extra"]); + assert.deepEqual(info.links, ["mason", "x"]); + assert.deepEqual(info.headers, [ + path.join(include, "mason.h"), + path.join(include, "detail/private.h"), + outside, + path.join(include, "ui.h"), + path.join(include, "extra/a.h"), + path.join(include, "extra/b/c.h"), + ]); + }); + + it("uses the unit's module map and builds the library it links", async () => { + const app = unit({ + "src/module.modulemap": + 'module mason [system] {\n header "mason.h"\n link "mason"\n}\n', + "src/mason.h": "int mason(void);", + "src/internal.h": "int internal(void);", + "src/mason.c": "int mason(void) { return 1; }", + }); + const proc = fakeProcess(); + await new LinuxNativeCodeBuilder(proc, new stubs.LoggerStub()).build( + [app], + options, + ); + + const cc = proc.calls.find((c) => c.command === "cc"); + assert.include(cc.args, "-Wl,-soname,libmason.so"); + assert.isTrue(fs.existsSync(path.join(options.stage, "lib/libmason.so"))); + assert.isFalse(fs.existsSync(path.join(options.stage, "lib/libapp.so"))); + + const generator = proc.calls.find((c) => + c.command.endsWith("metadata-generator"), + ); + assert.include( + generator.args, + `-fmodule-map-file=${path.join(app.dir, "src/module.modulemap")}`, + ); + assert.isFalse( + fs.existsSync(path.join(options.workDir, "app/gen/module.modulemap")), + ); + assert.equal( + fs.readFileSync(path.join(options.workDir, "app/gen/umbrella.h"), "utf8"), + `#include "${path.join(app.dir, "src/mason.h")}"\n`, + ); + assert.equal( + fs.readFileSync( + path.join(options.workDir, "app/gen/whitelist.txt"), + "utf8", + ), + "mason\nmason.*\n", + ); + assert.isTrue(fs.existsSync(path.join(options.stage, "metadata/app.bin"))); + }); + + it("wraps a library from a module map without sources", async () => { + const outside = path.join(root, "sqlite3.h"); + fs.writeFileSync(outside, "int sqlite3_open(void);"); + const app = unit({ + "src/module.modulemap": `module sqlite3 [system] {\n header "${outside}"\n link "sqlite3"\n}\n`, + }); + const proc = fakeProcess(); + const builder = new LinuxNativeCodeBuilder( + proc, + new stubs.LoggerStub(), + ); + await builder.build([app], options); + assert.isUndefined(proc.calls.find((c) => c.command === "cc")); + assert.equal( + fs.readFileSync(path.join(options.workDir, "app/gen/umbrella.h"), "utf8"), + `#include "${outside}"\n`, + ); + + // headers outside the unit are build inputs too + const count = proc.calls.length; + await builder.build([app], options); + assert.equal(proc.calls.length, count); + fs.writeFileSync( + outside, + "int sqlite3_open(void); int sqlite3_close(void);", + ); + await builder.build([app], options); + assert.isAbove(proc.calls.length, count); + }); + + it("warns when a module map doesn't link the compiled sources", async () => { + const app = unit({ + "src/module.modulemap": 'module mason {\n header "mason.h"\n}\n', + "src/mason.h": "int mason(void);", + "src/mason.c": "int mason(void) { return 1; }", + }); + const warnings: string[] = []; + const logger = new stubs.LoggerStub(); + logger.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + await new LinuxNativeCodeBuilder(fakeProcess(), logger).build( + [app], + options, + ); + assert.lengthOf(warnings, 1); + assert.include(warnings[0], 'link "app"'); + assert.isTrue(fs.existsSync(path.join(options.stage, "lib/libapp.so"))); + }); + + it("reads include paths and defines from compile_commands.json", () => { + const file = path.join(root, "compile_commands.json"); + fs.writeFileSync( + file, + JSON.stringify([ + { + directory: "/build", + command: + 'cc -I../inc -I /abs -isystem "/with space" \'-DNAME="a b"\' -D X=1 -UOLD -O2 -o x.o -c x.c', + file: "x.c", + }, + { + directory: "/build", + arguments: ["c++", "-I../inc", "-iquote", "q", "-c", "y.cc"], + file: "y.cc", + }, + ]), + ); + assert.deepEqual(compileCommandFlags(file), [ + "-I/inc", + "-I/abs", + "-isystem", + "/with space", + '-DNAME="a b"', + "-DX=1", + "-UOLD", + "-iquote", + "/build/q", + ]); + assert.deepEqual(compileCommandFlags(path.join(root, "missing.json")), []); + }); + + it("builds a CMake project and bundles the libraries it builds", async () => { + const app = unit({ + "CMakeLists.txt": "add_library(mason SHARED src/mason.c)", + "src/mason.c": "int mason(void) { return 1; }", + "src/mason.h": "int mason(void);", + "src/module.modulemap": + 'module mason {\n header "mason.h"\n link "mason"\n}\n', + }); + assert.isTrue(hasNativeCode(app.dir)); + const proc = fakeProcess(["libmason.so"]); + const builder = new LinuxNativeCodeBuilder( + proc, + new stubs.LoggerStub(), + ); + await builder.build([app], options); + + assert.isUndefined(proc.calls.find((c) => c.command === "cc")); + const configure = proc.calls.find( + (c) => c.command === "cmake" && c.args[0] === "-S", + ); + const build = path.join(options.workDir, "app.cmake/debug"); + assert.includeMembers(configure.args, [ + app.dir, + build, + "-DCMAKE_BUILD_TYPE=Debug", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DNS_ARCH=x64", + ]); + assert.deepEqual(fs.readdirSync(path.join(options.stage, "lib")), [ + "libmason.so", + ]); + const generator = proc.calls.find((c) => + c.command.endsWith("metadata-generator"), + ); + assert.includeMembers(generator.args, [ + `-I${path.resolve(build, "../inc")}`, + "-DMASON_API=1", + `-fmodule-map-file=${path.join(app.dir, "src/module.modulemap")}`, + ]); + + // CMake runs on every build and decides itself; the metadata is reused + const generated = proc.calls.filter((c) => + c.command.endsWith("metadata-generator"), + ).length; + await builder.build([app], { ...options }); + assert.lengthOf( + proc.calls.filter( + (c) => c.command === "cmake" && c.args[0] === "--build", + ), + 2, + ); + assert.lengthOf( + proc.calls.filter((c) => c.command.endsWith("metadata-generator")), + generated, + ); + // the CMake build folder survives a metadata rebuild + assert.isTrue(fs.existsSync(path.join(build, "compile_commands.json"))); + }); + + it("links a CMake project's libraries from a generated module map", async () => { + const app = unit({ + "CMakeLists.txt": "add_library(foo SHARED foo.c)", + "include/foo.h": "void foo(void);", + }); + const proc = fakeProcess(["libfoo.so"]); + await new LinuxNativeCodeBuilder(proc, new stubs.LoggerStub()).build( + [app], + { ...options, release: true }, + ); + const configure = proc.calls.find( + (c) => c.command === "cmake" && c.args[0] === "-S", + ); + assert.include(configure.args, "-DCMAKE_BUILD_TYPE=Release"); + const modulemap = fs.readFileSync( + path.join(options.workDir, "app/gen/module.modulemap"), + "utf8", + ); + assert.include(modulemap, 'link "libfoo.so"'); + assert.isTrue(fs.existsSync(path.join(options.stage, "lib/libfoo.so"))); + }); });