From 8021cd8566be5f8ac548ef352c18bc2df856aa6c Mon Sep 17 00:00:00 2001 From: aikawayataro Date: Tue, 19 Nov 2024 11:46:44 +0000 Subject: [PATCH] enhance: introduce template engine for commit templates (#704) (#719) --- src/Models/CommitTemplate.cs | 59 +---- src/Models/TemplateEngine.cs | 410 +++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 56 deletions(-) create mode 100644 src/Models/TemplateEngine.cs diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs index b34fa5a5..56e1992c 100644 --- a/src/Models/CommitTemplate.cs +++ b/src/Models/CommitTemplate.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; +using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,9 +6,6 @@ namespace SourceGit.Models { public partial class CommitTemplate : ObservableObject { - [GeneratedRegex(@"\$\{files(\:\d+)?\}")] - private static partial Regex REG_COMMIT_TEMPLATE_FILES(); - public string Name { get => _name; @@ -26,55 +20,8 @@ namespace SourceGit.Models public string Apply(Branch branch, List changes) { - var content = _content - .Replace("${files_num}", $"{changes.Count}") - .Replace("${branch_name}", branch.Name); - - var matches = REG_COMMIT_TEMPLATE_FILES().Matches(content); - if (matches.Count == 0) - return content; - - var builder = new StringBuilder(); - var last = 0; - for (int i = 0; i < matches.Count; i++) - { - var match = matches[i]; - if (!match.Success) - continue; - - var start = match.Index; - if (start != last) - builder.Append(content.Substring(last, start - last)); - - var countStr = match.Groups[1].Value; - var paths = new List(); - var more = string.Empty; - if (countStr is { Length: <= 1 }) - { - foreach (var c in changes) - paths.Add(c.Path); - } - else - { - var count = Math.Min(int.Parse(countStr.Substring(1)), changes.Count); - for (int j = 0; j < count; j++) - paths.Add(changes[j].Path); - - if (count < changes.Count) - more = $" and {changes.Count - count} other files"; - } - - builder.Append(string.Join(", ", paths)); - if (!string.IsNullOrEmpty(more)) - builder.Append(more); - - last = start + match.Length; - } - - if (last != content.Length - 1) - builder.Append(content.Substring(last)); - - return builder.ToString(); + var te = new TemplateEngine(); + return te.Eval(_content, branch, changes); } private string _name = string.Empty; diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs new file mode 100644 index 00000000..6b5f525d --- /dev/null +++ b/src/Models/TemplateEngine.cs @@ -0,0 +1,410 @@ +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'); + } + + // (?) 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(); + } + } +}