File: GenerateCommand.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.Diagnostics;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis.CSharp;
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 GenerateCommand : CommandBase
    {
        public GenerateCommand(Application parent)
            : base(parent, "generate")
        {
            Sources = Option("-s", ".cshtml files to compile", CommandOptionType.MultipleValue);
            Outputs = Option("-o", "Generated output file path", CommandOptionType.MultipleValue);
            RelativePaths = Option("-r", "Relative path", CommandOptionType.MultipleValue);
            FileKinds = Option("-k", "File kind", CommandOptionType.MultipleValue);
            CssScopeSources = Option("-cssscopedinput", ".razor file with scoped CSS", CommandOptionType.MultipleValue);
            CssScopeValues = Option("-cssscopevalue", "CSS scope value for .razor file with scoped CSS", CommandOptionType.MultipleValue);
            ProjectDirectory = Option("-p", "project root directory", CommandOptionType.SingleValue);
            TagHelperManifest = Option("-t", "tag helper manifest file", 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);
            RootNamespace = Option("--root-namespace", "root namespace for generated code", CommandOptionType.SingleValue);
            CSharpLanguageVersion = Option("--csharp-language-version", "csharp language version generated code", CommandOptionType.SingleValue);
            GenerateDeclaration = Option("--generate-declaration", "Generate declaration", CommandOptionType.NoValue);
            SupportLocalizedComponentNames = Option("--support-localized-component-names", "support localized component names", CommandOptionType.NoValue);
        }
 
        public CommandOption Sources { get; }
 
        public CommandOption Outputs { get; }
 
        public CommandOption RelativePaths { get; }
 
        public CommandOption FileKinds { get; }
 
        public CommandOption CssScopeSources { get; }
 
        public CommandOption CssScopeValues { get; }
 
        public CommandOption ProjectDirectory { get; }
 
        public CommandOption TagHelperManifest { get; }
 
        public CommandOption Version { get; }
 
        public CommandOption Configuration { get; }
 
        public CommandOption ExtensionNames { get; }
 
        public CommandOption ExtensionFilePaths { get; }
 
        public CommandOption RootNamespace { get; }
 
        public CommandOption CSharpLanguageVersion { get; }
 
        public CommandOption GenerateDeclaration { get; }
 
        public CommandOption SupportLocalizedComponentNames { get; }
 
        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 sourceItems = GetSourceItems(
                Sources.Values, Outputs.Values, RelativePaths.Values,
                FileKinds.Values, CssScopeSources.Values, CssScopeValues.Values);
 
            var result = ExecuteCore(
                configuration: configuration,
                projectDirectory: ProjectDirectory.Value(),
                tagHelperManifest: TagHelperManifest.Value(),
                sourceItems: sourceItems);
 
            return Task.FromResult(result);
        }
 
        protected override bool ValidateArguments()
        {
            if (Sources.Values.Count == 0)
            {
                Error.WriteLine($"{Sources.Description} should have at least one value.");
                return false;
            }
 
            if (Outputs.Values.Count != Sources.Values.Count)
            {
                Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {Outputs.Description} has {Outputs.Values.Count} values.");
                return false;
            }
 
            if (RelativePaths.Values.Count != Sources.Values.Count)
            {
                Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {RelativePaths.Description} has {RelativePaths.Values.Count} values.");
                return false;
            }
 
            if (FileKinds.Values.Count != 0 && FileKinds.Values.Count != Sources.Values.Count)
            {
                // 2.x tasks do not specify FileKinds - in which case, no values will be present. If a kind for one file is specified, we expect as many kind entries
                // as sources.
                Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {FileKinds.Description} has {FileKinds.Values.Count} values.");
                return false;
            }
 
            if (CssScopeSources.Values.Count != CssScopeValues.Values.Count)
            {
                // CssScopeSources and CssScopeValues arguments must appear as matched pairs
                Error.WriteLine($"{CssScopeSources.Description} has {CssScopeSources.Values.Count}, but {CssScopeValues.Description} has {CssScopeValues.Values.Count} values.");
                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;
                }
            }
 
            DiscoverCommand.PatchExtensions(ExtensionNames, ExtensionFilePaths, Error);
 
            return true;
        }
 
        private int ExecuteCore(
            RazorConfiguration configuration,
            string projectDirectory,
            string tagHelperManifest,
            SourceItem[] sourceItems)
        {
            tagHelperManifest = Path.Combine(projectDirectory, tagHelperManifest);
 
            var tagHelpers = GetTagHelpers(tagHelperManifest);
 
            var compositeFileSystem = new CompositeRazorProjectFileSystem(new[]
            {
                GetVirtualRazorProjectSystem(sourceItems),
                RazorProjectFileSystem.Create(projectDirectory),
            });
 
            var success = true;
 
            var engine = RazorProjectEngine.Create(configuration, compositeFileSystem, b =>
            {
                b.RegisterExtensions();
 
                b.Features.Add(new StaticTagHelperFeature() { TagHelpers = tagHelpers, });
 
                b.ConfigureCodeGenerationOptions(b =>
                {
                    if (GenerateDeclaration.HasValue())
                    {
                        b.SuppressPrimaryMethodBody = true;
                        b.SuppressChecksum = true;
                    }
 
                    if (SupportLocalizedComponentNames.HasValue())
                    {
                        b.SupportLocalizedComponentNames = true;
                    }
 
                    if (RootNamespace.HasValue())
                    {
                        b.RootNamespace = RootNamespace.Value();
                    }
                });
 
                if (CSharpLanguageVersion.HasValue())
                {
                    // Only set the C# language version if one was specified, otherwise it defaults to whatever
                    // value was set in the corresponding RazorConfiguration's extensions.
 
                    var rawLanguageVersion = CSharpLanguageVersion.Value();
                    if (LanguageVersionFacts.TryParse(rawLanguageVersion, out var csharpLanguageVersion))
                    {
                        b.SetCSharpLanguageVersion(csharpLanguageVersion);
                    }
                    else
                    {
                        success = false;
                        Error.WriteLine($"Unknown C# language version {rawLanguageVersion}.");
                    }
                }
            });
 
            var results = GenerateCode(engine, sourceItems);
            var isGeneratingDeclaration = GenerateDeclaration.HasValue();
 
            foreach (var result in results)
            {
                var errorCount = result.CSharpDocument.Diagnostics.Length;
                for (var i = 0; i < errorCount; i++)
                {
                    var error = result.CSharpDocument.Diagnostics[i];
                    if (error.Severity == RazorDiagnosticSeverity.Error)
                    {
                        success = false;
                    }
 
                    if (i < 100)
                    {
                        Error.WriteLine(error.ToString());
 
                        // Only show the first 100 errors to prevent massive string allocations.
                        if (i == 99)
                        {
                            Error.WriteLine($"And {errorCount - i + 1} more warnings/errors.");
                        }
                    }
                }
 
                if (success)
                {
                    // Only output the file if we generated it without errors.
                    var outputFilePath = result.InputItem.OutputPath;
                    var generatedCode = result.CSharpDocument.Text.ToString();
                    if (isGeneratingDeclaration)
                    {
                        // When emiting declarations, only write if it the contents are different.
                        // This allows build incrementalism to kick in when the declaration remains unchanged between builds.
                        if (File.Exists(outputFilePath) &&
                            string.Equals(File.ReadAllText(outputFilePath), generatedCode, StringComparison.Ordinal))
                        {
                            continue;
                        }
                    }
 
                    File.WriteAllText(outputFilePath, generatedCode);
                }
            }
 
            return success ? ExitCodeSuccess : ExitCodeFailureRazorError;
        }
 
        private VirtualRazorProjectFileSystem GetVirtualRazorProjectSystem(SourceItem[] inputItems)
        {
            var project = new VirtualRazorProjectFileSystem();
            foreach (var item in inputItems)
            {
                var projectItem = new DefaultRazorProjectItem(
                    basePath: "/",
                    filePath: item.FilePath,
                    relativePhysicalPath: item.RelativePhysicalPath,
                    fileKind: item.FileKind,
                    file: new FileInfo(item.SourcePath),
                    cssScope: item.CssScope);
 
                project.Add(projectItem);
            }
 
            return project;
        }
 
        private IReadOnlyList<TagHelperDescriptor> GetTagHelpers(string tagHelperManifest)
        {
            if (!File.Exists(tagHelperManifest))
            {
                return Array.Empty<TagHelperDescriptor>();
            }
 
            using (var stream = File.OpenRead(tagHelperManifest))
            {
                var reader = new JsonTextReader(new StreamReader(stream));
 
                var serializer = new JsonSerializer();
                serializer.Converters.Add(TagHelperDescriptorJsonConverter.Instance);
 
                var descriptors = serializer.Deserialize<IReadOnlyList<TagHelperDescriptor>>(reader);
                return descriptors;
            }
        }
 
        private static SourceItem[] GetSourceItems(List<string> sources, List<string> outputs, List<string> relativePath, List<string> fileKinds, List<string> cssScopeSources, List<string> cssScopeValues)
        {
            var cssScopeAssociations = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            for (var cssScopeSourceIndex = 0; cssScopeSourceIndex < cssScopeSources.Count; cssScopeSourceIndex++)
            {
                cssScopeAssociations.Add(cssScopeSources[cssScopeSourceIndex], cssScopeSourceIndex);
            }
 
            var items = new SourceItem[sources.Count];
            for (var i = 0; i < items.Length; i++)
            {
                var fileKind = fileKinds.Count > 0
                    ? ConvertFileKind(fileKinds[i])
                    : RazorFileKind.Legacy;
 
                if (AspNetCore.Razor.Language.FileKinds.IsComponent(fileKind))
                {
                    fileKind = GetComponentFileKindFromFilePath(sources[i]);
                }
 
                var cssScopeValue = cssScopeAssociations.TryGetValue(sources[i], out var cssScopeIndex)
                    ? cssScopeValues[cssScopeIndex]
                    : null;
 
                items[i] = new SourceItem(sources[i], outputs[i], relativePath[i], fileKind, cssScopeValue);
            }
 
            return items;
        }
 
        private static RazorFileKind ConvertFileKind(string fileKind)
        {
            if (string.Equals(fileKind, "component", StringComparison.OrdinalIgnoreCase))
            {
                return RazorFileKind.Component;
            }
 
            if (string.Equals(fileKind, "componentImport", StringComparison.OrdinalIgnoreCase))
            {
                return RazorFileKind.ComponentImport;
            }
 
            if (string.Equals(fileKind, "mvc", StringComparison.OrdinalIgnoreCase))
            {
                return RazorFileKind.Legacy;
            }
 
            return RazorFileKind.Legacy;
        }
 
        private static RazorFileKind GetComponentFileKindFromFilePath(string filePath)
        {
            return AspNetCore.Razor.Language.FileKinds.TryGetFileKindFromPath(filePath, out var kind) && kind != RazorFileKind.Legacy
                ? kind
                : RazorFileKind.Component;
        }
 
        private OutputItem[] GenerateCode(RazorProjectEngine engine, SourceItem[] inputs)
        {
            var outputs = new OutputItem[inputs.Length];
            Parallel.For(0, outputs.Length, new ParallelOptions() { MaxDegreeOfParallelism = Debugger.IsAttached ? 1 : 4 }, i =>
            {
                var inputItem = inputs[i];
 
                var codeDocument = engine.Process(engine.FileSystem.GetItem(inputItem.FilePath, inputItem.FileKind));
                var csharpDocument = codeDocument.GetCSharpDocument();
                outputs[i] = new OutputItem(inputItem, csharpDocument);
            });
 
            return outputs;
        }
 
        private struct OutputItem
        {
            public OutputItem(
                SourceItem inputItem,
                RazorCSharpDocument csharpDocument)
            {
                InputItem = inputItem;
                CSharpDocument = csharpDocument;
            }
 
            public SourceItem InputItem { get; }
 
            public RazorCSharpDocument CSharpDocument { get; }
        }
 
        private readonly struct SourceItem
        {
            public SourceItem(string sourcePath, string outputPath, string physicalRelativePath, RazorFileKind fileKind, string cssScope)
            {
                SourcePath = sourcePath;
                OutputPath = outputPath;
                RelativePhysicalPath = physicalRelativePath;
                FilePath = '/' + physicalRelativePath
                    .Replace(Path.DirectorySeparatorChar, '/')
                    .Replace("//", "/");
                FileKind = fileKind;
                CssScope = cssScope;
            }
 
            public string SourcePath { get; }
 
            public string OutputPath { get; }
 
            public string RelativePhysicalPath { get; }
 
            public string FilePath { get; }
 
            public RazorFileKind FileKind { get; }
 
            public string CssScope { get; }
        }
 
        private class StaticTagHelperFeature : RazorEngineFeatureBase, ITagHelperFeature
        {
            public IReadOnlyList<TagHelperDescriptor> TagHelpers { get; set; }
 
            public IReadOnlyList<TagHelperDescriptor> GetDescriptors() => TagHelpers;
        }
    }
}