diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildPropertyParser.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildPropertyParser.cs new file mode 100644 index 000000000000..505178a7f58d --- /dev/null +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildPropertyParser.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; + +#nullable enable + +namespace Microsoft.DotNet.Cli.Utils; + +public static class MSBuildPropertyParser { + public static IEnumerable<(string key, string value)> ParseProperties(string input) { + var currentPos = 0; + StringBuilder currentKey = new StringBuilder(); + StringBuilder currentValue = new StringBuilder(); + + (string key, string value) EmitAndReset() { + var key = currentKey.ToString(); + var value= currentValue.ToString(); + currentKey.Clear(); + currentValue.Clear(); + return (key, value); + } + + char? Peek() => currentPos < input.Length ? input[currentPos] : null; + + bool TryConsume(out char? consumed) { + if(input.Length > currentPos) { + consumed = input[currentPos]; + currentPos++; + return true; + } else { + consumed = null; + return false; + } + } + + void ParseKey() { + while (TryConsume(out var c) && c != '=') { + currentKey.Append(c); + } + } + + void ParseQuotedValue() { + TryConsume(out var leadingQuote); // consume the leading quote, which we know is there + currentValue.Append(leadingQuote); + while(TryConsume(out char? c)) { + currentValue.Append(c); + if (c == '"') { + // we're done + return; + } + if (c == '\\' && Peek() == '"') + { + // consume the escaped quote + TryConsume(out var c2); + currentValue.Append(c2); + } + } + } + + void ParseUnquotedValue() { + while(TryConsume(out char? c) && c != ';') { + currentValue.Append(c); + } + } + + void ParseValue() { + if (Peek() == '"') { + ParseQuotedValue(); + } else { + ParseUnquotedValue(); + } + } + + (string key, string value) ParseKeyValue() { + ParseKey(); + ParseValue(); + return EmitAndReset(); + } + + bool AtEnd() => currentPos == input.Length; + + while (!(AtEnd())) { + yield return ParseKeyValue(); + if (Peek() is char c && (c == ';' || c== ',')) { + TryConsume(out _); // swallow the next semicolon or comma delimiter + } + } + } +} diff --git a/src/Cli/dotnet/OptionForwardingExtensions.cs b/src/Cli/dotnet/OptionForwardingExtensions.cs index b5d24dd29555..38223e45dc5b 100644 --- a/src/Cli/dotnet/OptionForwardingExtensions.cs +++ b/src/Cli/dotnet/OptionForwardingExtensions.cs @@ -21,8 +21,9 @@ public static class OptionForwardingExtensions public static ForwardedOption ForwardAsProperty(this ForwardedOption option) => option .SetForwardingFunction((optionVals) => optionVals - .Select(optionVal => optionVal.Replace(";", "%3B")) // must escape semicolon-delimited property values when forwarding them to MSBuild - .Select(optionVal => $"{option.Aliases.FirstOrDefault()}:{optionVal}") + .SelectMany(Microsoft.DotNet.Cli.Utils.MSBuildPropertyParser.ParseProperties) + // must escape semicolon-delimited property values when forwarding them to MSBuild + .Select(keyValue => $"{option.Aliases.FirstOrDefault()}:{keyValue.key}={keyValue.value.Replace(";", "%3B")}") ); public static Option ForwardAsMany(this ForwardedOption option, Func> format) => option.SetForwardingFunction(format); diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/CreateWindowsSdkKnownFrameworkReferences.cs b/src/Tasks/Microsoft.NET.Build.Tasks/CreateWindowsSdkKnownFrameworkReferences.cs index 94a65656ee68..7b2e2a433f50 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/CreateWindowsSdkKnownFrameworkReferences.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/CreateWindowsSdkKnownFrameworkReferences.cs @@ -55,6 +55,9 @@ protected override void ExecuteCore() else { var normalizedTargetFrameworkVersion = ProcessFrameworkReferences.NormalizeVersion(new Version(TargetFrameworkVersion)); + + var knownFrameworkReferencesByWindowsSdkVersion = new Dictionary>(); + foreach (var supportedWindowsVersion in WindowsSdkSupportedTargetPlatformVersions) { var windowsSdkPackageVersion = supportedWindowsVersion.GetMetadata("WindowsSdkPackageVersion"); @@ -62,18 +65,38 @@ protected override void ExecuteCore() if (!string.IsNullOrEmpty(windowsSdkPackageVersion)) { var minimumNETVersion = supportedWindowsVersion.GetMetadata("MinimumNETVersion"); + Version normalizedMinimumVersion = new Version(0, 0, 0); if (!string.IsNullOrEmpty(minimumNETVersion)) { - var normalizedMinimumVersion = ProcessFrameworkReferences.NormalizeVersion(new Version(minimumNETVersion)); + normalizedMinimumVersion = ProcessFrameworkReferences.NormalizeVersion(new Version(minimumNETVersion)); if (normalizedMinimumVersion > normalizedTargetFrameworkVersion) { continue; } } - knownFrameworkReferences.Add(CreateKnownFrameworkReference(windowsSdkPackageVersion, TargetFrameworkVersion, supportedWindowsVersion.ItemSpec)); + if (!Version.TryParse(supportedWindowsVersion.ItemSpec, out var windowsSdkVersionParsed)) + { + continue; + } + + if (!knownFrameworkReferencesByWindowsSdkVersion.ContainsKey(windowsSdkVersionParsed)) + { + knownFrameworkReferencesByWindowsSdkVersion[windowsSdkVersionParsed] = new(); + } + + knownFrameworkReferencesByWindowsSdkVersion[windowsSdkVersionParsed].Add((normalizedMinimumVersion, CreateKnownFrameworkReference(windowsSdkPackageVersion, TargetFrameworkVersion, supportedWindowsVersion.ItemSpec))); + } } + + foreach (var knownFrameworkReferencesForSdkVersion in knownFrameworkReferencesByWindowsSdkVersion.Values) + { + // If there are multiple WindowsSdkSupportedTargetPlatformVersion items for the same Windows SDK version, choose the one with the highest minimum version. + // That way it is possible to use older packages when targeting older versions of .NET, and newer packages for newer versions of .NET + var highestMinimumVersion = knownFrameworkReferencesForSdkVersion.Max(t => t.minimumNetVersion); + knownFrameworkReferences.AddRange(knownFrameworkReferencesForSdkVersion.Where(t => t.minimumNetVersion == highestMinimumVersion).Select(t => t.knownFrameworkReference)); + } } KnownFrameworkReferences = knownFrameworkReferences.ToArray(); diff --git a/src/Tests/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildAWindowsDesktopProject.cs b/src/Tests/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildAWindowsDesktopProject.cs index 267654705889..b6a6ec6e22a4 100644 --- a/src/Tests/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildAWindowsDesktopProject.cs +++ b/src/Tests/Microsoft.NET.Build.Tests/GivenThatWeWantToBuildAWindowsDesktopProject.cs @@ -403,6 +403,55 @@ public void ItUsesCorrectWindowsSdkPackVersion(string targetFramework, bool? use var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: targetFramework + useWindowsSDKPreview + windowsSdkPackageVersion); + string referencedWindowsSdkVersion = GetReferencedWindowsSdkVersion(testAsset); + + // The patch version of the Windows SDK Ref pack will change over time, so we use a '*' in the expected version to indicate that and replace it with + // the 4th part of the version number of the resolved package. + if (expectedWindowsSdkPackageVersion.Contains('*')) + { + expectedWindowsSdkPackageVersion = expectedWindowsSdkPackageVersion.Replace("*", new Version(referencedWindowsSdkVersion).Revision.ToString()); + } + + referencedWindowsSdkVersion.Should().Be(expectedWindowsSdkPackageVersion); + } + + [WindowsOnlyTheory] + [InlineData("net5.0-windows10.0.22000.0", "10.0.22000.25")] + [InlineData("net6.0-windows10.0.22000.0", "10.0.22000.26")] + [InlineData("net6.0-windows10.0.19041.0", "10.0.19041.25")] + public void ItUsesTheHighestMatchingWindowsSdkPackageVersion(string targetFramework, string expectedWindowsSdkPackageVersion) + { + var testProject = new TestProject() + { + TargetFrameworks = targetFramework + }; + + var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: targetFramework) + .WithProjectChanges(project => + { + // Add items for available SDK versions for test + var testItems = XElement.Parse(@" + + + + + + + + + + "); + + project.Root.Add(testItems); + }); + + string referencedWindowsSdkVersion = GetReferencedWindowsSdkVersion(testAsset); + referencedWindowsSdkVersion.Should().Be(expectedWindowsSdkPackageVersion); + + } + + private string GetReferencedWindowsSdkVersion(TestAsset testAsset) + { var getValueCommand = new GetValuesCommand(testAsset, "PackageDownload", GetValuesCommand.ValueType.Item); getValueCommand.ShouldRestore = false; getValueCommand.DependsOnTargets = "_CheckForInvalidConfigurationAndPlatform;CollectPackageDownloads"; @@ -419,15 +468,7 @@ public void ItUsesCorrectWindowsSdkPackVersion(string targetFramework, bool? use packageDownloadVersion[0].Should().Be('['); packageDownloadVersion.Last().Should().Be(']'); - // The patch version of the Windows SDK Ref pack will change over time, so we use a '*' in the expected version to indicate that and replace it with - // the 4th part of the version number of the resolved package. - var trimmedPackageDownloadVersion = packageDownloadVersion.Substring(1, packageDownloadVersion.Length - 2); - if (expectedWindowsSdkPackageVersion.Contains('*')) - { - expectedWindowsSdkPackageVersion = expectedWindowsSdkPackageVersion.Replace("*", new Version(trimmedPackageDownloadVersion).Revision.ToString()); - } - - trimmedPackageDownloadVersion.Should().Be(expectedWindowsSdkPackageVersion); + return packageDownloadVersion.Substring(1, packageDownloadVersion.Length - 2); } private string GetPropertyValue(TestAsset testAsset, string propertyName) diff --git a/src/Tests/dotnet.Tests/ParserTests/MSBuildArgumentCommandLineParserTests.cs b/src/Tests/dotnet.Tests/ParserTests/MSBuildArgumentCommandLineParserTests.cs index 2ba24972fa82..90be35893665 100644 --- a/src/Tests/dotnet.Tests/ParserTests/MSBuildArgumentCommandLineParserTests.cs +++ b/src/Tests/dotnet.Tests/ParserTests/MSBuildArgumentCommandLineParserTests.cs @@ -49,6 +49,9 @@ public void MSBuildArgumentsAreForwardedCorrectly(string[] arguments, bool build [InlineData(new string[] { "-p:teamcity_buildConfName=\"Build, Test and Publish\"" }, new string[] { "--property:teamcity_buildConfName=\"Build, Test and Publish\"" })] [InlineData(new string[] { "-p:prop1=true", "-p:prop2=false" }, new string[] { "--property:prop1=true", "--property:prop2=false" })] [InlineData(new string[] { "-p:prop1=\".;/opt/usr\"" }, new string[] { "--property:prop1=\".%3B/opt/usr\"" })] + [InlineData(new string[] { "-p:prop1=true;prop2=false;prop3=\"wut\";prop4=\"1;2;3\"" }, new string[]{ "--property:prop1=true", "--property:prop2=false", "--property:prop3=\"wut\"", "--property:prop4=\"1%3B2%3B3\""})] + [InlineData(new string[] { "-p:prop4=\"1;2;3\"" }, new string[]{ "--property:prop4=\"1%3B2%3B3\""})] + [InlineData(new string[] { "-p:prop4=\"1 ;2 ;3 \"" }, new string[]{ "--property:prop4=\"1 %3B2 %3B3 \""})] public void Can_pass_msbuild_properties_safely(string[] tokens, string[] forwardedTokens) { var forwardingFunction = (CommonOptions.PropertiesOption as ForwardedOption).GetForwardingFunction(); var result = CommonOptions.PropertiesOption.Parse(tokens);