From 99426cec29ada5314b021f25350a972e9730c926 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:27:53 +0700 Subject: [PATCH] Write the published surface down, so that moving it is a diff The scorecard's stability item asks for the tool that tells a reviewer the public surface moved, before a user finds out. C++ has no such tool in the toolchain. A compiler is perfectly happy to see a method deleted, a parameter added or a noexcept dropped, and the person who finds out is whoever upgrades and rebuilds. api/surface.txt is every name include/zu.hpp publishes and the shape it publishes it in, 466 lines of it, generated by docs/surface.py out of the same Doxygen run as the reference and reviewed like any other file. The gate rebuilds it from the header and fails when the two disagree. It says what went, what arrived and what changed shape separately, because those are three different pieces of news: a name that arrived is a minor release and a name that went is not. Identity is what decides which overload a call picks, so the name, the parameter types and the const or ref qualifier. Everything else on a line is shape: the return type, noexcept, = delete, the default arguments. A parameter renamed is not a change at all, because there is no call that names one. That is a departure from zu-go's file, which keys on the name alone, and the reason is overloads: to_string is ten functions here and a key that could not tell them apart would report nine of them as gone the day the tenth moved. Generating it found that the reference had been missing the whole expected mirror. EXPAND_ONLY_PREDEF means a #define Doxygen read in the file is not a value it will evaluate an #if against, so every `#if ZU_HAS_EXPECTED` block was skipped and ninety two try_ members were published nowhere. They were warned about nowhere either, because a member the preprocessor threw away cannot be undocumented, which is the same vacuous shape as the include guard that emptied the reference before it. Both feature macros are predefined now and Connection alone goes from 28 published members to 47. Two comments in the formatter section were being read for the first time and needed \ and \ escaped. The std::formatter specializations are read out of the header rather than the XML, because the Doxyfile expands ZU_FORMATTER to nothing on purpose and std::format("{}", status) is still a call a user writes. docs/test_surface.py is the twenty two cases the gate rests on, run by ctest as surface-cases, on XML written out by hand rather than on Doxygen. Getting one of those judgements wrong does not make the gate fail, it makes it pass, or makes it report a renamed parameter as forty names leaving and forty arriving, which is a gate a reviewer learns to skip and comes to the same thing. The release publishes surface.txt beside the reference. Two of them from two releases diff into the answer somebody upgrading is actually asking for, which no amount of generated HTML will give them. 44 of 44 green on Linux, which is the 41 that were there plus reference, surface and surface-cases. The two failures worth having were exercised rather than reasoned about: with the include guard back in PREDEFINED the surface tool reports that nothing at all came out of the XML and names the cause, and with the identity rule widened to the whole line four of the twenty two cases fail. --- .github/workflows/ci.yml | 12 + .github/workflows/release.yml | 22 +- README.md | 4 +- api/surface.txt | 466 ++++++++++++++++++++++++++++++ docs/CMakeLists.txt | 47 +++- docs/Doxyfile.in | 26 +- docs/surface.py | 515 ++++++++++++++++++++++++++++++++++ docs/test_surface.py | 394 ++++++++++++++++++++++++++ include/zu.hpp | 4 +- 9 files changed, 1478 insertions(+), 12 deletions(-) create mode 100644 api/surface.txt create mode 100644 docs/surface.py create mode 100644 docs/test_surface.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 874f147..9ad9cc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,6 +299,18 @@ jobs: - name: Every type the header declares has a page and no member is bare run: ctest --test-dir build -R reference --output-on-failure + # The rules the surface gate rests on, before the gate that rests + # on them. These need neither Doxygen nor the header, and they run + # first because a wrong rule makes the gate below pass rather than + # fail, which is the failure that would not be reported. + - name: What identifies a declaration, and what is only its shape + run: ctest --test-dir build -R surface-cases --output-on-failure + + # api/surface.txt is a reviewed file. This is what tells a + # reviewer it should have been in the diff. + - name: The published surface is still what api/surface.txt says + run: ctest --test-dir build -R '^surface$' --output-on-failure + # So that a reviewer can read the page a change to a comment # produced rather than take the diff's word for it. - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6be37d0..e78014f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,23 +39,41 @@ jobs: - name: Every type the header declares has a page and no member is bare run: ctest --test-dir build -R reference --output-on-failure + # And the surface, which at a tag is not a review aid any more. A + # release whose api/surface.txt is out of date is a release whose + # published names are not the ones anybody agreed to, and the tag + # is the last moment that can still be found out cheaply. + - name: The published surface is still what api/surface.txt says + run: ctest --test-dir build -R 'surface' --output-on-failure + - name: Pack it run: | set -eu tar -czf "zu-cpp-reference-${GITHUB_REF_NAME}.tar.gz" \ -C build/docs/reference html + cp api/surface.txt "zu-cpp-surface-${GITHUB_REF_NAME}.txt" - uses: actions/upload-artifact@v4 with: name: reference - path: zu-cpp-reference-*.tar.gz + path: | + zu-cpp-reference-*.tar.gz + zu-cpp-surface-*.txt if-no-files-found: error # Only on a tag. A manual run builds the reference and leaves the # artifact, which is what somebody checking this file wants, and # does not attach anything to a release that is not there. + # + # The surface goes up beside the reference and not only inside it. + # It is one text file, and two of them from two releases diff into + # the answer to the question somebody upgrading is actually + # asking, which no amount of generated HTML will give them. - name: Attach it to the release if: startsWith(github.ref, 'refs/tags/') - run: gh release upload "$GITHUB_REF_NAME" "zu-cpp-reference-${GITHUB_REF_NAME}.tar.gz" --clobber + run: | + gh release upload "$GITHUB_REF_NAME" \ + "zu-cpp-reference-${GITHUB_REF_NAME}.tar.gz" \ + "zu-cpp-surface-${GITHUB_REF_NAME}.txt" --clobber env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 4dc5ca3..40b9450 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ lynn - `cmake/`, `find_package(Zu)` to find the engine and `find_package(zu-cpp)` to find this. vcpkg, Conan and pkg-config packaging come with the first release. - `docs/`, the API reference, generated from `include/zu.hpp` and published with the release rather than checked in beside the source. `docs/reference.py` is the part worth reading: Doxygen exits 0 on a header it extracted nothing from, so the check counts the types the header declares and fails when the reference does not have them, which is what an empty reference looks like from the outside. +- `api/surface.txt`, every name `include/zu.hpp` publishes and the shape it publishes it in, one per line. It is generated by `docs/surface.py` out of the same Doxygen run as the reference, and a change to the header that moves it fails CI until the file is regenerated, so moving the public surface is a diff a reviewer reads rather than something a user finds out about after upgrading. The check tells apart what went, what arrived and what changed shape, because a name that arrived is a minor release and a name that went is not. + - `scripts/install.sh`, the install a person with nothing on their machine gets, run nightly in a container holding a compiler and nothing else. It installs the wrapper, takes both programs off this page, builds them against the install and diffs what they print against the blocks under them. It is run again three times with a piece taken out of the install, because a build that still works without the header is a build that found one somewhere else. Four sanitizer jobs run over the suite, because an ABI nine languages depend on should fail loudly rather than corrupt quietly. The whole tree runs under ASan and UBSan; `test/misuse.c` runs again with leak detection on, which it can and the C++ files cannot, because it is the file that gives every handle back by hand; `test/threads.c` runs under TSan; and both C files run under valgrind, which sees what the sanitizers cannot, since libzu is compiled without instrumentation and memcheck does not need any. `test/tsan.supp` records what TSan is unable to be told about a library that takes no pthread lock, and why the reports from inside the engine are dropped rather than read. @@ -131,7 +133,7 @@ cmake -B build -DZU_CPP_DOCS=ON cmake --build build --target docs ``` -No engine is needed for that one either. +No engine is needed for that one either. The same option adds the surface gate, and `cmake --build build --target surface-update` rewrites `api/surface.txt` when the header was meant to move. The wrapper is header-only, so a project that would rather not use CMake needs the include path and nothing else. diff --git a/api/surface.txt b/api/surface.txt new file mode 100644 index 0000000..c457ab1 --- /dev/null +++ b/api/surface.txt @@ -0,0 +1,466 @@ +define ZU_HAS_EXPECTED +define ZU_HAS_FORMAT +formatter std::formatter< zu::Error > +formatter std::formatter< zu::Node > +formatter std::formatter< zu::Position > +formatter std::formatter< zu::Rel > +formatter std::formatter< zu::Severity > +formatter std::formatter< zu::Status > +formatter std::formatter< zu::Temporal > +formatter std::formatter< zu::TemporalKind > +formatter std::formatter< zu::Type > +formatter std::formatter< zu::Value > +class zu::Appender +func explicit zu::Appender::Appender(zu_appender *) noexcept +func zu::Appender::Appender() = default +func zu::Appender::append(Temporal) -> Appender & +func zu::Appender::append(bool) -> Appender & +func zu::Appender::append(const char *) -> Appender & +func zu::Appender::append(double) -> Appender & +func zu::Appender::append(int) -> Appender & +func zu::Appender::append(std::int64_t) -> Appender & +func zu::Appender::append(std::span< const std::uint8_t >) -> Appender & +func zu::Appender::append(std::string_view) -> Appender & +func zu::Appender::buffered() const -> std::uint64_t +func zu::Appender::close() -> std::uint64_t +func zu::Appender::col_name(std::uint32_t) const -> std::string_view +func zu::Appender::cols() const -> std::uint32_t +func zu::Appender::committed() const -> std::uint64_t +func zu::Appender::discard() -> std::uint64_t +func zu::Appender::end_row() -> Appender & +func zu::Appender::flush() -> void +func explicit zu::Appender::operator bool() const noexcept +func zu::Appender::raw() const noexcept -> zu_appender * +func zu::Appender::row(const Ts &...) -> Appender & +func zu::Appender::try_append(Temporal) -> expected< void > +func zu::Appender::try_append(bool) -> expected< void > +func zu::Appender::try_append(double) -> expected< void > +func zu::Appender::try_append(std::int64_t) -> expected< void > +func zu::Appender::try_append(std::span< const std::uint8_t >) -> expected< void > +func zu::Appender::try_append(std::string_view) -> expected< void > +func zu::Appender::try_close() -> expected< std::uint64_t > +func zu::Appender::try_col_name(std::uint32_t) const -> expected< std::string_view > +func zu::Appender::try_discard() -> expected< std::uint64_t > +func zu::Appender::try_end_row() -> expected< void > +func zu::Appender::try_flush() -> expected< void > +class zu::ClosedError : public zu::ProgrammingError +class zu::ConcurrentError : public zu::ProgrammingError +class zu::Config +func zu::Config::Config() +func zu::Config::memory_limit(std::size_t) -> Config & +func zu::Config::raw() const noexcept -> const zu_config * +func zu::Config::read_only(bool = true) -> Config & +func zu::Config::set(std::string_view, std::string_view) -> Config & +func zu::Config::threads(std::size_t) -> Config & +func zu::Config::try_set(std::string_view, std::string_view) -> expected< void > +class zu::Connection +func explicit zu::Connection::Connection(zu_conn *) noexcept +func zu::Connection::Connection() = default +func zu::Connection::Connection(Connection &&) noexcept = default +using zu::Connection::Progress -> std::function< bool(std::uint64_t rows, std::chrono::milliseconds elapsed)> +func zu::Connection::appender(std::string_view) -> Appender +func zu::Connection::begin(bool = false) -> void +func zu::Connection::clear_progress() -> void +func zu::Connection::commit() -> void +func static zu::Connection::create(std::string_view) -> Connection +func zu::Connection::duplicate() -> Connection +func zu::Connection::in_transaction() const -> bool +func zu::Connection::interrupt() -> void +func static zu::Connection::memory() -> Connection +func zu::Connection::on_progress(std::chrono::milliseconds, Progress) -> void +func static zu::Connection::open(std::string_view) -> Connection +func explicit zu::Connection::operator bool() const noexcept +func zu::Connection::operator=(Connection &&) noexcept -> Connection & +func zu::Connection::prepare(std::string_view) -> Statement +func zu::Connection::query(std::string_view) -> Result +func zu::Connection::raw() const noexcept -> zu_conn * +func zu::Connection::register_frame(Frame &) -> void +func zu::Connection::registered() const -> std::vector< std::string > +func zu::Connection::rollback() -> void +func zu::Connection::rows_read() const -> std::uint64_t +func zu::Connection::table_name(std::uint32_t) const -> std::optional< std::string > +func zu::Connection::transaction(bool = false) -> Transaction +func zu::Connection::try_appender(std::string_view) -> expected< Appender > +func zu::Connection::try_begin(bool = false) -> expected< void > +func zu::Connection::try_commit() -> expected< void > +func static zu::Connection::try_create(std::string_view) -> expected< Connection > +func zu::Connection::try_duplicate() -> expected< Connection > +func zu::Connection::try_in_transaction() const -> expected< bool > +func zu::Connection::try_interrupt() -> expected< void > +func static zu::Connection::try_memory() -> expected< Connection > +func zu::Connection::try_on_progress(std::chrono::milliseconds, Progress) -> expected< void > +func static zu::Connection::try_open(std::string_view) -> expected< Connection > +func zu::Connection::try_prepare(std::string_view) -> expected< Statement > +func zu::Connection::try_query(std::string_view) -> expected< Result > +func zu::Connection::try_register_frame(Frame &) -> expected< void > +func zu::Connection::try_registered() const -> expected< std::vector< std::string > > +func zu::Connection::try_rollback() -> expected< void > +func zu::Connection::try_rows_read() const -> expected< std::uint64_t > +func zu::Connection::try_table_name(std::uint32_t) const -> expected< std::optional< std::string > > +func zu::Connection::try_transaction(bool = false) -> expected< Transaction > +func zu::Connection::try_unregister_frame(std::string_view) -> expected< bool > +func zu::Connection::unregister_frame(std::string_view) -> bool +func zu::Connection::~Connection() +class zu::ConnectionError : public zu::Exception +func explicit zu::ConnectionError::Exception(Error) +class zu::DataError : public zu::Exception +func explicit zu::DataError::Exception(Error) +class zu::Database +func explicit zu::Database::Database(zu_database *) noexcept +func zu::Database::Database() = default +func zu::Database::connect() const -> Connection +func static zu::Database::create(std::string_view, const Config & = Config{}) -> Database +func zu::Database::is_memory() const -> bool +func static zu::Database::memory(const Config & = Config{}) -> Database +func static zu::Database::open(std::string_view, const Config & = Config{}) -> Database +func explicit zu::Database::operator bool() const noexcept +func zu::Database::path() const -> std::string_view +func zu::Database::raw() const noexcept -> zu_database * +func zu::Database::try_connect() const -> expected< Connection > +func static zu::Database::try_create(std::string_view, const Config & = Config{}) -> expected< Database > +func static zu::Database::try_memory(const Config & = Config{}) -> expected< Database > +func static zu::Database::try_open(std::string_view, const Config & = Config{}) -> expected< Database > +func zu::Database::try_path() const -> expected< std::string_view > +class zu::Error +func zu::Error::Error() = default +func zu::Error::code() const noexcept -> std::optional< std::string_view > +func zu::Error::condition() const noexcept -> std::optional< std::string_view > +func zu::Error::doc_url() const noexcept -> std::optional< std::string_view > +func zu::Error::excerpt() const noexcept -> std::optional< std::string_view > +func zu::Error::graph() const noexcept -> std::optional< std::string_view > +func zu::Error::message() const noexcept -> std::string_view +func zu::Error::position() const noexcept -> std::optional< Position > +func zu::Error::report() const -> std::string +func zu::Error::retryable() const noexcept -> bool +func zu::Error::schema() const noexcept -> std::optional< std::string_view > +func zu::Error::severity() const noexcept -> Severity +func zu::Error::status() const noexcept -> Status +func zu::Error::subject() const noexcept -> std::optional< std::string_view > +func zu::Error::subject_kind() const noexcept -> std::optional< std::string_view > +func static zu::Error::take(Status, zu_error *, std::string_view = {}) -> Error +class zu::Exception : public std::runtime_error +func explicit zu::Exception::Exception(Error) +func zu::Exception::code() const noexcept -> std::optional< std::string_view > +func zu::Exception::error() const noexcept -> const Error & +func zu::Exception::retryable() const noexcept -> bool +func zu::Exception::status() const noexcept -> Status +class zu::Frame +func explicit zu::Frame::Frame(zu_frame *) noexcept +func zu::Frame::Frame() = default +func zu::Frame::bools(std::string_view, std::span< const std::uint8_t >, std::uint64_t) -> Frame & +func zu::Frame::column(std::string_view, const R &, std::int64_t = 1, TemporalKind = TemporalKind::plain) -> Frame & +func static zu::Frame::create(std::string_view, std::uint64_t) -> Frame +func static zu::Frame::create(std::string_view, std::uint64_t, std::function< void()>) -> Frame +func static zu::Frame::create(std::string_view, std::uint64_t, std::shared_ptr< void >) -> Frame +func explicit zu::Frame::operator bool() const noexcept +func zu::Frame::raw() const noexcept -> zu_frame * +func zu::Frame::strings(std::string_view, std::span< const std::int32_t >, std::span< const char >) -> Frame & +func zu::Frame::strings(std::string_view, std::span< const std::int64_t >, std::span< const char >) -> Frame & +func zu::Frame::try_bools(std::string_view, std::span< const std::uint8_t >, std::uint64_t) -> expected< void > +func zu::Frame::try_column(std::string_view, const R &, std::int64_t = 1, TemporalKind = TemporalKind::plain) -> expected< void > +func static zu::Frame::try_create(std::string_view, std::uint64_t, std::function< void()> = {}) -> expected< Frame > +func zu::Frame::try_strings(std::string_view, std::span< const std::int32_t >, std::span< const char >) -> expected< void > +func zu::Frame::views(std::string_view, std::span< const std::byte >, std::span< const void *const >, std::span< const std::size_t >, std::uint64_t) -> Frame & +class zu::InternalError : public zu::Exception +func explicit zu::InternalError::Exception(Error) +class zu::InterruptedError : public zu::Exception +func explicit zu::InterruptedError::Exception(Error) +class zu::Loader +func explicit zu::Loader::Loader(zu_loader *) noexcept +func zu::Loader::Loader() = default +func zu::Loader::bools(std::string_view, std::span< const std::int32_t >) -> Loader & +func static zu::Loader::create(std::string_view) -> Loader +func zu::Loader::doubles(std::string_view, std::span< const double >) -> Loader & +func zu::Loader::edges(std::span< const std::uint32_t >, std::span< const std::uint32_t >) -> Loader & +func zu::Loader::finish() -> void +func zu::Loader::ints(std::string_view, std::span< const std::int64_t >) -> Loader & +func explicit zu::Loader::operator bool() const noexcept +func zu::Loader::raw() const noexcept -> zu_loader * +func zu::Loader::strings(std::string_view, const R &) -> Loader & +func zu::Loader::table(std::string_view, std::string_view, std::uint64_t) -> Loader & +func zu::Loader::temporals(std::string_view, TemporalKind, std::span< const std::int64_t >) -> Loader & +func zu::Loader::try_bools(std::string_view, std::span< const std::int32_t >) -> expected< void > +func static zu::Loader::try_create(std::string_view) -> expected< Loader > +func zu::Loader::try_doubles(std::string_view, std::span< const double >) -> expected< void > +func zu::Loader::try_edges(std::span< const std::uint32_t >, std::span< const std::uint32_t >) -> expected< void > +func zu::Loader::try_finish() -> expected< void > +func zu::Loader::try_ints(std::string_view, std::span< const std::int64_t >) -> expected< void > +func zu::Loader::try_strings(std::string_view, const R &) -> expected< void > +func zu::Loader::try_table(std::string_view, std::string_view, std::uint64_t) -> expected< void > +func zu::Loader::try_temporals(std::string_view, TemporalKind, std::span< const std::int64_t >) -> expected< void > +struct zu::Node +var zu::Node::offset -> std::uint64_t +friend zu::Node::operator==(const Node &, const Node &) = default -> bool +var zu::Node::table -> std::uint32_t +struct zu::Position +var zu::Position::column -> std::uint32_t +var zu::Position::line -> std::uint32_t +var zu::Position::offset -> std::uint32_t +friend zu::Position::operator==(const Position &, const Position &) = default -> bool +class zu::ProgrammingError : public zu::Exception +func explicit zu::ProgrammingError::Exception(Error) +struct zu::Rel +var zu::Rel::dst -> std::uint64_t +friend zu::Rel::operator==(const Rel &, const Rel &) = default -> bool +var zu::Rel::src -> std::uint64_t +var zu::Rel::table -> std::uint32_t +class zu::Result +struct zu::Result::ChunkExtent +var zu::Result::ChunkExtent::offset -> std::uint64_t +var zu::Result::ChunkExtent::rows -> std::uint64_t +func explicit zu::Result::Result(zu_result *) noexcept +func zu::Result::Result() = default +func zu::Result::begin() const noexcept -> iterator +func zu::Result::cell(std::uint64_t, std::uint32_t) const -> Value +func zu::Result::chunk(std::uint64_t) const -> ChunkExtent +func zu::Result::chunk_count() const noexcept -> std::uint64_t +func zu::Result::chunk_doubles(std::uint64_t, std::uint32_t) const -> std::span< const double > +func zu::Result::chunk_ints(std::uint64_t, std::uint32_t) const -> std::span< const std::int64_t > +func zu::Result::chunk_node_offsets(std::uint64_t, std::uint32_t) const -> std::span< const std::uint64_t > +func zu::Result::chunk_valid(std::uint64_t, std::uint32_t) const -> std::span< const std::uint8_t > +func zu::Result::cols() const noexcept -> std::uint32_t +func zu::Result::column(std::string_view) const -> std::uint32_t +func zu::Result::doubles(std::string_view) const -> std::span< const double > +func zu::Result::doubles(std::uint32_t) const -> std::span< const double > +func zu::Result::empty() const noexcept -> bool +func zu::Result::end() const noexcept -> iterator +func zu::Result::find(std::string_view) const -> std::optional< std::uint32_t > +func zu::Result::gqlstatus() const -> std::string_view +func zu::Result::ints(std::string_view) const -> std::span< const std::int64_t > +func zu::Result::ints(std::uint32_t) const -> std::span< const std::int64_t > +class zu::Result::iterator +using zu::Result::iterator::difference_type -> std::ptrdiff_t +func zu::Result::iterator::iterator() = default +func zu::Result::iterator::iterator(const Result *, std::uint64_t) noexcept +using zu::Result::iterator::iterator_category -> std::input_iterator_tag +using zu::Result::iterator::iterator_concept -> std::random_access_iterator_tag +func zu::Result::iterator::operator*() const noexcept -> Row +friend zu::Result::iterator::operator+(difference_type, iterator) noexcept -> iterator +friend zu::Result::iterator::operator+(iterator, difference_type) noexcept -> iterator +func zu::Result::iterator::operator++() noexcept -> iterator & +func zu::Result::iterator::operator++(int) noexcept -> iterator +func zu::Result::iterator::operator+=(difference_type) noexcept -> iterator & +friend zu::Result::iterator::operator-(const iterator &, const iterator &) noexcept -> difference_type +friend zu::Result::iterator::operator-(iterator, difference_type) noexcept -> iterator +func zu::Result::iterator::operator--() noexcept -> iterator & +func zu::Result::iterator::operator--(int) noexcept -> iterator +func zu::Result::iterator::operator-=(difference_type) noexcept -> iterator & +friend zu::Result::iterator::operator<=>(const iterator &, const iterator &) noexcept -> std::strong_ordering +friend zu::Result::iterator::operator==(const iterator &, const iterator &) noexcept -> bool +func zu::Result::iterator::operator[](difference_type) const noexcept -> Row +using zu::Result::iterator::reference -> Row +using zu::Result::iterator::value_type -> Row +func zu::Result::name(std::uint32_t) const -> std::string_view +func zu::Result::names() const -> const std::vector< std::string_view > & +func zu::Result::node_offsets(std::string_view) const -> std::span< const std::uint64_t > +func zu::Result::node_offsets(std::uint32_t) const -> std::span< const std::uint64_t > +func zu::Result::notice_count() const -> std::uint32_t +func zu::Result::notices() const -> std::vector< Error > +func explicit zu::Result::operator bool() const noexcept +func zu::Result::raw() const noexcept -> zu_result * +func zu::Result::row(std::uint64_t) const noexcept -> Row +func zu::Result::rows() const noexcept -> std::uint64_t +func zu::Result::size() const noexcept -> std::uint64_t +func zu::Result::str(std::uint64_t, std::uint32_t) const -> std::string_view +func zu::Result::to_arrow(ArrowArrayStream *, std::uint64_t = 0) && -> void +func zu::Result::to_arrow(Connection &, ArrowArrayStream *, std::uint64_t = 0) && -> void +func zu::Result::try_cell(std::uint64_t, std::uint32_t) const -> expected< Value > +func zu::Result::try_chunk(std::uint64_t) const -> expected< ChunkExtent > +func zu::Result::try_chunk_doubles(std::uint64_t, std::uint32_t) const -> expected< std::span< const double > > +func zu::Result::try_chunk_ints(std::uint64_t, std::uint32_t) const -> expected< std::span< const std::int64_t > > +func zu::Result::try_chunk_node_offsets(std::uint64_t, std::uint32_t) const -> expected< std::span< const std::uint64_t > > +func zu::Result::try_chunk_valid(std::uint64_t, std::uint32_t) const -> expected< std::span< const std::uint8_t > > +func zu::Result::try_column(std::string_view) const -> expected< std::uint32_t > +func zu::Result::try_doubles(std::uint32_t) const -> expected< std::span< const double > > +func zu::Result::try_ints(std::uint32_t) const -> expected< std::span< const std::int64_t > > +func zu::Result::try_name(std::uint32_t) const -> expected< std::string_view > +func zu::Result::try_node_offsets(std::uint32_t) const -> expected< std::span< const std::uint64_t > > +func zu::Result::try_str(std::uint64_t, std::uint32_t) const -> expected< std::string_view > +func zu::Result::try_to_arrow(Connection &, ArrowArrayStream *, std::uint64_t = 0) && -> expected< void > +func zu::Result::try_type(std::uint64_t, std::uint32_t) const -> expected< Type > +func zu::Result::try_valid(std::uint32_t) const -> expected< std::span< const std::uint8_t > > +func zu::Result::type(std::uint64_t, std::uint32_t) const -> Type +func zu::Result::valid(std::string_view) const -> std::span< const std::uint8_t > +func zu::Result::valid(std::uint32_t) const -> std::span< const std::uint8_t > +class zu::Row +func zu::Row::Row() = default +func zu::Row::Row(const Result *, std::uint64_t) noexcept +func zu::Row::get(std::string_view) const -> T +func zu::Row::get(std::uint32_t) const -> T +func zu::Row::index() const noexcept -> std::uint64_t +func zu::Row::is_null(std::string_view) const -> bool +func zu::Row::is_null(std::uint32_t) const -> bool +func zu::Row::operator[](std::string_view) const -> Value +func zu::Row::operator[](std::uint32_t) const -> Value +func zu::Row::size() const noexcept -> std::uint32_t +func zu::Row::type(std::string_view) const -> Type +func zu::Row::type(std::uint32_t) const -> Type +enum zu::Severity -> int +enum value zu::Severity::exception -> ZU_SEVERITY_EXCEPTION +enum value zu::Severity::informational -> ZU_SEVERITY_INFORMATIONAL +enum value zu::Severity::no_data -> ZU_SEVERITY_NO_DATA +enum value zu::Severity::success -> ZU_SEVERITY_SUCCESS +enum value zu::Severity::warning -> ZU_SEVERITY_WARNING +class zu::Statement +func explicit zu::Statement::Statement(zu_stmt *) noexcept +func zu::Statement::Statement() = default +func zu::Statement::bind(std::string_view, Temporal) -> Statement & +func zu::Statement::bind(std::string_view, bool) -> Statement & +func zu::Statement::bind(std::string_view, const char *) -> Statement & +func zu::Statement::bind(std::string_view, double) -> Statement & +func zu::Statement::bind(std::string_view, int) -> Statement & +func zu::Statement::bind(std::string_view, std::int64_t) -> Statement & +func zu::Statement::bind(std::string_view, std::nullptr_t) -> Statement & +func zu::Statement::bind(std::string_view, std::string_view) -> Statement & +func zu::Statement::bind(std::string_view, const std::optional< T > &) -> Statement & +func zu::Statement::bind_null(std::string_view) -> Statement & +func zu::Statement::execute() -> Result +func explicit zu::Statement::operator bool() const noexcept +func zu::Statement::raw() const noexcept -> zu_stmt * +func zu::Statement::try_bind(std::string_view, Temporal) -> expected< void > +func zu::Statement::try_bind(std::string_view, bool) -> expected< void > +func zu::Statement::try_bind(std::string_view, double) -> expected< void > +func zu::Statement::try_bind(std::string_view, std::int64_t) -> expected< void > +func zu::Statement::try_bind(std::string_view, std::string_view) -> expected< void > +func zu::Statement::try_bind_null(std::string_view) -> expected< void > +func zu::Statement::try_execute() -> expected< Result > +enum zu::Status -> int +enum value zu::Status::conflict -> ZU_CONFLICT +enum value zu::Status::corrupt -> ZU_CORRUPT +enum value zu::Status::done -> ZU_DONE +enum value zu::Status::error -> ZU_ERROR +enum value zu::Status::interrupted -> ZU_INTERRUPTED +enum value zu::Status::io -> ZU_IO +enum value zu::Status::misuse -> ZU_MISUSE +enum value zu::Status::misuse_closed -> ZU_MISUSE_CLOSED +enum value zu::Status::misuse_concurrent -> ZU_MISUSE_CONCURRENT +enum value zu::Status::ok -> ZU_OK +enum value zu::Status::unsupported -> ZU_UNSUPPORTED +class zu::SyntaxError : public zu::Exception +func explicit zu::SyntaxError::Exception(Error) +struct zu::Temporal +func zu::Temporal::as_days() const -> std::chrono::sys_days +func zu::Temporal::as_months() const -> std::chrono::months +func zu::Temporal::as_nanos() const -> std::chrono::nanoseconds +func zu::Temporal::as_time() const -> std::chrono::sys_time< std::chrono::nanoseconds > +var zu::Temporal::count -> std::int64_t +func static zu::Temporal::date(std::chrono::sys_days) -> Temporal +func static zu::Temporal::date(std::chrono::year_month_day) -> Temporal +func zu::Temporal::east() const -> std::chrono::minutes +var zu::Temporal::kind -> TemporalKind +func static zu::Temporal::local_datetime(std::chrono::sys_time< std::chrono::nanoseconds >) -> Temporal +func static zu::Temporal::local_time(std::chrono::nanoseconds) -> Temporal +func static zu::Temporal::months(std::chrono::months) -> Temporal +func static zu::Temporal::nanos(std::chrono::nanoseconds) -> Temporal +var zu::Temporal::offset -> std::int32_t +friend zu::Temporal::operator==(const Temporal &, const Temporal &) = default -> bool +func static zu::Temporal::zoned_datetime(std::chrono::sys_time< std::chrono::nanoseconds >, std::chrono::minutes) -> Temporal +func static zu::Temporal::zoned_time(std::chrono::nanoseconds, std::chrono::minutes) -> Temporal +enum zu::TemporalKind -> int +enum value zu::TemporalKind::date -> ZU_TEMPORAL_DATE +enum value zu::TemporalKind::duration_day_time -> ZU_TEMPORAL_DURATION_DAY_TIME +enum value zu::TemporalKind::duration_year_month -> ZU_TEMPORAL_DURATION_YEAR_MONTH +enum value zu::TemporalKind::local_datetime -> ZU_TEMPORAL_LOCAL_DATETIME +enum value zu::TemporalKind::local_time -> ZU_TEMPORAL_LOCAL_TIME +enum value zu::TemporalKind::plain -> ZU_FRAME_PLAIN +enum value zu::TemporalKind::zoned_datetime -> ZU_TEMPORAL_ZONED_DATETIME +enum value zu::TemporalKind::zoned_time -> ZU_TEMPORAL_ZONED_TIME +class zu::Transaction +func explicit zu::Transaction::Transaction(Connection &) noexcept +func zu::Transaction::Transaction() = default +func zu::Transaction::Transaction(Transaction &&) noexcept +func zu::Transaction::Transaction(const Transaction &) = delete +func zu::Transaction::commit() -> void +func explicit zu::Transaction::operator bool() const noexcept +func zu::Transaction::operator=(Transaction &&) noexcept -> Transaction & +func zu::Transaction::operator=(const Transaction &) = delete -> Transaction & +func zu::Transaction::rollback() -> void +func zu::Transaction::~Transaction() +class zu::TransactionError : public zu::Exception +func explicit zu::TransactionError::Exception(Error) +enum zu::Type -> int +enum value zu::Type::binding_table -> ZU_TYPE_BINDING_TABLE +enum value zu::Type::boolean -> ZU_TYPE_BOOL +enum value zu::Type::bytes -> ZU_TYPE_BYTES +enum value zu::Type::floating -> ZU_TYPE_FLOAT +enum value zu::Type::graph -> ZU_TYPE_GRAPH +enum value zu::Type::integer -> ZU_TYPE_INT +enum value zu::Type::list -> ZU_TYPE_LIST +enum value zu::Type::node -> ZU_TYPE_NODE +enum value zu::Type::null -> ZU_TYPE_NULL +enum value zu::Type::path -> ZU_TYPE_PATH +enum value zu::Type::record -> ZU_TYPE_RECORD +enum value zu::Type::rel -> ZU_TYPE_REL +enum value zu::Type::string -> ZU_TYPE_STR +enum value zu::Type::temporal -> ZU_TYPE_TEMPORAL +class zu::Value +func explicit zu::Value::Value(const zu_value *) noexcept +func zu::Value::Value() = default +func zu::Value::as_bool() const -> bool +func zu::Value::as_bytes() const -> std::span< const std::uint8_t > +func zu::Value::as_double() const -> double +func zu::Value::as_int() const -> std::int64_t +func zu::Value::as_node() const -> Node +func zu::Value::as_rel() const -> Rel +func zu::Value::as_string() const -> std::string_view +func zu::Value::as_temporal() const -> Temporal +func zu::Value::at(std::uint64_t) const -> Value +func zu::Value::elements() const -> ValueRange +func zu::Value::empty() const noexcept -> bool +func zu::Value::field(std::uint64_t) const -> std::string_view +func zu::Value::is_null() const noexcept -> bool +func explicit zu::Value::operator bool() const noexcept +func zu::Value::operator[](std::uint64_t) const -> Value +func zu::Value::raw() const noexcept -> const zu_value * +func zu::Value::size() const noexcept -> std::uint64_t +func zu::Value::try_as_bool() const -> expected< bool > +func zu::Value::try_as_bytes() const -> expected< std::span< const std::uint8_t > > +func zu::Value::try_as_double() const -> expected< double > +func zu::Value::try_as_int() const -> expected< std::int64_t > +func zu::Value::try_as_node() const -> expected< Node > +func zu::Value::try_as_rel() const -> expected< Rel > +func zu::Value::try_as_string() const -> expected< std::string_view > +func zu::Value::try_as_temporal() const -> expected< Temporal > +func zu::Value::try_at(std::uint64_t) const -> expected< Value > +func zu::Value::try_field(std::uint64_t) const -> expected< std::string_view > +func zu::Value::type() const noexcept -> Type +class zu::ValueRange : public std::ranges::view_interface< ValueRange > +func explicit zu::ValueRange::ValueRange(const Value *) noexcept +func zu::ValueRange::ValueRange() = default +func zu::ValueRange::begin() const noexcept -> iterator +func zu::ValueRange::end() const noexcept -> iterator +class zu::ValueRange::iterator +using zu::ValueRange::iterator::difference_type -> std::ptrdiff_t +func zu::ValueRange::iterator::iterator() = default +func zu::ValueRange::iterator::iterator(const Value *, std::uint64_t) noexcept +using zu::ValueRange::iterator::iterator_category -> std::input_iterator_tag +using zu::ValueRange::iterator::iterator_concept -> std::random_access_iterator_tag +func zu::ValueRange::iterator::operator*() const -> Value +friend zu::ValueRange::iterator::operator+(difference_type, iterator) noexcept -> iterator +friend zu::ValueRange::iterator::operator+(iterator, difference_type) noexcept -> iterator +func zu::ValueRange::iterator::operator++() noexcept -> iterator & +func zu::ValueRange::iterator::operator++(int) noexcept -> iterator +func zu::ValueRange::iterator::operator+=(difference_type) noexcept -> iterator & +friend zu::ValueRange::iterator::operator-(const iterator &, const iterator &) noexcept -> difference_type +friend zu::ValueRange::iterator::operator-(iterator, difference_type) noexcept -> iterator +func zu::ValueRange::iterator::operator--() noexcept -> iterator & +func zu::ValueRange::iterator::operator--(int) noexcept -> iterator +func zu::ValueRange::iterator::operator-=(difference_type) noexcept -> iterator & +friend zu::ValueRange::iterator::operator<=>(const iterator &, const iterator &) noexcept -> std::strong_ordering +friend zu::ValueRange::iterator::operator==(const iterator &, const iterator &) noexcept -> bool +func zu::ValueRange::iterator::operator[](difference_type) const -> Value +using zu::ValueRange::iterator::reference -> Value +using zu::ValueRange::iterator::value_type -> Value +func zu::abi_version() -> std::string_view +using zu::expected -> std::expected< T, Error > +func zu::to_string(Node) -> std::string +func zu::to_string(Position) -> std::string +func zu::to_string(Rel) -> std::string +func zu::to_string(Severity) noexcept -> std::string_view +func zu::to_string(Status) noexcept -> std::string_view +func zu::to_string(Temporal) -> std::string +func zu::to_string(TemporalKind) noexcept -> std::string_view +func zu::to_string(Type) noexcept -> std::string_view +func zu::to_string(const Error &) -> std::string +func zu::to_string(const Value &) -> std::string +func zu::version() -> std::string_view diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index fbeedb3..bdb0533 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -1,4 +1,5 @@ -# The reference, and the test that it is one. +# The reference, the published surface, and the tests that they are +# what they say. # # Off by default, because Doxygen is not a dependency of building or # using this header and a contributor with a compiler and nothing else @@ -7,7 +8,16 @@ # # cmake -B build -DZU_CPP_DOCS=ON # cmake --build build --target docs -# ctest --test-dir build -R reference +# ctest --test-dir build -R 'reference|surface' +# +# Both gates read one Doxygen run: the reference asks whether the +# header was documented, and the surface asks whether what it publishes +# is still what api/surface.txt says. When the answer to the second is +# no on purpose, +# +# cmake --build build --target surface-update +# +# writes the file again for review. # # No engine is needed for any of it. The reference is about the header, # and the header is what this repository owns. zu.h is generated in the @@ -43,9 +53,40 @@ add_custom_target(docs COMMENT "Generating the reference from include/zu.hpp" VERBATIM) -# The same command as the target, so that what CI publishes and what +set(ZU_CPP_SURFACE + "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/surface.py" "${ZU_CPP_DOXYFILE}") + +# Not part of `docs`, and not a test either. Rewriting a reviewed file +# is what a person asks for when they meant to move the surface, and a +# build that did it on the way past would turn the gate into a step +# that always passes. +add_custom_target(surface-update + COMMAND ${ZU_CPP_SURFACE} --update + DEPENDS "${ZU_CPP_DOCS_INPUT}" "${ZU_CPP_DOXYFILE}" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + COMMENT "Writing api/surface.txt from include/zu.hpp" + VERBATIM) + +# The same commands as the targets, so that what CI publishes and what # ctest grades are one run of one script and not two things that agree # until they do not. if(ZU_CPP_TESTS) add_test(NAME reference COMMAND ${ZU_CPP_REFERENCE}) + add_test(NAME surface COMMAND ${ZU_CPP_SURFACE}) + + # Both empty the reference directory and run Doxygen into it, so they + # are two tests over one piece of ground and ctest -j would have them + # deleting each other's output. The lock is what says so; the + # alternative, one test doing both jobs, would mean a header that is + # undocumented and a surface that moved arrive as one failure. + set_tests_properties(reference surface PROPERTIES RESOURCE_LOCK doxygen) + + # And the rules the surface gate rests on, which are judgements about + # which half of a C++ declaration is its identity. Getting one of them + # wrong does not make the gate fail, it makes it pass, so they are + # checked rather than argued. No Doxygen and no header in these: they + # run on XML written out by hand. + add_test(NAME surface-cases + COMMAND "${Python3_EXECUTABLE}" -m unittest discover + -s "${CMAKE_CURRENT_SOURCE_DIR}" -p "test_*.py") endif() diff --git a/docs/Doxyfile.in b/docs/Doxyfile.in index 6f23f20..9665430 100644 --- a/docs/Doxyfile.in +++ b/docs/Doxyfile.in @@ -68,13 +68,31 @@ EXPAND_ONLY_PREDEF = YES # in reference.py counts what the header declares rather than trusting # the exit status. # -# The feature test macros rather than ZU_HAS_EXPECTED and ZU_HAS_FORMAT, -# because the header defines those two itself from these and predefining -# them leaves the #else branch as the one Doxygen reads and documents. +# Both halves of each feature switch, and both are needed for different +# reasons. +# +# The feature test macros are here because the header defines +# ZU_HAS_EXPECTED and ZU_HAS_FORMAT from them, and with neither defined +# Doxygen reads the #else branch and documents the wrong one of the two +# definitions. +# +# ZU_HAS_EXPECTED and ZU_HAS_FORMAT are here because EXPAND_ONLY_PREDEF +# means a #define Doxygen read in the file is not a value it will +# evaluate an #if against. Without them every `#if ZU_HAS_EXPECTED` +# block is skipped, and that is the whole expected mirror: ninety two +# try_ members that were published nowhere and warned about nowhere, +# because a member the preprocessor threw away cannot be undocumented. +# Whether they agree with the branch the header would have taken is the +# reader's problem and not Doxygen's, so they are written out here as +# the values the reference is generated at. # # ZU_FORMATTER expands to nothing so that its ten invocations do not # arrive as ten functions nobody declared. The macro itself is -# documented where it is defined and names what it specializes. +# documented where it is defined and names what it specializes, and +# docs/surface.py puts the ten back, because a specialization a caller +# formats against is published whether Doxygen sees it or not. PREDEFINED = __cpp_lib_expected=202202L \ __cpp_lib_format=201907L \ + ZU_HAS_EXPECTED=1 \ + ZU_HAS_FORMAT=1 \ ZU_FORMATTER(TYPE)= diff --git a/docs/surface.py b/docs/surface.py new file mode 100644 index 0000000..3848802 --- /dev/null +++ b/docs/surface.py @@ -0,0 +1,515 @@ +"""The published surface, written down, so that moving it is a diff. + +The scorecard's stability item asks for the tool that tells a reviewer +the public surface moved, before a user finds out. C++ has no such tool +in the toolchain. A compiler is perfectly happy to see a method deleted, +a parameter added or a `noexcept` dropped, and the person who finds out +is whoever upgrades and rebuilds. What the other clients do about that +is keep the surface in a file: zu-node has an api-extractor report, +zu-go has an api/surface.txt in the shape of the api/go1.N.txt files the +language itself is held to. This is the same thing for a header. + +`api/surface.txt` is every name `include/zu.hpp` publishes and the shape +it publishes it in, one per line, sorted, and it is generated rather +than written: + + cmake --build build --target surface-update + +It is reviewed like any other file. The check below rebuilds it from the +header and fails when the two disagree, saying which names went, which +arrived and which changed shape, because those are three different +pieces of news. A name that arrived is a minor release. A name that went +or changed shape is a major one, or a mistake, and the point of the gate +is that a reviewer is told which they are looking at while it is still a +diff. + +It reads the XML Doxygen already writes for the reference rather than +parsing C++ itself, which is the whole reason it is a page of selection +rules and not a compiler front end. Doxygen has already decided what is +public, what `zu::detail` hides and what the preprocessor kept, and +having one answer to those questions instead of two is worth more than +independence here: the reference and the surface disagreeing about what +this header publishes would be a bug in its own right. + +That does leave one thing to say out loud. Doxygen extracts a member +only if somebody documented it, so a member with no comment on it is +missing from the XML and would be missing from here, which is a name +leaving the file without leaving the header. What catches that is the +other gate: WARN_IF_UNDOCUMENTED fires and reference.py fails on it. +The two are a pair, and turning either one off makes the other quieter +than it looks. + +Two things it deliberately does not do. It does not know the ABI: this +is a header-only wrapper, everything in it is inline, and what a caller +depends on is whether their source still compiles rather than whether an +object file still links. And it says nothing about behaviour, so a +signature that held still while its meaning changed passes here and is +caught, if it is caught, by the suite that runs the thing. + + python3 docs/surface.py [--update] +""" + +from __future__ import annotations + +import re +import sys +import xml.etree.ElementTree as ElementTree +from pathlib import Path + +import reference + +#: Where the surface is written down, relative to the top of the +#: repository. Beside the source rather than under docs/, because it is +#: a reviewed file and not a generated artifact of the documentation. +GOLDEN = "api/surface.txt" + +#: What separates a name from its type, and a class from what it +#: derives from. Both mean "and here is the rest of it", and both are +#: where the identity of a line stops. See `key`. +ARROW = " -> " +BASE = " : " + +#: The private struct and the `friend class X` grants are not a surface; +#: they are how the pieces reach each other. Doxygen marks both, so this +#: is a filter and not a list to keep up to date. +PUBLIC = ("public", "protected") + + +def text(node: ElementTree.Element | None) -> str: + """One XML node flattened to the text it renders as. + + Doxygen writes a type as a mix of text and `` elements, one ref + per name it could link, so the type of a member is spread across + children rather than sitting in one string. + """ + if node is None: + return "" + return collapse("".join(node.itertext())) + + +def collapse(s: str) -> str: + """Whitespace flattened, so reflowing a declaration is not a change. + + A signature written across four lines in the header and the same + signature written across one are one entry here. + """ + return re.sub(r"\s+", " ", s).strip() + + +def entry(kind: str, name: str, tail: str = "") -> tuple[str, str]: + """One line of the file, and the name it is filed under. + + The name is what the file is sorted by, so that everything about + `zu::Connection` is in one block: a diff is read by whoever is + changing a class, and a file sorted by kind would scatter that class + across nine places. + """ + return name, collapse(f"{kind} {name}{tail}") + + +def parameters(member: ElementTree.Element) -> str: + """The parameter list, by type, with the names taken out. + + A parameter name is documentation in C++ and nothing else: there is + no call that names one, so renaming it cannot break a caller and + should not read as a change to the surface. A default argument is + the opposite, since taking one away breaks every call that leaned on + it, so it stays. + """ + parts = [] + for param in member.findall("param"): + one = text(param.find("type")) + default = text(param.find("defval")) + if default: + one += f" = {default}" + parts.append(one) + return "(" + ", ".join(parts) + ")" + + +def qualifiers(member: ElementTree.Element) -> str: + """The part after the parameter list, and it is load bearing. + + `const` decides whether a caller with a const handle can call it. + `noexcept` is part of the type since C++17, so dropping one breaks + anybody who took the address. `= delete` is a promise that a call + does not compile, and turning it back into a definition is a change + to the surface in the direction nobody notices. The ref qualifier + decides whether a temporary can call it at all. + + All of it is in one string Doxygen already assembled, so this takes + it from after the closing bracket rather than rebuilding it out of + the attributes and getting the order wrong. + """ + args = text(member.find("argsstring")) + after = args[args.rfind(")") + 1 :] if ")" in args else "" + # `=default` and `=delete` arrive without the spaces a person writes. + return re.sub(r"\s*=\s*(default|delete)", r" = \1", after).rstrip() + + +def template(member: ElementTree.Element) -> str: + """The template parameter list, written after the name. + + C++ writes it before, and a report that did the same would sort + every template away from the class it belongs to, because these + lines are grouped by the name they publish. So `get` here + stands for `template ... get`. It is a report and not a + declaration; zu-go's says `method Reader.Read func(...)` for the + same reason. + """ + params = member.find("templateparamlist") + if params is None: + return "" + written = [] + for param in params: + one = text(param.find("type")) + # Doxygen puts the parameter's name in the type for `class T` + # and in a declname of its own for `class... Ts`, so asking for + # both and taking the name once is the only way to get `Ts` + # rather than `class... Ts Ts`. + declared = collapse(param.findtext("declname") or "") + if declared and not one.endswith(declared): + one += f" {declared}" + written.append(one) + return "<" + ", ".join(written) + ">" + + +def leading(member: ElementTree.Element) -> list[str]: + """The keywords C++ writes in front of a declaration. + + `explicit` is the one that earns this function. Taking it off a one + argument constructor compiles everywhere it did before and starts + accepting conversions nobody wrote, and putting it on breaks every + call that leaned on one, which is a break a compiler reports at the + call site and this file reports at review. + + `virtual` and `constexpr` are here for the day one of them is used + rather than because one is: a gate that only names the properties + the header happens to have today is a gate that stops noticing the + moment somebody adds one. + """ + out = [] + if member.get("static") == "yes": + out.append("static") + if member.get("explicit") == "yes": + out.append("explicit") + if member.get("constexpr") == "yes": + out.append("constexpr") + if member.get("virt") in ("virtual", "pure-virtual"): + out.append(member.get("virt", "").replace("-", " ")) + return out + + +def member_entry(member: ElementTree.Element) -> tuple[str, str] | None: + """One member of a class, a struct or a namespace, as a line.""" + if member.get("prot") not in PUBLIC: + return None + + kind = member.get("kind") + name = collapse(member.findtext("qualifiedname") or member.findtext("name") or "") + if not name: + return None + kind_type = text(member.find("type")) + + if kind in ("function", "friend"): + # A friend defined inside a class is not a member of it. It is + # found by argument dependent lookup, which is exactly how a + # caller writes `a == b`, so it is published and it is named + # apart from the members so that a reader is not misled about + # where it lives. + lead = " ".join(["friend" if kind == "friend" else "func"] + leading(member)) + signature = f"{template(member)}{parameters(member)}{qualifiers(member)}" + # A constructor and a destructor have no return type, and an + # arrow to nothing would read as one that went missing. + return entry(lead, name, signature + (ARROW + kind_type if kind_type else "")) + + if kind == "variable": + lead = "var static" if member.get("static") == "yes" else "var" + return entry(lead, name, ARROW + kind_type) + + if kind == "typedef": + return entry("using", name, template(member) + ARROW + kind_type) + + if kind == "define": + # A macro a caller writes `#if` against. Its replacement is not + # part of the surface: `ZU_HAS_EXPECTED` being 1 or 0 is what + # the compiler decided, not what this header promises. + return entry("define", name) + + return None + + +def enum_entries(member: ElementTree.Element) -> list[tuple[str, str]]: + """An enum and every enumerator in it. + + The enumerators are listed one per line rather than as a set on the + enum's line, because dropping one is the change this is here to + catch and a set would bury it in a line that already changed for + some other reason. Their initializers are on them: these are the + `ZU_` constants of the C ABI under another spelling, and an + enumerator quietly taking a different value is worse news than one + disappearing. + """ + if member.get("prot") not in PUBLIC: + return [] + name = collapse(member.findtext("qualifiedname") or member.findtext("name") or "") + lines = [entry("enum", name, ARROW + text(member.find("type")))] + for value in member.findall("enumvalue"): + if value.get("prot") not in PUBLIC: + continue + one = collapse(value.findtext("name") or "") + init = text(value.find("initializer")).removeprefix("=").strip() + lines.append(entry("enum value", f"{name}::{one}", ARROW + init)) + return lines + + +def compound_entry(compound: ElementTree.Element) -> tuple[str, str] | None: + """A class or a struct, with what it derives from.""" + if compound.get("prot") not in PUBLIC: + return None + kind = compound.get("kind") + if kind not in ("class", "struct"): + return None + name = collapse(compound.findtext("compoundname") or "") + bases = [] + for base in compound.findall("basecompoundref"): + prot = base.get("prot") or "public" + bases.append(f"{prot} {collapse(''.join(base.itertext()))}") + tail = BASE + ", ".join(bases) if bases else "" + return entry(kind, name, tail) + + +def undefined(header: Path) -> set[str]: + """The macros the header takes back before it ends. + + A macro that is `#undef`ed is not published, however defined it was + in the middle. `ZU_FORMATTER` is the one: it exists for ten lines at + the foot of this header and is gone by the closing guard, so a + caller cannot write it and it is not a name this repository owes + anybody. The specializations it made are published, and those are + counted in `formatters` instead. + """ + return { + match.group(1) + for line in header.read_text(encoding="utf-8").splitlines() + if (match := re.match(r"#undef\s+(\w+)", line.strip())) + } + + +def formatters(header: Path) -> list[tuple[str, str]]: + """The std::formatter specializations, read out of the header. + + These are the one thing the XML cannot answer. The Doxyfile expands + `ZU_FORMATTER` to nothing, deliberately, so that ten macro + invocations do not arrive in the reference as ten classes nobody + declared. But `std::format("{}", status)` is a call a user writes, + and a specialization that stopped being generated is a surface that + moved, so the invocations are counted here instead. + """ + found = [] + for line in header.read_text(encoding="utf-8").splitlines(): + match = re.match(r"ZU_FORMATTER\((.+)\);", line.strip()) + if match: + found.append(entry("formatter", f"std::formatter< {match.group(1)} >")) + return found + + +def surface(xml: Path, header: Path) -> list[str]: + """Every name this header publishes, sorted and without duplicates.""" + index = xml / "index.xml" + if not index.is_file(): + raise SystemExit(f"doxygen wrote no index at {index}") + + found: list[tuple[str, str]] = [] + taken_back = undefined(header) + for compound in ElementTree.parse(index).getroot(): + refid = compound.get("refid") or "" + page = xml / f"{refid}.xml" + if not page.is_file(): + continue + for definition in ElementTree.parse(page).iter("compounddef"): + # A directory is a compound to Doxygen and publishes + # nothing. The file is where the macros live, and it is also + # where every other name in the header turns up a second + # time, so it is read for those and left alone otherwise. + if definition.get("kind") == "dir": + continue + # A nested type that is private publishes nothing, and + # neither do its members, however public they are inside it. + # `Connection::Watch` is the case: the fields of it are + # public to the class that owns it and there is no caller + # anywhere who can name the type. + if definition.get("kind") in ("class", "struct"): + one = compound_entry(definition) + if one is None: + continue + found.append(one) + wanted = ("define",) if definition.get("kind") == "file" else None + for member in definition.iter("memberdef"): + if wanted and member.get("kind") not in wanted: + continue + if member.get("kind") == "define" and member.findtext("name") in taken_back: + continue + if member.get("kind") == "enum": + found.extend(enum_entries(member)) + continue + one = member_entry(member) + if one: + found.append(one) + + # Before the formatters, which are read out of the header and would + # be ten names standing in an otherwise empty file. Doxygen exits 0 + # on a header it extracted nothing from, so an empty walk is not an + # empty header, it is a Doxyfile that threw the file away. + if not found: + raise SystemExit( + f"nothing at all was read out of {xml}, which is what an empty reference " + "looks like rather than what a header looks like. Check PREDEFINED in the " + "Doxyfile for the include guard." + ) + + lines = [line for _, line in sorted(set(found + formatters(header)))] + + # `compare` files both surfaces by key, so two lines sharing one + # would mean one of them was never compared with anything and could + # leave without a word. That cannot happen for a header a compiler + # accepted, since two declarations with the same key are two + # declarations a call could not choose between, so this is a check + # on this file rather than on the header. + seen: dict[str, str] = {} + for line in lines: + clash = seen.setdefault(key(line), line) + if clash != line: + raise SystemExit( + f"two published names are filed under the same key, so one of them would " + f"not be compared with anything:\n {clash}\n {line}\nkey: {key(line)}" + ) + return lines + + +def key(line: str) -> str: + """What identifies a line, which is not the whole of it. + + Two surfaces are compared by identity so that a thing which changed + reads as one name changing rather than as one name leaving and a + different one arriving. That distinction is the whole value of the + gate: a reviewer who has to read every line of a diff to find out + whether anything went is a reviewer who stops reading it. + + For a function, identity is what decides which overload a call + picks, and that is the name and the types of the parameters and the + const or ref qualifier. Everything else on the line is a property of + the one function: the return type, `noexcept`, `= delete`, and the + default arguments, each of which can change while the same call site + still resolves to the same thing. Those read as a change, which is + what they are. + + A parameter added is a different overload and reads as one going and + one arriving. That is a difference from zu-go's file, which keys on + the name alone, and the reason for it is that C++ has overloads: + `to_string` is ten functions here, and a key that could not tell + them apart would report nine of them as gone the day the tenth + moved. + + Everywhere else identity is the name and the shape it is declared + with. Everything after the first arrow is the type of the name in + front of it, and for a class everything after the first colon is + what it derives from. + """ + head = line.split(ARROW, 1)[0].split(BASE, 1)[0] + close = head.rfind(")") + if close < 0: + return head + name, params = head[: head.find("(")], head[head.find("(") + 1 : close] + # A default argument is a property of the declaration and not of the + # overload: taking one away breaks callers and does not change which + # function a call that passed the argument was picking. + params = re.sub(r"\s*=\s*[^,]+", "", params) + kept = [word for word in head[close + 1 :].split() if word in ("const", "&", "&&")] + return collapse(f"{name}({params}) {' '.join(kept)}") + + +def compare(was: list[str], now: list[str]) -> tuple[list[str], list[str], list[tuple[str, str]]]: + """Three answers, because they are three different pieces of news.""" + before = {key(line): line for line in was} + after = {key(line): line for line in now} + gone = [before[k] for k in before if k not in after] + arrived = [after[k] for k in after if k not in before] + changed = [(before[k], after[k]) for k in before if k in after and before[k] != after[k]] + return sorted(gone), sorted(arrived), sorted(changed) + + +def report(gone: list[str], arrived: list[str], changed: list[tuple[str, str]]) -> None: + """What moved, in the order a reviewer cares about it.""" + for line in gone: + print(f" went {line}", file=sys.stderr) + for old, new in changed: + print(f" was {old}", file=sys.stderr) + print(f" is now {new}", file=sys.stderr) + for line in arrived: + print(f" arrived {line}", file=sys.stderr) + if gone or changed: + print( + "\nA name that went or changed shape breaks a caller who was using it, " + f"which is a major version or a mistake. Regenerate {GOLDEN} with\n" + " cmake --build build --target surface-update\n" + "and say in the pull request which of the two this is.", + file=sys.stderr, + ) + elif arrived: + print( + f"\nOnly new names, which is a minor version. Regenerate {GOLDEN} with\n" + " cmake --build build --target surface-update", + file=sys.stderr, + ) + + +def main(argv: list[str]) -> int: + if len(argv) not in (2, 3) or (len(argv) == 3 and argv[2] != "--update"): + print(f"usage: {argv[0]} [--update]", file=sys.stderr) + return 2 + update = len(argv) == 3 + + doxyfile = Path(argv[1]).resolve() + where = reference.settings(doxyfile) + header = Path(where["INPUT"]) + output = Path(where["OUTPUT_DIRECTORY"]) + + # The reference is rebuilt rather than found, so that this answers + # about the header on disk and never about an XML tree left behind + # by a checkout that has since moved on. Doxygen's own complaints + # are reference.py's to fail on; what is needed here is the XML. + reference.build(doxyfile) + + now = surface(output / "xml", header) + + # From this script rather than from the Doxyfile, because the + # Doxyfile is configured into the build tree and every path in it + # points there, and this one file is the repository's. + golden = Path(__file__).resolve().parent.parent / GOLDEN + if update: + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text("\n".join(now) + "\n", encoding="utf-8") + print(f"{len(now)} published names written to {golden}") + return 0 + + if not golden.is_file(): + print(f"there is no {golden} to check against", file=sys.stderr) + return 1 + + was = golden.read_text(encoding="utf-8").splitlines() + gone, arrived, changed = compare(was, now) + if not (gone or arrived or changed): + print(f"{len(now)} published names, and {GOLDEN} says the same") + return 0 + + print( + f"the surface moved: {len(gone)} went, {len(changed)} changed shape, " + f"{len(arrived)} arrived", + file=sys.stderr, + ) + report(gone, arrived, changed) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/docs/test_surface.py b/docs/test_surface.py new file mode 100644 index 0000000..e00bde9 --- /dev/null +++ b/docs/test_surface.py @@ -0,0 +1,394 @@ +"""The cases that decide whether the stability gate says anything. + +docs/surface.py answers three questions about two surfaces: what went, +what arrived, and what changed shape while keeping its name. Every one +of those answers is a judgement about which parts of a C++ declaration +are its identity and which are its shape, and getting that wrong does +not make the gate fail. It makes it pass, or makes it report a rename of +a parameter as forty names leaving and forty arriving, which is a gate a +reviewer learns to skip and is the same thing. + +So the judgements are the tests. There is no Doxygen here and no header: +these run on XML written out by hand, small enough to read, because what +is being checked is the rule and not the parse. + + python3 -m unittest discover -s docs -p 'test_*.py' + +stdlib unittest rather than pytest, because the docs job installs +Doxygen and a Python and nothing else, and a gate that needed a package +manager to run is a gate that is off on the machine that needed it. +""" + +from __future__ import annotations + +import unittest +import xml.etree.ElementTree as ElementTree + +import surface + + +def member(xml: str) -> ElementTree.Element: + """One memberdef, written out.""" + return ElementTree.fromstring(xml) + + +def line(xml: str) -> str: + """The line surface.py would file that member under.""" + filed = surface.member_entry(member(xml)) + assert filed is not None, "the member was not published at all" + return filed[1] + + +class TestOneMemberAsALine(unittest.TestCase): + """What a declaration comes out as, on the cases that carry news.""" + + def test_a_plain_method(self): + self.assertEqual( + line(""" + + Result + query + zu::Connection::query + (std::string_view q) + std::string_viewq + """), + "func zu::Connection::query(std::string_view) -> Result", + ) + + def test_a_parameter_name_is_not_published(self): + """There is no call that names one, so renaming one breaks nobody.""" + named = line(""" + + voidfzu::f + (int rows) + introws + """) + renamed = line(""" + + voidfzu::f + (int n) + intn + """) + self.assertEqual(named, renamed) + + def test_a_default_argument_is(self): + """Taking one away breaks every call that leaned on it.""" + self.assertIn( + "bool = false", + line(""" + + Transactiontransaction + zu::Connection::transaction + (bool read_only=false) + + boolread_only + false + + """), + ) + + def test_const_and_noexcept_survive_from_the_argsstring(self): + self.assertEqual( + line(""" + + zu_conn *raw + zu::Connection::raw + () const noexcept + """), + "func zu::Connection::raw() const noexcept -> zu_conn *", + ) + + def test_deleted_is_written_the_way_a_person_writes_it(self): + self.assertEqual( + line(""" + + Transaction + zu::Transaction::Transaction + (const Transaction &)=delete + const Transaction & + """), + "func zu::Transaction::Transaction(const Transaction &) = delete", + ) + + def test_a_constructor_has_no_arrow(self): + """An arrow to nothing reads as a return type that went missing.""" + self.assertNotIn( + surface.ARROW, + line(""" + + Connection + zu::Connection::Connection + (zu_conn *c) noexcept + zu_conn *c + """), + ) + + def test_explicit_and_static_are_written_down(self): + """Both decide what a call site is allowed to be.""" + self.assertTrue( + line(""" + + Connectionopen + zu::Connection::open + (std::string_view path) + std::string_viewpath + """).startswith("func static ") + ) + self.assertTrue( + line(""" + + Value + zu::Value::Value + (zu_value *v) + zu_value *v + """).startswith("func explicit ") + ) + + def test_a_template_parameter_is_named_once(self): + """Doxygen writes `class T` one way and `class... Ts` another.""" + self.assertEqual( + line(""" + + + class... TsTs + + Appender &row + zu::Appender::row + (const Ts &... v) + const Ts &... + """), + "func zu::Appender::row(const Ts &...) -> Appender &", + ) + + def test_a_private_member_is_not_published(self): + self.assertIsNone( + surface.member_entry(member(""" + + voidclose + zu::Connection::close + () + """)) + ) + + def test_a_reflowed_declaration_is_the_same_line(self): + """A signature broken across four lines and the same one across + one are one entry, so reformatting the header is not news.""" + self.assertEqual( + line(""" + + voidon_progress + zu::Connection::on_progress + (std::chrono::milliseconds every, + Progress watcher) + std::chrono::milliseconds + Progress + """), + "func zu::Connection::on_progress(std::chrono::milliseconds, Progress) -> void", + ) + + +class TestAnEnumIsItsValuesToo(unittest.TestCase): + """Every enumerator on a line of its own, with what it is equal to. + + These are the ZU_ constants of the C ABI under another spelling, and + one of them quietly taking a different number is worse news than one + disappearing. + """ + + SOURCE = """ + + intStatuszu::Status + ok= ZU_OK + io= ZU_IO + """ + + def test_the_enum_and_each_value(self): + self.assertEqual( + [written for _, written in surface.enum_entries(member(self.SOURCE))], + [ + "enum zu::Status -> int", + "enum value zu::Status::ok -> ZU_OK", + "enum value zu::Status::io -> ZU_IO", + ], + ) + + +class TestAClassAndWhatItDerivesFrom(unittest.TestCase): + def test_a_base_is_on_the_line(self): + compound = ElementTree.fromstring(""" + + zu::SyntaxError + zu::Exception + """) + self.assertEqual( + surface.compound_entry(compound)[1], + "class zu::SyntaxError : public zu::Exception", + ) + + def test_a_private_nested_type_publishes_nothing(self): + compound = ElementTree.fromstring(""" + + zu::Connection::Watch + """) + self.assertIsNone(surface.compound_entry(compound)) + + +class TestWhatIdentifiesALine(unittest.TestCase): + """The rule the three answers rest on. + + Everything that can change while the same call still picks the same + function is shape. Everything that decides which function a call + picks is identity. + """ + + def test_shape_is_not_identity(self): + for one, other in [ + # A return type. + ( + "func zu::Connection::query(std::string_view) -> Result", + "func zu::Connection::query(std::string_view) -> Statement", + ), + # noexcept, which is part of the type and not of the overload. + ( + "func zu::Connection::interrupt() -> void", + "func zu::Connection::interrupt() noexcept -> void", + ), + # A deletion undone. + ( + "func zu::Transaction::Transaction(const Transaction &) = delete", + "func zu::Transaction::Transaction(const Transaction &)", + ), + # A default argument taken away. + ( + "func zu::Connection::transaction(bool = false) -> Transaction", + "func zu::Connection::transaction(bool) -> Transaction", + ), + # What a class derives from. + ( + "class zu::SyntaxError : public zu::Exception", + "class zu::SyntaxError : public zu::ProgrammingError", + ), + ]: + self.assertEqual(surface.key(one), surface.key(other), one) + + def test_identity_is_identity(self): + for one, other in [ + # A parameter type. + ( + "func zu::Row::get(std::string_view) const -> T", + "func zu::Row::get(std::uint32_t) const -> T", + ), + # const, which is what a const handle can call. + ( + "func zu::Result::rows() const -> std::uint64_t", + "func zu::Result::rows() -> std::uint64_t", + ), + # static, which decides whether there is an object at all. + ( + "func static zu::Connection::open(std::string_view) -> Connection", + "func zu::Connection::open(std::string_view) -> Connection", + ), + # A whole parameter, which is a different overload. + ( + "func zu::Connection::appender(std::string_view) -> Appender", + "func zu::Connection::appender(std::string_view, bool = false) -> Appender", + ), + # And the ten to_string overloads, which a key that could not + # tell them apart would report nine of as gone. + ( + "func zu::to_string(Status) noexcept -> std::string_view", + "func zu::to_string(Severity) noexcept -> std::string_view", + ), + ]: + self.assertNotEqual(surface.key(one), surface.key(other), one) + + +class TestWhatMovedIsToldApartFromWhatArrived(unittest.TestCase): + """Every one of these would read as a name leaving and another + arriving if the identity of a line were the whole line, which is the + mistake that makes such a gate noise a reviewer learns to skip.""" + + WAS = [ + "class zu::Result", + "func zu::Connection::interrupt() -> void", + "func zu::Result::rows() const -> std::uint64_t", + "func zu::Row::get(std::string_view) const -> T", + ] + NOW = [ + "class zu::Result", + # noexcept added, which is a change and not a replacement. + "func zu::Connection::interrupt() noexcept -> void", + # rows is gone. + # columns has arrived. + "func zu::Result::columns() const -> std::uint32_t", + "func zu::Row::get(std::string_view) const -> T", + ] + + def setUp(self): + self.gone, self.arrived, self.changed = surface.compare(self.WAS, self.NOW) + + def test_what_went(self): + self.assertEqual(len(self.gone), 1) + self.assertIn("rows()", self.gone[0]) + + def test_what_arrived(self): + self.assertEqual(len(self.arrived), 1) + self.assertIn("columns()", self.arrived[0]) + + def test_what_changed_shape(self): + self.assertEqual(len(self.changed), 1) + self.assertIn("noexcept", self.changed[0][1]) + self.assertNotIn("noexcept", self.changed[0][0]) + + def test_what_did_not_move_is_in_none_of_the_three(self): + moved = self.gone + self.arrived + [old for old, _ in self.changed] + for line in ("class zu::Result", "func zu::Row::get(std::string_view) const -> T"): + self.assertNotIn(line, moved) + + def test_two_surfaces_that_agree_are_three_empty_answers(self): + self.assertEqual(surface.compare(self.WAS, self.WAS), ([], [], [])) + + +class TestTheHeaderIsReadForWhatDoxygenCannotSee(unittest.TestCase): + """The `std::formatter` specializations and the macros taken back. + + The Doxyfile expands ZU_FORMATTER to nothing on purpose, so ten + invocations do not arrive in the reference as ten classes nobody + declared. But `std::format("{}", status)` is a call a user writes. + """ + + SOURCE = """#define ZU_FORMATTER(TYPE) \\ + template <> struct std::formatter {} + +ZU_FORMATTER(zu::Status); +ZU_FORMATTER(zu::Value); + +#undef ZU_FORMATTER +""" + + def setUp(self): + import tempfile + from pathlib import Path + + self.dir = tempfile.TemporaryDirectory() + self.header = Path(self.dir.name) / "zu.hpp" + self.header.write_text(self.SOURCE, encoding="utf-8") + + def tearDown(self): + self.dir.cleanup() + + def test_the_specializations_are_published(self): + self.assertEqual( + [written for _, written in surface.formatters(self.header)], + [ + "formatter std::formatter< zu::Status >", + "formatter std::formatter< zu::Value >", + ], + ) + + def test_a_macro_the_header_takes_back_is_not(self): + self.assertEqual(surface.undefined(self.header), {"ZU_FORMATTER"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/include/zu.hpp b/include/zu.hpp index 6c6e953..d9eb552 100644 --- a/include/zu.hpp +++ b/include/zu.hpp @@ -3578,11 +3578,11 @@ inline std::string to_string(const Value& v) { * is rejected at compile time by the base class rather than accepted * and ignored. * - * was already included at the top of this header and + * \ was already included at the top of this header and * ZU_HAS_FORMAT was already defined, and nothing used either, which is * a header claiming a capability it did not have. * - * No operator<< to go with it, on purpose. is one of the + * No operator<< to go with it, on purpose. \ is one of the * heaviest headers in the standard library and it would land in every * translation unit that includes this one whether it prints or not, for * a wrapper whose first line is that it includes zu.h and calls nothing