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 changes) { public Branch branch = branch; public IReadOnlyList 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 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(_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 _tokens = []; private delegate string VariableGetter(Context context); private static readonly IReadOnlyDictionary s_variables = new Dictionary() { // 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(); 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 s_slicedVariables = new Dictionary() { // legacy variables {"files", GetFilesSliced}, // {"FILES", GetFilesSliced}, }; private static string GetFilesSliced(Context context, int count) { var sb = new StringBuilder(); var paths = new List(); 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(); } } }