Files
even-perfect-numbers-scanner/apply_patch.cs

1222 lines
34 KiB
C#

#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
internal sealed record PatchFile(string OriginalPath, string ModifiedPath, List<PatchHunk> Hunks, PatchFileType Type);
internal enum PatchFileType
{
Modify,
Add,
Delete
}
internal sealed record PatchHunk(int OriginalStart, int OriginalLength, int ModifiedStart, int ModifiedLength, List<PatchLine> 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<string> lines, string newLine, bool hadTrailingNewLine)
{
Lines = lines;
NewLine = newLine;
HadTrailingNewLine = hadTrailingNewLine;
}
public List<string> Lines { get; }
public string NewLine { get; }
public bool HadTrailingNewLine { get; set; }
}
internal readonly record struct PatchApplicationOptions(bool CheckOnly, bool IgnoreWhitespace, bool IgnoreLineEndings);
internal sealed record PatchRunResult(string PatchFilePath, bool Success, string? ErrorMessage);
internal sealed record PatchProfileEntry(
string PatchFilePath,
int FileCount,
int HunkCount,
int LineCount,
long PatchBytes,
TimeSpan ReadTime,
TimeSpan ParseTime,
TimeSpan ApplyTime);
internal sealed record LineReplacement(string RelativePath, int LineNumber, string? ExpectedContent, string NewContent);
internal sealed class LineReplacementException : Exception
{
public LineReplacementException(LineReplacement replacement, string message)
: base(message)
{
Replacement = replacement;
}
public LineReplacement Replacement { get; }
}
var args = Args?.ToArray() ?? Array.Empty<string>();
if (args.Length == 0)
{
PrintUsage();
Environment.Exit(1);
}
bool checkOnly = false;
bool ignoreWhitespace = false;
bool ignoreLineEndings = false;
bool profile = false;
string? explicitTargetRoot = null;
var patchFileArguments = new List<string>();
var lineReplacements = new List<LineReplacement>();
for (int i = 0; i < args.Length; i++)
{
var argument = args[i];
if (string.Equals(argument, "--check", StringComparison.Ordinal))
{
checkOnly = true;
continue;
}
if (string.Equals(argument, "--ignore-whitespace", StringComparison.Ordinal))
{
ignoreWhitespace = true;
continue;
}
if (string.Equals(argument, "--ignore-eol", StringComparison.Ordinal) || string.Equals(argument, "--ignore-line-endings", StringComparison.Ordinal))
{
ignoreLineEndings = true;
continue;
}
if (string.Equals(argument, "--profile", StringComparison.Ordinal))
{
profile = true;
continue;
}
if (string.Equals(argument, "--target", StringComparison.Ordinal))
{
if (i + 1 >= args.Length)
{
Console.Error.WriteLine("Missing value for --target option.");
Environment.Exit(1);
}
explicitTargetRoot = Path.GetFullPath(args[++i]);
continue;
}
if (string.Equals(argument, "--replace-line", StringComparison.Ordinal))
{
if (i + 4 >= args.Length)
{
Console.Error.WriteLine("--replace-line requires <path> <line-number> <expected-text> <new-text> arguments.");
Environment.Exit(1);
}
var replacementPath = args[++i];
if (!int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var replacementLine) || replacementLine <= 0)
{
Console.Error.WriteLine("--replace-line requires a positive integer line number.");
Environment.Exit(1);
}
var expected = args[++i];
if (string.Equals(expected, "_", StringComparison.Ordinal))
{
expected = null;
}
var newContent = args[++i];
lineReplacements.Add(new LineReplacement(replacementPath, replacementLine, expected, newContent));
continue;
}
patchFileArguments.Add(argument);
}
if (patchFileArguments.Count == 0 && lineReplacements.Count == 0)
{
PrintUsage();
Environment.Exit(1);
}
string targetRoot;
if (explicitTargetRoot is not null)
{
targetRoot = explicitTargetRoot;
}
else if (patchFileArguments.Count > 1)
{
var potentialTarget = patchFileArguments[^1];
if (Directory.Exists(potentialTarget))
{
targetRoot = Path.GetFullPath(potentialTarget);
patchFileArguments.RemoveAt(patchFileArguments.Count - 1);
}
else
{
targetRoot = Directory.GetCurrentDirectory();
}
}
else
{
targetRoot = Directory.GetCurrentDirectory();
}
if (patchFileArguments.Count == 0 && lineReplacements.Count == 0)
{
Console.Error.WriteLine("No patch files or line replacements specified.");
Environment.Exit(1);
}
targetRoot = Path.GetFullPath(targetRoot);
if (!Directory.Exists(targetRoot))
{
Console.Error.WriteLine($"Target directory '{targetRoot}' does not exist.");
Environment.Exit(1);
}
var patchFilePaths = patchFileArguments.Select(Path.GetFullPath).ToList();
var applicationOptions = new PatchApplicationOptions(checkOnly, ignoreWhitespace, ignoreLineEndings);
var patchResults = new List<PatchRunResult>();
List<PatchProfileEntry>? patchProfiles = profile ? new List<PatchProfileEntry>() : null;
foreach (var patchFilePath in patchFilePaths)
{
try
{
if (!File.Exists(patchFilePath))
{
throw new FileNotFoundException($"Patch file '{patchFilePath}' does not exist.", patchFilePath);
}
Stopwatch? stopwatch = null;
TimeSpan readDuration = default;
TimeSpan parseDuration = default;
TimeSpan applyDuration = default;
long patchBytes = 0L;
int fileCount = 0;
int hunkCount = 0;
int lineCount = 0;
if (profile)
{
stopwatch = Stopwatch.StartNew();
patchBytes = new FileInfo(patchFilePath).Length;
}
var parsedFiles = ParsePatchFromFile(patchFilePath);
if (stopwatch is not null)
{
readDuration = TimeSpan.Zero;
parseDuration = stopwatch.Elapsed;
fileCount = parsedFiles.Count;
foreach (var parsedFile in parsedFiles)
{
hunkCount += parsedFile.Hunks.Count;
foreach (var hunk in parsedFile.Hunks)
{
lineCount += hunk.Lines.Count;
}
}
stopwatch.Restart();
}
foreach (var patchFile in parsedFiles)
{
ApplyPatch(targetRoot, patchFile, applicationOptions);
}
if (stopwatch is not null && patchProfiles is not null)
{
applyDuration = stopwatch.Elapsed;
patchProfiles.Add(new PatchProfileEntry(patchFilePath, fileCount, hunkCount, lineCount, patchBytes, readDuration, parseDuration, applyDuration));
}
patchResults.Add(new PatchRunResult(patchFilePath, true, null));
if (!applicationOptions.CheckOnly)
{
TryDeletePatchFile(patchFilePath);
}
}
catch (Exception ex)
{
patchResults.Add(new PatchRunResult(patchFilePath, false, ex.Message));
}
}
if (lineReplacements.Count > 0)
{
var replacementResults = ApplyLineReplacements(targetRoot, lineReplacements, applicationOptions);
patchResults.AddRange(replacementResults);
}
foreach (var result in patchResults)
{
var displayPath = GetDisplayPath(result.PatchFilePath);
if (result.Success)
{
if (applicationOptions.CheckOnly)
{
Console.WriteLine($"Patch '{displayPath}' verified successfully. Ready to apply.");
}
else
{
Console.WriteLine($"Patch '{displayPath}' applied successfully.");
}
}
else
{
Console.Error.WriteLine($"Patch '{displayPath}' failed: {result.ErrorMessage}");
}
}
if (patchProfiles is { Count: > 0 })
{
Console.WriteLine("Patch profiling summary:");
foreach (var entry in patchProfiles)
{
var displayPath = GetDisplayPath(entry.PatchFilePath);
Console.WriteLine(
$"- {displayPath}: read {entry.ReadTime.TotalMilliseconds:F2} ms, parse {entry.ParseTime.TotalMilliseconds:F2} ms, apply {entry.ApplyTime.TotalMilliseconds:F2} ms " +
$"(files={entry.FileCount}, hunks={entry.HunkCount}, lines={entry.LineCount}, bytes={entry.PatchBytes})");
}
var totalFiles = patchProfiles.Sum(static e => e.FileCount);
var totalHunks = patchProfiles.Sum(static e => e.HunkCount);
var totalLines = patchProfiles.Sum(static e => e.LineCount);
var totalBytes = patchProfiles.Sum(static e => e.PatchBytes);
var totalRead = TimeSpan.FromTicks(patchProfiles.Sum(static e => e.ReadTime.Ticks));
var totalParse = TimeSpan.FromTicks(patchProfiles.Sum(static e => e.ParseTime.Ticks));
var totalApply = TimeSpan.FromTicks(patchProfiles.Sum(static e => e.ApplyTime.Ticks));
Console.WriteLine(
$"Totals: read {totalRead.TotalMilliseconds:F2} ms, parse {totalParse.TotalMilliseconds:F2} ms, apply {totalApply.TotalMilliseconds:F2} ms " +
$"(files={totalFiles}, hunks={totalHunks}, lines={totalLines}, bytes={totalBytes})");
}
if (patchResults.Any(static r => !r.Success))
{
Environment.Exit(1);
}
static List<PatchFile> ParsePatchFromFile(string patchFilePath)
{
var stream = File.OpenRead(patchFilePath);
var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 16_384, leaveOpen: false);
var lineReader = new PatchLineReader(reader);
var files = ParsePatch(lineReader);
lineReader.Dispose();
reader.Dispose();
stream.Dispose();
return files;
}
static List<PatchFile> ParsePatch(PatchLineReader reader)
{
var files = new List<PatchFile>();
string? diffHeader = null;
string? originalPath = null;
string? modifiedPath = null;
List<PatchHunk>? hunks = null;
while (reader.TryRead(out var currentLine))
{
if (currentLine is null)
{
break;
}
if (string.IsNullOrWhiteSpace(currentLine))
{
continue;
}
if (!currentLine.StartsWith("diff --git ", StringComparison.Ordinal))
{
if (diffHeader is null)
{
continue;
}
if (currentLine.StartsWith("--- ", StringComparison.Ordinal))
{
originalPath = NormalizePath(currentLine.AsSpan(4));
continue;
}
if (currentLine.StartsWith("+++ ", StringComparison.Ordinal))
{
modifiedPath = NormalizePath(currentLine.AsSpan(4));
continue;
}
if (currentLine.StartsWith("@@", StringComparison.Ordinal))
{
hunks ??= new List<PatchHunk>();
var hunk = ParseHunk(currentLine, reader);
hunks.Add(hunk);
continue;
}
continue;
}
if (diffHeader is not null)
{
FinalizePatchFile(files, diffHeader, originalPath, modifiedPath, hunks);
}
diffHeader = currentLine;
originalPath = null;
modifiedPath = null;
hunks = new List<PatchHunk>();
}
if (diffHeader is not null)
{
FinalizePatchFile(files, diffHeader, originalPath, modifiedPath, hunks);
}
return files;
}
static PatchHunk ParseHunk(string header, PatchLineReader reader)
{
var (originalStart, originalLength, modifiedStart, modifiedLength) = ParseHunkHeader(header);
var patchLines = new List<PatchLine>();
while (reader.TryPeek(out var nextLine))
{
if (nextLine is null)
{
break;
}
if (nextLine.StartsWith("diff --git ", StringComparison.Ordinal) ||
nextLine.StartsWith("@@", StringComparison.Ordinal) ||
nextLine.StartsWith("+++ ", StringComparison.Ordinal) ||
nextLine.StartsWith("--- ", StringComparison.Ordinal))
{
break;
}
if (!reader.TryRead(out var consumed) || consumed is null)
{
break;
}
var line = consumed;
if (line.StartsWith("+", StringComparison.Ordinal))
{
patchLines.Add(new AddedLine(line.Substring(1), true));
continue;
}
if (line.StartsWith("-", StringComparison.Ordinal))
{
patchLines.Add(new RemovedLine(line.Substring(1), true));
continue;
}
if (line.StartsWith(" ", StringComparison.Ordinal))
{
patchLines.Add(new ContextLine(line.Substring(1), true));
continue;
}
if (line.StartsWith("\\ No newline at end of file", StringComparison.Ordinal))
{
MarkLastLineWithoutTrailingNewline(patchLines);
continue;
}
if (line.Length == 0)
{
// Skip stray blank separators so hunks without an empty line still parse correctly.
continue;
}
throw new InvalidOperationException($"Unexpected line in hunk: '{line}'");
}
return new PatchHunk(originalStart, originalLength, modifiedStart, modifiedLength, patchLines);
}
static void FinalizePatchFile(
List<PatchFile> files,
string diffHeader,
string? originalPath,
string? modifiedPath,
List<PatchHunk>? hunks)
{
if (originalPath is null || modifiedPath is null)
{
throw new InvalidOperationException($"Missing file paths for patch header: {diffHeader}");
}
PatchFileType type = PatchFileType.Modify;
if (IsDevNull(originalPath) && !IsDevNull(modifiedPath))
{
type = PatchFileType.Add;
}
else if (!IsDevNull(originalPath) && IsDevNull(modifiedPath))
{
type = PatchFileType.Delete;
}
files.Add(new PatchFile(originalPath, modifiedPath, hunks ?? new List<PatchHunk>(), type));
}
private sealed class PatchLineReader
{
private const int ReadBufferSize = 4096;
private const int InitialLineBufferSize = 256;
private readonly StreamReader _reader;
private readonly char[] _readBuffer;
private char[] _lineBuffer;
private int _readLength;
private int _readIndex;
private string? _bufferedLine;
private bool _hasBufferedLine;
private bool _disposed;
public PatchLineReader(StreamReader reader)
{
_reader = reader;
_readBuffer = ArrayPool<char>.Shared.Rent(ReadBufferSize);
_lineBuffer = ArrayPool<char>.Shared.Rent(InitialLineBufferSize);
}
public bool TryPeek(out string? line)
{
if (!_hasBufferedLine)
{
if (!TryReadLineInternal(out _bufferedLine))
{
line = null;
return false;
}
_hasBufferedLine = true;
}
line = _bufferedLine;
return line is not null;
}
public bool TryRead(out string? line)
{
if (_hasBufferedLine)
{
line = _bufferedLine;
_bufferedLine = null;
_hasBufferedLine = false;
return line is not null;
}
return TryReadLineInternal(out line);
}
private bool TryReadLineInternal(out string? line)
{
if (_disposed)
{
line = null;
return false;
}
int length = 0;
while (true)
{
if (!TryReadChar(out char current))
{
if (length == 0)
{
line = null;
return false;
}
break;
}
if (current == '\r')
{
if ((_readIndex < _readLength) || EnsureCharBuffer())
{
if (_readIndex < _readLength && _readBuffer[_readIndex] == '\n')
{
_readIndex++;
}
}
break;
}
if (current == '\n')
{
break;
}
EnsureLineCapacity(length, length + 1);
_lineBuffer[length++] = current;
}
line = new string(_lineBuffer, 0, length);
return true;
}
private bool TryReadChar(out char value)
{
if (!EnsureCharBuffer())
{
value = '\0';
return false;
}
value = _readBuffer[_readIndex++];
return true;
}
private bool EnsureCharBuffer()
{
if (_readIndex < _readLength)
{
return true;
}
if (_disposed)
{
return false;
}
_readLength = _reader.Read(_readBuffer, 0, _readBuffer.Length);
_readIndex = 0;
return _readLength > 0;
}
private void EnsureLineCapacity(int currentLength, int requiredLength)
{
if (requiredLength <= _lineBuffer.Length)
{
return;
}
int newSize = _lineBuffer.Length * 2;
while (newSize < requiredLength)
{
newSize *= 2;
}
char[] newBuffer = ArrayPool<char>.Shared.Rent(newSize);
Array.Copy(_lineBuffer, newBuffer, currentLength);
ArrayPool<char>.Shared.Return(_lineBuffer);
_lineBuffer = newBuffer;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
ArrayPool<char>.Shared.Return(_readBuffer);
ArrayPool<char>.Shared.Return(_lineBuffer);
}
}
static void MarkLastLineWithoutTrailingNewline(List<PatchLine> 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)
{
var span = header.AsSpan();
int firstSpace = span.IndexOf(' ');
if (firstSpace < 0)
{
throw new InvalidOperationException($"Invalid hunk header: '{header}'.");
}
span = span[(firstSpace + 1)..];
int secondSpace = span.IndexOf(' ');
if (secondSpace < 0)
{
throw new InvalidOperationException($"Invalid hunk header: '{header}'.");
}
var originalRange = span[..secondSpace];
span = span[(secondSpace + 1)..];
int thirdSpace = span.IndexOf(' ');
ReadOnlySpan<char> modifiedRange;
if (thirdSpace < 0)
{
modifiedRange = span;
}
else
{
modifiedRange = span[..thirdSpace];
}
var (originalStart, originalLength) = ParseRange(originalRange);
var (modifiedStart, modifiedLength) = ParseRange(modifiedRange);
return (originalStart, originalLength, modifiedStart, modifiedLength);
}
static (int start, int length) ParseRange(ReadOnlySpan<char> range)
{
if (range.Length == 0)
{
throw new InvalidOperationException("Range descriptor cannot be empty.");
}
if (range[0] != '-' && range[0] != '+')
{
throw new InvalidOperationException($"Unexpected range prefix: '{range[0]}' in '{range.ToString()}'.");
}
var numericPortion = range[1..];
int commaIndex = numericPortion.IndexOf(',');
ReadOnlySpan<char> startText;
ReadOnlySpan<char> lengthText;
if (commaIndex >= 0)
{
startText = numericPortion[..commaIndex];
lengthText = numericPortion[(commaIndex + 1)..];
}
else
{
startText = numericPortion;
lengthText = "1";
}
return (int.Parse(startText, NumberStyles.Integer, CultureInfo.InvariantCulture), int.Parse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture));
}
static void ApplyPatch(string root, PatchFile patchFile, PatchApplicationOptions options)
{
string targetPath = DetermineTargetPath(root, patchFile);
switch (patchFile.Type)
{
case PatchFileType.Add:
ApplyAdd(targetPath, patchFile, options);
break;
case PatchFileType.Delete:
ApplyDelete(targetPath, patchFile, options);
break;
default:
ApplyModify(targetPath, patchFile, options);
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(ReadOnlySpan<char> headerPath)
{
var trimmed = headerPath.Trim();
if (trimmed.StartsWith("a/", StringComparison.Ordinal) || trimmed.StartsWith("b/", StringComparison.Ordinal))
{
trimmed = trimmed[2..];
}
return new string(trimmed);
}
static string NormalizePath(string headerPath) => NormalizePath(headerPath.AsSpan());
static bool IsDevNull(string? path)
{
return path is null || path.Equals("/dev/null", StringComparison.Ordinal);
}
static void ApplyAdd(string targetPath, PatchFile patchFile, PatchApplicationOptions options)
{
if (File.Exists(targetPath))
{
throw new InvalidOperationException($"Cannot add '{targetPath}' because it already exists.");
}
var directory = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrEmpty(directory) && !options.CheckOnly)
{
Directory.CreateDirectory(directory);
}
if (options.CheckOnly)
{
return;
}
var content = BuildContentFromHunks(patchFile.Hunks);
File.WriteAllText(targetPath, content);
}
static void ApplyDelete(string targetPath, PatchFile patchFile, PatchApplicationOptions options)
{
if (!File.Exists(targetPath))
{
throw new InvalidOperationException($"Cannot delete '{targetPath}' because it does not exist.");
}
var snapshot = ReadAllLinesPreservingNewlines(targetPath);
ApplyHunks(snapshot, patchFile.Hunks, options);
if (!options.CheckOnly)
{
File.Delete(targetPath);
}
}
static void ApplyModify(string targetPath, PatchFile patchFile, PatchApplicationOptions options)
{
if (!File.Exists(targetPath))
{
throw new InvalidOperationException($"Cannot modify '{targetPath}' because it does not exist.");
}
var snapshot = ReadAllLinesPreservingNewlines(targetPath);
ApplyHunks(snapshot, patchFile.Hunks, options);
if (!options.CheckOnly)
{
WriteAllLinesPreservingNewlines(targetPath, snapshot);
}
}
static string BuildContentFromHunks(List<PatchHunk> hunks)
{
var addedLines = hunks.SelectMany(h => h.Lines.OfType<AddedLine>()).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<PatchHunk> hunks, PatchApplicationOptions options)
{
var lines = snapshot.Lines;
int lineOffset = 0;
bool? trailingNewLineOverride = null;
foreach (var hunk in hunks)
{
int targetIndex = hunk.OriginalStart - 1 + lineOffset;
int relativeLine = 0;
if (targetIndex < 0)
{
throw new InvalidOperationException($"Invalid hunk target index computed for original line {hunk.OriginalStart}.");
}
foreach (var patchLine in hunk.Lines)
{
switch (patchLine)
{
case ContextLine context:
{
if (targetIndex >= lines.Count || !LinesMatch(lines[targetIndex], context.Content, options))
{
var actual = targetIndex < lines.Count ? lines[targetIndex] : null;
ThrowLineMismatch($"Context mismatch applying hunk starting at original line {hunk.OriginalStart + relativeLine}.", context.Content, actual, options);
}
targetIndex++;
trailingNewLineOverride = context.HasTrailingNewLine;
relativeLine++;
break;
}
case RemovedLine removed:
{
if (targetIndex >= lines.Count || !LinesMatch(lines[targetIndex], removed.Content, options))
{
var actual = targetIndex < lines.Count ? lines[targetIndex] : null;
ThrowLineMismatch($"Removal mismatch applying hunk starting at original line {hunk.OriginalStart + relativeLine}.", removed.Content, actual, options);
}
lines.RemoveAt(targetIndex);
lineOffset--;
trailingNewLineOverride = removed.HasTrailingNewLine;
relativeLine++;
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<string>(), 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);
}
static bool LinesMatch(string actual, string expected, PatchApplicationOptions options)
{
if (options.IgnoreLineEndings)
{
actual = NormalizeLineEndings(actual);
expected = NormalizeLineEndings(expected);
}
if (options.IgnoreWhitespace)
{
actual = NormalizeWhitespace(actual);
expected = NormalizeWhitespace(expected);
}
return string.Equals(actual, expected, StringComparison.Ordinal);
}
static string NormalizeWhitespace(string value)
{
if (value.Length == 0)
{
return value;
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
if (!char.IsWhiteSpace(ch))
{
builder.Append(ch);
}
}
return builder.ToString();
}
static string NormalizeLineEndings(string value)
{
if (value.IndexOf('\r', StringComparison.Ordinal) < 0)
{
return value;
}
return value.Replace("\r", string.Empty, StringComparison.Ordinal);
}
static string FormatLineForMessage(string line)
{
return '\'' + line
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\t", "\\t", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal)
.Replace("'", "\'", StringComparison.Ordinal) + '\'';
}
static void ThrowLineMismatch(string message, string expected, string? actual, PatchApplicationOptions options)
{
var builder = new StringBuilder();
builder.AppendLine(message);
builder.Append("Expected: ").AppendLine(FormatLineForMessage(expected));
builder.Append("Actual : ").AppendLine(actual is null ? "<missing line>" : FormatLineForMessage(actual));
if (options.IgnoreWhitespace || options.IgnoreLineEndings)
{
builder.Append("Comparison settings: ");
if (options.IgnoreWhitespace && options.IgnoreLineEndings)
{
builder.Append("ignoring whitespace and line endings.");
}
else if (options.IgnoreWhitespace)
{
builder.Append("ignoring whitespace.");
}
else
{
builder.Append("ignoring line endings.");
}
builder.AppendLine();
}
throw new InvalidOperationException(builder.ToString());
}
static string GetDisplayPath(string path)
{
try
{
var current = Directory.GetCurrentDirectory();
var relative = Path.GetRelativePath(current, path);
if (!relative.Contains("..", StringComparison.Ordinal))
{
return relative;
}
}
catch
{
return path;
}
return path;
}
static void TryDeletePatchFile(string patchFilePath)
{
try
{
if (File.Exists(patchFilePath))
{
File.Delete(patchFilePath);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: Failed to remove patch file '{GetDisplayPath(patchFilePath)}': {ex.Message}");
}
}
static List<PatchRunResult> ApplyLineReplacements(string root, List<LineReplacement> replacements, PatchApplicationOptions options)
{
var results = new List<PatchRunResult>();
foreach (var group in replacements.GroupBy(static r => NormalizeRelativePath(r.RelativePath), StringComparer.Ordinal))
{
var normalizedPath = group.Key;
var replacementsForFile = group.OrderBy(static r => r.LineNumber).ToList();
if (replacementsForFile.Count == 0)
{
continue;
}
try
{
var targetPath = Path.Combine(root, normalizedPath);
if (!File.Exists(targetPath))
{
var displayPath = GetDisplayPath(targetPath);
throw new LineReplacementException(replacementsForFile[0], $"Cannot replace line because file '{displayPath}' does not exist.");
}
var snapshot = ReadAllLinesPreservingNewlines(targetPath);
var lines = snapshot.Lines;
var pending = new List<(int Index, string NewContent)>();
foreach (var replacement in replacementsForFile)
{
if (replacement.LineNumber <= 0)
{
throw new LineReplacementException(replacement, $"Line numbers must be positive. Received {replacement.LineNumber} for '{GetDisplayPath(targetPath)}'.");
}
int index = replacement.LineNumber - 1;
if (index >= lines.Count)
{
throw new LineReplacementException(replacement, $"File '{GetDisplayPath(targetPath)}' contains only {lines.Count} lines. Cannot replace line {replacement.LineNumber}.");
}
if (replacement.ExpectedContent is not null)
{
var actual = lines[index];
if (!LinesMatch(actual, replacement.ExpectedContent, options))
{
try
{
ThrowLineMismatch($"Line {replacement.LineNumber} mismatch in '{GetDisplayPath(targetPath)}'.", replacement.ExpectedContent, actual, options);
}
catch (InvalidOperationException ex)
{
throw new LineReplacementException(replacement, ex.Message);
}
}
}
pending.Add((index, replacement.NewContent));
}
if (!options.CheckOnly)
{
foreach (var (index, newContent) in pending)
{
lines[index] = newContent;
}
WriteAllLinesPreservingNewlines(targetPath, snapshot);
}
foreach (var replacement in replacementsForFile)
{
results.Add(new PatchRunResult(BuildReplacementDisplay(replacement), true, null));
}
}
catch (LineReplacementException ex)
{
results.Add(new PatchRunResult(BuildReplacementDisplay(ex.Replacement), false, ex.Message));
}
catch (Exception ex)
{
var fallback = replacementsForFile[0];
results.Add(new PatchRunResult(BuildReplacementDisplay(fallback), false, ex.Message));
}
}
return results;
}
static string BuildReplacementDisplay(LineReplacement replacement)
{
var normalizedPath = NormalizeRelativePath(replacement.RelativePath);
return $"replace-line:{normalizedPath}:{replacement.LineNumber}";
}
static void PrintUsage()
{
Console.Error.WriteLine("Usage: dotnet script apply_patch.cs -- [options] <patch-file> [<patch-file> ...] [target-directory]");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --check Validate patches without modifying files.");
Console.Error.WriteLine(" --ignore-whitespace Ignore whitespace differences when matching context.");
Console.Error.WriteLine(" --ignore-eol Ignore line-ending differences when matching context.");
Console.Error.WriteLine(" --profile Collect read/parse/apply timings for each patch file.");
Console.Error.WriteLine(" --target <directory> Explicitly set the target directory.");
Console.Error.WriteLine(" --replace-line <path> <line-number> <expected-text> <new-text>");
Console.Error.WriteLine(" Replace a single line by absolute number; use '_' as expected-text to skip validation.");
}