diff --git a/README.md b/README.md index 4bd087f..213f73c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ If you're using [Central Package Management](https://learn.microsoft.com/en-us/n ``` ### C# -You'll need to enable C# documentation XML generation to ensure good analysis results (RT0000 will fire if it's not enabled). If your repo is not already using docxml globally, this can introduce a large number of errors and warnings specific to docxml. Additionally, turning on docxml adds additional output I/O that can slow down large repos. +By default, ReferenceTrimmer uses the Roslyn [`GetUsedAssemblyReferences`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.compilation.getusedassemblyreferences) API to determine which references are used. You'll need to enable C# documentation XML generation to ensure good analysis results (RT0000 will fire if it's not enabled). If your repo is not already using docxml globally, this can introduce a large number of errors and warnings specific to docxml. Additionally, turning on docxml adds additional output I/O that can slow down large repos. If your repo does not already set `` to `true`, add the following to your `Directory.Build.props` to enable it and suppress related warnings: @@ -50,6 +50,24 @@ If your repo does not already set `` to `true`, add t Note: To get better results, enable the [`IDE0005`](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005) unnecessary `using` rule. This prevents the C# compiler from seeing false positive assembly usage from unneeded `using` directives, which could cause it to miss a removable dependency. Note that IDE0005 also requires `` to be enabled. Documentation generation is also required for accuracy of used references detection (see https://github.com/dotnet/roslyn/issues/66188). +#### Experimental: Symbol-based analysis + +An alternative analysis mode is available that tracks symbol usage directly rather than relying on `GetUsedAssemblyReferences`. This mode can detect unused references that the default approach misses — for example, direct references to assemblies that are only used transitively by other dependencies. + +To opt in, set: + +```xml + + true + +``` + +Key differences from the default mode: +- **Does not require ``** — symbol-based detection is accurate regardless of documentation mode, and RT0000 is not emitted. +- **More precise for ProjectReference and PackageReference** — only assemblies whose types or members are directly referenced in code are considered "used." +- **Conservative for bare `` items** — assemblies transitively needed by used assemblies are still treated as "used" to avoid breaking runtime dependencies. +- **`` references are not tracked** — references used only in XML documentation comments will be reported as removable. + #### What makes a reference non-trimmable? ReferenceTrimmer automatically skips certain references that cannot be reliably evaluated. These are not reported as unused even if the compiler does not directly use them: @@ -150,10 +168,14 @@ To make ReferenceTrimmer opt-in rather than always-on, you can default it to `fa `$(EnableReferenceTrimmerDiagnostics)` - When set to `true`, writes used and unused reference lists to the intermediate output directory for debugging. Defaults to `false`. +`$(ReferenceTrimmerUseSymbolAnalysis)` - **Experimental.** When set to `true`, uses symbol-based usage detection instead of the default `GetUsedAssemblyReferences` compiler API. This approach tracks which assemblies contain symbols that the code actually references, which can detect unused references that the default approach misses (e.g., direct references to assemblies that are only used transitively by other dependencies). Defaults to `false`. + ## How does it work? ### C# -There are two main pieces to C# support. First there is an MSBuild task which collects all references passed to the compiler. There is also a Roslyn Analyzer which uses the [`GetUsedAssemblyReferences`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.compilation.getusedassemblyreferences) analyzer API which is available starting with Roslyn compiler that shipped with Visual Studio 2019 version 16.10, .NET 5. (see https://github.com/dotnet/roslyn/blob/main/docs/wiki/NuGet-packages.md#versioning). This is the compiler telling us exactly what references were needed as part of compilation. The analyzer then compares the set of references the Task gathered with the references the compiler says were used. +There are two main pieces to C# support. First there is an MSBuild task which collects all references passed to the compiler. There is also a Roslyn Analyzer which determines which references are actually used and compares them against the declared references the Task gathered. + +By default, the analyzer uses the [`GetUsedAssemblyReferences`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.compilation.getusedassemblyreferences) compiler API (available starting with Visual Studio 2019 version 16.10 / .NET 5). When `ReferenceTrimmerUseSymbolAnalysis` is enabled, it instead registers for symbol declarations and code operations to track which referenced assemblies contain symbols that the code actually uses. ### C++ (.vcxproj projects) ReferenceTrimmer enables the MSVC `link.exe` flags noted above, then parses output coming from the `Link` MSBuild task. It categorizes the outputs and emits them into the MSBuild console output and the JSON output file noted above. It does not issue MSBuild warnings at this time. diff --git a/src/Analyzer/ReferenceTrimmer.Analyzer.csproj b/src/Analyzer/ReferenceTrimmer.Analyzer.csproj index 002ce9d..82de58f 100644 --- a/src/Analyzer/ReferenceTrimmer.Analyzer.csproj +++ b/src/Analyzer/ReferenceTrimmer.Analyzer.csproj @@ -4,7 +4,7 @@ false - diff --git a/src/Analyzer/ReferenceTrimmerAnalyzer.cs b/src/Analyzer/ReferenceTrimmerAnalyzer.cs index 0fa619f..bf99019 100644 --- a/src/Analyzer/ReferenceTrimmerAnalyzer.cs +++ b/src/Analyzer/ReferenceTrimmerAnalyzer.cs @@ -1,8 +1,13 @@ -using System.Collections.Immutable; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; using Microsoft.CodeAnalysis.Text; using ReferenceTrimmer.Shared; +using CSharp = Microsoft.CodeAnalysis.CSharp; namespace ReferenceTrimmer.Analyzer; @@ -53,6 +58,11 @@ public class ReferenceTrimmerAnalyzer : DiagnosticAnalyzer DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly StringComparer PathComparer = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + /// /// The supported diagnostics. /// @@ -68,15 +78,50 @@ public override ImmutableArray SupportedDiagnostics public override void Initialize(AnalysisContext context) { context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationAction(DumpUsedReferences); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.RegisterCompilationStartAction(CompilationStart); + } + + private static void CompilationStart(CompilationStartAnalysisContext context) + { + // Check if ReferenceTrimmer is enabled + AdditionalText? declaredReferencesFile = FindDeclaredReferencesFile(context.Options.AdditionalFiles); + if (declaredReferencesFile == null) + { + return; + } + + Compilation compilation = context.Compilation; + + if (!compilation.Options.Errors.IsEmpty) + { + return; + } + + var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + bool useSymbolAnalysis = + globalOptions.TryGetValue("build_property.ReferenceTrimmerUseSymbolAnalysis", out string? useSymbol) + && string.Equals(useSymbol, "true", StringComparison.OrdinalIgnoreCase); + + if (useSymbolAnalysis) + { + InitializeSymbolBasedAnalysis(context, compilation, declaredReferencesFile); + } + else + { + context.RegisterCompilationEndAction(endContext => RunDefaultAnalysis(endContext, declaredReferencesFile)); + } } - private static void DumpUsedReferences(CompilationAnalysisContext context) + // ────────────────────────────────────────────────────────────────────── + // Default analysis path (GetUsedAssemblyReferences) + // ────────────────────────────────────────────────────────────────────── + + private static void RunDefaultAnalysis(CompilationAnalysisContext context, AdditionalText declaredReferencesFile) { try { - DumpUsedReferencesCore(context); + RunDefaultAnalysisCore(context, declaredReferencesFile); } catch (OperationCanceledException) { @@ -88,15 +133,8 @@ private static void DumpUsedReferences(CompilationAnalysisContext context) } } - private static void DumpUsedReferencesCore(CompilationAnalysisContext context) + private static void RunDefaultAnalysisCore(CompilationAnalysisContext context, AdditionalText declaredReferencesFile) { - AdditionalText? declaredReferencesFile = GetDeclaredReferencesFile(context); - if (declaredReferencesFile == null) - { - // Reference Trimmer is disabled - return; - } - SourceText? sourceText = declaredReferencesFile.GetText(context.CancellationToken); if (sourceText == null) { @@ -109,12 +147,7 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) context.ReportDiagnostic(Diagnostic.Create(RT0000Descriptor, Location.None)); } - if (!compilation.Options.Errors.IsEmpty) - { - return; - } - - HashSet usedReferences = new(StringComparer.OrdinalIgnoreCase); + HashSet usedReferences = new(PathComparer); foreach (MetadataReference metadataReference in compilation.GetUsedAssemblyReferences()) { if (metadataReference.Display != null) @@ -123,11 +156,500 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) } } - var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; - if (globalOptions.TryGetValue("build_property.EnableReferenceTrimmerDiagnostics", out string? enableDiagnostics) + ReportUnusedReferences(context, declaredReferencesFile, sourceText, usedReferences, usedReferences); + } + + // ────────────────────────────────────────────────────────────────────── + // Symbol-based analysis path (experimental, opt-in) + // ────────────────────────────────────────────────────────────────────── + + private static void InitializeSymbolBasedAnalysis( + CompilationStartAnalysisContext context, + Compilation compilation, + AdditionalText declaredReferencesFile) + { + // Build mappings from reference assembly identities to their metadata reference display paths. + // These are used both for symbol tracking and for the transitive closure computation. + var assemblyToPath = new Dictionary(); + var pathToAssembly = new Dictionary(PathComparer); + foreach (MetadataReference reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm && reference.Display != null) + { + if (!assemblyToPath.ContainsKey(asm.Identity)) + { + assemblyToPath.Add(asm.Identity, reference.Display); + } + + if (!pathToAssembly.ContainsKey(reference.Display)) + { + pathToAssembly.Add(reference.Display, asm); + } + } + } + + int totalReferenceCount = assemblyToPath.Count; + var usedReferencePaths = new ConcurrentDictionary(PathComparer); + // Monotonically increasing counter. Once it reaches totalReferenceCount, all + // callbacks short-circuit. A briefly stale read just means a few extra no-op lookups. + int trackedCount = 0; + + void TrackAssembly(IAssemblySymbol? assembly) + { + if (trackedCount >= totalReferenceCount) + { + return; + } + + // Skip the compilation's own assembly — it's never an external reference. + if (assembly == null || ReferenceEquals(assembly, compilation.Assembly)) + { + return; + } + + if (assemblyToPath.TryGetValue(assembly.Identity, out string? path) + && usedReferencePaths.TryAdd(path, 0)) + { + Interlocked.Increment(ref trackedCount); + } + } + + void TrackType(ITypeSymbol? type) + { + while (type != null) + { + if (trackedCount >= totalReferenceCount) + { + return; + } + + switch (type) + { + case IArrayTypeSymbol array: + type = array.ElementType; + continue; + case IPointerTypeSymbol pointer: + type = pointer.PointedAtType; + continue; + case IFunctionPointerTypeSymbol funcPtr: + TrackType(funcPtr.Signature.ReturnType); + foreach (IParameterSymbol fpParam in funcPtr.Signature.Parameters) + { + TrackType(fpParam.Type); + } + + return; + default: + TrackAssembly(type.ContainingAssembly); + if (type is INamedTypeSymbol named) + { + foreach (ITypeSymbol typeArg in named.TypeArguments) + { + TrackType(typeArg); + } + } + + return; + } + } + } + + void TrackAttribute(AttributeData attr) + { + TrackType(attr.AttributeClass); + foreach (TypedConstant arg in attr.ConstructorArguments) + { + TrackTypedConstant(arg); + } + + foreach (KeyValuePair arg in attr.NamedArguments) + { + TrackTypedConstant(arg.Value); + } + } + + void TrackTypedConstant(TypedConstant constant) + { + TrackType(constant.Type); + if (constant.Kind == TypedConstantKind.Type && constant.Value is ITypeSymbol typeValue) + { + TrackType(typeValue); + } + else if (constant.Kind == TypedConstantKind.Array && !constant.Values.IsDefault) + { + foreach (TypedConstant element in constant.Values) + { + TrackTypedConstant(element); + } + } + } + + void TrackPatternTypes(IPatternOperation pattern) + { + switch (pattern) + { + case ITypePatternOperation typePattern: + TrackType(typePattern.MatchedType); + break; + case IDeclarationPatternOperation declPattern: + TrackType(declPattern.MatchedType); + break; + case IRecursivePatternOperation recursivePattern: + TrackType(recursivePattern.MatchedType); + break; + case INegatedPatternOperation negated: + TrackPatternTypes(negated.Pattern); + break; + case IBinaryPatternOperation binary: + TrackPatternTypes(binary.LeftPattern); + TrackPatternTypes(binary.RightPattern); + break; + } + } + + // Track declaration-level type references: base types, interfaces, member signatures, attributes. + context.RegisterSymbolAction( + ctx => + { + switch (ctx.Symbol) + { + case INamedTypeSymbol namedType: + TrackType(namedType.BaseType); + foreach (INamedTypeSymbol iface in namedType.Interfaces) + { + TrackType(iface); + } + + foreach (ITypeParameterSymbol typeParam in namedType.TypeParameters) + { + foreach (ITypeSymbol constraint in typeParam.ConstraintTypes) + { + TrackType(constraint); + } + } + + foreach (AttributeData attr in namedType.GetAttributes()) + { + TrackAttribute(attr); + } + + break; + + case IMethodSymbol method: + TrackType(method.ReturnType); + foreach (IParameterSymbol param in method.Parameters) + { + TrackType(param.Type); + } + + foreach (ITypeParameterSymbol typeParam in method.TypeParameters) + { + foreach (ITypeSymbol constraint in typeParam.ConstraintTypes) + { + TrackType(constraint); + } + } + + foreach (AttributeData attr in method.GetAttributes()) + { + TrackAttribute(attr); + } + + foreach (AttributeData attr in method.GetReturnTypeAttributes()) + { + TrackAttribute(attr); + } + + break; + + case IPropertySymbol property: + TrackType(property.Type); + foreach (AttributeData attr in property.GetAttributes()) + { + TrackAttribute(attr); + } + + break; + + case IFieldSymbol field: + TrackType(field.Type); + foreach (AttributeData attr in field.GetAttributes()) + { + TrackAttribute(attr); + } + + break; + + case IEventSymbol evt: + TrackType(evt.Type); + foreach (AttributeData attr in evt.GetAttributes()) + { + TrackAttribute(attr); + } + + break; + } + }, + SymbolKind.NamedType, + SymbolKind.Method, + SymbolKind.Property, + SymbolKind.Field, + SymbolKind.Event); + + // Track body-level references: method calls, member access, object creation, type checks, etc. + context.RegisterOperationAction( + ctx => + { + IOperation operation = ctx.Operation; + TrackType(operation.Type); + + switch (operation) + { + case IInvocationOperation invocation: + TrackAssembly(invocation.TargetMethod.ContainingAssembly); + foreach (ITypeSymbol typeArg in invocation.TargetMethod.TypeArguments) + { + TrackType(typeArg); + } + + break; + + case IObjectCreationOperation creation: + TrackAssembly(creation.Constructor?.ContainingAssembly); + break; + + case IMemberReferenceOperation memberRef: + TrackAssembly(memberRef.Member.ContainingAssembly); + break; + + case ITypeOfOperation typeOfOp: + TrackType(typeOfOp.TypeOperand); + break; + + case IConversionOperation conversion: + TrackAssembly(conversion.OperatorMethod?.ContainingAssembly); + TrackType(conversion.Operand.Type); + break; + + case IBinaryOperation binary: + TrackAssembly(binary.OperatorMethod?.ContainingAssembly); + break; + + case IUnaryOperation unary: + TrackAssembly(unary.OperatorMethod?.ContainingAssembly); + break; + + case ICompoundAssignmentOperation compound: + TrackAssembly(compound.OperatorMethod?.ContainingAssembly); + break; + + case IIncrementOrDecrementOperation incDec: + TrackAssembly(incDec.OperatorMethod?.ContainingAssembly); + break; + + case IIsTypeOperation isTypeOp: + TrackType(isTypeOp.TypeOperand); + break; + + case IIsPatternOperation isPatternOp: + TrackPatternTypes(isPatternOp.Pattern); + break; + + case ISwitchOperation switchOp: + foreach (ISwitchCaseOperation caseOp in switchOp.Cases) + { + foreach (ICaseClauseOperation clause in caseOp.Clauses) + { + if (clause is IPatternCaseClauseOperation patternClause) + { + TrackPatternTypes(patternClause.Pattern); + } + } + } + + break; + + case ISwitchExpressionOperation switchExpr: + foreach (ISwitchExpressionArmOperation arm in switchExpr.Arms) + { + TrackPatternTypes(arm.Pattern); + } + + break; + + case ITypePatternOperation typePattern: + TrackType(typePattern.MatchedType); + break; + + case IDeclarationPatternOperation declPattern: + TrackType(declPattern.MatchedType); + break; + + case IRecursivePatternOperation recursivePattern: + TrackType(recursivePattern.MatchedType); + break; + + case ICatchClauseOperation catchClause: + TrackType(catchClause.ExceptionType); + break; + + case ISwitchExpressionArmOperation switchArm: + TrackPatternTypes(switchArm.Pattern); + break; + + case IPatternCaseClauseOperation patternClause: + TrackPatternTypes(patternClause.Pattern); + break; + + case ILocalFunctionOperation localFunc: + TrackType(localFunc.Symbol.ReturnType); + foreach (IParameterSymbol lfParam in localFunc.Symbol.Parameters) + { + TrackType(lfParam.Type); + } + + break; + + case IAnonymousFunctionOperation lambda: + foreach (IParameterSymbol lambdaParam in lambda.Symbol.Parameters) + { + TrackType(lambdaParam.Type); + } + + break; + + case IVariableDeclaratorOperation varDecl: + TrackType(varDecl.Symbol.Type); + break; + + case ISizeOfOperation sizeOfOp: + TrackType(sizeOfOp.TypeOperand); + break; + } + }, + OperationKind.Invocation, + OperationKind.ObjectCreation, + OperationKind.FieldReference, + OperationKind.PropertyReference, + OperationKind.EventReference, + OperationKind.MethodReference, + OperationKind.TypeOf, + OperationKind.DefaultValue, + OperationKind.Conversion, + OperationKind.Binary, + OperationKind.Unary, + OperationKind.CompoundAssignment, + OperationKind.Increment, + OperationKind.Decrement, + OperationKind.IsType, + OperationKind.IsPattern, + OperationKind.Switch, + OperationKind.SwitchExpression, + OperationKind.TypePattern, + OperationKind.DeclarationPattern, + OperationKind.RecursivePattern, + OperationKind.CatchClause, + OperationKind.SwitchExpressionArm, + OperationKind.CaseClause, + OperationKind.LocalFunction, + OperationKind.AnonymousFunction, + OperationKind.SizeOf, + OperationKind.VariableDeclarator); + + // Track nameof() and XML doc cref references via language-specific syntax actions. + // These require syntax-level analysis because nameof is lowered to a string literal + // and crefs live in documentation trivia — neither surfaces through IOperation. + if (compilation.Language == LanguageNames.CSharp) + { + RegisterCSharpSyntaxTracking(context, TrackAssembly, TrackType); + } + + context.RegisterCompilationEndAction(endContext => + { + try + { + // Track assembly-level attributes + foreach (AttributeData attr in compilation.Assembly.GetAttributes()) + { + TrackAttribute(attr); + } + + SourceText? sourceText = declaredReferencesFile.GetText(endContext.CancellationToken); + if (sourceText == null) + { + return; + } + + // Mark type-forwarding assemblies as used when the destination assembly is used. + // E.g. a package may forward types to the runtime; the code uses the type (tracking the + // runtime assembly) but the forwarder assembly must also be kept as a reference. + foreach (KeyValuePair kvp in pathToAssembly) + { + if (usedReferencePaths.ContainsKey(kvp.Key)) + { + continue; + } + + foreach (INamedTypeSymbol forwardedType in kvp.Value.GetForwardedTypes()) + { + if (forwardedType.ContainingAssembly != null + && assemblyToPath.TryGetValue(forwardedType.ContainingAssembly.Identity, out string? destPath) + && usedReferencePaths.ContainsKey(destPath)) + { + usedReferencePaths.TryAdd(kvp.Key, 0); + break; + } + } + } + + HashSet usedReferences = new(usedReferencePaths.Keys, PathComparer); + + // For bare Reference items (RT0001), we always need a conservative "transitively used" set + // because bare References control copy-to-output behavior directly and have no transitive + // resolution. Removing a bare Reference needed at runtime would break the application. + // + // For ProjectReference items (RT0002), we also need the conservative set when + // DisableTransitiveProjectReferences is enabled, since MSBuild won't propagate transitive + // project dependencies in that case. + HashSet transitivelyUsedReferences = ComputeTransitivelyUsedReferences(assemblyToPath, pathToAssembly, usedReferences); + + ReportUnusedReferences(endContext, declaredReferencesFile, sourceText, usedReferences, transitivelyUsedReferences); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + endContext.ReportDiagnostic(Diagnostic.Create(RT9999Descriptor, Location.None, ex.Message)); + } + }); + } + + // ────────────────────────────────────────────────────────────────────── + // Shared reporting logic + // ────────────────────────────────────────────────────────────────────── + + private static void ReportUnusedReferences( + CompilationAnalysisContext context, + AdditionalText declaredReferencesFile, + SourceText sourceText, + HashSet usedReferences, + HashSet transitivelyUsedReferences) + { + Compilation compilation = context.Compilation; + + bool disableTransitiveProjectReferences = + context.Options.AnalyzerConfigOptionsProvider.GlobalOptions + .TryGetValue("build_property.DisableTransitiveProjectReferences", out string? disableTransitive) + && string.Equals(disableTransitive, "true", StringComparison.OrdinalIgnoreCase); + HashSet projectReferenceUsedSet = disableTransitiveProjectReferences ? transitivelyUsedReferences : usedReferences; + + if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions + .TryGetValue("build_property.EnableReferenceTrimmerDiagnostics", out string? enableDiagnostics) && string.Equals(enableDiagnostics, "true", StringComparison.OrdinalIgnoreCase)) { - HashSet unusedReferences = new(StringComparer.OrdinalIgnoreCase); + HashSet unusedReferences = new(PathComparer); foreach (MetadataReference metadataReference in compilation.References) { if (metadataReference.Display != null && !usedReferences.Contains(metadataReference.Display)) @@ -139,14 +661,15 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) DumpReferencesInfo(usedReferences, unusedReferences, declaredReferencesFile.Path); } - Dictionary> packageAssembliesDict = new(StringComparer.OrdinalIgnoreCase); + Dictionary> packageAssembliesDict = new(PathComparer); foreach (DeclaredReference declaredReference in ReadDeclaredReferences(sourceText)) { switch (declaredReference.Kind) { case DeclaredReferenceKind.Reference: { - if (!usedReferences.Contains(declaredReference.AssemblyPath)) + // Use the conservative transitively-used set for bare References + if (!transitivelyUsedReferences.Contains(declaredReference.AssemblyPath)) { context.ReportDiagnostic(Diagnostic.Create(RT0001Descriptor, Location.None, declaredReference.Spec)); } @@ -155,7 +678,7 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) } case DeclaredReferenceKind.ProjectReference: { - if (!usedReferences.Contains(declaredReference.AssemblyPath)) + if (!projectReferenceUsedSet.Contains(declaredReference.AssemblyPath)) { context.ReportDiagnostic(Diagnostic.Create(RT0002Descriptor, Location.None, declaredReference.Spec)); } @@ -188,6 +711,118 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) } } + // ────────────────────────────────────────────────────────────────────── + // Language-specific syntax tracking (nameof, crefs) + // ────────────────────────────────────────────────────────────────────── + + // Separate methods per language to avoid JIT-loading the wrong language assembly. + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RegisterCSharpSyntaxTracking( + CompilationStartAnalysisContext context, + Action trackAssembly, + Action trackType) + { + // nameof() — appears as InvocationExpression at the syntax level but is + // lowered to a string literal in the IOperation tree. + context.RegisterSyntaxNodeAction(ctx => + { + if (ctx.Node is CSharp.Syntax.InvocationExpressionSyntax invocation + && invocation.Expression is CSharp.Syntax.IdentifierNameSyntax id + && id.Identifier.Text == "nameof" + && invocation.ArgumentList.Arguments.Count > 0) + { + // Verify it is actually the nameof operator, not a method called "nameof". + SymbolInfo invocationInfo = ctx.SemanticModel.GetSymbolInfo(invocation, ctx.CancellationToken); + if (invocationInfo.Symbol is IMethodSymbol) + { + return; + } + + SymbolInfo argInfo = ctx.SemanticModel.GetSymbolInfo(invocation.ArgumentList.Arguments[0].Expression, ctx.CancellationToken); + ISymbol? symbol = argInfo.Symbol ?? argInfo.CandidateSymbols.FirstOrDefault(); + if (symbol is ITypeSymbol typeSymbol) + { + trackType(typeSymbol); + } + else if (symbol != null) + { + trackAssembly(symbol.ContainingAssembly); + } + } + }, CSharp.SyntaxKind.InvocationExpression); + + // XML doc — only relevant when documentation generation is enabled, + // matching the behavior of GetUsedAssemblyReferences() in the legacy path. + context.RegisterSyntaxNodeAction(ctx => + { + if (ctx.SemanticModel.SyntaxTree.Options.DocumentationMode == DocumentationMode.None) + { + return; + } + + if (ctx.Node is CSharp.Syntax.XmlCrefAttributeSyntax cref) + { + SymbolInfo symbolInfo = ctx.SemanticModel.GetSymbolInfo(cref.Cref, ctx.CancellationToken); + ISymbol? symbol = symbolInfo.Symbol ?? symbolInfo.CandidateSymbols.FirstOrDefault(); + if (symbol is ITypeSymbol typeSymbol) + { + trackType(typeSymbol); + } + else if (symbol != null) + { + trackAssembly(symbol.ContainingAssembly); + } + } + }, CSharp.SyntaxKind.XmlCrefAttribute); + } + + // ────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────── + + private static AdditionalText? FindDeclaredReferencesFile(ImmutableArray additionalFiles) + { + foreach (AdditionalText additionalText in additionalFiles) + { + if (Path.GetFileName(additionalText.Path).Equals(DeclaredReferencesFileName, StringComparison.Ordinal)) + { + return additionalText; + } + } + + return null; + } + + private static HashSet ComputeTransitivelyUsedReferences( + Dictionary identityToPath, + Dictionary pathToAssembly, + HashSet usedReferences) + { + HashSet transitivelyUsed = new(usedReferences, PathComparer); + Queue queue = new(usedReferences); + while (queue.Count > 0) + { + string path = queue.Dequeue(); + if (pathToAssembly.TryGetValue(path, out IAssemblySymbol? asm)) + { + foreach (IModuleSymbol module in asm.Modules) + { + foreach (AssemblyIdentity dep in module.ReferencedAssemblies) + { + if (identityToPath.TryGetValue(dep, out string? depPath) + && transitivelyUsed.Add(depPath)) + { + queue.Enqueue(depPath); + } + } + } + } + } + + return transitivelyUsed; + } + private static void DumpReferencesInfo(HashSet usedReferences, HashSet unusedReferences, string declaredReferencesPath) { string dir = Path.GetDirectoryName(declaredReferencesPath); @@ -219,19 +854,6 @@ private static void WriteFile(string filePath, string text) } } - private static AdditionalText? GetDeclaredReferencesFile(CompilationAnalysisContext context) - { - foreach (AdditionalText additionalText in context.Options.AdditionalFiles) - { - if (Path.GetFileName(additionalText.Path).Equals(DeclaredReferencesFileName, StringComparison.Ordinal)) - { - return additionalText; - } - } - - return null; - } - // File format: tab-separated fields (AssemblyPath, Kind, Spec), one reference per line. // Keep in sync with SaveDeclaredReferences in CollectDeclaredReferencesTask.cs. private static IEnumerable ReadDeclaredReferences(SourceText sourceText) diff --git a/src/Package/build/ReferenceTrimmer.targets b/src/Package/build/ReferenceTrimmer.targets index a533591..393076d 100644 --- a/src/Package/build/ReferenceTrimmer.targets +++ b/src/Package/build/ReferenceTrimmer.targets @@ -7,6 +7,7 @@ + diff --git a/src/Tests/AnalyzerTests.cs b/src/Tests/AnalyzerTests.cs new file mode 100644 index 0000000..cc15b46 --- /dev/null +++ b/src/Tests/AnalyzerTests.cs @@ -0,0 +1,612 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using ReferenceTrimmer.Analyzer; + +namespace ReferenceTrimmer.Tests; + +[TestClass] +public sealed class AnalyzerTests +{ + [TestMethod] + public async Task UsedViaMethodCall() + { + var dep = EmitDependency("namespace Dep { public static class Foo { public static void Bar() {} } }"); + var diagnostics = await RunAnalyzerAsync( + "class C { void M() { Dep.Foo.Bar(); } }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UnusedReportsDiagnostic() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { }", + dep); + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + } + + [TestMethod] + public async Task UsedViaSwitchPattern() + { + var dep = EmitDependency("namespace Dep { public class SpecialType {} }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + bool Check(object o) { + switch (o) { case Dep.SpecialType _: return true; default: return false; } + } + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaDefaultExpression() + { + var dep = EmitDependency("namespace Dep { public struct ValueHolder { public int Value; } }"); + var diagnostics = await RunAnalyzerAsync( + "class C { object M() => default(Dep.ValueHolder); }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaOperator() + { + var dep = EmitDependency(@" + namespace Dep { + public struct Amount { + public int Value; + public Amount(int v) => Value = v; + public static Amount operator +(Amount a, Amount b) => new Amount(a.Value + b.Value); + } + }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + int M() { + var a = new Dep.Amount(1); + var b = new Dep.Amount(2); + return (a + b).Value; + } + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaIsPattern() + { + var dep = EmitDependency("namespace Dep { public class Marker {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { bool Check(object o) => o is Dep.Marker; }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaCatchClause() + { + var dep = EmitDependency("namespace Dep { public class MyException : System.Exception {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { void M() { try {} catch (Dep.MyException) {} } }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaBaseType() + { + var dep = EmitDependency("namespace Dep { public class Base {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Base { }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaAttribute() + { + var dep = EmitDependency(@" + namespace Dep { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class MyAttribute : System.Attribute {} + }"); + var diagnostics = await RunAnalyzerAsync( + "[Dep.My] class C { }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaTypeOf() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { System.Type M() => typeof(Dep.Foo); }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaGenericTypeArgument() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { System.Collections.Generic.List M() => null; }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaInterface() + { + var dep = EmitDependency("namespace Dep { public interface IMarker {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.IMarker { }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaTypeConstraint() + { + var dep = EmitDependency("namespace Dep { public class Base {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { void M() where T : Dep.Base {} }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaFieldType() + { + var dep = EmitDependency("namespace Dep { public class Holder {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { Dep.Holder _field; }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaParameterType() + { + var dep = EmitDependency("namespace Dep { public class Input {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { void M(Dep.Input x) {} }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaSwitchExpression() + { + var dep = EmitDependency("namespace Dep { public class SpecialType {} }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + bool Check(object o) => o switch { Dep.SpecialType _ => true, _ => false }; + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaConversionOperator() + { + var dep = EmitDependency(@" + namespace Dep { + public struct Wrapper { + public int Value; + public Wrapper(int v) => Value = v; + public static implicit operator int(Wrapper w) => w.Value; + } + }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { int M() { var w = new Dep.Wrapper(42); return w; } }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaAssemblyAttribute() + { + var dep = EmitDependency(@" + namespace Dep { + [System.AttributeUsage(System.AttributeTargets.Assembly)] + public class MyAsmAttribute : System.Attribute {} + }"); + var diagnostics = await RunAnalyzerAsync( + "[assembly: Dep.MyAsm] class C { }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task MultipleDepsOnlyUnusedReported() + { + var used = EmitDependency( + "namespace Used { public class Foo {} }", + assemblyName: "UsedDep"); + var unused = EmitDependency( + "namespace Unused { public class Bar {} }", + assemblyName: "UnusedDep"); + var diagnostics = await RunAnalyzerAsync( + "class C : Used.Foo { }", + [(used.Reference, used.Path, "ProjectReference", "../Used/Used.csproj"), + (unused.Reference, unused.Path, "ProjectReference", "../Unused/Unused.csproj")]); + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + Assert.IsTrue(diagnostics[0].GetMessage(CultureInfo.InvariantCulture).Contains("Unused")); + } + + [TestMethod] + public async Task UsedViaNameof() + { + var dep = EmitDependency("namespace Dep { public class Marker {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { string M() => nameof(Dep.Marker); }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaNameofMember() + { + var dep = EmitDependency("namespace Dep { public class Foo { public static int Bar; } }"); + var diagnostics = await RunAnalyzerAsync( + "class C { string M() => nameof(Dep.Foo.Bar); }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaXmlDocCref() + { + var dep = EmitDependency("namespace Dep { public class Documented {} }"); + var diagnostics = await RunAnalyzerAsync( + @"/// See . + class C { }", + [(dep.Reference, dep.Path, "ProjectReference", "../Dependency/Dependency.csproj")], + new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaXmlDocCrefMember() + { + var dep = EmitDependency("namespace Dep { public class Foo { public static void Bar() {} } }"); + var diagnostics = await RunAnalyzerAsync( + @"/// See . + class C { }", + [(dep.Reference, dep.Path, "ProjectReference", "../Dependency/Dependency.csproj")], + new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UnusedViaCrefWhenDocModeDisabled() + { + var dep = EmitDependency("namespace Dep { public class Documented {} }"); + // With DocumentationMode.None, cref references should NOT prevent removal + var diagnostics = await RunAnalyzerAsync( + @"/// See . + class C { }", + [(dep.Reference, dep.Path, "ProjectReference", "../Dependency/Dependency.csproj")], + new CSharpParseOptions(documentationMode: DocumentationMode.None)); + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + } + + [TestMethod] + public async Task UsedViaTypeForwarding() + { + // Emit "Runtime" assembly with the actual type + var runtime = EmitDependency( + "namespace Dep { public class Foo {} }", + assemblyName: "Runtime"); + + // Emit "Facade" assembly that forwards Dep.Foo to Runtime + var facadeTree = CSharpSyntaxTree.ParseText(@" + using System.Runtime.CompilerServices; + [assembly: TypeForwardedTo(typeof(Dep.Foo))] + "); + var facadeComp = CSharpCompilation.Create( + "Facade", + [facadeTree], + [CorlibRef, runtime.Reference], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + string facadePath = Path.Combine(Path.GetTempPath(), $"RT_Test_Facade_{Guid.NewGuid():N}.dll"); + var facadeResult = facadeComp.Emit(facadePath); + Assert.IsTrue(facadeResult.Success, $"Facade compilation failed:\n{string.Join("\n", facadeResult.Diagnostics)}"); + var facadeRef = MetadataReference.CreateFromFile(facadePath); + + // Library uses Dep.Foo (resolves to Runtime), but Facade should also be kept + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Foo { }", + [(facadeRef, facadePath, "ProjectReference", "../Facade/Facade.csproj"), + (runtime.Reference, runtime.Path, "ProjectReference", "../Runtime/Runtime.csproj")]); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaLocalVariableType() + { + var dep = EmitDependency("namespace Dep { public class Holder { public int Value; } }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + void M() { + Dep.Holder h = null; + _ = h; + } + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaLambdaParameterType() + { + // The lambda parameter type is the sole reference path to the dependency. + // System.Action avoids referencing Dep.Input through the delegate type. + var dep = EmitDependency("namespace Dep { public class Input {} }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + void M() { + System.Action a = (object x) => { + var y = (Dep.Input)x; + }; + } + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaLocalFunctionReturnType() + { + // The local function's return type is the sole reference path — no call site + // to avoid tracking via IInvocationOperation. + var dep = EmitDependency("namespace Dep { public class Result {} }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + void M() { + Dep.Result Local() => null; + _ = (object)null; + } + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaArrayElementType() + { + var dep = EmitDependency("namespace Dep { public class Item {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { Dep.Item[] M() => null; }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaAttributeTypeofArgument() + { + var dep = EmitDependency(@" + namespace Dep { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class TypedAttribute : System.Attribute { + public TypedAttribute(System.Type t) {} + } + public class Target {} + }"); + var diagnostics = await RunAnalyzerAsync( + "[Dep.TypedAttribute(typeof(Dep.Target))] class C { }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaRecursivePattern() + { + var dep = EmitDependency(@" + namespace Dep { + public class Point { + public int X { get; set; } + public int Y { get; set; } + } + }"); + var diagnostics = await RunAnalyzerAsync( + @"class C { + bool Check(object o) => o is Dep.Point { X: > 0 }; + }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UsedViaEventType() + { + var dep = EmitDependency("namespace Dep { public delegate void MyHandler(int x); }"); + var diagnostics = await RunAnalyzerAsync( + "class C { event Dep.MyHandler MyEvent; }", + dep); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UnusedPackageReportsRT0003() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { }", + [(dep.Reference, dep.Path, "PackageReference", "Dep.Package")]); + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0003", diagnostics[0].Id); + Assert.IsTrue(diagnostics[0].GetMessage(CultureInfo.InvariantCulture).Contains("Dep.Package")); + } + + [TestMethod] + public async Task UsedPackageDoesNotReportRT0003() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Foo { }", + [(dep.Reference, dep.Path, "PackageReference", "Dep.Package")]); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task PackageUsedIfAnyAssemblyUsed() + { + // A package has two assemblies; only one is used. The package should be kept. + var usedAsm = EmitDependency( + "namespace DepA { public class Foo {} }", + assemblyName: "Dep.PackageA"); + var unusedAsm = EmitDependency( + "namespace DepB { public class Bar {} }", + assemblyName: "Dep.PackageB"); + var diagnostics = await RunAnalyzerAsync( + "class C : DepA.Foo { }", + [(usedAsm.Reference, usedAsm.Path, "PackageReference", "Dep.Package"), + (unusedAsm.Reference, unusedAsm.Path, "PackageReference", "Dep.Package")]); + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + public async Task UnusedBareReferenceReportsRT0001() + { + var dep = EmitDependency("namespace Dep { public class Foo {} }"); + var diagnostics = await RunAnalyzerAsync( + "class C { }", + [(dep.Reference, dep.Path, "Reference", dep.Path)]); + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0001", diagnostics[0].Id); + } + + // ────────────────────────────────────────────────────────────────────── + // Test infrastructure + // ────────────────────────────────────────────────────────────────────── + + private static void AssertNoDiagnostics(ImmutableArray diagnostics) + { + if (diagnostics.Length > 0) + { + Assert.Fail($"Expected no diagnostics but got:\n{string.Join("\n", diagnostics.Select(d => $" {d.Id}: {d.GetMessage(CultureInfo.InvariantCulture)}"))}"); + } + } + + private static readonly MetadataReference CorlibRef = + MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + + /// + /// Compile dependency source into a DLL on disk and return the metadata reference + path. + /// + private static (MetadataReference Reference, string Path) EmitDependency(string source, string assemblyName = "Dependency") + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + assemblyName, + [tree], + [CorlibRef], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + string path = Path.Combine(Path.GetTempPath(), $"RT_Test_{assemblyName}_{Guid.NewGuid():N}.dll"); + var result = compilation.Emit(path); + Assert.IsTrue(result.Success, $"Dependency compilation failed:\n{string.Join("\n", result.Diagnostics)}"); + + return (MetadataReference.CreateFromFile(path), path); + } + + /// + /// Run the ReferenceTrimmerAnalyzer on the given library source with symbol-based analysis enabled. + /// Dependencies are declared as ProjectReference entries in the TSV file. + /// + private static async Task> RunAnalyzerAsync( + string librarySource, + (MetadataReference Reference, string Path) dependency, + string dependencySpec = "../Dependency/Dependency.csproj", + string referenceKind = "ProjectReference") + { + return await RunAnalyzerAsync( + librarySource, + [(dependency.Reference, dependency.Path, referenceKind, dependencySpec)]); + } + + /// + /// Run the ReferenceTrimmerAnalyzer with multiple declared dependencies. + /// Each dependency tuple: (Reference, Path, Kind, Spec). + /// + private static async Task> RunAnalyzerAsync( + string librarySource, + (MetadataReference Reference, string Path, string Kind, string Spec)[] dependencies, + CSharpParseOptions? parseOptions = null) + { + var tree = CSharpSyntaxTree.ParseText(librarySource, parseOptions); + var references = new List { CorlibRef }; + var tsvLines = new List(); + foreach (var dep in dependencies) + { + references.Add(dep.Reference); + tsvLines.Add($"{dep.Path}\t{dep.Kind}\t{dep.Spec}"); + } + + var compilation = CSharpCompilation.Create( + "Library", + [tree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + string tsvContent = string.Join("\n", tsvLines); + + var additionalTexts = ImmutableArray.Create( + new InMemoryAdditionalText("_ReferenceTrimmer_DeclaredReferences.tsv", tsvContent)); + + var globalOptions = new TestGlobalOptions(new Dictionary + { + ["build_property.ReferenceTrimmerUseSymbolAnalysis"] = "true", + }); + + var options = new AnalyzerOptions(additionalTexts, new TestOptionsProvider(globalOptions)); + var analyzer = new ReferenceTrimmerAnalyzer(); + var compilationWithAnalyzers = new CompilationWithAnalyzers( + compilation, + [analyzer], + new CompilationWithAnalyzersOptions(options, null, concurrentAnalysis: true, logAnalyzerExecutionTime: false)); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } + + // ── Mock types ─────────────────────────────────────────────────────── + + private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText + { + public override string Path => path; + + public override SourceText? GetText(CancellationToken cancellationToken = default) + => SourceText.From(content); + } + + private sealed class TestGlobalOptions(Dictionary values) : AnalyzerConfigOptions + { +#nullable disable + public override bool TryGetValue(string key, out string value) + => values.TryGetValue(key, out value); +#nullable restore + } + + private sealed class TestOptionsProvider(AnalyzerConfigOptions globalOptions) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions => globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + => new TestGlobalOptions([]); + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + => new TestGlobalOptions([]); + } +} diff --git a/src/Tests/E2ETests.cs b/src/Tests/E2ETests.cs index 38e52da..2f909a6 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -32,33 +32,80 @@ public static void ClassInitialize(TestContext _) } [TestMethod] - public Task UsedProjectReference() + [DataRow(false)] + [DataRow(true)] + public Task UsedProjectReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UsedProjectReferenceProduceReferenceAssembly() + [DataRow(false)] + [DataRow(true)] + public Task UsedProjectReferenceProduceReferenceAssembly(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UsedProjectReferenceNoReferenceAssembly() + [DataRow(false)] + [DataRow(true)] + public Task UsedProjectReferenceNoReferenceAssembly(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] + [DataRow(false)] [DataRow(true)] + public Task UsedProjectReferenceSwitchPattern(bool useSymbolAnalysis) + { + // Dependency type used only in switch expression type pattern and switch case clause pattern. + return RunMSBuildAsync( + projectFile: "Library/Library.csproj", + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); + } + + [TestMethod] + public Task UsedProjectReferenceNameof() + { + // Dependency type used only in nameof(). nameof is lowered to a string literal + // in IOperation, so only the syntax-level handler catches it. Symbol-analysis only. + return RunMSBuildAsync( + projectFile: "Library/Library.csproj", + expectedWarnings: [], + useSymbolAnalysis: true); + } + + [TestMethod] [DataRow(false)] - public Task UnusedProjectReference(bool enableReferenceTrimmerDiagnostics) + [DataRow(true)] + public Task UsedProjectReferenceCref(bool useSymbolAnalysis) + { + // Dependency type used only in XML doc . + // Both legacy (GetUsedAssemblyReferences with doc mode on) and symbol-based paths handle this. + return RunMSBuildAsync( + projectFile: "Library/Library.csproj", + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); + } + + [TestMethod] + [DataRow(true, false)] + [DataRow(false, false)] + [DataRow(true, true)] + [DataRow(false, true)] + public Task UnusedProjectReference(bool enableReferenceTrimmerDiagnostics, bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", @@ -66,57 +113,75 @@ public Task UnusedProjectReference(bool enableReferenceTrimmerDiagnostics) { new Warning("RT0002: ProjectReference ../Dependency/Dependency.csproj can be removed", "Library/Library.csproj"), }, - enableReferenceTrimmerDiagnostics: enableReferenceTrimmerDiagnostics); + enableReferenceTrimmerDiagnostics: enableReferenceTrimmerDiagnostics, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedProjectReferenceProduceReferenceAssembly() + [DataRow(false)] + [DataRow(true)] + public Task UnusedProjectReferenceProduceReferenceAssembly(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: new[] { new Warning("RT0002: ProjectReference ../Dependency/Dependency.csproj can be removed", "Library/Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedProjectReferenceNoReferenceAssembly() + [DataRow(false)] + [DataRow(true)] + public Task UnusedProjectReferenceNoReferenceAssembly(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: new[] { new Warning("RT0002: ProjectReference ../Dependency/Dependency.csproj can be removed", "Library/Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedProjectReferenceNoWarn() + [DataRow(false)] + [DataRow(true)] + public Task UnusedProjectReferenceNoWarn(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedProjectReferenceTreatAsUsed() + [DataRow(false)] + [DataRow(true)] + public Task UnusedProjectReferenceTreatAsUsed(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: Array.Empty()); + expectedWarnings: Array.Empty(), + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedProjectReferenceSuppressed() + [DataRow(false)] + [DataRow(true)] + public Task UnusedProjectReferenceSuppressed(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedTransitiveProjectReference() + [DataRow(false)] + [DataRow(true)] + public Task UnusedTransitiveProjectReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", @@ -124,11 +189,14 @@ public Task UnusedTransitiveProjectReference() { // Only the Dependency gets the warning. Library doesn't get penalized for a transitive dependency. new Warning("RT0002: ProjectReference ../TransitiveDependency/TransitiveDependency.csproj can be removed", "Dependency/Dependency.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedDirectAndTransitiveProjectReference() + [DataRow(false)] + [DataRow(true)] + public Task UnusedDirectAndTransitiveProjectReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", @@ -137,84 +205,118 @@ public Task UnusedDirectAndTransitiveProjectReference() // Both the Library and Dependency get the warning since both directly referenced it. new Warning("RT0002: ProjectReference ../TransitiveDependency/TransitiveDependency.csproj can be removed", "Dependency/Dependency.csproj"), new Warning("RT0002: ProjectReference ../TransitiveDependency/TransitiveDependency.csproj can be removed", "Library/Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UsedReferenceHintPath() + public Task UnusedDirectReferenceUsedTransitively() + { + // Library directly references TransitiveDependency but doesn't use it. + // Dependency uses TransitiveDependency. Symbol-based analysis is required to + // reliably detect this as unused — the default approach may or may not depending + // on whether reference assemblies strip the transitive metadata. + return RunMSBuildAsync( + projectFile: "Library/Library.csproj", + expectedWarnings: new[] + { + new Warning("RT0002: ProjectReference ../TransitiveDependency/TransitiveDependency.csproj can be removed", "Library/Library.csproj"), + }, + useSymbolAnalysis: true); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UsedReferenceHintPath(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UsedReferenceItemSpec() + [DataRow(false)] + [DataRow(true)] + public async Task UsedReferenceItemSpec(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceHintPath() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceHintPath(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: new[] { new Warning("RT0001: Reference Dependency can be removed", "Library/Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceHintPathNoWarn() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceHintPathNoWarn(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceHintPathTreatAsUsed() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceHintPathTreatAsUsed(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: Array.Empty()); + expectedWarnings: Array.Empty(), + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: Array.Empty()); + expectedWarnings: Array.Empty(), + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceItemSpec() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceItemSpec(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", @@ -230,128 +332,169 @@ await RunMSBuildAsync( @"RT0001: Reference ..\Dependency\bin\Debug\net472\\Dependency.dll can be removed", @"RT0001: Reference ../Dependency/bin/Debug/net472/Dependency.dll can be removed", ]), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceItemSpecNoWarn() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceItemSpecNoWarn(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task UnusedReferenceItemSpecTreatAsUsed() + [DataRow(false)] + [DataRow(true)] + public async Task UnusedReferenceItemSpecTreatAsUsed(bool useSymbolAnalysis) { - // For direct references, MSBuild can't determine build order so we need to ensure the dependency is already built await RunMSBuildAsync( projectFile: "Dependency/Dependency.csproj", - expectedWarnings: Array.Empty()); + expectedWarnings: Array.Empty(), + useSymbolAnalysis: useSymbolAnalysis); await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: Array.Empty()); + expectedWarnings: Array.Empty(), + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] + [DataRow(false)] + [DataRow(true)] [OSCondition(OperatingSystems.Windows, IgnoreMessage = "The GAC is Windows-specific")] - public async Task UnusedReferenceFromGac() + public async Task UnusedReferenceFromGac(bool useSymbolAnalysis) { await RunMSBuildAsync( projectFile: "Library.csproj", expectedWarnings: new[] { new Warning("RT0001: Reference Microsoft.Office.Interop.Outlook can be removed", "Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] + [DataRow(false)] + [DataRow(true)] [OSCondition(OperatingSystems.Windows, IgnoreMessage = "The GAC is Windows-specific")] - public async Task UsedReferenceFromGac() + public async Task UsedReferenceFromGac(bool useSymbolAnalysis) { await RunMSBuildAsync( projectFile: "Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UsedPackageReference() + [DataRow(false)] + [DataRow(true)] + public Task UsedPackageReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UsedIndirectPackageReference() + [DataRow(false)] + [DataRow(true)] + public Task UsedIndirectPackageReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "WebHost/WebHost.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedPackageReference() + [DataRow(false)] + [DataRow(true)] + public Task UnusedPackageReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: new[] { new Warning("RT0003: PackageReference Newtonsoft.Json can be removed", "Library/Library.csproj") - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedPackageReferenceNoWarn() + [DataRow(false)] + [DataRow(true)] + public Task UnusedPackageReferenceDocDisabled(bool useSymbolAnalysis) { + // Default: RT0000 fires (doc generation disabled warning); the unused package is not detected + // because GetUsedAssemblyReferences is less accurate without doc generation. + // Symbol analysis: RT0003 fires (unused package correctly detected regardless of doc mode). return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: useSymbolAnalysis + ? new[] { new Warning("RT0003: PackageReference Newtonsoft.Json can be removed", "Library/Library.csproj") } + : new[] { new Warning("RT0000: Enable /doc parameter or in MSBuild set true for accuracy of used references detection", "Library/Library.csproj") }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedPackageReferenceTreatAsUsed() + [DataRow(false)] + [DataRow(true)] + public Task UnusedPackageReferenceNoWarn(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task UnusedPackageReferenceDocDisabled() + [DataRow(false)] + [DataRow(true)] + public Task UnusedPackageReferenceTreatAsUsed(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: new[] - { - new Warning("RT0000: Enable /doc parameter or in MSBuild set true for accuracy of used references detection", "Library/Library.csproj") - }); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task BuildPackageReference() + [DataRow(false)] + [DataRow(true)] + public Task BuildPackageReference(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task MissingReferenceSourceTarget() + [DataRow(false)] + [DataRow(true)] + public Task MissingReferenceSourceTarget(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task PlatformPackageConflictResolution() + [DataRow(false)] + [DataRow(true)] + public Task PlatformPackageConflictResolution(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", @@ -359,64 +502,86 @@ public Task PlatformPackageConflictResolution() { // TODO: These "metapackages" should not be reported. new Warning("RT0003: PackageReference NETStandard.Library can be removed", "Library/Library.csproj"), - }); + }, + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task NoTargets() + [DataRow(false)] + [DataRow(true)] + public Task NoTargets(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Project.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task TargetFrameworkWithOs() + [DataRow(false)] + [DataRow(true)] + public Task TargetFrameworkWithOs(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task AbsoluteIntermediateOutputPath() + [DataRow(false)] + [DataRow(true)] + public Task AbsoluteIntermediateOutputPath(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task BuildExtensions() + [DataRow(false)] + [DataRow(true)] + public Task BuildExtensions(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task ReferenceInPackage() + [DataRow(false)] + [DataRow(true)] + public Task ReferenceInPackage(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Tests/Tests.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task ReferenceTrimmerDisabled() + [DataRow(false)] + [DataRow(true)] + public Task ReferenceTrimmerDisabled(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] + [DataRow(false)] + [DataRow(true)] [OSCondition(OperatingSystems.Windows, IgnoreMessage = "This test only applies to Windows")] - public async Task LegacyStyleProject() + public async Task LegacyStyleProject(bool useSymbolAnalysis) { await RunMSBuildAsync( projectFile: "Library/Library.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] @@ -428,8 +593,8 @@ await RunMSBuildAsync( expectedWarnings: [], expectedConsoleOutputs: [ - "Unused libraries:", // Ensure link.exe unused lib flags are active - @"\user32.lib", // Tail of variable unused lib paths like "C:\Program Files (x86)\Windows Kits\10\lib\10.0.19041.0\um\x86\user32.lib" + "Unused libraries:", + @"\user32.lib", "Unused MSVC libraries detected in project", " * Default Windows SDK import libraries:", " - Libraries needed: ", @@ -447,10 +612,10 @@ await RunMSBuildAsync( expectedWarnings: [], expectedConsoleOutputs: [ - "Unused libraries:", // Ensure link.exe unused lib flags are active + "Unused libraries:", "Unused MSVC libraries detected in project", " * Other libraries - ", - @"\Library.lib", // Tail of variable unused lib paths like "C:\Program Files (x86)\Windows Kits\10\lib\10.0.19041.0\um\x86\user32.lib" + @"\Library.lib", ], expectUnusedMsvcLibrariesLog: true); } @@ -464,39 +629,48 @@ await RunMSBuildAsync( expectedWarnings: [], expectedConsoleOutputs: [ - "Unused libraries:", // Ensure link.exe unused lib flags are active + "Unused libraries:", "Unused MSVC libraries detected in project", " * Other libraries - ", - @"\DLL.lib", // Tail of variable unused lib paths like "C:\Program Files (x86)\Windows Kits\10\lib\10.0.19041.0\um\x86\user32.lib" + @"\DLL.lib", ], expectUnusedMsvcLibrariesLog: true); } [TestMethod] - public Task WpfApp() + [DataRow(false)] + [DataRow(true)] + public Task WpfApp(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "WpfApp/WpfApp.csproj", - expectedWarnings: []); + expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public Task PackageReferenceWithFakeBuildFile() + [DataRow(false)] + [DataRow(true)] + public Task PackageReferenceWithFakeBuildFile(bool useSymbolAnalysis) { return RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: [ new Warning("RT0003: PackageReference Microsoft.Extensions.Primitives can be removed", "Library/Library.csproj"), - ]); + ], + useSymbolAnalysis: useSymbolAnalysis); } [TestMethod] - public async Task IgnorePackageBuildFiles() + [DataRow(false)] + [DataRow(true)] + public async Task IgnorePackageBuildFiles(bool useSymbolAnalysis) { await RunMSBuildAsync( projectFile: "Library/Library.csproj", expectedWarnings: [], + useSymbolAnalysis: useSymbolAnalysis, globalProperties: new Dictionary { { "IgnorePackageBuildFiles", "false" }, @@ -508,6 +682,7 @@ await RunMSBuildAsync( [ new Warning("RT0003: PackageReference Microsoft.Extensions.Logging can be removed", "Library/Library.csproj"), ], + useSymbolAnalysis: useSymbolAnalysis, globalProperties: new Dictionary { { "IgnorePackageBuildFiles", "true" }, @@ -643,6 +818,7 @@ private async Task RunMSBuildAsync( string[]? expectedConsoleOutputs = null, bool expectUnusedMsvcLibrariesLog = false, bool enableReferenceTrimmerDiagnostics = false, + bool useSymbolAnalysis = false, IReadOnlyDictionary? globalProperties = null) { var testDataSourcePath = Path.GetFullPath(Path.Combine("TestData", TestContext?.TestName ?? string.Empty)); @@ -668,7 +844,8 @@ private async Task RunMSBuildAsync( $"-flp1:logfile=\"{errorsFilePath}\";errorsonly " + $"-flp2:logfile=\"{warningsFilePath}\";warningsonly " + $"-distributedlogger:CentralLogger,\"{loggersAssemblyPath}\"*ForwardingLogger,\"{loggersAssemblyPath}\" " + - (enableReferenceTrimmerDiagnostics ? "-p:EnableReferenceTrimmerDiagnostics=true" : string.Empty); + (enableReferenceTrimmerDiagnostics ? "-p:EnableReferenceTrimmerDiagnostics=true " : string.Empty) + + (useSymbolAnalysis ? "-p:ReferenceTrimmerUseSymbolAnalysis=true " : string.Empty); if (globalProperties is not null) { diff --git a/src/Tests/ReferenceTrimmer.Tests.csproj b/src/Tests/ReferenceTrimmer.Tests.csproj index 4b62671..3548cf8 100644 --- a/src/Tests/ReferenceTrimmer.Tests.csproj +++ b/src/Tests/ReferenceTrimmer.Tests.csproj @@ -7,12 +7,16 @@ $(DefaultItemExcludes);TestData/**;TestResults/** + false Build;Pack + + + PreserveNewest diff --git a/src/Tests/TestData/Directory.Build.props b/src/Tests/TestData/Directory.Build.props index 4b8e07a..dfe3f08 100644 --- a/src/Tests/TestData/Directory.Build.props +++ b/src/Tests/TestData/Directory.Build.props @@ -1,7 +1,9 @@ - + true $(NoWarn);1591 diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.cs b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.cs new file mode 100644 index 0000000..1c8dcae --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.cs @@ -0,0 +1,8 @@ +namespace Dependency +{ + public static class Foo + { + // Dependency actually USES TransitiveDependency + public static string Bar() => TransitiveDependency.Foo.Baz(); + } +} diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.csproj b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.csproj new file mode 100644 index 0000000..ad84b02 --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Dependency/Dependency.csproj @@ -0,0 +1,9 @@ + + + Library + net472 + + + + + diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.cs b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.cs new file mode 100644 index 0000000..14c8eee --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.cs @@ -0,0 +1,8 @@ +namespace Library +{ + public static class Foo + { + // Library uses Dependency but does NOT use TransitiveDependency directly + public static string Bar() => Dependency.Foo.Bar(); + } +} diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.csproj b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.csproj new file mode 100644 index 0000000..a7a5dd3 --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/Library/Library.csproj @@ -0,0 +1,13 @@ + + + Library + net472 + + + + + + + diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.cs b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.cs new file mode 100644 index 0000000..47bdbe0 --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.cs @@ -0,0 +1,7 @@ +namespace TransitiveDependency +{ + public static class Foo + { + public static string Baz() => "Baz"; + } +} diff --git a/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.csproj b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.csproj new file mode 100644 index 0000000..4505da5 --- /dev/null +++ b/src/Tests/TestData/UnusedDirectReferenceUsedTransitively/TransitiveDependency/TransitiveDependency.csproj @@ -0,0 +1,6 @@ + + + Library + net472 + + diff --git a/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.cs b/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.cs new file mode 100644 index 0000000..e590b16 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.cs @@ -0,0 +1,7 @@ +namespace Dependency +{ + public static class Foo + { + public static string Bar() => "Baz"; + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.csproj b/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.csproj new file mode 100644 index 0000000..33af96b --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceCref/Dependency/Dependency.csproj @@ -0,0 +1,8 @@ + + + + Library + net472 + + + diff --git a/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.cs b/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.cs new file mode 100644 index 0000000..7d44745 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.cs @@ -0,0 +1,13 @@ +namespace Library +{ + public static class Bar + { + /// + /// See for details. + /// + /// + /// Also references . + /// + public static string GetName() => "bar"; + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.csproj b/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.csproj new file mode 100644 index 0000000..077c6a3 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceCref/Library/Library.csproj @@ -0,0 +1,12 @@ + + + + Library + net472 + + + + + + + diff --git a/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.cs b/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.cs new file mode 100644 index 0000000..e590b16 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.cs @@ -0,0 +1,7 @@ +namespace Dependency +{ + public static class Foo + { + public static string Bar() => "Baz"; + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.csproj b/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.csproj new file mode 100644 index 0000000..33af96b --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceNameof/Dependency/Dependency.csproj @@ -0,0 +1,8 @@ + + + + Library + net472 + + + diff --git a/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.cs b/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.cs new file mode 100644 index 0000000..ec53fa6 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.cs @@ -0,0 +1,9 @@ +namespace Library +{ + public static class Bar + { + // Dependency.Foo used only in nameof() — lowered to a string literal in the IOperation tree. + // Only the syntax-level nameof handler catches this. + public static string GetName() => nameof(Dependency.Foo); + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.csproj b/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.csproj new file mode 100644 index 0000000..077c6a3 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceNameof/Library/Library.csproj @@ -0,0 +1,12 @@ + + + + Library + net472 + + + + + + + diff --git a/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.cs b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.cs new file mode 100644 index 0000000..d9d54e5 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.cs @@ -0,0 +1,6 @@ +namespace Dependency +{ + public class Foo + { + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.csproj b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.csproj new file mode 100644 index 0000000..33af96b --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Dependency/Dependency.csproj @@ -0,0 +1,8 @@ + + + + Library + net472 + + + diff --git a/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.cs b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.cs new file mode 100644 index 0000000..a9723f4 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.cs @@ -0,0 +1,22 @@ +namespace Library +{ + public static class Bar + { + // Dependency.Foo used only in switch expression type pattern (ISwitchExpressionArmOperation) + public static string CategorizeExpr(object obj) => obj switch + { + Dependency.Foo => "foo", + _ => "other" + }; + + // Dependency.Foo used only in switch case clause pattern (IPatternCaseClauseOperation) + public static string CategorizeStmt(object obj) + { + switch (obj) + { + case Dependency.Foo _: return "foo"; + default: return "other"; + } + } + } +} diff --git a/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.csproj b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.csproj new file mode 100644 index 0000000..4309748 --- /dev/null +++ b/src/Tests/TestData/UsedProjectReferenceSwitchPattern/Library/Library.csproj @@ -0,0 +1,13 @@ + + + + Library + net472 + latest + + + + + + + diff --git a/version.json b/version.json index a48c5f6..d1037aa 100644 --- a/version.json +++ b/version.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/AArnott/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.4", - "assemblyVersion": "3.4", + "version": "3.5", + "assemblyVersion": "3.5", "buildNumberOffset": -1, "publicReleaseRefSpec": [ "^refs/tags/v\\d+\\.\\d+\\.\\d+"