diff --git a/apply_patch.cs b/apply_patch.cs new file mode 100644 index 0000000..a2759a2 --- /dev/null +++ b/apply_patch.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +internal sealed record PatchFile(string OriginalPath, string ModifiedPath, List Hunks, PatchFileType Type); + +internal enum PatchFileType +{ + Modify, + Add, + Delete +} + +internal sealed record PatchHunk(int OriginalStart, int OriginalLength, int ModifiedStart, int ModifiedLength, List Lines); + +internal abstract record PatchLine(string Content, bool HasTrailingNewLine); + +internal sealed record ContextLine(string Content, bool HasTrailingNewLine) : PatchLine(Content, HasTrailingNewLine); + +internal sealed record AddedLine(string Content, bool HasTrailingNewLine) : PatchLine(Content, HasTrailingNewLine); + +internal sealed record RemovedLine(string Content, bool HasTrailingNewLine) : PatchLine(Content, HasTrailingNewLine); + +internal sealed class FileSnapshot +{ + public FileSnapshot(List lines, string newLine, bool hadTrailingNewLine) + { + Lines = lines; + NewLine = newLine; + HadTrailingNewLine = hadTrailingNewLine; + } + + public List Lines { get; } + + public string NewLine { get; } + + public bool HadTrailingNewLine { get; set; } +} + +if (args.Length == 0) +{ + Console.Error.WriteLine("Usage: dotnet script apply_patch.cs -- [target-directory]"); + Environment.Exit(1); +} + +var patchFilePath = args[0]; +var targetRoot = args.Length > 1 ? Path.GetFullPath(args[1]) : Directory.GetCurrentDirectory(); + +if (!File.Exists(patchFilePath)) +{ + Console.Error.WriteLine($"Patch file '{patchFilePath}' does not exist."); + Environment.Exit(1); +} + +if (!Directory.Exists(targetRoot)) +{ + Console.Error.WriteLine($"Target directory '{targetRoot}' does not exist."); + Environment.Exit(1); +} + +var patchText = File.ReadAllLines(patchFilePath); +var parsedFiles = ParsePatch(patchText); + +foreach (var patchFile in parsedFiles) +{ + ApplyPatch(targetRoot, patchFile); +} + +static List ParsePatch(IReadOnlyList lines) +{ + var files = new List(); + int index = 0; + + while (index < lines.Count) + { + if (!lines[index].StartsWith("diff --git ", StringComparison.Ordinal)) + { + index++; + continue; + } + + var diffHeader = lines[index]; + index++; + + string? originalPath = null; + string? modifiedPath = null; + var hunks = new List(); + PatchFileType type = PatchFileType.Modify; + + while (index < lines.Count) + { + var line = lines[index]; + + if (line.StartsWith("diff --git ", StringComparison.Ordinal) && hunks.Count > 0) + { + break; + } + + if (line.StartsWith("--- ", StringComparison.Ordinal)) + { + originalPath = NormalizePath(line.Substring(4)); + index++; + continue; + } + + if (line.StartsWith("+++ ", StringComparison.Ordinal)) + { + modifiedPath = NormalizePath(line.Substring(4)); + index++; + continue; + } + + if (line.StartsWith("@@", StringComparison.Ordinal)) + { + var hunk = ParseHunk(lines, ref index); + hunks.Add(hunk); + continue; + } + + index++; + } + + if (originalPath is null || modifiedPath is null) + { + throw new InvalidOperationException($"Missing file paths for patch header: {diffHeader}"); + } + + if (IsDevNull(originalPath) && !IsDevNull(modifiedPath)) + { + type = PatchFileType.Add; + } + else if (!IsDevNull(originalPath) && IsDevNull(modifiedPath)) + { + type = PatchFileType.Delete; + } + + files.Add(new PatchFile(originalPath, modifiedPath, hunks, type)); + } + + return files; +} + +static PatchHunk ParseHunk(IReadOnlyList lines, ref int index) +{ + var header = lines[index]; + var (originalStart, originalLength, modifiedStart, modifiedLength) = ParseHunkHeader(header); + index++; + + var patchLines = new List(); + + while (index < lines.Count) + { + var line = lines[index]; + + if (line.StartsWith("diff --git ", StringComparison.Ordinal) || line.StartsWith("@@", StringComparison.Ordinal)) + { + break; + } + + if (line.StartsWith("+++ ", StringComparison.Ordinal) || line.StartsWith("--- ", StringComparison.Ordinal)) + { + break; + } + + if (line.StartsWith("+", StringComparison.Ordinal)) + { + patchLines.Add(new AddedLine(line.Substring(1), true)); + index++; + continue; + } + + if (line.StartsWith("-", StringComparison.Ordinal)) + { + patchLines.Add(new RemovedLine(line.Substring(1), true)); + index++; + continue; + } + + if (line.StartsWith(" ", StringComparison.Ordinal)) + { + patchLines.Add(new ContextLine(line.Substring(1), true)); + index++; + continue; + } + + if (line.StartsWith("\\ No newline at end of file", StringComparison.Ordinal)) + { + MarkLastLineWithoutTrailingNewline(patchLines); + index++; + continue; + } + + if (line.Length == 0) + { + patchLines.Add(new ContextLine(string.Empty, true)); + index++; + continue; + } + + throw new InvalidOperationException($"Unexpected line in hunk: '{line}'"); + } + + return new PatchHunk(originalStart, originalLength, modifiedStart, modifiedLength, patchLines); +} + +static void MarkLastLineWithoutTrailingNewline(List lines) +{ + if (lines.Count == 0) + { + return; + } + + var last = lines[^1]; + + lines[^1] = last switch + { + ContextLine context => context with { HasTrailingNewLine = false }, + AddedLine added => added with { HasTrailingNewLine = false }, + RemovedLine removed => removed with { HasTrailingNewLine = false }, + _ => last + }; +} + +static (int originalStart, int originalLength, int modifiedStart, int modifiedLength) ParseHunkHeader(string header) +{ + // Format: @@ -start,length +start,length @@ optional text + int firstSpace = header.IndexOf(' '); + int secondSpace = header.IndexOf(' ', firstSpace + 1); + var originalRange = header.Substring(firstSpace + 1, secondSpace - firstSpace - 1); + var modifiedRangeEnd = header.IndexOf(' ', secondSpace + 1); + string modifiedRange; + + if (modifiedRangeEnd == -1) + { + modifiedRange = header.Substring(secondSpace + 1).TrimEnd('@').Trim(); + } + else + { + modifiedRange = header.Substring(secondSpace + 1, modifiedRangeEnd - secondSpace - 1); + } + + var (originalStart, originalLength) = ParseRange(originalRange); + var (modifiedStart, modifiedLength) = ParseRange(modifiedRange); + return (originalStart, originalLength, modifiedStart, modifiedLength); +} + +static (int start, int length) ParseRange(string range) +{ + // Format: -start,length or +start,length + int commaIndex = range.IndexOf(','); + string startText; + string lengthText; + + if (commaIndex >= 0) + { + startText = range.Substring(1, commaIndex - 1); + lengthText = range[(commaIndex + 1)..]; + } + else + { + startText = range.Substring(1); + lengthText = "1"; + } + + return (int.Parse(startText, CultureInfo.InvariantCulture), int.Parse(lengthText, CultureInfo.InvariantCulture)); +} + +static void ApplyPatch(string root, PatchFile patchFile) +{ + string targetPath = DetermineTargetPath(root, patchFile); + + switch (patchFile.Type) + { + case PatchFileType.Add: + ApplyAdd(targetPath, patchFile); + break; + case PatchFileType.Delete: + ApplyDelete(targetPath, patchFile); + break; + default: + ApplyModify(targetPath, patchFile); + break; + } +} + +static string DetermineTargetPath(string root, PatchFile patchFile) +{ + string path = IsDevNull(patchFile.ModifiedPath) ? patchFile.OriginalPath : patchFile.ModifiedPath; + var normalized = NormalizeRelativePath(path); + return Path.Combine(root, normalized); +} + +static string NormalizeRelativePath(string path) +{ + if (path.StartsWith("a/", StringComparison.Ordinal) || path.StartsWith("b/", StringComparison.Ordinal)) + { + return path.Substring(2); + } + + return path; +} + +static string NormalizePath(string headerPath) +{ + var trimmed = headerPath.Trim(); + + if (trimmed.StartsWith("a/", StringComparison.Ordinal) || trimmed.StartsWith("b/", StringComparison.Ordinal)) + { + return trimmed.Substring(2); + } + + return trimmed; +} + +static bool IsDevNull(string? path) +{ + return path is null || path.Equals("/dev/null", StringComparison.Ordinal); +} + +static void ApplyAdd(string targetPath, PatchFile patchFile) +{ + if (File.Exists(targetPath)) + { + throw new InvalidOperationException($"Cannot add '{targetPath}' because it already exists."); + } + + var directory = Path.GetDirectoryName(targetPath); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + var content = BuildContentFromHunks(patchFile.Hunks); + File.WriteAllText(targetPath, content); +} + +static void ApplyDelete(string targetPath, PatchFile patchFile) +{ + if (!File.Exists(targetPath)) + { + throw new InvalidOperationException($"Cannot delete '{targetPath}' because it does not exist."); + } + + var snapshot = ReadAllLinesPreservingNewlines(targetPath); + ApplyHunks(snapshot, patchFile.Hunks); + File.Delete(targetPath); +} + +static void ApplyModify(string targetPath, PatchFile patchFile) +{ + if (!File.Exists(targetPath)) + { + throw new InvalidOperationException($"Cannot modify '{targetPath}' because it does not exist."); + } + + var snapshot = ReadAllLinesPreservingNewlines(targetPath); + ApplyHunks(snapshot, patchFile.Hunks); + WriteAllLinesPreservingNewlines(targetPath, snapshot); +} + +static string BuildContentFromHunks(List hunks) +{ + var addedLines = hunks.SelectMany(h => h.Lines.OfType()).ToList(); + + if (addedLines.Count == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + + for (int i = 0; i < addedLines.Count; i++) + { + var added = addedLines[i]; + builder.Append(added.Content); + + if (added.HasTrailingNewLine || i < addedLines.Count - 1) + { + builder.Append(Environment.NewLine); + } + } + + return builder.ToString(); +} + +static void ApplyHunks(FileSnapshot snapshot, List hunks) +{ + var lines = snapshot.Lines; + int lineOffset = 0; + bool? trailingNewLineOverride = null; + + foreach (var hunk in hunks) + { + int targetIndex = hunk.OriginalStart - 1 + lineOffset; + + foreach (var patchLine in hunk.Lines) + { + switch (patchLine) + { + case ContextLine context: + if (targetIndex >= lines.Count || !string.Equals(lines[targetIndex], context.Content, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Context mismatch applying hunk at line {hunk.OriginalStart}."); + } + + targetIndex++; + trailingNewLineOverride = context.HasTrailingNewLine; + break; + case RemovedLine removed: + if (targetIndex >= lines.Count || !string.Equals(lines[targetIndex], removed.Content, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Removal mismatch applying hunk at line {hunk.OriginalStart}."); + } + + lines.RemoveAt(targetIndex); + lineOffset--; + break; + case AddedLine added: + { + lines.Insert(targetIndex, added.Content); + targetIndex++; + lineOffset++; + trailingNewLineOverride = added.HasTrailingNewLine; + break; + } + } + } + } + + if (trailingNewLineOverride.HasValue) + { + snapshot.HadTrailingNewLine = trailingNewLineOverride.Value; + } +} + +static FileSnapshot ReadAllLinesPreservingNewlines(string path) +{ + var text = File.ReadAllText(path); + + if (text.Length == 0) + { + return new FileSnapshot(new List(), Environment.NewLine, false); + } + + var newLine = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var split = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).ToList(); + bool hadTrailingNewLine = text.EndsWith(newLine, StringComparison.Ordinal); + + if (hadTrailingNewLine && split.Count > 0 && split[^1].Length == 0) + { + split.RemoveAt(split.Count - 1); + } + + return new FileSnapshot(split, newLine, hadTrailingNewLine); +} + +static void WriteAllLinesPreservingNewlines(string path, FileSnapshot snapshot) +{ + var content = string.Join(snapshot.NewLine, snapshot.Lines); + + if (snapshot.HadTrailingNewLine) + { + content += snapshot.NewLine; + } + + File.WriteAllText(path, content); +}