Repository: dotnet/sdk
Pull request #47702: Process C# directives in file-based programs
Author: login: jjonescz
Labels: name: untriaged
description: Request triage from a team member
name: Area-run-file
description: Items related to the "dotnet run <file>" effort
Created: 2025-03-18T19:13:20Z | Merged: 2025-03-27T11:30:37Z

https://github.com/dotnet/sdk/blob/main/documentation/general/dotnet-run-file.md#directives-for-project-metadata


Diff:
diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md
index 6305e5293361..a3458a90f72d 100644
--- a/documentation/general/dotnet-run-file.md
+++ b/documentation/general/dotnet-run-file.md
@@ -183,15 +183,14 @@ Directives `sdk`, `package`, and `property` are translated into `<Project Sdk=".
 Other directives result in a warning, reserving them for future use.
 
 ```cs
-#:sdk      Microsoft.NET.Sdk.Web
-#:property TargetFramework=net11.0
-#:property LangVersion=preview
-#:package  System.CommandLine=2.0.0-*
+#:sdk Microsoft.NET.Sdk.Web
+#:property TargetFramework net11.0
+#:property LangVersion preview
+#:package System.CommandLine 2.0.0-*
 ```
 
 The value must be separated from the name of the directive by white space and any leading and trailing white space is not considered part of the value.
-Any value can optionally have two parts separated by `=` or `/`
-(the former is consistent with how properties are usually passed, e.g., `/p:Prop=Value`, and the latter is what the `<Project Sdk="Name/Version">` attribute uses).
+Any value can optionally have two parts separated by a space (more whitespace characters could be allowed in the future).
 The value of the first `#:sdk` is injected into `<Project Sdk="{0}">` with the separator (if any) replaced with `/`,
 and the subsequent `#:sdk` directive values are split by the separator and injected as `<Sdk Name="{0}" Version="{1}" />` elements (or without the `Version` attribute if there is no separator).
 It is an error if the first part (name) is empty (the version is allowed to be empty, but that results in empty `Version=""`).
diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
index 812504b447cb..64d13ff9a2c1 100644
--- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
+++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
@@ -3,7 +3,9 @@
 
 #nullable enable
 
+using System.Collections.Immutable;
 using System.CommandLine;
+using System.IO;
 using Microsoft.DotNet.Cli;
 using Microsoft.DotNet.Cli.Utils;
 using Microsoft.TemplateEngine.Cli.Commands;
@@ -35,13 +37,29 @@ public override int Execute()
             throw new GracefulException(LocalizableStrings.DirectoryAlreadyExists, targetDirectory);
         }
 
+        // Find directives (this can fail, so do this before creating the target directory).
+        var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
+        var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile);
+
         Directory.CreateDirectory(targetDirectory);
 
-        string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
-        string projectFileText = VirtualProjectBuildingCommand.GetNonVirtualProjectFileText();
-        File.WriteAllText(path: projectFile, contents: projectFileText);
+        var targetFile = Path.Join(targetDirectory, Path.GetFileName(file));
+
+        // If there were any directives, remove them from the file.
+        if (directives.Length != 0)
+        {
+            VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text, targetFile);
+            File.Delete(file);
+        }
+        else
+        {
+            File.Move(file, targetFile);
+        }
 
-        File.Move(file, Path.Join(targetDirectory, Path.GetFileName(file)));
+        string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
+        using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
+        using var writer = new StreamWriter(stream, Encoding.UTF8);
+        VirtualProjectBuildingCommand.WriteProjectFile(writer, directives);
 
         return 0;
     }
diff --git a/src/Cli/dotnet/Commands/Run/LocalizableStrings.resx b/src/Cli/dotnet/Commands/Run/LocalizableStrings.resx
index 7835c0542477..1be704f47749 100644
--- a/src/Cli/dotnet/Commands/Run/LocalizableStrings.resx
+++ b/src/Cli/dotnet/Commands/Run/LocalizableStrings.resx
@@ -244,4 +244,20 @@ Make the profile names distinct.</value>
   <data name="LaunchProfileDoesNotExist" xml:space="preserve">
     <value>A launch profile with the name '{0}' doesn't exist.</value>
   </data>
+  <data name="UnrecognizedDirective" xml:space="preserve">
+    <value>Unrecognized directive '{0}' at {1}.</value>
+    <comment>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</comment>
+  </data>
+  <data name="MissingDirectiveName" xml:space="preserve">
+    <value>Missing name of '{0}' at {1}.</value>
+    <comment>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</comment>
+  </data>
+  <data name="PropertyDirectiveMissingParts" xml:space="preserve">
+    <value>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</value>
+    <comment>{0} is the file path and line number. {Locked="="}</comment>
+  </data>
+  <data name="PropertyDirectiveInvalidName" xml:space="preserve">
+    <value>Invalid property name at {0}. {1}</value>
+    <comment>{0} is the file path and line number. {1} is an inner exception message.</comment>
+  </data>
 </root>
diff --git a/src/Cli/dotnet/Commands/Run/RunCommand.cs b/src/Cli/dotnet/Commands/Run/RunCommand.cs
index ec05fb977f47..a577c93c11e7 100644
--- a/src/Cli/dotnet/Commands/Run/RunCommand.cs
+++ b/src/Cli/dotnet/Commands/Run/RunCommand.cs
@@ -110,7 +110,7 @@ public int Execute()
             projectFactory = new VirtualProjectBuildingCommand
             {
                 EntryPointFileFullPath = EntryPointFileFullPath,
-            }.CreateProjectInstance;
+            }.PrepareProjectInstance().CreateProjectInstance;
         }
 
         try
diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
index 9066b1a840e1..43490483ab2d 100644
--- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
+++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
@@ -3,6 +3,11 @@
 
 #nullable enable
 
+using System;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Security;
+using System.Text.RegularExpressions;
 using System.Xml;
 using Microsoft.Build.Construction;
 using Microsoft.Build.Definition;
@@ -10,8 +15,11 @@
 using Microsoft.Build.Execution;
 using Microsoft.Build.Framework;
 using Microsoft.Build.Logging;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
 using Microsoft.DotNet.Cli;
 using Microsoft.DotNet.Cli.Utils;
+using LocalizableStrings = Microsoft.DotNet.Tools.Run.LocalizableStrings;
 
 namespace Microsoft.DotNet.Tools;
 
@@ -20,6 +28,9 @@ namespace Microsoft.DotNet.Tools;
 /// </summary>
 internal sealed class VirtualProjectBuildingCommand
 {
+    private ImmutableArray<CSharpDirective> _directives;
+    private string? _targetFilePath;
+
     public Dictionary<string, string> GlobalProperties { get; } = new(StringComparer.OrdinalIgnoreCase);
     public required string EntryPointFileFullPath { get; init; }
 
@@ -49,6 +60,8 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
             };
             BuildManager.DefaultBuildManager.BeginBuild(parameters);
 
+            PrepareProjectInstance();
+
             // Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`).
             // See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838
             // and https://github.com/dotnet/msbuild/issues/11519.
@@ -117,6 +130,31 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
         }
     }
 
+    /// <summary>
+    /// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
+    /// </summary>
+    public VirtualProjectBuildingCommand PrepareProjectInstance()
+    {
+        Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
+
+        var sourceFile = LoadSourceFile(EntryPointFileFullPath);
+        _directives = FindDirectives(sourceFile);
+
+        // If there were any `#:` directives, remove them from the file.
+        // (This is temporary until Roslyn is updated to ignore them.)
+        _targetFilePath = EntryPointFileFullPath;
+        if (_directives.Length != 0)
+        {
+            var targetDirectory = Path.Join(Path.GetDirectoryName(_targetFilePath), "obj");
+            Directory.CreateDirectory(targetDirectory);
+            _targetFilePath = Path.Join(targetDirectory, Path.GetFileName(_targetFilePath));
+
+            RemoveDirectivesFromFile(_directives, sourceFile.Text, _targetFilePath);
+        }
+
+        return this;
+    }
+
     public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection)
     {
         return CreateProjectInstance(projectCollection, addGlobalProperties: null);
@@ -139,82 +177,281 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (isVirtualProject)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (isVirtualProject)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
@@ -222,3 +459,161 @@ public static bool IsValidEntryPointPath(string entryPointFilePath)
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract class CSharpDirective
+{
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+    {
+        return directiveKind switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, directiveKind, directiveText),
+            "property" => Property.Parse(sourceFile, span, directiveKind, directiveText),
+            "package" => Package.Parse(sourceFile, span, directiveKind, directiveText),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseOptionalTwoParts(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+    {
+        var i = directiveText.IndexOf(' ', StringComparison.Ordinal);
+        var firstPart = checkFirstPart(i < 0 ? directiveText : directiveText[..i]);
+        var secondPart = i < 0 ? [] : directiveText.AsSpan((i + 1)..).TrimStart();
+        if (i < 0 || secondPart.IsWhiteSpace())
+        {
+            return (firstPart, null);
+        }
+
+        return (firstPart, secondPart.ToString());
+
+        string checkFirstPart(string firstPart)
+        {
+            if (string.IsNullOrWhiteSpace(firstPart))
+            {
+                throw new GracefulException(LocalizableStrings.MissingDirectiveName, directiveKind, sourceFile.GetLocationString(span));
+            }
+
+            return firstPart;
+        }
+    }
+
+    /// <summary>
+    /// <c>#!</c> directive.
+    /// </summary>
+    public sealed class Shebang : CSharpDirective;
+
+    /// <summary>
+    /// <c>#:sdk</c> directive.
+    /// </summary>
+    public sealed class Sdk : CSharpDirective
+    {
+        private Sdk() { }
+
+        public required string Name { get; init; }
+        public string? Version { get; init; }
+
+        public static new Sdk Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+        {
+            var (sdkName, sdkVersion) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText);
+
+            return new Sdk
+            {
+                Span = span,
+                Name = sdkName,
+                Version = sdkVersion,
+            };
+        }
+
+        public string ToSlashDelimitedString()
+        {
+            return Version is null ? Name : $"{Name}/{Version}";
+        }
+    }
+
+    /// <summary>
+    /// <c>#:property</c> directive.
+    /// </summary>
+    public sealed class Property : CSharpDirective
+    {
+        private Property() { }
+
+        public required string Name { get; init; }
+        public required string Value { get; init; }
+
+        public static new Property Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+        {
+            var (propertyName, propertyValue) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText);
+
+            if (propertyValue is null)
+            {
+                throw new GracefulException(LocalizableStrings.PropertyDirectiveMissingParts, sourceFile.GetLocationString(span));
+            }
+
+            try
+            {
+                propertyName = XmlConvert.VerifyName(propertyName);
+            }
+            catch (XmlException ex)
+            {
+                throw new GracefulException(string.Format(LocalizableStrings.PropertyDirectiveInvalidName, sourceFile.GetLocationString(span), ex.Message), ex);
+            }
+
+            return new Property
+            {
+                Span = span,
+                Name = propertyName,
+                Value = propertyValue,
+            };
+        }
+    }
+
+    /// <summary>
+    /// <c>#:package</c> directive.
+    /// </summary>
+    public sealed class Package : CSharpDirective
+    {
+        private Package() { }
+
+        public required string Name { get; init; }
+        public string? Version { get; init; }
+
+        public static new Package Parse(SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
+        {
+            var (packageName, packageVersion) = ParseOptionalTwoParts(sourceFile, span, directiveKind, directiveText);
+
+            return new Package
+            {
+                Span = span,
+                Name = packageName,
+                Version = packageVersion,
+            };
+        }
+    }
+}
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.cs.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.cs.xlf
index 59ada52ff3af..004b695765f4 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.cs.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.cs.xlf
@@ -51,11 +51,26 @@ Nastavte odlišné názvy profilů.</target>
         <target state="translated">Profil spuštění s názvem {0} neexistuje.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Pomocí možnosti -p je možné v jednu chvíli zadat pouze jeden projekt.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Vlastnosti, které mají být předány nástroji MSBuild</target>
@@ -151,6 +166,11 @@ Aktuální {1} je {2}.</target>
         <target state="translated">{0} není platný soubor projektu.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Použití nastavení spuštění z {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.de.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.de.xlf
index ecec47ebfcae..36658c85e829 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.de.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.de.xlf
@@ -51,11 +51,26 @@ Erstellen Sie eindeutige Profilnamen.</target>
         <target state="translated">Es ist kein Startprofil mit dem Namen "{0}" vorhanden.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Nur jeweils ein Projekt kann mithilfe der Option „-p“ angegeben werden.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Eigenschaften, die an MSBuild übergeben werden sollen.</target>
@@ -151,6 +166,11 @@ Ein ausführbares Projekt muss ein ausführbares TFM (z. B. net5.0) und den Outp
         <target state="translated">"{0}" ist keine gültige Projektdatei.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Die Starteinstellungen von {0} werden verwendet…</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.es.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.es.xlf
index 63695e47e069..b1b620cea61e 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.es.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.es.xlf
@@ -51,11 +51,26 @@ Defina nombres de perfiles distintos.</target>
         <target state="translated">No existe ningún perfil de inicio con el nombre "{0}".</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Solo se puede especificar un proyecto a la vez mediante la opción -p.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Propiedades que se van a pasar a MSBuild.</target>
@@ -151,6 +166,11 @@ El valor actual de {1} es "{2}".</target>
         <target state="translated">"{0}" no es un archivo de proyecto válido.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Usando la configuración de inicio de {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.fr.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.fr.xlf
index 286a7a541634..c9f29dd9fded 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.fr.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.fr.xlf
@@ -51,11 +51,26 @@ faites en sorte que les noms de profil soient distincts.</target>
         <target state="translated">Un profil de lancement avec le nom '{0}' n'existe pas.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Vous ne pouvez spécifier qu’un seul projet à la fois à l’aide de l’option -p.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Propriétés à passer à MSBuild</target>
@@ -151,6 +166,11 @@ Le {1} actuel est '{2}'.</target>
         <target state="translated">'{0}' n'est pas un fichier projet valide.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Utilisation des paramètres de lancement à partir de {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.it.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.it.xlf
index 5f52c5e48fae..b2342bb37998 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.it.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.it.xlf
@@ -51,11 +51,26 @@ Rendi distinti i nomi dei profili.</target>
         <target state="translated">Non esiste un profilo di avvio con il nome '{0}'.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">È possibile specificare un solo progetto alla volta utilizzando l'opzione -p.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Proprietà da passare a MSBuild.</target>
@@ -151,6 +166,11 @@ Il valore corrente di {1} è '{2}'.</target>
         <target state="translated">'{0}' non è un file di progetto valido.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Uso delle impostazioni di avvio di {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ja.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ja.xlf
index ab29ec8d15c3..af2846b5de11 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ja.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ja.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">'{0} ' という名前の起動プロファイルは存在しません。</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">-p オプションを使用して一度に指定できるプロジェクトは 1 つだけです。</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">MSBuild に渡されるプロパティ。</target>
@@ -151,6 +166,11 @@ The current {1} is '{2}'.</source>
         <target state="translated">'{0}' は有効なプロジェクト ファイルではありません。</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">{0} からの起動設定を使用中...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ko.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ko.xlf
index a2b77c1bc8bf..045b3666c091 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ko.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ko.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">이름이 '{0}'인 시작 프로필이 없습니다.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">-p 옵션을 사용하여 한 번에 하나의 프로젝트만 지정할 수 있습니다.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">MSBuild에 전달할 속성입니다.</target>
@@ -151,6 +166,11 @@ The current {1} is '{2}'.</source>
         <target state="translated">'{0}'은(는) 유효한 프로젝트 파일이 아닙니다.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">{0}의 시작 설정을 사용하는 중...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pl.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pl.xlf
index d013b5a9a62f..ec68e4ea4b84 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pl.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pl.xlf
@@ -51,11 +51,26 @@ Rozróżnij nazwy profilów.</target>
         <target state="translated">Profil uruchamiania o nazwie „{0}” nie istnieje.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Jednocześnie można określić tylko jeden projekt przy użyciu opcji -p.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Właściwości do przekazania do programu MSBuild.</target>
@@ -151,6 +166,11 @@ Bieżący element {1}: „{2}”.</target>
         <target state="translated">„{0}” nie jest prawidłowym plikiem projektu.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Używanie ustawień uruchamiania z profilu {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pt-BR.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pt-BR.xlf
index 258d2670d7e7..6814e4245ddd 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pt-BR.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.pt-BR.xlf
@@ -51,11 +51,26 @@ Diferencie os nomes dos perfis.</target>
         <target state="translated">Um perfil de lançamento com o nome '{0}' não existe.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">Somente um projeto pode ser especificado por vez usando a opção -p.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Propriedades a serem passadas para o MSBuild.</target>
@@ -151,6 +166,11 @@ O {1} atual é '{2}'.</target>
         <target state="translated">'{0}' não é um arquivo de projeto válido.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Usando as configurações de inicialização de {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ru.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ru.xlf
index 78de4b5ebcd1..8869a15391ca 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ru.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.ru.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">Профиль запуска с именем "{0}" не существует.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">С помощью параметра -p можно указать только один проект за раз.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">Свойства, которые необходимо передать в MSBuild.</target>
@@ -151,6 +166,11 @@ The current {1} is '{2}'.</source>
         <target state="translated">"{0}" не является допустимым файлом проекта.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">Используются параметры запуска из {0}...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.tr.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.tr.xlf
index 167786e6277a..fe58dc6dea4d 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.tr.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.tr.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">'{0}' adlı bir başlatma profili yok.</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">-p seçeneği kullanılarak tek seferde yalnızca bir proje belirtilebilir.</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">MSBuild'e geçirilecek özellikler.</target>
@@ -151,6 +166,11 @@ Geçerli {1}: '{2}'.</target>
         <target state="translated">'{0}' geçerli bir proje dosyası değil.</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">{0} içindeki başlatma ayarları kullanılıyor...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hans.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hans.xlf
index 799e30f616a5..8efa7b5ee5be 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hans.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hans.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">名为“{0}”的启动配置文件不存在。</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">使用 -p 选项时一次只能指定一个项目。</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">要传递到 MSBuild 的属性。</target>
@@ -151,6 +166,11 @@ The current {1} is '{2}'.</source>
         <target state="translated">“{0}”不是有效的项目文件。</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">从 {0} 使用启动设置...</target>
diff --git a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hant.xlf b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hant.xlf
index cec07d6b8f89..67af330485b2 100644
--- a/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hant.xlf
+++ b/src/Cli/dotnet/Commands/Run/xlf/LocalizableStrings.zh-Hant.xlf
@@ -51,11 +51,26 @@ Make the profile names distinct.</source>
         <target state="translated">名稱為 '{0}' 的啟動設定檔不存在。</target>
         <note />
       </trans-unit>
+      <trans-unit id="MissingDirectiveName">
+        <source>Missing name of '{0}' at {1}.</source>
+        <target state="new">Missing name of '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="OnlyOneProjectAllowed">
         <source>Only one project can be specified at a time using the -p option.</source>
         <target state="translated">使用 -p 選項時，一次只能指定一個專案。</target>
         <note>{Locked="-p"}</note>
       </trans-unit>
+      <trans-unit id="PropertyDirectiveInvalidName">
+        <source>Invalid property name at {0}. {1}</source>
+        <target state="new">Invalid property name at {0}. {1}</target>
+        <note>{0} is the file path and line number. {1} is an inner exception message.</note>
+      </trans-unit>
+      <trans-unit id="PropertyDirectiveMissingParts">
+        <source>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</source>
+        <target state="new">The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</target>
+        <note>{0} is the file path and line number. {Locked="="}</note>
+      </trans-unit>
       <trans-unit id="PropertyOptionDescription">
         <source>Properties to be passed to MSBuild.</source>
         <target state="translated">要傳送至 MSBuild 的屬性。</target>
@@ -151,6 +166,11 @@ The current {1} is '{2}'.</source>
         <target state="translated">'{0}' 並非有效的專案名稱。</target>
         <note />
       </trans-unit>
+      <trans-unit id="UnrecognizedDirective">
+        <source>Unrecognized directive '{0}' at {1}.</source>
+        <target state="new">Unrecognized directive '{0}' at {1}.</target>
+        <note>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</note>
+      </trans-unit>
       <trans-unit id="UsingLaunchSettingsFromMessage">
         <source>Using launch settings from {0}...</source>
         <target state="translated">使用來自 {0} 的啟動設定...</target>
diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj
index a63a04d75dce..905ff09f59c0 100644
--- a/src/Cli/dotnet/dotnet.csproj
+++ b/src/Cli/dotnet/dotnet.csproj
@@ -120,6 +120,7 @@
     <!-- Lift dependency of NETStandard.Library.NETFramework to version produced in SBRP. -->
     <PackageReference Include="NETStandard.Library" VersionOverride="$(NETStandardLibraryVersion)" ExcludeAssets="All" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
   </ItemGroup>
 
   <ItemGroup Condition=" '$(IncludeAspNetCoreRuntime)' != 'false' ">
diff --git a/test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs b/test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
index 0e3155a6b7cf..dfa42b5c641a 100644
--- a/test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
+++ b/test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
@@ -1,6 +1,10 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using Microsoft.CodeAnalysis.Text;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.DotNet.Tools;
+
 namespace Microsoft.DotNet.Cli.Project.Convert.Tests;
 
 public sealed class DotnetProjectConvertTests(ITestOutputHelper log) : SdkTest(log)
@@ -25,7 +29,15 @@ public void SameAsTemplate()
             .Execute()
             .Should().Pass();
 
-        var dotnetProjectConvertProject = Directory.EnumerateFiles(Path.Join(dotnetProjectConvert, "Program"), "*.csproj").Single();
+        new DirectoryInfo(dotnetProjectConvert)
+            .EnumerateFileSystemInfos().Select(d => d.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(dotnetProjectConvert, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        var dotnetProjectConvertProject = Path.Join(dotnetProjectConvert, "Program", "Program.csproj");
 
         Path.GetFileName(dotnetProjectConvertProject).Should().Be("Program.csproj");
 
@@ -57,6 +69,10 @@ public void DirectoryAlreadyExists()
             .Execute()
             .Should().Fail()
             .And.HaveStdErrContaining("The target directory already exists");
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(d => d.Name).Order()
+            .Should().BeEquivalentTo(["MyApp", "MyApp.cs"]);
     }
 
     [Fact]
@@ -95,6 +111,10 @@ public void OutputOption_DirectoryAlreadyExists()
             .Execute()
             .Should().Fail()
             .And.HaveStdErrContaining("The target directory already exists");
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(d => d.Name).Order()
+            .Should().BeEquivalentTo(["MyApp.cs", "SomeOutput"]);
     }
 
     [Fact]
@@ -110,8 +130,8 @@ public void MultipleEntryPointFiles()
             .Should().Pass();
 
         new DirectoryInfo(testInstance.Path)
-            .EnumerateDirectories().Select(d => d.Name).Order()
-            .Should().BeEquivalentTo(["Program1"]);
+            .EnumerateFileSystemInfos().Select(d => d.Name).Order()
+            .Should().BeEquivalentTo(["Program1", "Program2.cs"]);
 
         new DirectoryInfo(Path.Join(testInstance.Path, "Program1"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
@@ -128,6 +148,9 @@ public void NoFileArgument()
             .Execute()
             .Should().Fail()
             .And.HaveStdErrContaining("Required argument missing for command");
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Should().BeEmpty();
     }
 
     [Fact]
@@ -140,6 +163,9 @@ public void NonExistentFile()
             .Execute()
             .Should().Fail()
             .And.HaveStdErrContaining("The specified file must exist");
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Should().BeEmpty();
     }
 
     [Fact]
@@ -153,6 +179,10 @@ public void NonCSharpFile()
             .Execute()
             .Should().Fail()
             .And.HaveStdErrContaining("The specified file must exist and have '.cs' file extension");
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.vb"]);
     }
 
     [Fact]
@@ -166,6 +196,10 @@ public void ExtensionCasing()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.CS"]);
@@ -184,6 +218,10 @@ public void FileContent(string content)
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
@@ -205,8 +243,355 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk 9.1.0
+                #:property TargetFramework net11.0
+                #:package System.CommandLine 2.0.0-beta4.22272.1
+                #:property LangVersion preview
+                Console.WriteLine();
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <Sdk Name="Aspire.Hosting.Sdk" Version="9.1.0" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <TargetFramework>net11.0</TargetFramework>
+                    <LangVersion>preview</LangVersion>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                Console.WriteLine();
+                """);
+    }
+
+    [Fact]
+    public void Directives_Variable()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:package MyPackage $(MyProp)
+                #:property MyProp MyValue
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <MyProp>MyValue</MyProp>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="MyPackage" Version="$(MyProp)" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Fact]
+    public void Directives_Separators()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:property Prop1   One=a/b
+                #:property Prop2   Two/a=b
+                #:sdk First 1.0=a/b
+                #:sdk Second 2.0/a=b
+                #:sdk Third 3.0=a/b
+                #:package P1 1.0/a=b
+                #:package P2 2.0/a=b
+                """,
+            expectedProject: """
+                <Project Sdk="First/1.0=a/b">
+
+                  <Sdk Name="Second" Version="2.0/a=b" />
+                  <Sdk Name="Third" Version="3.0=a/b" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Prop1>One=a/b</Prop1>
+                    <Prop2>Two/a=b</Prop2>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="P1" Version="1.0/a=b" />
+                    <PackageReference Include="P2" Version="2.0/a=b" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Theory]
+    [InlineData("invalid")]
+    [InlineData("SDK")]
+    public void Directives_Unknown(string directive)
+    {
+        VerifyConversionThrows(
+            inputCSharp: $"""
+                #:sdk Test
+                #:{directive} Test
+                """,
+            expectedWildcardPattern: $"Unrecognized directive '{directive}' at /app/Program.cs:2.");
+    }
+
+    [Fact]
+    public void Directives_Empty()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:
+                #:sdk Test
+                """,
+            expectedWildcardPattern: "Unrecognized directive '' at /app/Program.cs:1.");
+    }
+
+    [Theory, CombinatorialData]
+    public void Directives_EmptyName(
+        [CombinatorialValues("sdk", "property", "package")] string directive,
+        [CombinatorialValues(" ", "")] string value)
+    {
+        VerifyConversionThrows(
+            inputCSharp: $"""
+                #:{directive}{value}
+                """,
+            expectedWildcardPattern: $"Missing name of '{directive}' at /app/Program.cs:1.");
+    }
+
+    [Fact]
+    public void Directives_MissingPropertyValue()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:property Test
+                """,
+            expectedWildcardPattern: "The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': /app/Program.cs:1");
+    }
+
+    [Fact]
+    public void Directives_InvalidPropertyName()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:property Name" Value
+                """,
+            expectedWildcardPattern: """
+                Invalid property name at /app/Program.cs:1. The '"' character, hexadecimal value 0x22, cannot be included in a name.
+                """);
+    }
+
+    [Fact]
+    public void Directives_Escaping()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:property Prop <test">
+                #:sdk <test"> ="<>test
+                #:package <test"> ="<>test
+                """,
+            expectedProject: """
+                <Project Sdk="&lt;test&quot;&gt;/=&quot;&lt;&gt;test">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Prop>&lt;test&quot;&gt;</Prop>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="&lt;test&quot;&gt;" Version="=&quot;&lt;&gt;test" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Fact]
+    public void Directives_Whitespace()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                 #  !  /test
+                  #!  /program   x   
+                    #:   sdk   TestSdk
+                #:property Name   Value   
+                #:property NugetPackageDescription "My package with spaces"
+                 # :property Name Value
+                """,
+            expectedProject: """
+                <Project Sdk="TestSdk">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Name>Value</Name>
+                    <NugetPackageDescription>&quot;My package with spaces&quot;</NugetPackageDescription>
+                  </PropertyGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                 #  !  /test
+                 # :property Name Value
+                """);
+    }
+
+    [Fact]
+    public void Directives_Whitespace_Invalid()
+    {
+        VerifyConversionThrows(
+            inputCSharp: $"""
+                #:   property   Name{'\t'}     Value
+                """,
+            expectedWildcardPattern: "Invalid property name at /app/Program.cs:1. The '\t' character, hexadecimal value 0x09, cannot be included in a name.");
+    }
+
+    private static void Convert(string inputCSharp, out string actualProject, out string? actualCSharp)
+    {
+        var sourceFile = new SourceFile("/app/Program.cs", SourceText.From(inputCSharp, Encoding.UTF8));
+        var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile);
+        var projectWriter = new StringWriter();
+        VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives);
+        actualProject = projectWriter.ToString();
+        actualCSharp = VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text)?.ToString();
+    }
+
+    /// <param name="expectedCSharp">
+    /// <see langword="null"/> means the conversion should not touch the C# content.
+    /// </param>
+    private static void VerifyConversion(string inputCSharp, string expectedProject, string? expectedCSharp)
+    {
+        Convert(inputCSharp, out var actualProject, out var actualCSharp);
+        actualProject.Should().Be(expectedProject);
+        actualCSharp.Should().Be(expectedCSharp);
+    }
+
+    private static void VerifyConversionThrows(string inputCSharp, string expectedWildcardPattern)
+    {
+        var convert = () => Convert(inputCSharp, out _, out _);
+        convert.Should().Throw<GracefulException>().WithMessage(expectedWildcardPattern);
+    }
 }
diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs
index 8aa2e57e72ee..43ee3de38dc6 100644
--- a/test/dotnet-run.Tests/RunFileTests.cs
+++ b/test/dotnet-run.Tests/RunFileTests.cs
@@ -775,4 +775,58 @@ public void Define_02()
             .Should().Fail()
             .And.HaveStdOutContaining("error CS5001:"); // Program does not contain a static 'Main' method suitable for an entry point
     }
+
+    [Fact]
+    public void PackageReference()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:package System.CommandLine 2.0.0-beta4.22272.1
+            using System.CommandLine;
+
+            var rootCommand = new RootCommand("Sample app for System.CommandLine");
+            return await rootCommand.InvokeAsync(args);
+            """);
+
+        new DotnetCommand(Log, "run", "Program.cs", "--", "--help")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass()
+            .And.HaveStdOutContaining("""
+                Description:
+                  Sample app for System.CommandLine
+                """);
+    }
+
+    [Fact]
+    public void PackageReference_CentralVersion()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Directory.Packages.props"), """
+            <Project>
+              <PropertyGroup>
+                <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
+              </PropertyGroup>
+              <ItemGroup>
+                <PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+              </ItemGroup>
+            </Project>
+            """);
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:package System.CommandLine
+            using System.CommandLine;
+
+            var rootCommand = new RootCommand("Sample app for System.CommandLine");
+            return await rootCommand.InvokeAsync(args);
+            """);
+
+        new DotnetCommand(Log, "run", "Program.cs", "--", "--help")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass()
+            .And.HaveStdOutContaining("""
+                Description:
+                  Sample app for System.CommandLine
+                """);
+    }
 }


Reviews:
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T19:14:49Z
user: login: DamianEdwards
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T22:33:02Z
user: login: Forgind
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T02:54:41Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T11:59:54Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:06:35Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:09:46Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:12:37Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:18:37Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:22:36Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:26:19Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:29:13Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T12:39:24Z
user: login: baronfel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T15:47:51Z
user: login: DamianEdwards
body: This is looking good from my point of view 👍 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T17:41:27Z
user: login: jaredpar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T17:04:23Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T18:02:28Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T18:03:01Z
user: login: jaredpar
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T16:02:53Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T16:53:26Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T16:58:38Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:03:13Z
user: login: baronfel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:07:07Z
user: login: baronfel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:07:57Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:15:36Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:20:36Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:26:08Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:27:56Z
user: login: KalleOlaviNiemitalo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:28:55Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:35:16Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:03:15Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:07:50Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:11:37Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:41:54Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:55:58Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T18:59:29Z
user: login: RikkiGibson
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T19:01:20Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:02:15Z
user: login: am11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:15:40Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:23:00Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:23:44Z
user: login: RikkiGibson
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T19:52:52Z
user: login: am11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T22:05:39Z
user: login: DamianEdwards
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T00:05:13Z
user: login: MiYanni
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T00:41:24Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T07:31:51Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T07:33:17Z
user: login: jjonescz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T07:35:16Z
user: login: DamianEdwards
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T17:49:06Z
user: login: RikkiGibson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T17:50:29Z
user: login: MiYanni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T18:59:09Z

Review comments:
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: I've tried using XmlWriter for this, but couldn't make it write indented XML with blank lines between sections. MSBuild APIs also seem unable to do that. So I ended up building the project file as string.
created_at: 2025-03-18T19:14:49Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: DamianEdwards
body: Can we pre-loop the property directives to see if this is being explicitly set in the C# file and omit this in that case? Or are we happy just relying on evaluation order given that we're writing the property directives after this?
created_at: 2025-03-18T22:33:02Z
author_association: MEMBER
diff_hunk: @@ -35,13 +37,29 @@ public override int Execute()
             throw new GracefulException(LocalizableStrings.DirectoryAlreadyExists, targetDirectory);
         }
 
+        // Find directives (this can fail, so do this before creating the target directory).
path: src/Cli/dotnet/commands/dotnet-project/convert/ProjectConvertCommand.cs
user: login: Forgind
body: This is a stupid example, but if you make .cs (a C# file with no name) then call this on it, I think it would fail? Not sure we really care. More relevant, though, I think it would be good to have some error handling logic including reverting state where relevant.
created_at: 2025-03-19T02:33:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
+{
+    private static readonly SearchValues<char> s_separators = SearchValues.Create('/', '=');
+
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: There are other directives that can just stay in the C# file, so I think it'd be best to just not make a directive in this case (and not remove it from the C# file)
created_at: 2025-03-19T02:40:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: In what case is this false? And should it mention to the caller that it failed?
created_at: 2025-03-19T02:41:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,6 +131,31 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
         }
     }
 
+    /// <summary>
+    /// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
+    /// </summary>
+    public VirtualProjectBuildingCommand PrepareProjectInstance()
+    {
+        Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
+
+        var sourceFile = CreateSourceFile(EntryPointFileFullPath);
+        _directives = FindDirectives(sourceFile);
+
+        // If there were any `#:` directives, remove them from the file.
+        // (This is temporary until Roslyn is updated to ignore them.)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: Is there any reason not to remove them if roslyn would ignore them anyway? Just wondering why this wouldn't be permanent
created_at: 2025-03-19T02:46:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,6 +131,31 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
         }
     }
 
+    /// <summary>
+    /// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
+    /// </summary>
+    public VirtualProjectBuildingCommand PrepareProjectInstance()
+    {
+        Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
+
+        var sourceFile = CreateSourceFile(EntryPointFileFullPath);
+        _directives = FindDirectives(sourceFile);
+
+        // If there were any `#:` directives, remove them from the file.
+        // (This is temporary until Roslyn is updated to ignore them.)
+        _targetFilePath = EntryPointFileFullPath;
+        if (_directives.Length != 0)
+        {
+            var targetDirectory = Path.Join(Path.GetDirectoryName(_targetFilePath), "obj");
+            Directory.CreateDirectory(targetDirectory);
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: Is the idea to make a .targets holding the directives information? That seems an odd choice to me compared with just putting them in the csproj
created_at: 2025-03-19T02:47:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: Would it be bad design-wise to just say that you can't have multiple sdks in a file? I can't think of a normal use case where you shouldn't just upgrade to a project file
created_at: 2025-03-19T02:52:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: Evaluation order should work. Do you have any concerns about it?
created_at: 2025-03-19T02:53:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: You don't have to Skip(1)? And perhaps this is nitpicky, but shouldn't you go in reverse order?
created_at: 2025-03-19T02:53:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: Forgind
body: Why do you still need this part if we're making a real project file on disk?
created_at: 2025-03-19T02:54:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: I'm happy relying on evaluation order for now. We could be smarter for the other properties as well. For example, if user has `#:property Nullable=disable`, we could replace the default `<Nullable>enable</Nullable>`. But that's tricky, because if user did `#:property Nullable=$(Nullable);something` for some reason, we probably shouldn't replace the default...
created_at: 2025-03-19T11:59:54Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
+{
+    private static readonly SearchValues<char> s_separators = SearchValues.Create('/', '=');
+
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: This is only handling `#:` directives and there shouldn't be any other than those three. (I.e., this code path is not reachable for existing C# directives like `#pragma`.) We want to error on unknown `#:` directives to reserve them for future use.
created_at: 2025-03-19T12:06:35Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: This is `false` if `RemoveDirectivesFromFile(directives, text)` returns `null` which happens if there are no directives, i.e., no changes to the file are necessary. The method could return `bool`, sure, but currently the caller doesn't need it. It is not a failure, it is just a no-op.
created_at: 2025-03-19T12:09:46Z
author_association: MEMBER
diff_hunk: @@ -117,6 +131,31 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
         }
     }
 
+    /// <summary>
+    /// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
+    /// </summary>
+    public VirtualProjectBuildingCommand PrepareProjectInstance()
+    {
+        Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
+
+        var sourceFile = CreateSourceFile(EntryPointFileFullPath);
+        _directives = FindDirectives(sourceFile);
+
+        // If there were any `#:` directives, remove them from the file.
+        // (This is temporary until Roslyn is updated to ignore them.)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: Because removing them changes line numbers, so for example location of errors reported by Roslyn is off. That's just one example, there are probably others where inconsistencies between the original file and the file passed to Roslyn would lead to unexpected behavior, like `#pragma checksum` or `CallerFilePathAttribute`.

Another reason is performance - now during build we need to create a copy of the file with the directives stripped out and pass that to the compiler instead of the original file, that's just an unnecessary perf hit.
created_at: 2025-03-19T12:12:36Z
author_association: MEMBER
diff_hunk: @@ -117,6 +131,31 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
         }
     }
 
+    /// <summary>
+    /// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
+    /// </summary>
+    public VirtualProjectBuildingCommand PrepareProjectInstance()
+    {
+        Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
+
+        var sourceFile = CreateSourceFile(EntryPointFileFullPath);
+        _directives = FindDirectives(sourceFile);
+
+        // If there were any `#:` directives, remove them from the file.
+        // (This is temporary until Roslyn is updated to ignore them.)
+        _targetFilePath = EntryPointFileFullPath;
+        if (_directives.Length != 0)
+        {
+            var targetDirectory = Path.Join(Path.GetDirectoryName(_targetFilePath), "obj");
+            Directory.CreateDirectory(targetDirectory);
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: No, this has nothing to do with `.targets`. I'm assuming you are referring to `targetDirectory`. Given `dotnet run ./app/file.cs`, targetDirectory will be `./app/obj/` where we will place `./app/obj/file.cs` that has the directives stripped out so we can pass it to Roslyn. This is just temporary, we will skip all this when Roslyn recognizes the directives (i.e., doesn't error on them).
created_at: 2025-03-19T12:18:37Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: That was my original intention but Damian pointed out that multiple sdks are common for example in aspire projects:

```csproj
<Project Sdk="Microsoft.NET.Sdk">

    <Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />

</Project>
```

and those can be otherwise very simple project files so it seems unnecessary to restrict them from file-based design.
created_at: 2025-03-19T12:22:36Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: > You don't have to Skip(1)?

No, that's only needed when the first Sdk is put into `<Project Sdk>` attribute but that never happens for virtual project files. Instead they get all sdks emitted as `<Import Project="Sdk.props" />` elements at the beginning and `<Import Project="Sdk.targets" />` at the end.

> shouldn't you go in reverse order?

I considered that but as far as I can tell, the following

```csproj
<Project>
<Sdk Name="1" />
<Sdk Name="2" />
</Project>
```

is equivalent to

```csproj
<Project>
<Import Project="Sdk.props" Sdk="1" />
<Import Project="Sdk.props" Sdk="2" />
<Import Project="Sdk.targets" Sdk="1" />
<Import Project="Sdk.targets" Sdk="2" />
</Project>
```
created_at: 2025-03-19T12:26:19Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: This part is inside `if (virtualProjectFile)`, it won't be emitted for real project files.

Note that this method (`WriteProjectFile`) is reused for both virtual project files (`dotnet run file` scenarios) and physical project files (`dotnet project convert` scenarios) to avoid duplicating the logic. Of course, there are some conditions like `if (virtualProjectFile)`, so the sharing isn't 100%, but that should all go away when NuGet targets are fixed to handle virtual project files and we don't need the hacky Target overrides anymore.
created_at: 2025-03-19T12:29:13Z
author_association: MEMBER
diff_hunk: @@ -35,13 +37,29 @@ public override int Execute()
             throw new GracefulException(LocalizableStrings.DirectoryAlreadyExists, targetDirectory);
         }
 
+        // Find directives (this can fail, so do this before creating the target directory).
path: src/Cli/dotnet/commands/dotnet-project/convert/ProjectConvertCommand.cs
user: login: jjonescz
body: > This is a stupid example, but if you make .cs (a C# file with no name) then call this on it, I think it would fail?

You're right, good catch. It will fail with an error saying the target directory already exists because the target directory will be the current directory. I guess we could add a dedicated error message for this case, but it doesn't seem necessary. Users will be able to work around by specifying `--output`. Although the generated project will also have an empty name, so probably won't work (but users can rename it and then it will work).

> More relevant, though, I think it would be good to have some error handling logic including reverting state where relevant.

That shouldn't be needed in normal scenarios. That's why all the checks that can fail are done up front, and only then we do the file modifications (creating target directory, moving files over). If any of the checks fail, we fail early. If the checks succeed but any of those file operations fail for some reason, I guess we could abort and try to cleanup. The cleanup could also fail though. I think it might be easier to leave the revert to the user in the rare(?) case it's needed.
created_at: 2025-03-19T12:39:24Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
path: src/Cli/dotnet/commands/dotnet-run/VirtualProjectBuildingCommand.cs
user: login: baronfel
body: here's the MSBuild logic for these implicit imports: https://github.com/dotnet/msbuild/blob/f1b335d0aa9423c304b5cb186bad40935a6e2f2b/src/Build/Construction/ProjectRootElement.cs#L1903-L1932

followed by https://github.com/dotnet/msbuild/blob/f1b335d0aa9423c304b5cb186bad40935a6e2f2b/src/Build/Definition/Project.cs#L2860-L2883 for the actual loading.

Short version: @jjonescz is correct here. it's not a nesting-doll model, it's an in-order Import for both the Sdk.props and Sdk.targets entrypoints.
created_at: 2025-03-19T15:47:51Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
+{
+    private static readonly SearchValues<char> s_separators = SearchValues.Create('/', '=');
+
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseNameAndOptionalVersion(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        var i = value.AsSpan().IndexOfAny(s_separators);
+        if (i < 0)
+        {
+            return (checkFirstPart(value), null);
+        }
+
+        return (checkFirstPart(value[..i]), value[(i + 1)..]);
+
+        string checkFirstPart(string firstPart)
+        {
+            if (string.IsNullOrWhiteSpace(firstPart))
+            {
+                throw new GracefulException(LocalizableStrings.MissingDirectiveName, name, sourceFile.GetLocationString(span));
+            }
+
+            return firstPart;
+        }
+    }
+
+    /// <summary>
+    /// <c>#!</c> directive.
+    /// </summary>
+    public sealed record Shebang : CSharpDirective;
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jaredpar
body: Is there a reason these nested types need to be `public`?
created_at: 2025-03-24T16:55:47Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jaredpar
body: Where do we actually use the equality aspect of `record`? 
created_at: 2025-03-24T16:56:37Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jaredpar
body: A bit esoteric but this will fail when the C# files are not utf8 encoded. The exact mechanims by which we detect the encoding is not available in the public API of Roslyn, it's a weird impl detail. But could lead to cases where we can process a file in the compiler but not in `dotnet run app.cs`. For now, think we should just say that files need to be utf8 encoded to work with `dotnet run app.cs`.
created_at: 2025-03-24T17:02:52Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
+{
+    private static readonly SearchValues<char> s_separators = SearchValues.Create('/', '=');
+
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseNameAndOptionalVersion(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        var i = value.AsSpan().IndexOfAny(s_separators);
+        if (i < 0)
+        {
+            return (checkFirstPart(value), null);
+        }
+
+        return (checkFirstPart(value[..i]), value[(i + 1)..]);
+
+        string checkFirstPart(string firstPart)
+        {
+            if (string.IsNullOrWhiteSpace(firstPart))
+            {
+                throw new GracefulException(LocalizableStrings.MissingDirectiveName, name, sourceFile.GetLocationString(span));
+            }
+
+            return firstPart;
+        }
+    }
+
+    /// <summary>
+    /// <c>#!</c> directive.
+    /// </summary>
+    public sealed record Shebang : CSharpDirective;
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: Yes, they are used above in the code that constructs the csproj.
created_at: 2025-03-24T18:02:27Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract record CSharpDirective
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: I don't think we do currently, I can remove the `record` keyword to make that clearer. Thanks.
created_at: 2025-03-24T18:03:00Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: design nit, not to be addressed in this PR: it feels strange that sdk versions and package versions are specified using a different syntax.
created_at: 2025-03-25T16:53:25Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: jjonescz
body: Assuming you mean the separator (`/` vs `=`), actually either is permitted currently for any directive; each has its own rationale as explained in the spec:

> Any value can optionally have two parts separated by `=` or `/` (the former is consistent with how properties are usually passed, e.g., `/p:Prop=Value`, and the latter is what the `<Project Sdk="Name/Version">` attribute uses).
created_at: 2025-03-25T16:58:37Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: Huh, I have never had a need to specify an sdk version in the project node, so I did not know.
created_at: 2025-03-25T17:03:12Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: baronfel
body: It's really only used for NuGet-delivered MSBuild SDKs - common ones are Traversal, NoTargets, and now Cargo, all of which are [here](https://github.com/microsoft/MSBuildSdks). 
created_at: 2025-03-25T17:07:07Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: baronfel
body: I do resonate with the feedback though - if we have a first-class syntax for the concept, we don't need to adhere to what MSBuild requires necessarily - as long as the 'eject' handles the transformation if required.
created_at: 2025-03-25T17:07:56Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: jjonescz
body: I can change this easily, I assume `=` would be better as the only allowed separator?
created_at: 2025-03-25T17:15:36Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
+                #:property LangVersion=preview
+                Console.WriteLine();
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <Sdk Name="Aspire.Hosting.Sdk" Version="9.1.0" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <TargetFramework>net11.0</TargetFramework>
+                    <LangVersion>preview</LangVersion>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                Console.WriteLine();
+                """);
+    }
+
+    [Fact]
+    public void Directives_Variable()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:package MyPackage=$(MyProp)
+                #:property MyProp=MyValue
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <MyProp>MyValue</MyProp>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="MyPackage" Version="$(MyProp)" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Fact]
+    public void Directives_Separators()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:property Prop1=One=a/b
+                #:property Prop2/Two/a=b
+                #:sdk First=1.0=a/b
+                #:sdk Second=2.0/a=b
+                #:sdk Third/3.0=a/b
+                #:package P1=1.0/a=b
+                #:package P2/2.0/a=b
+                """,
+            expectedProject: """
+                <Project Sdk="First/1.0=a/b">
+
+                  <Sdk Name="Second" Version="2.0/a=b" />
+                  <Sdk Name="Third" Version="3.0=a/b" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Prop1>One=a/b</Prop1>
+                    <Prop2>Two/a=b</Prop2>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="P1" Version="1.0/a=b" />
+                    <PackageReference Include="P2" Version="2.0/a=b" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Theory]
+    [InlineData("invalid")]
+    [InlineData("SDK")]
+    public void Directives_Unknown(string directive)
+    {
+        VerifyConversionThrows(
+            inputCSharp: $"""
+                #:sdk Test
+                #:{directive} Test
+                """,
+            expectedWildcardPattern: $"Unrecognized directive '{directive}' at /app/Program.cs:2.");
+    }
+
+    [Fact]
+    public void Directives_Empty()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:
+                #:sdk Test
+                """,
+            expectedWildcardPattern: "Unrecognized directive '' at /app/Program.cs:1.");
+    }
+
+    [Theory, CombinatorialData]
+    public void Directives_EmptyName(
+        [CombinatorialValues("sdk", "property", "package")] string directive,
+        [CombinatorialValues("=1.0", "")] string value)
+    {
+        VerifyConversionThrows(
+            inputCSharp: $"""
+                #:{directive} {value}
+                """,
+            expectedWildcardPattern: $"Missing name of '{directive}' at /app/Program.cs:1.");
+    }
+
+    [Fact]
+    public void Directives_MissingPropertyValue()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:property Test
+                """,
+            expectedWildcardPattern: "The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': /app/Program.cs:1");
+    }
+
+    [Fact]
+    public void Directives_InvalidPropertyName()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:property Name"=Value
+                """,
+            expectedWildcardPattern: """
+                Invalid property name at /app/Program.cs:1. The '"' character, hexadecimal value 0x22, cannot be included in a name.
+                """);
+    }
+
+    [Fact]
+    public void Directives_Escaping()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:property Prop=<test">
+                #:sdk <test">/="<>test
+                #:package <test">/="<>test
+                """,
+            expectedProject: """
+                <Project Sdk="&lt;test&quot;&gt;/=&quot;&lt;&gt;test">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Prop>&lt;test&quot;&gt;</Prop>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="&lt;test&quot;&gt;" Version="=&quot;&lt;&gt;test" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: "");
+    }
+
+    [Fact]
+    public void Directives_Whitespace()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                 #  !  /test
+                  #!  /program   x   
+                    #:   sdk   TestSdk
+                #:property Name=   Value   
+                 # :property Name=Value
+                """,
+            expectedProject: """
+                <Project Sdk="TestSdk">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <Name>   Value</Name>
+                  </PropertyGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                 #  !  /test
+                 # :property Name=Value
+                """);
+    }
+
+    [Fact]
+    public void Directives_Whitespace_Invalid()
+    {
+        VerifyConversionThrows(
+            inputCSharp: """
+                #:   property   Name   =   Value
+                """,
+            expectedWildcardPattern: "Invalid property name at /app/Program.cs:1. The ' ' character, hexadecimal value 0x20, cannot be included in a name.");
+    }
+
+    private static void Convert(string inputCSharp, out string actualProject, out string? actualCSharp)
+    {
+        var sourceFile = new SourceFile("/app/Program.cs", SourceText.From(inputCSharp, Encoding.UTF8));
+        var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile);
+        var projectWriter = new StringWriter();
+        VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives);
+        actualProject = projectWriter.ToString();
+        actualCSharp = VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text)?.ToString();
+    }
+
+    /// <param name="expectedCSharp">
+    /// <see langword="null"/> means the conversion should not touch the C# content.
+    /// </param>
+    private static void VerifyConversion(string inputCSharp, string expectedProject, string? expectedCSharp)
+    {
+        Convert(inputCSharp, out var actualProject, out var actualCSharp);
+        actualProject.Should().Be(expectedProject);
+        actualCSharp.Should().Be(expectedCSharp);
+    }
+
+    private static void VerifyConversionThrows(string inputCSharp, string expectedWildcardPattern)
+    {
+        0.Invoking(delegate { Convert(inputCSharp, out _, out _); })
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: It looks like the `Invoking` call is mainly for allowing use of an IIFE here. The Should which is used afterwards, also takes an Action for its receiver.

Consider if something like the following would be more suitable:

```cs
var convert = () => Convert(inputCSharp, out _, out _);
convert.Should().Throw<GracefulException>().WithMessage(expectedWildcardPattern);
```

I don't feel too strongly about it, except that using the `0` literal for a receiver, feels a bit "tricky", and slightly distracting from the purpose of the code.
created_at: 2025-03-25T17:20:35Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: I would personally prefer only allowing the `=`, compared to allowing either `=` or `/`. Otherwise if folks encounter the `/` they may think there is a difference.

If I were starting from scratch I would perhaps even want to just use a space as a delimiter here a la `#pragma`. But, I think that is muddying the waters too much for this PR, I would rather just get something out and get feedback, rather than spending time pursuing that. (Apologies that I missed this aspect of the design when that was being written up.)
created_at: 2025-03-25T17:26:07Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
+                #:property LangVersion=preview
+                Console.WriteLine();
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <Sdk Name="Aspire.Hosting.Sdk" Version="9.1.0" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <TargetFramework>net11.0</TargetFramework>
+                    <LangVersion>preview</LangVersion>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                Console.WriteLine();
+                """);
+    }
+
+    [Fact]
+    public void Directives_Variable()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:package MyPackage=$(MyProp)
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: It will be good to have a test at some point which shows that `#:package Package` by itself works, when a `Directory.Versions.props` (or whatever the right conventional file is) is adding a `<PackageVersion Include="Package" Version="..." />` item.
created_at: 2025-03-25T17:27:55Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: KalleOlaviNiemitalo
body: I prefer just changing to `#:package System.CommandLine/2.0.0-beta4.22272.1` and keeping `#:property TargetFramework=net11.0`.
created_at: 2025-03-25T17:28:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile CreateSourceFile(string filePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: RikkiGibson
body: It may be good to use a verb like "Load" or "Read" here as "Create" has a connotation of writing files to disk
created_at: 2025-03-25T17:35:15Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: jjonescz
body: I think I can do space as the separator, that sounds best to me.
created_at: 2025-03-25T18:03:15Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
+                #:property LangVersion=preview
+                Console.WriteLine();
+                """,
+            expectedProject: """
+                <Project Sdk="Microsoft.NET.Sdk">
+
+                  <Sdk Name="Aspire.Hosting.Sdk" Version="9.1.0" />
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                  <PropertyGroup>
+                    <TargetFramework>net11.0</TargetFramework>
+                    <LangVersion>preview</LangVersion>
+                  </PropertyGroup>
+
+                  <ItemGroup>
+                    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+                  </ItemGroup>
+
+                </Project>
+
+                """,
+            expectedCSharp: """
+                Console.WriteLine();
+                """);
+    }
+
+    [Fact]
+    public void Directives_Variable()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #:package MyPackage=$(MyProp)
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: jjonescz
body: I can add such test now, good idea, thanks.
created_at: 2025-03-25T18:07:49Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: RikkiGibson
body: nit: consider a more conventional name for a flag like `isVirtualProject`.

Also, I am wondering if the differences when project file is virtual, are so many/complex, that we would be better off with copy/pasted versions of this function, which each have a more clear flow of logic. Up to you whether such a change would be worthwhile.
created_at: 2025-03-25T18:11:36Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: > Also, I am wondering if the differences when project file is virtual, are so many/complex, that we would be better off with copy/pasted versions of this function, which each have a more clear flow of logic. Up to you whether such a change would be worthwhile.

I prefer the current approach. The virtual project differences are mostly self-contained `if`s apart from the sdk directives, so hopefully it isn't too confusing. We would need to have lot of duplicated logic otherwise. Also ideally the workarounds will be removed some time in the future and both virtual and physical projects will look the same.
created_at: 2025-03-25T18:41:54Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract class CSharpDirective
+{
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseNameAndOptionalVersion(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        var i = value.IndexOf(' ', StringComparison.Ordinal);
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: RikkiGibson
body: nit: it feels like the term "name" is ambiguous, for example, in `#:property AllowUnsafeBlocks=true` it's being used to refer both to `property` and to `AllowUnsafeBlocks`. Perhaps the parameter `name` should be something like `directiveName` here and in `CSharpDirective Parse()` above.
created_at: 2025-03-25T18:55:57Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract class CSharpDirective
+{
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseNameAndOptionalVersion(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        var i = value.IndexOf(' ', StringComparison.Ordinal);
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: RikkiGibson
body: This was meant to go on the line with `ParseNameAndOptionalVersion(...)`, the review tool is being wonky.
created_at: 2025-03-25T18:59:29Z
author_association: MEMBER
diff_hunk: @@ -139,86 +178,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, virtualProjectFile: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, virtualProjectFile: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool virtualProjectFile, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (virtualProjectFile)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (virtualProjectFile)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (virtualProjectFile)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
+    {
+        var positionSpan = new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
+        return $"{positionSpan.Path}:{positionSpan.StartLinePosition.Line + 1}";
+    }
+}
+
+internal static partial class Patterns
+{
+    [GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
+    public static partial Regex Directive { get; }
+
+    [GeneratedRegex("""^\s*#!.*$""")]
+    public static partial Regex Shebang { get; }
+}
+
+/// <summary>
+/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
+/// </summary>
+internal abstract class CSharpDirective
+{
+    private CSharpDirective() { }
+
+    /// <summary>
+    /// Span of the full line including the trailing line break.
+    /// </summary>
+    public required TextSpan Span { get; init; }
+
+    public static CSharpDirective Parse(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        return name switch
+        {
+            "sdk" => Sdk.Parse(sourceFile, span, name, value),
+            "property" => Property.Parse(sourceFile, span, name, value),
+            "package" => Package.Parse(sourceFile, span, name, value),
+            _ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
+        };
+    }
+
+    private static (string, string?) ParseNameAndOptionalVersion(SourceFile sourceFile, TextSpan span, string name, string value)
+    {
+        var i = value.IndexOf(' ', StringComparison.Ordinal);
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: > This was meant to go on the line with `ParseNameAndOptionalVersion(...)`, the review tool is being wonky.

That's probably because I pushed some commits in between your review comments, sorry
created_at: 2025-03-25T19:02:15Z
author_association: MEMBER
path: documentation/general/dotnet-run-file.md
user: login: am11
body: The `--from-stdin` idea here sounds interesting. We added stdin support in csc/vbc: https://github.com/dotnet/roslyn/pull/41166 but it requires wiring up to csc.dll in shell profile on Unix today (https://stackoverflow.com/a/56133028/863980). 

`cat Program.cs | dotnet run --from-stdin` akin to `cat main.c | gcc -xc -` would be a delight! :)
created_at: 2025-03-25T19:15:40Z
author_association: MEMBER
path: documentation/general/dotnet-run-file.md
user: login: jjonescz
body: Yes, I think that should be easy to implement, I can look into it soon as I also think it would be very nice to have.
created_at: 2025-03-25T19:23:00Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: jjonescz
body: > I prefer just changing to `#:package System.CommandLine/2.0.0-beta4.22272.1` and keeping `#:property TargetFramework=net11.0`.

Can you explain why would you choose `/` as separator for packages and `=` for properties?
created_at: 2025-03-25T19:23:44Z
author_association: MEMBER
path: documentation/general/dotnet-run-file.md
user: login: am11
body: Tiny suggestion: it would be cool if `--from-stdin` gets `-` alias based on the established / defacto convention:

```sh
printf "int main() {}" | clang -xc -
echo "int main() {}" | gcc -xc - -o a.out
cat file.rs | rustc -o hello -
printf "console.log('Hello from Node')" | node -
echo "print('Hello from Python')" | python3 -
cat file.lua | lua5.4 -
printf "puts 'Hello from Ruby'" | ruby -
echo "print 'Hello from Perl\n'" | perl -
wget -qO- http://example.com/script.sh | bash -
curl -sSL http://example.com/archive.tar.gz | tar xzf -
```

Note that hyphen is explicitly supported by those tools, and it works on windows as well with those tools (usually with UTF8 codepage). On Unix, they can also use `/dev/stdin` instead.
created_at: 2025-03-25T22:05:39Z
author_association: MEMBER
diff_hunk: @@ -184,14 +184,13 @@ Other directives result in a warning, reserving them for future use.
 
 ```cs
 #:sdk      Microsoft.NET.Sdk.Web
-#:property TargetFramework=net11.0
-#:property LangVersion=preview
-#:package  System.CommandLine=2.0.0-*
+#:property TargetFramework net11.0
+#:property LangVersion preview
+#:package  System.CommandLine 2.0.0-*
path: documentation/general/dotnet-run-file.md
user: login: MiYanni
body: I know the additional spaces after `sdk` and `package` are for vertical alignment but might be misleading for docs. Similarly, is this allowed?
```cs
#:sdk      Microsoft.NET.Sdk.Web
#:property TargetFramework    net11.0
#:property LangVersion        preview
#:package  System.CommandLine 2.0.0-*
```
created_at: 2025-03-25T22:10:56Z
author_association: MEMBER
diff_hunk: @@ -139,86 +177,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: MiYanni
body: I know this is being built to get functionality into the CLI quickly, but it feels like this really should be a model that you populate with values and then output as XML, instead of just a bunch of string smashing.
created_at: 2025-03-25T22:52:46Z
author_association: MEMBER
diff_hunk: @@ -139,86 +177,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (isVirtualProject)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (isVirtualProject)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: MiYanni
body: Huh. I didn't know records could have methods. I've only ever seen them used to store data.
created_at: 2025-03-25T22:59:19Z
author_association: MEMBER
path: documentation/general/dotnet-run-file.md
user: login: DamianEdwards
body: Yep I added stdin support in my global tool shim already at https://github.com/DamianEdwards/csrun
created_at: 2025-03-26T00:05:13Z
author_association: MEMBER
diff_hunk: @@ -184,14 +184,13 @@ Other directives result in a warning, reserving them for future use.
 
 ```cs
 #:sdk      Microsoft.NET.Sdk.Web
-#:property TargetFramework=net11.0
-#:property LangVersion=preview
-#:package  System.CommandLine=2.0.0-*
+#:property TargetFramework net11.0
+#:property LangVersion preview
+#:package  System.CommandLine 2.0.0-*
path: documentation/general/dotnet-run-file.md
user: login: jjonescz
body: Yes, that's allowed. There is a whitespace test which demonstrates that. I can simplify the example though, thanks.
created_at: 2025-03-26T07:31:50Z
author_association: MEMBER
diff_hunk: @@ -139,86 +177,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: > it feels like this really should be a model that you populate with values and then output as XML, instead of just a bunch of string smashing

Can you elaborate what you mean? There is a model - `CSharpDirective` - which is turned into XML. As I said in a comment earlier (https://github.com/dotnet/sdk/pull/47702#discussion_r2001818778), I've tried using XmlWriter but couldn't achieve the same result (btw, I'd argue that XmlWriter also doesn't really have a "model", it has some state but otherwise just writes the values as strings - which is similar to what I do here - but perhaps you meant something different?).

> I know this is being built to get functionality into the CLI quickly

In general, that is *not* my priority over quality code, so please don't assume that and feel free to give any feedback (I can address it in follow up PRs, since you already signed off, I'm planning to merge this one when CI is unstuck).
created_at: 2025-03-26T07:33:17Z
author_association: MEMBER
diff_hunk: @@ -139,86 +177,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
     {
-        return $"""
-            <Project Sdk="Microsoft.NET.Sdk">
+        int processedDirectives = 0;
 
+        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
+        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
+        var packageDirectives = directives.OfType<CSharpDirective.Package>();
+
+        string sdkValue = "Microsoft.NET.Sdk";
+
+        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
+        {
+            sdkValue = firstSdk.ToSlashDelimitedString();
+            processedDirectives++;
+        }
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine($"""
+                <Project>
+
+                  <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
+                  <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
+                """);
+        }
+        else
+        {
+            writer.WriteLine($"""
+                <Project Sdk="{EscapeValue(sdkValue)}">
+
+                """);
+        }
+
+        foreach (var sdk in sdkDirectives.Skip(1))
+        {
+            if (isVirtualProject)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.props" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+            else if (sdk.Version is null)
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" />
+                    """);
+            }
+            else
+            {
+                writer.WriteLine($"""
+                      <Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
+                    """);
+            }
+
+            processedDirectives++;
+        }
+
+        if (processedDirectives > 1)
+        {
+            writer.WriteLine();
+        }
+
+        // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
+        writer.WriteLine($"""
               <PropertyGroup>
-            {CommonProjectProperties}
+                <OutputType>Exe</OutputType>
+                <TargetFramework>net10.0</TargetFramework>
+                <ImplicitUsings>enable</ImplicitUsings>
+                <Nullable>enable</Nullable>
               </PropertyGroup>
+            """);
+
+        if (isVirtualProject)
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                    <EnableDefaultItems>false</EnableDefaultItems>
+                  </PropertyGroup>
+                """);
+        }
+
+        if (propertyDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <PropertyGroup>
+                """);
+
+            foreach (var property in propertyDirectives)
+            {
+                writer.WriteLine($"""
+                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
+                    """);
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </PropertyGroup>");
+        }
+
+        if (packageDirectives.Any())
+        {
+            writer.WriteLine("""
+
+                  <ItemGroup>
+                """);
+
+            foreach (var package in packageDirectives)
+            {
+                if (package.Version is null)
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" />
+                        """);
+                }
+                else
+                {
+                    writer.WriteLine($"""
+                            <PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
+                        """);
+                }
+
+                processedDirectives++;
+            }
+
+            writer.WriteLine("  </ItemGroup>");
+        }
+
+        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
+
+        if (isVirtualProject)
+        {
+            Debug.Assert(targetFilePath is not null);
+
+            writer.WriteLine($"""
+
+                  <ItemGroup>
+                    <Compile Include="{EscapeValue(targetFilePath)}" />
+                  </ItemGroup>
+
+                """);
+
+            foreach (var sdk in sdkDirectives)
+            {
+                writer.WriteLine($"""
+                      <Import Project="Sdk.targets" Sdk="{EscapeValue(sdk.ToSlashDelimitedString())}" />
+                    """);
+            }
+
+            if (!sdkDirectives.Any())
+            {
+                Debug.Assert(sdkValue == "Microsoft.NET.Sdk");
+                writer.WriteLine("""
+                      <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
+                    """);
+            }
+
+            writer.WriteLine("""
+
+                  <!--
+                    Override targets which don't work with project files that are not present on disk.
+                    See https://github.com/NuGet/Home/issues/14148.
+                  -->
+
+                  <Target Name="_FilterRestoreGraphProjectInputItems"
+                          DependsOnTargets="_LoadRestoreGraphEntryPoints"
+                          Returns="@(FilteredRestoreGraphProjectInputItems)">
+                    <ItemGroup>
+                      <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GetAllRestoreProjectPathItems"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems"
+                          Returns="@(_RestoreProjectPathItems)">
+                    <ItemGroup>
+                      <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
+                    </ItemGroup>
+                  </Target>
+
+                  <Target Name="_GenerateRestoreGraph"
+                          DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
+                          Returns="@(_RestoreGraphEntry)">
+                    <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
+                  </Target>
+                """);
+        }
+
+        writer.WriteLine("""
 
             </Project>
+            """);
 
-            """;
+        static string EscapeValue(string value) => SecurityElement.Escape(value);
     }
 
-    private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+    public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
     {
-        var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
-        var projectFileText = $"""
-            <Project>
-              <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
-              <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
+        var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
 
-              <PropertyGroup>
-            {CommonProjectProperties}
+        // NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
+        foreach (var line in sourceFile.Text.Lines)
+        {
+            var lineText = sourceFile.Text.ToString(line.Span);
 
-                <EnableDefaultItems>false</EnableDefaultItems>
-              </PropertyGroup>
+            if (Patterns.Shebang.IsMatch(lineText))
+            {
+                builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
+            }
+            else if (Patterns.Directive.Match(lineText) is { Success: true } match)
+            {
+                builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
+            }
+        }
 
-              <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
-
-              <!--
-                Override targets which don't work with project files that are not present on disk.
-                See https://github.com/NuGet/Home/issues/14148.
-              -->
-
-              <Target Name="_FilterRestoreGraphProjectInputItems"
-                      DependsOnTargets="_LoadRestoreGraphEntryPoints"
-                      Returns="@(FilteredRestoreGraphProjectInputItems)">
-                <ItemGroup>
-                  <FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GetAllRestoreProjectPathItems"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems"
-                      Returns="@(_RestoreProjectPathItems)">
-                <ItemGroup>
-                  <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
-                </ItemGroup>
-              </Target>
-
-              <Target Name="_GenerateRestoreGraph"
-                      DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
-                      Returns="@(_RestoreGraphEntry)">
-                <!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
-              </Target>
-            </Project>
-            """;
-        ProjectRootElement projectRoot;
-        using (var xmlReader = XmlReader.Create(new StringReader(projectFileText)))
+        // The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
+        return builder.ToImmutable();
+    }
+
+    public static SourceFile LoadSourceFile(string filePath)
+    {
+        using var stream = File.OpenRead(filePath);
+        return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
+    }
+
+    public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
+    {
+        if (directives.Length == 0)
+        {
+            return null;
+        }
+
+        Debug.Assert(directives.OrderBy(d => d.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
+
+        for (int i = directives.Length - 1; i >= 0; i--)
+        {
+            var directive = directives[i];
+            text = text.Replace(directive.Span, string.Empty);
+        }
+
+        return text;
+    }
+
+    public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
+    {
+        if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
         {
-            projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            using var stream = File.Open(filePath, FileMode.Create, FileAccess.Write);
+            using var writer = new StreamWriter(stream, Encoding.UTF8);
+            modifiedText.Write(writer);
         }
-        projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath);
-        projectRoot.FullPath = projectFileFullPath;
-        return projectRoot;
     }
 
     public static bool IsValidEntryPointPath(string entryPointFilePath)
     {
         return entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) && File.Exists(entryPointFilePath);
     }
 }
+
+internal readonly record struct SourceFile(string Path, SourceText Text)
+{
+    public string GetLocationString(TextSpan span)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: jjonescz
body: Yes, records are mostly syntactic sugar for capturing the constructor parameters into properties and synthesized equality, otherwise it's just a normal type.
created_at: 2025-03-26T07:35:16Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: DamianEdwards
body: > Can you explain why would you choose / as separator for packages and = for properties?

Because that matches the prior-art for each case today. PackageID=PackageVersion is not something seen anywhere else, but PackageID/PackageVersion is, and for properties PropertyName=PropertyValue matches the syntax at the CLI (`/p:Name=Value`).
created_at: 2025-03-26T17:49:06Z
author_association: MEMBER
diff_hunk: @@ -205,8 +243,353 @@ public void NestedDirectory()
             .Execute()
             .Should().Pass();
 
+        new DirectoryInfo(Path.Join(testInstance.Path, "app"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
         new DirectoryInfo(Path.Join(testInstance.Path, "app", "Program"))
             .EnumerateFileSystemInfos().Select(f => f.Name).Order()
             .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
     }
+
+    /// <summary>
+    /// When processing fails due to invalid directives, no conversion should be performed
+    /// (e.g., the target directory should not be created).
+    /// </summary>
+    [Fact]
+    public void ProcessingFails()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "#:invalid");
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Fail()
+            .And.HaveStdErrContaining("Unrecognized directive 'invalid' at");
+
+        new DirectoryInfo(Path.Join(testInstance.Path))
+            .EnumerateDirectories().Should().BeEmpty();
+    }
+
+    /// <summary>
+    /// End-to-end test of directive processing. More cases are covered by faster unit tests below.
+    /// </summary>
+    [Fact]
+    public void ProcessingSucceeds()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+            #:sdk Aspire.Hosting.Sdk/9.1.0
+            Console.WriteLine();
+            """);
+
+        new DotnetCommand(Log, "project", "convert", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        new DirectoryInfo(testInstance.Path)
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program"]);
+
+        new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
+            .EnumerateFileSystemInfos().Select(f => f.Name).Order()
+            .Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.cs"))
+            .Should().Be("Console.WriteLine();");
+
+        File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
+            .Should().Be("""
+                <Project Sdk="Aspire.Hosting.Sdk/9.1.0">
+
+                  <PropertyGroup>
+                    <OutputType>Exe</OutputType>
+                    <TargetFramework>net10.0</TargetFramework>
+                    <ImplicitUsings>enable</ImplicitUsings>
+                    <Nullable>enable</Nullable>
+                  </PropertyGroup>
+
+                </Project>
+
+                """);
+    }
+
+    [Fact]
+    public void Directives()
+    {
+        VerifyConversion(
+            inputCSharp: """
+                #!/program
+                #:sdk Microsoft.NET.Sdk
+                #:sdk Aspire.Hosting.Sdk/9.1.0
+                #:property TargetFramework=net11.0
+                #:package System.CommandLine=2.0.0-beta4.22272.1
path: test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs
user: login: RikkiGibson
body: Where is PackageId/PackageVersion used today?
created_at: 2025-03-26T17:50:29Z
author_association: MEMBER
diff_hunk: @@ -139,86 +177,443 @@ private ProjectInstance CreateProjectInstance(
         {
             GlobalProperties = globalProperties,
         });
+
+        ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
+        {
+            Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
+
+            var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
+            var projectFileWriter = new StringWriter();
+            WriteProjectFile(projectFileWriter, _directives, isVirtualProject: true, targetFilePath: _targetFilePath);
+            var projectFileText = projectFileWriter.ToString();
+
+            using var reader = new StringReader(projectFileText);
+            using var xmlReader = XmlReader.Create(reader);
+            var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
+            projectRoot.FullPath = projectFileFullPath;
+            return projectRoot;
+        }
     }
 
-    // Kept in sync with the default `dotnet new console` project file (enforced by `DotnetProjectAddTests.SameAsTemplate`).
-    private const string CommonProjectProperties = """
-            <OutputType>Exe</OutputType>
-            <TargetFramework>net10.0</TargetFramework>
-            <ImplicitUsings>enable</ImplicitUsings>
-            <Nullable>enable</Nullable>
-        """;
+    public static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives)
+    {
+        WriteProjectFile(writer, directives, isVirtualProject: false, targetFilePath: null);
+    }
 
-    public static string GetNonVirtualProjectFileText()
+    private static void WriteProjectFile(TextWriter writer, ImmutableArray<CSharpDirective> directives, bool isVirtualProject, string? targetFilePath)
path: src/Cli/dotnet/commands/Run/VirtualProjectBuildingCommand.cs
user: login: MiYanni
body: Okay. Some quick coding and research (since I haven't done this stuff in a while). Here's a working proof-of-concept that outputs exactly one of your project XML test scenarios from here: https://github.com/dotnet/sdk/blob/bee3c8d11222986b55ff6ace8cde38766cf9468f/test/dotnet-project-convert.Tests/DotnetProjectConvertTests.cs#L332-L355

The "blank line" and multiple property group concept is a bit hacky, but there might be a way to modify the serialization of the actual elements to include the blank lines. If that can be done, then you can do property groups as an array instead. I didn't want to spend too long looking into it, though.

Here's the code:
```C#
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Serialization;

// https://stackoverflow.com/a/3732234/294804
// This removes xmlns:xsi and xmlns:xsd from the root node.
XmlSerializerNamespaces emptyXmlNamespace = new([XmlQualifiedName.Empty]);

// https://stackoverflow.com/a/3732234/294804
// Sets indentation and removes the top ?xml element.
XmlWriterSettings xmlSettings = new()
{
    Indent = true,
    OmitXmlDeclaration = true
};

using StringWriter stringWriter = new();
using XmlWriter xmlWriter = XmlWriter.Create(stringWriter, xmlSettings);
new XmlSerializer(typeof(Project)).Serialize(xmlWriter, new Project(), emptyXmlNamespace);

// Convert BlankLine elements to blank lines.
// Remove numbers from PropertyGroup elements.
var lines = stringWriter.ToString().Split(Environment.NewLine);
for (var i = 0; i < lines.Length; i++)
{
    if (lines[i].Contains("BlankLine"))
    {
        lines[i] = string.Empty;
    }
    if (lines[i].Contains("PropertyGroup"))
    {
        lines[i] = Regex.Replace(lines[i], @"(PropertyGroup\d*)", "PropertyGroup");
    }
}
Console.WriteLine(string.Join(Environment.NewLine, lines));

[XmlRoot()]
public class Project
{
    [XmlAttribute("Sdk")]
    public string SdkAttribute { get; set; } = "Microsoft.NET.Sdk";

    // Blank line concept based on here: https://stackoverflow.com/a/13948941/294804
    public string BlankLine1 { get; set; } = string.Empty;

    public Sdk Sdk { get; set; } = new();

    public string BlankLine2 { get; set; } = string.Empty;

    public PropertyGroup1 PropertyGroup1 { get; set; } = new();

    public string BlankLine3 { get; set; } = string.Empty;

    public PropertyGroup2 PropertyGroup2 { get; set; } = new();

    public string BlankLine4 { get; set; } = string.Empty;

    public ItemGroup ItemGroup { get; set; } = new();

    public string BlankLine5 { get; set; } = string.Empty;
}

public class Sdk
{
    [XmlAttribute()]
    public string Name { get; set; } = "Aspire.Hosting.Sdk";

    [XmlAttribute()]
    public string Version { get; set; } = "9.1.0";
}

public class PropertyGroup1
{
    public string OutputType { get; set; } = "Exe";
    public string TargetFramework { get; set; } = "net10.0";
    public string ImplicitUsings { get; set; } = "enable";
    public string Nullable { get; set; } = "enable";
}

public class PropertyGroup2
{
    public string TargetFramework { get; set; } = "net11.0";
    public string LangVersion { get; set; } = "preview";
}

public class ItemGroup
{
    public PackageReference PackageReference { get; set; } = new();
}

public class PackageReference
{
    [XmlAttribute()]
    public string Include { get; set; } = "System.CommandLine";

    [XmlAttribute()]
    public string Version { get; set; } = "2.0.0-beta4.22272.1";
}
```

Here's the output:
![image](https://github.com/user-attachments/assets/5840fdee-e5b2-48d9-9beb-850db61869c2)
created_at: 2025-03-27T18:59:09Z
author_association: MEMBER

PR comments:
user: login: jjonescz
created_at: 2025-03-21T08:44:50Z
body: @RikkiGibson @chsienki @MiYanni for reviews, thanks
author_association: MEMBER
user: login: jjonescz
created_at: 2025-03-25T10:24:17Z
body: @RikkiGibson @chsienki @MiYanni for reviews, thanks
author_association: MEMBER
user: login: RikkiGibson
created_at: 2025-03-25T16:22:36Z
body: Taking a look
author_association: MEMBER
user: login: RikkiGibson
created_at: 2025-03-25T19:05:54Z
body: random test idea. It would be good to demonstrate the behavior for `#:package NugetPackageDescription "My package with spaces"`. For a property directive where the value has spaces in it, the user might think they should quote it because spaces are also a delimiter here. I am not too worried about what is the specific behavior right now, just that we record the behavior.
author_association: MEMBER
user: login: AdmiralSnyder
created_at: 2025-04-17T10:22:28Z
body: Hey, was CSI (c# interactive, csi.exe) and using the same conventions considered in the planning for this?
also, if one could use `dotnet run file.csx` as an alias for (or instead of) the invocation of csi.exe, i think that would be great.
author_association: NONE
user: login: jjonescz
created_at: 2025-04-17T11:08:21Z
body: > Hey, was CSI (c# interactive, csi.exe) and using the same conventions considered in the planning for this?

Yes, it was considered but CSX is a different C# dialect and doesn't have a simple grow up story to project-based programs. If by conventions you mean `#r` that was not chosen because it's a legacy syntax (from times when DLLs were referenced directly) and is much less clear than `#:package`. Here are the design docs:
- https://github.com/dotnet/sdk/blob/main/documentation/general/dotnet-run-file.md
- https://github.com/dotnet/csharplang/blob/main/proposals/ignored-directives.md
author_association: MEMBER
user: login: ravindUwU
created_at: 2025-04-27T04:38:54Z
body: Are there any plans to add `#:` directives to reference other C# projects (`<ProjectReference>`) or assemblies (`<Referece>`)? 🤔
author_association: NONE
user: login: jjonescz
created_at: 2025-04-28T08:24:46Z
body: > Are there any plans to add `#:` directives to reference other C# projects (`<ProjectReference>`) or assemblies (`<Referece>`)? 🤔

No plans for that. But feel free to file an issue, it's something that could be added if there is demand. Thanks.
author_association: MEMBER
user: login: ravindUwU
created_at: 2025-04-28T12:17:42Z
body: Thanks! #48746 ^^
author_association: NONE