Skip to content

Add file-based program API for use by IDE #48749

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

Merged
merged 8 commits into from
May 12, 2025
Merged

Conversation

jjonescz
Copy link
Member

No description provided.

@jjonescz jjonescz added the Area-run-file Items related to the "dotnet run <file>" effort label Apr 28, 2025
@jjonescz jjonescz requested a review from a team April 28, 2025 13:15
@jjonescz jjonescz marked this pull request as ready for review April 28, 2025 14:39
@Copilot Copilot AI review requested due to automatic review settings April 28, 2025 14:39
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This pull request adds a new file-based program API intended for IDE integration by introducing a new run-api command, updating parser and project conversion logic, and expanding test coverage.

  • Introduces new tests for API behavior and diagnostic responses in RunFileTests.
  • Refactors VirtualProjectBuildingCommand to use static helpers and updated error reporting.
  • Updates TestCommand and SdkCommandSpec to support standard input redirection and adds the run-api command to the parser.

Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/dotnet.Tests/CommandTests/Run/RunFileTests.cs Added tests for API functionality and diagnostics for file-based programs.
test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs Updated project conversion calls to use revised directive methods.
test/Microsoft.NET.TestFramework/Commands/TestCommand.cs Added support for redirecting standard input in tests.
test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs Updated ProcessStartInfo configuration for standard input redirection.
src/Cli/dotnet/Parser.cs Integrated the new run-api command into the parser.
src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs Refactored methods to use static helpers and improved error reporting with a diagnostics builder.
src/Cli/dotnet/Commands/Run/Api/RunApiCommandParser.cs and RunApiCommand.cs Added a new API command for handling file-based program inputs and outputs.
src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs Updated project conversion to call the revised directive methods correctly.

@RikkiGibson RikkiGibson self-assigned this Apr 28, 2025
Copy link
Member

@RikkiGibson RikkiGibson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. It would be good to provide a self contained sample usage of the CLI--just a simple case which shows, say, running the command from CLI, piping in some data, and getting a result out on the other end, which would indicate the expected schema of the command.

{
public override int Execute()
{
for (string line; (line = Console.ReadLine()) != null;)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't seen this kind of pattern before, is this a norm for apps which communicate using json over stdio? To essentially put each message on its own line?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON separated by newlines seemed simple and straightforward, but feel free to suggest alternatives (especially what IDE would like to communicate with). I guess JSON-RPC is more standard, but likely needs more code / libraries. Instead of stdio we could also use named pipes like compiler server does.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have particular concerns about the current solution, just trying to make sure I understand the design. Thanks!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd definitely consider JSON-RPC as it is a standard for this kind of stuff.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I did the out-of-proc build host stuff, I implemented my own RPC via single-line as well. That said, it's a bit unclear to me why we're needing multiple lines like this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, it's a bit unclear to me why we're needing multiple lines like this.

If you expect IDE is going to make only one-off requests (i.e., run new dotnet run-api process for each project separately), we can definitely simplify this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are no preferences, should we proceed with the current state for starters? If needed, we can change the API later (the run-api command is hidden, so I think we are free to change it).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally have no concerns about the current state. I don't see any reason the command shouldn't handle sequenced requests and responses, even if IDE doesn't make use of that capability.

If we found that spinning a new subprocess to make a project, each time #:s are edited or under other various "relatively infrequent" conditions (as compared to updates of ordinary source code), is problematic, then we could start keeping the subprocess alive for some period of time, and making use of this queueing capability.

Copy link
Member Author

@jjonescz jjonescz May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd definitely consider JSON-RPC as it is a standard for this kind of stuff.

@MiYanni Does this comment mean you want me to switch to json-rpc before merging this? There would be some questions like if we want a full compliance with the standard, then we probably need some library (which also means ship a new DLL along the sdk, and likely a new DLL load on the IDE side). Or we can implement this manually like Jason did for out-of-proc (@jasonmalinowski, can you point me to your implementation?). But perhaps we can follow up later since this is an internal API between SDK and IDE (and will need changing anyway for multi file support)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public sealed class GetProject : RunApiInput
{
public string? ArtifactsPath { get; init; }
public required string EntryPointFileFullPath { get; init; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This represents the primary bit of information that IDE must provide, right? This means that IDE must come up with a solution for searching, from a loose file that was opened, to find a "nearest relevant entry point", cracking the files as we go, and pass that in to dotnet cli. I think that's something we can do, just making sure of the expectation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is for single-file currently, so it should be simple - just pass the current file as the EntryPointFileFullPath.

For multi-file scenarios, I agree you would need to do the search you describe. The API will then also take the list of files to exclude (i.e., the other entry points). The implementation can automatically discover the other .cs files to include (which it needs to read for directives) based on the entry point or we can pass them to the API if that would be better for perf (since the IDE might have already discovered them via its more efficient file watchers).

Copy link
Member Author

@jjonescz jjonescz Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, it might be useful if the IDE checks SDK's version to determine whether run-api is present and whether it supports single-file or multi-file scenarios (and perhaps more in the future) - so the IDE can give better error messages when the SDK being used is not in sync with the features implemented in the IDE.

We could also introduce some run-api call like "GetCapabilities" that the IDE could use (and for SDKs that don't contain run-api command at all, presumably the IDE could detect that from the error message SDK produces when trying to run dotnet run-api).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a version stamp in requests/responses would give us enough expressiveness here? That would be a bit like SymbolKey.FormatVersion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be useful if the IDE checks SDK's version to determine whether run-api is present

If we have to do things like that, then I'd expect we have some API call we do for "get entry point discovery algorithm" or something, rather than an explicit check on the SDK version directly, especially if we try something experimentally and then have to roll it back in the SDK for a few weeks or something.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and consider if/when we ever have a situation where the user's repo might opt them into some other behavior)

""")}},"Diagnostics":
[{"Location":{
"Path":{{ToJson(programPath)}},
"Span":{"Start":{"Line":0,"Character":0},"End":{"Line":1,"Character":0}{{nop}}}{{nop}}},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, is this nop breaking-up the }s in order to keep distinct which ones are ending an interpolation and which ones are part of the content?

Copy link
Member Author

@jjonescz jjonescz Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nop is to avoid compiler errors due to the closing brackets }} inside an interpolated string $$""". (the errors are redundant IMO, the interpolated string could be compiled just fine even though it contains unmatched }}.) See also the doc comment at the nop constant.

Example:

var s1 = $$"""{"x":{{1}},"y":{"z":2}}"""; // error on the last }}

var s2 = $$$"""{"x":{{{1}}},"y":{"z":2}}"""; // ok but interpolation holes need soo many brackets (even more when the JSON is more nested)

const string nop = "";

var s3 = $$"""{"x":{{1}},"y":{"z":2}{{nop}}}"""; // workaround (we can keep the number of `$$` constant wrt nesting of the JSON)

/// (e.g., show an error message when an incompatible SDK version is being used).
/// </summary>
[JsonPropertyOrder(-1)]
public int Version { get; } = 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might also make sense for caller to pass in the "version" of the API they are using, implying the set of behaviors they expect, and the run-api command can reject the request in a well-defined way if it's not "compatible" with that version.

Copy link
Member

@RikkiGibson RikkiGibson Apr 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it looks like this property is on the base type? So the caller can always just read the version that comes out. It would be good to verify that this property is present in an error case. No need for caller to pass in the version.

@jjonescz
Copy link
Member Author

jjonescz commented May 2, 2025

@chsienki @MiYanni for reviews, thanks

Copy link
Member

@MiYanni MiYanni left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just recreated this issue as it relates to output from the CLI. JSON will be one of the bespoke formats.

{
public override int Execute()
{
for (string line; (line = Console.ReadLine()) != null;)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd definitely consider JSON-RPC as it is a standard for this kind of stuff.

@jjonescz jjonescz requested a review from MiYanni May 6, 2025 08:32
@RikkiGibson
Copy link
Member

@MiYanni for review

Copy link
Member

@MiYanni MiYanni left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current design is fine. My comment about JSON-RPC is generally for if the implementation grows, it is definitely something that should be considered. But I'm not going to halt merging this on doing all that work.

@jjonescz jjonescz merged commit bf4223b into dotnet:main May 12, 2025
30 checks passed
@jjonescz jjonescz deleted the sprint-api-cli branch May 12, 2025 14:17
@RikkiGibson
Copy link
Member

I'd like to keep an eye out for a nightly SDK which has this included.

Copy link
Member

@jasonmalinowski jasonmalinowski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies I didn't have a chance to leave comments prior to this merging -- and worse off the few I had written I forgot to post.

{
Command command = new("run-api")
{
Hidden = true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there precedent for other commands like this? Rather than having a new command can we/should we hide this behind just an --api flag on dotnet run?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert on dotnet.exe CLI, but I think hiding this under dotnet run --api means all the other dotnet run flags can still be passed and we would need to manually check and error on them, e.g., dotnet run --no-restore --api and so on.

Comment on lines +86 to +87
/// 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).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect that the version number isn't stated here -- we've already had to assume the shape of the output to even read this! Maybe we need to do a few more things:

  1. The version number could stated outside of the first JSON payload (maybe just on a line of it's own), simply because by the time we're reading this value we've already more or less had to assume the JSON output format to a certain extent. This would be critical if/when we have to break the communication format more generally (i.e. we need to switch to MessagePack because this payload is getting too big to use.)
  2. The invocation of the API command from the IDE includes as an input switch the max supported version. That way if we're in a situation where you need to ship a breaking change in the SDK to fix an issue, you can still potentially return an older version for a downlevel client until clients catch up. I recognize the SDK doesn't want to be supporting downlevel clients forever, but it could still at least be useful where if we have to ship a break in Preview n, you can drop the support in Preivew (n+1), but for that few week window while the preview is being built and folks have a mismatch of client versions, we can still negotiate a version that works.

{
public override int Execute()
{
for (string? line; (line = Console.ReadLine()) != null;)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you haven't already created one, add a test for non-ASCII stuff being included in the stream. One "fun" discovery we had when making our own JSON protocol similar to this for the MSBuildWorkspace build host stuff was that the console code page might gets screwed up in funny ways and mangle stuff. Since we're (currently) using Newtonsoft.Json our hack was just:

https://github.com/dotnet/roslyn/blob/0d6bd9de68dc7e15165dffc8934fe2f473b2c171/src/Workspaces/MSBuild/BuildHost/Rpc/Contracts/JsonSettings.cs#L25-L27

Not sure if you have a similar option here.

}
catch (Exception ex)
{
Respond(new RunApiOutput.Error { Message = ex.Message, Details = ex.ToString() });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just establishing the contract -- should we also be listening to stderr as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also be listening to stderr as well?

You mean from the IDE? I think you should, although the code inside this try/catch will report all its errors as stdout json message, other code above this (like command-line parsing) might not.

}
}

[JsonDerivedType(typeof(GetProject), nameof(GetProject))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to specify a TypeDescriminator here so it's very clear we're not using the class name (which might get renamed) as the actual API contract?

Comment on lines +79 to +80
[JsonDerivedType(typeof(Error), nameof(Error))]
[JsonDerivedType(typeof(Project), nameof(Project))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same potential comment as earlier: let's have explicit type discriminators here rather than implicitly encoding a type name.

errors.Add(new SimpleDiagnostic
{
Location = sourceFile.GetFileLinePositionSpan(trivia.Span),
Message = string.Format(CliCommandStrings.CannotConvertDirective, location),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which language is this being localized in? I wonder if we'll need to pass something along to give you a hint.


public sealed class GetProject : RunApiInput
{
public string? ArtifactsPath { get; init; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What it does mean for us to pass this in? I would have imagined you were returning it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is optional, the SDK normally computes it, but you can overwrite it which is useful for tests. I will add a clarifying comment.

@@ -967,6 +1009,27 @@ private Package() { }
}
}

internal sealed class SimpleDiagnostic
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need some sort of error/warning concept?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not right now, we can always add a severity property later, that shouldn't break compatibility

"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(""));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON output is always on a single line?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-run-file Items related to the "dotnet run <file>" effort
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants