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/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);