File: SyncedSource\FileBasedPrograms\VirtualProjectBuilder.cs
Web Access
Project: src\roslyn\src\Workspaces\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Workspaces.csproj (Microsoft.CodeAnalysis.CSharp.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.CodeAnalysis.Text;
using Microsoft.DotNet.Utilities;

namespace Microsoft.DotNet.FileBasedPrograms;

#if FILE_BASED_PROGRAMS_PUBLIC
public
#else
internal
#endif
sealed class VirtualProjectBuilder
{
    internal readonly record struct ExplicitProjectItem(string ItemType, string Include);

    internal const string FromIncludeDirectiveMetadataName = "FileBasedProgramsFromIncludeDirective";

    internal const string FromRefDirectiveMetadataName = "FileBasedProgramsFromRefDirective";

    private readonly IBuildService _buildService;

    private readonly string? _targetFramework;

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

    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(
        IBuildService buildService,
        string entryPointFileFullPath,
        string? targetFramework,
        string[]? requestedTargets = null,
        string? artifactsPath = null,
        SourceText? sourceText = null)
    {
        Debug.Assert(ExternalHelpers.IsPathFullyQualified(entryPointFileFullPath));

        _buildService = buildService;
        EntryPointFileFullPath = entryPointFileFullPath;
        RequestedTargets = requestedTargets;
        ArtifactsPath = artifactsPath;
        _targetFramework = 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)
    {
        yield return ("OutputType", "Exe");
        if (targetFramework != null) yield return ("TargetFramework", targetFramework);
        yield return ("ImplicitUsings", "enable");
        yield return ("Nullable", "enable");
        yield return ("PublishAot", "true");
        yield return ("PackAsTool", "true");
    }

    internal static IEnumerable<KeyValuePair<string, string>> GetGlobalBuildProperties() =>
    [
        // See https://github.com/dotnet/msbuild/blob/main/documentation/specs/build-nonexistent-projects-by-default.md.
        new KeyValuePair<string, string>("_BuildNonexistentProjectsByDefault", bool.TrueString),
        new KeyValuePair<string, string>("RestoreUseSkipNonexistentTargets", bool.FalseString),
    ];

    internal static string GetArtifactsPath(string entryPointFileFullPath, string? dotNetSubdirectory = null)
    {
        // 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(name: directoryName, dotNetSubdirectory: dotNetSubdirectory);
    }

    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. Conflicting 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(string? dotNetSubdirectory = null)
    {
        // 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(FileBasedProgramsResources.EmptyTempPath);
        }

        return Path.Combine(directory, "dotnet", dotNetSubdirectory ?? "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, string? dotNetSubdirectory = null)
    {
        return Path.Combine(GetTempSubdirectory(dotNetSubdirectory), 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 async ValueTask<ImmutableArray<CSharpDirective>> EvaluateDirectivesAsync(
        IProjectInstance 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(await project.ExpandStringAsync(projectDirective.Name).ConfigureAwait(false), CSharpDirective.Project.NameKind.Expanded);
                    projectDirective = projectDirective.EnsureProjectFilePath(reportError);

                    builder.Add(projectDirective);
                    break;

                case CSharpDirective.Ref refDirective:
                    refDirective = refDirective.WithName(await project.ExpandStringAsync(refDirective.Name).ConfigureAwait(false), CSharpDirective.Ref.NameKind.Expanded);
                    refDirective = refDirective.EnsureResolvedPath(reportError);

                    builder.Add(refDirective);
                    break;

                case CSharpDirective.IncludeOrExclude includeOrExcludeDirective:
                    var expandedPath = await project.ExpandStringAsync(includeOrExcludeDirective.Name).ConfigureAwait(false);
                    var fullPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(includeOrExcludeDirective.Info.SourceFile.Path)!, expandedPath));
                    includeOrExcludeDirective = includeOrExcludeDirective.WithName(fullPath);

                    if (mapping.IsDefault)
                    {
                        mapping = await GetItemMappingAsync(project, reportError).ConfigureAwait(false);
                    }

                    includeOrExcludeDirective = includeOrExcludeDirective.WithDeterminedItemType(reportError, mapping);

                    builder.Add(includeOrExcludeDirective);
                    break;

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

        return builder.DrainToImmutable();
    }

    internal async ValueTask<ImmutableArray<(string Extension, string ItemType)>> GetItemMappingAsync(IProjectInstance project, ErrorReporter reportError)
    {
        return CSharpDirective.IncludeOrExclude.ParseMapping(
            await project.GetPropertyValueAsync(CSharpDirective.IncludeOrExclude.MappingPropertyName).ConfigureAwait(false),
            EntryPointSourceFile,
            reportError);
    }

    public static async ValueTask<IProjectInstance> CreateProjectInstanceAsync(
        IBuildService buildService,
        string entryPointFilePath,
        string targetFramework,
        IProjectCollection projectCollection,
        Action<string, int, string> errorReporter)
    {
        var builder = new VirtualProjectBuilder(buildService, entryPointFilePath, targetFramework);

        var result = await builder.CreateProjectInstanceAsync(
            projectCollection,
            (text, path, textSpan, message, _) => errorReporter(path, text.Lines.GetLinePositionSpan(textSpan).Start.Line + 1, message)).ConfigureAwait(false);

        return result.Project;
    }

    internal readonly struct Result(
        IProjectInstance project,
        IProjectRootElement projectRootElement,
        ImmutableArray<CSharpDirective> evaluatedDirectives)
    {
        public IProjectInstance Project { get; } = project;
        public IProjectRootElement ProjectRootElement { get; } = projectRootElement;
        public ImmutableArray<CSharpDirective> EvaluatedDirectives { get; } = evaluatedDirectives;
    }

    internal async ValueTask<Result> CreateProjectInstanceAsync(
        IProjectCollection projectCollection,
        ErrorReporter reportError,
        ImmutableArray<CSharpDirective> directives = default,
        IDictionary<string, string>? additionalGlobalProperties = null,
        bool validateAllDirectives = false,
        HashSet<string>? processedRefFiles = null)
    {
        IProjectInstance project;
        IProjectRootElement projectRootElement;
        ImmutableArray<CSharpDirective> evaluatedDirectives;

        var directivesOriginal = directives;

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

        (string ProjectFileText, IProjectInstance ProjectInstance, IProjectRootElement 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) = await CreateProjectInstanceNoEvaluation(
                projectCollection,
                evaluatedDirectives,
                additionalGlobalProperties).ConfigureAwait(false);
        }
        else
        {
            var entryPointDirectory = Path.GetDirectoryName(EntryPointFileFullPath)!;
            var seenFiles = new HashSet<string>(StringComparer.Ordinal) { EntryPointFileFullPath };
            var filesToProcess = new Queue<string>();
            var evaluatedDirectiveBuilder = ImmutableArray.CreateBuilder<CSharpDirective>();
            var deduplicator = new DirectiveDeduplicator();

            do
            {
                var directivesForEvaluation = DeduplicateSdkDirectives(directives);

                // Create a project with properties from #:property directives so they can be expanded inside EvaluateDirectives.
                (project, projectRootElement) = await CreateProjectInstanceNoEvaluation(
                    projectCollection,
                    [.. evaluatedDirectiveBuilder, .. directivesForEvaluation],
                    additionalGlobalProperties).ConfigureAwait(false);

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

                // Detect duplicate directives across all files on evaluated directives. EvaluateDirectives only expands
                // #:project, #:ref, #:include, and #:exclude; #:property and #:package values are still unevaluated here.
                var deduplicatedFileEvaluatedDirectiveBuilder = ImmutableArray.CreateBuilder<CSharpDirective>(fileEvaluatedDirectives.Length);
                foreach (var directive in fileEvaluatedDirectives)
                {
                    if (directive is CSharpDirective.Sdk)
                    {
                        deduplicatedFileEvaluatedDirectiveBuilder.Add(directive);
                        continue;
                    }

                    if (directive is CSharpDirective.Named named)
                    {
                        deduplicator.CheckDirective(named, reportError, out bool shouldKeep);
                        if (!shouldKeep)
                        {
                            continue;
                        }
                    }

                    deduplicatedFileEvaluatedDirectiveBuilder.Add(directive);
                }

                fileEvaluatedDirectives = deduplicatedFileEvaluatedDirectiveBuilder.DrainToImmutable();

                evaluatedDirectiveBuilder.AddRange(fileEvaluatedDirectives);

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

                var compileItems = await project.GetItemMetadataValuesAsync("Compile", ["FullPath"]).ConfigureAwait(false);
                foreach (var compileItem in compileItems)
                {
                    Debug.Assert(compileItem.Length == 1);
                    var fullPath = compileItem[0];
                    var compilePath = Path.GetFullPath(Path.Combine(
                        entryPointDirectory,
                        fullPath));
                    if (seenFiles.Add(compilePath))
                    {
                        filesToProcess.Enqueue(compilePath);
                    }
                }
            }
            while (TryGetNextFileToProcess());

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

            bool TryGetNextFileToProcess()
            {
                while (filesToProcess.Count != 0)
                {
                    var filePath = filesToProcess.Dequeue();
                    if (!File.Exists(filePath))
                    {
                        reportError(EntryPointSourceFile.Text, EntryPointSourceFile.Path, default, string.Format(FileBasedProgramsResources.IncludedFileNotFound, filePath));
                        continue;
                    }

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

                return false;
            }

            // #:sdk directives become Sdk.props/Sdk.targets imports when creating the temporary project used for
            // directive evaluation, so identical duplicates must be removed before that project is created.
            ImmutableArray<CSharpDirective> DeduplicateSdkDirectives(ImmutableArray<CSharpDirective> directives)
            {
                if (!directives.Any(static directive => directive is CSharpDirective.Sdk))
                {
                    return directives;
                }

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

                foreach (var directive in directives)
                {
                    if (directive is CSharpDirective.Sdk sdk)
                    {
                        deduplicator.CheckDirective(sdk, reportError, out bool shouldKeep);
                        if (!shouldKeep)
                        {
                            changed = true;
                            continue;
                        }
                    }

                    builder.Add(directive);
                }

                return changed ? builder.DrainToImmutable() : directives;
            }
        }

        await CheckDirectivesAsync(project, evaluatedDirectives, reportError).ConfigureAwait(false);
        await CreateReferencedVirtualProjectsAsync(projectCollection, evaluatedDirectives, reportError, validateAllDirectives, processedRefFiles).ConfigureAwait(false);

        return new Result(project, projectRootElement, evaluatedDirectives);

        async ValueTask<(IProjectInstance, IProjectRootElement)> CreateProjectInstanceNoEvaluation(
            IProjectCollection projectCollection,
            ImmutableArray<CSharpDirective> directives,
            IDictionary<string, string>? additionalGlobalProperties = null)
        {
            var projectFileWriter = new StringWriter();

            WriteProjectFile(
                projectFileWriter,
                directives,
                GetDefaultProperties(_targetFramework),
                isVirtualProject: true,
                entryPointFilePath: EntryPointFileFullPath,
                artifactsPath: ArtifactsPath,
                includeRuntimeConfigInformation: RequestedTargets?.Any(static t => t is "Publish" or "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 project = await _buildService.CreateProjectInstanceFromProjectRootElementAsync(projectRoot, projectCollection, additionalGlobalProperties).ConfigureAwait(false);

            lastProject = (projectFileText, project, projectRoot);

            return (project, projectRoot);

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

    /// <summary>
    /// Recursively creates virtual <see cref="IProjectRootElement"/>s for all <c>#:ref</c> directives
    /// so MSBuild can resolve <c>&lt;ProjectReference&gt;</c> items to them.
    /// </summary>
    private async ValueTask CreateReferencedVirtualProjectsAsync(
        IProjectCollection projectCollection,
        ImmutableArray<CSharpDirective> directives,
        ErrorReporter reportError,
        bool validateAllDirectives,
        HashSet<string>? processedFiles)
    {
        if (!directives.Any(static d => d is CSharpDirective.Ref))
        {
            return;
        }

        processedFiles ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        processedFiles.Add(EntryPointFileFullPath);

        foreach (var refDirective in directives.OfType<CSharpDirective.Ref>())
        {
            Debug.Assert(refDirective.ResolvedPath is not null);

            if (refDirective.ResolvedPath is not { } resolvedPath)
            {
                continue;
            }

            if (!processedFiles.Add(resolvedPath))
            {
                continue;
            }

            var refBuilder = new VirtualProjectBuilder(_buildService, resolvedPath, _targetFramework);
            await refBuilder.CreateProjectInstanceAsync(
                projectCollection,
                reportError,
                validateAllDirectives: validateAllDirectives,
                processedRefFiles: processedFiles).ConfigureAwait(false);
        }
    }

    private async ValueTask CheckDirectivesAsync(
        IProjectInstance project,
        ImmutableArray<CSharpDirective> directives,
        ErrorReporter reportError)
    {
        var refEnabled = new StrongBox<bool?>();

        foreach (var directive in directives)
        {
            if (directive is CSharpDirective.Ref)
            {
                await CheckFlagEnabledAsync(refEnabled, CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, directive).ConfigureAwait(false);
            }
        }

        async ValueTask CheckFlagEnabledAsync(StrongBox<bool?> flag, string flagName, CSharpDirective directive)
        {
            bool value = flag.Value ??= MSBuildUtilities.ConvertStringToBool(await project.GetPropertyValueAsync(flagName).ConfigureAwait(false));

            if (!value)
            {
                reportError(
                    directive.Info.SourceFile.Text,
                    directive.Info.SourceFile.Path,
                    directive.Info.Span,
                    string.Format(FileBasedProgramsResources.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,
        ImmutableArray<ExplicitProjectItem> explicitProjectItems = default)
    {
        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>().ToArray();

        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>
                        """);
                }
            }
            // Some hosts (like MSBuildWorkspace) don't provide a default TargetFramework
            // and instead want to use the TargetFramework that's default corresponding to the imported SDK props.
            else if (!defaultProperties.Any(p => p.name == "TargetFramework"))
            {
                writer.WriteLine("""
                        <TargetFramework>net$(BundledNETCoreAppTargetFrameworkVersion)</TargetFramework>
                    """);
            }

            // 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 by default.
            processedDirectives += includeOrExcludeDirectives.Length;
        }
        else if (includeOrExcludeDirectives.Length > 0)
        {
            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;
                }

                if (includeOrExclude.Kind == CSharpDirective.IncludeOrExcludeKind.Include)
                {
                    writer.WriteLine($"""
                        <{itemType} Include="{EscapeValue(includeOrExclude.Name)}" {FromIncludeDirectiveMetadataName}="true" />
                    """);
                }
                else
                {
                    writer.WriteLine($"""
                        <{itemType} Remove="{EscapeValue(includeOrExclude.Name)}" />
                    """);
                }
            }

            writer.WriteLine("""
                  </ItemGroup>

                """);
        }

        if (!explicitProjectItems.IsDefaultOrEmpty)
        {
            writer.WriteLine("""
                  <ItemGroup>
                """);

            foreach (var (itemType, include) in explicitProjectItems)
            {
                writer.WriteLine($"""
                        <{itemType} Include="{EscapeValue(include)}" />
                    """);
            }

            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)}" {FromRefDirectiveMetadataName}="{EscapeValue(refDirective.ResolvedPath)}" />
                        """);
                }

                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)}" />
                    """);
            }
        }
    }
}