File: DiscoverCommand.cs
Web Access
Project: ..\..\..\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.Security.Cryptography;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.NET.Sdk.Razor.Tool.CommandLineUtils;
using Microsoft.NET.Sdk.Razor.Tool.Json;
using Newtonsoft.Json;
 
namespace Microsoft.NET.Sdk.Razor.Tool
{
    internal class DiscoverCommand : CommandBase
    {
        public DiscoverCommand(Application parent)
            : base(parent, "discover")
        {
            Assemblies = Argument("assemblies", "assemblies to search for tag helpers", multipleValues: true);
            TagHelperManifest = Option("-o", "output file", CommandOptionType.SingleValue);
            ProjectDirectory = Option("-p", "project root directory", CommandOptionType.SingleValue);
            Version = Option("-v|--version", "Razor language version", CommandOptionType.SingleValue);
            Configuration = Option("-c", "Razor configuration name", CommandOptionType.SingleValue);
            ExtensionNames = Option("-n", "extension name", CommandOptionType.MultipleValue);
            ExtensionFilePaths = Option("-e", "extension file path", CommandOptionType.MultipleValue);
        }
 
        public CommandArgument Assemblies { get; }
 
        public CommandOption TagHelperManifest { get; }
 
        public CommandOption ProjectDirectory { get; }
 
        public CommandOption Version { get; }
 
        public CommandOption Configuration { get; }
 
        public CommandOption ExtensionNames { get; }
 
        public CommandOption ExtensionFilePaths { get; }
 
        protected override bool ValidateArguments()
        {
            if (string.IsNullOrEmpty(TagHelperManifest.Value()))
            {
                Error.WriteLine($"{TagHelperManifest.Description} must be specified.");
                return false;
            }
 
            if (Assemblies.Values.Count == 0)
            {
                Error.WriteLine($"{Assemblies.Name} must have at least one value.");
                return false;
            }
 
            if (string.IsNullOrEmpty(ProjectDirectory.Value()))
            {
                ProjectDirectory.Values.Add(Environment.CurrentDirectory);
            }
 
            if (string.IsNullOrEmpty(Version.Value()))
            {
                Error.WriteLine($"{Version.Description} must be specified.");
                return false;
            }
            else if (!RazorLanguageVersion.TryParse(Version.Value(), out _))
            {
                Error.WriteLine($"Invalid option {Version.Value()} for Razor language version --version; must be Latest or a valid version in range {RazorLanguageVersion.Version_1_0} to {RazorLanguageVersion.Latest}.");
                return false;
            }
 
            if (string.IsNullOrEmpty(Configuration.Value()))
            {
                Error.WriteLine($"{Configuration.Description} must be specified.");
                return false;
            }
 
            if (ExtensionNames.Values.Count != ExtensionFilePaths.Values.Count)
            {
                Error.WriteLine($"{ExtensionNames.Description} and {ExtensionFilePaths.Description} should have the same number of values.");
            }
 
            foreach (var filePath in ExtensionFilePaths.Values)
            {
                if (!Path.IsPathRooted(filePath))
                {
                    Error.WriteLine($"Extension file paths must be fully-qualified, absolute paths.");
                    return false;
                }
            }
 
            PatchExtensions(ExtensionNames, ExtensionFilePaths, Error);
 
            return true;
        }
 
        private const string RazorCompilerFileName = "Microsoft.CodeAnalysis.Razor.Compiler.dll";
 
        /// <summary>
        /// Replaces the assembly for MVC extension with the one shipped alongside SDK (as opposed to the one from NuGet).
        /// </summary>
        /// <remarks>
        /// Needed so the Razor compiler can change its APIs without breaking legacy MVC scenarios.
        /// </remarks>
        internal static void PatchExtensions(CommandOption extensionNames, CommandOption extensionFilePaths, TextWriter error)
        {
            string currentDirectory = null;
 
            for (int i = 0; i < extensionNames.Values.Count; i++)
            {
                var extensionName = extensionNames.Values[i];
 
                string expectedOriginalPath = extensionName switch
                {
                    "MVC-1.0" or "MVC-1.1" or "MVC-2.0" or "MVC-2.1" => "Microsoft.AspNetCore.Mvc.Razor.Extensions",
                    "MVC-3.0" => "Microsoft.CodeAnalysis.Razor.Compiler",
                    _ => null,
                };
 
                if (expectedOriginalPath is not null)
                {
                    var extensionFilePath = extensionFilePaths.Values[i];
                    if (!string.Equals(expectedOriginalPath, Path.GetFileNameWithoutExtension(extensionFilePath), StringComparison.OrdinalIgnoreCase))
                    {
                        error.WriteLine($"Extension '{extensionName}' has unexpected path '{extensionFilePath}'.");
                    }
                    else
                    {
                        currentDirectory ??= Path.GetDirectoryName(typeof(Application).Assembly.Location);
                        extensionFilePaths.Values[i] = Path.Combine(currentDirectory, RazorCompilerFileName);
                    }
                }
            }
        }
 
        protected override Task<int> ExecuteCoreAsync()
        {
            if (!Parent.Checker.Check(ExtensionFilePaths.Values))
            {
                Error.WriteLine($"Extensions could not be loaded. See output for details.");
                return Task.FromResult(ExitCodeFailure);
            }
 
            var version = RazorLanguageVersion.Parse(Version.Value());
            var configuration = new RazorConfiguration(version, Configuration.Value(), Extensions: [], UseConsolidatedMvcViews: false);
 
            var result = ExecuteCore(
                configuration: configuration,
                projectDirectory: ProjectDirectory.Value(),
                outputFilePath: TagHelperManifest.Value(),
                assemblies: Assemblies.Values.ToArray());
 
            return Task.FromResult(result);
        }
 
        private int ExecuteCore(RazorConfiguration configuration, string projectDirectory, string outputFilePath, string[] assemblies)
        {
            outputFilePath = Path.Combine(projectDirectory, outputFilePath);
 
            var metadataReferences = new MetadataReference[assemblies.Length];
            for (var i = 0; i < assemblies.Length; i++)
            {
                metadataReferences[i] = Parent.AssemblyReferenceProvider(assemblies[i], default(MetadataReferenceProperties));
            }
 
            var engine = RazorProjectEngine.Create(configuration, RazorProjectFileSystem.Empty, b =>
            {
                b.RegisterExtensions();
 
                b.Features.Add(new DefaultMetadataReferenceFeature() { References = metadataReferences });
                b.Features.Add(new CompilationTagHelperFeature());
                b.Features.Add(new DefaultTagHelperDescriptorProvider());
 
                CompilerFeatures.Register(b);
            });
 
            var feature = engine.Engine.Features.OfType<ITagHelperFeature>().Single();
            var tagHelpers = feature.GetDescriptors();
 
            using (var stream = new MemoryStream())
            {
                Serialize(stream, tagHelpers);
 
                stream.Position = 0;
 
                var newHash = Hash(stream);
                var existingHash = Hash(outputFilePath);
 
                if (!HashesEqual(newHash, existingHash))
                {
                    stream.Position = 0;
                    using (var output = File.Open(outputFilePath, FileMode.Create))
                    {
                        stream.CopyTo(output);
                    }
                }
            }
 
            return ExitCodeSuccess;
        }
 
        private static byte[] Hash(string path)
        {
            if (!File.Exists(path))
            {
                return Array.Empty<byte>();
            }
 
            using (var stream = File.OpenRead(path))
            {
                return Hash(stream);
            }
        }
 
        private static byte[] Hash(Stream stream)
        {
            using (var sha = SHA256.Create())
            {
                sha.ComputeHash(stream);
                return sha.Hash;
            }
        }
 
        private bool HashesEqual(byte[] x, byte[] y)
        {
            if (x.Length != y.Length)
            {
                return false;
            }
 
            for (var i = 0; i < x.Length; i++)
            {
                if (x[i] != y[i])
                {
                    return false;
                }
            }
 
            return true;
        }
 
        private static void Serialize(Stream stream, IReadOnlyList<TagHelperDescriptor> tagHelpers)
        {
            using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 4096, leaveOpen: true))
            {
                var serializer = new JsonSerializer();
                serializer.Converters.Add(TagHelperDescriptorJsonConverter.Instance);
 
                serializer.Serialize(writer, tagHelpers);
            }
        }
    }
}