File: TemplateRestorePlan.cs
Web Access
Project: src\tools\GenerateTemplateManifest\GenerateTemplateManifest.csproj (GenerateTemplateManifest)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.TemplateEngine.Abstractions.Installer;
using Microsoft.TemplateEngine.Edge;
using Microsoft.TemplateEngine.Edge.Settings;
using Microsoft.TemplateEngine.Edge.Template;
 
namespace GenerateTemplateManifest;
 
internal sealed class TemplateRestorePlan
{
    private readonly Dictionary<string, RestoreProject> _projects = new(StringComparer.Ordinal);
 
    internal IReadOnlyCollection<RestoreProject> Projects => _projects.Values;
    internal int ConfigurationCount { get; private set; }
    internal int ProjectCount { get; private set; }
    internal HashSet<(string Name, string Version)> Sdks { get; } = [];
 
    internal async Task DiscoverAsync(string sourceDirectory, string processedDirectory, string workDirectory)
    {
        var snapshot = Path.Combine(workDirectory, "template-inputs");
        Directory.CreateDirectory(snapshot);
        var configs = Directory.GetFiles(sourceDirectory, "template.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal).ToArray();
        foreach (var configPath in configs)
        {
            var templateRoot = Path.GetDirectoryName(Path.GetDirectoryName(configPath))!;
            var relativeRoot = Path.GetRelativePath(sourceDirectory, templateRoot);
            var destination = Path.Combine(snapshot, relativeRoot);
            Directory.CreateDirectory(Path.Combine(destination, ".template.config"));
 
            // Render only dependency-bearing inputs, but leave the engine's conditions, computed
            // symbols, replacements and source exclusions intact. No post-actions are executed.
            foreach (var sourcePath in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories).Where(IsDependencyInput))
            {
                if (Path.GetExtension(sourcePath) is ".props" or ".targets")
                {
                    throw new InvalidDataException($"Shared template MSBuild input needs explicit restore-plan support: {sourcePath}");
                }
                var relative = Path.GetRelativePath(sourceDirectory, sourcePath);
                var output = Path.Combine(snapshot, relative);
                Directory.CreateDirectory(Path.GetDirectoryName(output)!);
                File.Copy(Path.Combine(processedDirectory, relative), output, overwrite: true);
            }
            File.Copy(Path.Combine(processedDirectory, Path.GetRelativePath(sourceDirectory, configPath)),
                Path.Combine(destination, ".template.config", "template.json"), overwrite: true);
        }
 
        var components = Microsoft.TemplateEngine.Edge.Components.AllComponents
            .Concat(Microsoft.TemplateEngine.Orchestrator.RunnableProjects.Components.AllComponents).ToList();
        using var host = new DefaultTemplateEngineHost("dotnetcli", "1.0.0", builtIns: components);
        using var settings = new EngineEnvironmentSettings(host, virtualizeSettings: true,
            settingsLocation: Path.Combine(workDirectory, "template-hive"));
        using var manager = new TemplatePackageManager(settings);
        var installed = await manager.GetBuiltInManagedProvider().InstallAsync([new InstallRequest(snapshot)], CancellationToken.None).ConfigureAwait(false);
        if (installed.Any(result => !result.Success))
        {
            throw new InvalidDataException($"Could not load dependency templates: {string.Join(", ", installed.Select(r => r.Error))}");
        }
        var templates = await manager.GetTemplatesAsync(CancellationToken.None).ConfigureAwait(false);
        if (templates.Count != configs.Length)
        {
            throw new InvalidDataException($"Expected {configs.Length} templates, discovered {templates.Count}.");
        }
        var creator = new TemplateCreator(settings);
        foreach (var template in templates.OrderBy(t => t.Identity, StringComparer.Ordinal))
        {
            var configPath = Path.Combine(snapshot, template.ConfigPlace.TrimStart('/', '\\').Replace('/', Path.DirectorySeparatorChar));
            var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
            var templateRoot = Path.GetDirectoryName(Path.GetDirectoryName(configPath))!;
            var content = string.Join("\n", Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories)
                .Where(IsDependencyInput).Select(File.ReadAllText));
 
            foreach (var parameters in GetConfigurations(config, content))
            {
                ConfigurationCount++;
                var output = Path.Combine(workDirectory, "rendered", ConfigurationCount.ToString("D4", CultureInfo.InvariantCulture));
                var result = await creator.InstantiateAsync(template, "Template", null, output, parameters).ConfigureAwait(false);
                if (result.Status != CreationResultStatus.Success)
                {
                    throw new InvalidDataException($"Could not render {template.Identity}: {result.Status}: {result.ErrorMessage}");
                }
                AddConfiguration(output, parameters);
            }
        }
    }
 
    internal static IEnumerable<Dictionary<string, string?>> GetConfigurations(JsonObject config, string content)
    {
        var symbols = config["symbols"]?.AsObject() ?? [];
        var relevant = content + "\nFramework\n" + config["sources"]?.ToJsonString();
        var included = new HashSet<string>(StringComparer.Ordinal);
        bool changed;
        do
        {
            changed = false;
            foreach (var (name, symbol) in symbols)
            {
                if (!included.Contains(name) &&
                    (Regex.IsMatch(relevant, $@"\b{Regex.Escape(name)}\b") ||
                     symbol?["replaces"]?.GetValue<string>() is { Length: > 0 } replacement &&
                     relevant.Contains(replacement, StringComparison.Ordinal)))
                {
                    included.Add(name);
                    relevant += "\n" + symbol!.ToJsonString();
                    changed = true;
                }
            }
        } while (changed);
 
        IEnumerable<Dictionary<string, string?>> configurations = [new(StringComparer.Ordinal)];
        foreach (var (name, symbol) in symbols.Where(s => included.Contains(s.Key) && s.Value?["type"]?.GetValue<string>() == "parameter"))
        {
            string[] values = symbol!["datatype"]?.GetValue<string>() switch
            {
                "bool" => ["false", "true"],
                "choice" => symbol["choices"]!.AsArray().Select(c => c!["choice"]!.GetValue<string>()).ToArray(),
                _ => throw new InvalidDataException($"Dependency input uses unbounded parameter '{name}'. Define finite coverage for it.")
            };
            configurations = configurations.SelectMany(c => values.Select(value =>
            {
                var copy = new Dictionary<string, string?>(c, StringComparer.Ordinal) { [name] = value };
                return copy;
            })).ToArray();
        }
        return configurations;
    }
 
    private void AddConfiguration(string directory, IReadOnlyDictionary<string, string?> parameters)
    {
        var documents = new Dictionary<string, XElement>(StringComparer.OrdinalIgnoreCase);
        foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Where(IsProject).Order(StringComparer.Ordinal))
        {
            documents.Add(Path.GetFullPath(path), XDocument.Load(path).Root!);
        }
        foreach (var path in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories))
        {
            var directives = File.ReadLines(path).Where(line => line.StartsWith("#:", StringComparison.Ordinal)).ToArray();
            if (directives.Length == 0)
            {
                continue;
            }
            var project = new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"),
                new XElement("PropertyGroup",
                    new XElement("TargetFramework", parameters["Framework"]),
                    new XElement("OutputType", "Exe"),
                    new XElement("FileBasedProgram", "true")));
            foreach (var directive in directives)
            {
                // File-based inputs use #:sdk Name@Version, #:package Name@Version, and #:property Name=Value.
                var match = Regex.Match(directive, @"^#:(sdk|package|property)\s+([^@=\s]+)[@=](.+)$");
                if (!match.Success)
                {
                    throw new InvalidDataException($"Unsupported file-based dependency directive: {directive}");
                }
                var name = match.Groups[2].Value;
                var value = match.Groups[3].Value;
                switch (match.Groups[1].Value)
                {
                    case "sdk":
                        project.SetAttributeValue("Sdk", $"{name}/{value}");
                        break;
                    case "package":
                        project.Add(new XElement("ItemGroup", new XElement("PackageReference", new XAttribute("Include", name), new XAttribute("Version", value))));
                        break;
                    case "property":
                        project.Element("PropertyGroup")!.Add(new XElement(name, value));
                        break;
                }
            }
            documents.Add(Path.ChangeExtension(path, ".csproj"), project);
        }
 
        var resolved = new Dictionary<string, RestoreProject>(StringComparer.OrdinalIgnoreCase);
        var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var path in documents.Keys)
        {
            Resolve(path);
        }
 
        RestoreProject Resolve(string path)
        {
            if (resolved.TryGetValue(path, out var existing))
            {
                return existing;
            }
            if (!visiting.Add(path))
            {
                throw new InvalidDataException($"Cyclic template project reference: {path}");
            }
            var project = Normalize(documents[path]);
            foreach (var reference in project.Descendants("ProjectReference"))
            {
                var relative = reference.Attribute("Include")!.Value.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
                var referencedPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, relative));
                if (!documents.ContainsKey(referencedPath))
                {
                    throw new InvalidDataException($"Missing template project reference: {referencedPath}");
                }
                var child = Resolve(referencedPath);
                reference.SetAttributeValue("Include", $"../{child.Name}/{child.Name}.csproj");
            }
            foreach (var sdk in (project.Attribute("Sdk")?.Value ?? "").Split(';'))
            {
                var parts = sdk.Split('/', 2);
                if (parts.Length == 2)
                {
                    Sdks.Add((parts[0], parts[1]));
                }
            }
 
            var framework = project.Descendants("TargetFramework").SingleOrDefault()?.Value;
            project.Descendants("TargetFramework").Remove();
            var key = project.ToString(SaveOptions.DisableFormatting);
            if (!_projects.TryGetValue(key, out var planned))
            {
                planned = new RestoreProject($"Graph{_projects.Count:D3}", project);
                _projects.Add(key, planned);
            }
            if (framework is not null)
            {
                planned.Frameworks.Add(framework);
            }
            ProjectCount++;
            resolved.Add(path, planned);
            visiting.Remove(path);
            return planned;
        }
    }
 
    internal static XElement Normalize(XElement source)
    {
        var project = new XElement(source);
        project.DescendantNodes().OfType<XComment>().Remove();
        // Choose/Import/Target can consume preceding properties/items. Moving groups across
        // those elements changes evaluation order, so preserve these project shapes verbatim.
        if (project.Elements().Any(element => element.Name.LocalName is not ("PropertyGroup" or "ItemGroup")))
        {
            return project;
        }
        // These affect compilation or app identity, never restore. Preserve all other properties
        // (including runner switches) and all dependency metadata rather than comparing package IDs alone.
        string[] compilationOnly = ["ImplicitUsings", "Nullable", "UserSecretsId", "IsPackable", "IsAspireSharedProject"];
        project.Elements("PropertyGroup").Elements().Where(p => compilationOnly.Contains(p.Name.LocalName, StringComparer.Ordinal)).Remove();
        foreach (var group in project.Elements("ItemGroup"))
        {
            group.Elements().Where(item => item.Name.LocalName == "Using").Remove();
        }
        foreach (var group in project.Elements().Where(g => g.Name.LocalName is "PropertyGroup" or "ItemGroup").ToArray())
        {
            if (!group.HasElements)
            {
                group.Remove();
            }
        }
        // Re-group unconditional properties/items so property ordering and group boundaries don't
        // defeat deduplication. Conditional groups remain intact for MSBuild to evaluate.
        foreach (var groupName in new[] { "PropertyGroup", "ItemGroup" })
        {
            var groups = project.Elements(groupName).ToArray();
            if (groups.Any(g => g.HasAttributes) ||
                groups.SelectMany(g => g.Elements()).Any(e => e.ToString().Contains("$(", StringComparison.Ordinal)) ||
                groups.SelectMany(g => g.Elements()).Any(e => e.Attribute("Update") is not null || e.Attribute("Remove") is not null) ||
                groupName == "PropertyGroup" && groups.SelectMany(g => g.Elements()).GroupBy(e => e.Name).Any(g => g.Count() > 1))
            {
                continue;
            }
            var elements = groups.SelectMany(g => g.Elements()).ToArray();
            if (groupName == "PropertyGroup")
            {
                elements = elements.OrderBy(e => e.ToString(SaveOptions.DisableFormatting), StringComparer.Ordinal).ToArray();
            }
            // Items are evaluated in declaration order, including across groups. For example,
            // <PackageReference Include="@(TemplatePackage)" /> must stay after its TemplatePackage items.
            groups.Remove();
            if (elements.Length > 0)
            {
                project.Add(new XElement(groupName, elements));
            }
        }
        return project;
    }
 
    internal string Write(string directory)
    {
        foreach (var project in Projects)
        {
            var root = new XElement(project.Project);
            root.AddFirst(new XElement("PropertyGroup",
                project.Frameworks.Count > 0 ? new XElement("TargetFrameworks", string.Join(";", project.Frameworks)) : null,
                new XElement("EnableDefaultItems", "false"),
                new XElement("ManagePackageVersionsCentrally", "false"),
                new XElement("NuGetAudit", "false")));
            var projectDirectory = Path.Combine(directory, project.Name);
            Directory.CreateDirectory(projectDirectory);
            new XDocument(root).Save(Path.Combine(projectDirectory, $"{project.Name}.csproj"));
        }
        var solutionPath = Path.Combine(directory, "TemplateDependencies.slnx");
        new XDocument(new XElement("Solution", Projects.Select(project =>
            new XElement("Project", new XAttribute("Path", $"{project.Name}/{project.Name}.csproj"))))).Save(solutionPath);
        return solutionPath;
    }
 
    private static bool IsProject(string path) => Path.GetExtension(path) is ".csproj" or ".esproj";
    private static bool IsDependencyInput(string path) => IsProject(path) || Path.GetExtension(path) is ".props" or ".targets" ||
        Path.GetExtension(path) == ".cs" && File.ReadLines(path).Any(line => line.StartsWith("#:", StringComparison.Ordinal));
 
    internal sealed class RestoreProject(string name, XElement project)
    {
        internal string Name { get; } = name;
        internal XElement Project { get; } = project;
        internal SortedSet<string> Frameworks { get; } = new(StringComparer.Ordinal);
    }
}