File: VirtualProjectBuilder.cs
Web Access
Project: src\src\sdk\src\Microsoft.DotNet.ProjectTools\Microsoft.DotNet.ProjectTools.csproj (Microsoft.DotNet.ProjectTools)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Security;
using System.Xml;
using Microsoft.Build.Construction;
using Microsoft.Build.Definition;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.CodeAnalysis.Text;
using Microsoft.DotNet.FileBasedPrograms;
using Microsoft.DotNet.Utilities;

namespace Microsoft.DotNet.ProjectTools;

public sealed class VirtualProjectBuilder
{
    private readonly IEnumerable<(string name, string value)> _defaultProperties;

    private (ImmutableArray<CSharpDirective> Original, ImmutableArray<CSharpDirective> Evaluated)? _evaluatedDirectives;

    /// <summary>
    /// Prevents the virtual project's <see cref="ProjectRootElement"/> from being garbage collected
    /// when MSBuild's <see cref="ProjectRootElementCache"/> demotes it to a weak reference
    /// (which can happen when many SDK import files fill the cache during NuGet restore).
    /// Without this, nested <c>&lt;MSBuild&gt;</c> tasks that re-evaluate the project with different properties
    /// would fail to find the <see cref="ProjectRootElement"/> in the cache and try to load it from disk,
    /// resulting in MSB4025 because the virtual project file does not exist on disk.
    /// </summary>
    private ProjectRootElement? _projectRootElement;

    internal string EntryPointFileFullPath { get; }

    internal SourceFile EntryPointSourceFile
    {
        get
        {
            if (field == default)
            {
                field = SourceFile.Load(EntryPointFileFullPath);
            }

            return field;
        }
    }

    internal string ArtifactsPath
        => field ??= GetArtifactsPath(EntryPointFileFullPath);

    internal string[]? RequestedTargets { get; }

    internal VirtualProjectBuilder(
        string entryPointFileFullPath,
        string targetFramework,
        string[]? requestedTargets = null,
        string? artifactsPath = null,
        SourceText? sourceText = null)
    {
        Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath));

        EntryPointFileFullPath = entryPointFileFullPath;
        RequestedTargets = requestedTargets;
        ArtifactsPath = artifactsPath;
        _defaultProperties = GetDefaultProperties(targetFramework);

        if (sourceText != null)
        {
            EntryPointSourceFile = new SourceFile(entryPointFileFullPath, sourceText);
        }
    }

    /// <remarks>
    /// Kept in sync with the default <c>dotnet new console</c> project file (enforced by <c>DotnetProjectConvertTests.SameAsTemplate</c>).
    /// </remarks>
    internal static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) =>
    [
        ("OutputType", "Exe"),
        ("TargetFramework", targetFramework),
        ("ImplicitUsings", "enable"),
        ("Nullable", "enable"),
        ("PublishAot", "true"),
        ("PackAsTool", "true"),
    ];

    internal static string GetArtifactsPath(string entryPointFileFullPath)
    {
        // Include entry point file name so the directory name is not completely opaque.
        string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
        string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath);
        string directoryName = $"{fileName}-{hash}";

        return GetTempSubpath(directoryName);
    }

    private const string CsprojExtension = ".csproj";

    public static string GetVirtualProjectPath(string entryPointFilePath)
        => entryPointFilePath + CsprojExtension;

    public static bool TryGetEntryPointFilePathFromVirtualProjectPath(string projectPath, [NotNullWhen(returnValue: true)] out string? entryPointFilePath)
    {
        if (projectPath.EndsWith(CsprojExtension, StringComparison.OrdinalIgnoreCase))
        {
            entryPointFilePath = projectPath[..^CsprojExtension.Length];
            if (IsValidEntryPointPath(entryPointFilePath))
            {
                return true;
            }
        }

        entryPointFilePath = null;
        return false;
    }

    /// <summary>
    /// Parses a source file to extract property value from directives.
    /// </summary>
    /// <returns>Array of frameworks if TargetFrameworks is specified, or empty otherwise</returns>
    public static string? GetPropertyFromSourceFile(string sourceFilePath, string propertyName)
    {
        var sourceFile = SourceFile.Load(sourceFilePath);
        var directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: false, ErrorReporters.IgnoringReporter);

        // Return the first value. Duplicate directives are not supported.
        return directives.OfType<CSharpDirective.Property>()
            .FirstOrDefault(p => string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase))?.Value;
    }

    /// <summary>
    /// Obtains a temporary subdirectory for file-based app artifacts, e.g., <c>/tmp/dotnet/runfile/</c>.
    /// </summary>
    internal static string GetTempSubdirectory()
    {
        // We want a location where permissions are expected to be restricted to the current user.
        string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
            ? Path.GetTempPath()
            : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

        if (string.IsNullOrEmpty(directory))
        {
            throw new InvalidOperationException(Resources.EmptyTempPath);
        }

        return Path.Join(directory, "dotnet", "runfile");
    }

    /// <summary>
    /// Obtains a specific temporary path in a subdirectory for file-based app artifacts, e.g., <c>/tmp/dotnet/runfile/{name}</c>.
    /// </summary>
    internal static string GetTempSubpath(string name)
    {
        return Path.Join(GetTempSubdirectory(), name);
    }

    public static bool IsValidEntryPointPath(string entryPointFilePath)
    {
        if (!File.Exists(entryPointFilePath))
        {
            return false;
        }

        if (entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        // Check if the first two characters are #!
        try
        {
            using var stream = File.OpenRead(entryPointFilePath);
            int first = stream.ReadByte();
            int second = stream.ReadByte();
            return first == '#' && second == '!';
        }
        catch
        {
            return false;
        }
    }

    /// <summary>
    /// Evaluates <paramref name="directives"/> against a <paramref name="project"/> and the file system.
    /// </summary>
    /// <remarks>
    /// All directives that need some other evaluation (described below) are expanded as MSBuild expressions
    /// (i.e., <c>$()</c> and <c>@()</c> are substituted with property and item values, etc.).
    /// <para/>
    /// <c>#:project</c> directives are resolved to full project file paths
    /// (e.g., if the evaluated value is a directory, finds a project in that directory).
    /// <para/>
    /// <c>#:include</c>/<c>#:exclude</c> have their <see cref="CSharpDirective.IncludeOrExclude.ItemType"/> determined
    /// and relative paths resolved relative to their containing file.
    /// </remarks>
    private ImmutableArray<CSharpDirective> EvaluateDirectives(
        ProjectInstance project,
        ImmutableArray<CSharpDirective> directives,
        ErrorReporter reportError)
    {
        if (!directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.IncludeOrExclude or CSharpDirective.Ref))
        {
            return directives;
        }

        var builder = ImmutableArray.CreateBuilder<CSharpDirective>(directives.Length);

        ImmutableArray<(string Extension, string ItemType)> mapping = default;

        foreach (var directive in directives)
        {
            switch (directive)
            {
                case CSharpDirective.Project projectDirective:
                    projectDirective = projectDirective.WithName(project.ExpandString(projectDirective.Name), CSharpDirective.Project.NameKind.Expanded);
                    projectDirective = projectDirective.EnsureProjectFilePath(reportError);

                    builder.Add(projectDirective);
                    break;

                case CSharpDirective.Ref refDirective:
                    refDirective = refDirective.WithName(project.ExpandString(refDirective.Name), CSharpDirective.Ref.NameKind.Expanded);
                    refDirective = refDirective.EnsureResolvedPath(reportError);

                    builder.Add(refDirective);
                    break;

                case CSharpDirective.IncludeOrExclude includeOrExcludeDirective:
                    var expandedPath = project.ExpandString(includeOrExcludeDirective.Name);
                    var fullPath = Path.GetFullPath(path: expandedPath, basePath: Path.GetDirectoryName(includeOrExcludeDirective.Info.SourceFile.Path)!);
                    includeOrExcludeDirective = includeOrExcludeDirective.WithName(fullPath);

                    if (mapping.IsDefault)
                    {
                        mapping = GetItemMapping(project, reportError);
                    }

                    includeOrExcludeDirective = includeOrExcludeDirective.WithDeterminedItemType(reportError, mapping);

                    builder.Add(includeOrExcludeDirective);
                    break;

                default:
                    builder.Add(directive);
                    break;
            }
        }

        return builder.DrainToImmutable();
    }

    internal ImmutableArray<(string Extension, string ItemType)> GetItemMapping(ProjectInstance project, ErrorReporter reportError)
    {
        return CSharpDirective.IncludeOrExclude.ParseMapping(
            project.GetPropertyValue(CSharpDirective.IncludeOrExclude.MappingPropertyName),
            EntryPointSourceFile,
            reportError);
    }

    public static ProjectInstance CreateProjectInstance(
        string entryPointFilePath,
        string targetFramework,
        ProjectCollection projectCollection,
        Action<string, int, string> errorReporter)
    {
        var builder = new VirtualProjectBuilder(entryPointFilePath, targetFramework);

        builder.CreateProjectInstance(
            projectCollection,
            (text, path, textSpan, message, _) => errorReporter(path, text.Lines.GetLinePositionSpan(textSpan).Start.Line + 1, message),
            out var projectInstance,
            projectRootElement: out _,
            evaluatedDirectives: out _);

        return projectInstance;
    }

    internal void CreateProjectInstance(
        ProjectCollection projectCollection,
        ErrorReporter reportError,
        out ProjectInstance project,
        out ProjectRootElement projectRootElement,
        out ImmutableArray<CSharpDirective> evaluatedDirectives,
        ImmutableArray<CSharpDirective> directives = default,
        Action<IDictionary<string, string>>? addGlobalProperties = null,
        bool validateAllDirectives = false)
    {
        var directivesOriginal = directives;

        if (directives.IsDefault)
        {
            directives = FileLevelDirectiveHelpers.FindDirectives(EntryPointSourceFile, validateAllDirectives, reportError, checkDuplicates: false);
        }

        (string ProjectFileText, ProjectInstance ProjectInstance, ProjectRootElement ProjectRootElement)? lastProject = null;

        // If we evaluated directives previously (e.g., during restore), reuse them.
        // We don't use the additional properties from `addGlobalProperties`
        // during directive evaluation anyway, so the directives can be reused safely.
        if (_evaluatedDirectives is { } cached &&
            cached.Original == directivesOriginal)
        {
            evaluatedDirectives = cached.Evaluated;
            (project, projectRootElement) = CreateProjectInstanceNoEvaluation(
                projectCollection,
                evaluatedDirectives,
                addGlobalProperties);

            CheckDirectives(project, evaluatedDirectives, reportError);

            return;
        }

        var entryPointDirectory = Path.GetDirectoryName(EntryPointFileFullPath)!;
        var seenFiles = new HashSet<string>(1, StringComparer.Ordinal) { EntryPointFileFullPath };
        var filesToProcess = new Queue<string>();
        var evaluatedDirectiveBuilder = ImmutableArray.CreateBuilder<CSharpDirective>();
        var deduplicator = new DirectiveDeduplicator();

        do
        {
            // Create a project with properties from #:property directives so they can be expanded inside EvaluateDirectives.
            (project, projectRootElement) = CreateProjectInstanceNoEvaluation(
                projectCollection,
                [.. evaluatedDirectiveBuilder, .. directives],
                addGlobalProperties);

            // Evaluate directives, e.g., determine item types for #:include/#:exclude from their file extension.
            var fileEvaluatedDirectives = EvaluateDirectives(project, directives, reportError);

            // Detect duplicate directives across all files on evaluated directives.
            foreach (var directive in fileEvaluatedDirectives)
            {
                if (directive is CSharpDirective.Named named)
                {
                    deduplicator.CheckDirective(named, reportError);
                }
            }

            evaluatedDirectiveBuilder.AddRange(fileEvaluatedDirectives);

            if (fileEvaluatedDirectives != directives)
            {
                // This project will contain items from #:include/#:exclude directives which we will traverse recursively.
                (project, projectRootElement) = CreateProjectInstanceNoEvaluation(
                    projectCollection,
                    evaluatedDirectiveBuilder.ToImmutable(),
                    addGlobalProperties);
            }

            var compileItems = project.GetItems("Compile");
            foreach (var compileItem in compileItems)
            {
                var compilePath = Path.GetFullPath(
                    path: compileItem.GetMetadataValue("FullPath"),
                    basePath: entryPointDirectory);
                if (seenFiles.Add(compilePath))
                {
                    filesToProcess.Enqueue(compilePath);
                }
            }
        }
        while (TryGetNextFileToProcess());

        evaluatedDirectives = evaluatedDirectiveBuilder.ToImmutable();
        _evaluatedDirectives = (directivesOriginal, evaluatedDirectives);

        CheckDirectives(project, evaluatedDirectives, reportError);

        bool TryGetNextFileToProcess()
        {
            while (filesToProcess.TryDequeue(out var filePath))
            {
                if (!File.Exists(filePath))
                {
                    reportError(EntryPointSourceFile.Text, EntryPointSourceFile.Path, default, string.Format(Resources.IncludedFileNotFound, filePath));
                    continue;
                }

                var sourceFile = SourceFile.Load(filePath);
                directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, validateAllDirectives, reportError, checkDuplicates: false);
                return true;
            }

            return false;
        }

        (ProjectInstance, ProjectRootElement) CreateProjectInstanceNoEvaluation(
            ProjectCollection projectCollection,
            ImmutableArray<CSharpDirective> directives,
            Action<IDictionary<string, string>>? addGlobalProperties = null)
        {
            var projectFileWriter = new StringWriter();

            WriteProjectFile(
                projectFileWriter,
                directives,
                _defaultProperties,
                isVirtualProject: true,
                entryPointFilePath: EntryPointFileFullPath,
                artifactsPath: ArtifactsPath,
                includeRuntimeConfigInformation: RequestedTargets?.ContainsAny("Publish", "Pack") != true);

            var projectFileText = projectFileWriter.ToString();

            // If nothing changed, reuse the previous project instance to avoid unnecessary re-evaluations.
            if (lastProject is { } cachedProject && cachedProject.ProjectFileText == projectFileText)
            {
                return (cachedProject.ProjectInstance, cachedProject.ProjectRootElement);
            }

            var projectRoot = CreateProjectRootElement(projectFileText, projectCollection);

            var globalProperties = projectCollection.GlobalProperties;
            if (addGlobalProperties is not null)
            {
                globalProperties = new Dictionary<string, string>(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase);
                addGlobalProperties(globalProperties);
            }

            var project = ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions
            {
                ProjectCollection = projectCollection,
                GlobalProperties = globalProperties,
            });

            lastProject = (projectFileText, project, projectRoot);

            return (project, projectRoot);

            ProjectRootElement CreateProjectRootElement(string projectFileText, ProjectCollection projectCollection)
            {
                using var reader = new StringReader(projectFileText);
                using var xmlReader = XmlReader.Create(reader);
                var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
                projectRoot.FullPath = GetVirtualProjectPath(EntryPointFileFullPath);
                _projectRootElement = projectRoot;
                return projectRoot;
            }
        }
    }

    private void CheckDirectives(
        ProjectInstance project,
        ImmutableArray<CSharpDirective> directives,
        ErrorReporter reportError)
    {
        bool? refEnabled = null;

        foreach (var directive in directives)
        {
            if (directive is CSharpDirective.Ref)
            {
                CheckFlagEnabled(ref refEnabled, CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, directive);
            }
        }

        void CheckFlagEnabled(ref bool? flag, string flagName, CSharpDirective directive)
        {
            bool value = flag ??= MSBuildUtilities.ConvertStringToBool(project.GetPropertyValue(flagName));

            if (!value)
            {
                reportError(
                    directive.Info.SourceFile.Text,
                    directive.Info.SourceFile.Path,
                    directive.Info.Span,
                    string.Format(Resources.ExperimentalFeatureDisabled, flagName));
            }
        }
    }

    internal static void WriteProjectFile(
        TextWriter writer,
        ImmutableArray<CSharpDirective> directives,
        IEnumerable<(string name, string value)> defaultProperties,
        bool isVirtualProject,
        string? entryPointFilePath = null,
        string? artifactsPath = null,
        bool includeRuntimeConfigInformation = true,
        string? userSecretsId = null)
    {
        Debug.Assert(userSecretsId == null || !isVirtualProject);

        int processedDirectives = 0;

        var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
        var propertyDirectives = directives.OfType<CSharpDirective.Property>();
        var packageDirectives = directives.OfType<CSharpDirective.Package>();
        var projectDirectives = directives.OfType<CSharpDirective.Project>();
        var refDirectives = directives.OfType<CSharpDirective.Ref>();
        var includeOrExcludeDirectives = directives.OfType<CSharpDirective.IncludeOrExclude>();

        const string defaultSdkName = "Microsoft.NET.Sdk";
        string firstSdkName;
        string? firstSdkVersion;

        if (sdkDirectives.FirstOrDefault() is { } firstSdk)
        {
            firstSdkName = firstSdk.Name;
            firstSdkVersion = firstSdk.Version;
            processedDirectives++;
        }
        else
        {
            firstSdkName = defaultSdkName;
            firstSdkVersion = null;
        }

        if (isVirtualProject)
        {
            Debug.Assert(!string.IsNullOrWhiteSpace(artifactsPath));
            Debug.Assert(entryPointFilePath is not null);

            // Note that ArtifactsPath needs to be specified before Sdk.props
            // (usually it's recommended to specify it in Directory.Build.props
            // but importing Sdk.props manually afterwards also works).
            writer.WriteLine($"""
                <Project>

                  <PropertyGroup>
                    <IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
                    <ArtifactsPath>{EscapeValue(artifactsPath)}</ArtifactsPath>
                    <AssemblyName>{EscapeValue(Path.GetFileNameWithoutExtension(entryPointFilePath))}</AssemblyName>
                    <RootNamespace>$(AssemblyName)</RootNamespace>
                    <PublishDir>artifacts/$(AssemblyName)</PublishDir>
                    <PackageOutputPath>artifacts/$(AssemblyName)</PackageOutputPath>
                    <FileBasedProgram>true</FileBasedProgram>
                    <EntryPointFilePath>{EscapeValue(entryPointFilePath)}</EntryPointFilePath>
                    <FileBasedProgramsItemMapping>{CSharpDirective.IncludeOrExclude.DefaultMappingString}</FileBasedProgramsItemMapping>
                    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
                    <DisableDefaultItemsInProjectFolder>true</DisableDefaultItemsInProjectFolder>
                """);

            // Only set these to false when using the default SDK with no additional SDKs
            // to avoid including .resx and other files that are typically not expected in simple file-based apps.
            // When other SDKs are used (e.g., Microsoft.NET.Sdk.Web), keep the default behavior.
            bool usingOnlyDefaultSdk = firstSdkName == defaultSdkName && sdkDirectives.Count() <= 1;
            if (usingOnlyDefaultSdk)
            {
                writer.WriteLine("""
                        <EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
                        <EnableDefaultNoneItems>false</EnableDefaultNoneItems>
                    """);
            }

            // Write default properties before importing SDKs so they can be overridden by SDKs
            // (and implicit build files which are imported by the default .NET SDK).
            foreach (var (name, value) in defaultProperties)
            {
                writer.WriteLine($"""
                        <{name}>{EscapeValue(value)}</{name}>
                    """);
            }

            writer.WriteLine($"""
                  </PropertyGroup>

                  <ItemGroup>
                    <Clean Include="{EscapeValue(artifactsPath)}/*" />
                  </ItemGroup>

                """);

            if (firstSdkVersion is null)
            {
                writer.WriteLine($"""
                      <Import Project="Sdk.props" Sdk="{EscapeValue(firstSdkName)}" />
                    """);
            }
            else
            {
                writer.WriteLine($"""
                      <Import Project="Sdk.props" Sdk="{EscapeValue(firstSdkName)}" Version="{EscapeValue(firstSdkVersion)}" />
                    """);
            }
        }
        else
        {
            string slashDelimited = firstSdkVersion is null
                ? firstSdkName
                : $"{firstSdkName}/{firstSdkVersion}";
            writer.WriteLine($"""
                <Project Sdk="{EscapeValue(slashDelimited)}">

                """);
        }

        foreach (var sdk in sdkDirectives.Skip(1))
        {
            if (isVirtualProject)
            {
                WriteImport(writer, "Sdk.props", sdk);
            }
            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 (isVirtualProject || processedDirectives > 1)
        {
            writer.WriteLine();
        }

        // Write default and custom properties.
        {
            writer.WriteLine("""
                  <PropertyGroup>
                """);

            // First write the default properties except those specified by the user.
            if (!isVirtualProject)
            {
                var customPropertyNames = propertyDirectives
                    .Select(static d => d.Name)
                    .ToHashSet(StringComparer.OrdinalIgnoreCase);

                foreach (var (name, value) in defaultProperties)
                {
                    if (!customPropertyNames.Contains(name))
                    {
                        writer.WriteLine($"""
                                <{name}>{EscapeValue(value)}</{name}>
                            """);
                    }
                }

                if (userSecretsId != null && !customPropertyNames.Contains("UserSecretsId"))
                {
                    writer.WriteLine($"""
                            <UserSecretsId>{EscapeValue(userSecretsId)}</UserSecretsId>
                        """);
                }
            }

            // Write custom properties.
            foreach (var property in propertyDirectives)
            {
                writer.WriteLine($"""
                        <{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
                    """);

                processedDirectives++;
            }

            // Write virtual-only properties which cannot be overridden.
            if (isVirtualProject)
            {
                writer.WriteLine("""
                        <RestoreUseStaticGraphEvaluation>false</RestoreUseStaticGraphEvaluation>
                        <Features>$(Features);FileBasedProgram</Features>
                    """);
            }

            writer.WriteLine("""
                  </PropertyGroup>

                """);
        }

        if (!isVirtualProject)
        {
            // In the real project, files are included by the conversion copying them to the output directory,
            // hence we don't need to transfer the #:include/#:exclude directives over.
            processedDirectives += includeOrExcludeDirectives.Count();
        }
        else if (includeOrExcludeDirectives.Any())
        {
            writer.WriteLine("""
                  <ItemGroup>
                """);

            foreach (var includeOrExclude in includeOrExcludeDirectives)
            {
                processedDirectives++;

                var itemType = includeOrExclude.ItemType;

                if (itemType == null)
                {
                    // Before directives are evaluated, the item type is null.
                    // We still need to create the project (so that we can evaluate $() properties),
                    // but we can skip the items.
                    continue;
                }

                writer.WriteLine($"""
                        <{itemType} {includeOrExclude.KindToMSBuildString()}="{EscapeValue(includeOrExclude.Name)}" />
                    """);
            }

            writer.WriteLine("""
                  </ItemGroup>

                """);
        }

        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>

                """);
        }

        if (projectDirectives.Any() || refDirectives.Any())
        {
            writer.WriteLine("""
                  <ItemGroup>
                """);

            foreach (var projectReference in projectDirectives)
            {
                writer.WriteLine($"""
                        <ProjectReference Include="{EscapeValue(projectReference.Name)}" />
                    """);

                processedDirectives++;
            }

            foreach (var refDirective in refDirectives)
            {
                if (refDirective.ResolvedPath is not null)
                {
                    var virtualProjectPath = GetVirtualProjectPath(refDirective.ResolvedPath);
                    writer.WriteLine($"""
                            <ProjectReference Include="{EscapeValue(virtualProjectPath)}" />
                        """);
                }

                processedDirectives++;
            }

            writer.WriteLine("""
                  </ItemGroup>

                """);
        }

        Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);

        if (isVirtualProject)
        {
            Debug.Assert(entryPointFilePath is not null);

            // We Exclude existing Compile items (which could be added e.g.
            // in Microsoft.NET.Sdk.DefaultItems.props when user sets EnableDefaultCompileItems=true,
            // or above via #:include/#:exclude directives).
            writer.WriteLine($"""
                  <ItemGroup>
                    <Compile Include="{EscapeValue(entryPointFilePath)}" Exclude="@(Compile)" />
                  </ItemGroup>

                """);

            if (includeRuntimeConfigInformation)
            {
                var entryPointDirectory = Path.GetDirectoryName(entryPointFilePath) ?? "";
                writer.WriteLine($"""
                      <ItemGroup>
                        <RuntimeHostConfigurationOption Include="EntryPointFilePath" Value="{EscapeValue(entryPointFilePath)}" />
                        <RuntimeHostConfigurationOption Include="EntryPointFileDirectoryPath" Value="{EscapeValue(entryPointDirectory)}" />
                      </ItemGroup>

                    """);
            }

            foreach (var sdk in sdkDirectives)
            {
                WriteImport(writer, "Sdk.targets", sdk);
            }

            if (!sdkDirectives.Any())
            {
                Debug.Assert(firstSdkName == defaultSdkName && firstSdkVersion == null);
                writer.WriteLine($"""
                      <Import Project="Sdk.targets" Sdk="{defaultSdkName}" />
                    """);
            }

            writer.WriteLine();
        }

        writer.WriteLine("""
            </Project>
            """);

        static string EscapeValue(string value) => SecurityElement.Escape(value);

        static void WriteImport(TextWriter writer, string project, CSharpDirective.Sdk sdk)
        {
            if (sdk.Version is null)
            {
                writer.WriteLine($"""
                      <Import Project="{EscapeValue(project)}" Sdk="{EscapeValue(sdk.Name)}" />
                    """);
            }
            else
            {
                writer.WriteLine($"""
                      <Import Project="{EscapeValue(project)}" Sdk="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
                    """);
            }
        }
    }
}