실제 소스에 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>
211 lines
8.6 KiB
C#
211 lines
8.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using MergeHub.Views.Controls;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>
|
|
/// 언어 표(<see cref="LanguageProfile"/>)를 보고 한 줄을 토큰으로 쪼갠다.
|
|
///
|
|
/// C# 은 전용 토크나이저로 넘긴다 - 축자·보간 문자열과 접근제한자 구분이 표로 담기지 않고,
|
|
/// 이미 테스트로 굳어 있어 굳이 다시 만들 이유가 없다.
|
|
/// 마크업(XML · XAML · HTML)도 따로 보낸다 - <b>꺾쇠 안이냐 밖이냐</b>로 갈리는 규칙이라
|
|
/// 여기의 단어 단위 훑기에 얹을 수가 없다. <see cref="MarkupTokenizer"/> 를 보라.
|
|
/// 평문·Markdown 처럼 코드가 아닌 형식은 토큰을 만들지 않는다(그냥 검은 글씨로 나온다).
|
|
/// </summary>
|
|
public static class CodeTokenizer
|
|
{
|
|
private const string OperatorChars = "=+-*/%!<>&|^~?:";
|
|
private const string PunctuationChars = "(){}[];,.";
|
|
|
|
private static readonly IReadOnlyList<CodeToken> Empty = Array.Empty<CodeToken>();
|
|
|
|
public static IReadOnlyList<CodeToken> Tokenize(string line, LanguageProfile profile,
|
|
CarryOver carry, out CarryOver next)
|
|
{
|
|
if (profile.UseCSharpTokenizer)
|
|
{
|
|
var wasInComment = (carry & CarryOver.BlockComment) != 0;
|
|
var result = CSharpTokenizer.Tokenize(line, wasInComment, out var stillInComment);
|
|
next = stillInComment ? CarryOver.BlockComment : CarryOver.None;
|
|
return result;
|
|
}
|
|
|
|
if (profile.UseMarkupTokenizer)
|
|
return MarkupTokenizer.Tokenize(line, profile, carry, out next);
|
|
|
|
// 아래로는 꺾쇠 상태를 쓰지 않는다. 블록 주석만 넘긴다.
|
|
var inBlockComment = (carry & CarryOver.BlockComment) != 0;
|
|
next = carry & CarryOver.BlockComment;
|
|
|
|
if (!profile.IsCode || string.IsNullOrEmpty(line)) return Empty;
|
|
|
|
var tokens = new List<CodeToken>();
|
|
var i = 0;
|
|
|
|
// 앞 줄에서 넘어온 블록 주석을 먼저 닫는다.
|
|
if (inBlockComment && profile.HasBlockComment)
|
|
{
|
|
var close = line.IndexOf(profile.BlockEnd!, StringComparison.Ordinal);
|
|
if (close < 0)
|
|
{
|
|
tokens.Add(new CodeToken(0, line.Length, CodeTokenKind.Comment));
|
|
return tokens;
|
|
}
|
|
|
|
i = close + profile.BlockEnd!.Length;
|
|
tokens.Add(new CodeToken(0, i, CodeTokenKind.Comment));
|
|
next = CarryOver.None;
|
|
}
|
|
|
|
// ---- 전처리 지시문 ----
|
|
//
|
|
// 줄 첫머리가 <c>#</c> 이면 그 줄 전체가 지시문이다(#include · #define · #ifdef).
|
|
// C# 쪽과 같은 규칙으로 칠한다 - 같은 모양인데 언어마다 다르게 보이면 더 헷갈린다.
|
|
// 블록 주석이 이미 열려 있던 줄은 해당 없다(i 가 0 이 아니다).
|
|
if (profile.HasPreprocessor && i == 0)
|
|
{
|
|
var firstVisible = 0;
|
|
while (firstVisible < line.Length && char.IsWhiteSpace(line[firstVisible])) firstVisible++;
|
|
|
|
if (firstVisible < line.Length && line[firstVisible] == '#')
|
|
{
|
|
tokens.Add(new CodeToken(firstVisible, line.Length - firstVisible, CodeTokenKind.Preprocessor));
|
|
return tokens;
|
|
}
|
|
}
|
|
|
|
while (i < line.Length)
|
|
{
|
|
var start = i;
|
|
var c = line[i];
|
|
|
|
// ---- 한 줄 주석 ----
|
|
if (TryMatchLineComment(line, i, profile))
|
|
{
|
|
tokens.Add(new CodeToken(start, line.Length - start, CodeTokenKind.Comment));
|
|
break;
|
|
}
|
|
|
|
// ---- 블록 주석 ----
|
|
if (profile.HasBlockComment && Matches(line, i, profile.BlockStart!))
|
|
{
|
|
var end = line.IndexOf(profile.BlockEnd!, i + profile.BlockStart!.Length, StringComparison.Ordinal);
|
|
|
|
if (end < 0)
|
|
{
|
|
tokens.Add(new CodeToken(start, line.Length - start, CodeTokenKind.Comment));
|
|
next = CarryOver.BlockComment;
|
|
break;
|
|
}
|
|
|
|
i = end + profile.BlockEnd!.Length;
|
|
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Comment));
|
|
continue;
|
|
}
|
|
|
|
// ---- 문자열 ----
|
|
if (Array.IndexOf(profile.Quotes, c) >= 0)
|
|
{
|
|
i += ReadQuoted(line, i, c);
|
|
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Text));
|
|
continue;
|
|
}
|
|
|
|
// ---- 숫자 ----
|
|
if (char.IsDigit(c))
|
|
{
|
|
i++;
|
|
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '.' || line[i] == '_'))
|
|
{
|
|
if (line[i] == '.' && i + 1 < line.Length && line[i + 1] == '.') break;
|
|
i++;
|
|
}
|
|
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Number));
|
|
continue;
|
|
}
|
|
|
|
// ---- 식별자 / 예약어 ----
|
|
if (char.IsLetter(c) || c == '_')
|
|
{
|
|
i++;
|
|
while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_')) i++;
|
|
|
|
var word = line.Substring(start, i - start);
|
|
tokens.Add(new CodeToken(start, i - start, Classify(word, line, i, profile)));
|
|
continue;
|
|
}
|
|
|
|
if (OperatorChars.IndexOf(c) >= 0)
|
|
{
|
|
i++;
|
|
while (i < line.Length && OperatorChars.IndexOf(line[i]) >= 0) i++;
|
|
tokens.Add(new CodeToken(start, i - start, CodeTokenKind.Operator));
|
|
continue;
|
|
}
|
|
|
|
if (PunctuationChars.IndexOf(c) >= 0)
|
|
{
|
|
tokens.Add(new CodeToken(start, 1, CodeTokenKind.Punctuation));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
i++; // 공백 등 나머지
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 한 줄 주석 표시가 여기서 시작하는지.
|
|
/// 배치 파일의 <c>rem </c> 처럼 글자로 된 표시가 있어 대소문자 규칙을 따른다.
|
|
/// </summary>
|
|
private static bool TryMatchLineComment(string line, int index, LanguageProfile profile)
|
|
{
|
|
foreach (var marker in profile.LineComments)
|
|
{
|
|
if (marker.Length == 0) continue;
|
|
if (Matches(line, index, marker, profile.CaseSensitive)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool Matches(string line, int index, string token, bool caseSensitive = true)
|
|
{
|
|
if (index + token.Length > line.Length) return false;
|
|
|
|
return string.Compare(line, index, token, 0, token.Length,
|
|
caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase) == 0;
|
|
}
|
|
|
|
private static CodeTokenKind Classify(string word, string line, int afterIndex, LanguageProfile profile)
|
|
{
|
|
if (profile.ControlKeywords.Contains(word)) return CodeTokenKind.ControlKeyword;
|
|
if (profile.TypeKeywords.Contains(word)) return CodeTokenKind.TypeKeyword;
|
|
if (profile.Keywords.Contains(word)) return CodeTokenKind.Keyword;
|
|
|
|
var j = afterIndex;
|
|
while (j < line.Length && char.IsWhiteSpace(line[j])) j++;
|
|
if (j < line.Length && line[j] == '(') return CodeTokenKind.Method;
|
|
|
|
return char.IsUpper(word[0]) ? CodeTokenKind.Type : CodeTokenKind.Default;
|
|
}
|
|
|
|
/// <summary>여는 따옴표부터 닫는 따옴표까지의 길이. 닫히지 않으면 줄 끝까지.</summary>
|
|
private static int ReadQuoted(string line, int index, char quote)
|
|
{
|
|
var i = index + 1;
|
|
|
|
while (i < line.Length)
|
|
{
|
|
if (line[i] == '\\') { i += 2; continue; }
|
|
if (line[i] == quote) return i + 1 - index;
|
|
i++;
|
|
}
|
|
|
|
return line.Length - index;
|
|
}
|
|
}
|
|
}
|