Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#
# Copyright (c) 2023 Christian Mazakas
# Copyright (c) 2023 Alan de Freitas
# Copyright (c) 2026 Michael Vandeberg
#
# Distributed under the Boost Software License, Version 1.0. (See accompanying
# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
Expand Down Expand Up @@ -249,6 +250,21 @@ jobs:
working-directory: boost-root/libs/capy/doc
run: node lint/doc-lint.mjs

# BLOCKING, and deliberately outside the baseline/comparator posture above:
# this is not a style rule with a backlog to burn down, it is a structural
# check that every `include::` still resolves. Pages take code straight from
# the compiled snippets and, since #381, from the public headers, so an
# include naming a tag that no longer exists renders an EMPTY block rather
# than failing. Asciidoctor exits 0 on that (warning only) and the Antora
# leg above only asserts that build/site exists, so nothing else here would
# catch a marker deleted during ordinary refactoring. It stands at zero
# violations, so there is nothing to grandfather.
- name: "Lint: include tags resolve"
if: always()
continue-on-error: false
working-directory: boost-root/libs/capy/doc
run: node lint/check-include-tags.mjs

- name: "Lint: accessibility (pa11y-ci)"
if: always()
continue-on-error: true
Expand Down
11 changes: 11 additions & 0 deletions doc/antora.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#
# Copyright (c) 2025 Mohammad Nejati
# Copyright (c) 2026 Michael Vandeberg
#
# Distributed under the Boost Software License, Version 1.0. (See accompanying
# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
Expand Down Expand Up @@ -28,6 +29,16 @@ ext:
into: modules/ROOT/examples/snippets
- dir: test/doc/programs
into: modules/ROOT/examples/programs
# Public headers carrying `tag::`/`end::` markers, so pages can include a
# definition straight from the library instead of keeping a copy that
# drifts. Scanned under their real path, so an `example$include/...`
# target in a page reads as the header it actually is.
- dir: include
files:
- boost/capy/concept/*.hpp
- boost/capy/buffers.hpp
- boost/capy/ex/frame_allocator.hpp
into: modules/ROOT/examples/include
cpp-reference:
config: doc/mrdocs.yml
cpp-tagfiles:
Expand Down
145 changes: 145 additions & 0 deletions doc/lint/check-include-tags.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env node
//
// check-include-tags.mjs — every `include::example$...[tag=...]` in a page
// must resolve to a real file that really carries that tag. Node built-ins
// only, no dependencies.
//
// Why this exists: pages pull code out of compiled snippets and, since
// issue #381, straight out of the public headers, so a definition shown on
// a page cannot drift from the definition that ships. That moves the risk
// rather than removing it. Asciidoctor treats a missing include tag as a
// WARNING and still exits 0 — verified locally: a page referencing a
// nonexistent tag renders an empty listing block and the build succeeds.
// The Antora CI leg cannot catch it either; it only asserts that
// `build/site` exists, precisely because Antora also exits 0 on failure.
//
// So without this gate, deleting a `tag::`/`end::` marker from a header
// during ordinary refactoring silently empties whatever page included it,
// and nothing goes red. That is a worse failure than the drift it replaced:
// drift is at least visible on the page.
//
// The example$ -> repo-path mapping is read out of doc/antora.yml's
// collector scan config rather than hardcoded here, so adding a scan entry
// cannot leave this check behind.
//
// Blocking: exits 1 on any unresolved include target or missing tag.
//
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DOC_DIR = path.resolve(SCRIPT_DIR, '..');
const REPO_ROOT = path.resolve(DOC_DIR, '..');
const PAGES_DIR = path.join(DOC_DIR, 'modules', 'ROOT', 'pages');
const EXAMPLES_PREFIX = 'modules/ROOT/examples';

// Parse the `dir:`/`into:` pairs under ext.collector.scan in antora.yml.
// A full YAML parser would be a dependency; the block is a flat list of
// two- and three-key entries, so an indentation-aware line scan is enough.
function readScanMap(yamlPath) {
const lines = fs.readFileSync(yamlPath, 'utf8').split('\n');
const entries = [];
let cur = null;
for (const line of lines) {
if (/^\s*#/.test(line)) continue;
const dir = line.match(/^\s*-\s*dir:\s*(\S+)\s*$/);
if (dir) {
if (cur) entries.push(cur);
cur = { dir: dir[1], into: null };
continue;
}
const into = line.match(/^\s*into:\s*(\S+)\s*$/);
if (into && cur) {
cur.into = into[1];
entries.push(cur);
cur = null;
}
}
if (cur) entries.push(cur);

// example$<sub>/<rest> -> <dir>/<rest>. Longest prefix wins, so a nested
// mapping such as examples/snippets is preferred over bare examples.
return entries
.filter((e) => e.into && e.into.startsWith(EXAMPLES_PREFIX))
.map((e) => ({
prefix: e.into.slice(EXAMPLES_PREFIX.length).replace(/^\//, ''),
dir: e.dir,
}))
.sort((a, b) => b.prefix.length - a.prefix.length);
}

function resolveTarget(resource, scanMap) {
for (const { prefix, dir } of scanMap) {
if (prefix === '') return path.join(REPO_ROOT, dir, resource);
if (resource === prefix || resource.startsWith(prefix + '/')) {
return path.join(REPO_ROOT, dir, resource.slice(prefix.length).replace(/^\//, ''));
}
}
return null;
}

// tag=a | tags=a;b;!c | tags=a,b — negations and wildcards select
// nothing on their own, so they are not names this check can verify.
function tagsOf(attrs) {
const m = attrs.match(/\btags?=([^,\]]*)/);
if (!m) return [];
return m[1]
.split(/[;,]/)
.map((t) => t.trim())
.filter((t) => t && !t.startsWith('!') && !t.includes('*'));
}

function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else if (e.name.endsWith('.adoc')) out.push(p);
}
return out;
}

const scanMap = readScanMap(path.join(DOC_DIR, 'antora.yml'));
if (scanMap.length === 0) {
console.error('check-include-tags: no collector scan entries found in antora.yml');
process.exit(1);
}

const violations = [];
let checked = 0;

for (const page of walk(PAGES_DIR)) {
const rel = path.relative(REPO_ROOT, page);
const lines = fs.readFileSync(page, 'utf8').split('\n');
lines.forEach((line, i) => {
const m = line.match(/^include::example\$(\S+?)\[([^\]]*)\]/);
if (!m) return;
const [, resource, attrs] = m;
const where = `${rel}:${i + 1}`;
const target = resolveTarget(resource, scanMap);
if (!target) {
violations.push(`${where}: example$${resource} matches no collector scan entry`);
return;
}
if (!fs.existsSync(target)) {
violations.push(`${where}: example$${resource} resolves to a missing file (${path.relative(REPO_ROOT, target)})`);
return;
}
const body = fs.readFileSync(target, 'utf8');
for (const tag of tagsOf(attrs)) {
checked++;
const has = body.includes(`tag::${tag}[]`) && body.includes(`end::${tag}[]`);
if (!has) {
violations.push(`${where}: tag '${tag}' not found in ${path.relative(REPO_ROOT, target)}`);
}
}
});
}

if (violations.length) {
console.error(`check-include-tags: ${violations.length} violation(s)\n`);
for (const v of violations) console.error(` ${v}`);
process.exit(1);
}

console.log(`check-include-tags: OK — ${checked} tagged include(s) resolve to a live tag.`);
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ cpp:safe_resume[]'s implementation:
[source,cpp]
----
include::example$snippets/9k_executor.cpp[tag=safe_resume]
include::example$include/boost/capy/ex/frame_allocator.hpp[tag=safe_resume]
----
The cost is two TLS accesses (one read, one write) per `.resume()` call, negligible compared to the cost of resuming a coroutine.
Expand Down
4 changes: 2 additions & 2 deletions doc/modules/ROOT/pages/5.buffers/5a.buffers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,14 @@ Treating a single buffer as a one-element sequence is deliberate. It lets one co

[source,cpp]
----
include::example$snippets/5c_sequences.cpp[tag=const_buffer_sequence_concept,indent=0]
include::example$include/boost/capy/buffers.hpp[tag=const_buffer_sequence_concept,indent=0]
----

A type satisfies cpp:ConstBufferSequence[] if it converts to cpp:const_buffer[] directly, or if it is a bidirectional range whose elements convert to cpp:const_buffer[].

[source,cpp]
----
include::example$snippets/5c_sequences.cpp[tag=mutable_buffer_sequence_concept,indent=0]
include::example$include/boost/capy/buffers.hpp[tag=mutable_buffer_sequence_concept,indent=0]
----

cpp:MutableBufferSequence[] follows the same pattern for cpp:mutable_buffer[].
Expand Down
4 changes: 2 additions & 2 deletions doc/modules/ROOT/pages/6.streams/6b.streams.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A type satisfies cpp:ReadStream[] if it provides partial read operations via `re

[source,cpp]
----
include::example$snippets/6b_streams.cpp[tag=read_stream_concept]
include::example$include/boost/capy/concept/read_stream.hpp[tag=read_stream_concept]
----

The `requires` clause names a single representative buffer (cpp:mutable_buffer_archetype[]) because a {cpp} concept cannot say "works with every buffer sequence." The real contract is that `read_some` accepts *any* cpp:MutableBufferSequence[]—one buffer or a range; the archetype only samples that requirement.
Expand Down Expand Up @@ -40,7 +40,7 @@ A type satisfies cpp:WriteStream[] if it provides partial write operations via `

[source,cpp]
----
include::example$snippets/6b_streams.cpp[tag=write_stream_concept]
include::example$include/boost/capy/concept/write_stream.hpp[tag=write_stream_concept]
----

As with cpp:ReadStream[], the cpp:const_buffer_archetype[] is only a representative: the real contract is that `write_some` accepts *any* cpp:ConstBufferSequence[], which a {cpp} concept cannot fully express.
Expand Down
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ cpp:ReadStream[] is the fundamental partial-read primitive among Capy's stream c

[source,cpp]
----
include::example$snippets/9c_read_stream.cpp[tag=concept_definition,indent=0]
include::example$include/boost/capy/concept/read_stream.hpp[tag=read_stream_concept,indent=0]
----

The `requires` clause checks `read_some` against a single representative buffer, cpp:mutable_buffer_archetype[], because a {cpp} concept cannot quantify over "every buffer sequence." The contract is stronger than what the compiler verifies: a cpp:ReadStream[] must accept *any* cpp:MutableBufferSequence[]—a single buffer or a range of them—and the archetype stands in for that universally-quantified requirement.
Expand Down
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ cpp:WriteStream[] is the fundamental partial-write primitive among Capy's stream

[source,cpp]
----
include::example$snippets/9f_write_stream.cpp[tag=write_stream_concept]
include::example$include/boost/capy/concept/write_stream.hpp[tag=write_stream_concept]
----

The `requires` clause checks `write_some` against a single representative buffer, cpp:const_buffer_archetype[], because a {cpp} concept cannot quantify over "every buffer sequence." The contract is stronger than what the compiler verifies: a cpp:WriteStream[] must accept *any* cpp:ConstBufferSequence[]—a single buffer or a range of them—and the archetype stands in for that universally-quantified requirement.
Expand Down
2 changes: 1 addition & 1 deletion doc/modules/ROOT/pages/9.design/9k.Executor.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The cpp:Executor[] concept exists to answer one question: when a coroutine is re

[source,cpp]
----
include::example$snippets/9k_executor.cpp[tag=executor_concept]
include::example$include/boost/capy/concept/executor.hpp[tag=executor_concept]
----

An cpp:Executor[] provides exactly two scheduling operations:
Expand Down
6 changes: 3 additions & 3 deletions doc/modules/ROOT/pages/9.design/9n.WhyNotCobaltConcepts.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ cpp:IoRunnable[] refines cpp:IoAwaitable[] with operations needed to start a tas

[source,cpp]
----
include::example$snippets/9n_why_not_cobalt_concepts.cpp[tag=io_runnable_concept]
include::example$include/boost/capy/concept/io_runnable.hpp[tag=io_runnable_concept]
----

Context injection (cpp:IoRunnable::set_environment[set_environment], cpp:IoRunnable::set_continuation[set_continuation]) consists of `noexcept` requirements that cpp:IoRunnable[] places on `T::promise_type`; launcher functions invoke them through the typed handle returned by cpp:IoRunnable::handle[handle()] before resuming the frame.
Expand Down Expand Up @@ -215,7 +215,7 @@ Capy's cpp:WriteStream[] concept requires `write_some` to accept any cpp:ConstBu

[source,cpp]
----
include::example$snippets/9n_why_not_cobalt_concepts.cpp[tag=write_stream_concept_short]
include::example$include/boost/capy/concept/write_stream.hpp[tag=write_stream_concept]
----

The type-erased wrapper cpp:any_write_stream[] also models the cpp:WriteStream[] concept. Its `write_some` is a template that accepts any cpp:ConstBufferSequence[]:
Expand Down Expand Up @@ -438,7 +438,7 @@ Capy separates the abstraction from the wrapper. cpp:WriteStream[] is a {cpp}20

[source,cpp]
----
include::example$snippets/9n_why_not_cobalt_concepts.cpp[tag=write_stream_concept]
include::example$include/boost/capy/concept/write_stream.hpp[tag=write_stream_concept]
----

cpp:any_write_stream[] is a type-erased wrapper that satisfies this concept. It is one possible reification, not the only one. Users can:
Expand Down
4 changes: 4 additions & 0 deletions include/boost/capy/buffers.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,13 @@ class const_buffer

@see const_buffer, MutableBufferSequence
*/
// tag::const_buffer_sequence_concept[]
template<typename T>
concept ConstBufferSequence =
std::is_convertible_v<T, const_buffer> || (
std::ranges::bidirectional_range<T> &&
std::is_convertible_v<std::ranges::range_value_t<T>, const_buffer>);
// end::const_buffer_sequence_concept[]

/** Requires a type to convert to `mutable_buffer`, or be a range of such buffers.

Expand All @@ -258,11 +260,13 @@ concept ConstBufferSequence =

@see mutable_buffer, ConstBufferSequence
*/
// tag::mutable_buffer_sequence_concept[]
template<typename T>
concept MutableBufferSequence =
std::is_convertible_v<T, mutable_buffer> || (
std::ranges::bidirectional_range<T> &&
std::is_convertible_v<std::ranges::range_value_t<T>, mutable_buffer>);
// end::mutable_buffer_sequence_concept[]

/** Return an iterator to the first buffer in a sequence.

Expand Down
2 changes: 2 additions & 0 deletions include/boost/capy/concept/executor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class execution_context;
@see ExecutionContext, execution_context
*/
// tag::executor_concept[]
template<class E>
concept Executor =
std::is_nothrow_copy_constructible_v<E> &&
Expand All @@ -157,6 +158,7 @@ concept Executor =
{ ce.dispatch(c) } -> std::same_as<std::coroutine_handle<>>;
{ ce.post(c) };
};
// end::executor_concept[]

} // capy
} // boost
Expand Down
2 changes: 2 additions & 0 deletions include/boost/capy/concept/io_runnable.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ namespace capy {

@see IoAwaitable, run, run_async
*/
// tag::io_runnable_concept[]
template<typename T>
concept IoRunnable =
IoAwaitable<T> &&
Expand All @@ -102,6 +103,7 @@ concept IoRunnable =
requires(typename T::promise_type& p) {
p.result();
});
// end::io_runnable_concept[]

} // namespace capy
} // namespace boost
Expand Down
2 changes: 2 additions & 0 deletions include/boost/capy/concept/read_stream.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ namespace capy {
@see IoAwaitable, MutableBufferSequence, awaitable_decomposes_to
*/
// tag::read_stream_concept[]
template<typename T>
concept ReadStream =
requires(T& stream, mutable_buffer_archetype buffers)
Expand All @@ -113,6 +114,7 @@ concept ReadStream =
decltype(stream.read_some(buffers)),
std::error_code, std::size_t>;
};
// end::read_stream_concept[]

} // namespace capy
} // namespace boost
Expand Down
2 changes: 2 additions & 0 deletions include/boost/capy/concept/write_stream.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ namespace capy {
@see IoAwaitable, ConstBufferSequence, awaitable_decomposes_to
*/
// tag::write_stream_concept[]
template<typename T>
concept WriteStream =
requires(T& stream, const_buffer_archetype buffers)
Expand All @@ -122,6 +123,7 @@ concept WriteStream =
decltype(stream.write_some(buffers)),
std::error_code, std::size_t>;
};
// end::write_stream_concept[]

} // namespace capy
} // namespace boost
Expand Down
Loading
Loading