using System.Buffers; using System.Collections.Frozen; namespace AIStudio.Tools.CodeHighlighting; /// /// Finds the parts of a code block which get a color, following the rules of its language. /// /// /// The chat highlights a code block anew with every chunk of a streamed answer, and AI Studio runs on /// small computers such as a Raspberry Pi, too. Hence, the tokenizer reads the code once from its start /// to its end, without regular expressions, and looks words up without a string for each of them. It /// never fails: what the rules do not know stays plain, and a string or comment which is still open, /// because the model has not written its end yet, runs to the end of its line or of the code. /// public sealed class CodeTokenizer { /// /// The characters which make a variable of their own right after its prefix, such as $? in a shell. /// private const string SPECIAL_VARIABLE_NAMES = "?#@*!$-"; private static readonly SearchValues HEX_DIGITS = SearchValues.Create("0123456789abcdefABCDEF"); private readonly string code; private readonly CodeLanguageRules rules; private readonly List tokens = []; private readonly FrozenSet.AlternateLookup> keywords; private readonly FrozenSet.AlternateLookup> types; private readonly FrozenSet.AlternateLookup> literals; private readonly FrozenSet.AlternateLookup> functionDeclarations; private readonly FrozenSet.AlternateLookup> typeDeclarations; private readonly FrozenSet.AlternateLookup> stringPrefixes; private readonly FrozenSet.AlternateLookup> hyphenOperators; private int position; /// /// What the next word names, after a keyword such as def or class. /// private CodeTokenKind? expectedName; /// /// How deep the blocks of a style sheet nest at the current position. /// private int blockDepth; private CodeTokenizer(string code, CodeLanguageRules rules) { this.code = code; this.rules = rules; this.keywords = rules.Keywords.GetAlternateLookup>(); this.types = rules.Types.GetAlternateLookup>(); this.literals = rules.Literals.GetAlternateLookup>(); this.functionDeclarations = rules.FunctionDeclarations.GetAlternateLookup>(); this.typeDeclarations = rules.TypeDeclarations.GetAlternateLookup>(); this.stringPrefixes = rules.StringPrefixes.GetAlternateLookup>(); this.hyphenOperators = rules.HyphenOperators.GetAlternateLookup>(); } /// /// Finds the parts of the code which get a color. /// /// The code of the block. /// The rules of its language. /// The parts in the order of the code, none of them overlapping another. The code between /// them stays plain. public static IReadOnlyList Tokenize(string code, CodeLanguageRules rules) { var tokenizer = new CodeTokenizer(code, rules); tokenizer.Run(); return tokenizer.tokens; } private void Run() { while (this.position < this.code.Length) { if (char.IsWhiteSpace(this.code[this.position])) { this.position++; continue; } if (this.TryReadComment() || this.TryReadWord()) continue; // // Anything else, such as a string or a parenthesis, ends what a keyword like def expected: // this.expectedName = null; if (this.TryReadPreprocessorDirective() || this.TryReadString() || this.TryReadQuotedName() || this.TryReadVariable() || this.TryReadAnnotation() || this.TryReadStyleSheetPart() || this.TryReadHyphenWord() || this.TryReadBracketType() || this.TryReadNumber()) continue; this.position++; } } private bool TryReadComment() { if (this.rules.HasLongBrackets && this.StartsWithAt(this.position, "--") && this.TryFindLongBracketEnd(this.position + 2, out var longEnd)) { this.AddAndMove(this.position, longEnd, CodeTokenKind.COMMENT); return true; } foreach (var comment in this.rules.BlockComments) { if (!this.StartsWithAt(this.position, comment.Open)) continue; var close = this.code.IndexOf(comment.Close, this.position + comment.Open.Length, StringComparison.Ordinal); this.AddAndMove(this.position, close < 0 ? this.code.Length : close + comment.Close.Length, CodeTokenKind.COMMENT); return true; } foreach (var comment in this.rules.LineComments) { if (!this.StartsWithAt(this.position, comment)) continue; if (this.rules.LineCommentsNeedSeparator && this.position > 0 && !IsSeparator(this.code[this.position - 1])) continue; this.AddAndMove(this.position, this.LineEnd(this.position), CodeTokenKind.COMMENT); return true; } return false; } private bool TryReadWord() { var start = this.position; if (!this.IsWordStart(start)) return false; var end = this.WordEnd(start + 1); var word = this.code.AsSpan(start, end - start); // // A prefix, such as the f of an f-string, belongs to the string right behind it: // if (this.stringPrefixes.Contains(word) && this.TryReadStringAt(end, start)) { this.expectedName = null; return true; } var expected = this.expectedName; this.expectedName = null; if (this.keywords.Contains(word)) { if (this.functionDeclarations.Contains(word)) this.expectedName = CodeTokenKind.FUNCTION; else if (this.typeDeclarations.Contains(word)) this.expectedName = CodeTokenKind.TYPE; this.AddAndMove(start, end, CodeTokenKind.KEYWORD); return true; } if (this.ClassifyName(word, end, expected) is { } kind) this.AddAndMove(start, end, kind); else this.position = end; return true; } private CodeTokenKind? ClassifyName(ReadOnlySpan word, int end, CodeTokenKind? expected) { if (this.literals.Contains(word)) return CodeTokenKind.LITERAL; // // In function M.greet, the function is greet, and M is the table or object it belongs to. The // parenthesis after greet makes it a function then: // if (expected is CodeTokenKind.FUNCTION && end < this.code.Length && this.code[end] is '.' or ':') return null; if (expected is not null) return expected; if (this.types.Contains(word)) return CodeTokenKind.TYPE; if (this.rules.IsStyleSheet && this.blockDepth > 0 && this.IsPropertyName(end)) return CodeTokenKind.ATTRIBUTE; if (this.rules.HighlightFunctionCalls && end < this.code.Length && this.code[end] == '(') return CodeTokenKind.FUNCTION; if (this.rules.HighlightCommandNames && IsCommandName(word)) return CodeTokenKind.FUNCTION; if (this.rules.HighlightPascalCaseTypes && IsPascalCase(word)) return CodeTokenKind.TYPE; return null; } private bool TryReadPreprocessorDirective() { if (!this.rules.HasPreprocessor || this.code[this.position] != '#' || !this.IsAtLineStart(this.position)) return false; this.AddAndMove(this.position, this.LineEnd(this.position), CodeTokenKind.META); return true; } private bool TryReadString() { if (this.TryReadStringAt(this.position, this.position)) return true; if (!this.rules.HasLongBrackets || !this.TryFindLongBracketEnd(this.position, out var end)) return false; this.AddAndMove(this.position, end, CodeTokenKind.STRING); return true; } /// /// Reads a string which opens at a position, perhaps behind a prefix. /// /// Where the string would open. /// Where its part starts: at its prefix, when it has one. /// Whether a string opens there. private bool TryReadStringAt(int openAt, int tokenStart) { foreach (var rule in this.rules.Strings) { if (!this.StartsWithAt(openAt, rule.Open)) continue; this.AddAndMove(tokenStart, this.FindStringEnd(openAt + rule.Open.Length, rule), CodeTokenKind.STRING); return true; } return false; } private bool TryReadQuotedName() { foreach (var rule in this.rules.QuotedNames) { if (!this.StartsWithAt(this.position, rule.Open)) continue; this.position = this.FindStringEnd(this.position + rule.Open.Length, rule); return true; } return false; } private int FindStringEnd(int from, CodeStringRule rule) { var index = from; while (index < this.code.Length) { var character = this.code[index]; if (character == rule.Escape) { index += 2; continue; } if (!rule.IsMultiline && character is '\n') return this.LineEnd(index); if (this.StartsWithAt(index, rule.Close)) { if (rule.DoubledCloseIsLiteral && this.StartsWithAt(index + rule.Close.Length, rule.Close)) { index += 2 * rule.Close.Length; continue; } return index + rule.Close.Length; } index++; } return this.code.Length; } /// /// Finds the end of a long bracket of Lua, such as [[ … ]] or [==[ … ]==], which opens at a position. /// /// Where the bracket would open. /// Where it ends, which is the end of the code when it was never closed. /// Whether a long bracket opens there. private bool TryFindLongBracketEnd(int openAt, out int end) { end = openAt; if (openAt >= this.code.Length || this.code[openAt] != '[') return false; var level = 0; while (openAt + 1 + level < this.code.Length && this.code[openAt + 1 + level] == '=') level++; var contentStart = openAt + 2 + level; if (contentStart > this.code.Length || this.code[contentStart - 1] != '[') return false; var close = $"]{new string('=', level)}]"; var closeAt = this.code.IndexOf(close, contentStart, StringComparison.Ordinal); end = closeAt < 0 ? this.code.Length : closeAt + close.Length; return true; } private bool TryReadVariable() { if (this.rules.VariablePrefix is not { } prefix || this.code[this.position] != prefix || this.position + 1 >= this.code.Length) return false; var next = this.code[this.position + 1]; int end; if (next is '{') { var close = this.code.IndexOf('}', this.position + 2); var lineEnd = this.LineEnd(this.position); end = close < 0 || close > lineEnd ? lineEnd : close + 1; } else if (char.IsLetter(next) || next is '_') { end = this.position + 2; while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '_' || this.rules.VariableNameCharacters.Contains(this.code[end]))) end++; } else if (char.IsAsciiDigit(next) || SPECIAL_VARIABLE_NAMES.Contains(next)) end = this.position + 2; else return false; this.AddAndMove(this.position, end, CodeTokenKind.VARIABLE); return true; } private bool TryReadAnnotation() { if (this.rules.AnnotationPrefix is not { } prefix || this.code[this.position] != prefix || this.position + 1 >= this.code.Length) return false; var next = this.code[this.position + 1]; if (!char.IsLetter(next) && next is not '_') return false; var end = this.position + 2; while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '_' or '.')) end++; this.AddAndMove(this.position, end, CodeTokenKind.META); return true; } private bool TryReadStyleSheetPart() { if (!this.rules.IsStyleSheet) return false; switch (this.code[this.position]) { case '{': this.blockDepth++; this.position++; return true; case '}': this.blockDepth = Math.Max(0, this.blockDepth - 1); this.position++; return true; case '.' when this.IsWordStart(this.position + 1): this.AddAndMove(this.position, this.WordEnd(this.position + 1), CodeTokenKind.SELECTOR); return true; case '@' when this.IsWordStart(this.position + 1): this.AddAndMove(this.position, this.WordEnd(this.position + 1), CodeTokenKind.KEYWORD); return true; case '!' when this.StartsWithAt(this.position + 1, "important"): this.AddAndMove(this.position, this.position + 1 + "important".Length, CodeTokenKind.KEYWORD); return true; // // Within a block, #fff is a color. Before it, #main is the ID a selector names: // case '#' when this.position + 1 < this.code.Length && this.IsWordCharacter(this.code[this.position + 1]): var end = this.WordEnd(this.position + 1); var isColor = this.blockDepth > 0 && IsHexColor(this.code.AsSpan(this.position + 1, end - this.position - 1)); this.AddAndMove(this.position, end, isColor ? CodeTokenKind.NUMBER : CodeTokenKind.SELECTOR); return true; default: return false; } } /// /// Reads a word after a hyphen, such as -eq or -Path in PowerShell. An operator gets a color, the /// name of a parameter stays plain. /// private bool TryReadHyphenWord() { if (this.rules.HyphenOperators.Count is 0 || this.code[this.position] != '-' || this.position + 1 >= this.code.Length || !char.IsLetter(this.code[this.position + 1])) return false; if (this.position > 0 && this.IsWordCharacter(this.code[this.position - 1])) return false; var end = this.position + 2; while (end < this.code.Length && char.IsLetterOrDigit(this.code[end])) end++; if (this.hyphenOperators.Contains(this.code.AsSpan(this.position + 1, end - this.position - 1))) this.AddAndMove(this.position, end, CodeTokenKind.KEYWORD); else this.position = end; return true; } /// /// Reads a type in square brackets, such as [string] or [int[]] in PowerShell. Brackets right after /// a name or a closing bracket index into it instead, as in $list[0]. /// private bool TryReadBracketType() { if (!this.rules.HasBracketTypes || this.code[this.position] != '[' || this.position + 1 >= this.code.Length || !char.IsLetter(this.code[this.position + 1])) return false; if (this.position > 0 && (this.IsWordCharacter(this.code[this.position - 1]) || this.code[this.position - 1] is ']' or ')')) return false; var end = this.position + 2; while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '.' or '_')) end++; if (this.StartsWithAt(end, "[]")) end += 2; if (end >= this.code.Length || this.code[end] != ']') return false; this.tokens.Add(new(this.position + 1, end - this.position - 1, CodeTokenKind.TYPE)); this.position = end + 1; return true; } private bool TryReadNumber() { var start = this.position; var character = this.code[start]; var startsWithPoint = character is '.' && start + 1 < this.code.Length && char.IsAsciiDigit(this.code[start + 1]) && (start is 0 || this.code[start - 1] != '.'); if (!char.IsAsciiDigit(character) && !startsWithPoint) return false; int end; if (startsWithPoint) end = this.DigitsEnd(start + 1); else if (character is '0' && start + 1 < this.code.Length && this.code[start + 1] is 'x' or 'X' or 'b' or 'B' or 'o' or 'O') { end = start + 2; while (end < this.code.Length && (char.IsAsciiHexDigit(this.code[end]) || this.code[end] is '_')) end++; } else { end = this.DigitsEnd(start); if (end + 1 < this.code.Length && this.code[end] is '.' && char.IsAsciiDigit(this.code[end + 1])) end = this.DigitsEnd(end + 1); } if (end < this.code.Length && this.code[end] is 'e' or 'E') { var exponent = end + 1; if (exponent < this.code.Length && this.code[exponent] is '+' or '-') exponent++; if (exponent < this.code.Length && char.IsAsciiDigit(this.code[exponent])) end = this.DigitsEnd(exponent); } // // A suffix, such as the f of 1.5f, the UL of 10UL, or the px of 10px, belongs to the number: // while (end < this.code.Length && char.IsAsciiLetterOrDigit(this.code[end])) end++; if (this.rules.IsStyleSheet && end < this.code.Length && this.code[end] is '%') end++; this.AddAndMove(start, end, CodeTokenKind.NUMBER); return true; } /// /// Whether a word inside a block of a style sheet names a property: a colon follows it, and its /// line goes on to a semicolon or to the end of the block, not to a block of its own, as the /// selector a:hover { does. /// private bool IsPropertyName(int end) { var index = end; while (index < this.code.Length && this.code[index] is ' ' or '\t') index++; if (index >= this.code.Length || this.code[index] != ':') return false; var lineEnd = this.LineEnd(index); for (var next = index + 1; next < lineEnd; next++) { switch (this.code[next]) { case '{': return false; case ';' or '}': return true; } } return true; } private bool IsWordStart(int index) { if (index >= this.code.Length) return false; var character = this.code[index]; if (char.IsLetter(character) || character is '_') return true; if (!this.rules.WordStartCharacters.Contains(character)) return false; // // A hyphen alone is a minus sign, as in 10px - 2px or -2px: // if (character is not '-') return true; return index + 1 < this.code.Length && (char.IsLetter(this.code[index + 1]) || this.code[index + 1] is '_' or '-'); } private bool IsWordCharacter(char character) => char.IsLetterOrDigit(character) || character is '_' || this.rules.WordCharacters.Contains(character); private int WordEnd(int from) { var end = from; while (end < this.code.Length && this.IsWordCharacter(this.code[end])) end++; return end; } private int DigitsEnd(int from) { var end = from; while (end < this.code.Length && (char.IsAsciiDigit(this.code[end]) || this.code[end] is '_')) end++; return end; } /// /// Where the line of a position ends, before its line break. /// private int LineEnd(int from) { var lineBreak = this.code.IndexOf('\n', from); if (lineBreak < 0) return this.code.Length; return lineBreak > from && this.code[lineBreak - 1] is '\r' ? lineBreak - 1 : lineBreak; } private bool IsAtLineStart(int index) { for (var previous = index - 1; previous >= 0; previous--) { switch (this.code[previous]) { case '\n': return true; case not (' ' or '\t'): return false; } } return true; } private bool StartsWithAt(int index, string value) => index <= this.code.Length && this.code.AsSpan(index).StartsWith(value, StringComparison.Ordinal); private void AddAndMove(int start, int end, CodeTokenKind kind) { this.tokens.Add(new(start, end - start, kind)); this.position = end; } private static bool IsSeparator(char character) => char.IsWhiteSpace(character) || character is ';' or '(' or ')' or '{' or '}' or '|' or '&'; private static bool IsPascalCase(ReadOnlySpan word) => word.Length > 1 && char.IsUpper(word[0]) && word.ContainsAnyInRange('a', 'z'); /// /// Whether a word has the form Verb-Noun, as the commands of PowerShell do. /// private static bool IsCommandName(ReadOnlySpan word) { var hyphen = word.IndexOf('-'); return hyphen > 0 && hyphen < word.Length - 1 && char.IsLetter(word[hyphen - 1]) && char.IsLetter(word[hyphen + 1]); } private static bool IsHexColor(ReadOnlySpan value) => value.Length is 3 or 4 or 6 or 8 && !value.ContainsAnyExcept(HEX_DIGITS); }