Skip to content

Add file-based program API for use by debugger #48905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
15 changes: 14 additions & 1 deletion src/Cli/Microsoft.DotNet.Cli.Utils/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

namespace Microsoft.DotNet.Cli.Utils;

public class Command(Process? process, bool trimTrailingNewlines = false) : ICommand
public class Command(Process? process, bool trimTrailingNewlines = false, IDictionary<string, string?>? customEnvironmentVariables = null) : ICommand
{
private readonly Process _process = process ?? throw new ArgumentNullException(nameof(process));

private readonly Dictionary<string, string?>? _customEnvironmentVariables =
// copy the dictionary to avoid mutating the original
customEnvironmentVariables == null ? null : new(customEnvironmentVariables);

private StreamForwarder? _stdOut;

private StreamForwarder? _stdErr;
Expand Down Expand Up @@ -98,6 +102,7 @@ public ICommand WorkingDirectory(string? projectDirectory)
public ICommand EnvironmentVariable(string name, string? value)
{
_process.StartInfo.Environment[name] = value;
_customEnvironmentVariables?[name] = value;
return this;
}

Expand Down Expand Up @@ -179,6 +184,14 @@ public ICommand OnErrorLine(Action<string> handler)

public string CommandArgs => _process.StartInfo.Arguments;

public ProcessStartInfo StartInfo => _process.StartInfo;

/// <summary>
/// If set in the constructor, it's used to keep track of environment variables modified via <see cref="EnvironmentVariable"/>
/// unlike <see cref="ProcessStartInfo.Environment"/> which includes all environment variables of the current process.
/// </summary>
public IReadOnlyDictionary<string, string?>? CustomEnvironmentVariables => _customEnvironmentVariables;

public ICommand SetCommandArgs(string commandArgs)
{
_process.StartInfo.Arguments = commandArgs;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,6 @@ public static Command Create(CommandSpec commandSpec)
StartInfo = psi
};

return new Command(_process);
return new Command(_process, customEnvironmentVariables: commandSpec.EnvironmentVariables);
}
}
57 changes: 57 additions & 0 deletions src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -46,6 +47,7 @@ static void Respond(RunApiOutput message)
}

[JsonDerivedType(typeof(GetProject), nameof(GetProject))]
[JsonDerivedType(typeof(GetRunCommand), nameof(GetRunCommand))]
internal abstract class RunApiInput
{
private RunApiInput() { }
Expand Down Expand Up @@ -74,10 +76,57 @@ public override RunApiOutput Execute()
};
}
}

public sealed class GetRunCommand : RunApiInput
{
public string? ArtifactsPath { get; init; }
public required string EntryPointFileFullPath { get; init; }

public override RunApiOutput Execute()
{
var buildCommand = new VirtualProjectBuildingCommand(
entryPointFileFullPath: EntryPointFileFullPath,
msbuildArgs: [],
verbosity: VerbosityOptions.quiet,
interactive: false)
{
CustomArtifactsPath = ArtifactsPath,
};

buildCommand.PrepareProjectInstance();

var runCommand = new RunCommand(
noBuild: false,
projectFileOrDirectory: null,
launchProfile: null,
noLaunchProfile: false,
noLaunchProfileArguments: false,
noRestore: false,
noCache: false,
interactive: false,
verbosity: VerbosityOptions.quiet,
restoreArgs: [],
args: [EntryPointFileFullPath],
environmentVariables: ReadOnlyDictionary<string, string>.Empty);

runCommand.TryGetLaunchProfileSettingsIfNeeded(out var launchSettings);
var targetCommand = (Utils.Command)runCommand.GetTargetCommand(buildCommand.CreateProjectInstance);
runCommand.ApplyLaunchSettingsProfileToCommand(targetCommand, launchSettings);

return new RunApiOutput.RunCommand
{
ExecutablePath = targetCommand.CommandName,
CommandLineArguments = targetCommand.CommandArgs,
WorkingDirectory = targetCommand.StartInfo.WorkingDirectory,
EnvironmentVariables = targetCommand.CustomEnvironmentVariables ?? ReadOnlyDictionary<string, string?>.Empty,
};
}
}
}

[JsonDerivedType(typeof(Error), nameof(Error))]
[JsonDerivedType(typeof(Project), nameof(Project))]
[JsonDerivedType(typeof(RunCommand), nameof(RunCommand))]
internal abstract class RunApiOutput
{
private RunApiOutput() { }
Expand All @@ -100,6 +149,14 @@ public sealed class Project : RunApiOutput
public required string Content { get; init; }
public required ImmutableArray<SimpleDiagnostic> Diagnostics { get; init; }
}

public sealed class RunCommand : RunApiOutput
{
public required string ExecutablePath { get; init; }
public required string CommandLineArguments { get; init; }
public required string? WorkingDirectory { get; init; }
public required IReadOnlyDictionary<string, string?> EnvironmentVariables { get; init; }
}
}

[JsonSerializable(typeof(RunApiInput))]
Expand Down
10 changes: 5 additions & 5 deletions src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class RunCommand

private bool ShouldBuild => !NoBuild;

public string LaunchProfile { get; }
public string? LaunchProfile { get; }
public bool NoLaunchProfile { get; }

/// <summary>
Expand All @@ -64,7 +64,7 @@ public class RunCommand
public RunCommand(
bool noBuild,
string? projectFileOrDirectory,
string launchProfile,
string? launchProfile,
bool noLaunchProfile,
bool noLaunchProfileArguments,
bool noRestore,
Expand Down Expand Up @@ -145,7 +145,7 @@ public int Execute()
}
}

private void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, ProjectLaunchSettingsModel? launchSettings)
internal void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, ProjectLaunchSettingsModel? launchSettings)
{
if (launchSettings == null)
{
Expand All @@ -172,7 +172,7 @@ private void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, Project
}
}

private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? launchSettingsModel)
internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? launchSettingsModel)
{
launchSettingsModel = default;
if (NoLaunchProfile)
Expand Down Expand Up @@ -310,7 +310,7 @@ internal static VerbosityOptions GetDefaultVerbosity(bool interactive)
return interactive ? VerbosityOptions.minimal : VerbosityOptions.quiet;
}

private ICommand GetTargetCommand(Func<ProjectCollection, ProjectInstance>? projectFactory)
internal ICommand GetTargetCommand(Func<ProjectCollection, ProjectInstance>? projectFactory)
{
FacadeLogger? logger = LoggerUtility.DetermineBinlogger(RestoreArgs, "dotnet-run");
var project = EvaluateProject(ProjectFileFullPath, projectFactory, RestoreArgs, logger);
Expand Down
3 changes: 2 additions & 1 deletion src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public VirtualProjectBuildingCommand(
public Dictionary<string, string> GlobalProperties { get; }
public string[] BinaryLoggerArgs { get; }
public VerbosityOptions Verbosity { get; }
public string? CustomArtifactsPath { get; init; }
public bool NoRestore { get; init; }
public bool NoCache { get; init; }
public bool NoBuild { get; init; }
Expand Down Expand Up @@ -431,7 +432,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
}
}

private string GetArtifactsPath() => GetArtifactsPath(EntryPointFileFullPath);
private string GetArtifactsPath() => CustomArtifactsPath ?? GetArtifactsPath(EntryPointFileFullPath);

// internal for testing
internal static string GetArtifactsPath(string entryPointFileFullPath)
Expand Down
23 changes: 23 additions & 0 deletions test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1393,4 +1393,27 @@ public void Api_Error()
.And.HaveStdOutContaining("Unknown1")
.And.HaveStdOutContaining("Unknown2");
}

[Fact]
public void Api_RunCommand()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
var programPath = Path.Join(testInstance.Path, "Program.cs");
File.WriteAllText(programPath, """
Console.WriteLine();
""");

string artifactsPath = OperatingSystem.IsWindows() ? @"C:\artifacts" : "/artifacts";
string executablePath = OperatingSystem.IsWindows() ? @"C:\artifacts\bin\debug\Program.exe" : "/artifacts/bin/debug/Program";
new DotnetCommand(Log, "run-api")
.WithStandardInput($$"""
{"$type":"GetRunCommand","EntryPointFileFullPath":{{ToJson(programPath)}},"ArtifactsPath":{{ToJson(artifactsPath)}}}
""")
.Execute()
.Should().Pass()
// DOTNET_ROOT environment variable is platform dependent so we don't verify it fully for simplicity
.And.HaveStdOutContaining($$"""
{"$type":"RunCommand","Version":1,"ExecutablePath":{{ToJson(executablePath)}},"CommandLineArguments":"","WorkingDirectory":"","EnvironmentVariables":{"DOTNET_ROOT
""");
}
}