Ubisam-MergeTool/src/MergeHub/Services/CodeSanityChecker.cs
ksj09406 26446d5e6b 5차 B-1: XAML 태그·속성·값을 갈라 칠한다
실제 소스에 XAML 이 400개 42,477줄인데 여태 주석과 따옴표만 알아봐서
사실상 평문이었다. 마크업 전용 토크나이저를 따로 뒀다.

기존 토크나이저에 못 얹은 까닭은 규칙이 다르기 때문이다. 그쪽은 낱말을
읽어 예약어 표와 맞춰 보는데, 마크업은 같은 Content 가 꺾쇠 안에서는
속성 이름이고 밖에서는 그냥 글자다. 낱말이 무엇이냐가 아니라 어디에
있느냐로 갈린다.

줄 넘김 상태를 bool 하나에서 깃발 묶음(CarryOver)으로 넓혔다. XAML 은
여는 태그가 여러 줄에 걸치는 것이 보통이라 - 시험에 쓴 실제 파일은 첫
태그 하나가 14줄이다 - "지금 꺾쇠 안이다"를 안 넘기면 이어지는 줄의
속성이 그냥 글자로 보인다. 부르는 자리 다섯을 모두 바꿨다.

색은 새로 만들지 않고 이미 있는 갈래에 얹었다. 태그는 예약어 색,
속성 이름은 메서드 색, 값은 문자열 색, 앞가지는 타입 색이다. 태그
밖의 본문은 일부러 안 칠한다.

붙이는 중에 파일 비교 화면이 통째로 비어서 뜨는 일이 있었다. 의존
속성 타입을 bool 에서 CarryOver 로 바꾸면서 기본값 false 를 같이 안
고쳤다. 빌드도 통과하고 로그에도 안 남는다. 같은 주의를 코드 옆에
적어 뒀다.

실제 저장소 XAML 사본(638줄)으로 화면 확인. C# 색칠은 그대로.
MarkupTest 65건. 전체 688건 통과(하네스 20벌). 경고 0 · 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:01:58 +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 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;
}
/// <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));
}
}
}