실제 소스에 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>
255 lines
13 KiB
C#
255 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>
|
|
/// 한 가지 언어(또는 문서 형식)를 어떻게 읽을지 적어 둔 표.
|
|
///
|
|
/// 언어마다 토크나이저를 따로 만들지 않는 이유가 있다. 색칠에 필요한 것은
|
|
/// "주석은 어디서 시작하나 · 문자열은 무엇으로 묶나 · 예약어는 무엇인가" 이 셋뿐이고,
|
|
/// 나머지(숫자·식별자·연산자)는 어느 언어나 거의 같다. 그래서 <see cref="CodeTokenizer"/>
|
|
/// 하나가 이 표를 보고 동작한다.
|
|
///
|
|
/// C# 만 예외로 전용 토크나이저를 그대로 쓴다 - 축자/보간 문자열(@"" $"")과
|
|
/// 접근제한자 구분처럼 표로 적기 어려운 규칙이 있고, 이미 테스트로 굳어 있다.
|
|
/// </summary>
|
|
public sealed class LanguageProfile
|
|
{
|
|
public LanguageProfile(string name,
|
|
string[] extensions,
|
|
string[]? lineComments = null,
|
|
string? blockStart = null,
|
|
string? blockEnd = null,
|
|
char[]? quotes = null,
|
|
IEnumerable<string>? keywords = null,
|
|
IEnumerable<string>? controlKeywords = null,
|
|
IEnumerable<string>? typeKeywords = null,
|
|
bool caseSensitive = true,
|
|
bool isCode = true,
|
|
bool hasBraces = false,
|
|
bool useCSharpTokenizer = false,
|
|
bool useMarkupTokenizer = false,
|
|
bool hasPreprocessor = false)
|
|
{
|
|
Name = name;
|
|
Extensions = extensions;
|
|
LineComments = lineComments ?? Array.Empty<string>();
|
|
BlockStart = blockStart;
|
|
BlockEnd = blockEnd;
|
|
Quotes = quotes ?? Array.Empty<char>();
|
|
CaseSensitive = caseSensitive;
|
|
IsCode = isCode;
|
|
HasBraces = hasBraces;
|
|
UseCSharpTokenizer = useCSharpTokenizer;
|
|
UseMarkupTokenizer = useMarkupTokenizer;
|
|
HasPreprocessor = hasPreprocessor;
|
|
|
|
var comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
|
|
Keywords = new HashSet<string>(keywords ?? Array.Empty<string>(), comparer);
|
|
ControlKeywords = new HashSet<string>(controlKeywords ?? Array.Empty<string>(), comparer);
|
|
TypeKeywords = new HashSet<string>(typeKeywords ?? Array.Empty<string>(), comparer);
|
|
}
|
|
|
|
public string Name { get; }
|
|
public string[] Extensions { get; }
|
|
public string[] LineComments { get; }
|
|
public string? BlockStart { get; }
|
|
public string? BlockEnd { get; }
|
|
public char[] Quotes { get; }
|
|
public bool CaseSensitive { get; }
|
|
|
|
/// <summary>
|
|
/// <c>#include</c> · <c>#define</c> 처럼 <b>줄 첫머리의 # 지시문</b>을 쓰는 언어인지.
|
|
/// C 계열이 그렇다. 켜 두면 그 줄 전체를 지시문 색으로 칠한다 - C# 과 같은 규칙이다.
|
|
/// </summary>
|
|
public bool HasPreprocessor { get; }
|
|
|
|
/// <summary>문법 색을 입힐 대상인지. 평문·표 형식 문서는 false 다.</summary>
|
|
public bool IsCode { get; }
|
|
|
|
/// <summary>중괄호로 블록을 여닫는지. 코드 검사(짝 맞추기)를 걸 수 있는 조건이다.</summary>
|
|
public bool HasBraces { get; }
|
|
|
|
/// <summary>C# 전용 토크나이저를 쓸지.</summary>
|
|
public bool UseCSharpTokenizer { get; }
|
|
|
|
/// <summary>
|
|
/// 마크업 전용 토크나이저를 쓸지. 마크업은 단어가 아니라 <b>꺾쇠 안이냐 밖이냐</b>로 갈려서
|
|
/// 예약어 표로는 담기지 않는다. 까닭은 <see cref="MarkupTokenizer"/> 에 적었다.
|
|
/// </summary>
|
|
public bool UseMarkupTokenizer { get; }
|
|
|
|
public HashSet<string> Keywords { get; }
|
|
public HashSet<string> ControlKeywords { get; }
|
|
public HashSet<string> TypeKeywords { get; }
|
|
|
|
public bool HasBlockComment => BlockStart is { Length: > 0 } && BlockEnd is { Length: > 0 };
|
|
}
|
|
|
|
/// <summary>
|
|
/// 확장자로 언어를 고른다. 모르는 확장자는 평문으로 본다 -
|
|
/// 이 도구는 소스만이 아니라 설정 파일·레시피·문서도 비교해야 한다.
|
|
/// </summary>
|
|
public static class LanguageCatalog
|
|
{
|
|
// 여러 언어가 나눠 쓰는 예약어 묶음. 표를 짧게 유지하려고 따로 뺐다.
|
|
private static readonly string[] CFamilyControl =
|
|
{
|
|
"break", "case", "catch", "continue", "default", "do", "else", "finally",
|
|
"for", "goto", "if", "return", "switch", "throw", "try", "while"
|
|
};
|
|
|
|
private static readonly string[] CFamilyTypes =
|
|
{
|
|
"auto", "bool", "char", "const", "double", "float", "int", "long",
|
|
"short", "signed", "unsigned", "void", "size_t", "wchar_t"
|
|
};
|
|
|
|
public static readonly LanguageProfile Plain = new LanguageProfile(
|
|
"평문", new[] { ".txt", ".log", ".csv", ".text" }, isCode: false);
|
|
|
|
public static readonly LanguageProfile CSharp = new LanguageProfile(
|
|
"C#", new[] { ".cs", ".csx" },
|
|
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '"', '\'' },
|
|
hasBraces: true, useCSharpTokenizer: true);
|
|
|
|
public static readonly LanguageProfile CFamily = new LanguageProfile(
|
|
"C / C++", new[] { ".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".inl" },
|
|
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[]
|
|
{
|
|
"class", "struct", "enum", "union", "namespace", "template", "typename",
|
|
"public", "private", "protected", "virtual", "override", "static", "extern",
|
|
"inline", "operator", "new", "delete", "this", "nullptr", "true", "false",
|
|
"using", "typedef", "sizeof", "friend", "explicit", "mutable", "constexpr"
|
|
},
|
|
controlKeywords: CFamilyControl, typeKeywords: CFamilyTypes, hasBraces: true,
|
|
hasPreprocessor: true);
|
|
|
|
public static readonly LanguageProfile Java = new LanguageProfile(
|
|
"Java", new[] { ".java" },
|
|
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[]
|
|
{
|
|
"abstract", "class", "extends", "final", "implements", "import", "instanceof",
|
|
"interface", "native", "new", "null", "package", "private", "protected",
|
|
"public", "static", "super", "synchronized", "this", "throws", "transient",
|
|
"true", "false", "volatile", "enum", "record", "var"
|
|
},
|
|
controlKeywords: CFamilyControl, typeKeywords: CFamilyTypes, hasBraces: true);
|
|
|
|
public static readonly LanguageProfile JavaScript = new LanguageProfile(
|
|
"JavaScript / TypeScript", new[] { ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs" },
|
|
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '"', '\'', '`' },
|
|
keywords: new[]
|
|
{
|
|
"class", "const", "let", "var", "function", "new", "this", "super", "extends",
|
|
"import", "export", "from", "as", "default", "async", "await", "null",
|
|
"undefined", "true", "false", "typeof", "instanceof", "in", "of", "delete",
|
|
"interface", "type", "enum", "implements", "public", "private", "readonly"
|
|
},
|
|
controlKeywords: CFamilyControl, typeKeywords: new[] { "number", "string", "boolean", "any", "void", "never", "unknown" },
|
|
hasBraces: true);
|
|
|
|
public static readonly LanguageProfile Python = new LanguageProfile(
|
|
"Python", new[] { ".py", ".pyw", ".pyi" },
|
|
lineComments: new[] { "#" },
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[]
|
|
{
|
|
"and", "as", "assert", "async", "await", "class", "def", "del", "from",
|
|
"global", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or",
|
|
"pass", "True", "False", "with", "yield", "self"
|
|
},
|
|
controlKeywords: new[] { "break", "continue", "elif", "else", "except", "finally", "for", "if", "raise", "return", "try", "while" },
|
|
typeKeywords: new[] { "bool", "bytes", "dict", "float", "int", "list", "set", "str", "tuple" });
|
|
|
|
public static readonly LanguageProfile Sql = new LanguageProfile(
|
|
"SQL", new[] { ".sql" },
|
|
lineComments: new[] { "--" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '\'', '"' },
|
|
keywords: new[]
|
|
{
|
|
"select", "from", "where", "insert", "into", "values", "update", "set",
|
|
"delete", "create", "alter", "drop", "table", "view", "index", "join",
|
|
"inner", "left", "right", "outer", "on", "group", "order", "by", "having",
|
|
"union", "distinct", "as", "and", "or", "not", "null", "primary", "key",
|
|
"foreign", "references", "default", "constraint"
|
|
},
|
|
controlKeywords: new[] { "begin", "end", "if", "else", "while", "case", "when", "then", "return" },
|
|
typeKeywords: new[] { "int", "bigint", "smallint", "varchar", "nvarchar", "char", "nchar", "text", "date", "datetime", "decimal", "float", "bit" },
|
|
caseSensitive: false);
|
|
|
|
public static readonly LanguageProfile Shell = new LanguageProfile(
|
|
"셸 / 배치", new[] { ".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1", ".psm1" },
|
|
lineComments: new[] { "#", "::", "rem " },
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[] { "echo", "set", "export", "function", "local", "source", "param", "return" },
|
|
controlKeywords: new[] { "if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "goto", "exit" },
|
|
caseSensitive: false);
|
|
|
|
public static readonly LanguageProfile Markup = new LanguageProfile(
|
|
"XML / XAML / HTML", new[] { ".xml", ".xaml", ".html", ".htm", ".svg", ".xsd", ".xsl", ".csproj", ".vbproj", ".config", ".props", ".targets", ".resx" },
|
|
blockStart: "<!--", blockEnd: "-->",
|
|
quotes: new[] { '"', '\'' },
|
|
caseSensitive: false, useMarkupTokenizer: true);
|
|
|
|
public static readonly LanguageProfile Json = new LanguageProfile(
|
|
"JSON", new[] { ".json", ".jsonc" },
|
|
lineComments: new[] { "//" }, blockStart: "/*", blockEnd: "*/",
|
|
quotes: new[] { '"' },
|
|
keywords: new[] { "true", "false", "null" });
|
|
|
|
public static readonly LanguageProfile Yaml = new LanguageProfile(
|
|
"YAML", new[] { ".yml", ".yaml" },
|
|
lineComments: new[] { "#" },
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[] { "true", "false", "null", "yes", "no" },
|
|
caseSensitive: false);
|
|
|
|
public static readonly LanguageProfile Ini = new LanguageProfile(
|
|
"설정 / INI", new[] { ".ini", ".cfg", ".conf", ".properties", ".toml", ".editorconfig", ".gitconfig" },
|
|
lineComments: new[] { ";", "#" },
|
|
quotes: new[] { '"', '\'' },
|
|
keywords: new[] { "true", "false" },
|
|
caseSensitive: false);
|
|
|
|
public static readonly LanguageProfile Markdown = new LanguageProfile(
|
|
"Markdown", new[] { ".md", ".markdown" }, isCode: false);
|
|
|
|
/// <summary>화면에 고를 수 있게 늘어놓는 순서. 평문이 맨 앞이다(가장 넓게 쓰인다).</summary>
|
|
public static readonly IReadOnlyList<LanguageProfile> All = new[]
|
|
{
|
|
Plain, CSharp, CFamily, Java, JavaScript, Python, Sql, Shell, Markup, Json, Yaml, Ini, Markdown
|
|
};
|
|
|
|
/// <summary>경로의 확장자로 언어를 고른다. 모르면 평문.</summary>
|
|
public static LanguageProfile ForPath(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return Plain;
|
|
|
|
string extension;
|
|
try { extension = Path.GetExtension(path)!.ToLowerInvariant(); }
|
|
catch (ArgumentException) { return Plain; }
|
|
|
|
if (string.IsNullOrEmpty(extension)) return Plain;
|
|
|
|
foreach (var profile in All)
|
|
{
|
|
foreach (var candidate in profile.Extensions)
|
|
{
|
|
if (string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)) return profile;
|
|
}
|
|
}
|
|
|
|
return Plain;
|
|
}
|
|
}
|
|
}
|