mirror of
https://github.com/sourcegit-scm/sourcegit
synced 2025-05-21 20:24:59 +00:00
410 lines
12 KiB
C#
410 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace SourceGit.Models
|
|
{
|
|
public class TemplateEngine
|
|
{
|
|
private class Context(Branch branch, IReadOnlyList<Change> changes)
|
|
{
|
|
public Branch branch = branch;
|
|
public IReadOnlyList<Change> changes = changes;
|
|
}
|
|
|
|
private class Text(string text)
|
|
{
|
|
public string text = text;
|
|
}
|
|
|
|
private class Variable(string name)
|
|
{
|
|
public string name = name;
|
|
}
|
|
|
|
private class SlicedVariable(string name, int count)
|
|
{
|
|
public string name = name;
|
|
public int count = count;
|
|
}
|
|
|
|
private class RegexVariable(string name, Regex regex, string replacement)
|
|
{
|
|
public string name = name;
|
|
public Regex regex = regex;
|
|
public string replacement = replacement;
|
|
}
|
|
|
|
private const char ESCAPE = '\\';
|
|
private const char VARIABLE_ANCHOR = '$';
|
|
private const char VARIABLE_START = '{';
|
|
private const char VARIABLE_END = '}';
|
|
private const char VARIABLE_SLICE = ':';
|
|
private const char VARIABLE_REGEX = '/';
|
|
private const char NEWLINE = '\n';
|
|
private const RegexOptions REGEX_OPTIONS = RegexOptions.Singleline | RegexOptions.IgnoreCase;
|
|
|
|
public string Eval(string text, Branch branch, IReadOnlyList<Change> changes)
|
|
{
|
|
Reset();
|
|
|
|
_chars = text.ToCharArray();
|
|
Parse();
|
|
|
|
var context = new Context(branch, changes);
|
|
var sb = new StringBuilder();
|
|
sb.EnsureCapacity(text.Length);
|
|
foreach (var token in _tokens)
|
|
{
|
|
switch (token)
|
|
{
|
|
case Text text_token:
|
|
sb.Append(text_token.text);
|
|
break;
|
|
case Variable var_token:
|
|
sb.Append(EvalVariable(context, var_token));
|
|
break;
|
|
case SlicedVariable sliced_var:
|
|
sb.Append(EvalVariable(context, sliced_var));
|
|
break;
|
|
case RegexVariable regex_var:
|
|
sb.Append(EvalVariable(context, regex_var));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private void Reset()
|
|
{
|
|
_pos = 0;
|
|
_chars = [];
|
|
_tokens.Clear();
|
|
}
|
|
|
|
private char? Next()
|
|
{
|
|
var c = Peek();
|
|
if (c is not null)
|
|
{
|
|
_pos++;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
private char? Peek()
|
|
{
|
|
return (_pos >= _chars.Length) ? null : _chars[_pos];
|
|
}
|
|
|
|
private int? Integer()
|
|
{
|
|
var start = _pos;
|
|
while (Peek() is char c && c >= '0' && c <= '9')
|
|
{
|
|
_pos++;
|
|
}
|
|
if (start >= _pos)
|
|
return null;
|
|
|
|
var chars = new ReadOnlySpan<char>(_chars, start, _pos - start);
|
|
return int.Parse(chars);
|
|
}
|
|
|
|
private void Parse()
|
|
{
|
|
// text token start
|
|
var tok = _pos;
|
|
bool esc = false;
|
|
while (Next() is char c)
|
|
{
|
|
if (esc)
|
|
{
|
|
esc = false;
|
|
continue;
|
|
}
|
|
switch (c)
|
|
{
|
|
case ESCAPE:
|
|
// allow to escape only \ and $
|
|
if (Peek() is char nc && (nc == ESCAPE || nc == VARIABLE_ANCHOR))
|
|
{
|
|
esc = true;
|
|
FlushText(tok, _pos - 1);
|
|
tok = _pos;
|
|
}
|
|
break;
|
|
case VARIABLE_ANCHOR:
|
|
// backup the position
|
|
var bak = _pos;
|
|
var variable = TryParseVariable();
|
|
if (variable is null)
|
|
{
|
|
// no variable found, rollback
|
|
_pos = bak;
|
|
}
|
|
else
|
|
{
|
|
// variable found, flush a text token
|
|
FlushText(tok, bak - 1);
|
|
_tokens.Add(variable);
|
|
tok = _pos;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// flush text token
|
|
FlushText(tok, _pos);
|
|
}
|
|
|
|
private void FlushText(int start, int end)
|
|
{
|
|
int len = end - start;
|
|
if (len <= 0)
|
|
return;
|
|
var text = new string(_chars, start, len);
|
|
_tokens.Add(new Text(text));
|
|
}
|
|
|
|
private object TryParseVariable()
|
|
{
|
|
if (Next() != VARIABLE_START)
|
|
return null;
|
|
int name_start = _pos;
|
|
while (Next() is char c)
|
|
{
|
|
// name character, continue advancing
|
|
if (IsNameChar(c))
|
|
continue;
|
|
|
|
var name_end = _pos - 1;
|
|
// not a name character but name is empty, cancel
|
|
if (name_start >= name_end)
|
|
return null;
|
|
var name = new string(_chars, name_start, name_end - name_start);
|
|
|
|
return c switch
|
|
{
|
|
// variable
|
|
VARIABLE_END => new Variable(name),
|
|
// sliced variable
|
|
VARIABLE_SLICE => TryParseSlicedVariable(name),
|
|
// regex variable
|
|
VARIABLE_REGEX => TryParseRegexVariable(name),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private object TryParseSlicedVariable(string name)
|
|
{
|
|
int? n = Integer();
|
|
if (n is null)
|
|
return null;
|
|
if (Next() != VARIABLE_END)
|
|
return null;
|
|
|
|
return new SlicedVariable(name, (int)n);
|
|
}
|
|
|
|
private object TryParseRegexVariable(string name)
|
|
{
|
|
var regex = ParseRegex();
|
|
if (regex == null)
|
|
return null;
|
|
var replacement = ParseReplacement();
|
|
if (replacement == null)
|
|
return null;
|
|
|
|
return new RegexVariable(name, regex, replacement);
|
|
}
|
|
|
|
private Regex ParseRegex()
|
|
{
|
|
var sb = new StringBuilder();
|
|
var tok = _pos;
|
|
var esc = false;
|
|
while (Next() is char c)
|
|
{
|
|
if (esc)
|
|
{
|
|
esc = false;
|
|
continue;
|
|
}
|
|
switch (c)
|
|
{
|
|
case ESCAPE:
|
|
// allow to escape only / as \ and { used frequently in regexes
|
|
if (Peek() == VARIABLE_REGEX)
|
|
{
|
|
esc = true;
|
|
sb.Append(_chars, tok, _pos - 1 - tok);
|
|
tok = _pos;
|
|
}
|
|
break;
|
|
case VARIABLE_REGEX:
|
|
// goto is fine
|
|
goto Loop_exit;
|
|
case NEWLINE:
|
|
// no newlines allowed
|
|
return null;
|
|
}
|
|
}
|
|
Loop_exit:
|
|
sb.Append(_chars, tok, _pos - 1 - tok);
|
|
|
|
try
|
|
{
|
|
var pattern = sb.ToString();
|
|
if (pattern.Length == 0)
|
|
return null;
|
|
var regex = new Regex(pattern, REGEX_OPTIONS);
|
|
|
|
return regex;
|
|
}
|
|
catch (RegexParseException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private string ParseReplacement()
|
|
{
|
|
var sb = new StringBuilder();
|
|
var tok = _pos;
|
|
var esc = false;
|
|
while (Next() is char c)
|
|
{
|
|
if (esc)
|
|
{
|
|
esc = false;
|
|
continue;
|
|
}
|
|
switch (c)
|
|
{
|
|
case ESCAPE:
|
|
// allow to escape only }
|
|
if (Peek() == VARIABLE_END)
|
|
{
|
|
esc = true;
|
|
sb.Append(_chars, tok, _pos - 1 - tok);
|
|
tok = _pos;
|
|
}
|
|
break;
|
|
case VARIABLE_END:
|
|
// goto is fine
|
|
goto Loop_exit;
|
|
case NEWLINE:
|
|
// no newlines allowed
|
|
return null;
|
|
}
|
|
}
|
|
Loop_exit:
|
|
sb.Append(_chars, tok, _pos - 1 - tok);
|
|
|
|
var replacement = sb.ToString();
|
|
|
|
return replacement;
|
|
}
|
|
|
|
private static bool IsNameChar(char c)
|
|
{
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
|
|
}
|
|
|
|
// (?) notice or log if variable is not found
|
|
private static string EvalVariable(Context context, string name)
|
|
{
|
|
if (!s_variables.TryGetValue(name, out var getter))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
return getter(context);
|
|
}
|
|
|
|
private static string EvalVariable(Context context, Variable variable)
|
|
{
|
|
return EvalVariable(context, variable.name);
|
|
}
|
|
|
|
private static string EvalVariable(Context context, SlicedVariable variable)
|
|
{
|
|
if (!s_slicedVariables.TryGetValue(variable.name, out var getter))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
return getter(context, variable.count);
|
|
}
|
|
|
|
private static string EvalVariable(Context context, RegexVariable variable)
|
|
{
|
|
var str = EvalVariable(context, variable.name);
|
|
if (string.IsNullOrEmpty(str))
|
|
return str;
|
|
return variable.regex.Replace(str, variable.replacement);
|
|
}
|
|
|
|
private int _pos = 0;
|
|
private char[] _chars = [];
|
|
private readonly List<object> _tokens = [];
|
|
|
|
private delegate string VariableGetter(Context context);
|
|
|
|
private static readonly IReadOnlyDictionary<string, VariableGetter> s_variables = new Dictionary<string, VariableGetter>() {
|
|
// legacy variables
|
|
{"branch_name", GetBranchName},
|
|
{"files_num", GetFilesCount},
|
|
{"files", GetFiles},
|
|
//
|
|
{"BRANCH", GetBranchName},
|
|
{"FILES_COUNT", GetFilesCount},
|
|
{"FILES", GetFiles},
|
|
};
|
|
|
|
private static string GetBranchName(Context context)
|
|
{
|
|
return context.branch.Name;
|
|
}
|
|
|
|
private static string GetFilesCount(Context context)
|
|
{
|
|
return context.changes.Count.ToString();
|
|
}
|
|
|
|
private static string GetFiles(Context context)
|
|
{
|
|
var paths = new List<string>();
|
|
foreach (var c in context.changes)
|
|
paths.Add(c.Path);
|
|
return string.Join(", ", paths);
|
|
}
|
|
|
|
private delegate string VariableSliceGetter(Context context, int count);
|
|
|
|
private static readonly IReadOnlyDictionary<string, VariableSliceGetter> s_slicedVariables = new Dictionary<string, VariableSliceGetter>() {
|
|
// legacy variables
|
|
{"files", GetFilesSliced},
|
|
//
|
|
{"FILES", GetFilesSliced},
|
|
};
|
|
|
|
private static string GetFilesSliced(Context context, int count)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var paths = new List<string>();
|
|
var max = Math.Min(count, context.changes.Count);
|
|
for (int i = 0; i < max; i++)
|
|
paths.Add(context.changes[i].Path);
|
|
|
|
sb.AppendJoin(", ", paths);
|
|
if (max < context.changes.Count)
|
|
sb.AppendFormat(" and {0} other files", context.changes.Count - max);
|
|
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
}
|