using System.Text.RegularExpressions; namespace RelayClient.Helpers; /// /// Discord-style syntax highlighting for ```lang...``` fenced code blocks. Builds a list of /// MAUI Spans (with colors from the VS Code Dark+ palette) that the caller drops into a /// FormattedString. /// /// How it works: /// - The opening fence captures an optional language tag (e.g. ```cs, ```python). /// - Aliases resolves "cs" → "csharp", "js" → "javascript", etc. /// - Tokenizers[lang] is a compiled regex with named groups (comment/string/number/word/…). /// - For each match, SpanForMatch picks a colour based on which group matched + whether /// a "word" hit a language keyword set. /// /// Adding a new language: register an alias (if needed), a Keywords set, and a tokenizer regex. /// public static class SyntaxHighlighter { /// Fallback identifier color (light grey). Used for any token we don't recognise. private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4"); /// Language keywords (if, for, return, etc.) — VS Code's "control flow" blue. private static readonly Color KeywordColor = Color.FromArgb("#569CD6"); /// String literals — orange/salmon. private static readonly Color StringColor = Color.FromArgb("#CE9178"); /// Numeric literals — soft green. private static readonly Color NumberColor = Color.FromArgb("#B5CEA8"); /// Comments — green, rendered italic. private static readonly Color CommentColor = Color.FromArgb("#6A9955"); /// Type names (heuristic: uppercase-start words in C#/JS/TS) — teal. private static readonly Color TypeColor = Color.FromArgb("#4EC9B0"); /// Function names — yellow. Currently unused (we don't disambiguate function calls). private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA"); /// Operators — same as default. Reserved for future use. private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4"); /// HTML tag names (<div>, </p>) — blue. private static readonly Color TagColor = Color.FromArgb("#569CD6"); /// HTML/CSS attribute names, YAML keys, bash variables — light blue. private static readonly Color AttrColor = Color.FromArgb("#9CDCFE"); /// Monospace font registered in MauiProgram. Used for all code-block spans. private const string FontFamily = "AnonymousProRegular"; /// /// Short language tags → canonical names. So users can write ```cs (instead of ```csharp), /// ```py instead of ```python, etc. Case-insensitive. /// private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) { ["cs"] = "csharp", ["c#"] = "csharp", ["js"] = "javascript", ["jsx"] = "javascript", ["ts"] = "typescript", ["tsx"] = "typescript", ["py"] = "python", ["sh"] = "bash", ["shell"] = "bash", ["zsh"] = "bash", ["htm"] = "html", ["xml"] = "html", ["yml"] = "yaml" }; /// /// Per-language keyword sets. A token in a "word" match-group that hits one of these /// gets rendered with KeywordColor. Case-sensitivity matches the language — Ordinal /// for most languages, OrdinalIgnoreCase for SQL and CSS. /// private static readonly Dictionary> Keywords = new(StringComparer.OrdinalIgnoreCase) { ["csharp"] = 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","get","goto","if", "implicit","in","int","interface","internal","is","lock","long","namespace","new","null", "object","operator","out","override","params","partial","private","protected","public", "readonly","record","ref","return","sbyte","sealed","set","short","sizeof","stackalloc", "static","string","struct","switch","this","throw","true","try","typeof","uint","ulong", "unchecked","unsafe","ushort","using","var","virtual","void","volatile","while","yield", "nameof","when","where","global","init","required","file","scoped","with" }, ["javascript"] = new(StringComparer.Ordinal) { "async","await","break","case","catch","class","const","continue","debugger","default", "delete","do","else","enum","export","extends","false","finally","for","from","function", "get","if","implements","import","in","instanceof","let","new","null","of","package", "private","protected","public","return","set","static","super","switch","this","throw", "true","try","typeof","undefined","var","void","while","with","yield" }, ["typescript"] = new(StringComparer.Ordinal) { "any","as","async","await","boolean","break","case","catch","class","const","continue", "debugger","declare","default","delete","do","else","enum","export","extends","false", "finally","for","from","function","get","if","implements","import","in","instanceof", "interface","is","keyof","let","namespace","never","new","null","number","of","package", "private","protected","public","readonly","return","set","static","string","super", "switch","this","throw","true","try","type","typeof","undefined","unknown","var","void", "while","with","yield" }, ["python"] = new(StringComparer.Ordinal) { "and","as","assert","async","await","break","class","continue","def","del","elif","else", "except","False","finally","for","from","global","if","import","in","is","lambda","None", "nonlocal","not","or","pass","raise","return","True","try","while","with","yield","self", "cls","match","case" }, ["sql"] = new(StringComparer.OrdinalIgnoreCase) { "select","from","where","insert","update","delete","create","alter","drop","table","index", "view","join","inner","outer","left","right","full","cross","on","as","group","by","order", "having","distinct","union","all","into","values","set","null","not","and","or","in","like", "between","is","true","false","primary","key","foreign","references","default","limit", "offset","with","case","when","then","else","end","exists","cast","begin","commit","rollback" }, ["bash"] = new(StringComparer.Ordinal) { "if","then","else","elif","fi","for","in","do","done","while","until","case","esac", "function","return","break","continue","exit","echo","printf","export","local","readonly", "source","alias","unset","trap","set","eval","exec","shift","let","declare","typeset" }, ["json"] = new(StringComparer.Ordinal) { "true","false","null" }, ["yaml"] = new(StringComparer.Ordinal) { "true","false","null","yes","no","on","off" }, ["css"] = new(StringComparer.OrdinalIgnoreCase) { "important","inherit","initial","unset","auto","none","normal","bold","italic","center", "left","right","top","bottom","flex","grid","block","inline","absolute","relative","fixed", "sticky","static" } }; /// /// Per-language compiled token regex. Each pattern uses named groups (comment/string/ /// number/word/tag/attr/…) which SpanForMatch dispatches on. Initialised lazily in the /// static constructor so the heavy regex compilation is paid once at startup. /// private static readonly Dictionary Tokenizers = new(StringComparer.Ordinal); static SyntaxHighlighter() { const RegexOptions opts = RegexOptions.Compiled | RegexOptions.Singleline; Tokenizers["csharp"] = new Regex( @"(?//[^\n]*|/\*.*?\*/)" + @"|(?@""(?:""""|[^""])*""|\$""(?:\\.|[^""\\])*""|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" + @"|(?\b\d+(?:\.\d+)?[fFdDmMuUlL]*\b)" + @"|(?[A-Za-z_]\w*)", opts); Tokenizers["javascript"] = new Regex( @"(?//[^\n]*|/\*.*?\*/)" + @"|(?""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)" + @"|(?\b\d+(?:\.\d+)?\b)" + @"|(?[A-Za-z_$][\w$]*)", opts); Tokenizers["typescript"] = Tokenizers["javascript"]; Tokenizers["python"] = new Regex( @"(?\#[^\n]*)" + @"|(?""""""[\s\S]*?""""""|'''[\s\S]*?'''|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" + @"|(?\b\d+(?:\.\d+)?\b)" + @"|(?[A-Za-z_]\w*)", opts); Tokenizers["sql"] = new Regex( @"(?--[^\n]*|/\*.*?\*/)" + @"|(?'(?:''|[^'])*')" + @"|(?\b\d+(?:\.\d+)?\b)" + @"|(?[A-Za-z_]\w*)", opts); Tokenizers["bash"] = new Regex( @"(?\#[^\n]*)" + @"|(?""(?:\\.|[^""\\])*""|'[^']*')" + @"|(?\b\d+\b)" + @"|(?\$\{?[A-Za-z_]\w*\}?)" + @"|(?[A-Za-z_][\w-]*)", opts); Tokenizers["json"] = new Regex( @"(?""(?:\\.|[^""\\])*"")" + @"|(?-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)" + @"|(?true|false|null)", opts); Tokenizers["yaml"] = new Regex( @"(?\#[^\n]*)" + @"|(?""(?:\\.|[^""\\])*""|'[^']*')" + @"|(?^[ \t]*[A-Za-z_][\w-]*(?=\s*:))" + @"|(?\b\d+(?:\.\d+)?\b)" + @"|(?[A-Za-z_][\w-]*)", opts | RegexOptions.Multiline); Tokenizers["html"] = new Regex( @"(?)" + @"|(?""[^""]*""|'[^']*')" + @"|(?\b[A-Za-z_][\w-]*(?==))", opts); Tokenizers["css"] = new Regex( @"(?/\*.*?\*/)" + @"|(?""[^""]*""|'[^']*')" + @"|(?-?\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b)" + @"|(?[.#]?[A-Za-z_][\w-]*(?=\s*[{,]))" + @"|(?[A-Za-z-]+(?=\s*:))" + @"|(?[A-Za-z_][\w-]*)", opts); Tokenizers["diff"] = new Regex( @"(?^\+[^\n]*)" + @"|(?^-[^\n]*)" + @"|(?^@@[^\n]*)", opts | RegexOptions.Multiline); Tokenizers["markdown"] = new Regex( @"(?
^#{1,6}[^\n]*)" + @"|(?\*\*[^*\n]+\*\*|__[^_\n]+__)" + @"|(?\*[^*\n]+\*|_[^_\n]+_)" + @"|(?`[^`\n]+`)" + @"|(?\[[^\]]+\]\([^)]+\))", opts | RegexOptions.Multiline); } /// /// Entry point. Walks every regex match in the code, emits plain spans for the gaps and /// styled spans for the matches. If the language is unknown (or not specified), returns a /// single default-colored span — code still renders in the monospace font, just no colors. /// public static List Highlight(string code, string? language, double fontSize) { var lang = Resolve(language); var spans = new List(); if (lang is null || !Tokenizers.TryGetValue(lang, out var tokenizer)) { spans.Add(MakeSpan(code, DefaultColor, fontSize)); return spans; } var keywords = Keywords.GetValueOrDefault(lang); int cursor = 0; foreach (Match m in tokenizer.Matches(code)) { if (m.Index > cursor) spans.Add(MakeSpan(code[cursor..m.Index], DefaultColor, fontSize)); spans.Add(SpanForMatch(m, lang, keywords, fontSize)); cursor = m.Index + m.Length; } if (cursor < code.Length) spans.Add(MakeSpan(code[cursor..], DefaultColor, fontSize)); return spans; } /// /// Maps a regex Match to a colored Span by inspecting which named group succeeded. Words /// fall through to a keyword-set lookup; in C#/JS/TS, uppercase-start words that aren't /// keywords are treated as type names (a cheap heuristic that works surprisingly well). /// private static Span SpanForMatch(Match m, string lang, HashSet? keywords, double fontSize) { if (m.Groups["comment"].Success) return MakeSpan(m.Value, CommentColor, fontSize, italic: true); if (m.Groups["string"].Success) return MakeSpan(m.Value, StringColor, fontSize); if (m.Groups["number"].Success) return MakeSpan(m.Value, NumberColor, fontSize); if (m.Groups["variable"].Success) return MakeSpan(m.Value, AttrColor, fontSize); if (m.Groups["tag"].Success) return MakeSpan(m.Value, TagColor, fontSize); if (m.Groups["attr"].Success) return MakeSpan(m.Value, AttrColor, fontSize); if (m.Groups["selector"].Success) return MakeSpan(m.Value, TypeColor, fontSize); if (m.Groups["prop"].Success) return MakeSpan(m.Value, AttrColor, fontSize); if (m.Groups["key"].Success) return MakeSpan(m.Value, AttrColor, fontSize); if (m.Groups["add"].Success) return MakeSpan(m.Value, Color.FromArgb("#6A9955"), fontSize); if (m.Groups["del"].Success) return MakeSpan(m.Value, Color.FromArgb("#F48771"), fontSize); if (m.Groups["hunk"].Success) return MakeSpan(m.Value, KeywordColor, fontSize); if (m.Groups["header"].Success) return MakeSpan(m.Value, KeywordColor, fontSize, bold: true); if (m.Groups["bold"].Success) return MakeSpan(m.Value, DefaultColor, fontSize, bold: true); if (m.Groups["italic"].Success) return MakeSpan(m.Value, DefaultColor, fontSize, italic: true); if (m.Groups["code"].Success) return MakeSpan(m.Value, StringColor, fontSize); if (m.Groups["link"].Success) return MakeSpan(m.Value, AttrColor, fontSize); if (m.Groups["word"].Success) { var word = m.Value; var compareSet = keywords; if (compareSet is not null && compareSet.Contains(word)) return MakeSpan(word, KeywordColor, fontSize); if (lang is "csharp" or "javascript" or "typescript" && word.Length > 0 && char.IsUpper(word[0])) return MakeSpan(word, TypeColor, fontSize); return MakeSpan(word, DefaultColor, fontSize); } return MakeSpan(m.Value, DefaultColor, fontSize); } /// Helper: build a Span with the monospace code font and the given colour + bold/italic flags. private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false) { var attrs = FontAttributes.None; if (bold) attrs |= FontAttributes.Bold; if (italic) attrs |= FontAttributes.Italic; return new Span { Text = text, TextColor = color, FontSize = fontSize, FontFamily = FontFamily, FontAttributes = attrs }; } /// Normalises a user-supplied language tag through the Aliases table. Returns null for empty/whitespace input. private static string? Resolve(string? language) { if (string.IsNullOrWhiteSpace(language)) return null; var lower = language.Trim().ToLowerInvariant(); return Aliases.GetValueOrDefault(lower, lower); } }