From 24f06a856bf640839212c339c26d0d5a7901abc3 Mon Sep 17 00:00:00 2001 From: NopeCharon <115794254+NopeCharon@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:42:29 +0530 Subject: [PATCH] feat: the TOML reader accepts TOML 1.1 inline tables written over several lines mcpp.libs.toml read an inline table only when it was written on one line: a newline, a comment or a comma after the last pair inside the braces was refused with `expected key`. TOML 1.1.0 allows all three, and the provider example in docs/35 already writes `runner = { ... }` over three lines, which mcpp could not read. read_inline_table now skips newlines and comments between the braces and the pairs and accepts a comma after the last pair. A pair stays on one line, an empty element (`{ , }`, `{ a = 1,, }`) is still refused, and a table that never closes is reported at its `{` as `unterminated inline table`. Every document that parsed before parses into the same tree. Two text editors meet the new form: - upsert_dependency_text (`mcpp add`, template self-dependency injection) replaced an existing entry by its first line only, so its own re-parse refused the result. It now replaces the whole value. - `mcpp remove` erased the first line only and wrote the file without a re-parse, leaving the rest of the value as a corrupt manifest. It now erases the whole value. Both use one helper, mcpp::manifest::assignment_end, which finds the newline after a value by counting brackets outside strings and comments. `mcpp index pin` and `unpin` still edit only an entry on one line; their refusal of a multi-line one now says so instead of calling it malformed. Docs: docs/04 states the TOML dialect, docs/09 the pin/unpin boundary, in English and Chinese. Tests: Toml.AnInlineTableSpansLines, Toml.AnInlineTableKeepsEachPairOnOneLine, ManifestEditor.ReplacesAnEntryWrittenOverSeveralLines, ManifestEditor.AnAssignmentEndsWhereItsValueCloses, and a multi-line entry in e2e 23_remove_update.sh. Closes #928. --- docs/04-mcpp-toml.md | 13 +++++++++ docs/09-commands-by-scenario.md | 3 +- docs/zh/04-mcpp-toml.md | 12 ++++++++ docs/zh/09-commands-by-scenario.md | 3 +- modules/libs/src/toml.cppm | 14 ++++++---- modules/manifest/src/toml.cppm | 35 ++++++++++++++++++++++- src/pm/commands.cppm | 5 ++-- src/pm/index_management.cppm | 8 ++++-- tests/e2e/23_remove_update.sh | 13 ++++++++- tests/unit/test_manifest.cpp | 45 ++++++++++++++++++++++++++++++ tests/unit/test_toml.cpp | 44 +++++++++++++++++++++++++++++ 11 files changed, 182 insertions(+), 13 deletions(-) diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index b60fcd439..c96ed801f 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -13,6 +13,19 @@ own — dependencies are [05](05-dependencies.md), features are `mcpp.toml` is the project configuration file for the mcpp build tool, analogous to Cargo's `Cargo.toml` or Node's `package.json`. Place it in the project root; `mcpp build` discovers and reads it automatically. +The file is TOML. An inline table may span several lines, carry comments and +end with a comma after its last pair, as TOML 1.1 allows; each `key = value` +pair stays on one line (#928). The same holds for every TOML file mcpp reads +(`mcpp.lock`, `config.toml`, template metadata). + +```toml +[dependencies] +widget = { + version = "2.0.0", + features = ["gui"], # a comment inside the table +} +``` + ## 1. Minimal Examples mcpp is designed around **convention over configuration** — most fields have sensible defaults, so the simplest `mcpp.toml` is just a few lines: diff --git a/docs/09-commands-by-scenario.md b/docs/09-commands-by-scenario.md index be8d8d42d..220e1e3aa 100644 --- a/docs/09-commands-by-scenario.md +++ b/docs/09-commands-by-scenario.md @@ -288,7 +288,8 @@ of any run that read such an index, including runs that then succeeded. fails rather than fetch. `--locked` fails when resolution differs from `mcpp.lock` instead of rewriting it, which is the shape a CI job wants. `mcpp index pin ` records a commit for a custom index in -`mcpp.toml`; `mcpp index unpin` removes it. +`mcpp.toml`; `mcpp index unpin` removes it. Both edit an `[indices]` entry +written on one line, and refuse one whose inline table spans several lines. ## Download progress diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 22a68b630..9ad5d016c 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -13,6 +13,18 @@ `mcpp.toml` 是 mcpp 构建工具的工程配置文件,类似于 Cargo 的 `Cargo.toml` 或 Node 的 `package.json`。把它放在工程根目录;`mcpp build` 会自动发现并读取它。 +该文件是 TOML。内联表可以跨多行书写、包含注释,并在最后一对之后带一个逗号, +这与 TOML 1.1 的规定一致;每一对 `key = value` 仍写在一行之内(#928)。mcpp +读取的每个 TOML 文件(`mcpp.lock`、`config.toml`、模板元数据)都遵循同一规则。 + +```toml +[dependencies] +widget = { + version = "2.0.0", + features = ["gui"], # 表内的注释 +} +``` + ## 1. 最小化示例 mcpp 按**约定优于配置**设计——大多数字段都有合理的默认值,因此最简单的 diff --git a/docs/zh/09-commands-by-scenario.md b/docs/zh/09-commands-by-scenario.md index 834408f35..2d6c50371 100644 --- a/docs/zh/09-commands-by-scenario.md +++ b/docs/zh/09-commands-by-scenario.md @@ -246,7 +246,8 @@ tip: the refreshed package index `mcpplibs` requires a newer mcpp; this run used `--offline`(或 `MCPP_OFFLINE=1`)在单次调用中禁止网络,宁可失败也不拉取。 `--locked` 在解析结果与 `mcpp.lock` 不一致时失败,而不是改写它,这正是 CI 作业需要的形状。`mcpp index pin ` 把一个自定义索引的某个 -commit 记进 `mcpp.toml`;`mcpp index unpin` 移除它。 +commit 记进 `mcpp.toml`;`mcpp index unpin` 移除它。二者编辑写在一行之内的 +`[indices]` 条目;内联表跨多行的条目会被拒绝。 ## 下载进度 diff --git a/modules/libs/src/toml.cppm b/modules/libs/src/toml.cppm index 556dc3abb..58d4c45b2 100644 --- a/modules/libs/src/toml.cppm +++ b/modules/libs/src/toml.cppm @@ -5,7 +5,7 @@ // - Booleans: key = true / false // - Integers // - Arrays of strings: key = ["a", "b"] -// - Inline tables (rudimentary): key = { version = "..." } +// - Inline tables: key = { version = "..." }, across lines as TOML 1.1 allows // Comments: # to end of line. Multiline strings ("""/''') supported; no datetime. export module mcpp.libs.toml; @@ -379,14 +379,18 @@ inline std::expected read_array(Lexer& L) { return Value{std::move(out)}; } +// `{ key = value, ... }`, as TOML 1.1 writes it: newlines and comments may +// stand between the braces and the pairs, and a comma may follow the last +// pair. A pair itself stays on one line (`key = value`), as at the top level. inline std::expected read_inline_table(Lexer& L) { if (L.peek() != '{') return std::unexpected(ParseError{"expected '{'", L.position()}); + const Position open = L.position(); L.advance(); Table tab; - L.skip_inline_whitespace(); - if (L.peek() == '}') { L.advance(); return Value{tab}; } while (true) { - L.skip_inline_whitespace(); + L.skip_whitespace_and_comments(); + if (L.peek() == '}') { L.advance(); break; } + if (L.eof()) return std::unexpected(ParseError{"unterminated inline table", open}); auto k = read_key(L); if (!k) return std::unexpected(k.error()); L.skip_inline_whitespace(); @@ -398,7 +402,7 @@ inline std::expected read_inline_table(Lexer& L) { auto v = read_value(L); if (!v) return std::unexpected(v.error()); tab[*k] = std::move(*v); - L.skip_inline_whitespace(); + L.skip_whitespace_and_comments(); if (L.peek() == ',') { L.advance(); continue; } if (L.peek() == '}') { L.advance(); break; } return std::unexpected(ParseError{"expected ',' or '}' in inline table", L.position()}); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index b235a3389..a07987599 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -597,6 +597,14 @@ std::expected upsert_dependency_text(std::string_view source, const DependencyTextEdit& edit); +// Where the `key = value` line that begins at `lineStart` ends: the newline +// after its value, or the end of `text`. An inline table or an array may +// continue over the following lines (TOML 1.1 inline tables, any array), so +// an editor that replaced or erased the first line alone would leave the rest +// of the value behind. Brackets are counted outside strings and comments; the +// value ends at the first newline where none is open. +std::size_t assignment_end(std::string_view text, std::size_t lineStart); + // Every path the lib-root convention would accept, in extension-table order // (`.cppm` first, then whatever `[build] module_extensions` declares). An // explicit `[lib] path` collapses this to that one entry. @@ -5774,6 +5782,31 @@ license = "Apache-2.0" )", packageName); } +std::size_t assignment_end(std::string_view text, std::size_t lineStart) { + int depth = 0; + char quote = '\0'; + bool comment = false; + for (std::size_t i = lineStart; i < text.size(); ++i) { + const char c = text[i]; + if (c == '\n') { + comment = false; + if (depth <= 0 && quote == '\0') return i; + continue; + } + if (comment) continue; + if (quote != '\0') { + if (c == '\\' && quote == '"') ++i; + else if (c == quote) quote = '\0'; + continue; + } + if (c == '"' || c == '\'') quote = c; + else if (c == '#') comment = true; + else if (c == '{' || c == '[') ++depth; + else if (c == '}' || c == ']') --depth; + } + return text.size(); +} + std::expected upsert_dependency_text(std::string_view source, const DependencyTextEdit& edit) { @@ -5916,7 +5949,7 @@ upsert_dependency_text(std::string_view source, }; if (!line.starts_with('#') && (assignment_for(key) || assignment_for(quotedKey))) { - return std::pair{lineStart, lineEnd}; + return std::pair{lineStart, assignment_end(text, lineStart)}; } if (newline == std::string_view::npos || newline >= section.sectionEnd) break; diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index ededc7cc1..e9145f626 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -420,11 +420,12 @@ inline int cmd_remove(const mcpplibs::cmdline::ParsedArgs& parsed) { } bool changed = false; + // The entry's whole value goes: an inline table written over several + // lines (TOML 1.1) ends where its braces close, not at the first newline. auto erase_line_at = [&](std::size_t p) { auto bol = text.rfind('\n', p); - auto eol = text.find('\n', p); if (bol == std::string::npos) bol = 0; else ++bol; - if (eol == std::string::npos) eol = text.size(); + const auto eol = mcpp::manifest::assignment_end(text, bol); text.erase(bol, (eol - bol) + (eol < text.size() ? 1 : 0)); changed = true; }; diff --git a/src/pm/index_management.cppm b/src/pm/index_management.cppm index c4a35a45a..27fdcc33e 100644 --- a/src/pm/index_management.cppm +++ b/src/pm/index_management.cppm @@ -420,7 +420,9 @@ export int index_pin(const std::string& name, std::string rev) { // Inline table. Find the closing brace and insert/update rev. auto braceClose = lineContent.find('}', braceOpen); if (braceClose == std::string::npos) { - mcpp::ui::error("malformed inline table in mcpp.toml"); + mcpp::ui::error(std::format( + "the inline table of index `{}` does not close on its line in mcpp.toml; " + "`mcpp index pin` edits an entry written on one line", name)); return 1; } auto tableContent = lineContent.substr(braceOpen + 1, braceClose - braceOpen - 1); @@ -521,7 +523,9 @@ export int index_unpin(const std::string& name) { if (braceOpen != std::string::npos) { auto braceClose = lineContent.find('}', braceOpen); if (braceClose == std::string::npos) { - mcpp::ui::error("malformed inline table in mcpp.toml"); + mcpp::ui::error(std::format( + "the inline table of index `{}` does not close on its line in mcpp.toml; " + "`mcpp index unpin` edits an entry written on one line", name)); return 1; } auto tableContent = lineContent.substr(braceOpen + 1, braceClose - braceOpen - 1); diff --git a/tests/e2e/23_remove_update.sh b/tests/e2e/23_remove_update.sh index 4810dbaef..a3136426a 100755 --- a/tests/e2e/23_remove_update.sh +++ b/tests/e2e/23_remove_update.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # requires: -# 23_remove_update.sh — M4 #3: mcpp remove / mcpp update. +# 23_remove_update.sh — M4 #3: mcpp remove / mcpp update; a multi-line entry +# is removed whole (#928). set -e TMP=$(mktemp -d) @@ -17,6 +18,10 @@ cat >> mcpp.toml <<'EOF' [dependencies] foo = "1.0.0" +baz = { + version = "3.0.0", # an inline table over several lines (TOML 1.1) + features = ["x"], +} bar = "2.0.0" EOF @@ -26,6 +31,12 @@ grep -q 'Removing' /tmp/_r.log || { cat /tmp/_r.log; exit 1; } if grep -qE '^("foo"|foo) = ' mcpp.toml; then echo "foo not actually removed"; cat mcpp.toml; exit 1; fi grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { echo "bar accidentally removed"; cat mcpp.toml; exit 1; } +# remove baz: its whole value goes, not only the line that opens it (#928) +"$MCPP" remove baz > /tmp/_r.log 2>&1 +grep -q 'Removing' /tmp/_r.log || { cat /tmp/_r.log; exit 1; } +if grep -qE 'baz|3\.0\.0|features|^\}' mcpp.toml; then echo "baz not wholly removed"; cat mcpp.toml; exit 1; fi +grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { echo "bar accidentally removed"; cat mcpp.toml; exit 1; } + # remove non-existent → exit code 1 rc=0 "$MCPP" remove nonexistent > /tmp/_r2.log 2>&1 || rc=$? diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 6693562bf..09c00fc68 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -475,6 +475,51 @@ widget = "1.0.0" expect(ne(invalid.error().find("invalid manifest"), std::string::npos)); }}; +// An entry written as an inline table over several lines (TOML 1.1) is +// replaced whole: replacing its first line alone left the rest of the old +// value behind, and the edit was refused as an invalid manifest. +UTest replaces_an_entry_written_over_several_lines{"ManifestEditor/ReplacesAnEntryWrittenOverSeveralLines", [] { + constexpr std::string_view source = R"([package] +name = "app" +version = "0.1.0" + +[dependencies.acme] +widget = { + version = "1.0.0", # the brace } in a comment is no close + features = ["gui", + "a]b"], +} +gadget = "1.0.0" +)"; + auto edited = mcpp::manifest::upsert_dependency_text(source, { + .namespace_ = "acme", + .shortName = "widget", + .version = "2.0.0", + }); + if (auto&& failed = expect(fatal(edited.has_value())); !failed) failed << edited.error(); + expect(ne(edited->find("[dependencies.acme]\nwidget = \"2.0.0\"\ngadget = \"1.0.0\"\n"), std::string::npos)); + auto parsed = mcpp::manifest::parse_string(*edited); + if (auto&& failed = expect(fatal(parsed.has_value())); !failed) failed << parsed.error().format(); + expect(fatal(eq(parsed->dependencies.size(), 2u))); + expect(eq(parsed->dependencies.at("acme.widget").version, "2.0.0")); + expect(eq(parsed->dependencies.at("acme.widget").features, std::vector{})); + expect(eq(parsed->dependencies.at("acme.gadget").version, "1.0.0")); +}}; + +// The end of an assignment is the newline after its value, wherever the value +// closes: brackets inside strings and comments do not count. +UTest an_assignment_ends_where_its_value_closes{"ManifestEditor/AnAssignmentEndsWhereItsValueCloses", [] { + using mcpp::manifest::assignment_end; + constexpr std::string_view one = "a = \"1\"\nb = 2\n"; + expect(eq(assignment_end(one, 0), one.find('\n'))); + expect(eq(assignment_end(one, 8), one.size() - 1)); + + constexpr std::string_view many = "a = {\n x = \"}\", # ]\n y = ['{',\n \"\\\"[\"],\n}\nb = 2"; + expect(eq(assignment_end(many, 0), many.find("\nb = 2"))); + expect(eq(assignment_end("a = { x = 1 }", 0), 13u)); + expect(eq(assignment_end("a = {\n", 0), 6u)); // never closes: the end of the text +}}; + UTest multiple_entries_across_platforms{"ListXpkgVersions/MultipleEntriesAcrossPlatforms", [] { constexpr auto src = R"( package = { diff --git a/tests/unit/test_toml.cpp b/tests/unit/test_toml.cpp index 5ab1ce0c9..98d58de31 100644 --- a/tests/unit/test_toml.cpp +++ b/tests/unit/test_toml.cpp @@ -102,6 +102,50 @@ UTest a_unicode_escape_names_a_scalar_value{"Toml/AUnicodeEscapeNamesAScalarValu if (auto&& failed = expect(ne(bad.error().message.find("\\uDFFF"), std::string::npos)); !failed) failed << bad.error().message; }}; +// TOML 1.1: an inline table may span lines, hold comments, and end with a +// comma. Each was refused with `expected key`, so the multi-line `runner = {...}` +// of docs/35 could not be read. +UTest an_inline_table_spans_lines{"Toml/AnInlineTableSpansLines", [] { + auto d = t::parse(R"( +runner = { # a comment after the brace + cmd = ["dirun", "--", "{}"], + hosts = ["linux-x86_64", + "macos-aarch64"], + # a comment line between pairs + env = { A = "1", B = { C = 2, }, }, +} +after = true +)"); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(*d->get_string_array("runner.cmd"), std::vector({"dirun", "--", "{}"}))); + expect(eq(*d->get_string_array("runner.hosts"), std::vector({"linux-x86_64", "macos-aarch64"}))); + expect(eq(d->get_string("runner.env.A").value_or(""), "1")); + expect(eq(d->get_int("runner.env.B.C").value_or(-1), 2)); + expect(eq(d->get_bool("after").value_or(false), true)); + + d = t::parse("empty = {\n}\nx = { a = 1, }"); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(fatal(d->get_table("empty") != nullptr)); + expect(d->get_table("empty")->empty()); + expect(eq(d->get_int("x.a").value_or(-1), 1)); +}}; + +// What TOML 1.1 still refuses in an inline table: a pair split across lines, +// a missing separator, an empty element, and a table that never closes. +UTest an_inline_table_keeps_each_pair_on_one_line{"Toml/AnInlineTableKeepsEachPairOnOneLine", [] { + expect(!(t::parse("x = { a\n= 1 }").has_value())); + expect(!(t::parse("x = { a =\n1 }").has_value())); + expect(!(t::parse("x = { a = 1\nb = 2 }").has_value())); + expect(!(t::parse("x = { a = 1,, }").has_value())); + expect(!(t::parse("x = { , }").has_value())); + + auto open = t::parse("x = {\n a = 1,\n"); + expect(fatal(!open.has_value())); + if (auto&& failed = expect(eq(open.error().message, "unterminated inline table")); !failed) failed << open.error().message; + expect(eq(open.error().where.line, 1u)); + expect(eq(open.error().where.column, 5u)); +}}; + UTest reject_unterminated_string{"Toml/RejectUnterminatedString", [] { auto d = t::parse(R"(x = "no end)"); expect(!d.has_value());