Skip to content

Commit 1ffd275

Browse files
committed
feat: lib-root convention (src/<package-tail>.cppm + optional [lib].path)
Introduces a Cargo-style "lib root" — a single primary module-interface file that aggregates the package's public API. Two recognised forms: * Convention (no config): `src/<package-tail>.cppm` where `<package-tail>` is the last `.`-segment of `[package].name`. Examples: mcpplibs.tinyhttps → src/tinyhttps.cppm mcpplibs.cmdline → src/cmdline.cppm gtest → src/gtest.cppm * Override: `[lib] path = "src/foo.cppm"` (cargo-style). Either way, the file must `export module <full-package-name>;` (no `:partition` suffix) — partitions go in sibling files and are re-exported from the lib root, mirroring Rust's `lib.rs` aggregating `pub mod`s. Lib-root checks fire only for projects that ship a `kind = "lib"` (or `shared`) target. Pure binaries (mcpp itself, scaffolded `mcpp new`) are unaffected. Validation policy: - explicit `[lib].path` pointing at a missing file → ERROR - convention miss (no `src/<tail>.cppm`) → WARNING (soft on-ramp; existing libs aren't broken — they get a polite reminder) - lib-root file exists but `export module …:partition;` → ERROR (lib root must be the primary module, never a partition) - lib-root file exports a different module name than [package].name → ERROR Existing libs that already follow the convention (mcpplibs.cmdline, mcpplibs.templates, mcpplibs.tinyhttps) keep working unchanged. The soft warning gives anyone who doesn't a clear path to compliance without a hard break. Coverage: 4 manifest unit tests + 5 validate tests + a manual smoke on tinyhttps (zero warnings) and a deliberate file-rename reproduction of the warning path.
1 parent ce8ff94 commit 1ffd275

5 files changed

Lines changed: 347 additions & 1 deletion

File tree

src/cli.cppm

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,11 @@ prepare_build(bool print_fingerprint,
13321332
std::println(stderr, "warning: {}", w.format());
13331333
}
13341334

1335-
auto report = mcpp::modgraph::validate(scan.graph, *m);
1335+
auto report = mcpp::modgraph::validate(scan.graph, *m, *root);
1336+
for (auto& w : report.warnings) {
1337+
if (w.path.empty()) std::println(stderr, "warning: {}", w.message);
1338+
else std::println(stderr, "warning: {}: {}", w.path.string(), w.message);
1339+
}
13361340
if (!report.ok()) {
13371341
std::string msg = "validation errors:\n";
13381342
for (auto& e : report.errors) {

src/manifest.cppm

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ struct TargetEntry {
9797
std::string linkage; // "static" | "dynamic" | "" (= auto by libc)
9898
};
9999

100+
// `[lib]` — library "root" interface convention.
101+
//
102+
// Convention-over-configuration: a library package's primary module
103+
// interface lives at `src/<package-tail>.cppm`, where `<package-tail>` is
104+
// the last dotted segment of `[package].name` (e.g. `mcpplibs.tinyhttps`
105+
// → `src/tinyhttps.cppm`). That file declares `export module
106+
// <full-package-name>;` and re-exports the public partitions. The lib
107+
// root then drives:
108+
// * `[modules].exports` default (the lib root's module = the only
109+
// externally-visible base module),
110+
// * `mcpp publish` xpkg generation (consumer just `import <name>;`),
111+
// * downstream tooling (docs / explain) entry point.
112+
//
113+
// Override the convention with `[lib].path = "src/foo.cppm"` (cargo-style)
114+
// — the file must still `export module <package-name>;` (no partition).
115+
//
116+
// Lib-root is only meaningful for projects that ship a `kind = "lib"`
117+
// target. Pure-binary projects (mcpp itself, scaffolded `mcpp new`)
118+
// don't trigger any lib-root checks.
119+
struct LibConfig {
120+
std::filesystem::path path; // explicit override; empty = use convention
121+
};
122+
100123
// `[pack]` — `mcpp pack` configuration. See docs/35-pack-design.md.
101124
//
102125
// `default_mode` picks the bundling strategy when the user runs bare
@@ -140,6 +163,9 @@ struct Manifest {
140163
// [pack] — `mcpp pack` config (see docs/35-pack-design.md).
141164
PackConfig packConfig;
142165

166+
// [lib] — library root interface convention (M5.x+).
167+
LibConfig lib;
168+
143169
// M5.0: post-parse computed/inferred state
144170
bool usesModules = true; // refined by scanner
145171
bool usesImportStd = true; // refined by scanner
@@ -182,6 +208,19 @@ McppField extract_mcpp_field(std::string_view luaContent);
182208
std::vector<std::string>
183209
list_xpkg_versions(std::string_view luaContent, std::string_view platform);
184210

211+
// Resolve the lib-root path for a manifest:
212+
// 1. `[lib].path` if explicitly set (cargo-style override),
213+
// 2. otherwise the convention `src/<package-tail>.cppm`, where
214+
// `<package-tail>` is the last `.`-segment of [package].name
215+
// (e.g. `mcpplibs.tinyhttps` → `src/tinyhttps.cppm`).
216+
// The returned path is relative to the package root unless the user
217+
// passed an absolute path in `[lib].path`.
218+
std::filesystem::path resolve_lib_root_path(const Manifest& manifest);
219+
220+
// True if the manifest declares at least one `kind = "lib"` target.
221+
// Lib-root convention only applies when this returns true.
222+
bool has_lib_target(const Manifest& manifest);
223+
185224
// Synthesize a Manifest from an xpkg .lua file's `mcpp = {}` segment.
186225
// Used when a fetched dep has no source/mcpp.toml — the index entry's
187226
// `mcpp = {}` workaround block carries the missing build info.
@@ -494,6 +533,11 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
494533
if (auto v = doc->get_string_array("build.cxxflags")) m.buildConfig.cxxflags = *v;
495534
if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v;
496535

536+
// [lib] — library root convention (cargo-style).
537+
if (auto v = doc->get_string("lib.path")) {
538+
m.lib.path = *v;
539+
}
540+
497541
// [pack] — `mcpp pack` configuration. See docs/35-pack-design.md.
498542
if (auto v = doc->get_string("pack.default_mode")) {
499543
const auto& s = *v;
@@ -1207,4 +1251,25 @@ license = "Apache-2.0"
12071251
)", packageName);
12081252
}
12091253

1254+
bool has_lib_target(const Manifest& manifest) {
1255+
for (auto& t : manifest.targets) {
1256+
if (t.kind == Target::Library || t.kind == Target::SharedLibrary) {
1257+
return true;
1258+
}
1259+
}
1260+
return false;
1261+
}
1262+
1263+
std::filesystem::path resolve_lib_root_path(const Manifest& manifest) {
1264+
if (!manifest.lib.path.empty()) {
1265+
return manifest.lib.path;
1266+
}
1267+
// Convention: src/<package-tail>.cppm
1268+
std::string tail = manifest.package.name;
1269+
if (auto p = tail.rfind('.'); p != std::string::npos) {
1270+
tail = tail.substr(p + 1);
1271+
}
1272+
return std::filesystem::path("src") / (tail + ".cppm");
1273+
}
1274+
12101275
} // namespace mcpp::manifest

src/modgraph/validate.cppm

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ struct ValidateReport {
2424
ValidateReport validate(const Graph& g,
2525
const mcpp::manifest::Manifest& manifest);
2626

27+
// Same as `validate` plus a project-root path used to verify that the
28+
// lib-root convention file actually exists on disk. Pass an empty path
29+
// to disable the on-disk check (used by unit tests that build a Graph
30+
// in memory without writing source files).
31+
ValidateReport validate(const Graph& g,
32+
const mcpp::manifest::Manifest& manifest,
33+
const std::filesystem::path& projectRoot);
34+
2735
bool is_public_package_name(std::string_view name);
2836
bool is_forbidden_top_module(std::string_view name);
2937

@@ -48,6 +56,13 @@ bool is_forbidden_top_module(std::string_view name) {
4856

4957
ValidateReport validate(const Graph& g,
5058
const mcpp::manifest::Manifest& manifest)
59+
{
60+
return validate(g, manifest, /*projectRoot=*/{});
61+
}
62+
63+
ValidateReport validate(const Graph& g,
64+
const mcpp::manifest::Manifest& manifest,
65+
const std::filesystem::path& projectRoot)
5166
{
5267
ValidateReport r;
5368

@@ -107,6 +122,83 @@ ValidateReport validate(const Graph& g,
107122
}
108123
}
109124

125+
// 2.5 Lib-root convention (M5.x+).
126+
//
127+
// For projects that ship a `kind = "lib"` target, expect a primary
128+
// module-interface file at either `[lib].path` (explicit override) or
129+
// `src/<package-tail>.cppm` (default convention). The file must
130+
// declare `export module <full-package-name>;` (no partition suffix);
131+
// partitions go in sibling files and are aggregated by re-exporting
132+
// from the lib root, à la `lib.rs` in cargo.
133+
//
134+
// Pure-binary projects (mcpp itself, scaffolded `mcpp new`) skip this
135+
// check — they have no lib-root concept.
136+
if (mcpp::manifest::has_lib_target(manifest)) {
137+
auto lib_root_rel = mcpp::manifest::resolve_lib_root_path(manifest);
138+
const bool was_explicit = !manifest.lib.path.empty();
139+
140+
// On-disk existence check (skipped when projectRoot is empty —
141+
// unit tests can build Graphs in memory without writing files).
142+
if (!projectRoot.empty()) {
143+
auto lib_root_abs = lib_root_rel.is_absolute()
144+
? lib_root_rel
145+
: (projectRoot / lib_root_rel);
146+
std::error_code ec;
147+
const bool exists = std::filesystem::exists(lib_root_abs, ec);
148+
if (!exists) {
149+
if (was_explicit) {
150+
// Explicit `[lib].path` pointing at a missing file is
151+
// always an error.
152+
r.errors.push_back({lib_root_rel, std::format(
153+
"[lib].path '{}' does not exist", lib_root_rel.string())});
154+
} else {
155+
// Convention miss is a warning — gives existing projects
156+
// a soft on-ramp before they rename / move files.
157+
r.warnings.push_back({lib_root_rel, std::format(
158+
"lib target without conventional lib root '{}' "
159+
"(create the file or set [lib].path)",
160+
lib_root_rel.string())});
161+
}
162+
}
163+
}
164+
165+
// Even without on-disk verification we can still cross-check the
166+
// graph: if a unit at the lib-root path is present, it must
167+
// export `<package-name>` exactly (no partition).
168+
const mcpp::modgraph::SourceUnit* lib_unit = nullptr;
169+
for (auto& u : g.units) {
170+
// Match relative or absolute — projectRoot may be empty in
171+
// tests, so we just compare path tails.
172+
auto u_rel = u.path.is_absolute() && !projectRoot.empty()
173+
? std::filesystem::relative(u.path, projectRoot)
174+
: u.path;
175+
if (u_rel == lib_root_rel || u.path == lib_root_rel) {
176+
lib_unit = &u;
177+
break;
178+
}
179+
}
180+
if (lib_unit) {
181+
if (!lib_unit->provides) {
182+
r.errors.push_back({lib_unit->path, std::format(
183+
"lib root '{}' must declare `export module {};`",
184+
lib_root_rel.string(), manifest.package.name)});
185+
} else {
186+
const auto& m = lib_unit->provides->logicalName;
187+
if (m.find(':') != std::string::npos) {
188+
r.errors.push_back({lib_unit->path, std::format(
189+
"lib root '{}' exports a partition '{}' — must be the "
190+
"primary module '{}' (no `:partition` suffix)",
191+
lib_root_rel.string(), m, manifest.package.name)});
192+
} else if (m != manifest.package.name) {
193+
r.errors.push_back({lib_unit->path, std::format(
194+
"lib root '{}' exports module '{}', expected '{}' "
195+
"(must match [package].name)",
196+
lib_root_rel.string(), m, manifest.package.name)});
197+
}
198+
}
199+
}
200+
}
201+
110202
// 3. Topology
111203
auto topo = topo_sort(g);
112204
if (!topo) {

tests/unit/test_manifest.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,67 @@ package = {
330330
EXPECT_EQ(b.version, "0.0.2");
331331
}
332332

333+
TEST(Manifest, LibRootInferredFromPackageName) {
334+
constexpr auto src = R"(
335+
[package]
336+
name = "mcpplibs.tinyhttps"
337+
version = "0.2.0"
338+
[targets.tinyhttps]
339+
kind = "lib"
340+
)";
341+
auto m = mcpp::manifest::parse_string(src);
342+
ASSERT_TRUE(m.has_value()) << m.error().format();
343+
EXPECT_TRUE(m->lib.path.empty());
344+
EXPECT_TRUE(mcpp::manifest::has_lib_target(*m));
345+
auto root = mcpp::manifest::resolve_lib_root_path(*m);
346+
EXPECT_EQ(root.string(), "src/tinyhttps.cppm");
347+
}
348+
349+
TEST(Manifest, LibRootBareNameNoNamespace) {
350+
constexpr auto src = R"(
351+
[package]
352+
name = "gtest"
353+
version = "1.0.0"
354+
[targets.gtest]
355+
kind = "lib"
356+
)";
357+
auto m = mcpp::manifest::parse_string(src);
358+
ASSERT_TRUE(m.has_value()) << m.error().format();
359+
auto root = mcpp::manifest::resolve_lib_root_path(*m);
360+
EXPECT_EQ(root.string(), "src/gtest.cppm");
361+
}
362+
363+
TEST(Manifest, LibRootExplicitOverride) {
364+
constexpr auto src = R"(
365+
[package]
366+
name = "mcpplibs.tinyhttps"
367+
version = "0.2.0"
368+
[lib]
369+
path = "src/api.cppm"
370+
[targets.tinyhttps]
371+
kind = "lib"
372+
)";
373+
auto m = mcpp::manifest::parse_string(src);
374+
ASSERT_TRUE(m.has_value()) << m.error().format();
375+
EXPECT_EQ(m->lib.path.string(), "src/api.cppm");
376+
auto root = mcpp::manifest::resolve_lib_root_path(*m);
377+
EXPECT_EQ(root.string(), "src/api.cppm");
378+
}
379+
380+
TEST(Manifest, HasLibTargetFalseForBareBinaryManifest) {
381+
// No [targets.*] declared → parse_string leaves targets empty.
382+
// load() would later infer a bin/lib from sources, but parse_string
383+
// alone leaves it bare; either way no lib target.
384+
constexpr auto src = R"(
385+
[package]
386+
name = "mcpp"
387+
version = "0.0.2"
388+
)";
389+
auto m = mcpp::manifest::parse_string(src);
390+
ASSERT_TRUE(m.has_value()) << m.error().format();
391+
EXPECT_FALSE(mcpp::manifest::has_lib_target(*m));
392+
}
393+
333394
TEST(ListXpkgVersions, IgnoresCommentedEntries) {
334395
constexpr auto src = R"(
335396
package = {

0 commit comments

Comments
 (0)