File: Template\ParameterSetBuilder.cs
Web Access
Project: src\src\sdk\src\TemplateEngine\Microsoft.TemplateEngine.Edge\Microsoft.TemplateEngine.Edge.csproj (Microsoft.TemplateEngine.Edge)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Abstractions.Parameters;
using Microsoft.TemplateEngine.Utils;

namespace Microsoft.TemplateEngine.Edge.Template
{
    internal class ParameterSetBuilder : ParameterDefinitionSet, IParameterSetBuilder,
#pragma warning disable CS0618 // Type or member is obsolete
        IParameterSet
#pragma warning restore CS0618 // Type or member is obsolete
    {
        private readonly Dictionary<ITemplateParameter, EvalData> _resolvedValues;
        private InputDataSet? _result;

        internal ParameterSetBuilder(IReadOnlyDictionary<string, ITemplateParameter> parameters) : base(parameters)
        {
            _resolvedValues = parameters.ToDictionary(p => p.Value, p => new EvalData(p.Value));
        }

        internal ParameterSetBuilder(IParameterDefinitionSet parameters) : this(parameters.AsReadonlyDictionary())
        { }

        public static IParameterSetBuilder CreateWithDefaults(IGenerator generator, IParameterDefinitionSet parametersDefinition, IEngineEnvironmentSettings environment, string? name = null)
        {
            var result = CreateWithDefaults(generator, parametersDefinition, name, environment, out IReadOnlyList<string> errors);
            if (errors.Any())
            {
                throw new Exception("ParameterDefinitionSet with errors encountered: " + errors.ToCsvString());
            }

            return result;
        }

        public static IParameterSetBuilder CreateWithDefaults(IGenerator generator, IParameterDefinitionSet parametersDefinition, string? name, IEngineEnvironmentSettings environment, out IReadOnlyList<string> paramsWithInvalidValues)
        {
            IParameterSetBuilder templateParams = new ParameterSetBuilder(parametersDefinition);
            List<string> paramsWithInvalidValuesList = new List<string>();

            foreach (ITemplateParameter param in templateParams)
            {
                if (param.IsName)
                {
                    if (name != null)
                    {
                        templateParams.SetParameterValue(param, name, DataSource.NameParameter);
                    }
                }
                else
                {
                    templateParams.SetParameterDefault(
                        generator,
                        param,
                        environment,
                        true,
                        param.Precedence.CanBeRequired,
                        paramsWithInvalidValuesList);
                }
            }

            paramsWithInvalidValues = paramsWithInvalidValuesList;
            return templateParams;
        }

        #region MyRegion IParameterSet members
        //We want all IParameterSet members in single region - hence not following recommended ordering
#pragma warning disable SA1201 // Elements should appear in the correct order
        IEnumerable<ITemplateParameter> IParameterSet.ParameterDefinitions => this;
#pragma warning restore SA1201 // Elements should appear in the correct order

        IDictionary<ITemplateParameter, object?> IParameterSet.ResolvedValues =>
            _resolvedValues
                .Where(p => p.Value.Value != null)
                .ToDictionary(k => k.Key, k => k.Value.Value);

        bool IParameterSet.TryGetParameterDefinition(string name, out ITemplateParameter parameter) => TryGetValue(name, out parameter);

        #endregion /MyRegion IParameterSet members

        public void SetParameterValue(ITemplateParameter parameter, object value, DataSource dataSource)
        {
            _resolvedValues[parameter].SetValue(value, dataSource);
            _result = null;
        }

        public void SetParameterEvaluation(ITemplateParameter parameter, EvaluatedInputParameterData evaluatedParameterData)
        {
            if (!_resolvedValues.ContainsKey(parameter))
            {
                return;
            }

            var old = _resolvedValues[parameter];
            _resolvedValues[parameter] = new EvalData(evaluatedParameterData);
            if (old.InputDataState != InputDataState.Unset)
            {
                _resolvedValues[parameter].SetValue(old.Value, old.DataSource);
            }

            _result = null;
        }

        public bool HasParameterValue(ITemplateParameter parameter) => _resolvedValues[parameter].InputDataState != InputDataState.Unset;

        public bool CheckIsParametersEvaluationCorrect(IGenerator generator, ILogger logger, bool throwOnError, out IReadOnlyList<string> paramsWithInvalidEvaluations)
        {
            List<EvalData> evaluatedParameters = _resolvedValues.Values.ToList();
            List<EvalData> clonedParameters = evaluatedParameters.Select(v => v.Clone()).ToList();
            List<string> invalidParams = new List<string>();
            try
            {
                RunDatasetEvaluation(clonedParameters, generator, logger);
            }
            catch (Exception e)
            {
                logger.LogInformation(e, "Cross-check evaluation of host provided condition evaluations failed.");
                if (throwOnError)
                {
                    throw;
                }
            }

            foreach (var pair in evaluatedParameters.Zip(clonedParameters, (a, b) => (a, b)))
            {
                if (pair.a.IsEnabledConditionResult != pair.b.IsEnabledConditionResult ||
                    pair.a.IsRequiredConditionResult != pair.b.IsRequiredConditionResult)
                {
                    invalidParams.Add(pair.a.ParameterDefinition.Name);
                }
            }

            paramsWithInvalidEvaluations = invalidParams;
            return invalidParams.Count == 0;
        }

        public InputDataSet Build(bool evaluateConditions, IGenerator generator, ILogger logger)
        {
            if (_result == null)
            {
                if (evaluateConditions)
                {
                    this.EvaluateConditionalParameters(generator, logger);
                }

                _result = new InputDataSet(
                    this,
                    _resolvedValues.Select(p => p.Value.ToParameterData()).ToList());
            }

            return _result!;
        }

        public void SetParameterDefault(IGenerator generator, ITemplateParameter parameter, IEngineEnvironmentSettings environment, bool useHostDefaults, bool isRequired, List<string> paramsWithInvalidValues)
        {
            ITemplateEngineHost host = environment.Host;
            if (useHostDefaults && host.TryGetHostParamDefault(parameter.Name, out string? hostParamValue) && hostParamValue != null)
            {
                object? resolvedValue = generator.ConvertParameterValueToType(environment, parameter, hostParamValue, out bool valueResolutionError);
                if (!valueResolutionError)
                {
                    if (resolvedValue is null)
                    {
                        throw new InvalidOperationException($"{nameof(resolvedValue)} cannot be null when {nameof(valueResolutionError)} is 'false'.");
                    }
                    this.SetParameterValue(parameter, resolvedValue, DataSource.HostDefault);
                }
                else
                {
                    paramsWithInvalidValues.Add(parameter.Name);
                }
            }
            // This for newly optional that does not have value set
            else if (!isRequired && parameter.DefaultValue != null)
            {
                object? resolvedValue = generator.ConvertParameterValueToType(environment, parameter, parameter.DefaultValue, out bool valueResolutionError);
                if (!valueResolutionError)
                {
                    if (resolvedValue is null)
                    {
                        throw new InvalidOperationException($"{nameof(resolvedValue)} cannot be null when {nameof(valueResolutionError)} is 'false'.");
                    }
                    this.SetParameterValue(parameter, resolvedValue, DataSource.Default);
                }
                else
                {
                    paramsWithInvalidValues.Add(parameter.Name);
                }
            }
        }

        private static void RunDatasetEvaluation(List<EvalData> evaluatedParameters, IGenerator generator, ILogger logger)
        {
            Dictionary<string, EvalData> variables =
                evaluatedParameters
                    .Where(p => p.Value != null)
                    .ToDictionary(p => p.ParameterDefinition.Name, p => p);

            IDictionary<string, object> variableCollection =
                variables.ToDictionary(p => p.Key, p => p.Value.Value!);

            EvaluateEnablementConditions(generator, evaluatedParameters, variableCollection, variables, logger);
            EvaluateRequirementCondition(generator, evaluatedParameters, variableCollection, logger);
        }

        private static void EvaluateEnablementConditions(
            IGenerator generator,
            IReadOnlyList<EvalData> parameters,
            IDictionary<string, object> variableCollection,
            Dictionary<string, EvalData> variables,
            ILogger logger)
        {
            Dictionary<EvalData, HashSet<EvalData>> parametersDependencies = new();

            // First parameters traversal.
            //   - evaluate all IsEnabledCondition - and get the dependencies between the parameters during doing so
            foreach (EvalData parameter in parameters)
            {
                if (!string.IsNullOrEmpty(parameter.ParameterDefinition.Precedence.IsEnabledCondition))
                {
                    HashSet<string> referencedVariablesKeys = new HashSet<string>();
                    // Do not remove from the variable collection though - we want to capture all dependencies between parameters in the first traversal.
                    // Those will be bulk removed before second traversal (traversing only the required dependencies).
                    parameter.IsEnabledConditionResult = EvaluateParameterCondition(
                        parameter.ParameterDefinition.Precedence.IsEnabledCondition!,
                        parameter.ParameterDefinition.Name,
                        "IsEnabled",
                        generator,
                        variableCollection,
                        referencedVariablesKeys,
                        logger);

                    if (referencedVariablesKeys.Any())
                    {
                        parametersDependencies[parameter] = new HashSet<EvalData>(referencedVariablesKeys.Select(idx => variables[idx]));
                    }
                }
            }

            // No dependencies between parameters detected - no need to process further the second evaluation
            if (parametersDependencies.Count == 0)
            {
                return;
            }

            DirectedGraph<EvalData> parametersDependenciesGraph = new(parametersDependencies);
            // Get the transitive closure of parameters that need to be recalculated, based on the knowledge of params that
            IReadOnlyList<EvalData> disabledParameters = parameters.Where(p => p.IsEnabledConditionResult.HasValue && !p.IsEnabledConditionResult.Value).ToList();
            DirectedGraph<EvalData> parametersToRecalculate =
                parametersDependenciesGraph.GetSubGraphDependentOnVertices(disabledParameters, includeSeedVertices: false);

            // Second traversal - for transitive dependencies of parameters that need to be disabled
            if (parametersToRecalculate.TryGetTopologicalSort(out IReadOnlyList<EvalData> orderedParameters))
            {
                disabledParameters.ForEach(p => variableCollection.Remove(p.ParameterDefinition.Name));

                if (parametersDependenciesGraph.HasCycle(out var cycle))
                {
                    logger.LogWarning(LocalizableStrings.ConditionEvaluation_Warning_CyclicDependency, cycle.Select(p => p.ParameterDefinition.Name).ToCsvString());
                }

                foreach (EvalData parameter in orderedParameters)
                {
                    bool isEnabled = EvaluateParameterCondition(
                        parameter.ParameterDefinition.Precedence.IsEnabledCondition!,
                        parameter.ParameterDefinition.Name,
                        "IsEnabled",
                        generator,
                        variableCollection,
                        null,
                        logger);
                    parameter.IsEnabledConditionResult = isEnabled;
                    if (!isEnabled)
                    {
                        variableCollection.Remove(parameter.ParameterDefinition.Name);
                    }
                }
            }
            else if (parametersToRecalculate.HasCycle(out var cycle))
            {
                throw new TemplateAuthoringException(
                    string.Format(
                        LocalizableStrings.ConditionEvaluation_Error_CyclicDependency,
                        cycle.Select(p => p.ParameterDefinition.Name).ToCsvString()),
                    "Conditional ParameterDefinitionSet");
            }
            else
            {
                throw new Exception(LocalizableStrings.ConditionEvaluation_Error_TopologicalSort);
            }
        }

        private static bool EvaluateParameterCondition(
            string condition,
            string parameterName,
            string conditionName,
            IGenerator generator,
            IDictionary<string, object> variableCollection,
            HashSet<string>? referencedVariablesKeys,
            ILogger logger)
        {
            if (!generator.TryEvaluateFromString(logger, condition, variableCollection, out bool result, out string evaluationError, referencedVariablesKeys))
            {
                throw new TemplateAuthoringException(
                    string.Format(
                        LocalizableStrings.ConditionEvaluation_Error_MismatchedCondition,
                        conditionName,
                        parameterName,
                        condition,
                        evaluationError),
                    parameterName);
            }

            return result;
        }

        private static void EvaluateRequirementCondition(
            IGenerator generator,
            IReadOnlyList<EvalData> parameters,
            IDictionary<string, object> variableCollection,
            ILogger logger)
        {
            foreach (EvalData parameter in parameters)
            {
                if (!string.IsNullOrEmpty(parameter.ParameterDefinition.Precedence.IsRequiredCondition))
                {
                    parameter.IsRequiredConditionResult = EvaluateParameterCondition(
                        parameter.ParameterDefinition.Precedence.IsRequiredCondition!,
                        parameter.ParameterDefinition.Name,
                        "IsRequired",
                        generator,
                        variableCollection,
                        null,
                        logger);
                }
            }
        }

        private void EvaluateConditionalParameters(IGenerator generator, ILogger logger)
        {
            List<EvalData> evaluatedParameters = _resolvedValues.Values.ToList();
            RunDatasetEvaluation(evaluatedParameters, generator, logger);
        }

        private class EvalData
        {
            public EvalData(ITemplateParameter parameterDefinition)
            {
                ParameterDefinition = parameterDefinition;
            }

            public EvalData(EvaluatedInputParameterData other)
                : this(other.ParameterDefinition, other.Value, other.IsEnabledConditionResult, other.IsRequiredConditionResult)
            {
                this.DataSource = DataSource.NoSource;
            }

            private EvalData(
                ITemplateParameter parameterDefinition,
                object? value,
                bool? isEnabledConditionResult,
                bool? isRequiredConditionResult)
            {
                ParameterDefinition = parameterDefinition;
                Value = value;
                IsEnabledConditionResult = isEnabledConditionResult;
                IsRequiredConditionResult = isRequiredConditionResult;
            }

            public ITemplateParameter ParameterDefinition { get; }

            public InputDataState InputDataState { get; private set; } = InputDataState.Unset;

            public bool? IsEnabledConditionResult { get; set; }

            public bool? IsRequiredConditionResult { get; set; }

            public DataSource DataSource { get; private set; } = DataSource.NoSource;

            public object? Value { get; private set; }

            public void SetValue(object? value, DataSource source)
            {
                Value = value;
                DataSource = source;
                InputDataState = InputDataStateUtil.GetInputDataState(value);
            }

            public override string ToString() => $"{ParameterDefinition}: {Value?.ToString() ?? "<null>"}";

            public EvaluatedInputParameterData ToParameterData()
            {
                return new EvaluatedInputParameterData(
                    this.ParameterDefinition,
                    this.Value,
                    DataSource,
                    this.IsEnabledConditionResult,
                    this.IsRequiredConditionResult,
                    InputDataState);
            }

            public EvalData Clone()
            {
                var ds = DataSource;
                return new EvalData(ParameterDefinition, Value, IsEnabledConditionResult, IsRequiredConditionResult)
                {
                    DataSource = ds
                };
            }

        }
    }
}