File: RazorSourceGeneratorHost.cs
Web Access
Project: src\sdk\src\RazorSdk\Tool\Microsoft.NET.Sdk.Razor.Tool.csproj (rzc)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using Microsoft.NET.Sdk.Razor.SourceGenerators;

namespace Microsoft.NET.Sdk.Razor.Tool;

/// <summary>
/// Shared infrastructure for driving the Razor source generator in-process, so the
/// <c>generate</c> and <c>discover</c> commands can produce the same output as an ordinary
/// .NET 6+ build instead of calling the Razor engine directly.
/// </summary>
internal static class RazorSourceGeneratorHost
{
    /// <summary>
    /// Builds an <see cref="AnalyzerConfigOptionsProvider"/> that surfaces the MSBuild-derived
    /// build properties and per-file metadata the source generator reads. The keys mirror what the
    /// Razor SDK targets pass via <c>CompilerVisibleProperty</c>/<c>CompilerVisibleItemMetadata</c>.
    /// </summary>
    public static RazorAnalyzerConfigOptionsProvider CreateOptionsProvider(
        string razorConfiguration,
        string razorLanguageVersion,
        string rootNamespace,
        bool supportLocalizedComponentNames,
        bool generateMetadataSourceChecksumAttributes,
        string projectDirectory,
        IReadOnlyList<RazorInputFile> files)
    {
        var globalOptions = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["build_property.SupportLocalizedComponentNames"] = Bool(supportLocalizedComponentNames),
            ["build_property.GenerateRazorMetadataSourceChecksumAttributes"] = Bool(generateMetadataSourceChecksumAttributes),
        };

        // Only surface values that were actually provided. An absent option must be absent from the
        // analyzer config (so the generator applies its own default), not present with a null value.
        AddIfPresent(globalOptions, "build_property.RazorConfiguration", razorConfiguration);
        AddIfPresent(globalOptions, "build_property.RazorLangVersion", razorLanguageVersion);
        AddIfPresent(globalOptions, "build_property.RootNamespace", rootNamespace);
        AddIfPresent(globalOptions, "build_property.MSBuildProjectDirectory", projectDirectory);

        var fileOptions = new Dictionary<string, AnalyzerConfigOptions>(StringComparer.Ordinal);
        foreach (var file in files)
        {
            var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
            {
                // The generator base64-decodes TargetPath, matching the EncodeRazorInputItem task.
                ["build_metadata.AdditionalFiles.TargetPath"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(file.TargetPath)),
            };

            if (!string.IsNullOrEmpty(file.CssScope))
            {
                metadata["build_metadata.AdditionalFiles.CssScope"] = file.CssScope;
            }

            fileOptions[file.SourcePath] = new RazorAnalyzerConfigOptions(metadata);
        }

        return new RazorAnalyzerConfigOptionsProvider(new RazorAnalyzerConfigOptions(globalOptions), fileOptions);
    }

    /// <summary>
    /// Turns the input files into <see cref="AdditionalText"/> entries the generator treats as Razor sources.
    /// </summary>
    public static ImmutableArray<AdditionalText> CreateAdditionalTexts(IReadOnlyList<RazorInputFile> files)
    {
        var builder = ImmutableArray.CreateBuilder<AdditionalText>(files.Count);
        foreach (var file in files)
        {
            builder.Add(new RazorSourceGeneratorAdditionalText(file.SourcePath, file.Text));
        }

        return builder.MoveToImmutable();
    }

    /// <summary>
    /// Creates the compilation whose references the generator scans for tag helpers. It has no syntax
    /// trees of its own; the generator adds the generated component declarations internally.
    /// </summary>
    public static CSharpCompilation CreateCompilation(
        IEnumerable<string> referencePaths,
        Func<string, MetadataReferenceProperties, PortableExecutableReference> referenceProvider,
        string assemblyName = "RazorGeneration")
    {
        var references = referencePaths.Select(path => (MetadataReference)referenceProvider(path, default));
        return CSharpCompilation.Create(
            assemblyName,
            syntaxTrees: null,
            references,
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
    }

    /// <summary>
    /// Creates a generator driver hosting the Razor source generator. Host outputs are left enabled
    /// (nothing disabled) so <c>discover</c> can read the <c>RazorGeneratorResult</c> host output.
    /// </summary>
    public static GeneratorDriver CreateDriver(CSharpParseOptions parseOptions)
    {
        var generator = new RazorSourceGenerator().AsSourceGenerator();
        var driverOptions = new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: false);
        return CSharpGeneratorDriver.Create([generator], parseOptions: parseOptions, driverOptions: driverOptions);
    }

    public static CSharpParseOptions CreateParseOptions(LanguageVersion languageVersion)
        => new(languageVersion);

    private static string Bool(bool value) => value ? "true" : "false";

    private static void AddIfPresent(Dictionary<string, string> options, string key, string value)
    {
        if (!string.IsNullOrEmpty(value))
        {
            options[key] = value;
        }
    }
}

/// <summary>
/// A Razor source file handed to the generator: the physical path on disk, the project-relative
/// target path (which drives the generated hint name and namespace), and an optional CSS scope.
/// </summary>
internal readonly struct RazorInputFile
{
    public RazorInputFile(string sourcePath, string targetPath, string cssScope = null, SourceText text = null)
    {
        SourcePath = sourcePath;
        TargetPath = targetPath;
        CssScope = cssScope;
        Text = text;
    }

    public string SourcePath { get; }

    public string TargetPath { get; }

    public string CssScope { get; }

    /// <summary>In-memory content, used for synthetic inputs; when null the file is read from <see cref="SourcePath"/>.</summary>
    public SourceText Text { get; }
}

internal sealed class RazorSourceGeneratorAdditionalText : AdditionalText
{
    private readonly SourceText _text;

    public RazorSourceGeneratorAdditionalText(string path, SourceText text = null)
    {
        Path = path;
        _text = text;
    }

    public override string Path { get; }

    public override SourceText GetText(CancellationToken cancellationToken = default)
    {
        if (_text is not null)
        {
            return _text;
        }

        using var stream = File.OpenRead(Path);
        return SourceText.From(stream, Encoding.UTF8);
    }
}

internal sealed class RazorAnalyzerConfigOptions : AnalyzerConfigOptions
{
    private readonly Dictionary<string, string> _options;

    public RazorAnalyzerConfigOptions(Dictionary<string, string> options)
    {
        _options = options;
    }

    public override bool TryGetValue(string key, out string value) => _options.TryGetValue(key, out value);

    public override IEnumerable<string> Keys => _options.Keys;
}

internal sealed class RazorAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
{
    private static readonly AnalyzerConfigOptions s_empty = new RazorAnalyzerConfigOptions(new Dictionary<string, string>(StringComparer.Ordinal));

    private readonly Dictionary<string, AnalyzerConfigOptions> _fileOptions;

    public RazorAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions, Dictionary<string, AnalyzerConfigOptions> fileOptions)
    {
        GlobalOptions = globalOptions;
        _fileOptions = fileOptions;
    }

    public override AnalyzerConfigOptions GlobalOptions { get; }

    public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => s_empty;

    public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
        => _fileOptions.TryGetValue(textFile.Path, out var options) ? options : s_empty;
}