From ca7ecac1c7534865f0a0f9d2e5f3b29d6b3f69c1 Mon Sep 17 00:00:00 2001
From: David Federman <david.federman@outlook.com>
Date: Mon, 30 Sep 2024 21:22:50 -0700
Subject: [PATCH] Add support for TreatAsUsed metadata (#104)

---
 src/Tasks/CollectDeclaredReferencesTask.cs    | 40 ++++++++++++++++--
 src/Tests/E2ETests.cs                         | 42 +++++++++++++++++++
 .../Library/Library.csproj                    |  2 +-
 .../Library/Library.cs                        |  7 ++++
 .../Library/Library.csproj                    | 12 ++++++
 .../Library/Library.csproj                    |  2 +-
 .../Dependency/Dependency.cs                  |  7 ++++
 .../Dependency/Dependency.csproj              |  8 ++++
 .../Library/Library.cs                        |  7 ++++
 .../Library/Library.csproj                    | 12 ++++++
 .../Library/Library.csproj                    |  2 +-
 .../Dependency/Dependency.cs                  |  7 ++++
 .../Dependency/Dependency.csproj              |  8 ++++
 .../Library/Library.cs                        |  7 ++++
 .../Library/Library.csproj                    | 15 +++++++
 .../Library/Library.csproj                    |  2 +-
 .../Dependency/Dependency.cs                  |  7 ++++
 .../Dependency/Dependency.csproj              |  8 ++++
 .../Library/Library.cs                        |  7 ++++
 .../Library/Library.csproj                    | 12 ++++++
 20 files changed, 207 insertions(+), 7 deletions(-)
 create mode 100644 src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.cs
 create mode 100644 src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.csproj
 create mode 100644 src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.cs
 create mode 100644 src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.csproj
 create mode 100644 src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.cs
 create mode 100644 src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.csproj
 create mode 100644 src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.cs
 create mode 100644 src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.csproj
 create mode 100644 src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.cs
 create mode 100644 src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.csproj
 create mode 100644 src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.cs
 create mode 100644 src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.csproj
 create mode 100644 src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.cs
 create mode 100644 src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.csproj

diff --git a/src/Tasks/CollectDeclaredReferencesTask.cs b/src/Tasks/CollectDeclaredReferencesTask.cs
index 247acc5..0c38cf7 100644
--- a/src/Tasks/CollectDeclaredReferencesTask.cs
+++ b/src/Tasks/CollectDeclaredReferencesTask.cs
@@ -24,6 +24,7 @@ public sealed class CollectDeclaredReferencesTask : MSBuildTask
     };
 
     private const string NoWarn = "NoWarn";
+    private const string TreatAsUsed = "TreatAsUsed";
 
     [Required]
     public string? OutputFile { get; set; }
@@ -86,7 +87,7 @@ public override bool Execute()
                     }
 
                     // Ignore suppressions
-                    if (reference.GetMetadata(NoWarn).Contains("RT0001"))
+                    if (IsSuppressed(reference, "RT0001"))
                     {
                         continue;
                     }
@@ -130,7 +131,7 @@ public override bool Execute()
                 foreach (ITaskItem projectReference in ProjectReferences)
                 {
                     // Ignore suppressions
-                    if (projectReference.GetMetadata(NoWarn).Contains("RT0002"))
+                    if (IsSuppressed(projectReference, "RT0002"))
                     {
                         continue;
                     }
@@ -158,7 +159,7 @@ public override bool Execute()
                 foreach (ITaskItem packageReference in PackageReferences)
                 {
                     // Ignore suppressions
-                    if (packageReference.GetMetadata(NoWarn).Contains("RT0003"))
+                    if (IsSuppressed(packageReference, "RT0003"))
                     {
                         continue;
                     }
@@ -375,6 +376,39 @@ private HashSet<string> GetTargetFrameworkAssemblyNames()
         return null;
     }
 
+    private static bool IsSuppressed(ITaskItem item, string warningId)
+    {
+        ReadOnlySpan<char> warningIdSpan = warningId.AsSpan();
+        ReadOnlySpan<char> remainingNoWarn = item.GetMetadata(NoWarn).AsSpan();
+        while (!remainingNoWarn.IsEmpty)
+        {
+            ReadOnlySpan<char> currentNoWarn;
+            int idx = remainingNoWarn.IndexOf(';');
+            if (idx == -1)
+            {
+                currentNoWarn = remainingNoWarn;
+                remainingNoWarn = ReadOnlySpan<char>.Empty;
+            }
+            else
+            {
+                currentNoWarn = remainingNoWarn.Slice(0, idx);
+                remainingNoWarn = remainingNoWarn.Slice(idx + 1);
+            }
+
+            if (currentNoWarn.Trim().Equals(warningIdSpan, StringComparison.OrdinalIgnoreCase))
+            {
+                return true;
+            }
+        }
+
+        if (item.GetMetadata(TreatAsUsed).Equals("True", StringComparison.OrdinalIgnoreCase))
+        {
+            return true;
+        }
+
+        return false;
+    }
+
     private sealed class PackageInfoBuilder
     {
         private List<string>? _compileTimeAssemblies;
diff --git a/src/Tests/E2ETests.cs b/src/Tests/E2ETests.cs
index 7107775..a54e692 100644
--- a/src/Tests/E2ETests.cs
+++ b/src/Tests/E2ETests.cs
@@ -96,6 +96,14 @@ public Task UnusedProjectReferenceNoWarn()
             expectedWarnings: []);
     }
 
+    [TestMethod]
+    public Task UnusedProjectReferenceTreatAsUsed()
+    {
+        return RunMSBuildAsync(
+            projectFile: "Library/Library.csproj",
+            expectedWarnings: Array.Empty<Warning>());
+    }
+
     [TestMethod]
     public Task UnusedProjectReferenceSuppressed()
     {
@@ -184,6 +192,19 @@ await RunMSBuildAsync(
             expectedWarnings: []);
     }
 
+    [TestMethod]
+    public async Task UnusedReferenceHintPathTreatAsUsed()
+    {
+        // 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<Warning>());
+
+        await RunMSBuildAsync(
+            projectFile: "Library/Library.csproj",
+            expectedWarnings: Array.Empty<Warning>());
+    }
+
     [TestMethod]
     public async Task UnusedReferenceItemSpec()
     {
@@ -222,6 +243,19 @@ await RunMSBuildAsync(
             expectedWarnings: []);
     }
 
+    [TestMethod]
+    public async Task UnusedReferenceItemSpecTreatAsUsed()
+    {
+        // 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<Warning>());
+
+        await RunMSBuildAsync(
+            projectFile: "Library/Library.csproj",
+            expectedWarnings: Array.Empty<Warning>());
+    }
+
     [TestMethod]
     public async Task UnusedReferenceFromGac()
     {
@@ -288,6 +322,14 @@ public Task UnusedPackageReferenceNoWarn()
             expectedWarnings: []);
     }
 
+    [TestMethod]
+    public Task UnusedPackageReferenceTreatAsUsed()
+    {
+        return RunMSBuildAsync(
+            projectFile: "Library/Library.csproj",
+            expectedWarnings: []);
+    }
+
     [TestMethod]
     public Task UnusedPackageReferenceDocDisabled()
     {
diff --git a/src/Tests/TestData/UnusedPackageReferenceNoWarn/Library/Library.csproj b/src/Tests/TestData/UnusedPackageReferenceNoWarn/Library/Library.csproj
index 3261f14..d500a3a 100644
--- a/src/Tests/TestData/UnusedPackageReferenceNoWarn/Library/Library.csproj
+++ b/src/Tests/TestData/UnusedPackageReferenceNoWarn/Library/Library.csproj
@@ -6,7 +6,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Newtonsoft.Json" Version="13.0.2" NoWarn="RT0003"/>
+    <PackageReference Include="Newtonsoft.Json" Version="13.0.2" NoWarn="Foo; rt0003 ; Bar"/>
   </ItemGroup>
 
 </Project>
diff --git a/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.cs b/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.cs
new file mode 100644
index 0000000..958c389
--- /dev/null
+++ b/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.cs
@@ -0,0 +1,7 @@
+namespace Library
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.csproj b/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.csproj
new file mode 100644
index 0000000..8ce2a5f
--- /dev/null
+++ b/src/Tests/TestData/UnusedPackageReferenceTreatAsUsed/Library/Library.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Newtonsoft.Json" Version="13.0.2" TreatAsUsed="true"/>
+  </ItemGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedProjectReferenceNoWarn/Library/Library.csproj b/src/Tests/TestData/UnusedProjectReferenceNoWarn/Library/Library.csproj
index 7787015..f9c6303 100644
--- a/src/Tests/TestData/UnusedProjectReferenceNoWarn/Library/Library.csproj
+++ b/src/Tests/TestData/UnusedProjectReferenceNoWarn/Library/Library.csproj
@@ -6,7 +6,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <ProjectReference Include="../Dependency/Dependency.csproj" NoWarn="RT0002" />
+    <ProjectReference Include="../Dependency/Dependency.csproj" NoWarn="Foo; rt0002 ; Bar" />
   </ItemGroup>
 
 </Project>
diff --git a/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.cs b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.cs
new file mode 100644
index 0000000..ad07022
--- /dev/null
+++ b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.cs
@@ -0,0 +1,7 @@
+namespace Dependency
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.csproj b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.csproj
new file mode 100644
index 0000000..33af96b
--- /dev/null
+++ b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Dependency/Dependency.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.cs b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.cs
new file mode 100644
index 0000000..958c389
--- /dev/null
+++ b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.cs
@@ -0,0 +1,7 @@
+namespace Library
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.csproj b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.csproj
new file mode 100644
index 0000000..6d2174e
--- /dev/null
+++ b/src/Tests/TestData/UnusedProjectReferenceTreatAsUsed/Library/Library.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="../Dependency/Dependency.csproj" TreatAsUsed="true" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedReferenceHintPathNoWarn/Library/Library.csproj b/src/Tests/TestData/UnusedReferenceHintPathNoWarn/Library/Library.csproj
index d7c55f5..f1c7e5f 100644
--- a/src/Tests/TestData/UnusedReferenceHintPathNoWarn/Library/Library.csproj
+++ b/src/Tests/TestData/UnusedReferenceHintPathNoWarn/Library/Library.csproj
@@ -8,7 +8,7 @@
   <ItemGroup>
     <Reference Include="Dependency">
       <HintPath>..\Dependency\$(OutputPath)\Dependency.dll</HintPath>
-      <NoWarn>RT0001</NoWarn>
+      <NoWarn>Foo; rt0001 ; Bar</NoWarn>
     </Reference>
   </ItemGroup>
 
diff --git a/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.cs b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.cs
new file mode 100644
index 0000000..ad07022
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.cs
@@ -0,0 +1,7 @@
+namespace Dependency
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.csproj b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.csproj
new file mode 100644
index 0000000..33af96b
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Dependency/Dependency.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.cs b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.cs
new file mode 100644
index 0000000..958c389
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.cs
@@ -0,0 +1,7 @@
+namespace Library
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.csproj b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.csproj
new file mode 100644
index 0000000..30c1c99
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceHintPathTreatAsUsed/Library/Library.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Dependency">
+      <HintPath>..\Dependency\$(OutputPath)\Dependency.dll</HintPath>
+      <TreatAsUsed>true</TreatAsUsed>
+    </Reference>
+  </ItemGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedReferenceItemSpecNoWarn/Library/Library.csproj b/src/Tests/TestData/UnusedReferenceItemSpecNoWarn/Library/Library.csproj
index 19146d3..0162fd3 100644
--- a/src/Tests/TestData/UnusedReferenceItemSpecNoWarn/Library/Library.csproj
+++ b/src/Tests/TestData/UnusedReferenceItemSpecNoWarn/Library/Library.csproj
@@ -6,7 +6,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <Reference Include="..\Dependency\$(OutputPath)\Dependency.dll" NoWarn="RT0001"/>
+    <Reference Include="..\Dependency\$(OutputPath)\Dependency.dll" NoWarn="Foo; rt0001 ; Bar"/>
   </ItemGroup>
 
 </Project>
diff --git a/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.cs b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.cs
new file mode 100644
index 0000000..ad07022
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.cs
@@ -0,0 +1,7 @@
+namespace Dependency
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.csproj b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.csproj
new file mode 100644
index 0000000..33af96b
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Dependency/Dependency.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+</Project>
diff --git a/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.cs b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.cs
new file mode 100644
index 0000000..958c389
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.cs
@@ -0,0 +1,7 @@
+namespace Library
+{
+    public static class Foo
+    {
+        public static string Bar() => "Baz";
+    }
+}
diff --git a/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.csproj b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.csproj
new file mode 100644
index 0000000..660dedd
--- /dev/null
+++ b/src/Tests/TestData/UnusedReferenceItemSpecTreatAsUsed/Library/Library.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Library</OutputType>
+    <TargetFramework>net472</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="..\Dependency\$(OutputPath)\Dependency.dll" TreatAsUsed="true" />
+  </ItemGroup>
+
+</Project>