diff --git a/.github/copilot/skills/incremental-test.md b/.github/copilot/skills/incremental-test.md index a15c42f6cd28..7422b8a28d79 100644 --- a/.github/copilot/skills/incremental-test.md +++ b/.github/copilot/skills/incremental-test.md @@ -6,6 +6,7 @@ Use it after making source code changes to quickly build only the modified proje ## Prerequisites - A full build must have been completed at least once (via `build.cmd` or `build.sh`) so that the redist SDK layout exists at `artifacts\bin\redist\Debug\dotnet\sdk\\`. +- The repo-local `.dotnet` SDK must match the version expected by the test projects. If the runtime or SDK version is out of date (e.g., test build fails with a missing framework error), run `.\restore.cmd` (or `./restore.sh` on macOS/Linux) to download the correct SDK into `.dotnet`. - This workflow uses Windows/PowerShell commands and paths. On macOS/Linux, substitute forward slashes and use `cp` instead of `Copy-Item`. ## When to use diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 9b731cc3e561..9c9ed03ab167 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -112,8 +112,8 @@ If a dash (`-`) is given instead of the target path (i.e., `dotnet run -`), the In this case, the current working directory is not used to search for other files (launch profiles, other sources in case of multi-file apps); the compilation consists solely of the single file read from the standard input. However, the current working directory is still used as the working directory for building and executing the program. -To reference projects relative to the current working directory (instead of relative to the temporary directory the file is isolated in), -you can use something like `#:project $(MSBuildStartupDirectory)/relative/path`. +To reference projects or files relative to the current working directory (instead of relative to the temporary directory the file is isolated in), +you can use something like `#:project $(MSBuildStartupDirectory)/relative/path` or `#:ref $(MSBuildStartupDirectory)/relative/lib.cs`. `dotnet path.cs` is a shortcut for `dotnet run --file path.cs` provided that `path.cs` is a valid [target path](#target-path) (`dotnet -` is currently not supported) and it is not a DLL path, built-in command, or a NuGet tool (e.g., `dotnet watch` invokes the `dotnet-watch` tool @@ -180,11 +180,12 @@ which are [ignored][ignored-directives] by the C# language but recognized by the #:property LangVersion=preview #:package System.CommandLine@2.0.0-* #:project ../MyLibrary +#:ref ../lib/lib.cs #:include ./**/*.cs ``` Each directive has a kind (e.g., `package`), a name (e.g., `System.CommandLine`), a separator (e.g., `@`), and a value (e.g., the package version). -The value is required for `#:property`, optional for `#:package`/`#:sdk`, and disallowed for `#:project`/`#:include`. +The value is required for `#:property`, optional for `#:package`/`#:sdk`, and disallowed for `#:project`/`#:ref`/`#:include`. The name must be separated from the kind of the directive by whitespace and any leading and trailing white space is not considered part of the name and value. @@ -211,6 +212,26 @@ The directives are processed as follows: (because `ProjectReference` items don't support directory paths). An error is reported if zero or more than one projects are found in the directory, just like `dotnet reference add` would do. +- Each `#:ref` references another `.cs` file as a separate project reference. + A virtual project is created for the referenced file (e.g., `lib.cs` produces a virtual `lib.cs.csproj`), + and a `` is injected in an ``. + It is an error if the name is empty or if the referenced file does not exist. + Unlike `#:project`, `#:ref` points to a `.cs` file (not a `.csproj` file or directory). + + The referenced file is itself a file-based program with its own virtual project (defaulting to `OutputType=Exe`). + Library files without an entry point should use `#:property OutputType=Library` to avoid compilation errors. + Because the referenced file is compiled as a separate assembly, internal members of the referenced file are not accessible from the referencing file. + The `#:ref` directive is transitive: a referenced file can itself contain `#:ref` directives (or any other directives). + + Relative paths are resolved relative to the file containing the directive. + MSBuild variables (like `$(MSBuildProjectDirectory)`) can be used in the path. + + During [conversion](#grow-up), each `#:ref` directive creates a separate library project in a sibling directory + and a corresponding `` entry is added to the converted project. + The conversion is recursive: any `#:ref` directives in the referenced files are also converted in the same way. + + This directive is currently gated under a feature flag that can be enabled by setting the MSBuild property `ExperimentalFileBasedProgramEnableRefDirective=true`. + - Each `#:include` is injected as `<{1} Include="{0}" />` in an `` where `{0}` is the directive's value and `{1}` is determined by its extension. The mapping can be customized by setting the MSBuild property `FileBasedProgramsItemMapping` @@ -230,9 +251,9 @@ The directives are processed as follows: - Other directive kinds result in an error, reserving them for future use. Directive values support MSBuild variables (like `$(..)`) normally as they are translated literally and left to MSBuild engine to process. -However, in `#:project` directives, variables might not be preserved during [grow up](#grow-up), +However, in `#:project` and `#:ref` directives, variables might not be preserved during [grow up](#grow-up), because there is additional processing of those directives that makes it technically challenging to preserve variables in all cases -(project directive values need to be resolved to be relative to the target directory +(project and ref directive values need to be resolved to be relative to the target directory and also to point to a project file rather than a directory). Note that it is not expected that variables inside the path change their meaning during the conversion, so for example `#:project ../$(LibName)` is translated to `` (i.e., the variable is preserved). diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index cbe3d3838aa3..57a847ae25f7 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -165,6 +165,14 @@ The '#:project' directive is invalid: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + + + Could not find file '{0}'. + {0} is the file path. + Missing name of '{0}'. {0} is the directive name like 'package' or 'sdk'. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 89413dc860cc..bee6360deaaf 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -323,6 +323,7 @@ public void ReportError(TextSpan span, string message) case "property": return Property.Parse(context); case "package": return Package.Parse(context); case "project": return Project.Parse(context); + case "ref": return Ref.Parse(context); case "include" or "exclude": return IncludeOrExclude.Parse(context); default: context.ReportError(string.Format(FileBasedProgramsResources.UnrecognizedDirective, context.DirectiveKind)); @@ -587,6 +588,100 @@ void ReportError(string message) public override string ToString() => $"#:project {Name}"; } + /// + /// #:ref directive. References another file-based app as a library. + /// + public sealed class Ref : Named + { + public const string ExperimentalFileBasedProgramEnableRefDirective = nameof(ExperimentalFileBasedProgramEnableRefDirective); + + [SetsRequiredMembers] + public Ref(in ParseInfo info, string name) : base(info) + { + Name = name; + OriginalName = name; + } + + /// + /// Preserved across calls, i.e., + /// this is the original directive text as entered by the user. + /// + public string OriginalName { get; init; } + + /// + /// This is the with MSBuild $(..) vars expanded. + /// + public string? ExpandedName { get; init; } + + /// + /// The resolved full path to the referenced .cs file. + /// + public string? ResolvedPath { get; init; } + + public static new Ref? Parse(in ParseContext context) + { + var directiveText = context.DirectiveText; + if (directiveText.IsWhiteSpace()) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + return new Ref(context.Info, directiveText); + } + + public enum NameKind + { + /// + /// Change and . + /// + Expanded = 1, + + /// + /// Change and . + /// + Resolved = 2, + + /// + /// Change only . + /// + Final = 3, + } + + public Ref WithName(string name, NameKind kind) + { + return new Ref(Info, name) + { + OriginalName = OriginalName, + ExpandedName = kind == NameKind.Expanded ? name : ExpandedName, + ResolvedPath = kind == NameKind.Resolved ? name : ResolvedPath, + }; + } + + /// + /// Resolves the path relative to the source file's directory. + /// + public Ref EnsureResolvedPath(ErrorReporter errorReporter) + { + var sourcePath = Info.SourceFile.Path; + var sourceDirectory = Path.GetDirectoryName(sourcePath) + ?? throw new InvalidOperationException($"Source file path '{sourcePath}' does not have a containing directory."); + + var resolvedFilePath = Path.GetFullPath(Path.Combine(sourceDirectory, Name.Replace('\\', '/'))); + + if (!File.Exists(resolvedFilePath)) + { + errorReporter(Info.SourceFile.Text, sourcePath, Info.Span, + string.Format(FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, resolvedFilePath))); + } + + return WithName(resolvedFilePath, NameKind.Resolved); + } + + public override string ToString() => $"#:ref {Name}"; + } + public enum IncludeOrExcludeKind { Include, diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index 8beab97ae92c..bbf3d44bc01b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -3,6 +3,7 @@ const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.Experi const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableItemMapping = "ExperimentalFileBasedProgramEnableItemMapping" -> string! const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives = "ExperimentalFileBasedProgramEnableTransitiveDirectives" -> string! const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.MappingPropertyName = "FileBasedProgramsItemMapping" -> string! +const Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective = "ExperimentalFileBasedProgramEnableRefDirective" -> string! Microsoft.DotNet.FileBasedPrograms.CSharpDirective Microsoft.DotNet.FileBasedPrograms.CSharpDirective.CSharpDirective(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude @@ -71,6 +72,20 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Property(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.get -> string! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.EnsureResolvedPath(Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ExpandedName.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ExpandedName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind.Resolved = 2 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.OriginalName.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.OriginalName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.Ref(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info, string! name) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ResolvedPath.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ResolvedPath.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.WithName(string! name, Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind kind) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Sdk(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Version.get -> string? @@ -123,6 +138,7 @@ override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.IncludeOrExclude.ToS override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.ToString() -> string! +override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang.ToString() -> string! override Microsoft.DotNet.FileBasedPrograms.SourceFile.GetHashCode() -> int @@ -134,6 +150,7 @@ static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Parse(in Micro static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property? +static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk? static Microsoft.DotNet.FileBasedPrograms.ErrorReporters.CreateCollectingReporter(out System.Collections.Immutable.ImmutableArray.Builder! builder) -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.CombineHashCodes(int value1, int value2) -> int diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index fccd6ec81449..c7b527bc9754 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -17,6 +17,11 @@ Nenašel se projekt ani adresář {0}. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error chyba @@ -57,6 +62,11 @@ Direktiva #:project je neplatná: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Chybí název pro: {0}. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index acfc69549e69..ae85b7a2a9fb 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -17,6 +17,11 @@ Das Projekt oder Verzeichnis "{0}" wurde nicht gefunden. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error Fehler @@ -57,6 +62,11 @@ Die Anweisung „#:p roject“ ist ungültig: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Fehlender Name der Anweisung „{0}“. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 7fcfcf9a443e..99af05fd0a22 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -17,6 +17,11 @@ No se encuentra el proyecto o directorio "{0}". + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error error @@ -57,6 +62,11 @@ La directiva "#:project" no es válida: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Falta el nombre de "{0}". diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 88c34c93ba8a..90add5915dd1 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -17,6 +17,11 @@ Projet ou répertoire '{0}' introuvable. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error erreur @@ -57,6 +62,11 @@ La directive « #:project » n’est pas valide : {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Nom manquant pour « {0} ». diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 655d5b367356..47de1ef37010 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -17,6 +17,11 @@ Non sono stati trovati progetti o directory `{0}`. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error errore @@ -57,6 +62,11 @@ La direttiva '#:project' non è valida: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Manca il nome di '{0}'. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 2d2cc195c23a..573c9be2ff5c 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -17,6 +17,11 @@ プロジェクトまたはディレクトリ `{0}` が見つかりませんでした。 + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error エラー @@ -57,6 +62,11 @@ '#:p roject' ディレクティブが無効です: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. '{0}' の名前がありません。 diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index 7082b47f9aa8..b466d59e7a63 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -17,6 +17,11 @@ 프로젝트 또는 디렉터리 {0}을(를) 찾을 수 없습니다. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error 오류 @@ -57,6 +62,11 @@ '#:p roject' 지시문이 잘못되었습니다. {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. '{0}' 이름이 없습니다. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 4cd99b2c63c3..eaf6b0b78253 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -17,6 +17,11 @@ Nie można odnaleźć projektu ani katalogu „{0}”. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error błąd @@ -57,6 +62,11 @@ Dyrektywa „#:project” jest nieprawidłowa: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Brak nazwy „{0}”. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index a5ef266abb7e..72cc2e9f3706 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -17,6 +17,11 @@ Não foi possível encontrar o projeto ou diretório ‘{0}’. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error erro @@ -57,6 +62,11 @@ A diretiva '#:project' é inválida:{0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Nome de '{0}' ausente. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index 3405fe15916b..5d580c932efe 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -17,6 +17,11 @@ Не удалось найти проект или каталог "{0}". + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error ошибка @@ -57,6 +62,11 @@ Недопустимая директива "#:project": {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. Отсутствует имя "{0}". diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 40ee3f703b98..e64081b711aa 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -17,6 +17,11 @@ `{0}` projesi veya dizini bulunamadı. + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error hata @@ -57,6 +62,11 @@ ‘#:project’ yönergesi geçersizdir: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. '{0}' adı eksik. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index b5c92edfd759..bbcefb9f1d7f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -17,6 +17,11 @@ 找不到项目或目录“{0}”。 + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error 错误 @@ -57,6 +62,11 @@ '#:project' 指令无效: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. 缺少 '{0}' 的名称。 diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index d65f38fe59f2..ecfe1971bf1c 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -17,6 +17,11 @@ 找不到專案或目錄 `{0}`。 + + Could not find file '{0}'. + Could not find file '{0}'. + {0} is the file path. + error 錯誤 @@ -57,6 +62,11 @@ '#:project' 指示詞無效: {0} {0} is the inner error message. + + The '#:ref' directive is invalid: {0} + The '#:ref' directive is invalid: {0} + {Locked="#:ref"}{0} is the inner error message. + Missing name of '{0}'. 缺少 '{0}' 的名稱。 diff --git a/src/Cli/dotnet/Commands/CliCommandStrings.resx b/src/Cli/dotnet/Commands/CliCommandStrings.resx index 8987541a2308..7b40ab16089e 100644 --- a/src/Cli/dotnet/Commands/CliCommandStrings.resx +++ b/src/Cli/dotnet/Commands/CliCommandStrings.resx @@ -862,6 +862,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man No - keep all source files + + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Project(s) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index bab18b2e8ac2..4838b909c8ae 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -4,8 +4,8 @@ using System.Collections.Immutable; using System.CommandLine; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.FileBasedPrograms; @@ -58,90 +58,121 @@ public override int Execute() out var evaluatedDirectives, validateAllDirectives: !_force); + // When the entry point has #:ref directives, place all converted projects in subfolders. + bool hasRefs = evaluatedDirectives.Any(static d => d is CSharpDirective.Ref); + string entryPointName = Path.GetFileNameWithoutExtension(file); + string entryPointOutputDir = hasRefs ? Path.Combine(targetDirectory, entryPointName) : targetDirectory; + + // Pre-validate ref target directories (check for duplicates and existing dirs). + if (hasRefs) + { + var usedFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { entryPointName }; + ValidateRefTargetDirectories(evaluatedDirectives, Path.GetDirectoryName(file)!, + new HashSet(StringComparer.OrdinalIgnoreCase), usedFolderNames); + } + + ConvertFile(file, entryPointOutputDir, isEntryPointFile: true); + // Find other items to copy over, e.g., default Content items like JSON files in Web apps. - var includeItems = FindIncludedItems().ToList(); + var includeItems = FindIncludedItems(builder, projectInstance, file).ToList(); - CreateDirectory(targetDirectory); + // Convert referenced files (#:ref directives) into library projects. + var convertedRefFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var refIncludeItems = new List<(string ItemType, string FullPath, string RelativePath)>(); + ConvertReferencedFiles(evaluatedDirectives, Path.GetDirectoryName(file)!); - var targetFile = Path.Join(targetDirectory, Path.GetFileName(file)); + // Copy or move over included items. + CopyIncludedItems(includeItems, entryPointOutputDir); - // Process the entry point file. - if (_dryRun) - { - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, file, targetFile); - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetFile); - } - else + // Handle deletion of source files if requested. + bool shouldDelete = _deleteSource || TryAskForDeleteSource(); + if (shouldDelete) { - VirtualProjectBuildingCommand.RemoveDirectivesFromFile(builder.EntryPointSourceFile, targetFile); - } + // Delete the entry point file + DeleteFile(file); - // Create project file. - string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj"); - if (_dryRun) - { - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCreateFile, projectFile); - } - else - { - using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write); - using var writer = new StreamWriter(stream, Encoding.UTF8); - VirtualProjectBuilder.WriteProjectFile( - writer, - UpdateDirectives(evaluatedDirectives), - isVirtualProject: false, - userSecretsId: DetermineUserSecretsId(), - defaultProperties: GetDefaultProperties()); + // Delete all included items (e.g., via #:include directives and default items) + foreach (var item in includeItems) + { + DeleteFile(item.FullPath); + } + + // Delete converted referenced files and their included items + foreach (var refFile in convertedRefFiles) + { + DeleteFile(refFile); + } + + foreach (var item in refIncludeItems) + { + DeleteFile(item.FullPath); + } } - // Copy or move over included items. - foreach (var item in includeItems) + return 0; + + (VirtualProjectBuilder builder, ProjectInstance projectInstance, ImmutableArray evaluatedDirectives) + ConvertFile(string sourceFile, string outputDirectory, bool isEntryPointFile) { - string targetItemFullPath = Path.Combine(targetDirectory, item.RelativePath); + var sourceDirectory = Path.GetDirectoryName(sourceFile)!; - // Ignore already-copied files. - if (File.Exists(targetItemFullPath)) + VirtualProjectBuilder fileBuilder; + ProjectInstance fileProjectInstance; + ImmutableArray fileDirectives; + + if (isEntryPointFile) + { + fileBuilder = builder; + fileProjectInstance = projectInstance; + fileDirectives = evaluatedDirectives; + } + else { - continue; + fileBuilder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework); + + fileBuilder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + out fileProjectInstance, + projectRootElement: out _, + out fileDirectives, + validateAllDirectives: !_force); } - string targetItemDirectory = Path.GetDirectoryName(targetItemFullPath)!; - CreateDirectory(targetItemDirectory); + CreateDirectory(outputDirectory); - if (item.ItemType == "Compile") + // Copy the .cs file with directives removed. + var targetFile = Path.Join(outputDirectory, Path.GetFileName(sourceFile)); + if (_dryRun) { - if (_dryRun) - { - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, item.FullPath, targetItemFullPath); - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetItemFullPath); - } - else - { - var sourceFile = SourceFile.Load(item.FullPath); - VirtualProjectBuildingCommand.RemoveDirectivesFromFile(sourceFile, targetItemFullPath); - } + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, sourceFile, targetFile); + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetFile); } else { - CopyFile(item.FullPath, targetItemFullPath); + VirtualProjectBuildingCommand.RemoveDirectivesFromFile(fileBuilder.EntryPointSourceFile, targetFile); } - } - - // Handle deletion of source files if requested. - bool shouldDelete = _deleteSource || TryAskForDeleteSource(file); - if (shouldDelete) - { - // Delete the entry point file - DeleteFile(file); - // Delete all included items (e.g., via #:include directives and default items) - foreach (var item in includeItems) + // Create project file. + var projectFile = Path.Join(outputDirectory, Path.GetFileNameWithoutExtension(sourceFile) + ".csproj"); + if (_dryRun) { - DeleteFile(item.FullPath); + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCreateFile, projectFile); + } + else + { + using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write); + using var writer = new StreamWriter(stream, Encoding.UTF8); + VirtualProjectBuilder.WriteProjectFile( + writer, + UpdateDirectives(fileDirectives, sourceDirectory, outputDirectory), + isVirtualProject: false, + userSecretsId: isEntryPointFile ? DetermineUserSecretsId(fileProjectInstance) : null, + defaultProperties: GetDefaultProperties(fileProjectInstance)); } - } - return 0; + return (fileBuilder, fileProjectInstance, fileDirectives); + } void CreateDirectory(string path) { @@ -183,14 +214,125 @@ void DeleteFile(string path) } } - IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems() + void CopyIncludedItems(List<(string ItemType, string FullPath, string RelativePath)> items, string outputDirectory) { - string entryPointFileDirectory = PathUtilities.EnsureTrailingSlash(Path.GetDirectoryName(file)!); + foreach (var item in items) + { + string targetItemFullPath = Path.Combine(outputDirectory, item.RelativePath); + + // Ignore already-copied files. + if (File.Exists(targetItemFullPath)) + { + continue; + } + + string targetItemDirectory = Path.GetDirectoryName(targetItemFullPath)!; + CreateDirectory(targetItemDirectory); + + if (item.ItemType == "Compile") + { + if (_dryRun) + { + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, item.FullPath, targetItemFullPath); + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetItemFullPath); + } + else + { + var sourceFile = SourceFile.Load(item.FullPath); + VirtualProjectBuildingCommand.RemoveDirectivesFromFile(sourceFile, targetItemFullPath); + } + } + else + { + CopyFile(item.FullPath, targetItemFullPath); + } + } + } + + void ConvertReferencedFiles(ImmutableArray directives, string sourceDirectory) + { + foreach (var directive in directives) + { + if (directive is not CSharpDirective.Ref refDirective) + { + continue; + } + + var refPath = refDirective.ResolvedPath ?? Path.GetFullPath(Path.Combine(sourceDirectory, refDirective.Name.Replace('\\', '/'))); + + if (!convertedRefFiles.Add(refPath)) + { + continue; + } + + var refName = Path.GetFileNameWithoutExtension(refPath); + var refDir = Path.GetDirectoryName(refPath)!; + var refTargetDirectory = Path.Combine(targetDirectory, refName); + + var (refBuilder, refProjectInstance, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, isEntryPointFile: false); + + // Copy included items (e.g., default Content items) for the referenced file. + var items = FindIncludedItems(refBuilder, refProjectInstance, refPath).ToList(); + CopyIncludedItems(items, refTargetDirectory); + refIncludeItems.AddRange(items); + + // Recursively convert referenced files in the referenced file. + ConvertReferencedFiles(refEvaluatedDirectives, refDir); + } + } + + void ValidateRefTargetDirectories(ImmutableArray directives, string sourceDirectory, HashSet visited, HashSet usedFolderNames) + { + foreach (var directive in directives) + { + if (directive is not CSharpDirective.Ref refDirective) + { + continue; + } + + var refPath = refDirective.ResolvedPath ?? Path.GetFullPath(Path.Combine(sourceDirectory, refDirective.Name.Replace('\\', '/'))); + + if (!visited.Add(refPath)) + { + continue; + } + + var refName = Path.GetFileNameWithoutExtension(refPath); + var refDir = Path.GetDirectoryName(refPath)!; + var refTargetDirectory = Path.Combine(targetDirectory, refName); + + if (!usedFolderNames.Add(refName)) + { + throw new GracefulException(CliCommandStrings.ProjectConvertDuplicateRefFolderName, refTargetDirectory); + } + + if (Directory.Exists(refTargetDirectory)) + { + throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, refTargetDirectory); + } + + // Recursively validate transitive refs. + var refBuilder = new VirtualProjectBuilder(refPath, VirtualProjectBuildingCommand.TargetFramework); + refBuilder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + project: out _, + projectRootElement: out _, + out var refDirectives, + validateAllDirectives: !_force); + ValidateRefTargetDirectories(refDirectives, refDir, visited, usedFolderNames); + } + } + + IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems( + VirtualProjectBuilder fileBuilder, ProjectInstance fileProjectInstance, string sourceFile) + { + string sourceFileDirectory = PathUtilities.EnsureTrailingSlash(Path.GetDirectoryName(sourceFile)!); // Include only items we know are files. - var mapping = builder.GetItemMapping(projectInstance, VirtualProjectBuildingCommand.ThrowingReporter); + var mapping = fileBuilder.GetItemMapping(fileProjectInstance, VirtualProjectBuildingCommand.ThrowingReporter); - var items = mapping.SelectMany(e => projectInstance.GetItems(e.ItemType)); + var items = mapping.SelectMany(e => fileProjectInstance.GetItems(e.ItemType)); var topLevelFileNames = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -203,7 +345,7 @@ void DeleteFile(string path) continue; } - string itemFullPath = Path.GetFullPath(path: item.GetMetadataValue("FullPath"), basePath: entryPointFileDirectory); + string itemFullPath = Path.GetFullPath(path: item.GetMetadataValue("FullPath"), basePath: sourceFileDirectory); // Exclude items that do not exist. if (!File.Exists(itemFullPath)) @@ -211,7 +353,7 @@ void DeleteFile(string path) continue; } - string itemRelativePath = Path.GetRelativePath(relativeTo: entryPointFileDirectory, path: itemFullPath); + string itemRelativePath = Path.GetRelativePath(relativeTo: sourceFileDirectory, path: itemFullPath); // Files outside the source directory should be copied into the target directory at the top level. // Possibly with a number suffix to avoid conflicts. @@ -241,16 +383,17 @@ void DeleteFile(string path) } } - string? DetermineUserSecretsId() + string? DetermineUserSecretsId(ProjectInstance projectInstance) { var implicitValue = projectInstance.GetPropertyValue("_ImplicitFileBasedProgramUserSecretsId"); var actualValue = projectInstance.GetPropertyValue("UserSecretsId"); return implicitValue == actualValue ? actualValue : null; } - ImmutableArray UpdateDirectives(ImmutableArray directives) + ImmutableArray UpdateDirectives(ImmutableArray directives, string? sourceDirectory = null, string? outputDirectory = null) { - var sourceDirectory = Path.GetDirectoryName(file)!; + sourceDirectory ??= Path.GetDirectoryName(file)!; + outputDirectory ??= targetDirectory; var result = ImmutableArray.CreateBuilder(directives.Length); @@ -282,7 +425,7 @@ ImmutableArray UpdateDirectives(ImmutableArray // The `OriginalName` is absolute if there are no `$(..)` vars at the start. if (!Path.IsPathFullyQualified(project.OriginalName)) { - project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name), CSharpDirective.Project.NameKind.Final); + project = project.WithName(Path.GetRelativePath(relativeTo: outputDirectory, path: project.Name), CSharpDirective.Project.NameKind.Final); result.Add(project); continue; } @@ -296,18 +439,36 @@ ImmutableArray UpdateDirectives(ImmutableArray project = project.WithName(Path.Join(project.OriginalName, projectFileName), CSharpDirective.Project.NameKind.Final); } - project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: Path.Combine(sourceDirectory, project.Name)), CSharpDirective.Project.NameKind.Final); + project = project.WithName(Path.GetRelativePath(relativeTo: outputDirectory, path: Path.Combine(sourceDirectory, project.Name)), CSharpDirective.Project.NameKind.Final); result.Add(project); continue; } + // Convert #:ref directives to #:project directives pointing to the referenced file's + // expected converted project location (i.e., subfolder of the output directory named after the .cs file). + if (directive is CSharpDirective.Ref refDirective) + { + var refPath = refDirective.ResolvedPath ?? Path.GetFullPath(Path.Combine(sourceDirectory, refDirective.Name.Replace('\\', '/'))); + var refName = Path.GetFileNameWithoutExtension(refPath); + + // The referenced file's converted project is expected at: //.csproj + var convertedProjectPath = Path.Combine(targetDirectory, refName, refName + ".csproj"); + var relativePath = Path.GetRelativePath(relativeTo: outputDirectory, path: convertedProjectPath); + + result.Add(new CSharpDirective.Project(refDirective.Info, relativePath) + { + OriginalName = refDirective.OriginalName, + }); + continue; + } + result.Add(directive); } return result.DrainToImmutable(); } - IEnumerable<(string name, string value)> GetDefaultProperties() + IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance projectInstance) { foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework)) { @@ -375,7 +536,7 @@ private string DetermineOutputDirectory(string file) return targetDirectory; } - private bool TryAskForDeleteSource(string sourceFile) + private bool TryAskForDeleteSource() { if (!_interactive) { diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 0d8ea09da7ac..f3a9ef6eacab 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -118,6 +118,13 @@ internal sealed class VirtualProjectBuildingCommand : CommandBase public VirtualProjectBuilder Builder { get; } public MSBuildArgs MSBuildArgs { get; } + /// + /// Keeps strong references to s created for #:ref directives, + /// preventing their s from being garbage collected + /// (same reason as VirtualProjectBuilder._projectRootElement). + /// + private readonly List _referencedBuilders = []; + public ImmutableArray Directives { get @@ -526,6 +533,12 @@ bool CanSaveCache(ProjectInstance projectInstance) return false; } + if (EvaluatedDirectives.Any(static d => d is CSharpDirective.Ref)) + { + Reporter.Verbose.WriteLine("Not saving cache because there is a ref directive."); + return false; + } + if (EvaluatedDirectives.Any(static d => d is CSharpDirective.IncludeOrExclude { Kind: CSharpDirective.IncludeOrExcludeKind.Include } includeDirective && includeDirective.Name.AsSpan().ContainsAny('*', '?'))) @@ -756,9 +769,9 @@ public bool DetermineFinalCanReuseAuxiliaryFiles() /// private CacheInfo? ComputeCacheEntry() { - if (Directives.Any(static d => d is CSharpDirective.Project)) + if (Directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.Ref)) { - Reporter.Verbose.WriteLine("Skipping computing cache because there are project directives."); + Reporter.Verbose.WriteLine("Skipping computing cache because there are project or ref directives."); return null; } @@ -1177,9 +1190,68 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection EvaluatedDirectives = evaluatedDirectives; + // Create virtual ProjectRootElements for all #:ref directives so MSBuild can resolve them. + CreateReferencedVirtualProjects(projectCollection, evaluatedDirectives); + return project; } + /// + /// Recursively creates virtual s for all #:ref directives + /// in the given (and transitively in referenced files). + /// The s are registered in the 's + /// ProjectRootElementCache so MSBuild can resolve <ProjectReference> items to them. + /// + private void CreateReferencedVirtualProjects( + ProjectCollection projectCollection, + ImmutableArray directives) + { + var processedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath }; + CreateReferencedVirtualProjectsCore(projectCollection, directives, processedFiles, _referencedBuilders); + + static void CreateReferencedVirtualProjectsCore( + ProjectCollection projectCollection, + ImmutableArray directives, + HashSet processedFiles, + List referencedBuilders) + { + foreach (var refDirective in directives.OfType()) + { + // ResolvedPath is always set when using ThrowingReporter (EnsureResolvedPath throws on error). + Debug.Assert(refDirective.ResolvedPath is not null); + + if (refDirective.ResolvedPath is not { } resolvedPath) + { + continue; + } + + if (!processedFiles.Add(resolvedPath)) + { + // Already processed or cycle detected. + continue; + } + + var refBuilder = new VirtualProjectBuilder( + resolvedPath, + TargetFramework); + + refBuilder.CreateProjectInstance( + projectCollection, + ThrowingReporter, + project: out _, + projectRootElement: out _, + out var refEvaluatedDirectives); + + // Keep a strong reference to prevent GC from collecting the ProjectRootElement + // after MSBuild's ProjectRootElementCache demotes it to a weak reference. + referencedBuilders.Add(refBuilder); + + // Recursively create virtual projects for any #:ref in the referenced file. + CreateReferencedVirtualProjectsCore(projectCollection, refEvaluatedDirectives, processedFiles, referencedBuilders); + } + } + } + /// /// Creates a temporary subdirectory for file-based apps. /// Use to obtain the path. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf index 5b25a0b4082e..2f5d8f197eba 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf @@ -1256,6 +1256,11 @@ Nástroj {1} (verze {2}) se úspěšně nainstaloval. Do souboru manifestu {3} s Odstraněný zdrojový soubor: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Zkušební spuštění: odebere direktivy na úrovni souboru ze souboru: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf index 6013df11d21e..67d8a75d1641 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf @@ -1256,6 +1256,11 @@ Das Tool "{1}" (Version {2}) wurde erfolgreich installiert. Der Eintrag wird der Quelldatei gelöscht: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Probelauf. Anweisungen auf Dateiebene würden aus Datei entfernt: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf index 808fabcec6f5..a7784fbe6057 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf @@ -1256,6 +1256,11 @@ La herramienta "{1}" (versión "{2}") se instaló correctamente. Se ha agregado Archivo de origen eliminado: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Simulación: se eliminarían las directivas a nivel de archivo del archivo {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf index 7298d1c4f990..173b8a464603 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf @@ -1256,6 +1256,11 @@ L'outil '{1}' (version '{2}') a été correctement installé. L'entrée est ajou Fichier source supprimé : {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Test à blanc : supprimerait les directives de niveau fichier du fichier : {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf index 7ff173c7450f..a4ecc79c41ba 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf @@ -1256,6 +1256,11 @@ Lo strumento '{1}' versione '{2}' è stato installato. La voce è stata aggiunta File di origine eliminato: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Esecuzione di prova: rimuoverebbe le direttive a livello di file dal file: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf index 9af952478d44..857e8e4e459a 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man 削除されたソース ファイル: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} ドライ ラン: ファイルからファイル レベルのディレクティブを削除します: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf index f5a085361b43..da738b37cbe7 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man 원본 파일을 삭제함: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} 시험 실행: 파일에서 파일 수준 지시문을 제거합니다. {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf index 68ff0ba7942d..da4ba95c1ea4 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf @@ -1256,6 +1256,11 @@ Narzędzie „{1}” (wersja „{2}”) zostało pomyślnie zainstalowane. Wpis Usunięto plik źródłowy: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Przebieg próbny: spowoduje usunięcie dyrektyw na poziomie pliku z pliku: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf index 3cee39fa42fa..426840b2d695 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf @@ -1256,6 +1256,11 @@ A ferramenta '{1}' (versão '{2}') foi instalada com êxito. A entrada foi adici Arquivo de origem excluído: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Execução de teste: diretivas de nível de arquivo a serem removidas do arquivo: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf index 4c2f8b799955..47a36ac13072 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man Исходный файл удален: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Пробный прогон: будут удалены директивы на уровне файла из файла: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf index f2cbb5075f24..a702754d4ca1 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man Kaynak dosya silindi: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} Deneme çalıştırması: dosya düzeyindeki yönergeleri şu dosyadan kaldırıyor: {0} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf index c851f5d272b1..97f4b9fe46cf 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man 已删除的源文件: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} 试运行: 将从文件 {0} 中移除文件级指令 diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf index a70f1ca045af..c3c7ed0d05c9 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf @@ -1256,6 +1256,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man 已刪除來源檔案: {0} {0} is the source file path. + + Multiple referenced files would be converted into the same directory: '{0}' + Multiple referenced files would be converted into the same directory: '{0}' + {0} is the target directory path. + Dry run: would remove file-level directives from file: {0} 試執行: 將從檔案移除檔案層級指示詞: {0} diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 164f72c23a68..31c0e0c68f63 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -185,7 +185,7 @@ private ImmutableArray EvaluateDirectives( ImmutableArray directives, ErrorReporter reportError) { - if (!directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.IncludeOrExclude)) + if (!directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.IncludeOrExclude or CSharpDirective.Ref)) { return directives; } @@ -205,6 +205,13 @@ private ImmutableArray EvaluateDirectives( builder.Add(projectDirective); break; + case CSharpDirective.Ref refDirective: + refDirective = refDirective.WithName(project.ExpandString(refDirective.Name), CSharpDirective.Ref.NameKind.Expanded); + refDirective = refDirective.EnsureResolvedPath(reportError); + + builder.Add(refDirective); + break; + case CSharpDirective.IncludeOrExclude includeOrExcludeDirective: var expandedPath = project.ExpandString(includeOrExcludeDirective.Name); var fullPath = Path.GetFullPath(path: expandedPath, basePath: Path.GetDirectoryName(includeOrExcludeDirective.Info.SourceFile.Path)!); @@ -417,12 +424,18 @@ private void CheckDirectives( ImmutableArray directives, ErrorReporter reportError) { + bool? refEnabled = null; bool? includeEnabled = null; bool? excludeEnabled = null; bool? transitiveEnabled = null; foreach (var directive in directives) { + if (directive is CSharpDirective.Ref) + { + CheckFlagEnabled(ref refEnabled, CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, directive); + } + if (directive is CSharpDirective.IncludeOrExclude includeOrExcludeDirective) { if (includeOrExcludeDirective.Kind == CSharpDirective.IncludeOrExcludeKind.Include) @@ -475,6 +488,7 @@ internal static void WriteProjectFile( var propertyDirectives = directives.OfType(); var packageDirectives = directives.OfType(); var projectDirectives = directives.OfType(); + var refDirectives = directives.OfType(); var includeOrExcludeDirectives = directives.OfType(); const string defaultSdkName = "Microsoft.NET.Sdk"; @@ -722,7 +736,7 @@ internal static void WriteProjectFile( """); } - if (projectDirectives.Any()) + if (projectDirectives.Any() || refDirectives.Any()) { writer.WriteLine(""" @@ -737,6 +751,19 @@ internal static void WriteProjectFile( processedDirectives++; } + foreach (var refDirective in refDirectives) + { + if (refDirective.ResolvedPath is not null) + { + var virtualProjectPath = GetVirtualProjectPath(refDirective.ResolvedPath); + writer.WriteLine($""" + + """); + } + + processedDirectives++; + } + writer.WriteLine(""" diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 3bb43deb767c..c0e03300da4e 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -203,6 +203,458 @@ public static void M() """); } + [Fact] + public void RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + var expectedOutput = "Hello, World!"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // #:ref lib.cs should become a ProjectReference to ../lib/lib.csproj + File.ReadAllText(Path.Join(outputDirFullPath, "app", "app.csproj")) + .Should().Contain($""" + + """); + + // The referenced library should have been converted too. + var libProjectDir = Path.Join(outputDirFullPath, "lib"); + File.Exists(Path.Join(libProjectDir, "lib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libProjectDir, "lib.cs")).Should().BeTrue(); + File.ReadAllText(Path.Join(libProjectDir, "lib.csproj")) + .Should().Contain("Library"); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Fact] + public void RefDirective_Transitive_Convert() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + namespace Lib2; + public static class Helper + { + public static string Get() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class Facade + { + public static string Get() => $"from lib1 and {Lib2.Helper.Get()}"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.Facade.Get()); + """); + + var expectedOutput = "from lib1 and from lib2"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // All three projects should exist. + File.Exists(Path.Join(outputDirFullPath, "app", "app.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib1", "lib1.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib2", "lib2.csproj")).Should().BeTrue(); + + // lib1.csproj should reference lib2. + File.ReadAllText(Path.Join(outputDirFullPath, "lib1", "lib1.csproj")) + .Should().Contain($""" + + """); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Fact] + public void RefDirective_DuplicateFolderName() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref a/lib.cs + #:ref b/lib.cs + Console.WriteLine(A.Lib.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + + new DirectoryInfo(testInstance.Path) + .EnumerateFileSystemInfos().Select(d => d.Name).Order() + .Should().BeEquivalentTo(["a", "app.cs", "b", "Directory.Build.props"]); + } + + [Fact] + public void RefDirective_DuplicateFolderName_Transitive() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + // a/lib.cs is referenced by mid.cs + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + // mid.cs references a/lib.cs + File.WriteAllText(Path.Join(testInstance.Path, "mid.cs"), """ + #:property OutputType=Library + #:ref a/lib.cs + namespace Mid; + public static class Mid { public static string Get() => A.Lib.Get(); } + """); + + // b/lib.cs would conflict with a/lib.cs (both "lib") + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref mid.cs + #:ref b/lib.cs + Console.WriteLine(Mid.Mid.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + } + + [Fact] + public void RefDirective_DuplicateFolderName_ViaInclude() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + true + true + + + """); + + // a/lib.cs is referenced by the app directly + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + // b/lib.cs would conflict (same name "lib") - referenced via #:include-d file + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + // extra.cs is included and references b/lib.cs + File.WriteAllText(Path.Join(testInstance.Path, "extra.cs"), """ + #:ref b/lib.cs + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref a/lib.cs + #:include extra.cs + Console.WriteLine(A.Lib.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + } + + /// + /// Verifies that default items (e.g., appsettings.json) in a #:ref'd file's directory + /// are copied to the converted project output directory. + /// + [Fact] + public void RefDirective_IncludedItemsCopied() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + #:property EnableDefaultNoneItems=true + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + // A non-code file next to the library that should be picked up as a default item. + File.WriteAllText(Path.Join(libDir, "data.json"), """{ "key": "value" }"""); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // The library's included item (data.json) should be copied to the ref output directory. + var libOutputDir = Path.Join(outputDirFullPath, "mylib"); + File.Exists(Path.Join(libOutputDir, "mylib.cs")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "mylib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "data.json")).Should().BeTrue(); + } + + /// + /// Verifies that --delete-source also deletes included items of #:ref'd files. + /// + [Fact] + public void RefDirective_IncludedItemsDeleted() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + #:property EnableDefaultNoneItems=true + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(libDir, "config.json"), """{ "setting": true }"""); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath, "--delete-source") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // Source files should be deleted. + File.Exists(Path.Join(testInstance.Path, "app.cs")).Should().BeFalse(); + File.Exists(Path.Join(libDir, "mylib.cs")).Should().BeFalse(); + File.Exists(Path.Join(libDir, "config.json")).Should().BeFalse(); + + // Converted files should exist. + var libOutputDir = Path.Join(outputDirFullPath, "mylib"); + File.Exists(Path.Join(libOutputDir, "mylib.cs")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "mylib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "config.json")).Should().BeTrue(); + } + + /// + /// Converting one app that #:refs a library does not affect other apps that also reference the same library. + /// + [Fact] + public void RefDirective_ConvertScope() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app1.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app2.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var unrelatedDir = Path.Join(testInstance.Path, "unrelated"); + Directory.CreateDirectory(unrelatedDir); + File.WriteAllText(Path.Join(unrelatedDir, "app3.cs"), """ + #:ref ../lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app1.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // app1 should be converted. + File.Exists(Path.Join(outputDirFullPath, "app1", "app1.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib", "lib.csproj")).Should().BeTrue(); + + // app2 and app3 should be unaffected (still exist as .cs files with their directives intact). + File.ReadAllText(Path.Join(testInstance.Path, "app2.cs")).Should().Contain("#:ref lib.cs"); + File.ReadAllText(Path.Join(unrelatedDir, "app3.cs")).Should().Contain("#:ref ../lib.cs"); + } + [Fact] public void ProjectReference_FullPath_WithVars() { diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index baa1adcd74e7..44356531c095 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -147,6 +147,24 @@ internal static string DirectiveError(string path, int line, string messageForma return $"{path}({line}): {FileBasedProgramsResources.DirectiveError}: {string.Format(messageFormat, args)}"; } + private static void EnableRefDirective(TestDirectory testInstance) + { + var propsPath = Path.Join(testInstance.Path, "Directory.Build.props"); + var propsContent = File.Exists(propsPath) ? File.ReadAllText(propsPath) : null; + if (propsContent is not null && propsContent.Contains(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective)) + { + return; + } + + File.WriteAllText(propsPath, $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + } + /// /// dotnet run file.cs succeeds without a project file. /// @@ -588,6 +606,61 @@ public class LibClass .And.HaveStdErrContaining(errorParts[1]); } + /// + /// dotnet run - with #:ref uses $(MSBuildStartupDirectory) to resolve paths. + /// Relative paths don't work from stdin since the file is in an isolated temp directory. + /// Analogous to . + /// + [Fact] + public void ReadFromStdin_RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello from lib!"; + } + """); + + var appDir = Path.Join(testInstance.Path, "app"); + Directory.CreateDirectory(appDir); + + new DotnetCommand(Log, "run", "-") + .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithStandardInput(""" + #:ref $(MSBuildStartupDirectory)/../lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from lib!"); + + // Relative paths are resolved from the isolated temp directory, hence they don't work. + + var errorParts = DirectiveError("app.cs", 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, "{}")).Split("{}"); + errorParts.Should().HaveCount(2); + + new DotnetCommand(Log, "run", "-") + .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithStandardInput(""" + #:ref ../lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(errorParts[0]) + .And.HaveStdErrContaining(errorParts[1]); + } + [Fact] public void ReadFromStdin_NoBuild() { @@ -3113,132 +3186,820 @@ public class LibClass new DotnetCommand(Log, "run", "App/Program.cs") .WithWorkingDirectory(testInstance.Path) .Execute() - .Should().Pass() - .And.HaveStdOut(expectedOutput); + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Theory] + [InlineData(null)] + [InlineData("app")] + public void ProjectReference_Errors(string? subdir) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, """ + #:project wrong.csproj + """); + + // Project file does not exist. + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "wrong.csproj")))); + + File.WriteAllText(filePath, """ + #:project dir/ + """); + + // Project directory does not exist. + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + + Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); + + // Directory exists but has no project file. + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindAnyProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), ""); + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj2.csproj"), ""); + + // Directory exists but has multiple project files. + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.MoreThanOneProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + + // Malformed MSBuild variable syntax. + File.WriteAllText(filePath, """ + #:project $(Test + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "$(Test")))); + } + + [Theory] + [InlineData(null)] + [InlineData("app")] + public void ProjectReference_Duplicate(string? subdir) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), $""" + + + {ToolsetInfo.CurrentTargetFramework} + + + """); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project dir/ + Console.WriteLine("Hello"); + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 2, FileBasedProgramsResources.DuplicateDirective, "#:project dir/")); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project dir/proj1.csproj + Console.WriteLine("Hello"); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello"); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project $(MSBuildProjectDirectory)/dir/ + Console.WriteLine("Hello"); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello"); + } + + [Fact] + public void RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + [Fact] + public void RefDirective_Subdirectory() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + /// + /// Analogous to but for #:ref. + /// + [Theory] + [InlineData(null)] + [InlineData("app")] + public void RefDirective_Errors(string? subdir) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + // Missing name. + File.WriteAllText(filePath, """ + #:ref + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.MissingDirectiveName, "ref")); + + // File does not exist. + File.WriteAllText(filePath, """ + #:ref nonexistent.cs + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, subdir, "nonexistent.cs")))); + } + + /// + /// Verifies that #:ref produces a metadata (assembly) reference, + /// meaning internal members are not accessible unless InternalsVisibleTo is used. + /// + [Fact] + public void RefDirective_InternalsNotAccessible() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class PublicClass + { + public static string PublicMethod() => "public"; + internal static string InternalMethod() => "internal"; + } + internal static class InternalClass + { + public static string Method() => "internal class"; + } + """); + + // Accessing internal member should fail. + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.PublicClass.InternalMethod()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining("error CS"); + + // Accessing public member should succeed. + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.PublicClass.PublicMethod()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("public"); + } + + /// + /// Verifies transitive #:ref references work: app.cs → lib1.cs → lib2.cs. + /// + [Fact] + public void RefDirective_Transitive() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + namespace Lib2; + public static class Base + { + public static string Value() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class Middle + { + public static string Value() => $"from lib1 and {Lib2.Base.Value()}"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.Middle.Value()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("from lib1 and from lib2"); + } + + /// + /// #:ref with various path formats (forward slashes, backslashes, MSBuild properties, parent dirs). + /// Analogous to . + /// + [Theory] + [InlineData("../Lib/lib.cs")] + [InlineData(@"..\Lib\lib.cs")] + [InlineData("$(MSBuildProjectDirectory)/../$(LibDirName)/lib.cs")] + [InlineData(@"$(MSBuildProjectDirectory)\..\Lib\lib.cs")] + public void RefDirective_PathFormats(string arg) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libDir = Path.Join(testInstance.Path, "Lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + var appDir = Path.Join(testInstance.Path, "App"); + Directory.CreateDirectory(appDir); + + File.WriteAllText(Path.Join(appDir, "app.cs"), $""" + #:ref {arg} + #:property LibDirName=Lib + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + var expectedOutput = "Hello, World!"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(appDir) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + // Running from a different working directory shouldn't affect handling of the relative paths. + new DotnetCommand(Log, "run", "App/app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + /// + /// #:ref duplicate detection. + /// Analogous to . + /// + [Theory] + [InlineData(null)] + [InlineData("app")] + public void RefDirective_Duplicate(string? subdir) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + File.WriteAllText(Path.Join(testInstance.Path, subdir, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 2, FileBasedProgramsResources.DuplicateDirective, "#:ref lib.cs")); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref ./lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate ref + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref $(MSBuildProjectDirectory)/lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate ref + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + } + + /// + /// #:ref is an experimental feature that must be opted into. + /// Analogous to . + /// + [Fact] + public void RefDirective_FeatureFlag() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + File.WriteAllText(libPath, """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErr($""" + {DirectiveError(programPath, 1, Resources.ExperimentalFeatureDisabled, CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective)} + + {CliCommandStrings.RunCommandException} + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + } + + /// + /// Combining #:ref and #:include in the same file-based app. + /// + [Fact] + public void RefDirective_WithInclude() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #!/usr/bin/env dotnet + #:property OutputType=Library + #:include LibHelper.cs + #:include LibFormatter.cs + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => LibFormatter.Format(LibHelper.Prefix, name); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "LibHelper.cs"), """ + namespace MyLib; + public static class LibHelper + { + public static string Prefix => "Hello"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "LibFormatter.cs"), """ + namespace MyLib; + public static class LibFormatter + { + public static string Format(string prefix, string name) => $"{prefix}, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), """ + static class Util + { + public static string GetName() => "World"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:ref lib.cs + #:include Util.cs + Console.WriteLine(MyLib.Greeter.Greet(Util.GetName())); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + /// + /// A #:ref library can target a different framework (e.g., netstandard2.0) + /// than the referencing app (net10.0). + /// + [Fact] + public void RefDirective_DifferentTargetFramework() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + #:property TargetFramework=netstandard2.0 + #:property LangVersion=latest + #:property ImplicitUsings=disable + #:property PublishAot=false + namespace MyLib; + public static class Greeter + { + #if NETSTANDARD2_0 + public static string Greet() => "Hello from netstandard2.0!"; + #else + public static string Greet() => "Hello from other!"; + #endif + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + #if NET10_0_OR_GREATER + Console.WriteLine("App is net10.0+: " + MyLib.Greeter.Greet()); + #else + Console.WriteLine("App is older: " + MyLib.Greeter.Greet()); + #endif + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("App is net10.0+: Hello from netstandard2.0!"); + } + + /// + /// #:ref *.cs does not expand globs — it looks for a literal file named *.cs. + /// + [Fact] + public void RefDirective_Glob() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + var filePath = Path.Join(testInstance.Path, "app.cs"); + File.WriteAllText(filePath, """ + #:ref *.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, "*.cs")))); } - [Theory] - [InlineData(null)] - [InlineData("app")] - public void ProjectReference_Errors(string? subdir) + /// + /// Verifies that cyclic #:ref references (lib1 → lib2 → lib1) do not cause an infinite loop. + /// + [Fact] + public void RefDirective_Cycle() { var testInstance = _testAssetsManager.CreateTestDirectory(); - var relativeFilePath = Path.Join(subdir, "Program.cs"); - var filePath = Path.Join(testInstance.Path, relativeFilePath); - Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); - File.WriteAllText(filePath, """ - #:project wrong.csproj + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class C1 { public static string Get() => "lib1"; } """); - // Project file does not exist. - new DotnetCommand(Log, "run", relativeFilePath) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "wrong.csproj")))); + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + #:ref lib1.cs + namespace Lib2; + public static class C2 { public static string Get() => "lib2"; } + """); - File.WriteAllText(filePath, """ - #:project dir/ + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.C1.Get()); """); - // Project directory does not exist. - new DotnetCommand(Log, "run", relativeFilePath) + // Should not hang. The cycle is broken by processedFiles deduplication. + // error NU1108: Cycle detected. + // error NU1108: lib1 -> lib2 -> lib1. + new DotnetCommand(Log, "run", "app.cs") .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + .And.HaveStdOutContaining("error NU1108"); + } - Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); + /// + /// Two #:include'd files each have #:ref to the same library. + /// The deduplication via processedFiles should ensure the library is only processed once. + /// + [Fact] + public void RefDirective_DuplicateRefFromIncludedFiles() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); - // Directory exists but has no project file. - new DotnetCommand(Log, "run", relativeFilePath) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindAnyProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives}>true + + + """); - File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), ""); - File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj2.csproj"), ""); + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); - // Directory exists but has multiple project files. - new DotnetCommand(Log, "run", relativeFilePath) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.MoreThanOneProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + File.WriteAllText(Path.Join(testInstance.Path, "helper1.cs"), """ + #:ref lib.cs + static class Helper1 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); - // Malformed MSBuild variable syntax. - File.WriteAllText(filePath, """ - #:project $(Test + File.WriteAllText(Path.Join(testInstance.Path, "helper2.cs"), """ + #:ref lib.cs + static class Helper2 + { + public static string Get() => MyLib.Greeter.Greet(); + } """); - new DotnetCommand(Log, "run", relativeFilePath) + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:include helper1.cs + #:include helper2.cs + Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); + """); + + new DotnetCommand(Log, "run", "app.cs") .WithWorkingDirectory(testInstance.Path) .Execute() - .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "$(Test")))); + .Should().Pass() + .And.HaveStdOut("Hello! Hello!"); } - [Theory] - [InlineData(null)] - [InlineData("app")] - public void ProjectReference_Duplicate(string? subdir) + /// + /// Two #:include'd files in different directories each have #:ref to the same library + /// using different relative paths. Deduplication via processedFiles uses the resolved (absolute) path, + /// so the library is only processed once. + /// + [Fact] + public void RefDirective_DuplicateRefFromIncludedFiles_Subdirectories() { var testInstance = _testAssetsManager.CreateTestDirectory(); - var relativeFilePath = Path.Join(subdir, "Program.cs"); - var filePath = Path.Join(testInstance.Path, relativeFilePath); - Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); - Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); - File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), $""" - + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + - {ToolsetInfo.CurrentTargetFramework} + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives}>true """); - File.WriteAllText(filePath, """ - #:project dir/ - #:project dir/ - Console.WriteLine("Hello"); + // lib.cs is in the root directory. + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } """); - new DotnetCommand(Log, "run", relativeFilePath) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(filePath, 2, FileBasedProgramsResources.DuplicateDirective, "#:project dir/")); + // helper1.cs is in sub1/, refers to lib.cs via ../lib.cs. + var sub1 = Path.Join(testInstance.Path, "sub1"); + Directory.CreateDirectory(sub1); + File.WriteAllText(Path.Join(sub1, "helper1.cs"), """ + #:ref ../lib.cs + static class Helper1 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); - File.WriteAllText(filePath, """ - #:project dir/ - #:project dir/proj1.csproj - Console.WriteLine("Hello"); + // helper2.cs is in sub2/nested/, refers to lib.cs via ../../lib.cs (different relative path, same resolved path). + var sub2 = Path.Join(testInstance.Path, "sub2", "nested"); + Directory.CreateDirectory(sub2); + File.WriteAllText(Path.Join(sub2, "helper2.cs"), """ + #:ref ../../lib.cs + static class Helper2 + { + public static string Get() => MyLib.Greeter.Greet(); + } """); - // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference - new DotnetCommand(Log, "run", relativeFilePath) + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:include sub1/helper1.cs + #:include sub2/nested/helper2.cs + Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); + """); + + new DotnetCommand(Log, "run", "app.cs") .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Pass() - .And.HaveStdOut("Hello"); + .And.HaveStdOut("Hello! Hello!"); + } - File.WriteAllText(filePath, """ - #:project dir/ - #:project $(MSBuildProjectDirectory)/dir/ - Console.WriteLine("Hello"); + /// + /// Both #:include and #:ref pointing at the same file. + /// The file ends up both compiled into the current assembly and referenced as a separate assembly. + /// This is expected to produce a compilation error (duplicate type definitions). + /// + [Fact] + public void RefDirective_IncludeAndRefSameFile() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives}>true + + """); - // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference - new DotnetCommand(Log, "run", relativeFilePath) + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + #:include lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // The #:include brings in lib.cs's #:property OutputType=Library, making the app a library. + // error CS8805: Program using top-level statements must be an executable. + new DotnetCommand(Log, "run", "app.cs") .WithWorkingDirectory(testInstance.Path) .Execute() - .Should().Pass() - .And.HaveStdOut("Hello"); + .Should().Fail() + .And.HaveStdOutContaining("error CS8805"); } [Theory, CombinatorialData] @@ -4883,6 +5644,50 @@ public class LibClass Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v2", programFileName: programFileName); } + /// + /// optimization currently does not support #:ref references and hence is disabled if those are present. + /// Analogous to . + /// + [Fact] + public void UpToDate_RefDirectives() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + var libCode = """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "v1"; + } + """; + File.WriteAllText(libPath, libCode); + + var programCode = """ + #:ref lib.cs + Console.WriteLine("Hello " + MyLib.Greeter.Greet()); + """; + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, programCode); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); + + // We cannot detect changes in referenced files, so we always rebuild. + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); + + libCode = libCode.Replace("v1", "v2"); + File.WriteAllText(libPath, libCode); + + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v2"); + } + /// /// optimization considers default items. /// Also tests optimization. @@ -5590,6 +6395,52 @@ public class LibClass Build(testInstance, BuildLevel.All, expectedOutput: "v2 Hello from Lib v2", programFileName: programFileName); } + /// + /// See . + /// This optimization currently does not support #:ref references and hence is disabled if those are present. + /// Analogous to . + /// + [Fact] + public void CscOnly_AfterMSBuild_RefDirectives() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + var libCode = """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "v1"; + } + """; + File.WriteAllText(libPath, libCode); + + var programCode = """ + #:ref lib.cs + Console.WriteLine("Hello " + MyLib.Greeter.Greet()); + """; + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, programCode); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); + + programCode = programCode.Replace("Hello", "Hi"); + File.WriteAllText(programPath, programCode); + + libCode = libCode.Replace("v1", "v2"); + File.WriteAllText(libPath, libCode); + + // Cannot use CSC because we cannot detect updates in the referenced file. + Build(testInstance, BuildLevel.All, expectedOutput: "Hi v2"); + } + /// /// See . /// If users have more complex build customizations, they can opt out of the optimization. @@ -6532,4 +7383,59 @@ public void VirtualProject_SurvivesGCDuringRestore() .Should().Pass() .And.HaveStdOut("Hello from virtual project"); } + + /// + /// Same as but for #:ref referenced projects. + /// The referenced project's must also survive GC. + /// + [Fact] + public void VirtualProject_SurvivesGCDuringRestore_RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello from ref"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #:ref Lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // Directory.Build.targets that forces GC during restore, + // after SDK imports have already evicted the virtual PRE from the strong cache. + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ + + + + + + + + <_ForceGCTask /> + + + """); + + new DotnetCommand(Log, "run", "--no-cache", "Program.cs") + // A cache size of 1 ensures the virtual PRE is evicted from the strong cache + // as soon as any SDK .targets/.props file is loaded during evaluation. + .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from ref"); + } }