Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expect mapped paths in InterceptsLocationAttribute #68242

Merged
merged 8 commits into from
Jun 9, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/features/interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,20 @@ https://github.com/dotnet/roslyn/issues/67079 is a bug which causes file-local s

#### File paths

File paths used in `[InterceptsLocation]` must exactly match the paths on the syntax trees they refer to by ordinal comparison. `SyntaxTree.FilePath` has already applied `/pathmap` substitution, so the paths used in the attribute will be less environment-specific in many projects.
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:

The compiler does not map `#line` directives when determining if an `[InterceptsLocation]` attribute intercepts a particular call in syntax.
```cs
using Microsoft.CodeAnalysis;

string GetInterceptorFilePath(SyntaxTree tree, Compilation compilation)
{
return compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that if this feature makes it past the experimental phase, we might revisit providing "convenience public APIs". For the current release, it feels more appropriate to indicate what helpers the generator author themselves needs to include.

}
```

PROTOTYPE(ic): editorconfig support matches paths in cross-platform fashion (e.g. normalizing slashes). We should revisit how that works and consider if the same matching strategy should be used instead of ordinal comparison.
The file path given in the attribute must be equal by ordinal comparison to the value given by the above function.

The compiler does not map `#line` directives when determining if an `[InterceptsLocation]` attribute intercepts a particular call in syntax.

#### Position

Expand Down
6 changes: 3 additions & 3 deletions src/Compilers/CSharp/Portable/CSharpResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -7517,6 +7517,9 @@ To remove the warning, you can use /reference instead (set the Embed Interop Typ
<data name="ERR_InterceptorPathNotInCompilationWithCandidate" xml:space="preserve">
<value>Cannot intercept: compilation does not contain a file with path '{0}'. Did you mean to use path '{1}'?</value>
</data>
<data name="ERR_InterceptorPathNotInCompilationWithUnmappedCandidate" xml:space="preserve">
<value>Cannot intercept: Path '{0}' is unmapped. Expected mapped path '{1}'.</value>
</data>
<data name="ERR_InterceptorLineOutOfRange" xml:space="preserve">
<value>The given file has '{0}' lines, which is fewer than the provided line number '{1}'.</value>
</data>
Expand All @@ -7541,9 +7544,6 @@ To remove the warning, you can use /reference instead (set the Embed Interop Typ
<data name="WRN_InterceptorSignatureMismatch_Title" xml:space="preserve">
<value>Signatures of interceptable and interceptor methods do not match.</value>
</data>
<data name="ERR_InterceptableMethodMustBeOrdinary" xml:space="preserve">
<value>An interceptable method must be an ordinary member method.</value>
</data>
<data name="ERR_InterceptorMethodMustBeOrdinary" xml:space="preserve">
<value>An interceptor method must be an ordinary member method.</value>
</data>
Expand Down
30 changes: 30 additions & 0 deletions src/Compilers/CSharp/Portable/Compilation/CSharpCompilation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ internal Conversions Conversions
/// </summary>
private readonly ConcurrentCache<TypeSymbol, NamedTypeSymbol> _typeToNullableVersion = new ConcurrentCache<TypeSymbol, NamedTypeSymbol>(size: 100);

/// <summary>Lazily caches SyntaxTrees by their mapped path. Used to look up the syntax tree referenced by an interceptor. <see cref="TryGetInterceptor"/></summary>
private ImmutableSegmentedDictionary<string, OneOrMany<SyntaxTree>> _mappedPathToSyntaxTree;

public override string Language
{
get
Expand Down Expand Up @@ -1037,6 +1040,33 @@ internal override int GetSyntaxTreeOrdinal(SyntaxTree tree)
}
}

internal OneOrMany<SyntaxTree> GetSyntaxTreesByMappedPath(string mappedPath)
{
// We could consider storing this on SyntaxAndDeclarationManager instead, and updating it incrementally.
// However, this would make it more difficult for it to be "pay-for-play",
// i.e. only created in compilations where interceptors are used.
var mappedPathToSyntaxTree = _mappedPathToSyntaxTree;
if (mappedPathToSyntaxTree.IsDefault)
{
RoslynImmutableInterlocked.InterlockedInitialize(ref _mappedPathToSyntaxTree, computeMappedPathToSyntaxTree());
mappedPathToSyntaxTree = _mappedPathToSyntaxTree;
}

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

ImmutableSegmentedDictionary<string, OneOrMany<SyntaxTree>> computeMappedPathToSyntaxTree()
{
var builder = ImmutableSegmentedDictionary.CreateBuilder<string, OneOrMany<SyntaxTree>>();
var resolver = Options.SourceReferenceResolver;
foreach (var tree in SyntaxTrees)
{
var path = resolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
builder[path] = builder.ContainsKey(path) ? builder[path].Add(tree) : OneOrMany.Create(tree);
}
return builder.ToImmutable();
}
}

#endregion

#region References
Expand Down
2 changes: 1 addition & 1 deletion src/Compilers/CSharp/Portable/Errors/ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2201,7 +2201,7 @@ internal enum ErrorCode
ERR_InterceptorLineOutOfRange = 27005,
ERR_InterceptorCharacterOutOfRange = 27006,
ERR_InterceptorSignatureMismatch = 27007,
ERR_InterceptableMethodMustBeOrdinary = 27008,
Copy link
Member

@cston cston May 26, 2023

Choose a reason for hiding this comment

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

ERR_InterceptableMethodMustBeOrdinary

Is this error code no longer used? Can we remove the resource string as well? #Resolved

ERR_InterceptorPathNotInCompilationWithUnmappedCandidate = 27008,
ERR_InterceptorMethodMustBeOrdinary = 27009,
ERR_InterceptorMustReferToStartOfTokenPosition = 27010,
ERR_InterceptorMustHaveMatchingThisParameter = 27011,
Expand Down
2 changes: 1 addition & 1 deletion src/Compilers/CSharp/Portable/Errors/ErrorFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2328,10 +2328,10 @@ internal static bool IsBuildOnlyDiagnostic(ErrorCode code)
case ErrorCode.ERR_InterceptorCannotBeGeneric:
case ErrorCode.ERR_InterceptorPathNotInCompilation:
case ErrorCode.ERR_InterceptorPathNotInCompilationWithCandidate:
case ErrorCode.ERR_InterceptorPathNotInCompilationWithUnmappedCandidate:
case ErrorCode.ERR_InterceptorPositionBadToken:
case ErrorCode.ERR_InterceptorLineOutOfRange:
case ErrorCode.ERR_InterceptorCharacterOutOfRange:
case ErrorCode.ERR_InterceptableMethodMustBeOrdinary:
case ErrorCode.ERR_InterceptorMethodMustBeOrdinary:
case ErrorCode.ERR_InterceptorMustReferToStartOfTokenPosition:
case ErrorCode.ERR_InterceptorFilePathCannotBeNull:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ private void InterceptCallAndAdjustArguments(
{
// Special case when intercepting an extension method call in reduced form with a non-extension.
this._diagnostics.Add(ErrorCode.ERR_InterceptorMustHaveMatchingThisParameter, attributeLocation, method.Parameters[0], method);
// PROTOYPE(ic): use a symbol display format which includes the 'this' modifier?
// PROTOTYPE(ic): use a symbol display format which includes the 'this' modifier?
//this._diagnostics.Add(ErrorCode.ERR_InterceptorMustHaveMatchingThisParameter, attributeLocation, new FormattedSymbol(method.Parameters[0], SymbolDisplayFormat.CSharpErrorMessageFormat), method);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -967,8 +967,8 @@ private void DecodeInterceptsLocationAttribute(DecodeWellKnownAttributeArguments
return;
}

var filePath = (string?)attributeArguments[0].Value;
if (filePath is null)
var attributeFilePath = (string?)attributeArguments[0].Value;
if (attributeFilePath is null)
{
diagnostics.Add(ErrorCode.ERR_InterceptorFilePathCannotBeNull, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax));
return;
Expand All @@ -987,42 +987,46 @@ private void DecodeInterceptsLocationAttribute(DecodeWellKnownAttributeArguments
}

var syntaxTrees = DeclaringCompilation.SyntaxTrees;
SyntaxTree? matchingTree = null;
// PROTOTYPE(ic): we need to resolve the paths before comparing (i.e. respect /pathmap).
// At that time, we should look at caching the resolved paths for the trees in a set (or maybe a Map<string, OneOrMany<SyntaxTree>>).
// so we can reduce the cost of these checks.
foreach (var tree in syntaxTrees)
var matchingTrees = DeclaringCompilation.GetSyntaxTreesByMappedPath(attributeFilePath);
if (matchingTrees.Count == 0)
{
if (tree.FilePath == filePath)
var referenceResolver = DeclaringCompilation.Options.SourceReferenceResolver;
Copy link
Member

@jcouv jcouv Jun 8, 2023

Choose a reason for hiding this comment

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

nit: consider moving closer to usage #Closed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because it's used in each subsequent branch in this block, I felt more comfortable declaring it where it is currently, instead of after the statement declaring unmappedMatch, for example.

I'm line-breaking some of the diagnostics.Add calls to make the usages easier to spot.

// if we expect '/_/Program.cs':

// we might get: 'C:\Project\Program.cs' <-- path not mapped
var unmappedMatch = syntaxTrees.FirstOrDefault(static (tree, filePath) => tree.FilePath == filePath, attributeFilePath);
if (unmappedMatch != null)
{
if (matchingTree == null)
{
matchingTree = tree;
// need to keep searching in case we find another tree with the same path
}
else
{
diagnostics.Add(ErrorCode.ERR_InterceptorNonUniquePath, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), filePath);
return;
}
diagnostics.Add(ErrorCode.ERR_InterceptorPathNotInCompilationWithUnmappedCandidate, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), attributeFilePath, mapPath(referenceResolver, unmappedMatch));
return;
}
}

if (matchingTree == null)
{
var suffixMatch = syntaxTrees.FirstOrDefault(static (tree, filePath) => tree.FilePath.EndsWith(filePath), filePath);
// we might get: '\_\Program.cs' <-- slashes not normalized
// we might get: '\_/Program.cs' <-- slashes don't match
// we might get: 'Program.cs' <-- suffix match
// Force normalization of all '\' to '/', but when we recommend a path in the diagnostic message, ensure it will match what we expect if the user decides to use it.
var suffixMatch = syntaxTrees.FirstOrDefault(static (tree, pair)
=> mapPath(pair.referenceResolver, tree)
.Replace('\\', '/')
.EndsWith(pair.attributeFilePath),
(referenceResolver, attributeFilePath: attributeFilePath.Replace('\\', '/')));
if (suffixMatch != null)
{
diagnostics.Add(ErrorCode.ERR_InterceptorPathNotInCompilationWithCandidate, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), filePath, suffixMatch.FilePath);
}
else
{
diagnostics.Add(ErrorCode.ERR_InterceptorPathNotInCompilation, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), filePath);
diagnostics.Add(ErrorCode.ERR_InterceptorPathNotInCompilationWithCandidate, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), attributeFilePath, mapPath(referenceResolver, suffixMatch));
return;
}

diagnostics.Add(ErrorCode.ERR_InterceptorPathNotInCompilation, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), attributeFilePath);

return;
}
else if (matchingTrees.Count > 1)
{
diagnostics.Add(ErrorCode.ERR_InterceptorNonUniquePath, attributeData.GetAttributeArgumentSyntaxLocation(filePathParameterIndex, attributeSyntax), attributeFilePath);
return;
}

SyntaxTree? matchingTree = matchingTrees[0];
// Internally, line and character numbers are 0-indexed, but when they appear in code or diagnostic messages, they are 1-indexed.
int lineNumberZeroBased = lineNumberOneBased - 1;
int characterNumberZeroBased = characterNumberOneBased - 1;
Expand Down Expand Up @@ -1079,7 +1083,12 @@ private void DecodeInterceptsLocationAttribute(DecodeWellKnownAttributeArguments
return;
}

DeclaringCompilation.AddInterception(filePath, lineNumberZeroBased, characterNumberZeroBased, attributeLocation, this);
DeclaringCompilation.AddInterception(matchingTree.FilePath, lineNumberZeroBased, characterNumberZeroBased, attributeLocation, this);

static string mapPath(SourceReferenceResolver? referenceResolver, SyntaxTree tree)
{
return referenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
}
}

private void DecodeUnmanagedCallersOnlyAttribute(ref DecodeWellKnownAttributeArguments<AttributeSyntax, CSharpAttributeData, AttributeLocation> arguments)
Expand Down
10 changes: 5 additions & 5 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading