UbisamBase 플랫폼 위에서 도는 플러그인. 지금까지 만든 것을 그대로 담는다. - 폴더 비교 / 파일 비교(부분 수용·워드랩·찾기) / 3-Way 병합 / Git 조작 - 비교 엔진: histogram 정렬 · 이동 블록 감지 · 무시 규칙 · 13개 언어 토크나이저 - doc/ 에 작업 지시서와 진행 기록 빌드 산출물(bin/obj)은 추적하지 않는다 - 플랫폼 런타임이 빌드 때마다 D:\UbisamPlatform\bin 에서 복사되어 들어오므로 저장소에 담을 이유가 없다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
9.2 KiB
C#
231 lines
9.2 KiB
C#
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));
|
||
}
|
||
}
|
||
}
|