diff --git a/.gitignore b/.gitignore
index 77b3f03dc0e..43f47c9e72a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ artifacts/
.dotnet/
.tools/
.packages/
+BenchmarkDotNet.Artifacts/
# Visual Studio 2015 cache/options directory
.vs/
diff --git a/MSBuild.Dev.slnf b/MSBuild.Dev.slnf
index b16cb4e7346..c19f4aca995 100644
--- a/MSBuild.Dev.slnf
+++ b/MSBuild.Dev.slnf
@@ -10,6 +10,7 @@
"src\\Framework\\Microsoft.Build.Framework.csproj",
"src\\MSBuild.UnitTests\\Microsoft.Build.CommandLine.UnitTests.csproj",
"src\\MSBuild\\MSBuild.csproj",
+ "src\\MSBuild.Benchmarks\\MSBuild.Benchmarks.csproj",
"src\\StringTools\\StringTools.csproj",
"src\\Tasks.UnitTests\\Microsoft.Build.Tasks.UnitTests.csproj",
"src\\Tasks\\Microsoft.Build.Tasks.csproj",
diff --git a/MSBuild.slnx b/MSBuild.slnx
index 405c0641e55..a0d0af13a75 100644
--- a/MSBuild.slnx
+++ b/MSBuild.slnx
@@ -67,6 +67,10 @@
+
+
+
+
diff --git a/eng/dependabot/Directory.Packages.props b/eng/dependabot/Directory.Packages.props
index fc8641a4ad9..a105a42d060 100644
--- a/eng/dependabot/Directory.Packages.props
+++ b/eng/dependabot/Directory.Packages.props
@@ -10,9 +10,12 @@
these properties to override package versions if necessary. -->
-
+
+
+
+
diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs
index 6cba67e231c..1cfaff635a1 100644
--- a/src/Build/BackEnd/BuildManager/BuildManager.cs
+++ b/src/Build/BackEnd/BuildManager/BuildManager.cs
@@ -1105,6 +1105,7 @@ public void EndBuild()
}
TaskRouter.ClearCache();
+ ItemSpecModifiers.ClearDefiningProjectCache();
}
catch (Exception e)
{
diff --git a/src/Build/Definition/BuiltInMetadata.cs b/src/Build/Definition/BuiltInMetadata.cs
index b93845b4b3b..d8cf8babc57 100644
--- a/src/Build/Definition/BuiltInMetadata.cs
+++ b/src/Build/Definition/BuiltInMetadata.cs
@@ -2,7 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
-using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
@@ -19,22 +19,12 @@ internal static class BuiltInMetadata
///
/// Retrieves the count of built-in metadata.
///
- internal static int MetadataCount
- {
- [DebuggerStepThrough]
- get
- { return ItemSpecModifiers.All.Length; }
- }
+ internal static int MetadataCount => ItemSpecModifiers.All.Length;
///
/// Retrieves the list of metadata names.
///
- internal static ICollection MetadataNames
- {
- [DebuggerStepThrough]
- get
- { return ItemSpecModifiers.All; }
- }
+ internal static ImmutableArray MetadataNames => ItemSpecModifiers.All;
///
/// Retrieves a built-in metadata value and caches it.
@@ -48,45 +38,53 @@ internal static ICollection MetadataNames
/// The evaluated include for the item.
/// The path to the project that defined this item
/// The name of the metadata.
- /// The generated full path, for caching
+ /// The generated full path, for caching
/// The unescaped metadata value.
- internal static string GetMetadataValue(string currentDirectory, string evaluatedIncludeBeforeWildcardExpansionEscaped, string evaluatedIncludeEscaped, string definingProjectEscaped, string name, ref string fullPath)
- {
- return EscapingUtilities.UnescapeAll(GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, name, ref fullPath));
- }
+ internal static string GetMetadataValue(
+ string currentDirectory,
+ string evaluatedIncludeBeforeWildcardExpansionEscaped,
+ string evaluatedIncludeEscaped,
+ string definingProjectEscaped,
+ string name,
+ ref ItemSpecModifiers.Cache cache)
+ => EscapingUtilities.UnescapeAll(GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, name, ref cache));
///
- /// Retrieves a built-in metadata value and caches it.
+ /// Retrieves a built-in metadata value, caching derivable results in the provided per-item cache.
/// If value is not available, returns empty string.
///
- ///
- /// The current directory for evaluation. Null if this is being called from a task, otherwise
- /// it should be the project's directory.
- ///
- /// The evaluated include prior to wildcard expansion.
- /// The evaluated include for the item.
- /// The path to the project that defined this item
- /// The name of the metadata.
- /// The generated full path, for caching
- /// The escaped as necessary metadata value.
- internal static string GetMetadataValueEscaped(string currentDirectory, string evaluatedIncludeBeforeWildcardExpansionEscaped, string evaluatedIncludeEscaped, string definingProjectEscaped, string name, ref string fullPath)
+ internal static string GetMetadataValueEscaped(
+ string currentDirectory,
+ string evaluatedIncludeBeforeWildcardExpansionEscaped,
+ string evaluatedIncludeEscaped,
+ string definingProjectEscaped,
+ string name,
+ ref ItemSpecModifiers.Cache cache)
{
- // This is an assert, not a VerifyThrow, because the caller should already have done this check, and it's slow/hot.
- Debug.Assert(ItemSpecModifiers.IsItemSpecModifier(name));
-
- string value;
- if (String.Equals(name, ItemSpecModifiers.RecursiveDir, StringComparison.OrdinalIgnoreCase))
+ if (ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind))
{
- value = GetRecursiveDirValue(evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped);
- }
- else
- {
- value = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, evaluatedIncludeEscaped, definingProjectEscaped, name, ref fullPath);
+ return GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, modifierKind, ref cache);
}
- return value;
+ Debug.Fail($"Expected a valid item-spec modifier, got \"{name}\".");
+ return string.Empty;
}
+ ///
+ /// Retrieves a built-in metadata value, caching derivable results in the provided per-item cache.
+ /// If value is not available, returns empty string.
+ ///
+ internal static string GetMetadataValueEscaped(
+ string currentDirectory,
+ string evaluatedIncludeBeforeWildcardExpansionEscaped,
+ string evaluatedIncludeEscaped,
+ string definingProjectEscaped,
+ ItemSpecModifierKind modifierKind,
+ ref ItemSpecModifiers.Cache cache)
+ => modifierKind is ItemSpecModifierKind.RecursiveDir
+ ? GetRecursiveDirValue(evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped)
+ : ItemSpecModifiers.GetItemSpecModifier(evaluatedIncludeEscaped, modifierKind, currentDirectory, definingProjectEscaped, ref cache);
+
///
/// Extract the value for "RecursiveDir", if any, from the Include.
/// If there is none, returns an empty string.
diff --git a/src/Build/Definition/ProjectItem.cs b/src/Build/Definition/ProjectItem.cs
index 19c954401c2..2e07889568b 100644
--- a/src/Build/Definition/ProjectItem.cs
+++ b/src/Build/Definition/ProjectItem.cs
@@ -100,9 +100,9 @@ public class ProjectItem : IItem, IProjectMetadataParent, IItem
private PropertyDictionary _directMetadata;
///
- /// Cached value of the fullpath metadata. All other metadata are computed on demand.
+ /// Cached values of derivable item-spec modifiers. All time-based metadata are computed on demand.
///
- private string _fullPath;
+ private ItemSpecModifiers.Cache _cachedModifiers;
///
/// External projects support
@@ -299,12 +299,7 @@ public ICollection Metadata
/// Includes any metadata inherited from item definitions.
/// Includes both custom and built-in metadata.
///
- public int MetadataCount
- {
- [DebuggerStepThrough]
- get
- { return Metadata.Count + ItemSpecModifiers.All.Length; }
- }
+ public int MetadataCount => Metadata.Count + ItemSpecModifiers.All.Length;
///
/// Implementation of IKeyed exposing the item type, so items
@@ -700,7 +695,7 @@ public void Rename(string name)
return;
}
- _fullPath = null; // Clear cached value
+ _cachedModifiers.Clear(); // Clear cached values
if (_xml.Count == 0 /* no metadata */ && _project.IsSuitableExistingItemXml(_xml, name, null /* no metadata */) && !FileMatcher.HasWildcardsSemicolonItemOrPropertyReferences(name))
{
@@ -854,16 +849,9 @@ internal void SplitOwnItemElement()
/// the specified name, if any.
///
private string GetBuiltInMetadataEscaped(string name)
- {
- string value = null;
-
- if (ItemSpecModifiers.IsItemSpecModifier(name))
- {
- value = BuiltInMetadata.GetMetadataValueEscaped(_project.DirectoryPath, _evaluatedIncludeBeforeWildcardExpansionEscaped, _evaluatedIncludeEscaped, this.Xml.ContainingProject.FullPath, name, ref _fullPath);
- }
-
- return value;
- }
+ => ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind)
+ ? BuiltInMetadata.GetMetadataValueEscaped(_project.DirectoryPath, _evaluatedIncludeBeforeWildcardExpansionEscaped, _evaluatedIncludeEscaped, Xml.ContainingProject.FullPath, modifierKind, ref _cachedModifiers)
+ : null;
///
/// Retrieves the named metadata from the item definition, if any.
diff --git a/src/Build/Evaluation/Expander.cs b/src/Build/Evaluation/Expander.cs
index f9fc77ec5a6..64a1ce461cf 100644
--- a/src/Build/Evaluation/Expander.cs
+++ b/src/Build/Evaluation/Expander.cs
@@ -2560,7 +2560,7 @@ internal static void ItemSpecModifierFunction(IElementLocation elementLocation,
string directoryToUse = item.Value.ProjectDirectory ?? FileUtilities.CurrentThreadWorkingDirectory ?? Directory.GetCurrentDirectory();
string definingProjectEscaped = item.Value.GetMetadataValueEscaped(ItemSpecModifiers.DefiningProjectFullPath);
- result = ItemSpecModifiers.GetItemSpecModifier(directoryToUse, item.Key, definingProjectEscaped, functionName);
+ result = ItemSpecModifiers.GetItemSpecModifier(item.Key, functionName, directoryToUse, definingProjectEscaped);
}
// InvalidOperationException is how GetItemSpecModifier communicates invalid conditions upwards, so
// we do not want to rethrow in that case.
@@ -3328,7 +3328,7 @@ private static string GetMetadataValueFromMatch(
string directoryToUse = sourceOfMetadata.ProjectDirectory ?? FileUtilities.CurrentThreadWorkingDirectory ?? Directory.GetCurrentDirectory();
string definingProjectEscaped = sourceOfMetadata.GetMetadataValueEscaped(ItemSpecModifiers.DefiningProjectFullPath);
- value = ItemSpecModifiers.GetItemSpecModifier(directoryToUse, itemSpec, definingProjectEscaped, match.Name);
+ value = ItemSpecModifiers.GetItemSpecModifier(itemSpec, match.Name, directoryToUse, definingProjectEscaped);
}
else
{
diff --git a/src/Build/Instance/ProjectItemInstance.cs b/src/Build/Instance/ProjectItemInstance.cs
index b8a70ceac25..0550a44977a 100644
--- a/src/Build/Instance/ProjectItemInstance.cs
+++ b/src/Build/Instance/ProjectItemInstance.cs
@@ -817,9 +817,9 @@ internal sealed class TaskItem :
private IReadOnlyDictionary _directMetadata;
///
- /// Cached value of the fullpath metadata. All other metadata are computed on demand.
+ /// Cached values of derivable item-spec modifiers. All time-based metadata are computed on demand.
///
- private string _fullPath;
+ private ItemSpecModifiers.Cache _cachedModifiers;
///
/// All the item definitions that apply to this item, in order of
@@ -893,7 +893,7 @@ private TaskItem(TaskItem source, bool addOriginalItemSpec)
_includeEscaped = source._includeEscaped;
_includeBeforeWildcardExpansionEscaped = source._includeBeforeWildcardExpansionEscaped;
source.CopyMetadataTo(this, addOriginalItemSpec);
- _fullPath = source._fullPath;
+ _cachedModifiers = source._cachedModifiers;
_definingFileEscaped = source._definingFileEscaped;
}
@@ -936,7 +936,7 @@ public string ItemSpec
ErrorUtilities.VerifyThrowArgumentNull(value, "ItemSpec");
_includeEscaped = value;
- _fullPath = null; // Clear cached value
+ _cachedModifiers.Clear(); // Clear cached values
}
}
@@ -981,7 +981,10 @@ public ICollection MetadataNames
names.Add(metadatum.Key);
}
- names.AddRange(ItemSpecModifiers.All);
+ foreach (string name in ItemSpecModifiers.All)
+ {
+ names.Add(name);
+ }
return names;
}
@@ -1054,7 +1057,7 @@ internal string IncludeEscaped
ErrorUtilities.VerifyThrowArgumentLength(value, "IncludeEscaped");
_includeEscaped = value;
- _fullPath = null; // Clear cached value
+ _cachedModifiers.Clear(); // Clear cached values
}
}
@@ -2081,16 +2084,9 @@ internal TaskItem DeepClone(bool isImmutable)
/// If value is not available, returns empty string.
///
private string GetBuiltInMetadataEscaped(string name)
- {
- string value = String.Empty;
-
- if (ItemSpecModifiers.IsItemSpecModifier(name))
- {
- value = BuiltInMetadata.GetMetadataValueEscaped(_projectDirectory, _includeBeforeWildcardExpansionEscaped, _includeEscaped, _definingFileEscaped, name, ref _fullPath);
- }
-
- return value;
- }
+ => ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind)
+ ? BuiltInMetadata.GetMetadataValueEscaped(_projectDirectory, _includeBeforeWildcardExpansionEscaped, _includeEscaped, _definingFileEscaped, modifierKind, ref _cachedModifiers)
+ : string.Empty;
///
/// Retrieves the named metadata from the item definition, if any.
diff --git a/src/Build/Instance/ProjectMetadataInstance.cs b/src/Build/Instance/ProjectMetadataInstance.cs
index 548f35ff5fd..a37a6f5e3c5 100644
--- a/src/Build/Instance/ProjectMetadataInstance.cs
+++ b/src/Build/Instance/ProjectMetadataInstance.cs
@@ -241,12 +241,10 @@ internal static void VerifyThrowReservedName(string name)
// PERF: This sequence of checks is faster than a full HashSet lookup since finding a match is an error case.
// Otherwise, many keys would still match to a bucket and begin a string comparison.
VerifyThrowReservedNameAllowItemSpecModifiers(name);
- foreach (string itemSpecModifier in ItemSpecModifiers.All)
+
+ if (ItemSpecModifiers.IsItemSpecModifier(name))
{
- if (itemSpecModifier.Length == name.Length && itemSpecModifier[0] == char.ToUpperInvariant(name[0]))
- {
- ErrorUtilities.VerifyThrowArgument(!MSBuildNameIgnoreCaseComparer.Default.Equals(itemSpecModifier, name), "OM_ReservedName", name);
- }
+ ErrorUtilities.ThrowArgument("OM_ReservedName", name);
}
}
diff --git a/src/Framework.UnitTests/FileUtilities_Tests.cs b/src/Framework.UnitTests/FileUtilities_Tests.cs
index 6e8965ade92..a69c632765b 100644
--- a/src/Framework.UnitTests/FileUtilities_Tests.cs
+++ b/src/Framework.UnitTests/FileUtilities_Tests.cs
@@ -32,56 +32,56 @@ public void GetItemSpecModifier()
private static void TestGetItemSpecModifier(string currentDirectory)
{
- string cache = null;
- string modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, "foo", String.Empty, ItemSpecModifiers.RecursiveDir, ref cache);
+ ItemSpecModifiers.Cache cache = default;
+ string modifier = ItemSpecModifiers.GetItemSpecModifier("foo", ItemSpecModifierKind.RecursiveDir, currentDirectory, String.Empty, ref cache);
Assert.Equal(String.Empty, modifier);
- cache = null;
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, "foo", String.Empty, ItemSpecModifiers.ModifiedTime, ref cache);
+ cache.Clear();
+ modifier = ItemSpecModifiers.GetItemSpecModifier("foo", ItemSpecModifierKind.ModifiedTime, currentDirectory, String.Empty, ref cache);
Assert.Equal(String.Empty, modifier);
- cache = null;
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"foo\goo", String.Empty, ItemSpecModifiers.RelativeDir, ref cache);
+ cache.Clear();
+ modifier = ItemSpecModifiers.GetItemSpecModifier(@"foo\goo", ItemSpecModifierKind.RelativeDir, currentDirectory, String.Empty, ref cache);
Assert.Equal(@"foo" + Path.DirectorySeparatorChar, modifier);
// confirm we get the same thing back the second time
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"foo\goo", String.Empty, ItemSpecModifiers.RelativeDir, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(@"foo\goo", ItemSpecModifierKind.RelativeDir, currentDirectory, String.Empty, ref cache);
Assert.Equal(@"foo" + Path.DirectorySeparatorChar, modifier);
- cache = null;
+ cache.Clear();
string itemSpec = NativeMethodsShared.IsWindows ? @"c:\foo.txt" : "/foo.txt";
string itemSpecDir = NativeMethodsShared.IsWindows ? @"c:\" : "/";
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.FullPath, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.FullPath, currentDirectory, String.Empty, ref cache);
Assert.Equal(itemSpec, modifier);
- Assert.Equal(itemSpec, cache);
+ Assert.Equal(itemSpec, cache.FullPath);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.RootDir, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.RootDir, currentDirectory, String.Empty, ref cache);
Assert.Equal(itemSpecDir, modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Filename, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Filename, currentDirectory, String.Empty, ref cache);
Assert.Equal(@"foo", modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Extension, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Extension, currentDirectory, String.Empty, ref cache);
Assert.Equal(@".txt", modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Directory, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Directory, currentDirectory, String.Empty, ref cache);
Assert.Equal(String.Empty, modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Identity, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Identity, currentDirectory, String.Empty, ref cache);
Assert.Equal(itemSpec, modifier);
string projectPath = NativeMethodsShared.IsWindows ? @"c:\abc\goo.proj" : @"/abc/goo.proj";
string projectPathDir = NativeMethodsShared.IsWindows ? @"c:\abc\" : @"/abc/";
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectDirectory, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectDirectory, currentDirectory, projectPath, ref cache);
Assert.Equal(projectPathDir, modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectExtension, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectExtension, currentDirectory, projectPath, ref cache);
Assert.Equal(@".proj", modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectFullPath, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectFullPath, currentDirectory, projectPath, ref cache);
Assert.Equal(projectPath, modifier);
- modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectName, ref cache);
+ modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectName, currentDirectory, projectPath, ref cache);
Assert.Equal(@"goo", modifier);
}
@@ -175,8 +175,7 @@ private static void TestGetItemSpecModifierOnBadPath(string currentDirectory)
{
try
{
- string cache = null;
- ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"http://www.microsoft.com", String.Empty, ItemSpecModifiers.RootDir, ref cache);
+ ItemSpecModifiers.GetItemSpecModifier(@"http://www.microsoft.com", ItemSpecModifiers.RootDir, currentDirectory, String.Empty);
}
catch (Exception e)
{
@@ -440,9 +439,9 @@ public void GetItemSpecModifierRootDirThatFitsIntoMaxPath()
{
string currentDirectory = @"c:\aardvark\aardvark\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890";
string fullPath = @"c:\aardvark\aardvark\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\a.cs";
- string cache = fullPath;
+ ItemSpecModifiers.Cache cache = new() { FullPath = fullPath };
- Assert.Equal(@"c:\", ItemSpecModifiers.GetItemSpecModifier(currentDirectory, fullPath, String.Empty, ItemSpecModifiers.RootDir, ref cache));
+ Assert.Equal(@"c:\", ItemSpecModifiers.GetItemSpecModifier(fullPath, ItemSpecModifierKind.RootDir, currentDirectory, String.Empty, ref cache));
}
[Fact]
diff --git a/src/Framework/ItemSpecModifierKind.cs b/src/Framework/ItemSpecModifierKind.cs
new file mode 100644
index 00000000000..cf0e7fffe92
--- /dev/null
+++ b/src/Framework/ItemSpecModifierKind.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Build.Framework;
+
+internal enum ItemSpecModifierKind
+{
+ FullPath,
+ RootDir,
+ Filename,
+ Extension,
+ RelativeDir,
+ Directory,
+ RecursiveDir,
+ Identity,
+ ModifiedTime,
+ CreatedTime,
+ AccessedTime,
+ DefiningProjectFullPath,
+ DefiningProjectDirectory,
+ DefiningProjectName,
+ DefiningProjectExtension
+}
diff --git a/src/Framework/ItemSpecModifiers.cs b/src/Framework/ItemSpecModifiers.cs
index 7e87652adb7..aded6fb420a 100644
--- a/src/Framework/ItemSpecModifiers.cs
+++ b/src/Framework/ItemSpecModifiers.cs
@@ -2,40 +2,39 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
-using System.Collections.Frozen;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
+using System.Runtime.CompilerServices;
using Microsoft.Build.Shared;
-using Microsoft.Build.Shared.FileSystem;
-
-#nullable disable
namespace Microsoft.Build.Framework;
///
-/// Encapsulates the definitions of the item-spec modifiers a.k.a. reserved item metadata.
+/// Encapsulates the definitions of the item-spec modifiers a.k.a. reserved item metadata.
///
internal static class ItemSpecModifiers
{
- internal const string FullPath = "FullPath";
- internal const string RootDir = "RootDir";
- internal const string Filename = "Filename";
- internal const string Extension = "Extension";
- internal const string RelativeDir = "RelativeDir";
- internal const string Directory = "Directory";
- internal const string RecursiveDir = "RecursiveDir";
- internal const string Identity = "Identity";
- internal const string ModifiedTime = "ModifiedTime";
- internal const string CreatedTime = "CreatedTime";
- internal const string AccessedTime = "AccessedTime";
- internal const string DefiningProjectFullPath = "DefiningProjectFullPath";
- internal const string DefiningProjectDirectory = "DefiningProjectDirectory";
- internal const string DefiningProjectName = "DefiningProjectName";
- internal const string DefiningProjectExtension = "DefiningProjectExtension";
+ public const string FullPath = "FullPath";
+ public const string RootDir = "RootDir";
+ public const string Filename = "Filename";
+ public const string Extension = "Extension";
+ public const string RelativeDir = "RelativeDir";
+ public const string Directory = "Directory";
+ public const string RecursiveDir = "RecursiveDir";
+ public const string Identity = "Identity";
+ public const string ModifiedTime = "ModifiedTime";
+ public const string CreatedTime = "CreatedTime";
+ public const string AccessedTime = "AccessedTime";
+ public const string DefiningProjectFullPath = "DefiningProjectFullPath";
+ public const string DefiningProjectDirectory = "DefiningProjectDirectory";
+ public const string DefiningProjectName = "DefiningProjectName";
+ public const string DefiningProjectExtension = "DefiningProjectExtension";
// These are all the well-known attributes.
- internal static readonly string[] All =
- {
+ public static readonly ImmutableArray All =
+ [
FullPath,
RootDir,
Filename,
@@ -51,76 +50,327 @@ internal static class ItemSpecModifiers
DefiningProjectDirectory,
DefiningProjectName,
DefiningProjectExtension
- };
+ ];
- private static readonly FrozenSet s_tableOfItemSpecModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, All);
- private static readonly FrozenSet s_tableOfDefiningProjectModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase,
- [
- DefiningProjectFullPath,
- DefiningProjectDirectory,
- DefiningProjectName,
- DefiningProjectExtension,
- ]);
+ ///
+ ///
+ /// Caches derivable item-spec modifier results for a single item spec.
+ /// Stored on item instances (e.g., TaskItem, ProjectItemInstance.TaskItem)
+ /// alongside the item spec, replacing the former string _fullPath field.
+ ///
+ ///
+ /// Time-based modifiers (ModifiedTime, CreatedTime, AccessedTime) and RecursiveDir
+ /// are intentionally excluded — time-based modifiers hit the file system and should
+ /// not be cached, and RecursiveDir requires wildcard context that only the caller has.
+ ///
+ ///
+ /// DefiningProject* modifiers are cached separately in a static shared cache
+ /// () keyed by the defining project path,
+ /// since many items share the same defining project.
+ ///
+ ///
+ internal struct Cache
+ {
+ public string? FullPath;
+ public string? RootDir;
+ public string? Filename;
+ public string? Extension;
+ public string? RelativeDir;
+ public string? Directory;
+
+ ///
+ /// Clears all cached values. Called when the item spec changes.
+ ///
+ public void Clear()
+ => this = default;
+ }
///
- /// Indicates if the given name is reserved for an item-spec modifier.
+ /// Cached results for all four DefiningProject* modifiers, computed from a single
+ /// defining project path. Instances are shared across all items that originate from
+ /// the same project file.
///
- internal static bool IsItemSpecModifier(string name)
+ private sealed class DefiningProjectModifierCache
{
- if (name == null)
+ public readonly string FullPath;
+ public readonly string Directory;
+ public readonly string Name;
+ public readonly string Extension;
+
+ public DefiningProjectModifierCache(string? currentDirectory, string definingProjectEscaped)
{
- return false;
+ FullPath = ComputeFullPath(currentDirectory, definingProjectEscaped);
+ string rootDir = ComputeRootDir(FullPath);
+ string directory = ComputeDirectory(FullPath);
+ Directory = Path.Combine(rootDir, directory);
+ Name = ComputeFilename(definingProjectEscaped);
+ Extension = ComputeExtension(definingProjectEscaped);
}
-
- // Could still be a case-insensitive match.
- bool result = s_tableOfItemSpecModifiers.Contains(name);
-
- return result;
}
///
- /// Indicates if the given name is reserved for one of the specific subset of itemspec
- /// modifiers to do with the defining project of the item.
+ /// Static cache of DefiningProject* results keyed by the escaped defining project path.
+ /// In a typical build there are only a handful of distinct defining projects (tens, not thousands),
+ /// so this dictionary stays very small. The cache lives for the lifetime of the process.
///
- internal static bool IsDefiningProjectModifier(string name) => s_tableOfDefiningProjectModifiers.Contains(name);
+ private static readonly ConcurrentDictionary s_definingProjectCache =
+ new(StringComparer.OrdinalIgnoreCase);
///
- /// Indicates if the given name is reserved for a derivable item-spec modifier.
- /// Derivable means it can be computed given a file name.
+ /// Clears the static DefiningProject* modifier cache. Call at the end of a build
+ /// (e.g., from BuildManager.EndBuild) to prevent stale entries from accumulating
+ /// in long-lived processes such as Visual Studio.
///
- /// Name to check.
- /// true, if name of a derivable modifier
- internal static bool IsDerivableItemSpecModifier(string name)
- {
- bool isItemSpecModifier = IsItemSpecModifier(name);
+ public static void ClearDefiningProjectCache()
+ => s_definingProjectCache.Clear();
- if (isItemSpecModifier)
+ ///
+ /// Resolves a modifier name to its using a length+char switch
+ /// instead of a dictionary lookup. Every length bucket is unique or disambiguated by at
+ /// most two character comparisons, so misses are rejected in O(1) with no hashing.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool TryGetModifierKind(string name, out ItemSpecModifierKind kind)
+ {
+ switch (name.Length)
{
- if (name.Length == 12)
- {
- if (name[0] == 'R' || name[0] == 'r')
+ case 7:
+ // RootDir
+ if (string.Equals(name, RootDir, StringComparison.OrdinalIgnoreCase))
{
- // The only 12 letter ItemSpecModifier that starts with 'R' is 'RecursiveDir'
- return false;
+ kind = ItemSpecModifierKind.RootDir;
+ return true;
}
- }
+
+ break;
+
+ case 8:
+ // FullPath, Filename, Identity
+ switch (name[0])
+ {
+ case 'F' or 'f':
+ switch (name[1])
+ {
+ case 'U' or 'u':
+ if (string.Equals(name, FullPath, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.FullPath;
+ return true;
+ }
+
+ break;
+
+ case 'I' or 'i':
+ if (string.Equals(name, Filename, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.Filename;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
+
+ case 'I' or 'i':
+ if (string.Equals(name, Identity, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.Identity;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
+
+ case 9:
+ // Extension, Directory
+ switch (name[0])
+ {
+ case 'E' or 'e':
+ if (string.Equals(name, Extension, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.Extension;
+ return true;
+ }
+
+ break;
+
+ case 'D' or 'd':
+ if (string.Equals(name, Directory, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.Directory;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
+
+ case 11:
+ // RelativeDir, CreatedTime
+ switch (name[0])
+ {
+ case 'R' or 'r':
+ if (string.Equals(name, RelativeDir, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.RelativeDir;
+ return true;
+ }
+
+ break;
+
+ case 'C' or 'c':
+ if (string.Equals(name, CreatedTime, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.CreatedTime;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
+
+ case 12:
+ // RecursiveDir, ModifiedTime, AccessedTime
+ switch (name[0])
+ {
+ case 'R' or 'r':
+ if (string.Equals(name, RecursiveDir, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.RecursiveDir;
+ return true;
+ }
+
+ break;
+
+ case 'M' or 'm':
+ if (string.Equals(name, ModifiedTime, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.ModifiedTime;
+ return true;
+ }
+
+ break;
+
+ case 'A' or 'a':
+ if (string.Equals(name, AccessedTime, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.AccessedTime;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
+
+ case 19:
+ // DefiningProjectName
+ if (string.Equals(name, DefiningProjectName, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.DefiningProjectName;
+ return true;
+ }
+
+ break;
+
+ case 23:
+ // DefiningProjectFullPath
+ if (string.Equals(name, DefiningProjectFullPath, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.DefiningProjectFullPath;
+ return true;
+ }
+
+ break;
+
+ case 24:
+ // DefiningProjectDirectory, DefiningProjectExtension
+ switch (name[15])
+ {
+ case 'D' or 'd':
+ if (string.Equals(name, DefiningProjectDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.DefiningProjectDirectory;
+ return true;
+ }
+
+ break;
+
+ case 'E' or 'e':
+ if (string.Equals(name, DefiningProjectExtension, StringComparison.OrdinalIgnoreCase))
+ {
+ kind = ItemSpecModifierKind.DefiningProjectExtension;
+ return true;
+ }
+
+ break;
+ }
+
+ break;
}
- return isItemSpecModifier;
+ kind = default;
+ return false;
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool TryGetDerivableModifierKind(string name, out ItemSpecModifierKind result)
+ {
+ if (TryGetModifierKind(name, out ItemSpecModifierKind kind) &&
+ kind is not ItemSpecModifierKind.RecursiveDir)
+ {
+ result = kind;
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ ///
+ /// Indicates if the given name is reserved for an item-spec modifier.
+ ///
+ public static bool IsItemSpecModifier([NotNullWhen(true)] string? name)
+ => name is not null
+ && TryGetModifierKind(name, out _);
+
+ ///
+ /// Indicates if the given name is reserved for a derivable item-spec modifier.
+ /// Derivable means it can be computed given a file name.
+ ///
+ /// Name to check.
+ /// true, if name of a derivable modifier
+ public static bool IsDerivableItemSpecModifier([NotNullWhen(true)] string? name)
+ => name is not null
+ && TryGetDerivableModifierKind(name, out _);
+
///
- /// Performs path manipulations on the given item-spec as directed.
- /// Does not cache the result.
+ /// Performs path manipulations on the given item-spec as directed.
+ /// Does not cache the result.
///
- internal static string GetItemSpecModifier(string currentDirectory, string itemSpec, string definingProjectEscaped, string modifier)
+ /// The item-spec to modify.
+ /// The modifier to apply to the item-spec.
+ /// The root directory for relative item-specs.
+ /// The path to the project that defined this item (may be null).
+ public static string GetItemSpecModifier(string itemSpec, string modifier, string? currentDirectory, string? definingProjectEscaped)
{
- string dummy = null;
- return GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, modifier, ref dummy);
+ if (!TryGetModifierKind(modifier, out ItemSpecModifierKind kind))
+ {
+ throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
+ }
+
+ Cache cache = default;
+ return GetItemSpecModifier(itemSpec, kind, currentDirectory, definingProjectEscaped, ref cache);
}
///
- /// Performs path manipulations on the given item-spec as directed.
+ /// Performs path manipulations on the given item-spec as directed, caching
+ /// derivable results in for subsequent calls on the same item spec.
///
/// Supported modifiers:
/// %(FullPath) = full path of item
@@ -138,251 +388,238 @@ internal static string GetItemSpecModifier(string currentDirectory, string itemS
/// NOTES:
/// 1) This method always returns an empty string for the %(RecursiveDir) modifier because it does not have enough
/// information to compute it -- only the BuildItem class can compute this modifier.
- /// 2) All but the file time modifiers could be cached, but it's not worth the space. Only full path is cached, as the others are just string manipulations.
+ /// 2) Time-based modifiers are not cached — they hit the file system and may change between calls.
+ /// 3) DefiningProject* modifiers operate on , not .
+ /// Their results are cached in a static shared cache keyed by the defining project path, since many
+ /// items share the same defining project and the set of distinct projects is small (typically tens).
///
///
- /// Methods of the Path class "normalize" slashes and periods. For example:
- /// 1) successive slashes are combined into 1 slash
- /// 2) trailing periods are discarded
- /// 3) forward slashes are changed to back-slashes
- ///
- /// As a result, we cannot rely on any file-spec that has passed through a Path method to remain the same. We will
- /// therefore not bother preserving slashes and periods when file-specs are transformed.
- ///
/// Never returns null.
///
- /// The root directory for relative item-specs. When called on the Engine thread, this is the project directory. When called as part of building a task, it is null, indicating that the current directory should be used.
/// The item-spec to modify.
- /// The path to the project that defined this item (may be null).
/// The modifier to apply to the item-spec.
- /// Full path if any was previously computed, to cache.
+ /// The root directory for relative item-specs.
+ /// The path to the project that defined this item (may be null).
+ /// Per-item cache of derivable modifier values.
/// The modified item-spec (can be empty string, but will never be null).
/// Thrown when the item-spec is not a path.
- [SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Justification = "Pre-existing")]
- internal static string GetItemSpecModifier(string currentDirectory, string itemSpec, string definingProjectEscaped, string modifier, ref string fullPath)
+ public static string GetItemSpecModifier(
+ string itemSpec,
+ ItemSpecModifierKind modifier,
+ string? currentDirectory,
+ string? definingProjectEscaped,
+ ref Cache cache)
{
FrameworkErrorUtilities.VerifyThrow(itemSpec != null, "Need item-spec to modify.");
- FrameworkErrorUtilities.VerifyThrow(modifier != null, "Need modifier to apply to item-spec.");
-
- string modifiedItemSpec = null;
try
{
- if (string.Equals(modifier, FullPath, StringComparison.OrdinalIgnoreCase))
+ switch (modifier)
{
- if (fullPath != null)
- {
- return fullPath;
- }
+ case ItemSpecModifierKind.FullPath:
+ return cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec);
- if (currentDirectory == null)
- {
- currentDirectory = FileUtilities.CurrentThreadWorkingDirectory ?? string.Empty;
- }
+ case ItemSpecModifierKind.RootDir:
+ return cache.RootDir ??= ComputeRootDir(cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec));
- modifiedItemSpec = FileUtilities.GetFullPath(itemSpec, currentDirectory);
- fullPath = modifiedItemSpec;
+ case ItemSpecModifierKind.Filename:
+ return cache.Filename ??= ComputeFilename(itemSpec);
- ThrowForUrl(modifiedItemSpec, itemSpec, currentDirectory);
- }
- else if (string.Equals(modifier, RootDir, StringComparison.OrdinalIgnoreCase))
- {
- GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath);
+ case ItemSpecModifierKind.Extension:
+ return cache.Extension ??= ComputeExtension(itemSpec);
- modifiedItemSpec = Path.GetPathRoot(fullPath);
+ case ItemSpecModifierKind.RelativeDir:
+ return cache.RelativeDir ??= ComputeRelativeDir(itemSpec);
- if (!FileUtilities.EndsWithSlash(modifiedItemSpec))
- {
- FrameworkErrorUtilities.VerifyThrow(
- FileUtilitiesRegex.StartsWithUncPattern(modifiedItemSpec),
- "Only UNC shares should be missing trailing slashes.");
+ case ItemSpecModifierKind.Directory:
+ return cache.Directory ??= ComputeDirectory(cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec));
- // restore/append trailing slash if Path.GetPathRoot() has either removed it, or failed to add it
- // (this happens with UNC shares)
- modifiedItemSpec += Path.DirectorySeparatorChar;
- }
- }
- else if (string.Equals(modifier, Filename, StringComparison.OrdinalIgnoreCase))
- {
- // if the item-spec is a root directory, it can have no filename
- if (IsRootDirectory(itemSpec))
- {
- // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements
- // in a UNC file-spec as filenames e.g. \\server, \\server\share
- modifiedItemSpec = string.Empty;
- }
- else
- {
- // Fix path to avoid problem with Path.GetFileNameWithoutExtension when backslashes in itemSpec on Unix
- modifiedItemSpec = Path.GetFileNameWithoutExtension(FileUtilities.FixFilePath(itemSpec));
- }
- }
- else if (string.Equals(modifier, Extension, StringComparison.OrdinalIgnoreCase))
- {
- // if the item-spec is a root directory, it can have no extension
- if (IsRootDirectory(itemSpec))
- {
- // NOTE: this is to prevent Path.GetExtension() from treating server and share elements in a UNC
- // file-spec as filenames e.g. \\server.ext, \\server\share.ext
- modifiedItemSpec = string.Empty;
- }
- else
- {
- modifiedItemSpec = Path.GetExtension(itemSpec);
- }
- }
- else if (string.Equals(modifier, RelativeDir, StringComparison.OrdinalIgnoreCase))
- {
- modifiedItemSpec = FileUtilities.GetDirectory(itemSpec);
- }
- else if (string.Equals(modifier, Directory, StringComparison.OrdinalIgnoreCase))
- {
- GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath);
+ case ItemSpecModifierKind.RecursiveDir:
+ return string.Empty;
- modifiedItemSpec = FileUtilities.GetDirectory(fullPath);
+ case ItemSpecModifierKind.Identity:
+ return itemSpec;
- if (NativeMethods.IsWindows)
- {
- int length = -1;
- if (FileUtilitiesRegex.StartsWithDrivePattern(modifiedItemSpec))
- {
- length = 2;
- }
- else
- {
- length = FileUtilitiesRegex.StartsWithUncPatternMatchLength(modifiedItemSpec);
- }
-
- if (length != -1)
- {
- FrameworkErrorUtilities.VerifyThrow((modifiedItemSpec.Length > length) && FileUtilities.IsSlash(modifiedItemSpec[length]),
- "Root directory must have a trailing slash.");
-
- modifiedItemSpec = modifiedItemSpec.Substring(length + 1);
- }
- }
- else
- {
- FrameworkErrorUtilities.VerifyThrow(!string.IsNullOrEmpty(modifiedItemSpec) && FileUtilities.IsSlash(modifiedItemSpec[0]),
- "Expected a full non-windows path rooted at '/'.");
+ // Time-based modifiers are NOT cached - they hit the file system.
+ case ItemSpecModifierKind.ModifiedTime:
+ return ComputeModifiedTime(itemSpec);
- // A full unix path is always rooted at
- // `/`, and a root-relative path is the
- // rest of the string.
- modifiedItemSpec = modifiedItemSpec.Substring(1);
- }
+ case ItemSpecModifierKind.CreatedTime:
+ return ComputeCreatedTime(itemSpec);
+
+ case ItemSpecModifierKind.AccessedTime:
+ return ComputeAccessedTime(itemSpec);
+
+ default:
+ break;
}
- else if (string.Equals(modifier, RecursiveDir, StringComparison.OrdinalIgnoreCase))
+
+ // DefiningProject* modifiers — these operate on definingProjectEscaped, NOT itemSpec.
+ // Results are cached in a static shared dictionary keyed by the defining project path.
+ if (string.IsNullOrEmpty(definingProjectEscaped))
{
- // only the BuildItem class can compute this modifier -- so leave empty
- modifiedItemSpec = String.Empty;
+ return string.Empty;
}
- else if (string.Equals(modifier, Identity, StringComparison.OrdinalIgnoreCase))
+
+ FrameworkErrorUtilities.VerifyThrow(definingProjectEscaped != null, "How could definingProjectEscaped by null?");
+
+ // Fast path: check if we already have cached results for this defining project.
+ // This avoids any closure allocation on the hot path. The miss path only runs once per distinct defining project.
+ if (!s_definingProjectCache.TryGetValue(definingProjectEscaped, out DefiningProjectModifierCache? definingProjectModifiers))
{
- modifiedItemSpec = itemSpec;
+ string? dir = currentDirectory;
+ definingProjectModifiers = s_definingProjectCache.GetOrAdd(
+ definingProjectEscaped,
+ key => new DefiningProjectModifierCache(dir, key));
}
- else if (string.Equals(modifier, ModifiedTime, StringComparison.OrdinalIgnoreCase))
+
+ switch (modifier)
{
- // About to go out to the filesystem. This means data is leaving the engine, so need
- // to unescape first.
- string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
+ case ItemSpecModifierKind.DefiningProjectFullPath:
+ return definingProjectModifiers.FullPath;
- FileInfo info = FileUtilities.GetFileInfoNoThrow(unescapedItemSpec);
+ case ItemSpecModifierKind.DefiningProjectDirectory:
+ return definingProjectModifiers.Directory;
- if (info != null)
- {
- modifiedItemSpec = info.LastWriteTime.ToString(FileUtilities.FileTimeFormat, null);
- }
- else
- {
- // File does not exist, or path is a directory
- modifiedItemSpec = String.Empty;
- }
- }
- else if (string.Equals(modifier, CreatedTime, StringComparison.OrdinalIgnoreCase))
- {
- // About to go out to the filesystem. This means data is leaving the engine, so need
- // to unescape first.
- string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
+ case ItemSpecModifierKind.DefiningProjectName:
+ return definingProjectModifiers.Name;
- if (FileSystems.Default.FileExists(unescapedItemSpec))
- {
- modifiedItemSpec = File.GetCreationTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null);
- }
- else
- {
- // File does not exist, or path is a directory
- modifiedItemSpec = String.Empty;
- }
+ case ItemSpecModifierKind.DefiningProjectExtension:
+ return definingProjectModifiers.Extension;
}
- else if (string.Equals(modifier, AccessedTime, StringComparison.OrdinalIgnoreCase))
- {
- // About to go out to the filesystem. This means data is leaving the engine, so need
- // to unescape first.
- string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
+ }
+ catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
+ {
+ throw new InvalidOperationException(SR.FormatInvalidFilespecForTransform(modifier, itemSpec, e.Message));
+ }
- if (FileSystems.Default.FileExists(unescapedItemSpec))
- {
- modifiedItemSpec = File.GetLastAccessTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null);
- }
- else
- {
- // File does not exist, or path is a directory
- modifiedItemSpec = String.Empty;
- }
- }
- else if (IsDefiningProjectModifier(modifier))
- {
- if (String.IsNullOrEmpty(definingProjectEscaped))
- {
- // We have nothing to work with, but that's sometimes OK -- so just return String.Empty
- modifiedItemSpec = String.Empty;
- }
- else
- {
- if (string.Equals(modifier, DefiningProjectDirectory, StringComparison.OrdinalIgnoreCase))
- {
- // ItemSpecModifiers.Directory does not contain the root directory
- modifiedItemSpec = Path.Combine(
- GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, RootDir),
- GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, Directory));
- }
- else
- {
- string additionalModifier = null;
-
- if (string.Equals(modifier, DefiningProjectFullPath, StringComparison.OrdinalIgnoreCase))
- {
- additionalModifier = FullPath;
- }
- else if (string.Equals(modifier, DefiningProjectName, StringComparison.OrdinalIgnoreCase))
- {
- additionalModifier = Filename;
- }
- else if (string.Equals(modifier, DefiningProjectExtension, StringComparison.OrdinalIgnoreCase))
- {
- additionalModifier = Extension;
- }
- else
- {
- throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
- }
+ throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
+ }
- modifiedItemSpec = GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, additionalModifier);
- }
- }
+ private static string ComputeFullPath(string? currentDirectory, string itemSpec)
+ {
+ currentDirectory ??= FileUtilities.CurrentThreadWorkingDirectory ?? string.Empty;
+
+ string result = FileUtilities.GetFullPath(itemSpec, currentDirectory);
+
+ ThrowForUrl(result, itemSpec, currentDirectory);
+
+ return result;
+ }
+
+ private static string ComputeRootDir(string fullPath)
+ {
+ string? root = Path.GetPathRoot(fullPath)!;
+
+ if (!FileUtilities.EndsWithSlash(root))
+ {
+ FrameworkErrorUtilities.VerifyThrow(
+ FileUtilitiesRegex.StartsWithUncPattern(root),
+ "Only UNC shares should be missing trailing slashes.");
+
+ // restore/append trailing slash if Path.GetPathRoot() has either removed it, or failed to add it
+ // (this happens with UNC shares)
+ root += Path.DirectorySeparatorChar;
+ }
+
+ return root;
+ }
+
+ private static string ComputeFilename(string itemSpec)
+ {
+ // if the item-spec is a root directory, it can have no filename
+ if (IsRootDirectory(itemSpec))
+ {
+ // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements
+ // in a UNC file-spec as filenames e.g. \\server, \\server\share
+ return string.Empty;
+ }
+ else
+ {
+ // Fix path to avoid problem with Path.GetFileNameWithoutExtension when backslashes in itemSpec on Unix
+ return Path.GetFileNameWithoutExtension(FileUtilities.FixFilePath(itemSpec));
+ }
+ }
+
+ private static string ComputeExtension(string itemSpec)
+ {
+ // if the item-spec is a root directory, it can have no extension
+ if (IsRootDirectory(itemSpec))
+ {
+ // NOTE: this is to prevent Path.GetExtension() from treating server and share elements in a UNC
+ // file-spec as filenames e.g. \\server.ext, \\server\share.ext
+ return string.Empty;
+ }
+ else
+ {
+ return Path.GetExtension(itemSpec);
+ }
+ }
+
+ private static string ComputeRelativeDir(string itemSpec)
+ => FileUtilities.GetDirectory(itemSpec);
+
+ private static string ComputeDirectory(string fullPath)
+ {
+ string directory = FileUtilities.GetDirectory(fullPath);
+
+ if (NativeMethods.IsWindows)
+ {
+ int length;
+
+ if (FileUtilitiesRegex.StartsWithDrivePattern(directory))
+ {
+ length = 2;
}
else
{
- throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
+ length = FileUtilitiesRegex.StartsWithUncPatternMatchLength(directory);
}
+
+ if (length != -1)
+ {
+ FrameworkErrorUtilities.VerifyThrow(
+ (directory.Length > length) && FileUtilities.IsSlash(directory[length]),
+ "Root directory must have a trailing slash.");
+
+ return directory.Substring(length + 1);
+ }
+
+ return directory;
}
- catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
- {
- throw new InvalidOperationException(SR.FormatInvalidFilespecForTransform(modifier, itemSpec, e.Message));
- }
- return modifiedItemSpec;
+ FrameworkErrorUtilities.VerifyThrow(
+ !string.IsNullOrEmpty(directory) && FileUtilities.IsSlash(directory[0]),
+ "Expected a full non-windows path rooted at '/'.");
+
+ // A full unix path is always rooted at
+ // `/`, and a root-relative path is the
+ // rest of the string.
+ return directory.Substring(1);
+ }
+
+ private static string ComputeModifiedTime(string itemSpec)
+ => TryGetFileInfo(itemSpec, out FileInfo? info)
+ ? info.LastWriteTime.ToString(FileUtilities.FileTimeFormat)
+ : string.Empty;
+
+ private static string ComputeCreatedTime(string itemSpec)
+ => TryGetFileInfo(itemSpec, out FileInfo? info)
+ ? info.CreationTime.ToString(FileUtilities.FileTimeFormat)
+ : string.Empty;
+
+ private static string ComputeAccessedTime(string itemSpec)
+ => TryGetFileInfo(itemSpec, out FileInfo? info)
+ ? info.LastAccessTime.ToString(FileUtilities.FileTimeFormat)
+ : string.Empty;
+
+ private static bool TryGetFileInfo(string itemSpec, [NotNullWhen(true)] out FileInfo? result)
+ {
+ // About to go out to the file system. This means data is leaving the engine, so need to unescape first.
+ string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
+
+ result = FileUtilities.GetFileInfoNoThrow(unescapedItemSpec);
+ return result is not null;
}
///
@@ -440,7 +677,7 @@ private static void ThrowForUrl(string fullPath, string itemSpec, string current
if (fullPath.IndexOf(':') != fullPath.LastIndexOf(':'))
{
// Cause a better error to appear
- fullPath = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec));
+ _ = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec));
}
}
}
diff --git a/src/Framework/Properties/AssemblyInfo.cs b/src/Framework/Properties/AssemblyInfo.cs
index 80691b8860b..7473cdc18aa 100644
--- a/src/Framework/Properties/AssemblyInfo.cs
+++ b/src/Framework/Properties/AssemblyInfo.cs
@@ -55,6 +55,8 @@
[assembly: InternalsVisibleTo("Microsoft.Build.Tasks.UnitTests, PublicKey=002400000480000094000000060200000024000052534131000400000100010015c01ae1f50e8cc09ba9eac9147cf8fd9fce2cfe9f8dce4f7301c4132ca9fb50ce8cbf1df4dc18dd4d210e4345c744ecb3365ed327efdbc52603faa5e21daa11234c8c4a73e51f03bf192544581ebe107adee3a34928e39d04e524a9ce729d5090bfd7dad9d10c722c0def9ccc08ff0a03790e48bcd1f9b6c476063e1966a1c4")]
[assembly: InternalsVisibleTo("Microsoft.Build.UnitTests.Shared, PublicKey=002400000480000094000000060200000024000052534131000400000100010015c01ae1f50e8cc09ba9eac9147cf8fd9fce2cfe9f8dce4f7301c4132ca9fb50ce8cbf1df4dc18dd4d210e4345c744ecb3365ed327efdbc52603faa5e21daa11234c8c4a73e51f03bf192544581ebe107adee3a34928e39d04e524a9ce729d5090bfd7dad9d10c722c0def9ccc08ff0a03790e48bcd1f9b6c476063e1966a1c4")]
+[assembly: InternalsVisibleTo("MSBuild.Benchmarks, PublicKey=002400000480000094000000060200000024000052534131000400000100010007d1fa57c4aed9f0a32e84aa0faefd0de9e8fd6aec8f87fb03766c834c99921eb23be79ad9d5dcc1dd9ad236132102900b723cf980957fc4e177108fc607774f29e8320e92ea05ece4e821c0a5efe8f1645c4c0c93c1ab99285d622caa652c1dfad63d745d6f2de5f17e5eaf0fc4963d261c8a12436518206dc093344d5ad293")]
+
// This is the assembly-level GUID, and the GUID for the TypeLib associated with
// this assembly. We should specify this explicitly, as opposed to letting
// tlbexp just pick whatever it wants.
diff --git a/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs b/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs
new file mode 100644
index 00000000000..7cbc44be416
--- /dev/null
+++ b/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs
@@ -0,0 +1,271 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using BenchmarkDotNet.Attributes;
+using Microsoft.Build.Evaluation;
+using Microsoft.Build.Execution;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace MSBuild.Benchmarks;
+
+[MemoryDiagnoser]
+public class DefiningProjectModifiersBenchmark
+{
+ ///
+ /// Number of items per project file.
+ ///
+ private const int ItemsPerProject = 100;
+
+ ///
+ /// Number of times each modifier is read per item, simulating repeated metadata access
+ /// during evaluation, task execution, etc.
+ ///
+ private const int RepeatedReads = 10;
+
+ private string _tempDir = null!;
+ private ProjectInstance _singleProjectInstance = null!;
+ private ProjectInstance _multiProjectInstance = null!;
+ private TaskItem[] _taskItemsWithDefiningProject = null!;
+
+ [GlobalSetup]
+ public void GlobalSetup()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "MSBuildBenchmarks", Guid.NewGuid().ToString("N"));
+ string srcDir = Path.Combine(_tempDir, "src");
+ Directory.CreateDirectory(srcDir);
+
+ // Create dummy files.
+ for (int i = 0; i < ItemsPerProject; i++)
+ {
+ File.WriteAllText(Path.Combine(srcDir, $"File{i}.cs"), string.Empty);
+ }
+
+ // --- Single-project scenario ---
+ // All items defined in one project file. DefiningProjectFullPath is the same for all items,
+ // so a cache keyed by defining project path would hit on every item after the first.
+ using (var pc = new ProjectCollection())
+ {
+ var root = Microsoft.Build.Construction.ProjectRootElement.Create(pc);
+ root.FullPath = Path.Combine(_tempDir, "SingleProject.csproj");
+
+ var itemGroup = root.AddItemGroup();
+ for (int i = 0; i < ItemsPerProject; i++)
+ {
+ itemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs"));
+ }
+
+ var project = new Project(root, null, null, pc);
+ _singleProjectInstance = project.CreateProjectInstance();
+ }
+
+ // --- Multi-project scenario ---
+ // Items imported from a second project file. The main project and the imported project
+ // each define items, so there are two distinct DefiningProjectFullPath values.
+ using (var pc = new ProjectCollection())
+ {
+ // Imported project defines half the items.
+ var importRoot = Microsoft.Build.Construction.ProjectRootElement.Create(pc);
+ importRoot.FullPath = Path.Combine(_tempDir, "Imported.props");
+ var importItemGroup = importRoot.AddItemGroup();
+ for (int i = 0; i < ItemsPerProject / 2; i++)
+ {
+ importItemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs"));
+ }
+
+ importRoot.Save();
+
+ // Main project imports the props file and defines the other half.
+ var mainRoot = Microsoft.Build.Construction.ProjectRootElement.Create(pc);
+ mainRoot.FullPath = Path.Combine(_tempDir, "MainProject.csproj");
+ mainRoot.AddImport("Imported.props");
+ var mainItemGroup = mainRoot.AddItemGroup();
+ for (int i = ItemsPerProject / 2; i < ItemsPerProject; i++)
+ {
+ mainItemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs"));
+ }
+
+ var project = new Project(mainRoot, null, null, pc);
+ _multiProjectInstance = project.CreateProjectInstance();
+ }
+
+ // --- TaskItem instances with defining project set ---
+ // Copy from ProjectItemInstance so that _definingProject is populated.
+ var sourceItems = _singleProjectInstance.GetItems("Compile").ToArray();
+ _taskItemsWithDefiningProject = new TaskItem[sourceItems.Length];
+ for (int i = 0; i < sourceItems.Length; i++)
+ {
+ _taskItemsWithDefiningProject[i] = new TaskItem(sourceItems[i]);
+ }
+ }
+
+ [GlobalCleanup]
+ public void GlobalCleanup()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read all DefiningProject* modifiers on one item.
+ // Cold-cache baseline for all four DefiningProject modifiers.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_AllDefiningProjectModifiers_Once()
+ {
+ ProjectItemInstance item = _singleProjectInstance.GetItems("Compile").First();
+ string last = null!;
+
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectFullPath);
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory);
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectName);
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectExtension);
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read DefiningProjectDirectory repeatedly.
+ // This is the most expensive DefiningProject modifier — it resolves
+ // FullPath, RootDir, and Directory internally. Repeated reads on the
+ // same item should benefit heavily from caching.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_DefiningProjectDirectory_Repeated()
+ {
+ ProjectItemInstance item = _singleProjectInstance.GetItems("Compile").First();
+ string last = null!;
+
+ for (int i = 0; i < RepeatedReads; i++)
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read DefiningProjectName + DefiningProjectExtension
+ // on all items from a single project.
+ // All items share the same defining project, so a per-defining-project
+ // cache should compute once and return cached results for the rest.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_DefiningProjectNameExtension_AllItems_SingleProject()
+ {
+ string last = null!;
+
+ foreach (ProjectItemInstance item in _singleProjectInstance.GetItems("Compile"))
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectName);
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectExtension);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read DefiningProjectFullPath on all items from a
+ // multi-project scenario (main + import).
+ // Items come from two different defining projects, so a cache keyed by
+ // defining project path has two entries.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_DefiningProjectFullPath_AllItems_MultiProject()
+ {
+ string last = null!;
+
+ foreach (ProjectItemInstance item in _multiProjectInstance.GetItems("Compile"))
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectFullPath);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read DefiningProjectDirectory on all items from a
+ // multi-project scenario, repeated.
+ // The most expensive modifier across multiple passes — represents the
+ // worst case for uncached DefiningProject resolution.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_DefiningProjectDirectory_AllItems_MultiProject_Repeated()
+ {
+ string last = null!;
+
+ for (int pass = 0; pass < RepeatedReads; pass++)
+ {
+ foreach (ProjectItemInstance item in _multiProjectInstance.GetItems("Compile"))
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory);
+ }
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read all DefiningProject* modifiers on one item.
+ // Exercises the Utilities.TaskItem → ItemSpecModifiers path with a
+ // defining project obtained by copying from a ProjectItemInstance.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_AllDefiningProjectModifiers_Once()
+ {
+ TaskItem item = _taskItemsWithDefiningProject[0];
+ string last = null!;
+
+ last = item.GetMetadata(ItemSpecModifiers.DefiningProjectFullPath);
+ last = item.GetMetadata(ItemSpecModifiers.DefiningProjectDirectory);
+ last = item.GetMetadata(ItemSpecModifiers.DefiningProjectName);
+ last = item.GetMetadata(ItemSpecModifiers.DefiningProjectExtension);
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read DefiningProjectName + DefiningProjectExtension across
+ // all items. All share the same defining project path.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_DefiningProjectNameExtension_AllItems()
+ {
+ string last = null!;
+
+ for (int i = 0; i < _taskItemsWithDefiningProject.Length; i++)
+ {
+ last = _taskItemsWithDefiningProject[i].GetMetadata(ItemSpecModifiers.DefiningProjectName);
+ last = _taskItemsWithDefiningProject[i].GetMetadata(ItemSpecModifiers.DefiningProjectExtension);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read DefiningProjectDirectory repeatedly on one item.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_DefiningProjectDirectory_Repeated()
+ {
+ TaskItem item = _taskItemsWithDefiningProject[0];
+ string last = null!;
+
+ for (int i = 0; i < RepeatedReads; i++)
+ {
+ last = item.GetMetadata(ItemSpecModifiers.DefiningProjectDirectory);
+ }
+
+ return last;
+ }
+}
diff --git a/src/MSBuild.Benchmarks/Extensions.cs b/src/MSBuild.Benchmarks/Extensions.cs
new file mode 100644
index 00000000000..c1f9ea8fe72
--- /dev/null
+++ b/src/MSBuild.Benchmarks/Extensions.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using BenchmarkDotNet.Reports;
+
+namespace MSBuild.Benchmarks;
+
+internal static class Extensions
+{
+ public static int ToExitCode(this IEnumerable summaries)
+ {
+ // an empty summary means that initial filtering and validation did not allow to run
+ if (!summaries.Any())
+ {
+ return 1;
+ }
+
+ // if anything has failed, it's an error
+ return summaries.Any(HasAnyErrors) ? 1 : 0;
+ }
+
+ public static bool HasAnyErrors(this Summary summary)
+ => summary.HasCriticalValidationErrors || summary.Reports.Any(report => report.HasAnyErrors());
+
+ public static bool HasAnyErrors(this BenchmarkReport report)
+ => !report.BuildResult.IsBuildSuccess || !report.AllMeasurements.Any();
+}
diff --git a/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs b/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs
new file mode 100644
index 00000000000..5c34dbcbb59
--- /dev/null
+++ b/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs
@@ -0,0 +1,115 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using BenchmarkDotNet.Attributes;
+using Microsoft.Build.Framework;
+
+namespace MSBuild.Benchmarks;
+
+[MemoryDiagnoser]
+public class ItemSpecModifiersBenchmark
+{
+ private string _currentDirectory = null!;
+ private string _itemSpec = null!;
+ private string _definingProjectEscaped = null!;
+ private string _recursiveDirModifier = null!;
+
+ [GlobalSetup]
+ public void GlobalSetup()
+ {
+ _currentDirectory = Path.GetTempPath();
+ _itemSpec = Path.Combine(_currentDirectory, "src", "Framework", "ItemSpecModifiers.cs");
+ _definingProjectEscaped = Path.Combine(_currentDirectory, "src", "Framework", "Microsoft.Build.Framework.csproj");
+
+ // Ensure the file exists so time-based modifiers can resolve.
+ Directory.CreateDirectory(Path.GetDirectoryName(_itemSpec)!);
+ if (!File.Exists(_itemSpec))
+ {
+ File.WriteAllText(_itemSpec, string.Empty);
+ }
+
+ _recursiveDirModifier = ItemSpecModifiers.RecursiveDir;
+ }
+
+ [GlobalCleanup]
+ public void GlobalCleanup()
+ {
+ if (File.Exists(_itemSpec))
+ {
+ File.Delete(_itemSpec);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // FrozenSet lookup – covers IsItemSpecModifier for all known modifiers
+ // plus a miss.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public int IsItemSpecModifier_AllModifiers()
+ {
+ int count = 0;
+ foreach (string modifier in ItemSpecModifiers.All)
+ {
+ if (ItemSpecModifiers.IsItemSpecModifier(modifier))
+ {
+ count++;
+ }
+ }
+
+ // Also check a miss.
+ if (ItemSpecModifiers.IsItemSpecModifier("SomeCustomMetadata"))
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ // -----------------------------------------------------------------------
+ // IsDerivableItemSpecModifier – RecursiveDir is the only modifier that
+ // hits the length+char guard returning false.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public bool IsDerivableItemSpecModifier_RecursiveDir()
+ => ItemSpecModifiers.IsDerivableItemSpecModifier(_recursiveDirModifier);
+
+ // -----------------------------------------------------------------------
+ // GetItemSpecModifier – FullPath is the most commonly used modifier and
+ // the baseline for the caching benchmark below.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string GetItemSpecModifier_FullPath()
+ => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.FullPath, _currentDirectory, _definingProjectEscaped);
+
+ // -----------------------------------------------------------------------
+ // GetItemSpecModifier – Directory has the most complex logic: resolves
+ // FullPath internally, strips the root, and differs by OS.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string GetItemSpecModifier_Directory()
+ => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.Directory, _currentDirectory, _definingProjectEscaped);
+
+ // -----------------------------------------------------------------------
+ // GetItemSpecModifier – file-time modifier (I/O-bound). All three time
+ // modifiers share the same code shape; ModifiedTime uses
+ // FileUtilities.GetFileInfoNoThrow which is the most common path.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string GetItemSpecModifier_ModifiedTime()
+ => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.ModifiedTime, _currentDirectory, _definingProjectEscaped);
+
+ // -----------------------------------------------------------------------
+ // GetItemSpecModifier – DefiningProjectDirectory is the most expensive
+ // defining-project modifier: it recursively resolves both RootDir and
+ // Directory on the defining project path.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string GetItemSpecModifier_DefiningProjectDirectory()
+ => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.DefiningProjectDirectory, _currentDirectory, _definingProjectEscaped);
+}
diff --git a/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs b/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs
new file mode 100644
index 00000000000..e1a452257f9
--- /dev/null
+++ b/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs
@@ -0,0 +1,220 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using BenchmarkDotNet.Attributes;
+using Microsoft.Build.Evaluation;
+using Microsoft.Build.Execution;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace MSBuild.Benchmarks;
+
+[MemoryDiagnoser]
+public class ItemSpecModifiersCachingBenchmark
+{
+ ///
+ /// Number of items to create for the multi-item benchmarks.
+ ///
+ private const int ItemCount = 200;
+
+ ///
+ /// Number of times each modifier is read per item, simulating repeated metadata access
+ /// during evaluation, task execution, etc.
+ ///
+ private const int RepeatedReads = 10;
+
+ private string _tempDir = null!;
+ private TaskItem[] _taskItems = null!;
+ private ProjectInstance _projectInstance = null!;
+
+ [GlobalSetup]
+ public void GlobalSetup()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "MSBuildBenchmarks", Guid.NewGuid().ToString("N"));
+ string srcDir = Path.Combine(_tempDir, "src", "Framework");
+ Directory.CreateDirectory(srcDir);
+
+ // Create TaskItem instances with realistic file paths.
+ _taskItems = new TaskItem[ItemCount];
+ for (int i = 0; i < ItemCount; i++)
+ {
+ string filePath = Path.Combine(srcDir, $"File{i}.cs");
+ File.WriteAllText(filePath, string.Empty);
+ _taskItems[i] = new TaskItem(filePath);
+ }
+
+ // Create a ProjectInstance with the same items for the ProjectItemInstance benchmarks.
+ using var projectCollection = new ProjectCollection();
+ var root = Microsoft.Build.Construction.ProjectRootElement.Create(projectCollection);
+ root.FullPath = Path.Combine(_tempDir, "Test.csproj");
+
+ var itemGroup = root.AddItemGroup();
+ for (int i = 0; i < ItemCount; i++)
+ {
+ itemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs"));
+ }
+
+ var project = new Project(root, null, null, projectCollection);
+ _projectInstance = project.CreateProjectInstance();
+ }
+
+ [GlobalCleanup]
+ public void GlobalCleanup()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read all derivable modifiers on one item once.
+ // This is the cold-cache baseline — every modifier must be computed.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_AllDerivableModifiers_Once()
+ {
+ TaskItem item = _taskItems[0];
+ string last = null!;
+
+ last = item.GetMetadata(ItemSpecModifiers.FullPath);
+ last = item.GetMetadata(ItemSpecModifiers.RootDir);
+ last = item.GetMetadata(ItemSpecModifiers.Filename);
+ last = item.GetMetadata(ItemSpecModifiers.Extension);
+ last = item.GetMetadata(ItemSpecModifiers.RelativeDir);
+ last = item.GetMetadata(ItemSpecModifiers.Directory);
+ last = item.GetMetadata(ItemSpecModifiers.Identity);
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read Filename + Extension repeatedly on one item.
+ // This is the hot-path pattern — tasks reading the same metadata many
+ // times on the same item. The cache should make reads 2..N near-free.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_FilenameAndExtension_Repeated()
+ {
+ TaskItem item = _taskItems[0];
+ string last = null!;
+
+ for (int i = 0; i < RepeatedReads; i++)
+ {
+ last = item.GetMetadata(ItemSpecModifiers.Filename);
+ last = item.GetMetadata(ItemSpecModifiers.Extension);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read Filename across many items.
+ // Simulates a task iterating all items and reading %(Filename) on each.
+ // First read per item populates the cache; this measures the amortized
+ // cost including the initial computation.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_Filename_ManyItems()
+ {
+ string last = null!;
+
+ for (int i = 0; i < _taskItems.Length; i++)
+ {
+ last = _taskItems[i].GetMetadata(ItemSpecModifiers.Filename);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // TaskItem: Read FullPath + Directory + RootDir repeatedly on one item.
+ // Directory and RootDir both depend on FullPath internally, so the cache
+ // should eliminate redundant Path.GetFullPath calls after the first read.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string TaskItem_FullPathDerivedModifiers_Repeated()
+ {
+ TaskItem item = _taskItems[0];
+ string last = null!;
+
+ for (int i = 0; i < RepeatedReads; i++)
+ {
+ last = item.GetMetadata(ItemSpecModifiers.FullPath);
+ last = item.GetMetadata(ItemSpecModifiers.RootDir);
+ last = item.GetMetadata(ItemSpecModifiers.Directory);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read all derivable modifiers once.
+ // Exercises the ProjectItemInstance.TaskItem → BuiltInMetadata →
+ // ItemSpecModifiers.GetItemSpecModifier(ref CachedItemSpecModifiers) path.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_AllDerivableModifiers_Once()
+ {
+ ProjectItemInstance item = _projectInstance.GetItems("Compile").First();
+ string last = null!;
+
+ last = item.GetMetadataValue(ItemSpecModifiers.FullPath);
+ last = item.GetMetadataValue(ItemSpecModifiers.RootDir);
+ last = item.GetMetadataValue(ItemSpecModifiers.Filename);
+ last = item.GetMetadataValue(ItemSpecModifiers.Extension);
+ last = item.GetMetadataValue(ItemSpecModifiers.RelativeDir);
+ last = item.GetMetadataValue(ItemSpecModifiers.Directory);
+ last = item.GetMetadataValue(ItemSpecModifiers.Identity);
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read Filename + Extension on all items.
+ // The dominant real-world pattern — iterating all Compile items and
+ // reading %(Filename)%(Extension) for output path computation.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_FilenameExtension_AllItems()
+ {
+ string last = null!;
+
+ foreach (ProjectItemInstance item in _projectInstance.GetItems("Compile"))
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.Filename);
+ last = item.GetMetadataValue(ItemSpecModifiers.Extension);
+ }
+
+ return last;
+ }
+
+ // -----------------------------------------------------------------------
+ // ProjectItemInstance: Read Filename + Extension on all items, repeated.
+ // Simulates multiple targets or tasks reading the same metadata from
+ // the same evaluated items during a single build.
+ // -----------------------------------------------------------------------
+
+ [Benchmark]
+ public string ProjectItemInstance_FilenameExtension_AllItems_Repeated()
+ {
+ string last = null!;
+
+ for (int pass = 0; pass < RepeatedReads; pass++)
+ {
+ foreach (ProjectItemInstance item in _projectInstance.GetItems("Compile"))
+ {
+ last = item.GetMetadataValue(ItemSpecModifiers.Filename);
+ last = item.GetMetadataValue(ItemSpecModifiers.Extension);
+ }
+ }
+
+ return last;
+ }
+}
diff --git a/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj b/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj
new file mode 100644
index 00000000000..1f2f22d058e
--- /dev/null
+++ b/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ false
+ $(RuntimeOutputTargetFrameworks)
+ $(RuntimeOutputPlatformTarget)
+
+ false
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MSBuild.Benchmarks/Program.cs b/src/MSBuild.Benchmarks/Program.cs
new file mode 100644
index 00000000000..0fd6fdb93b0
--- /dev/null
+++ b/src/MSBuild.Benchmarks/Program.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Diagnostics.Windows;
+using BenchmarkDotNet.Jobs;
+using BenchmarkDotNet.Running;
+using static MSBuild.Benchmarks.Extensions;
+
+var argList = new List(args);
+
+ParseAndRemoveBooleanParameter(argList, "--collect-etw", out bool collectEtw);
+ParseAndRemoveBooleanParameter(argList, "--disable-ngen", out bool disableNGen);
+ParseAndRemoveBooleanParameter(argList, "--disable-inlining", out bool disableJitInlining);
+
+return BenchmarkSwitcher
+ .FromAssembly(typeof(Program).Assembly)
+ .Run([.. argList], GetConfig(collectEtw, disableNGen, disableJitInlining))
+ .ToExitCode();
+
+static IConfig GetConfig(bool collectEtw, bool disableNGen, bool disableJitInlining)
+{
+ if (Debugger.IsAttached)
+ {
+ return new DebugInProcessConfig();
+ }
+
+ IConfig config = DefaultConfig.Instance;
+
+ if (collectEtw)
+ {
+ config = config.AddDiagnoser(new EtwProfiler());
+ }
+
+ // Use a mutator for settings that should apply to all jobs
+ // (default or CLI-specified like --job short).
+ Job overrides = new Job()
+ .DontEnforcePowerPlan();
+
+ if (disableNGen)
+ {
+ overrides = overrides
+ .WithEnvironmentVariable("COMPlus_ZapDisable", "1")
+ .WithEnvironmentVariable("COMPlus_ReadyToRun", "0")
+ .WithEnvironmentVariable("DOTNET_ReadyToRun", "0");
+ }
+
+ if (disableJitInlining)
+ {
+ overrides = overrides
+ .WithEnvironmentVariable("COMPlus_JitNoInline", "1")
+ .WithEnvironmentVariable("DOTNET_JitNoInline", "1");
+ }
+
+ config = config.AddJob(overrides.AsMutator());
+
+ return config;
+}
+
+static void ParseAndRemoveBooleanParameter(List argsList, string parameter, out bool parameterValue)
+{
+ int parameterIndex = argsList.IndexOf(parameter);
+
+ if (parameterIndex != -1)
+ {
+ argsList.RemoveAt(parameterIndex);
+
+ parameterValue = true;
+ }
+ else
+ {
+ parameterValue = false;
+ }
+}
diff --git a/src/MSBuild.Benchmarks/readme.md b/src/MSBuild.Benchmarks/readme.md
new file mode 100644
index 00000000000..e21364acf2f
--- /dev/null
+++ b/src/MSBuild.Benchmarks/readme.md
@@ -0,0 +1,45 @@
+# MSBuild Benchmarks
+
+This project contains performance benchmarks for MSBuild using [BenchmarkDotNet](https://benchmarkdotnet.org/).
+
+## Running Benchmarks
+
+### Run All Benchmarks
+
+```
+cd src/MSBuild.Benchmarks
+dotnet run -c Release
+```
+
+### Run Benchmarks on a Specific TFM
+
+```
+cd src/MSBuild.Benchmarks
+dotnet run -c Release -f net472
+dotnet run -c Release -f net10.0
+```
+
+### Filter to a Specific Benchmark Class
+
+```
+dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark*"
+```
+
+### Filter to a Single Benchmark Method
+
+```
+dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark.IncludeOnly"
+```
+## Command-Line Options
+
+### Custom Options
+
+- `--collect-etw` - Enable ETW (Event Tracing for Windows) profiling diagnostics
+- `--disable-ngen` - Disable NGEN/ReadyToRun to measure pure JIT performance
+- `--disable-inlining` - Disable JIT inlining for more accurate method-level profiling
+
+These custom options can be combined with any BenchmarkDotNet options:
+
+```
+dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark*" --job short --disable-ngen
+```
diff --git a/src/Shared/TaskParameter.cs b/src/Shared/TaskParameter.cs
index b132424ddac..5fb6aff6ad9 100644
--- a/src/Shared/TaskParameter.cs
+++ b/src/Shared/TaskParameter.cs
@@ -560,9 +560,9 @@ private class TaskParameterTaskItem :
private Dictionary _customEscapedMetadata = null;
///
- /// Cache for fullpath metadata
+ /// Cache for derivable modifier values
///
- private string _fullPath;
+ private ItemSpecModifiers.Cache _cachedModifiers;
///
/// Constructor for serialization
@@ -669,7 +669,11 @@ public ICollection MetadataNames
get
{
List metadataNames = (_customEscapedMetadata == null) ? new List() : new List(_customEscapedMetadata.Keys);
- metadataNames.AddRange(ItemSpecModifiers.All);
+
+ foreach (string name in ItemSpecModifiers.All)
+ {
+ metadataNames.Add(name);
+ }
return metadataNames;
}
@@ -850,22 +854,19 @@ public override object InitializeLifetimeService()
///
string ITaskItem2.GetMetadataValueEscaped(string metadataName)
{
- ErrorUtilities.VerifyThrowArgumentNull(metadataName);
-
- string metadataValue = null;
+ ArgumentNullException.ThrowIfNull(metadataName);
- if (ItemSpecModifiers.IsDerivableItemSpecModifier(metadataName))
+ if (ItemSpecModifiers.TryGetDerivableModifierKind(metadataName, out ItemSpecModifierKind modifierKind))
{
// FileUtilities.GetItemSpecModifier is expecting escaped data, which we assume we already are.
// Passing in a null for currentDirectory indicates we are already in the correct current directory
- metadataValue = ItemSpecModifiers.GetItemSpecModifier(null, _escapedItemSpec, _escapedDefiningProject, metadataName, ref _fullPath);
- }
- else if (_customEscapedMetadata != null)
- {
- _customEscapedMetadata.TryGetValue(metadataName, out metadataValue);
+ return ItemSpecModifiers.GetItemSpecModifier(_escapedItemSpec, modifierKind, null, _escapedDefiningProject, ref _cachedModifiers);
}
- return metadataValue ?? String.Empty;
+ string metadataValue = null;
+ _customEscapedMetadata?.TryGetValue(metadataName, out metadataValue);
+
+ return metadataValue ?? string.Empty;
}
///
diff --git a/src/Utilities/TaskItem.cs b/src/Utilities/TaskItem.cs
index 2cfb2f9d97b..8d0da54927c 100644
--- a/src/Utilities/TaskItem.cs
+++ b/src/Utilities/TaskItem.cs
@@ -51,8 +51,8 @@ public sealed class TaskItem :
// Values are stored in escaped form.
private ImmutableDictionary _metadata;
- // cache of the fullpath value
- private string _fullPath;
+ // cache of derivable modifier values
+ private ItemSpecModifiers.Cache _cachedModifiers;
///
/// May be defined if we're copying this item from a pre-existing one. Otherwise,
@@ -186,7 +186,7 @@ public string ItemSpec
ErrorUtilities.VerifyThrowArgumentNull(value, nameof(ItemSpec));
_itemSpec = FileUtilities.FixFilePath(value);
- _fullPath = null;
+ _cachedModifiers.Clear();
}
}
@@ -205,7 +205,7 @@ string ITaskItem2.EvaluatedIncludeEscaped
set
{
_itemSpec = FileUtilities.FixFilePath(value);
- _fullPath = null;
+ _cachedModifiers.Clear();
}
}
@@ -226,7 +226,10 @@ public ICollection MetadataNames
metadataNames.AddRange(_metadata.Keys);
}
- metadataNames.AddRange(ItemSpecModifiers.All);
+ foreach (string name in ItemSpecModifiers.All)
+ {
+ metadataNames.Add(name);
+ }
return metadataNames;
}
@@ -500,21 +503,18 @@ public static explicit operator string(TaskItem taskItemToCast)
///
string ITaskItem2.GetMetadataValueEscaped(string metadataName)
{
- ErrorUtilities.VerifyThrowArgumentNull(metadataName);
-
- string metadataValue = null;
+ ArgumentNullException.ThrowIfNull(metadataName);
- if (ItemSpecModifiers.IsDerivableItemSpecModifier(metadataName))
+ if (ItemSpecModifiers.TryGetDerivableModifierKind(metadataName, out ItemSpecModifierKind modifierKind))
{
// FileUtilities.GetItemSpecModifier is expecting escaped data, which we assume we already are.
// Passing in a null for currentDirectory indicates we are already in the correct current directory
- metadataValue = ItemSpecModifiers.GetItemSpecModifier(null, _itemSpec, _definingProject, metadataName, ref _fullPath);
- }
- else
- {
- _metadata?.TryGetValue(metadataName, out metadataValue);
+ return ItemSpecModifiers.GetItemSpecModifier(_itemSpec, modifierKind, null, _definingProject, ref _cachedModifiers);
}
+ string metadataValue = null;
+ _metadata?.TryGetValue(metadataName, out metadataValue);
+
return metadataValue ?? string.Empty;
}