Skip to content

Commit

Permalink
Add support for checksum-based interceptors (#72814)
Browse files Browse the repository at this point in the history
  • Loading branch information
RikkiGibson committed Apr 15, 2024
1 parent c3ce26c commit 839e1ab
Show file tree
Hide file tree
Showing 30 changed files with 1,734 additions and 54 deletions.
45 changes: 21 additions & 24 deletions docs/features/interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ using System;
using System.Runtime.CompilerServices;

var c = new C();
c.InterceptableMethod(1); // (L1,C1): prints "interceptor 1"
c.InterceptableMethod(1); // (L2,C2): prints "other interceptor 1"
c.InterceptableMethod(2); // (L3,C3): prints "other interceptor 2"
c.InterceptableMethod(1); // L1: prints "interceptor 1"
c.InterceptableMethod(1); // L2: prints "other interceptor 1"
c.InterceptableMethod(2); // L3: prints "other interceptor 2"
c.InterceptableMethod(1); // prints "interceptable 1"
class C
Expand All @@ -28,14 +28,14 @@ class C
// generated code
static class D
{
[InterceptsLocation("Program.cs", line: /*L1*/, character: /*C1*/)] // refers to the call at (L1, C1)
[InterceptsLocation(version: 1, data: "...(refers to the call at L1)")]
public static void InterceptorMethod(this C c, int param)
{
Console.WriteLine($"interceptor {param}");
}

[InterceptsLocation("Program.cs", line: /*L2*/, character: /*C2*/)] // refers to the call at (L2, C2)
[InterceptsLocation("Program.cs", line: /*L3*/, character: /*C3*/)] // refers to the call at (L3, C3)
[InterceptsLocation(version: 1, data: "...(refers to the call at L2)")]
[InterceptsLocation(version: 1, data: "...(refers to the call at L3)")]
public static void OtherInterceptorMethod(this C c, int param)
{
Console.WriteLine($"other interceptor {param}");
Expand All @@ -54,7 +54,7 @@ A method indicates that it is an *interceptor* by adding one or more `[Intercept
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class InterceptsLocationAttribute(string filePath, int line, int character) : Attribute
public sealed class InterceptsLocationAttribute(int version, string data) : Attribute
{
}
}
Expand All @@ -66,29 +66,26 @@ Any "ordinary method" (i.e. with `MethodKind.Ordinary`) can have its calls inter

File-local declarations of this type (`file class InterceptsLocationAttribute`) are valid and usages are recognized by the compiler when they are within the same file and compilation. A generator which needs to declare this attribute should use a file-local declaration to ensure it doesn't conflict with other generators that need to do the same thing.

#### File paths
In prior experimental releases of the feature, a well-known constructor signature `InterceptsLocation(string path, int line, int column)]` was also supported. Support for this constructor will be **dropped** prior to stable release of the feature.

The *referenced syntax tree* of an `[InterceptsLocation]` is determined by normalizing the `filePath` argument value relative to the path of the containing syntax tree of the `[InterceptsLocation]` usage, similar to how paths in `#line` directives are normalized. Let this normalized path be called `normalizedInterceptorPath`. If exactly one syntax tree in the compilation has a normalized path which matches `normalizedInterceptorPath` by ordinal string comparison, that is the *referenced syntax tree*. Otherwise, an error occurs.
#### Location encoding

`#line` directives are not considered when determining the call referenced by an `[InterceptsLocation]` attribute. In other words, the file path, line and column numbers used in `[InterceptsLocation]` are expected to refer to *unmapped* source locations.
The arguments to `[InterceptsLocation]` are:
1. a version number. The compiler may introduce new encodings for the location in the future, with corresponding new version numbers.
2. an opaque data string. This is not intended to be human-readable.

Temporarily, for compatibility purposes, when the initial matching strategy outlined above fails to match any syntax trees, we will fall back to a "compat" matching strategy which works in the following way:
- A *mapped path* of each syntax tree is determined by applying [`/pathmap`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.commandlinearguments.pathmap?view=roslyn-dotnet-4.7.0) substitution to `SyntaxTree.FilePath`.
- For a given `[InterceptsLocation]` usage, the `filePath` argument value is compared to the *mapped path* of each syntax tree using ordinal string comparison. If exactly one syntax tree matches under this comparison, that is the *referenced syntax tree*. Otherwise, an error occurs.

Support for the "compat" strategy will be dropped prior to stable release. Tracked by https://github.com/dotnet/roslyn/issues/72265.
The "version 1" data encoding is a base64-encoded string consisting of the following data:
- 16 byte xxHash128 content checksum of the file containing the intercepted call.
- int32 in little-endian format for the position (i.e. `SyntaxNode.Position`) of the call in syntax.
- utf-8 string data containing a display file name, used for error reporting.

#### Position

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`.

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.

#### Attribute creation

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.

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.
Roslyn provides a convenience API, `GetInterceptableLocation(this SemanticModel, InvocationExpressionSyntax, CancellationToken)` for inserting `[InterceptsLocation]` into generated source code. We recommend that source generators depend on this API in order to intercept calls.

### Non-invocation method usages

Expand All @@ -103,7 +100,7 @@ Interceptors cannot be declared in generic types at any level of nesting.
Interceptors must either be non-generic, or have arity equal to the sum of the arity of the original method's arity and containing type arities. For example:

```cs
Grandparent<int>.Parent<bool>.Original<string>(1, false, "a");
Grandparent<int>.Parent<bool>.Original<string>(1, false, "a"); // L1
class Grandparent<T1>
{
Expand All @@ -115,7 +112,7 @@ class Grandparent<T1>

class Interceptors
{
[InterceptsLocation("Program.cs", 1, 33)]
[InterceptsLocation(1, "..(refers to call at L1)")]
public static void Interceptor<T1, T2, T3>(T1 t1, T2 t2, T3 t3) { }
}
```
Expand All @@ -136,13 +133,13 @@ static class Program
{
public static void M<T2>(T2 t)
{
C.InterceptableMethod(t);
C.InterceptableMethod(t); // L1
}
}

static class D
{
[InterceptsLocation("Program.cs", 12, 11)]
[InterceptsLocation(1, "..(refers to call at L1)")]
public static void Interceptor1<T2>(T2 t) => throw null!;
}
```
Expand Down
20 changes: 20 additions & 0 deletions src/Compilers/CSharp/Portable/CSharpExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,26 @@ public static Conversion ClassifyConversion(this SemanticModel? semanticModel, i
var csModel = semanticModel as CSharpSemanticModel;
return csModel?.GetInterceptorMethod(node, cancellationToken);
}

/// <summary>
/// If <paramref name="node"/> cannot be intercepted syntactically, returns null.
/// Otherwise, returns an instance which can be used to intercept the call denoted by <paramref name="node"/>.
/// </summary>
[Experimental(RoslynExperiments.Interceptors, UrlFormat = RoslynExperiments.Interceptors_Url)]
public static InterceptableLocation? GetInterceptableLocation(this SemanticModel? semanticModel, InvocationExpressionSyntax node, CancellationToken cancellationToken = default)
{
var csModel = semanticModel as CSharpSemanticModel;
return csModel?.GetInterceptableLocation(node, cancellationToken);
}

/// <summary>
/// Gets an attribute list syntax consisting of an InterceptsLocationAttribute, which intercepts the call referenced by parameter <paramref name="location"/>.
/// </summary>
[Experimental(RoslynExperiments.Interceptors, UrlFormat = RoslynExperiments.Interceptors_Url)]
public static string GetInterceptsLocationAttributeSyntax(this InterceptableLocation location)
{
return $"""[global::System.Runtime.CompilerServices.InterceptsLocationAttribute({location.Version}, "{location.Data}")]""";
}
#endregion
}
}
15 changes: 15 additions & 0 deletions src/Compilers/CSharp/Portable/CSharpResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -7908,4 +7908,19 @@ To remove the warning, you can use /reference instead (set the Embed Interop Typ
<data name="ERR_CannotDynamicInvokeOnExpression" xml:space="preserve">
<value>Cannot perform a dynamic invocation on an expression with type '{0}'.</value>
</data>
<data name="ERR_InterceptsLocationDataInvalidFormat" xml:space="preserve">
<value>The data argument to InterceptsLocationAttribute is not in the correct format.</value>
</data>
<data name="ERR_InterceptsLocationUnsupportedVersion" xml:space="preserve">
<value>Version '{0}' of the interceptors format is not supported. The latest supported version is '1'.</value>
</data>
<data name="ERR_InterceptsLocationDuplicateFile" xml:space="preserve">
<value>Cannot intercept a call in file '{0}' because it is duplicated elsewhere in the compilation.</value>
</data>
<data name="ERR_InterceptsLocationFileNotFound" xml:space="preserve">
<value>Cannot intercept a call in file '{0}' because a matching file was not found in the compilation.</value>
</data>
<data name="ERR_InterceptsLocationDataInvalidPosition" xml:space="preserve">
<value>The data argument to InterceptsLocationAttribute refers to an invalid position in file '{0}'.</value>
</data>
</root>
45 changes: 37 additions & 8 deletions src/Compilers/CSharp/Portable/Compilation/CSharpCompilation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using System;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
Expand Down Expand Up @@ -160,8 +161,12 @@ internal Conversions Conversions
private ImmutableSegmentedDictionary<string, OneOrMany<SyntaxTree>> _mappedPathToSyntaxTree;

/// <summary>Lazily caches SyntaxTrees by their path. Used to look up the syntax tree referenced by an interceptor.</summary>
/// <remarks>Must be removed prior to interceptors stable release.</remarks>
private ImmutableSegmentedDictionary<string, OneOrMany<SyntaxTree>> _pathToSyntaxTree;

/// <summary>Lazily caches SyntaxTrees by their xxHash128 checksum. Used to look up the syntax tree referenced by an interceptor.</summary>
private ImmutableSegmentedDictionary<ReadOnlyMemory<byte>, OneOrMany<SyntaxTree>> _contentHashToSyntaxTree;

public override string Language
{
get
Expand Down Expand Up @@ -1075,6 +1080,32 @@ ImmutableSegmentedDictionary<string, OneOrMany<SyntaxTree>> computeMappedPathToS
}
}

internal OneOrMany<SyntaxTree> GetSyntaxTreesByContentHash(ReadOnlyMemory<byte> contentHash)
{
Debug.Assert(contentHash.Length == InterceptableLocation1.ContentHashLength);

var contentHashToSyntaxTree = _contentHashToSyntaxTree;
if (contentHashToSyntaxTree.IsDefault)
{
RoslynImmutableInterlocked.InterlockedInitialize(ref _contentHashToSyntaxTree, computeHashToSyntaxTree());
contentHashToSyntaxTree = _contentHashToSyntaxTree;
}

return contentHashToSyntaxTree.TryGetValue(contentHash, out var value) ? value : OneOrMany<SyntaxTree>.Empty;

ImmutableSegmentedDictionary<ReadOnlyMemory<byte>, OneOrMany<SyntaxTree>> computeHashToSyntaxTree()
{
var builder = ImmutableSegmentedDictionary.CreateBuilder<ReadOnlyMemory<byte>, OneOrMany<SyntaxTree>>(ContentHashComparer.Instance);
foreach (var tree in SyntaxTrees)
{
var text = tree.GetText();
var hash = text.GetContentHash().AsMemory();
builder[hash] = builder.TryGetValue(hash, out var existing) ? existing.Add(tree) : OneOrMany.Create(tree);
}
return builder.ToImmutable();
}
}

internal OneOrMany<SyntaxTree> GetSyntaxTreesByPath(string path)
{
// We could consider storing this on SyntaxAndDeclarationManager instead, and updating it incrementally.
Expand Down Expand Up @@ -2399,15 +2430,15 @@ internal void AddModuleInitializerMethod(MethodSymbol method)
internal bool InterceptorsDiscoveryComplete;

// NB: the 'Many' case for these dictionary values means there are duplicates. An error is reported for this after binding.
private ConcurrentDictionary<(string FilePath, int Line, int Character), OneOrMany<(Location AttributeLocation, MethodSymbol Interceptor)>>? _interceptions;
private ConcurrentDictionary<(string FilePath, int Position), OneOrMany<(Location AttributeLocation, MethodSymbol Interceptor)>>? _interceptions;

internal void AddInterception(string filePath, int line, int character, Location attributeLocation, MethodSymbol interceptor)
internal void AddInterception(string filePath, int position, Location attributeLocation, MethodSymbol interceptor)
{
Debug.Assert(!_declarationDiagnosticsFrozen);
Debug.Assert(!InterceptorsDiscoveryComplete);

var dictionary = LazyInitializer.EnsureInitialized(ref _interceptions);
dictionary.AddOrUpdate((filePath, line, character),
dictionary.AddOrUpdate((filePath, position),
addValueFactory: static (key, newValue) => OneOrMany.Create(newValue),
updateValueFactory: static (key, existingValues, newValue) =>
{
Expand All @@ -2427,9 +2458,9 @@ internal void AddInterception(string filePath, int line, int character, Location
factoryArgument: (AttributeLocation: attributeLocation, Interceptor: interceptor));
}

internal (Location AttributeLocation, MethodSymbol Interceptor)? TryGetInterceptor(Location? callLocation)
internal (Location AttributeLocation, MethodSymbol Interceptor)? TryGetInterceptor(SimpleNameSyntax? node)
{
if (callLocation is null || !callLocation.IsInSource)
if (node is null)
{
return null;
}
Expand All @@ -2440,9 +2471,7 @@ internal void AddInterception(string filePath, int line, int character, Location
return null;
}

var callLineColumn = callLocation.GetLineSpan().Span.Start;
var key = (callLocation.SourceTree.FilePath, callLineColumn.Line, callLineColumn.Character);

var key = (node.SyntaxTree.FilePath, node.Position);
if (_interceptions.TryGetValue(key, out var interceptionsAtAGivenLocation) && interceptionsAtAGivenLocation is [var oneInterception])
{
return oneInterception;
Expand Down
29 changes: 28 additions & 1 deletion src/Compilers/CSharp/Portable/Compilation/CSharpSemanticModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5208,13 +5208,40 @@ protected sealed override ISymbol GetDeclaredSymbolCore(SyntaxNode node, Cancell

CheckSyntaxNode(node);

if (node.GetInterceptableNameSyntax() is { } nameSyntax && Compilation.TryGetInterceptor(nameSyntax.GetLocation()) is (_, MethodSymbol interceptor))
if (node.GetInterceptableNameSyntax() is { } nameSyntax && Compilation.TryGetInterceptor(nameSyntax) is (_, MethodSymbol interceptor))
{
return interceptor.GetPublicSymbol();
}

return null;
}

#pragma warning disable RSEXPERIMENTAL002 // Internal usage of experimental API
public InterceptableLocation? GetInterceptableLocation(InvocationExpressionSyntax node, CancellationToken cancellationToken)
{
CheckSyntaxNode(node);
if (node.GetInterceptableNameSyntax() is not { } nameSyntax)
{
return null;
}

return GetInterceptableLocationInternal(nameSyntax, cancellationToken);
}

// Factored out for ease of test authoring, especially for scenarios involving unsupported syntax.
internal InterceptableLocation GetInterceptableLocationInternal(SyntaxNode nameSyntax, CancellationToken cancellationToken)
{
var tree = nameSyntax.SyntaxTree;
var text = tree.GetText(cancellationToken);
var path = tree.FilePath;
var checksum = text.GetContentHash();

var lineSpan = nameSyntax.Location.GetLineSpan().Span.Start;
var lineNumberOneIndexed = lineSpan.Line + 1;
var characterNumberOneIndexed = lineSpan.Character + 1;

return new InterceptableLocation1(checksum, path, nameSyntax.Position, lineNumberOneIndexed, characterNumberOneIndexed);
}
#nullable disable

protected static SynthesizedPrimaryConstructor TryGetSynthesizedPrimaryConstructor(TypeDeclarationSyntax node, NamedTypeSymbol type)
Expand Down
6 changes: 6 additions & 0 deletions src/Compilers/CSharp/Portable/Errors/ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2303,6 +2303,12 @@ internal enum ErrorCode
ERR_NoModifiersOnUsing = 9229,
ERR_CannotDynamicInvokeOnExpression = 9230,

ERR_InterceptsLocationDataInvalidFormat = 9231,
ERR_InterceptsLocationUnsupportedVersion = 9232,
ERR_InterceptsLocationDuplicateFile = 9233,
ERR_InterceptsLocationFileNotFound = 9234,
ERR_InterceptsLocationDataInvalidPosition = 9235,

#endregion

// Note: you will need to do the following after adding errors:
Expand Down
5 changes: 5 additions & 0 deletions src/Compilers/CSharp/Portable/Errors/ErrorFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,11 @@ internal static bool IsBuildOnlyDiagnostic(ErrorCode code)
case ErrorCode.ERR_ParamsCollectionMissingConstructor:
case ErrorCode.ERR_NoModifiersOnUsing:
case ErrorCode.ERR_CannotDynamicInvokeOnExpression:
case ErrorCode.ERR_InterceptsLocationDataInvalidFormat:
case ErrorCode.ERR_InterceptsLocationUnsupportedVersion:
case ErrorCode.ERR_InterceptsLocationDuplicateFile:
case ErrorCode.ERR_InterceptsLocationFileNotFound:
case ErrorCode.ERR_InterceptsLocationDataInvalidPosition:
return false;
default:
// NOTE: All error codes must be explicitly handled in this switch statement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ private PEModuleBuilder? EmitModule
{
Debug.Assert(!nameofOperator.WasCompilerGenerated);
var nameofIdentiferSyntax = (IdentifierNameSyntax)((InvocationExpressionSyntax)nameofOperator.Syntax).Expression;
if (this._compilation.TryGetInterceptor(nameofIdentiferSyntax.Location) is not null)
if (this._compilation.TryGetInterceptor(nameofIdentiferSyntax) is not null)
{
this._diagnostics.Add(ErrorCode.ERR_InterceptorCannotInterceptNameof, nameofIdentiferSyntax.Location);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,13 @@ private void InterceptCallAndAdjustArguments(
bool invokedAsExtensionMethod,
Syntax.SimpleNameSyntax? nameSyntax)
{
var interceptableLocation = nameSyntax?.Location;
if (this._compilation.TryGetInterceptor(interceptableLocation) is not var (attributeLocation, interceptor))
if (this._compilation.TryGetInterceptor(nameSyntax) is not var (attributeLocation, interceptor))
{
// The call was not intercepted.
return;
}

Debug.Assert(nameSyntax != null);
Debug.Assert(interceptableLocation != null);
Debug.Assert(interceptor.IsDefinition);
Debug.Assert(!interceptor.ContainingType.IsGenericType);

Expand Down
Loading

0 comments on commit 839e1ab

Please sign in to comment.