Skip to content

Fix continue in for loops, nested qualified generic arguments, and associated type lookup through bounds - #1600

Merged
sbillig merged 23 commits into
masterfrom
pr/upstream-small-fixes
Sep 29, 2026
Merged

sbillig merged 23 commits into
masterfrom
pr/upstream-small-fixes

Conversation

@micahscopes

@micahscopes micahscopes commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Small bug fixes to the loop lowering, the parser and associated type lookup, plus tests for a fix that mainline #1535 made, a clearer ambiguity hint, and a layout fix and a packed record layout test moved here from #1589. Sean's commits fix the for loop regression the first commit introduced, rework how the parser decides a <<, extend that decision to method calls, the formatter and the tree-sitter grammar, keep deep nesting fast, and make supertrait lookup merge and compare candidates correctly. Each fix has a test that fails without it.

continue in a for loop now advances. continue jumped back to the loop condition without running the index increment, so the loop revisited the same element. For example, in for i in 0..5 { continue } the index never moved past 0. The increment now has its own block, and both the end of the body and continue go through it. while loops are unchanged. Test: crates/fe/tests/fixtures/fe_test/for_continue_advances.fe (unconditional, nested, and break-after-continue cases, plus a while inside a for). In four Sonatina IR snapshots of existing for loops, the existing increment moves into its own block, the body gains one jump to it, and blocks renumber.

That change first built the increment block for every loop, so a body that always ends in break, return or a diverging call left an unreachable block that read the loop index, and loops such as for v in a { return v } failed with an internal borrow checking error. Sean's 53b847b and 8ad84af fix this: lowering records whether a reachable continue targets the loop, and when neither fallthrough nor continue reaches the increment, the block is closed with a jump to itself, as unreachable if and match joins are, and nothing is emitted into it. His cases in for_continue_advances.fe cover a body that always breaks, always returns, exits on every branch, or diverges. Three more cases there cover shapes the existing continue cases do not: continue in one arm of an if/else with return in the other, continue and break in the arms of a match, and an inner loop that always breaks inside an outer loop that advances.

Wrapped<<T as Model>::Point> now parses as a generic argument. The parser treated the << as a left shift and skipped the generic arguments. In type position they ended up attached to the whole type instead of Wrapped, where lowering ignores them. In expression position, for example Wrapped<<M as Model>::Point>::new(point), the parser reported a syntax error. After a path segment, a << now opens generic arguments only when a qualified path follows it, since no shift operand continues with >:: (Sean's fd291c8). An earlier version of this PR let a generic-argument trial parse decide, which misread shifts whose operand contains a cast, such as value << bits as u256 >> 1. A bare qualified first argument, Wrapped<<T as Model>>, is not read as generic arguments; Wrapped< <T as Model> > is, and the formatter now keeps that spacing (a2a1699). The same rule applies to method calls such as receiver.take<<M as Model>::Point>(42) (a1b41e2) and to the tree-sitter grammar, whose scanner skips string literals and comments while it looks for the closing > (cd37d81, 392092b; the vendored wasm is regenerated). The speculative parses behind these decisions are now memoized per position, with error recovery confined to the scopes a probe opened, so twelve levels of Wrap<<T as Model>::Point> parse in milliseconds instead of about 96 seconds (ccd46d3, 14f04dd, 0acbfa0). Tests: syntax tree fixtures for the type form and for shifts after a path, including the cast forms; an error recovery fixture for a missing closing >; a parser fixture for the expression form; executed fixtures for the regressed shifts, for a method call with a qualified argument, and for a shift compared against a qualified associated constant; formatter snapshots; tree-sitter tests for the accepted and the shift forms; a nesting-depth test for the parser; and a trait resolution fixture that uses the nested argument in a trait method signature.

Tests for impls that stay generic over the trait's parameter. Inside trait T<A>, Self::Out used to drop A, so impl<A, P: T<A>> T<A> for Wrap<P> with fn f(_ a: A) -> Self::Out was rejected with "expected Wrap<P>::Out". Mainline #1535 fixed this with the same change this PR made, building the trait instance from all of the trait's parameters, so this PR now only adds tests for that case: crates/fe/tests/fixtures/fe_test/generic_trait_arg_impl_assoc_type.fe runs such an impl, and a type check fixture keeps the error for a genuinely wrong return type, which names the real type, (P::Out, A).

Self::Dom from a supertrait now resolves. In trait Eval: Arrow { fn eval(_ value: Self::Dom) }, where Arrow declares type Dom, Self::Dom failed with "Dom is not found". If the trait has no associated type with that name, lookup now checks the bounds its supertraits imply for Self, starting from the same trait instance #1535 builds for Self::Out. Bounds on the trait's own associated types, such as type Inner: Arrow, are not used. Sean's 938b152 merges these inherited candidates with the bounds in scope at the reference instead of returning early, so a method's own where Self: Extra competes with an inherited Item and is reported as ambiguous rather than silently losing, and a bound reached twice is not ambiguous with itself. His 6b69446 compares candidates under the implied bounds, so a diamond that reaches one declaration through Left: Base<Item = u256> and Right: Base resolves, while paths that bind different types stay ambiguous. When two supertraits both declare the name, the ambiguity error has a hint to write <Self as Left>::Dom. When the two are the same generic trait, as in Gen<u8> + Gen<u16>, the candidates and the hint now include the type arguments, <Self as Gen<u16>>::Out; associated constants get the same change. Tests: type check fixtures for the single-supertrait case, the ambiguous case, the generic ambiguous case for types and constants, the associated type bound case, a method-level where bound, a repeated bound, and the diamond with an equality, for Self and for a generic receiver, with and without conflicting equalities.

U::Dom no longer resolves through a bound on another parameter. In fn f<T: Arrow, U>(_ x: U::Dom), U::Dom silently meant T::Dom, and so did Self::Dom in trait Eval<T: Arrow>. The lookup replaced the parameters in each bound's self type with fresh inference variables before matching, so a bound on T matched any receiver. On mainline the leak makes Self::Dom in trait Eval<T: Arrow>: Arrow silently mean T::Dom, so an impl written against the supertrait's Dom is rejected. With inherited and contextual candidates merged (938b152), the leaked bound now shows up as a second Arrow candidate, and the reference is reported as ambiguous. The bounds in scope name the same parameters as the receiver, so they are now matched as written, as method selection already does. That removes the leaked candidate, and the reference resolves to the supertrait's Dom. Test: a type check fixture for the trait case with an inline bound and with a where clause, the function case, T::Dom in the same function, which still resolves, and the supertrait case, which resolves without ambiguity.

Finite nested layouts are no longer rejected as expanding. struct Call { argument: Option<Argument> } with struct Argument { label: Option<u256> } was rejected as an expanding type, because the layout recurrence check treated any repeat of a nominal type (Option here) through ordinary fields as growth. For two struct or enum frames, an earlier type now counts as a recurrence only when it structurally embeds the current one: the same constructor with each argument embedded pairwise, or the earlier type embedded in one of the current type's arguments. Const arguments count as one class, and associated types, type variables and invalid types always count, since the check cannot see into them. Provider families keep the old, stricter rule. The check still terminates on growing types, because the types met along one path are trees over finitely many constructors, and by Kruskal's tree theorem an infinite path would meet an earlier type embedded in a later one; the theorem gives no useful depth bound, so a test also checks the time directly. Tests: finite_nested_options.fe now has a finite layout, expanding_structural_arguments.fe (Growing<T> holding Growing<(T, T)>) is still reported as a non-regular cycle, and expanding_through_wrappers.fe covers growth through an inserted wrapper, directly (Wrapped<T> holding Wrapped<Option<T>>) and through a second type (Ping<T> to Pong<(T, u8)> to Ping<Option<(T, u8)>>), with the check run under a 60 second deadline so a walk that never ends fails instead of hanging. These tests query the layout schema directly, because a growing struct is otherwise rejected earlier as a recursive type definition. This change and its tests moved here from #1589.

A test for packed record layout moved here from #1589. crates/fe/tests/fixtures/fe_test/raw_record_layout.fe checks the byte layout and stride of packed records stored in a memory array, and writes through a raw pointer to a nested field and to an array element of one record. It passes on the current compiler and guards the projection path against regressions. The #1589 review asked for it to move out of that PR.

Testing. The full workspace test run passed locally, 3611 of 3611 tests, before a last test-only change that folded one fe_test fixture file into another and shared a helper between two layout tests. The affected tests (the for_continue_advances.fe run and format round trip, and all of layout_evidence) pass at the head of this branch, which has 3609 tests in total, and fmt, clippy and the newsfragment check are clean.

Merging with the open PRs: #1582 and #1606 merge cleanly. #1576 conflicts in the vendored tree-sitter wasm and its input hash, crates/fmt/tests/format_snapshots.rs and crates/parser/src/parser/param.rs, all from the parser and formatter commits. #1589 as published conflicts in crates/hir/src/analysis/ty/layout_holes.rs and crates/hir/tests/layout_evidence.rs, because it still carries the layout fix that moved here; the conflicts go away once #1589 drops it.

@micahscopes
micahscopes marked this pull request as ready for review September 25, 2026 08:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 821230f637

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/parser/src/parser/path.rs
`continue` inside a `for` loop jumped straight back to the loop condition,
skipping the index increment that only ran when the body fell through. The
loop therefore revisited the same element forever, or until something else
in the body broke out of it.

Lower the increment into its own block and make it the loop's continue
target, so normal fallthrough and `continue` both advance the sequence
before the condition is checked again. `while` loops keep the condition as
their continue target.

The fe test fixture covers an unconditional `continue`, `continue` in a
nested `for`, `continue` followed by `break`, and a `while` nested in a
`for`. The Sonatina IR snapshots for existing `for` loops change only by the
new increment block and its jump.
A path segment skipped generic argument parsing whenever the next tokens
were `<<`, treating them as a left shift. In `Wrapped<<T as Model>::Point>`
that left the arguments off the `Wrapped` segment: in type position they
attached to the surrounding path type, where lowering does not read them,
and in expression position the parser reported a malformed shift.

Drop the `<<` exclusion and let the existing generic argument dry run
decide. A real shift such as `value << 2` does not parse as a generic
argument list, so it still parses as a shift.

Tests: a syntax tree fixture for the type form, a parser fixture for the
expression form (a call and a struct literal), a type check fixture that
uses the nested argument in a trait method signature, and a syntax tree
fixture that keeps shifts after a path parsing as shifts.
Mainline #1535 builds `Self::Out` inside a generic trait from all of the
trait's parameters, so `impl<A, P: T<A>> T<A> for Wrap<P>` with
`fn f(_ a: A) -> Self::Out` now type-checks. Its fixture covers bare
associated names on concrete impls; these fixtures cover an impl that
stays generic over the trait's parameter.

The fe test fixture runs such an impl end to end. The type check fixture
keeps the diagnostic for a genuinely wrong return type, which names the
impl's actual `(P::Out, A)` instead of `Wrap<P>::Out`.
Inside a trait, `Self::Name` was looked up only among the trait's own
associated types. In `trait Eval: Arrow`, `Self::Dom` failed with "`Dom`
is not found" even though every `Self` of `Eval` also implements `Arrow`,
which declares `Dom`.

When the trait itself has no associated type of that name, look through
the bounds implied for `Self` by the trait's declared supertraits
(transitively) and return every one that declares it. Bounds on the trait's
own associated types, such as `type Inner: Arrow`, have `Self::Inner` as
their self type and are skipped, so `Self::Dom` does not resolve through
them. One match resolves the name; two or more go through the existing
ambiguity diagnostic, which suggests the qualified form such as
`<Self as Left>::Dom`.

Type check fixtures cover the single-supertrait case, which now has no
diagnostics, the ambiguous two-supertrait case, and an associated type
bound that must not be used.
Review of the nested qualified generic argument fix asked whether
`value << <T as Model>::BITS > limit` could now be claimed by the generic
argument trial on `value`, leaving `limit` disconnected. It is not: that
expression has three `<` tokens (`<<`, then the `<` of `<T`), so the trial
would need `< <T as Model>::BITS >` to parse as a type argument, which it
does not, and the parser falls back to the shift. The result is
`(value << <T as Model>::BITS) > limit`, as before the fix.

This fixture pins that behavior with executed checks: `>`, `>=`, the
unspaced `value<<<T as Model>::BITS`, a following `>>`, and the comparison
as a call argument. The unspaced form is deliberate, so the formatter's
spacing change is expected; the formatter round-trip test compares meaning.
@micahscopes
micahscopes force-pushed the pr/upstream-small-fixes branch from 9bab302 to e15f10a Compare September 28, 2026 00:45
@micahscopes micahscopes changed the title Fix continue in for loops, nested qualified generic arguments, and two associated type lookups Fix continue in for loops, nested qualified generic arguments, and supertrait associated type lookup Sep 28, 2026
@micahscopes
micahscopes requested a review from sbillig September 28, 2026 15:50
The advance block that `continue` now targets was emitted even when every
path through the loop body leaves the loop through `break`, `return`, or a
diverging call. The orphaned block read the loop index, which semantic
normalization cannot resolve in an unreachable block, so valid functions
such as `for i in 0..3 { break }` failed with an internal borrow checking
error.

Record whether a `continue` emits an edge from a live block. When neither
fallthrough nor `continue` reaches the advance block, close it with a
self-loop, as dead `if` and `match` joins already are.
Removing the `<<` exclusion let any successful generic-argument trial
claim a left shift. A cast inside a shift is also a valid qualified
type, so `value << bits as u256 >> 1` parsed as `value< <bits as u256> >`
followed by a stray `1`. Path segments after the first are parsed as
types, so `M::BITS << bits as u8 >> 1` broke the same way.

After a path segment, `<<` now opens generic arguments only when a
qualified path follows, as in `Wrapped<<T as Model>::Point>`. No shift
operand continues with `>::`, so that prefix decides it and the
arguments are parsed directly instead of trying the whole list first.
This also drops the repeated full-list trial at each nesting level of
`Wrap<<... as Model>::Point>`. `QualifiedTypeScope` now reports a missing
`as` instead of asserting, so it can recognize the prefix.

A bare qualified first argument, `Wrapped<<T as Model>>`, is again not
read as generic arguments; `Wrapped< <T as Model> >` still is.

Tests: the regressed shifts added to the `path_lshift` syntax tree
fixture, a recovery fixture for a qualified argument list missing its
closing `>`, and an executed fixture for each regressed shift.
The formatter printed `Wrapped< <T as Model>>` as `Wrapped<<T as Model>>`.
The parser reads `<<` as generic arguments only before a qualified path,
so the formatted code became a shift in expressions and detached the
argument from `Wrapped` in types.

Format such lists with inner spaces, `Wrapped< <T as Model> >`, and leave
qualified paths such as `Wrapped<<T as Model>::Point>` unchanged.
`Self::Name` inside a trait returned as soon as the trait's implied
supertrait bounds produced a candidate, skipping the scan of the caller's
assumptions that follows it. A method-level `where Self: Extra` therefore
never competed with an inherited associated type: with `trait Sub: Base`
and `fn take(_ x: Self::Item) where Self: Extra`, where `Base` and
`Extra` both declare `Item`, `Base::Item` was selected silently. An impl
written against `Extra::Item` was then rejected for the inherited type.
The generic receiver form `fn take<T: Base>(_ x: T::Item) where T: Extra`
reports the ambiguity correctly, because both bounds reach it through one
assumption list.

A trait's own `Self` bounds are absent from `assumptions` in header
positions such as a method signature, where assuming `Self: Trait` while
the trait's interface is still being lowered can recurse through the
in-progress definition. The two candidate sources are therefore disjoint
and must be merged rather than one preferred. Candidates are also
deduplicated, so a method that restates a bound the enclosing trait
already implies is not reported as ambiguous with itself.
Candidates were compared by normalizing each one under the caller's
assumptions alone. A diamond that reaches one declaration twice, once
with an equality, produced two keys and was reported as ambiguous with
itself: with `trait Left: Base<Item = u256>` and `trait Right: Base`, the
candidate from `Left` carries the binding and normalizes to `u256`, while
the binding-free candidate from `Right` stays an unresolved projection
because the equality only appears once the bounds are extended.

Normalization now uses the implied-bound closure of the assumptions, plus
the enclosing trait's own implied bounds when the receiver is that
trait's `Self`, which header positions withhold from `assumptions`. Both
the `Self` and the generic receiver forms of the diamond are affected;
the latter was wrong before the inherited lookup existed. Each candidate
is still keyed on its own equality first, so paths that disagree on the
bound type remain ambiguous instead of collapsing to whichever was seen
first.
Telling generic arguments from a shift or a comparison needs a speculative
parse, and four of them run over the same syntax: the qualified-type
check, the `<<` check, the const-call check, and the argument-list check.
Each level of a nested argument probed the positions inside it, and every
enclosing level probed them all again, so cost grew about fivefold per
level of depth. `Wrap<<T as Model>::Point>` nested twelve deep is 299
bytes and took about 96 seconds to parse; nine deep took 0.8 seconds.

`dry_run` restores the stream, position, buffered trivia and errors, and
leaves the tree builder alone, so a probe that records no error is a
function of the position it starts from and of whether newlines are
trivia there. The input does not change during a parse, so those
outcomes are now kept and reused. Twelve deep parses in milliseconds and
two hundred deep, at 4.8 KB, in about a tenth of a second.

Outcomes reached through error recovery are not reused. `recover` scans
the whole enclosing scope stack for a token to stop at, so how far it
consumes, and with it the outcome, can depend on where the probe ran from
rather than only on the position. Those are re-run as before, which
leaves malformed input exactly as it was.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T04:47:32.695832Z 8ad84af New commits
🔒 Security Review ✅ Completed 2026-09-29T04:50:35.094529Z 8ad84af New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ccd46d3070

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/parser/src/parser/mod.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1b41e2f6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/hir/src/analysis/name_resolution/path_resolver.rs
Path segments decide a `<<` by whether a qualified path follows, but
`is_method_call` still rejected one outright, so
`receiver.take<<M as Model>::Point>(42)` parsed `.take` as a field and the
`<<` as a shift. The type and static path forms the same rule already
fixed made the inconsistency visible: `Wrapped<<M as Model>::Point>::new(p)`
parsed while the method form did not.

Both now call `lshift_opens_generic_args`, so the rule lives in one place
and cannot drift. A `<<` that is not followed by a qualified path is still
a shift, as in `value.bits << 1`.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd37d81e6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/tree-sitter-fe/src/scanner.c
sbillig and others added 6 commits September 29, 2026 02:44
Only a probe that reaches its outcome without error is independent of the
scope stack it ran under, but the check for that compared error-list
lengths, and `recover` does not add to the error list while in dry run
mode: it raises the dry run's own flag instead. A probe whose recovery
consumed tokens therefore looked error free and had its outcome cached,
which is the context-dependent case the check exists to exclude.

Both signals are now consulted. No input was found where the previous
check produced a wrong tree or diagnostic, so this closes the hole rather
than fixing an observed misparse.
The compiler accepts `Wrapped<<M as Model>::Point>::new(point)` but the
tree-sitter grammar reported an error on it, so editors flagged valid
code. In a type position the scanner already emitted the first `<` of a
`<<` as a generic open, because a comparison cannot appear there and
nothing else the `<<` could be remains. In an expression position both a
generic open and a comparison are possible, so it deferred to the internal
lexer and got a shift.

The scanner now applies the rule the parser uses: it scans the second `<`
to its matching `>` and takes the `<<` as a generic open only when `::`
follows, since no shift operand continues with `>::`. Shifts are
unaffected, including `value << bits as u256 >> 1` and a shift whose
operand is qualified, as in `value << <M as Model>::BITS > limit`, whose
closing `>` is a comparison.

The vendored wasm is regenerated, as its input hash covers the scanner.
Coverage goes in the parser crate's tree-sitter tests rather than a
fixture directory, because both tree-sitter fixture suites skip
`uitest/fixtures/parser`, and it pins the shift forms alongside the
accepted ones.
Deciding whether a `<<` opens generic arguments means scanning ahead for the
`>` that closes a qualified path, and the scan runs on to the end of the
enclosing block before giving up. It read every character as code, so a `>::`
inside a string literal or a comment closed the angle nesting and made an
ordinary shift lex as a generic open, leaving ERROR nodes on valid code:

    let a = x << y
    let s = "a>::b"

`a <<= y` was affected the same way, since the shift-assign reached the
lookahead too. Only editor highlighting was wrong; the compiler decides this
on tokens and was never confused.

String literals and comments are now skipped whole, and a `=` right after the
second `<` settles `<<=` as a shift before any scanning. The vendored wasm is
regenerated, as its input hash covers the scanner.

The sibling single-`<` scan has the same hole for string literals, which is
left alone here: it is pre-existing, plain comparisons are far more common
than shifts, and the change deserves its own coverage.
A probe outcome is only reusable if it depends on nothing but the input and
the position, and error recovery was the one thing that broke that: `recover`
searches the whole enclosing scope stack for a token to stop at, so how far it
consumes, and therefore the outcome, depends on where the probe ran from.

Excluding those outcomes from the cache does not work. The check could not see
recovery inside a *nested* dry run, which is where it happens once probes nest,
because an inner `dry_run` discards both the error list and the dry run's own
flag. Counting every error in a never-reverted counter closes that but un-caches
far too much: malformed nesting at depth 10 went from 13 ms to 99 ms, growing
about 2.5x per level. Exempting a recovery whose match resolved inside the probe
is unsound, because another enclosing context can accept an earlier token and
stop the scan sooner.

So the dependence is removed rather than detected. A probe records its scope
depth and `recover` searches no further out, which is what a probe already asks
-- would this construct parse here, on its own -- and a probe's tree and errors
are discarded either way. Every outcome is then a function of the cache key, so
the cache no longer needs to judge which ones qualify.

Malformed input is memoized as a result, which the cache never managed before:
about 20-35 us at any depth, against 13 ms at depth 10 growing over twofold per
level. An editor sees malformed input on every keystroke, so that is the case
that matters most.

The new test pins the reuse, not the confinement, which has no effect on
timing; that nothing observable changed is what the rest of the suite shows.
When every body path leaves the loop, `advance_bb` is closed with a self-loop
and nothing can reach it, but the increment was emitted into it anyway and
relied on `push_stmt` and `set_terminator` no-opping on a terminated block.
`emit_expr` still allocated its temps, so each such loop left two locals
declared and never assigned.

The emission now sits in the arm that knows the block is reachable, and the
self-loop in the other. Same CFG either way.
The skipped advance block fix (53b847b, 8ad84af) builds the
increment when the body falls through or a reachable `continue` targets
it. `continue_and_break_preserve_sequence_order` already covers a body
that never falls through while a `continue` reaches the increment: an
`if` with no `else` holds the `continue`, and a `break` follows it.
These cases add three shapes that one does not reach:

- `continue` in one arm of an `if`/`else` and `return` in the other, so
  the `if` join is unreachable and the other path leaves the function
  instead of the loop;
- `continue` and `break` in the two arms of a `match`, so the reachable
  `continue` comes from the `match` lowering;
- an inner loop whose body always breaks inside an outer loop that
  advances by falling through, so the inner loop skips its increment
  while the outer loop keeps its own.

They sit in `for_continue_advances.fe` next to the existing `continue`
cases and the cases where every path exits. All three pass with the fix.
For a type parameter receiver, associated type lookup tried each bound
in scope by unifying the receiver with the bound's self type. It first
replaced the parameters in that self type with fresh inference
variables, so a bound's bare `T` became a variable that unified with any
receiver. `U::Dom` in `fn f<T: Arrow, U>(_ x: U::Dom)` therefore
resolved to `T::Dom`, and so did `Self::Dom` in `trait Eval<T: Arrow>`,
with or without a `where` clause. Mainline has the same behavior.

On mainline the leak makes `Self::Dom` in `trait Eval<T: Arrow>: Arrow`
silently mean `T::Dom`, so an impl written against the supertrait's
`Dom` is rejected. With inherited and contextual candidates merged
(938b152), the leaked bound now shows up as a second `Arrow`
candidate, and the reference is reported as ambiguous (2-0009).

The bounds in scope name the same parameters as the receiver, so match
their self types as they are. Method selection already keeps bound
arguments intact for type parameter receivers for the same reason.

The type check fixture covers `Self::Dom` in a generic trait with an
inline bound and with a `where` clause, `U::Dom` next to `T: Arrow` in a
function, `T::Dom` in the same function, which still resolves, and
`Self::Dom` from a supertrait next to `T: Arrow`, which resolves without
ambiguity.

Failing first: at 8ad84af the snapshot lacks the three "not found"
errors. That run predates the supertrait case. For that case, on master
the trait resolves `Self::Dom` to `T::Dom` without an error: with
`type Dom = u32` for `D` and `type Dom = bool` for `S`, the valid
`impl Eval<D> for S { fn eval(_ v: bool) -> u256 { 0 } }` is rejected
with "expected `u32`, found `bool`". At 8ad84af with only this
fixture added, the same trait reported 2-0009 with `Arrow` listed
twice; that was observed in a session log, and the probe binary was
not kept.
When `Self::Out` was ambiguous between `Gen<u8>` and `Gen<u16>`, both
candidate labels read `Gen` and the hint suggested `<Self as Gen>::Out`,
which does not pick either one. The same happened for an associated
constant with impls of `Gen<u8>` and `Gen<u16>`.

Render the trait in candidate labels and hints with its type arguments,
as it is written after `as`, and leave out associated type bindings,
which are not part of that path. Trait instances that compare equal by
name and self type are now ordered by that rendering, so the hint picks
the same candidate every time. Traits without type arguments print as
before, so existing snapshots do not change.

Type check fixtures cover the two-supertrait `Gen<u8> + Gen<u16>` case
and the associated constant case.
Cover the byte layout and stride of packed records stored in a memory
array, and writes through a raw pointer to a nested field and to an
array element of one record. The test passes on the current compiler;
it guards the projection path against regressions.
Layout recurrence checking treated any repetition of a nominal type
through ordinary fields as growth, so a struct holding
`Option<Argument>`, where `Argument` holds `Option<u256>`, was rejected
as an expanding type although its layout is finite. For two ADT frames,
keep an ancestor as a recurrence only when its type structurally embeds
the current type. Provider families keep the stricter rule.
The finite nested layout fix narrows which earlier types count as a
recurrence, so it needs evidence that the check still stops on types
that grow. The existing test covers growth by duplicating an argument,
`Growing<T>` holding `Growing<(T, T)>`.

Add growth through an inserted wrapper, directly (`Wrapped<T>` holding
`Wrapped<Option<T>>`) and through a second type (`Ping<T>` holding
`Pong<(T, u8)>`, which holds `Ping<Option<T>>`). The test queries the
layout schema directly, since a growing struct is otherwise rejected
earlier as a recursive type definition, and runs the check on a worker
thread under a 60 second deadline, so a walk that never ends fails the
test instead of hanging it. Both types are reported as non-regular
cycles well under the deadline. The new test and the existing one read
the schema through one helper.
@micahscopes micahscopes changed the title Fix continue in for loops, nested qualified generic arguments, and supertrait associated type lookup Fix continue in for loops, nested qualified generic arguments, and associated type lookup through bounds Sep 29, 2026
@sbillig
sbillig merged commit 79b54a6 into master Sep 29, 2026
9 checks passed
@sbillig
sbillig deleted the pr/upstream-small-fixes branch September 29, 2026 19:51
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.

2 participants