using System; using System.Collections.Generic; using System.Text; using MergeHub.Views.Controls; namespace MergeHub.Services { /// 줄 하나에서 발견한 문제. public sealed class CodeProblem { public CodeProblem(int lineIndex, string message) { LineIndex = lineIndex; Message = message; } /// Lines 컬렉션에서의 위치(0부터). public int LineIndex { get; } public string Message { get; } } /// /// 편집한 내용이 "일단 컴파일은 될 모양인지"를 훑어보는 가벼운 검사기. /// /// 컴파일러가 아니다. Roslyn 없이 토크나이저만으로 잡을 수 있는, /// 그러나 실제로 자주 저지르는 실수만 본다: /// 1. 주석·문자열 밖에 한글 등 식별자로 쓸 수 없는 글자 (주석 기호를 빼먹은 경우) /// 2. 닫히지 않은 따옴표 /// 3. 파일 전체의 괄호 짝 ( ) { } [ ] /// /// 놓치는 것이 많으므로 "빌드가 된다"는 보증이 아니라 "눈에 띄는 실수 알림"으로 쓴다. /// public static class CodeSanityChecker { /// 이 종류의 토큰 안에서는 어떤 글자가 나와도 문제가 아니다. private static bool IsFreeText(CodeTokenKind kind) => kind == CodeTokenKind.Text || kind == CodeTokenKind.Comment || kind == CodeTokenKind.XmlDoc || kind == CodeTokenKind.Preprocessor; /// /// 코드로 다룰 파일인지. .txt·.md 같은 문서나 YAML·INI 처럼 괄호로 블록을 여닫지 않는 /// 형식에는 이 검사(괄호 짝·식별자 글자)가 뜻이 없다. 판단은 언어 표 한 곳에서만 한다. /// public static bool AppliesTo(string? path) => LanguageCatalog.ForPath(path).HasBraces; /// 언어를 따로 주지 않으면 C# 으로 본다(이 도구가 처음부터 다루던 언어). public static IReadOnlyList Check(IReadOnlyList lines) => Check(lines, LanguageCatalog.CSharp); public static IReadOnlyList Check(IReadOnlyList lines, LanguageProfile profile) { var problems = new List(); var carry = CarryOver.None; int round = 0, curly = 0, square = 0; for (var i = 0; i < lines.Count; i++) { var line = lines[i]; var tokens = CodeTokenizer.Tokenize(line, profile, carry, out carry); // 각 글자가 어떤 종류 안에 있는지 표시해 둔다(토큰이 안 잡힌 자리는 코드로 본다). var free = new bool[line.Length]; foreach (var token in tokens) { if (!IsFreeText(token.Kind)) continue; var end = Math.Min(line.Length, token.Start + token.Length); for (var k = token.Start; k < end; k++) free[k] = true; } var illegal = CollectIllegalSymbols(line, free); if (illegal.Length > 0) { problems.Add(new CodeProblem(i, $"코드 자리에 쓸 수 없는 문자가 있습니다: {illegal}")); } else if (LooksLikeProse(line, tokens)) { problems.Add(new CodeProblem(i, "주석 기호 없이 문장을 쓴 것 같습니다. // 를 앞에 붙이거나 문자열로 감싸세요.")); } if (HasUnclosedQuote(line, tokens)) problems.Add(new CodeProblem(i, "따옴표가 닫히지 않았습니다.")); CountBrackets(line, free, tokens, ref round, ref curly, ref square); } if ((carry & CarryOver.BlockComment) != 0) problems.Add(new CodeProblem(Math.Max(0, lines.Count - 1), "블록 주석 /* 가 닫히지 않았습니다.")); AddBalanceProblem(problems, lines.Count, round, '(', ')'); AddBalanceProblem(problems, lines.Count, curly, '{', '}'); AddBalanceProblem(problems, lines.Count, square, '[', ']'); return problems; } /// /// 코드 자리에 있으면 무조건 컴파일이 깨지는 문자만 모은다. /// 전각 기호(? : ,), 전각 공백 같은 것들이다. /// 한글·한자 "문자"는 C# 식별자로 쓸 수 있으므로 여기서 걸지 않는다. /// private static string CollectIllegalSymbols(string line, bool[] free) { var found = new StringBuilder(); var seen = new HashSet(); for (var i = 0; i < line.Length; i++) { if (free[i]) continue; var c = line[i]; if (c < 128) continue; // ASCII 는 문제없다 if (char.IsLetter(c)) continue; // 한글 변수명 등 - 합법 if (c == '_') continue; if (seen.Add(c)) found.Append(c); if (found.Length >= 12) break; } return found.ToString(); } /// /// "주석 기호를 빼먹고 문장을 그대로 쓴" 줄인지 본다. /// /// 한글 낱말 하나는 변수명일 수 있으므로(= var 메시지) 걸지 않는다. /// 대신 한글 낱말이 연산자·구두점 없이 공백만 두고 둘 이상 이어지면 /// (= 여기 한글을 그냥 씀) 문장으로 본다. 코드에서는 거의 나오지 않는 배치다. /// private static bool LooksLikeProse(string line, IReadOnlyList tokens) { var run = 0; var previousEnd = -1; foreach (var token in tokens) { if (IsFreeText(token.Kind)) { run = 0; previousEnd = -1; continue; } var text = line.Substring(token.Start, token.Length); var isWord = token.Kind is CodeTokenKind.Default or CodeTokenKind.Type or CodeTokenKind.Method; if (!isWord || !ContainsNonAsciiLetter(text)) { run = 0; previousEnd = -1; continue; } // 앞 낱말과 사이에 공백만 있었는지 확인한다. var onlySpaceBetween = previousEnd >= 0 && IsAllWhitespace(line, previousEnd, token.Start); run = onlySpaceBetween ? run + 1 : 1; previousEnd = token.Start + token.Length; if (run >= 2) return true; } return false; } private static bool ContainsNonAsciiLetter(string text) { foreach (var c in text) if (c >= 128 && char.IsLetter(c)) return true; return false; } private static bool IsAllWhitespace(string line, int start, int end) { for (var i = start; i < end; i++) if (!char.IsWhiteSpace(line[i])) return false; return true; } /// 따옴표가 줄 끝까지 안 닫혔는지. 토크나이저가 만든 Text 토큰의 끝으로 판단한다. private static bool HasUnclosedQuote(string line, IReadOnlyList tokens) { foreach (var token in tokens) { if (token.Kind != CodeTokenKind.Text) continue; if (token.Start + token.Length < line.Length) continue; // 줄 끝까지 이어진 문자열 - 마지막 글자가 여는 따옴표와 같아야 닫힌 것이다. var text = line.Substring(token.Start, token.Length); var quote = text.IndexOf('"') >= 0 ? '"' : '\''; if (text.Length < 2 || text[text.Length - 1] != quote) return true; } return false; } private static void CountBrackets(string line, bool[] free, IReadOnlyList tokens, ref int round, ref int curly, ref int square) { for (var i = 0; i < line.Length; i++) { if (free[i]) continue; switch (line[i]) { case '(': round++; break; case ')': round--; break; case '{': curly++; break; case '}': curly--; break; case '[': square++; break; case ']': square--; break; } } } private static void AddBalanceProblem(List problems, int lineCount, int balance, char open, char close) { if (balance == 0) return; var message = balance > 0 ? $"{open} 가 {balance}개 더 많습니다 ({close} 를 빠뜨렸나요?)" : $"{close} 가 {-balance}개 더 많습니다."; problems.Add(new CodeProblem(Math.Max(0, lineCount - 1), message)); } } }