diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
index 497f6768881f..38c605d1e1ef 100644
--- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
+++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
@@ -30,7 +30,7 @@ public override int Execute()
// Find directives (this can fail, so do this before creating the target directory).
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
- var directives = VirtualProjectBuildingCommand.FindDirectivesForConversion(sourceFile, force: _force);
+ var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, errors: null);
Directory.CreateDirectory(targetDirectory);
@@ -50,7 +50,7 @@ public override int Execute()
string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
- VirtualProjectBuildingCommand.WriteProjectFile(writer, directives);
+ VirtualProjectBuildingCommand.WriteProjectFile(writer, directives, isVirtualProject: false);
return 0;
}
diff --git a/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
new file mode 100644
index 000000000000..2ff5376e5563
--- /dev/null
+++ b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
@@ -0,0 +1,107 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Immutable;
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Cli.Commands.Run.Api;
+
+///
+/// Takes JSON from stdin lines, produces JSON on stdout lines, doesn't perform any changes.
+/// Can be used by IDEs to see the project file behind a file-based program.
+///
+internal sealed class RunApiCommand(ParseResult parseResult) : CommandBase(parseResult)
+{
+ public override int Execute()
+ {
+ for (string? line; (line = Console.ReadLine()) != null;)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ try
+ {
+ RunApiInput input = JsonSerializer.Deserialize(line, RunFileApiJsonSerializerContext.Default.RunApiInput)!;
+ RunApiOutput output = input.Execute();
+ Respond(output);
+ }
+ catch (Exception ex)
+ {
+ Respond(new RunApiOutput.Error { Message = ex.Message, Details = ex.ToString() });
+ }
+ }
+
+ return 0;
+
+ static void Respond(RunApiOutput message)
+ {
+ string json = JsonSerializer.Serialize(message, RunFileApiJsonSerializerContext.Default.RunApiOutput);
+ Console.WriteLine(json);
+ }
+ }
+}
+
+[JsonDerivedType(typeof(GetProject), nameof(GetProject))]
+internal abstract class RunApiInput
+{
+ private RunApiInput() { }
+
+ public abstract RunApiOutput Execute();
+
+ public sealed class GetProject : RunApiInput
+ {
+ public string? ArtifactsPath { get; init; }
+ public required string EntryPointFileFullPath { get; init; }
+
+ public override RunApiOutput Execute()
+ {
+ var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(EntryPointFileFullPath);
+ var errors = ImmutableArray.CreateBuilder();
+ var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: true, errors);
+ string artifactsPath = ArtifactsPath ?? VirtualProjectBuildingCommand.GetArtifactsPath(EntryPointFileFullPath);
+
+ var csprojWriter = new StringWriter();
+ VirtualProjectBuildingCommand.WriteProjectFile(csprojWriter, directives, isVirtualProject: true, targetFilePath: EntryPointFileFullPath, artifactsPath: artifactsPath);
+
+ return new RunApiOutput.Project
+ {
+ Content = csprojWriter.ToString(),
+ Diagnostics = errors.ToImmutableArray(),
+ };
+ }
+ }
+}
+
+[JsonDerivedType(typeof(Error), nameof(Error))]
+[JsonDerivedType(typeof(Project), nameof(Project))]
+internal abstract class RunApiOutput
+{
+ private RunApiOutput() { }
+
+ ///
+ /// When the API shape or behavior changes, this should be incremented so the callers (IDEs) can act accordingly
+ /// (e.g., show an error message when an incompatible SDK version is being used).
+ ///
+ [JsonPropertyOrder(-1)]
+ public int Version { get; } = 1;
+
+ public sealed class Error : RunApiOutput
+ {
+ public required string Message { get; init; }
+ public required string Details { get; init; }
+ }
+
+ public sealed class Project : RunApiOutput
+ {
+ public required string Content { get; init; }
+ public required ImmutableArray Diagnostics { get; init; }
+ }
+}
+
+[JsonSerializable(typeof(RunApiInput))]
+[JsonSerializable(typeof(RunApiOutput))]
+internal partial class RunFileApiJsonSerializerContext : JsonSerializerContext;
diff --git a/src/Cli/dotnet/Commands/Run/Api/RunApiCommandParser.cs b/src/Cli/dotnet/Commands/Run/Api/RunApiCommandParser.cs
new file mode 100644
index 000000000000..483b0b9fecfd
--- /dev/null
+++ b/src/Cli/dotnet/Commands/Run/Api/RunApiCommandParser.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+
+namespace Microsoft.DotNet.Cli.Commands.Run.Api;
+
+internal sealed class RunApiCommandParser
+{
+ public static Command GetCommand()
+ {
+ Command command = new("run-api")
+ {
+ Hidden = true,
+ };
+
+ command.SetAction((parseResult) => new RunApiCommand(parseResult).Execute());
+ return command;
+ }
+}
diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
index 32322e9463bd..c9788a46a5ec 100644
--- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
+++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
@@ -381,7 +381,7 @@ public VirtualProjectBuildingCommand PrepareProjectInstance()
Debug.Assert(_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
var sourceFile = LoadSourceFile(EntryPointFileFullPath);
- _directives = FindDirectives(sourceFile, reportErrors: false);
+ _directives = FindDirectives(sourceFile, reportAllErrors: false, errors: null);
return this;
}
@@ -449,17 +449,12 @@ internal static string GetArtifactsPath(string entryPointFileFullPath)
return Path.Join(directory, "dotnet", "runfile", directoryName);
}
- public static void WriteProjectFile(TextWriter writer, ImmutableArray directives)
- {
- WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null, artifactsPath: null);
- }
-
- private static void WriteProjectFile(
+ public static void WriteProjectFile(
TextWriter writer,
ImmutableArray directives,
bool isVirtualProject,
- string? targetFilePath,
- string? artifactsPath)
+ string? targetFilePath = null,
+ string? artifactsPath = null)
{
int processedDirectives = 0;
@@ -523,7 +518,7 @@ private static void WriteProjectFile(
processedDirectives++;
}
- if (processedDirectives > 1)
+ if (isVirtualProject || processedDirectives > 1)
{
writer.WriteLine();
}
@@ -674,14 +669,22 @@ Override targets which don't work with project files that are not present on dis
static string EscapeValue(string value) => SecurityElement.Escape(value);
}
- public static ImmutableArray FindDirectivesForConversion(SourceFile sourceFile, bool force)
+ ///
+ /// If , the whole is parsed to find diagnostics about every app directive.
+ /// Otherwise, only directives up to the first C# token is checked.
+ /// The former is useful for dotnet project convert where we want to report all errors because it would be difficult to fix them up after the conversion.
+ /// The latter is useful for dotnet run file.cs where if there are app directives after the first token,
+ /// compiler reports anyway, so we speed up success scenarios by not parsing the whole file up front in the SDK CLI.
+ ///
+ ///
+ /// If , the first error is thrown as .
+ /// Otherwise, all errors are put into the list.
+ /// Does not have any effect when is .
+ ///
+ public static ImmutableArray FindDirectives(SourceFile sourceFile, bool reportAllErrors, ImmutableArray.Builder? errors)
{
- return FindDirectives(sourceFile, reportErrors: !force);
- }
-
#pragma warning disable RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
- private static ImmutableArray FindDirectives(SourceFile sourceFile, bool reportErrors)
- {
+
var builder = ImmutableArray.CreateBuilder();
SyntaxTokenParser tokenizer = SyntaxFactory.CreateTokenParser(sourceFile.Text,
CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")]));
@@ -720,7 +723,11 @@ private static ImmutableArray FindDirectives(SourceFile sourceF
var name = parts.MoveNext() ? message[parts.Current] : default;
var value = parts.MoveNext() ? message[parts.Current] : default;
Debug.Assert(!parts.MoveNext());
- builder.Add(CSharpDirective.Parse(sourceFile, span, name.ToString(), value.ToString()));
+
+ if (CSharpDirective.Parse(errors, sourceFile, span, name.ToString(), value.ToString()) is { } directive)
+ {
+ builder.Add(directive);
+ }
}
previousWhiteSpaceSpan = default;
@@ -728,7 +735,7 @@ private static ImmutableArray FindDirectives(SourceFile sourceF
// In conversion mode, we want to report errors for any invalid directives in the rest of the file
// so users don't end up with invalid directives in the converted project.
- if (reportErrors)
+ if (reportAllErrors)
{
tokenizer.ResetTo(result);
@@ -738,12 +745,12 @@ private static ImmutableArray FindDirectives(SourceFile sourceF
foreach (var trivia in result.Token.LeadingTrivia)
{
- reportErrorFor(sourceFile, trivia);
+ reportErrorFor(trivia);
}
foreach (var trivia in result.Token.TrailingTrivia)
{
- reportErrorFor(sourceFile, trivia);
+ reportErrorFor(trivia);
}
}
while (!result.Token.IsKind(SyntaxKind.EndOfFileToken));
@@ -758,15 +765,27 @@ static TextSpan getFullSpan(TextSpan previousWhiteSpaceSpan, SyntaxTrivia trivia
return previousWhiteSpaceSpan.IsEmpty ? trivia.FullSpan : TextSpan.FromBounds(previousWhiteSpaceSpan.Start, trivia.FullSpan.End);
}
- static void reportErrorFor(SourceFile sourceFile, SyntaxTrivia trivia)
+ void reportErrorFor(SyntaxTrivia trivia)
{
if (trivia.ContainsDiagnostics && trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
{
- throw new GracefulException(CliCommandStrings.CannotConvertDirective, sourceFile.GetLocationString(trivia.Span));
+ string location = sourceFile.GetLocationString(trivia.Span);
+ if (errors != null)
+ {
+ errors.Add(new SimpleDiagnostic
+ {
+ Location = sourceFile.GetFileLinePositionSpan(trivia.Span),
+ Message = string.Format(CliCommandStrings.CannotConvertDirective, location),
+ });
+ }
+ else
+ {
+ throw new GracefulException(CliCommandStrings.CannotConvertDirective, location);
+ }
}
}
- }
#pragma warning restore RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
+ }
public static SourceFile LoadSourceFile(string filePath)
{
@@ -810,9 +829,14 @@ public static bool IsValidEntryPointPath(string entryPointFilePath)
internal readonly record struct SourceFile(string Path, SourceText Text)
{
+ public FileLinePositionSpan GetFileLinePositionSpan(TextSpan span)
+ {
+ return new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+ }
+
public string GetLocationString(TextSpan span)
{
- var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+ var positionSpan = GetFileLinePositionSpan(span);
return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
}
}
@@ -835,23 +859,42 @@ private CSharpDirective() { }
///
public required TextSpan Span { get; init; }
- public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+ public static CSharpDirective? Parse(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
{
return directiveKind switch
{
- "sdk" => Sdk.Parse(sourceFile, span, directiveKind, directiveText),
- "property" => Property.Parse(sourceFile, span, directiveKind, directiveText),
- "package" => Package.Parse(sourceFile, span, directiveKind, directiveText),
- _ => throw new GracefulException(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span)),
+ "sdk" => Sdk.Parse(errors, sourceFile, span, directiveKind, directiveText),
+ "property" => Property.Parse(errors, sourceFile, span, directiveKind, directiveText),
+ "package" => Package.Parse(errors, sourceFile, span, directiveKind, directiveText),
+ _ => ReportError(errors, sourceFile, span, string.Format(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span))),
};
}
- private static (string, string?) ParseOptionalTwoParts(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText, SearchValues? separators = null)
+ private static T? ReportError(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string message, Exception? inner = null)
+ {
+ if (errors != null)
+ {
+ errors.Add(new SimpleDiagnostic { Location = sourceFile.GetFileLinePositionSpan(span), Message = message });
+ return default;
+ }
+ else
+ {
+ throw new GracefulException(message, inner);
+ }
+ }
+
+ private static (string, string?)? ParseOptionalTwoParts(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText, SearchValues? separators = null)
{
var i = separators != null
? directiveText.AsSpan().IndexOfAny(separators)
: directiveText.IndexOf(' ', StringComparison.Ordinal);
- var firstPart = checkFirstPart(i < 0 ? directiveText : directiveText[..i]);
+ var firstPart = i < 0 ? directiveText : directiveText[..i];
+
+ if (string.IsNullOrWhiteSpace(firstPart))
+ {
+ return ReportError<(string, string?)?>(errors, sourceFile, span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind, sourceFile.GetLocationString(span)));
+ }
+
var secondPart = i < 0 ? [] : directiveText.AsSpan((i + 1)..).TrimStart();
if (i < 0 || secondPart.IsWhiteSpace())
{
@@ -859,16 +902,6 @@ private static (string, string?) ParseOptionalTwoParts(SourceFile sourceFile, Te
}
return (firstPart, secondPart.ToString());
-
- string checkFirstPart(string firstPart)
- {
- if (string.IsNullOrWhiteSpace(firstPart))
- {
- throw new GracefulException(CliCommandStrings.MissingDirectiveName, directiveKind, sourceFile.GetLocationString(span));
- }
-
- return firstPart;
- }
}
///
@@ -886,9 +919,12 @@ private Sdk() { }
public required string Name { get; init; }
public string? Version { get; init; }
- public static new Sdk Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+ public static new Sdk? Parse(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
{
- var (sdkName, sdkVersion) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText);
+ if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText) is not var (sdkName, sdkVersion))
+ {
+ return null;
+ }
return new Sdk
{
@@ -914,13 +950,16 @@ private Property() { }
public required string Name { get; init; }
public required string Value { get; init; }
- public static new Property Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+ public static new Property? Parse(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
{
- var (propertyName, propertyValue) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText);
+ if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText) is not var (propertyName, propertyValue))
+ {
+ return null;
+ }
if (propertyValue is null)
{
- throw new GracefulException(CliCommandStrings.PropertyDirectiveMissingParts, sourceFile.GetLocationString(span));
+ return ReportError(errors, sourceFile, span, string.Format(CliCommandStrings.PropertyDirectiveMissingParts, sourceFile.GetLocationString(span)));
}
try
@@ -929,7 +968,7 @@ private Property() { }
}
catch (XmlException ex)
{
- throw new GracefulException(string.Format(CliCommandStrings.PropertyDirectiveInvalidName, sourceFile.GetLocationString(span), ex.Message), ex);
+ return ReportError(errors, sourceFile, span, string.Format(CliCommandStrings.PropertyDirectiveInvalidName, sourceFile.GetLocationString(span), ex.Message), ex);
}
return new Property
@@ -953,9 +992,12 @@ private Package() { }
public required string Name { get; init; }
public string? Version { get; init; }
- public static new Package Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+ public static new Package? Parse(ImmutableArray.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
{
- var (packageName, packageVersion) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText, s_separators);
+ if (ParseOptionalTwoParts(errors, sourceFile, span, directiveKind, directiveText, s_separators) is not var (packageName, packageVersion))
+ {
+ return null;
+ }
return new Package
{
@@ -967,6 +1009,27 @@ private Package() { }
}
}
+internal sealed class SimpleDiagnostic
+{
+ public required Position Location { get; init; }
+ public required string Message { get; init; }
+
+ ///
+ /// An adapter of that ensures we JSON-serialize only the necessary fields.
+ ///
+ public readonly struct Position
+ {
+ public string Path { get; init; }
+ public LinePositionSpan Span { get; init; }
+
+ public static implicit operator Position(FileLinePositionSpan fileLinePositionSpan) => new()
+ {
+ Path = fileLinePositionSpan.Path,
+ Span = fileLinePositionSpan.Span,
+ };
+ }
+}
+
internal sealed class RunFileBuildCacheEntry
{
private static StringComparer GlobalPropertiesComparer => StringComparer.OrdinalIgnoreCase;
diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs
index 39ca4d7fd029..3ce2e132ad25 100644
--- a/src/Cli/dotnet/Parser.cs
+++ b/src/Cli/dotnet/Parser.cs
@@ -32,6 +32,7 @@
using Microsoft.DotNet.Cli.Commands.Reference;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Commands.Run;
+using Microsoft.DotNet.Cli.Commands.Run.Api;
using Microsoft.DotNet.Cli.Commands.Sdk;
using Microsoft.DotNet.Cli.Commands.Solution;
using Microsoft.DotNet.Cli.Commands.Store;
@@ -80,6 +81,7 @@ public static class Parser
RemoveCommandParser.GetCommand(),
RestoreCommandParser.GetCommand(),
RunCommandParser.GetCommand(),
+ RunApiCommandParser.GetCommand(),
SolutionCommandParser.GetCommand(),
StoreCommandParser.GetCommand(),
TestCommandParser.GetCommand(),
diff --git a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs
index 40db520c2ccc..863fd738d0e8 100644
--- a/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs
+++ b/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs
@@ -17,6 +17,8 @@ public class SdkCommandSpec
public string? WorkingDirectory { get; set; }
+ public bool RedirectStandardInput { get; set; }
+
private string EscapeArgs()
{
// Note: this doesn't handle invoking .cmd files via "cmd /c" on Windows, which probably won't be necessary here
@@ -40,7 +42,8 @@ public ProcessStartInfo ToProcessStartInfo(bool doNotEscapeArguments = false)
{
FileName = FileName,
Arguments = doNotEscapeArguments ? string.Join(" ", Arguments) : EscapeArgs(),
- UseShellExecute = false
+ UseShellExecute = false,
+ RedirectStandardInput = RedirectStandardInput,
};
foreach (var kvp in Environment)
{
diff --git a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs
index 96d555bb293f..a31b826d40d8 100644
--- a/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs
+++ b/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs
@@ -20,6 +20,8 @@ public abstract class TestCommand
public List EnvironmentToRemove { get; } = new List();
+ public bool RedirectStandardInput { get; set; }
+
// These only work via Execute(), not when using GetProcessStartInfo()
public Action? CommandOutputHandler { get; set; }
public Action? ProcessStartedHandler { get; set; }
@@ -43,6 +45,18 @@ public TestCommand WithWorkingDirectory(string workingDirectory)
return this;
}
+ public TestCommand WithStandardInput(string stdin)
+ {
+ Debug.Assert(ProcessStartedHandler == null);
+ RedirectStandardInput = true;
+ ProcessStartedHandler = (process) =>
+ {
+ process.StandardInput.Write(stdin);
+ process.StandardInput.Close();
+ };
+ return this;
+ }
+
///
/// Instructs not to escape the arguments when launching command.
/// This may be used to pass ready arguments line as single string argument.
@@ -84,6 +98,8 @@ private SdkCommandSpec CreateCommandSpec(IEnumerable args)
commandSpec.Arguments = Arguments.Concat(commandSpec.Arguments).ToList();
}
+ commandSpec.RedirectStandardInput = RedirectStandardInput;
+
return commandSpec;
}
diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
index a45a57bc3f08..1993b7004c48 100644
--- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
+++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
@@ -726,9 +726,9 @@ public void Directives_Comments()
private static void Convert(string inputCSharp, out string actualProject, out string? actualCSharp, bool force)
{
var sourceFile = new SourceFile("/app/Program.cs", SourceText.From(inputCSharp, Encoding.UTF8));
- var directives = VirtualProjectBuildingCommand.FindDirectivesForConversion(sourceFile, force: force);
+ var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !force, errors: null);
var projectWriter = new StringWriter();
- VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives);
+ VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives, isVirtualProject: false);
actualProject = projectWriter.ToString();
actualCSharp = VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text)?.ToString();
}
diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
index 2aa7e1a3339c..6c9ddb9a340e 100644
--- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
+++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Text.Json;
+using Microsoft.CodeAnalysis;
using Microsoft.DotNet.Cli.Commands;
using Microsoft.DotNet.Cli.Commands.Run;
@@ -1096,4 +1098,299 @@ public void UpToDate_InvalidOptions()
.Should().Fail()
.And.HaveStdErrContaining(string.Format(CliCommandStrings.InvalidOptionCombination, RunCommandParser.NoCacheOption.Name, RunCommandParser.NoRestoreOption.Name));
}
+
+ private static string ToJson(string s) => JsonSerializer.Serialize(s);
+
+ ///
+ /// Simplifies using interpolated raw strings with nested JSON,
+ /// e.g, in $$"""{x:{y:1}}""", the }} would result in an error.
+ ///
+ private const string nop = "";
+
+ [Fact]
+ public void Api()
+ {
+ var testInstance = _testAssetsManager.CreateTestDirectory();
+ var programPath = Path.Join(testInstance.Path, "Program.cs");
+ File.WriteAllText(programPath, """
+ #!/program
+ #:sdk Microsoft.NET.Sdk
+ #:sdk Aspire.Hosting.Sdk 9.1.0
+ #:property TargetFramework net11.0
+ #:package System.CommandLine 2.0.0-beta4.22272.1
+ #:property LangVersion preview
+ Console.WriteLine();
+ """);
+
+ new DotnetCommand(Log, "run-api")
+ .WithStandardInput($$"""
+ {"$type":"GetProject","EntryPointFileFullPath":{{ToJson(programPath)}},"ArtifactsPath":"/artifacts"}
+ """)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOut($$"""
+ {"$type":"Project","Version":1,"Content":{{ToJson($"""
+
+
+
+ false
+ /artifacts
+
+
+
+
+
+
+
+ Exe
+ {ToolsetInfo.CurrentTargetFramework}
+ enable
+ enable
+
+
+
+ false
+
+
+
+ net11.0
+ preview
+
+
+
+ $(Features);FileBasedProgram
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+
+
+
+
+
+
+
+
+
+ """)}},"Diagnostics":[]}
+ """);
+ }
+
+ [Fact]
+ public void Api_Diagnostic_01()
+ {
+ var testInstance = _testAssetsManager.CreateTestDirectory();
+ var programPath = Path.Join(testInstance.Path, "Program.cs");
+ File.WriteAllText(programPath, """
+ Console.WriteLine();
+ #:property LangVersion preview
+ """);
+
+ new DotnetCommand(Log, "run-api")
+ .WithStandardInput($$"""
+ {"$type":"GetProject","EntryPointFileFullPath":{{ToJson(programPath)}},"ArtifactsPath":"/artifacts"}
+ """)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOut($$"""
+ {"$type":"Project","Version":1,"Content":{{ToJson($"""
+
+
+
+ false
+ /artifacts
+
+
+
+
+
+
+ Exe
+ {ToolsetInfo.CurrentTargetFramework}
+ enable
+ enable
+
+
+
+ false
+
+
+
+ $(Features);FileBasedProgram
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+
+
+
+
+
+
+
+
+
+ """)}},"Diagnostics":
+ [{"Location":{
+ "Path":{{ToJson(programPath)}},
+ "Span":{"Start":{"Line":1,"Character":0},"End":{"Line":1,"Character":30}{{nop}}}{{nop}}},
+ "Message":{{ToJson(string.Format(CliCommandStrings.CannotConvertDirective, $"{programPath}:2"))}}}]}
+ """.ReplaceLineEndings(""));
+ }
+
+ [Fact]
+ public void Api_Diagnostic_02()
+ {
+ var testInstance = _testAssetsManager.CreateTestDirectory();
+ var programPath = Path.Join(testInstance.Path, "Program.cs");
+ File.WriteAllText(programPath, """
+ #:unknown directive
+ Console.WriteLine();
+ """);
+
+ new DotnetCommand(Log, "run-api")
+ .WithStandardInput($$"""
+ {"$type":"GetProject","EntryPointFileFullPath":{{ToJson(programPath)}},"ArtifactsPath":"/artifacts"}
+ """)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOut($$"""
+ {"$type":"Project","Version":1,"Content":{{ToJson($"""
+
+
+
+ false
+ /artifacts
+
+
+
+
+
+
+ Exe
+ {ToolsetInfo.CurrentTargetFramework}
+ enable
+ enable
+
+
+
+ false
+
+
+
+ $(Features);FileBasedProgram
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+
+
+
+
+
+
+
+
+
+ """)}},"Diagnostics":
+ [{"Location":{
+ "Path":{{ToJson(programPath)}},
+ "Span":{"Start":{"Line":0,"Character":0},"End":{"Line":1,"Character":0}{{nop}}}{{nop}}},
+ "Message":{{ToJson(string.Format(CliCommandStrings.UnrecognizedDirective, "unknown", $"{programPath}:1"))}}}]}
+ """.ReplaceLineEndings(""));
+ }
+
+ [Fact]
+ public void Api_Error()
+ {
+ new DotnetCommand(Log, "run-api")
+ .WithStandardInput("""
+ {"$type":"Unknown1"}
+ {"$type":"Unknown2"}
+ """)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOutContaining("""
+ {"$type":"Error","Version":1,"Message":
+ """)
+ .And.HaveStdOutContaining("Unknown1")
+ .And.HaveStdOutContaining("Unknown2");
+ }
}