Ubisam-MergeTool/src/MergeHub/Services/CodeSanityChecker.cs
ksj09406 7719094edf 최초 커밋: MergeHub (사내 Git Merge/Diff 도구)
UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다.
- 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작
- 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저
- doc/ 에 작업 지시서와 진행 기록

빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다
D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:13:27 +09:00

231 lines
9.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Text;
using MergeHub.Views.Controls;
namespace MergeHub.Services
{
/// <summary>줄 하나에서 발견한 문제.</summary>
public sealed class CodeProblem
{
public CodeProblem(int lineIndex, string message)
{
LineIndex = lineIndex;
Message = message;
}
/// <summary>Lines 컬렉션에서의 위치(0부터).</summary>
public int LineIndex { get; }
public string Message { get; }
}
/// <summary>
/// 편집한 내용이 "일단 컴파일은 될 모양인지"를 훑어보는 가벼운 검사기.
///
/// 컴파일러가 아니다. Roslyn 없이 토크나이저만으로 잡을 수 있는,
/// 그러나 실제로 자주 저지르는 실수만 본다:
/// 1. 주석·문자열 밖에 한글 등 식별자로 쓸 수 없는 글자 (주석 기호를 빼먹은 경우)
/// 2. 닫히지 않은 따옴표
/// 3. 파일 전체의 괄호 짝 ( ) { } [ ]
///
/// 놓치는 것이 많으므로 "빌드가 된다"는 보증이 아니라 "눈에 띄는 실수 알림"으로 쓴다.
/// </summary>
public static class CodeSanityChecker
{
/// <summary>이 종류의 토큰 안에서는 어떤 글자가 나와도 문제가 아니다.</summary>
private static bool IsFreeText(CodeTokenKind kind) =>
kind == CodeTokenKind.Text ||
kind == CodeTokenKind.Comment ||
kind == CodeTokenKind.XmlDoc ||
kind == CodeTokenKind.Preprocessor;
/// <summary>
/// 코드로 다룰 파일인지. .txt·.md 같은 문서나 YAML·INI 처럼 괄호로 블록을 여닫지 않는
/// 형식에는 이 검사(괄호 짝·식별자 글자)가 뜻이 없다. 판단은 언어 표 한 곳에서만 한다.
/// </summary>
public static bool AppliesTo(string? path) => LanguageCatalog.ForPath(path).HasBraces;
/// <summary>언어를 따로 주지 않으면 C# 으로 본다(이 도구가 처음부터 다루던 언어).</summary>
public static IReadOnlyList<CodeProblem> Check(IReadOnlyList<string> lines) =>
Check(lines, LanguageCatalog.CSharp);
public static IReadOnlyList<CodeProblem> Check(IReadOnlyList<string> lines, LanguageProfile profile)
{
var problems = new List<CodeProblem>();
var inBlockComment = false;
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, inBlockComment, out inBlockComment);
// 각 글자가 어떤 종류 안에 있는지 표시해 둔다(토큰이 안 잡힌 자리는 코드로 본다).
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 (inBlockComment)
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;
}
/// <summary>
/// 코드 자리에 있으면 무조건 컴파일이 깨지는 문자만 모은다.
/// 전각 기호(? : ,), 전각 공백 같은 것들이다.
/// 한글·한자 "문자"는 C# 식별자로 쓸 수 있으므로 여기서 걸지 않는다.
/// </summary>
private static string CollectIllegalSymbols(string line, bool[] free)
{
var found = new StringBuilder();
var seen = new HashSet<char>();
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();
}
/// <summary>
/// "주석 기호를 빼먹고 문장을 그대로 쓴" 줄인지 본다.
///
/// 한글 낱말 하나는 변수명일 수 있으므로(= var 메시지) 걸지 않는다.
/// 대신 한글 낱말이 연산자·구두점 없이 공백만 두고 둘 이상 이어지면
/// (= 여기 한글을 그냥 씀) 문장으로 본다. 코드에서는 거의 나오지 않는 배치다.
/// </summary>
private static bool LooksLikeProse(string line, IReadOnlyList<CodeToken> 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;
}
/// <summary>따옴표가 줄 끝까지 안 닫혔는지. 토크나이저가 만든 Text 토큰의 끝으로 판단한다.</summary>
private static bool HasUnclosedQuote(string line, IReadOnlyList<CodeToken> 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<CodeToken> 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<CodeProblem> 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));
}
}
}