using System.Text; using Nuuru.Server.Constants; namespace Nuuru.Server.Services.BBCode { public class BbTokenizer { private readonly string _input; private readonly Func _isValidEmote; private int _pos; private bool _atLineStart = true; // Track open delimited spans: delimiter string → tag name private readonly Dictionary _openDelimiters = new(); public BbTokenizer(string input, Func? isValidEmote = null) { // Normalize newlines _input = input.Replace("\r\n", "\n").Replace("\r", "\n"); _isValidEmote = isValidEmote ?? Emotes.IsBuiltIn; _pos = 0; } public List Tokenize() { var tokens = new List(); var textBuffer = new StringBuilder(); while (!AtEnd) { // Try to match special patterns BbToken? token = null; // Newline if (Current == '\n') { FlushText(tokens, textBuffer); tokens.Add(new NewlineToken()); Advance(); _atLineStart = true; continue; } // Check for closing delimiters before anything else var closedDelimiter = TryCloseDelimiter(); if (closedDelimiter != null) { FlushText(tokens, textBuffer); tokens.Add(closedDelimiter); _atLineStart = false; continue; } // BBCode tag if (Current == '[') { token = TryMatchTag(); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); // Content after [quote ...] starts a new conceptual line _atLineStart = token is OpenTagToken { Name: "quote" } or OpenTagWithAttributesToken { Name: "quote" }; continue; } } // Greentext: > at start of line if (_atLineStart && Current == '>') { token = TryMatchGreentext(); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // Orangetext: < at start of line if (_atLineStart && Current == '<') { token = TryMatchOrangetext(); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // ==redtext== if (Current == '=' && Peek(1) == '=') { token = TryOpenDelimited("==", "redtext"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // --bluetext-- if (Current == '-' && Peek(1) == '-') { token = TryOpenDelimited("--", "bluetext"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // %%glowtext%% if (Current == '%' && Peek(1) == '%') { token = TryOpenDelimited("%%", "glowtext"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // !!redglow!! if (Current == '!' && Peek(1) == '!') { token = TryOpenDelimited("!!", "redglow"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // ::yellowglow:: if (Current == ':' && Peek(1) == ':') { token = TryOpenDelimited("::", "yellowglow"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // :emote: shorthand (single colon, must check after double colon) if (Current == ':' && Peek(1) != ':') { token = TryMatchEmote(); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // ~-~rainbow~-~ if (Current == '~' && Peek(1) == '-' && Peek(2) == '~') { token = TryOpenDelimited("~-~", "rainbow"); if (token != null) { FlushText(tokens, textBuffer); tokens.Add(token); _atLineStart = false; continue; } } // Regular text character textBuffer.Append(Current); _atLineStart = false; Advance(); } FlushText(tokens, textBuffer); return tokens; } private bool AtEnd => _pos >= _input.Length; private char Current => AtEnd ? '\0' : _input[_pos]; private char Peek(int offset) { var index = _pos + offset; return index >= _input.Length ? '\0' : _input[index]; } private void Advance() => _pos++; private void FlushText(List tokens, StringBuilder buffer) { if (buffer.Length > 0) { tokens.Add(new TextToken(buffer.ToString())); buffer.Clear(); } } private BbToken? TryMatchTag() { // Must start with [ if (Current != '[') return null; var start = _pos; Advance(); // consume [ // Check for closing tag var isClosing = Current == '/'; if (isClosing) Advance(); // Special case: [*] list item tag if (!isClosing && Current == '*' && Peek(1) == ']') { Advance(); // consume * Advance(); // consume ] return new OpenTagToken("*", null); } // Read tag name (alphanumeric) var nameBuilder = new StringBuilder(); while (!AtEnd && char.IsLetterOrDigit(Current)) { nameBuilder.Append(Current); Advance(); } if (nameBuilder.Length == 0) { _pos = start; return null; } var tagName = nameBuilder.ToString().ToLowerInvariant(); // Check for multi-attribute syntax: [quote key=value key2=value2], [mention userguid=X], or [attachment width=X height=Y] if (!isClosing && (tagName == "quote" || tagName == "mention" || tagName == "attachment") && Current == ' ') { var attributes = TryParseMultiAttributes(); if (attributes != null && Current == ']') { Advance(); // consume ] return new OpenTagWithAttributesToken(tagName, attributes); } // Failed to parse multi-attributes, reset and try normal parsing _pos = start; Advance(); // skip [ while (!AtEnd && char.IsLetterOrDigit(Current)) Advance(); // skip tag name } // Read optional attribute (=value) string? attribute = null; if (!isClosing && Current == '=') { Advance(); // consume = var attrBuilder = new StringBuilder(); while (!AtEnd && Current != ']') { attrBuilder.Append(Current); Advance(); } attribute = attrBuilder.ToString(); } // Must end with ] if (Current != ']') { _pos = start; return null; } Advance(); // consume ] return isClosing ? new CloseTagToken(tagName) : new OpenTagToken(tagName, attribute); } private Dictionary? TryParseMultiAttributes() { var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); while (!AtEnd && Current != ']') { // Skip whitespace while (!AtEnd && Current == ' ') Advance(); if (Current == ']') break; // Read key (alphanumeric) var keyBuilder = new StringBuilder(); while (!AtEnd && char.IsLetterOrDigit(Current)) { keyBuilder.Append(Current); Advance(); } if (keyBuilder.Length == 0 || Current != '=') { return null; // Malformed } Advance(); // consume = // Read value (until space or ]) var valueBuilder = new StringBuilder(); while (!AtEnd && Current != ' ' && Current != ']') { valueBuilder.Append(Current); Advance(); } attributes[keyBuilder.ToString()] = valueBuilder.ToString(); } return attributes.Count > 0 ? attributes : null; } private BbToken? TryMatchGreentext() { // > at start of line — emit marker only, let main loop tokenize the rest if (Current != '>') return null; Advance(); // consume > return new GreentextToken(""); } private BbToken? TryMatchOrangetext() { // < at start of line — emit marker only, let main loop tokenize the rest if (Current != '<') return null; Advance(); // consume < return new OrangetextToken(""); } private CloseTagToken? TryCloseDelimiter() { // Check if any open delimiter matches at the current position foreach (var (delimiter, tagName) in _openDelimiters) { if (MatchesAt(delimiter, _pos)) { _pos += delimiter.Length; _openDelimiters.Remove(delimiter); return new CloseTagToken(tagName); } } return null; } private BbToken? TryOpenDelimited(string delimiter, string tagName) { // Don't open if this delimiter is already open if (_openDelimiters.ContainsKey(delimiter)) return null; // Verify opening delimiter matches if (!MatchesAt(delimiter, _pos)) return null; // Lookahead: verify closing delimiter exists on the same line with content between var searchPos = _pos + delimiter.Length; var hasContent = false; while (searchPos < _input.Length && _input[searchPos] != '\n') { if (MatchesAt(delimiter, searchPos) && hasContent) { // Valid span — consume opening delimiter only _pos += delimiter.Length; _openDelimiters[delimiter] = tagName; return new OpenTagToken(tagName, null); } hasContent = true; searchPos++; } return null; } private bool MatchesAt(string pattern, int pos) { for (int i = 0; i < pattern.Length; i++) { if (pos + i >= _input.Length || _input[pos + i] != pattern[i]) return false; } return true; } private BbToken? TryMatchEmote() { // :emotename: pattern if (Current != ':') return null; var start = _pos; Advance(); // consume opening : // Read emote name (alphanumeric only) var nameBuilder = new StringBuilder(); while (!AtEnd && char.IsLetterOrDigit(Current)) { nameBuilder.Append(Current); Advance(); } // Must have a name and end with : if (nameBuilder.Length == 0 || Current != ':') { _pos = start; return null; } var emoteName = nameBuilder.ToString(); // Must be a valid emote if (!_isValidEmote(emoteName)) { _pos = start; return null; } Advance(); // consume closing : return new EmoteToken(emoteName.ToLowerInvariant()); } } }