Skip to content

Commit def0686

Browse files
authored
Merge pull request #68564 from dotnet/features/interceptors
Merge "interceptors" experimental feature to main
2 parents 7aa5af7 + 168b475 commit def0686

35 files changed

+6886
-35
lines changed

docs/features/interceptors.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Interceptors
2+
3+
## Summary
4+
[summary]: #summary
5+
6+
*Interceptors* are an experimental compiler feature planned to ship in .NET 8. The feature may be subject to breaking changes or removal in a future release.
7+
8+
An *interceptor* is a method which can declaratively substitute a call to an *interceptable* method with a call to itself at compile time. This substitution occurs by having the interceptor declare the source locations of the calls that it intercepts. This provides a limited facility to change the semantics of existing code by adding new code to a compilation (e.g. in a source generator).
9+
10+
```cs
11+
using System;
12+
using System.Runtime.CompilerServices;
13+
14+
var c = new C();
15+
c.InterceptableMethod(1); // (L1,C1): prints "interceptor 1"
16+
c.InterceptableMethod(1); // (L2,C2): prints "other interceptor 1"
17+
c.InterceptableMethod(2); // (L3,C3): prints "other interceptor 2"
18+
c.InterceptableMethod(1); // prints "interceptable 1"
19+
20+
class C
21+
{
22+
public void InterceptableMethod(int param)
23+
{
24+
Console.WriteLine($"interceptable {param}");
25+
}
26+
}
27+
28+
// generated code
29+
static class D
30+
{
31+
[InterceptsLocation("Program.cs", line: /*L1*/, character: /*C1*/)] // refers to the call at (L1, C1)
32+
public static void InterceptorMethod(this C c, int param)
33+
{
34+
Console.WriteLine($"interceptor {param}");
35+
}
36+
37+
[InterceptsLocation("Program.cs", line: /*L2*/, character: /*C2*/)] // refers to the call at (L2, C2)
38+
[InterceptsLocation("Program.cs", line: /*L3*/, character: /*C3*/)] // refers to the call at (L3, C3)
39+
public static void OtherInterceptorMethod(this C c, int param)
40+
{
41+
Console.WriteLine($"other interceptor {param}");
42+
}
43+
}
44+
```
45+
46+
## Detailed design
47+
[design]: #detailed-design
48+
49+
### InterceptsLocationAttribute
50+
51+
A method indicates that it is an *interceptor* by adding one or more `[InterceptsLocation]` attributes. These attributes refer to the source locations of the calls it intercepts.
52+
53+
```cs
54+
namespace System.Runtime.CompilerServices
55+
{
56+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
57+
public sealed class InterceptsLocationAttribute(string filePath, int line, int character) : Attribute
58+
{
59+
}
60+
}
61+
```
62+
63+
Any "ordinary method" (i.e. with `MethodKind.Ordinary`) can have its calls intercepted.
64+
65+
`[InterceptsLocation]` attributes included in source are emitted to the resulting assembly, just like other custom attributes.
66+
67+
PROTOTYPE(ic): We may want to recognize `file class InterceptsLocationAttribute` as a valid declaration of the attribute, to allow generators to bring the attribute in without conflicting with other generators which may also be bringing the attribute in. See open question in [User opt-in](#user-opt-in).
68+
https://github.com/dotnet/roslyn/issues/67079 is a bug which causes file-local source declarations of well-known attributes to be generally treated as known. When that bug is fixed, we may want to single out `InterceptsLocationAttribute` as "recognized, even though they are file-local".
69+
70+
#### File paths
71+
72+
File paths used in `[InterceptsLocation]` are expected to have `/pathmap` substitution already applied. Generators should accomplish this by locally recreating the file path transformation performed by the compiler:
73+
74+
```cs
75+
using Microsoft.CodeAnalysis;
76+
77+
string GetInterceptorFilePath(SyntaxTree tree, Compilation compilation)
78+
{
79+
return compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
80+
}
81+
```
82+
83+
The file path given in the attribute must be equal by ordinal comparison to the value given by the above function.
84+
85+
The compiler does not map `#line` directives when determining if an `[InterceptsLocation]` attribute intercepts a particular call in syntax.
86+
87+
#### Position
88+
89+
Line and column numbers in `[InterceptsLocation]` are 1-indexed to match existing places where source locations are displayed to the user. For example, in `Diagnostic.ToString`.
90+
91+
The location of the call is the location of the simple name syntax which denotes the interceptable method. For example, in `app.MapGet(...)`, the name syntax for `MapGet` would be considered the location of the call. For a static method call like `System.Console.WriteLine(...)`, the name syntax for `WriteLine` is the location of the call. If we allow intercepting calls to property accessors in the future (e.g `obj.Property`), we would also be able to use the name syntax in this way.
92+
93+
#### Attribute creation
94+
95+
The goal of the above decisions is to make it so that when source generators are filling in `[InterceptsLocation(...)]`, they simply need to read `nameSyntax.SyntaxTree.FilePath` and `nameSyntax.GetLineSpan().Span.Start` for the exact file path and position information they need to use.
96+
97+
We should provide samples of recommended coding patterns for generator authors to show correct usage of these, including the "translation" from 0-indexed to 1-indexed positions.
98+
99+
### Non-invocation method usages
100+
101+
Conversion to delegate type, address-of, etc. usages of methods cannot be intercepted.
102+
103+
Interception can only occur for calls to ordinary member methods--not constructors, delegates, properties, local functions, operators, etc. Support for more member kinds may be added in the future.
104+
105+
### Arity
106+
107+
Interceptors cannot have type parameters or be declared in generic types at any level of nesting.
108+
109+
This limitation prevents interceptors from matching the signature of an interceptable call in cases where the interceptable call uses type parameters which are not in scope at the interceptor declaration. We can consider adjusting the rules to alleviate this limitation if compelling scenarios arise for it in the future.
110+
111+
```cs
112+
using System.Runtime.CompilerServices;
113+
114+
class C
115+
{
116+
public static void InterceptableMethod<T1>(T1 t) => throw null!;
117+
}
118+
119+
static class Program
120+
{
121+
public static void M<T2>(T2 t)
122+
{
123+
C.InterceptableMethod(t);
124+
}
125+
}
126+
127+
static class D
128+
{
129+
[InterceptsLocation("Program.cs", 12, 11)]
130+
public static void Interceptor1(object s) => throw null!;
131+
}
132+
```
133+
134+
### Signature matching
135+
136+
When a call is intercepted, the interceptor and interceptable methods must meet the signature matching requirements detailed below:
137+
- When an interceptable instance method is compared to a classic extension method, we use the extension method in reduced form for comparison. The extension method parameter with the `this` modifier is compared to the instance method `this` parameter.
138+
- The returns and parameters, including the `this` parameter, must have the same ref kinds and types.
139+
- A warning is reported instead of an error if a type difference is found where the types are not distinct to the runtime. For example, `object` and `dynamic`.
140+
- No warning or error is reported for a *safe* nullability difference, such as when the interceptable method accepts a `string` parameter, and the interceptor accepts a `string?` parameter.
141+
- Method names and parameter names are not required to match.
142+
- Parameter default values are not required to match. When intercepting, default values on the interceptor method are ignored.
143+
- `params` modifiers are not required to match.
144+
- `scoped` modifiers and `[UnscopedRef]` must be equivalent.
145+
- In general, attributes which normally affect the behavior of the call site, such as `[CallerLineNumber]` are ignored on the interceptor of an intercepted call.
146+
- The only exception to this is when the attribute affects "capabilities" of the method in a way that affects safety, such as with `[UnscopedRef]`. Such attributes are required to match across interceptable and interceptor methods.
147+
148+
Arity does not need to match between intercepted and interceptor methods. In other words, it is permitted to intercept a generic method with a non-generic interceptor.
149+
150+
### Conflicting interceptors
151+
152+
If more than one interceptor refers to the same location, it is a compile-time error.
153+
154+
If an `[InterceptsLocation]` attribute is found in the compilation which does not refer to the location of an explicit method call, it is a compile-time error.
155+
156+
### Interceptor accessibility
157+
158+
An interceptor must be accessible at the location where interception is occurring.
159+
160+
An interceptor contained in a file-local type is permitted to intercept a call in another file, even though the interceptor is not normally *visible* at the call site.
161+
162+
This allows generator authors to avoid *polluting lookup* with interceptors, helps avoid name conflicts, and prevents use of interceptors in *unintended positions* from the interceptor author's point-of-view.
163+
164+
We may also want to consider adjusting behavior of `[EditorBrowsable]` to work in the same compilation.
165+
166+
### Editor experience
167+
168+
Interceptors are treated like a post-compilation step in this design. Diagnostics are given for misuse of interceptors, but some diagnostics are only given in the command-line build and not in the IDE. There is limited traceability in the editor for which calls in a compilation are actually being intercepted. If this feature is brought forward past the experimental stage, this limitation will need to be re-examined.
169+
170+
### User opt-in
171+
172+
Interceptors will require a feature flag during the experimental phase. The flag can be enabled with `/features=InterceptorsPreview` on the command line or `<Features>InterceptorsPreview</Features>` in msbuild.
173+
174+
### Implementation strategy
175+
176+
During the binding phase, `InterceptsLocationAttribute` usages are decoded and the related data for each usage are collected in a `ConcurrentSet` on the compilation:
177+
- intercepted file-path and location
178+
- attribute location
179+
- attributed method symbol
180+
181+
At this time, diagnostics are reported for the following conditions:
182+
- problems specific to the attributed interceptor method itself, for example, that it is not an ordinary method.
183+
- syntactic problems specific to the referenced location, for example, that it does not refer to an applicable simple name as defined in [Position](#position) subsection.
184+
185+
During the lowering phase, when a given `BoundCall` is lowered:
186+
- we check if its syntax contains an applicable simple name
187+
- if so, we lookup whether it is being intercepted, based on data about `InterceptsLocationAttribute` collected during the binding phase.
188+
- if it is being intercepted, we perform an additional step after lowering of the receiver and arguments is completed:
189+
- substitute the interceptable method with the interceptor method on the `BoundCall`.
190+
- if the interceptor is a classic extension method, and the interceptable method is an instance method, we adjust the `BoundCall` to use the receiver as the first argument of the call, "pushing" the other arguments forward, similar to the way it would have bound if the original call were to an extension method in reduced form.
191+
192+
At this time, diagnostics are reported for the following conditions:
193+
- incompatibility between the interceptor and interceptable methods, for example, in their signatures.
194+
- *duplicate* `[InterceptsLocation]`, that is, multiple interceptors which intercept the same call.
195+
- interceptor is not accessible at the call site.

src/Compilers/CSharp/Portable/BoundTree/BoundExpression.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Immutable;
66
using System.Diagnostics;
77
using Microsoft.CodeAnalysis.CSharp.Symbols;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
89
using Roslyn.Utilities;
910
using System;
1011

@@ -238,6 +239,34 @@ public override Symbol ExpressionSymbol
238239
return this.Method;
239240
}
240241
}
242+
243+
public Location? InterceptableLocation
244+
{
245+
get
246+
{
247+
// When this assertion fails, it means a new syntax is being used which corresponds to a BoundCall.
248+
// The developer needs to determine how this new syntax should interact with interceptors (produce an error, permit intercepting the call, etc...)
249+
Debug.Assert(this.WasCompilerGenerated || this.Syntax is InvocationExpressionSyntax or ConstructorInitializerSyntax or PrimaryConstructorBaseTypeSyntax { ArgumentList: { } },
250+
$"Unexpected syntax kind for BoundCall: {this.Syntax.Kind()}");
251+
252+
if (this.WasCompilerGenerated || this.Syntax is not InvocationExpressionSyntax syntax)
253+
{
254+
return null;
255+
}
256+
257+
// If a qualified name is used as a valid receiver of an invocation syntax at some point,
258+
// we probably want to treat it similarly to a MemberAccessExpression.
259+
// However, we don't expect to encounter it.
260+
Debug.Assert(syntax.Expression is not QualifiedNameSyntax);
261+
262+
return syntax.Expression switch
263+
{
264+
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Location,
265+
SimpleNameSyntax name => name.Location,
266+
_ => null
267+
};
268+
}
269+
}
241270
}
242271

243272
internal partial class BoundTypeExpression

src/Compilers/CSharp/Portable/CSharpResources.resx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7505,10 +7505,94 @@ To remove the warning, you can use /reference instead (set the Embed Interop Typ
75057505
<data name="ERR_BadCaseInSwitchArm" xml:space="preserve">
75067506
<value>A switch expression arm does not begin with a 'case' keyword.</value>
75077507
</data>
7508+
<data name="ERR_InterceptorsFeatureNotEnabled" xml:space="preserve">
7509+
<value>The 'interceptors' experimental feature is not enabled. Add '&lt;Features&gt;InterceptorsPreview&lt;/Features&gt;' to your project.</value>
7510+
</data>
7511+
<data name="ERR_InterceptorCannotBeGeneric" xml:space="preserve">
7512+
<value>Method '{0}' cannot be used as an interceptor because it or its containing type has type parameters.</value>
7513+
</data>
7514+
<data name="ERR_InterceptorPathNotInCompilation" xml:space="preserve">
7515+
<value>Cannot intercept: compilation does not contain a file with path '{0}'.</value>
7516+
</data>
7517+
<data name="ERR_InterceptorPathNotInCompilationWithCandidate" xml:space="preserve">
7518+
<value>Cannot intercept: compilation does not contain a file with path '{0}'. Did you mean to use path '{1}'?</value>
7519+
</data>
7520+
<data name="ERR_InterceptorPathNotInCompilationWithUnmappedCandidate" xml:space="preserve">
7521+
<value>Cannot intercept: Path '{0}' is unmapped. Expected mapped path '{1}'.</value>
7522+
</data>
7523+
<data name="ERR_InterceptorLineOutOfRange" xml:space="preserve">
7524+
<value>The given file has '{0}' lines, which is fewer than the provided line number '{1}'.</value>
7525+
</data>
7526+
<data name="ERR_InterceptorCharacterOutOfRange" xml:space="preserve">
7527+
<value>The given line is '{0}' characters long, which is fewer than the provided character number '{1}'.</value>
7528+
</data>
7529+
<data name="ERR_InterceptorLineCharacterMustBePositive" xml:space="preserve">
7530+
<value>Line and character numbers provided to InterceptsLocationAttribute must be positive.</value>
7531+
</data>
7532+
<data name="ERR_InterceptorPositionBadToken" xml:space="preserve">
7533+
<value>The provided line and character number does not refer to an interceptable method name, but rather to token '{0}'.</value>
7534+
</data>
7535+
<data name="ERR_InterceptorMustReferToStartOfTokenPosition" xml:space="preserve">
7536+
<value>The provided line and character number does not refer to the start of token '{0}'. Did you mean to use line '{1}' and character '{2}'?</value>
7537+
</data>
7538+
<data name="ERR_InterceptorSignatureMismatch" xml:space="preserve">
7539+
<value>Cannot intercept method '{0}' with interceptor '{1}' because the signatures do not match.</value>
7540+
</data>
7541+
<data name="WRN_InterceptorSignatureMismatch" xml:space="preserve">
7542+
<value>Intercepting a call to '{0}' with interceptor '{1}', but the signatures do not match.</value>
7543+
</data>
7544+
<data name="WRN_InterceptorSignatureMismatch_Title" xml:space="preserve">
7545+
<value>Signatures of interceptable and interceptor methods do not match.</value>
7546+
</data>
7547+
<data name="ERR_InterceptorMethodMustBeOrdinary" xml:space="preserve">
7548+
<value>An interceptor method must be an ordinary member method.</value>
7549+
</data>
7550+
<data name="ERR_InterceptorMustHaveMatchingThisParameter" xml:space="preserve">
7551+
<value>Interceptor must have a 'this' parameter matching parameter '{0}' on '{1}'.</value>
7552+
</data>
7553+
<data name="ERR_InterceptorMustNotHaveThisParameter" xml:space="preserve">
7554+
<value>Interceptor must not have a 'this' parameter because '{0}' does not have a 'this' parameter.</value>
7555+
</data>
7556+
<data name="ERR_InterceptorFilePathCannotBeNull" xml:space="preserve">
7557+
<value>Interceptor cannot have a 'null' file path.</value>
7558+
</data>
7559+
<data name="ERR_InterceptorNameNotInvoked" xml:space="preserve">
7560+
<value>Possible method name '{0}' cannot be intercepted because it is not being invoked.</value>
7561+
</data>
7562+
<data name="ERR_InterceptorNonUniquePath" xml:space="preserve">
7563+
<value>Cannot intercept a call in file with path '{0}' because multiple files in the compilation have this path.</value>
7564+
</data>
7565+
<data name="ERR_DuplicateInterceptor" xml:space="preserve">
7566+
<value>The indicated call is intercepted multiple times.</value>
7567+
</data>
7568+
<data name="ERR_InterceptorNotAccessible" xml:space="preserve">
7569+
<value>Cannot intercept call with '{0}' because it is not accessible within '{1}'.</value>
7570+
</data>
7571+
<data name="ERR_InterceptorScopedMismatch" xml:space="preserve">
7572+
<value>Cannot intercept call to '{0}' with '{1}' because of a difference in 'scoped' modifiers or '[UnscopedRef]' attributes.</value>
7573+
</data>
75087574
<data name="ERR_ConstantValueOfTypeExpected" xml:space="preserve">
75097575
<value>A constant value of type '{0}' is expected</value>
75107576
</data>
75117577
<data name="ERR_UnsupportedPrimaryConstructorParameterCapturingRefAny" xml:space="preserve">
75127578
<value>Cannot use primary constructor parameter of type '{0}' inside an instance member</value>
75137579
</data>
7580+
<data name="WRN_NullabilityMismatchInParameterTypeOnInterceptor" xml:space="preserve">
7581+
<value>Nullability of reference types in type of parameter '{0}' doesn't match interceptable method '{1}'.</value>
7582+
</data>
7583+
<data name="WRN_NullabilityMismatchInParameterTypeOnInterceptor_Title" xml:space="preserve">
7584+
<value>Nullability of reference types in type of parameter doesn't match interceptable method.</value>
7585+
</data>
7586+
<data name="WRN_NullabilityMismatchInReturnTypeOnInterceptor" xml:space="preserve">
7587+
<value>Nullability of reference types in return type doesn't match interceptable method '{0}'.</value>
7588+
</data>
7589+
<data name="WRN_NullabilityMismatchInReturnTypeOnInterceptor_Title" xml:space="preserve">
7590+
<value>Nullability of reference types in return type doesn't match interceptable method.</value>
7591+
</data>
7592+
<data name="ERR_InterceptorCannotInterceptNameof" xml:space="preserve">
7593+
<value>A nameof operator cannot be intercepted.</value>
7594+
</data>
7595+
<data name="ERR_InterceptorCannotUseUnmanagedCallersOnly" xml:space="preserve">
7596+
<value>An interceptor cannot be marked with 'UnmanagedCallersOnlyAttribute'.</value>
7597+
</data>
75147598
</root>

0 commit comments

Comments
 (0)