mirror of
https://github.com/actions/runner.git
synced 2025-12-10 12:36:23 +00:00
Compare updated template evaluator (#4092)
This commit is contained in:
@@ -172,6 +172,7 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string ContainerActionRunnerTemp = "actions_container_action_runner_temp";
|
public static readonly string ContainerActionRunnerTemp = "actions_container_action_runner_temp";
|
||||||
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
||||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||||
|
public static readonly string CompareTemplateEvaluator = "actions_runner_compare_template_evaluator";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node version migration related constants
|
// Node version migration related constants
|
||||||
|
|||||||
@@ -1306,10 +1306,14 @@ namespace GitHub.Runner.Worker
|
|||||||
UpdateGlobalStepsContext();
|
UpdateGlobalStepsContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
|
||||||
|
{
|
||||||
|
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
|
||||||
|
}
|
||||||
|
|
||||||
private static void NoOp()
|
private static void NoOp()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Error/Warning/etc methods are created as extension methods to simplify unit testing.
|
// The Error/Warning/etc methods are created as extension methods to simplify unit testing.
|
||||||
@@ -1390,8 +1394,15 @@ namespace GitHub.Runner.Worker
|
|||||||
return new[] { new KeyValuePair<string, object>(nameof(IExecutionContext), context) };
|
return new[] { new KeyValuePair<string, object>(nameof(IExecutionContext), context) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||||
{
|
{
|
||||||
|
// Create wrapper?
|
||||||
|
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareTemplateEvaluator) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_TEMPLATE_EVALUATOR")))
|
||||||
|
{
|
||||||
|
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy
|
||||||
if (traceWriter == null)
|
if (traceWriter == null)
|
||||||
{
|
{
|
||||||
traceWriter = context.ToTemplateTraceWriter();
|
traceWriter = context.ToTemplateTraceWriter();
|
||||||
|
|||||||
@@ -22,4 +22,13 @@ namespace GitHub.Runner.Worker.Expressions
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NewAlwaysFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||||
|
{
|
||||||
|
protected override Object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext context, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,18 @@ namespace GitHub.Runner.Worker.Expressions
|
|||||||
return jobStatus == ActionResult.Cancelled;
|
return jobStatus == ActionResult.Cancelled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NewCancelledFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||||
|
{
|
||||||
|
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||||
|
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||||
|
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||||
|
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||||
|
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||||
|
return jobStatus == ActionResult.Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NewFailureFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||||
|
{
|
||||||
|
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||||
|
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||||
|
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||||
|
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||||
|
|
||||||
|
// Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps
|
||||||
|
var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main;
|
||||||
|
if (isCompositeMainStep)
|
||||||
|
{
|
||||||
|
ActionResult actionStatus = EnumUtil.TryParse<ActionResult>(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success;
|
||||||
|
return actionStatus == ActionResult.Failure;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||||
|
return jobStatus == ActionResult.Failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,4 +143,137 @@ namespace GitHub.Runner.Worker.Expressions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NewHashFilesFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||||
|
{
|
||||||
|
private const int _hashFileTimeoutSeconds = 120;
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
GitHub.Actions.Expressions.Sdk.EvaluationContext context,
|
||||||
|
out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var templateContext = context.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||||
|
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||||
|
templateContext.ExpressionValues.TryGetValue(PipelineTemplateConstants.GitHub, out var githubContextData);
|
||||||
|
ArgUtil.NotNull(githubContextData, nameof(githubContextData));
|
||||||
|
var githubContext = githubContextData as GitHub.Actions.Expressions.Data.DictionaryExpressionData;
|
||||||
|
ArgUtil.NotNull(githubContext, nameof(githubContext));
|
||||||
|
|
||||||
|
if (!githubContext.TryGetValue(PipelineTemplateConstants.HostWorkspace, out var workspace))
|
||||||
|
{
|
||||||
|
githubContext.TryGetValue(PipelineTemplateConstants.Workspace, out workspace);
|
||||||
|
}
|
||||||
|
ArgUtil.NotNull(workspace, nameof(workspace));
|
||||||
|
|
||||||
|
var workspaceData = workspace as GitHub.Actions.Expressions.Data.StringExpressionData;
|
||||||
|
ArgUtil.NotNull(workspaceData, nameof(workspaceData));
|
||||||
|
|
||||||
|
string githubWorkspace = workspaceData.Value;
|
||||||
|
|
||||||
|
bool followSymlink = false;
|
||||||
|
List<string> patterns = new();
|
||||||
|
var firstParameter = true;
|
||||||
|
foreach (var parameter in Parameters)
|
||||||
|
{
|
||||||
|
var parameterString = parameter.Evaluate(context).ConvertToString();
|
||||||
|
if (firstParameter)
|
||||||
|
{
|
||||||
|
firstParameter = false;
|
||||||
|
if (parameterString.StartsWith("--"))
|
||||||
|
{
|
||||||
|
if (string.Equals(parameterString, "--follow-symbolic-links", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
followSymlink = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException($"Invalid glob option {parameterString}, avaliable option: '--follow-symbolic-links'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns.Add(parameterString);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Trace.Info($"Search root directory: '{githubWorkspace}'");
|
||||||
|
context.Trace.Info($"Search pattern: '{string.Join(", ", patterns)}'");
|
||||||
|
|
||||||
|
string binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
|
||||||
|
string runnerRoot = new DirectoryInfo(binDir).Parent.FullName;
|
||||||
|
|
||||||
|
string node = Path.Combine(runnerRoot, "externals", NodeUtil.GetInternalNodeVersion(), "bin", $"node{IOUtil.ExeExtension}");
|
||||||
|
string hashFilesScript = Path.Combine(binDir, "hashFiles");
|
||||||
|
var hashResult = string.Empty;
|
||||||
|
var p = new ProcessInvoker(new NewHashFilesTrace(context.Trace));
|
||||||
|
p.ErrorDataReceived += ((_, data) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(data.Data) && data.Data.StartsWith("__OUTPUT__") && data.Data.EndsWith("__OUTPUT__"))
|
||||||
|
{
|
||||||
|
hashResult = data.Data.Substring(10, data.Data.Length - 20);
|
||||||
|
context.Trace.Info($"Hash result: '{hashResult}'");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Trace.Info(data.Data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
p.OutputDataReceived += ((_, data) =>
|
||||||
|
{
|
||||||
|
context.Trace.Info(data.Data);
|
||||||
|
});
|
||||||
|
|
||||||
|
var env = new Dictionary<string, string>();
|
||||||
|
if (followSymlink)
|
||||||
|
{
|
||||||
|
env["followSymbolicLinks"] = "true";
|
||||||
|
}
|
||||||
|
env["patterns"] = string.Join(Environment.NewLine, patterns);
|
||||||
|
|
||||||
|
using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(_hashFileTimeoutSeconds)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int exitCode = p.ExecuteAsync(workingDirectory: githubWorkspace,
|
||||||
|
fileName: node,
|
||||||
|
arguments: $"\"{hashFilesScript.Replace("\"", "\\\"")}\"",
|
||||||
|
environment: env,
|
||||||
|
requireExitCodeZero: false,
|
||||||
|
cancellationToken: tokenSource.Token).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (exitCode != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') failed. Fail to hash files under directory '{githubWorkspace}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (tokenSource.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') couldn't finish within {_hashFileTimeoutSeconds} seconds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NewHashFilesTrace : ITraceWriter
|
||||||
|
{
|
||||||
|
private GitHub.Actions.Expressions.ITraceWriter _trace;
|
||||||
|
|
||||||
|
public NewHashFilesTrace(GitHub.Actions.Expressions.ITraceWriter trace)
|
||||||
|
{
|
||||||
|
_trace = trace;
|
||||||
|
}
|
||||||
|
public void Info(string message)
|
||||||
|
{
|
||||||
|
_trace.Info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Verbose(string message)
|
||||||
|
{
|
||||||
|
_trace.Info(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NewSuccessFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||||
|
{
|
||||||
|
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||||
|
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||||
|
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||||
|
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||||
|
|
||||||
|
// Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps
|
||||||
|
var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main;
|
||||||
|
if (isCompositeMainStep)
|
||||||
|
{
|
||||||
|
ActionResult actionStatus = EnumUtil.TryParse<ActionResult>(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success;
|
||||||
|
return actionStatus == ActionResult.Success;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||||
|
return jobStatus == ActionResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ namespace GitHub.Runner.Worker
|
|||||||
public bool WriteDebug { get; set; }
|
public bool WriteDebug { get; set; }
|
||||||
public string InfrastructureFailureCategory { get; set; }
|
public string InfrastructureFailureCategory { get; set; }
|
||||||
public JObject ContainerHookState { get; set; }
|
public JObject ContainerHookState { get; set; }
|
||||||
|
public bool HasTemplateEvaluatorMismatch { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
679
src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs
Normal file
679
src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.Actions.WorkflowParser;
|
||||||
|
using GitHub.DistributedTask.Expressions2;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.DistributedTask.Pipelines;
|
||||||
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
|
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||||
|
using GitHub.DistributedTask.WebApi;
|
||||||
|
using GitHub.Runner.Common;
|
||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
using ObjectTemplating = GitHub.DistributedTask.ObjectTemplating;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Worker
|
||||||
|
{
|
||||||
|
internal sealed class PipelineTemplateEvaluatorWrapper : IPipelineTemplateEvaluator
|
||||||
|
{
|
||||||
|
private PipelineTemplateEvaluator _legacyEvaluator;
|
||||||
|
private WorkflowTemplateEvaluator _newEvaluator;
|
||||||
|
private IExecutionContext _context;
|
||||||
|
private Tracing _trace;
|
||||||
|
|
||||||
|
public PipelineTemplateEvaluatorWrapper(
|
||||||
|
IHostContext hostContext,
|
||||||
|
IExecutionContext context,
|
||||||
|
ObjectTemplating.ITraceWriter traceWriter = null)
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(hostContext, nameof(hostContext));
|
||||||
|
ArgUtil.NotNull(context, nameof(context));
|
||||||
|
_context = context;
|
||||||
|
_trace = hostContext.GetTrace(nameof(PipelineTemplateEvaluatorWrapper));
|
||||||
|
|
||||||
|
if (traceWriter == null)
|
||||||
|
{
|
||||||
|
traceWriter = context.ToTemplateTraceWriter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy evaluator
|
||||||
|
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||||
|
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||||
|
{
|
||||||
|
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||||
|
};
|
||||||
|
|
||||||
|
// New evaluator
|
||||||
|
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||||
|
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
|
||||||
|
{
|
||||||
|
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EvaluateStepContinueOnError(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepContinueOnError",
|
||||||
|
() => _legacyEvaluator.EvaluateStepContinueOnError(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateStepContinueOnError(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => legacyResult == newResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string EvaluateStepDisplayName(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepDisplayName",
|
||||||
|
() => _legacyEvaluator.EvaluateStepDisplayName(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateStepName(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => string.Equals(legacyResult, newResult, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> EvaluateStepEnvironment(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions,
|
||||||
|
StringComparer keyComparer)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepEnvironment",
|
||||||
|
() => _legacyEvaluator.EvaluateStepEnvironment(token, contextData, expressionFunctions, keyComparer),
|
||||||
|
() => _newEvaluator.EvaluateStepEnvironment(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), keyComparer),
|
||||||
|
CompareStepEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EvaluateStepIf(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions,
|
||||||
|
IEnumerable<KeyValuePair<string, object>> expressionState)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepIf",
|
||||||
|
() => _legacyEvaluator.EvaluateStepIf(token, contextData, expressionFunctions, expressionState),
|
||||||
|
() => _newEvaluator.EvaluateStepIf(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), expressionState),
|
||||||
|
(legacyResult, newResult) => legacyResult == newResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> EvaluateStepInputs(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepInputs",
|
||||||
|
() => _legacyEvaluator.EvaluateStepInputs(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateStepInputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "StepInputs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int EvaluateStepTimeout(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateStepTimeout",
|
||||||
|
() => _legacyEvaluator.EvaluateStepTimeout(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateStepTimeout(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => legacyResult == newResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GitHub.DistributedTask.Pipelines.JobContainer EvaluateJobContainer(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateJobContainer",
|
||||||
|
() => _legacyEvaluator.EvaluateJobContainer(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateJobContainer(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
CompareJobContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> EvaluateJobOutput(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateJobOutput",
|
||||||
|
() => _legacyEvaluator.EvaluateJobOutput(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateJobOutputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobOutput"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public TemplateToken EvaluateEnvironmentUrl(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateEnvironmentUrl",
|
||||||
|
() => _legacyEvaluator.EvaluateEnvironmentUrl(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateJobEnvironmentUrl(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
CompareEnvironmentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> EvaluateJobDefaultsRun(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateJobDefaultsRun",
|
||||||
|
() => _legacyEvaluator.EvaluateJobDefaultsRun(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateJobDefaultsRun(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobDefaultsRun"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IList<KeyValuePair<string, GitHub.DistributedTask.Pipelines.JobContainer>> EvaluateJobServiceContainers(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateJobServiceContainers",
|
||||||
|
() => _legacyEvaluator.EvaluateJobServiceContainers(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateJobServiceContainers(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
(legacyResult, newResult) => CompareJobServiceContainers(legacyResult, newResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
public GitHub.DistributedTask.Pipelines.Snapshot EvaluateJobSnapshotRequest(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
return EvaluateAndCompare(
|
||||||
|
"EvaluateJobSnapshotRequest",
|
||||||
|
() => _legacyEvaluator.EvaluateJobSnapshotRequest(token, contextData, expressionFunctions),
|
||||||
|
() => _newEvaluator.EvaluateSnapshot(string.Empty, ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||||
|
CompareSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordMismatch(string methodName)
|
||||||
|
{
|
||||||
|
if (!_context.Global.HasTemplateEvaluatorMismatch)
|
||||||
|
{
|
||||||
|
_context.Global.HasTemplateEvaluatorMismatch = true;
|
||||||
|
var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorMismatch: {methodName}" };
|
||||||
|
_context.Global.JobTelemetry.Add(telemetry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordComparisonError(string errorDetails)
|
||||||
|
{
|
||||||
|
if (!_context.Global.HasTemplateEvaluatorMismatch)
|
||||||
|
{
|
||||||
|
_context.Global.HasTemplateEvaluatorMismatch = true;
|
||||||
|
var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorComparisonError: {errorDetails}" };
|
||||||
|
_context.Global.JobTelemetry.Add(telemetry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
|
||||||
|
string methodName,
|
||||||
|
Func<TLegacy> legacyEvaluator,
|
||||||
|
Func<TNew> newEvaluator,
|
||||||
|
Func<TLegacy, TNew, bool> resultComparer)
|
||||||
|
{
|
||||||
|
// Legacy evaluator
|
||||||
|
var legacyException = default(Exception);
|
||||||
|
var legacyResult = default(TLegacy);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
legacyResult = legacyEvaluator();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
legacyException = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with new evaluator
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ArgUtil.NotNull(_context, nameof(_context));
|
||||||
|
ArgUtil.NotNull(_newEvaluator, nameof(_newEvaluator));
|
||||||
|
_trace.Info(methodName);
|
||||||
|
|
||||||
|
// New evaluator
|
||||||
|
var newException = default(Exception);
|
||||||
|
var newResult = default(TNew);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
newResult = newEvaluator();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
newException = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare results or exceptions
|
||||||
|
if (legacyException != null || newException != null)
|
||||||
|
{
|
||||||
|
// Either one or both threw exceptions - compare them
|
||||||
|
if (!CompareExceptions(legacyException, newException))
|
||||||
|
{
|
||||||
|
_trace.Info($"{methodName} exception mismatch");
|
||||||
|
RecordMismatch($"{methodName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Both succeeded - compare results
|
||||||
|
if (!resultComparer(legacyResult, newResult))
|
||||||
|
{
|
||||||
|
_trace.Info($"{methodName} mismatch");
|
||||||
|
RecordMismatch($"{methodName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_trace.Info($"Comparison failed: {ex.Message}");
|
||||||
|
RecordComparisonError($"{methodName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw legacy exception if any
|
||||||
|
if (legacyException != null)
|
||||||
|
{
|
||||||
|
throw legacyException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return legacyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken ConvertToken(
|
||||||
|
GitHub.DistributedTask.ObjectTemplating.Tokens.TemplateToken token)
|
||||||
|
{
|
||||||
|
if (token == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = StringUtil.ConvertToJson(token, Newtonsoft.Json.Formatting.None);
|
||||||
|
return StringUtil.ConvertFromJson<GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken>(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GitHub.Actions.Expressions.Data.DictionaryExpressionData ConvertData(
|
||||||
|
GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData contextData)
|
||||||
|
{
|
||||||
|
if (contextData == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = StringUtil.ConvertToJson(contextData, Newtonsoft.Json.Formatting.None);
|
||||||
|
return StringUtil.ConvertFromJson<GitHub.Actions.Expressions.Data.DictionaryExpressionData>(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IList<GitHub.Actions.Expressions.IFunctionInfo> ConvertFunctions(
|
||||||
|
IList<GitHub.DistributedTask.Expressions2.IFunctionInfo> expressionFunctions)
|
||||||
|
{
|
||||||
|
if (expressionFunctions == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||||
|
foreach (var func in expressionFunctions)
|
||||||
|
{
|
||||||
|
GitHub.Actions.Expressions.IFunctionInfo newFunc = func.Name switch
|
||||||
|
{
|
||||||
|
"always" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewAlwaysFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||||
|
"cancelled" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewCancelledFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||||
|
"failure" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewFailureFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||||
|
"success" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewSuccessFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||||
|
"hashFiles" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewHashFilesFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||||
|
_ => throw new NotSupportedException($"Expression function '{func.Name}' is not supported for conversion")
|
||||||
|
};
|
||||||
|
result.Add(newFunc);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareStepEnvironment(
|
||||||
|
Dictionary<string, string> legacyResult,
|
||||||
|
Dictionary<string, string> newResult)
|
||||||
|
{
|
||||||
|
return CompareDictionaries(legacyResult, newResult, "StepEnvironment");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareEnvironmentUrl(
|
||||||
|
TemplateToken legacyResult,
|
||||||
|
GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newResult)
|
||||||
|
{
|
||||||
|
var legacyJson = legacyResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(legacyResult, Newtonsoft.Json.Formatting.None) : null;
|
||||||
|
var newJson = newResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult, Newtonsoft.Json.Formatting.None) : null;
|
||||||
|
return legacyJson == newJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareJobContainer(
|
||||||
|
GitHub.DistributedTask.Pipelines.JobContainer legacyResult,
|
||||||
|
GitHub.Actions.WorkflowParser.JobContainer newResult)
|
||||||
|
{
|
||||||
|
if (legacyResult == null && newResult == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyResult == null || newResult == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobContainer mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyResult.Image, newResult.Image, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobContainer mismatch - Image differs (legacy='{legacyResult.Image}', new='{newResult.Image}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyResult.Options, newResult.Options, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobContainer mismatch - Options differs (legacy='{legacyResult.Options}', new='{newResult.Options}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CompareLists(legacyResult.Volumes, newResult.Volumes, "Volumes"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CompareLists(legacyResult.Ports, newResult.Ports, "Ports"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CompareCredentials(legacyResult.Credentials, newResult.Credentials))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareCredentials(
|
||||||
|
GitHub.DistributedTask.Pipelines.ContainerRegistryCredentials legacyCreds,
|
||||||
|
GitHub.Actions.WorkflowParser.ContainerRegistryCredentials newCreds)
|
||||||
|
{
|
||||||
|
if (legacyCreds == null && newCreds == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyCreds == null || newCreds == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareCredentials mismatch - one is null (legacy={legacyCreds == null}, new={newCreds == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyCreds.Username, newCreds.Username, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareCredentials mismatch - Credentials.Username differs (legacy='{legacyCreds.Username}', new='{newCreds.Username}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyCreds.Password, newCreds.Password, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareCredentials mismatch - Credentials.Password differs");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareLists(IList<string> legacyList, IList<string> newList, string fieldName)
|
||||||
|
{
|
||||||
|
if (legacyList == null && newList == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyList == null || newList == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareLists mismatch - {fieldName} - one is null (legacy={legacyList == null}, new={newList == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyList.Count != newList.Count)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareLists mismatch - {fieldName}.Count differs (legacy={legacyList.Count}, new={newList.Count})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < legacyList.Count; i++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(legacyList[i], newList[i], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareLists mismatch - {fieldName}[{i}] differs (legacy='{legacyList[i]}', new='{newList[i]}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareDictionaries(IDictionary<string, string> legacyDict, IDictionary<string, string> newDict, string fieldName)
|
||||||
|
{
|
||||||
|
if (legacyDict == null && newDict == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyDict == null || newDict == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareDictionaries mismatch - {fieldName} - one is null (legacy={legacyDict == null}, new={newDict == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyDict is Dictionary<String, String> legacyTypedDict && newDict is Dictionary<String, String> newTypedDict)
|
||||||
|
{
|
||||||
|
if (!object.Equals(legacyTypedDict.Comparer, newTypedDict.Comparer))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareDictionaries mismatch - {fieldName} - different comparers (legacy={legacyTypedDict.Comparer.GetType().Name}, new={newTypedDict.Comparer.GetType().Name})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyDict.Count != newDict.Count)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareDictionaries mismatch - {fieldName}.Count differs (legacy={legacyDict.Count}, new={newDict.Count})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in legacyDict)
|
||||||
|
{
|
||||||
|
if (!newDict.TryGetValue(kvp.Key, out var newValue))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareDictionaries mismatch - {fieldName} - key '{kvp.Key}' missing in new result");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(kvp.Value, newValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareDictionaries mismatch - {fieldName}['{kvp.Key}'] differs (legacy='{kvp.Value}', new='{newValue}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareJobServiceContainers(
|
||||||
|
IList<KeyValuePair<string, GitHub.DistributedTask.Pipelines.JobContainer>> legacyResult,
|
||||||
|
IList<KeyValuePair<string, GitHub.Actions.WorkflowParser.JobContainer>> newResult)
|
||||||
|
{
|
||||||
|
if (legacyResult == null && newResult == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyResult == null || newResult == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobServiceContainers mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyResult.Count != newResult.Count)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers.Count differs (legacy={legacyResult.Count}, new={newResult.Count})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < legacyResult.Count; i++)
|
||||||
|
{
|
||||||
|
var legacyKvp = legacyResult[i];
|
||||||
|
var newKvp = newResult[i];
|
||||||
|
|
||||||
|
if (!string.Equals(legacyKvp.Key, newKvp.Key, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers[{i}].Key differs (legacy='{legacyKvp.Key}', new='{newKvp.Key}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CompareJobContainer(legacyKvp.Value, newKvp.Value))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers['{legacyKvp.Key}']");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareSnapshot(
|
||||||
|
GitHub.DistributedTask.Pipelines.Snapshot legacyResult,
|
||||||
|
GitHub.Actions.WorkflowParser.Snapshot newResult)
|
||||||
|
{
|
||||||
|
if (legacyResult == null && newResult == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyResult == null || newResult == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareSnapshot mismatch - one is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyResult.ImageName, newResult.ImageName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareSnapshot mismatch - Snapshot.ImageName differs (legacy='{legacyResult.ImageName}', new='{newResult.ImageName}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(legacyResult.Version, newResult.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareSnapshot mismatch - Snapshot.Version differs (legacy='{legacyResult.Version}', new='{newResult.Version}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare Condition (legacy) vs If (new)
|
||||||
|
// Legacy has Condition as string, new has If as BasicExpressionToken
|
||||||
|
// For comparison, we'll serialize the If token and compare with Condition
|
||||||
|
var newIfValue = newResult.If != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult.If, Newtonsoft.Json.Formatting.None) : null;
|
||||||
|
|
||||||
|
// Legacy Condition is a string expression like "success()"
|
||||||
|
// New If is a BasicExpressionToken that needs to be serialized
|
||||||
|
// We'll do a basic comparison - if both are null/empty or both exist
|
||||||
|
var legacyHasCondition = !string.IsNullOrEmpty(legacyResult.Condition);
|
||||||
|
var newHasIf = newResult.If != null;
|
||||||
|
|
||||||
|
if (legacyHasCondition != newHasIf)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareSnapshot mismatch - condition/if presence differs (legacy has condition={legacyHasCondition}, new has if={newHasIf})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompareExceptions(Exception legacyException, Exception newException)
|
||||||
|
{
|
||||||
|
if (legacyException == null && newException == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyException == null || newException == null)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareExceptions mismatch - one exception is null (legacy={legacyException == null}, new={newException == null})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare exception messages recursively (including inner exceptions)
|
||||||
|
var legacyMessages = GetExceptionMessages(legacyException);
|
||||||
|
var newMessages = GetExceptionMessages(newException);
|
||||||
|
|
||||||
|
if (legacyMessages.Count != newMessages.Count)
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareExceptions mismatch - different number of exception messages (legacy={legacyMessages.Count}, new={newMessages.Count})");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < legacyMessages.Count; i++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(legacyMessages[i], newMessages[i], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_trace.Info($"CompareExceptions mismatch - exception messages differ at level {i} (legacy='{legacyMessages[i]}', new='{newMessages[i]}')");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IList<string> GetExceptionMessages(Exception ex)
|
||||||
|
{
|
||||||
|
var messages = new List<string>();
|
||||||
|
var toProcess = new Queue<Exception>();
|
||||||
|
toProcess.Enqueue(ex);
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
while (toProcess.Count > 0 && count < 50)
|
||||||
|
{
|
||||||
|
var current = toProcess.Dequeue();
|
||||||
|
if (current == null) continue;
|
||||||
|
|
||||||
|
messages.Add(current.Message);
|
||||||
|
count++;
|
||||||
|
|
||||||
|
// Special handling for AggregateException - enqueue all inner exceptions
|
||||||
|
if (current is AggregateException aggregateEx)
|
||||||
|
{
|
||||||
|
foreach (var innerEx in aggregateEx.InnerExceptions)
|
||||||
|
{
|
||||||
|
if (innerEx != null && count < 50)
|
||||||
|
{
|
||||||
|
toProcess.Enqueue(innerEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (current.InnerException != null)
|
||||||
|
{
|
||||||
|
toProcess.Enqueue(current.InnerException);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failsafe: if we have too many exceptions, stop and return what we have
|
||||||
|
if (count >= 50)
|
||||||
|
{
|
||||||
|
_trace.Info("CompareExceptions failsafe triggered - too many exceptions (50+)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.DistributedTask.Expressions2;
|
||||||
|
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||||
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
|
|
||||||
|
namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPipelineTemplateEvaluator
|
||||||
|
{
|
||||||
|
Boolean EvaluateStepContinueOnError(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
String EvaluateStepDisplayName(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
Dictionary<String, String> EvaluateStepEnvironment(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions,
|
||||||
|
StringComparer keyComparer);
|
||||||
|
|
||||||
|
Boolean EvaluateStepIf(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions,
|
||||||
|
IEnumerable<KeyValuePair<String, Object>> expressionState);
|
||||||
|
|
||||||
|
Dictionary<String, String> EvaluateStepInputs(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
Int32 EvaluateStepTimeout(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
JobContainer EvaluateJobContainer(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
Dictionary<String, String> EvaluateJobOutput(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
TemplateToken EvaluateEnvironmentUrl(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
Dictionary<String, String> EvaluateJobDefaultsRun(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
IList<KeyValuePair<String, JobContainer>> EvaluateJobServiceContainers(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
|
||||||
|
Snapshot EvaluateJobSnapshotRequest(
|
||||||
|
TemplateToken token,
|
||||||
|
DictionaryContextData contextData,
|
||||||
|
IList<IFunctionInfo> expressionFunctions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
|||||||
/// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs.
|
/// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
public class PipelineTemplateEvaluator
|
public class PipelineTemplateEvaluator : IPipelineTemplateEvaluator
|
||||||
{
|
{
|
||||||
public PipelineTemplateEvaluator(
|
public PipelineTemplateEvaluator(
|
||||||
ITraceWriter trace,
|
ITraceWriter trace,
|
||||||
|
|||||||
111
src/Sdk/Expressions/Data/ArrayExpressionData.cs
Normal file
111
src/Sdk/Expressions/Data/ArrayExpressionData.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
[JsonObject]
|
||||||
|
public sealed class ArrayExpressionData : ExpressionData, IEnumerable<ExpressionData>, IReadOnlyArray
|
||||||
|
{
|
||||||
|
public ArrayExpressionData()
|
||||||
|
: base(ExpressionDataType.Array)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public Int32 Count => m_items?.Count ?? 0;
|
||||||
|
|
||||||
|
public ExpressionData this[Int32 index] => m_items[index];
|
||||||
|
|
||||||
|
Object IReadOnlyArray.this[Int32 index] => m_items[index];
|
||||||
|
|
||||||
|
public void Add(ExpressionData item)
|
||||||
|
{
|
||||||
|
if (m_items == null)
|
||||||
|
{
|
||||||
|
m_items = new List<ExpressionData>();
|
||||||
|
}
|
||||||
|
|
||||||
|
m_items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
var result = new ArrayExpressionData();
|
||||||
|
if (m_items?.Count > 0)
|
||||||
|
{
|
||||||
|
result.m_items = new List<ExpressionData>(m_items.Count);
|
||||||
|
foreach (var item in m_items)
|
||||||
|
{
|
||||||
|
result.m_items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
var result = new JArray();
|
||||||
|
if (m_items?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_items)
|
||||||
|
{
|
||||||
|
result.Add(item?.ToJToken() ?? JValue.CreateNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<ExpressionData> GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_items?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_items)
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_items?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_items)
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IReadOnlyArray.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_items?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_items)
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[OnSerializing]
|
||||||
|
private void OnSerializing(StreamingContext context)
|
||||||
|
{
|
||||||
|
if (m_items?.Count == 0)
|
||||||
|
{
|
||||||
|
m_items = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "a", EmitDefaultValue = false)]
|
||||||
|
private List<ExpressionData> m_items;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Sdk/Expressions/Data/BooleanExpressionData.cs
Normal file
58
src/Sdk/Expressions/Data/BooleanExpressionData.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
public sealed class BooleanExpressionData : ExpressionData, IBoolean
|
||||||
|
{
|
||||||
|
public BooleanExpressionData(Boolean value)
|
||||||
|
: base(ExpressionDataType.Boolean)
|
||||||
|
{
|
||||||
|
m_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
return new BooleanExpressionData(m_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
return (JToken)m_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override String ToString()
|
||||||
|
{
|
||||||
|
return m_value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean IBoolean.GetBoolean()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator Boolean(BooleanExpressionData data)
|
||||||
|
{
|
||||||
|
return data.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator BooleanExpressionData(Boolean data)
|
||||||
|
{
|
||||||
|
return new BooleanExpressionData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "b", EmitDefaultValue = false)]
|
||||||
|
private Boolean m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
[JsonObject]
|
||||||
|
public class CaseSensitiveDictionaryExpressionData : ExpressionData, IEnumerable<KeyValuePair<String, ExpressionData>>, IReadOnlyObject
|
||||||
|
{
|
||||||
|
public CaseSensitiveDictionaryExpressionData()
|
||||||
|
: base(ExpressionDataType.CaseSensitiveDictionary)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public Int32 Count => m_list?.Count ?? 0;
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public IEnumerable<String> Keys
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public IEnumerable<ExpressionData> Values
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<Object> IReadOnlyObject.Values
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<String, Int32> IndexLookup
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_indexLookup == null)
|
||||||
|
{
|
||||||
|
m_indexLookup = new Dictionary<String, Int32>(StringComparer.Ordinal);
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < m_list.Count; i++)
|
||||||
|
{
|
||||||
|
var pair = m_list[i];
|
||||||
|
m_indexLookup.Add(pair.Key, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_indexLookup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DictionaryExpressionDataPair> List
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list == null)
|
||||||
|
{
|
||||||
|
m_list = new List<DictionaryExpressionDataPair>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressionData this[String key]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var index = IndexLookup[key];
|
||||||
|
return m_list[index].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Existing
|
||||||
|
if (IndexLookup.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
key = m_list[index].Key; // preserve casing
|
||||||
|
m_list[index] = new DictionaryExpressionDataPair(key, value);
|
||||||
|
}
|
||||||
|
// New
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object IReadOnlyObject.this[String key]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var index = IndexLookup[key];
|
||||||
|
return m_list[index].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal KeyValuePair<String, ExpressionData> this[Int32 index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var pair = m_list[index];
|
||||||
|
return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(IEnumerable<KeyValuePair<String, ExpressionData>> pairs)
|
||||||
|
{
|
||||||
|
foreach (var pair in pairs)
|
||||||
|
{
|
||||||
|
Add(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(
|
||||||
|
String key,
|
||||||
|
ExpressionData value)
|
||||||
|
{
|
||||||
|
IndexLookup.Add(key, m_list?.Count ?? 0);
|
||||||
|
List.Add(new DictionaryExpressionDataPair(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
var result = new CaseSensitiveDictionaryExpressionData();
|
||||||
|
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
result.m_list = new List<DictionaryExpressionDataPair>(m_list.Count);
|
||||||
|
foreach (var item in m_list)
|
||||||
|
{
|
||||||
|
result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
var json = new JObject();
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_list)
|
||||||
|
{
|
||||||
|
json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean ContainsKey(String key)
|
||||||
|
{
|
||||||
|
return TryGetValue(key, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<KeyValuePair<String, ExpressionData>> GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IReadOnlyObject.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, Object>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean TryGetValue(
|
||||||
|
String key,
|
||||||
|
out ExpressionData value)
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0 &&
|
||||||
|
IndexLookup.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
value = m_list[index].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean IReadOnlyObject.TryGetValue(
|
||||||
|
String key,
|
||||||
|
out Object value)
|
||||||
|
{
|
||||||
|
if (TryGetValue(key, out ExpressionData data))
|
||||||
|
{
|
||||||
|
value = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[OnSerializing]
|
||||||
|
private void OnSerializing(StreamingContext context)
|
||||||
|
{
|
||||||
|
if (m_list?.Count == 0)
|
||||||
|
{
|
||||||
|
m_list = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataContract]
|
||||||
|
private sealed class DictionaryExpressionDataPair
|
||||||
|
{
|
||||||
|
public DictionaryExpressionDataPair(
|
||||||
|
String key,
|
||||||
|
ExpressionData value)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "k")]
|
||||||
|
public readonly String Key;
|
||||||
|
|
||||||
|
[DataMember(Name = "v")]
|
||||||
|
public readonly ExpressionData Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<String, Int32> m_indexLookup;
|
||||||
|
|
||||||
|
[DataMember(Name = "d", EmitDefaultValue = false)]
|
||||||
|
private List<DictionaryExpressionDataPair> m_list;
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/Sdk/Expressions/Data/DictionaryExpressionData.cs
Normal file
289
src/Sdk/Expressions/Data/DictionaryExpressionData.cs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
[JsonObject]
|
||||||
|
public class DictionaryExpressionData : ExpressionData, IEnumerable<KeyValuePair<String, ExpressionData>>, IReadOnlyObject
|
||||||
|
{
|
||||||
|
public DictionaryExpressionData()
|
||||||
|
: base(ExpressionDataType.Dictionary)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public Int32 Count => m_list?.Count ?? 0;
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public IEnumerable<String> Keys
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[IgnoreDataMember]
|
||||||
|
public IEnumerable<ExpressionData> Values
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<Object> IReadOnlyObject.Values
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<String, Int32> IndexLookup
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_indexLookup == null)
|
||||||
|
{
|
||||||
|
m_indexLookup = new Dictionary<String, Int32>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < m_list.Count; i++)
|
||||||
|
{
|
||||||
|
var pair = m_list[i];
|
||||||
|
m_indexLookup.Add(pair.Key, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_indexLookup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DictionaryExpressionDataPair> List
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_list == null)
|
||||||
|
{
|
||||||
|
m_list = new List<DictionaryExpressionDataPair>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressionData this[String key]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var index = IndexLookup[key];
|
||||||
|
return m_list[index].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Existing
|
||||||
|
if (IndexLookup.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
key = m_list[index].Key; // preserve casing
|
||||||
|
m_list[index] = new DictionaryExpressionDataPair(key, value);
|
||||||
|
}
|
||||||
|
// New
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object IReadOnlyObject.this[String key]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var index = IndexLookup[key];
|
||||||
|
return m_list[index].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal KeyValuePair<String, ExpressionData> this[Int32 index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var pair = m_list[index];
|
||||||
|
return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(IEnumerable<KeyValuePair<String, ExpressionData>> pairs)
|
||||||
|
{
|
||||||
|
foreach (var pair in pairs)
|
||||||
|
{
|
||||||
|
Add(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(
|
||||||
|
String key,
|
||||||
|
ExpressionData value)
|
||||||
|
{
|
||||||
|
IndexLookup.Add(key, m_list?.Count ?? 0);
|
||||||
|
List.Add(new DictionaryExpressionDataPair(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
var result = new DictionaryExpressionData();
|
||||||
|
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
result.m_list = new List<DictionaryExpressionDataPair>(m_list.Count);
|
||||||
|
foreach (var item in m_list)
|
||||||
|
{
|
||||||
|
result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
var json = new JObject();
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in m_list)
|
||||||
|
{
|
||||||
|
json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean ContainsKey(String key)
|
||||||
|
{
|
||||||
|
return TryGetValue(key, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<KeyValuePair<String, ExpressionData>> GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IReadOnlyObject.GetEnumerator()
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in m_list)
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<String, Object>(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean TryGetValue(
|
||||||
|
String key,
|
||||||
|
out ExpressionData value)
|
||||||
|
{
|
||||||
|
if (m_list?.Count > 0 &&
|
||||||
|
IndexLookup.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
value = m_list[index].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean IReadOnlyObject.TryGetValue(
|
||||||
|
String key,
|
||||||
|
out Object value)
|
||||||
|
{
|
||||||
|
if (TryGetValue(key, out ExpressionData data))
|
||||||
|
{
|
||||||
|
value = data;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[OnSerializing]
|
||||||
|
private void OnSerializing(StreamingContext context)
|
||||||
|
{
|
||||||
|
if (m_list?.Count == 0)
|
||||||
|
{
|
||||||
|
m_list = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataContract]
|
||||||
|
private sealed class DictionaryExpressionDataPair
|
||||||
|
{
|
||||||
|
public DictionaryExpressionDataPair(
|
||||||
|
String key,
|
||||||
|
ExpressionData value)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "k")]
|
||||||
|
public readonly String Key;
|
||||||
|
|
||||||
|
[DataMember(Name = "v")]
|
||||||
|
public readonly ExpressionData Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<String, Int32> m_indexLookup;
|
||||||
|
|
||||||
|
[DataMember(Name = "d", EmitDefaultValue = false)]
|
||||||
|
private List<DictionaryExpressionDataPair> m_list;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Sdk/Expressions/Data/ExpressionData.cs
Normal file
27
src/Sdk/Expressions/Data/ExpressionData.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all template tokens
|
||||||
|
/// </summary>
|
||||||
|
[DataContract]
|
||||||
|
[JsonConverter(typeof(ExpressionDataJsonConverter))]
|
||||||
|
public abstract class ExpressionData
|
||||||
|
{
|
||||||
|
protected ExpressionData(Int32 type)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "t", EmitDefaultValue = false)]
|
||||||
|
internal Int32 Type { get; }
|
||||||
|
|
||||||
|
public abstract ExpressionData Clone();
|
||||||
|
|
||||||
|
public abstract JToken ToJToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/Sdk/Expressions/Data/ExpressionDataExtensions.cs
Normal file
156
src/Sdk/Expressions/Data/ExpressionDataExtensions.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
public static class ExpressionDataExtensions
|
||||||
|
{
|
||||||
|
public static ArrayExpressionData AssertArray(
|
||||||
|
this ExpressionData value,
|
||||||
|
String objectDescription)
|
||||||
|
{
|
||||||
|
if (value is ArrayExpressionData array)
|
||||||
|
{
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(ArrayExpressionData)}' was expected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DictionaryExpressionData AssertDictionary(
|
||||||
|
this ExpressionData value,
|
||||||
|
String objectDescription)
|
||||||
|
{
|
||||||
|
if (value is DictionaryExpressionData dictionary)
|
||||||
|
{
|
||||||
|
return dictionary;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(DictionaryExpressionData)}' was expected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StringExpressionData AssertString(
|
||||||
|
this ExpressionData value,
|
||||||
|
String objectDescription)
|
||||||
|
{
|
||||||
|
if (value is StringExpressionData str)
|
||||||
|
{
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(StringExpressionData)}' was expected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all context data objects (depth first)
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<ExpressionData> Traverse(this ExpressionData value)
|
||||||
|
{
|
||||||
|
return Traverse(value, omitKeys: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all context data objects (depth first)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="omitKeys">If true, dictionary keys are omitted</param>
|
||||||
|
public static IEnumerable<ExpressionData> Traverse(
|
||||||
|
this ExpressionData value,
|
||||||
|
Boolean omitKeys)
|
||||||
|
{
|
||||||
|
yield return value;
|
||||||
|
|
||||||
|
if (value is ArrayExpressionData || value is DictionaryExpressionData)
|
||||||
|
{
|
||||||
|
var state = new TraversalState(null, value);
|
||||||
|
while (state != null)
|
||||||
|
{
|
||||||
|
if (state.MoveNext(omitKeys))
|
||||||
|
{
|
||||||
|
value = state.Current;
|
||||||
|
yield return value;
|
||||||
|
|
||||||
|
if (value is ArrayExpressionData || value is DictionaryExpressionData)
|
||||||
|
{
|
||||||
|
state = new TraversalState(state, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = state.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TraversalState
|
||||||
|
{
|
||||||
|
public TraversalState(
|
||||||
|
TraversalState parent,
|
||||||
|
ExpressionData data)
|
||||||
|
{
|
||||||
|
Parent = parent;
|
||||||
|
m_data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean MoveNext(Boolean omitKeys)
|
||||||
|
{
|
||||||
|
switch (m_data.Type)
|
||||||
|
{
|
||||||
|
case ExpressionDataType.Array:
|
||||||
|
var array = m_data.AssertArray("array");
|
||||||
|
if (++m_index < array.Count)
|
||||||
|
{
|
||||||
|
Current = array[m_index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ExpressionDataType.Dictionary:
|
||||||
|
var dictionary = m_data.AssertDictionary("dictionary");
|
||||||
|
|
||||||
|
// Return the value
|
||||||
|
if (m_isKey)
|
||||||
|
{
|
||||||
|
m_isKey = false;
|
||||||
|
Current = dictionary[m_index].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (++m_index < dictionary.Count)
|
||||||
|
{
|
||||||
|
// Skip the key, return the value
|
||||||
|
if (omitKeys)
|
||||||
|
{
|
||||||
|
m_isKey = false;
|
||||||
|
Current = dictionary[m_index].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the key
|
||||||
|
m_isKey = true;
|
||||||
|
Current = new StringExpressionData(dictionary[m_index].Key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Current = null;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected {nameof(ExpressionData)} type '{m_data.Type}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExpressionData m_data;
|
||||||
|
private Int32 m_index = -1;
|
||||||
|
private Boolean m_isKey;
|
||||||
|
public ExpressionData Current;
|
||||||
|
public TraversalState Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs
Normal file
199
src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// JSON serializer for ExpressionData objects
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ExpressionDataJsonConverter : JsonConverter
|
||||||
|
{
|
||||||
|
public override Boolean CanWrite
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Boolean CanConvert(Type objectType)
|
||||||
|
{
|
||||||
|
return typeof(ExpressionData).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Object ReadJson(
|
||||||
|
JsonReader reader,
|
||||||
|
Type objectType,
|
||||||
|
Object existingValue,
|
||||||
|
JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
switch (reader.TokenType)
|
||||||
|
{
|
||||||
|
case JsonToken.String:
|
||||||
|
return new StringExpressionData(reader.Value.ToString());
|
||||||
|
|
||||||
|
case JsonToken.Boolean:
|
||||||
|
return new BooleanExpressionData((Boolean)reader.Value);
|
||||||
|
|
||||||
|
case JsonToken.Float:
|
||||||
|
return new NumberExpressionData((Double)reader.Value);
|
||||||
|
|
||||||
|
case JsonToken.Integer:
|
||||||
|
return new NumberExpressionData((Double)(Int64)reader.Value);
|
||||||
|
|
||||||
|
case JsonToken.StartObject:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Int32? type = null;
|
||||||
|
JObject value = JObject.Load(reader);
|
||||||
|
if (!value.TryGetValue("t", StringComparison.OrdinalIgnoreCase, out JToken typeValue))
|
||||||
|
{
|
||||||
|
type = ExpressionDataType.String;
|
||||||
|
}
|
||||||
|
else if (typeValue.Type == JTokenType.Integer)
|
||||||
|
{
|
||||||
|
type = (Int32)typeValue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return existingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object newValue = null;
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case ExpressionDataType.String:
|
||||||
|
newValue = new StringExpressionData(null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExpressionDataType.Array:
|
||||||
|
newValue = new ArrayExpressionData();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExpressionDataType.Dictionary:
|
||||||
|
newValue = new DictionaryExpressionData();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExpressionDataType.Boolean:
|
||||||
|
newValue = new BooleanExpressionData(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExpressionDataType.Number:
|
||||||
|
newValue = new NumberExpressionData(0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ExpressionDataType.CaseSensitiveDictionary:
|
||||||
|
newValue = new CaseSensitiveDictionaryExpressionData();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected {nameof(ExpressionDataType)} '{type}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
using JsonReader objectReader = value.CreateReader();
|
||||||
|
serializer.Populate(objectReader, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void WriteJson(
|
||||||
|
JsonWriter writer,
|
||||||
|
Object value,
|
||||||
|
JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
if (Object.ReferenceEquals(value, null))
|
||||||
|
{
|
||||||
|
writer.WriteNull();
|
||||||
|
}
|
||||||
|
else if (value is StringExpressionData stringData)
|
||||||
|
{
|
||||||
|
writer.WriteValue(stringData.Value);
|
||||||
|
}
|
||||||
|
else if (value is BooleanExpressionData boolData)
|
||||||
|
{
|
||||||
|
writer.WriteValue(boolData.Value);
|
||||||
|
}
|
||||||
|
else if (value is NumberExpressionData numberData)
|
||||||
|
{
|
||||||
|
writer.WriteValue(numberData.Value);
|
||||||
|
}
|
||||||
|
else if (value is ArrayExpressionData arrayData)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WritePropertyName("t");
|
||||||
|
writer.WriteValue(ExpressionDataType.Array);
|
||||||
|
if (arrayData.Count > 0)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("a");
|
||||||
|
writer.WriteStartArray();
|
||||||
|
foreach (var item in arrayData)
|
||||||
|
{
|
||||||
|
serializer.Serialize(writer, item);
|
||||||
|
}
|
||||||
|
writer.WriteEndArray();
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
else if (value is DictionaryExpressionData dictionaryData)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WritePropertyName("t");
|
||||||
|
writer.WriteValue(ExpressionDataType.Dictionary);
|
||||||
|
if (dictionaryData.Count > 0)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("d");
|
||||||
|
writer.WriteStartArray();
|
||||||
|
foreach (var pair in dictionaryData)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WritePropertyName("k");
|
||||||
|
writer.WriteValue(pair.Key);
|
||||||
|
writer.WritePropertyName("v");
|
||||||
|
serializer.Serialize(writer, pair.Value);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
writer.WriteEndArray();
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
else if (value is CaseSensitiveDictionaryExpressionData caseSensitiveDictionaryData)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WritePropertyName("t");
|
||||||
|
writer.WriteValue(ExpressionDataType.CaseSensitiveDictionary);
|
||||||
|
if (caseSensitiveDictionaryData.Count > 0)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("d");
|
||||||
|
writer.WriteStartArray();
|
||||||
|
foreach (var pair in caseSensitiveDictionaryData)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WritePropertyName("k");
|
||||||
|
writer.WriteValue(pair.Key);
|
||||||
|
writer.WritePropertyName("v");
|
||||||
|
serializer.Serialize(writer, pair.Value);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
writer.WriteEndArray();
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Unexpected type '{value.GetType().Name}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Sdk/Expressions/Data/ExpressionDataType.cs
Normal file
19
src/Sdk/Expressions/Data/ExpressionDataType.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
internal static class ExpressionDataType
|
||||||
|
{
|
||||||
|
internal const Int32 String = 0;
|
||||||
|
|
||||||
|
internal const Int32 Array = 1;
|
||||||
|
|
||||||
|
internal const Int32 Dictionary = 2;
|
||||||
|
|
||||||
|
internal const Int32 Boolean = 3;
|
||||||
|
|
||||||
|
internal const Int32 Number = 4;
|
||||||
|
|
||||||
|
internal const Int32 CaseSensitiveDictionary = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Sdk/Expressions/Data/JTokenExtensions.cs
Normal file
64
src/Sdk/Expressions/Data/JTokenExtensions.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
public static class JTokenExtensions
|
||||||
|
{
|
||||||
|
public static ExpressionData ToExpressionData(this JToken value)
|
||||||
|
{
|
||||||
|
return value.ToExpressionData(1, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExpressionData ToExpressionData(
|
||||||
|
this JToken value,
|
||||||
|
Int32 depth,
|
||||||
|
Int32 maxDepth)
|
||||||
|
{
|
||||||
|
if (depth < maxDepth)
|
||||||
|
{
|
||||||
|
if (value.Type == JTokenType.String)
|
||||||
|
{
|
||||||
|
return new StringExpressionData((String)value);
|
||||||
|
}
|
||||||
|
else if (value.Type == JTokenType.Boolean)
|
||||||
|
{
|
||||||
|
return new BooleanExpressionData((Boolean)value);
|
||||||
|
}
|
||||||
|
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
|
||||||
|
{
|
||||||
|
return new NumberExpressionData((Double)value);
|
||||||
|
}
|
||||||
|
else if (value.Type == JTokenType.Object)
|
||||||
|
{
|
||||||
|
var subContext = new DictionaryExpressionData();
|
||||||
|
var obj = (JObject)value;
|
||||||
|
foreach (var property in obj.Properties())
|
||||||
|
{
|
||||||
|
subContext[property.Name] = ToExpressionData(property.Value, depth + 1, maxDepth);
|
||||||
|
}
|
||||||
|
return subContext;
|
||||||
|
}
|
||||||
|
else if (value.Type == JTokenType.Array)
|
||||||
|
{
|
||||||
|
var arrayContext = new ArrayExpressionData();
|
||||||
|
var arr = (JArray)value;
|
||||||
|
foreach (var element in arr)
|
||||||
|
{
|
||||||
|
arrayContext.Add(ToExpressionData(element, depth + 1, maxDepth));
|
||||||
|
}
|
||||||
|
return arrayContext;
|
||||||
|
}
|
||||||
|
else if (value.Type == JTokenType.Null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't understand the type or have reached our max, return as string
|
||||||
|
return new StringExpressionData(value.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/Sdk/Expressions/Data/NumberExpressionData.cs
Normal file
78
src/Sdk/Expressions/Data/NumberExpressionData.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
public sealed class NumberExpressionData : ExpressionData, INumber
|
||||||
|
{
|
||||||
|
public NumberExpressionData(Double value)
|
||||||
|
: base(ExpressionDataType.Number)
|
||||||
|
{
|
||||||
|
m_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
return new NumberExpressionData(m_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
if (Double.IsNaN(m_value) || m_value == Double.PositiveInfinity || m_value == Double.NegativeInfinity)
|
||||||
|
{
|
||||||
|
return (JToken)m_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var floored = Math.Floor(m_value);
|
||||||
|
if (m_value == floored && m_value <= (Double)Int32.MaxValue && m_value >= (Double)Int32.MinValue)
|
||||||
|
{
|
||||||
|
var flooredInt = (Int32)floored;
|
||||||
|
return (JToken)flooredInt;
|
||||||
|
}
|
||||||
|
else if (m_value == floored && m_value <= (Double)Int64.MaxValue && m_value >= (Double)Int64.MinValue)
|
||||||
|
{
|
||||||
|
var flooredInt = (Int64)floored;
|
||||||
|
return (JToken)flooredInt;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return (JToken)m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override String ToString()
|
||||||
|
{
|
||||||
|
return m_value.ToString("G15", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
Double INumber.GetNumber()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator Double(NumberExpressionData data)
|
||||||
|
{
|
||||||
|
return data.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator NumberExpressionData(Double data)
|
||||||
|
{
|
||||||
|
return new NumberExpressionData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "n", EmitDefaultValue = false)]
|
||||||
|
private Double m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Sdk/Expressions/Data/StringExpressionData.cs
Normal file
74
src/Sdk/Expressions/Data/StringExpressionData.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Data
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
public sealed class StringExpressionData : ExpressionData, IString
|
||||||
|
{
|
||||||
|
public StringExpressionData(String value)
|
||||||
|
: base(ExpressionDataType.String)
|
||||||
|
{
|
||||||
|
m_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String Value
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (m_value == null)
|
||||||
|
{
|
||||||
|
m_value = String.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExpressionData Clone()
|
||||||
|
{
|
||||||
|
return new StringExpressionData(m_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override JToken ToJToken()
|
||||||
|
{
|
||||||
|
return (JToken)m_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String IString.GetString()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override String ToString()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator String(StringExpressionData data)
|
||||||
|
{
|
||||||
|
return data.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator StringExpressionData(String data)
|
||||||
|
{
|
||||||
|
return new StringExpressionData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[OnSerializing]
|
||||||
|
private void OnSerializing(StreamingContext context)
|
||||||
|
{
|
||||||
|
if (m_value?.Length == 0)
|
||||||
|
{
|
||||||
|
m_value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "s", EmitDefaultValue = false)]
|
||||||
|
private String m_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Sdk/Expressions/EvaluationOptions.cs
Normal file
50
src/Sdk/Expressions/EvaluationOptions.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public sealed class EvaluationOptions
|
||||||
|
{
|
||||||
|
public EvaluationOptions()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public EvaluationOptions(EvaluationOptions copy)
|
||||||
|
{
|
||||||
|
if (copy != null)
|
||||||
|
{
|
||||||
|
MaxMemory = copy.MaxMemory;
|
||||||
|
MaxCacheMemory = copy.MaxCacheMemory;
|
||||||
|
StrictJsonParsing = copy.StrictJsonParsing;
|
||||||
|
AlwaysTraceExpanded = copy.AlwaysTraceExpanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum memory (in bytes) allowed during expression evaluation.
|
||||||
|
/// Memory is tracked across the entire expression tree evaluation to protect against DOS attacks.
|
||||||
|
/// Default is 1 MB (1048576 bytes) if not specified.
|
||||||
|
/// </summary>
|
||||||
|
public Int32 MaxMemory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum memory (in bytes) allowed for caching expanded expression results during tracing.
|
||||||
|
/// When exceeded, the cache is cleared and expressions may not be fully expanded in trace output.
|
||||||
|
/// Default is 1 MB (1048576 bytes) if not specified.
|
||||||
|
/// </summary>
|
||||||
|
public Int32 MaxCacheMemory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to enforce strict JSON parsing in the fromJson function.
|
||||||
|
/// When true, rejects JSON with comments, trailing commas, single quotes, and other non-standard features.
|
||||||
|
/// Default is false if not specified.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean StrictJsonParsing { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to always include the expanded expression in trace output.
|
||||||
|
/// When true, the expanded expression is always traced even if it matches the original expression or result.
|
||||||
|
/// Default is false if not specified.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AlwaysTraceExpanded { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
459
src/Sdk/Expressions/EvaluationResult.cs
Normal file
459
src/Sdk/Expressions/EvaluationResult.cs
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public sealed class EvaluationResult
|
||||||
|
{
|
||||||
|
internal EvaluationResult(
|
||||||
|
EvaluationContext context,
|
||||||
|
Int32 level,
|
||||||
|
Object val,
|
||||||
|
ValueKind kind,
|
||||||
|
Object raw)
|
||||||
|
: this(context, level, val, kind, raw, false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal EvaluationResult(
|
||||||
|
EvaluationContext context,
|
||||||
|
Int32 level,
|
||||||
|
Object val,
|
||||||
|
ValueKind kind,
|
||||||
|
Object raw,
|
||||||
|
Boolean omitTracing)
|
||||||
|
{
|
||||||
|
m_level = level;
|
||||||
|
Value = val;
|
||||||
|
Kind = kind;
|
||||||
|
Raw = raw;
|
||||||
|
m_omitTracing = omitTracing;
|
||||||
|
|
||||||
|
if (!omitTracing)
|
||||||
|
{
|
||||||
|
TraceValue(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When an interface converter is applied to the node result, raw contains the original value
|
||||||
|
/// </summary>
|
||||||
|
public Object Raw { get; }
|
||||||
|
|
||||||
|
public Object Value { get; }
|
||||||
|
|
||||||
|
public Boolean IsFalsy
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
return true;
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
var boolean = (Boolean)Value;
|
||||||
|
return !boolean;
|
||||||
|
case ValueKind.Number:
|
||||||
|
var number = (Double)Value;
|
||||||
|
return number == 0d || Double.IsNaN(number);
|
||||||
|
case ValueKind.String:
|
||||||
|
var str = (String)Value;
|
||||||
|
return String.Equals(str, String.Empty, StringComparison.Ordinal);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean IsPrimitive => ExpressionUtility.IsPrimitive(Kind);
|
||||||
|
|
||||||
|
public Boolean IsTruthy => !IsFalsy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractEqual(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return AbstractEqual(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractGreaterThan(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return AbstractGreaterThan(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractGreaterThanOrEqual(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return AbstractEqual(Value, right.Value) || AbstractGreaterThan(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractLessThan(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return AbstractLessThan(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractLessThanOrEqual(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return AbstractEqual(Value, right.Value) || AbstractLessThan(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
public Boolean AbstractNotEqual(EvaluationResult right)
|
||||||
|
{
|
||||||
|
return !AbstractEqual(Value, right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double ConvertToNumber()
|
||||||
|
{
|
||||||
|
return ConvertToNumber(Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String ConvertToString()
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
return String.Empty;
|
||||||
|
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
return ((Boolean)Value) ? ExpressionConstants.True : ExpressionConstants.False;
|
||||||
|
|
||||||
|
case ValueKind.Number:
|
||||||
|
if ((Double)Value == -0)
|
||||||
|
{
|
||||||
|
// .NET Core 3.0 now prints negative zero as -0, so we need this to keep out behavior consistent
|
||||||
|
return ((Double)0).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
return ((Double)Value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
case ValueKind.String:
|
||||||
|
return Value as String;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Kind.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean TryGetCollectionInterface(out Object collection)
|
||||||
|
{
|
||||||
|
if ((Kind == ValueKind.Object || Kind == ValueKind.Array))
|
||||||
|
{
|
||||||
|
var obj = Value;
|
||||||
|
if (obj is IReadOnlyObject)
|
||||||
|
{
|
||||||
|
collection = obj;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (obj is IReadOnlyArray)
|
||||||
|
{
|
||||||
|
collection = obj;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collection = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Useful for working with values that are not the direct evaluation result of a parameter.
|
||||||
|
/// This allows ExpressionNode authors to leverage the coercion and comparison functions
|
||||||
|
/// for any values.
|
||||||
|
///
|
||||||
|
/// Also note, the value will be canonicalized (for example numeric types converted to double) and any
|
||||||
|
/// matching interfaces applied.
|
||||||
|
/// </summary>
|
||||||
|
public static EvaluationResult CreateIntermediateResult(
|
||||||
|
EvaluationContext context,
|
||||||
|
Object obj)
|
||||||
|
{
|
||||||
|
var val = ExpressionUtility.ConvertToCanonicalValue(obj, out ValueKind kind, out Object raw);
|
||||||
|
return new EvaluationResult(context, 0, val, kind, raw, omitTracing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TraceValue(EvaluationContext context)
|
||||||
|
{
|
||||||
|
if (!m_omitTracing)
|
||||||
|
{
|
||||||
|
TraceValue(context, Value, Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TraceValue(
|
||||||
|
EvaluationContext context,
|
||||||
|
Object val,
|
||||||
|
ValueKind kind)
|
||||||
|
{
|
||||||
|
if (!m_omitTracing)
|
||||||
|
{
|
||||||
|
TraceVerbose(context, String.Concat("=> ", ExpressionUtility.FormatValue(context?.SecretMasker, val, kind)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TraceVerbose(
|
||||||
|
EvaluationContext context,
|
||||||
|
String message)
|
||||||
|
{
|
||||||
|
if (!m_omitTracing)
|
||||||
|
{
|
||||||
|
context?.Trace.Verbose(String.Empty.PadLeft(m_level * 2, '.') + (message ?? String.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
private static Boolean AbstractEqual(
|
||||||
|
Object canonicalLeftValue,
|
||||||
|
Object canonicalRightValue)
|
||||||
|
{
|
||||||
|
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||||
|
|
||||||
|
// Same kind
|
||||||
|
if (leftKind == rightKind)
|
||||||
|
{
|
||||||
|
switch (leftKind)
|
||||||
|
{
|
||||||
|
// Null, Null
|
||||||
|
case ValueKind.Null:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Number, Number
|
||||||
|
case ValueKind.Number:
|
||||||
|
var leftDouble = (Double)canonicalLeftValue;
|
||||||
|
var rightDouble = (Double)canonicalRightValue;
|
||||||
|
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return leftDouble == rightDouble;
|
||||||
|
|
||||||
|
// String, String
|
||||||
|
case ValueKind.String:
|
||||||
|
var leftString = (String)canonicalLeftValue;
|
||||||
|
var rightString = (String)canonicalRightValue;
|
||||||
|
return String.Equals(leftString, rightString, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Boolean, Boolean
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||||
|
var rightBoolean = (Boolean)canonicalRightValue;
|
||||||
|
return leftBoolean == rightBoolean;
|
||||||
|
|
||||||
|
// Object, Object
|
||||||
|
case ValueKind.Object:
|
||||||
|
case ValueKind.Array:
|
||||||
|
return Object.ReferenceEquals(canonicalLeftValue, canonicalRightValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
private static Boolean AbstractGreaterThan(
|
||||||
|
Object canonicalLeftValue,
|
||||||
|
Object canonicalRightValue)
|
||||||
|
{
|
||||||
|
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||||
|
|
||||||
|
// Same kind
|
||||||
|
if (leftKind == rightKind)
|
||||||
|
{
|
||||||
|
switch (leftKind)
|
||||||
|
{
|
||||||
|
// Number, Number
|
||||||
|
case ValueKind.Number:
|
||||||
|
var leftDouble = (Double)canonicalLeftValue;
|
||||||
|
var rightDouble = (Double)canonicalRightValue;
|
||||||
|
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return leftDouble > rightDouble;
|
||||||
|
|
||||||
|
// String, String
|
||||||
|
case ValueKind.String:
|
||||||
|
var leftString = (String)canonicalLeftValue;
|
||||||
|
var rightString = (String)canonicalRightValue;
|
||||||
|
return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) > 0;
|
||||||
|
|
||||||
|
// Boolean, Boolean
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||||
|
var rightBoolean = (Boolean)canonicalRightValue;
|
||||||
|
return leftBoolean && !rightBoolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||||
|
/// </summary>
|
||||||
|
private static Boolean AbstractLessThan(
|
||||||
|
Object canonicalLeftValue,
|
||||||
|
Object canonicalRightValue)
|
||||||
|
{
|
||||||
|
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||||
|
|
||||||
|
// Same kind
|
||||||
|
if (leftKind == rightKind)
|
||||||
|
{
|
||||||
|
switch (leftKind)
|
||||||
|
{
|
||||||
|
// Number, Number
|
||||||
|
case ValueKind.Number:
|
||||||
|
var leftDouble = (Double)canonicalLeftValue;
|
||||||
|
var rightDouble = (Double)canonicalRightValue;
|
||||||
|
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return leftDouble < rightDouble;
|
||||||
|
|
||||||
|
// String, String
|
||||||
|
case ValueKind.String:
|
||||||
|
var leftString = (String)canonicalLeftValue;
|
||||||
|
var rightString = (String)canonicalRightValue;
|
||||||
|
return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) < 0;
|
||||||
|
|
||||||
|
// Boolean, Boolean
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||||
|
var rightBoolean = (Boolean)canonicalRightValue;
|
||||||
|
return !leftBoolean && rightBoolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||||
|
/// Except objects are not coerced to primitives.
|
||||||
|
private static void CoerceTypes(
|
||||||
|
ref Object canonicalLeftValue,
|
||||||
|
ref Object canonicalRightValue,
|
||||||
|
out ValueKind leftKind,
|
||||||
|
out ValueKind rightKind)
|
||||||
|
{
|
||||||
|
leftKind = GetKind(canonicalLeftValue);
|
||||||
|
rightKind = GetKind(canonicalRightValue);
|
||||||
|
|
||||||
|
// Same kind
|
||||||
|
if (leftKind == rightKind)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
// Number, String
|
||||||
|
else if (leftKind == ValueKind.Number && rightKind == ValueKind.String)
|
||||||
|
{
|
||||||
|
canonicalRightValue = ConvertToNumber(canonicalRightValue);
|
||||||
|
rightKind = ValueKind.Number;
|
||||||
|
}
|
||||||
|
// String, Number
|
||||||
|
else if (leftKind == ValueKind.String && rightKind == ValueKind.Number)
|
||||||
|
{
|
||||||
|
canonicalLeftValue = ConvertToNumber(canonicalLeftValue);
|
||||||
|
leftKind = ValueKind.Number;
|
||||||
|
}
|
||||||
|
// Boolean|Null, Any
|
||||||
|
else if (leftKind == ValueKind.Boolean || leftKind == ValueKind.Null)
|
||||||
|
{
|
||||||
|
canonicalLeftValue = ConvertToNumber(canonicalLeftValue);
|
||||||
|
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind);
|
||||||
|
}
|
||||||
|
// Any, Boolean|Null
|
||||||
|
else if (rightKind == ValueKind.Boolean || rightKind == ValueKind.Null)
|
||||||
|
{
|
||||||
|
canonicalRightValue = ConvertToNumber(canonicalRightValue);
|
||||||
|
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For primitives, follows the Javascript rules (the Number function in Javascript). Otherwise NaN.
|
||||||
|
/// </summary>
|
||||||
|
private static Double ConvertToNumber(Object canonicalValue)
|
||||||
|
{
|
||||||
|
var kind = GetKind(canonicalValue);
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
return 0d;
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
return (Boolean)canonicalValue ? 1d : 0d;
|
||||||
|
case ValueKind.Number:
|
||||||
|
return (Double)canonicalValue;
|
||||||
|
case ValueKind.String:
|
||||||
|
return ExpressionUtility.ParseNumber(canonicalValue as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValueKind GetKind(Object canonicalValue)
|
||||||
|
{
|
||||||
|
if (Object.ReferenceEquals(canonicalValue, null))
|
||||||
|
{
|
||||||
|
return ValueKind.Null;
|
||||||
|
}
|
||||||
|
else if (canonicalValue is Boolean)
|
||||||
|
{
|
||||||
|
return ValueKind.Boolean;
|
||||||
|
}
|
||||||
|
else if (canonicalValue is Double)
|
||||||
|
{
|
||||||
|
return ValueKind.Number;
|
||||||
|
}
|
||||||
|
else if (canonicalValue is String)
|
||||||
|
{
|
||||||
|
return ValueKind.String;
|
||||||
|
}
|
||||||
|
else if (canonicalValue is IReadOnlyObject)
|
||||||
|
{
|
||||||
|
return ValueKind.Object;
|
||||||
|
}
|
||||||
|
else if (canonicalValue is IReadOnlyArray)
|
||||||
|
{
|
||||||
|
return ValueKind.Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueKind.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Int32 m_level;
|
||||||
|
private readonly Boolean m_omitTracing;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Sdk/Expressions/ExpressionConstants.cs
Normal file
62
src/Sdk/Expressions/ExpressionConstants.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using GitHub.Actions.Expressions.Sdk.Functions;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public static class ExpressionConstants
|
||||||
|
{
|
||||||
|
static ExpressionConstants()
|
||||||
|
{
|
||||||
|
AddFunction<Contains>("contains", 2, 2);
|
||||||
|
AddFunction<EndsWith>("endsWith", 2, 2);
|
||||||
|
AddFunction<Format>("format", 1, Byte.MaxValue);
|
||||||
|
AddFunction<Join>("join", 1, 2);
|
||||||
|
AddFunction<StartsWith>("startsWith", 2, 2);
|
||||||
|
AddFunction<ToJson>("toJson", 1, 1);
|
||||||
|
AddFunction<FromJson>("fromJson", 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFunction<T>(String name, Int32 minParameters, Int32 maxParameters)
|
||||||
|
where T : Function, new()
|
||||||
|
{
|
||||||
|
s_wellKnownFunctions.Add(name, new FunctionInfo<T>(name, minParameters, maxParameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static readonly String False = "false";
|
||||||
|
internal static readonly String Infinity = "Infinity";
|
||||||
|
internal static readonly Int32 MaxDepth = 50;
|
||||||
|
internal static readonly Int32 MaxLength = 21000; // Under 85,000 large object heap threshold, even if .NET switches to UTF-32
|
||||||
|
internal static readonly String NaN = "NaN";
|
||||||
|
internal static readonly String NegativeInfinity = "-Infinity";
|
||||||
|
public static readonly String Null = "null";
|
||||||
|
internal static readonly String NumberFormat = "G15";
|
||||||
|
internal static readonly String True = "true";
|
||||||
|
private static readonly Dictionary<String, IFunctionInfo> s_wellKnownFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public static readonly IReadOnlyDictionary<String, IFunctionInfo> WellKnownFunctions = new ReadOnlyDictionary<String, IFunctionInfo>(s_wellKnownFunctions);
|
||||||
|
|
||||||
|
// Punctuation
|
||||||
|
internal const Char StartGroup = '('; // logical grouping
|
||||||
|
internal const Char StartIndex = '[';
|
||||||
|
public static readonly Char StartParameter = '('; // function call
|
||||||
|
internal const Char EndGroup = ')'; // logical grouping
|
||||||
|
internal const Char EndIndex = ']';
|
||||||
|
public static readonly Char EndParameter = ')'; // function calll
|
||||||
|
internal const Char Separator = ',';
|
||||||
|
internal const Char Dereference = '.';
|
||||||
|
internal const Char Wildcard = '*';
|
||||||
|
|
||||||
|
// Operators
|
||||||
|
internal const String Not = "!";
|
||||||
|
internal const String NotEqual = "!=";
|
||||||
|
internal const String GreaterThan = ">";
|
||||||
|
internal const String GreaterThanOrEqual = ">=";
|
||||||
|
internal const String LessThan = "<";
|
||||||
|
internal const String LessThanOrEqual = "<=";
|
||||||
|
internal const String Equal = "==";
|
||||||
|
internal const String And = "&&";
|
||||||
|
internal const String Or = "||";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Sdk/Expressions/ExpressionException.cs
Normal file
21
src/Sdk/Expressions/ExpressionException.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public class ExpressionException : Exception
|
||||||
|
{
|
||||||
|
internal ExpressionException(ISecretMasker secretMasker, String message)
|
||||||
|
{
|
||||||
|
if (secretMasker != null)
|
||||||
|
{
|
||||||
|
message = secretMasker.MaskSecrets(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override String Message => m_message;
|
||||||
|
|
||||||
|
private readonly String m_message;
|
||||||
|
}
|
||||||
|
}
|
||||||
471
src/Sdk/Expressions/ExpressionParser.cs
Normal file
471
src/Sdk/Expressions/ExpressionParser.cs
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Actions.Expressions.Sdk.Operators;
|
||||||
|
using GitHub.Actions.Expressions.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using GitHub.Actions.Expressions.Sdk.Functions;
|
||||||
|
|
||||||
|
public sealed class ExpressionParser
|
||||||
|
{
|
||||||
|
public IExpressionNode CreateTree(
|
||||||
|
String expression,
|
||||||
|
ITraceWriter trace,
|
||||||
|
IEnumerable<INamedValueInfo> namedValues,
|
||||||
|
IEnumerable<IFunctionInfo> functions)
|
||||||
|
{
|
||||||
|
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||||
|
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||||
|
return CreateTree(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IExpressionNode ValidateSyntax(
|
||||||
|
String expression,
|
||||||
|
ITraceWriter trace)
|
||||||
|
{
|
||||||
|
var context = new ParseContext(expression, trace, namedValues: null, functions: null, allowUnknownKeywords: true);
|
||||||
|
context.Trace.Info($"Validating expression syntax: <{expression}>");
|
||||||
|
return CreateTree(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IExpressionNode CreateTree(ParseContext context)
|
||||||
|
{
|
||||||
|
// Push the tokens
|
||||||
|
while (context.LexicalAnalyzer.TryGetNextToken(ref context.Token))
|
||||||
|
{
|
||||||
|
// Unexpected
|
||||||
|
if (context.Token.Kind == TokenKind.Unexpected)
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.UnexpectedSymbol, context.Token, context.Expression);
|
||||||
|
}
|
||||||
|
// Operator
|
||||||
|
else if (context.Token.IsOperator)
|
||||||
|
{
|
||||||
|
PushOperator(context);
|
||||||
|
}
|
||||||
|
// Operand
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PushOperand(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.LastToken = context.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tokens
|
||||||
|
if (context.LastToken == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check unexpected end of expression
|
||||||
|
if (context.Operators.Count > 0)
|
||||||
|
{
|
||||||
|
var unexpectedLastToken = false;
|
||||||
|
switch (context.LastToken.Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
// Legal
|
||||||
|
break;
|
||||||
|
case TokenKind.Function:
|
||||||
|
// Illegal
|
||||||
|
unexpectedLastToken = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
unexpectedLastToken = context.LastToken.IsOperator;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unexpectedLastToken || context.LexicalAnalyzer.UnclosedTokens.Any())
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.UnexpectedEndOfExpression, context.LastToken, context.Expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush operators
|
||||||
|
while (context.Operators.Count > 0)
|
||||||
|
{
|
||||||
|
FlushTopOperator(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max depth
|
||||||
|
var result = context.Operands.Single();
|
||||||
|
CheckMaxDepth(context, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PushOperand(ParseContext context)
|
||||||
|
{
|
||||||
|
// Create the node
|
||||||
|
var node = default(ExpressionNode);
|
||||||
|
switch (context.Token.Kind)
|
||||||
|
{
|
||||||
|
// Function
|
||||||
|
case TokenKind.Function:
|
||||||
|
var function = context.Token.RawValue;
|
||||||
|
if (TryGetFunctionInfo(context, function, out var functionInfo))
|
||||||
|
{
|
||||||
|
node = functionInfo.CreateNode();
|
||||||
|
node.Name = function;
|
||||||
|
}
|
||||||
|
else if (context.AllowUnknownKeywords)
|
||||||
|
{
|
||||||
|
node = new NoOperation();
|
||||||
|
node.Name = function;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.UnrecognizedFunction, context.Token, context.Expression);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Named-value
|
||||||
|
case TokenKind.NamedValue:
|
||||||
|
var name = context.Token.RawValue;
|
||||||
|
if (context.ExtensionNamedValues.TryGetValue(name, out var namedValueInfo))
|
||||||
|
{
|
||||||
|
node = namedValueInfo.CreateNode();
|
||||||
|
node.Name = name;
|
||||||
|
|
||||||
|
}
|
||||||
|
else if (context.AllowUnknownKeywords)
|
||||||
|
{
|
||||||
|
node = new NoOperationNamedValue();
|
||||||
|
node.Name = name;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.UnrecognizedNamedValue, context.Token, context.Expression);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Otherwise simple
|
||||||
|
default:
|
||||||
|
node = context.Token.ToNode();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the operand
|
||||||
|
context.Operands.Push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PushOperator(ParseContext context)
|
||||||
|
{
|
||||||
|
// Flush higher or equal precedence
|
||||||
|
if (context.Token.Associativity == Associativity.LeftToRight)
|
||||||
|
{
|
||||||
|
var precedence = context.Token.Precedence;
|
||||||
|
while (context.Operators.Count > 0)
|
||||||
|
{
|
||||||
|
var topOperator = context.Operators.Peek();
|
||||||
|
if (precedence <= topOperator.Precedence &&
|
||||||
|
topOperator.Kind != TokenKind.StartGroup && // Unless top is "(" logical grouping
|
||||||
|
topOperator.Kind != TokenKind.StartIndex && // or unless top is "["
|
||||||
|
topOperator.Kind != TokenKind.StartParameters &&// or unless top is "(" function call
|
||||||
|
topOperator.Kind != TokenKind.Separator) // or unless top is ","
|
||||||
|
{
|
||||||
|
FlushTopOperator(context);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the operator
|
||||||
|
context.Operators.Push(context.Token);
|
||||||
|
|
||||||
|
// Process closing operators now, since context.LastToken is required
|
||||||
|
// to accurately process TokenKind.EndParameters
|
||||||
|
switch (context.Token.Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
FlushTopOperator(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FlushTopOperator(ParseContext context)
|
||||||
|
{
|
||||||
|
// Special handling for closing operators
|
||||||
|
switch (context.Operators.Peek().Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
FlushTopEndIndex(context);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
FlushTopEndGroup(context);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
FlushTopEndParameters(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop the operator
|
||||||
|
var @operator = context.Operators.Pop();
|
||||||
|
|
||||||
|
// Create the node
|
||||||
|
var node = (Container)@operator.ToNode();
|
||||||
|
|
||||||
|
// Pop the operands, add to the node
|
||||||
|
var operands = PopOperands(context, @operator.OperandCount);
|
||||||
|
foreach (var operand in operands)
|
||||||
|
{
|
||||||
|
// Flatten nested And
|
||||||
|
if (node is And)
|
||||||
|
{
|
||||||
|
if (operand is And nestedAnd)
|
||||||
|
{
|
||||||
|
foreach (var nestedParameter in nestedAnd.Parameters)
|
||||||
|
{
|
||||||
|
node.AddParameter(nestedParameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flatten nested Or
|
||||||
|
else if (node is Or)
|
||||||
|
{
|
||||||
|
if (operand is Or nestedOr)
|
||||||
|
{
|
||||||
|
foreach (var nestedParameter in nestedOr.Parameters)
|
||||||
|
{
|
||||||
|
node.AddParameter(nestedParameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.AddParameter(operand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the node to the operand stack
|
||||||
|
context.Operands.Push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flushes the ")" logical grouping operator
|
||||||
|
/// </summary>
|
||||||
|
private static void FlushTopEndGroup(ParseContext context)
|
||||||
|
{
|
||||||
|
// Pop the operators
|
||||||
|
PopOperator(context, TokenKind.EndGroup); // ")" logical grouping
|
||||||
|
PopOperator(context, TokenKind.StartGroup); // "(" logical grouping
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flushes the "]" operator
|
||||||
|
/// </summary>
|
||||||
|
private static void FlushTopEndIndex(ParseContext context)
|
||||||
|
{
|
||||||
|
// Pop the operators
|
||||||
|
PopOperator(context, TokenKind.EndIndex); // "]"
|
||||||
|
var @operator = PopOperator(context, TokenKind.StartIndex); // "["
|
||||||
|
|
||||||
|
// Create the node
|
||||||
|
var node = (Container)@operator.ToNode();
|
||||||
|
|
||||||
|
// Pop the operands, add to the node
|
||||||
|
var operands = PopOperands(context, @operator.OperandCount);
|
||||||
|
foreach (var operand in operands)
|
||||||
|
{
|
||||||
|
node.AddParameter(operand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the node to the operand stack
|
||||||
|
context.Operands.Push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ")" function call
|
||||||
|
private static void FlushTopEndParameters(ParseContext context)
|
||||||
|
{
|
||||||
|
// Pop the operator
|
||||||
|
var @operator = PopOperator(context, TokenKind.EndParameters); // ")" function call
|
||||||
|
|
||||||
|
// Sanity check top operator is the current token
|
||||||
|
if (!Object.ReferenceEquals(@operator, context.Token))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Expected the operator to be the current token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var function = default(Function);
|
||||||
|
|
||||||
|
// No parameters
|
||||||
|
if (context.LastToken.Kind == TokenKind.StartParameters)
|
||||||
|
{
|
||||||
|
// Node already exists on the operand stack
|
||||||
|
function = (Function)context.Operands.Peek();
|
||||||
|
}
|
||||||
|
// Has parameters
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Pop the operands
|
||||||
|
var parameterCount = 1;
|
||||||
|
while (context.Operators.Peek().Kind == TokenKind.Separator)
|
||||||
|
{
|
||||||
|
parameterCount++;
|
||||||
|
context.Operators.Pop();
|
||||||
|
}
|
||||||
|
var functionOperands = PopOperands(context, parameterCount);
|
||||||
|
|
||||||
|
// Node already exists on the operand stack
|
||||||
|
function = (Function)context.Operands.Peek();
|
||||||
|
|
||||||
|
// Add the operands to the node
|
||||||
|
foreach (var operand in functionOperands)
|
||||||
|
{
|
||||||
|
function.AddParameter(operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop the "(" operator too
|
||||||
|
@operator = PopOperator(context, TokenKind.StartParameters);
|
||||||
|
|
||||||
|
// Check min/max parameter count
|
||||||
|
TryGetFunctionInfo(context, function.Name, out var functionInfo);
|
||||||
|
if (functionInfo == null && context.AllowUnknownKeywords)
|
||||||
|
{
|
||||||
|
// Don't check min/max
|
||||||
|
}
|
||||||
|
else if (function.Parameters.Count < functionInfo.MinParameters)
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.TooFewParameters, token: @operator, expression: context.Expression);
|
||||||
|
}
|
||||||
|
else if (function.Parameters.Count > functionInfo.MaxParameters)
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.TooManyParameters, token: @operator, expression: context.Expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pops N operands from the operand stack. The operands are returned
|
||||||
|
/// in their natural listed order, i.e. not last-in-first-out.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ExpressionNode> PopOperands(
|
||||||
|
ParseContext context,
|
||||||
|
Int32 count)
|
||||||
|
{
|
||||||
|
var result = new List<ExpressionNode>();
|
||||||
|
while (count-- > 0)
|
||||||
|
{
|
||||||
|
result.Add(context.Operands.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Reverse();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pops an operator and asserts it is the expected kind.
|
||||||
|
/// </summary>
|
||||||
|
private static Token PopOperator(
|
||||||
|
ParseContext context,
|
||||||
|
TokenKind expected)
|
||||||
|
{
|
||||||
|
var token = context.Operators.Pop();
|
||||||
|
if (token.Kind != expected)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Expected operator '{expected}' to be popped. Actual '{token.Kind}'.");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the max depth of the expression tree
|
||||||
|
/// </summary>
|
||||||
|
private static void CheckMaxDepth(
|
||||||
|
ParseContext context,
|
||||||
|
ExpressionNode node,
|
||||||
|
Int32 depth = 1)
|
||||||
|
{
|
||||||
|
if (depth > ExpressionConstants.MaxDepth)
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.ExceededMaxDepth, token: null, expression: context.Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is Container container)
|
||||||
|
{
|
||||||
|
foreach (var parameter in container.Parameters)
|
||||||
|
{
|
||||||
|
CheckMaxDepth(context, parameter, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean TryGetFunctionInfo(
|
||||||
|
ParseContext context,
|
||||||
|
String name,
|
||||||
|
out IFunctionInfo functionInfo)
|
||||||
|
{
|
||||||
|
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||||
|
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ParseContext
|
||||||
|
{
|
||||||
|
public Boolean AllowUnknownKeywords;
|
||||||
|
public readonly String Expression;
|
||||||
|
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public readonly LexicalAnalyzer LexicalAnalyzer;
|
||||||
|
public readonly Stack<ExpressionNode> Operands = new Stack<ExpressionNode>();
|
||||||
|
public readonly Stack<Token> Operators = new Stack<Token>();
|
||||||
|
public readonly ITraceWriter Trace;
|
||||||
|
public Token Token;
|
||||||
|
public Token LastToken;
|
||||||
|
|
||||||
|
public ParseContext(
|
||||||
|
String expression,
|
||||||
|
ITraceWriter trace,
|
||||||
|
IEnumerable<INamedValueInfo> namedValues,
|
||||||
|
IEnumerable<IFunctionInfo> functions,
|
||||||
|
Boolean allowUnknownKeywords = false)
|
||||||
|
{
|
||||||
|
Expression = expression ?? String.Empty;
|
||||||
|
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||||
|
{
|
||||||
|
throw new ParseException(ParseExceptionKind.ExceededMaxLength, token: null, expression: Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace = trace ?? new NoOperationTraceWriter();
|
||||||
|
foreach (var namedValueInfo in (namedValues ?? new INamedValueInfo[0]))
|
||||||
|
{
|
||||||
|
ExtensionNamedValues.Add(namedValueInfo.Name, namedValueInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var functionInfo in (functions ?? new IFunctionInfo[0]))
|
||||||
|
{
|
||||||
|
ExtensionFunctions.Add(functionInfo.Name, functionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||||
|
AllowUnknownKeywords = allowUnknownKeywords;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoOperationTraceWriter : ITraceWriter
|
||||||
|
{
|
||||||
|
public void Info(String message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Verbose(String message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Sdk/Expressions/FunctionInfo.cs
Normal file
27
src/Sdk/Expressions/FunctionInfo.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public class FunctionInfo<T> : IFunctionInfo
|
||||||
|
where T : Function, new()
|
||||||
|
{
|
||||||
|
public FunctionInfo(String name, Int32 minParameters, Int32 maxParameters)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
MinParameters = minParameters;
|
||||||
|
MaxParameters = maxParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String Name { get; }
|
||||||
|
|
||||||
|
public Int32 MinParameters { get; }
|
||||||
|
|
||||||
|
public Int32 MaxParameters { get; }
|
||||||
|
|
||||||
|
public Function CreateNode()
|
||||||
|
{
|
||||||
|
return new T();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Sdk/Expressions/IExpressionNode.cs
Normal file
24
src/Sdk/Expressions/IExpressionNode.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public interface IExpressionNode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the expression and returns the result, wrapped in a helper
|
||||||
|
/// for converting, comparing, and traversing objects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="trace">Optional trace writer</param>
|
||||||
|
/// <param name="secretMasker">Optional secret masker</param>
|
||||||
|
/// <param name="state">State object for custom evaluation function nodes and custom named-value nodes</param>
|
||||||
|
/// <param name="options">Evaluation options</param>
|
||||||
|
EvaluationResult Evaluate(
|
||||||
|
ITraceWriter trace,
|
||||||
|
ISecretMasker? secretMasker,
|
||||||
|
Object state,
|
||||||
|
EvaluationOptions options);
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/Sdk/Expressions/IExpressionNodeExtensions.cs
Normal file
320
src/Sdk/Expressions/IExpressionNodeExtensions.cs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using Index = GitHub.Actions.Expressions.Sdk.Operators.Index;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public static class IExpressionNodeExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the node and all descendant nodes
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<IExpressionNode> Traverse(this IExpressionNode node)
|
||||||
|
{
|
||||||
|
yield return node;
|
||||||
|
|
||||||
|
if (node is Container container && container.Parameters.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var parameter in container.Parameters)
|
||||||
|
{
|
||||||
|
foreach (var descendant in parameter.Traverse())
|
||||||
|
{
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether specific contexts or sub-properties of contexts are referenced.
|
||||||
|
/// If a conclusive determination cannot be made, then the pattern is considered matched.
|
||||||
|
/// For example, the expression "toJson(github)" matches the pattern "github.event" because
|
||||||
|
/// the value is passed to a function. Not enough information is known to determine whether
|
||||||
|
/// the function requires the sub-property. Therefore, assume it is required.
|
||||||
|
///
|
||||||
|
/// Patterns may contain wildcards to match any literal. For example, the pattern
|
||||||
|
/// "needs.*.outputs" will produce a match for the expression "needs.my-job.outputs.my-output".
|
||||||
|
/// </summary>
|
||||||
|
public static Boolean[] CheckReferencesContext(
|
||||||
|
this IExpressionNode tree,
|
||||||
|
params String[] patterns)
|
||||||
|
{
|
||||||
|
// The result is an array of booleans, one per pattern
|
||||||
|
var result = new Boolean[patterns.Length];
|
||||||
|
|
||||||
|
// Stores the match segments for each pattern. For example
|
||||||
|
// the patterns [ "github.event", "needs.*.outputs" ] would
|
||||||
|
// be stored as:
|
||||||
|
// [
|
||||||
|
// [
|
||||||
|
// NamedValue:github
|
||||||
|
// Literal:"event"
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// NamedValue:needs
|
||||||
|
// Wildcard:*
|
||||||
|
// Literal:"outputs"
|
||||||
|
// ]
|
||||||
|
// ]
|
||||||
|
var segmentedPatterns = default(Stack<IExpressionNode>[]);
|
||||||
|
|
||||||
|
// Walk the expression tree
|
||||||
|
var stack = new Stack<IExpressionNode>();
|
||||||
|
stack.Push(tree);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var node = stack.Pop();
|
||||||
|
|
||||||
|
// Attempt to match a named-value or index operator.
|
||||||
|
// Note, when entering this block, descendant nodes are only pushed
|
||||||
|
// to the stack for further processing under special conditions.
|
||||||
|
if (node is NamedValue || node is Index)
|
||||||
|
{
|
||||||
|
// Lazy initialize the pattern segments
|
||||||
|
if (segmentedPatterns is null)
|
||||||
|
{
|
||||||
|
segmentedPatterns = new Stack<IExpressionNode>[patterns.Length];
|
||||||
|
var parser = new ExpressionParser();
|
||||||
|
for (var i = 0; i < patterns.Length; i++)
|
||||||
|
{
|
||||||
|
var pattern = patterns[i];
|
||||||
|
var patternTree = parser.ValidateSyntax(pattern, null);
|
||||||
|
var patternSegments = GetMatchSegments(patternTree, out _);
|
||||||
|
if (patternSegments.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid context-match-pattern '{pattern}'");
|
||||||
|
}
|
||||||
|
segmentedPatterns[i] = patternSegments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match
|
||||||
|
Match(node, segmentedPatterns, result, out var needsFurtherAnalysis);
|
||||||
|
|
||||||
|
// Push nested nodes that need further analysis
|
||||||
|
if (needsFurtherAnalysis?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var nestedNode in needsFurtherAnalysis)
|
||||||
|
{
|
||||||
|
stack.Push(nestedNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Push children of any other container node.
|
||||||
|
else if (node is Container container && container.Parameters.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var child in container.Parameters)
|
||||||
|
{
|
||||||
|
stack.Push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempts to match a node within a user-provided-expression against a set of patterns.
|
||||||
|
//
|
||||||
|
// For example consider the user-provided-expression "github.event.base_ref || github.event.before"
|
||||||
|
// The Match method would be called twice, once for the sub-expression "github.event.base_ref" and
|
||||||
|
// once for the sub-expression "github.event.before".
|
||||||
|
private static void Match(
|
||||||
|
IExpressionNode node,
|
||||||
|
Stack<IExpressionNode>[] patterns,
|
||||||
|
Boolean[] result,
|
||||||
|
out List<ExpressionNode> needsFurtherAnalysis)
|
||||||
|
{
|
||||||
|
var nodeSegments = GetMatchSegments(node, out needsFurtherAnalysis);
|
||||||
|
|
||||||
|
if (nodeSegments.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeNamedValue = nodeSegments.Peek() as NamedValue;
|
||||||
|
var originalNodeSegments = nodeSegments;
|
||||||
|
|
||||||
|
for (var i = 0; i < patterns.Length; i++)
|
||||||
|
{
|
||||||
|
var patternSegments = patterns[i];
|
||||||
|
var patternNamedValue = patternSegments.Peek() as NamedValue;
|
||||||
|
|
||||||
|
// Compare the named-value
|
||||||
|
if (String.Equals(nodeNamedValue.Name, patternNamedValue.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Clone the stacks before mutating
|
||||||
|
nodeSegments = new Stack<IExpressionNode>(originalNodeSegments.Reverse()); // Push reverse to preserve order
|
||||||
|
nodeSegments.Pop();
|
||||||
|
patternSegments = new Stack<IExpressionNode>(patternSegments.Reverse()); // Push reverse to preserve order
|
||||||
|
patternSegments.Pop();
|
||||||
|
|
||||||
|
// Walk the stacks
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Every pattern segment was matched
|
||||||
|
if (patternSegments.Count == 0)
|
||||||
|
{
|
||||||
|
result[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Every node segment was matched. Treat the pattern as matched. There is not
|
||||||
|
// enough information to determine whether the property is required; assume it is.
|
||||||
|
// For example, consider the pattern "github.event" and the expression "toJson(github)".
|
||||||
|
// In this example the function requires the full structure of the named-value.
|
||||||
|
else if (nodeSegments.Count == 0)
|
||||||
|
{
|
||||||
|
result[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeSegment = nodeSegments.Pop();
|
||||||
|
var patternSegment = patternSegments.Pop();
|
||||||
|
|
||||||
|
// The behavior of a wildcard varies depending on whether the left operand
|
||||||
|
// is an array or an object. For simplicity, treat the pattern as matched.
|
||||||
|
if (nodeSegment is Wildcard)
|
||||||
|
{
|
||||||
|
result[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Treat a wildcard pattern segment as matching any literal segment
|
||||||
|
else if (patternSegment is Wildcard)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert literals to string and compare
|
||||||
|
var nodeLiteral = nodeSegment as Literal;
|
||||||
|
var nodeEvaluationResult = EvaluationResult.CreateIntermediateResult(null, nodeLiteral.Value);
|
||||||
|
var nodeString = nodeEvaluationResult.ConvertToString();
|
||||||
|
var patternLiteral = patternSegment as Literal;
|
||||||
|
var patternEvaluationResult = EvaluationResult.CreateIntermediateResult(null, patternLiteral.Value);
|
||||||
|
var patternString = patternEvaluationResult.ConvertToString();
|
||||||
|
if (String.Equals(nodeString, patternString, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to number and compare
|
||||||
|
var nodeNumber = nodeEvaluationResult.ConvertToNumber();
|
||||||
|
if (!Double.IsNaN(nodeNumber) && nodeNumber >= 0d && nodeNumber <= (Double)Int32.MaxValue)
|
||||||
|
{
|
||||||
|
var patternNumber = patternEvaluationResult.ConvertToNumber();
|
||||||
|
if (!Double.IsNaN(patternNumber) && patternNumber >= 0 && patternNumber <= (Double)Int32.MaxValue)
|
||||||
|
{
|
||||||
|
nodeNumber = Math.Floor(nodeNumber);
|
||||||
|
patternNumber = Math.Floor(patternNumber);
|
||||||
|
if (nodeNumber == patternNumber)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not matched
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function is used to convert a pattern or a user-provided-expression into a
|
||||||
|
// consistent structure for easy comparison. The result is a stack containing only
|
||||||
|
// nodes of type NamedValue, Literal, or Wildcard. All Index nodes are discarded.
|
||||||
|
//
|
||||||
|
// For example, consider the pattern "needs.*.outputs". The expression tree looks like:
|
||||||
|
// Index(
|
||||||
|
// Index(
|
||||||
|
// NamedValue:needs,
|
||||||
|
// Wildcard:*
|
||||||
|
// ),
|
||||||
|
// Literal:"outputs"
|
||||||
|
// )
|
||||||
|
// The result would be:
|
||||||
|
// [
|
||||||
|
// NamedValue:needs
|
||||||
|
// Wildcard:*
|
||||||
|
// Literal:"outputs"
|
||||||
|
// ]
|
||||||
|
//
|
||||||
|
// Any nested expression trees that require further analysis, are returned separately.
|
||||||
|
// For example, consider the expression "needs.build.outputs[github.event.base_ref]"
|
||||||
|
// The result would be:
|
||||||
|
// [
|
||||||
|
// NamedValue:needs
|
||||||
|
// Literal:"build"
|
||||||
|
// Literal:"outputs"
|
||||||
|
// ]
|
||||||
|
// And the nested expression tree "github.event.base_ref" would be tracked as needing
|
||||||
|
// further analysis.
|
||||||
|
private static Stack<IExpressionNode> GetMatchSegments(
|
||||||
|
IExpressionNode node,
|
||||||
|
out List<ExpressionNode> needsFurtherAnalysis)
|
||||||
|
{
|
||||||
|
var result = new Stack<IExpressionNode>();
|
||||||
|
needsFurtherAnalysis = new List<ExpressionNode>();
|
||||||
|
|
||||||
|
// Node is a named-value
|
||||||
|
if (node is NamedValue)
|
||||||
|
{
|
||||||
|
result.Push(node);
|
||||||
|
}
|
||||||
|
// Node is an index
|
||||||
|
else if (node is Index index)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Parameter 1
|
||||||
|
//
|
||||||
|
var parameter1 = index.Parameters[1];
|
||||||
|
|
||||||
|
// Treat anything other than literal as a wildcard
|
||||||
|
result.Push(parameter1 is Literal ? parameter1 : new Wildcard());
|
||||||
|
|
||||||
|
// Further analysis required by the caller if parameter 1 is a Function/Operator/NamedValue
|
||||||
|
if (parameter1 is Container || parameter1 is NamedValue)
|
||||||
|
{
|
||||||
|
needsFurtherAnalysis.Add(parameter1);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Parameter 0
|
||||||
|
//
|
||||||
|
var parameter0 = index.Parameters[0];
|
||||||
|
|
||||||
|
// Parameter 0 is a named-value
|
||||||
|
if (parameter0 is NamedValue)
|
||||||
|
{
|
||||||
|
result.Push(parameter0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Parameter 0 is an index
|
||||||
|
else if (parameter0 is Index index2)
|
||||||
|
{
|
||||||
|
index = index2;
|
||||||
|
}
|
||||||
|
// Otherwise clear
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Clear();
|
||||||
|
|
||||||
|
// Further analysis required by the caller if parameter 0 is a Function/Operator
|
||||||
|
if (parameter0 is Container)
|
||||||
|
{
|
||||||
|
needsFurtherAnalysis.Add(parameter0);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Sdk/Expressions/IFunctionInfo.cs
Normal file
13
src/Sdk/Expressions/IFunctionInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public interface IFunctionInfo
|
||||||
|
{
|
||||||
|
String Name { get; }
|
||||||
|
Int32 MinParameters { get; }
|
||||||
|
Int32 MaxParameters { get; }
|
||||||
|
Function CreateNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Sdk/Expressions/INamedValueInfo.cs
Normal file
11
src/Sdk/Expressions/INamedValueInfo.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public interface INamedValueInfo
|
||||||
|
{
|
||||||
|
String Name { get; }
|
||||||
|
NamedValue CreateNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Sdk/Expressions/ISecretMasker.cs
Normal file
12
src/Sdk/Expressions/ISecretMasker.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to mask secrets from trace messages and exception messages
|
||||||
|
/// </summary>
|
||||||
|
public interface ISecretMasker
|
||||||
|
{
|
||||||
|
String MaskSecrets(String input);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Sdk/Expressions/ITraceWriter.cs
Normal file
10
src/Sdk/Expressions/ITraceWriter.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public interface ITraceWriter
|
||||||
|
{
|
||||||
|
void Info(String message);
|
||||||
|
void Verbose(String message);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Sdk/Expressions/NamedValueInfo.cs
Normal file
21
src/Sdk/Expressions/NamedValueInfo.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public class NamedValueInfo<T> : INamedValueInfo
|
||||||
|
where T : NamedValue, new()
|
||||||
|
{
|
||||||
|
public NamedValueInfo(String name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String Name { get; }
|
||||||
|
|
||||||
|
public NamedValue CreateNode()
|
||||||
|
{
|
||||||
|
return new T();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Sdk/Expressions/NoOpSecretMasker.cs
Normal file
12
src/Sdk/Expressions/NoOpSecretMasker.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
internal sealed class NoOpSecretMasker : ISecretMasker
|
||||||
|
{
|
||||||
|
public String MaskSecrets(String input)
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/Sdk/Expressions/ParseException.cs
Normal file
68
src/Sdk/Expressions/ParseException.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public sealed class ParseException : ExpressionException
|
||||||
|
{
|
||||||
|
internal ParseException(ParseExceptionKind kind, Token token, String expression)
|
||||||
|
: base(secretMasker: null, message: String.Empty)
|
||||||
|
{
|
||||||
|
Expression = expression;
|
||||||
|
Kind = kind;
|
||||||
|
RawToken = token?.RawValue;
|
||||||
|
TokenIndex = token?.Index ?? 0;
|
||||||
|
String description;
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ParseExceptionKind.ExceededMaxDepth:
|
||||||
|
description = $"Exceeded max expression depth {ExpressionConstants.MaxDepth}";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.ExceededMaxLength:
|
||||||
|
description = $"Exceeded max expression length {ExpressionConstants.MaxLength}";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.TooFewParameters:
|
||||||
|
description = "Too few parameters supplied";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.TooManyParameters:
|
||||||
|
description = "Too many parameters supplied";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.UnexpectedEndOfExpression:
|
||||||
|
description = "Unexpected end of expression";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.UnexpectedSymbol:
|
||||||
|
description = "Unexpected symbol";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.UnrecognizedFunction:
|
||||||
|
description = "Unrecognized function";
|
||||||
|
break;
|
||||||
|
case ParseExceptionKind.UnrecognizedNamedValue:
|
||||||
|
description = "Unrecognized named-value";
|
||||||
|
break;
|
||||||
|
default: // Should never reach here.
|
||||||
|
throw new Exception($"Unexpected parse exception kind '{kind}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token == null)
|
||||||
|
{
|
||||||
|
Message = description;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Message = $"{description}: '{RawToken}'. Located at position {TokenIndex + 1} within expression: {Expression}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal String Expression { get; }
|
||||||
|
|
||||||
|
internal ParseExceptionKind Kind { get; }
|
||||||
|
|
||||||
|
internal String RawToken { get; }
|
||||||
|
|
||||||
|
internal Int32 TokenIndex { get; }
|
||||||
|
|
||||||
|
public sealed override String Message { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Sdk/Expressions/ParseExceptionKind.cs
Normal file
14
src/Sdk/Expressions/ParseExceptionKind.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
internal enum ParseExceptionKind
|
||||||
|
{
|
||||||
|
ExceededMaxDepth,
|
||||||
|
ExceededMaxLength,
|
||||||
|
TooFewParameters,
|
||||||
|
TooManyParameters,
|
||||||
|
UnexpectedEndOfExpression,
|
||||||
|
UnexpectedSymbol,
|
||||||
|
UnrecognizedFunction,
|
||||||
|
UnrecognizedNamedValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/Sdk/Expressions/Resources/ExpressionResources.cs
Normal file
277
src/Sdk/Expressions/Resources/ExpressionResources.cs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
// *** AUTOMATICALLY GENERATED BY GenResourceClass -- DO NOT EDIT!!! ***
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Resources;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions {
|
||||||
|
|
||||||
|
|
||||||
|
internal static class ExpressionResources
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
//********************************************************************************************
|
||||||
|
/// Creates the resource manager instance.
|
||||||
|
//********************************************************************************************
|
||||||
|
static ExpressionResources()
|
||||||
|
{
|
||||||
|
s_resMgr = new ResourceManager("GitHub.Actions.Expressions.ExpressionResources", typeof(ExpressionResources).GetTypeInfo().Assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResourceManager Manager
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return s_resMgr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//********************************************************************************************
|
||||||
|
/// Returns a localized string given a resource string name.
|
||||||
|
//********************************************************************************************
|
||||||
|
public static String Get(
|
||||||
|
String resourceName)
|
||||||
|
{
|
||||||
|
return s_resMgr.GetString(resourceName, CultureInfo.CurrentUICulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
//********************************************************************************************
|
||||||
|
/// Returns a localized integer given a resource string name.
|
||||||
|
//********************************************************************************************
|
||||||
|
public static int GetInt(
|
||||||
|
String resourceName)
|
||||||
|
{
|
||||||
|
return (int)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
//********************************************************************************************
|
||||||
|
/// Returns a localized string given a resource string name.
|
||||||
|
//********************************************************************************************
|
||||||
|
public static bool GetBool(
|
||||||
|
String resourceName)
|
||||||
|
{
|
||||||
|
return (bool)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//********************************************************************************************
|
||||||
|
/// A little helper function to alleviate some typing associated with loading resources and
|
||||||
|
/// formatting the strings. In DEBUG builds, it also asserts that the number of format
|
||||||
|
/// arguments and the length of args match.
|
||||||
|
//********************************************************************************************
|
||||||
|
private static String Format( // The formatted resource string.
|
||||||
|
String resourceName, // The name of the resource.
|
||||||
|
params Object[] args) // Arguments to format.
|
||||||
|
{
|
||||||
|
String resource = Get(resourceName);
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Check to make sure that the number of format string arguments matches the number of
|
||||||
|
// arguments passed in.
|
||||||
|
int formatArgCount = 0;
|
||||||
|
bool[] argSeen = new bool[100];
|
||||||
|
for (int i = 0; i < resource.Length; i++)
|
||||||
|
{
|
||||||
|
if (resource[i] == '{')
|
||||||
|
{
|
||||||
|
if (i + 1 < resource.Length &&
|
||||||
|
resource[i + 1] == '{')
|
||||||
|
{
|
||||||
|
i++; // Skip the escaped curly braces.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Move past the curly brace and leading whitespace.
|
||||||
|
i++;
|
||||||
|
while (Char.IsWhiteSpace(resource[i]))
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the argument number.
|
||||||
|
int length = 0;
|
||||||
|
while (i + length < resource.Length && Char.IsDigit(resource[i + length]))
|
||||||
|
{
|
||||||
|
length++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record it if it hasn't already been seen.
|
||||||
|
int argNumber = int.Parse(resource.Substring(i, length), CultureInfo.InvariantCulture);
|
||||||
|
if (!argSeen[argNumber])
|
||||||
|
{
|
||||||
|
formatArgCount++; // Count it as a formatting argument.
|
||||||
|
argSeen[argNumber] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Assert(args != null || formatArgCount == 0,
|
||||||
|
String.Format(CultureInfo.InvariantCulture, "The number of format arguments is {0}, but the args parameter is null.", formatArgCount));
|
||||||
|
Debug.Assert(args == null || formatArgCount == args.Length,
|
||||||
|
String.Format(CultureInfo.InvariantCulture, "Coding error using resource \"{0}\": The number of format arguments {1} != number of args {2}",
|
||||||
|
resourceName, formatArgCount, args != null ? args.Length : 0));
|
||||||
|
#endif // DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
if (args == null)
|
||||||
|
{
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are any DateTime structs in the arguments, we need to bracket them
|
||||||
|
// to make sure they are within the supported range of the current calendar.
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
// DateTime is a struct, we cannot use the as operator and null check.
|
||||||
|
if (args[i] is DateTime)
|
||||||
|
{
|
||||||
|
DateTime dateTime = (DateTime)args[i];
|
||||||
|
|
||||||
|
// We need to fetch the calendar on each Format call since it may change.
|
||||||
|
// Since we don't have more than one DateTime for resource, do not
|
||||||
|
// bother to cache this for the duration of the for loop.
|
||||||
|
Calendar calendar = DateTimeFormatInfo.CurrentInfo.Calendar;
|
||||||
|
if (dateTime > calendar.MaxSupportedDateTime)
|
||||||
|
{
|
||||||
|
args[i] = calendar.MaxSupportedDateTime;
|
||||||
|
}
|
||||||
|
else if (dateTime < calendar.MinSupportedDateTime)
|
||||||
|
{
|
||||||
|
args[i] = calendar.MinSupportedDateTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(CultureInfo.CurrentCulture, resource, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// According to the documentation for the ResourceManager class, it should be sufficient to
|
||||||
|
// create a single static instance. The following is an excerpt from the 1.1 documentation.
|
||||||
|
// Using the methods of ResourceManager, a caller can access the resources for a particular
|
||||||
|
// culture using the GetObject and GetString methods. By default, these methods return the
|
||||||
|
// resource for the culture determined by the current cultural settings of the thread that made
|
||||||
|
// the call.
|
||||||
|
private static ResourceManager s_resMgr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The maximum allowed memory size was exceeded while evaluating the following expression: {0}
|
||||||
|
/// </summary>
|
||||||
|
public static String ExceededAllowedMemory(object arg0) { return Format("ExceededAllowedMemory", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with a number.
|
||||||
|
///
|
||||||
|
/// Exceeded max expression depth {0}
|
||||||
|
/// </summary>
|
||||||
|
public static String ExceededMaxExpressionDepth(object arg0) { return Format("ExceededMaxExpressionDepth", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with a number.
|
||||||
|
///
|
||||||
|
/// Exceeded max expression length {0}
|
||||||
|
/// </summary>
|
||||||
|
public static String ExceededMaxExpressionLength(object arg0) { return Format("ExceededMaxExpressionLength", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expected a property name to follow the dereference operator '.'
|
||||||
|
/// </summary>
|
||||||
|
public static String ExpectedPropertyName() { return Get("ExpectedPropertyName"); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expected '(' to follow a function
|
||||||
|
/// </summary>
|
||||||
|
public static String ExpectedStartParameter() { return Get("ExpectedStartParameter"); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The following format string references more arguments than were supplied: {0}
|
||||||
|
/// </summary>
|
||||||
|
public static String InvalidFormatArgIndex(object arg0) { return Format("InvalidFormatArgIndex", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The format specifiers '{0}' are not valid for objects of type '{1}'
|
||||||
|
/// </summary>
|
||||||
|
public static String InvalidFormatSpecifiers(object arg0, object arg1) { return Format("InvalidFormatSpecifiers", arg0, arg1); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The following format string is invalid: {0}
|
||||||
|
/// </summary>
|
||||||
|
public static String InvalidFormatString(object arg0) { return Format("InvalidFormatString", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key not found '{0}'
|
||||||
|
/// </summary>
|
||||||
|
public static String KeyNotFound(object arg0) { return Format("KeyNotFound", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with the error message
|
||||||
|
///
|
||||||
|
/// {0}.
|
||||||
|
/// </summary>
|
||||||
|
public static String ParseErrorWithFwlink(object arg0) { return Format("ParseErrorWithFwlink", arg0); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with the parse error message
|
||||||
|
/// 1 is replaced with the token
|
||||||
|
/// 2 is replaced with the character position within the string
|
||||||
|
/// 3 is replaced with the full statement
|
||||||
|
///
|
||||||
|
/// {0}: '{1}'. Located at position {2} within expression: {3}.
|
||||||
|
/// </summary>
|
||||||
|
public static String ParseErrorWithTokenInfo(object arg0, object arg1, object arg2, object arg3) { return Format("ParseErrorWithTokenInfo", arg0, arg1, arg2, arg3); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with the from-type.
|
||||||
|
/// 1 is replaced with the to-type.
|
||||||
|
/// 2 is replaced with the value.
|
||||||
|
///
|
||||||
|
/// Unable to convert from {0} to {1}. Value: {2}
|
||||||
|
/// </summary>
|
||||||
|
public static String TypeCastError(object arg0, object arg1, object arg2) { return Format("TypeCastError", arg0, arg1, arg2); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with the from-type.
|
||||||
|
/// 1 is replaced with the to-type.
|
||||||
|
///
|
||||||
|
/// Unable to convert from {0} to {1}.
|
||||||
|
/// </summary>
|
||||||
|
public static String TypeCastErrorNoValue(object arg0, object arg1) { return Format("TypeCastErrorNoValue", arg0, arg1); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 is replaced with the from-type.
|
||||||
|
/// 1 is replaced with the to-type.
|
||||||
|
/// 2 is replaced with the value.
|
||||||
|
/// 3 is replaced with the error message.
|
||||||
|
///
|
||||||
|
/// Unable to convert from {0} to {1}. Value: {2}. Error: {3}
|
||||||
|
/// </summary>
|
||||||
|
public static String TypeCastErrorWithError(object arg0, object arg1, object arg2, object arg3) { return Format("TypeCastErrorWithError", arg0, arg1, arg2, arg3); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unclosed function
|
||||||
|
/// </summary>
|
||||||
|
public static String UnclosedFunction() { return Get("UnclosedFunction"); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unclosed indexer
|
||||||
|
/// </summary>
|
||||||
|
public static String UnclosedIndexer() { return Get("UnclosedIndexer"); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unexpected symbol
|
||||||
|
/// </summary>
|
||||||
|
public static String UnexpectedSymbol() { return Get("UnexpectedSymbol"); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unrecognized value
|
||||||
|
/// </summary>
|
||||||
|
public static String UnrecognizedValue() { return Get("UnrecognizedValue"); }
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
190
src/Sdk/Expressions/Resources/ExpressionResources.resx
Normal file
190
src/Sdk/Expressions/Resources/ExpressionResources.resx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="ExceededAllowedMemory" xml:space="preserve">
|
||||||
|
<value>The maximum allowed memory size was exceeded while evaluating the following expression: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="ExceededMaxExpressionDepth" xml:space="preserve">
|
||||||
|
<value>Exceeded max expression depth {0}</value>
|
||||||
|
<comment>0 is replaced with a number.</comment>
|
||||||
|
</data>
|
||||||
|
<data name="ExceededMaxExpressionLength" xml:space="preserve">
|
||||||
|
<value>Exceeded max expression length {0}</value>
|
||||||
|
<comment>0 is replaced with a number.</comment>
|
||||||
|
</data>
|
||||||
|
<data name="ExpectedPropertyName" xml:space="preserve">
|
||||||
|
<value>Expected a property name to follow the dereference operator '.'</value>
|
||||||
|
</data>
|
||||||
|
<data name="ExpectedStartParameter" xml:space="preserve">
|
||||||
|
<value>Expected '(' to follow a function</value>
|
||||||
|
</data>
|
||||||
|
<data name="InvalidFormatArgIndex" xml:space="preserve">
|
||||||
|
<value>The following format string references more arguments than were supplied: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="InvalidFormatSpecifiers" xml:space="preserve">
|
||||||
|
<value>The format specifiers '{0}' are not valid for objects of type '{1}'</value>
|
||||||
|
</data>
|
||||||
|
<data name="InvalidFormatString" xml:space="preserve">
|
||||||
|
<value>The following format string is invalid: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="KeyNotFound" xml:space="preserve">
|
||||||
|
<value>Key not found '{0}'</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParseErrorWithFwlink" xml:space="preserve">
|
||||||
|
<value>{0}.</value>
|
||||||
|
<comment>0 is replaced with the error message</comment>
|
||||||
|
</data>
|
||||||
|
<data name="ParseErrorWithTokenInfo" xml:space="preserve">
|
||||||
|
<value>{0}: '{1}'. Located at position {2} within expression: {3}.</value>
|
||||||
|
<comment>0 is replaced with the parse error message
|
||||||
|
1 is replaced with the token
|
||||||
|
2 is replaced with the character position within the string
|
||||||
|
3 is replaced with the full statement</comment>
|
||||||
|
</data>
|
||||||
|
<data name="TypeCastError" xml:space="preserve">
|
||||||
|
<value>Unable to convert from {0} to {1}. Value: {2}</value>
|
||||||
|
<comment>0 is replaced with the from-type.
|
||||||
|
1 is replaced with the to-type.
|
||||||
|
2 is replaced with the value.</comment>
|
||||||
|
</data>
|
||||||
|
<data name="TypeCastErrorNoValue" xml:space="preserve">
|
||||||
|
<value>Unable to convert from {0} to {1}.</value>
|
||||||
|
<comment>0 is replaced with the from-type.
|
||||||
|
1 is replaced with the to-type.</comment>
|
||||||
|
</data>
|
||||||
|
<data name="TypeCastErrorWithError" xml:space="preserve">
|
||||||
|
<value>Unable to convert from {0} to {1}. Value: {2}. Error: {3}</value>
|
||||||
|
<comment>0 is replaced with the from-type.
|
||||||
|
1 is replaced with the to-type.
|
||||||
|
2 is replaced with the value.
|
||||||
|
3 is replaced with the error message.</comment>
|
||||||
|
</data>
|
||||||
|
<data name="UnclosedFunction" xml:space="preserve">
|
||||||
|
<value>Unclosed function</value>
|
||||||
|
</data>
|
||||||
|
<data name="UnclosedIndexer" xml:space="preserve">
|
||||||
|
<value>Unclosed indexer</value>
|
||||||
|
</data>
|
||||||
|
<data name="UnexpectedSymbol" xml:space="preserve">
|
||||||
|
<value>Unexpected symbol</value>
|
||||||
|
</data>
|
||||||
|
<data name="UnrecognizedValue" xml:space="preserve">
|
||||||
|
<value>Unrecognized value</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
17
src/Sdk/Expressions/Sdk/Container.cs
Normal file
17
src/Sdk/Expressions/Sdk/Container.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public abstract class Container : ExpressionNode
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ExpressionNode> Parameters => m_parameters.AsReadOnly();
|
||||||
|
|
||||||
|
public void AddParameter(ExpressionNode node)
|
||||||
|
{
|
||||||
|
m_parameters.Add(node);
|
||||||
|
node.Container = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<ExpressionNode> m_parameters = new List<ExpressionNode>();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Sdk/Expressions/Sdk/EvaluationContext.cs
Normal file
79
src/Sdk/Expressions/Sdk/EvaluationContext.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public sealed class EvaluationContext
|
||||||
|
{
|
||||||
|
internal EvaluationContext(
|
||||||
|
ITraceWriter trace,
|
||||||
|
ISecretMasker secretMasker,
|
||||||
|
Object state,
|
||||||
|
EvaluationOptions options,
|
||||||
|
ExpressionNode node)
|
||||||
|
{
|
||||||
|
Trace = trace ?? throw new ArgumentNullException(nameof(trace));
|
||||||
|
SecretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||||
|
State = state;
|
||||||
|
|
||||||
|
// Copy the options
|
||||||
|
options = new EvaluationOptions(copy: options);
|
||||||
|
if (options.MaxMemory == 0)
|
||||||
|
{
|
||||||
|
// Set a reasonable default max memory
|
||||||
|
options.MaxMemory = 1048576; // 1 mb
|
||||||
|
}
|
||||||
|
if (options.MaxCacheMemory <= 0)
|
||||||
|
{
|
||||||
|
// Set a reasonable default max cache bytes
|
||||||
|
options.MaxCacheMemory = 1048576; // 1 mb
|
||||||
|
}
|
||||||
|
Options = options;
|
||||||
|
Memory = new EvaluationMemory(options.MaxMemory, node);
|
||||||
|
|
||||||
|
m_traceResults = new Dictionary<ExpressionNode, String>();
|
||||||
|
m_traceMemory = new MemoryCounter(null, options.MaxCacheMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ITraceWriter Trace { get; }
|
||||||
|
|
||||||
|
public ISecretMasker SecretMasker { get; }
|
||||||
|
|
||||||
|
public Object State { get; }
|
||||||
|
|
||||||
|
internal EvaluationMemory Memory { get; }
|
||||||
|
|
||||||
|
internal EvaluationOptions Options { get; }
|
||||||
|
|
||||||
|
internal void SetTraceResult(
|
||||||
|
ExpressionNode node,
|
||||||
|
EvaluationResult result)
|
||||||
|
{
|
||||||
|
// Remove if previously added. This typically should not happen. This could happen
|
||||||
|
// due to a badly authored function. So we'll handle it and track memory correctly.
|
||||||
|
if (m_traceResults.TryGetValue(node, out String oldValue))
|
||||||
|
{
|
||||||
|
m_traceMemory.Remove(oldValue);
|
||||||
|
m_traceResults.Remove(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max memory
|
||||||
|
String value = ExpressionUtility.FormatValue(SecretMasker, result);
|
||||||
|
if (m_traceMemory.TryAdd(value))
|
||||||
|
{
|
||||||
|
// Store the result
|
||||||
|
m_traceResults[node] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Boolean TryGetTraceResult(ExpressionNode node, out String value)
|
||||||
|
{
|
||||||
|
return m_traceResults.TryGetValue(node, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<ExpressionNode, String> m_traceResults = new Dictionary<ExpressionNode, String>();
|
||||||
|
private readonly MemoryCounter m_traceMemory;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Sdk/Expressions/Sdk/EvaluationMemory.cs
Normal file
111
src/Sdk/Expressions/Sdk/EvaluationMemory.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is an internal class only.
|
||||||
|
///
|
||||||
|
/// This class is used to track current memory consumption
|
||||||
|
/// across the entire expression evaluation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EvaluationMemory
|
||||||
|
{
|
||||||
|
internal EvaluationMemory(
|
||||||
|
Int32 maxBytes,
|
||||||
|
ExpressionNode node)
|
||||||
|
{
|
||||||
|
m_maxAmount = maxBytes;
|
||||||
|
m_node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AddAmount(
|
||||||
|
Int32 depth,
|
||||||
|
Int32 bytes,
|
||||||
|
Boolean trimDepth = false)
|
||||||
|
{
|
||||||
|
// Trim deeper depths
|
||||||
|
if (trimDepth)
|
||||||
|
{
|
||||||
|
while (m_maxActiveDepth > depth)
|
||||||
|
{
|
||||||
|
var amount = m_depths[m_maxActiveDepth];
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
// Sanity check
|
||||||
|
if (amount > m_totalAmount)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Bytes to subtract exceeds total bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtract from the total
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
m_totalAmount -= amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the amount
|
||||||
|
m_depths[m_maxActiveDepth] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_maxActiveDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow the depths
|
||||||
|
if (depth > m_maxActiveDepth)
|
||||||
|
{
|
||||||
|
// Grow the list
|
||||||
|
while (m_depths.Count <= depth)
|
||||||
|
{
|
||||||
|
m_depths.Add(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the max active depth
|
||||||
|
m_maxActiveDepth = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
// Add to the depth
|
||||||
|
m_depths[depth] += bytes;
|
||||||
|
|
||||||
|
// Add to the total
|
||||||
|
m_totalAmount += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max
|
||||||
|
if (m_totalAmount > m_maxAmount)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(ExpressionResources.ExceededAllowedMemory(m_node?.ConvertToExpression()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Int32 CalculateBytes(Object obj)
|
||||||
|
{
|
||||||
|
if (obj is String str)
|
||||||
|
{
|
||||||
|
// This measurement doesn't have to be perfect
|
||||||
|
// https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/
|
||||||
|
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
return c_stringBaseOverhead + ((str?.Length ?? 0) * sizeof(Char));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return c_minObjectSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const Int32 c_minObjectSize = 24;
|
||||||
|
private const Int32 c_stringBaseOverhead = 26;
|
||||||
|
private readonly List<Int32> m_depths = new List<Int32>();
|
||||||
|
private readonly Int32 m_maxAmount;
|
||||||
|
private readonly ExpressionNode m_node;
|
||||||
|
private Int32 m_maxActiveDepth = -1;
|
||||||
|
private Int32 m_totalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs
Normal file
34
src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
internal sealed class EvaluationTraceWriter : ITraceWriter
|
||||||
|
{
|
||||||
|
public EvaluationTraceWriter(ITraceWriter trace, ISecretMasker secretMasker)
|
||||||
|
{
|
||||||
|
m_trace = trace;
|
||||||
|
m_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Info(String message)
|
||||||
|
{
|
||||||
|
if (m_trace != null)
|
||||||
|
{
|
||||||
|
message = m_secretMasker.MaskSecrets(message);
|
||||||
|
m_trace.Info(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Verbose(String message)
|
||||||
|
{
|
||||||
|
if (m_trace != null)
|
||||||
|
{
|
||||||
|
message = m_secretMasker.MaskSecrets(message);
|
||||||
|
m_trace.Verbose(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ISecretMasker m_secretMasker;
|
||||||
|
private readonly ITraceWriter m_trace;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/Sdk/Expressions/Sdk/ExpressionNode.cs
Normal file
187
src/Sdk/Expressions/Sdk/ExpressionNode.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public abstract class ExpressionNode : IExpressionNode
|
||||||
|
{
|
||||||
|
internal Container Container { get; set; }
|
||||||
|
|
||||||
|
internal Int32 Level { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name is used for tracing. Normally the parser will set the name. However if a node
|
||||||
|
/// is added manually, then the name may not be set and will fallback to the type name.
|
||||||
|
/// </summary>
|
||||||
|
public String Name
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return !String.IsNullOrEmpty(m_name) ? m_name : this.GetType().Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
set
|
||||||
|
{
|
||||||
|
m_name = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the evalation result should be stored on the context and used
|
||||||
|
/// when the expanded result is traced.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Boolean TraceFullyExpanded { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IExpressionNode entry point.
|
||||||
|
/// </summary>
|
||||||
|
EvaluationResult IExpressionNode.Evaluate(
|
||||||
|
ITraceWriter trace,
|
||||||
|
ISecretMasker secretMasker,
|
||||||
|
Object state,
|
||||||
|
EvaluationOptions options)
|
||||||
|
{
|
||||||
|
if (Container != null)
|
||||||
|
{
|
||||||
|
// Do not localize. This is an SDK consumer error.
|
||||||
|
throw new NotSupportedException($"Expected {nameof(IExpressionNode)}.{nameof(Evaluate)} to be called on root node only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var originalSecretMasker = secretMasker;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Evaluate
|
||||||
|
secretMasker = secretMasker ?? new NoOpSecretMasker();
|
||||||
|
trace = new EvaluationTraceWriter(trace, secretMasker);
|
||||||
|
var context = new EvaluationContext(trace, secretMasker, state, options, this);
|
||||||
|
var originalExpression = ConvertToExpression();
|
||||||
|
trace.Info($"Evaluating: {originalExpression}");
|
||||||
|
var result = Evaluate(context);
|
||||||
|
|
||||||
|
// Trace the result
|
||||||
|
TraceTreeResult(context, originalExpression, result.Value, result.Kind);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (secretMasker != null && secretMasker != originalSecretMasker)
|
||||||
|
{
|
||||||
|
(secretMasker as IDisposable)?.Dispose();
|
||||||
|
secretMasker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This function is intended only for ExpressionNode authors to call. The EvaluationContext
|
||||||
|
/// caches result-state specific to the evaluation instance.
|
||||||
|
/// </summary>
|
||||||
|
public EvaluationResult Evaluate(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Evaluate
|
||||||
|
Level = Container == null ? 0 : Container.Level + 1;
|
||||||
|
TraceVerbose(context, Level, $"Evaluating {Name}:");
|
||||||
|
var coreResult = EvaluateCore(context, out ResultMemory coreMemory);
|
||||||
|
|
||||||
|
if (coreMemory == null)
|
||||||
|
{
|
||||||
|
coreMemory = new ResultMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to canonical value
|
||||||
|
var val = ExpressionUtility.ConvertToCanonicalValue(coreResult, out ValueKind kind, out Object raw);
|
||||||
|
|
||||||
|
// The depth can be safely trimmed when the total size of the core result is known,
|
||||||
|
// or when the total size of the core result can easily be determined.
|
||||||
|
var trimDepth = coreMemory.IsTotal || (Object.ReferenceEquals(raw, null) && ExpressionUtility.IsPrimitive(kind));
|
||||||
|
|
||||||
|
// Account for the memory overhead of the core result
|
||||||
|
var coreBytes = coreMemory.Bytes ?? EvaluationMemory.CalculateBytes(raw ?? val);
|
||||||
|
context.Memory.AddAmount(Level, coreBytes, trimDepth);
|
||||||
|
|
||||||
|
// Account for the memory overhead of the conversion result
|
||||||
|
if (!Object.ReferenceEquals(raw, null))
|
||||||
|
{
|
||||||
|
var conversionBytes = EvaluationMemory.CalculateBytes(val);
|
||||||
|
context.Memory.AddAmount(Level, conversionBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new EvaluationResult(context, Level, val, kind, raw);
|
||||||
|
|
||||||
|
// Store the trace result
|
||||||
|
if (this.TraceFullyExpanded)
|
||||||
|
{
|
||||||
|
context.SetTraceResult(this, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract String ConvertToExpression();
|
||||||
|
|
||||||
|
internal abstract String ConvertToExpandedExpression(EvaluationContext context);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the node
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current expression context</param>
|
||||||
|
/// <param name="resultMemory">
|
||||||
|
/// Helps determine how much memory is being consumed across the evaluation of the expression.
|
||||||
|
/// </param>
|
||||||
|
protected abstract Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory);
|
||||||
|
|
||||||
|
protected MemoryCounter CreateMemoryCounter(EvaluationContext context)
|
||||||
|
{
|
||||||
|
return new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TraceTreeResult(
|
||||||
|
EvaluationContext context,
|
||||||
|
String originalExpression,
|
||||||
|
Object result,
|
||||||
|
ValueKind kind)
|
||||||
|
{
|
||||||
|
// Get the expanded expression
|
||||||
|
String expandedExpression = ConvertToExpandedExpression(context);
|
||||||
|
|
||||||
|
// Format the result
|
||||||
|
String traceValue = ExpressionUtility.FormatValue(context.SecretMasker, result, kind);
|
||||||
|
|
||||||
|
// Only trace the expanded expression if it is meaningfully different (or if always showing)
|
||||||
|
if (context.Options.AlwaysTraceExpanded ||
|
||||||
|
(!String.Equals(expandedExpression, originalExpression, StringComparison.Ordinal) &&
|
||||||
|
!String.Equals(expandedExpression, traceValue, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
if (!context.Options.AlwaysTraceExpanded &&
|
||||||
|
kind == ValueKind.Number &&
|
||||||
|
String.Equals(expandedExpression, $"'{traceValue}'", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// Don't bother tracing the expanded expression when the result is a number and the
|
||||||
|
// expanded expresion is a precisely matching string.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Trace.Info($"Expanded: {expandedExpression}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always trace the result
|
||||||
|
context.Trace.Info($"Result: {traceValue}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TraceVerbose(
|
||||||
|
EvaluationContext context,
|
||||||
|
Int32 level,
|
||||||
|
String message)
|
||||||
|
{
|
||||||
|
context.Trace.Verbose(String.Empty.PadLeft(level * 2, '.') + (message ?? String.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String m_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/Sdk/Expressions/Sdk/ExpressionUtility.cs
Normal file
295
src/Sdk/Expressions/Sdk/ExpressionUtility.cs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public static class ExpressionUtility
|
||||||
|
{
|
||||||
|
internal static Object ConvertToCanonicalValue(
|
||||||
|
Object val,
|
||||||
|
out ValueKind kind,
|
||||||
|
out Object raw)
|
||||||
|
{
|
||||||
|
raw = null;
|
||||||
|
|
||||||
|
if (Object.ReferenceEquals(val, null))
|
||||||
|
{
|
||||||
|
kind = ValueKind.Null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else if (val is Boolean)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Boolean;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
else if (val is Double)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Number;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
else if (val is String)
|
||||||
|
{
|
||||||
|
kind = ValueKind.String;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
else if (val is INull n)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Null;
|
||||||
|
raw = val;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else if (val is IBoolean boolean)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Boolean;
|
||||||
|
raw = val;
|
||||||
|
return boolean.GetBoolean();
|
||||||
|
}
|
||||||
|
else if (val is INumber number)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Number;
|
||||||
|
raw = val;
|
||||||
|
return number.GetNumber();
|
||||||
|
}
|
||||||
|
else if (val is IString str)
|
||||||
|
{
|
||||||
|
kind = ValueKind.String;
|
||||||
|
raw = val;
|
||||||
|
return str.GetString();
|
||||||
|
}
|
||||||
|
else if (val is IReadOnlyObject)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Object;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
else if (val is IReadOnlyArray)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Array;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
else if (!val.GetType().GetTypeInfo().IsClass)
|
||||||
|
{
|
||||||
|
if (val is Decimal || val is Byte || val is SByte || val is Int16 || val is UInt16 || val is Int32 || val is UInt32 || val is Int64 || val is UInt64 || val is Single)
|
||||||
|
{
|
||||||
|
kind = ValueKind.Number;
|
||||||
|
return Convert.ToDouble(val);
|
||||||
|
}
|
||||||
|
else if (val is Enum)
|
||||||
|
{
|
||||||
|
var strVal = String.Format(CultureInfo.InvariantCulture, "{0:G}", val);
|
||||||
|
if (Double.TryParse(strVal, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out Double doubleValue))
|
||||||
|
{
|
||||||
|
kind = ValueKind.Number;
|
||||||
|
return doubleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
kind = ValueKind.String;
|
||||||
|
return strVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kind = ValueKind.Object;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||||
|
/// For example the string "hello world" returns 'hello world'. Note, null will return the null token; pass empty string
|
||||||
|
/// if you want the empty string token instead.
|
||||||
|
/// </summary>
|
||||||
|
public static String ConvertToParseToken(String str)
|
||||||
|
{
|
||||||
|
if (str == null)
|
||||||
|
{
|
||||||
|
return FormatValue(null, null, ValueKind.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormatValue(null, str, ValueKind.String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||||
|
/// </summary>
|
||||||
|
public static String ConvertToParseToken(Double d)
|
||||||
|
{
|
||||||
|
return FormatValue(null, d, ValueKind.Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||||
|
/// </summary>
|
||||||
|
public static String ConvertToParseToken(Boolean b)
|
||||||
|
{
|
||||||
|
return FormatValue(null, b, ValueKind.Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static String FormatValue(
|
||||||
|
ISecretMasker secretMasker,
|
||||||
|
EvaluationResult evaluationResult)
|
||||||
|
{
|
||||||
|
return FormatValue(secretMasker, evaluationResult.Value, evaluationResult.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static String FormatValue(
|
||||||
|
ISecretMasker secretMasker,
|
||||||
|
Object value,
|
||||||
|
ValueKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
return ExpressionConstants.Null;
|
||||||
|
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
return ((Boolean)value) ? ExpressionConstants.True : ExpressionConstants.False;
|
||||||
|
|
||||||
|
case ValueKind.Number:
|
||||||
|
var strNumber = ((Double)value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||||
|
return secretMasker != null ? secretMasker.MaskSecrets(strNumber) : strNumber;
|
||||||
|
|
||||||
|
case ValueKind.String:
|
||||||
|
// Mask secrets before string-escaping.
|
||||||
|
var strValue = secretMasker != null ? secretMasker.MaskSecrets(value as String) : value as String;
|
||||||
|
return $"'{StringEscape(strValue)}'";
|
||||||
|
|
||||||
|
case ValueKind.Array:
|
||||||
|
case ValueKind.Object:
|
||||||
|
return kind.ToString();
|
||||||
|
|
||||||
|
default: // Should never reach here.
|
||||||
|
throw new NotSupportedException($"Unable to convert to expanded expression. Unexpected value kind: {kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsLegalKeyword(String str)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(str))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = str[0];
|
||||||
|
if ((first >= 'a' && first <= 'z') ||
|
||||||
|
(first >= 'A' && first <= 'Z') ||
|
||||||
|
first == '_')
|
||||||
|
{
|
||||||
|
for (var i = 1; i < str.Length; i++)
|
||||||
|
{
|
||||||
|
var c = str[i];
|
||||||
|
if ((c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') ||
|
||||||
|
c == '_' ||
|
||||||
|
c == '-')
|
||||||
|
{
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Boolean IsPrimitive(ValueKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
case ValueKind.Number:
|
||||||
|
case ValueKind.String:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rules here attempt to follow Javascript rules for coercing a string into a number
|
||||||
|
/// for comparison. That is, the Number() function in Javascript.
|
||||||
|
/// </summary>
|
||||||
|
internal static Double ParseNumber(String str)
|
||||||
|
{
|
||||||
|
// Trim
|
||||||
|
str = str?.Trim() ?? String.Empty;
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
if (String.IsNullOrEmpty(str))
|
||||||
|
{
|
||||||
|
return 0d;
|
||||||
|
}
|
||||||
|
// Try parse
|
||||||
|
else if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
// Check for 0x[0-9a-fA-F]+
|
||||||
|
else if (str[0] == '0' &&
|
||||||
|
str.Length > 2 &&
|
||||||
|
str[1] == 'x' &&
|
||||||
|
str.Skip(2).All(x => (x >= '0' && x <= '9') || (x >= 'a' && x <= 'f') || (x >= 'A' && x <= 'F')))
|
||||||
|
{
|
||||||
|
// Try parse
|
||||||
|
if (Int32.TryParse(str.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var integer))
|
||||||
|
{
|
||||||
|
return (Double)integer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise exceeds range
|
||||||
|
}
|
||||||
|
// Check for 0o[0-9]+
|
||||||
|
else if (str[0] == '0' &&
|
||||||
|
str.Length > 2 &&
|
||||||
|
str[1] == 'o' &&
|
||||||
|
str.Skip(2).All(x => x >= '0' && x <= '7'))
|
||||||
|
{
|
||||||
|
// Try parse
|
||||||
|
var integer = default(Int32?);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
integer = Convert.ToInt32(str.Substring(2), 8);
|
||||||
|
}
|
||||||
|
// Otherwise exceeds range
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
if (integer != null)
|
||||||
|
{
|
||||||
|
return (Double)integer.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Infinity
|
||||||
|
else if (String.Equals(str, ExpressionConstants.Infinity, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Double.PositiveInfinity;
|
||||||
|
}
|
||||||
|
// -Infinity
|
||||||
|
else if (String.Equals(str, ExpressionConstants.NegativeInfinity, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Double.NegativeInfinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise NaN
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String StringEscape(String value)
|
||||||
|
{
|
||||||
|
return String.IsNullOrEmpty(value) ? String.Empty : value.Replace("'", "''");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Sdk/Expressions/Sdk/Function.cs
Normal file
43
src/Sdk/Expressions/Sdk/Function.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public abstract class Function : Container
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generally this should not be overridden. True indicates the result of the node is traced as part of the
|
||||||
|
/// "expanded" trace information. Otherwise the node expression is printed, and parameters to the node may or
|
||||||
|
/// may not be fully expanded - depending on each respective parameter's trace-fully-expanded setting.
|
||||||
|
///
|
||||||
|
/// The purpose is so the end user can understand how their expression expanded at run time. For example, consider
|
||||||
|
/// the expression: eq(variables.publish, 'true'). The runtime-expanded expression may be: eq('true', 'true')
|
||||||
|
/// </summary>
|
||||||
|
protected override Boolean TraceFullyExpanded => true;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}({1})",
|
||||||
|
Name,
|
||||||
|
String.Join(", ", Parameters.Select(x => x.ConvertToExpression())));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}({1})",
|
||||||
|
Name,
|
||||||
|
String.Join(", ", Parameters.Select(x => x.ConvertToExpandedExpression(context))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Functions/Contains.cs
Normal file
46
src/Sdk/Expressions/Sdk/Functions/Contains.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class Contains : Function
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
if (left.IsPrimitive)
|
||||||
|
{
|
||||||
|
var leftString = left.ConvertToString();
|
||||||
|
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
if (right.IsPrimitive)
|
||||||
|
{
|
||||||
|
var rightString = right.ConvertToString();
|
||||||
|
return leftString.IndexOf(rightString, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (left.TryGetCollectionInterface(out var collection) &&
|
||||||
|
collection is IReadOnlyArray array &&
|
||||||
|
array.Count > 0)
|
||||||
|
{
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
foreach (var item in array)
|
||||||
|
{
|
||||||
|
var itemResult = EvaluationResult.CreateIntermediateResult(context, item);
|
||||||
|
if (right.AbstractEqual(itemResult))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Sdk/Expressions/Sdk/Functions/EndsWith.cs
Normal file
32
src/Sdk/Expressions/Sdk/Functions/EndsWith.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class EndsWith : Function
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
if (left.IsPrimitive)
|
||||||
|
{
|
||||||
|
var leftString = left.ConvertToString();
|
||||||
|
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
if (right.IsPrimitive)
|
||||||
|
{
|
||||||
|
var rightString = right.ConvertToString();
|
||||||
|
return leftString.EndsWith(rightString, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/Sdk/Expressions/Sdk/Functions/Format.cs
Normal file
299
src/Sdk/Expressions/Sdk/Functions/Format.cs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
public sealed class Format : Function
|
||||||
|
{
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var format = Parameters[0].Evaluate(context).ConvertToString();
|
||||||
|
var index = 0;
|
||||||
|
var result = new FormatResultBuilder(this, context, CreateMemoryCounter(context));
|
||||||
|
while (index < format.Length)
|
||||||
|
{
|
||||||
|
var lbrace = format.IndexOf('{', index);
|
||||||
|
var rbrace = format.IndexOf('}', index);
|
||||||
|
|
||||||
|
// Left brace
|
||||||
|
if (lbrace >= 0 && (rbrace < 0 || rbrace > lbrace))
|
||||||
|
{
|
||||||
|
// Escaped left brace
|
||||||
|
if (SafeCharAt(format, lbrace + 1) == '{')
|
||||||
|
{
|
||||||
|
result.Append(format.Substring(index, lbrace - index + 1));
|
||||||
|
index = lbrace + 2;
|
||||||
|
}
|
||||||
|
// Left brace, number, optional format specifiers, right brace
|
||||||
|
else if (rbrace > lbrace + 1 &&
|
||||||
|
ReadArgIndex(format, lbrace + 1, out Byte argIndex, out Int32 endArgIndex) &&
|
||||||
|
ReadFormatSpecifiers(format, endArgIndex + 1, out String formatSpecifiers, out rbrace))
|
||||||
|
{
|
||||||
|
// Check parameter count
|
||||||
|
if (argIndex > Parameters.Count - 2)
|
||||||
|
{
|
||||||
|
throw new FormatException(ExpressionResources.InvalidFormatArgIndex(format));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the portion before the left brace
|
||||||
|
if (lbrace > index)
|
||||||
|
{
|
||||||
|
result.Append(format.Substring(index, lbrace - index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the arg
|
||||||
|
result.Append(argIndex, formatSpecifiers);
|
||||||
|
index = rbrace + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new FormatException(ExpressionResources.InvalidFormatString(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Right brace
|
||||||
|
else if (rbrace >= 0)
|
||||||
|
{
|
||||||
|
// Escaped right brace
|
||||||
|
if (SafeCharAt(format, rbrace + 1) == '}')
|
||||||
|
{
|
||||||
|
result.Append(format.Substring(index, rbrace - index + 1));
|
||||||
|
index = rbrace + 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new FormatException(ExpressionResources.InvalidFormatString(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last segment
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Append(format.Substring(index));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean ReadArgIndex(
|
||||||
|
String str,
|
||||||
|
Int32 startIndex,
|
||||||
|
out Byte result,
|
||||||
|
out Int32 endIndex)
|
||||||
|
{
|
||||||
|
// Count the number of digits
|
||||||
|
var length = 0;
|
||||||
|
while (Char.IsDigit(SafeCharAt(str, startIndex + length)))
|
||||||
|
{
|
||||||
|
length++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one digit
|
||||||
|
if (length < 1)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
endIndex = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the number
|
||||||
|
endIndex = startIndex + length - 1;
|
||||||
|
return Byte.TryParse(str.Substring(startIndex, length), NumberStyles.None, CultureInfo.InvariantCulture, out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean ReadFormatSpecifiers(
|
||||||
|
String str,
|
||||||
|
Int32 startIndex,
|
||||||
|
out String result,
|
||||||
|
out Int32 rbrace)
|
||||||
|
{
|
||||||
|
// No format specifiers
|
||||||
|
var c = SafeCharAt(str, startIndex);
|
||||||
|
if (c == '}')
|
||||||
|
{
|
||||||
|
result = String.Empty;
|
||||||
|
rbrace = startIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate starts with ":"
|
||||||
|
if (c != ':')
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
rbrace = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the specifiers
|
||||||
|
var specifiers = new StringBuilder();
|
||||||
|
var index = startIndex + 1;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Validate not the end of the string
|
||||||
|
if (index >= str.Length)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
rbrace = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
c = str[index];
|
||||||
|
|
||||||
|
// Not right-brace
|
||||||
|
if (c != '}')
|
||||||
|
{
|
||||||
|
specifiers.Append(c);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
// Escaped right-brace
|
||||||
|
else if (SafeCharAt(str, index + 1) == '}')
|
||||||
|
{
|
||||||
|
specifiers.Append('}');
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
// Closing right-brace
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = specifiers.ToString();
|
||||||
|
rbrace = index;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Char SafeCharAt(
|
||||||
|
String str,
|
||||||
|
Int32 index)
|
||||||
|
{
|
||||||
|
if (str.Length > index)
|
||||||
|
{
|
||||||
|
return str[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FormatResultBuilder
|
||||||
|
{
|
||||||
|
internal FormatResultBuilder(
|
||||||
|
Format node,
|
||||||
|
EvaluationContext context,
|
||||||
|
MemoryCounter counter)
|
||||||
|
{
|
||||||
|
m_node = node;
|
||||||
|
m_context = context;
|
||||||
|
m_counter = counter;
|
||||||
|
m_cache = new ArgValue[node.Parameters.Count - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final string. This is when lazy segments are evaluated.
|
||||||
|
public override String ToString()
|
||||||
|
{
|
||||||
|
return String.Join(
|
||||||
|
String.Empty,
|
||||||
|
m_segments.Select(obj =>
|
||||||
|
{
|
||||||
|
if (obj is Lazy<String> lazy)
|
||||||
|
{
|
||||||
|
return lazy.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return obj as String;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a static value
|
||||||
|
internal void Append(String value)
|
||||||
|
{
|
||||||
|
if (value?.Length > 0)
|
||||||
|
{
|
||||||
|
// Track memory
|
||||||
|
m_counter.Add(value);
|
||||||
|
|
||||||
|
// Append the segment
|
||||||
|
m_segments.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append an argument
|
||||||
|
internal void Append(
|
||||||
|
Int32 argIndex,
|
||||||
|
String formatSpecifiers)
|
||||||
|
{
|
||||||
|
// Delay execution until the final ToString
|
||||||
|
m_segments.Add(new Lazy<String>(() =>
|
||||||
|
{
|
||||||
|
String result;
|
||||||
|
|
||||||
|
// Get the arg from the cache
|
||||||
|
var argValue = m_cache[argIndex];
|
||||||
|
|
||||||
|
// Evaluate the arg and cache the result
|
||||||
|
if (argValue == null)
|
||||||
|
{
|
||||||
|
// The evaluation result is required when format specifiers are used. Otherwise the string
|
||||||
|
// result is required. Go ahead and store both values. Since ConvertToString produces tracing,
|
||||||
|
// we need to run that now so the tracing appears in order in the log.
|
||||||
|
var evaluationResult = m_node.Parameters[argIndex + 1].Evaluate(m_context);
|
||||||
|
var stringResult = evaluationResult.ConvertToString();
|
||||||
|
argValue = new ArgValue(evaluationResult, stringResult);
|
||||||
|
m_cache[argIndex] = argValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No format specifiers
|
||||||
|
if (String.IsNullOrEmpty(formatSpecifiers))
|
||||||
|
{
|
||||||
|
result = argValue.StringResult;
|
||||||
|
}
|
||||||
|
// Invalid
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new FormatException(ExpressionResources.InvalidFormatSpecifiers(formatSpecifiers, argValue.EvaluationResult.Kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track memory
|
||||||
|
if (!String.IsNullOrEmpty(result))
|
||||||
|
{
|
||||||
|
m_counter.Add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ArgValue[] m_cache;
|
||||||
|
private readonly EvaluationContext m_context;
|
||||||
|
private readonly MemoryCounter m_counter;
|
||||||
|
private readonly Format m_node;
|
||||||
|
private readonly List<Object> m_segments = new List<Object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores an EvaluateResult and the value converted to a String.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ArgValue
|
||||||
|
{
|
||||||
|
public ArgValue(
|
||||||
|
EvaluationResult evaluationResult,
|
||||||
|
String stringResult)
|
||||||
|
{
|
||||||
|
EvaluationResult = evaluationResult;
|
||||||
|
StringResult = stringResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EvaluationResult EvaluationResult { get; }
|
||||||
|
|
||||||
|
public String StringResult { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/Sdk/Expressions/Sdk/Functions/FromJson.cs
Normal file
49
src/Sdk/Expressions/Sdk/Functions/FromJson.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using GitHub.Actions.Expressions.Data;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class FromJson : Function
|
||||||
|
{
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var json = Parameters[0].Evaluate(context).ConvertToString();
|
||||||
|
|
||||||
|
if (context.Options.StrictJsonParsing)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonParser.Parse(json);
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException ex)
|
||||||
|
{
|
||||||
|
throw new System.Text.Json.JsonException($"Error parsing fromJson: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new System.Text.Json.JsonException($"Unexpected error parsing fromJson: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stringReader = new StringReader(json);
|
||||||
|
using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Double };
|
||||||
|
var token = JToken.ReadFrom(jsonReader);
|
||||||
|
return token.ToExpressionData();
|
||||||
|
}
|
||||||
|
catch (JsonReaderException ex)
|
||||||
|
{
|
||||||
|
throw new JsonReaderException("Error parsing fromJson", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Sdk/Expressions/Sdk/Functions/Join.cs
Normal file
76
src/Sdk/Expressions/Sdk/Functions/Join.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class Join : Function
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => true;
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var items = Parameters[0].Evaluate(context);
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (items.TryGetCollectionInterface(out var collection) &&
|
||||||
|
collection is IReadOnlyArray array &&
|
||||||
|
array.Count > 0)
|
||||||
|
{
|
||||||
|
var result = new StringBuilder();
|
||||||
|
var memory = new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
|
||||||
|
// Append the first item
|
||||||
|
var item = array[0];
|
||||||
|
var itemResult = EvaluationResult.CreateIntermediateResult(context, item);
|
||||||
|
var itemString = itemResult.ConvertToString();
|
||||||
|
memory.Add(itemString);
|
||||||
|
result.Append(itemString);
|
||||||
|
|
||||||
|
// More items?
|
||||||
|
if (array.Count > 1)
|
||||||
|
{
|
||||||
|
var separator = ",";
|
||||||
|
if (Parameters.Count > 1)
|
||||||
|
{
|
||||||
|
var separatorResult = Parameters[1].Evaluate(context);
|
||||||
|
if (separatorResult.IsPrimitive)
|
||||||
|
{
|
||||||
|
separator = separatorResult.ConvertToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 1; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
// Append the separator
|
||||||
|
memory.Add(separator);
|
||||||
|
result.Append(separator);
|
||||||
|
|
||||||
|
// Append the next item
|
||||||
|
var nextItem = array[i];
|
||||||
|
var nextItemResult = EvaluationResult.CreateIntermediateResult(context, nextItem);
|
||||||
|
var nextItemString = nextItemResult.ConvertToString();
|
||||||
|
memory.Add(nextItemString);
|
||||||
|
result.Append(nextItemString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
// Primitive
|
||||||
|
else if (items.IsPrimitive)
|
||||||
|
{
|
||||||
|
return items.ConvertToString();
|
||||||
|
}
|
||||||
|
// Otherwise return empty string
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return String.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/Sdk/Expressions/Sdk/Functions/JsonParser.cs
Normal file
125
src/Sdk/Expressions/Sdk/Functions/JsonParser.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GitHub.Actions.Expressions.Data;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class JsonParser
|
||||||
|
{
|
||||||
|
public static ExpressionData Parse(string json)
|
||||||
|
{
|
||||||
|
var reader = new Utf8JsonReader(
|
||||||
|
Encoding.UTF8.GetBytes(json),
|
||||||
|
new JsonReaderOptions{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
CommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
MaxDepth = 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// EOF?
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
throw new Exception("Expected at least one JSON token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read
|
||||||
|
var result = ReadRecursive(ref reader);
|
||||||
|
|
||||||
|
// Not EOF?
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
throw new Exception($"Expected end of JSON but encountered '{reader.TokenType}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpressionData ReadRecursive(ref Utf8JsonReader reader)
|
||||||
|
{
|
||||||
|
switch (reader.TokenType)
|
||||||
|
{
|
||||||
|
case JsonTokenType.StartArray:
|
||||||
|
return ReadArray(ref reader);
|
||||||
|
case JsonTokenType.StartObject:
|
||||||
|
return ReadObject(ref reader);
|
||||||
|
case JsonTokenType.Null:
|
||||||
|
return null;
|
||||||
|
case JsonTokenType.False:
|
||||||
|
return new BooleanExpressionData(false);
|
||||||
|
case JsonTokenType.True:
|
||||||
|
return new BooleanExpressionData(true);
|
||||||
|
case JsonTokenType.Number:
|
||||||
|
return new NumberExpressionData(reader.GetDouble());
|
||||||
|
case JsonTokenType.String:
|
||||||
|
return new StringExpressionData(reader.GetString());
|
||||||
|
default:
|
||||||
|
throw new Exception($"Unexpected token type '{reader.TokenType}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArrayExpressionData ReadArray(ref Utf8JsonReader reader)
|
||||||
|
{
|
||||||
|
var result = new ArrayExpressionData();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
// End array
|
||||||
|
if (reader.TokenType == JsonTokenType.EndArray)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item
|
||||||
|
result.Add(ReadRecursive(ref reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF
|
||||||
|
throw new Exception($"Unexpected end of JSON while reading array");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DictionaryExpressionData ReadObject(ref Utf8JsonReader reader)
|
||||||
|
{
|
||||||
|
var result = new DictionaryExpressionData();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var key = null as string;
|
||||||
|
switch (reader.TokenType)
|
||||||
|
{
|
||||||
|
// End object
|
||||||
|
case JsonTokenType.EndObject:
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// Property name
|
||||||
|
case JsonTokenType.PropertyName:
|
||||||
|
key = reader.GetString();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Exception($"Unexpected token type '{reader.TokenType}' while reading object");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value
|
||||||
|
var value = null as ExpressionData;
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
value = ReadRecursive(ref reader);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Exception("Unexpected end of JSON when reading object-pair value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add
|
||||||
|
if (!result.ContainsKey(key))
|
||||||
|
{
|
||||||
|
result.Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF
|
||||||
|
throw new Exception($"Unexpected end of JSON while reading object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Sdk/Expressions/Sdk/Functions/NoOperation.cs
Normal file
20
src/Sdk/Expressions/Sdk/Functions/NoOperation.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Useful when validating an expression
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NoOperation : Function
|
||||||
|
{
|
||||||
|
protected override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Sdk/Expressions/Sdk/Functions/StartsWith.cs
Normal file
32
src/Sdk/Expressions/Sdk/Functions/StartsWith.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class StartsWith : Function
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
if (left.IsPrimitive)
|
||||||
|
{
|
||||||
|
var leftString = left.ConvertToString();
|
||||||
|
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
if (right.IsPrimitive)
|
||||||
|
{
|
||||||
|
var rightString = right.ConvertToString();
|
||||||
|
return leftString.StartsWith(rightString, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
392
src/Sdk/Expressions/Sdk/Functions/ToJson.cs
Normal file
392
src/Sdk/Expressions/Sdk/Functions/ToJson.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||||
|
{
|
||||||
|
internal sealed class ToJson : Function
|
||||||
|
{
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var result = new StringBuilder();
|
||||||
|
var memory = new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
var current = Parameters[0].Evaluate(context);
|
||||||
|
var ancestors = new Stack<ICollectionEnumerator>();
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
// Descend as much as possible
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Collection
|
||||||
|
if (current.TryGetCollectionInterface(out Object collection))
|
||||||
|
{
|
||||||
|
// Array
|
||||||
|
if (collection is IReadOnlyArray array)
|
||||||
|
{
|
||||||
|
if (array.Count > 0)
|
||||||
|
{
|
||||||
|
// Write array start
|
||||||
|
WriteArrayStart(result, memory, ancestors);
|
||||||
|
|
||||||
|
// Move to first item
|
||||||
|
var enumerator = new ArrayEnumerator(context, current, array);
|
||||||
|
enumerator.MoveNext();
|
||||||
|
ancestors.Push(enumerator);
|
||||||
|
current = enumerator.Current;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Write empty array
|
||||||
|
WriteEmptyArray(result, memory, ancestors);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mapping
|
||||||
|
else if (collection is IReadOnlyObject obj)
|
||||||
|
{
|
||||||
|
if (obj.Count > 0)
|
||||||
|
{
|
||||||
|
// Write mapping start
|
||||||
|
WriteMappingStart(result, memory, ancestors);
|
||||||
|
|
||||||
|
// Move to first pair
|
||||||
|
var enumerator = new ObjectEnumerator(context, current, obj);
|
||||||
|
enumerator.MoveNext();
|
||||||
|
ancestors.Push(enumerator);
|
||||||
|
|
||||||
|
// Write mapping key
|
||||||
|
WriteMappingKey(context, result, memory, enumerator.Current.Key, ancestors);
|
||||||
|
|
||||||
|
// Move to mapping value
|
||||||
|
current = enumerator.Current.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Write empty mapping
|
||||||
|
WriteEmptyMapping(result, memory, ancestors);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Unexpected type '{collection?.GetType().FullName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not a collection
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Write value
|
||||||
|
WriteValue(context, result, memory, current, ancestors);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next sibling or ancestor sibling
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if (ancestors.Count > 0)
|
||||||
|
{
|
||||||
|
var parent = ancestors.Peek();
|
||||||
|
|
||||||
|
// Parent array
|
||||||
|
if (parent is ArrayEnumerator arrayEnumerator)
|
||||||
|
{
|
||||||
|
// Move to next item
|
||||||
|
if (arrayEnumerator.MoveNext())
|
||||||
|
{
|
||||||
|
current = arrayEnumerator.Current;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Move to parent
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ancestors.Pop();
|
||||||
|
current = arrayEnumerator.Array;
|
||||||
|
|
||||||
|
// Write array end
|
||||||
|
WriteArrayEnd(result, memory, ancestors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parent mapping
|
||||||
|
else if (parent is ObjectEnumerator objectEnumerator)
|
||||||
|
{
|
||||||
|
// Move to next pair
|
||||||
|
if (objectEnumerator.MoveNext())
|
||||||
|
{
|
||||||
|
// Write mapping key
|
||||||
|
WriteMappingKey(context, result, memory, objectEnumerator.Current.Key, ancestors);
|
||||||
|
|
||||||
|
// Move to mapping value
|
||||||
|
current = objectEnumerator.Current.Value;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Move to parent
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ancestors.Pop();
|
||||||
|
current = objectEnumerator.Object;
|
||||||
|
|
||||||
|
// Write mapping end
|
||||||
|
WriteMappingEnd(result, memory, ancestors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Unexpected type '{parent?.GetType().FullName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} while (current != null);
|
||||||
|
|
||||||
|
} while (current != null);
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteArrayStart(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = PrefixValue("[", ancestors);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteMappingStart(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = PrefixValue("{", ancestors);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteArrayEnd(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = $"\n{new String(' ', ancestors.Count * 2)}]";
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteMappingEnd(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = $"\n{new String(' ', ancestors.Count * 2)}}}";
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteEmptyArray(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = PrefixValue("[]", ancestors);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteEmptyMapping(
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = PrefixValue("{}", ancestors);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteMappingKey(
|
||||||
|
EvaluationContext context,
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
EvaluationResult key,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
var str = PrefixValue(JsonConvert.ToString(key.ConvertToString()), ancestors, isMappingKey: true);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteValue(
|
||||||
|
EvaluationContext context,
|
||||||
|
StringBuilder writer,
|
||||||
|
MemoryCounter memory,
|
||||||
|
EvaluationResult value,
|
||||||
|
Stack<ICollectionEnumerator> ancestors)
|
||||||
|
{
|
||||||
|
String str;
|
||||||
|
switch (value.Kind)
|
||||||
|
{
|
||||||
|
case ValueKind.Null:
|
||||||
|
str = "null";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ValueKind.Boolean:
|
||||||
|
str = (Boolean)value.Value ? "true" : "false";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ValueKind.Number:
|
||||||
|
str = value.ConvertToString();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ValueKind.String:
|
||||||
|
str = JsonConvert.ToString(value.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
str = "{}"; // The value is an object we don't know how to traverse
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
str = PrefixValue(str, ancestors);
|
||||||
|
memory.Add(str);
|
||||||
|
writer.Append(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String PrefixValue(
|
||||||
|
String value,
|
||||||
|
Stack<ICollectionEnumerator> ancestors,
|
||||||
|
Boolean isMappingKey = false)
|
||||||
|
{
|
||||||
|
var level = ancestors.Count;
|
||||||
|
var parent = level > 0 ? ancestors.Peek() : null;
|
||||||
|
|
||||||
|
if (!isMappingKey && parent is ObjectEnumerator)
|
||||||
|
{
|
||||||
|
return $": {value}";
|
||||||
|
}
|
||||||
|
else if (level > 0)
|
||||||
|
{
|
||||||
|
return $"{(parent.IsFirst ? String.Empty : ",")}\n{new String(' ', level * 2)}{value}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface ICollectionEnumerator : IEnumerator
|
||||||
|
{
|
||||||
|
Boolean IsFirst { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ArrayEnumerator : ICollectionEnumerator
|
||||||
|
{
|
||||||
|
public ArrayEnumerator(
|
||||||
|
EvaluationContext context,
|
||||||
|
EvaluationResult result,
|
||||||
|
IReadOnlyArray array)
|
||||||
|
{
|
||||||
|
m_context = context;
|
||||||
|
m_result = result;
|
||||||
|
m_enumerator = array.GetEnumerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public EvaluationResult Array => m_result;
|
||||||
|
|
||||||
|
public EvaluationResult Current => m_current;
|
||||||
|
|
||||||
|
Object IEnumerator.Current => m_current;
|
||||||
|
|
||||||
|
public Boolean IsFirst => m_index == 0;
|
||||||
|
|
||||||
|
public Boolean MoveNext()
|
||||||
|
{
|
||||||
|
if (m_enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
m_current = EvaluationResult.CreateIntermediateResult(m_context, m_enumerator.Current);
|
||||||
|
m_index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
m_current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(nameof(Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly EvaluationContext m_context;
|
||||||
|
private readonly IEnumerator m_enumerator;
|
||||||
|
private readonly EvaluationResult m_result;
|
||||||
|
private EvaluationResult m_current;
|
||||||
|
private Int32 m_index = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ObjectEnumerator : ICollectionEnumerator
|
||||||
|
{
|
||||||
|
public ObjectEnumerator(
|
||||||
|
EvaluationContext context,
|
||||||
|
EvaluationResult result,
|
||||||
|
IReadOnlyObject obj)
|
||||||
|
{
|
||||||
|
m_context = context;
|
||||||
|
m_result = result;
|
||||||
|
m_enumerator = obj.GetEnumerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyValuePair<EvaluationResult, EvaluationResult> Current => m_current;
|
||||||
|
|
||||||
|
Object IEnumerator.Current => m_current;
|
||||||
|
|
||||||
|
public Boolean IsFirst => m_index == 0;
|
||||||
|
|
||||||
|
public EvaluationResult Object => m_result;
|
||||||
|
|
||||||
|
public Boolean MoveNext()
|
||||||
|
{
|
||||||
|
if (m_enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var current = (KeyValuePair<String, Object>)m_enumerator.Current;
|
||||||
|
var key = EvaluationResult.CreateIntermediateResult(m_context, current.Key);
|
||||||
|
var value = EvaluationResult.CreateIntermediateResult(m_context, current.Value);
|
||||||
|
m_current = new KeyValuePair<EvaluationResult, EvaluationResult>(key, value);
|
||||||
|
m_index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
m_current = default(KeyValuePair<EvaluationResult, EvaluationResult>);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(nameof(Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly EvaluationContext m_context;
|
||||||
|
private readonly IEnumerator m_enumerator;
|
||||||
|
private readonly EvaluationResult m_result;
|
||||||
|
private KeyValuePair<EvaluationResult, EvaluationResult> m_current;
|
||||||
|
private Int32 m_index = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Sdk/Expressions/Sdk/IBoolean.cs
Normal file
9
src/Sdk/Expressions/Sdk/IBoolean.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface IBoolean
|
||||||
|
{
|
||||||
|
Boolean GetBoolean();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Sdk/Expressions/Sdk/INull.cs
Normal file
7
src/Sdk/Expressions/Sdk/INull.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface INull
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Sdk/Expressions/Sdk/INumber.cs
Normal file
9
src/Sdk/Expressions/Sdk/INumber.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface INumber
|
||||||
|
{
|
||||||
|
Double GetNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Sdk/Expressions/Sdk/IReadOnlyArray.cs
Normal file
14
src/Sdk/Expressions/Sdk/IReadOnlyArray.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface IReadOnlyArray
|
||||||
|
{
|
||||||
|
Int32 Count { get; }
|
||||||
|
|
||||||
|
Object this[Int32 index] { get; }
|
||||||
|
|
||||||
|
IEnumerator GetEnumerator();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Sdk/Expressions/Sdk/IReadOnlyObject.cs
Normal file
25
src/Sdk/Expressions/Sdk/IReadOnlyObject.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface IReadOnlyObject
|
||||||
|
{
|
||||||
|
Int32 Count { get; }
|
||||||
|
|
||||||
|
IEnumerable<String> Keys { get; }
|
||||||
|
|
||||||
|
IEnumerable<Object> Values { get; }
|
||||||
|
|
||||||
|
Object this[String key] { get; }
|
||||||
|
|
||||||
|
Boolean ContainsKey(String key);
|
||||||
|
|
||||||
|
IEnumerator GetEnumerator();
|
||||||
|
|
||||||
|
Boolean TryGetValue(
|
||||||
|
String key,
|
||||||
|
out Object value);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Sdk/Expressions/Sdk/IString.cs
Normal file
9
src/Sdk/Expressions/Sdk/IString.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public interface IString
|
||||||
|
{
|
||||||
|
String GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Sdk/Expressions/Sdk/Literal.cs
Normal file
43
src/Sdk/Expressions/Sdk/Literal.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public sealed class Literal : ExpressionNode
|
||||||
|
{
|
||||||
|
public Literal(Object val)
|
||||||
|
{
|
||||||
|
Value = ExpressionUtility.ConvertToCanonicalValue(val, out var kind, out _);
|
||||||
|
Kind = kind;
|
||||||
|
Name = kind.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueKind Kind { get; }
|
||||||
|
|
||||||
|
public Object Value { get; }
|
||||||
|
|
||||||
|
// Prevent the value from being stored on the evaluation context.
|
||||||
|
// This avoids unneccessarily duplicating the value in memory.
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return ExpressionUtility.FormatValue(null, Value, Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
return ExpressionUtility.FormatValue(null, Value, Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
92
src/Sdk/Expressions/Sdk/MemoryCounter.cs
Normal file
92
src/Sdk/Expressions/Sdk/MemoryCounter.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for ExpressionNode authors. This class helps calculate memory overhead for a result object.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoryCounter
|
||||||
|
{
|
||||||
|
internal MemoryCounter(
|
||||||
|
ExpressionNode node,
|
||||||
|
Int32? maxBytes)
|
||||||
|
{
|
||||||
|
m_node = node;
|
||||||
|
m_maxBytes = (maxBytes ?? 0) > 0 ? maxBytes.Value : Int32.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Int32 CurrentBytes => m_currentBytes;
|
||||||
|
|
||||||
|
public void Add(Int32 amount)
|
||||||
|
{
|
||||||
|
if (!TryAdd(amount))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(ExpressionResources.ExceededAllowedMemory(m_node?.ConvertToExpression()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(String value)
|
||||||
|
{
|
||||||
|
Add(CalculateSize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMinObjectSize()
|
||||||
|
{
|
||||||
|
Add(MinObjectSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(String value)
|
||||||
|
{
|
||||||
|
m_currentBytes -= CalculateSize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Int32 CalculateSize(String value)
|
||||||
|
{
|
||||||
|
// This measurement doesn't have to be perfect.
|
||||||
|
// https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/
|
||||||
|
|
||||||
|
Int32 bytes;
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
bytes = StringBaseOverhead + ((value?.Length ?? 0) * 2);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Boolean TryAdd(Int32 amount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
amount += m_currentBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > m_maxBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_currentBytes = amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Boolean TryAdd(String value)
|
||||||
|
{
|
||||||
|
return TryAdd(CalculateSize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const Int32 MinObjectSize = 24;
|
||||||
|
internal const Int32 StringBaseOverhead = 26;
|
||||||
|
private readonly Int32 m_maxBytes;
|
||||||
|
private readonly ExpressionNode m_node;
|
||||||
|
private Int32 m_currentBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Sdk/Expressions/Sdk/NamedValue.cs
Normal file
22
src/Sdk/Expressions/Sdk/NamedValue.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public abstract class NamedValue : ExpressionNode
|
||||||
|
{
|
||||||
|
public sealed override string ConvertToExpression() => Name;
|
||||||
|
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => true;
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Sdk/Expressions/Sdk/NoOperationNamedValue.cs
Normal file
20
src/Sdk/Expressions/Sdk/NoOperationNamedValue.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Useful when validating an expression
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NoOperationNamedValue : NamedValue
|
||||||
|
{
|
||||||
|
protected override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Sdk/Expressions/Sdk/Operators/And.cs
Normal file
53
src/Sdk/Expressions/Sdk/Operators/And.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class And : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0})",
|
||||||
|
String.Join(" && ", Parameters.Select(x => x.ConvertToExpression())));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0})",
|
||||||
|
String.Join(" && ", Parameters.Select(x => x.ConvertToExpandedExpression(context))));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var result = default(EvaluationResult);
|
||||||
|
foreach (var parameter in Parameters)
|
||||||
|
{
|
||||||
|
result = parameter.Evaluate(context);
|
||||||
|
if (result.IsFalsy)
|
||||||
|
{
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result?.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/Equal.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/Equal.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class Equal : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} == {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} == {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractEqual(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/GreaterThan.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/GreaterThan.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class GreaterThan : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} > {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} > {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractGreaterThan(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/GreaterThanOrEqual.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/GreaterThanOrEqual.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class GreaterThanOrEqual : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} >= {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} >= {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractGreaterThanOrEqual(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/Sdk/Expressions/Sdk/Operators/Index.cs
Normal file
319
src/Sdk/Expressions/Sdk/Operators/Index.cs
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class Index : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => true;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
// Wildcard - use .* notation
|
||||||
|
if (Parameters[1] is Wildcard)
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.*",
|
||||||
|
Parameters[0].ConvertToExpression());
|
||||||
|
}
|
||||||
|
// Verify if we can simplify the expression, we would rather return
|
||||||
|
// github.sha then github['sha'] so we check if this is a simple case.
|
||||||
|
else if (Parameters[1] is Literal literal &&
|
||||||
|
literal.Value is String literalString &&
|
||||||
|
ExpressionUtility.IsLegalKeyword(literalString))
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.{1}",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
literalString);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}[{1}]",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard - use .* notation
|
||||||
|
if (Parameters[1] is Wildcard)
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.*",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
// Verify if we can simplify the expression, we would rather return
|
||||||
|
// github.sha then github['sha'] so we check if this is a simple case.
|
||||||
|
else if (Parameters[1] is Literal literal &&
|
||||||
|
literal.Value is String literalString &&
|
||||||
|
ExpressionUtility.IsLegalKeyword(literalString))
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}.{1}",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
literalString);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}[{1}]",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
|
||||||
|
// Not a collection
|
||||||
|
if (!left.TryGetCollectionInterface(out Object collection))
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return Parameters[1] is Wildcard ? new FilteredArray() : null;
|
||||||
|
}
|
||||||
|
// Filtered array
|
||||||
|
else if (collection is FilteredArray filteredArray)
|
||||||
|
{
|
||||||
|
return HandleFilteredArray(context, filteredArray, out resultMemory);
|
||||||
|
}
|
||||||
|
// Object
|
||||||
|
else if (collection is IReadOnlyObject obj)
|
||||||
|
{
|
||||||
|
return HandleObject(context, obj, out resultMemory);
|
||||||
|
}
|
||||||
|
// Array
|
||||||
|
else if (collection is IReadOnlyArray array)
|
||||||
|
{
|
||||||
|
return HandleArray(context, array, out resultMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object HandleFilteredArray(
|
||||||
|
EvaluationContext context,
|
||||||
|
FilteredArray filteredArray,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
var result = new FilteredArray();
|
||||||
|
var counter = new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
|
||||||
|
var index = new IndexHelper(context, Parameters[1]);
|
||||||
|
|
||||||
|
foreach (var item in filteredArray)
|
||||||
|
{
|
||||||
|
// Leverage the expression SDK to traverse the object
|
||||||
|
var itemResult = EvaluationResult.CreateIntermediateResult(context, item);
|
||||||
|
if (itemResult.TryGetCollectionInterface(out var nestedCollection))
|
||||||
|
{
|
||||||
|
// Apply the index to each child object
|
||||||
|
if (nestedCollection is IReadOnlyObject nestedObject)
|
||||||
|
{
|
||||||
|
// Wildcard
|
||||||
|
if (index.IsWildcard)
|
||||||
|
{
|
||||||
|
foreach (var val in nestedObject.Values)
|
||||||
|
{
|
||||||
|
result.Add(val);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// String
|
||||||
|
else if (index.HasStringIndex)
|
||||||
|
{
|
||||||
|
if (nestedObject.TryGetValue(index.StringIndex, out var nestedObjectValue))
|
||||||
|
{
|
||||||
|
result.Add(nestedObjectValue);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply the index to each child array
|
||||||
|
else if (nestedCollection is IReadOnlyArray nestedArray)
|
||||||
|
{
|
||||||
|
// Wildcard
|
||||||
|
if (index.IsWildcard)
|
||||||
|
{
|
||||||
|
foreach (var val in nestedArray)
|
||||||
|
{
|
||||||
|
result.Add(val);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// String
|
||||||
|
else if (index.HasIntegerIndex &&
|
||||||
|
index.IntegerIndex < nestedArray.Count)
|
||||||
|
{
|
||||||
|
result.Add(nestedArray[index.IntegerIndex]);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = new ResultMemory { Bytes = counter.CurrentBytes };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object HandleObject(
|
||||||
|
EvaluationContext context,
|
||||||
|
IReadOnlyObject obj,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
var index = new IndexHelper(context, Parameters[1]);
|
||||||
|
|
||||||
|
// Wildcard
|
||||||
|
if (index.IsWildcard)
|
||||||
|
{
|
||||||
|
var filteredArray = new FilteredArray();
|
||||||
|
var counter = new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
counter.AddMinObjectSize();
|
||||||
|
|
||||||
|
foreach (var val in obj.Values)
|
||||||
|
{
|
||||||
|
filteredArray.Add(val);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = new ResultMemory { Bytes = counter.CurrentBytes };
|
||||||
|
return filteredArray;
|
||||||
|
}
|
||||||
|
// String
|
||||||
|
else if (index.HasStringIndex &&
|
||||||
|
obj.TryGetValue(index.StringIndex, out var result))
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object HandleArray(
|
||||||
|
EvaluationContext context,
|
||||||
|
IReadOnlyArray array,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
var index = new IndexHelper(context, Parameters[1]);
|
||||||
|
|
||||||
|
// Wildcard
|
||||||
|
if (index.IsWildcard)
|
||||||
|
{
|
||||||
|
var filtered = new FilteredArray();
|
||||||
|
var counter = new MemoryCounter(this, context.Options.MaxMemory);
|
||||||
|
counter.AddMinObjectSize();
|
||||||
|
|
||||||
|
foreach (var item in array)
|
||||||
|
{
|
||||||
|
filtered.Add(item);
|
||||||
|
counter.Add(IntPtr.Size);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = new ResultMemory { Bytes = counter.CurrentBytes };
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
// Integer
|
||||||
|
else if (index.HasIntegerIndex && index.IntegerIndex < array.Count)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return array[index.IntegerIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMemory = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FilteredArray : IReadOnlyArray
|
||||||
|
{
|
||||||
|
public FilteredArray()
|
||||||
|
{
|
||||||
|
m_list = new List<Object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(Object o)
|
||||||
|
{
|
||||||
|
m_list.Add(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Int32 Count => m_list.Count;
|
||||||
|
|
||||||
|
public Object this[Int32 index] => m_list[index];
|
||||||
|
|
||||||
|
public IEnumerator GetEnumerator() => m_list.GetEnumerator();
|
||||||
|
|
||||||
|
private readonly IList<Object> m_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class IndexHelper
|
||||||
|
{
|
||||||
|
public IndexHelper(
|
||||||
|
EvaluationContext context,
|
||||||
|
ExpressionNode parameter)
|
||||||
|
{
|
||||||
|
m_parameter = parameter;
|
||||||
|
m_result = parameter.Evaluate(context);
|
||||||
|
|
||||||
|
m_integerIndex = new Lazy<Int32?>(() =>
|
||||||
|
{
|
||||||
|
var doubleIndex = m_result.ConvertToNumber();
|
||||||
|
if (Double.IsNaN(doubleIndex) || doubleIndex < 0d)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
doubleIndex = Math.Floor(doubleIndex);
|
||||||
|
if (doubleIndex > (Double)Int32.MaxValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Int32)doubleIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
m_stringIndex = new Lazy<String>(() =>
|
||||||
|
{
|
||||||
|
return m_result.IsPrimitive ? m_result.ConvertToString() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean HasIntegerIndex => m_integerIndex.Value != null;
|
||||||
|
|
||||||
|
public Boolean HasStringIndex => m_stringIndex.Value != null;
|
||||||
|
|
||||||
|
public Boolean IsWildcard => m_parameter is Wildcard;
|
||||||
|
|
||||||
|
public Int32 IntegerIndex => m_integerIndex.Value ?? default(Int32);
|
||||||
|
|
||||||
|
public String StringIndex => m_stringIndex.Value;
|
||||||
|
|
||||||
|
private readonly ExpressionNode m_parameter;
|
||||||
|
private readonly EvaluationResult m_result;
|
||||||
|
private readonly Lazy<Int32?> m_integerIndex;
|
||||||
|
private readonly Lazy<String> m_stringIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/LessThan.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/LessThan.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class LessThan : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} < {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} < {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractLessThan(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/LessThanOrEqual.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/LessThanOrEqual.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class LessThanOrEqual : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} <= {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} <= {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractLessThanOrEqual(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Sdk/Expressions/Sdk/Operators/Not.cs
Normal file
43
src/Sdk/Expressions/Sdk/Operators/Not.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class Not : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"!{0}",
|
||||||
|
Parameters[0].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"!{0}",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var result = Parameters[0].Evaluate(context);
|
||||||
|
return result.IsFalsy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/Sdk/Expressions/Sdk/Operators/NotEqual.cs
Normal file
46
src/Sdk/Expressions/Sdk/Operators/NotEqual.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class NotEqual : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} != {1})",
|
||||||
|
Parameters[0].ConvertToExpression(),
|
||||||
|
Parameters[1].ConvertToExpression());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0} != {1})",
|
||||||
|
Parameters[0].ConvertToExpandedExpression(context),
|
||||||
|
Parameters[1].ConvertToExpandedExpression(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var left = Parameters[0].Evaluate(context);
|
||||||
|
var right = Parameters[1].Evaluate(context);
|
||||||
|
return left.AbstractNotEqual(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Sdk/Expressions/Sdk/Operators/Or.cs
Normal file
53
src/Sdk/Expressions/Sdk/Operators/Or.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk.Operators
|
||||||
|
{
|
||||||
|
internal sealed class Or : Container
|
||||||
|
{
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0})",
|
||||||
|
String.Join(" || ", Parameters.Select(x => x.ConvertToExpression())));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
// Check if the result was stored
|
||||||
|
if (context.TryGetTraceResult(this, out String result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"({0})",
|
||||||
|
String.Join(" || ", Parameters.Select(x => x.ConvertToExpandedExpression(context))));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
var result = default(EvaluationResult);
|
||||||
|
foreach (var parameter in Parameters)
|
||||||
|
{
|
||||||
|
result = parameter.Evaluate(context);
|
||||||
|
if (result.IsTruthy)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result?.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Sdk/Expressions/Sdk/ResultMemory.cs
Normal file
56
src/Sdk/Expressions/Sdk/ResultMemory.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public class ResultMemory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Only set a non-null value when both of the following conditions are met:
|
||||||
|
/// 1) The result is a complex object. In other words, the result is
|
||||||
|
/// not a simple type: string, boolean, number, or null.
|
||||||
|
/// 2) The result is a newly created object.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// For example, consider a function jsonParse() which takes a string parameter,
|
||||||
|
/// and returns a JToken object. The JToken object is newly created and a rough
|
||||||
|
/// measurement should be returned for the number of bytes it consumes in memory.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// For another example, consider a function which returns a sub-object from a
|
||||||
|
/// complex parameter value. From the perspective of an individual function,
|
||||||
|
/// the size of the complex parameter value is unknown. In this situation, set the
|
||||||
|
/// value to IntPtr.Size.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// When you are unsure, set the value to null. Null indicates the overhead of a
|
||||||
|
/// new pointer should be accounted for.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public Int32? Bytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether <c ref="Bytes" /> represents the total size of the result.
|
||||||
|
/// True indicates the accounting-overhead of downstream parameters can be discarded.
|
||||||
|
///
|
||||||
|
/// For <c ref="EvaluationOptions.Converters" />, this value is currently ignored.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// For example, consider a funciton jsonParse() which takes a string paramter,
|
||||||
|
/// and returns a JToken object. The JToken object is newly created and a rough
|
||||||
|
/// measurement should be returned for the amount of bytes it consumes in memory.
|
||||||
|
/// Set the <c ref="IsTotal" /> to true, since new object contains no references
|
||||||
|
/// to previously allocated memory.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// For another example, consider a function which wraps a complex parameter result.
|
||||||
|
/// <c ref="Bytes" /> should be set to the amount of newly allocated memory.
|
||||||
|
/// However since the object references previously allocated memory, set <c ref="IsTotal" />
|
||||||
|
/// to false.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public Boolean IsTotal { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Sdk/Expressions/Sdk/Wildcard.cs
Normal file
32
src/Sdk/Expressions/Sdk/Wildcard.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Sdk
|
||||||
|
{
|
||||||
|
public sealed class Wildcard : ExpressionNode
|
||||||
|
{
|
||||||
|
// Prevent the value from being stored on the evaluation context.
|
||||||
|
// This avoids unneccessarily duplicating the value in memory.
|
||||||
|
protected sealed override Boolean TraceFullyExpanded => false;
|
||||||
|
|
||||||
|
public sealed override String ConvertToExpression()
|
||||||
|
{
|
||||||
|
return ExpressionConstants.Wildcard.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||||
|
{
|
||||||
|
return ExpressionConstants.Wildcard.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sealed override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return ExpressionConstants.Wildcard.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
9
src/Sdk/Expressions/Tokens/Associativity.cs
Normal file
9
src/Sdk/Expressions/Tokens/Associativity.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace GitHub.Actions.Expressions.Tokens
|
||||||
|
{
|
||||||
|
internal enum Associativity
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
LeftToRight,
|
||||||
|
RightToLeft,
|
||||||
|
}
|
||||||
|
}
|
||||||
492
src/Sdk/Expressions/Tokens/LexicalAnalyzer.cs
Normal file
492
src/Sdk/Expressions/Tokens/LexicalAnalyzer.cs
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Tokens
|
||||||
|
{
|
||||||
|
internal sealed class LexicalAnalyzer
|
||||||
|
{
|
||||||
|
public LexicalAnalyzer(String expression)
|
||||||
|
{
|
||||||
|
m_expression = expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Token> UnclosedTokens => m_unclosedTokens;
|
||||||
|
|
||||||
|
public Boolean TryGetNextToken(ref Token token)
|
||||||
|
{
|
||||||
|
// Skip whitespace
|
||||||
|
while (m_index < m_expression.Length && Char.IsWhiteSpace(m_expression[m_index]))
|
||||||
|
{
|
||||||
|
m_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test end of string
|
||||||
|
if (m_index >= m_expression.Length)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the first character to determine the type of token.
|
||||||
|
var c = m_expression[m_index];
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.StartGroup: // "("
|
||||||
|
// Function call
|
||||||
|
if (m_lastToken?.Kind == TokenKind.Function)
|
||||||
|
{
|
||||||
|
token = CreateToken(TokenKind.StartParameters, c, m_index++);
|
||||||
|
}
|
||||||
|
// Logical grouping
|
||||||
|
else
|
||||||
|
{
|
||||||
|
token = CreateToken(TokenKind.StartGroup, c, m_index++);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ExpressionConstants.StartIndex: // "["
|
||||||
|
token = CreateToken(TokenKind.StartIndex, c, m_index++);
|
||||||
|
break;
|
||||||
|
case ExpressionConstants.EndGroup: // ")"
|
||||||
|
// Function call
|
||||||
|
if (m_unclosedTokens.FirstOrDefault()?.Kind == TokenKind.StartParameters) // "(" function call
|
||||||
|
{
|
||||||
|
token = CreateToken(TokenKind.EndParameters, c, m_index++);
|
||||||
|
}
|
||||||
|
// Logical grouping
|
||||||
|
else
|
||||||
|
{
|
||||||
|
token = CreateToken(TokenKind.EndGroup, c, m_index++);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ExpressionConstants.EndIndex: // "]"
|
||||||
|
token = CreateToken(TokenKind.EndIndex, c, m_index++);
|
||||||
|
break;
|
||||||
|
case ExpressionConstants.Separator: // ","
|
||||||
|
token = CreateToken(TokenKind.Separator, c, m_index++);
|
||||||
|
break;
|
||||||
|
case ExpressionConstants.Wildcard: // "*"
|
||||||
|
token = CreateToken(TokenKind.Wildcard, c, m_index++);
|
||||||
|
break;
|
||||||
|
case '\'':
|
||||||
|
token = ReadStringToken();
|
||||||
|
break;
|
||||||
|
case '!': // "!" and "!="
|
||||||
|
case '>': // ">" and ">="
|
||||||
|
case '<': // "<" and "<="
|
||||||
|
case '=': // "=="
|
||||||
|
case '&': // "&&"
|
||||||
|
case '|': // "||"
|
||||||
|
token = ReadOperator();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (c == '.')
|
||||||
|
{
|
||||||
|
// Number
|
||||||
|
if (m_lastToken == null ||
|
||||||
|
m_lastToken.Kind == TokenKind.Separator || // ","
|
||||||
|
m_lastToken.Kind == TokenKind.StartGroup || // "(" logical grouping
|
||||||
|
m_lastToken.Kind == TokenKind.StartIndex || // "["
|
||||||
|
m_lastToken.Kind == TokenKind.StartParameters || // "(" function call
|
||||||
|
m_lastToken.Kind == TokenKind.LogicalOperator) // "!", "==", etc
|
||||||
|
{
|
||||||
|
token = ReadNumberToken();
|
||||||
|
}
|
||||||
|
// "."
|
||||||
|
else
|
||||||
|
{
|
||||||
|
token = CreateToken(TokenKind.Dereference, c, m_index++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (c == '-' || c == '+' || (c >= '0' && c <= '9'))
|
||||||
|
{
|
||||||
|
token = ReadNumberToken();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
token = ReadKeywordToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_lastToken = token;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadNumberToken()
|
||||||
|
{
|
||||||
|
var startIndex = m_index;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
m_index++;
|
||||||
|
}
|
||||||
|
while (m_index < m_expression.Length && (!TestTokenBoundary(m_expression[m_index]) || m_expression[m_index] == '.'));
|
||||||
|
|
||||||
|
var length = m_index - startIndex;
|
||||||
|
var str = m_expression.Substring(startIndex, length);
|
||||||
|
var d = ExpressionUtility.ParseNumber(str);
|
||||||
|
|
||||||
|
if (Double.IsNaN(d))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Unexpected, str, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateToken(TokenKind.Number, str, startIndex, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadKeywordToken()
|
||||||
|
{
|
||||||
|
// Read to the end of the keyword.
|
||||||
|
var startIndex = m_index;
|
||||||
|
m_index++; // Skip the first char. It is already known to be the start of the keyword.
|
||||||
|
while (m_index < m_expression.Length && !TestTokenBoundary(m_expression[m_index]))
|
||||||
|
{
|
||||||
|
m_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if valid keyword character sequence.
|
||||||
|
var length = m_index - startIndex;
|
||||||
|
var str = m_expression.Substring(startIndex, length);
|
||||||
|
if (ExpressionUtility.IsLegalKeyword(str))
|
||||||
|
{
|
||||||
|
// Test if follows property dereference operator.
|
||||||
|
if (m_lastToken != null && m_lastToken.Kind == TokenKind.Dereference)
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.PropertyName, str, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null
|
||||||
|
if (str.Equals(ExpressionConstants.Null, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Null, str, startIndex);
|
||||||
|
}
|
||||||
|
// Boolean
|
||||||
|
else if (str.Equals(ExpressionConstants.True, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Boolean, str, startIndex, true);
|
||||||
|
}
|
||||||
|
else if (str.Equals(ExpressionConstants.False, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Boolean, str, startIndex, false);
|
||||||
|
}
|
||||||
|
// NaN
|
||||||
|
else if (str.Equals(ExpressionConstants.NaN, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Number, str, startIndex, Double.NaN);
|
||||||
|
}
|
||||||
|
// Infinity
|
||||||
|
else if (str.Equals(ExpressionConstants.Infinity, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Number, str, startIndex, Double.PositiveInfinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookahead
|
||||||
|
var tempIndex = m_index;
|
||||||
|
while (tempIndex < m_expression.Length && Char.IsWhiteSpace(m_expression[tempIndex]))
|
||||||
|
{
|
||||||
|
tempIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function
|
||||||
|
if (tempIndex < m_expression.Length && m_expression[tempIndex] == ExpressionConstants.StartGroup) // "("
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.Function, str, startIndex);
|
||||||
|
}
|
||||||
|
// Named-value
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.NamedValue, str, startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Invalid keyword
|
||||||
|
return CreateToken(TokenKind.Unexpected, str, startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadStringToken()
|
||||||
|
{
|
||||||
|
var startIndex = m_index;
|
||||||
|
var c = default(Char);
|
||||||
|
var closed = false;
|
||||||
|
var str = new StringBuilder();
|
||||||
|
m_index++; // Skip the leading single-quote.
|
||||||
|
while (m_index < m_expression.Length)
|
||||||
|
{
|
||||||
|
c = m_expression[m_index++];
|
||||||
|
if (c == '\'')
|
||||||
|
{
|
||||||
|
// End of string.
|
||||||
|
if (m_index >= m_expression.Length || m_expression[m_index] != '\'')
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escaped single quote.
|
||||||
|
m_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
str.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = m_index - startIndex;
|
||||||
|
var rawValue = m_expression.Substring(startIndex, length);
|
||||||
|
if (closed)
|
||||||
|
{
|
||||||
|
return CreateToken(TokenKind.String, rawValue, startIndex, str.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateToken(TokenKind.Unexpected, rawValue, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadOperator()
|
||||||
|
{
|
||||||
|
var startIndex = m_index;
|
||||||
|
var raw = default(String);
|
||||||
|
m_index++;
|
||||||
|
|
||||||
|
// Check for a two-character operator
|
||||||
|
if (m_index < m_expression.Length)
|
||||||
|
{
|
||||||
|
m_index++;
|
||||||
|
raw = m_expression.Substring(startIndex, 2);
|
||||||
|
switch (raw)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.NotEqual:
|
||||||
|
case ExpressionConstants.GreaterThanOrEqual:
|
||||||
|
case ExpressionConstants.LessThanOrEqual:
|
||||||
|
case ExpressionConstants.Equal:
|
||||||
|
case ExpressionConstants.And:
|
||||||
|
case ExpressionConstants.Or:
|
||||||
|
return CreateToken(TokenKind.LogicalOperator, raw, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
m_index--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for one-character operator
|
||||||
|
raw = m_expression.Substring(startIndex, 1);
|
||||||
|
switch (raw)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not:
|
||||||
|
case ExpressionConstants.GreaterThan:
|
||||||
|
case ExpressionConstants.LessThan:
|
||||||
|
return CreateToken(TokenKind.LogicalOperator, raw, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected
|
||||||
|
while (m_index < m_expression.Length && !TestTokenBoundary(m_expression[m_index]))
|
||||||
|
{
|
||||||
|
m_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = m_index - startIndex;
|
||||||
|
raw = m_expression.Substring(startIndex, length);
|
||||||
|
return CreateToken(TokenKind.Unexpected, raw, startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean TestTokenBoundary(Char c)
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.StartGroup: // "("
|
||||||
|
case ExpressionConstants.StartIndex: // "["
|
||||||
|
case ExpressionConstants.EndGroup: // ")"
|
||||||
|
case ExpressionConstants.EndIndex: // "]"
|
||||||
|
case ExpressionConstants.Separator: // ","
|
||||||
|
case ExpressionConstants.Dereference: // "."
|
||||||
|
case '!': // "!" and "!="
|
||||||
|
case '>': // ">" and ">="
|
||||||
|
case '<': // "<" and "<="
|
||||||
|
case '=': // "=="
|
||||||
|
case '&': // "&&"
|
||||||
|
case '|': // "||"
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return char.IsWhiteSpace(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token CreateToken(
|
||||||
|
TokenKind kind,
|
||||||
|
Char rawValue,
|
||||||
|
Int32 index,
|
||||||
|
Object parsedValue = null)
|
||||||
|
{
|
||||||
|
return CreateToken(kind, rawValue.ToString(), index, parsedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token CreateToken(
|
||||||
|
TokenKind kind,
|
||||||
|
String rawValue,
|
||||||
|
Int32 index,
|
||||||
|
Object parsedValue = null)
|
||||||
|
{
|
||||||
|
// Check whether the current token is legal based on the last token
|
||||||
|
var legal = false;
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartGroup: // "(" logical grouping
|
||||||
|
// Is first or follows "," or "(" or "[" or a logical operator
|
||||||
|
legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.StartIndex, TokenKind.LogicalOperator);
|
||||||
|
break;
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
// Follows ")", "]", "*", a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.StartParameters: // "(" function call
|
||||||
|
// Follows a function
|
||||||
|
legal = CheckLastToken(TokenKind.Function);
|
||||||
|
break;
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
// Follows ")", "]", "*", a literal, a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
// Follows ")", "]", "*", a literal, a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
// Follows "(" function call, ")", "]", "*", a literal, a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.StartParameters, TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.Separator: // ","
|
||||||
|
// Follows ")", "]", "*", a literal, a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.Dereference: // "."
|
||||||
|
// Follows ")", "]", "*", a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
case TokenKind.Wildcard: // "*"
|
||||||
|
// Follows "[" or "."
|
||||||
|
legal = CheckLastToken(TokenKind.StartIndex, TokenKind.Dereference);
|
||||||
|
break;
|
||||||
|
case TokenKind.LogicalOperator: // "!", "==", etc
|
||||||
|
switch (rawValue)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not:
|
||||||
|
// Is first or follows "," or "(" or "[" or a logical operator
|
||||||
|
legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.StartIndex, TokenKind.LogicalOperator);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Follows ")", "]", "*", a literal, a property name, or a named-value
|
||||||
|
legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TokenKind.Null:
|
||||||
|
case TokenKind.Boolean:
|
||||||
|
case TokenKind.Number:
|
||||||
|
case TokenKind.String:
|
||||||
|
// Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc)
|
||||||
|
legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator);
|
||||||
|
break;
|
||||||
|
case TokenKind.PropertyName:
|
||||||
|
// Follows "."
|
||||||
|
legal = CheckLastToken(TokenKind.Dereference);
|
||||||
|
break;
|
||||||
|
case TokenKind.Function:
|
||||||
|
// Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc)
|
||||||
|
legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator);
|
||||||
|
break;
|
||||||
|
case TokenKind.NamedValue:
|
||||||
|
// Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc)
|
||||||
|
legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Illegal
|
||||||
|
if (!legal)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.Unexpected, rawValue, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legal so far
|
||||||
|
var token = new Token(kind, rawValue, index, parsedValue);
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartGroup: // "(" logical grouping
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
case TokenKind.StartParameters: // "(" function call
|
||||||
|
// Track start token
|
||||||
|
m_unclosedTokens.Push(token);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
// Check inside logical grouping
|
||||||
|
if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartGroup)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.Unexpected, rawValue, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop start token
|
||||||
|
m_unclosedTokens.Pop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
// Check inside indexer
|
||||||
|
if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartIndex)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.Unexpected, rawValue, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop start token
|
||||||
|
m_unclosedTokens.Pop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
// Check inside function call
|
||||||
|
if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartParameters)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.Unexpected, rawValue, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop start token
|
||||||
|
m_unclosedTokens.Pop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenKind.Separator: // ","
|
||||||
|
// Check inside function call
|
||||||
|
if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartParameters)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.Unexpected, rawValue, index);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the last token kind is in the array of allowed kinds.
|
||||||
|
/// </summary>
|
||||||
|
private Boolean CheckLastToken(params TokenKind?[] allowed)
|
||||||
|
{
|
||||||
|
var lastKind = m_lastToken?.Kind;
|
||||||
|
foreach (var kind in allowed)
|
||||||
|
{
|
||||||
|
if (kind == lastKind)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly String m_expression; // Raw expression string
|
||||||
|
private readonly Stack<Token> m_unclosedTokens = new Stack<Token>(); // Unclosed start tokens
|
||||||
|
private Int32 m_index; // Index of raw expression string
|
||||||
|
private Token m_lastToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/Sdk/Expressions/Tokens/Token.cs
Normal file
211
src/Sdk/Expressions/Tokens/Token.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using GitHub.Actions.Expressions.Sdk.Operators;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Tokens
|
||||||
|
{
|
||||||
|
internal sealed class Token
|
||||||
|
{
|
||||||
|
public Token(
|
||||||
|
TokenKind kind,
|
||||||
|
String rawValue,
|
||||||
|
Int32 index,
|
||||||
|
Object parsedValue = null)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
RawValue = rawValue;
|
||||||
|
Index = index;
|
||||||
|
ParsedValue = parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenKind Kind { get; }
|
||||||
|
|
||||||
|
public String RawValue { get; }
|
||||||
|
|
||||||
|
public Int32 Index { get; }
|
||||||
|
|
||||||
|
public Object ParsedValue { get; }
|
||||||
|
|
||||||
|
public Associativity Associativity
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartGroup:
|
||||||
|
return Associativity.None;
|
||||||
|
case TokenKind.LogicalOperator:
|
||||||
|
switch (RawValue)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not: // "!"
|
||||||
|
return Associativity.RightToLeft;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsOperator ? Associativity.LeftToRight : Associativity.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean IsOperator
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartGroup: // "(" logical grouping
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
case TokenKind.StartParameters: // "(" function call
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
case TokenKind.Separator: // ","
|
||||||
|
case TokenKind.Dereference: // "."
|
||||||
|
case TokenKind.LogicalOperator: // "!", "==", etc
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator precedence. The value is only meaningful for operator tokens.
|
||||||
|
/// </summary>
|
||||||
|
public Int32 Precedence
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartGroup: // "(" logical grouping
|
||||||
|
return 20;
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
case TokenKind.StartParameters: // "(" function call
|
||||||
|
case TokenKind.Dereference: // "."
|
||||||
|
return 19;
|
||||||
|
case TokenKind.LogicalOperator:
|
||||||
|
switch (RawValue)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not: // "!"
|
||||||
|
return 16;
|
||||||
|
case ExpressionConstants.GreaterThan: // ">"
|
||||||
|
case ExpressionConstants.GreaterThanOrEqual:// ">="
|
||||||
|
case ExpressionConstants.LessThan: // "<"
|
||||||
|
case ExpressionConstants.LessThanOrEqual: // "<="
|
||||||
|
return 11;
|
||||||
|
case ExpressionConstants.Equal: // "=="
|
||||||
|
case ExpressionConstants.NotEqual: // "!="
|
||||||
|
return 10;
|
||||||
|
case ExpressionConstants.And: // "&&"
|
||||||
|
return 6;
|
||||||
|
case ExpressionConstants.Or: // "||"
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TokenKind.EndGroup: // ")" logical grouping
|
||||||
|
case TokenKind.EndIndex: // "]"
|
||||||
|
case TokenKind.EndParameters: // ")" function call
|
||||||
|
case TokenKind.Separator: // ","
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expected number of operands. The value is only meaningful for standalone unary operators and binary operators.
|
||||||
|
/// </summary>
|
||||||
|
public Int32 OperandCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
case TokenKind.Dereference: // "."
|
||||||
|
return 2;
|
||||||
|
case TokenKind.LogicalOperator:
|
||||||
|
switch (RawValue)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not: // "!"
|
||||||
|
return 1;
|
||||||
|
case ExpressionConstants.GreaterThan: // ">"
|
||||||
|
case ExpressionConstants.GreaterThanOrEqual:// ">="
|
||||||
|
case ExpressionConstants.LessThan: // "<"
|
||||||
|
case ExpressionConstants.LessThanOrEqual: // "<="
|
||||||
|
case ExpressionConstants.Equal: // "=="
|
||||||
|
case ExpressionConstants.NotEqual: // "!="
|
||||||
|
case ExpressionConstants.And: // "&&"
|
||||||
|
case ExpressionConstants.Or: // "|"
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressionNode ToNode()
|
||||||
|
{
|
||||||
|
switch (Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.StartIndex: // "["
|
||||||
|
case TokenKind.Dereference: // "."
|
||||||
|
return new Sdk.Operators.Index();
|
||||||
|
|
||||||
|
case TokenKind.LogicalOperator:
|
||||||
|
switch (RawValue)
|
||||||
|
{
|
||||||
|
case ExpressionConstants.Not: // "!"
|
||||||
|
return new Not();
|
||||||
|
|
||||||
|
case ExpressionConstants.NotEqual: // "!="
|
||||||
|
return new NotEqual();
|
||||||
|
|
||||||
|
case ExpressionConstants.GreaterThan: // ">"
|
||||||
|
return new GreaterThan();
|
||||||
|
|
||||||
|
case ExpressionConstants.GreaterThanOrEqual:// ">="
|
||||||
|
return new GreaterThanOrEqual();
|
||||||
|
|
||||||
|
case ExpressionConstants.LessThan: // "<"
|
||||||
|
return new LessThan();
|
||||||
|
|
||||||
|
case ExpressionConstants.LessThanOrEqual: // "<="
|
||||||
|
return new LessThanOrEqual();
|
||||||
|
|
||||||
|
case ExpressionConstants.Equal: // "=="
|
||||||
|
return new Equal();
|
||||||
|
|
||||||
|
case ExpressionConstants.And: // "&&"
|
||||||
|
return new And();
|
||||||
|
|
||||||
|
case ExpressionConstants.Or: // "||"
|
||||||
|
return new Or();
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected logical operator '{RawValue}' when creating node");
|
||||||
|
}
|
||||||
|
|
||||||
|
case TokenKind.Null:
|
||||||
|
case TokenKind.Boolean:
|
||||||
|
case TokenKind.Number:
|
||||||
|
case TokenKind.String:
|
||||||
|
return new Literal(ParsedValue);
|
||||||
|
|
||||||
|
case TokenKind.PropertyName:
|
||||||
|
return new Literal(RawValue);
|
||||||
|
|
||||||
|
case TokenKind.Wildcard: // "*"
|
||||||
|
return new Wildcard();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Unexpected kind '{Kind}' when creating node");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Sdk/Expressions/Tokens/TokenKind.cs
Normal file
30
src/Sdk/Expressions/Tokens/TokenKind.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions.Tokens
|
||||||
|
{
|
||||||
|
internal enum TokenKind
|
||||||
|
{
|
||||||
|
// Punctuation
|
||||||
|
StartGroup, // "(" logical grouping
|
||||||
|
StartIndex, // "["
|
||||||
|
StartParameters, // "(" function call
|
||||||
|
EndGroup, // ")" logical grouping
|
||||||
|
EndIndex, // "]"
|
||||||
|
EndParameters, // ")" function call
|
||||||
|
Separator, // ","
|
||||||
|
Dereference, // "."
|
||||||
|
Wildcard, // "*"
|
||||||
|
LogicalOperator, // "!", "==", etc
|
||||||
|
|
||||||
|
// Values
|
||||||
|
Null,
|
||||||
|
Boolean,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
PropertyName,
|
||||||
|
Function,
|
||||||
|
NamedValue,
|
||||||
|
|
||||||
|
Unexpected,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Sdk/Expressions/ValueKind.cs
Normal file
14
src/Sdk/Expressions/ValueKind.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.Expressions
|
||||||
|
{
|
||||||
|
public enum ValueKind
|
||||||
|
{
|
||||||
|
Array,
|
||||||
|
Boolean,
|
||||||
|
Null,
|
||||||
|
Number,
|
||||||
|
Object,
|
||||||
|
String,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<NoWarn>NU1701;NU1603;SYSLIB0050;SYSLIB0051</NoWarn>
|
<NoWarn>NU1701;NU1603;SYSLIB0050;SYSLIB0051</NoWarn>
|
||||||
<Version>$(Version)</Version>
|
<Version>$(Version)</Version>
|
||||||
<DefineConstants>TRACE</DefineConstants>
|
<DefineConstants>TRACE</DefineConstants>
|
||||||
<LangVersion>8.0</LangVersion>
|
<LangVersion>11.0</LangVersion>
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -33,5 +33,8 @@
|
|||||||
<EmbeddedResource Include="DTPipelines\workflow-v1.0.json">
|
<EmbeddedResource Include="DTPipelines\workflow-v1.0.json">
|
||||||
<LogicalName>GitHub.DistributedTask.Pipelines.ObjectTemplating.workflow-v1.0.json</LogicalName>
|
<LogicalName>GitHub.DistributedTask.Pipelines.ObjectTemplating.workflow-v1.0.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="WorkflowParser\workflow-v1.0.json">
|
||||||
|
<LogicalName>GitHub.Actions.WorkflowParser.workflow-v1.0.json</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
85
src/Sdk/WorkflowParser/ActionStep.cs
Normal file
85
src/Sdk/WorkflowParser/ActionStep.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser
|
||||||
|
{
|
||||||
|
[DataContract]
|
||||||
|
public sealed class ActionStep : IStep
|
||||||
|
{
|
||||||
|
[DataMember(Order = 0, Name = "id", EmitDefaultValue = false)]
|
||||||
|
public string? Id
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the display name
|
||||||
|
/// </summary>
|
||||||
|
[DataMember(Order = 1, Name = "name", EmitDefaultValue = false)]
|
||||||
|
public ScalarToken? Name
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 2, Name = "if", EmitDefaultValue = false)]
|
||||||
|
public BasicExpressionToken? If
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 3, Name = "continue-on-error", EmitDefaultValue = false)]
|
||||||
|
public ScalarToken? ContinueOnError
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 4, Name = "timeout-minutes", EmitDefaultValue = false)]
|
||||||
|
public ScalarToken? TimeoutMinutes
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 5, Name = "env", EmitDefaultValue = false)]
|
||||||
|
public TemplateToken? Env
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 6, Name = "uses", EmitDefaultValue = false)]
|
||||||
|
public StringToken? Uses
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Order = 7, Name = "with", EmitDefaultValue = false)]
|
||||||
|
public TemplateToken? With
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IStep Clone(bool omitSource)
|
||||||
|
{
|
||||||
|
return new ActionStep
|
||||||
|
{
|
||||||
|
ContinueOnError = ContinueOnError?.Clone(omitSource) as ScalarToken,
|
||||||
|
Env = Env?.Clone(omitSource),
|
||||||
|
Id = Id,
|
||||||
|
If = If?.Clone(omitSource) as BasicExpressionToken,
|
||||||
|
Name = Name?.Clone(omitSource) as ScalarToken,
|
||||||
|
TimeoutMinutes = TimeoutMinutes?.Clone(omitSource) as ScalarToken,
|
||||||
|
Uses = Uses?.Clone(omitSource) as StringToken,
|
||||||
|
With = With?.Clone(omitSource),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Sdk/WorkflowParser/ActionsEnvironmentReference.cs
Normal file
25
src/Sdk/WorkflowParser/ActionsEnvironmentReference.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Information about an environment parsed from YML with evaluated name, URL will be evaluated on runner
|
||||||
|
/// </summary>
|
||||||
|
[DataContract]
|
||||||
|
public sealed class ActionsEnvironmentReference
|
||||||
|
{
|
||||||
|
public ActionsEnvironmentReference(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public TemplateToken? Url { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Sdk/WorkflowParser/CollectionsExtensions.cs
Normal file
22
src/Sdk/WorkflowParser/CollectionsExtensions.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser
|
||||||
|
{
|
||||||
|
internal static class CollectionsExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds all of the given values to this collection.
|
||||||
|
/// Can be used with dictionaries, which implement <see cref="ICollection{T}"/> and <see cref="IEnumerable{T}"/> where T is <see cref="KeyValuePair{TKey, TValue}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static TCollection AddRange<T, TCollection>(this TCollection collection, IEnumerable<T> values)
|
||||||
|
where TCollection : ICollection<T>
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
collection.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Sdk/WorkflowParser/Conversion/EmptyServerTraceWriter.cs
Normal file
14
src/Sdk/WorkflowParser/Conversion/EmptyServerTraceWriter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal sealed class EmptyServerTraceWriter : IServerTraceWriter
|
||||||
|
{
|
||||||
|
public void TraceAlways(
|
||||||
|
Int32 tracepoint,
|
||||||
|
String format,
|
||||||
|
params Object[] arguments)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/Sdk/WorkflowParser/Conversion/IdBuilder.cs
Normal file
183
src/Sdk/WorkflowParser/Conversion/IdBuilder.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builder for job and step IDs
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class IdBuilder
|
||||||
|
{
|
||||||
|
internal void AppendSegment(String value)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_name.Length == 0)
|
||||||
|
{
|
||||||
|
var first = value[0];
|
||||||
|
if ((first >= 'a' && first <= 'z') ||
|
||||||
|
(first >= 'A' && first <= 'Z') ||
|
||||||
|
first == '_')
|
||||||
|
{
|
||||||
|
// Legal first char
|
||||||
|
}
|
||||||
|
else if ((first >= '0' && first <= '9') || first == '-')
|
||||||
|
{
|
||||||
|
// Illegal first char, but legal char.
|
||||||
|
// Prepend "_".
|
||||||
|
m_name.Append("_");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Illegal char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Separator
|
||||||
|
m_name.Append(c_separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
if ((c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') ||
|
||||||
|
c == '_' ||
|
||||||
|
c == '-')
|
||||||
|
{
|
||||||
|
// Legal
|
||||||
|
m_name.Append(c);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Illegal
|
||||||
|
m_name.Append("_");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the ID from the segments
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="allowReservedPrefix">When true, generated IDs may begin with "__" depending upon the segments
|
||||||
|
/// and collisions with known IDs. When false, generated IDs will never begin with the reserved prefix "__".</param>
|
||||||
|
/// <param name="maxLength">The maximum length of the generated ID.</param>
|
||||||
|
internal String Build(
|
||||||
|
Boolean allowReservedPrefix,
|
||||||
|
Int32 maxLength = WorkflowConstants.MaxNodeNameLength)
|
||||||
|
{
|
||||||
|
// Ensure reasonable max length
|
||||||
|
if (maxLength <= 5) // Must be long enough to accommodate at least one character + length of max suffix "_999" (refer suffix logic further below)
|
||||||
|
{
|
||||||
|
maxLength = WorkflowConstants.MaxNodeNameLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
var original = m_name.Length > 0 ? m_name.ToString() : "job";
|
||||||
|
|
||||||
|
// Avoid prefix "__" when not allowed
|
||||||
|
if (!allowReservedPrefix && original.StartsWith("__", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
original = $"_{original.TrimStart('_')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var attempt = 1;
|
||||||
|
var suffix = default(String);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (attempt == 1)
|
||||||
|
{
|
||||||
|
suffix = String.Empty;
|
||||||
|
}
|
||||||
|
else if (attempt < 1000)
|
||||||
|
{
|
||||||
|
// Special case to avoid prefix "__" when not allowed
|
||||||
|
if (!allowReservedPrefix && String.Equals(original, "_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
suffix = String.Format(CultureInfo.InvariantCulture, "{0}", attempt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
suffix = String.Format(CultureInfo.InvariantCulture, "_{0}", attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to create a unique name");
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = original.Substring(0, Math.Min(original.Length, maxLength - suffix.Length)) + suffix;
|
||||||
|
|
||||||
|
if (m_distinctNames.Add(candidate))
|
||||||
|
{
|
||||||
|
m_name.Clear();
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Boolean TryAddKnownId(
|
||||||
|
String value,
|
||||||
|
out String error)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(value) ||
|
||||||
|
!IsValid(value) ||
|
||||||
|
value.Length >= WorkflowConstants.MaxNodeNameLength)
|
||||||
|
{
|
||||||
|
error = $"The identifier '{value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and must be less than {WorkflowConstants.MaxNodeNameLength} characters.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (value.StartsWith("__", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
error = $"The identifier '{value}' is invalid. IDs starting with '__' are reserved.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (!m_distinctNames.Add(value))
|
||||||
|
{
|
||||||
|
error = $"The identifier '{value}' may not be used more than once within the same scope.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean IsValid(String name)
|
||||||
|
{
|
||||||
|
var result = true;
|
||||||
|
for (Int32 i = 0; i < name.Length; i++)
|
||||||
|
{
|
||||||
|
if ((name[i] >= 'a' && name[i] <= 'z') ||
|
||||||
|
(name[i] >= 'A' && name[i] <= 'Z') ||
|
||||||
|
(name[i] >= '0' && name[i] <= '9' && i > 0) ||
|
||||||
|
(name[i] == '_') ||
|
||||||
|
(name[i] == '-' && i > 0))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const String c_separator = "_";
|
||||||
|
private readonly HashSet<String> m_distinctNames = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly StringBuilder m_name = new StringBuilder();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Sdk/WorkflowParser/Conversion/JobCountValidator.cs
Normal file
44
src/Sdk/WorkflowParser/Conversion/JobCountValidator.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal sealed class JobCountValidator
|
||||||
|
{
|
||||||
|
public JobCountValidator(
|
||||||
|
TemplateContext context,
|
||||||
|
Int32 maxCount)
|
||||||
|
{
|
||||||
|
m_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
m_maxCount = maxCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increments the job counter.
|
||||||
|
///
|
||||||
|
/// Appends an error to the template context only when the max job count is initially exceeded.
|
||||||
|
/// Additional calls will not append more errors.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The token to use for error reporting.</param>
|
||||||
|
public void Increment(TemplateToken? token)
|
||||||
|
{
|
||||||
|
// Initial breach?
|
||||||
|
if (m_maxCount > 0 &&
|
||||||
|
m_count + 1 > m_maxCount &&
|
||||||
|
m_count <= m_maxCount)
|
||||||
|
{
|
||||||
|
m_context.Error(token, $"Workflows may not contain more than {m_maxCount} jobs across all referenced files");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment
|
||||||
|
m_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TemplateContext m_context;
|
||||||
|
private readonly Int32 m_maxCount;
|
||||||
|
private Int32 m_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Sdk/WorkflowParser/Conversion/JobNameBuilder.cs
Normal file
64
src/Sdk/WorkflowParser/Conversion/JobNameBuilder.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builder for job display names. Used when appending strategy configuration values to build a display name.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class JobNameBuilder
|
||||||
|
{
|
||||||
|
public JobNameBuilder(String jobName)
|
||||||
|
{
|
||||||
|
if (!String.IsNullOrEmpty(jobName))
|
||||||
|
{
|
||||||
|
m_jobName = jobName;
|
||||||
|
m_segments = new List<String>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AppendSegment(String value)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(value) || m_segments == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_segments.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String Build()
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(m_jobName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = default(String);
|
||||||
|
if (m_segments.Count == 0)
|
||||||
|
{
|
||||||
|
name = m_jobName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var joinedSegments = String.Join(", ", m_segments);
|
||||||
|
name = String.Format(CultureInfo.InvariantCulture, "{0} ({1})", m_jobName, joinedSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Int32 maxNameLength = 100;
|
||||||
|
if (name.Length > maxNameLength)
|
||||||
|
{
|
||||||
|
name = name.Substring(0, maxNameLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
m_segments.Clear();
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly String m_jobName;
|
||||||
|
private readonly List<String> m_segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/Sdk/WorkflowParser/Conversion/JsonObjectReader.cs
Normal file
236
src/Sdk/WorkflowParser/Conversion/JsonObjectReader.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal sealed class JsonObjectReader : IObjectReader
|
||||||
|
{
|
||||||
|
internal JsonObjectReader(
|
||||||
|
Int32? fileId,
|
||||||
|
String input)
|
||||||
|
{
|
||||||
|
m_fileId = fileId;
|
||||||
|
var token = JToken.Parse(input);
|
||||||
|
m_enumerator = GetEvents(token, true).GetEnumerator();
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean AllowLiteral(out LiteralToken literal)
|
||||||
|
{
|
||||||
|
var current = m_enumerator.Current;
|
||||||
|
switch (current.Type)
|
||||||
|
{
|
||||||
|
case ParseEventType.Null:
|
||||||
|
literal = new NullToken(m_fileId, current.Line, current.Column);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ParseEventType.Boolean:
|
||||||
|
literal = new BooleanToken(m_fileId, current.Line, current.Column, (Boolean)current.Value);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ParseEventType.Number:
|
||||||
|
literal = new NumberToken(m_fileId, current.Line, current.Column, (Double)current.Value);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ParseEventType.String:
|
||||||
|
literal = new StringToken(m_fileId, current.Line, current.Column, (String)current.Value);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
literal = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean AllowSequenceStart(out SequenceToken sequence)
|
||||||
|
{
|
||||||
|
var current = m_enumerator.Current;
|
||||||
|
if (current.Type == ParseEventType.SequenceStart)
|
||||||
|
{
|
||||||
|
sequence = new SequenceToken(m_fileId, current.Line, current.Column);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean AllowSequenceEnd()
|
||||||
|
{
|
||||||
|
if (m_enumerator.Current.Type == ParseEventType.SequenceEnd)
|
||||||
|
{
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean AllowMappingStart(out MappingToken mapping)
|
||||||
|
{
|
||||||
|
var current = m_enumerator.Current;
|
||||||
|
if (current.Type == ParseEventType.MappingStart)
|
||||||
|
{
|
||||||
|
mapping = new MappingToken(m_fileId, current.Line, current.Column);
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean AllowMappingEnd()
|
||||||
|
{
|
||||||
|
if (m_enumerator.Current.Type == ParseEventType.MappingEnd)
|
||||||
|
{
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes the last parsing events, which are expected to be DocumentEnd and StreamEnd.
|
||||||
|
/// </summary>
|
||||||
|
public void ValidateEnd()
|
||||||
|
{
|
||||||
|
if (m_enumerator.Current.Type == ParseEventType.DocumentEnd)
|
||||||
|
{
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Expected end of reader");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes the first parsing events, which are expected to be StreamStart and DocumentStart.
|
||||||
|
/// </summary>
|
||||||
|
public void ValidateStart()
|
||||||
|
{
|
||||||
|
if (m_enumerator.Current.Type == ParseEventType.DocumentStart)
|
||||||
|
{
|
||||||
|
m_enumerator.MoveNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Expected start of reader");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<ParseEvent> GetEvents(
|
||||||
|
JToken token,
|
||||||
|
Boolean root = false)
|
||||||
|
{
|
||||||
|
if (root)
|
||||||
|
{
|
||||||
|
yield return new ParseEvent(0, 0, ParseEventType.DocumentStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineInfo = token as Newtonsoft.Json.IJsonLineInfo;
|
||||||
|
var line = lineInfo.LineNumber;
|
||||||
|
var column = lineInfo.LinePosition;
|
||||||
|
|
||||||
|
switch (token.Type)
|
||||||
|
{
|
||||||
|
case JTokenType.Null:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.Null, null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JTokenType.Boolean:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.Boolean, token.ToObject<Boolean>());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JTokenType.Float:
|
||||||
|
case JTokenType.Integer:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.Number, token.ToObject<Double>());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JTokenType.String:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.String, token.ToObject<String>());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JTokenType.Array:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.SequenceStart);
|
||||||
|
foreach (var item in (token as JArray))
|
||||||
|
{
|
||||||
|
foreach (var e in GetEvents(item))
|
||||||
|
{
|
||||||
|
yield return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.SequenceEnd);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JTokenType.Object:
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.MappingStart);
|
||||||
|
foreach (var pair in (token as JObject))
|
||||||
|
{
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.String, pair.Key ?? String.Empty);
|
||||||
|
foreach (var e in GetEvents(pair.Value))
|
||||||
|
{
|
||||||
|
yield return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield return new ParseEvent(line, column, ParseEventType.MappingEnd);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected JTokenType {token.Type}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root)
|
||||||
|
{
|
||||||
|
yield return new ParseEvent(0, 0, ParseEventType.DocumentEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ParseEvent
|
||||||
|
{
|
||||||
|
public ParseEvent(
|
||||||
|
Int32 line,
|
||||||
|
Int32 column,
|
||||||
|
ParseEventType type,
|
||||||
|
Object value = null)
|
||||||
|
{
|
||||||
|
Line = line;
|
||||||
|
Column = column;
|
||||||
|
Type = type;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly Int32 Line;
|
||||||
|
public readonly Int32 Column;
|
||||||
|
public readonly ParseEventType Type;
|
||||||
|
public readonly Object Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ParseEventType
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Null,
|
||||||
|
Boolean,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
SequenceStart,
|
||||||
|
SequenceEnd,
|
||||||
|
MappingStart,
|
||||||
|
MappingEnd,
|
||||||
|
DocumentStart,
|
||||||
|
DocumentEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator<ParseEvent> m_enumerator;
|
||||||
|
private Int32? m_fileId;
|
||||||
|
}
|
||||||
|
}
|
||||||
738
src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs
Normal file
738
src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using GitHub.Actions.Expressions;
|
||||||
|
using GitHub.Actions.Expressions.Data;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to build a matrix cross product and apply include/exclude filters.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MatrixBuilder
|
||||||
|
{
|
||||||
|
internal MatrixBuilder(
|
||||||
|
TemplateContext context,
|
||||||
|
String jobName)
|
||||||
|
{
|
||||||
|
m_context = context;
|
||||||
|
m_jobName = jobName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an input vector. <c ref="Build" /> creates a cross product from all input vectors.
|
||||||
|
///
|
||||||
|
/// For example, given the matrix:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// os: [linux, windows]
|
||||||
|
///
|
||||||
|
/// This method should be called twice:
|
||||||
|
/// AddVector("arch", ...);
|
||||||
|
/// AddVector("os", ...)
|
||||||
|
/// </summary>
|
||||||
|
internal void AddVector(
|
||||||
|
String name,
|
||||||
|
SequenceToken vector)
|
||||||
|
{
|
||||||
|
m_vectors.Add(name, vector.ToExpressionData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the sequence containg all exclude mappings.
|
||||||
|
/// </summary>
|
||||||
|
internal void Exclude(SequenceToken exclude)
|
||||||
|
{
|
||||||
|
m_excludeSequence = exclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the sequence containg all include mappings.
|
||||||
|
/// </summary>
|
||||||
|
internal void Include(SequenceToken include)
|
||||||
|
{
|
||||||
|
m_includeSequence = include;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the matrix.
|
||||||
|
///
|
||||||
|
/// In addition to computing the cross product of all input vectors, this method also:
|
||||||
|
/// 1. Applies all exclude filters against each cross product vector
|
||||||
|
/// 2. Applies all include filters against each cross product vector, which may
|
||||||
|
/// add additional values into existing vectors
|
||||||
|
/// 3. Appends all unmatched include vectors, as additional result vectors
|
||||||
|
///
|
||||||
|
/// Example 1, simple cross product:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// os: [linux, windows]
|
||||||
|
/// The result would contain the following vectors:
|
||||||
|
/// [arch: x64, os: linux]
|
||||||
|
/// [arch: x64, os: windows]
|
||||||
|
/// [arch: x86, os: linux]
|
||||||
|
/// [arch: x86, os: windows]
|
||||||
|
///
|
||||||
|
/// Example 2, using exclude filter:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// os: [linux, windows]
|
||||||
|
/// exclude:
|
||||||
|
/// - arch: x86
|
||||||
|
/// os: linux
|
||||||
|
/// The result would contain the following vectors:
|
||||||
|
/// [arch: x64, os: linux]
|
||||||
|
/// [arch: x64, os: windows]
|
||||||
|
/// [arch: x86, os: windows]
|
||||||
|
///
|
||||||
|
/// Example 3, using include filter to add additional values:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// os: [linux, windows]
|
||||||
|
/// include:
|
||||||
|
/// - arch: x64
|
||||||
|
/// os: linux
|
||||||
|
/// publish: true
|
||||||
|
/// The result would contain the following vectors:
|
||||||
|
/// [arch: x64, os: linux, publish: true]
|
||||||
|
/// [arch: x64, os: windows]
|
||||||
|
/// [arch: x86, os: linux]
|
||||||
|
/// [arch: x86, os: windows]
|
||||||
|
///
|
||||||
|
/// Example 4, include additional vectors:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// os: [linux, windows]
|
||||||
|
/// include:
|
||||||
|
/// - arch: x64
|
||||||
|
/// - os: macos
|
||||||
|
/// The result would contain the following vectors:
|
||||||
|
/// [arch: x64, os: linux]
|
||||||
|
/// [arch: x64, os: windows]
|
||||||
|
/// [arch: x86, os: linux]
|
||||||
|
/// [arch: x86, os: windows]
|
||||||
|
/// [arch: x64, os: macos]
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>One strategy configuration per result vector</returns>
|
||||||
|
internal IEnumerable<StrategyConfiguration> Build()
|
||||||
|
{
|
||||||
|
// Parse includes/excludes
|
||||||
|
var include = new MatrixInclude(m_context, m_vectors, m_includeSequence);
|
||||||
|
var exclude = new MatrixExclude(m_context, m_vectors, m_excludeSequence);
|
||||||
|
|
||||||
|
// Calculate the cross product size
|
||||||
|
int productSize;
|
||||||
|
if (m_vectors.Count > 0)
|
||||||
|
{
|
||||||
|
productSize = 1;
|
||||||
|
foreach (var vectorPair in m_vectors)
|
||||||
|
{
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
var vector = vectorPair.Value.AssertArray("vector");
|
||||||
|
productSize *= vector.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
productSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idBuilder = new IdBuilder();
|
||||||
|
|
||||||
|
// Cross product vectors
|
||||||
|
for (var productIndex = 0; productIndex < productSize; productIndex++)
|
||||||
|
{
|
||||||
|
// Matrix
|
||||||
|
var matrix = new DictionaryExpressionData();
|
||||||
|
var blockSize = productSize;
|
||||||
|
foreach (var vectorPair in m_vectors)
|
||||||
|
{
|
||||||
|
var vectorName = vectorPair.Key;
|
||||||
|
var vector = vectorPair.Value.AssertArray("vector");
|
||||||
|
blockSize = blockSize / vector.Count;
|
||||||
|
var vectorIndex = (productIndex / blockSize) % vector.Count;
|
||||||
|
matrix.Add(vectorName, vector[vectorIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude
|
||||||
|
if (exclude.Match(matrix))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include extra values in the vector
|
||||||
|
include.Match(matrix, out var extra);
|
||||||
|
|
||||||
|
// Create the configuration
|
||||||
|
yield return CreateConfiguration(idBuilder, matrix, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit vectors
|
||||||
|
foreach (var matrix in include.GetUnmatchedVectors())
|
||||||
|
{
|
||||||
|
yield return CreateConfiguration(idBuilder, matrix, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StrategyConfiguration CreateConfiguration(
|
||||||
|
IdBuilder idBuilder,
|
||||||
|
DictionaryExpressionData matrix,
|
||||||
|
DictionaryExpressionData extra)
|
||||||
|
{
|
||||||
|
// New configuration
|
||||||
|
var configuration = new StrategyConfiguration();
|
||||||
|
m_context.Memory.AddBytes(TemplateMemory.MinObjectSize);
|
||||||
|
|
||||||
|
// Gather segments for ID and display name
|
||||||
|
var nameBuilder = new JobNameBuilder(m_jobName);
|
||||||
|
foreach (var matrixData in matrix.Traverse(omitKeys: true))
|
||||||
|
{
|
||||||
|
var segment = default(String);
|
||||||
|
if (matrixData is BooleanExpressionData || matrixData is NumberExpressionData || matrixData is StringExpressionData)
|
||||||
|
{
|
||||||
|
segment = matrixData.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(segment))
|
||||||
|
{
|
||||||
|
// ID segment
|
||||||
|
idBuilder.AppendSegment(segment);
|
||||||
|
|
||||||
|
// Display name segment
|
||||||
|
nameBuilder.AppendSegment(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Id
|
||||||
|
configuration.Id = idBuilder.Build(allowReservedPrefix: false, maxLength: m_context.GetFeatures().ShortMatrixIds ? 25 : WorkflowConstants.MaxNodeNameLength);
|
||||||
|
m_context.Memory.AddBytes(configuration.Id);
|
||||||
|
|
||||||
|
// Display name
|
||||||
|
configuration.Name = nameBuilder.Build();
|
||||||
|
m_context.Memory.AddBytes(configuration.Name);
|
||||||
|
|
||||||
|
// Extra values
|
||||||
|
if (extra?.Count > 0)
|
||||||
|
{
|
||||||
|
matrix.Add(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix context
|
||||||
|
configuration.ExpressionData.Add(WorkflowTemplateConstants.Matrix, matrix);
|
||||||
|
m_context.Memory.AddBytes(WorkflowTemplateConstants.Matrix);
|
||||||
|
m_context.Memory.AddBytes(matrix, traverse: true);
|
||||||
|
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the sequence "strategy.matrix.include"
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MatrixInclude
|
||||||
|
{
|
||||||
|
public MatrixInclude(
|
||||||
|
TemplateContext context,
|
||||||
|
DictionaryExpressionData vectors,
|
||||||
|
SequenceToken includeSequence)
|
||||||
|
{
|
||||||
|
// Convert to includes sets
|
||||||
|
if (includeSequence?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var includeItem in includeSequence)
|
||||||
|
{
|
||||||
|
var includeMapping = includeItem.AssertMapping("matrix includes item");
|
||||||
|
|
||||||
|
// Distinguish filters versus extra
|
||||||
|
var filter = new MappingToken(null, null, null);
|
||||||
|
var extra = new DictionaryExpressionData();
|
||||||
|
foreach (var includePair in includeMapping)
|
||||||
|
{
|
||||||
|
var includeKeyLiteral = includePair.Key.AssertString("matrix include item key");
|
||||||
|
if (vectors.ContainsKey(includeKeyLiteral.Value))
|
||||||
|
{
|
||||||
|
filter.Add(includeKeyLiteral, includePair.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
extra.Add(includeKeyLiteral.Value, includePair.Value.ToExpressionData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one filter or extra
|
||||||
|
if (filter.Count == 0 && extra.Count == 0)
|
||||||
|
{
|
||||||
|
context.Error(includeMapping, $"Matrix include mapping does not contain any values");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter
|
||||||
|
m_filters.Add(new MatrixIncludeFilter(filter, extra));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_matches = new Boolean[m_filters.Count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches a vector from the cross product against each include filter.
|
||||||
|
///
|
||||||
|
/// For example, given the matrix:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// config: [release, debug]
|
||||||
|
/// include:
|
||||||
|
/// - arch: x64
|
||||||
|
/// config: release
|
||||||
|
/// publish: true
|
||||||
|
///
|
||||||
|
/// This method would return the following:
|
||||||
|
/// Match(
|
||||||
|
/// matrix: {arch: x64, config: release},
|
||||||
|
/// out extra: {publish: true})
|
||||||
|
/// => true
|
||||||
|
///
|
||||||
|
/// Match(
|
||||||
|
/// matrix: {arch: x64, config: debug},
|
||||||
|
/// out extra: null)
|
||||||
|
/// => false
|
||||||
|
///
|
||||||
|
/// Match(
|
||||||
|
/// matrix: {arch: x86, config: release},
|
||||||
|
/// out extra: null)
|
||||||
|
/// => false
|
||||||
|
///
|
||||||
|
/// Match(
|
||||||
|
/// matrix: {arch: x86, config: debug},
|
||||||
|
/// out extra: null)
|
||||||
|
/// => false
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="matrix">A vector of the cross product</param>
|
||||||
|
/// <param name="extra">Extra values to add to the vector</param>
|
||||||
|
/// <returns>True if the vector matched at least one include filter</returns>
|
||||||
|
public Boolean Match(
|
||||||
|
DictionaryExpressionData matrix,
|
||||||
|
out DictionaryExpressionData extra)
|
||||||
|
{
|
||||||
|
extra = default(DictionaryExpressionData);
|
||||||
|
for (var i = 0; i < m_filters.Count; i++)
|
||||||
|
{
|
||||||
|
var filter = m_filters[i];
|
||||||
|
if (filter.Match(matrix, out var items))
|
||||||
|
{
|
||||||
|
m_matches[i] = true;
|
||||||
|
|
||||||
|
if (extra == null)
|
||||||
|
{
|
||||||
|
extra = new DictionaryExpressionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in items)
|
||||||
|
{
|
||||||
|
extra[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extra != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all additional vectors to add. These are additional configurations that were not produced
|
||||||
|
/// from the cross product. These are include vectors that did not match any cross product results.
|
||||||
|
///
|
||||||
|
/// For example, given the matrix:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// config: [release, debug]
|
||||||
|
/// include:
|
||||||
|
/// - arch: arm64
|
||||||
|
/// config: debug
|
||||||
|
///
|
||||||
|
/// This method would return the following:
|
||||||
|
/// - {arch: arm64, config: debug}
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<DictionaryExpressionData> GetUnmatchedVectors()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < m_filters.Count; i++)
|
||||||
|
{
|
||||||
|
if (m_matches[i])
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = m_filters[i];
|
||||||
|
var matrix = new DictionaryExpressionData();
|
||||||
|
foreach (var pair in filter.Filter)
|
||||||
|
{
|
||||||
|
var keyLiteral = pair.Key.AssertString("matrix include item key");
|
||||||
|
matrix.Add(keyLiteral.Value, pair.Value.ToExpressionData());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var includePair in filter.Extra)
|
||||||
|
{
|
||||||
|
matrix.Add(includePair.Key, includePair.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return matrix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<MatrixIncludeFilter> m_filters = new List<MatrixIncludeFilter>();
|
||||||
|
|
||||||
|
// Tracks whether a filter has been matched
|
||||||
|
private readonly Boolean[] m_matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an item within the sequence "strategy.matrix.include"
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MatrixIncludeFilter : MatrixFilter
|
||||||
|
{
|
||||||
|
public MatrixIncludeFilter(
|
||||||
|
MappingToken filter,
|
||||||
|
DictionaryExpressionData extra)
|
||||||
|
: base(filter)
|
||||||
|
{
|
||||||
|
Filter = filter;
|
||||||
|
Extra = extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean Match(
|
||||||
|
DictionaryExpressionData matrix,
|
||||||
|
out DictionaryExpressionData extra)
|
||||||
|
{
|
||||||
|
if (base.Match(matrix))
|
||||||
|
{
|
||||||
|
extra = Extra;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
extra = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DictionaryExpressionData Extra { get; }
|
||||||
|
public MappingToken Filter { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the sequence "strategy.matrix.exclude"
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MatrixExclude
|
||||||
|
{
|
||||||
|
public MatrixExclude(
|
||||||
|
TemplateContext context,
|
||||||
|
DictionaryExpressionData vectors,
|
||||||
|
SequenceToken excludeSequence)
|
||||||
|
{
|
||||||
|
// Convert to excludes sets
|
||||||
|
if (excludeSequence?.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var excludeItem in excludeSequence)
|
||||||
|
{
|
||||||
|
var excludeMapping = excludeItem.AssertMapping("matrix excludes item");
|
||||||
|
|
||||||
|
// Check empty
|
||||||
|
if (excludeMapping.Count == 0)
|
||||||
|
{
|
||||||
|
context.Error(excludeMapping, $"Matrix exclude filter must not be empty");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate first-level keys
|
||||||
|
foreach (var excludePair in excludeMapping)
|
||||||
|
{
|
||||||
|
var excludeKey = excludePair.Key.AssertString("matrix excludes item key");
|
||||||
|
if (!vectors.ContainsKey(excludeKey.Value))
|
||||||
|
{
|
||||||
|
context.Error(excludeKey, $"Matrix exclude key '{excludeKey.Value}' does not match any key within the matrix");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter
|
||||||
|
m_filters.Add(new MatrixExcludeFilter(excludeMapping));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches a vector from the cross product against each exclude filter.
|
||||||
|
///
|
||||||
|
/// For example, given the matrix:
|
||||||
|
/// arch: [x64, x86]
|
||||||
|
/// config: [release, debug]
|
||||||
|
/// exclude:
|
||||||
|
/// - arch: x86
|
||||||
|
/// config: release
|
||||||
|
///
|
||||||
|
/// This method would return the following:
|
||||||
|
/// Match( {arch: x64, config: release} ) => false
|
||||||
|
/// Match( {arch: x64, config: debug} ) => false
|
||||||
|
/// Match( {arch: x86, config: release} ) => true
|
||||||
|
/// Match( {arch: x86, config: debug} ) => false
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="matrix">A vector of the cross product</param>
|
||||||
|
/// <param name="extra">Extra values to add to the vector</param>
|
||||||
|
/// <returns>True if the vector matched at least one exclude filter</returns>
|
||||||
|
public Boolean Match(DictionaryExpressionData matrix)
|
||||||
|
{
|
||||||
|
foreach (var filter in m_filters)
|
||||||
|
{
|
||||||
|
if (filter.Match(matrix))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<MatrixExcludeFilter> m_filters = new List<MatrixExcludeFilter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an item within the sequence "strategy.matrix.exclude"
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MatrixExcludeFilter : MatrixFilter
|
||||||
|
{
|
||||||
|
public MatrixExcludeFilter(MappingToken filter)
|
||||||
|
: base(filter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public new Boolean Match(DictionaryExpressionData matrix)
|
||||||
|
{
|
||||||
|
return base.Match(matrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for matrix include/exclude filters. That is, an item within the
|
||||||
|
/// sequence "strategy.matrix.include" or within the sequence "strategy.matrix.exclude".
|
||||||
|
/// </summary>
|
||||||
|
private abstract class MatrixFilter
|
||||||
|
{
|
||||||
|
protected MatrixFilter(MappingToken matrixFilter)
|
||||||
|
{
|
||||||
|
// Traverse the structure and add an expression to compare each leaf node.
|
||||||
|
// For example, given the filter:
|
||||||
|
// versions:
|
||||||
|
// node-version: 12
|
||||||
|
// npm-version: 6
|
||||||
|
// config: release
|
||||||
|
// The following filter expressions would be created:
|
||||||
|
// - matrix.versions.node-version == 12
|
||||||
|
// - matrix.versions.npm-version == 6
|
||||||
|
// - matrix.config == 'release'
|
||||||
|
var state = new MappingState(null, matrixFilter) as TokenState;
|
||||||
|
while (state != null)
|
||||||
|
{
|
||||||
|
if (state.MoveNext())
|
||||||
|
{
|
||||||
|
// Leaf
|
||||||
|
if (state.Current is LiteralToken literal)
|
||||||
|
{
|
||||||
|
AddExpression(state.Path, literal);
|
||||||
|
}
|
||||||
|
// Mapping
|
||||||
|
else if (state.Current is MappingToken mapping)
|
||||||
|
{
|
||||||
|
state = new MappingState(state, mapping);
|
||||||
|
}
|
||||||
|
// Sequence
|
||||||
|
else if (state.Current is SequenceToken sequence)
|
||||||
|
{
|
||||||
|
state = new SequenceState(state, sequence);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Unexpected token type '{state.Current.Type}' when constructing matrix filter expressions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = state.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Boolean Match(DictionaryExpressionData matrix)
|
||||||
|
{
|
||||||
|
if (matrix.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Matrix filter cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var expression in m_expressions)
|
||||||
|
{
|
||||||
|
var result = expression.Evaluate(null, null, matrix, null);
|
||||||
|
if (result.IsFalsy)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddExpression(
|
||||||
|
String path,
|
||||||
|
LiteralToken literal)
|
||||||
|
{
|
||||||
|
var expressionLiteral = default(String);
|
||||||
|
switch (literal.Type)
|
||||||
|
{
|
||||||
|
case TokenType.Null:
|
||||||
|
expressionLiteral = ExpressionConstants.Null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenType.Boolean:
|
||||||
|
var booleanToken = literal as BooleanToken;
|
||||||
|
expressionLiteral = ExpressionUtility.ConvertToParseToken(booleanToken.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenType.Number:
|
||||||
|
var numberToken = literal as NumberToken;
|
||||||
|
expressionLiteral = ExpressionUtility.ConvertToParseToken(numberToken.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TokenType.String:
|
||||||
|
var stringToken = literal as StringToken;
|
||||||
|
expressionLiteral = ExpressionUtility.ConvertToParseToken(stringToken.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected literal type '{literal.Type}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parser = new ExpressionParser();
|
||||||
|
var expressionString = $"{path} == {expressionLiteral}";
|
||||||
|
var expression = parser.CreateTree(expressionString, null, s_matrixFilterNamedValues, null);
|
||||||
|
m_expressions.Add(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to maintain state while traversing a mapping when building filter expressions.
|
||||||
|
/// See <see cref="MatrixFilter"/> for more info.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MappingState : TokenState
|
||||||
|
{
|
||||||
|
public MappingState(
|
||||||
|
TokenState parent,
|
||||||
|
MappingToken mapping)
|
||||||
|
: base(parent)
|
||||||
|
{
|
||||||
|
m_mapping = mapping;
|
||||||
|
m_index = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Boolean MoveNext()
|
||||||
|
{
|
||||||
|
if (++m_index < m_mapping.Count)
|
||||||
|
{
|
||||||
|
var pair = m_mapping[m_index];
|
||||||
|
var keyLiteral = pair.Key.AssertString("matrix filter key");
|
||||||
|
Current = pair.Value;
|
||||||
|
var parentPath = Parent?.Path ?? WorkflowTemplateConstants.Matrix;
|
||||||
|
Path = $"{parentPath}[{ExpressionUtility.ConvertToParseToken(keyLiteral.Value)}]";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
Path = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MappingToken m_mapping;
|
||||||
|
private Int32 m_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to maintain state while traversing a sequence when building filter expressions.
|
||||||
|
/// See <see cref="MatrixFilter"/> for more info.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SequenceState : TokenState
|
||||||
|
{
|
||||||
|
public SequenceState(
|
||||||
|
TokenState parent,
|
||||||
|
SequenceToken sequence)
|
||||||
|
: base(parent)
|
||||||
|
{
|
||||||
|
m_sequence = sequence;
|
||||||
|
m_index = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Boolean MoveNext()
|
||||||
|
{
|
||||||
|
if (++m_index < m_sequence.Count)
|
||||||
|
{
|
||||||
|
Current = m_sequence[m_index];
|
||||||
|
var parentPath = Parent?.Path ?? WorkflowTemplateConstants.Matrix;
|
||||||
|
Path = $"{parentPath}[{ExpressionUtility.ConvertToParseToken((Double)m_index)}]";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
Path = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SequenceToken m_sequence;
|
||||||
|
private Int32 m_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to maintain state while traversing a mapping/sequence when building filter expressions.
|
||||||
|
/// See <see cref="MatrixFilter"/> for more info.
|
||||||
|
/// </summary>
|
||||||
|
private abstract class TokenState
|
||||||
|
{
|
||||||
|
protected TokenState(TokenState parent)
|
||||||
|
{
|
||||||
|
Parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TemplateToken Current { get; protected set; }
|
||||||
|
public TokenState Parent { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The expression used to reference the current position within the structure.
|
||||||
|
/// For example: matrix.node-version
|
||||||
|
/// </summary>
|
||||||
|
public String Path { get; protected set; }
|
||||||
|
|
||||||
|
public abstract Boolean MoveNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the "matrix" context within an include/exclude expression
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MatrixNamedValue : NamedValue
|
||||||
|
{
|
||||||
|
protected override Object EvaluateCore(
|
||||||
|
EvaluationContext context,
|
||||||
|
out ResultMemory resultMemory)
|
||||||
|
{
|
||||||
|
resultMemory = null;
|
||||||
|
return context.State;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly INamedValueInfo[] s_matrixFilterNamedValues = new INamedValueInfo[]
|
||||||
|
{
|
||||||
|
new NamedValueInfo<MatrixNamedValue>(WorkflowTemplateConstants.Matrix),
|
||||||
|
};
|
||||||
|
private readonly List<IExpressionNode> m_expressions = new List<IExpressionNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TemplateContext m_context;
|
||||||
|
private readonly String m_jobName;
|
||||||
|
private readonly DictionaryExpressionData m_vectors = new DictionaryExpressionData();
|
||||||
|
private SequenceToken m_excludeSequence;
|
||||||
|
private SequenceToken m_includeSequence;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal static class PermissionLevelExtensions
|
||||||
|
{
|
||||||
|
public static bool IsLessThanOrEqualTo(
|
||||||
|
this PermissionLevel permissionLevel,
|
||||||
|
PermissionLevel other)
|
||||||
|
{
|
||||||
|
switch (permissionLevel, other)
|
||||||
|
{
|
||||||
|
case (PermissionLevel.NoAccess, PermissionLevel.NoAccess):
|
||||||
|
case (PermissionLevel.NoAccess, PermissionLevel.Read):
|
||||||
|
case (PermissionLevel.NoAccess, PermissionLevel.Write):
|
||||||
|
case (PermissionLevel.Read, PermissionLevel.Read):
|
||||||
|
case (PermissionLevel.Read, PermissionLevel.Write):
|
||||||
|
case (PermissionLevel.Write, PermissionLevel.Write):
|
||||||
|
return true;
|
||||||
|
case (PermissionLevel.Read, PermissionLevel.NoAccess):
|
||||||
|
case (PermissionLevel.Write, PermissionLevel.NoAccess):
|
||||||
|
case (PermissionLevel.Write, PermissionLevel.Read):
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
throw new ArgumentException($"Invalid enum comparison: {permissionLevel} and {other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ConvertToString(this PermissionLevel permissionLevel)
|
||||||
|
{
|
||||||
|
switch (permissionLevel)
|
||||||
|
{
|
||||||
|
case PermissionLevel.NoAccess:
|
||||||
|
return "none";
|
||||||
|
case PermissionLevel.Read:
|
||||||
|
return "read";
|
||||||
|
case PermissionLevel.Write:
|
||||||
|
return "write";
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"invalid permission level found. {permissionLevel}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal sealed class PermissionLevelViolation
|
||||||
|
{
|
||||||
|
|
||||||
|
public PermissionLevelViolation(string permissionName, PermissionLevel requestedPermissions, PermissionLevel allowedPermissions)
|
||||||
|
{
|
||||||
|
PermissionName = permissionName;
|
||||||
|
RequestedPermissionLevel = requestedPermissions;
|
||||||
|
AllowedPermissionLevel = allowedPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PermissionName
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PermissionLevel RequestedPermissionLevel
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
public PermissionLevel AllowedPermissionLevel
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RequestedPermissionLevelString()
|
||||||
|
{
|
||||||
|
return $"{PermissionName}: {RequestedPermissionLevel.ConvertToString()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AllowedPermissionLevelString()
|
||||||
|
{
|
||||||
|
return $"{PermissionName}: {AllowedPermissionLevel.ConvertToString()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Sdk/WorkflowParser/Conversion/PermissionsHelper.cs
Normal file
79
src/Sdk/WorkflowParser/Conversion/PermissionsHelper.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal static class PermissionsHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates permissions requested in a reusable workflow do not exceed allowed permissions
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The template context</param>
|
||||||
|
/// <param name="workflowJob">The reusable workflow job</param>
|
||||||
|
/// <param name="embeddedJob">(Optional) Used when formatting errors related to an embedded job within the reusable workflow</param>
|
||||||
|
/// <param name="requested">The permissions within the reusable workflow file. These may be defined either at the root of the file, or may be defined on a job within the file.</param>
|
||||||
|
/// <param name="explicitMax">(Optional) The max permissions explicitly allowed by the caller</param>
|
||||||
|
/// <param name="permissionsPolicy">The default permissions policy</param>
|
||||||
|
/// <param name="isTrusted">Indicates whether the reusable workflow exists within the same trust boundary (e.g. enterprise/organization) as a the root workflow</param>
|
||||||
|
internal static void ValidateEmbeddedPermissions(
|
||||||
|
TemplateContext context,
|
||||||
|
ReusableWorkflowJob workflowJob,
|
||||||
|
IJob? embeddedJob,
|
||||||
|
Permissions requested,
|
||||||
|
Permissions? explicitMax,
|
||||||
|
string permissionsPolicy,
|
||||||
|
bool isTrusted)
|
||||||
|
{
|
||||||
|
if (requested == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission);
|
||||||
|
|
||||||
|
if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations))
|
||||||
|
{
|
||||||
|
var requestedStr = string.Join(", ", permissionLevelViolations.Select(x => x.RequestedPermissionLevelString()));
|
||||||
|
var allowedStr = string.Join(", ", permissionLevelViolations.Select(x => x.AllowedPermissionLevelString()));
|
||||||
|
if (embeddedJob != null)
|
||||||
|
{
|
||||||
|
context.Error(workflowJob.Id, $"Error calling workflow '{workflowJob.Ref}'. The nested job '{embeddedJob.Id!.Value}' is requesting '{requestedStr}', but is only allowed '{allowedStr}'.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Error(workflowJob.Id, $"Error calling workflow '{workflowJob.Ref}'. The workflow is requesting '{requestedStr}', but is only allowed '{allowedStr}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates permissions based on policy
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The template context</param>
|
||||||
|
/// <param name="permissionsPolicy">The permissions policy</param>
|
||||||
|
/// <param name="includeIdToken">Indicates whether the permissions should include an ID token</param>
|
||||||
|
private static Permissions CreatePermissionsFromPolicy(
|
||||||
|
TemplateContext context,
|
||||||
|
string permissionsPolicy,
|
||||||
|
bool includeIdToken,
|
||||||
|
bool includeModels)
|
||||||
|
{
|
||||||
|
switch (permissionsPolicy)
|
||||||
|
{
|
||||||
|
case WorkflowConstants.PermissionsPolicy.LimitedRead:
|
||||||
|
return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false)
|
||||||
|
{
|
||||||
|
Contents = PermissionLevel.Read,
|
||||||
|
Packages = PermissionLevel.Read,
|
||||||
|
};
|
||||||
|
case WorkflowConstants.PermissionsPolicy.Write:
|
||||||
|
return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels);
|
||||||
|
default:
|
||||||
|
throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/Sdk/WorkflowParser/Conversion/ReusableWorkflowsLoader.cs
Normal file
272
src/Sdk/WorkflowParser/Conversion/ReusableWorkflowsLoader.cs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GitHub.Actions.Expressions;
|
||||||
|
using GitHub.Actions.Expressions.Sdk;
|
||||||
|
using GitHub.Actions.WorkflowParser.Conversion;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads reusable workflows
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ReusableWorkflowsLoader
|
||||||
|
{
|
||||||
|
private ReusableWorkflowsLoader(
|
||||||
|
IServerTraceWriter serverTrace,
|
||||||
|
ITraceWriter trace,
|
||||||
|
ParseOptions options,
|
||||||
|
WorkflowUsage usage,
|
||||||
|
TemplateContext context,
|
||||||
|
WorkflowTemplate workflowTemplate,
|
||||||
|
YamlTemplateLoader loader,
|
||||||
|
String permissionPolicy,
|
||||||
|
IDictionary<string, ReferencedWorkflow> referencedWorkflows)
|
||||||
|
{
|
||||||
|
m_serverTrace = serverTrace ?? new EmptyServerTraceWriter();
|
||||||
|
m_trace = trace ?? new EmptyTraceWriter();
|
||||||
|
m_parseOptions = new ParseOptions(options ?? throw new ArgumentNullException(nameof(options)));
|
||||||
|
m_usage = usage ?? throw new ArgumentNullException(nameof(usage));
|
||||||
|
m_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
m_workflowTemplate = workflowTemplate ?? throw new ArgumentNullException(nameof(workflowTemplate));
|
||||||
|
m_loader = loader ?? throw new ArgumentNullException(nameof(loader));
|
||||||
|
m_permissionPolicy = permissionPolicy ?? throw new ArgumentNullException(nameof(permissionPolicy));
|
||||||
|
m_referencedWorkflows = referencedWorkflows ?? throw new ArgumentNullException(nameof(referencedWorkflows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads reusable workflows if not in an error state.
|
||||||
|
///
|
||||||
|
/// Any new errors are recorded to both <c ref="TemplateContext.Errors" /> and <c ref="WorkflowTemplate.Errors" />.
|
||||||
|
/// </summary>
|
||||||
|
public static void Load(
|
||||||
|
IServerTraceWriter serverTrace,
|
||||||
|
ITraceWriter trace,
|
||||||
|
ParseOptions options,
|
||||||
|
WorkflowUsage usage,
|
||||||
|
TemplateContext context,
|
||||||
|
WorkflowTemplate workflowTemplate,
|
||||||
|
YamlTemplateLoader loader,
|
||||||
|
String permissionPolicy,
|
||||||
|
IDictionary<string, ReferencedWorkflow> referencedWorkflows)
|
||||||
|
{
|
||||||
|
new ReusableWorkflowsLoader(serverTrace, trace, options, usage, context, workflowTemplate, loader, permissionPolicy, referencedWorkflows)
|
||||||
|
.Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refer overload
|
||||||
|
/// </summary>
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
// Skip reusable workflows?
|
||||||
|
if (m_parseOptions.SkipReusableWorkflows)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check errors
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note, the "finally" block appends context.Errors to workflowTemplate
|
||||||
|
var hasReusableWorkflowJob = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var job in m_workflowTemplate.Jobs)
|
||||||
|
{
|
||||||
|
// Load reusable workflow
|
||||||
|
if (job is ReusableWorkflowJob workflowJob)
|
||||||
|
{
|
||||||
|
hasReusableWorkflowJob = true;
|
||||||
|
LoadRecursive(workflowJob);
|
||||||
|
|
||||||
|
// Check errors
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ReferencedWorkflowNotFoundException)
|
||||||
|
{
|
||||||
|
// Long term, catch TemplateUserException and let others bubble
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
m_context.Errors.Add(ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Append context.Errors to workflowTemplate
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var error in m_context.Errors)
|
||||||
|
{
|
||||||
|
m_workflowTemplate.Errors.Add(new WorkflowValidationError(error.Code, error.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update WorkflowTemplate.FileTable with referenced workflows
|
||||||
|
if (hasReusableWorkflowJob)
|
||||||
|
{
|
||||||
|
m_workflowTemplate.FileTable.Clear();
|
||||||
|
m_workflowTemplate.FileTable.AddRange(m_context.GetFileTable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This loads referenced workflow by parsing the workflow file and converting to workflow template WorkflowJob.
|
||||||
|
/// </summary>
|
||||||
|
private void LoadRecursive(
|
||||||
|
ReusableWorkflowJob workflowJob,
|
||||||
|
int depth = 1)
|
||||||
|
{
|
||||||
|
// Check depth
|
||||||
|
if (depth > m_parseOptions.MaxNestedReusableWorkflowsDepth)
|
||||||
|
{
|
||||||
|
throw new Exception($"Nested reusable workflow depth exceeded {m_parseOptions.MaxNestedReusableWorkflowsDepth}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TemplateToken tokens;
|
||||||
|
|
||||||
|
// Load the reusable workflow
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fully qualify workflow ref
|
||||||
|
workflowJob.Ref = FullyQualifyWorkflowRef(m_context, workflowJob.Ref, m_referencedWorkflows);
|
||||||
|
var isTrusted = IsReferencedWorkflowTrusted(workflowJob.Ref.Value);
|
||||||
|
|
||||||
|
// Parse template tokens
|
||||||
|
tokens = m_loader.ParseWorkflow(m_context, workflowJob.Ref.Value);
|
||||||
|
|
||||||
|
// Gather telemetry
|
||||||
|
m_usage.Gather(m_context, tokens);
|
||||||
|
|
||||||
|
// Check errors
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
// Short-circuit
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to workflow types
|
||||||
|
WorkflowTemplateConverter.ConvertToReferencedWorkflow(m_context, tokens, workflowJob, m_permissionPolicy, isTrusted);
|
||||||
|
|
||||||
|
// Check errors
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
// Short-circuit
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Prefix errors with caller file/line/col
|
||||||
|
PrefixErrorsWithCallerInfo(workflowJob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load nested reusable workflows
|
||||||
|
foreach (var nestedJob in workflowJob.Jobs)
|
||||||
|
{
|
||||||
|
if (nestedJob is ReusableWorkflowJob nestedWorkflowJob)
|
||||||
|
{
|
||||||
|
// Recurse
|
||||||
|
LoadRecursive(nestedWorkflowJob, depth + 1);
|
||||||
|
|
||||||
|
// Check errors
|
||||||
|
if (m_context.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For the given token and referencedWorkflows, resolve the workflow reference (i.e. token value)
|
||||||
|
/// This ensures that the workflow reference is the fully qualified form (nwo+path+version) even when calling local workflows without nwo or version
|
||||||
|
/// </summary>
|
||||||
|
internal static StringToken FullyQualifyWorkflowRef(
|
||||||
|
TemplateContext context,
|
||||||
|
StringToken workflowJobRef,
|
||||||
|
IDictionary<string, ReferencedWorkflow> referencedWorkflows)
|
||||||
|
{
|
||||||
|
if (!workflowJobRef.Value.StartsWith(WorkflowTemplateConstants.LocalPrefix))
|
||||||
|
{
|
||||||
|
return workflowJobRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
var callerPath = context.GetFileName(workflowJobRef.FileId.Value);
|
||||||
|
if (!referencedWorkflows.TryGetValue(callerPath, out ReferencedWorkflow callerWorkflow) || callerWorkflow == null)
|
||||||
|
{
|
||||||
|
throw new ReferencedWorkflowNotFoundException($"Cannot find the caller workflow from the referenced workflows: '{callerPath}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath = workflowJobRef.Value.Substring(WorkflowTemplateConstants.LocalPrefix.Length);
|
||||||
|
var path = $"{callerWorkflow.Repository}/{filePath}@{callerWorkflow.ResolvedSha}";
|
||||||
|
|
||||||
|
return new StringToken(workflowJobRef.FileId, workflowJobRef.Line, workflowJobRef.Column, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prefixes all error messages with the caller file/line/column.
|
||||||
|
/// </summary>
|
||||||
|
private void PrefixErrorsWithCallerInfo(ReusableWorkflowJob workflowJob)
|
||||||
|
{
|
||||||
|
if (m_context.Errors.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var callerFile = m_context.GetFileName(workflowJob.Ref.FileId.Value);
|
||||||
|
for (int i = 0; i < m_context.Errors.Count; i++)
|
||||||
|
{
|
||||||
|
var errorMessage = m_context.Errors.GetMessage(i);
|
||||||
|
if (String.IsNullOrEmpty(errorMessage) || !errorMessage.StartsWith(callerFile))
|
||||||
|
{
|
||||||
|
// when there is no caller file in the error message, we add it for annotation
|
||||||
|
m_context.Errors.PrefixMessage(
|
||||||
|
i,
|
||||||
|
TemplateStrings.CalledWorkflowNotValidWithErrors(
|
||||||
|
callerFile,
|
||||||
|
TemplateStrings.LineColumn(workflowJob.Ref.Line, workflowJob.Ref.Column)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the given workflowJobRefValue is trusted
|
||||||
|
/// </summary>
|
||||||
|
private bool IsReferencedWorkflowTrusted(String workflowJobRefValue)
|
||||||
|
{
|
||||||
|
if (m_referencedWorkflows.TryGetValue(workflowJobRefValue, out ReferencedWorkflow referencedWorkflow) &&
|
||||||
|
referencedWorkflow != null)
|
||||||
|
{
|
||||||
|
return referencedWorkflow.IsTrusted();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TemplateContext m_context;
|
||||||
|
private readonly YamlTemplateLoader m_loader;
|
||||||
|
private readonly ParseOptions m_parseOptions;
|
||||||
|
private readonly string m_permissionPolicy;
|
||||||
|
private readonly IDictionary<string, ReferencedWorkflow> m_referencedWorkflows;
|
||||||
|
private readonly IServerTraceWriter m_serverTrace;
|
||||||
|
private readonly ITraceWriter m_trace;
|
||||||
|
private readonly WorkflowUsage m_usage;
|
||||||
|
private readonly WorkflowTemplate m_workflowTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Sdk/WorkflowParser/Conversion/TemplateTokenExtensions.cs
Normal file
76
src/Sdk/WorkflowParser/Conversion/TemplateTokenExtensions.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using GitHub.Actions.Expressions.Data;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal static class TemplateTokenExtensions
|
||||||
|
{
|
||||||
|
public static ArrayExpressionData ToExpressionData(this SequenceToken sequence)
|
||||||
|
{
|
||||||
|
var token = sequence as TemplateToken;
|
||||||
|
var expressionData = token.ToExpressionData();
|
||||||
|
return expressionData.AssertArray("converted sequence token");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DictionaryExpressionData ToExpressionData(this MappingToken mapping)
|
||||||
|
{
|
||||||
|
var token = mapping as TemplateToken;
|
||||||
|
var expressionData = token.ToExpressionData();
|
||||||
|
return expressionData.AssertDictionary("converted mapping token");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExpressionData ToExpressionData(this TemplateToken token)
|
||||||
|
{
|
||||||
|
switch (token.Type)
|
||||||
|
{
|
||||||
|
case TokenType.Mapping:
|
||||||
|
var mapping = token as MappingToken;
|
||||||
|
var dictionary = new DictionaryExpressionData();
|
||||||
|
if (mapping.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var pair in mapping)
|
||||||
|
{
|
||||||
|
var keyLiteral = pair.Key.AssertString("dictionary context data key");
|
||||||
|
var key = keyLiteral.Value;
|
||||||
|
var value = pair.Value.ToExpressionData();
|
||||||
|
dictionary.Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dictionary;
|
||||||
|
|
||||||
|
case TokenType.Sequence:
|
||||||
|
var sequence = token as SequenceToken;
|
||||||
|
var array = new ArrayExpressionData();
|
||||||
|
if (sequence.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var item in sequence)
|
||||||
|
{
|
||||||
|
array.Add(item.ToExpressionData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
|
||||||
|
case TokenType.Null:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case TokenType.Boolean:
|
||||||
|
var boolean = token as BooleanToken;
|
||||||
|
return new BooleanExpressionData(boolean.Value);
|
||||||
|
|
||||||
|
case TokenType.Number:
|
||||||
|
var number = token as NumberToken;
|
||||||
|
return new NumberExpressionData(number.Value);
|
||||||
|
|
||||||
|
case TokenType.String:
|
||||||
|
var stringToken = token as StringToken;
|
||||||
|
return new StringExpressionData(stringToken.Value);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unexpected {nameof(TemplateToken)} type '{token.Type}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Sdk/WorkflowParser/Conversion/WorkflowSchemaFactory.cs
Normal file
63
src/Sdk/WorkflowParser/Conversion/WorkflowSchemaFactory.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using GitHub.Actions.WorkflowParser.ObjectTemplating.Schema;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the schema for workflows
|
||||||
|
/// </summary>
|
||||||
|
internal static class WorkflowSchemaFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the template schema for the specified features.
|
||||||
|
/// </summary>
|
||||||
|
internal static TemplateSchema GetSchema(WorkflowFeatures features)
|
||||||
|
{
|
||||||
|
if (features == null)
|
||||||
|
{
|
||||||
|
throw new System.ArgumentNullException(nameof(features));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find resource names corresponding to enabled features
|
||||||
|
var resourceNames = WorkflowFeatures.Names
|
||||||
|
.Where(x => features.GetFeature(x)) // Enabled features only
|
||||||
|
.Select(x => string.Concat(c_resourcePrefix, "-", x, c_resourceSuffix)) // To resource name
|
||||||
|
.Where(x => s_resourceNames.Contains(x)) // Resource must exist
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// More than one resource found?
|
||||||
|
if (resourceNames.Count > 1)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException("Failed to load workflow schema. Only one feature flag with schema changes can be enabled at a time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resourceName = resourceNames.FirstOrDefault() ?? c_defaultResourceName;
|
||||||
|
return s_schemas.GetOrAdd(
|
||||||
|
resourceName,
|
||||||
|
(resourceName) =>
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var json = default(String);
|
||||||
|
using (var stream = assembly.GetManifestResourceStream(resourceName)!)
|
||||||
|
using (var streamReader = new StreamReader(stream))
|
||||||
|
{
|
||||||
|
json = streamReader.ReadToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectReader = new JsonObjectReader(null, json);
|
||||||
|
return TemplateSchema.Load(objectReader);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string c_resourcePrefix = "GitHub.Actions.WorkflowParser.workflow-v1.0";
|
||||||
|
private const string c_resourceSuffix = ".json";
|
||||||
|
private const string c_defaultResourceName = c_resourcePrefix + c_resourceSuffix;
|
||||||
|
private static readonly HashSet<string> s_resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames().ToHashSet(StringComparer.Ordinal);
|
||||||
|
private static readonly ConcurrentDictionary<string, TemplateSchema> s_schemas = new(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs
Normal file
121
src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace GitHub.Actions.WorkflowParser.Conversion
|
||||||
|
{
|
||||||
|
internal static class WorkflowTemplateConstants
|
||||||
|
{
|
||||||
|
public const String Always = "always";
|
||||||
|
public const String BooleanNeedsContext = "boolean-needs-context";
|
||||||
|
public const String BooleanStepsContext = "boolean-steps-context";
|
||||||
|
public const String BooleanStrategyContext = "boolean-strategy-context";
|
||||||
|
public const String CancelInProgress = "cancel-in-progress";
|
||||||
|
public const String CancelTimeoutMinutes = "cancel-timeout-minutes";
|
||||||
|
public const String Cancelled = "cancelled";
|
||||||
|
public const String Concurrency = "concurrency";
|
||||||
|
public const String Container = "container";
|
||||||
|
public const String ContinueOnError = "continue-on-error";
|
||||||
|
public const String Credentials = "credentials";
|
||||||
|
public const String Default = "default";
|
||||||
|
public const String Defaults = "defaults";
|
||||||
|
public const String Description = "description";
|
||||||
|
public const String DockerUriPrefix = "docker://";
|
||||||
|
public const String EmbeddedConcurrency = "embedded-concurrency";
|
||||||
|
public const String Env = "env";
|
||||||
|
public const String Ent = "ent";
|
||||||
|
public const String Enterprise = "enterprise";
|
||||||
|
public const String Environment = "environment";
|
||||||
|
public const String Event = "event";
|
||||||
|
public const String EventName = "event_name";
|
||||||
|
public const String EventPattern = "github.event";
|
||||||
|
public const String Exclude = "exclude";
|
||||||
|
public const String FailFast = "fail-fast";
|
||||||
|
public const String Failure = "failure";
|
||||||
|
public const String GitHub = "github";
|
||||||
|
public const String Group = "group";
|
||||||
|
public const String HashFiles = "hashFiles";
|
||||||
|
public const String Id = "id";
|
||||||
|
public const String If = "if";
|
||||||
|
public const String Image = "image";
|
||||||
|
public const String ImageName = "image-name";
|
||||||
|
public const String CustomImageVersion = "version";
|
||||||
|
public const String Include = "include";
|
||||||
|
public const String Inherit = "inherit";
|
||||||
|
public const String Inputs = "inputs";
|
||||||
|
public const String InputsPattern = "inputs.*";
|
||||||
|
public const String Job = "job";
|
||||||
|
public const String JobConcurrency = "job-concurrency";
|
||||||
|
public const String JobDefaultsRun = "job-defaults-run";
|
||||||
|
public const String JobEnvironment = "job-environment";
|
||||||
|
public const String JobIfResult = "job-if-result";
|
||||||
|
public const String JobOutputs = "job-outputs";
|
||||||
|
public const String Jobs = "jobs";
|
||||||
|
public const String JobsPattern = "jobs.*";
|
||||||
|
public const String JobsOutputsPattern = "jobs.*.outputs";
|
||||||
|
public const String Labels = "labels";
|
||||||
|
public const String LocalPrefix = "./";
|
||||||
|
public const String Matrix = "matrix";
|
||||||
|
public const String MaxParallel = "max-parallel";
|
||||||
|
public const String Name = "name";
|
||||||
|
public const String Needs = "needs";
|
||||||
|
public const String NumberNeedsContext = "number-needs-context";
|
||||||
|
public const String NumberStepsContext = "number-steps-context";
|
||||||
|
public const String NumberStrategyContext = "number-strategy-context";
|
||||||
|
public const String On = "on";
|
||||||
|
public const String Options = "options";
|
||||||
|
public const String Org = "org";
|
||||||
|
public const String Organization = "organization";
|
||||||
|
public const String Outputs = "outputs";
|
||||||
|
public const String OutputsPattern = "needs.*.outputs";
|
||||||
|
public const String Password = "password";
|
||||||
|
public const String Permissions = "permissions";
|
||||||
|
public const String Pool = "pool";
|
||||||
|
public const String Ports = "ports";
|
||||||
|
public const String Required = "required";
|
||||||
|
public const String Result = "result";
|
||||||
|
public const String Run = "run";
|
||||||
|
public const String RunName = "run-name";
|
||||||
|
public const String Runner = "runner";
|
||||||
|
public const String RunsOn = "runs-on";
|
||||||
|
public const String Secret = "secret";
|
||||||
|
public const String Secrets = "secrets";
|
||||||
|
public const String Services = "services";
|
||||||
|
public const String Shell = "shell";
|
||||||
|
public const String Skipped = "skipped";
|
||||||
|
public const String Slash = "/";
|
||||||
|
public const String Snapshot = "snapshot";
|
||||||
|
public const String StepEnv = "step-env";
|
||||||
|
public const String StepIfResult = "step-if-result";
|
||||||
|
public const String StepWith = "step-with";
|
||||||
|
public const String Steps = "steps";
|
||||||
|
public const String Strategy = "strategy";
|
||||||
|
public const String StringNeedsContext = "string-needs-context";
|
||||||
|
public const String StringRunnerContextNoSecrets = "string-runner-context-no-secrets";
|
||||||
|
public const String StringStepsContext = "string-steps-context";
|
||||||
|
public const String StringStrategyContext = "string-strategy-context";
|
||||||
|
public const String Success = "success";
|
||||||
|
public const String TimeoutMinutes = "timeout-minutes";
|
||||||
|
public const String Type = "type";
|
||||||
|
public const String TypeString = "string";
|
||||||
|
public const String TypeBoolean = "boolean";
|
||||||
|
public const String TypeNumber = "number";
|
||||||
|
public const String Url = "url";
|
||||||
|
public const String Username = "username";
|
||||||
|
public const String Uses = "uses";
|
||||||
|
public const String Vars = "vars";
|
||||||
|
public const String VarsPattern = "vars.*";
|
||||||
|
public const String VmImage = "vmImage";
|
||||||
|
public const String Volumes = "volumes";
|
||||||
|
public const String With = "with";
|
||||||
|
public const String Workflow = "workflow";
|
||||||
|
public const String Workflow_1_0 = "workflow-v1.0";
|
||||||
|
public const String WorkflowCall = "workflow_call";
|
||||||
|
public const String WorkflowCallInputs = "workflow-call-inputs";
|
||||||
|
public const String WorkflowCallOutputs = "workflow-call-outputs";
|
||||||
|
public const String WorkflowConcurrency = "workflow-concurrency";
|
||||||
|
public const String WorkflowDispatch = "workflow_dispatch";
|
||||||
|
public const String WorkflowJobSecrets = "workflow-job-secrets";
|
||||||
|
public const String WorkflowJobWith = "workflow-job-with";
|
||||||
|
public const String WorkflowRoot = "workflow-root";
|
||||||
|
public const String WorkingDirectory = "working-directory";
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user