199 lines
7.5 KiB
C#
199 lines
7.5 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace MergeHub.Services
|
||
{
|
||
/// <summary>공백을 얼마나 따질지.</summary>
|
||
public enum WhitespaceMode
|
||
{
|
||
/// <summary>공백 하나까지 그대로 비교한다.</summary>
|
||
Exact,
|
||
/// <summary>공백의 개수만 무시한다. 들여쓰기 폭을 바꿔도 같은 줄로 본다.</summary>
|
||
IgnoreAmount,
|
||
/// <summary>공백을 아예 없는 셈 친다. 탭/스페이스 정리를 무시할 때.</summary>
|
||
IgnoreAll
|
||
}
|
||
|
||
/// <summary>
|
||
/// "무엇을 차이로 볼 것인가"를 정하는 규칙.
|
||
///
|
||
/// 실무에서 diff 를 어지럽히는 것은 대개 내용이 아니라 형식이다 - 들여쓰기 정리,
|
||
/// 대소문자, 매번 바뀌는 타임스탬프 줄. 이런 것을 걸러야 진짜 변경이 눈에 들어온다.
|
||
///
|
||
/// 규칙은 두 갈래로 나뉘어 쓰인다.
|
||
/// · <see cref="KeyOf"/> — 정렬할 때 "같은 줄인가"를 판단하는 비교용 문자열.
|
||
/// 화면에 보이는 원문은 건드리지 않는다.
|
||
/// · <see cref="IsFiltered"/> — 줄 자체를 차이 계산에서 빼는 정규식 필터.
|
||
/// 정렬을 마친 뒤에 적용한다. 필터에 걸린 줄까지 같은 것으로 묶어 정렬하면
|
||
/// 엉뚱한 줄끼리 짝지어지기 때문이다.
|
||
/// </summary>
|
||
public sealed class CompareRules
|
||
{
|
||
public WhitespaceMode Whitespace { get; set; } = WhitespaceMode.Exact;
|
||
|
||
public bool IgnoreCase { get; set; }
|
||
|
||
/// <summary>이 정규식에 걸리는 줄은 차이로 세지 않는다. 잘못된 식은 조용히 버린다.</summary>
|
||
public IReadOnlyList<string> LinePatterns { get; set; } = Array.Empty<string>();
|
||
|
||
/// <summary>줄 필터를 켤지. 식은 그대로 두고 잠시 끄고 싶을 때가 있다.</summary>
|
||
public bool LineFilterEnabled { get; set; } = true;
|
||
|
||
/// <summary>
|
||
/// 머지 주석(<c>// [이름] 날짜</c>)을 차이로 세지 않을지(6차 B-5).
|
||
///
|
||
/// 주석은 소스에 남기되 비교에서는 무시한다. 줄 전체가 주석이면 줄 필터처럼 걸러지고,
|
||
/// 줄 끝에 붙은 주석은 <b>그 조각만 떼고</b> 견준다 - 코드가 같으면 같은 줄이다.
|
||
/// 보는 방식일 뿐 파일의 글은 건드리지 않는다.
|
||
/// </summary>
|
||
public bool IgnoreMergeNotes { get; set; }
|
||
|
||
/// <summary>머지 주석 기호를 고를 파일 경로. 확장자만 본다.</summary>
|
||
public string? NotePath { get; set; }
|
||
|
||
/// <summary>
|
||
/// 주석만 있는 줄의 비교용 키. 줄마다 이름·날짜가 달라도 서로 같은 줄로 짝지어진다.
|
||
/// 빈 줄과 짝지어지지 않도록 소스에 나올 수 없는 글자로 만든다.
|
||
/// </summary>
|
||
public const string MergeNoteKey = "\u0000merge-note";
|
||
|
||
private Regex[]? _compiled;
|
||
private string? _compiledFrom;
|
||
|
||
/// <summary>어떤 규칙도 켜져 있지 않으면 비교용 변환을 건너뛸 수 있다.</summary>
|
||
public bool IsPlain =>
|
||
Whitespace == WhitespaceMode.Exact && !IgnoreCase && !IgnoreMergeNotes;
|
||
|
||
/// <summary>
|
||
/// 정렬에서 "같은 줄인가"를 따질 때 쓰는 문자열.
|
||
/// 원문은 그대로 두고 이 값만 비교하므로 화면 표시는 달라지지 않는다.
|
||
/// </summary>
|
||
public string KeyOf(string line)
|
||
{
|
||
if (line.Length == 0) return line;
|
||
|
||
if (IgnoreMergeNotes)
|
||
{
|
||
if (MergeNote.LooksLikeNote(line, NotePath)) return MergeNoteKey;
|
||
|
||
var cut = MergeNote.TailStart(line, NotePath);
|
||
if (cut > 0) line = line.Substring(0, cut);
|
||
}
|
||
|
||
var text = Whitespace switch
|
||
{
|
||
WhitespaceMode.IgnoreAll => StripSpaces(line),
|
||
WhitespaceMode.IgnoreAmount => CollapseSpaces(line),
|
||
_ => line
|
||
};
|
||
|
||
return IgnoreCase ? text.ToUpperInvariant() : text;
|
||
}
|
||
|
||
/// <summary>줄 필터에 걸리는 줄인지. 걸리면 차이로 세지 않는다.</summary>
|
||
public bool IsFiltered(string? line)
|
||
{
|
||
if (line is null) return false;
|
||
|
||
// 머지 주석만 있는 줄은 줄 필터 켜짐과 따로 본다 - 설정이 둘이다(6차 B-5).
|
||
if (IgnoreMergeNotes && MergeNote.LooksLikeNote(line, NotePath)) return true;
|
||
|
||
if (!LineFilterEnabled) return false;
|
||
|
||
foreach (var rule in Compiled())
|
||
{
|
||
if (rule.IsMatch(line)) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>상태바에 한 줄로 요약한다. 켜진 게 없으면 null.</summary>
|
||
public string? Describe()
|
||
{
|
||
var parts = new List<string>(3);
|
||
|
||
if (Whitespace == WhitespaceMode.IgnoreAmount) parts.Add("공백 개수");
|
||
else if (Whitespace == WhitespaceMode.IgnoreAll) parts.Add("공백 전부");
|
||
|
||
if (IgnoreCase) parts.Add("대소문자");
|
||
if (IgnoreMergeNotes) parts.Add("머지 주석");
|
||
|
||
var rules = Compiled().Length;
|
||
if (rules > 0) parts.Add($"줄필터 {rules}");
|
||
|
||
return parts.Count == 0 ? null : "무시: " + string.Join(" · ", parts);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 정규식은 쓸 때마다 다시 만들면 느리므로 캐시한다.
|
||
/// 사용자가 식을 고치면 목록 문자열이 달라지므로 그때만 다시 만든다.
|
||
/// </summary>
|
||
private Regex[] Compiled()
|
||
{
|
||
if (!LineFilterEnabled) return Array.Empty<Regex>();
|
||
|
||
var signature = string.Join("", LinePatterns);
|
||
if (_compiled is not null && _compiledFrom == signature) return _compiled;
|
||
|
||
var list = new List<Regex>(LinePatterns.Count);
|
||
|
||
foreach (var pattern in LinePatterns)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(pattern)) continue;
|
||
|
||
try
|
||
{
|
||
list.Add(new Regex(pattern, RegexOptions.CultureInvariant));
|
||
}
|
||
catch (ArgumentException)
|
||
{
|
||
// 사용자가 식을 고치는 중일 수 있다. 잘못된 식 하나 때문에 비교가 멈추면 안 된다.
|
||
}
|
||
}
|
||
|
||
_compiledFrom = signature;
|
||
_compiled = list.ToArray();
|
||
return _compiled;
|
||
}
|
||
|
||
/// <summary>연속된 공백을 하나로 줄이고 앞뒤를 다듬는다.</summary>
|
||
private static string CollapseSpaces(string line)
|
||
{
|
||
var sb = new StringBuilder(line.Length);
|
||
var pendingSpace = false;
|
||
|
||
foreach (var c in line)
|
||
{
|
||
if (char.IsWhiteSpace(c))
|
||
{
|
||
if (sb.Length > 0) pendingSpace = true;
|
||
continue;
|
||
}
|
||
|
||
if (pendingSpace)
|
||
{
|
||
sb.Append(' ');
|
||
pendingSpace = false;
|
||
}
|
||
|
||
sb.Append(c);
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static string StripSpaces(string line)
|
||
{
|
||
var sb = new StringBuilder(line.Length);
|
||
|
||
foreach (var c in line)
|
||
if (!char.IsWhiteSpace(c)) sb.Append(c);
|
||
|
||
return sb.ToString();
|
||
}
|
||
}
|
||
}
|