583 lines
22 KiB
C#
583 lines
22 KiB
C#
using gregExtractor.Models;
|
|
|
|
namespace gregExtractor.Services;
|
|
|
|
public sealed class FrameworkSyncService
|
|
{
|
|
private sealed class LineCounters
|
|
{
|
|
public int Added { get; set; }
|
|
public int Removed { get; set; }
|
|
}
|
|
|
|
private const string PatchMarker = "// [GREG_SYNC_INSERT_PATCHES]";
|
|
private const string EventIdsMarker = "// [GREG_SYNC_INSERT_EVENTIDS]";
|
|
private const string EventMapMarker = "// [GREG_SYNC_INSERT_EVENTIDS_MAP]";
|
|
private const string DtoMarker = "// [GREG_SYNC_INSERT_DTOS]";
|
|
private const string ApiMarker = "// [GREG_SYNC_INSERT_GAMEAPI]";
|
|
|
|
public SyncResult Sync(SyncOptions options)
|
|
{
|
|
if (options is null)
|
|
throw new ArgumentNullException(nameof(options));
|
|
|
|
if (string.IsNullOrWhiteSpace(options.FrameworkSourceDir) || !Directory.Exists(options.FrameworkSourceDir))
|
|
throw new DirectoryNotFoundException($"Framework source directory not found: {options.FrameworkSourceDir}");
|
|
|
|
var filesWritten = new List<string>();
|
|
var warnings = new List<string>();
|
|
var counters = new LineCounters();
|
|
|
|
string harmonyPath = FindFile(options.FrameworkSourceDir, "gregHarmonyPatches.cs");
|
|
string nativeHooksPath = FindFile(options.FrameworkSourceDir, "gregNativeEventHooks.cs");
|
|
string eventsPath = FindFile(options.FrameworkSourceDir, "Events.cs");
|
|
string gameApiPath = FindFile(options.FrameworkSourceDir, "gregGameApi.cs");
|
|
|
|
ProcessFile(harmonyPath, options, progressPrefix: "gregHarmonyPatches.cs", PatchMarker,
|
|
(content, now) => ApplyHarmonyChanges(content, options, now, counters),
|
|
filesWritten);
|
|
|
|
ProcessFile(nativeHooksPath, options, progressPrefix: "gregNativeEventHooks.cs", EventIdsMarker,
|
|
(content, now) => ApplyNativeHooksChanges(content, options, now, counters),
|
|
filesWritten);
|
|
|
|
ProcessFile(eventsPath, options, progressPrefix: "Events.cs", DtoMarker,
|
|
(content, now) => ApplyEventsChanges(content, options, now, counters),
|
|
filesWritten);
|
|
|
|
ProcessFile(gameApiPath, options, progressPrefix: "gregGameApi.cs", ApiMarker,
|
|
(content, now) => ApplyGameApiChanges(content, options, now, warnings, counters),
|
|
filesWritten);
|
|
|
|
return new SyncResult(filesWritten, counters.Added, counters.Removed, warnings);
|
|
}
|
|
|
|
private static string ApplyHarmonyChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
|
|
{
|
|
content = EnsureMarker(content, PatchMarker);
|
|
|
|
foreach (HookDefinition hook in options.Diff.Added)
|
|
{
|
|
string className = GetPatchClassName(hook);
|
|
if (content.Contains($"internal static class {className}", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
string snippet = BuildHarmonyPatchSnippet(hook, now);
|
|
content = InsertAtMarker(content, PatchMarker, snippet);
|
|
counters.Added += CountLines(snippet);
|
|
options.Progress?.Report($"+ [NEU] {DiffService.GetKey(hook)} → Patch hinzugefügt");
|
|
}
|
|
|
|
foreach (HookDefinition hook in options.Diff.Removed)
|
|
{
|
|
string className = GetPatchClassName(hook);
|
|
if (TryCommentOutClass(content, className, now, "Methode existiert nicht mehr in neuer Assembly-Version", out string updated, out int removedLines))
|
|
{
|
|
content = updated;
|
|
counters.Removed += removedLines;
|
|
options.Progress?.Report($"- [ENTFERNT] {DiffService.GetKey(hook)} → Patch auskommentiert");
|
|
}
|
|
}
|
|
|
|
foreach (HookDefinition hook in options.Diff.Changed)
|
|
{
|
|
string className = GetPatchClassName(hook);
|
|
if (TryUpdatePostfixSignature(content, className, hook, now, out string updated))
|
|
{
|
|
content = updated;
|
|
options.Progress?.Report($"~ [GEÄNDERT] {DiffService.GetKey(hook)} → Signatur aktualisiert");
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
private static string ApplyNativeHooksChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
|
|
{
|
|
content = EnsureMarker(content, EventIdsMarker);
|
|
content = EnsureMarker(content, EventMapMarker);
|
|
|
|
int nextFreeId = GetNextFreeEventId(content);
|
|
|
|
foreach (HookDefinition hook in options.Diff.Added)
|
|
{
|
|
string eventName = GetEventIdName(hook);
|
|
if (!content.Contains($"public const int {eventName}", StringComparison.Ordinal))
|
|
{
|
|
string constSnippet = $" // [AUTO-GENERATED]{Environment.NewLine} public const int {eventName} = {nextFreeId};{Environment.NewLine}";
|
|
content = InsertAtMarker(content, EventIdsMarker, constSnippet);
|
|
counters.Added += CountLines(constSnippet);
|
|
nextFreeId++;
|
|
}
|
|
|
|
if (!content.Contains($"EventIds.{eventName}", StringComparison.Ordinal))
|
|
{
|
|
string mapSnippet = $" {{ EventIds.{eventName}, GregHookName.Create(\"{Escape(hook.Group)}\", \"{Escape(hook.MethodName)}\") }},{Environment.NewLine}";
|
|
content = InsertAtMarker(content, EventMapMarker, mapSnippet);
|
|
counters.Added += CountLines(mapSnippet);
|
|
}
|
|
}
|
|
|
|
foreach (HookDefinition hook in options.Diff.Removed)
|
|
{
|
|
string eventName = GetEventIdName(hook);
|
|
content = CommentMatchingLine(content, $"public const int {eventName}", $"// [DEPRECATED — {now:O}]", counters);
|
|
content = CommentMatchingLine(content, $"EventIds.{eventName}", $"// [DEPRECATED — {now:O}]", counters);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
private static string ApplyEventsChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
|
|
{
|
|
content = EnsureMarker(content, DtoMarker);
|
|
|
|
foreach (HookDefinition hook in options.Diff.Added)
|
|
{
|
|
if (hook.IsVoid && hook.Parameters.Count == 0)
|
|
continue;
|
|
|
|
string structName = GetDtoName(hook);
|
|
if (content.Contains($"public struct {structName}", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
string dto = BuildDtoSnippet(hook, now);
|
|
content = InsertAtMarker(content, DtoMarker, dto);
|
|
counters.Added += CountLines(dto);
|
|
}
|
|
|
|
foreach (HookDefinition hook in options.Diff.Removed)
|
|
{
|
|
string structName = GetDtoName(hook);
|
|
if (TryCommentOutStruct(content, structName, now, out string updated, out int removedLines))
|
|
{
|
|
content = updated;
|
|
counters.Removed += removedLines;
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
private static string ApplyGameApiChanges(string content, SyncOptions options, DateTime now, List<string> warnings, LineCounters counters)
|
|
{
|
|
content = EnsureMarker(content, ApiMarker);
|
|
|
|
foreach (HookDefinition hook in options.Diff.Added.Where(IsCriticalHook))
|
|
{
|
|
string methodToken = $"{hook.MethodName}Delegate";
|
|
if (content.Contains(methodToken, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
string snippet = BuildGameApiTodoSnippet(hook, now);
|
|
content = InsertAtMarker(content, ApiMarker, snippet);
|
|
counters.Added += CountLines(snippet);
|
|
|
|
warnings.Add($"⚠️ GameAPITable: {hook.MethodName} manuell prüfen + API_TABLE_VERSION erhöhen!");
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
private static string BuildHarmonyPatchSnippet(HookDefinition hook, DateTime now)
|
|
{
|
|
string className = GetPatchClassName(hook);
|
|
string parameterList = BuildPatchParameterList(hook);
|
|
string namespaceAndClass = $"{hook.Namespace}.{hook.ClassName}";
|
|
string eventName = GetEventIdName(hook);
|
|
|
|
return $"// [AUTO-GENERATED by gregExtractor sync — {now:yyyy-MM-dd}]\n" +
|
|
$"// Hook: {hook.Group} — Überprüfe Payload und aktiviere Event-Dispatch!\n" +
|
|
$"[HarmonyPatch(typeof({namespaceAndClass}),\n" +
|
|
$" nameof({namespaceAndClass}.{hook.MethodName}))]\n" +
|
|
$"internal static class {className}\n" +
|
|
"{\n" +
|
|
$" private static void Postfix({hook.ClassName} __instance{parameterList})\n" +
|
|
" {\n" +
|
|
" // TODO: Payload extrahieren und dispatchen\n" +
|
|
" // GregHookIntegration.EmitForSimple(\n" +
|
|
$" // EventIds.{eventName},\n" +
|
|
" // __instance\n" +
|
|
" // );\n" +
|
|
" }\n" +
|
|
"}\n\n";
|
|
}
|
|
|
|
private static string BuildDtoSnippet(HookDefinition hook, DateTime now)
|
|
{
|
|
string structName = GetDtoName(hook);
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine($"// [AUTO-GENERATED by gregExtractor sync — {now:O}]");
|
|
builder.AppendLine("[StructLayout(LayoutKind.Sequential)]");
|
|
builder.AppendLine($"public struct {structName} : IModEvent");
|
|
builder.AppendLine("{");
|
|
builder.AppendLine(" public DateTime OccurredAtUtc { get; init; }");
|
|
|
|
foreach (HookParameter parameter in hook.Parameters)
|
|
{
|
|
string parameterName = ToPascalCase(parameter.Name);
|
|
string parameterType = NormalizeTypeName(parameter.Type);
|
|
builder.AppendLine($" public {parameterType} {parameterName} {{ get; init; }}");
|
|
}
|
|
|
|
builder.AppendLine("}");
|
|
builder.AppendLine();
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static string BuildGameApiTodoSnippet(HookDefinition hook, DateTime now)
|
|
{
|
|
string delegateSignature = string.Join(", ", hook.Parameters.Select(x => $"{NormalizeTypeName(x.Type)} {ToCamelCase(x.Name)}"));
|
|
string fnName = ToCamelCase(hook.MethodName) + "Fn";
|
|
|
|
return $"// [AUTO-GENERATED — Version Bump erforderlich!]\n" +
|
|
"// WICHTIG: GameAPITable ist ABI-kritisch.\n" +
|
|
"// Neue Felder NUR ANS ENDE anhängen, niemals reordnen!\n" +
|
|
"// Nach dieser Änderung: API_TABLE_VERSION erhöhen!\n" +
|
|
$"// [GeneratedAt: {now:O}]\n" +
|
|
$"// public delegate {NormalizeTypeName(hook.ReturnType)} {hook.MethodName}Delegate({delegateSignature});\n" +
|
|
"// public IntPtr " + fnName + "; // Offset: TODO_nextOffset\n\n";
|
|
}
|
|
|
|
private static string EnsureMarker(string content, string marker)
|
|
{
|
|
if (content.Contains(marker, StringComparison.Ordinal))
|
|
return content;
|
|
|
|
return content.TrimEnd() + Environment.NewLine + Environment.NewLine + marker + Environment.NewLine;
|
|
}
|
|
|
|
private static string InsertAtMarker(string content, string marker, string snippet)
|
|
{
|
|
int markerIndex = content.IndexOf(marker, StringComparison.Ordinal);
|
|
if (markerIndex < 0)
|
|
return content + Environment.NewLine + snippet;
|
|
|
|
int insertIndex = markerIndex + marker.Length;
|
|
return content.Insert(insertIndex, Environment.NewLine + snippet);
|
|
}
|
|
|
|
private static int GetNextFreeEventId(string content)
|
|
{
|
|
MatchCollection matches = Regex.Matches(content, @"public\s+const\s+int\s+\w+\s*=\s*(\d+)\s*;", RegexOptions.CultureInvariant);
|
|
int max = 0;
|
|
foreach (Match match in matches)
|
|
{
|
|
if (!int.TryParse(match.Groups[1].Value, out int value))
|
|
continue;
|
|
max = Math.Max(max, value);
|
|
}
|
|
|
|
return max + 1;
|
|
}
|
|
|
|
private static string CommentMatchingLine(string content, string contains, string deprecationHeader, LineCounters counters)
|
|
{
|
|
string[] lines = content.Replace("\r", string.Empty, StringComparison.Ordinal).Split('\n');
|
|
bool changed = false;
|
|
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
if (!lines[i].Contains(contains, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (lines[i].TrimStart().StartsWith("//", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
lines[i] = "// " + lines[i];
|
|
counters.Removed++;
|
|
changed = true;
|
|
|
|
if (i == 0 || !lines[i - 1].Contains("[DEPRECATED", StringComparison.Ordinal))
|
|
{
|
|
lines[i] = deprecationHeader + Environment.NewLine + lines[i];
|
|
counters.Removed++;
|
|
}
|
|
}
|
|
|
|
return changed ? string.Join(Environment.NewLine, lines) : content;
|
|
}
|
|
|
|
private static bool TryCommentOutClass(string content, string className, DateTime now, string reason, out string updatedContent, out int removedLines)
|
|
{
|
|
return TryCommentOutTypeBlock(content, $"internal static class {className}",
|
|
$"// [DEPRECATED by gregExtractor sync — {now:O}]\n// {reason}",
|
|
out updatedContent,
|
|
out removedLines);
|
|
}
|
|
|
|
private static bool TryCommentOutStruct(string content, string structName, DateTime now, out string updatedContent, out int removedLines)
|
|
{
|
|
bool success = TryCommentOutTypeBlock(content, $"public struct {structName}",
|
|
$"[Obsolete(\"Removed in game version — {now:O}\")]\n// [DEPRECATED by gregExtractor sync — {now:O}]",
|
|
out updatedContent,
|
|
out removedLines);
|
|
|
|
return success;
|
|
}
|
|
|
|
private static bool TryCommentOutTypeBlock(string content, string typeDeclarationNeedle, string header, out string updatedContent, out int removedLines)
|
|
{
|
|
updatedContent = content;
|
|
removedLines = 0;
|
|
|
|
int typeIndex = content.IndexOf(typeDeclarationNeedle, StringComparison.Ordinal);
|
|
if (typeIndex < 0)
|
|
return false;
|
|
|
|
int blockStart = FindBlockStart(content, typeIndex);
|
|
int braceStart = content.IndexOf('{', typeIndex);
|
|
if (braceStart < 0)
|
|
return false;
|
|
|
|
int blockEnd = FindMatchingBrace(content, braceStart);
|
|
if (blockEnd < 0)
|
|
return false;
|
|
|
|
string block = content.Substring(blockStart, blockEnd - blockStart + 1);
|
|
if (block.Contains("[DEPRECATED by gregExtractor sync", StringComparison.Ordinal))
|
|
return false;
|
|
|
|
string commentedBlock = string.Join(Environment.NewLine,
|
|
block.Replace("\r", string.Empty, StringComparison.Ordinal)
|
|
.Split('\n')
|
|
.Select(line => line.TrimStart().StartsWith("//", StringComparison.Ordinal) ? line : "// " + line));
|
|
|
|
removedLines += CountLines(block);
|
|
string replacement = header + Environment.NewLine + commentedBlock;
|
|
updatedContent = content.Remove(blockStart, block.Length).Insert(blockStart, replacement);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryUpdatePostfixSignature(string content, string className, HookDefinition hook, DateTime now, out string updatedContent)
|
|
{
|
|
updatedContent = content;
|
|
|
|
int classIndex = content.IndexOf($"internal static class {className}", StringComparison.Ordinal);
|
|
if (classIndex < 0)
|
|
return false;
|
|
|
|
int classBraceStart = content.IndexOf('{', classIndex);
|
|
if (classBraceStart < 0)
|
|
return false;
|
|
|
|
int classBraceEnd = FindMatchingBrace(content, classBraceStart);
|
|
if (classBraceEnd < 0)
|
|
return false;
|
|
|
|
string classBlock = content.Substring(classIndex, classBraceEnd - classIndex + 1);
|
|
Match postfixMatch = Regex.Match(classBlock, @"private\s+static\s+void\s+Postfix\s*\(([^)]*)\)", RegexOptions.CultureInvariant);
|
|
if (!postfixMatch.Success)
|
|
return false;
|
|
|
|
string newSignature = $"{hook.ClassName} __instance{BuildPatchParameterList(hook)}";
|
|
if (string.Equals(postfixMatch.Groups[1].Value.Trim(), newSignature.Trim(), StringComparison.Ordinal))
|
|
return false;
|
|
|
|
string replacedBlock = classBlock.Replace(postfixMatch.Groups[1].Value, newSignature, StringComparison.Ordinal);
|
|
|
|
if (!replacedBlock.Contains("[SIGNATURE CHANGED", StringComparison.Ordinal))
|
|
{
|
|
replacedBlock = replacedBlock.Replace("private static void Postfix", $"// [SIGNATURE CHANGED — {now:O}] Bitte Payload prüfen!{Environment.NewLine} private static void Postfix", StringComparison.Ordinal);
|
|
}
|
|
|
|
updatedContent = content.Remove(classIndex, classBlock.Length).Insert(classIndex, replacedBlock);
|
|
return true;
|
|
}
|
|
|
|
private static int FindBlockStart(string content, int index)
|
|
{
|
|
int start = index;
|
|
while (start > 0)
|
|
{
|
|
int previousLineStart = content.LastIndexOf('\n', Math.Max(0, start - 2));
|
|
if (previousLineStart < 0)
|
|
break;
|
|
|
|
string line = content[(previousLineStart + 1)..start].Trim();
|
|
if (line.StartsWith("[", StringComparison.Ordinal) || line.StartsWith("//", StringComparison.Ordinal))
|
|
{
|
|
start = previousLineStart + 1;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return start;
|
|
}
|
|
|
|
private static int FindMatchingBrace(string content, int openBraceIndex)
|
|
{
|
|
int depth = 0;
|
|
for (int i = openBraceIndex; i < content.Length; i++)
|
|
{
|
|
char current = content[i];
|
|
if (current == '{')
|
|
depth++;
|
|
else if (current == '}')
|
|
depth--;
|
|
|
|
if (depth == 0)
|
|
return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
private static void ProcessFile(
|
|
string filePath,
|
|
SyncOptions options,
|
|
string progressPrefix,
|
|
string requiredMarker,
|
|
Func<string, DateTime, string> transform,
|
|
List<string> filesWritten)
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
options.Progress?.Report($"> Verarbeite {progressPrefix}");
|
|
|
|
string content = File.ReadAllText(filePath);
|
|
string transformed = transform(content, now);
|
|
|
|
if (string.Equals(content, transformed, StringComparison.Ordinal))
|
|
{
|
|
options.Progress?.Report($"= Keine Änderungen in {progressPrefix}");
|
|
return;
|
|
}
|
|
|
|
if (options.DryRun)
|
|
{
|
|
options.Progress?.Report($"[DryRun] Würde {progressPrefix} schreiben");
|
|
filesWritten.Add(filePath + " (dry-run)");
|
|
return;
|
|
}
|
|
|
|
string backupPath = CreateBackup(filePath, now);
|
|
options.Progress?.Report($"> Backup erstellt: {backupPath}");
|
|
|
|
File.WriteAllText(filePath, transformed, Encoding.UTF8);
|
|
filesWritten.Add(filePath);
|
|
options.Progress?.Report($"✓ {progressPrefix} aktualisiert");
|
|
}
|
|
|
|
private static string CreateBackup(string filePath, DateTime now)
|
|
{
|
|
string backupPath = filePath + $".bak.{now:yyyyMMdd_HHmmss}";
|
|
File.Copy(filePath, backupPath, overwrite: true);
|
|
return backupPath;
|
|
}
|
|
|
|
private static string FindFile(string root, string fileName)
|
|
{
|
|
string? file = Directory
|
|
.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
|
|
.FirstOrDefault();
|
|
|
|
if (string.IsNullOrWhiteSpace(file))
|
|
throw new FileNotFoundException($"Required framework file '{fileName}' not found under: {root}");
|
|
|
|
return file;
|
|
}
|
|
|
|
private static bool IsCriticalHook(HookDefinition hook)
|
|
{
|
|
string group = hook.Group ?? string.Empty;
|
|
string method = hook.MethodName ?? string.Empty;
|
|
|
|
return group.Contains("economy", StringComparison.OrdinalIgnoreCase)
|
|
|| group.Contains("persistence", StringComparison.OrdinalIgnoreCase)
|
|
|| group.Contains("hardware", StringComparison.OrdinalIgnoreCase)
|
|
|| method.Contains("save", StringComparison.OrdinalIgnoreCase)
|
|
|| method.Contains("load", StringComparison.OrdinalIgnoreCase)
|
|
|| method.Contains("coin", StringComparison.OrdinalIgnoreCase)
|
|
|| method.Contains("money", StringComparison.OrdinalIgnoreCase)
|
|
|| method.Contains("server", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string BuildPatchParameterList(HookDefinition hook)
|
|
{
|
|
if (hook.Parameters.Count == 0)
|
|
return string.Empty;
|
|
|
|
return string.Concat(hook.Parameters.Select(parameter => $", {NormalizeTypeName(parameter.Type)} {ToCamelCase(parameter.Name)}"));
|
|
}
|
|
|
|
private static string GetPatchClassName(HookDefinition hook)
|
|
{
|
|
return SanitizeIdentifier($"{hook.ClassName}_{hook.MethodName}Patch");
|
|
}
|
|
|
|
private static string GetDtoName(HookDefinition hook)
|
|
{
|
|
return SanitizeIdentifier($"{hook.ClassName}_{hook.MethodName}Data");
|
|
}
|
|
|
|
private static string GetEventIdName(HookDefinition hook)
|
|
{
|
|
return SanitizeIdentifier($"{hook.Group}_{hook.MethodName}");
|
|
}
|
|
|
|
private static string NormalizeTypeName(string type)
|
|
{
|
|
return (type ?? "object").Trim();
|
|
}
|
|
|
|
private static string SanitizeIdentifier(string value)
|
|
{
|
|
var builder = new StringBuilder(value.Length);
|
|
foreach (char c in value)
|
|
{
|
|
if (char.IsLetterOrDigit(c) || c == '_')
|
|
builder.Append(c);
|
|
else
|
|
builder.Append('_');
|
|
}
|
|
|
|
if (builder.Length == 0)
|
|
return "GeneratedSymbol";
|
|
|
|
if (!char.IsLetter(builder[0]) && builder[0] != '_')
|
|
builder.Insert(0, '_');
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static int CountLines(string text)
|
|
{
|
|
return text.Replace("\r", string.Empty, StringComparison.Ordinal).Split('\n').Length;
|
|
}
|
|
|
|
private static string ToPascalCase(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return "Value";
|
|
|
|
string[] parts = value.Split(new[] { '_', '-', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 0)
|
|
return "Value";
|
|
|
|
return string.Concat(parts.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
|
|
}
|
|
|
|
private static string ToCamelCase(string value)
|
|
{
|
|
string pascal = ToPascalCase(value);
|
|
if (pascal.Length == 1)
|
|
return pascal.ToLowerInvariant();
|
|
|
|
return char.ToLowerInvariant(pascal[0]) + pascal[1..];
|
|
}
|
|
|
|
private static string Escape(string value)
|
|
{
|
|
return (value ?? string.Empty).Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
public sealed record SyncOptions(
|
|
string FrameworkSourceDir,
|
|
HookDiff Diff,
|
|
List<HookDefinition> AllHooks,
|
|
bool DryRun,
|
|
IProgress<string>? Progress);
|