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 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;
}
///
/// 코드 자리에 있으면 무조건 컴파일이 깨지는 문자만 모은다.
/// 전각 기호(? : ,), 전각 공백 같은 것들이다.
/// 한글·한자 "문자"는 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));
}
}
}