using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
namespace AxCopilot.Services;
///
/// 간이 마크다운 → WPF UIElement 변환기.
/// 지원: **볼드**, *이탤릭*, `인라인코드`, ```코드블록```, ### 헤더, - 리스트, --- 구분선
///
public static class MarkdownRenderer
{
private static readonly Brush FilePathBrush = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
private static readonly Brush CodeSymbolBrush = new SolidColorBrush(Color.FromRgb(0xF2, 0x8C, 0x79));
public static StackPanel Render(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
{
var panel = new StackPanel();
if (string.IsNullOrEmpty(markdown))
return panel;
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var i = 0;
while (i < lines.Length)
{
var line = lines[i];
// 코드 블록 ```
if (line.TrimStart().StartsWith("```"))
{
var lang = line.TrimStart().Length > 3 ? line.TrimStart()[3..].Trim() : "";
var codeLines = new System.Text.StringBuilder();
i++;
while (i < lines.Length && !lines[i].TrimStart().StartsWith("```"))
{
codeLines.AppendLine(lines[i]);
i++;
}
if (i < lines.Length) i++; // skip closing ```
var codeBlock = CreateCodeBlock(codeLines.ToString().TrimEnd(), lang, textColor, codeBg, accentColor);
panel.Children.Add(codeBlock);
continue;
}
// 빈 줄
if (string.IsNullOrWhiteSpace(line))
{
panel.Children.Add(new Border { Height = 6 });
i++;
continue;
}
// 구분선 ---
if (Regex.IsMatch(line.Trim(), @"^-{3,}$|^\*{3,}$"))
{
panel.Children.Add(new Border
{
Height = 1,
Background = secondaryColor,
Opacity = 0.3,
Margin = new Thickness(0, 8, 0, 8)
});
i++;
continue;
}
// 헤더 ###
if (line.StartsWith('#'))
{
var level = 0;
while (level < line.Length && line[level] == '#') level++;
var headerText = line[level..].Trim();
var fontSize = level switch { 1 => 20.0, 2 => 17.0, 3 => 15.0, _ => 14.0 };
var tb = new TextBlock
{
FontSize = fontSize,
FontWeight = FontWeights.Bold,
Foreground = textColor,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, level == 1 ? 12 : 8, 0, 4)
};
AddInlines(tb.Inlines, headerText, textColor, accentColor, codeBg);
panel.Children.Add(tb);
i++;
continue;
}
// 리스트 항목 - 또는 * 또는 숫자.
if (Regex.IsMatch(line, @"^\s*[-*]\s") || Regex.IsMatch(line, @"^\s*\d+\.\s"))
{
var match = Regex.Match(line, @"^(\s*)([-*]|\d+\.)\s(.*)");
if (match.Success)
{
var indent = match.Groups[1].Value.Length / 2;
var bullet = match.Groups[2].Value;
var content = match.Groups[3].Value;
var bulletChar = bullet is "-" or "*" ? "•" : bullet;
var grid = new Grid
{
Margin = new Thickness(12 + indent * 16, 2, 0, 2)
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(18) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var bulletTb = new TextBlock
{
Text = bulletChar,
FontSize = 13.5,
Foreground = accentColor,
VerticalAlignment = VerticalAlignment.Top
};
Grid.SetColumn(bulletTb, 0);
grid.Children.Add(bulletTb);
var tb = new TextBlock
{
FontSize = 13.5,
Foreground = textColor,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Top
};
Grid.SetColumn(tb, 1);
AddInlines(tb.Inlines, content, textColor, accentColor, codeBg);
grid.Children.Add(tb);
panel.Children.Add(grid);
}
i++;
continue;
}
// 인용 블록 >
if (line.TrimStart().StartsWith('>'))
{
var quoteLines = new List();
while (i < lines.Length && lines[i].TrimStart().StartsWith('>'))
{
var ql = lines[i].TrimStart();
quoteLines.Add(ql.Length > 1 ? ql[1..].TrimStart() : "");
i++;
}
var quoteBorder = new Border
{
BorderBrush = accentColor,
BorderThickness = new Thickness(3, 0, 0, 0),
Padding = new Thickness(12, 6, 8, 6),
Margin = new Thickness(4, 4, 0, 4),
Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)),
};
var quoteTb = new TextBlock
{
FontSize = 13,
FontStyle = FontStyles.Italic,
Foreground = secondaryColor,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
};
AddInlines(quoteTb.Inlines, string.Join("\n", quoteLines), textColor, accentColor, codeBg);
quoteBorder.Child = quoteTb;
panel.Children.Add(quoteBorder);
continue;
}
// 테이블 | col1 | col2 |
if (line.Contains('|') && line.Trim().StartsWith('|'))
{
var tableRows = new List();
while (i < lines.Length && lines[i].Contains('|'))
{
tableRows.Add(lines[i]);
i++;
}
var table = CreateMarkdownTable(tableRows, textColor, accentColor, codeBg);
if (table != null) panel.Children.Add(table);
continue;
}
// 일반 텍스트 단락
var para = new TextBlock
{
FontSize = 13.5,
Foreground = textColor,
TextWrapping = TextWrapping.Wrap,
LineHeight = 22,
Margin = new Thickness(0, 2, 0, 2)
};
AddInlines(para.Inlines, line, textColor, accentColor, codeBg);
panel.Children.Add(para);
i++;
}
return panel;
}
/// 인라인 마크다운 처리: **볼드**, *이탤릭*, `코드`, ~~취소선~~, [링크](url)
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg)
{
// 패턴: [link](url) | ~~strikethrough~~ | **bold** | *italic* | `code` | 일반텍스트
var pattern = @"(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|([^*`~\[]+))";
var matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
if (m.Groups[2].Success && m.Groups[3].Success) // [link](url)
{
var linkUrl = m.Groups[3].Value;
var hl = new Hyperlink(new Run(m.Groups[2].Value))
{
Foreground = accentColor,
TextDecorations = null,
Cursor = System.Windows.Input.Cursors.Hand,
};
hl.Click += (_, _) =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(linkUrl) { UseShellExecute = true }); } catch { }
};
inlines.Add(hl);
}
else if (m.Groups[4].Success) // ~~strikethrough~~
{
inlines.Add(new Run(m.Groups[4].Value)
{
TextDecorations = TextDecorations.Strikethrough,
Foreground = new SolidColorBrush(Color.FromArgb(0x99, 0xFF, 0xFF, 0xFF)),
});
}
else if (m.Groups[5].Success) // **bold**
{
inlines.Add(new Run(m.Groups[5].Value) { FontWeight = FontWeights.Bold });
}
else if (m.Groups[6].Success) // *italic*
{
inlines.Add(new Run(m.Groups[6].Value) { FontStyle = FontStyles.Italic });
}
else if (m.Groups[7].Success) // `code`
{
var codeBorder = new Border
{
Background = codeBg,
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(1, 0, 1, 0),
Child = new TextBlock
{
Text = m.Groups[7].Value,
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
Foreground = accentColor
}
};
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
}
else if (m.Groups[8].Success) // 일반 텍스트
{
AddPlainTextWithFilePaths(inlines, m.Groups[8].Value);
}
}
}
/// 파일 경로 패턴을 파란색 강조하여 추가하는 정적 Regex.
private static readonly Regex FilePathPattern = new(
@"(?])(" +
@"[A-Za-z]:\\[^\s<>""',;)]+|" + // 절대 경로: C:\folder\file.ext
@"\.{1,2}/[\w\-./]+(?:\.[a-zA-Z]{1,10})?|" + // 상대 경로: ./src/file.cs
@"[\w\-]+(?:/[\w\-\.]+){1,}(?:\.[a-zA-Z]{1,10})?|" + // 폴더/파일: src/utils/helper.cs
@"[\w\-]+\.(?:cs|py|js|ts|tsx|jsx|json|xml|html|htm|css|md|txt|yml|yaml|toml|sh|bat|ps1|csproj|sln|docx|xlsx|pptx|pdf|csv|enc|skill))" +
@"(?=[,\s;)""'<]|$)",
RegexOptions.Compiled);
/// 파일 경로 활성 여부 (설정 연동).
public static bool EnableFilePathHighlight { get; set; } = true;
/// 코드 심볼 강조 활성 여부 (설정/탭 연동).
public static bool EnableCodeSymbolHighlight { get; set; } = true;
private static readonly Regex CodeSymbolPattern = new(
@"(?일반 텍스트에서 파일 경로 패턴을 감지하여 파란색으로 강조합니다.
private static void AddPlainTextWithFilePaths(InlineCollection inlines, string text)
{
if (!EnableFilePathHighlight && !EnableCodeSymbolHighlight)
{
inlines.Add(new Run(text));
return;
}
MatchCollection? matches = null;
if (EnableFilePathHighlight)
matches = FilePathPattern.Matches(text);
if (matches == null || matches.Count == 0)
{
AddPlainTextWithCodeSymbols(inlines, text);
return;
}
var lastIndex = 0;
foreach (Match pm in matches)
{
// 매치 전 텍스트
if (pm.Index > lastIndex)
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..pm.Index]);
// 파일 경로 — 파란색 강조
inlines.Add(new Run(pm.Value)
{
Foreground = FilePathBrush,
FontWeight = FontWeights.Medium,
});
lastIndex = pm.Index + pm.Length;
}
// 남은 텍스트
if (lastIndex < text.Length)
AddPlainTextWithCodeSymbols(inlines, text[lastIndex..]);
}
private static void AddPlainTextWithCodeSymbols(InlineCollection inlines, string text)
{
if (string.IsNullOrEmpty(text))
return;
if (!EnableCodeSymbolHighlight)
{
inlines.Add(new Run(text));
return;
}
var matches = CodeSymbolPattern.Matches(text);
if (matches.Count == 0)
{
inlines.Add(new Run(text));
return;
}
var lastIndex = 0;
foreach (Match match in matches)
{
if (match.Index > lastIndex)
inlines.Add(new Run(text[lastIndex..match.Index]));
inlines.Add(new Run(match.Value)
{
Foreground = CodeSymbolBrush,
FontWeight = FontWeights.SemiBold,
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
inlines.Add(new Run(text[lastIndex..]));
}
/// 마크다운 테이블을 Grid로 렌더링합니다.
private static FrameworkElement? CreateMarkdownTable(List rows, Brush textColor, Brush accentColor, Brush codeBg)
{
if (rows.Count < 2) return null;
// 셀 파싱
static string[] ParseRow(string row)
{
var trimmed = row.Trim().Trim('|');
return trimmed.Split('|').Select(c => c.Trim()).ToArray();
}
var headers = ParseRow(rows[0]);
var colCount = headers.Length;
if (colCount == 0) return null;
// 구분선 행(---|---) 건너뛰기
var dataStart = 1;
if (rows.Count > 1 && Regex.IsMatch(rows[1].Trim(), @"^[\|\s:\-]+$"))
dataStart = 2;
var grid = new Grid { Margin = new Thickness(0, 6, 0, 6) };
for (int c = 0; c < colCount; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var rowIndex = 0;
// 헤더 행
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (int c = 0; c < colCount; c++)
{
var cell = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
BorderThickness = new Thickness(0, 0, c < colCount - 1 ? 1 : 0, 1),
Padding = new Thickness(8, 5, 8, 5),
};
var tb = new TextBlock
{
Text = c < headers.Length ? headers[c] : "",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = textColor, TextWrapping = TextWrapping.Wrap,
};
cell.Child = tb;
Grid.SetRow(cell, rowIndex);
Grid.SetColumn(cell, c);
grid.Children.Add(cell);
}
rowIndex++;
// 데이터 행
for (int r = dataStart; r < rows.Count; r++)
{
var cols = ParseRow(rows[r]);
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (int c = 0; c < colCount; c++)
{
var cell = new Border
{
Background = r % 2 == 0
? new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF))
: Brushes.Transparent,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)),
BorderThickness = new Thickness(0, 0, c < colCount - 1 ? 1 : 0, 1),
Padding = new Thickness(8, 4, 8, 4),
};
var tb = new TextBlock
{
FontSize = 12, Foreground = textColor, TextWrapping = TextWrapping.Wrap,
};
AddInlines(tb.Inlines, c < cols.Length ? cols[c] : "", textColor, accentColor, codeBg);
cell.Child = tb;
Grid.SetRow(cell, rowIndex);
Grid.SetColumn(cell, c);
grid.Children.Add(cell);
}
rowIndex++;
}
var wrapper = new Border
{
CornerRadius = new CornerRadius(6),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
BorderThickness = new Thickness(1),
ClipToBounds = true,
Margin = new Thickness(0, 4, 0, 4),
};
wrapper.Child = grid;
return wrapper;
}
/// 코드 블록 UI 생성 (헤더 + 복사 버튼 + 코드)
private static Border CreateCodeBlock(string code, string lang, Brush textColor, Brush codeBg, Brush accentColor)
{
var container = new Border
{
Background = codeBg,
CornerRadius = new CornerRadius(10),
Margin = new Thickness(0, 6, 0, 6),
Padding = new Thickness(0)
};
var stack = new StackPanel();
// 헤더 (언어 + 복사 버튼)
var header = new Border
{
Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)),
CornerRadius = new CornerRadius(10, 10, 0, 0),
Padding = new Thickness(14, 6, 8, 6)
};
var headerGrid = new Grid();
headerGrid.Children.Add(new TextBlock
{
Text = string.IsNullOrEmpty(lang) ? "code" : lang,
FontSize = 11,
Foreground = accentColor,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center
});
// 우측 버튼 패널
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
var capturedCode = code;
var capturedLang = lang;
// 파일 저장 버튼
var saveBtn = CreateCodeHeaderButton("\uE74E", "저장", textColor);
saveBtn.Click += (_, _) =>
{
try
{
var ext = GetExtensionForLang(capturedLang);
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"code{ext}",
Filter = $"코드 파일 (*{ext})|*{ext}|모든 파일 (*.*)|*.*",
};
if (dlg.ShowDialog() == true)
System.IO.File.WriteAllText(dlg.FileName, capturedCode);
}
catch { }
};
btnPanel.Children.Add(saveBtn);
// 전체화면 버튼
var expandBtn = CreateCodeHeaderButton("\uE740", "확대", textColor);
expandBtn.Click += (_, _) => ShowCodeFullScreen(capturedCode, capturedLang, codeBg, textColor);
btnPanel.Children.Add(expandBtn);
// 복사 버튼
var copyBtn = CreateCodeHeaderButton("\uE8C8", "복사", textColor);
copyBtn.Click += (_, _) => { try { Clipboard.SetText(capturedCode); } catch { } };
btnPanel.Children.Add(copyBtn);
headerGrid.Children.Add(btnPanel);
header.Child = headerGrid;
stack.Children.Add(header);
// 코드 본문 (라인 번호 + 구문 하이라이팅)
var codeGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
codeGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
codeGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// 라인 번호
var codeLines = code.Split('\n');
var lineNumbers = new TextBlock
{
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
Foreground = new SolidColorBrush(Color.FromArgb(0x50, 0xFF, 0xFF, 0xFF)),
Padding = new Thickness(10, 10, 6, 14),
LineHeight = 20,
TextAlignment = TextAlignment.Right,
Text = string.Join("\n", Enumerable.Range(1, codeLines.Length)),
};
Grid.SetColumn(lineNumbers, 0);
codeGrid.Children.Add(lineNumbers);
var codeText = new TextBlock
{
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
Foreground = textColor,
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(8, 10, 14, 14),
LineHeight = 20
};
ApplySyntaxHighlighting(codeText, code, lang, textColor);
Grid.SetColumn(codeText, 1);
codeGrid.Children.Add(codeText);
stack.Children.Add(codeGrid);
container.Child = stack;
return container;
}
private static Button CreateCodeHeaderButton(string mdlIcon, string label, Brush fg)
{
return new Button
{
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = System.Windows.Input.Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Padding = new Thickness(5, 2, 5, 2),
Margin = new Thickness(2, 0, 0, 0),
Content = new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = mdlIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10, Foreground = fg, Opacity = 0.6,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 3, 0),
},
new TextBlock { Text = label, FontSize = 10, Foreground = fg, Opacity = 0.6 },
}
}
};
}
private static string GetExtensionForLang(string lang) => (lang ?? "").ToLowerInvariant() switch
{
"csharp" or "cs" => ".cs",
"python" or "py" => ".py",
"javascript" or "js" => ".js",
"typescript" or "ts" => ".ts",
"java" => ".java",
"html" => ".html",
"css" => ".css",
"json" => ".json",
"xml" => ".xml",
"sql" => ".sql",
"bash" or "sh" or "shell" => ".sh",
"powershell" or "ps1" => ".ps1",
"bat" or "cmd" => ".bat",
"yaml" or "yml" => ".yml",
"markdown" or "md" => ".md",
"cpp" or "c++" => ".cpp",
"c" => ".c",
"go" => ".go",
"rust" or "rs" => ".rs",
_ => ".txt",
};
private static void ShowCodeFullScreen(string code, string lang, Brush codeBg, Brush textColor)
{
var win = new Window
{
Title = $"코드 — {(string.IsNullOrEmpty(lang) ? "code" : lang)}",
Width = 900, Height = 650,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Background = codeBg is SolidColorBrush scb ? new SolidColorBrush(scb.Color) : Brushes.Black,
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var lines = code.Split('\n');
var lineNumTb = new TextBlock
{
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 13, LineHeight = 22,
Foreground = new SolidColorBrush(Color.FromArgb(0x50, 0xFF, 0xFF, 0xFF)),
Padding = new Thickness(16, 16, 8, 16),
TextAlignment = TextAlignment.Right,
Text = string.Join("\n", Enumerable.Range(1, lines.Length)),
};
Grid.SetColumn(lineNumTb, 0);
grid.Children.Add(lineNumTb);
var codeTb = new TextBlock
{
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 13, LineHeight = 22,
Foreground = textColor,
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(8, 16, 16, 16),
};
ApplySyntaxHighlighting(codeTb, code, lang, textColor);
Grid.SetColumn(codeTb, 1);
grid.Children.Add(codeTb);
var sv = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = grid,
};
win.Content = sv;
win.Show();
}
// ─── 구문 하이라이팅 ──────────────────────────────────────────────────
private static readonly Brush KeywordBrush = new SolidColorBrush(Color.FromRgb(0xC5, 0x86, 0xC0)); // purple
private static readonly Brush TypeBrush = new SolidColorBrush(Color.FromRgb(0x4E, 0xC9, 0xB0)); // teal
private static readonly Brush StringBrush = new SolidColorBrush(Color.FromRgb(0xCE, 0x91, 0x78)); // orange
private static readonly Brush CommentBrush = new SolidColorBrush(Color.FromRgb(0x6A, 0x99, 0x55)); // green
private static readonly Brush NumberBrush = new SolidColorBrush(Color.FromRgb(0xB5, 0xCE, 0xA8)); // light green
private static readonly Brush MethodBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0xDC, 0xAA)); // yellow
private static readonly Brush DirectiveBrush = new SolidColorBrush(Color.FromRgb(0x9C, 0xDC, 0xFE)); // light blue
private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal)
{
"abstract","as","async","await","base","bool","break","byte","case","catch","char","checked",
"class","const","continue","decimal","default","delegate","do","double","else","enum","event",
"explicit","extern","false","finally","fixed","float","for","foreach","goto","if","implicit",
"in","int","interface","internal","is","lock","long","namespace","new","null","object","operator",
"out","override","params","private","protected","public","readonly","ref","return","sbyte","sealed",
"short","sizeof","stackalloc","static","string","struct","switch","this","throw","true","try",
"typeof","uint","ulong","unchecked","unsafe","ushort","using","var","virtual","void","volatile",
"while","yield","record","init","required","get","set","value","where","when","and","or","not",
};
private static readonly HashSet PythonKeywords = new(StringComparer.Ordinal)
{
"False","None","True","and","as","assert","async","await","break","class","continue","def",
"del","elif","else","except","finally","for","from","global","if","import","in","is","lambda",
"nonlocal","not","or","pass","raise","return","try","while","with","yield",
};
private static readonly HashSet JsKeywords = new(StringComparer.Ordinal)
{
"abstract","arguments","async","await","boolean","break","byte","case","catch","char","class",
"const","continue","debugger","default","delete","do","double","else","enum","eval","export",
"extends","false","final","finally","float","for","from","function","goto","if","implements",
"import","in","instanceof","int","interface","let","long","native","new","null","of","package",
"private","protected","public","return","short","static","super","switch","synchronized","this",
"throw","throws","transient","true","try","typeof","undefined","var","void","volatile","while",
"with","yield",
};
private static readonly HashSet JavaKeywords = new(StringComparer.Ordinal)
{
"abstract","assert","boolean","break","byte","case","catch","char","class","const","continue",
"default","do","double","else","enum","extends","false","final","finally","float","for","goto",
"if","implements","import","instanceof","int","interface","long","native","new","null","package",
"private","protected","public","return","short","static","strictfp","super","switch","synchronized",
"this","throw","throws","transient","true","try","void","volatile","while","var","record","sealed",
"permits","yield",
};
private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"SELECT","FROM","WHERE","INSERT","INTO","UPDATE","SET","DELETE","CREATE","DROP","ALTER","TABLE",
"INDEX","VIEW","JOIN","INNER","LEFT","RIGHT","OUTER","FULL","CROSS","ON","AND","OR","NOT","IN",
"IS","NULL","LIKE","BETWEEN","EXISTS","HAVING","GROUP","BY","ORDER","ASC","DESC","LIMIT","OFFSET",
"UNION","ALL","DISTINCT","AS","CASE","WHEN","THEN","ELSE","END","VALUES","PRIMARY","KEY","FOREIGN",
"REFERENCES","CONSTRAINT","UNIQUE","CHECK","DEFAULT","COUNT","SUM","AVG","MIN","MAX","CAST",
"COALESCE","IF","BEGIN","COMMIT","ROLLBACK","GRANT","REVOKE","TRUNCATE","WITH","RECURSIVE",
};
private static HashSet GetKeywordsForLang(string lang) => lang.ToLowerInvariant() switch
{
"csharp" or "cs" or "c#" => CSharpKeywords,
"python" or "py" => PythonKeywords,
"javascript" or "js" or "jsx" => JsKeywords,
"typescript" or "ts" or "tsx" => JsKeywords,
"java" or "kotlin" or "kt" => JavaKeywords,
"sql" or "mysql" or "postgresql" or "sqlite" => SqlKeywords,
"go" or "golang" => new(StringComparer.Ordinal)
{
"break","case","chan","const","continue","default","defer","else","fallthrough","for","func",
"go","goto","if","import","interface","map","package","range","return","select","struct",
"switch","type","var","nil","true","false","append","len","cap","make","new","panic","recover",
},
"rust" or "rs" => new(StringComparer.Ordinal)
{
"as","async","await","break","const","continue","crate","dyn","else","enum","extern","false",
"fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return",
"self","Self","static","struct","super","trait","true","type","unsafe","use","where","while",
"yield","Box","Vec","String","Option","Result","Some","None","Ok","Err","println","macro_rules",
},
"cpp" or "c++" or "c" or "h" or "hpp" => new(StringComparer.Ordinal)
{
"auto","break","case","char","const","continue","default","do","double","else","enum","extern",
"float","for","goto","if","inline","int","long","register","return","short","signed","sizeof",
"static","struct","switch","typedef","union","unsigned","void","volatile","while","class",
"namespace","using","public","private","protected","virtual","override","template","typename",
"nullptr","true","false","new","delete","throw","try","catch","const_cast","dynamic_cast",
"static_cast","reinterpret_cast","bool","string","include","define","ifdef","ifndef","endif",
},
_ => CSharpKeywords, // default
};
private static readonly Regex SyntaxPattern = new(
@"(//[^\n]*|#[^\n]*)" + // single-line comment (// or #)
@"|(""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*')" + // strings
@"|(\b\d+\.?\d*[fFdDmMlL]?\b)" + // numbers
@"|(\b[A-Z]\w*(?=\s*[\.<\(]))" + // type-like (PascalCase before . < ()
@"|(\b\w+(?=\s*\())" + // method call
@"|(\b\w+\b)", // identifier / keyword
RegexOptions.Compiled);
private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor)
{
if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code))
{
tb.Text = code;
return;
}
var keywords = GetKeywordsForLang(lang);
var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell"
or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1";
foreach (Match m in SyntaxPattern.Matches(code))
{
if (m.Groups[1].Success) // comment
{
var commentText = m.Groups[1].Value;
// # 주석은 해당 언어에서만 적용
if (commentText.StartsWith('#') && !isCommentHash)
{
// # 이 주석이 아닌 언어: 일반 텍스트로 처리
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
}
else
{
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
}
}
else if (m.Groups[2].Success) // string
{
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
}
else if (m.Groups[3].Success) // number
{
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
}
else if (m.Groups[4].Success) // type
{
var word = m.Groups[4].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
}
else if (m.Groups[5].Success) // method
{
var word = m.Groups[5].Value;
if (keywords.Contains(word))
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
else
tb.Inlines.Add(new Run(word) { Foreground = MethodBrush });
}
else if (m.Groups[6].Success) // identifier
{
var word = m.Groups[6].Value;
if (keywords.Contains(word))
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
else
tb.Inlines.Add(new Run(word) { Foreground = defaultColor });
}
}
// Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움
// Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완
// 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근
tb.Inlines.Clear();
int lastEnd = 0;
foreach (Match m in SyntaxPattern.Matches(code))
{
if (m.Index > lastEnd)
tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
if (m.Groups[1].Success)
{
var commentText = m.Groups[1].Value;
if (commentText.StartsWith('#') && !isCommentHash)
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
else
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
}
else if (m.Groups[2].Success)
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
else if (m.Groups[3].Success)
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
else if (m.Groups[4].Success)
{
var word = m.Groups[4].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
}
else if (m.Groups[5].Success)
{
var word = m.Groups[5].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
}
else if (m.Groups[6].Success)
{
var word = m.Groups[6].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
}
lastEnd = m.Index + m.Length;
}
if (lastEnd < code.Length)
tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
}
}