Skip to content

feat: claim binding call sites through interceptors - #89

Merged
glennawatson merged 13 commits into
mainfrom
feat/report-foreign-mixin-binding-calls
Sep 9, 2026
Merged

feat: claim binding call sites through interceptors#89
glennawatson merged 13 commits into
mainfrom
feat/report-foreign-mixin-binding-calls

Conversation

@glennawatson

@glennawatson glennawatson commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature: compile-time bindings reach every call site, plus the analyzer diagnostic for the calls that never
reached the generator at all.

What is the new behavior?

A binding call site is claimed by an interceptor wherever the compiler can honour one.

  • From Roslyn 4.13 the generator describes each call site to the compiler and attaches the generated method to
    it. Nothing about the redirection goes through extension-method lookup, so it serves the call sites an
    overload cannot: a file declared outside the project's root namespace, and a project below C# 10 whose
    assembly exposes its internals.
  • Interception is refused on a language version rather than chosen by one, so a
    <LangVersion>7.3</LangVersion> project on a current SDK gets it.
  • All twelve generated APIs are claimed: WhenChanged, WhenChanging, WhenAnyValue, WhenAny,
    WhenAnyObservable, BindOneWay, BindTwoWay, OneWayBind, Bind, BindTo, BindCommand and
    BindInteraction.
  • Each interceptor carries the signature of the stub it replaces, including the receiver and the caller-info
    parameters, which is what the compiler requires of one. Each API writes that list once, and the same writer
    serves its dispatch overload.
  • <ReactiveUIBindingUseInterceptors>false</ReactiveUIBindingUseInterceptors> keeps the overloads on a
    compiler that could intercept.

Each runtime package carries the generator and its analyzer once per compiler generation.

  • analyzers/dotnet/roslyn4.8/cs and analyzers/dotnet/roslyn4.13/cs. The .NET SDK resolves the highest slot
    at or below the compiler building the project.
  • A build that never narrows the slots itself is handed every one. The packaged targets drop the slots that
    compiler is not served by, so the generator is not loaded twice and no dispatch file is emitted twice.
  • A compiler older than Roslyn 4.8 receives no slot, which would leave every binding call throwing at run time
    with nothing to point at. The targets fail the build with RXUIBIND100 naming both versions instead.

RXUIBIND011 reports a binding call that ReactiveUI's own mixin answered.

  • Which method a call reaches is decided by extension-method lookup. A file that imports ReactiveUI and not
    ReactiveUI.Binding binds to ReactiveUI's mixin, and every extractor recognises a call by its declaring
    type, so that call is not one of ours: no dispatch is generated and no other diagnostic has anything to say
    about it.
  • The declaring type is read after walking out of any nested type, so an extension declared in an extension
    block is recognised by the class the consumer wrote rather than the synthesized one its members belong to.
  • The diagnostic only registers for a compilation that references this package.

RXUIBIND009 stays silent where the call site is claimed outright. The reach of a dispatch overload decides
nothing when no overload is what serves the call.

RXUIBIND002 stays silent behind BindTo, whose first type argument names the value type of a stream the
caller already built rather than an object anything observes.

The suites run against both compiler builds. The generator and analyzer test projects each have a mirror
compiled against Roslyn 4.13, so the tier a build gets is asserted on the build that gets it.

NSubstitute is gone. The symbol shapes those tests needed come from a compilation instead: a function
pointer's signature belongs to no type, an array type to no namespace, and an extension block declares its
members in a type with no name.

What is the current behavior?

Every call site is reached by a concrete overload competing in extension-method lookup. A file outside the root
namespace, or a project below C# 10 whose assembly exposes its internals, is out of that overload's reach and
falls through to the runtime stub - which throws.

Closes #81
Closes #87

What might this PR break?

  • A project that deliberately calls ReactiveUI's mixins while referencing this package sees a warning per call
    site from RXUIBIND011.
  • A compiler older than Roslyn 4.8 now fails the build with RXUIBIND100 rather than building without a
    generator.
  • Generated code moves to the ReactiveUI.Binding.Generated.Interceptors namespace where interception is in
    effect. Anything reaching into the generated dispatch class by name would not find it there; nothing in the
    public surface does.

Checklist

  • I have read the Contribute guide
  • Tests have been added or updated (for bug fixes / features)
  • Docs have been added or updated (for bug fixes / features)
  • Changes target the main branch
  • PR title follows Conventional Commits

Additional information

Verified against real builds rather than only the suite, since which tier a project gets is decided by the
compiler and not by anything in the repository:

Sample SDK / compiler Slot resolved Tier emitted Outcome
net10.0 11 / 4.13+ roslyn4.13 interceptors six bindings observed and written
net462, <LangVersion>7.3 8.0.4xx / 4.11 roslyn4.8 overloads six bindings, run on Windows 11
net462, both slots handed over 11 / 4.13+ roslyn4.13 interceptors one generator loaded, build clean
net462 7.0.4xx / 4.7 none none RXUIBIND100

A generation pass measured with the EventPipe GcVerbose profiler allocates the same either way - 1.92 MB at
one view-model pair, 29.54 MB at sixteen, 118.1 MB at sixty-four - and the trace attributes no allocation site
to this package at all. Every one it names is Roslyn binding the call: extension-method type-argument
inference, alpha renaming and parameter substitution.

- RXUIBIND011 reports a call that extension-method lookup sent to ReactiveUI's
  mixin rather than a generated overload. Nothing reported this before: the
  extractors recognise a call by its declaring type, so such a call was never
  one of ours, generated no dispatch, and took the runtime expression engine
  with the build still green.
- A file reaches that state by importing ReactiveUI for ReactiveCommand
  alongside this package, or by an editor's import cleanup removing the binding
  import that the generated overloads had made look unused.
- The declaring type is read after walking out of any nested type, so an
  extension declared in an extension block is recognised by the class the
  consumer wrote rather than by the synthesized one holding its members.

Closes #87
… overload

- The scenario imported ReactiveUI and not this package, so its WhenAnyValue
  call reached ReactiveUI's mixin and exercised the reflection engine rather
  than the generated observation the scenario is named for.
- Found by RXUIBIND011 on its first run over the repository.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.48%. Comparing base (5393d32) to head (10773d0).

Additional details and impacted files
@@           Coverage Diff            @@
##             main      #89    +/-   ##
========================================
  Coverage   99.48%   99.48%            
========================================
  Files         246      250     +4     
  Lines       10410    10651   +241     
  Branches     1125     1180    +55     
========================================
+ Hits        10356    10596   +240     
  Misses         42       42            
- Partials       12       13     +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…iler allows

- Compile the generator a second time against Roslyn 4.13, the first release where
  the call-site description API is supported rather than experimental. The two
  builds share every source file and differ only in what ROSLYN_4_13 turns on.
- Where that build is loaded and the consumer has opted the generated namespace
  into interception, an observation claims its call site by name instead of
  competing for it through extension-method lookup. That removes the namespace
  placement, the generated import and the per-call expression-text match.
- A call site the compiler declines to describe is left alone, which is the same
  outcome an out-of-reach dispatch overload already produces.
- Read the description in one helper and write the attribute in one emitter, so
  neither build grows its own copy and no API-specific code repeats it.

Towards #81
…ation APIs

- Move the grouping, the attribute and the parameter list into InterceptorEmitter,
  so WhenAny reuses them rather than repeating them and each API supplies only
  the signature it is called with.
- Claim WhenAny call sites through the same path as WhenChanged, WhenChanging
  and WhenAnyValue.
…eptors

- BindOneWay, BindTwoWay, OneWayBind and Bind already describe their output
  through one dispatch descriptor, so the interceptor shape is written once
  against that descriptor rather than four times.
- The interceptor takes what the call site passes and forwards to the worker,
  so the dispatch parameters and the run-time selector-text match go away.
…sites

- Emit interceptors for BindTo, BindCommand and BindInteraction, completing
  the tier across every generated API.
- Share the parameter writers between each API's dispatch overload and its
  interceptor, so one description of a signature serves both.
- Give every interceptor the signature of the stub it replaces, including
  the receiver and the caller-info parameters, which is what the compiler
  requires of one.
- Emit each API's parameter list from one writer serving both its dispatch
  overload and its interceptor.
- Ship the generator and analyzer in a slot per compiler generation, with
  targets that keep a build handed both from loading both and that list the
  generated namespace where interception can be honoured.
- Stay silent on the reach of a dispatch overload where the call site is
  claimed outright instead.
- Stop reporting a stream's value type as unobservable behind BindTo, whose
  first type argument names no object anything observes.
- Run the generator and analyzer suites against both compiler builds.
- Replace the substituted symbols with ones a compilation produces: a
  function pointer's signature belongs to no type, and an extension block
  declares its members in a type with no name.
- Set out the interceptor and overload mechanisms, what each package slot
  carries, and the settings a consumer has.
- List BindTo among the supported APIs.
- Assert the shipped slots against the versions the targets select between.
- Measure a generation pass with and without the interception opt-in, against
  both compiler builds.
- Pass the language-feature snapshot as an argument rather than closing over
  it, so the writer each observation API supplies captures nothing and the
  compiler caches one delegate for the whole compilation.
- Drop the dependency group entry for a package nothing references.
@glennawatson glennawatson changed the title feat(analyzer): report a binding call answered by ReactiveUI's own mixin feat: claim binding call sites through interceptors Sep 9, 2026
- Name a defined language version instead of the moving preview, which the
  test-only Roslyn bump to 5.9.0 makes expressible.
- Exercise all twelve binding APIs through the interceptor tier, so every
  emitter's interceptor path is emitted and its signature validated.
- Cover RXUIBIND009 for a build that lists the generated namespace and for one
  that lists another package's.
- Let a scenario hand parse options straight to the analyzer helper, which is
  how the interception opt-in is expressed.
- Ask one question where the generator and the analyzer each asked two: a
  compiler that cannot describe a call site answers before the opt-in is read,
  so neither has to pair the two tests itself.
- Drop a null guard on a named type's namespace. A named type always has one,
  and the shapes that do not - an array, a pointer, a function pointer - are
  not named types and never arrive there.
- Claim a BindCommand with an observable parameter and a WhenAnyObservable
  with a selector, so both interceptor bodies are emitted and compiled.
- Read a call site the compiler declines to describe, which is the answer every
  caller already handles.
- Say what the array case proves, rather than naming it for a guard it never
  reaches.
- Compare the Android widget names in order rather than switching over them,
  so each comparison is a branch a caller can reach rather than a bucket of a
  jump table the compiler derived.
- Give TimePicker's four names a predicate that says why there are four: the
  hour and the minute are spelled one way from API 23 and another before it.
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@glennawatson
glennawatson merged commit 8472562 into main Sep 9, 2026
12 checks passed
@glennawatson
glennawatson deleted the feat/report-foreign-mixin-binding-calls branch September 9, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant