|
| 1 | +--- |
| 2 | +subject: triage |
| 3 | +status: active |
| 4 | +--- |
| 5 | + |
| 6 | +# #655: the `arguments` array is not the command the build runs |
| 7 | + |
| 8 | +**Revision 2026-09-17 (before implementation).** §3 proposed that the |
| 9 | +database adopt each host's own reading (C1) and that defines be tagged at |
| 10 | +ingestion (C2). The maintainer asked for the option with the best cross-platform |
| 11 | +compatibility, so the adopted design replaces C1: an element of `cflags`, |
| 12 | +`cxxflags` and `asmflags` has one host-independent syntax, the build quotes each |
| 13 | +word for its host, and the databases list the words without reading any command |
| 14 | +line back. C2 is kept in a simpler form (a define enters the list already |
| 15 | +spelled as one word). §3 below is the proposal as written; the adopted design, |
| 16 | +its criteria and the findings made while implementing are |
| 17 | +`2026-09-17-655-implementation-plan.md`. |
| 18 | + |
| 19 | +**Issue:** mcpp-community/mcpp#655, "emit build-database: shell-escaped quotes |
| 20 | +inside the `arguments` array break consumers". |
| 21 | + |
| 22 | +Engine code was read at `51aeee0e` (origin/main, mcpp 2026.9.16.2). A statement |
| 23 | +marked *measured* was run on Linux x86_64 with the released mcpp 2026.9.16.2 and |
| 24 | +`gcc@16.1.0`; the probe is `2026-09-17-655-probe.sh`. A statement marked *read* |
| 25 | +names a file and line and was not executed. Nothing here ran on macOS or Windows. |
| 26 | + |
| 27 | +## 1. Verdict |
| 28 | + |
| 29 | +The report is a real engine defect, and it is not a documentation question. |
| 30 | +SPEC-005 R3.6 and R3.7 state that a unit's `arguments` is its compile command |
| 31 | +and that it is the same record `compile_commands.json` lists; the JSON |
| 32 | +Compilation Database and S1 both define `arguments` as an argument vector that |
| 33 | +is executed without a shell. Consumers should not unescape defensively: after |
| 34 | +the fix below, a defensive unescape would corrupt a define whose value really |
| 35 | +contains a backslash. |
| 36 | + |
| 37 | +The issue describes one case. The defect is wider. The probe compares, for five |
| 38 | +macros, the value the compiler receives when ninja runs the build with the value |
| 39 | +it receives when the database's `arguments` is executed directly (*measured*): |
| 40 | + |
| 41 | +| Declared in `mcpp.toml` | Build (ninja, `/bin/sh -c`) | `arguments` executed | Agree | |
| 42 | +|---|---|---|---| |
| 43 | +| `cflags = ["-DESC=\\\"esc.h\\\""]` (the libarchive form) | `ESC "esc.h"` | `ESC \"esc.h\"` | no, #655 | |
| 44 | +| `cflags = ["-DMID=\"mid\""]` | `MID mid` | `MID "mid"` | no | |
| 45 | +| `cflags = ["-DSQ='sq'"]` | `SQ sq` | `SQ 'sq'` | no | |
| 46 | +| `defines = ["DEF=\"def\""]` | `DEF def` | `DEF "def"` | no, and the build is the wrong one | |
| 47 | +| `defines = ["SPACE=\"a b\""]` | `SPACE "a b"` | `SPACE "a` plus a stray argument `b"`; the compiler exits 1 | no | |
| 48 | + |
| 49 | +Only the database's reading of `SPACE` stops the compiler. The other four |
| 50 | +disagreements compile silently with a different macro value, which is the worse |
| 51 | +form for a language server. |
| 52 | + |
| 53 | +## 2. Cause |
| 54 | + |
| 55 | +### 2.1 Two word splitters answer one question, and the per-unit path uses the wrong one |
| 56 | + |
| 57 | +The ninja rule is `command = $cc $cflags $unit_cflags ...` |
| 58 | +(`src/build/ninja_backend.cppm:1246`), and ninja runs it with `/bin/sh -c` on |
| 59 | +POSIX hosts and with `CreateProcess` on Windows. Every flag string in the engine |
| 60 | +is therefore text in the host's command-line syntax. The database has to turn |
| 61 | +that text back into words by the same rules the host applies. |
| 62 | + |
| 63 | +The engine contains two splitters for that purpose (*read*): |
| 64 | + |
| 65 | +- `mcpp::build::split_flags` (`src/build/compile_commands.cppm:133`) is used for |
| 66 | + every translation unit in both databases. It undoes ninja's `$` escapes and |
| 67 | + removes a quote only when the quote opens a token. It does not implement |
| 68 | + backslash escapes, it treats a quote in the middle of a token as data, and it |
| 69 | + has one rule for both hosts. The unit test |
| 70 | + `CompileCommandsArgs.InnerQuotesAreNotStripped` |
| 71 | + (`tests/unit/test_compile_commands.cpp:307`) asserts the second of these |
| 72 | + choices, so the divergence in rows MID and SQ is pinned by a test rather than |
| 73 | + merely untested. |
| 74 | +- `split_command_words` (`src/build/build_database.cppm:309`), added in #636 for |
| 75 | + the standard library units (R3.11), implements POSIX `sh` word splitting |
| 76 | + (backslash outside quotes, the four escapable characters inside double quotes, |
| 77 | + quotes anywhere in a word) and, separately, the MSVCRT rules that |
| 78 | + `CreateProcess` programs use. |
| 79 | + |
| 80 | +The second splitter is the correct one, and it already exists. The per-unit path |
| 81 | +was never moved onto it. |
| 82 | + |
| 83 | +### 2.2 The database joins package flags differently from the build |
| 84 | + |
| 85 | +`package_flag_args` (`src/build/compile_commands.cppm:211`) joins |
| 86 | +`packageCflags`/`packageCxxflags` with bare spaces. The build joins the same |
| 87 | +vector with `join_flags` (`src/build/ninja_backend.cppm:198`), which |
| 88 | +shell-quotes a `-D` element that contains a space (mcpp#234). The database |
| 89 | +therefore parses a string the build never wrote, which is why `SPACE` is cut in |
| 90 | +two even though its quotes open the token. |
| 91 | + |
| 92 | +### 2.3 The silent twin: `defines` values lose their quotes in the build |
| 93 | + |
| 94 | +`join_flags` quotes a define only when it contains a space. The `defines` key is |
| 95 | +documented as `name` or `name=value` (`docs/04-mcpp-toml.md:333`), that is a |
| 96 | +value and not shell text, but `DEF="def"` reaches `sh` unquoted and the compiler |
| 97 | +receives `-DDEF=def` (*measured*). This is a build defect that the database |
| 98 | +defect was hiding: once the database agrees with the build, it will faithfully |
| 99 | +report the wrong value. |
| 100 | + |
| 101 | +The quoting cannot simply be widened to every `-D` element, because |
| 102 | +`scanner.cppm:1209` pushes `defines` into the same vector as raw `cflags`, and a |
| 103 | +raw `cflags` element such as libarchive's `-DPLATFORM_CONFIG_H=\"...\"` is shell |
| 104 | +text that must not be quoted again. The origin of the element is lost at |
| 105 | +ingestion. |
| 106 | + |
| 107 | +## 3. Proposed changes |
| 108 | + |
| 109 | +### C1. One splitter, one rendering (fixes #655 and rows MID, SQ, SPACE) |
| 110 | + |
| 111 | +1. Move `split_command_words` out of `build_database.cppm` into |
| 112 | + `compile_commands.cppm` (or `flags.cppm`) as the single host-command-line |
| 113 | + splitter, and make `split_flags` "undo ninja `$` escapes, then |
| 114 | + `split_command_words(s, is_windows)`". The ninja unescape must come first, as |
| 115 | + it does today, because `escape_ninja_chars` runs before `shell_quote_arg`. |
| 116 | +2. Make `package_flag_args` call `join_flags` (export it from `ninja_backend` or |
| 117 | + move it beside `shell_quote_arg` in `flags.cppm`), so the database parses the |
| 118 | + byte string the build writes. |
| 119 | +3. Replace `InnerQuotesAreNotStripped` with a test stating the opposite: a |
| 120 | + mid-token quote is quoting, because `sh` treats it so. |
| 121 | + |
| 122 | +No manifest or index change is needed. The libarchive recipe |
| 123 | +(`mcpp-index/pkgs/c/compat.libarchive.lua:48`) is correct shell text for the |
| 124 | +contract the build already implements. |
| 125 | + |
| 126 | +### C2. `defines` are values, rendered by the engine (fixes row DEF) |
| 127 | + |
| 128 | +Keep the origin of a define through ingestion: either a separate |
| 129 | +`packageDefines` vector on the unit, or the element pushed already rendered with |
| 130 | +`shell_quote_arg`. `join_flags` then quotes every define that contains any |
| 131 | +character in `shell_quote_arg`'s trigger set, not only a space, and never |
| 132 | +touches a raw `cflags` element. |
| 133 | + |
| 134 | +This changes the build's output for a define whose value contains a quote, |
| 135 | +backslash, `$` or similar. No package in `mcpp-community/mcpp-index` declares |
| 136 | +such a `defines` entry (*read*, grep over `pkgs/`), so the change is visible |
| 137 | +only to a project that relied on the value being shell text. It should be named |
| 138 | +in the release notes. |
| 139 | + |
| 140 | +### C3. State the flag contract in the documentation |
| 141 | + |
| 142 | +`docs/04-mcpp-toml.md` (and its `zh` copy) should state that a `cflags`, |
| 143 | +`cxxflags` or `ldflags` element is command-line text in the host's syntax and may |
| 144 | +hold several words (`-include foo.h`), whereas a `defines` element is one value |
| 145 | +that mcpp quotes. SPEC-005 R3.6 should add one sentence: `arguments` holds the |
| 146 | +words the host passes to the compiler, after the host's quoting is removed. |
| 147 | + |
| 148 | +A further change, holding flags as argument vectors from ingestion to the ninja |
| 149 | +writer so that no string is ever parsed back, would remove the splitter |
| 150 | +altogether. It is not proposed here: the index publishes flag elements that pack |
| 151 | +several words, so it needs a manifest contract change and a migration, and C1 |
| 152 | +already makes the database agree with the build by construction. |
| 153 | + |
| 154 | +## 4. Criteria |
| 155 | + |
| 156 | +- **K1 (differential, the one that decides C1).** For a table of flag strings, |
| 157 | + including every row of §1, a path with a space, `$ORIGIN`, `-include x.h`, a |
| 158 | + backslash that precedes no quote, and an empty quoted argument, the words |
| 159 | + `split_flags` returns equal the argv that `/bin/sh -c 'printf "%s\0" <string>'` |
| 160 | + prints. On Windows the reference is a helper that prints its own `argv`. |
| 161 | + The oracle is the host, not an expectation written by hand. |
| 162 | +- **K2 (end to end).** `2026-09-17-655-probe.sh` prints identical `READING |
| 163 | + build` and `READING database` lines for all five macros, and the database's |
| 164 | + exec returns 0. |
| 165 | +- **K3 (row DEF).** `READING build: #define DEF "def"`. Before C2 it reads |
| 166 | + `DEF def`. |
| 167 | +- **K4 (revert).** Revert C1 alone and K1 and K2 turn red; revert C2 alone and |
| 168 | + K3 turns red. A criterion that stays green without its change measures nothing. |
| 169 | +- **K5 (consumer).** The reporter's case: `openxlings/xlings` with the released |
| 170 | + build produces `-DPLATFORM_CONFIG_H="mcpp_libarchive_config.h"` in both |
| 171 | + databases, and mcpp-language-server indexes libarchive without the |
| 172 | + `expected "FILENAME"` error. |
| 173 | + |
| 174 | +## 5. Open points for review |
| 175 | + |
| 176 | +1. Windows was not measured. `split_command_words(…, windows=true)` follows the |
| 177 | + MSVCRT rules; whether every Windows compile edge is a plain `CreateProcess` |
| 178 | + (no `cmd /c` wrapper, which would add cmd.exe's own parsing) should be read |
| 179 | + from `ninja_backend.cppm` before K1's Windows leg is written. |
| 180 | +2. A raw `cflags` element with single quotes (`-DSQ='sq'`) already yields a |
| 181 | + different macro on Linux (`sq`) and on Windows (`'sq'`), because the flag is |
| 182 | + host command-line text. C1 makes the database report each host faithfully; |
| 183 | + it does not make the manifest portable. C3 documents that; whether to |
| 184 | + normalise raw flags to one syntax is a separate decision. |
| 185 | +3. The reply to the reporter: `arguments` is exec-ready, the escaped form is an |
| 186 | + engine defect, and consumers should not unescape. |
0 commit comments