using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.RegularExpressions; namespace MergeHub.Services { /// C# 여는 중괄호 · else/catch/finally 를 어디에 둘지. public enum CSharpBraceStyle { /// 맞추지 않는다. 파일 그대로 견준다. None = 0, /// /// AURORA. Visual Studio [텍스트 편집기 > C# > 코드 스타일 > 서식 > 줄 바꿈] 에서 /// 형식 · 메서드와 로컬 함수의 여는 중괄호만 새 줄, 나머지(속성 · 접근자 · 익명 메서드 · 제어 블록 · /// 익명 형식 · 초기화 · 람다)는 같은 줄. else · catch · finally 는 같은 줄(} else {). /// Aurora = 1, /// Allman. 여는 중괄호는 모두 새 줄, else · catch · finally 도 새 줄(Visual Studio 기본값과 같다). Allman = 2, } /// /// C# 코드의 중괄호 줄바꿈을 한 가지 스타일로 맞춘다(6차 추가 - C# 코드 스타일 맞춤). /// /// 같은 코드인데 한쪽은 if (x) {, 다른 쪽은 if (x) + 다음 줄 { 로 쓰면 /// 줄 단위 비교에서 그 자리마다 차이가 둘씩 뜬다. 스타일이 다른 사람 코드를 병합할 때 진짜 변경이 묻힌다. /// 그래서 비교하기 전에 양쪽을 같은 스타일로 맞춘다. /// /// 줄의 위치만 옮긴다 - 여는 중괄호를 앞 줄 끝으로 올리거나 다음 줄로 내리고, /// } 와 else/catch/finally 를 붙이거나 뗀다. 들여쓰기 · 공백 · 그 밖의 서식은 건드리지 않는다. /// 주석 · 문자열 · 보간 문자열 · 전처리 줄 안의 중괄호는 코드로 보지 않는다. /// 앞 줄 끝에 주석이 있으면 올리지 않는다(중괄호가 주석 안으로 들어가 버린다). /// 스타일을 판단할 수 없는 중괄호(그냥 블록 · 중첩 배열 초기화)는 그대로 둔다. /// public static class CSharpStyle { /// 중괄호가 여는 것의 갈래. Visual Studio 옵션의 항목과 같게 나눈다. public enum BraceKind { /// 판단하지 않는다(그냥 블록 · 알 수 없음). 옮기지 않는다. Unknown, Type, Method, Property, Accessor, AnonymousMethod, Control, AnonymousType, Initializer, Lambda, } /// 이 파일에 스타일 맞춤을 걸지. C# 파일(.cs)만. public static bool Applies(string? path, CSharpBraceStyle style) => style != CSharpBraceStyle.None && !string.IsNullOrEmpty(path) && string.Equals(Path.GetExtension(path), ".cs", StringComparison.OrdinalIgnoreCase); public static string Label(CSharpBraceStyle style) => style switch { CSharpBraceStyle.Aurora => "AURORA", CSharpBraceStyle.Allman => "Allman", _ => "끔", }; /// /// 글 전체를 스타일에 맞춘다. 바꿀 것이 없으면 받은 글을 그대로 돌려준다(줄끝도 그대로). /// 바꾸면 줄끝은 글에 CRLF 가 하나라도 있으면 CRLF, 아니면 LF 로 잇는다. /// public static string Format(string text, CSharpBraceStyle style) { if (style == CSharpBraceStyle.None || string.IsNullOrEmpty(text)) return text; var newLine = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; var endsWithNewLine = text.EndsWith("\n", StringComparison.Ordinal); var lines = new List(text.Replace("\r\n", "\n").Split('\n')); if (endsWithNewLine) lines.RemoveAt(lines.Count - 1); var any = false; for (var pass = 0; pass < 8; pass++) { var changed = PlaceKeywords(lines, style); changed |= PlaceBraces(lines, style); if (!changed) break; any = true; } if (!any) return text; var result = string.Join(newLine, lines); return endsWithNewLine ? result + newLine : result; } /// 그 갈래의 여는 중괄호를 새 줄에 둘지. public static bool OpenBraceOnNewLine(BraceKind kind, CSharpBraceStyle style) => style == CSharpBraceStyle.Allman || kind is BraceKind.Type or BraceKind.Method; // ================= else · catch · finally ================= private static readonly Regex KeywordLine = new Regex(@"^(else|catch|finally)\b", RegexOptions.Compiled); private static readonly Regex CloseThenKeyword = new Regex(@"^\}\s*(else|catch|finally)\b", RegexOptions.Compiled); private static bool PlaceKeywords(List lines, CSharpBraceStyle style) { var mask = Mask(lines); var changed = false; for (var i = lines.Count - 1; i >= 1; i--) { if (mask.Inside[i] || mask.Preprocessor[i]) continue; var code = mask.Code[i].Trim(); if (style == CSharpBraceStyle.Aurora) { // } → } else { // else { if (!KeywordLine.IsMatch(code)) continue; var p = i - 1; if (mask.Inside[p] || mask.Preprocessor[p]) continue; if (lines[p].Trim() != "}" || mask.Code[p].Trim() != "}") continue; lines[p] = lines[p].TrimEnd() + " " + lines[i].TrimStart(); lines.RemoveAt(i); changed = true; } else { // } else { → } // else { if (!CloseThenKeyword.IsMatch(code) || !lines[i].TrimStart().StartsWith("}", StringComparison.Ordinal)) continue; var indent = Indent(lines[i]); var rest = lines[i].TrimStart().Substring(1).TrimStart(); lines[i] = indent + "}"; lines.Insert(i + 1, indent + rest); changed = true; } } return changed; } // ================= 여는 중괄호 ================= private static bool PlaceBraces(List lines, CSharpBraceStyle style) { var mask = Mask(lines); var changed = false; for (var i = lines.Count - 1; i >= 0; i--) { if (mask.Inside[i] || mask.Preprocessor[i]) continue; var code = mask.Code[i]; var first = FirstNonSpace(code); if (first < 0) continue; // (가) 줄이 '{' 로 시작한다 → 같은 줄 스타일이면 앞 줄 끝으로 올린다. if (code[first] == '{') { if (i == 0) continue; var p = i - 1; if (mask.Inside[p] || mask.Preprocessor[p]) continue; var prevCode = mask.Code[p].TrimEnd(); if (prevCode.Trim().Length == 0) continue; // 앞 줄 끝에 주석이 있으면 올리지 않는다 - 원문 끝과 코드 끝이 같아야 한다. if (lines[p].TrimEnd().Length != prevCode.Length) continue; var (kind, _) = Classify(mask, i, first); if (kind == BraceKind.Unknown || OpenBraceOnNewLine(kind, style)) continue; lines[p] = lines[p].TrimEnd() + " " + lines[i].TrimStart(); lines.RemoveAt(i); changed = true; continue; } // (나) 코드 뒤 줄 끝에 '{' 가 있다 → 새 줄 스타일이면 다음 줄로 내린다. var trimmed = code.TrimEnd(); if (trimmed.Length == 0 || trimmed[trimmed.Length - 1] != '{') continue; var brace = trimmed.Length - 1; if (code.Substring(0, brace).Trim().Length == 0) continue; if (code.Substring(0, brace).TrimStart().StartsWith("}", StringComparison.Ordinal)) continue; var (k, headerLine) = Classify(mask, i, brace); if (k == BraceKind.Unknown || !OpenBraceOnNewLine(k, style)) continue; var original = lines[i]; lines[i] = original.Substring(0, brace).TrimEnd(); lines.Insert(i + 1, Indent(lines[headerLine]) + original.Substring(brace)); changed = true; } return changed; } /// /// (line, col) 의 여는 중괄호가 무엇을 여는지. 앞쪽 코드를 거꾸로 읽어 머리(선언 · 제어문 등)를 모은다. /// 머리가 시작한 줄 번호도 준다 - 중괄호를 내릴 때 그 줄의 들여쓰기를 따른다. /// private static (BraceKind Kind, int HeaderLine) Classify(Masked mask, int line, int col) { var chars = new List(128); int paren = 0, bracket = 0, angle = 0; var start = line; var pos = col - 1; var l = line; while (chars.Count < 4000) { if (pos < 0) { l--; if (l < 0 || mask.Preprocessor[l]) break; pos = mask.Code[l].Length - 1; chars.Add(' '); continue; } var text = mask.Code[l]; var ch = text[pos]; if (ch == ')') paren++; else if (ch == '(') { if (paren == 0) break; paren--; } else if (ch == ']') bracket++; else if (ch == '[') { if (bracket == 0) break; bracket--; } else if (ch == '>' && !(pos > 0 && (text[pos - 1] == '=' || text[pos - 1] == '-')) && !(pos + 1 < text.Length && text[pos + 1] == '=')) angle++; else if (ch == '<') { if (angle > 0) angle--; } else if (paren == 0 && bracket == 0 && (ch == ';' || ch == '{' || ch == '}')) break; else if (paren == 0 && bracket == 0 && angle == 0 && ch == ',') break; if (!char.IsWhiteSpace(ch)) start = l; chars.Add(ch); pos--; } chars.Reverse(); return (KindOfHeader(new string(chars.ToArray())), start); } private static readonly HashSet ControlWords = new HashSet(StringComparer.Ordinal) { "if", "else", "for", "foreach", "while", "do", "switch", "try", "catch", "finally", "using", "lock", "fixed", "unsafe", "checked", "unchecked", }; private static readonly Regex Spaces = new Regex(@"\s+", RegexOptions.Compiled); private static readonly Regex CaseLabel = new Regex(@"^(case\b.*|default)\s*:$", RegexOptions.Compiled); private static readonly Regex AccessorHeader = new Regex(@"^((public|private|protected|internal)\s+)*(get|set|init|add|remove)$", RegexOptions.Compiled); private static readonly Regex TypeKeyword = new Regex(@"\b(class|struct|interface|enum|record|namespace)\b", RegexOptions.Compiled); private static readonly Regex WhereClause = new Regex(@"\bwhere\s+\w+\s*:", RegexOptions.Compiled); /// 중괄호 앞 머리 글로 갈래를 정한다. 검사에서 직접 부를 수 있게 열어 둔다. public static BraceKind KindOfHeader(string header) { var h = Spaces.Replace(header ?? string.Empty, " ").Trim(); // 앞에 붙은 특성([Test] 등)은 뗀다. while (h.StartsWith("[", StringComparison.Ordinal)) { var close = MatchingClose(h, 0, '[', ']'); if (close < 0) break; h = h.Substring(close + 1).TrimStart(); } if (h.Length == 0) return BraceKind.Unknown; if (h.EndsWith("=>", StringComparison.Ordinal)) return BraceKind.Lambda; if (CaseLabel.IsMatch(h)) return BraceKind.Control; var firstWord = Regex.Match(h, @"^[A-Za-z_]\w*").Value; if (ControlWords.Contains(firstWord)) return BraceKind.Control; if (AccessorHeader.IsMatch(h)) return BraceKind.Accessor; var paren = h.IndexOf('('); var type = TypeKeyword.Match(h); if (type.Success && (paren < 0 || type.Index < paren)) return BraceKind.Type; if (Regex.IsMatch(h, @"\bdelegate\b")) return BraceKind.AnonymousMethod; // 제약이 붙은 제네릭 메서드: void M() where T : new() if (WhereClause.IsMatch(h) && paren >= 0) return BraceKind.Method; if (h == "new") return BraceKind.AnonymousType; if (EndsWithCreation(h)) return BraceKind.Initializer; if (HasAssignment(h)) return BraceKind.Initializer; if (h.EndsWith("switch", StringComparison.Ordinal)) return BraceKind.Initializer; if (h.EndsWith(")", StringComparison.Ordinal)) return BraceKind.Method; var last = h[h.Length - 1]; if (char.IsLetterOrDigit(last) || last == '_' || last == '>' || last == ']' || last == '?') return BraceKind.Property; return BraceKind.Unknown; } /// /// 머리가 new 형식 · new 형식(인자) · new[] · new() 로 끝나는지 - 개체 · 컬렉션 · 배열 초기화. /// 멤버 숨기기 한정자(public new void M())는 new 뒤에 낱말이 둘 이상이라 아니다. /// private static bool EndsWithCreation(string h) { var at = LastWord(h, "new"); if (at < 0) return false; var rest = h.Substring(at + 3).Trim(); // 끝의 (인자) 한 덩어리를 뗀다. if (rest.EndsWith(")", StringComparison.Ordinal)) { var open = MatchingOpen(rest, rest.Length - 1, '(', ')'); if (open < 0) return false; rest = rest.Substring(0, open).Trim(); } if (rest.Length == 0) return true; // new() · new (...) // 형식 이름 하나(점 · 제네릭 · 배열 표시 포함), 공백으로 나뉜 낱말이 없어야 한다. var depth = 0; foreach (var c in rest) { if (c == '<' || c == '[') depth++; else if (c == '>' || c == ']') depth--; else if (c == ' ' && depth == 0) return false; else if (depth == 0 && !(char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '?')) return false; } return true; } /// 괄호 밖에 대입 '=' 가 있는지(==, !=, <=, >=, => 는 빼고). private static bool HasAssignment(string h) { var depth = 0; for (var i = 0; i < h.Length; i++) { var c = h[i]; if (c == '(' || c == '[') depth++; else if (c == ')' || c == ']') depth--; else if (c == '=' && depth == 0) { var prev = i > 0 ? h[i - 1] : ' '; var next = i + 1 < h.Length ? h[i + 1] : ' '; if (next == '=' || next == '>' || prev == '=' || prev == '!' || prev == '<' || prev == '>') continue; return true; } } return false; } private static int LastWord(string h, string word) { var at = h.Length; while (true) { at = h.LastIndexOf(word, at - 1, StringComparison.Ordinal); if (at < 0) return -1; var before = at == 0 || !(char.IsLetterOrDigit(h[at - 1]) || h[at - 1] == '_'); var after = at + word.Length >= h.Length || !(char.IsLetterOrDigit(h[at + word.Length]) || h[at + word.Length] == '_'); if (before && after) return at; if (at == 0) return -1; } } private static int MatchingClose(string s, int open, char o, char c) { var depth = 0; for (var i = open; i < s.Length; i++) { if (s[i] == o) depth++; else if (s[i] == c && --depth == 0) return i; } return -1; } private static int MatchingOpen(string s, int close, char o, char c) { var depth = 0; for (var i = close; i >= 0; i--) { if (s[i] == c) depth++; else if (s[i] == o && --depth == 0) return i; } return -1; } private static int FirstNonSpace(string s) { for (var i = 0; i < s.Length; i++) if (!char.IsWhiteSpace(s[i])) return i; return -1; } private static string Indent(string line) => line.Substring(0, line.Length - line.TrimStart().Length); // ================= 주석 · 문자열 가리기 ================= /// 줄마다 코드만 남긴 글(주석은 공백, 문자열 속은 '_'). 길이는 원문과 같다. private sealed class Masked { public Masked(string[] code, bool[] inside, bool[] preprocessor) { Code = code; Inside = inside; Preprocessor = preprocessor; } public string[] Code { get; } /// 줄이 블록 주석 · 여러 줄 문자열 안에서 시작하는지. public bool[] Inside { get; } public bool[] Preprocessor { get; } } private enum Mode { Code, LineComment, BlockComment, Str, VerbatimStr, InterpStr, InterpVerbatimStr, Char } private static Masked Mask(IReadOnlyList lines) { var src = string.Join("\n", lines); var mask = src.ToCharArray(); var modes = new Stack(); modes.Push(Mode.Code); var holes = new Stack(); // 보간 구멍마다 그 안의 중괄호 깊이 var inside = new bool[lines.Count]; var line = 0; var n = src.Length; var i = 0; while (i < n) { var ch = src[i]; var next = i + 1 < n ? src[i + 1] : '\0'; var next2 = i + 2 < n ? src[i + 2] : '\0'; if (ch == '\n') { var top = modes.Peek(); if (top is Mode.LineComment or Mode.Str or Mode.Char or Mode.InterpStr) { modes.Pop(); top = modes.Peek(); } line++; if (line < inside.Length) inside[line] = top != Mode.Code || holes.Count > 0; i++; continue; } switch (modes.Peek()) { case Mode.Code: if (ch == '/' && next == '/') { modes.Push(Mode.LineComment); mask[i] = ' '; mask[i + 1] = ' '; i += 2; continue; } if (ch == '/' && next == '*') { modes.Push(Mode.BlockComment); mask[i] = ' '; mask[i + 1] = ' '; i += 2; continue; } if ((ch == '$' && next == '@' && next2 == '"') || (ch == '@' && next == '$' && next2 == '"')) { modes.Push(Mode.InterpVerbatimStr); Hide(mask, i, 3, holes); i += 3; continue; } if (ch == '$' && next == '"') { modes.Push(Mode.InterpStr); Hide(mask, i, 2, holes); i += 2; continue; } if (ch == '@' && next == '"') { modes.Push(Mode.VerbatimStr); Hide(mask, i, 2, holes); i += 2; continue; } if (ch == '"') { modes.Push(Mode.Str); Hide(mask, i, 1, holes); i++; continue; } if (ch == '\'') { modes.Push(Mode.Char); Hide(mask, i, 1, holes); i++; continue; } if (holes.Count > 0) { if (ch == '{') holes.Push(holes.Pop() + 1); else if (ch == '}') { var depth = holes.Pop(); if (depth == 0) { modes.Pop(); mask[i] = '_'; i++; continue; } holes.Push(depth - 1); } mask[i] = '_'; } i++; continue; case Mode.LineComment: mask[i] = ' '; i++; continue; case Mode.BlockComment: if (ch == '*' && next == '/') { mask[i] = ' '; mask[i + 1] = ' '; modes.Pop(); i += 2; continue; } mask[i] = ' '; i++; continue; case Mode.Str: case Mode.Char: var quote = modes.Peek() == Mode.Str ? '"' : '\''; if (ch == '\\') { mask[i] = '_'; if (i + 1 < n && next != '\n') mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; } if (ch == quote) { modes.Pop(); mask[i] = '_'; i++; continue; } mask[i] = '_'; i++; continue; case Mode.VerbatimStr: if (ch == '"' && next == '"') { mask[i] = '_'; mask[i + 1] = '_'; i += 2; continue; } if (ch == '"') modes.Pop(); mask[i] = '_'; i++; continue; case Mode.InterpStr: case Mode.InterpVerbatimStr: var verbatim = modes.Peek() == Mode.InterpVerbatimStr; if ((ch == '{' && next == '{') || (ch == '}' && next == '}') || (verbatim && ch == '"' && next == '"')) { mask[i] = '_'; mask[i + 1] = '_'; i += 2; continue; } if (!verbatim && ch == '\\') { mask[i] = '_'; if (next != '\n' && i + 1 < n) mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; } if (ch == '"') { modes.Pop(); mask[i] = '_'; i++; continue; } if (ch == '{') { modes.Push(Mode.Code); holes.Push(0); mask[i] = '_'; i++; continue; } mask[i] = '_'; i++; continue; } } var code = new string(mask).Split('\n'); var preprocessor = new bool[code.Length]; for (var k = 0; k < code.Length; k++) preprocessor[k] = !inside[k] && code[k].TrimStart().StartsWith("#", StringComparison.Ordinal); return new Masked(code, inside, preprocessor); } /// 문자열 여는 기호를 가린다. 보간 구멍 안에서 연 문자열이면 원래도 가려져야 할 자리다. private static void Hide(char[] mask, int at, int count, Stack holes) { for (var k = 0; k < count && at + k < mask.Length; k++) mask[at + k] = '_'; } } }