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