parquet_read_filter.f90 Source File


Source Code

!===========================================
! Author: Elmo Tempel (elmo.tempel@ut.ee)
!===========================================
!> Filter-expression parsing: turns one parquet_filter%add rule ("(ra > 180 and dec <= 0) or
!> id is_null") into the packed leaf arrays plus the postfix (RPN) node list the C++ evaluator
!> runs (see parquet_reader_set_filter in parquet_wrapper.cpp). Purely syntactic -- no schema
!> access at all, so nothing here can tell whether a column exists or whether a value suits its
!> type; that validation stays C++-side, where the schema is.
!>
!> Parsing runs Fortran-side, rather than sending the raw text across the bind(C) boundary, for
!> three reasons: a syntax error is reported through the same error-context helpers every other
!> Fortran-side failure uses, the parser is unit-testable in process with no file and no reader
!> (which is why the single-clause tokenizer has always been Fortran), and the boundary keeps
!> carrying fixed-width packed strings, the convention tools/check_bindc_boundary.py already
!> checks.
!>
!> The grammar (precedence not > and > or; keywords case-insensitive):
!>
!>     expr     := or_expr
!>     or_expr  := and_expr { or and_expr }
!>     and_expr := not_expr { and not_expr }
!>     not_expr := [ not ] not_expr | primary
!>     primary  := '(' expr ')' | clause
!>     clause   := NAME OP [ VALUE ]
!>
!> A clause is exactly what it has always been, and is still tokenized by
!> parquet_tokenize_filter_rule -- so every clause-level error message (and therefore every
!> error scenario asserting one) is unchanged by the arrival of the expression layer.
!>
!> The lexer reports token boundaries into the caller's own rule text rather than copying each
!> token out, so lexing a rule costs three small integer arrays regardless of how long the rule
!> is (a filter_max_rule_len-sized rule would otherwise need a token array of that length
!> squared, in the tens of megabytes).
submodule (parquet_core:parquet_read) parquet_read_filter
    implicit none

    !> Token kinds lex_filter_expr produces. TK_TEXT covers everything that is neither
    !> punctuation nor a keyword -- a column name, an operator, an unquoted value -- because
    !> which of those a text token is depends on its position within a clause, which the clause
    !> tokenizer decides, not the lexer.
    integer, parameter :: TK_TEXT = 1
    integer, parameter :: TK_QUOTED = 2 !! a double-quoted value; the quotes stay in, for the clause tokenizer.
    integer, parameter :: TK_LPAREN = 3
    integer, parameter :: TK_RPAREN = 4
    integer, parameter :: TK_AND = 5
    integer, parameter :: TK_OR = 6
    integer, parameter :: TK_NOT = 7

contains

    !> Lexes `rule` into parallel kind/start/end arrays, where start/end index into `rule`
    !> itself. Splits on whitespace and on the parentheses that are the grammar's only
    !> punctuation, recognizes the three keywords case-insensitively, and consumes a
    !> double-quoted run as ONE token (so a quoted value may contain spaces, parentheses, or the
    !> words and/or/not without being mis-lexed). Reports failure rather than aborting, so the
    !> caller can attach file context to the message.
    subroutine lex_filter_expr(rule, kinds, tok_lo, tok_hi, ntok, ok, errmsg)
        character(len=*), intent(in) :: rule !! raw filter expression text.
        integer, allocatable, intent(out) :: kinds(:) !! one TK_* per token.
        integer, allocatable, intent(out) :: tok_lo(:) !! each token's first character position in `rule`.
        integer, allocatable, intent(out) :: tok_hi(:) !! each token's last character position in `rule`.
        integer, intent(out) :: ntok !! number of tokens produced.
        logical, intent(out) :: ok !! .true. if the text lexed cleanly.
        character(len=:), allocatable, intent(out) :: errmsg !! failure message; "" when ok.
        integer :: i, n, start, cap
        character(len=1) :: c

        ok = .false.
        errmsg = ""
        ntok = 0
        n = len(rule)
        ! Every token is at least one character long and "((((" is a legal, maximally dense run of
        ! them, so the token count cannot exceed the character count. (One token per TWO characters
        ! looks like the tighter bound -- it is what a space-separated expression needs -- but
        ! parentheses need no separator, and under-sizing here overruns the arrays below.)
        cap = n + 2
        allocate(kinds(cap), tok_lo(cap), tok_hi(cap))

        i = 1
        do while (i <= n)
            c = rule(i:i)
            if (c == " " .or. c == char(9)) then
                i = i + 1
            else if (c == "(") then
                call push_token(TK_LPAREN, i, i)
                i = i + 1
            else if (c == ")") then
                call push_token(TK_RPAREN, i, i)
                i = i + 1
            else if (c == '"') then
                start = i
                i = i + 1
                do while (i <= n)
                    if (rule(i:i) == '"') exit
                    i = i + 1
                end do
                if (i > n) then
                    errmsg = "filter expression '" // trim(rule) // "' has an unterminated quoted value"
                    return
                end if
                call push_token(TK_QUOTED, start, i)
                i = i + 1
            else
                start = i
                do while (i <= n)
                    c = rule(i:i)
                    if (c == " " .or. c == char(9) .or. c == "(" .or. c == ")" .or. c == '"') exit
                    i = i + 1
                end do
                call push_token(keyword_kind(rule(start:i-1)), start, i - 1)
            end if
        end do

        if (ntok == 0) then
            errmsg = "empty filter rule"
            return
        end if
        ok = .true.
    contains
        !> Appends one token; the arrays are pre-sized to a bound the loop cannot exceed.
        subroutine push_token(kind, lo, hi)
            integer, intent(in) :: kind !! the token's TK_* kind.
            integer, intent(in) :: lo !! first character position.
            integer, intent(in) :: hi !! last character position.
            ntok = ntok + 1
            kinds(ntok) = kind
            tok_lo(ntok) = lo
            tok_hi(ntok) = hi
        end subroutine push_token
        !> TK_AND/TK_OR/TK_NOT for a bare keyword (any casing), TK_TEXT otherwise -- so a column
        !> named "android" or "nothing" is an ordinary text token, not a keyword.
        pure integer function keyword_kind(word) result(res)
            character(len=*), intent(in) :: word !! the whole token's text.
            select case (ascii_lower(word))
            case ("and")
                res = TK_AND
            case ("or")
                res = TK_OR
            case ("not")
                res = TK_NOT
            case default
                res = TK_TEXT
            end select
        end function keyword_kind
    end subroutine lex_filter_expr

    !> Lowercases an ASCII string; the three keywords are pure ASCII, so no locale-aware folding
    !> is needed. A character-valued function is fine here despite this project's ban on them:
    !> that ban covers deferred-length ALLOCATABLE results, whose gfortran codegen is not
    !> reliably thread-safe (GCC PR113797) -- this result's length is assumed from the argument.
    pure function ascii_lower(s) result(res)
        character(len=*), intent(in) :: s !! text to fold.
        character(len=len(s)) :: res !! `s` with every A-Z mapped to a-z.
        integer :: i, code
        do i = 1, len(s)
            code = iachar(s(i:i))
            if (code >= iachar("A") .and. code <= iachar("Z")) then
                res(i:i) = achar(code - iachar("A") + iachar("a"))
            else
                res(i:i) = s(i:i)
            end if
        end do
    end function ascii_lower

    !> Parses one rule and APPENDS its postfix node list, plus one packed leaf per clause, to
    !> the accumulators. Several calls therefore build one expression program; the caller folds
    !> them together by appending an ND_AND node after the second and each later rule (see
    !> parquet_apply_filter), which is what makes two %add calls mean (expr1) and (expr2).
    module procedure parquet_parse_filter_expr
        integer, allocatable :: kinds(:), tok_lo(:), tok_hi(:)
        integer :: ntok, pos, depth
        character(len=:), allocatable :: unexpected_tok

        call lex_filter_expr(rule, kinds, tok_lo, tok_hi, ntok, ok, errmsg)
        if (.not. ok) return

        pos = 1
        depth = 0
        call parse_or_expr(ok, errmsg)
        if (.not. ok) return
        if (pos <= ntok) then
            ok = .false.
            call tok_text(pos, unexpected_tok)
            errmsg = "filter expression '" // trim(rule) // "': unexpected '" // unexpected_tok // &
                "' (a missing and/or?)"
            return
        end if
    contains
        !> The token at `p`, as it appears in the rule. A subroutine (not a `character(len=...)`
        !> function whose length is a specification expression over host-associated arrays) --
        !> that shape reliably segfaults ifx 2026.1.1 when called from a sibling contained
        !> procedure such as parse_clause/parse_primary; see the ifx internal-compiler-error
        !> report filed alongside this fix for the minimal reproducer.
        pure subroutine tok_text(p, res)
            integer, intent(in) :: p !! 1-based token index.
            character(len=:), allocatable, intent(out) :: res !! that token's own text.
            res = rule(tok_lo(p):tok_hi(p))
        end subroutine tok_text
        !> or_expr := and_expr { or and_expr } -- lowest precedence, so it is the entry point.
        recursive subroutine parse_or_expr(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if this production parsed.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            call parse_and_expr(sub_ok, sub_err)
            if (.not. sub_ok) return
            do while (pos <= ntok)
                if (kinds(pos) /= TK_OR) exit
                pos = pos + 1
                call parse_and_expr(sub_ok, sub_err)
                if (.not. sub_ok) return
                call emit_node(ND_OR, 0, sub_ok, sub_err)
                if (.not. sub_ok) return
            end do
        end subroutine parse_or_expr
        !> and_expr := not_expr { and not_expr }.
        recursive subroutine parse_and_expr(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if this production parsed.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            call parse_not_expr(sub_ok, sub_err)
            if (.not. sub_ok) return
            do while (pos <= ntok)
                if (kinds(pos) /= TK_AND) exit
                pos = pos + 1
                call parse_not_expr(sub_ok, sub_err)
                if (.not. sub_ok) return
                call emit_node(ND_AND, 0, sub_ok, sub_err)
                if (.not. sub_ok) return
            end do
        end subroutine parse_and_expr
        !> not_expr := [ not ] not_expr | primary -- right-recursive, so "not not a" is legal
        !> (and idempotent) with no special case. Each `not` counts as one nesting level, so a
        !> pathological "not not not ..." hits the depth cap rather than the call stack.
        recursive subroutine parse_not_expr(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if this production parsed.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            if (pos <= ntok) then
                if (kinds(pos) == TK_NOT) then
                    pos = pos + 1
                    call enter_level(sub_ok, sub_err)
                    if (.not. sub_ok) return
                    call parse_not_expr(sub_ok, sub_err)
                    depth = depth - 1
                    if (.not. sub_ok) return
                    call emit_node(ND_NOT, 0, sub_ok, sub_err)
                    return
                end if
            end if
            call parse_primary(sub_ok, sub_err)
        end subroutine parse_not_expr
        !> primary := '(' expr ')' | clause.
        recursive subroutine parse_primary(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if this production parsed.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            character(len=:), allocatable :: cur_tok
            sub_ok = .false.
            sub_err = ""
            if (pos > ntok) then
                sub_err = "filter expression '" // trim(rule) // "' ends after an operator; a clause is missing"
                return
            end if
            select case (kinds(pos))
            case (TK_LPAREN)
                pos = pos + 1
                call enter_level(sub_ok, sub_err)
                if (.not. sub_ok) return
                if (pos <= ntok) then
                    if (kinds(pos) == TK_RPAREN) then
                        sub_ok = .false.
                        sub_err = "filter expression '" // trim(rule) // "' has an empty '()' group"
                        return
                    end if
                end if
                call parse_or_expr(sub_ok, sub_err)
                depth = depth - 1
                if (.not. sub_ok) return
                if (pos > ntok) then
                    sub_ok = .false.
                    sub_err = "filter expression '" // trim(rule) // "' has an unbalanced '('"
                    return
                end if
                if (kinds(pos) /= TK_RPAREN) then
                    sub_ok = .false.
                    call tok_text(pos, cur_tok)
                    sub_err = "filter expression '" // trim(rule) // "': expected ')' but found '" // &
                        cur_tok // "'"
                    return
                end if
                pos = pos + 1
            case (TK_RPAREN)
                sub_err = "filter expression '" // trim(rule) // "' has an unbalanced ')'"
            case (TK_AND, TK_OR)
                call tok_text(pos, cur_tok)
                sub_err = "filter expression '" // trim(rule) // "' has a dangling '" // cur_tok // &
                    "' with no clause on one side of it"
            case default
                call parse_clause(sub_ok, sub_err)
            end select
        end subroutine parse_primary
        !> clause := NAME OP [ VALUE ] -- collects the (up to three) text/quoted tokens that make
        !> up one clause, reassembles them into the single string parquet_tokenize_filter_rule
        !> already understands, and stores the result as one leaf. Reassembling rather than
        !> reimplementing is deliberate: the clause-level syntax, and every clause-level error
        !> message, then still has exactly one definition in the code base.
        subroutine parse_clause(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if the clause parsed.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            character(len=:), allocatable :: clause_text, pname, pop, pvalue, cur_tok
            logical :: pis_string
            integer :: taken

            sub_ok = .false.
            sub_err = ""
            clause_text = ""
            taken = 0
            do while (pos <= ntok)
                if (taken >= 3) exit
                if (kinds(pos) /= TK_TEXT .and. kinds(pos) /= TK_QUOTED) exit
                if (taken > 0) clause_text = clause_text // " "
                call tok_text(pos, cur_tok)
                clause_text = clause_text // cur_tok
                taken = taken + 1
                pos = pos + 1
                ! A no-value operator ends the clause immediately, so a following bare name
                ! ("a is_null b > 1", missing its combinator) is reported as an unexpected token
                ! rather than silently swallowed as this clause's value.
                if (taken == 2) then
                    if (is_valueless_op(clause_text)) exit
                end if
            end do

            call parquet_tokenize_filter_rule(clause_text, pname, pop, pvalue, pis_string, sub_ok, sub_err)
            if (.not. sub_ok) return
            call emit_leaf(pname, pop, pvalue, pis_string, sub_ok, sub_err)
            if (.not. sub_ok) return
            call emit_node(ND_LEAF, nleaves, sub_ok, sub_err)
        end subroutine parse_clause
        !> Whether the clause text collected so far ends in an operator that takes no value.
        pure logical function is_valueless_op(clause_text) result(res)
            character(len=*), intent(in) :: clause_text !! the "<name> <op>" text collected so far.
            character(len=:), allocatable :: last
            last = ascii_lower(clause_text(index(clause_text, " ", back=.true.) + 1:))
            res = last == "is_null" .or. last == "is_not_null" .or. &
                last == "is_nan" .or. last == "is_not_nan"
        end function is_valueless_op
        !> Counts one nesting level, refusing to go deeper than filter_max_depth. The cap keeps
        !> adversarial input ("((((((...") a clean error stop rather than a stack overflow, and
        !> bounds the C++ evaluator's peak memory, which is (live operands) * nrows bytes.
        subroutine enter_level(sub_ok, sub_err)
            logical, intent(out) :: sub_ok !! .true. if the new level is within the cap.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            character(len=32) :: cap_str
            depth = depth + 1
            sub_ok = depth <= filter_max_depth
            sub_err = ""
            if (.not. sub_ok) then
                write(cap_str, '(i0)') filter_max_depth
                sub_err = "filter expression is nested deeper than the supported limit (" // trim(cap_str) // &
                    " levels)"
            end if
        end subroutine enter_level
        !> Appends one leaf to the packed leaf accumulators, growing them as needed and enforcing
        !> the per-leaf fixed widths the bind(C) packing convention imposes.
        subroutine emit_leaf(pname, pop, pvalue, pis_string, sub_ok, sub_err)
            character(len=*), intent(in) :: pname !! the clause's column name (may be a dotted struct path).
            character(len=*), intent(in) :: pop !! the clause's operator.
            character(len=*), intent(in) :: pvalue !! the clause's value, unquoted; "" for a valueless operator.
            logical, intent(in) :: pis_string !! .true. if the value was double-quoted in the source.
            logical, intent(out) :: sub_ok !! .true. if the leaf fits and was stored.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.

            sub_ok = .true.
            sub_err = ""
            if (len(pname) > filter_leaf_name_len .or. len(pop) > filter_leaf_op_len .or. &
                    len(pvalue) > filter_leaf_value_len) then
                sub_ok = .false.
                sub_err = "filter rule exceeds an internal length limit: " // trim(rule)
                return
            end if
            call grow_leaves(nleaves + 1, leaf_name, leaf_op, leaf_value, leaf_is_string)
            nleaves = nleaves + 1
            leaf_name(nleaves) = pname
            leaf_op(nleaves) = pop
            leaf_value(nleaves) = pvalue
            leaf_is_string(nleaves) = merge(1_int8, 0_int8, pis_string)
        end subroutine emit_leaf
        !> Appends one postfix node, enforcing filter_max_nodes across the whole filter (the
        !> accumulator is shared by every %add call, so the cap is per filter, not per rule).
        subroutine emit_node(kind, leaf_index, sub_ok, sub_err)
            integer, intent(in) :: kind !! ND_* node kind.
            integer, intent(in) :: leaf_index !! 1-based leaf index for ND_LEAF; 0 otherwise.
            logical, intent(out) :: sub_ok !! .true. if the node fits and was stored.
            character(len=:), allocatable, intent(out) :: sub_err !! failure message; "" when sub_ok.
            call append_node(kind, leaf_index, node_kind, node_leaf, nnodes, sub_ok, sub_err)
        end subroutine emit_node
    end procedure parquet_parse_filter_expr

    !> Appends one operator node to an already-built node list -- how parquet_apply_filter folds
    !> several %add rules together with ND_AND without re-entering the parser.
    module procedure parquet_append_filter_node
        call append_node(kind, 0, node_kind, node_leaf, nnodes, ok, errmsg)
    end procedure parquet_append_filter_node

    !> The one place a node is appended: grows the accumulators, enforces filter_max_nodes, and
    !> reports (rather than aborts) when the cap is hit.
    subroutine append_node(kind, leaf_index, node_kind, node_leaf, nnodes, ok, errmsg)
        integer, intent(in) :: kind !! ND_* node kind.
        integer, intent(in) :: leaf_index !! 1-based leaf index for ND_LEAF; 0 otherwise.
        integer(int8), allocatable, intent(inout) :: node_kind(:) !! node-kind accumulator.
        integer(int32), allocatable, intent(inout) :: node_leaf(:) !! node-leaf-index accumulator.
        integer, intent(inout) :: nnodes !! nodes in use; incremented on success.
        logical, intent(out) :: ok !! .true. if the node fits and was stored.
        character(len=:), allocatable, intent(out) :: errmsg !! failure message; "" when ok.
        integer(int8), allocatable :: tmp_kind(:)
        integer(int32), allocatable :: tmp_leaf(:)
        character(len=32) :: cap_str
        integer :: cap

        ok = .true.
        errmsg = ""
        if (nnodes + 1 > filter_max_nodes) then
            write(cap_str, '(i0)') filter_max_nodes
            ok = .false.
            errmsg = "filter expression has more terms than the supported limit (" // trim(cap_str) // " nodes)"
            return
        end if
        if (.not. allocated(node_kind)) then
            allocate(node_kind(8), node_leaf(8))
        else if (size(node_kind) < nnodes + 1) then
            ! Double rather than fit exactly, so building a large expression stays linear.
            cap = 2 * size(node_kind)
            allocate(tmp_kind(cap), tmp_leaf(cap))
            tmp_kind(1:nnodes) = node_kind(1:nnodes)
            tmp_leaf(1:nnodes) = node_leaf(1:nnodes)
            call move_alloc(tmp_kind, node_kind)
            call move_alloc(tmp_leaf, node_leaf)
        end if
        nnodes = nnodes + 1
        node_kind(nnodes) = int(kind, int8)
        node_leaf(nnodes) = int(leaf_index, int32)
    end subroutine append_node

    !> Grows the four parallel leaf accumulators to hold at least `needed` entries; see
    !> append_node for the doubling rationale.
    subroutine grow_leaves(needed, leaf_name, leaf_op, leaf_value, leaf_is_string)
        integer, intent(in) :: needed !! entries the caller is about to require.
        character(len=filter_leaf_name_len), allocatable, intent(inout) :: leaf_name(:) !! column names.
        character(len=filter_leaf_op_len), allocatable, intent(inout) :: leaf_op(:) !! operators.
        character(len=filter_leaf_value_len), allocatable, intent(inout) :: leaf_value(:) !! values.
        integer(int8), allocatable, intent(inout) :: leaf_is_string(:) !! "value was quoted" flags.
        character(len=filter_leaf_name_len), allocatable :: tmp_name(:)
        character(len=filter_leaf_op_len), allocatable :: tmp_op(:)
        character(len=filter_leaf_value_len), allocatable :: tmp_value(:)
        integer(int8), allocatable :: tmp_flag(:)
        integer :: cap, n

        if (.not. allocated(leaf_name)) then
            allocate(leaf_name(8), leaf_op(8), leaf_value(8), leaf_is_string(8))
            return
        end if
        if (size(leaf_name) >= needed) return
        n = size(leaf_name)
        cap = max(2 * n, needed)
        allocate(tmp_name(cap), tmp_op(cap), tmp_value(cap), tmp_flag(cap))
        tmp_name(1:n) = leaf_name
        tmp_op(1:n) = leaf_op
        tmp_value(1:n) = leaf_value
        tmp_flag(1:n) = leaf_is_string
        call move_alloc(tmp_name, leaf_name)
        call move_alloc(tmp_op, leaf_op)
        call move_alloc(tmp_value, leaf_value)
        call move_alloc(tmp_flag, leaf_is_string)
    end subroutine grow_leaves

    !> Re-renders a parsed expression from its node list, in canonical form: one space between
    !> tokens, parentheses only where precedence needs them, and a value re-quoted when the
    !> source quoted it. Used for parquet_reader_print_stat's "filter:" line, so what was
    !> actually applied stays visible even though the per-column view cannot represent a
    !> non-flat expression.
    !>
    !> Walks the postfix list with a stack of already-rendered fragments, tracking each
    !> fragment's precedence so a child is parenthesised exactly when its own operator binds
    !> more loosely than its parent's.
    module procedure parquet_render_filter_expr
        character(len=:), allocatable :: stack_text(:)
        integer, allocatable :: stack_prec(:)
        character(len=:), allocatable :: lhs, rhs, rendered
        integer :: i, top, frag_len, stack_cap, prec, kind, li
        ! Precedence levels, used only to decide parentheses: a leaf never needs them, and a
        ! child is wrapped when its level is above its parent's.
        integer, parameter :: PREC_LEAF = 0, PREC_NOT = 1, PREC_AND = 2, PREC_OR = 3

        text = ""
        if (nnodes <= 0) return

        ! One fragment can grow to the whole expression, so every stack slot is sized for that:
        ! each leaf contributes its three fields plus quotes/spaces, and each operator node at
        ! most " and " plus a pair of parentheses.
        frag_len = nleaves * (filter_leaf_name_len + filter_leaf_op_len + filter_leaf_value_len + 4) + &
            nnodes * 7 + 1
        ! Stack depth is bounded by the recursion that produced the list: between two nesting
        ! levels the parser holds at most one pending operand per production (or/and/not), so
        ! three per level, plus the operand being built.
        stack_cap = min(nnodes, 3 * (filter_max_depth + 1) + 2)
        allocate(character(len=frag_len) :: stack_text(stack_cap))
        allocate(stack_prec(stack_cap))
        top = 0

        do i = 1, nnodes
            kind = int(node_kind(i))
            select case (kind)
            case (ND_LEAF)
                li = int(node_leaf(i))
                rendered = trim(leaf_name(li)) // " " // trim(leaf_op(li))
                if (leaf_is_string(li) /= 0_int8) then
                    rendered = rendered // ' "' // trim(leaf_value(li)) // '"'
                else if (len_trim(leaf_value(li)) > 0) then
                    rendered = rendered // " " // trim(leaf_value(li))
                end if
                top = top + 1
                ! gcov attribution artifact: the condition below is evaluated for every leaf, so the
                ! `if` line registers hits even though the branch is never taken -- confirmed by its
                ! own body (the two lines inside) reliably showing zero.
                if (top > stack_cap) then ! GCOVR_EXCL_START -- assertion: the bound above is
                    ! derived from the parser's own recursion, so exceeding it would mean the
                    ! node list did not come from parquet_parse_filter_expr.
                    text = ""
                    return
                end if ! GCOVR_EXCL_STOP
                stack_text(top) = rendered
                stack_prec(top) = PREC_LEAF
            case (ND_NOT)
                lhs = trim(wrap(trim(stack_text(top)), stack_prec(top), PREC_NOT))
                stack_text(top) = "not " // lhs
                stack_prec(top) = PREC_NOT
            case (ND_AND, ND_OR)
                prec = PREC_OR
                if (kind == ND_AND) prec = PREC_AND
                rhs = trim(wrap(trim(stack_text(top)), stack_prec(top), prec))
                lhs = trim(wrap(trim(stack_text(top-1)), stack_prec(top-1), prec))
                top = top - 1
                if (kind == ND_AND) then
                    stack_text(top) = lhs // " and " // rhs
                else
                    stack_text(top) = lhs // " or " // rhs
                end if
                stack_prec(top) = prec
            end select
        end do

        text = trim(stack_text(1))
    contains
        !> Parenthesises `frag` iff its own precedence level binds more loosely than the parent
        !> operator's -- exactly when dropping the parentheses would change the parse.
        pure function wrap(frag, frag_prec, parent_prec) result(res)
            character(len=*), intent(in) :: frag !! already-rendered child fragment.
            integer, intent(in) :: frag_prec !! the child's own precedence level.
            integer, intent(in) :: parent_prec !! the parent operator's precedence level.
            character(len=len(frag)+2) :: res !! `frag`, parenthesised if needed (blank-padded if not).
            if (frag_prec > parent_prec) then
                res = "(" // frag // ")"
            else
                res = frag
            end if
        end function wrap
    end procedure parquet_render_filter_expr

    !> Renames the columns a filter's rules refer to, by parsing each rule, substituting names in
    !> the parsed leaf array, and re-rendering. See parquet_filter's own doc comment for what this
    !> is for and for the two deliberate non-failures (an unmatched name, an unparseable rule).
    !>
    !> Substituting in the PARSED form rather than in the rule text is what makes this safe:
    !> parquet_parse_filter_expr has already separated column names from operators, keywords,
    !> parentheses and quoted literals into leaf_name(:), so no second tokenizer is involved and a
    !> literal that happens to spell a column name cannot be hit by mistake.
    !>
    !> A rule none of whose leaves changed keeps its ORIGINAL text, byte for byte, rather than
    !> being re-rendered into canonical form. That is deliberate: it holds the round trip -- the
    !> one genuinely new correctness risk this procedure introduces, since it makes
    !> parquet_render_filter_expr semantically load-bearing for the first time -- to the rules that
    !> actually had to be rewritten, instead of running every rule of every filter through it.
    module procedure parquet_filter_remap_column_names
        integer(int8), allocatable :: node_kind(:), leaf_is_string(:)
        integer(int32), allocatable :: node_leaf(:)
        character(len=filter_leaf_name_len), allocatable :: leaf_name(:)
        character(len=filter_leaf_op_len), allocatable :: leaf_op(:)
        character(len=filter_leaf_value_len), allocatable :: leaf_value(:)
        character(len=:), allocatable :: errmsg, text, tmp(:)
        character(len=32) :: cap_str
        logical :: ok, changed
        integer :: i, j, k, nnodes, nleaves

        if (size(from) /= size(to)) error stop "parquet_filter%remap_column_names: from and to " // &
            "must have the same size"
        if (size(from) == 0 .or. this%n == 0) return

        do i = 1, this%n
            nnodes = 0
            nleaves = 0
            call parquet_parse_filter_expr(this%rules(i), node_kind, node_leaf, nnodes, leaf_name, &
                leaf_op, leaf_value, leaf_is_string, nleaves, ok, errmsg)
            ! Left as-is on purpose: the reader that eventually applies this rule reports the parse
            ! failure with the file name attached, which is a better message than anything this
            ! procedure could produce, and renaming a rule nobody can parse has no meaning anyway.
            if (.not. ok) cycle
            changed = .false.
            do j = 1, nleaves
                do k = 1, size(from)
                    if (trim(leaf_name(j)) /= trim(from(k))) cycle
                    if (len_trim(to(k)) > filter_leaf_name_len) then
                        write(cap_str, '(i0)') filter_leaf_name_len
                        error stop "parquet_filter%remap_column_names: replacement column name '" // &
                            trim(to(k)) // "' exceeds the maximum supported length (" // &
                            trim(cap_str) // " characters)"
                    end if
                    leaf_name(j) = trim(to(k))
                    changed = .true.
                    exit
                end do
            end do
            if (.not. changed) cycle
            call parquet_render_filter_expr(node_kind, node_leaf, nnodes, leaf_name, leaf_op, &
                leaf_value, leaf_is_string, nleaves, text)
            ! Raised here rather than left to %add's generic length error, which would name a limit
            ! the caller never wrote: the rule they wrote fitted, and only the renaming pushed it
            ! over.
            if (len(text) > filter_max_rule_len) then
                write(cap_str, '(i0)') filter_max_rule_len
                error stop "parquet_filter%remap_column_names: rule exceeds the maximum supported " // &
                    "length (" // trim(cap_str) // " characters) after remapping its column names: " // &
                    text(1:100) // "..."
            end if
            if (len(text) <= len(this%rules)) then
                this%rules(i) = text
            else
                ! Every entry shares one length (the %add convention), so a longer rewritten rule
                ! re-lengthens the whole array.
                allocate(character(len=len(text)) :: tmp(this%n))
                tmp(1:this%n) = this%rules(1:this%n)
                tmp(i) = text
                call move_alloc(tmp, this%rules)
            end if
        end do
    end procedure parquet_filter_remap_column_names

end submodule parquet_read_filter