Skip to content

Remove the boost test runner#38

Open
rustaceanrob wants to merge 6 commits into
2140-dev:masterfrom
rustaceanrob:boost-removal
Open

Remove the boost test runner#38
rustaceanrob wants to merge 6 commits into
2140-dev:masterfrom
rustaceanrob:boost-removal

Conversation

@rustaceanrob

@rustaceanrob rustaceanrob commented Jun 2, 2026

Copy link
Copy Markdown
Member

Replaces the boost test runner with a simple header-only variant. Simplifies the macros to the following:

  • CHECK, valid with any comparison operator (e.g. ==, !=), optional message
  • REQUIRE, valid with any comparison operator, optional message
  • CHECK_EQUAL_RANGES, better debugging for vectors
  • THROW_*, macros for checking throwing conditions
  • Info and warn messages

This framework has 4 log levels, offers test filtering, and also includes suites and fixtures. The runner executes in approximately the same time as boost, and should compile faster as well.

A list of a few advantages over boost in addition to macro simplification:

  • Compilation fails when attempting to compare integers with different sign types
  • Types that implement ToString are printed when CHECK or REQUIRE fails
  • Flexible to additions (ABORT or ASSERT macros that fail-fast)

To try it out:

./build/bin/test_bitcoin -l=info
./build/bin/test_bitcoin -t=versionbits_tests

On commit 1 and commit 2, these are given by guidance of Catch2. && conditions are unrolled into two separate checks.

Replace all boost macros (third commit)
import argparse
import re
import sys
from pathlib import Path


# ---------------------------------------------------------------------------
# Parser primitives
# ---------------------------------------------------------------------------

_DIGIT_SEP_PREV = set("0123456789abcdefABCDEF'")


def _is_char_literal_open(text: str, i: int) -> bool:
    """A `'` at position i is a char-literal opener unless the previous
    character is a digit, hex letter, or another digit separator (C++14
    digit-separator usage like 1'000'000 or 0xFF'FF)."""
    return i == 0 or text[i - 1] not in _DIGIT_SEP_PREV


def find_matching_paren(text: str, open_idx: int) -> int | None:
    """Return index of ')' that matches '(' at open_idx, respecting brackets,
    braces, and string/char literals."""
    assert text[open_idx] == "("
    depth = 0
    i = open_idx
    in_str = False
    in_char = False
    while i < len(text):
        c = text[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == '"':
                in_str = False
        elif in_char:
            if c == "\\":
                i += 2
                continue
            if c == "'":
                in_char = False
        elif c == '"':
            in_str = True
        elif c == "'" and _is_char_literal_open(text, i):
            in_char = True
        elif c in "([{":
            depth += 1
        elif c in ")]}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return None


def _is_binary_context(text: str, i: int) -> bool:
    """At position i (start of `|`, `&`, or `^`), is the previous non-whitespace
    character one that makes this a binary operator (not unary `&`)?"""
    j = i - 1
    while j >= 0 and text[j] in " \t\n":
        j -= 1
    if j < 0:
        return False
    return text[j].isalnum() or text[j] in "_)]}\"'"


def needs_parens(expr: str) -> bool:
    """True if expr contains a top-level operator that would interact badly
    with the framework's Decomposer (which captures via `<=`, applies one
    comparison via CapturedExpression's overloads, then yields a Result that
    has no operators).

    Detected: `&&`, `||`, `==`, `!=`, `<=`, `>=`, `|`, `&`, `^`. Bare `<` /
    `>` are intentionally skipped because they are commonly template
    brackets (`vector<int>`, `static_cast<T>`) which this scanner can't
    distinguish from comparison without a real C++ parser; if a real
    comparison with bare `<`/`>` ends up in an operand, the resulting build
    error is easy to spot and fix at the call site.

    Compound assignments (|=, &=, ^=) and unary `&` (address-of) are skipped."""
    depth = 0
    i = 0
    in_str = False
    in_char = False
    n = len(expr)
    while i < n:
        c = expr[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == '"':
                in_str = False
            i += 1
            continue
        if in_char:
            if c == "\\":
                i += 2
                continue
            if c == "'":
                in_char = False
            i += 1
            continue
        if c == '"':
            in_str = True
            i += 1
            continue
        if c == "'" and _is_char_literal_open(expr, i):
            in_char = True
            i += 1
            continue
        if c in "([{":
            depth += 1
            i += 1
            continue
        if c in ")]}":
            depth -= 1
            i += 1
            continue
        if depth == 0:
            two = expr[i : i + 2] if i + 1 < n else ""
            if two in ("&&", "||", "==", "!=", "<=", ">="):
                return True
            if c in "|&^" and two[1:] != "=" and _is_binary_context(expr, i):
                return True
        i += 1
    return False


# Backwards-compat alias for has_top_level_bitwise's old name.
has_top_level_bitwise = needs_parens


def wrap_if_bitwise(expr: str) -> str:
    expr = expr.strip()
    return f"({expr})" if needs_parens(expr) else expr


def split_top_level_args(args: str) -> list[str]:
    """Split macro arg-list on top-level commas."""
    parts: list[str] = []
    depth = 0
    last = 0
    i = 0
    in_str = False
    in_char = False
    while i < len(args):
        c = args[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == '"':
                in_str = False
        elif in_char:
            if c == "\\":
                i += 2
                continue
            if c == "'":
                in_char = False
        elif c == '"':
            in_str = True
        elif c == "'" and _is_char_literal_open(args, i):
            in_char = True
        elif c in "([{":
            depth += 1
        elif c in ")]}":
            depth -= 1
        elif c == "," and depth == 0:
            parts.append(args[last:i])
            last = i + 1
        i += 1
    parts.append(args[last:])
    return parts


# ---------------------------------------------------------------------------
# Macro tables
# ---------------------------------------------------------------------------

SIMPLE_RENAMES = {
    "BOOST_CHECK": "CHECK",
    "BOOST_REQUIRE": "REQUIRE",
    "BOOST_TEST": "CHECK",
    "BOOST_TEST_REQUIRE": "REQUIRE",
    "BOOST_CHECK_NO_THROW": "CHECK_NOTHROW",
    "BOOST_REQUIRE_NO_THROW": "REQUIRE_NOTHROW",
    "BOOST_CHECK_THROW": "CHECK_THROWS_AS",
    "BOOST_CHECK_EXCEPTION": "CHECK_EXCEPTION",
    "BOOST_TEST_MESSAGE": "TEST_MESSAGE",
    "BOOST_WARN_MESSAGE": "WARN_MESSAGE",
}

MESSAGE_RENAMES = {
    "BOOST_CHECK_MESSAGE": "CHECK",
    "BOOST_REQUIRE_MESSAGE": "REQUIRE",
}

COMPARISON_REWRITES = {
    "BOOST_CHECK_EQUAL":   ("CHECK", "=="),
    "BOOST_REQUIRE_EQUAL": ("REQUIRE", "=="),
    "BOOST_CHECK_NE":      ("CHECK", "!="),
    "BOOST_CHECK_LT":      ("CHECK", "<"),
    "BOOST_CHECK_LE":      ("CHECK", "<="),
    "BOOST_CHECK_GT":      ("CHECK", ">"),
    "BOOST_CHECK_GE":      ("CHECK", ">="),
    "BOOST_REQUIRE_NE":    ("REQUIRE", "!="),
    "BOOST_REQUIRE_LT":    ("REQUIRE", "<"),
    "BOOST_REQUIRE_LE":    ("REQUIRE", "<="),
    "BOOST_REQUIRE_GT":    ("REQUIRE", ">"),
    "BOOST_REQUIRE_GE":    ("REQUIRE", ">="),
}

COMMENTED_OUT = (
    "BOOST_TEST_INFO",
    "BOOST_TEST_INFO_SCOPE",
)

REPORTED_ONLY = (
    "BOOST_CHECK_CLOSE",
)

STRUCTURAL_MACROS = (
    "BOOST_AUTO_TEST_CASE",
    "BOOST_FIXTURE_TEST_CASE",
    "BOOST_AUTO_TEST_SUITE",
    "BOOST_FIXTURE_TEST_SUITE",
    "BOOST_AUTO_TEST_SUITE_END",
    "BOOST_CHECK_EQUAL_COLLECTIONS",
    "BOOST_ERROR",
)

ALL_KNOWN = (
    list(SIMPLE_RENAMES)
    + list(MESSAGE_RENAMES)
    + list(COMPARISON_REWRITES)
    + list(COMMENTED_OUT)
    + list(REPORTED_ONLY)
    + list(STRUCTURAL_MACROS)
)
# Longer names first so the alternation doesn't match a prefix.
KNOWN_SORTED = sorted(set(ALL_KNOWN), key=len, reverse=True)
MACRO_RE = re.compile(r"\b(" + "|".join(re.escape(m) for m in KNOWN_SORTED) + r")\s*\(")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def quote_name(name_arg: str) -> str:
    return f'"{name_arg.strip()}"'


def collapse_iter_pair(begin_expr: str, end_expr: str) -> str:
    """X.begin()/X.end() -> X. X->begin()/X->end() -> *X. Else subrange()."""
    b = begin_expr.strip()
    e = end_expr.strip()
    m_b = re.match(r"^(.+)\.begin\s*\(\s*\)$", b)
    m_e = re.match(r"^(.+)\.end\s*\(\s*\)$", e)
    if m_b and m_e and m_b.group(1).strip() == m_e.group(1).strip():
        return m_b.group(1).strip()
    m_b = re.match(r"^(.+)->begin\s*\(\s*\)$", b)
    m_e = re.match(r"^(.+)->end\s*\(\s*\)$", e)
    if m_b and m_e and m_b.group(1).strip() == m_e.group(1).strip():
        return f"*({m_b.group(1).strip()})"
    return f"std::ranges::subrange({b}, {e})"


# ---------------------------------------------------------------------------
# Main rewrite
# ---------------------------------------------------------------------------

def rewrite_includes_and_module(text: str) -> str:
    # Any boost/test include -> framework include.
    text = re.sub(
        r"^#include\s*<boost/test/[^>]+>\s*$",
        "#include <test/util/framework.hpp>",
        text,
        flags=re.MULTILINE,
    )
    # BOOST_TEST_MODULE in standalone test files -> framework main hook.
    text = re.sub(
        r"^#define\s+BOOST_TEST_MODULE\b.*$",
        "#define BITCOIN_TEST_MAIN",
        text,
        flags=re.MULTILINE,
    )
    # Collapse adjacent duplicate framework includes (a boost file may have
    # included two headers from boost/test/).
    text = re.sub(
        r"(#include <test/util/framework\.hpp>\n)(?:[ \t]*#include <test/util/framework\.hpp>\n)+",
        r"\1",
        text,
    )
    return text


def file_namespaces(text: str) -> set[str]:
    """Return the set of namespace names declared anywhere in the file. Used
    to decide whether to wrap a test suite body in a matching namespace —
    Boost.Test opens `namespace <suite>` implicitly, but the framework does
    not, so a fixture/helper defined in `namespace foo` won't be visible
    inside the suite body without an explicit re-open."""
    return set(re.findall(r"^[ \t]*namespace[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*\{", text, re.MULTILINE))


def rewrite_macros(text: str):
    """Walk macro calls in order, applying conversions while tracking the
    current fixture-suite stack. Returns (new_text, reports)."""
    out: list[str] = []
    cursor = 0
    # Each entry: (fixture-or-None, opened-namespace-or-None)
    suite_stack: list[tuple[str | None, str | None]] = []
    declared_namespaces = file_namespaces(text)
    reports: dict[str, list[int]] = {}

    for m in MACRO_RE.finditer(text):
        macro = m.group(1)
        call_start = m.start()
        paren_open = m.end() - 1
        paren_close = find_matching_paren(text, paren_open)
        if paren_close is None:
            continue
        args_text = text[paren_open + 1 : paren_close]
        line = text.count("\n", 0, call_start) + 1

        replacement: str | None = None

        if macro in SIMPLE_RENAMES:
            target = SIMPLE_RENAMES[macro]
            # CHECK/REQUIRE go through Decomposer; a stray top-level bitwise
            # op would be applied to a Result. Wrap the whole expression.
            if target in ("CHECK", "REQUIRE") and has_top_level_bitwise(args_text):
                replacement = f"{target}(({args_text}))"
            else:
                replacement = f"{target}({args_text})"

        elif macro in MESSAGE_RENAMES:
            args = split_top_level_args(args_text)
            if len(args) >= 2:
                expr = wrap_if_bitwise(args[0])
                msg = ",".join(args[1:]).strip()
                replacement = f"{MESSAGE_RENAMES[macro]}({expr}, {msg})"

        elif macro in COMPARISON_REWRITES:
            new_name, op = COMPARISON_REWRITES[macro]
            args = split_top_level_args(args_text)
            if len(args) == 2:
                a = wrap_if_bitwise(args[0])
                b = wrap_if_bitwise(args[1])
                replacement = f"{new_name}({a} {op} {b})"

        elif macro == "BOOST_CHECK_EQUAL_COLLECTIONS":
            args = split_top_level_args(args_text)
            if len(args) == 4:
                lhs = collapse_iter_pair(args[0], args[1])
                rhs = collapse_iter_pair(args[2], args[3])
                replacement = f"CHECK_EQUAL_RANGES({lhs}, {rhs})"

        elif macro == "BOOST_ERROR":
            replacement = f"CHECK(false, {args_text.strip()})"

        elif macro == "BOOST_AUTO_TEST_CASE":
            args = split_top_level_args(args_text)
            name = quote_name(args[0])
            fixture = suite_stack[-1][0] if suite_stack else None
            if fixture:
                replacement = f"FIXTURE_TEST_CASE({name}, {fixture})"
            else:
                replacement = f"TEST_CASE({name})"

        elif macro == "BOOST_FIXTURE_TEST_CASE":
            args = split_top_level_args(args_text)
            if len(args) >= 2:
                name = quote_name(args[0])
                fixture = args[1].strip()
                replacement = f"FIXTURE_TEST_CASE({name}, {fixture})"

        elif macro == "BOOST_AUTO_TEST_SUITE":
            args = split_top_level_args(args_text)
            suite_name = args[0].strip()
            ns = suite_name if suite_name in declared_namespaces else None
            suite_stack.append((None, ns))
            prefix = f"namespace {ns} {{\n" if ns else ""
            replacement = f'{prefix}TEST_SUITE_BEGIN("{suite_name}")'

        elif macro == "BOOST_FIXTURE_TEST_SUITE":
            args = split_top_level_args(args_text)
            if len(args) >= 2:
                suite_name = args[0].strip()
                fixture = args[1].strip()
                ns = suite_name if suite_name in declared_namespaces else None
                suite_stack.append((fixture, ns))
                prefix = f"namespace {ns} {{\n" if ns else ""
                replacement = f'{prefix}TEST_SUITE_BEGIN("{suite_name}")'

        elif macro == "BOOST_AUTO_TEST_SUITE_END":
            ns = suite_stack.pop()[1] if suite_stack else None
            suffix = f"\n}} // namespace {ns}" if ns else ""
            replacement = f"TEST_SUITE_END(){suffix}"

        elif macro in COMMENTED_OUT:
            replacement = f"/* {macro}({args_text}) */"

        elif macro in REPORTED_ONLY:
            reports.setdefault(macro, []).append(line)

        if replacement is None:
            continue

        out.append(text[cursor:call_start])
        out.append(replacement)
        cursor = paren_close + 1

    out.append(text[cursor:])
    return "".join(out), reports


def find_remaining_boost(text: str, exclude: set[str]) -> dict[str, list[int]]:
    """Scan for any BOOST_* identifier still present, excluding known-allowed
    ones (e.g. macros we deliberately left untouched)."""
    remaining: dict[str, list[int]] = {}
    for m in re.finditer(r"\bBOOST_[A-Z_]+", text):
        name = m.group(0)
        if name in exclude:
            continue
        line = text.count("\n", 0, m.start()) + 1
        remaining.setdefault(name, []).append(line)
    return remaining


def transform(text: str) -> tuple[str, dict[str, list[int]]]:
    text = rewrite_includes_and_module(text)
    text, reports = rewrite_macros(text)
    leftover = find_remaining_boost(text, exclude=set(REPORTED_ONLY))
    for macro, lines in leftover.items():
        reports.setdefault(macro, []).extend(lines)
    return text, reports


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def iter_source_files(paths: list[Path]):
    for p in paths:
        if p.is_file():
            if p.suffix in (".cpp", ".h", ".hpp", ".cc"):
                yield p
            continue
        for f in p.rglob("*"):
            if f.is_file() and f.suffix in (".cpp", ".h", ".hpp", ".cc"):
                yield f


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("paths", nargs="+", type=Path)
    ap.add_argument("--check", action="store_true", help="Exit 1 if any file would change")
    ap.add_argument("--quiet", action="store_true")
    args = ap.parse_args()

    total = 0
    changed = 0
    all_reports: dict[Path, dict[str, list[int]]] = {}

    for path in iter_source_files(args.paths):
        total += 1
        original = path.read_text()
        new_text, reports = transform(original)
        if new_text != original:
            changed += 1
            if not args.check:
                path.write_text(new_text)
            if not args.quiet:
                verb = "would rewrite" if args.check else "rewrote"
                print(f"{verb} {path}")
        if reports:
            all_reports[path] = reports

    if all_reports and not args.quiet:
        print("\nUnconverted BOOST_* identifiers (manual review):")
        for path in sorted(all_reports):
            for macro in sorted(all_reports[path]):
                for ln in all_reports[path][macro]:
                    print(f"  {path}:{ln}: {macro}")

    if not args.quiet:
        verb = "would rewrite" if args.check else "rewrote"
        print(f"\n{verb} {changed}/{total} file(s)")

    return 1 if args.check and changed > 0 else 0


if __name__ == "__main__":
    sys.exit(main())

@rustaceanrob rustaceanrob force-pushed the boost-removal branch 4 times, most recently from 896e74d to 742edb9 Compare June 3, 2026 12:35
@rustaceanrob rustaceanrob force-pushed the boost-removal branch 13 times, most recently from 3367419 to c807d70 Compare June 6, 2026 09:26
-BEGIN VERIFY SCRIPT-
set -eu

MACRO_RE='BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW'

FILES=$(git grep -lE "\b(${MACRO_RE})[[:space:]]*\(" -- \
    ':(glob)src/test/**/*.cpp' ':(glob)src/test/**/*.h' \
    ':(glob)src/test/*.cpp' ':(glob)src/test/*.h' \
    ':(glob)src/ipc/test/**/*.cpp' ':(glob)src/ipc/test/**/*.h' \
    ':(glob)src/ipc/test/*.cpp' ':(glob)src/ipc/test/*.h' 2>/dev/null || true)

if [ -z "$FILES" ]; then
    echo "no matching files"
    exit 0
fi

perl -i -0777 -pe '
use strict;
use warnings;

my $names = "BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW";
my $re = qr/\b($names)\s*\(/;
my @DIGIT_SEP_PREV = (0) x 256;
$DIGIT_SEP_PREV[ord($_)] = 1 for split //, "0123456789abcdefABCDEF" . chr(39);

sub is_char_open {
    my ($s, $i) = @_;
    return 1 if $i == 0;
    return $DIGIT_SEP_PREV[ord(substr($$s, $i-1, 1))] ? 0 : 1;
}

sub close_paren {
    my ($s, $open) = @_;
    my ($depth, $i, $in_str, $in_char) = (0, $open, 0, 0);
    my $n = length($$s);
    while ($i < $n) {
        my $c = substr($$s, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
        } elsif ($c eq q{"}) { $in_str = 1; }
        elsif ($c eq chr(39) && is_char_open($s, $i)) { $in_char = 1; }
        elsif ($c =~ /[(\[{]/) { $depth++; }
        elsif ($c =~ /[)\]}]/) { $depth--; return $i if $depth == 0; }
        $i++;
    }
    return -1;
}

sub split_first_comma {
    my ($s) = @_;
    my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
    my $n = length($s);
    while ($i < $n) {
        my $c = substr($s, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
        } elsif ($c eq q{"}) { $in_str = 1; }
        elsif ($c eq chr(39) && is_char_open(\$s, $i)) { $in_char = 1; }
        elsif ($c =~ /[(\[{]/) { $depth++; }
        elsif ($c =~ /[)\]}]/) { $depth--; }
        elsif ($c eq "," && $depth == 0) {
            return (substr($s, 0, $i), substr($s, $i));
        }
        $i++;
    }
    return ($s, "");
}

sub top_level_and_positions {
    my ($e) = @_;
    my @ops;
    my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
    my $n = length($e);
    while ($i < $n) {
        my $c = substr($e, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
            $i++; next;
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
            $i++; next;
        }
        if ($c eq q{"}) { $in_str = 1; $i++; next; }
        if ($c eq chr(39) && is_char_open(\$e, $i)) { $in_char = 1; $i++; next; }
        if ($c =~ /[(\[{]/) { $depth++; $i++; next; }
        if ($c =~ /[)\]}]/) { $depth--; $i++; next; }
        if ($depth == 0 && $i + 1 < $n && substr($e, $i, 2) eq "&&") {
            push @ops, $i;
            $i += 2; next;
        }
        $i++;
    }
    return @ops;
}

my $text = $_;
my $out  = "";
my $cur  = 0;

while ($text =~ /$re/g) {
    my $macro    = $1;
    my $m_start  = $-[0];
    my $p_open   = $+[0] - 1;
    my $p_close  = close_paren(\$text, $p_open);
    next if $p_close < 0;

    my $args = substr($text, $p_open + 1, $p_close - $p_open - 1);
    my ($expr_raw, $message) = split_first_comma($args);
    my $expr = $expr_raw;
    $expr =~ s/^\s+//;
    $expr =~ s/\s+$//;

    my @ats = top_level_and_positions($expr);
    next unless @ats;

    # Compose the replacement with original indentation.
    my $line_start = rindex(substr($text, 0, $m_start), "\n") + 1;
    my $indent = substr($text, $line_start, $m_start - $line_start);
    $indent =~ s/[^\s].*//s;

    my @Parts;
    my $last = 0;
    for my $p (@ats) {
        my $piece = substr($expr, $last, $p - $last);
        $piece =~ s/^\s+//; $piece =~ s/\s+$//;
        push @Parts, $piece;
        $last = $p + 2;
    }
    my $tail = substr($expr, $last);
    $tail =~ s/^\s+//; $tail =~ s/\s+$//;
    push @Parts, $tail;

    my $sep = ";\n" . $indent;
    my $replacement = join($sep, map { "$macro($_$message)" } @Parts);

    $out .= substr($text, $cur, $m_start - $cur);
    $out .= $replacement;
    $cur = $p_close + 1;
    # Resume the global match where the new content ends.
    pos($text) = $cur;
}
$out .= substr($text, $cur);
$_ = $out;
' -- $FILES
-END VERIFY SCRIPT-
-BEGIN VERIFY SCRIPT-
set -eu

MACRO_RE='BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW'

FILES=$(git grep -lE "\b(${MACRO_RE})[[:space:]]*\(" -- \
    ':(glob)src/test/**/*.cpp' ':(glob)src/test/**/*.h' \
    ':(glob)src/test/*.cpp' ':(glob)src/test/*.h' \
    ':(glob)src/ipc/test/**/*.cpp' ':(glob)src/ipc/test/**/*.h' \
    ':(glob)src/ipc/test/*.cpp' ':(glob)src/ipc/test/*.h' 2>/dev/null || true)

if [ -z "$FILES" ]; then
    echo "no matching files"
    exit 0
fi

perl -i -0777 -pe '
use strict;
use warnings;

my $names = "BOOST_CHECK|BOOST_REQUIRE|BOOST_CHECK_MESSAGE|BOOST_REQUIRE_MESSAGE|BOOST_CHECK_NO_THROW|BOOST_REQUIRE_NO_THROW";
my $re = qr/\b($names)\s*\(/;
my @DIGIT_SEP_PREV = (0) x 256;
$DIGIT_SEP_PREV[ord($_)] = 1 for split //, "0123456789abcdefABCDEF" . chr(39);

sub is_char_open {
    my ($s, $i) = @_;
    return 1 if $i == 0;
    return $DIGIT_SEP_PREV[ord(substr($$s, $i-1, 1))] ? 0 : 1;
}

sub close_paren {
    my ($s, $open) = @_;
    my ($depth, $i, $in_str, $in_char) = (0, $open, 0, 0);
    my $n = length($$s);
    while ($i < $n) {
        my $c = substr($$s, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
        } elsif ($c eq q{"}) { $in_str = 1; }
        elsif ($c eq chr(39) && is_char_open($s, $i)) { $in_char = 1; }
        elsif ($c =~ /[(\[{]/) { $depth++; }
        elsif ($c =~ /[)\]}]/) { $depth--; return $i if $depth == 0; }
        $i++;
    }
    return -1;
}

sub split_first_comma {
    my ($s) = @_;
    my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
    my $n = length($s);
    while ($i < $n) {
        my $c = substr($s, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
        } elsif ($c eq q{"}) { $in_str = 1; }
        elsif ($c eq chr(39) && is_char_open(\$s, $i)) { $in_char = 1; }
        elsif ($c =~ /[(\[{]/) { $depth++; }
        elsif ($c =~ /[)\]}]/) { $depth--; }
        elsif ($c eq "," && $depth == 0) {
            return (substr($s, 0, $i), substr($s, $i));
        }
        $i++;
    }
    return ($s, "");
}

sub has_top_level_or {
    my ($e) = @_;
    my ($depth, $i, $in_str, $in_char) = (0, 0, 0, 0);
    my $n = length($e);
    while ($i < $n) {
        my $c = substr($e, $i, 1);
        if ($in_str) {
            if ($c eq "\\") { $i += 2; next; }
            $in_str = 0 if $c eq q{"};
            $i++; next;
        } elsif ($in_char) {
            if ($c eq "\\") { $i += 2; next; }
            $in_char = 0 if $c eq chr(39);
            $i++; next;
        }
        if ($c eq q{"}) { $in_str = 1; $i++; next; }
        if ($c eq chr(39) && is_char_open(\$e, $i)) { $in_char = 1; $i++; next; }
        if ($c =~ /[(\[{]/) { $depth++; $i++; next; }
        if ($c =~ /[)\]}]/) { $depth--; $i++; next; }
        if ($depth == 0 && $i + 1 < $n && substr($e, $i, 2) eq "||") {
            return 1;
        }
        $i++;
    }
    return 0;
}

my $text = $_;
my $out  = "";
my $cur  = 0;

while ($text =~ /$re/g) {
    my $macro   = $1;
    my $m_start = $-[0];
    my $p_open  = $+[0] - 1;
    my $p_close = close_paren(\$text, $p_open);
    next if $p_close < 0;

    my $args = substr($text, $p_open + 1, $p_close - $p_open - 1);
    my ($expr_raw, $message) = split_first_comma($args);
    my $expr = $expr_raw;
    $expr =~ s/^\s+//;
    $expr =~ s/\s+$//;

    next unless has_top_level_or($expr);

    $out .= substr($text, $cur, $m_start - $cur);
    $out .= "$macro(($expr)$message)";
    $cur = $p_close + 1;
    pos($text) = $cur;
}
$out .= substr($text, $cur);
$_ = $out;
' -- $FILES
-END VERIFY SCRIPT-
@rustaceanrob rustaceanrob force-pushed the boost-removal branch 5 times, most recently from 270a034 to ef29ae0 Compare June 6, 2026 19:31
@rustaceanrob rustaceanrob changed the title [Do-not-merge]: Remove the boost test runner Remove the boost test runner Jun 7, 2026
@rustaceanrob rustaceanrob marked this pull request as ready for review June 7, 2026 11:07
@rustaceanrob rustaceanrob force-pushed the boost-removal branch 2 times, most recently from e780299 to ae72cf9 Compare June 8, 2026 08:56
@rustaceanrob rustaceanrob requested a review from ismaelsadeeq June 8, 2026 09:13
@rustaceanrob rustaceanrob requested a review from josibake June 8, 2026 11:34
@ismaelsadeeq

Copy link
Copy Markdown
Member

Concept ACK, nice work

❯ build/bin/test_bitcoin
Running 658 test cases...

658 tests: 658 passed, 0 failed, 0 skipped (26431755 checks)

@josibake josibake left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

Overall, looking great! Did some testing and uncovered a lil bug, will continue reading through the code tomorrow. There are also some stale document comments , not a priority but something to fix before the final pass.

std::string_view arg = argv[i];
if (arg == "--") {
for (int j{i + 1}; j < argc; ++j) {
opts.passthrough.emplace_back(argv[j]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is being used. I ran some old justfile commands I had laying around and testdatadir wasn't used, but the tests ran. I suspect this is because test/main.cpp reads framework::user_args() but I don't see opts.passthrough being copied in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be fixed in eccc7db. Please give 77bf05d a spin when you get a chance.

Adds src/test/util/framework.hpp as a lightweight Boost.Test replacement.
This commit only introduces the header; build-system integration and
call-site migration follow in subsequent commits.

Includes:
- `CHECK`, valid with any comparison operator, optional message
- `REQUIRE`, valid with any comparison operator, optional message
- `CHECK_EQUAL_RANGES`, better debugging for vectors
- `THROW_*`, macros for checking throwing conditions
- Info and warn messages
Integrates src/test/util/framework.hpp into the build (CMake, main.cpp)
and replaces the Boost.Test macros across the unit test suite via a
scripted diff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants