From 5cd16764e58e4ea0442eb6cedbeeb53c694797b4 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 13 Mar 2026 21:58:35 +0100 Subject: [PATCH 01/19] Implement `#:ref` directive for file-based apps --- .../FileBasedProgramsResources.resx | 8 + .../FileLevelDirectiveHelpers.cs | 98 ++++++++++ .../InternalAPI.Unshipped.txt | 16 ++ .../xlf/FileBasedProgramsResources.cs.xlf | 10 ++ .../xlf/FileBasedProgramsResources.de.xlf | 10 ++ .../xlf/FileBasedProgramsResources.es.xlf | 10 ++ .../xlf/FileBasedProgramsResources.fr.xlf | 10 ++ .../xlf/FileBasedProgramsResources.it.xlf | 10 ++ .../xlf/FileBasedProgramsResources.ja.xlf | 10 ++ .../xlf/FileBasedProgramsResources.ko.xlf | 10 ++ .../xlf/FileBasedProgramsResources.pl.xlf | 10 ++ .../xlf/FileBasedProgramsResources.pt-BR.xlf | 10 ++ .../xlf/FileBasedProgramsResources.ru.xlf | 10 ++ .../xlf/FileBasedProgramsResources.tr.xlf | 10 ++ .../FileBasedProgramsResources.zh-Hans.xlf | 10 ++ .../FileBasedProgramsResources.zh-Hant.xlf | 10 ++ .../Project/Convert/ProjectConvertCommand.cs | 19 ++ .../Run/VirtualProjectBuildingCommand.cs | 55 +++++- .../VirtualProjectBuilder.cs | 34 +++- .../Convert/DotnetProjectConvertTests.cs | 39 ++++ .../CommandTests/Run/RunFileTests.cs | 167 ++++++++++++++++++ 21 files changed, 558 insertions(+), 8 deletions(-) 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..d21db9d195be 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,103 @@ 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 + { + [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 new Ref(Info, Name) + { + OriginalName = OriginalName, + ExpandedName = ExpandedName, + ResolvedPath = resolvedFilePath, + }; + } + + 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..d6ff6dfbbea6 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -71,6 +71,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 +137,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 +149,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/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index fa79bd32a677..e3f3c58e7fb9 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -300,6 +300,25 @@ ImmutableArray UpdateDirectives(ImmutableArray continue; } + // Convert #:ref directives to #:project directives pointing to the referenced file's + // expected converted project location (i.e., sibling 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); + var refDir = Path.GetDirectoryName(refPath)!; + + // The referenced file's converted project is expected at: //.csproj + var convertedProjectPath = Path.Combine(refDir, refName, refName + ".csproj"); + var relativePath = Path.GetRelativePath(relativeTo: targetDirectory, path: convertedProjectPath); + + result.Add(new CSharpDirective.Project(refDirective.Info, relativePath) + { + OriginalName = refDirective.OriginalName, + }); + continue; + } + result.Add(directive); } diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 2282adbd535c..fb06c6b4c04a 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -521,6 +521,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('*', '?'))) @@ -751,9 +757,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; } @@ -1171,9 +1177,54 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection EvaluatedDirectives = evaluatedDirectives; + // Create virtual ProjectRootElements for all #:ref directives so MSBuild can resolve them. + CreateReferencedVirtualProjects(projectCollection, evaluatedDirectives, processedFiles: null); + 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, + HashSet? processedFiles) + { + foreach (var refDirective in directives.OfType()) + { + if (refDirective.ResolvedPath is not { } resolvedPath) + { + continue; + } + + processedFiles ??= new HashSet(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath }; + + if (!processedFiles.Add(resolvedPath)) + { + // Already processed or cycle detected. + continue; + } + + var refBuilder = new VirtualProjectBuilder( + resolvedPath, + TargetFramework, + outputType: "Library"); + + refBuilder.CreateProjectInstance( + projectCollection, + ThrowingReporter, + out _, + out var refEvaluatedDirectives); + + // Recursively create virtual projects for any #:ref in the referenced file. + CreateReferencedVirtualProjects(projectCollection, refEvaluatedDirectives, processedFiles); + } + } + /// /// Creates a temporary subdirectory for file-based apps. /// Use to obtain the path. diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 0c2950fa03df..944ae7ea4988 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -47,14 +47,15 @@ internal VirtualProjectBuilder( string targetFramework, string[]? requestedTargets = null, string? artifactsPath = null, - SourceText? sourceText = null) + SourceText? sourceText = null, + string outputType = "Exe") { Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath)); EntryPointFileFullPath = entryPointFileFullPath; RequestedTargets = requestedTargets; ArtifactsPath = artifactsPath; - _defaultProperties = GetDefaultProperties(targetFramework); + _defaultProperties = GetDefaultProperties(targetFramework, outputType); if (sourceText != null) { @@ -65,9 +66,9 @@ internal VirtualProjectBuilder( /// /// Kept in sync with the default dotnet new console project file (enforced by DotnetProjectConvertTests.SameAsTemplate). /// - internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) => + internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework, string outputType = "Exe") => [ - ("OutputType", "Exe"), + ("OutputType", outputType), ("TargetFramework", targetFramework), ("ImplicitUsings", "enable"), ("Nullable", "enable"), @@ -175,7 +176,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; } @@ -195,6 +196,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)!); @@ -462,6 +470,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"; @@ -708,7 +717,7 @@ internal static void WriteProjectFile( """); } - if (projectDirectives.Any()) + if (projectDirectives.Any() || refDirectives.Any()) { writer.WriteLine(""" @@ -723,6 +732,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 333d62f6ec73..5f2976ab0d65 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -203,6 +203,45 @@ public static void M() """); } + [Fact] + public void RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + 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.csproj")) + .Should().Contain($""" + + """); + } + [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 585e5ab925bc..024f00d1f58a 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -3115,6 +3115,173 @@ public void ProjectReference_Duplicate(string? subdir) .And.HaveStdOut("Hello"); } + [Fact] + public void RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + 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(); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + 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!"); + } + + [Fact] + public void RefDirective_Errors() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var filePath = Path.Join(testInstance.Path, "Program.cs"); + + // Missing name. + File.WriteAllText(filePath, """ + #:ref + """); + + new DotnetCommand(Log, "run", "Program.cs") + .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", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, "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(); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + 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(); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + namespace Lib2; + public static class Base + { + public static string Value() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #: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"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, From eb9f15ee6d445d85b83292fea289f98361a5389f Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 16 Mar 2026 17:11:23 +0100 Subject: [PATCH 02/19] Convert referenced projects too --- .../Project/Convert/ProjectConvertCommand.cs | 151 ++++++++++++------ .../Convert/DotnetProjectConvertTests.cs | 74 +++++++++ 2 files changed, 176 insertions(+), 49 deletions(-) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index e3f3c58e7fb9..761b2d7a7da7 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -6,6 +6,7 @@ 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; @@ -48,50 +49,14 @@ public override int Execute() // Create a project instance for evaluation. var projectCollection = new ProjectCollection(); - var builder = new VirtualProjectBuilder(file, VirtualProjectBuildingCommand.TargetFramework); - - builder.CreateProjectInstance( - projectCollection, - VirtualProjectBuildingCommand.ThrowingReporter, - out var projectInstance, - out var evaluatedDirectives, - validateAllDirectives: !_force); + var (builder, projectInstance, evaluatedDirectives) = ConvertFile(file, targetDirectory, outputType: "Exe", isEntryPointFile: true); // Find other items to copy over, e.g., default Content items like JSON files in Web apps. var includeItems = FindIncludedItems().ToList(); - CreateDirectory(targetDirectory); - - var targetFile = Path.Join(targetDirectory, Path.GetFileName(file)); - - // Process the entry point file. - if (_dryRun) - { - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, file, targetFile); - Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetFile); - } - else - { - VirtualProjectBuildingCommand.RemoveDirectivesFromFile(builder.EntryPointSourceFile, targetFile); - } - - // 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()); - } + // Convert referenced files (#:ref directives) into library projects. + var convertedRefFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + ConvertReferencedFiles(evaluatedDirectives, Path.GetDirectoryName(file)!); // Copy or move over included items. foreach (var item in includeItems) @@ -138,10 +103,65 @@ public override int Execute() { DeleteFile(item.FullPath); } + + // Delete converted referenced files + foreach (var refFile in convertedRefFiles) + { + DeleteFile(refFile); + } } return 0; + (VirtualProjectBuilder builder, ProjectInstance projectInstance, ImmutableArray evaluatedDirectives) + ConvertFile(string sourceFile, string outputDirectory, string outputType, bool isEntryPointFile) + { + var sourceDirectory = Path.GetDirectoryName(sourceFile)!; + + var builder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework, outputType: outputType); + + builder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + out var projectInstance, + out var evaluatedDirectives, + validateAllDirectives: !_force); + + CreateDirectory(outputDirectory); + + // Copy the .cs file with directives removed. + var targetFile = Path.Join(outputDirectory, Path.GetFileName(sourceFile)); + if (_dryRun) + { + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, sourceFile, targetFile); + Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetFile); + } + else + { + VirtualProjectBuildingCommand.RemoveDirectivesFromFile(builder.EntryPointSourceFile, targetFile); + } + + // Create project file. + var projectFile = Path.Join(outputDirectory, Path.GetFileNameWithoutExtension(sourceFile) + ".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, sourceDirectory, outputDirectory), + isVirtualProject: false, + userSecretsId: isEntryPointFile ? DetermineUserSecretsId(projectInstance) : null, + defaultProperties: GetDefaultProperties(projectInstance, outputType)); + } + + return (builder, projectInstance, evaluatedDirectives); + } + void CreateDirectory(string path) { if (_dryRun) @@ -182,6 +202,38 @@ void DeleteFile(string path) } } + 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(refDir, refName); + + if (Directory.Exists(refTargetDirectory)) + { + continue; + } + + var (_, _, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", isEntryPointFile: false); + + // Recursively convert referenced files in the referenced file. + ConvertReferencedFiles(refEvaluatedDirectives, refDir); + } + } + IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems() { string entryPointFileDirectory = PathUtilities.EnsureTrailingSlash(Path.GetDirectoryName(file)!); @@ -240,16 +292,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); @@ -281,7 +334,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; } @@ -295,7 +348,7 @@ 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; } @@ -310,7 +363,7 @@ ImmutableArray UpdateDirectives(ImmutableArray // The referenced file's converted project is expected at: //.csproj var convertedProjectPath = Path.Combine(refDir, refName, refName + ".csproj"); - var relativePath = Path.GetRelativePath(relativeTo: targetDirectory, path: convertedProjectPath); + var relativePath = Path.GetRelativePath(relativeTo: outputDirectory, path: convertedProjectPath); result.Add(new CSharpDirective.Project(refDirective.Info, relativePath) { @@ -325,11 +378,11 @@ ImmutableArray UpdateDirectives(ImmutableArray return result.DrainToImmutable(); } - IEnumerable<(string name, string value)> GetDefaultProperties() + IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance pi, string outputType) { - foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework)) + foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework, outputType)) { - string projectValue = projectInstance.GetPropertyValue(name); + string projectValue = pi.GetPropertyValue(name); if (string.Equals(projectValue, defaultValue, StringComparison.OrdinalIgnoreCase)) { yield return (name, defaultValue); diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 5f2976ab0d65..45b71c1b2214 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -240,6 +240,80 @@ public static class Greeter .Should().Contain($""" """); + + // The referenced library should have been converted too. + var libProjectDir = Path.Join(testInstance.Path, "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(outputDirFullPath) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Fact] + public void RefDirective_Transitive_Convert() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + namespace Lib2; + public static class Helper + { + public static string Get() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #: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.csproj")).Should().BeTrue(); + File.Exists(Path.Join(testInstance.Path, "lib1", "lib1.csproj")).Should().BeTrue(); + File.Exists(Path.Join(testInstance.Path, "lib2", "lib2.csproj")).Should().BeTrue(); + + // lib1.csproj should reference lib2. + File.ReadAllText(Path.Join(testInstance.Path, "lib1", "lib1.csproj")) + .Should().Contain($""" + + """); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(outputDirFullPath) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); } [Fact] From 35adfeb164f65902607c204a8ab604bfd494438c Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 14:02:19 +0100 Subject: [PATCH 03/19] Extend tests --- .../CommandTests/Run/RunFileTests.cs | 267 +++++++++++++++++- 1 file changed, 261 insertions(+), 6 deletions(-) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 024f00d1f58a..e40a67f1ecd4 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -587,6 +587,58 @@ 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"), """ + 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) + .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) + .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() { @@ -3168,18 +3220,25 @@ public static class Greeter .And.HaveStdOut("Hello, World!"); } - [Fact] - public void RefDirective_Errors() + /// + /// Analogous to but for #:ref. + /// + [Theory] + [InlineData(null)] + [InlineData("app")] + public void RefDirective_Errors(string? subdir) { var testInstance = _testAssetsManager.CreateTestDirectory(); - var filePath = Path.Join(testInstance.Path, "Program.cs"); + 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", "Program.cs") + new DotnetCommand(Log, "run", relativeFilePath) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() @@ -3190,12 +3249,12 @@ public void RefDirective_Errors() #:ref nonexistent.cs """); - new DotnetCommand(Log, "run", "Program.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, "nonexistent.cs")))); + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, subdir, "nonexistent.cs")))); } /// @@ -3282,6 +3341,116 @@ public static class Middle .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(); + + var libDir = Path.Join(testInstance.Path, "Lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "lib.cs"), """ + 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(); + 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"), """ + 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!"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, @@ -4914,6 +5083,48 @@ 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(); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + var libCode = """ + 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. @@ -5621,6 +5832,50 @@ 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(); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + var libCode = """ + 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. From 23a7acb42943ff825e20f2f2c3099c50ffaa3dee Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 14:28:29 +0100 Subject: [PATCH 04/19] Add experimental opt-in feature flag --- .../FileLevelDirectiveHelpers.cs | 2 + .../VirtualProjectBuilder.cs | 6 ++ .../Convert/DotnetProjectConvertTests.cs | 16 +++++ .../CommandTests/Run/RunFileTests.cs | 71 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index d21db9d195be..a0c5184cacfe 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -593,6 +593,8 @@ void ReportError(string message) /// public sealed class Ref : Named { + public const string ExperimentalFileBasedProgramEnableRefDirective = nameof(ExperimentalFileBasedProgramEnableRefDirective); + [SetsRequiredMembers] public Ref(in ParseInfo info, string name) : base(info) { diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 944ae7ea4988..29c49210f9a4 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -412,12 +412,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) diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 45b71c1b2214..a2c1cde302fe 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -208,6 +208,14 @@ 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"), """ namespace MyLib; public static class Greeter @@ -261,6 +269,14 @@ 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"), """ namespace Lib2; public static class Helper diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index e40a67f1ecd4..08767e595d98 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -146,6 +146,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. /// @@ -613,6 +631,7 @@ public static class Greeter new DotnetCommand(Log, "run", "-") .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") .WithStandardInput(""" #:ref $(MSBuildStartupDirectory)/../lib/mylib.cs Console.WriteLine(MyLib.Greeter.Greet()); @@ -629,6 +648,7 @@ public static class Greeter new DotnetCommand(Log, "run", "-") .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") .WithStandardInput(""" #:ref ../lib/mylib.cs Console.WriteLine(MyLib.Greeter.Greet()); @@ -3171,6 +3191,7 @@ public void ProjectReference_Duplicate(string? subdir) public void RefDirective() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ namespace MyLib; @@ -3196,6 +3217,7 @@ public static class Greeter public void RefDirective_Subdirectory() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); var libDir = Path.Join(testInstance.Path, "lib"); Directory.CreateDirectory(libDir); @@ -3229,6 +3251,7 @@ public static class Greeter 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)!); @@ -3265,6 +3288,7 @@ public void RefDirective_Errors(string? subdir) public void RefDirective_InternalsNotAccessible() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ namespace MyLib; @@ -3311,6 +3335,7 @@ internal static class InternalClass public void RefDirective_Transitive() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ namespace Lib2; @@ -3353,6 +3378,7 @@ public static class Middle public void RefDirective_PathFormats(string arg) { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); var libDir = Path.Join(testInstance.Path, "Lib"); Directory.CreateDirectory(libDir); @@ -3400,6 +3426,7 @@ public static class Greeter 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)!); @@ -3451,6 +3478,48 @@ public static class Greeter .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, """ + 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!"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, @@ -5091,6 +5160,7 @@ public class LibClass public void UpToDate_RefDirectives() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); var libPath = Path.Join(testInstance.Path, "lib.cs"); var libCode = """ @@ -5841,6 +5911,7 @@ public class LibClass public void CscOnly_AfterMSBuild_RefDirectives() { var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); var libPath = Path.Join(testInstance.Path, "lib.cs"); var libCode = """ From e598825562e35e3a60e3f83920fbfa61cce01d6b Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 14:36:35 +0100 Subject: [PATCH 05/19] Document the feature in the spec --- documentation/general/dotnet-run-file.md | 29 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index bdaf0176fdaa..c8110b2739cf 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -110,8 +110,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 @@ -178,11 +178,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. @@ -209,6 +210,24 @@ 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 compiled library. + A virtual project with `OutputType=Library` 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). + + 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. + + 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 is replaced by a `#:project` directive pointing to that project. + The conversion is recursive: any `#:ref` directives in the referenced files are also converted. + + 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` @@ -228,9 +247,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). From fcd188d9478e5343adfea32a71a06ea45086a844 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 14:51:22 +0100 Subject: [PATCH 06/19] Fixup API file --- .../Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index d6ff6dfbbea6..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 From b53cb294778a5aca8fb7e8942f521a74cc99bb8f Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 14:59:37 +0100 Subject: [PATCH 07/19] Improve code --- .../Commands/Project/Convert/ProjectConvertCommand.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index 761b2d7a7da7..982c660d83c6 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -4,7 +4,6 @@ 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; @@ -92,7 +91,7 @@ public override int Execute() } // Handle deletion of source files if requested. - bool shouldDelete = _deleteSource || TryAskForDeleteSource(file); + bool shouldDelete = _deleteSource || TryAskForDeleteSource(); if (shouldDelete) { // Delete the entry point file @@ -378,11 +377,11 @@ ImmutableArray UpdateDirectives(ImmutableArray return result.DrainToImmutable(); } - IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance pi, string outputType) + IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance projectInstance, string outputType) { foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework, outputType)) { - string projectValue = pi.GetPropertyValue(name); + string projectValue = projectInstance.GetPropertyValue(name); if (string.Equals(projectValue, defaultValue, StringComparison.OrdinalIgnoreCase)) { yield return (name, defaultValue); @@ -446,7 +445,7 @@ private string DetermineOutputDirectory(string file) return targetDirectory; } - private bool TryAskForDeleteSource(string sourceFile) + private bool TryAskForDeleteSource() { if (!_interactive) { From c3195fc4cb0306739bc9ecfa85471fdbd2b53d3c Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 15:14:54 +0100 Subject: [PATCH 08/19] Fail if target directory already exists --- .../Project/Convert/ProjectConvertCommand.cs | 45 ++++++++++++++++++- .../Convert/DotnetProjectConvertTests.cs | 44 ++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index 982c660d83c6..6bf6191edac5 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -126,6 +126,12 @@ public override int Execute() out var evaluatedDirectives, validateAllDirectives: !_force); + // Pre-validate: ensure all ref target directories don't already exist before writing any files. + if (isEntryPointFile) + { + ValidateRefTargetDirectories(evaluatedDirectives, sourceDirectory, new HashSet(StringComparer.OrdinalIgnoreCase)); + } + CreateDirectory(outputDirectory); // Copy the .cs file with directives removed. @@ -223,7 +229,7 @@ void ConvertReferencedFiles(ImmutableArray directives, string s if (Directory.Exists(refTargetDirectory)) { - continue; + throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, refTargetDirectory); } var (_, _, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", isEntryPointFile: false); @@ -233,6 +239,43 @@ void ConvertReferencedFiles(ImmutableArray directives, string s } } + void ValidateRefTargetDirectories(ImmutableArray directives, string sourceDirectory, HashSet visited) + { + 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(refDir, refName); + + if (Directory.Exists(refTargetDirectory)) + { + throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, refTargetDirectory); + } + + // Recursively validate transitive refs. + var refBuilder = new VirtualProjectBuilder(refPath, VirtualProjectBuildingCommand.TargetFramework, outputType: "Library"); + refBuilder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + out _, + out var refDirectives, + validateAllDirectives: !_force); + ValidateRefTargetDirectories(refDirectives, refDir, visited); + } + } + IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems() { string entryPointFileDirectory = PathUtilities.EnsureTrailingSlash(Path.GetDirectoryName(file)!); diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index a2c1cde302fe..24bfa7178a86 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -332,6 +332,50 @@ public static class Facade .And.HaveStdOut(expectedOutput); } + [Fact] + public void RefDirective_DirectoryAlreadyExists() + { + 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"), """ + 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")); + """); + + // Pre-create the directory that the ref conversion would target. + var libTargetDirectory = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libTargetDirectory); + + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", "Project") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.DirectoryAlreadyExists, libTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(Path.Join(testInstance.Path, "Project")).Should().BeFalse(); + + new DirectoryInfo(testInstance.Path) + .EnumerateFileSystemInfos().Select(d => d.Name).Order() + .Should().BeEquivalentTo(["app.cs", "Directory.Build.props", "lib", "lib.cs"]); + } + [Fact] public void ProjectReference_FullPath_WithVars() { From 1f432b8ab4efd6f95d4ecdf59b3a4d8b1ad000d3 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 23 Mar 2026 16:13:16 +0100 Subject: [PATCH 09/19] Convert all into subfolder and check duplicate target dirs --- .../dotnet/Commands/CliCommandStrings.resx | 4 + .../Project/Convert/ProjectConvertCommand.cs | 88 ++++++---- .../Commands/xlf/CliCommandStrings.cs.xlf | 5 + .../Commands/xlf/CliCommandStrings.de.xlf | 5 + .../Commands/xlf/CliCommandStrings.es.xlf | 5 + .../Commands/xlf/CliCommandStrings.fr.xlf | 5 + .../Commands/xlf/CliCommandStrings.it.xlf | 5 + .../Commands/xlf/CliCommandStrings.ja.xlf | 5 + .../Commands/xlf/CliCommandStrings.ko.xlf | 5 + .../Commands/xlf/CliCommandStrings.pl.xlf | 5 + .../Commands/xlf/CliCommandStrings.pt-BR.xlf | 5 + .../Commands/xlf/CliCommandStrings.ru.xlf | 5 + .../Commands/xlf/CliCommandStrings.tr.xlf | 5 + .../xlf/CliCommandStrings.zh-Hans.xlf | 5 + .../xlf/CliCommandStrings.zh-Hant.xlf | 5 + .../Convert/DotnetProjectConvertTests.cs | 157 +++++++++++++++--- 16 files changed, 259 insertions(+), 55 deletions(-) diff --git a/src/Cli/dotnet/Commands/CliCommandStrings.resx b/src/Cli/dotnet/Commands/CliCommandStrings.resx index 61630d8c6b27..4a9cd0b9453d 100644 --- a/src/Cli/dotnet/Commands/CliCommandStrings.resx +++ b/src/Cli/dotnet/Commands/CliCommandStrings.resx @@ -867,6 +867,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 6bf6191edac5..33faaf04acf0 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -48,7 +48,29 @@ public override int Execute() // Create a project instance for evaluation. var projectCollection = new ProjectCollection(); - var (builder, projectInstance, evaluatedDirectives) = ConvertFile(file, targetDirectory, outputType: "Exe", isEntryPointFile: true); + var builder = new VirtualProjectBuilder(file, VirtualProjectBuildingCommand.TargetFramework, outputType: "Exe"); + + builder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + out var projectInstance, + 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, outputType: "Exe", isEntryPointFile: true); // Find other items to copy over, e.g., default Content items like JSON files in Web apps. var includeItems = FindIncludedItems().ToList(); @@ -60,7 +82,7 @@ public override int Execute() // Copy or move over included items. foreach (var item in includeItems) { - string targetItemFullPath = Path.Combine(targetDirectory, item.RelativePath); + string targetItemFullPath = Path.Combine(entryPointOutputDir, item.RelativePath); // Ignore already-copied files. if (File.Exists(targetItemFullPath)) @@ -117,19 +139,26 @@ public override int Execute() { var sourceDirectory = Path.GetDirectoryName(sourceFile)!; - var builder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework, outputType: outputType); + VirtualProjectBuilder fileBuilder; + ProjectInstance fileProjectInstance; + ImmutableArray fileDirectives; - builder.CreateProjectInstance( - projectCollection, - VirtualProjectBuildingCommand.ThrowingReporter, - out var projectInstance, - out var evaluatedDirectives, - validateAllDirectives: !_force); - - // Pre-validate: ensure all ref target directories don't already exist before writing any files. if (isEntryPointFile) { - ValidateRefTargetDirectories(evaluatedDirectives, sourceDirectory, new HashSet(StringComparer.OrdinalIgnoreCase)); + fileBuilder = builder; + fileProjectInstance = projectInstance; + fileDirectives = evaluatedDirectives; + } + else + { + fileBuilder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework, outputType: outputType); + + fileBuilder.CreateProjectInstance( + projectCollection, + VirtualProjectBuildingCommand.ThrowingReporter, + out fileProjectInstance, + out fileDirectives, + validateAllDirectives: !_force); } CreateDirectory(outputDirectory); @@ -143,7 +172,7 @@ public override int Execute() } else { - VirtualProjectBuildingCommand.RemoveDirectivesFromFile(builder.EntryPointSourceFile, targetFile); + VirtualProjectBuildingCommand.RemoveDirectivesFromFile(fileBuilder.EntryPointSourceFile, targetFile); } // Create project file. @@ -158,13 +187,13 @@ public override int Execute() using var writer = new StreamWriter(stream, Encoding.UTF8); VirtualProjectBuilder.WriteProjectFile( writer, - UpdateDirectives(evaluatedDirectives, sourceDirectory, outputDirectory), + UpdateDirectives(fileDirectives, sourceDirectory, outputDirectory), isVirtualProject: false, - userSecretsId: isEntryPointFile ? DetermineUserSecretsId(projectInstance) : null, - defaultProperties: GetDefaultProperties(projectInstance, outputType)); + userSecretsId: isEntryPointFile ? DetermineUserSecretsId(fileProjectInstance) : null, + defaultProperties: GetDefaultProperties(fileProjectInstance, outputType)); } - return (builder, projectInstance, evaluatedDirectives); + return (fileBuilder, fileProjectInstance, fileDirectives); } void CreateDirectory(string path) @@ -225,12 +254,7 @@ void ConvertReferencedFiles(ImmutableArray directives, string s var refName = Path.GetFileNameWithoutExtension(refPath); var refDir = Path.GetDirectoryName(refPath)!; - var refTargetDirectory = Path.Combine(refDir, refName); - - if (Directory.Exists(refTargetDirectory)) - { - throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, refTargetDirectory); - } + var refTargetDirectory = Path.Combine(targetDirectory, refName); var (_, _, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", isEntryPointFile: false); @@ -239,7 +263,7 @@ void ConvertReferencedFiles(ImmutableArray directives, string s } } - void ValidateRefTargetDirectories(ImmutableArray directives, string sourceDirectory, HashSet visited) + void ValidateRefTargetDirectories(ImmutableArray directives, string sourceDirectory, HashSet visited, HashSet usedFolderNames) { foreach (var directive in directives) { @@ -257,7 +281,12 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st var refName = Path.GetFileNameWithoutExtension(refPath); var refDir = Path.GetDirectoryName(refPath)!; - var refTargetDirectory = Path.Combine(refDir, refName); + var refTargetDirectory = Path.Combine(targetDirectory, refName); + + if (!usedFolderNames.Add(refName)) + { + throw new GracefulException(CliCommandStrings.ProjectConvertDuplicateRefFolderName, refTargetDirectory); + } if (Directory.Exists(refTargetDirectory)) { @@ -272,7 +301,7 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st out _, out var refDirectives, validateAllDirectives: !_force); - ValidateRefTargetDirectories(refDirectives, refDir, visited); + ValidateRefTargetDirectories(refDirectives, refDir, visited, usedFolderNames); } } @@ -396,15 +425,14 @@ ImmutableArray UpdateDirectives(ImmutableArray } // Convert #:ref directives to #:project directives pointing to the referenced file's - // expected converted project location (i.e., sibling directory named after the .cs file). + // 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); - var refDir = Path.GetDirectoryName(refPath)!; - // The referenced file's converted project is expected at: //.csproj - var convertedProjectPath = Path.Combine(refDir, refName, refName + ".csproj"); + // 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) diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf index 3bb69f375577..b944ae120ded 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf @@ -1262,6 +1262,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 0ec39df110ce..83805ecc7adc 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf @@ -1262,6 +1262,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 cd33ddb1cae8..e65be1c17d89 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf @@ -1262,6 +1262,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 8a0bd0573548..37df9c7ed3a0 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf @@ -1262,6 +1262,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 c78cd43a5dc0..863d152b7a2d 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf @@ -1262,6 +1262,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 207944301937..ad9b2cd027a5 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf @@ -1262,6 +1262,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 4788a1e6c97f..d2b29e50cd06 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf @@ -1262,6 +1262,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 4d5b146ff4f4..75c65713a7ef 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf @@ -1262,6 +1262,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 0acb57aad166..8840100cf275 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf @@ -1262,6 +1262,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 61550b7f9a60..cb65c8f77333 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf @@ -1262,6 +1262,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 450e91d741f3..1f3c4cc35cb1 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf @@ -1262,6 +1262,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 c3bdb44fa189..a93e9ebaedec 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf @@ -1262,6 +1262,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 30fa6a45f568..5eb3bc35c921 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf @@ -1262,6 +1262,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/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 24bfa7178a86..446c6e06957a 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -244,13 +244,13 @@ public static class Greeter .Should().Pass(); // #:ref lib.cs should become a ProjectReference to ../lib/lib.csproj - File.ReadAllText(Path.Join(outputDirFullPath, "app.csproj")) + File.ReadAllText(Path.Join(outputDirFullPath, "app", "app.csproj")) .Should().Contain($""" """); // The referenced library should have been converted too. - var libProjectDir = Path.Join(testInstance.Path, "lib"); + 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")) @@ -258,7 +258,7 @@ public static class Greeter // The converted project should build and produce the same output. new DotnetCommand(Log, "run") - .WithWorkingDirectory(outputDirFullPath) + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) .Execute() .Should().Pass() .And.HaveStdOut(expectedOutput); @@ -314,26 +314,26 @@ public static class Facade .Should().Pass(); // All three projects should exist. - File.Exists(Path.Join(outputDirFullPath, "app.csproj")).Should().BeTrue(); - File.Exists(Path.Join(testInstance.Path, "lib1", "lib1.csproj")).Should().BeTrue(); - File.Exists(Path.Join(testInstance.Path, "lib2", "lib2.csproj")).Should().BeTrue(); + 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(testInstance.Path, "lib1", "lib1.csproj")) + 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(outputDirFullPath) + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) .Execute() .Should().Pass() .And.HaveStdOut(expectedOutput); } [Fact] - public void RefDirective_DirectoryAlreadyExists() + public void RefDirective_DuplicateFolderName() { var testInstance = _testAssetsManager.CreateTestDirectory(); @@ -345,35 +345,142 @@ public void RefDirective_DirectoryAlreadyExists() """); - File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ - namespace MyLib; - public static class Greeter - { - public static string Greet(string name) => $"Hello, {name}!"; - } + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + namespace A; + public static class Lib { public static string Get() => "a"; } """); - File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ - #:ref lib.cs - Console.WriteLine(MyLib.Greeter.Greet("World")); + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + namespace B; + public static class Lib { public static string Get() => "b"; } """); - // Pre-create the directory that the ref conversion would target. - var libTargetDirectory = Path.Join(testInstance.Path, "lib"); - Directory.CreateDirectory(libTargetDirectory); + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref a/lib.cs + #:ref b/lib.cs + Console.WriteLine(A.Lib.Get() + B.Lib.Get()); + """); - new DotnetCommand(Log, "project", "convert", "app.cs", "-o", "Project") + 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.DirectoryAlreadyExists, libTargetDirectory)); + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); // Nothing should have been converted. - Directory.Exists(Path.Join(testInstance.Path, "Project")).Should().BeFalse(); + Directory.Exists(outputDirFullPath).Should().BeFalse(); new DirectoryInfo(testInstance.Path) .EnumerateFileSystemInfos().Select(d => d.Name).Order() - .Should().BeEquivalentTo(["app.cs", "Directory.Build.props", "lib", "lib.cs"]); + .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"), """ + 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"), """ + #: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"), """ + 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"), """ + 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"), """ + 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(); } [Fact] From 6cced48ffded5172c22d035b23397f8d0f29d89e Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 27 Mar 2026 14:02:15 +0100 Subject: [PATCH 10/19] Clarify docs --- documentation/general/dotnet-run-file.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index c8110b2739cf..89c778823367 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -223,8 +223,8 @@ The directives are processed as follows: 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 is replaced by a `#:project` directive pointing to that project. - The conversion is recursive: any `#:ref` directives in the referenced files are also converted. + 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`. From 78aef61f457bbb8d56ac212a3d0630b4fdfbd94c Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 27 Mar 2026 14:11:45 +0100 Subject: [PATCH 11/19] Convert included items of referenced file-based libraries --- .../Project/Convert/ProjectConvertCommand.cs | 97 ++++++++++------- .../Convert/DotnetProjectConvertTests.cs | 103 ++++++++++++++++++ 2 files changed, 160 insertions(+), 40 deletions(-) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index 33faaf04acf0..6bc157a416a4 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -73,44 +73,15 @@ public override int Execute() ConvertFile(file, entryPointOutputDir, outputType: "Exe", 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(); // 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)!); // Copy or move over included items. - foreach (var item in includeItems) - { - string targetItemFullPath = Path.Combine(entryPointOutputDir, 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); - } - } + CopyIncludedItems(includeItems, entryPointOutputDir); // Handle deletion of source files if requested. bool shouldDelete = _deleteSource || TryAskForDeleteSource(); @@ -125,11 +96,16 @@ public override int Execute() DeleteFile(item.FullPath); } - // Delete converted referenced files + // Delete converted referenced files and their included items foreach (var refFile in convertedRefFiles) { DeleteFile(refFile); } + + foreach (var item in refIncludeItems) + { + DeleteFile(item.FullPath); + } } return 0; @@ -236,6 +212,41 @@ void DeleteFile(string path) } } + void CopyIncludedItems(List<(string ItemType, string FullPath, string RelativePath)> items, string outputDirectory) + { + 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) @@ -256,7 +267,12 @@ void ConvertReferencedFiles(ImmutableArray directives, string s var refDir = Path.GetDirectoryName(refPath)!; var refTargetDirectory = Path.Combine(targetDirectory, refName); - var (_, _, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", isEntryPointFile: false); + var (refBuilder, refProjectInstance, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", 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); @@ -305,14 +321,15 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st } } - IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems() + IEnumerable<(string ItemType, string FullPath, string RelativePath)> FindIncludedItems( + VirtualProjectBuilder fileBuilder, ProjectInstance fileProjectInstance, string sourceFile) { - string entryPointFileDirectory = PathUtilities.EnsureTrailingSlash(Path.GetDirectoryName(file)!); + 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); @@ -325,7 +342,7 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st 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)) @@ -333,7 +350,7 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st 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. diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 446c6e06957a..6458bb5a822d 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -483,6 +483,109 @@ public static class Lib { public static string Get() => "b"; } 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 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 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(); + } + [Fact] public void ProjectReference_FullPath_WithVars() { From 61485cacab02fb3f623b986b8ab29fc376bcaa2f Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 31 Mar 2026 15:12:31 +0200 Subject: [PATCH 12/19] Update incremental skill --- .github/copilot/skills/incremental-test.md | 1 + 1 file changed, 1 insertion(+) 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 From a881f53a2dc3ff0dbc78403da86c4b4092bc4da6 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 31 Mar 2026 15:17:52 +0200 Subject: [PATCH 13/19] Test combination of `#:ref` and `#:include` --- documentation/general/dotnet-run-file.md | 2 +- .../CommandTests/Run/RunFileTests.cs | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 89c778823367..c4b31cf1f0fd 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -217,7 +217,7 @@ The directives are processed as follows: Unlike `#:project`, `#:ref` points to a `.cs` file (not a `.csproj` file or directory). 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. + 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. diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 08767e595d98..15bc6a271cb9 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -3520,6 +3520,69 @@ public static class Greeter .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 + true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #: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"), """ + #: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!"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, From 4ac58e986542b48c7158d90dbef0ed1c517330ed Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 31 Mar 2026 15:59:18 +0200 Subject: [PATCH 14/19] Keep OutputType default to Exe --- documentation/general/dotnet-run-file.md | 6 ++++-- .../Project/Convert/ProjectConvertCommand.cs | 18 +++++++++--------- .../Run/VirtualProjectBuildingCommand.cs | 3 +-- .../VirtualProjectBuilder.cs | 9 ++++----- .../Convert/DotnetProjectConvertTests.cs | 12 ++++++++++++ .../CommandTests/Run/RunFileTests.cs | 14 +++++++++++++- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index c4b31cf1f0fd..f4ec683495c6 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -210,12 +210,14 @@ 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 compiled library. - A virtual project with `OutputType=Library` is created for the referenced file (e.g., `lib.cs` produces a virtual `lib.cs.csproj`), +- 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). diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index 6bc157a416a4..e05c3d6ad610 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -48,7 +48,7 @@ public override int Execute() // Create a project instance for evaluation. var projectCollection = new ProjectCollection(); - var builder = new VirtualProjectBuilder(file, VirtualProjectBuildingCommand.TargetFramework, outputType: "Exe"); + var builder = new VirtualProjectBuilder(file, VirtualProjectBuildingCommand.TargetFramework); builder.CreateProjectInstance( projectCollection, @@ -70,7 +70,7 @@ public override int Execute() new HashSet(StringComparer.OrdinalIgnoreCase), usedFolderNames); } - ConvertFile(file, entryPointOutputDir, outputType: "Exe", isEntryPointFile: true); + 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(builder, projectInstance, file).ToList(); @@ -111,7 +111,7 @@ public override int Execute() return 0; (VirtualProjectBuilder builder, ProjectInstance projectInstance, ImmutableArray evaluatedDirectives) - ConvertFile(string sourceFile, string outputDirectory, string outputType, bool isEntryPointFile) + ConvertFile(string sourceFile, string outputDirectory, bool isEntryPointFile) { var sourceDirectory = Path.GetDirectoryName(sourceFile)!; @@ -127,7 +127,7 @@ public override int Execute() } else { - fileBuilder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework, outputType: outputType); + fileBuilder = new VirtualProjectBuilder(sourceFile, VirtualProjectBuildingCommand.TargetFramework); fileBuilder.CreateProjectInstance( projectCollection, @@ -166,7 +166,7 @@ public override int Execute() UpdateDirectives(fileDirectives, sourceDirectory, outputDirectory), isVirtualProject: false, userSecretsId: isEntryPointFile ? DetermineUserSecretsId(fileProjectInstance) : null, - defaultProperties: GetDefaultProperties(fileProjectInstance, outputType)); + defaultProperties: GetDefaultProperties(fileProjectInstance)); } return (fileBuilder, fileProjectInstance, fileDirectives); @@ -267,7 +267,7 @@ void ConvertReferencedFiles(ImmutableArray directives, string s var refDir = Path.GetDirectoryName(refPath)!; var refTargetDirectory = Path.Combine(targetDirectory, refName); - var (refBuilder, refProjectInstance, refEvaluatedDirectives) = ConvertFile(refPath, refTargetDirectory, outputType: "Library", isEntryPointFile: false); + 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(); @@ -310,7 +310,7 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st } // Recursively validate transitive refs. - var refBuilder = new VirtualProjectBuilder(refPath, VirtualProjectBuildingCommand.TargetFramework, outputType: "Library"); + var refBuilder = new VirtualProjectBuilder(refPath, VirtualProjectBuildingCommand.TargetFramework); refBuilder.CreateProjectInstance( projectCollection, VirtualProjectBuildingCommand.ThrowingReporter, @@ -465,9 +465,9 @@ ImmutableArray UpdateDirectives(ImmutableArray return result.DrainToImmutable(); } - IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance projectInstance, string outputType) + IEnumerable<(string name, string value)> GetDefaultProperties(ProjectInstance projectInstance) { - foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework, outputType)) + foreach (var (name, defaultValue) in VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework)) { string projectValue = projectInstance.GetPropertyValue(name); if (string.Equals(projectValue, defaultValue, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index fb06c6b4c04a..9db977af5103 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -1211,8 +1211,7 @@ private void CreateReferencedVirtualProjects( var refBuilder = new VirtualProjectBuilder( resolvedPath, - TargetFramework, - outputType: "Library"); + TargetFramework); refBuilder.CreateProjectInstance( projectCollection, diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 29c49210f9a4..0b25fa747027 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -47,15 +47,14 @@ internal VirtualProjectBuilder( string targetFramework, string[]? requestedTargets = null, string? artifactsPath = null, - SourceText? sourceText = null, - string outputType = "Exe") + SourceText? sourceText = null) { Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath)); EntryPointFileFullPath = entryPointFileFullPath; RequestedTargets = requestedTargets; ArtifactsPath = artifactsPath; - _defaultProperties = GetDefaultProperties(targetFramework, outputType); + _defaultProperties = GetDefaultProperties(targetFramework); if (sourceText != null) { @@ -66,9 +65,9 @@ internal VirtualProjectBuilder( /// /// Kept in sync with the default dotnet new console project file (enforced by DotnetProjectConvertTests.SameAsTemplate). /// - internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework, string outputType = "Exe") => + internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) => [ - ("OutputType", outputType), + ("OutputType", "Exe"), ("TargetFramework", targetFramework), ("ImplicitUsings", "enable"), ("Nullable", "enable"), diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 6458bb5a822d..2c82ad2bfa92 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -217,6 +217,7 @@ public void RefDirective() """); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -278,6 +279,7 @@ public void RefDirective_Transitive_Convert() """); File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library namespace Lib2; public static class Helper { @@ -286,6 +288,7 @@ public static class Helper """); File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library #:ref lib2.cs namespace Lib1; public static class Facade @@ -347,12 +350,14 @@ public void RefDirective_DuplicateFolderName() 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"; } """); @@ -395,12 +400,14 @@ public void RefDirective_DuplicateFolderName_Transitive() // 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(); } @@ -409,6 +416,7 @@ 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"; } """); @@ -449,6 +457,7 @@ public void RefDirective_DuplicateFolderName_ViaInclude() // 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"; } """); @@ -456,6 +465,7 @@ 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"; } """); @@ -504,6 +514,7 @@ public void RefDirective_IncludedItemsCopied() Directory.CreateDirectory(libDir); File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library #:property EnableDefaultNoneItems=true namespace MyLib; public static class Greeter @@ -553,6 +564,7 @@ public void RefDirective_IncludedItemsDeleted() Directory.CreateDirectory(libDir); File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library #:property EnableDefaultNoneItems=true namespace MyLib; public static class Greeter diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 15bc6a271cb9..387b2cf43b54 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; @@ -619,6 +619,7 @@ public void ReadFromStdin_RefDirective() Directory.CreateDirectory(libDir); File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3194,6 +3195,7 @@ public void RefDirective() EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3223,6 +3225,7 @@ public void RefDirective_Subdirectory() Directory.CreateDirectory(libDir); File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3291,6 +3294,7 @@ public void RefDirective_InternalsNotAccessible() EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class PublicClass { @@ -3338,6 +3342,7 @@ public void RefDirective_Transitive() EnableRefDirective(testInstance); File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library namespace Lib2; public static class Base { @@ -3346,6 +3351,7 @@ public static class Base """); File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library #:ref lib2.cs namespace Lib1; public static class Middle @@ -3384,6 +3390,7 @@ public void RefDirective_PathFormats(string arg) Directory.CreateDirectory(libDir); File.WriteAllText(Path.Join(libDir, "lib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3432,6 +3439,7 @@ public void RefDirective_Duplicate(string? subdir) Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); File.WriteAllText(Path.Join(testInstance.Path, subdir, "lib.cs"), """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3489,6 +3497,7 @@ public void RefDirective_FeatureFlag() var libPath = Path.Join(testInstance.Path, "lib.cs"); File.WriteAllText(libPath, """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -3538,6 +3547,7 @@ public void RefDirective_WithInclude() """); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library #:include LibHelper.cs #:include LibFormatter.cs namespace MyLib; @@ -5227,6 +5237,7 @@ public void UpToDate_RefDirectives() var libPath = Path.Join(testInstance.Path, "lib.cs"); var libCode = """ + #:property OutputType=Library namespace MyLib; public static class Greeter { @@ -5978,6 +5989,7 @@ public void CscOnly_AfterMSBuild_RefDirectives() var libPath = Path.Join(testInstance.Path, "lib.cs"); var libCode = """ + #:property OutputType=Library namespace MyLib; public static class Greeter { From 459f033ce38681f826fa58c4faf5d75a357c0c83 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 7 Apr 2026 18:11:49 +0200 Subject: [PATCH 15/19] Improve code --- documentation/general/dotnet-run-file.md | 2 +- .../FileLevelDirectiveHelpers.cs | 7 +- .../Run/VirtualProjectBuildingCommand.cs | 55 +++-- .../VirtualProjectBuilder.cs | 2 +- .../Convert/DotnetProjectConvertTests.cs | 57 +++++ .../CommandTests/Run/RunFileTests.cs | 216 +++++++++++++++++- 6 files changed, 307 insertions(+), 32 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index f4ec683495c6..bea36ae2a82d 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -212,7 +212,7 @@ The directives are processed as follows: - 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 ``. + 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). diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index a0c5184cacfe..bee6360deaaf 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -676,12 +676,7 @@ public Ref EnsureResolvedPath(ErrorReporter errorReporter) string.Format(FileBasedProgramsResources.CouldNotFindRefFile, resolvedFilePath))); } - return new Ref(Info, Name) - { - OriginalName = OriginalName, - ExpandedName = ExpandedName, - ResolvedPath = resolvedFilePath, - }; + return WithName(resolvedFilePath, NameKind.Resolved); } public override string ToString() => $"#:ref {Name}"; diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 9db977af5103..855d3c24fbcf 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -1178,7 +1178,7 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection EvaluatedDirectives = evaluatedDirectives; // Create virtual ProjectRootElements for all #:ref directives so MSBuild can resolve them. - CreateReferencedVirtualProjects(projectCollection, evaluatedDirectives, processedFiles: null); + CreateReferencedVirtualProjects(projectCollection, evaluatedDirectives); return project; } @@ -1191,36 +1191,45 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection /// private void CreateReferencedVirtualProjects( ProjectCollection projectCollection, - ImmutableArray directives, - HashSet? processedFiles) + ImmutableArray directives) { - foreach (var refDirective in directives.OfType()) + var processedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath }; + CreateReferencedVirtualProjectsCore(projectCollection, directives, processedFiles); + + static void CreateReferencedVirtualProjectsCore( + ProjectCollection projectCollection, + ImmutableArray directives, + HashSet processedFiles) { - if (refDirective.ResolvedPath is not { } resolvedPath) + foreach (var refDirective in directives.OfType()) { - continue; - } + // ResolvedPath is always set when using ThrowingReporter (EnsureResolvedPath throws on error). + Debug.Assert(refDirective.ResolvedPath is not null); - processedFiles ??= new HashSet(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath }; + if (refDirective.ResolvedPath is not { } resolvedPath) + { + continue; + } - if (!processedFiles.Add(resolvedPath)) - { - // Already processed or cycle detected. - continue; - } + if (!processedFiles.Add(resolvedPath)) + { + // Already processed or cycle detected. + continue; + } - var refBuilder = new VirtualProjectBuilder( - resolvedPath, - TargetFramework); + var refBuilder = new VirtualProjectBuilder( + resolvedPath, + TargetFramework); - refBuilder.CreateProjectInstance( - projectCollection, - ThrowingReporter, - out _, - out var refEvaluatedDirectives); + refBuilder.CreateProjectInstance( + projectCollection, + ThrowingReporter, + out _, + out var refEvaluatedDirectives); - // Recursively create virtual projects for any #:ref in the referenced file. - CreateReferencedVirtualProjects(projectCollection, refEvaluatedDirectives, processedFiles); + // Recursively create virtual projects for any #:ref in the referenced file. + CreateReferencedVirtualProjectsCore(projectCollection, refEvaluatedDirectives, processedFiles); + } } } diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index 0b25fa747027..55ff015af4fb 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -743,7 +743,7 @@ internal static void WriteProjectFile( { var virtualProjectPath = GetVirtualProjectPath(refDirective.ResolvedPath); writer.WriteLine($""" - + """); } diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 2c82ad2bfa92..f63d312d10fa 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -598,6 +598,63 @@ public static class Greeter 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 387b2cf43b54..e1b8ab653202 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -3541,7 +3541,7 @@ public void RefDirective_WithInclude() <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true - true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true """); @@ -3593,6 +3593,220 @@ static class Util .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")))); + } + + /// + /// Verifies that cyclic #:ref references (lib1 → lib2 → lib1) do not cause an infinite loop. + /// + [Fact] + public void RefDirective_Cycle() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + 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"; } + """); + + 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(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.C1.Get()); + """); + + // 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.HaveStdOutContaining("error NU1108"); + } + + /// + /// 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(); + + 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, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "helper1.cs"), """ + #:ref lib.cs + static class Helper1 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "helper2.cs"), """ + #:ref lib.cs + static class Helper2 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:include helper1.cs + #:include helper2.cs + Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello! 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 + + + """); + + 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().Fail() + .And.HaveStdOutContaining("error CS8805"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, From 8cd613df7b03ebf239e18a8d81e2cc34a10062d6 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Wed, 8 Apr 2026 10:52:15 +0200 Subject: [PATCH 16/19] Extend a test --- .../CommandTests/Run/RunFileTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index e1b8ab653202..b1dcefdb8011 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -3763,6 +3763,71 @@ static class Helper2 .And.HaveStdOut("Hello! Hello!"); } + /// + /// 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(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableIncludeDirective}>true + <{CSharpDirective.IncludeOrExclude.ExperimentalFileBasedProgramEnableTransitiveDirectives}>true + + + """); + + // 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!"; + } + """); + + // 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(); + } + """); + + // 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(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #: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! 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. From a6f0839abed7617d85f16faaf7fed0d4918623f9 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 14 Apr 2026 12:00:30 +0200 Subject: [PATCH 17/19] Fixup after merge --- .../dotnet/Commands/Project/Convert/ProjectConvertCommand.cs | 4 +++- src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index ddbbe457f904..4838b909c8ae 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -134,6 +134,7 @@ public override int Execute() projectCollection, VirtualProjectBuildingCommand.ThrowingReporter, out fileProjectInstance, + projectRootElement: out _, out fileDirectives, validateAllDirectives: !_force); } @@ -315,7 +316,8 @@ void ValidateRefTargetDirectories(ImmutableArray directives, st refBuilder.CreateProjectInstance( projectCollection, VirtualProjectBuildingCommand.ThrowingReporter, - out _, + project: out _, + projectRootElement: out _, out var refDirectives, validateAllDirectives: !_force); ValidateRefTargetDirectories(refDirectives, refDir, visited, usedFolderNames); diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index f1ac54ade606..74d897915769 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -1230,7 +1230,8 @@ static void CreateReferencedVirtualProjectsCore( refBuilder.CreateProjectInstance( projectCollection, ThrowingReporter, - out _, + project: out _, + projectRootElement: out _, out var refEvaluatedDirectives); // Recursively create virtual projects for any #:ref in the referenced file. From 5b9635f7420fbc506c023f9d22a3f73bf1b54f02 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 14 Apr 2026 11:24:00 +0200 Subject: [PATCH 18/19] Avoid GC'ing referenced projects --- .../Run/VirtualProjectBuildingCommand.cs | 18 +++++- .../CommandTests/Run/RunFileTests.cs | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 74d897915769..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 @@ -1200,12 +1207,13 @@ private void CreateReferencedVirtualProjects( ImmutableArray directives) { var processedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath }; - CreateReferencedVirtualProjectsCore(projectCollection, directives, processedFiles); + CreateReferencedVirtualProjectsCore(projectCollection, directives, processedFiles, _referencedBuilders); static void CreateReferencedVirtualProjectsCore( ProjectCollection projectCollection, ImmutableArray directives, - HashSet processedFiles) + HashSet processedFiles, + List referencedBuilders) { foreach (var refDirective in directives.OfType()) { @@ -1234,8 +1242,12 @@ static void CreateReferencedVirtualProjectsCore( 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); + CreateReferencedVirtualProjectsCore(projectCollection, refEvaluatedDirectives, processedFiles, referencedBuilders); } } } diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 188f2f5e1fba..f7559bc9e47c 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -7379,4 +7379,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"); + } } From 2a40de3022e2b8898d44650b8ac801ef70a9c775 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 14 Apr 2026 13:40:46 +0200 Subject: [PATCH 19/19] Fixup tests after merge --- test/dotnet.Tests/CommandTests/Run/RunFileTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index f7559bc9e47c..44356531c095 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -3673,6 +3673,7 @@ public void RefDirective_WithInclude() """); File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #!/usr/bin/env dotnet #:property OutputType=Library #:include LibHelper.cs #:include LibFormatter.cs @@ -3707,6 +3708,7 @@ static class Util """); 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())); @@ -3877,6 +3879,7 @@ static class Helper2 """); File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet #:include helper1.cs #:include helper2.cs Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); @@ -3942,6 +3945,7 @@ static class Helper2 """); 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());