Skip to content

Add const where predicates - #1576

Open
micahscopes wants to merge 14 commits into
masterfrom
pr/checked-generation-const-predicates
Open

micahscopes wants to merge 14 commits into
masterfrom
pr/checked-generation-const-predicates

Conversation

@micahscopes

@micahscopes micahscopes commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

This lets an item state a boolean condition in its where clause, such as N > 0, and have the compiler check it wherever the item is used.

  • A where clause can hold boolean conditions next to trait bounds

    const fn bounded<const N: usize>() -> u256 where N > 0 { 42 }
    

    bounded<1>() is accepted and bounded<0>() is rejected, even though the body never reads N. On master, a where clause takes only trait bounds, so where N > 0 is a parse error.

  • A generic caller satisfies a condition by stating the same one

    const fn forward<const COUNT: usize>() -> u256 where COUNT > 0 { bounded<COUNT>() }
    

    The same holds in anonymous constants of the caller's signature and body, such as an array length.

  • Structs, enums and inherent methods take conditions too

    struct Bounded<const N: usize> where N > 0 { value: u256 }
    

    Every use of Bounded<0> is rejected, including unused type annotations, defaults and aliases. For a constrained enum, unit variants such as Choice<0>::Empty are rejected too. Method conditions are checked on the method that ordinary resolution picks.

  • A condition without generic parameters is checked where it is declared, even if nothing uses the item

    fn unused() where false {}
    
  • A condition that needs several lines can be written as a block

    fn bounded<const N: usize>() where {
        let limit: usize = 8
        N < limit
    } {}
    
  • where T without : now parses as a condition, and type checking reports the missing trait bound. On master it is a parse error.

  • A tuple-variant constructor used as a value, such as let f = Choice::Value, is an error. On master it panics during lowering.

  • #[error] lowering errors get code prefix 13. On master they share prefix 16 with borrow-checking errors, so error[16-0003] can name two different errors.

A few decisions:

  • Forwarding is exact, not implication: after substitution the two conditions must be the same resolved expression, so N > 1 does not establish N > 0
  • A concrete condition is evaluated at compile time, and a failed evaluation is an error, never a pass
  • Conditions never select impls or methods and add no solver assumptions
  • A braced condition is read as a condition only when a ,, another predicate or the item's own { follows it, so where T: Copy, { body } keeps its meaning. In trait and extern function declarations, a block right after where is always a condition
  • Compile-time evaluation refuses a body that has an unmet requirement

Limits:

  • Conditions in traits, on trait impls with generic parameters, on generic impl blocks themselves and on items nested in generic items are rejected with an error that says so
  • Only literals, const paths, unary and binary operations, casts and calls to const functions can be forwarded. Blocks, control flow and other expressions need concrete arguments
  • A constrained struct or enum must be fully applied; it cannot pass through a higher-kinded parameter
  • Predicate blocks are borrow checked like other const bodies. The test for this needs frame-local borrows in const evaluation (Run more ordinary Fe at compile time #1582), so it will land with that PR
  • The tree-sitter grammar reads where T: Copy, { body } as a condition without a body, since it cannot look past the block; the compiler's parser is unaffected

Also included:

  • Explicit const arguments of method and associated function calls, such as Window<1> {}.take<3>(), are evaluated against the parameter's type, as a free function's are. On master, compile-time evaluation of such a function panics when it reads that parameter. It is its own commit, with a test.
  • A fix for a flaky race between parallel trybuild tests.
  • fe fmt and the tree-sitter grammar understand the new syntax.

Recent mainline changes:

Overlap with open PRs (git merge-tree against their current heads, compared with master): this PR adds conflicts with #1535 in ty_check/callable.rs, in the loop that evaluates explicit const arguments, and in ty_check/env.rs; with #1582 in const_check.rs, where it changes the effects condition that this PR moves; and with draft #1536 in instance/template.rs. #1535 already conflicts with master in capability/index.rs, capability/shape.rs and const_ty.rs, and #1582 in ctfe/machine.rs. Other open PRs, including #1589, get no new conflicts.

Const predicates on trait impls would build on this requirement checking.

@micahscopes micahscopes changed the title Checked generation and const where predicates Add const where predicates Sep 23, 2026
@micahscopes
micahscopes force-pushed the pr/checked-generation-const-predicates branch 4 times, most recently from 96b4bba to 2cda47f Compare September 23, 2026 22:10
fe-mir and fe-hir each had two #[test] functions with their own
trybuild::TestCases. Under nextest every test runs in its own process,
and both processes build their single case as `trybuild000` in the same
scratch project under target/tests/trybuild/<crate>. trybuild's
cross-process lock is a best-effort lockfile that another process takes
over after 1.5 s without a refresh, so on a slow build one process can
rewrite the shared manifest while the other is compiling. The other then
checks its sibling's file, finds no error for its own case, and reports
"Expected test case to fail to compile, but it succeeded". This was seen
on Windows CI, where the two fe-mir tests overlapped and finished 0.2 s
apart.

Keep one `ui` test per crate that globs tests/ui/*.rs, the pattern
trace-facts already uses. Both cases now run in one process as distinct
bins, and new cases cannot reintroduce the race.
`DiagnosticPass::ErrorLower` and `DiagnosticPass::SemanticBorrowck`
both used pass code 16, so `error[16-0003]` could name either a generic
`#[error]` struct or a borrow-checking error. Give error lowering the
unused code 13. Only the four `#[error]` diagnostics change their
rendered code.
An explicit const argument such as the `3` in `Window<1> {}.take<3>()`
or `Window<1>::take<3>()` was lowered without an expected type and
stayed unevaluated. A free function's arguments are evaluated when its
type is applied, but a method's and an associated function's are not,
so constant evaluation of such a function that reads its const
parameter panicked with "instantiated constant template retains its
value description".

Evaluate an explicit const argument against the parameter's type when
it is unified, as type application does. An argument whose expected
type still holds an inference variable, or that fails to evaluate, is
left as before.

The new `fe test` fixture failed with that panic before this change.
Parse boolean conditions in `where` clauses next to type bounds, and
check each condition that mentions no generic parameter where it is
declared, even if the item is unused. A condition passes only when it
evaluates to `true`; type errors, non-const operations and failed
evaluation are errors. Const conditions in generic scopes are rejected
until use-site checking exists.

`where T` without `:` now parses as a const condition, so a lone path
that names a type reports the missing trait bound instead of a type
used as a value.

The tree-sitter grammar, its vendored wasm build and the formatter
learn the new predicate form.

A predicate block goes through semantic borrow checking like any const
body. Its test needs frame-local borrows in const evaluation (#1582)
to evaluate the valid case, so it is added with that change.
Allow const `where` conditions on top-level generic functions. The
declaration checks each condition's type and const-ness, and every use
of the function discharges its conditions after inference: a concrete
condition is evaluated, and a condition that still mentions a caller's
parameter must match one of the caller's own conditions exactly after
substitution. Function values and calls in anonymous constant bodies
use the same check.

Inference and discharge are separate queries, so evaluation can use a
finished inference while a condition is checked. A failed requirement
is reported once, at the call site, with the reason.
An anonymous constant in a generic function's signature or body, such
as an array length, could not use the function's conditions, so a call
to a constrained helper there always failed. Let such constants forward
the enclosing function's conditions by the same exact-match rule as
calls. A predicate still cannot use the function's conditions to
justify itself, including through constants nested inside it.
Allow const `where` conditions on generic structs. Every concrete use
of the type must satisfy them: construction, type annotations, defaults,
aliases, fields and trait arguments, whether declared or inferred. A
generic use forwards a condition only by stating the same condition,
as with generic functions. Mentioning `Bounded<N>` does not establish
`N > 0`.

Each unmet condition is reported once, where the type enters: at the
innermost written type, or at the expression that instantiates it. An
enclosing type does not repeat a failure a nested written type reported.
A call's generic arguments are either written in its path, and checked
there, or inferred from expressions that are checked themselves, so a
function-typed expression is not checked for them. Patterns, binding
uses, blocks and branches only carry a type that entered elsewhere.

A constrained record must be fully applied where it is used as a type,
since nothing would carry its conditions through a higher-kinded
parameter.
A tuple-variant constructor such as `Choice::Value` has direct-call
lowering but no value representation, so using one without calling it,
for example `let f = Choice::Value`, panicked during lowering. Report
an error at the path instead and mark the expression invalid so later
lowering skips it.
Allow const `where` conditions on generic enums, with the same rules as
records. The conditions apply to the whole type, so constructing a unit
variant of `Choice<0>` fails too, and every variant's payload types must
be valid even when that variant is never built. Payloads may forward the
enum's conditions into nested constrained types, and a predicate can
still rely on ordinary trait bounds such as `T::ALLOWED`.
Allow const `where` conditions on inherent methods and associated
functions, over both the impl's parameters and the method's own. The
conditions of the method that ordinary resolution picked are discharged
at each receiver or qualified call; they never take part in choosing a
method. The checker maps the impl's parameters onto the method's leading
generic slots before applying the call's arguments.

The unsupported-context error now names where const predicates are and
are not supported.
A block condition had to be parenthesized, as in `where ({ ... })`,
because a `{` after `where` could also open the item's body. Accept a
bare block as a predicate right after `where` or after a `,`, when what
follows it continues the header: a `,`, another predicate, or the
item's own `{`. A `{` after a completed predicate is always the body,
so `where T: Copy { body }` and `where T: Copy, { body }` keep their
meaning, including when the body starts on the next line. Trait and
extern function declarations, whose bodies are optional, read a block
right after `where` as a predicate.

The tree-sitter grammar accepts a block predicate as well, and the
vendored wasm build is regenerated. The tooling grammar cannot look
past the block, so it reads the `where T: Copy, { body }` form as a
predicate with no body.
The driver tests check substrings of the errors, so duplicate or
misplaced labels pass unnoticed. Snapshot the rendered errors for
declaration failures, calls to generic functions and inherent methods,
record and enum uses, recursive requirements, and the contexts that are
rejected.
A type whose arguments still hold an inference variable is not checked
at that use, and a layout hole leaves such a variable without a "type
annotation needed" error. Check that the concrete arguments are still
checked where the type is declared, next to a valid control case.

Also cover a record requirement that reaches its own record through a
function's requirement. The cycle must be rejected with the same
diagnostics in every query order.
check_body cloned the whole TypedBody out of the tracked infer_body
query, and the tracked check_* queries then kept that copy, so every
function, const and anonymous const body was stored twice. Share the
tables behind an Arc, with copy-on-write for the few places that
mutate a TypedBody. The write-only has_diagnostics flag stays outside
the Arc, so setting it never copies. The shared tables type is private
to the crate, and query results are unchanged.

This branch has not been deployed

No deployments
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.

1 participant