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"); + } }