mirror of
https://github.com/sourcegit-scm/sourcegit.git
synced 2024-12-25 21:07:20 -08:00
parent
73687689ce
commit
8021cd8566
2 changed files with 413 additions and 56 deletions
|
@ -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<Change> 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<string>();
|
||||
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;
|
||||
|
|
410
src/Models/TemplateEngine.cs
Normal file
410
src/Models/TemplateEngine.cs
Normal file
|
@ -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<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');
|
||||
}
|
||||
|
||||
// (?) 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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue