|
| 1 | +# `dotnet run file.cs` |
| 2 | + |
| 3 | +This is a proposal for extending the dotnet CLI to allow running C# source files with no need for an explicit backing project. |
| 4 | +We call these *file-based programs* (as opposed to *project-based programs*). |
| 5 | + |
| 6 | +```ps1 |
| 7 | +dotnet run file.cs |
| 8 | +``` |
| 9 | + |
| 10 | +> [!NOTE] |
| 11 | +> This document describes the ideal final state, but the feature will be implemented in [stages](#stages). |
| 12 | +
|
| 13 | +> [!CAUTION] |
| 14 | +> The current implementation has been limited to single file support for the initial preview |
| 15 | +> (as if the implicit project file had `<EnableDefaultItems>false</EnableDefaultItems>` and an explicit `<Compile Include="file.cs" />`), |
| 16 | +> but this proposal describes a situation where all files in the target directory are included. |
| 17 | +> Once a final decision is made, the proposal will be updated. |
| 18 | +
|
| 19 | +## Motivation |
| 20 | + |
| 21 | +File-based programs |
| 22 | +- should be a viable alternative to using PowerShell/bash scripts in .NET repos, and |
| 23 | +- lower the entry barrier for new customers. |
| 24 | + |
| 25 | +## Guiding principle |
| 26 | + |
| 27 | +The overarching guiding principle is that file-based programs have a simple and reliable [grow up](#grow-up) story to project-based programs. |
| 28 | +Previous file-based approaches like scripting are a variant of C# and as such have no simple and reliable grow up story. |
| 29 | + |
| 30 | +## Implicit project file |
| 31 | + |
| 32 | +The [guiding principle](#guiding-principle) implies that we can think of file-based programs as having an implicit project file. |
| 33 | + |
| 34 | +The implicit project file is the default project that would be created by running `dotnet new console`. |
| 35 | +This means that the behavior of `dotnet run file.cs` can change between SDK versions if the `dotnet new console` template changes. |
| 36 | +In the future we can consider supporting more SDKs like the Web SDK. |
| 37 | + |
| 38 | +## Grow up |
| 39 | + |
| 40 | +When file-based programs reach an inflection point where build customizations in a project file are needed, |
| 41 | +a single CLI command can be executed to generate a project file. |
| 42 | +In fact, this command simply materializes the [implicit project file](#implicit-project-file) on disk. |
| 43 | +This action should not change the behavior of the target program. |
| 44 | + |
| 45 | +```ps1 |
| 46 | +dotnet project add |
| 47 | +``` |
| 48 | + |
| 49 | +## Target path |
| 50 | + |
| 51 | +The path passed to `dotnet run ./some/path.cs` is called *the target path*. |
| 52 | +If it is a file, it is called *the target file*. |
| 53 | +*The target directory* is the directory of the target file, or the target path if it is not a file. |
| 54 | + |
| 55 | +We can consider adding an option like `dotnet run --from-stdin` which would read the C# file from the standard input. |
| 56 | +In this case, the current working directory would not be used to search for project or other C# files, |
| 57 | +the compilation would consist solely of the single file read from the standard input. |
| 58 | +Similarly, it could be possible to specify the whole C# source text in a command-like argument |
| 59 | +like `dotnet run --code 'Console.WriteLine("Hi")'`. |
| 60 | + |
| 61 | +## Integration into the existing `dotnet run` command |
| 62 | + |
| 63 | +`dotnet run file.cs` already has a meaning if there is a project file inside the current directory, |
| 64 | +specifically `file.cs` is passed as the first command-line argument to the target program. |
| 65 | +We preserve this behavior to avoid a breaking change. |
| 66 | +The file-based build and run kicks in only when: |
| 67 | +- a project file cannot be found (in the current directory or via the `--project` option), and |
| 68 | +- if the target path is a file, it has the `.cs` file extension, and |
| 69 | +- the target path (file or directory) exists. |
| 70 | + |
| 71 | +> [!NOTE] |
| 72 | +> This means that `dotnet run path` stops working when a file-based program [grows up](#grow-up) into a project-based program. |
| 73 | +> |
| 74 | +> Users could avoid that by using `cd path; dotnet run` instead. For that to work always (before and after grow up), |
| 75 | +> `dotnet run` without a `--project` argument and without a project file in the current directory |
| 76 | +> would need to search for a file-based program in the current directory instead of failing. |
| 77 | +> |
| 78 | +> We can also consider adding some universal option that would work with both project-based and file-based programs, |
| 79 | +> like `dotnet run --directory ./dir/`. For inspiration, `dotnet test` also has a `--directory` option. |
| 80 | +> Although users might expect there to be a `--file` option, as well. Both could be unified as `--path`. |
| 81 | +> |
| 82 | +> If we want to also support [multi-entry-point scenarios](#multiple-entry-points), |
| 83 | +> we might need an option like `dotnet run --entry ./dir/name` |
| 84 | +> which would work for both `./dir/name.cs` and `./dir/name/name.csproj`. |
| 85 | +
|
| 86 | +File-based programs are processed by `dotnet run` equivalently to project-based programs unless specified otherwise in this document. |
| 87 | +For example, the remaining command-line arguments after the first argument (the target path) are passed through to the target app |
| 88 | +(except for the arguments recognized by `dotnet run` unless they are after the `--` separator). |
| 89 | + |
| 90 | +## Entry points |
| 91 | + |
| 92 | +If a file is given to `dotnet run`, it has to be an *entry-point file*, otherwise an error is reported. |
| 93 | +We want to report an error for non-entry-point files to avoid the confusion of being able to `dotnet run util.cs`. |
| 94 | + |
| 95 | +Currently, entry-point files must contain top-level statements, |
| 96 | +but other entry-point forms like classic `Main` method can be recognized in the future. |
| 97 | +We could modify Roslyn to accept the entry-point path and then it would be the compiler's responsibility |
| 98 | +to check whether the file contains an entry point (of any kind) and report an error otherwise. |
| 99 | + |
| 100 | +Because of the [implicit project file](#implicit-project-file), |
| 101 | +other files in the target directory or its subdirectories are included in the compilation. |
| 102 | +For example, other `.cs` files but also `.resx` (embedded resources). |
| 103 | +Similarly, implicit build files like `Directory.Build.props` or `Directory.Packages.props` are used during the build. |
| 104 | + |
| 105 | +> [!NOTE] |
| 106 | +> Performance issues might arise if there are many [nested files](#nested-files) (possibly unintentionally), |
| 107 | +> and also it might not be clear to users that `dotnet run file.cs` will include other `.cs` files in the compilation. |
| 108 | +> Therefore we could consider some switch (a command-line option and/or a `#` language directive) to enable/disable this behavior. |
| 109 | +> When disabled, [grow up](#grow-up) would generate projects in subdirectories similarly to [multi-entry-point scenarios](#multiple-entry-points) |
| 110 | +> to preserve the behavior. |
| 111 | +
|
| 112 | +### Nested files |
| 113 | + |
| 114 | +If there are nested project files like |
| 115 | +``` |
| 116 | +App/File.cs |
| 117 | +App/Nested/Nested.csproj |
| 118 | +App/Nested/File.cs |
| 119 | +``` |
| 120 | +executing `dotnet run app/file.cs` includes the nested `.cs` file in the compilation. |
| 121 | +That might be unexpected, hence we could consider reporting an error in such situation. |
| 122 | +However, the same problem exists for normal builds with explicit project files |
| 123 | +and usually the build fails because there are multiple entry points or other clashes. |
| 124 | + |
| 125 | +Similarly, we could report an error if there are many nested directories and files, |
| 126 | +so for example if someone puts a C# file into `C:/sources` |
| 127 | +and executes `dotnet run C:/sources/file.cs` or opens that in the IDE, we do not walk all user's sources. |
| 128 | +Again, this problem exists with project-based programs as well. |
| 129 | +Note that having a project-based or file-based program in the drive root would result in |
| 130 | +[error MSB5029](https://learn.microsoft.com/visualstudio/msbuild/errors/msb5029). |
| 131 | + |
| 132 | +### Multiple entry points |
| 133 | + |
| 134 | +If there are multiple entry-point files in the target directory, the target path must be a file |
| 135 | +(an error is reported if it points to a directory instead). |
| 136 | +Then the build ignores other entry-point files. |
| 137 | + |
| 138 | +Thanks to this, it is possible to have a structure like |
| 139 | +``` |
| 140 | +App/Util.cs |
| 141 | +App/Program1.cs |
| 142 | +App/Program2.cs |
| 143 | +``` |
| 144 | +where either `Program1.cs` or `Program2.cs` can be run and both of them have access to `Util.cs`. |
| 145 | + |
| 146 | +In this case, there are multiple implicit projects |
| 147 | +(and during [grow up](#grow-up), multiple project files are materialized |
| 148 | +and the original C# files are moved to the corresponding project subdirectories): |
| 149 | +``` |
| 150 | +App/Shared/Util.cs |
| 151 | +App/Program1/Program1.cs |
| 152 | +App/Program1/Program1.csproj |
| 153 | +App/Program2/Program2.cs |
| 154 | +App/Program2/Program2.csproj |
| 155 | +``` |
| 156 | + |
| 157 | +The generated folders might need to be named differently to avoid clashes with existing folders. |
| 158 | + |
| 159 | +The entry-point projects (`Program1` and `Program2` in our example) |
| 160 | +have the shared `.cs` files source-included via `<Content Include="../Shared/**/*.cs" />`. |
| 161 | +We could consider having the projects directly in the top-level folder instead |
| 162 | +but that might result in clashes of build outputs that are not project-scoped, like `project.assets.json`. |
| 163 | +If we did that though, it would be enough to exclude the other entry points rather than including all the shared `.cs` files. |
| 164 | + |
| 165 | +Unless the [artifacts output layout][artifacts-output] is used (which is recommended), |
| 166 | +those implicit projects mean that build artifacts are placed under those implicit directories |
| 167 | +even though they don't exist on disk prior to build: |
| 168 | +``` |
| 169 | +App/Program1/bin/ |
| 170 | +App/Program1/obj/ |
| 171 | +App/Program2/bin/ |
| 172 | +App/Program2/obj/ |
| 173 | +``` |
| 174 | + |
| 175 | +## Package references |
| 176 | + |
| 177 | +It is possible to specify NuGet package references via the `#package` directive. |
| 178 | + |
| 179 | +```cs |
| 180 | +#package Newtonsoft.Json 13.0.1 |
| 181 | +``` |
| 182 | + |
| 183 | +The C# language needs to be updated to ignore these directives (instead of failing the compilation). |
| 184 | +See [the corresponding language proposal][pound]. |
| 185 | + |
| 186 | +If these directives were limited by the language to only appear near the top of the file (similar to `#define` directives), |
| 187 | +the dotnet CLI could be more efficient in searching for them. |
| 188 | + |
| 189 | +It should be also possible to look for these directives from the dotnet CLI via a regex instead of parsing the whole C# file via Roslyn. |
| 190 | + |
| 191 | +We do not limit `#package` directives to appear only in entry point files. |
| 192 | +Indeed, it might be beneficial to let a non-entry-point file like `Util.cs` be self-contained and have all the `#package`s it needs specified in it, |
| 193 | +which also makes it possible to share it independently or symlink it to multiple script folders. |
| 194 | +This is also similar to `global using`s which users usually put into a single file but don't have to. |
| 195 | + |
| 196 | +We could consider deduplicating `#package` directives (if they have the same version) |
| 197 | +so separate "self-contained" utilities can reference overlapping sets of packages |
| 198 | +even if they end up in the same compilation. |
| 199 | +But for starters we can simply translate every `#package` directive into `<PackageReference>` |
| 200 | +and let the existing MSBuild/NuGet logic deal with duplicates. |
| 201 | + |
| 202 | +It is valid to have a `#package` directive without a version. |
| 203 | +That's useful when central package management (CPM) is used. |
| 204 | +NuGet will report an appropriate error if the version is missing and CPM is not enabled. |
| 205 | + |
| 206 | +During [grow up](#grow-up), `#package` directives are removed from the `.cs` files and turned into `<PackageReference>` elements in the corresponding `.csproj` files. |
| 207 | +For project-based programs, `#package` directives are an error (reported by Roslyn when it's told it is in "project-based" mode). |
| 208 | + |
| 209 | +## SDK directive |
| 210 | + |
| 211 | +We could also recognize `#sdk` directive to allow web file-based programs for example. |
| 212 | + |
| 213 | +```cs |
| 214 | +#sdk Microsoft.NET.Sdk.Web |
| 215 | +``` |
| 216 | + |
| 217 | +It should have similar restrictions as the `#package` directive. |
| 218 | +It should also be an error to specify multiple different `#sdk` directives |
| 219 | +but it could be allowed to specify the same SDK multiple times similarly to `#package` directives |
| 220 | +(again so that self-contained utility files can declare their required SDK). |
| 221 | + |
| 222 | +## Shebang |
| 223 | + |
| 224 | +Along with `#package`, the language can also ignore `#!` which could be then used for [shebang][shebang] support. |
| 225 | + |
| 226 | +```cs |
| 227 | +#!/usr/bin/dotnet run |
| 228 | +Console.WriteLine("Hello"); |
| 229 | +``` |
| 230 | + |
| 231 | +It might be beneficial to also ship `dotnet-run` binary |
| 232 | +(or `dotnet-run-file` that would only work with file-based programs, not project-based ones, perhaps simply named `cs`) |
| 233 | +because some shells do not support multiple command-line arguments in the shebang |
| 234 | +which is needed if one wants to use `/usr/bin/env` to find the `dotnet` executable |
| 235 | +(although `-S` argument can be sometimes used to enable multiple argument support): |
| 236 | + |
| 237 | +```cs |
| 238 | +#!/usr/bin/env dotnet run |
| 239 | +// ^ Might not work in all shells. "dotnet run" might be passed as a single argument to "env". |
| 240 | +``` |
| 241 | +```cs |
| 242 | +#!/usr/bin/env dotnet-run |
| 243 | +// ^ Should work in all shells. |
| 244 | +``` |
| 245 | +```cs |
| 246 | +#!/usr/bin/env -S dotnet run |
| 247 | +// ^ Workaround in some shells. |
| 248 | +``` |
| 249 | + |
| 250 | +We could also consider making `dotnet file.cs` work because `dotnet file.dll` also works today |
| 251 | +but that would require changes to the native dotnet host. |
| 252 | + |
| 253 | +## Other commands |
| 254 | + |
| 255 | +We can consider supporting other commands like `dotnet build`, `dotnet pack`, `dotnet watch`. |
| 256 | + |
| 257 | +These commands need to have a way to receive the target path similarly to `dotnet run`, |
| 258 | +e.g., via options like `--directory`/`--entry` as described [above](#integration-into-the-existing-dotnet-run-command), |
| 259 | +or as the first argument if it makes sense for them. |
| 260 | + |
| 261 | +We could also add `dotnet compile` command that would be the equivalent of `dotnet build` but for file-based programs |
| 262 | +(because "compiling" might make more sense for file-based programs than "building"). |
| 263 | + |
| 264 | +### `dotnet package add` |
| 265 | + |
| 266 | +Adding package references via `dotnet package add` could be supported for file-based programs as well, |
| 267 | +i.e., the command would add a `#package` directive to the top of a `.cs` file. |
| 268 | + |
| 269 | +## Implementation |
| 270 | + |
| 271 | +The build is performed using MSBuild APIs on in-memory project files. |
| 272 | + |
| 273 | +### Optimizations |
| 274 | + |
| 275 | +MSBuild invocation can be skipped in subsequent `dotnet run file.cs` invocations if an up-to-date check detects that inputs didn't change. |
| 276 | +We always need to re-run MSBuild if implicit build files like `Directory.Build.props` change but |
| 277 | +from `.cs` files, the only relevant MSBuild inputs are the `#package` directives, |
| 278 | +hence we can first check the `.cs` file timestamps and for those that have changed, compare the sets of `#package` directives. |
| 279 | +If only `.cs` files change, it is enough to invoke `csc.exe` (directly or via a build server) |
| 280 | +re-using command-line arguments that the last MSBuild invocation passed to the compiler. |
| 281 | +If no inputs change, it is enough to start the target executable without invoking the build at all. |
| 282 | + |
| 283 | +### Stages |
| 284 | + |
| 285 | +The plan is to implement the feature in stages (the order might be different): |
| 286 | + |
| 287 | +- Bare bones `dotnet run file.cs` support: only files, not folders; a single entry-point; no optimizations. |
| 288 | +- Optimizations (caching / up-to-date check). |
| 289 | +- Multiple entry points. |
| 290 | +- Grow up command. |
| 291 | +- Folder support: `dotnet run ./dir/`. |
| 292 | +- Package references via `#package`. |
| 293 | + |
| 294 | +## Alternatives |
| 295 | + |
| 296 | +### Explicit importing |
| 297 | + |
| 298 | +Instead of implicitly including files from the target directory, the importing could be explicit, like via a directive: |
| 299 | + |
| 300 | +```cs |
| 301 | +#import ./another-file.cs |
| 302 | +``` |
| 303 | + |
| 304 | +<!-- |
| 305 | +## Links |
| 306 | +--> |
| 307 | + |
| 308 | +[artifacts-output]: https://learn.microsoft.com/dotnet/core/sdk/artifacts-output |
| 309 | +[pound]: https://github.com/dotnet/csharplang/issues/3507 |
| 310 | +[shebang]: https://en.wikipedia.org/wiki/Shebang_%28Unix%29 |
0 commit comments