diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ef067191..632cffcc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,20 @@ name: docs +# The site deploys from the release branch only, so everything it publishes — the +# single-header artifact included — is released, verified content. Pull requests +# into main and release still build-check the site without deploying. + on: push: branches: - - main + - release paths: - '.github/workflows/docs.yml' - 'docs/**' - 'include/**' - 'tests/**' - 'examples/**' + - 'scripts/**' - 'CMakeLists.txt' - 'cmake/**' - '*.md' @@ -17,12 +22,14 @@ on: pull_request: branches: - main + - release paths: - '.github/workflows/docs.yml' - 'docs/**' - 'include/**' - 'tests/**' - 'examples/**' + - 'scripts/**' - 'CMakeLists.txt' - 'cmake/**' - '*.md' @@ -56,7 +63,7 @@ jobs: cmake --build . --target export_docs - name: Upload artifact - if: ${{ github.repository == 'libfn/functional' && github.ref_type == 'branch' && github.ref_name == 'main' }} + if: ${{ github.repository == 'libfn/functional' && github.ref_type == 'branch' && github.ref_name == 'release' }} uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./.build/docs @@ -66,7 +73,7 @@ jobs: # Separate job: permissions are per-job, so PR runs must not hold the deploy scopes deploy_docs: needs: generate_docs - if: ${{ github.repository == 'libfn/functional' && github.ref_type == 'branch' && github.ref_name == 'main' }} + if: ${{ github.repository == 'libfn/functional' && github.ref_type == 'branch' && github.ref_name == 'release' }} runs-on: ${{ github.repository == 'libfn/functional' && 'warp-ubuntu-latest-arm64-2x' || 'ubuntu-latest' }} permissions: pages: write diff --git a/.github/workflows/single-header.yml b/.github/workflows/single-header.yml new file mode 100644 index 00000000..17f6162a --- /dev/null +++ b/.github/workflows/single-header.yml @@ -0,0 +1,129 @@ +name: single-header + +# The artifact is generated and verified on every PR touching the headers, so a +# header that breaks amalgamation fails at review time rather than at release +# time. The release branch — from which the docs site embeds the artifact — gets +# the same verification. Publishing is then a re-run of a check already known +# green, bound to the tag by `release: published`. + +on: + push: + branches: + - main + - release + paths: + - '.github/workflows/single-header.yml' + - 'include/**' + - 'examples/polygon/**' + - 'scripts/amalgamate.py' + - 'VERSION' + pull_request: + branches: + - main + - release + paths: + - '.github/workflows/single-header.yml' + - 'include/**' + - 'examples/polygon/**' + - 'scripts/amalgamate.py' + - 'VERSION' + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + timeout-minutes: 15 + runs-on: ${{ github.repository == 'libfn/functional' && 'warp-ubuntu-latest-arm64-2x' || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + # Test a small selection of compilers only - build.yml carries the full compiler breadth. + include: + - { mode: cxx20, compiler: "gcc:13" } + - { mode: cxx20, compiler: "clang:22" } + - { mode: cxx26, compiler: "gcc:16" } + container: libfn.azurecr.io/ci-build-${{ matrix.compiler }}-sha-95f599d + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # git describe needs the tags for the provenance banner + fetch-depth: 0 + + - name: Generate + run: | + mkdir -p .single + python3 scripts/amalgamate.py -o .single/libfn.hpp + head -n 20 .single/libfn.hpp + + # A shim include tree resolves to the amalgamation, so the example compiles + # unmodified against it. Intentionally skip pfn and fn/detail headers here. + - name: Build shim include tree + run: | + for header in $(cd include && find fn -maxdepth 1 -name '*.hpp'); do + mkdir -p ".single/shim/$(dirname "${header}")" + echo '#include ' > ".single/shim/${header}" + done + + # The check that catches a dedupe regression in the guardless pair directly: + # if macro_end were emitted once, FWD would still be defined here. + - name: Sentinel + run: | + cat > .single/sentinel.cpp <<'EOF' + #include + #include + #ifdef FWD + #error FWD leaked from the amalgamation + #endif + #ifdef DEDUCED_RETURN + #error DEDUCED_RETURN leaked from the amalgamation + #endif + #ifdef ASSERT + #error ASSERT leaked from the amalgamation + #endif + int main() {} + EOF + ${CXX:-c++} -std=${{ matrix.mode == 'cxx26' && 'c++26' || 'c++20' }} \ + ${{ matrix.mode == 'cxx26' && '-DLIBFN_CXX26' || '' }} \ + -Wall -Wextra -Werror -Wno-missing-braces \ + -I.single -c .single/sentinel.cpp -o /dev/null + + # Real client code, checked end to end: the example must not only compile against + # the artifact but find the all-letters word at runtime. If this fails because of + # missing pfn or fn/detail headers, fix the example, not this workflow. + - name: Build and run the polygon example against the single header + shell: bash + run: | + ${CXX:-c++} -std=${{ matrix.mode == 'cxx26' && 'c++26' || 'c++20' }} \ + ${{ matrix.mode == 'cxx26' && '-DLIBFN_CXX26' || '' }} \ + -Wall -Wextra -Werror -Wno-missing-braces \ + -I.single/shim -I.single examples/polygon/main.cpp -o .single/polygon + .single/polygon uoilefdr examples/polygon/data/long.txt | grep -E '^\* fluoride$' + + publish: + if: github.event_name == 'release' + needs: verify + timeout-minutes: 15 + runs-on: ${{ github.repository == 'libfn/functional' && 'warp-ubuntu-latest-arm64-2x' || 'ubuntu-latest' }} + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Generate + run: | + python3 scripts/amalgamate.py --revision "${TAG}" -o "libfn-${TAG}.hpp" + env: + TAG: ${{ github.event.release.tag_name }} + + - name: Attach to the release + run: gh release upload "${TAG}" "libfn-${TAG}.hpp" --clobber + env: + TAG: ${{ github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 844b4338..33ed598a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,225 +2,90 @@ Design history of libfn, newest first. The living documents — [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md), [docs/](docs/) — describe only the present state of the design; when a decision makes an earlier idea obsolete, this file is where the transition is recorded and explained. -## The conjunction sums two ungraded errors — 25 July 2026 +## libfn 0.1.0: the first tagged release — 2 August 2026 -- **`operator&` no longer asks its operands to arrive graded** ([#384](https://github.com/libfn/functional/issues/384)): conjoining `expected` with `expected` was ill-formed unless one of the two error types was already a `copack`, though the same pair disjoined without complaint. The conjunction's error channel *is* the sum, so producing the grade is the operator's work, not a precondition on its operands — and nowhere else does a sum ask for one: `|` forms its value sum out of two ungraded value types, in every carrier. Of the four channels — `&`'s value product and error sum, `|`'s value sum and error product — this was the only one gated. The four different-error arms now serve any distinct pair, widening to `copack_for` exactly as they did for a graded operand, and `conjoin` follows as their fold. Purely additive: every expression newly accepted was ill-formed before, and a matching pair of error types still collapses to the plain error it always had. -- **The bind is deliberately left out of this.** `and_then` and `or_else` are the foundational operations, and a callback whose error type merely differs from self's still refuses: switching a pipeline to the graded monad is the caller's decision to make, not something a bind does behind their back, and the library offers spellings enough to opt in. +libfn is a header-only C++20 functional-programming library: `fn`'s monadic composition and types, layered over `pfn`'s C++23/26 vocabulary-type polyfills. The `0.1.0` tag is the first release, opening the versioning contract SemVer's bare `0.y.z` otherwise leaves informal: a `y` bump is a breaking change (API and/or ABI), a `z` bump stays compatible — and, being header-only, a binary links against exactly one libfn version. -## The verbs reach an uninhabited value side, and `pack` compares — 25 July 2026 +A first release has no prior version to diff against, so this entry presents what the library offers at `0.1.0`. The dated design history it replaces stays readable at commit [`41ac614`](https://github.com/libfn/functional/blob/41ac614/CHANGELOG.md), the last before the first release candidate. -- **Every value-side verb now serves a carrier whose value side is uninhabited** ([#380](https://github.com/libfn/functional/issues/380)): `optional>`, never engaged, and `expected, E>`, always holding its error. Their members have been the identity there since the empty-sum doctrine (17 July below) — the mapping returns `*this` and the callback is neither invoked nor instantiated — but the verbs answered false, so a pipeline could not reach what the member could. `transform` and `and_then` delegate to those vacuous members; `inspect` observes nothing, `filter` rejects nothing and `fail` never fires, each passing the operand through; `discard` always accepted them. The operand class is named: `some_empty_value` for the value side and `some_empty_error` for its mirror, the identity `expected` and only it, which `some_identity` now spells as its third alternative. Both answer for any type at all — the carrier test short-circuits before the value or error type is named — so their negations stay usable in constraints. -- **Applicability remains a property of the callback**: the `applicable_` concepts keep answering false for these operands, because a vacuous operation consults no callback and so no callback is applicable to it. What applies is the verb, which is the question `monadic_invocable` answers — the constraint `operator|` itself carries, asked of the verb's own `apply` object so that every arm counts. Both concepts now say so in their documentation, the distinction having been discoverable only by reading the constraint. -- **Where the identity cluster refuses, an uninhabited value side accepts, and the difference is not arbitrary**: `filter` and `fail` refuse `just`, `choice` and the identity `expected` (18 July below) because a rejection there has nowhere to go — the operation cannot do its work. Over an uninhabited value side the error channel is live and the operand is in it already, so the same verbs are merely dead code, which the type system proves rather than the caller promising it. -- **`value_or` admits a void value side**: `value_or()` substitutes the empty value, so a failed `expected` comes back engaged, and any argument makes the call non-viable, there being nothing to build the empty value from. Previously excluded by an arity accident rather than a decision — its sibling `recover` has always served void expecteds — which also left the family's unit outside a verb that is otherwise total over it. `expected_unit` now names that unit, `expected>`. -- **`conjoin` and `disjoin` each take one world, and never mix them**: a monadic carrier among `conjoin`'s arguments used to become a `pack` element, packing the carriers rather than conjoining what they carry — and reaching their values would need `value()`, which throws. The data fold now refuses carriers outright, and a second arm folds `operator&` over carriers instead, so `conjoin(a, b)` means the conjunction when both are carriers and the product when neither is; a mixed list matches nothing rather than being resolved by its leading argument. `disjoin` is likewise carriers-only in every arity, where it used to reach the built-in `operator|` and fold two integers into 3. -- **`pack` compares element by element, `<=>` lexicographically**: defaulting the comparisons would have deleted them for a reference element, and a pack of references is what `as_pack` builds from lvalues (24 July below), so the folds are written out and compare referents — the semantics of `fn::optional` and of `std::tuple`, whose *assignment* is the pointer-like operation, not their comparison. Every element decides, and one that cannot be compared leaves the operator non-viable rather than ill-formed; an element brings its own `<=>` or the pack has no ordering, none being synthesized from `<`. Neither `constexpr` nor an exception specification is spelled, both being computed — and spelling either is a hard error for an element whose own comparison does not match it. A `copack` over packs becomes comparable as a consequence, its equality having always required each alternative to be equality-comparable. +### The monadic vocabulary -## `expected`'s comparison against a value answers instead of recursing — 25 July 2026 +- **Value/error channel pairs**: `and_then`/`or_else`, `transform`/`transform_error`, `inspect`/`inspect_error`. +- **`fail`, `recover` and `filter` round out the set**: force an error, clear one, or test the value. +- **`just`, `choice` and an error-unit `expected`** form one identity cluster around a shared bind. +- **`and_then` and `or_else` join heterogeneous** `expected`/`optional` branches into their lossless superset. +- **The same two verbs also convert directly** between `expected` and `optional` carriers. +- **`value_or` extracts the value or a fallback**; `discard` drops a result kept only for its side effects. +- **`copack_value`, `copack_error`, `as_copack` and `as_pack`** lift plain data into the monadic types. -- **That comparison is no longer a hidden friend** ([#381](https://github.com/libfn/functional/issues/381)): alone among the equality operators it constrains itself on the *other* operand — `*x == v` must be valid and convertible to `bool`, an extension over [expected.object.eq]'s Mandates, so that asking answers rather than erroring inside the body. As a hidden friend of the shared storage base its own operand could only be spelled through the policy, a non-deduced context, so deduction rejected nothing and the constraint was evaluated for *every* left operand, whatever its type — constraints are checked before conversion sequences are formed. Where the right operand reached the same operator by ADL — a function pointer returning that `expected`, or any class template over one — the constraint asked the question it was answering, and both compilers reject that outright: `fn::just` could not be compared, nor even asked about. Declared at namespace scope, once per carrier, the operand is deduced and a left operand which is not that `expected` fails deduction before the constraint is reached; libstdc++ pairs the same constraint with the same deduction, for the same reason. The two are a package: the standard's hidden friend is safe precisely because its Mandates never runs during overload resolution. The three sibling operators keep their form — they constrain on the operands' channels, never on the other operand's type, so they cannot refer to themselves. +### The type algebra: pack and copack -## Carrier conversions through `or_else` and `and_then` — 24 July 2026 +- **`pack` is the product**: a move-friendly, tuple-like holder for a fixed set of values. +- **`pack` carries multiple values** through `expected`, `optional`, `choice` and every monadic operation. +- **`pack` supports structured bindings and `std::get`** via the tuple protocol; a nested `pack` element flattens. +- **`pack` compares element-wise**: `==` and lexicographic `<=>`, each computed from the elements' own operators. +- **`copack` is the co-product**: a tagged union; `choice` wraps it with `and_then`, `transform` and `inspect`. +- **`copack<>` is the empty co-product**: uninhabited, the identity grade every error union builds from. +- **The error channel is graded**: sequencing derives each pipeline's exact `copack` of failure modes. +- **`choice::and_then` joins branches** of differing `choice` types into their combined superset. +- **`copack` and `choice` construct an alternative in place**, and assign from a value matching exactly one. +- **`emplace` reconstructs a copack/choice alternative** in place, when assignment itself refuses. -- **The `or_else` verb recovers across the `expected`/`optional` pair** ([#377](https://github.com/libfn/functional/issues/377)): the callback witnesses the inhabited dead state — `optional`'s empty state without arguments, `expected`'s error per alternative — and its declared carrier now names the result kind, so an `optional` pipeline recovers into `expected` and back. Self's value joins the target's value side under the same grade-sticky rules as the same-kind joins (19 July below): a `copack<>` value acquires the branch values keeping the copack spelling — `optional> | or_else(() -> expected)` is `expected, Foo>` — a plain value must be retained by every branch exactly, and the singular lift applies. The members stay strict same-kind; the verb is the licensed cross-carrier place, exactly as the cluster bind (18 July below). -- **`or_else` invokes on 1-states and is silent on 0-states** — the one rule; the vacuous identity over an identity `expected` is its n = 0 case, derived rather than legislated. `optional`'s empty state is inhabited — a state that *is* — so its callback always runs, `optional>` included; the identity `expected`'s `copack<>` error is the zero: the fold of the callback over no alternatives has no input to form a result with, so the operand passes through unchanged and the callback is never consulted — not even its type, `or_else(42)` with no callable at all compiles there. Probing the callback's type to let the uninhabited source convert too was considered and rejected: it conflates zero with unit, it would derive a result kind from a callable that is never invocable — a fixed-arity generic callback cannot even be asked — and an unreachable catch-all overload in a handed-in callback would flip the result kind of an expression it can never serve. -- **The `and_then` bridge: identity inputs bind into `optional` and `expected`** ([#376](https://github.com/libfn/functional/issues/376)): the cluster bind's restriction of targets to the cluster (18 July below) was conservative, not algebraic — its losslessness argument concerns the *input's* uninhabited channels, never the target's kind. A `just`, `choice` or identity-`expected` input now binds through callbacks returning `optional` or `expected`, choice dispatch branches joining per the hetero rules and a branch set mixing the two kinds refused; a live-error `expected` binds into the cluster and into `optional`, results of its own kind staying on the member. The duality with the entry above is deliberate: `or_else` converts on the dead channel, where the callback witnesses the state that does not map; `and_then` converts on the value channel, licensed exactly when the input's other channels are uninhabited. -- **Grades track the carrier you chose**: `just` is the gradeless world — `just | and_then(() -> expected)` is `expected`, the result exactly as the callback declared — while the identity `expected` is the graded world: `expected>` keeps expected-returning callbacks on its member, whose grade-sticky join answers `expected>`. The cluster isomorphism preserves payloads, not grading vocabulary, so the twins may answer differently under one expression; unifying the two worlds' result grades would erase the choice the pipeline author made by picking a carrier. +### Multidispatch: apply -## `as_pack` takes explicit element types — 24 July 2026 +- **`apply`/`apply_r` dispatch one callable** across `pack`, `copack`, `choice`, `optional` and `expected` uniformly. +- **`apply_type` and `apply_type_r` add exhaustive**, type-indexed dispatch across all four carriers. +- **Every `apply`-family member takes trailing arguments**, appended after each arm's unpacked content. +- **`fn::overload` builds an ad hoc visitor** by combining several callables into one. +- **`pfn` polyfills the C++26 apply trait family**: `is_applicable`, `is_nothrow_applicable`, `apply_result`. -- **The lift may spell the pack it builds** ([#375](https://github.com/libfn/functional/pull/375)): the deduced `as_pack` preserves value categories — over lvalues it builds a pack of references, its job in the pipelines — which left `pack` from existing objects with no direct spelling. `as_pack(i, d)` now names the elements outright: each argument is taken by value in the named type, converting at the call boundary — narrowing included, `as_pack(i, d)` is deliberate coercion — while an rvalue argument still relocates and a named reference type keeps its meaning, `as_pack(x)` binding `pack`. The rule is all-or-none: the explicit form names every element type, exact arity, nothing deduced, and the deduced overload absorbs explicit template arguments into an unnamed leading parameter pack its constraint requires empty — so a partial spelling such as `as_pack(x, 42)` finds no viable overload instead of half-deducing behind the named prefix. Every rejection class is substitution-visible: asking answers instead of hard-erroring. +### Composing pipelines: operator& and operator| -## The test suite catches up with the release waves — 23 July 2026 +- **`operator&` conjoins two carriers**: values become a `pack`; the leftmost failure supplies the error. +- **`operator|` disjoins two carriers**: the leftmost success wins; both failing keeps every error in a `pack`. +- **`conjoin` and `disjoin` are the n-ary folds** of `&` and `|`. +- **`conjoin` also folds plain data** into a `pack`; a call mixing data and carriers matches nothing. +- **Both operators admit the identity cluster** and compose two ungraded error types directly. -- **Every join and operator family exercises its throwing paths at runtime** ([#351](https://github.com/libfn/functional/issues/351)): the review of the test lines added since the #85 cleanup found the additions faithful to the standards, but the exception axis uniformly missing — no runtime throw anywhere in the new join and operator surface, and the hetero-join `noexcept` specifications probing only the callback. Each family now arms a fuse-carrying local fixture — the n-th relocation throws — paired with an operands-unchanged check, the completing twin and its constant-evaluation replay; the `noexcept` sections weigh the widening relocations and prove the dead arms weightless. CONTRIBUTING's exception rule records the practice. -- **The conjunction's tests mirror the disjunction's**: the `operator&` coverage, grown as one long section of "expected pack support" since before the disjunction existed, is its own TEST_CASE, and `disjoin`'s case sits beside `conjoin`'s in pack.cpp with their shared machinery. The lone tuple-like payload unpacking through the verbs, the `just` payload mandate, and the identity cluster's operand-category sweep are pinned. +### Standard-library polyfills: pfn -## The disjunction, and the identity cluster in both operators — 22 July 2026 +- **`pfn::expected` is a spec-faithful C++20 polyfill** of `std::expected`, plus `has_error()`. +- **`pfn::optional` is a full C++26-shaped polyfill** on C++20: `optional`, plus a range interface. +- **`fn::expected` and `fn::optional` build on the pfn versions**; `fn` is a strict superset of `pfn`. +- **`unexpected`, `unexpect`, `unexpect_t` and `bad_expected_access`** are available directly in namespace `fn`. +- **`expected` supports move-only value and error types**, in construction, assignment and comparison. -- **`operator|` disjoins two carriers** ([#368](https://github.com/libfn/functional/issues/368)): the De Morgan dual of `&`, the channels swapped — the value channel is the *sum* of the value types, the error channel the *product* of both errors, riding the same fold as the conjunction, which is what distributes graded errors correctly. The leftmost engaged operand wins and injects by type; both-failed folds both errors into a `pack`, positionally, never deduplicating — the product is tuple-like and position says *where* a failure happened (a pack-typed error splices flat, `tuple_cat`-like) — while the value sum may collapse a same-type pair bare, because type-indexed identity makes the arms indistinguishable anyway, the mirror of `&`'s same-error split. Void enters a genuine sum as `pack<>` — both are 1, and a copack cannot hold `void` — and the all-unit case collapses back to the family's unit carrier: `just | just` is `just`, `expected | expected` is `expected>`, and associativity holds across the collapse — both groupings of `just | just | just` agree on `choice_for, int>`. Carrier `|` coexists with the pipeline's verb application by constraint — two carrier operands are never a verb application — following the parser-combinator precedent for alternation. `fn::disjoin` is the n-ary fold of `|`, beside `conjoin`. -- **The identity cluster is admitted in the conjunction** ([#368](https://github.com/libfn/functional/issues/368)): a `just`, `choice` or identity-`expected` operand always contributes its value to the product and adds no term to the error sum, so the fallible operand's channels decide alone — its error passes through unchanged, plain or graded. `just` and `expected>` are the product's unit and elide; an uninhabited value side absorbs the product — multiplication by zero, which is why it must stay uninhabited. Cluster & cluster stays in the cluster: identity over a pack is spelled `just`, over a copack `choice`. -- **A cluster operand makes the disjunction total**: it contributes an uninhabited factor to the error product, so the result never fails and collapses into the cluster — spelled `just` while the value sum stays one bare type, `choice` when the union is genuine. Left catch holds: an engaged left operand's value is the whole answer. -- **The mixed `expected`/`optional` combination stays refused in both operators**: `optional` brings no error type, and spelling its unit error inside `expected`'s error channel is a decision to take deliberately, not a fold accident. +### Correctness guarantees -## The conjunction short-circuits over an uninhabited value side — 22 July 2026 +- **Every monadic operation is concept-constrained, constexpr**, with a computed `noexcept`. +- **The `|` and `&` pipelines carry a verb's computed `noexcept`** through the whole expression. +- **A monadic result is `[[nodiscard]]`**; `discard` spells the deliberate drop. +- **`copack`, `choice`, `optional` and `expected` assign** with the strong exception guarantee, never valueless. +- **Copy/move assignment is trivial** exactly when every held alternative's own assignment is. +- **A constraint asks the question its body performs**: every rejection is substitution-visible. -- **An operand whose value side is the empty copack now composes under `&`** ([#367](https://github.com/libfn/functional/issues/367)): a value product with an uninhabited factor is itself uninhabited, so `expected, E> & expected` keeps the empty-copack value, unions the errors as ever and always resolves into the error channel — leftmost failure first — while `optional> & optional` is always empty. Previously a hard error even to ask about: the constraints admitted the operand and the join then named `copack<>`'s value fold in its declared type, outside the immediate context — the one gap the empty-sum sweep (17 July below) left in its promise that every operation over an uninhabited side compiles and short-circuits. The dead fold arm is now never named and weighs nothing in `noexcept`; only the live error side's relocation counts. +### Type ordering & ABI stability -## `fn::identity` became `conjoin`, and `just` constructs implicitly — 22 July 2026 +- **`fn` and `pfn` live in an ABI-versioned namespace**; a mismatched version fails to link, never collides. +- **Type ordering is total by construction**; a sort-key collision is a compile error. +- **`pfn` keeps one ABI namespace across language modes**; only `fn`'s `copack`/`choice` layout varies by mode. -- **The n-ary fold of `operator&` renamed** ([#366](https://github.com/libfn/functional/pull/366)): `fn::identity` collided head-on with `std::identity` — a different meaning entirely — and, since the identity cluster arrived (18 July below), with the library's own use of "identity" for the carriers whose error grade is uninhabited. `conjoin` says what the invocable does — fold its arguments under `&`, a single argument forwarded unchanged — and reserves `disjoin` for `|`'s fold (delivered the same day, above). Invocables are verbs in this library, nouns name concepts: `conjunction` was rejected on those grounds, and collides with `std::conjunction` besides. -- **`just` default-constructs implicitly**: there is nothing to convert from, so `explicit` guarded nothing — the payload-free unit now matches the primary template's default constructor and [expected.void.cons]. The tag constructors are explicit — the tags are protocol vocabulary, not values to convert from — and `std::in_place_t` joins `std::in_place_type_t` among them, with a deduction guide making `just{std::in_place}` the unit's terse spelling. +### Standards, compilers & packaging -## The C++26 `std::type_order` mode — 21 July 2026 +- **Targets C++20 as the sole baseline** across every `fn`/`pfn` header; no later standard is required. +- **Supported compilers**: gcc 12+, clang 16+, Apple Clang 16+, and MSVC 2022+. +- **An opt-in `LIBFN_CXX26` mode** switches type ordering onto the standard `std::type_order` (gcc 16+). +- **`libfn::fn_cxx26` selects the mode with one link line**, carrying its define and its C++26 language requirement. +- **Installs as plain header-only CMake**, or as a tested Conan, vcpkg, Nix flake or Bazel module package. +- **Public headers live under** `include/fn` and `include/pfn`. -- **`LIBFN_CXX26` selects `std::type_order` as the type ordering** ([#352](https://github.com/libfn/functional/issues/352)): `normalized`'s keys become pairwise `type_order` ranks — injective by identity, so the #326 collision floor cannot fire and the sortkey scrape's spelling collisions are gone; the scrape remains the default mode. The two orders genuinely differ (probed: neither is the other's refinement), which the ABI namespace twin exists for — each mode's `fn` mangles apart and mixed-mode programs fail loud at link time. A compiler without `__cpp_lib_type_order` (today: everything except gcc 16) rejects the mode with one `#error` naming the requirement — capability answers, never version checks. -- **`libfn::fn_cxx26` is the packaged entry point**: an interface target that transitively delivers `fn`, the define, and the C++26 standard requirement (`cxx_std_26`) — one dependency line per consumer target, choose exactly one of `libfn::fn` / `libfn::fn_cxx26`; mirrored as bazel `:fn_cxx26` and a conan component. `pfn` stays out of the mode by policy (the entry below). -- **Canonical-order pins in tests and examples now spell `copack_for`/`choice_for`** where the pin's purpose is set semantics: the exact order was never the contract, and the mode's different order exposed every pin that pretended otherwise. `VALIDATE_CXX26` builds the suite in the mode (gcc-16 CI lanes, Debug and Release); the C++26 standard level itself surfaced two genuine standard changes en route — `std::complex` turning tuple-like (P2819) and `std::to_string` formatting through `std::format` (P2587) — both now gated on their feature-test macros. +### Documentation & examples -## `pfn` is mode-less — 21 July 2026 +- **`TYPE_ALGEBRA.md` works the library's type algebra** from first principles. +- **The API reference is generated from Doxygen comments**; the docs site also carries usage guides. +- **Worked examples** — a compiled RPN calculator and a polygon library — show the pipeline end to end. +- **The README's own worked example** is a compiled, tested source file, kept in sync with the prose. -- **`pfn` wraps in the base spelling, never the `_cxx26` twin** (refines the 20 July entry below, where one shared spelling wrapped both layers): mode-dependent layouts come from `copack`/`choice` alternative ordering — `fn`-layer machinery that `pfn`'s spec-fidelity polyfills can never touch — so `pfn::v0_0_9_cxx26::expected` would have been a bit-identical type behind a gratuitously incompatible mangled name. Pinned to `inline namespace LIBFN_VERSION_BASE` (the new mode-less macro in `libfn_version.hpp`, synced from `VERSION` like the rest), `pfn`'s vocabulary types pass legally across mode boundaries — a library exposing `pfn::expected` in its API links from either mode — while `fn` types correctly cannot. The wrap check enforces the layer rule: `fn` opens the mode-sensitive spelling, `pfn` the base. +### Project history -## The ABI-versioning inline namespace — 20 July 2026 - -- **Everything in `fn` and `pfn` lives in `inline namespace LIBFN_VERSION`** ([#352](https://github.com/libfn/functional/issues/352)): spelled `v0_0_9` today, `v0_` once a 0.y line is tagged — the 0.0.z line versions per patch, while z bumps within a tagged 0.y line are ABI-compatible and share the namespace — with a `_dev` suffix available for prerelease builds and a `_cxx26` twin reserved for the C++26 `std::type_order` mode, whose layouts may differ. Two library lines now fail loud at link time instead of silently ODR-colliding. The spelling lives in the new root header `include/libfn_version.hpp` — below both `fn` and `pfn`, the only header either may reach outside its own tree — and is never hand-edited: `scripts/sync_versions.py` derives it from `VERSION` exactly as it mirrors the packaging literals, and a pre-commit check refuses any `fn`/`pfn` namespace opening that drops the wrap. -- **`FWD` and `DEDUCED_RETURN` no longer leak from `fn` headers**: the standalone macro headers are gone, folded into the guard-less bracketing pair `fn/detail/macro_begin.hpp`/`macro_end.hpp` (push and define on entry, undef and pop on exit, nesting-safe by the pragma stack), and every per-header sentinel now errors if `FWD`, `DEDUCED_RETURN` or `ASSERT` survives an include — the only macros a libfn header leaves behind are its include guard and `LIBFN_VERSION`. - -## `expected` joins heterogeneous `and_then` and `or_else` branches — 19 July 2026 - -- **A copack-valued `expected` may bind through branches returning different expecteds** ([#98](https://github.com/libfn/functional/issues/98)): the result is the unique lossless join — values in `copack_for` (exact convergence preserved; a copack-valued result flattens through it; all-void joins to void), errors unioned with self's own grade (`copack_for`; a plain `E` must be retained by every branch exactly, and a `copack<>` grade simply acquires the branch errors). `or_else` mirrors it with the roles swapped: recovery branches over the error copack join their values with self's pass-through value — a copack value opting in, exactly as a copack error does for `and_then` — and union their errors. Convergent callbacks keep their exact types, behaviour and diagnostics; a mixed void and non-void set answers instead of erroring, and so does asking — `applicable_and_then` and `applicable_or_else` now probe the join, where the old concepts detonated the select convergence assert on these very shapes. Previously every applicable branch had to agree on one expected type; the map/bind correspondence broke exactly there, since `transform` already joined heterogeneous mapped values. -- **The singular lift, both ways**: a branch may spell a plain carried side's grade as its singular `copack` — binding `expected, int>` through branches whose errors are `int` or `copack` joins them to `copack`, the copack spelling winning so grading never silently drops; mirrored for `or_else`'s value side. The other direction is `fn::get`: the sole alternative of a **singular** copack, returned in the copack's own cv-qualification and value category, exactly as `apply` would pass it — dispatch-free because a one-alternative copack cannot miss. -- **`optional` joins too**: a copack-valued `optional` binds through branches returning different optionals into `optional>` — the simplest instance of the join, with no carried side to grade — closing the divergence with `choice` and `expected`; `transform` needed nothing, its collapse always joined. Same guarantees: convergence keeps its exact type, asking answers, and a branch whose optional carries a reference leaves the join unformable rather than silently copying the referent. -- **`just::transform` turned viable-but-loud**: an inadmissible result — a copack, a reference, an array — now keeps the member viable and fires the family's mid-body `static_assert` on use, naming choice and the verb's promotion, where the identity cluster had it drop quietly from the overload set. The quiet gate was mechanics, not pedagogy — the declared return may not name `just` for these payloads — and the mechanics stay: the declaration now falls back to the raw result type, formable and inert, so asking still answers off the declared type without ever instantiating the body. - -## The identity cluster: `just`, `some_identity`, and `and_then` across carriers — 18 July 2026 - -- **`fn::just` is the family's unit as a value** ([#350](https://github.com/libfn/functional/issues/350)): the canonical, minimal identity carrier — one payload, no error channel, no empty state (the precedent is `std::execution::just`, the immediately-ready computation, not Haskell's Maybe-`Just`). As trivial as `T` permits in copying, moving, assignment and destruction, a structural type when `T` is one, with in-place construction (`just a(std::in_place_type, "foo")` deduces), converting assignment through `T`'s own `operator=` (self-assignment is a no-op by address identity), `emplace` with the strong guarantee, unconditionally-noexcept `value()`, `transform`/`and_then`, and the `apply` family with `apply_type` keyed `std::in_place_type`. A copack payload is rejected with a message directing to `choice` — dispatch granularity belongs to the engine, so identity over a copack is spelled `choice`, and the mandate keeps one spelling per meaning. `just` is the payload-free unit, `just{}` deduced. -- **`fn::some_identity` names the equivalence class**: `choice`, `just`, and an `expected` whose error is the empty copack — the carriers whose short-circuit grade is uninhabited, canonically and payload-preservingly isomorphic; none is privileged, so "identity" is a concept, not a type. -- **The `and_then` functor binds across the cluster** ([#350](https://github.com/libfn/functional/issues/350)): an identity input's callback may return *any* identity carrier, and the bind follows the function — `expected, copack<>>` piped through all-choice branches evaporates into the superset choice (#349's join engine, its target kind now decoupled from the dispatched payload), other branches converge on one carrier type, and results of the input's own kind stay on the carrier's member, error-widening included. The cluster bind is lossless exactly because the channels a carrier switch drops are uninhabited; a live-error `expected` or an `optional` still refuses the switch. The members stay strict same-kind — the carrier's own bind; the cluster bind is Kleisli composition along the canonical isomorphisms and belongs to the verb layer, the licensed cross-carrier place. En route, `applicable_and_then`'s copack-payload probes moved onto an assert-free result trait: asking about a divergent-branch callback now answers instead of tripping the select convergence assert — the piped form of #349's join. -- **`transform` follows `and_then` into the cluster**: a `just` operand pipes through the verb, its member serving the endo case, and a copack-returning callback is promoted to the **choice over the same alternatives** — verb-level only, along the canonical isomorphism the member's mandate names, since `just>` is ill-formed by design. The member's declared result is gated, so every inadmissible payload — a copack, a reference, an array — leaves no viable overload and answers cleanly wherever the member is named, instead of firing a mandate inside a probe or a `noexcept` specification. -- **`inspect` and `discard` reach the whole cluster**: `inspect` gained `just` legs — the callback observes the always-present payload (nullary for `just`) and the operand passes through unchanged — while `choice` already had a leg and expected-at-bottom needs none, a value-side verb reaching it through the general `expected` leg. `discard` accepted every carrier the moment `just` became a monadic type; the suite now pins all three. -- **The dead-side verbs curate the cluster**: `or_else`, `transform_error`, `inspect_error`, `recover` and `value_or` refuse `just` and `choice` — carriers with no dead side to serve — by a visible requires-clause on each general arm, retiring the deleted choice overloads (and `transform_error`'s optional one) that had said the same thing as call-site diagnostics. `fail` and `filter` refuse the **whole** cluster, identity expected included: there is no error to fail into, and a vacuous `fail` would lie about the pipeline's failure modes — the requires-clause states the refusal instead of leaving it to the accident that nothing converts into `copack<>`. The `expected` at the empty copack is the deliberate exception: its dead-side verbs stay and are vacuous — `or_else` and `transform_error` delegate to the vacuous members (the empty-sum entry below), `inspect_error` and `recover` pass the operand through, nothing observed and nothing rebuilt, and `value_or` keeps its general arm with the fallback constrained yet dead — so a generic pipeline over an identity expected stays closed, while the `applicable_` concepts underneath still answer false: the dedicated arms, not the concepts, admit the operand. - -## macOS 14 support removed — 18 July 2026 - -- **The macOS-14 CI lane (Apple clang 15) is gone**: Apple clang 15 was the only compiler in the matrix without P0960 (parenthesized aggregate initialization, a C++20 feature) and sat below the documented Apple Clang 16.0 floor; its frontend also crashes outright on the `just` test suite. With the lane went the test-side aggregate-construction wraps it alone required — the terse in-place forms (`{unexpect, "…"}`, `{in_place, N}`) are restored throughout the verb tests and `examples/simple`. - -## `choice::and_then` joins differing branches into the superset choice — 18 July 2026 - -- **Branches of the dispatch may return different choice types** ([#349](https://github.com/libfn/functional/issues/349)): the result is the grade-spliced superset — all branches' alternatives, normalized and deduplicated exactly as `choice_for` produces. Previously every applicable branch had to agree on one choice type. `and_then` is the join, the one operation licensed to cross the choice boundary, and with the superset collapse `x.transform(f) == x.and_then([](auto &&v) { return choice{f(FWD(v))}; })` holds for heterogeneous branches too. Purely additive: everything now accepted was ill-formed before, and convergent callbacks keep their exact types and behaviour. The join is its own detail fold beside `transform`'s collapse — the collapse keeps a returned choice whole (fmap nests the atom), the join splices its alternatives (bind flattens) — and each branch's result reaches the superset through the existing copack widening constructors, a narrower choice converting through its copack base. A value-returning callback remains rejected, and an inapplicable one still drops `and_then` from the overload set. - -## `sum` became `copack` — 17 July 2026 - -- **The co-product of types renamed** ([#83](https://github.com/libfn/functional/issues/83)): `fn::sum` is `fn::copack`, and the whole vocabulary follows — `copack_for`, `some_copack`, `empty_copack`, `as_copack`, the graded verbs `copack_value` and `copack_error`, and the header `fn/copack.hpp`. The old name read as an arithmetic operation; `copack` names what the type is — the co-product of types, orthogonal to `pack`, the product. A clean break with no alias: the library is pre-0.1 and has no compatibility surface to keep. - -## The `apply` family takes trailing arguments uniformly — 17 July 2026 - -- **`apply_type` and `apply_type_r` on `sum`, `choice`, `optional` and `expected`, and `choice`'s `apply` and `apply_r`, now accept trailing arguments** ([#342](https://github.com/libfn/functional/issues/342)), appended after each arm's unpacked content — the shape `sum::apply` has always had as the member leg of `fn::apply`'s engine protocol (the free function routes `fn::apply(fn, sum, extras...)` through the member). The disparity was an artifact of that protocol, not a design decision: nothing ever forced the arguments onto the tagged members, which have no free-function counterpart, nor onto `choice`, an atom the free `apply` never dispatches — while the internal type-indexed machinery anticipated them all along. On `optional` the empty arm receives `(std::nullopt, extras...)`, mirroring the untagged empty arm's `(extras...)`; on `expected` the value arm receives `(std::in_place, extras...)`. - -## `optional` and `expected` gained the `apply` family — 17 July 2026 - -- **`apply`, `apply_r`, `apply_type` and `apply_type_r` eliminate both states through one callable** ([#339](https://github.com/libfn/functional/issues/339)): the lower-level tool beside the monadic members, as on `sum` and `choice` (#268), shipped ahead of the type-indexed verb form (#341). Both arms are required outright and must agree on one result type (`apply_r` converts). The untagged `apply` hands each state's content over exactly as `fn::apply` would — a `pack` or tuple-like payload by elements, a `sum` payload by dispatch, anything else whole — the empty arm of `optional` is invoked without arguments, and trailing arguments follow the content, as on `sum`. `apply_type` keys each arm by the constructor tag that names the state — `std::in_place` for the held value, `std::nullopt` for empty, `fn::unexpect` for the error — so the dispatch is airtight by construction even where `T` and `E` interconvert: a lone `double` arm silently serves both rows of `expected` on the untagged path, and the tags never convert. As on `sum::apply_type`, a tuple-like payload's elements form is the tagged row's one signature (the untagged path keeps the pass-whole fallback); within a `sum` payload the dispatch stays the value path — the tag guards the state, not the sum's rows. - -## The empty sum's monads answer and short-circuit — 17 July 2026 - -- **The `apply` family requires no arm for an uninhabited row** ([#346](https://github.com/libfn/functional/issues/346)): the members know in their constraints that a `sum<>` side is never set, so the inhabited row's arm alone is exhaustive and dispatch calls it without a branch — `expected>{42}.apply_type([](std::in_place_t, int i) { ... })` just works, with no arm for the error path. The uninhabited row is never named: an arm set carrying an arm for it compiles without instantiating it, under the same discipline (and the same poisoned-callback pin) as the verbs below. On `optional>` the mirror holds — the empty state is the one inhabited row, so `apply` requires only the nullary arm and `apply_type` only the `std::nullopt` arm. The condition is named by the new `empty_sum` concept, published beside `some_sum` — a concept-id keeps its answer under negation, where the spelled-out conjunction is one atomic constraint that a substitution failure poisons whole. -- **`optional>` and `expected, E>` are now legal, and every monadic operation over an uninhabited `sum<>` side compiles and short-circuits** ([#344](https://github.com/libfn/functional/issues/344)). `sum<>` is uninhabited by construction, so a side of that type is guaranteed never constructed: `optional>` is never engaged, `expected, E>` always holds its error, `expected>` always its value. Asking about `transform_error` on `expected>` used to be a hard error rather than an answer — the sum-keyed arm's constraint folds over zero alternatives, passing vacuously, and the deduced return then names `sum<>::transform`, which does not exist — while `optional>` and `expected, E>` were rejected outright. Now the operations whose callback belongs to the uninhabited side — `transform`/`and_then` over an empty-sum value, `transform_error`/`or_else` over an empty-sum error — are the identity: they return `*this` unchanged, and the callback is not invoked and not even instantiated (nothing in the arm names it; pinned by a callback whose instantiation for any argument is a hard error). Operations whose callback belongs to an inhabited state keep their full meaning: `or_else` on `optional>` runs its callback — the empty state is the one state that type has — with the widening contract unchanged, since `sum_for, U>` is `U`'s own normal form. Widening composes the same way on every side: an arm that would relocate an uninhabited `sum<>` — self's, or one inside the callback's own result — is never named and never weighs in the extension `noexcept`, so `or_else` can widen away from an empty value and `and_then` can join a callback whose expected carries the empty error. A caller who wants their callback invoked wants the unit, not the void: extend the alternatives, or use `pack<>` — the empty product, whose elimination genuinely calls a nullary arm. `sum<>` also gained defaulted assignment, so the conditional-assignment gates of the containing types compose. - -## `sum` and `choice` gained type-indexed dispatch — 16 July 2026 - -- **`apply_type` and `apply_type_r` dispatch on the exact alternative** ([#268](https://github.com/libfn/functional/issues/268)): each arm receives `std::in_place_type_t` followed by the alternative unpacked exactly as `apply` unpacks it — a `pack`'s or a tuple-like's elements, a plain value whole. The tag never converts, so arm selection is airtight where the value path is subject to implicit conversions — over `sum` a lone `double` arm applies on the value path, silently absorbing the `int` alternative, while `apply_type` refuses it — and the tag carries exactly what the unpacking loses: which row of the dispatch table the elements came from. Exhaustiveness is required outright; a missing arm makes the whole dispatch non-viable, and asking answers instead of hard-erroring (the internal type-indexed dispatch now fails substitution cleanly, which the public constraint requires). Type-indexed forms of the monadic verbs are deliberately not members — they belong to the verb layer, when needed. - -## Type normalization diagnoses sort-key collisions — 16 July 2026 - -- **`normalized` asserts that distinct types have distinct sort keys** ([#326](https://github.com/libfn/functional/issues/326)). The type ordering scrapes compiler type spellings, and two distinct types could share one — gcc prints same-scope lambdas identically, clang prints same-named function-local types bare — so `sum_for` silently became `sum`: a genuine alternative vanished, and with tied keys the order had no canonical answer either. A collision is now a mandate error at the point of use ("two distinct types share a sort key"), on every supported compiler; distinct keys are untouched and genuine duplicates still deduplicate. The scrape and its assert both retire with `std::type_order` (P2830, C++26 — its motivation cites this library). - -## `pack` splices in every append spelling, and hands elements over whole — 16 July 2026 - -- **The tag form of `append` now constructs the named pack from its arguments and splices it** ([#328](https://github.com/libfn/functional/issues/328)): appending a pack means concatenation however it is spelled, joining the two spellings that always spliced — a deduced pack value, and a tag with a prebuilt matching pack, the route `operator&`'s fold takes. The tag spelling costs one relocation per element more than appending the elements directly; the result never differs. A tag that merely names an ill-formed pack answers instead of hard-erroring. -- **Pack machinery consumes values through `INVOKE`, never through `fn::apply`'s engine.** Since the tuple-like arm (below), routing the splice relocation and `pack::apply`'s elements through the engine gave a *lone* tuple-like element `std::apply`'s meaning: appending a pack whose only element is a `std::tuple` was a hard error, and `pack>::apply` unpacked the element that the same call hands over whole whenever a sibling or extra argument accompanies it. Elements are terminal, and the engine's unpacking belongs to the deliberate elimination boundary alone — `std::invoke` is useful exactly as defined, which is what #84 renamed the multidispatch *away* from. - -## `pack` rejects a nested `pack` element — 16 July 2026 - -- **The element mandate that has always rejected a `sum` now also rejects a `pack`** ([#328](https://github.com/libfn/functional/issues/328)). A nested pack was representable and incoherent: `apply` flattened it (a callback over `pack, C>` saw three arguments) while the tuple protocol preserved it (`tuple_size` answered two) — and since the tuple-like arm below, the same position answered differently by kind, an own `pack` element flattening where a `std::tuple` element passes whole. The product is associative, so the nested spelling was a non-canonical duplicate of the flat one — the same self-normalization `sum` has always applied to itself. Nothing is lost: appending a whole pack has always spliced, and grouped data keeps its home in an opaque atom — `std::tuple`, `std::array`, a user wrapper, or `choice`, which the algebra never opens. - -## `invoke` became `apply` — 15 July 2026 - -- **The multidispatch verb and its whole vocabulary renamed** ([#84](https://github.com/libfn/functional/issues/84)): `fn::apply`/`apply_r`, the members on `sum`, `choice` and `pack`, the `apply_result`/`is_applicable` traits and the `applicable`/`typelist_applicable` concepts. The dispatch — unpack packs, dispatch sums, fold several operands into one — extends `std::apply`'s mechanism, not `std::invoke`'s: the implementation reaches `std::invoke` at the bottom, which is precisely why the old name overpromised. Type-indexed internals and true `std::invoke`-level names keep theirs. -- **`fn::apply` serves `std::apply`'s own domain too**: a lone tuple-like argument unpacks through the machinery shared with `pfn::apply` (#325), so the two agree on the entire std domain by construction — and the elements are terminal: a sum element is handed over whole, never dispatched. A generic callable therefore unpacks where it used to receive the tuple whole; a callable viable only for the whole tuple is still served, and a tuple-like among further arguments still passes whole. The arm reaches dispatch too: a tuple-like alternative of a `sum` or `choice` now unpacks like a `pack` alternative always has — per alternative, so one overload set can unpack a tuple alternative and take a plain sibling whole. - -## `pfn` grew the C++26 `apply` trait family — 15 July 2026 - -- **`pfn/tuple.hpp` polyfills P1317R2** ([#325](https://github.com/libfn/functional/issues/325)): `is_applicable`/`is_nothrow_applicable`, the SFINAE-friendly `apply_result`/`apply_result_t`, and `apply` in its C++26 shape — a declared return type where C++20's deduced return is a hard error outside the immediate context, and a computed exception specification. The suite's own probes demonstrated the defect being fixed: an unqualified negative probe over std arguments finds C++20's `std::apply` through ADL and hard-errors where the C++26 shape substitutes away. Groundwork for #84: `fn`'s multidispatch verbs rename onto `std::apply`'s vocabulary, extending a conforming polyfill. - -## `sum` and `choice` gained `emplace` — 15 July 2026 - -- **`emplace(args...)` is the mutation path for alternatives that do not support assignment** ([#318](https://github.com/libfn/functional/issues/318)): destroy-and-reconstruct requested at the call site by name, so senders and reference-holding packs — whose assignment `operator=` rightly refuses — can change the alternative held by an existing object again. Always reconstructs, also for the alternative already held, and the constraint asks about `T` alone: no sibling alternative is consulted. Unlike `std::expected::emplace` (nothrow constructions only, unconditionally `noexcept`) a throwing construction is admitted through an internalized temporary; unlike `std::variant::emplace` (unconstrained, valueless when construction throws) the case with no safe arm is constrained away — strong guarantee, never valueless, conditionally `noexcept`. - -## `sum` and `choice` assign from a value of one alternative — 15 July 2026 - -- **A value of exactly one alternative now assigns directly** ([#319](https://github.com/libfn/functional/issues/319)): assigned in place through the alternative's own `operator=` when it is the one held, replaced by construction otherwise. `s = v` used to exist only through the converting constructor's temporary sum and the whole-sum assignment, whose constraints let an uninvolved alternative forbid the operation and whose route relocated every value through the temporary. Exact alternative only, as the converting constructors take one — never `std::variant`'s converting-assignment resolution — so a convertible non-alternative stays rejected and interconvertible alternatives never meet in overload resolution. - -## `optional` and `expected` assignment is trivial when the payload permits — 15 July 2026 - -- **Copy and move assignment of `optional` and `expected` are now trivial exactly under the standard's conditions** ([#308](https://github.com/libfn/functional/issues/308)): the contained types trivially copy/move constructible, trivially assignable and trivially destructible ([optional.assign], [expected.object.assign], [expected.void.assign]). Neither type was ever trivially assignable, in either library — a conformance defect, and with the constructors and destructor already conditionally trivial, assignment was the one member keeping these types out of `is_trivially_copyable`. Like `sum`'s entry below, this lands before the first tagged release because trivially copyable types change ABI. - -## `sum` and `choice` assign across widening — 14 July 2026 - -- **A narrower `sum` now assigns into a wider one on the widening constructors' terms** ([#310](https://github.com/libfn/functional/issues/310)): `operator=` is constrained on the alternatives the source can actually deliver, and the incoming alternative is assigned in place when it is the one held, or replaces it by construction otherwise — the per-alternative decision same-type assignment already makes. Previously `wide = narrow` existed only by routing through the widening constructor: a whole temporary sum, a copy plus a move where one copy suffices, and viability gated on the destination's every alternative — an uninvolved alternative with no safe replacement arm forbade assignments it took no part in. -- **`choice` declares its own widening assignment and delegates to `sum`'s.** A declared `operator=` hides every base overload, so without its own pair `choice` would have been left out silently; the delegation also admits a `sum` over the same alternatives, which previously paid for a temporary despite having nothing to widen. - -## `sum` is as trivial as its alternatives permit — 14 July 2026 - -- **Every special member of `sum` (and through it `choice`) is now trivial exactly when every alternative permits it** — the gates `std::variant` uses ([#309](https://github.com/libfn/functional/issues/309)). A `sum` of fundamentals, or of trivially copyable `pack`s, is trivially copyable — register-passed and `memcpy`-safe — where previously no `sum` supported any trivial operation. The sum-of-packs case is the one that pays: joining sum-valued monads yields the cartesian product, and that is what a multidispatch pipeline copies at every stage. Trivially copyable sums change ABI (register passing), which is why this lands before the first tagged release. -- **`variadic_union` carries the propagation.** Its copy, move and assignment exist only as constrained defaults: the machinery above constructs through a tagged constructor, reads through `ptr_variadic_union` and destroys per member, so the non-trivial cases need none of them. The tagged constructor replaces aggregate designated-initializer construction — a union with declared constructors is not an aggregate — with `make_variadic_union` keeping its interface and its brace-initialization semantics. - -## `sum` assignment requires assignable alternatives — 14 July 2026 - -- **`sum::operator=` (and through it `choice`'s) now requires every alternative to be assignable, and assigns the held alternative in place whenever the incoming one is the same type; only a change of alternative reconstructs.** As introduced the day before ([#304](https://github.com/libfn/functional/pull/304)), assignment always destroyed the alternative in hand and constructed the incoming one over it, deliberately asking nothing of the alternatives' own `operator=` — which proved unsound: destroy-and-reconstruct synthesizes an operation the alternative may have refused. `fn::pack` deletes its assignment because C++ cannot rebind a reference, and `optional` and `expected` of that pack honour the refusal — yet a `sum` of it was assignable, quietly rebinding the reference ([#311](https://github.com/libfn/functional/issues/311)). A sum of packs is the normal form of this library's type algebra, so this sat in the middle of the library, not at an edge. -- **The strong exception guarantee stays, on both paths, and there is still no valueless state.** An unchanged alternative is assigned directly where its assignment cannot throw, through a temporary where its move assignment cannot, and under a nothrow snapshot with rollback otherwise; a replacement still builds a throwing copy into a temporary before the alternative in hand is destroyed. -- **Assignment as destructive replacement — other languages' default — remains a possible future direction, deliberately not taken.** In C++ the type system lets a type refuse assignment outright, and that refusal can be load-bearing; an operation the language may one day define (destructive relocation) is not one a container should synthesize meanwhile. - -## Honest `noexcept`, honest constraints — 13 July 2026 - -The release-0.1 bug backlog landed as thirteen fixes ([#295](https://github.com/libfn/functional/pull/295)–[#307](https://github.com/libfn/functional/pull/307)), closing nineteen issues. The common thread: what the library declares — `noexcept`, constraints, concept answers — is now derived from what its bodies do. - -- **Every `noexcept` specification is computed, none assumed** ([#298](https://github.com/libfn/functional/pull/298), [#300](https://github.com/libfn/functional/pull/300), [#301](https://github.com/libfn/functional/pull/301), [#302](https://github.com/libfn/functional/pull/302)). The internal nothrow-invocability traits were hardcoded `true`; they are now real, and on that basis the `sum`/`pack`/`choice` constructors and dispatch, the verbs, `operator|` and `operator&` derive their specifications from the layers they delegate to. `v | fn::and_then(f)` was unconditionally `noexcept` where `v.and_then(f)` told the truth — the pipeline spelling turned a throwing callback into `std::terminate`; a join whose error is `sum<>` now promises the "cannot fail" its type spells; and `sum_value`/`sum_error` gained specifications and `constexpr`, completing constant-evaluated pipelines across the graded lift. -- **Constraints ask the question the body performs** ([#296](https://github.com/libfn/functional/pull/296), [#297](https://github.com/libfn/functional/pull/297), [#303](https://github.com/libfn/functional/pull/303), [#305](https://github.com/libfn/functional/pull/305)). The storage brace-initializes, and `is_constructible_v` spells parenthesized initialization — the two disagree on aggregates and narrowing — so construction constraints ask `T{args...}`, and ask it of the function that stores (`make_variadic_union`) rather than restating it in a trait free to drift. Sum-case callbacks are constrained in the immediate context, so a category-partial visitor sheds its non-viable overloads instead of poisoning the call; and the relocating verbs (`value_or`, `recover`, `fail`, `filter`) ask whether the side they carry unchanged can be carried in the value category it arrives in. -- **`sum` and `choice` became assignable — strong exception guarantee, no valueless state** ([#304](https://github.com/libfn/functional/pull/304), [#183](https://github.com/libfn/functional/issues/183)); the entry "`sum` assignment requires assignable alternatives" above reverses its destroy-and-reconstruct form in favour of the alternatives' own `operator=`. -- **Edges answer instead of erroring** ([#299](https://github.com/libfn/functional/pull/299), [#306](https://github.com/libfn/functional/pull/306), [#307](https://github.com/libfn/functional/pull/307)). `convertible_to_unexpected`, `convertible_to_optional` and `convertible_to_choice` answer `false` for `void`; a callback whose result no `sum` can hold drops its caller from the overload set; and `sum`'s hand-written `operator!=` is gone — C++20 rewriting synthesizes it from `==`, so their constraints cannot drift apart. - -## Codecov reads coverage from the `gcovr` aggregate — 12 July 2026 - -- **Codecov ingests an aggregated cobertura report rather than the raw `.gcov` text.** `gcov` reports template code once per instantiation, and codecov's parser counted the unexecuted instantiation records as missed lines: it reported ~98.9% where the aggregate over the same `.gcda` reports every line of `include/` covered. For a library which is almost entirely templates that is not a rounding error, and it is not fixable from this side — reported upstream in April 2024 and still unanswered: [codecov/feedback#334](https://github.com/codecov/feedback/issues/334). The aggregating is `--merge-lines`: since gcovr 8 a line record is kept per instantiation, which would have carried the same defect into the new report. -- **SonarCloud keeps reading the raw `.gcov` files.** Given the same cobertura report it counts 267 covered lines as missed, because its own parse of the sources marks as executable many lines for which no runtime code is ever emitted — template and constant-evaluated code that gcov cannot report on. So the two tools read different inputs, and the `.gcov` text is generated for sonarcloud alone. What an accurate SonarCloud report would need is not yet understood. -- **Branch coverage no longer counts the exception paths.** The aggregate excludes throw and unreachable branches, so the condition count reflects decisions written in the source rather than artefacts of the exception model. `parsers.cobertura.partials_as_hits` states for the parser in use the same lines-not-branches policy that the retired `parsers.gcov.branch_detection` block encoded. - -## `expected`'s associated types joined namespace `fn` — 10 July 2026 - -- **`fn` re-exports `unexpected`, `unexpect`, `unexpect_t` and `bad_expected_access` from `pfn`.** Nothing is added — these are using-declarations, so both spellings name the same types — but the `` vocabulary is complete under one namespace, and constructing the error state in otherwise pure-`fn` code no longer reaches into `pfn` (previously every example returned `pfn::unexpected`). No `optional` counterparts: `optional`'s associated types are already in C++20's `std`, so no polyfills and nothing to bring from `pfn` to `fn` namespace. - -## `fn::pack` gained the tuple protocol — 9 July 2026 - -- **`fn::pack` now models the tuple protocol** — `std::tuple_size`, `std::tuple_element`, and an ADL-found `get`, so a pack works with structured bindings and the generic `using std::get; get(p)` idiom. -- **A const pack propagates const onto its reference elements.** `get` carries the pack's const through to the element, so `get<0>` on a `const pack` yields `T const&`. This diverges from `std::tuple`, whose reference members ignore container const — a deliberate difference: it lets a const pack hand out read-only views of referenced data, which a caller passing a pack of references by const reference generally wants. `std::tuple_element` is specialized to match `get` rather than defer to the generic `tuple_element` wrapper. - -## `operator&` composes the `sum<>` unit error — 8 July 2026 - -- **A non-void `fn::expected` carrying the `sum<>` unit error now composes under `operator&`.** A never-erroring operand — `expected>`, the "cannot fail" grade — folds into a fallible `&`-chain, adding its value to the pack and no alternative to the widened error. The different-error overload had been ill-formed here: its error lambda deduced `void` for the `sum<>` side, poisoning the pack join's return-type deduction. The README example follows the fix, collapsing from a two-stage pipeline into a single `and_then` over the cartesian `(a & op & b)` dispatch table, its operator honestly typed `expected, sum<>>`. - -## Documentation realigned — 7 July 2026 - -- **README.md caught up with the two-library reality.** Its founding description — `fn` extends the `std` vocabulary types via inheritance and requires a C++23 standard library (gcc 13 / clang 18) — predated `pfn` (September 2025) and the C++20 rebase (June 2026). Replaced by the present state: `fn` builds on `pfn`, everything is C++20, minimums gcc 12 / clang 16. A "Using the library" section names the supported packaging routes. -- **The private-fork recommendation retired.** README advised consuming libfn via a private fork, a hedge against pre-standardization refactoring; packaging, the versioning contract and the approaching first tagged release replaced it with pinned-revision consumption. -- **This file introduced.** Living documents stay in timeless present tense; design transitions are recorded here, dated, in the same change that makes them. - -## Design update — 6 July 2026 - -- **`fn::optional` was rebased onto `pfn`'s implementation, and the superset principle became load-bearing** (#253): every `fn` type with a `pfn` counterpart is a strict superset of it — switching a valid `pfn` program to `fn` changes neither compilation nor behaviour, `noexcept` included; where the two shapes conflict, `pfn` wins. Enforced by `tests/fn/expected_polyfill.cpp` and `tests/fn/optional_polyfill.cpp`, which run the whole `pfn` suite against the `fn` types. Obsoletes `fn::optional`'s standalone implementation. -- **Range support (P3168) landed in both `pfn::optional` and `fn::optional`** (#257). Each library mints its own minimal iterator — a pointer wrapper exposing only what the standard mandates, a Hyrum's-law defence — mirrored per library rather than shared, because the iterator concepts demand exact-type signatures and no `pfn` type may be reachable through `fn`'s interface. -- **The in-place constructors of `sum` and `choice` became `explicit`** (#256). - -## `optional` polyfill — 3 July 2026 - -- **`pfn::optional` implemented** (#176): a C++20 polyfill of `std::optional` as specified for C++26, including `optional`. - -## C++20 became the sole export surface — 29 June 2026 - -- **`fn` moved off the C++23 standard library and onto `pfn`** (#202): `fn::expected` derives from `pfn`'s implementation instead of `std::expected` (`fn::optional` followed on 6 July). Obsoletes the founding design — extending `std` types via inheritance — and with it the C++23 standard-library requirement and the gcc 13 / clang 18 floor; the minimum toolchain became gcc 12 / clang 16. -- **MSVC became a supported compiler** (Visual Studio 2022 and 2026), joining gcc, clang and Apple Clang. -- **C++23 became an internal validation tier**: the test-only CMake option `VALIDATE_CXX23` additionally builds the tests and examples as C++23 on compilers with solid C++23 support (gcc 15+, clang 21+; rejected with MSVC). Obsoletes the packaging-facing `DISABLE_CXX23` knob from 30 May — packaging carries no standard-mode knob at all. - -## Fork-friendly analysis workflows — 23 June 2026 - -- **codecov and SonarCloud runs work from forks** (#240): a producer → artifact → consumer split keeps secrets away from PR-triggered code while forks report against their own accounts. Obsoletes upstream-only scanning. - -## Packaging and the versioning contract — 30 May 2026 - -- **`VERSION` became the single source of truth** (#212): mirrored into `ports/libfn/vcpkg.json` and `MODULE.bazel` by a pre-commit hook. The release contract: versions are `0.y.z`, a bump in `y` is breaking, a bump in `z` is a compatible fix or addition — but inline definitions may change, so one libfn version per binary. -- **Packaging landed** (#212, refined in #218): conan recipe (components `fn` and `pfn`), in-repo vcpkg port (`ports/libfn`) and Bazel module, joining the Nix flake (2024) — all exercised by consumer-side CI. CMake exports the `libfn::fn` and `libfn::pfn` targets. - -## `pfn` — 21 September 2025 - -- **`pfn` was born** (#155): a C++20 polyfill of ``, a second library beside `fn`. The start of the permanent two-library split: `pfn` polyfills what the committee has already standardized — specification fidelity is its whole point — while `fn` carries the novel extensions. At this point `fn` still extended `std::expected`/`std::optional` directly and required C++23; the rebase onto `pfn` came in June 2026. +- **Project inception**: a handful of direct commits set up the repository, before the pull-request history begins. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f86a089..8f23bad5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ cmake --build . --target coverage **Requirements:** -* **gcovr 8.4+:** The coverage build uses the `--merge-lines` option, which was added in `gcovr` 8.4. Since package managers often distribute older versions, using a Python virtual environment (`venv`) is recommended as the easiest way to install a newer version. +* **gcovr 8.4+:** The coverage build uses the `--merge-lines` option, which was added in `gcovr` 8.4. Since package managers often distribute older versions, using a Python virtual environment (`venv`, `uv` etc.) is recommended as the easiest way to install a newer version. * **Coverage tool:** The coverage tool must match your compiler: * GCC uses `gcov`. * Clang uses `llvm-cov gcov`. @@ -171,6 +171,8 @@ To make an `fn`-level facility available to `fn/detail`, hoist it: the implement The namespace spelling is derived, not copied: 0.y lines with y ≥ 1 share `v0_` (z bumps are ABI-compatible), the 0.0.z line versions per patch, a SemVer prerelease is appended (`-dev` → `_dev`), and the `_cxx26` twin (selected by defining `LIBFN_CXX26`) keeps `_cxx26` last. `pfn` is mode-less: its layouts never depend on the C++26 type ordering or other language features, so it wraps in `LIBFN_VERSION_BASE` — the plain spelling regardless of mode — and its types stay link-compatible across modes. A second hook (`scripts/check_namespace_wrap.py`) verifies the layer rule: every `namespace fn` opening in `include/` carries `inline namespace LIBFN_VERSION`, every `namespace pfn` opening `inline namespace LIBFN_VERSION_BASE`. +CHANGELOG.md is summarized immediately before a release: the accumulated dated entries collapse into a smaller list describing changes in a compact manner, without dates. The summary also names the commit carrying the last complete detailed list — the one right before the first release candidate — where the full history stays readable. + ## Pre-commit This repository uses [pre-commit](https://pre-commit.com/) to enforce formatting of the C++ source code and perform other checks. The details can be seen in `.pre-commit-config.yaml`. To install git commit hooks, which will run checks on the repository as you commit changes: diff --git a/MODULE.bazel b/MODULE.bazel index bbefc727..09b00a40 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "libfn", - version = "0.0.9", + version = "0.1.0-rc1", ) bazel_dep(name = "rules_cc", version = "0.1.1") diff --git a/README.md b/README.md index cdd0ac13..93eb7cb0 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ public: -> fn::expected> { if (d == 0) return fn::unexpected{fn::copack{DivByZero{}}}; + // Note, std::gcd precondition is that `|n|` and `|d|` must both be representable. if (n == std::numeric_limits::min() || d == std::numeric_limits::min()) return fn::unexpected{fn::copack{Overflow{}}}; @@ -117,7 +118,7 @@ The library features demonstrated by the code example above: * **Monadic sequences** — `operator|` pipes a `expected` (or `optional`) through operations: `and_then` and `transform` act on the value, `or_else`, `recover` and `transform_error` on the error, with `filter`, `inspect`, `fail` and more besides. * **Graded errors** — each stage fails its own way — a malformed string, a zero denominator, an out-of-range result — and the library folds these into one `copack` whose type it derives for you: here `copack`, never spelled by hand. -* **Composing values** — `operator&` gathers successful operands left to right: two values become a `pack`, a third appends to it. A `pack` is a heterogeneous product — the operands as one value, spread into the next call; for example in `make`, where a `pack` returned from `parse` is passed to an overload taking two numbers. +* **Composing values** — `operator&` gathers successful operands left to right: two values become a `pack`, a third appends to it. A `pack` is a heterogeneous product — the operands as one value, spread into the next call; for example in `make`'s `string_view` overload, where a `pack` returned from `parse` is passed to an overload taking two numbers. * **Composing alternatives** — when a side is a `copack` (a co-product — one of several types, indexed by type, not by position like `std::variant`), `&` distributes over it, pairing every alternative with the other operand. Two copacks yield the full cartesian product. The result type is flattened, deduplicated and sorted for you. * **Multidispatch** — the pack (or copack of packs) flows into the next stage as separate arguments. An `fn::overload` — or any function — dispatches on the runtime alternative by ordinary overload resolution. Dispatch is exhaustive: a missing handler is a compile error. * **Identity monad** — `expected>` cannot hold an error (enforced at compile time), a spelling of the identity monad; the example lifts `op` into it as `Op`. @@ -157,20 +158,22 @@ A third target, `libfn::fn_cxx26`, is the same headers entered with the [`LIBFN_ With `libfn::fn_cxx26`, a compiler that does not implement `std::type_order` stops at the first libfn header, with an `#error` naming the feature. Mixing the two entry points in one binary stops at the linker, on an undefined reference whose type names differ from the definition's by the `_cxx26` ABI namespace. Both are loud by design: the namespaces are separate so that two layouts cannot merge unnoticed — the invariant [TYPE_ALGEBRA.md](TYPE_ALGEBRA.md) calls one normalization order per program. -Packaging is provided — and exercised by CI — for [conan](conanfile.py), [vcpkg](ports/libfn) (an in-repo port), [Nix](flake.nix) and [Bazel](MODULE.bazel); plain CMake `FetchContent` or `add_subdirectory` works as well. Until the first tagged release, consume a pinned git revision — and read [Backwards compatibility](#backwards-compatibility). +Packaging is provided — and exercised by CI — for [conan](conanfile.py), [vcpkg](ports/libfn) (an in-repo port), [Nix](flake.nix) and [Bazel](MODULE.bazel); plain CMake `FetchContent` or `add_subdirectory` works as well. Consume a tagged release — and read [Backwards compatibility](#backwards-compatibility). Every packaging route above except Bazel also delivers the compile options the headers require. Under Bazel — and a plain copy of `include/` — these options don't arrive automatically; provide them yourself: C++20 or newer (`--cxxopt=-std=c++20` in Bazel), `-Wno-missing-braces` on clang (`fn::pack` initialization elides braces by design), and with MSVC `/permissive-` plus `_HAS_CXX23`. The authoritative set is the `INTERFACE` options in [cmake/CompilationOptions.cmake](cmake/CompilationOptions.cmake). +A single header — the whole library in one file — serves online compilers and standalone reproducers, where an include path is not an option. The documentation site publishes it at [`https://libfn.org/libfn.hpp`](https://libfn.org/libfn.hpp), which Compiler Explorer can include directly by URL; each tagged release attaches the same file as `libfn-.hpp`. Prefer the real headers otherwise: they give real paths in diagnostics. The compile options above apply; define `LIBFN_CXX26` and compile as C++26 to select the C++26 mode. + ## Backwards compatibility -The maintainers aim for compatibility with the proposed changes to the C++ standard library, **rather than with the existing uses** of the code in this repo. In practice, this means that all code in this repo should be considered "under intensive development and unstable" until the standardization of the proposed facilities. +The maintainers aim for compatibility with the proposed changes to the C++ standard library, **rather than with the existing uses** of the code in this repo. A facility proposed in `include/fn` therefore tracks its paper: names and semantics may change when the paper does. Such a change bumps **`y`**, and so arrives only with a deliberate upgrade. ## Versioning and ABI Releases are numbered `0.y.z` and will stay below `1.0.0` for the foreseeable future. [SemVer](https://semver.org/) treats any `0.y.z` version as unstable — anything may change — so libfn narrows that into a usable contract: - a bump in **`y`** is a **breaking** change (API and/or ABI); -- a bump in **`z`** is a bug fix or a purely additive extension: the API and ABI stay compatible, but inline function definitions may change — see below. +- a bump in **`z`** is a bug fix or a purely additive extension: upgrading never breaks a consumer. Because the library is header-only, **use a single libfn version per binary**. Mixing versions in one program is an ODR violation — and that includes two `z` releases of the *same* `y` line, whose inline definitions may differ even though the ABI matches. @@ -182,6 +185,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development environment, building * Gašper Ažman, for providing the inspiration in ["(Fun)ctional C++ and the M-word"][gasper-functional-presentation] * Bartosz Milewski, for taking the time to explain [parametrised and graded monads][parametrised-and-graded-monads] and [effect systems][effect-systems] +* [Mykola Golubyev][mykola-golubyev], for implementing fixes in [znai][znai] needed by this project * [Ripple][ripple], for allowing the main author the time to work on this library ## License @@ -199,3 +203,5 @@ Distributed under the ISC License; see [LICENSE.md](LICENSE.md) for the terms. [parametrised-and-graded-monads]: https://arxiv.org/pdf/2001.10274.pdf [effect-systems]: https://www.doc.ic.ac.uk/~dorchard/publ/haskell14-effects.pdf [ripple]: https://ripple.com/ +[mykola-golubyev]: https://github.com/MykolaGolubyev +[znai]: https://github.com/testingisdocumenting/znai diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index f55af1b0..249e75c5 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -73,11 +73,11 @@ auto load(UserId) -> fn::expected>; auto graded_pipeline(std::string_view sv) -> void { - auto pipeline = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); + auto result = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); - // The exact derived error union is recorded in the type: + // The exact derived error union of the pipeline is recorded in the type: static_assert( - std::same_as>>); } ``` @@ -101,7 +101,7 @@ You may also use `copack` on a value side of most carriers (except for `just > In `libfn` that pomonoid is carried by the finite sets of C++ types: > -> - **Grades ($\mathcal{E}$)**: Finite sets of alternative types (errors). +> - **Grades ($\mathcal{E}$)**: Finite sets of alternative types (usually errors). > - **Monoidal multiplication ($\bullet$)**: Set union ($\cup$), representing effect accumulation. > - **Identity ($I$)**: The empty set ($\emptyset$), representing the zero-error/never-failing state. > - **Partial order ($\le$)**: Subset relation ($\subseteq$), which licenses effect approximation (subeffecting / widening). @@ -1101,12 +1101,13 @@ Guidelines: * The primary audience of this document is C++ software engineers. It must be easy for them to read and digest. * The mathematical asides anchor the prose in category theory for those versed in it: each states precisely what the surrounding prose approximates, and skipping them costs no practical understanding. * Mathematical asides must be maintained to demonstrate the sanity and coherence of the library design. -* The numbered sections form the pedagogical arc the rules above govern. The unnumbered closing sections are reference appendices: Functional terminology restates the document's terms and may introduce nothing new; Further reading lists the formal sources. +* The numbered sections form the pedagogical arc the rules above govern; the unnumbered closing sections are reference appendices and may introduce nothing new. Index: -**1. The Opening Map (Preamble & Section 3)** +**1. The Opening Map (Preamble & Sections 1, 3)** * **Payload Types (`pack`/`copack`)** and **Computation Carriers (`optional`/`expected`/`just`/`choice`)** are first introduced as a sparse, high-level list in the main preamble. + * **Section 1** motivates the algebra with the problem it solves, with a pipeline example of graded monad. * The term **identity cluster** is introduced in Section 3 under the infallible carriers as a simple grouping definition: "_Together, `just`, `choice` and `expected>` form the identity cluster_." It does not expand on its operations or mathematical properties here, keeping the introduction minimal. **2. Core Algebraic Foundations (Sections 2, 3, & 4)** diff --git a/VERSION b/VERSION index c5d54ec3..77d657b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.9 +0.1.0-rc1 diff --git a/cmake/Docs.cmake b/cmake/Docs.cmake index c950bad9..117c084d 100644 --- a/cmake/Docs.cmake +++ b/cmake/Docs.cmake @@ -33,6 +33,10 @@ macro(znai_export_docs TARGET SOURCE_DIR DEPLOY_DIR) COMMAND ${Znai} --source ${SOURCE_DIR} --deploy ${DEPLOY_DIR} --doc-id '""' --lookup-paths ${CMAKE_BINARY_DIR} COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/scripts/fix_site_urls.py" "${DEPLOY_DIR}" + # The site carries the single-header artifact: Pages serves it with the permissive + # CORS that Compiler Explorer's URL include needs and GitHub release assets lack. + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/amalgamate.py" -o "${DEPLOY_DIR}/libfn.hpp" COMMENT "Exporting documentation to ${DEPLOY_DIR}" ) endmacro() diff --git a/examples/calculator/calculator.hpp b/examples/calculator/calculator.hpp index 975dd53a..3ad129ea 100644 --- a/examples/calculator/calculator.hpp +++ b/examples/calculator/calculator.hpp @@ -6,11 +6,11 @@ #ifndef EXAMPLES_CALCULATOR_CALCULATOR #define EXAMPLES_CALCULATOR_CALCULATOR -#include "fn/and_then.hpp" -#include "fn/expected.hpp" -#include "fn/pack.hpp" -#include "fn/transform.hpp" -#include "fn/utility.hpp" +#include +#include +#include +#include +#include #include #include diff --git a/examples/calculator/main.cpp b/examples/calculator/main.cpp index 59f61ab9..ea2c15e5 100644 --- a/examples/calculator/main.cpp +++ b/examples/calculator/main.cpp @@ -5,7 +5,7 @@ #include "calculator.hpp" -#include "fn/utility.hpp" +#include #include #include diff --git a/examples/calculator/test.cpp b/examples/calculator/test.cpp index b983f0c2..581e4416 100644 --- a/examples/calculator/test.cpp +++ b/examples/calculator/test.cpp @@ -5,7 +5,7 @@ #include "calculator.hpp" -#include "fn/pack.hpp" +#include #include diff --git a/examples/polygon/main.cpp b/examples/polygon/main.cpp index fbab9052..b56ff6bb 100644 --- a/examples/polygon/main.cpp +++ b/examples/polygon/main.cpp @@ -5,9 +5,9 @@ #include "polygon.hpp" -#include "fn/and_then.hpp" -#include "fn/expected.hpp" -#include "fn/or_else.hpp" +#include +#include +#include #include #include diff --git a/examples/polygon/polygon.hpp b/examples/polygon/polygon.hpp index 490ff8dc..e6b93a56 100644 --- a/examples/polygon/polygon.hpp +++ b/examples/polygon/polygon.hpp @@ -6,8 +6,8 @@ #ifndef EXAMPLES_POLYGON_POLYGON #define EXAMPLES_POLYGON_POLYGON -#include "fn/and_then.hpp" -#include "fn/expected.hpp" +#include +#include #include #include diff --git a/examples/readme/main.cpp b/examples/readme/main.cpp index fbcb727d..00b2e612 100644 --- a/examples/readme/main.cpp +++ b/examples/readme/main.cpp @@ -123,6 +123,7 @@ class Rational { -> fn::expected> { if (d == 0) return fn::unexpected{fn::copack{DivByZero{}}}; + // Note, std::gcd precondition is that `|n|` and `|d|` must both be representable. if (n == std::numeric_limits::min() || d == std::numeric_limits::min()) return fn::unexpected{fn::copack{Overflow{}}}; diff --git a/examples/type_algebra/main.cpp b/examples/type_algebra/main.cpp index dca67560..4a32644b 100644 --- a/examples/type_algebra/main.cpp +++ b/examples/type_algebra/main.cpp @@ -80,11 +80,11 @@ auto load(UserId) -> fn::expected>; auto graded_pipeline(std::string_view sv) -> void { - auto pipeline = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); + auto result = parse_id(sv) | fn::and_then(validate) | fn::and_then(load); - // The exact derived error union is recorded in the type: + // The exact derived error union of the pipeline is recorded in the type: static_assert( - std::same_as>>); } // sync-example-graded-pipeline diff --git a/include/libfn_version.hpp b/include/libfn_version.hpp index 4b32e6e2..fe710fc9 100644 --- a/include/libfn_version.hpp +++ b/include/libfn_version.hpp @@ -7,12 +7,12 @@ #define INCLUDE_LIBFN_VERSION // Mode-less version for pfn, which never uses C++26 features. -#define LIBFN_VERSION_BASE v0_0_9 +#define LIBFN_VERSION_BASE v0_1_rc1 #ifdef LIBFN_CXX26 -#define LIBFN_VERSION v0_0_9_cxx26 +#define LIBFN_VERSION v0_1_rc1_cxx26 #else -#define LIBFN_VERSION v0_0_9 +#define LIBFN_VERSION v0_1_rc1 #endif #endif // INCLUDE_LIBFN_VERSION diff --git a/ports/libfn/vcpkg.json b/ports/libfn/vcpkg.json index a043b222..14f6f47b 100644 --- a/ports/libfn/vcpkg.json +++ b/ports/libfn/vcpkg.json @@ -1,6 +1,6 @@ { "name": "libfn", - "version-semver": "0.0.9", + "version-semver": "0.1.0-rc1", "description": "Functional programming in C++", "homepage": "https://github.com/libfn/functional", "license": "ISC", diff --git a/scripts/amalgamate.py b/scripts/amalgamate.py new file mode 100644 index 00000000..7f28189d --- /dev/null +++ b/scripts/amalgamate.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Generate a single self-contained header from the include/ tree. + +Intended for online compilers (Compiler Explorer, Wandbox) and standalone bug +reproducers, where adding an include path is not an option. The output is a +release artifact, not a supported way to consume the library: prefer the real +headers, which give real paths in diagnostics. + +The expansion rule is derived from the headers themselves rather than from a +hand-kept list, so it stays correct as headers are added: + + - a header carrying an `#ifndef INCLUDE_*` guard is emitted at its first + inclusion and skipped afterwards, exactly as the preprocessor would; + - a header without a guard is emitted at *every* inclusion site. This is not + an edge case to tolerate but the reason the rule exists: fn/detail/ + macro_begin.hpp and macro_end.hpp are deliberately guardless, and bracket + the 23 headers that use FWD / DEDUCED_RETURN. Emitting them once would + leave FWD undefined after the first macro_end. + +Guards are kept in the output (harmless, and the result stays idempotent under +double inclusion). Standard-library includes at a header's top level — outside +any conditional block other than the include guard — are hoisted and +deduplicated; inside a conditional block they stay in place, where the +preprocessor evaluates them in context. LIBFN_CXX26 is never baked in: +libfn_version.hpp is inlined verbatim, so one artifact serves both modes, +selected as usual by defining the macro. + +The rewrites recognize the includes these headers use, conservatively: a local +include the script cannot resolve or cannot safely relocate, and any +conditional structure it cannot follow, fail the run rather than guess their +way into the artifact. + +Usage: scripts/amalgamate.py [-o OUTPUT] [--revision SHA] +""" +import argparse +import pathlib +import re +import subprocess +import sys + +repo = pathlib.Path(__file__).resolve().parents[1] +include = repo / "include" +cmakelists = include / "CMakeLists.txt" + +# The install file sets are the source of truth for what ships; check_install_headers.py +# keeps them honest against the tree. Public roots are everything not under a detail/. +LISTED_RE = re.compile(r"^\s+((?:pfn|fn)/\S+\.hpp|libfn_version\.hpp)\s*$", re.MULTILINE) +GUARD_RE = re.compile(r"^#ifndef (INCLUDE_\w+)\r?\n#define \1\s*$", re.MULTILINE) +LOCAL_INCLUDE_RE = re.compile(r'^#include ["<]((?:pfn|fn)/[^">]+\.hpp|libfn_version\.hpp)[">]\s*$') +# libfn_version.hpp is spelled without a directory, so exclude it explicitly. +SYSTEM_INCLUDE_RE = re.compile(r"^#include <(?!libfn_version\.hpp)([^/>]+)>\s*$") +BANNER_RE = re.compile(r"\A(?://[^\n]*\n)+\n") # per-file ISC notice; LICENSE.md at the top covers them all +COND_OPEN_RE = re.compile(r"^\s*#\s*(?:if|ifdef|ifndef)\b") +COND_CLOSE_RE = re.compile(r"^\s*#\s*endif\b") +# Any local-include-looking line the strict recognizer does not match must fail loudly. +LOCAL_SUSPECT_RE = re.compile(r'^\s*#\s*include\s*["<](?:(?:pfn|fn)/|libfn_version\.hpp)') + +LICENCE = """// libfn — single-header amalgamation of https://github.com/libfn/functional +// +{licence} +// +// GENERATED FILE — DO NOT EDIT. Regenerate with scripts/amalgamate.py. +// Version: {version} +// Revision: {revision} +""" + + +def read(header: str) -> str: + path = include / header + if not path.is_file(): + sys.stderr.write(f"{header}: included but not found under include/\n") + sys.exit(1) + return path.read_text() + + +def public_roots() -> list[str]: + listed = set(LISTED_RE.findall(cmakelists.read_text())) + if not listed: + sys.stderr.write(f"{cmakelists.relative_to(repo)}: no header file sets found\n") + sys.exit(1) + return sorted(h for h in listed if "/detail/" not in h) + + +def expand(header: str, emitted: set[str], stack: tuple[str, ...], system: set[str], out: list[str]) -> None: + """Depth-first expansion reproducing the preprocessor's order for `header`.""" + if header in stack: + sys.stderr.write(f"{header}: include cycle via {' -> '.join(stack)}\n") + sys.exit(1) + text = read(header) + guarded = bool(GUARD_RE.search(text)) + if guarded and header in emitted: + return + emitted.add(header) + + text = BANNER_RE.sub("", text) + out.append(f"\n// ---------- BEGIN {header} ----------\n") + # Hoisting an include out of a conditional block would change what the + # preprocessor sees, so hoist only at the file's baseline depth — inside the + # include guard and nothing else; deeper system includes stay in place. + baseline = 1 if guarded else 0 + depth = 0 + kept: list[str] = [] + for lineno, line in enumerate(text.splitlines(), 1): + if COND_OPEN_RE.match(line): + depth += 1 + elif COND_CLOSE_RE.match(line): + depth -= 1 + elif local := LOCAL_INCLUDE_RE.match(line): + if depth != baseline: + sys.stderr.write(f"{header}:{lineno}: local include inside a conditional block\n") + sys.exit(1) + out.append("\n".join(kept)) + kept = [] + expand(local.group(1), emitted, stack + (header,), system, out) + out.append(f"\n// ---------- RESUME {header} ----------\n") + continue + elif found := SYSTEM_INCLUDE_RE.match(line): + if depth == baseline: + system.add(found.group(1)) + continue + elif LOCAL_SUSPECT_RE.match(line): + sys.stderr.write(f"{header}:{lineno}: unrecognized local include spelling\n") + sys.exit(1) + kept.append(line) + if depth != 0: + sys.stderr.write(f"{header}: unbalanced preprocessor conditionals\n") + sys.exit(1) + out.append("\n".join(kept)) + out.append(f"\n// ---------- END {header} ----------\n") + + +def revision() -> str: + try: + described = subprocess.run( + ["git", "-C", str(repo), "describe", "--tags", "--always", "--dirty"], + capture_output=True, + text=True, + check=True, + ) + return described.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("-o", "--output", type=pathlib.Path, help="write here instead of stdout") +parser.add_argument("--revision", help="provenance string; defaults to git describe") +args = parser.parse_args() + +emitted: set[str] = set() +system: set[str] = set() +body: list[str] = [] +for root in public_roots(): + expand(root, emitted, (), system, body) + +version = (repo / "VERSION").read_text().strip() +licence = "\n".join(("// " + line).rstrip() for line in (repo / "LICENSE.md").read_text().splitlines()) +document = [ + LICENCE.format(licence=licence, version=version, revision=args.revision or revision()), + "\n#ifndef INCLUDE_LIBFN_AMALGAMATED\n#define INCLUDE_LIBFN_AMALGAMATED\n\n", + "".join(f"#include <{h}>\n" for h in sorted(system)), + "".join(body), + "\n#endif // INCLUDE_LIBFN_AMALGAMATED\n", +] +text = re.sub(r"\n{3,}", "\n\n", "".join(document)) + +if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text) +else: + sys.stdout.write(text) diff --git a/scripts/stage_docs_source.py b/scripts/stage_docs_source.py index 903b0547..a1fe8277 100644 --- a/scripts/stage_docs_source.py +++ b/scripts/stage_docs_source.py @@ -11,6 +11,9 @@ The rewrites recognize the markdown these documents use, conservatively: an ambiguous or unrecognized construct fails the staging rather than guessing its way onto the site. + +The staged footer additionally names the version the site was generated from, read literally +from VERSION. """ from __future__ import annotations @@ -295,6 +298,17 @@ def toc(out: pathlib.Path, generated: dict[str, list[str]]) -> None: (out / "toc").write_text("\n".join(lines) + "\n", encoding="utf-8") +def footer(out: pathlib.Path, repo: pathlib.Path) -> None: + """Append the version the site was generated from to the staged footer's right column.""" + path = out / "footer.md" + lines = path.read_text(encoding="utf-8").splitlines() + if "right:" not in lines or not lines or lines[-1] != "```": + fail("docs/footer.md: expected a columns block with a right: column, closed by a bare fence") + version = (repo / "VERSION").read_text(encoding="utf-8").strip() + lines[-1:] = ["", f"libfn version {version}.", "```"] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("repo", type=pathlib.Path, help="repository root") @@ -305,6 +319,7 @@ def main() -> None: if out.exists(): shutil.rmtree(out) shutil.copytree(repo / "docs", out) + footer(out, repo) # docs/lookup-paths reaches the sources it quotes relatively, which the staged copy is no # longer placed to do. diff --git a/test_package/.clangd b/test_package/.clangd index 46854238..6e54daa3 100644 --- a/test_package/.clangd +++ b/test_package/.clangd @@ -7,5 +7,6 @@ CompileFlags: CompilationDatabase: None Diagnostics: ClangTidy: - Add: [bugprone*, readability*, performance*] - Remove: [readability-static-accessed-through-instance, readability-magic-numbers, readability-identifier-length] + # This is play code. Remove what the root .clangd's Adds inherited here. + Add: [bugprone*, readability*] + Remove: [modernize*, performance*, readability-static-accessed-through-instance, readability-magic-numbers, readability-identifier-length] diff --git a/test_package/src/main.cpp b/test_package/src/main.cpp index ff44b029..6f11b327 100644 --- a/test_package/src/main.cpp +++ b/test_package/src/main.cpp @@ -1,54 +1,75 @@ -#include #include #include -#include +#include -#include +#include #include +#include -static constexpr char const *src[] = {")", R"(#include -#include +static constexpr char const *src[] = { + R"(#include #include -#include +#include -#include +#include #include +#include -static constexpr char const *src[] = {"%c", R"(%s%c", - ""}; +static constexpr char const *src[] = { +)", + R"( +}; -int main() +int main())", + R"( { - using quine_t = fn::choice_for, fn::pack, fn::pack>; - return std::accumulate( - std::begin(src), std::end(src), quine_t{fn::as_pack()}, // - [](quine_t &&acc, char const *s) -> quine_t { - return acc - | fn::and_then(fn::overload{[s]() -> quine_t { return fn::pack{*s}; }, - [s](char c) -> quine_t { return fn::pack{c, s}; }, - [](char c, char const *fmt) -> quine_t { - std::printf(fmt, c, fmt, c); - return fn::as_pack(); - }}); - }) - .apply([]([[maybe_unused]] auto &&...args) -> int { return sizeof...(args); }); + static constexpr auto quote = [](char const *s) { return std::string(" R\"(") + s + ")\",\n"; }; + using quine_t + = fn::choice_for, fn::pack, fn::pack>;)", + R"( + return std::accumulate(std::begin(src), // + std::end(src), // + quine_t(fn::as_pack()), // + [](quine_t acc, char const *s) { + return std::move(acc) + | fn::transform(fn::overload{ + [s]() { return fn::as_pack(s, quote(s)); }, + [s](std::string prefix, std::string quoted, auto &&...suffix) { + return fn::as_pack( + prefix, quoted + quote(s), (suffix + ... + s)); + }, + }); + }))", + R"( + .apply([](auto &&...strings) { + ((std::cout << strings), ...); + return 0; + }); } )", - ""}; + +}; int main() { - using quine_t = fn::choice_for, fn::pack, fn::pack>; - return std::accumulate( - std::begin(src), std::end(src), quine_t{fn::as_pack()}, // - [](quine_t &&acc, char const *s) -> quine_t { - return acc - | fn::and_then(fn::overload{[s]() -> quine_t { return fn::pack{*s}; }, - [s](char c) -> quine_t { return fn::pack{c, s}; }, - [](char c, char const *fmt) -> quine_t { - std::printf(fmt, c, fmt, c); - return fn::as_pack(); - }}); - }) - .apply([]([[maybe_unused]] auto &&...args) -> int { return sizeof...(args); }); + static constexpr auto quote = [](char const *s) { return std::string(" R\"(") + s + ")\",\n"; }; + using quine_t + = fn::choice_for, fn::pack, fn::pack>; + return std::accumulate(std::begin(src), // + std::end(src), // + quine_t(fn::as_pack()), // + [](quine_t acc, char const *s) { + return std::move(acc) + | fn::transform(fn::overload{ + [s]() { return fn::as_pack(s, quote(s)); }, + [s](std::string prefix, std::string quoted, auto &&...suffix) { + return fn::as_pack( + prefix, quoted + quote(s), (suffix + ... + s)); + }, + }); + }) + .apply([](auto &&...strings) { + ((std::cout << strings), ...); + return 0; + }); }