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.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
@ -9,9 +6,6 @@ namespace SourceGit.Models
|
||||||
{
|
{
|
||||||
public partial class CommitTemplate : ObservableObject
|
public partial class CommitTemplate : ObservableObject
|
||||||
{
|
{
|
||||||
[GeneratedRegex(@"\$\{files(\:\d+)?\}")]
|
|
||||||
private static partial Regex REG_COMMIT_TEMPLATE_FILES();
|
|
||||||
|
|
||||||
public string Name
|
public string Name
|
||||||
{
|
{
|
||||||
get => _name;
|
get => _name;
|
||||||
|
@ -26,55 +20,8 @@ namespace SourceGit.Models
|
||||||
|
|
||||||
public string Apply(Branch branch, List<Change> changes)
|
public string Apply(Branch branch, List<Change> changes)
|
||||||
{
|
{
|
||||||
var content = _content
|
var te = new TemplateEngine();
|
||||||
.Replace("${files_num}", $"{changes.Count}")
|
return te.Eval(_content, branch, changes);
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _name = string.Empty;
|
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