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 }); } }