Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions documentation/specs/command-line-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Experimental command-line parsing API

`Microsoft.Build.CommandLine.Experimental.CommandLineParser` is an internal API shared with the .NET SDK.
`Parse(IReadOnlyList<string>, CommandLineParsingOptions)` accepts tokens without an executable path.
The existing `Parse(IEnumerable<string>)` overload remains available.
The enumerable overload copies its input before parsing and enumerates the caller's input only once.
The list overload does not copy its input. Its input must remain unchanged and support repeated enumeration until parsing completes.

All options default to `true`, which preserves existing parsing behavior:

| Option | Behavior when `false` |
| --- | --- |
| `ThrowOnUnknownSwitches` | Collect unknown switch tokens in `UnrecognizedArguments` and continue parsing known switches. |
| `ReadResponseFiles` | Do not read explicit or automatic response files. Return explicit `@file` tokens in `UnexpandedResponseFileArguments`. |
| `ReadLoggingArgumentsFromEnvironment` | Do not include switches from `MSBUILD_LOGGING_ARGS`. |

For token-only classification, set all three options to `false`:

```csharp
CommandLineSwitchesAccessor result = parser.Parse(tokens, new CommandLineParsingOptions
{
ThrowOnUnknownSwitches = false,
ReadResponseFiles = false,
ReadLoggingArgumentsFromEnvironment = false
});
```

Both returned token collections preserve original quoting, input order, and duplicates.
When response-file reading is enabled, `UnrecognizedArguments` also includes unknown tokens from those files.
Explicit response files expand at their position in the input.
Automatic response-file switches have lower precedence than command-line switches.
The API preserves existing automatic response-file discovery. It does not add the CLI's separate project-directory discovery step.

Unknown-switch collection does not suppress other errors.
Malformed known switches, duplicate project arguments, and response-file errors still cause exceptions.
Non-switch tokens still follow MSBuild's project-argument rules.
When response-file reading is disabled, even missing or repeated `@file` tokens remain unexpanded without errors.
`-noautoresponse` retains its existing meaning: it disables automatic response files, but not explicit response files.

Options apply to one parse call and are not retained by the parser.
Later calls do not change earlier results.
The MSBuild CLI retains strict parsing and its existing response-file behavior.
272 changes: 266 additions & 6 deletions src/MSBuild.UnitTests/CommandLineParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,297 @@

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Build.CommandLine.Experimental;
using Microsoft.Build.Framework;
using Microsoft.Build.UnitTests;
using Shouldly;
using Xunit;

namespace Microsoft.Build.CommandLine.UnitTests
{
public class CommandLineParserTests
{
private readonly ITestOutputHelper _output;

public CommandLineParserTests(ITestOutputHelper output)
{
_output = output;
}

[Fact]
public void ParseReturnsInstance()
{
CommandLineParser parser = new CommandLineParser();
CommandLineSwitchesAccessor result = parser.Parse(["/targets:targets.txt"]); // first parameter must be the executable name
CommandLineParser parser = new();
CommandLineSwitchesAccessor result = parser.Parse(["/targets:targets.txt"]);

result.Targets.ShouldNotBeNull();
result.Targets.ShouldBe(["targets.txt"]);
result.UnrecognizedArguments.ShouldBeEmpty();
result.UnexpandedResponseFileArguments.ShouldBeEmpty();
}

[Fact]
public void ParseThrowsException()
{
CommandLineParser parser = new CommandLineParser();
CommandLineParser parser = new();

Should.Throw<CommandLineSwitchException>(() =>
{
// first parameter must be the executable name
parser.Parse(["tempproject.proj", "tempproject.proj"]);
});
}

[Fact]
public void UnknownSwitchesThrowByDefault()
{
CommandLineParser parser = new();

Should.Throw<CommandLineSwitchException>(() => parser.Parse(["-sdk-only", "-noconsolelogger"]));
Should.Throw<CommandLineSwitchException>(() => parser.Parse(["-sdk-only"], new CommandLineParsingOptions()));

CommandLineSwitches switches = new();
parser.GatherCommandLineSwitches(["-sdk-only"], switches);
Should.Throw<CommandLineSwitchException>(() => switches.ThrowErrors());
}

[Fact]
public void UnknownSwitchesRetainOriginalTokensAndDoNotStopParsing()
{
IReadOnlyList<string> arguments = [
"-sdk-only", "-p:Configuration=Release", "\"--application:quoted value\"",
"/sdk-only:1", "-sdk-only", "--mt:false", "-noconsolelogger", "project.proj"
];
CommandLineParser parser = new();

CommandLineSwitchesAccessor result = parser.Parse(arguments, TokenOnlyOptions());

result.UnrecognizedArguments.ShouldBe(["-sdk-only", "\"--application:quoted value\"", "/sdk-only:1", "-sdk-only"]);
result.Property.ShouldBe(["Configuration=Release"]);
result.MultiThreaded.ShouldBe(["false"]);
result.NoConsoleLogger.ShouldBe(true);
result.Project.ShouldBe(["project.proj"]);
}

[Theory]
[InlineData("-p:")]
[InlineData("-help:unexpected")]
[InlineData("first.proj", "second.proj")]
public void AllowingUnknownSwitchesStillThrowsForMalformedKnownArguments(string argument, string? secondArgument = null)
{
List<string> arguments = ["-sdk-only", argument];
if (secondArgument is not null)
{
arguments.Add(secondArgument);
}

CommandLineParser parser = new();
Should.Throw<CommandLineSwitchException>(() => parser.Parse(arguments, TokenOnlyOptions()));
}

[Fact]
public void DisabledResponseFilesReturnUnexpandedTokensWithoutReadingThem()
{
using TestEnvironment env = TestEnvironment.Create(_output);
var responseFile = env.CreateFile("arguments.rsp", "-p:FromResponseFile=true -help:unexpected");
string responseArgument = $"@\"{responseFile.Path}\"";
CommandLineParser parser = new();

CommandLineSwitchesAccessor result = parser.Parse(
[responseArgument, "@missing.rsp", "@", responseArgument, "-p:FromCommandLine=true"],
new CommandLineParsingOptions { ReadResponseFiles = false, ReadLoggingArgumentsFromEnvironment = false });

result.Property.ShouldBe(["FromCommandLine=true"]);
result.UnexpandedResponseFileArguments.ShouldBe([responseArgument, "@missing.rsp", "@", responseArgument]);
result.UnrecognizedArguments.ShouldBeEmpty();
parser.IncludedResponseFiles.ShouldBeEmpty();
}

[Fact]
public void ResponseFilesAreReadByDefaultIncludingNestedFiles()
{
using TestEnvironment env = TestEnvironment.Create(_output);
var nestedFile = env.CreateFile("nested.rsp", "-p:Nested=true -v:quiet");
var responseFile = env.CreateFile("arguments.rsp", $"@\"{nestedFile.Path}\" -p:Outer=true");
CommandLineParser parser = new();

CommandLineSwitchesAccessor result = parser.Parse(
["-noautoresponse", $"@\"{responseFile.Path}\"", "-v:minimal"]);

result.Property.ShouldBe(["Nested=true", "Outer=true"]);
result.Verbosity.ShouldBe(["quiet", "minimal"]);
result.UnexpandedResponseFileArguments.ShouldBeEmpty();
parser.IncludedResponseFiles.ShouldBe([responseFile.Path, nestedFile.Path]);
}

[Fact]
public void UnknownSwitchesInNestedResponseFilesAreCollected()
{
using TestEnvironment env = TestEnvironment.Create(_output);
var nestedFile = env.CreateFile("nested.rsp", "\"--application:quoted value\" -p:Nested=true");
var responseFile = env.CreateFile("arguments.rsp", $"-outer-only @\"{nestedFile.Path}\" -outer-only");
CommandLineParser parser = new();

CommandLineSwitchesAccessor result = parser.Parse(
["-noautoresponse", "-before-only", $"@\"{responseFile.Path}\"", "-after-only"],
new CommandLineParsingOptions { ThrowOnUnknownSwitches = false });

result.UnrecognizedArguments.ShouldBe([
"-before-only", "-outer-only", "\"--application:quoted value\"", "-outer-only", "-after-only"
]);
result.Property.ShouldBe(["Nested=true"]);
}

[Theory]
[InlineData("-sdk-only")]
[InlineData("-p:")]
public void ResponseFileErrorsStillThrowByDefault(string contents)
{
using TestEnvironment env = TestEnvironment.Create(_output);
var responseFile = env.CreateFile("arguments.rsp", contents);
CommandLineParser parser = new();

Should.Throw<CommandLineSwitchException>(() => parser.Parse(["-noautoresponse", $"@\"{responseFile.Path}\""]));
}

[Fact]
public void AllowingUnknownSwitchesStillRejectsRepeatedResponseFiles()
{
using TestEnvironment env = TestEnvironment.Create(_output);
var responseFile = env.CreateFile("arguments.rsp", "-sdk-only");
CommandLineParser parser = new();

Should.Throw<InitializationException>(() => parser.Parse(
["-noautoresponse", $"@\"{responseFile.Path}\"", $"@\"{responseFile.Path}\""],
new CommandLineParsingOptions { ThrowOnUnknownSwitches = false }));
}

[Fact]
public void AllowingUnknownSwitchesStillRejectsMissingResponseFiles()
{
using TestEnvironment env = TestEnvironment.Create(_output);
string missingFile = Path.Combine(env.DefaultTestDirectory.Path, "missing.rsp");
CommandLineParser parser = new();

Should.Throw<InitializationException>(() => parser.Parse(
["-noautoresponse", $"@\"{missingFile}\""],
new CommandLineParsingOptions { ThrowOnUnknownSwitches = false }));
}

[Fact]
public void DisablingResponseFilesAlsoDisablesAutomaticResponseFiles()
{
CommandLineParser parser = new();
string automaticFile = Path.Combine(Path.GetDirectoryName(typeof(MSBuildApp).Assembly.Location)!, "MSBuild.rsp");
File.Exists(automaticFile).ShouldBeTrue();

parser.Parse([]);
parser.IncludedResponseFiles.ShouldContain(automaticFile);

parser.Parse([], new CommandLineParsingOptions { ReadResponseFiles = false });
parser.IncludedResponseFiles.ShouldBeEmpty();
}

[Fact]
public void LoggingArgumentsFromEnvironmentCanBeExcluded()
{
using TestEnvironment env = TestEnvironment.Create(_output);
env.SetEnvironmentVariable(Traits.MSBuildLoggingArgsEnvVarName, "-bl:environment.binlog -check:all");
CommandLineParser parser = new();

CommandLineSwitchesAccessor defaultResult = parser.Parse(["-noautoresponse"]);
defaultResult.BinaryLogger.ShouldBe(["environment.binlog"]);
defaultResult.Check.ShouldBe(["all"]);

CommandLineSwitchesAccessor tokenOnlyResult = parser.Parse(["-bl:explicit.binlog"], TokenOnlyOptions());
tokenOnlyResult.BinaryLogger.ShouldBe(["explicit.binlog"]);
tokenOnlyResult.Check.ShouldBeNull();
}

[Fact]
public void ParserReuseDoesNotRetainOptionsOrChangeEarlierResults()
{
CommandLineParser parser = new();
CommandLineParsingOptions options = TokenOnlyOptions();
CommandLineSwitchesAccessor first = parser.Parse(["-first-only", "@first.rsp"], options);
CommandLineSwitchesAccessor second = parser.Parse(["-second-only", "@second.rsp"], options);

options.ThrowOnUnknownSwitches = true;
Should.Throw<CommandLineSwitchException>(() => parser.Parse(["-sdk-only"], options));
Should.Throw<CommandLineSwitchException>(() => parser.Parse(["-sdk-only"]));

first.UnrecognizedArguments.ShouldBe(["-first-only"]);
first.UnexpandedResponseFileArguments.ShouldBe(["@first.rsp"]);
second.UnrecognizedArguments.ShouldBe(["-second-only"]);
second.UnexpandedResponseFileArguments.ShouldBe(["@second.rsp"]);
}

[Fact]
public void EnumerableOverloadEnumeratesInputOnlyOnce()
{
int enumerations = 0;
IEnumerable<string> Arguments()
{
(++enumerations).ShouldBe(1);
yield return "-noautoresponse";
yield return "-targets:targets.txt";
}

CommandLineParser parser = new();
parser.Parse(Arguments()).Targets.ShouldBe(["targets.txt"]);
enumerations.ShouldBe(1);
}

[Fact]
public void EnumerableOverloadEnumeratesReadOnlyListOnlyOnce()
{
SingleEnumerationReadOnlyList arguments = new();
CommandLineParser parser = new();

parser.Parse((IEnumerable<string>)arguments).Targets.ShouldBe(["targets.txt"]);

arguments.Enumerations.ShouldBe(1);
}

private sealed class SingleEnumerationReadOnlyList : IReadOnlyList<string>
{
public int Enumerations { get; private set; }

public int Count => 2;

public string this[int index] => index switch
{
0 => "-noautoresponse",
1 => "-targets:targets.txt",
_ => throw new ArgumentOutOfRangeException(nameof(index))
};

public IEnumerator<string> GetEnumerator()
{
(++Enumerations).ShouldBe(1);
for (int index = 0; index < Count; index++)
{
yield return this[index];
}
}

System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

[Fact]
public void NullArgumentsAreRejected()
{
CommandLineParser parser = new();
Should.Throw<ArgumentNullException>(() => parser.Parse((IReadOnlyList<string>)null!));
Should.Throw<ArgumentNullException>(() => parser.Parse((IEnumerable<string>)null!));
}

private static CommandLineParsingOptions TokenOnlyOptions() => new()
{
ThrowOnUnknownSwitches = false,
ReadResponseFiles = false,
ReadLoggingArgumentsFromEnvironment = false
};
}
}
Loading
Loading