C++ Style
The C++ coding style for MBO Works repositories. It sits on top of two
machine-enforced config files and adds the human conventions below. When in doubt
the config files win; this document explains and extends them so a contributor (or
an AI assistant) can follow them without reverse-engineering the tooling.
Toolchain (machine-enforced)
- C++23, compiled with
clang. Compilation with GCC is kept working on a
best-effort basis. clang-formatwith.clang-formatformats all C++ code. Run
it; do not hand-format against it. CI rejects any reformatting diff.clang-tidywith.clang-tidyruns against the compilation
database generated bybazel run //:refresh_compile_commandsusing the maintained
helly25 extractor fork. CI resolves clang-tidy from the pinned hermetic LLVM
distribution, lints changed translation units on ordinary pull requests, and expands
to the whole first-party database when headers or build/toolchain policy changes.
WarningsAsErrorsmakes findings explicit. The dedicated CI step is temporarily
report-only while the existing tree is brought to zero; database generation and tool
discovery still gate CI. Never apply clang-tidy fixes automatically. The enabled set is
broad:abseil-*,bugprone-*,cppcoreguidelines-*,google-*,misc-*,
modernize-*,performance-*,portability-*, andreadability-*. LLVM 22.1.8's
crashingabseil-unchecked-statusor-accesscheck is excluded until the pinned toolchain
contains a fix.
What .clang-format decides (do not fight it)
- Google base style, column limit 120.
AlignAfterOpenBracket: AlwaysBreak+BinPackParameters: false: when arguments
do not fit, each goes on its own line at one indent (not aligned to the paren).PointerAlignment: Left(int* p),QualifierAlignment: Left(const int).IntegerLiteralSeparatordecimal every 3: write1'000'000.RemoveSemicolon,InsertBraces(always brace bodies),SeparateDefinitionBlocks.InsertTrailingCommasis deliberately OFF (it would force every aggregate to
never-bin-pack). That is the lever behind the trailing-comma rule below: a manual
trailing comma opts a single aggregate into one-element-per-line.
Naming (enforced by .clang-tidy readability-identifier-naming)
- Types / classes / structs / enums / aliases / functions:
CamelCase(LimitedMap). - Variables / parameters / data members / namespaces:
lower_case(flag_count). - Private data members:
lower_casewith a trailing_(flags_). - constexpr / enum constants / global + static constants:
k+CamelCase(kMaxDepth). - Macros:
UPPER_CASE, prefixedCARVE_(CARVE_...). (A macro you call from the mbo
dependency keeps its ownMBO_prefix, e.g.MBO_RETURN_IF_ERROR; the rule is about
macros carve itself defines.)
Code organization
- All exported code lives under the top library directory (in carve:
carve/). - Each subdirectory uses namespace
carve::{dir}(andcarve::{dir}::{sub}).- Internal-only code:
carve::{dir}::{sub}_internal. No namespace component is ever
justinternal. - Exported-but-detail code may use a
detailsub-namespace (fully qualify it, since
the baredetailname can collide).
- Internal-only code:
- Header guards:
{PATH}_{FILE}_(path + filename, uppercased, non-alphanumerics ->
_, trailing_). A file may not start with a sibling directory's basename +_:
foo/bar.handfoo_bar.hwould both yieldFOO_BAR_H_. - Forward-declared symbols must be fully implemented in the same source file (except
theImpl-in-header / implementation-in-.ccpattern). - Macros: avoid them. An exported macro (defined in a header for other code to use) is
prefixedCARVE_(private implCARVE_PRIVATE_...). A macro local to one translation unit- a
.cc/ test file, or a header helper you#undefright after its last use - may stay
unprefixed (stillUPPER_CASE);#undefa local macro as soon after its last use as
possible.
- a
- C-library names: use the
std::form; the unqualified global alias is optional. A
<cstddef>/<cXXX>header is only required to declaresize_t,int64_t,memcpy,
... innamespace std; whether it also injects the bare global name is
implementation-defined, so we never rely on it.- In headers, always fully qualify (
std::size_t); no namespace-levelusing. - In an implementation file (
.cc), ausing std::size_t;(or otherusing std::...)
is fine where it reads better - bring the name in explicitly rather than leaning on the
global alias. Be consistent within a file, but readability outranks consistency.
- In headers, always fully qualify (
- Library flags are prefixed by their path/namespace (e.g. a flag in
carve/<pkg>is
--carve_<pkg>_...). The application entry-point binary (carve) uses bare flag names
(--targets,--output,--jobs).
Formatting conventions on top of clang-format
clang-format picks a layout per line; these habits steer it toward the readable one.
-
No comment at the end of a long line. Put the comment on its own line above
the element. A trailing// ...that pushes a line past 120 makes clang-format
explode the element across several lines.// -exec run in the matched entry's directory {.name = "-execdir", .kind = Kind::kAction, .arity = -1},
-
Trailing comma on the last field of a complex aggregate breaks it one field per
line. BecauseInsertTrailingCommasis off, the comma is your per-aggregate opt-in.
Use it for long/complex initializers; a short one that reads on a single line stays.const Drop drop{ .line = line, .layer = Source::kProject, .safety = Safety::kSecurity, };
In a long array of struct literals - a registry-style table such as
kDescriptorsor
kSubcommands- put the trailing comma on every element, even the short ones that
would fit on a single line, so clang-format expands the whole table uniformly, one
field per line. A consistent table you scroll through reads better than a mix of
one-liners and exploded rows packed to save height. This stays a deliberate, per-table
choice made element by element:InsertTrailingCommasis off (we do not always want
trailing commas), so clang-format never forces it for you. -
Force a line break with a comment rather than let clang-format cram a value at the right
margin. A long argument - especially a raw string such as a protoR"pb(...)pb"- otherwise
gets packed onto the call line and shoved against the 120 column, unreadable. A trailing
comment makes clang-format keep the element on its own line. Mark it// NL("new line"):EXPECT_THAT( // NL message, EqualsProto(R"pb(name: "n" value: 1)pb"));
When there is a relevant reason, keep the prefix and add it:
// NL: <short reason>, preferred
over a bare// NL. Always keep theNLprefix - do not drop to a bare//or an unprefixed
comment - for two reasons: it marks the comment as load-bearing for layout, so a reader knows
that removing it re-crams the line; and the consistent marker is machine-checkable, so a
pre-commit rule can verify these lines stay broken.Reserve
// clang-format off/onfor a genuine table or hand-aligned expression that
clang-format cannot lay out -// NLonly inserts breaks, so it cannot keep an over-120
line whole or stop a reflow (e.g. a one-line-per-case test table, or a multi-clause
requires(...)). Do not use it to hand-place ordinary layout, and keep theoff/onpair
tight and adjacent so theonis never forgotten: everything between the two loses every
formatting guarantee above.
Idioms
- Pass
absl::Statusby value, notconst&: it is a tagged pointer, and
.clang-tidy performance-unnecessary-value-paramallowlists it (withabsl::StatusOr
andstd::string_view).StatusOr<T>'s cost depends onT. - Prefer container algorithms from
absl/algorithm/container.h(absl::c_contains,
c_any_of,c_find,c_equal,c_sort, ...) or C++23std::rangesover hand-rolled
loops: range-based (no begin/end), well-named, constexpr-friendly. A loop that returns
on the first match is anabsl::c_any_ofwith a lambda; a reserve-then-push copy into a
std::vectoris range constructionstd::vector<T>(src.begin(), src.end()). Keep a raw
loop only when it builds a non-trivial structure no algorithm expresses cleanly. - Container choice: prefer the Abseil containers over the bare
std::ones. The reason is
in their favor, not againststd::: the Abseil variants are faster, support transparent
(heterogeneous) lookup - find in astd::string-keyed map with astd::string_view, no
temporarystd::string- andflat_hash_*stores elements inline for lower memory.- Never
std::unordered_*- no<unordered_map>/<unordered_set>include, and none
ofunordered_map/unordered_set/unordered_multimap/unordered_multiset. Use
absl::flat_hash_map/flat_hash_set, or thenode_hash_map/node_hash_setvariants
when you need pointer/reference stability. This holds in tests too - the one exception is a
container-compatibility test that deliberately exercises astd::unordered_*input. - Avoid
std::map/std::set(and themultivariants) in library code; prefer
absl::btree_map/absl::btree_set, which keep the same ordered interface but are
cache-friendlier. In tests,std::map/std::setare fine. - Small compile-time tables:
mbo::container::LimitedMap/LimitedSet. - A type-detection trait may name a
std::container type freely - it must, to recognize it.
- Never
- A read-only string parameter is
std::string_view(by value), notconst std::string&.
This is pretty much always: astd::string_viewbinds to astd::string, a string literal,
achar*, or another view with no allocation and no.c_str()dance, so theconst&only
narrows what callers may pass. Keepconst std::string&(orstd::stringby value) only
when the body genuinely needs astd::string- it calls.c_str()for a C API, stores or
moves the argument, or passes it to something that itself wantsconst std::string&. - A by-value
std::string_viewis neverconst. The characters it views are already
const; making the view itselfconstonly disables the view's own API
(remove_prefix,remove_suffix, reassignment) for no benefit. This applies to locals
and range-forvariables. (Not tostd::string_view::size_type, which is asize_t,
nor toabsl::Span<const std::string_view>, whereconstis the element type of an
immutable span.) - Do not overload
const T*to express "optional" or to conflate omission with value. A
raw pointer mixes nullability, the pointed-at value, and ownership/lifetime into one type
the caller has to second-guess. For an optional reference use
std::optional<std::reference_wrapper<T>>or mbo'smbo::types::OptionalRef<T>
(mbo/types/optional_ref.h, available through themboworks_mbodependency) and its related
types (e.g.OptionalDataOrRefwhen it may own a value or refer to one). - Mark a return value
[[nodiscard]]when silently ignoring it is a bug - an
absl::Status/StatusOr, a parsed result, aConsume..."did it match?" flag, an acquired
handle. The exception is a function designed for its result to be optionally used: a builder
method returning*thisfor chaining, or a mutator that also returns the previous value as a
convenience. We disablemodernize-use-nodiscardin.clang-tidyprecisely because it would
blanket-annotate every const method - apply[[nodiscard]]by judgment instead. - switch / case: order case labels alphabetically. Where the cases are a uniform
mapping (key -> handler or value), prefer a constexprmbo::container::LimitedMap
lookup over a switch. - No em-dashes anywhere (code, comments, docs, commit messages). Use a spaced
hyphen-.
Error handling: absl::Status and the MBO status macros
Propagate errors with the macros from mbo/status/status_macros.h
(@mboworks_mbo//mbo/status:status_macros_cc), not a hand-written
if (!x.ok()) return x.status();.
- A "value or error" type IS
absl::StatusOr<T>. Do not hand-roll a struct that bundles
a value (or astd::vector/std::optional) with anabsl::Statusplus an "ok" flag:
absl::StatusOr<T>is exactly that, enforces the not-ok-has-no-value invariant by
construction, composes with the macros below, and matches every other API. MBO_RETURN_IF_ERROR(expr)evaluates aStatusorStatusOrand returns early if
it is not OK. It returns ambo::status::StatusBuilder, which converts to the calling
function'sabsl::Statusorabsl::StatusOr<T>, so it works in both.MBO_ASSIGN_OR_RETURN(Type var, expr)binds the value of aStatusOr<Type>to
var(a new declaration - carry the type - or an existing variable) or returns the
status. After it,varis the value (not aStatusOr); usevar, not*var.MBO_MOVE_TO_OR_RETURN(expr, target)is the variant whose target may contain commas;
the expression comes first so structured bindings work:
MBO_MOVE_TO_OR_RETURN(MakePair(), auto [a, b]);.
absl::StatusOr<Report> Build(std::string_view path) {
MBO_ASSIGN_OR_RETURN(const Config config, LoadConfig(path)); // value, or return status
Report report = Analyze(config);
MBO_RETURN_IF_ERROR(Persist(report)); // Status guard
return report;
}- When you only need the guard but the
StatusOrvalue is large and used later through
*expr, guard on the status to avoid copying the value:MBO_RETURN_IF_ERROR(big.status());
then use*big. - Do not force the macros where they do not fit: a recovery branch (e.g.
absl::IsNotFound(s)-> return a default value), a function that returnsbool/int
rather than a status, or a CLI path that prints a message and returns an exit code. - Pass
absl::Statusby value (see Idioms).
Output, logging, and AbslStringify
-
Never use C strings or
.c_str()except to satisfy a C / system API (e.g. building
char* argv[]forexecvp); comment why at that one boundary. Keepstd::string/
std::string_viewend to end - the round-trip out to aconst char*and back is
clutter, not interop. -
Never write output through C stdio (
printf/fprintf/fputsto thestdout/
stderrFILE*). Use the C++ streamsstd::cout/std::cerr(or the logging library).
The anti-pattern to delete on sight:std::fputs(absl::StrCat(...).c_str(), stderr). -
Format with Abseil:
absl::StrCatfor plain concatenation,absl::StrFormatfor a
formatted string, andabsl::StreamFormatto write a formatted line straight to a
stream with no temporary:std::cerr << absl::StreamFormat("wrote %d entries\n", n);.
A plain<<is fine for a simple string. Choose per line, but be consistent within a
single function (do not mix aStreamFormatand a<<chain in the same function). -
Make your types printable with
AbslStringify, not a hand-rolledoperator<</
ToString. Abseil's string-conversion extension point is a hidden-friend hook:struct Point { int x = 0; int y = 0; template<typename Sink> friend void AbslStringify(Sink& sink, const Point& p) { absl::Format(&sink, "(%d, %d)", p.x, p.y); } };
One hook makes the type work with
absl::StrCat,absl::StrFormat/StreamFormat
%v,absl::StrJoin, and Abseil logging - and gives GoogleTest readable failure output.
absl::Status/StatusOralready stringify; print them with%v.
Concurrency and thread safety
These are the baseline for any multi-threaded code, not extra credit; threading bugs are
silent and data-dependent, so the annotations and the sanitizer are the standing guard.
Most of this repo is single-threaded today, so this section is the standard to apply when
you add multi-threaded code, not a description of current breadth.
- Use
absl::Mutex+absl::MutexLock, notstd::mutex/std::lock_guardor
atomics-as-synchronization. Construct the lock from a reference:absl::MutexLock lock(mu_);
(the pointer constructor is deprecated). - Annotate everything (
absl/base/thread_annotations.h):ABSL_GUARDED_BY(mu_)on
every member the mutex protects, andABSL_LOCKS_EXCLUDED/
ABSL_EXCLUSIVE_LOCKS_REQUIREDon methods. A comment must state exactly which members a
given mutex guards, and call out any shared state deliberately left unguarded together
with the invariant that keeps it safe (e.g. "each index is handed to exactly one worker,
so distinct elements never alias"). - If a type has more than one mutex, document their lock-acquisition order (which is
taken before which) so the ordering that prevents deadlock is explicit. - Enforce the annotations: compile first-party code with clang's
-Wthread-safety
(with-Werroran unguarded access becomes a build failure), and run the Linux
ThreadSanitizer CI job as the complementary runtime race detector. ASan/LSan/UBSan
and Linux MSan cover the other sanitizer classes. LLVM-linking targets carryno_san
and stay excluded because their prebuilt archives cannot be instrumented consistently
with first-party code.
Protocol Buffers
- Generated repeated fields and maps are STL-compatible - range-iterate them, do not
index withfield_size()+field(i).field_size()is still fine forreserve()and
== 0emptiness checks; compare two repeated fields withabsl::c_equal. - Edition 2024: string accessors default to
string_type = VIEWand return
std::string_view(so the by-value-string_viewrule applies to them); singular fields
have explicit presence by default, soset_x(0)serializes a0. To mean "absent",
leave the field unset (never call its setter) rather than setting the default. - Build test protos from text, not setters:
mbo::proto::ParseTextProtoOrDie(R"pb(field: 1 nested { k: "v" })pb")
(@mboworks_proto//mbo/proto:parse_text_proto_cc). TheR"pb(...)pb"raw string is what
clang-format leaves alone, so the proto stays readable. Do not imperativelyset_/add_
your way to a fixture. - Assert on protos structurally with
mbo::proto::EqualsProto
(@mboworks_proto//mbo/proto:matchers_cc), which also accepts a text-proto string:
EXPECT_THAT(msg, EqualsProto(R"pb(field: 1)pb"));. Match a subset with
Partially(EqualsProto(...)). Never compare serialized strings.
Testing (GoogleTest / GoogleMock)
All exported code must be tested, at every level (unit, integration, and end-to-end where
it applies). A one-shot manual check or a script you ran once is planning input, never a
substitute for a committed test. Tests use GoogleTest + GoogleMock with these conventions.
Structure
- Always
TEST_Fwith a fixture, never a bareTEST. Even an empty
struct FooTest : ::testing::Test {};is preferred, so shared setup has a home. - One behaviour per test; name the test for the behaviour it asserts.
- Typed and parameterized tests supply a name generator (for
TYPED_TEST_SUITE/
INSTANTIATE_TEST_SUITE_P), deriving the case name from the type or value, so failures
read as named cases rather than numbered ones (Suite/0,Suite/1).
Assertions: gmock matchers, not EXPECT_EQ
-
Assert with
EXPECT_THAT/ASSERT_THAT+ a matcher rather than the comparison macros
EXPECT_EQ/NE/GT/LT/GE/LE(and theirASSERT_forms): matchers compose and
give far better failure messages. The accepted exception is the booleanEXPECT_TRUE/
EXPECT_FALSE(andASSERT_TRUE/ASSERT_FALSE), which read fine on their own. Within a
single test keep one style - do not mix, say,EXPECT_TRUE(x)andEXPECT_THAT(y, IsTrue()). -
For multiline text, use
mbo::testing::EqualsText, which reports a useful
unified diff while retaining matcher style. -
Name matchers unqualified - never a
::testing::/::absl_testing::/::mbo::testing::
prefix inline. Bring each matcher in with ausing ::testing::Foo;(or
using ::mbo::testing::Foo;) in the test's anonymous namespace, then writeFoo(...)in the
assertion. A qualified matcher written inline inside anEXPECT_THAT/ASSERT_THATis the
smell to fix by adding theusing. Fixture utilities (::testing::Test,::testing::TempDir)
are not matchers and keep their qualification. Theunqualified-matcherspre-commit guard
enforces this. -
Eqis optional - a readability choice, not a rule.EXPECT_THAT(x, value)auto-wraps a
bare value inEq, so both forms compile. The value ofEXPECT_THATis that the line reads as
a sentence -EXPECT_THAT(foo, Eq(25))is "expect that foo equals 25" - so keepEqwhere it
makes the assertion read that way, and drop it where the value already carries the meaning
(EXPECT_THAT(name, "foo")). Inside composite matchers prefer bare elements
(ElementsAre(1, 2, 3));Optional/Pointeeare the exception and need the innerEq(see
below). Strings sometimes needStrEq(e.g. achar*subject, where bareEqcompares
pointers). For booleans,IsTrue()/IsFalse()usually read better than a baretrue/
falseorEq(true):EXPECT_THAT(found, IsTrue()). -
Floats / doubles: never
Eq/==; useFloatEq/DoubleEq(orNear). -
Optionals:
EXPECT_THAT(opt, Eq(std::nullopt))for empty,Optional(...)for a
value. Nested matchers do not auto-wrap:Optional(Eq("x"))andPointee(Eq("x"))
need the explicit innerEq(a bare value fails to compile there). -
Containers:
ElementsAre(...)/UnorderedElementsAre(...)/SizeIs(n)/
IsEmpty()cover size + order + contents in one matcher, instead of an
ASSERT_EQ(v.size(), n)followed by indexedEXPECT_EQs.ElementsAreauto-wraps
each bare element inEq. -
Size and emptiness: match the container, never extract then match. Use
SizeIs/IsEmptyon the container itself, not.size()/.empty()fed to a
scalar matcher orEXPECT_TRUE- the matcher form prints the container on failure
while the extracted form throws it away. So:EXPECT_THAT(v, SizeIs(3)), notEXPECT_THAT(v.size(), 3)/EXPECT_EQ(v.size(), 3).SizeIscomposes with a matcher for bounds:EXPECT_THAT(v, SizeIs(Le(90))),
notEXPECT_THAT(v.size(), Le(90)).EXPECT_THAT(v, IsEmpty())/EXPECT_THAT(v, Not(IsEmpty())), not
EXPECT_TRUE(v.empty())/EXPECT_FALSE(v.empty())(this is the one place a
.empty()boolean should still become a matcher).
-
Struct elements: fold per-field checks into the element matcher with the
3-arg, namedField("member", &T::member, m)+AllOf, ideally via a small
testing::Matcher<T> FooIs(...)helper, so a mismatch names the field rather than
reporting "whose given field is...".testing::Matcher<ResolvedFlag> FlagIs(const std::string& flag, Source source) { return AllOf(Field("flag", &ResolvedFlag::flag, flag), Field("source", &ResolvedFlag::source, source)); } EXPECT_THAT(Resolve(in), ElementsAre(FlagIs("--color", Source::kUser), FlagIs("--sort", Source::kUser)));
Status matchers
- Use the
mbo::testingstatus matchers (#include "mbo/testing/status.h", dep
@mboworks_mbo//mbo/testing:status_cc):IsOk(),IsOkAndHolds(m),
StatusIs(absl::StatusCode::kInvalidArgument[, msg]), plus the assert-OK-and-bind macros
MBO_ASSERT_OK_AND_ASSIGN(target, expr)andMBO_ASSERT_OK_AND_MOVE_TO(expr, target)
(test mirrors ofMBO_ASSIGN_OR_RETURN/MBO_MOVE_TO_OR_RETURN) andMBO_EXPECT_OK/
MBO_ASSERT_OK. mbo's set is the MBO Works canonical superset: it works on bothStatus
andStatusOr, adds payload matchers and the bind macros, and forwards the abseil
matchers it shares -- features Abseil keeps internal and has not open-sourced. So prefer
it; Abseil's::absl_testing::matchers (absl/status/status_matchers.h) are not
used. Like all matchers they are written unqualified (see "Assertions"), so an inline
::absl_testing::is also caught by theunqualified-matchersguard. Prefer
IsOkAndHolds(m)overIsOk()followed by dereferencing:
EXPECT_THAT(Parse(in), IsOkAndHolds(SizeIs(3))).
Protocol-buffer fixtures and matchers
Build proto test data with mbo::proto::ParseTextProtoOrDie(R"pb(...)pb") and assert with
mbo::proto::EqualsProto / Partially(EqualsProto(...)) - never imperative setters or
serialized-string comparison. See the Protocol Buffers section.
Shell / binary-level tests
- The MBO Works convention is mboworks/bashtest
(bazel_dep(name = "mboworks_bashtest", repo_name = "com_mboworks_bashtest"), whose macro
emits@com_mboworks_bashtestlabels), not a hand-rolledsh_test:
load("@com_mboworks_bashtest//bashtest:bashtest.bzl", "bashtest"), then a script that
sources"${mboworks_bashtest}", definestest::name()functions usingexpect_eq/
expect_contains/expect_not_contains, and ends withtest_runner.- It runs under macOS bash 3.2: no
mapfile/readarrayor other bash-4 features.
Read lines withwhile IFS= read -r line; do arr+=("$line"); done <<< "$out".
- It runs under macOS bash 3.2: no
- carve does not currently depend on bashtest and has no shell-level tests. Binary-level
behavior is covered by a C++cc_testend-to-end harness (carve/e2e) that drives the
carvebinary against synthetic workspaces, and by Starlark ruleanalysis_tests
(rules/carve_test.bzl). Reach for bashtest only if a genuinely shell-level test is needed.