Skip to content

build!: raise the standard to C++23, and spend it - #826

Merged
andiwand merged 6 commits into
mainfrom
feat/cpp23
Sep 6, 2026
Merged

build!: raise the standard to C++23, and spend it#826
andiwand merged 6 commits into
mainfrom
feat/cpp23

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Raises the standard to C++23 and spends it on the three things this codebase
had waiting for it. Five commits, each reviewable on its own.

Rebased on main, now that #827 (the parts that needed no C++23) and #828 (the
locale fix, which brought fmt in) have landed. Everything left here genuinely
requires the standard.

The bump costs nothing

The bump commit is build configuration and documentation only — no source change
at all. The two transitive includes libc++'s C++23 headers stopped handing out
went in with #827.

odr + odr_test + translate, RelWithDebInfo 0 warnings
the same with -Werror, as build_test.yml runs it clean
odr_test 1443 passed, 6 skipped, 0 failed — reference html byte for byte
clang-tidy on every changed file no new finding; each one also fires on main, checked file by file
every changed source, compiled by NDK 28.1 to an object file clean
odr built at CMAKE_OSX_DEPLOYMENT_TARGET=12.0 clean — fmt does not switch to std::to_chars at C++23, 0 undefined symbols
conan install -s compiler.cppstd=23 one dependency rebuilds (gtest); every other package ID is unchanged

MSVC is the caveat: it has no /std:c++23, and CMake maps CXX_STANDARD 23
to /std:c++latest. The windows-2022-msvc-1940 job moves onto C++26-draft mode.

Three ceilings sit under the standard

None is visible from a local build, so AGENTS.md records all three.

  1. The library half stops at emsdk 3.1.73's libc++ 18.1 — the oldest in the
    profile matrix, and the newest emsdk conan-center packages, so not a pin that
    can be bumped. No views::enumerate, std::generator,
    std::move_only_function, std::flat_map. std::mdspan is out from the
    other side, since libstdc++ has none.
  2. std::format is unusable, on every slice. The apple profiles deploy to
    macOS 12 / iOS 15 (apple.jinja), and libc++ annotates the floating-point
    std::to_chars that <format> formats through as introduced in macOS 13.3.
    <format> instantiates that formatter whatever the argument types are, so
    even std::format("{}:{}", host, port) fails — and one call anywhere in
    src/ breaks the framework build. A host-default compile passes and proves
    nothing; -mmacosx-version-min=12.0 is the check.
  3. Deducing this is fine on accessors, not on a capturing recursive lambda.
    NDK 28.1's clang 19 segfaults on [&](this auto self, …) that recurses. Only
    codegen shows it — -fsyntax-only passes.

CI found 2 and 3 on the first push of this branch, after a local run that had
tested with -fsyntax-only and no deployment target. Both are now in AGENTS.md
with the command that reproduces them.

What it is spent on

Deducing this — 42 accessor pairs. #823 had just centralised the element
registry, and SideTable / SortedSideTable wrote the const workaround out by
hand: two public overloads delegating to a static helper templated on the
object, under a comment naming the trick. Every engine then repeated the pair
for each payload accessor. Both go, and a registry accessor is now

[[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) {
  return self.m_texts.at(id);
}

pdf's Array and Dictionary lose eleven more. −238/+118 lines.
ooxml_text_list's Y-combinator stays, for ceiling 3 above, and says so.

resize_and_overwrite — 12.3 → 16.7 GB/s on byte_stream::read_u8s, the
chunked read every binary engine goes through, measured against an in-memory
stream (which is what a zip entry is). resize zero-fills a tail the very next
read overwrites. The callback must not throw, so a short read shrinks the string
back and the throw happens at the call site. xls_io's string body is left
alone — its read_bytes throws from inside, and a non-throwing path just for it
is not worth the buffer it saves.

ranges::to for the two loops that did nothing but map a range into a vector
fed straight into one call.

The public headers stay C++20

Nothing propagates the standard — no target_compile_features(odr PUBLIC …), no
cppstd in package_info — so a consumer keeps whatever standard it picked, as
long as src/odr/*.hpp stays C++20. Nothing checked that, and a this auto &self
in a public header would have broken someone else's build rather than ours, so
the last commit adds odr_public_headers_cpp20: all seventeen public headers in
one TU at cxx_std_20. One object file, no test data, no link, builds by
default. It was verified to fail on a deducing-this construct and pass again
without it.

conanfile.py's check_min_cppstd moves to 23 but sits in validate_build,
which constrains building odr rather than using it. That is the one
consumer-visible change, and it has a CHANGELOG entry.

Not done, on purpose

  • std::format — ceiling 2, and off the table until the apple deployment
    targets reach macOS 13.3 / iOS 16.3. fix(html): keep the host's locale out of the css we write #828 already took the locale-safety it
    would have bought, through fmt. String building stays + / std::to_string;
    fmt is no faster at those lengths.
  • std::unreachable — the switch defaults throw by design (AGENTS.md Fail
    fast
    ); swapping a throw for UB is backwards.
  • std::to_underlying — there is no static_cast<…>(std::underlying_type_t<…>)
    anywhere.
  • std::byteswaputil::byte::{from,to}_{little,big}_endian is already
    byte-wise and portable, which is what keeps the big-endian gap tractable.
  • Multidimensional operator[]Sheet::cell(column, row) would read as
    sheet[column, row], but it is public API and a C++23-only spelling would push
    the standard onto consumers.

@andiwand
andiwand force-pushed the feat/cpp23 branch 2 times, most recently from a93fab2 to aee7ad5 Compare September 6, 2026 09:22
@andiwand
andiwand changed the base branch from main to chore/include-hygiene September 6, 2026 10:13
Base automatically changed from chore/include-hygiene to main September 6, 2026 10:16
andiwand and others added 6 commits September 6, 2026 14:39
The whole matrix already supports it: clang 18, gcc 14, apple-clang, MSVC 19.40,
NDK 28.1 and emsdk 3.1.73. It costs no source change at all — the two transitive
includes libc++'s C++23 headers stopped handing out went in with #827, which is
the whole of it.

Three ceilings sit under the standard, and AGENTS.md records all three because
none is discoverable from a local build. The library half is capped by emsdk
3.1.73's libc++ 18.1, the oldest here and the newest emsdk conan-center
packages. `std::format` is unusable on any slice — the apple profiles deploy to
macOS 12 / iOS 15, and libc++ marks the floating-point `to_chars` that `<format>`
instantiates as macOS 13.3 / iOS 16.3, which is why #828 formats through `fmt`.
And NDK 28.1's clang 19 segfaults on a capturing recursive lambda taking
`this auto self`.

The public headers stay C++20 — no `target_compile_features(odr PUBLIC …)` and
no `cppstd` in `package_info`, so a consumer picks its own standard, and
`check_min_cppstd` sits in `validate_build` where it constrains building `odr`
rather than using it.

On MSVC there is no `/std:c++23`; CMake maps `CXX_STANDARD 23` to
`/std:c++latest`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
Forty-two accessors were the same body twice, once for each constness, with
only the spelled-out return type telling the two apart. An explicit object
parameter deduces that, so each pair is one function returning `T &` or
`const T &` from a single body.

The shared registry (#823) is where it pays most: `SideTable` and
`SortedSideTable` had written the workaround out by hand — two public overloads
delegating to a `static` helper templated on the object, under a comment naming
the trick — and every engine then repeated the pair for each of its payload
accessors. Both go, and a registry's accessor is

    [[nodiscard]] auto &text_element_at(this auto &self,
                                        const ElementIdentifier id) {
      return self.m_texts.at(id);
    }

`pdf`'s `Array` and `Dictionary` lose eleven more.

`ooxml_text_list`'s numbering walk would have dropped its Y-combinator with
them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses
through an explicit object parameter, so it keeps passing itself along and says
why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
`resize` fills the new tail with zeros; every one of these then writes over all
of it. `resize_and_overwrite` hands the chunk over unwritten instead — 12.3 GB/s
to 16.7 GB/s on `read_u8s` against an in-memory stream, which is what a zip
entry is.

The callback must not throw, so a short read shrinks the string back to the
offset it started from and the throw happens at the call site. `ppt`'s
`read_raw_text_bytes` loses its second `resize` with it: the callback returns
`gcount()` and the string is already the right length.

`xls_io`'s string body is left alone — its `read_bytes` throws from inside, and
a non-throwing path just for this is not worth the buffer it saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
Both loops did nothing but map a range onto a vector, and both fed it straight
into one call. `views::transform | ranges::to<std::vector<std::string>>()` says
that in the expression that uses it, so neither needs a named variable any more.

`type1_charstring` takes the iterator-pair `std::reverse` next to it with them,
per the ranges convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
The bump made `odr` C++23 and deliberately did not pass that on — no
`target_compile_features(odr PUBLIC …)`, no `cppstd` in the conan
`package_info` — so a consumer keeps whatever standard it picked, as long as
`src/odr/*.hpp` stays C++20. Nothing checked that, and a `this auto &self` in a
public header would have broken someone else's build rather than ours.

`odr_public_headers_cpp20` includes all seventeen of them and compiles at
C++20. One object file, no test data, no link, no gtest — it either compiles or
it does not. It lives in `test/CMakeLists.txt` and so builds under `ODR_TEST`,
which is also what keeps it out of the conan package: `exports_sources` ships
no `test/`, and an unconditional target naming a file that is not there fails
the package build at generate time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
The C++23-does-not-propagate rationale now lives in AGENTS.md alone; the
test file and its cmake target state what they are and stop there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QiYawTVZyuMLQBQngdTeu
@andiwand
andiwand merged commit 98ee778 into main Sep 6, 2026
25 checks passed
@andiwand
andiwand deleted the feat/cpp23 branch September 6, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant