C# - kolorowanie składni
15.07.2013 23:34
Wstęp
Napiszmy bibliotekę, która będzie kolorowała nam składnie jakiegoś języka i zwracała widok jako html. Założenia- Chcemy umożliwić definiowanie własnych schematów kolorowania
- Chcemy umożliwić zmienianie skórki
Jako, że nie ma być to zaawansowany system (prościutki raczej) oprzemy jego silę na wyrażeniach reguralnych. Algorytm będzie wyglądał mniej-więcej tak:
- Załadujemy z definicji języka wszystkie wyrażenia regularne - każdy będzie miał swój priorytet.
- Poszukamy w tekście wzorców
- Przefiltrujemy listę wzorców usuwając te z mniejszym priorytetem
- Zbudujemy html-a
Zaczynamy
Na początek garść wyjątków
public class NoLanguageProvidedException : Exception { public NoLanguageProvidedException() : base("No language provided.") { } }
public class NoStyleColorProvidedException : Exception { public NoStyleColorProvidedException() : base("No color provided within theme style.") { } }
public class NoThemeProvidedException : Exception { public NoThemeProvidedException() : base("No theme provided.") { } }
public class NoThemeStyleProvidedException : Exception { public NoThemeStyleProvidedException() : base("No theme style provided (style is null)") { } }
Priorytety będziemy ustawiać za pomocą następującego enum-a, który jednocześnie będzie nam służył do rozpoznawania priorytetu kodu oraz stylu na jaki mamy pokolorwać:
public enum TokenScope { String = 0x1, Comment = 0x2, Preprocessor = 0x4, Keyword = 0x8 }
Przygotujmy teraz część odpowiedzialną za skórkę. Podstawową komórką będzie nastepująca klasa:
public class Style { public string HexColor { get; set; } public bool Bold { get; set; } public bool Italic { get; set; } }
Bedzie ona reprezentowała styl jakiegoś kawałka kodu. Jakiego? To już zawrzemy w interfejsie:
public interface ITheme { string BaseHexColor { get; } string BackgroundHexColor { get; } Style GetStyle(TokenScope scope); }
Mając taki zestaw, możemy przygotować sobie jakieś theme:
public class ObsidianTheme : ITheme { public string BaseHexColor { get { return "#F1F2F3"; } } public string BackgroundHexColor { get { return "#111"; } } public Style GetStyle(TokenScope scope) { switch (scope) { case TokenScope.Comment: return this.CommentStyle; case TokenScope.Keyword: return this.KeywordStyle; case TokenScope.Preprocessor: return this.PreprocessorStyle; case TokenScope.String: return this.StringStyle; default: return this.KeywordStyle; } } private Style KeywordStyle { get { return new Style { Bold = false, HexColor = "#93C763", Italic = false }; } } private Style StringStyle { get { return new Style { Bold = false, HexColor = "#EC7600", Italic = false }; } } private Style PreprocessorStyle { get { return new Style { Bold = false, HexColor = "#003399", Italic = true }; } } private Style CommentStyle { get { return new Style { Bold = false, HexColor = "#888888", Italic = true }; } } }
Zajmijmy się teraz jęzkami, tj składnią danych języków. Każdy będzie składał się z reguł: wyrażenia regularnego oraz priorytetu aka typu:
public class Rule { public string RegularExpression { get; set; } public TokenScope Scope { get; set; } }
Język więc będzie zbiorem takich reguł:
public interface ILanguage { IEnumerable<Rule> GetRules(); }
No i przykładowy język
public class Csharp : ILanguage { public IEnumerable<Rule> GetRules() { return new[] { new Rule { RegularExpression = "\"(?:[^\"\\\\]|\\\\.)*\"", Scope = TokenScope.String }, new Rule { RegularExpression = @"'\\[a-zA-Z|\\]'", Scope = TokenScope.String }, new Rule { RegularExpression = @"'\\[a-zA-Z|\\]'", Scope = TokenScope.String }, new Rule { RegularExpression = @"'\\[x|u|U][0-7ABCDEFabcdef]{1,8}'", Scope = TokenScope.String }, new Rule { RegularExpression = @"//.*|/\*[\s\S]*\*/", Scope = TokenScope.Comment }, new Rule { RegularExpression = @"\b(?:abstract|as|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|struct|sbyte|sealed|sizeof|stackalloc|short|static|string|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|var|virtual|volatile|void|while)\b", Scope = TokenScope.Keyword }, new Rule { RegularExpression = @"^#.*$", Scope = TokenScope.Keyword } }; }
Aby łatwiej było się posługwiać regułami przy budowaniu html-a musimy podać je małej obróbce. Po niej, reguła będzie prezentowana przez nastepującą klasę:
internal class PreProcessedRule { public string SourcePiece { get; set; } public int StartIndex { get; set; } public TokenScope Scope { get; set; } }
Przygotujmy pomocniczy interfejs:
internal interface IPreProcessedRules : IEnumerable<PreProcessedRule> { IEnumerable<PreProcessedRule> GetPreProcessedRules(); }
Oraz jego implementację, która będzie już na przeprowadzała "preprocessing":
internal class PreProcessedRules : IPreProcessedRules { private IEnumerable<PreProcessedRule> preProcessedRules; internal PreProcessedRules(string sourceCode, IEnumerable<Rule> rules) { this.preProcessedRules = Enumerable.Empty<PreProcessedRule>(); this.PreProcessRules(sourceCode, rules); } public IEnumerable<PreProcessedRule> GetPreProcessedRules() { return this.preProcessedRules; } public IEnumerator<PreProcessedRule> GetEnumerator() { return this.preProcessedRules.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } private void PreProcessRules(string sourceCode, IEnumerable<Rule> rules) { var matches = rules.SelectMany( rule => Regex.Matches( sourceCode, rule.RegularExpression, RegexOptions.Multiline) .Cast<Match>() .Select( match => new { match.Index, match.Value, rule.Scope })) .OrderBy(match => match.Index) .ThenBy(match => match.Scope) .ToList(); for (var j = 0; i < matches.Count; j++) { if (j + 1 >= matches.Count) { continue; } var currentMatchIndexEnd = matches[j].Index + matches[j].Value.Length; if (currentMatchIndexEnd <= matches[j + 1].Index) { continue; } matches.RemoveAt(j + 1); i--; } this.preProcessedRules = matches.Select( match => new PreProcessedRule { Scope = match.Scope, SourcePiece = match.Value, StartIndex = match.Index }) .ToList(); } }
Siłę stanowi metoda PreProcessRules, która:
- Szuka wszystkich wzorów w całym tekście
- Sortuje wg. znalezionego indeksu (pozycji pierwszej litery znalezionego wzorca) a następnie po priorytecie
- Usuwa zbędne wzorce
Interfejs reprezentujacy "przetworzony" język
internal interface IPreProcessedLanguage : ILanguage { string SourceCode { get; } IPreProcessedRules GetPreProcessedRules(); }
I jego implementacja:
internal class PreProcessedLanguage : IPreProcessedLanguage { private readonly ILanguage language; private readonly IPreProcessedRules preProcessedRules; internal PreProcessedLanguage(string sourceCode, ILanguage language) { this.language = language; this.SourceCode = sourceCode; this.preProcessedRules = new PreProcessedRules(sourceCode, this.GetRules()); } public IPreProcessedRules GetPreProcessedRules() { return this.preProcessedRules; } public IEnumerable<Rule> GetRules() { return this.language.GetRules(); } public string SourceCode { get; set; } }
Teraz parser, który mając kod źródłowy, "theme" oraz poddany obróbce język zbuduje nasz html
internal class HtmlParser { private readonly IPreProcessedLanguage preProcessedLanguage; private readonly ITheme theme; internal HtmlParser(IPreProcessedLanguage preProcessedLanguage, ITheme theme) { this.preProcessedLanguage = preProcessedLanguage; this.theme = theme; } public string Parse() { var builder = new StringBuilder( PreprePreOpenTag( this.theme.BaseHexColor, this.theme.BackgroundHexColor)); builder.Append(this.ParseSourceCode()); builder.Append("</pre>"); return builder.ToString(); } private string ParseSourceCode() { var source = this.preProcessedLanguage.SourceCode; var processedText = new StringBuilder(); var rules = this.preProcessedLanguage.GetPreProcessedRules().ToList(); if (!rules.Any()) { return source; } processedText.Append(GetTextBegin(source, rules)); for (var j = 0; j < rules.Count(); j++) { var rule = rules[j]; var style = this.theme.GetStyle(rule.Scope); FormatSourcePiece(processedText, rule.SourcePiece, style); string restOfSource; if (j + 1 < rules.Count()) { var nextRule = rules[j + 1]; var restStartIndex = rule.StartIndex + rule.SourcePiece.Length; restOfSource = source.Substring( restStartIndex, nextRule.StartIndex - restStartIndex); } else { restOfSource = source.Substring( rule.StartIndex + rule.SourcePiece.Length); } processedText.Append(restOfSource); } return processedText.ToString(); } private static void FormatSourcePiece(StringBuilder builder, string source, Style style) { if (style.Bold) { source = string.Format("<b>{0}</b>", source); } if (style.Italic) { source = string.Format("<i>{0}</i>", source); } builder.AppendFormat( "<span style=\"color: {0};\">{1}</span>", style.HexColor, source); } private static string GetTextBegin( string sourceCode, IEnumerable<PreProcessedRule> rules) { return sourceCode.Substring(0, rules.ElementAt(0).StartIndex); } private static string PreprePreOpenTag( string hexColor, string hexBackgroundColor) { return string.Format( "<pre style=\"color: {0}; background-color: {1};\">\n", hexColor, hexBackgroundColor); } }
Mając to wszystko możemy wyeksponować użytkownikowi interfejs:
public interface ICodeColorizer { ICodeColorizer WithLanguage(ILanguage language); ICodeColorizer WithTheme(ITheme theme); string ToHtml(); }
wraz z implementacją:
internal class CodeColorizer : ICodeColorizer { private readonly string sourceCode; private ILanguage language; private ITheme theme; internal CodeColorizer(string sourceCode) { if (sourceCode == null) { throw new ArgumentNullException("sourceCode"); } this.sourceCode = sourceCode; } private ILanguage Language { get { if (this.language == null) { throw new NoLanguageProvidedException(); } return this.language; } set { this.language = value; } } private ITheme Theme { get { if (this.theme == null) { throw new NoThemeProvidedException(); } VerifyTheme(this.theme); return this.theme; } set { this.theme = value; } } public ICodeColorizer WithLanguage(ILanguage language) { this.Language = language; return this; } public ICodeColorizer WithTheme(ITheme theme) { this.Theme = theme; return this; } public string ToHtml() { var htmlParser = new HtmlParser( this.PreProcessLanguage(), this.Theme); return htmlParser.Parse(); } private IPreProcessedLanguage PreProcessLanguage() { return new PreProcessedLanguage(this.sourceCode, this.Language); } private static void VerifyTheme(ITheme theme) { var allTokens = GetValues<TokenScope>(); foreach (var style in allTokens.Select(theme.GetStyle)) { if (style == null) { throw new NoThemeStyleProvidedException(); } if (style.HexColor == null) { throw new NoStyleColorProvidedException(); } } if (theme.BaseHexColor == null || theme.BackgroundHexColor == null) { throw new NoStyleColorProvidedException(); } } public static IEnumerable<T> GetValues<T>() { if (!typeof(T).IsEnum) throw new InvalidOperationException("Type must be enumeration type."); return GetValuesImplicit<T>(); } private static IEnumerable<T> GetValuesImplicit<T>() { return from field in typeof(T).GetFields() where field.IsLiteral && !string.IsNullOrEmpty(field.Name) select (T)field.GetValue(null); } }
I metoda statyczna umożliwiająca stworzenie instnacji CodeColorizer:
public static class Colorizer { public static ICodeColorizer Colorize(string sourceCode) { return new CodeColorizer(sourceCode); } }
Przykład użycia:
const string SampleCode = "public void Main()\n{\n\tvar a = new MyObject();\n}"; var language = new Csharp(); var theme = new OblivionTheme(); html = Colorizer.Colorize(SampleCode) .WithLanguage(language) .WithTheme(theme).ToHtml();
Testy jednostkowe:
Soolucja na codeplex.com