using System.Buffers; namespace ParadoxSaveParser.Lib; internal enum TokenType : byte { // default value, so a slot that was never scanned is recognizably empty Invalid, // any bare or quoted value: the tokenizer does not tell numbers from strings StringOrNumber, Equals, BracketOpen, BracketClose, EndOfFile } /// /// Splits a Paradox text save into tokens, reading the stream through a reusable buffer. /// Token text is exposed as a span into that buffer, so a token costs no allocation at all /// unless it happens to straddle a buffer refill. /// /// The parser can tell the tokenizer to throw away a whole {...} block with /// . Blocks rejected by the search expression are then never /// turned into tokens or strings, which is what makes a query cheaper than a full parse. /// /// internal sealed class Tokenizer { /// Bytes that end an unquoted token. private static readonly SearchValues TokenEnd = SearchValues.Create(" \t\r\n={}\""u8); private static readonly SearchValues Whitespace = SearchValues.Create(" \t\r\n"u8); /// Everything has to look at while counting depth. private static readonly SearchValues BlockChars = SearchValues.Create("{}\""u8); /// One scanned token. Reused forever, so scanning a token allocates nothing. private sealed class Slot { public TokenType Type; // position of the token's first byte in the file, for error messages public int Line; public short Column; // text is either a range of the shared buffer, or, once a refill would overwrite it, // a copy in Scratch public int Start; public int Length; // grown on demand and kept between tokens, so long tokens stop reallocating after a while public byte[] Scratch = []; public bool InScratch; } private readonly Stream _stream; private readonly byte[] _buffer; // current token plus up to two lookahead tokens private readonly Slot[] _slots = [new Slot(), new Slot(), new Slot()]; // index of the current token; the slots are used as a ring, so the array is never shifted private int _current; // how many slots after _current already hold a scanned, not yet consumed token private int _lookahead; // read position and amount of valid data in _buffer private int _pos; private int _length; // set once the stream has no more bytes; stops Fill() from calling Read() again private bool _eof; // position of _pos in the file; _column is 0-based here and reported 1-based private int _line = 1; private int _column; public Tokenizer(Stream stream, int bufferSize) { if (bufferSize < 16) throw new ArgumentOutOfRangeException(nameof(bufferSize), bufferSize, "buffer is too small"); _stream = stream; _buffer = new byte[bufferSize]; } public TokenType Type => _slots[_current].Type; public int Line => _slots[_current].Line; public short Column => _slots[_current].Column; /// /// Bytes of the current token, without quotes. Valid only until the next /// , because the buffer underneath it gets reused. /// public ReadOnlySpan Text { get { var slot = _slots[_current]; // a token that survived a refill was copied out; everything else still points into the buffer return slot.InScratch ? slot.Scratch.AsSpan(0, slot.Length) : _buffer.AsSpan(slot.Start, slot.Length); } } /// Consumes the file's magic header and checks it, before any token is scanned. public void ReadHeader(ReadOnlySpan expected, Encoding encoding) { // read straight from the stream: this runs before the buffer holds anything Span head = stackalloc byte[expected.Length]; _stream.ReadExactly(head); if (!head.SequenceEqual(expected)) throw new Exception($"Invalid gamestate header. " + $"Expected '{encoding.GetString(expected)}', got '{encoding.GetString(head)}'."); } /// false at end of file public bool Read() { _current = NextSlot(_current); // the next slot may already be filled by an earlier PeekType, then there is nothing to scan if (_lookahead > 0) _lookahead--; else Scan(_slots[_current]); return _slots[_current].Type != TokenType.EndOfFile; } /// /// Type of a token that has not been consumed yet, 1 or 2 tokens ahead of the current one. /// Only types are available: the parser never needs the text of a token it has not reached. /// public TokenType PeekType(int offset) { // scan only as far as asked, so lookahead never runs into a block SkipBlock is about to drop while (_lookahead < offset) { // first slot after the ones that are already filled int slot = _current; for (int i = 0; i <= _lookahead; i++) slot = NextSlot(slot); Scan(_slots[slot]); _lookahead++; } int index = _current; for (int i = 0; i < offset; i++) index = NextSlot(index); return _slots[index].Type; } /// /// Throws away the block opened by the current { token without tokenizing it: /// raw bytes are scanned for braces until the depth returns to zero. /// Braces inside quoted strings are text and do not change the depth. /// Leaves the closing } as the current token. /// public void SkipBlock() { int depth = 1; // tokens that lookahead already pulled out of the buffer still count towards the depth while (depth > 0 && _lookahead > 0) { _current = NextSlot(_current); _lookahead--; switch (_slots[_current].Type) { case TokenType.BracketOpen: depth++; break; case TokenType.BracketClose: depth--; break; case TokenType.EndOfFile: return; } } bool inQuotes = false; while (depth > 0) { if (_pos >= _length && !Fill()) break; // unbalanced braces: the file ended inside the block var span = _buffer.AsSpan(_pos, _length - _pos); // inside a string only the closing quote matters, braces there are ordinary characters int i = inQuotes ? span.IndexOf((byte)'\"') : span.IndexOfAny(BlockChars); if (i < 0) { // nothing interesting in this bufferful, drop all of it and refill Consume(span.Length); continue; } byte b = span[i]; Consume(i + 1); if (b == (byte)'\"') inQuotes = !inQuotes; else if (b == (byte)'{') depth++; else depth--; } // hand the parser the closing brace it expects, without having tokenized anything inside var current = _slots[_current]; current.Type = depth == 0 ? TokenType.BracketClose : TokenType.EndOfFile; current.Length = 0; current.InScratch = false; current.Line = _line; current.Column = (short)_column; } public string Describe(Encoding encoding) => Type switch { TokenType.StringOrNumber => Text.Length == 0 ? "NULL" : encoding.GetString(Text), TokenType.Equals => "=", TokenType.BracketOpen => "{", TokenType.BracketClose => "}", TokenType.EndOfFile => "END_OF_FILE", _ => "INVALID_TOKEN", }; /// Slot indices wrap around: the three slots form a ring buffer. private int NextSlot(int i) => i + 1 == _slots.Length ? 0 : i + 1; /// Reads the next token from the stream into . private void Scan(Slot slot) { // loops only to skip comments, which produce no token while (true) { if (!SkipWhitespace()) { slot.Type = TokenType.EndOfFile; slot.Length = 0; slot.InScratch = false; slot.Line = _line; slot.Column = (short)_column; return; } slot.Line = _line; slot.Column = (short)(_column + 1); // columns are reported 1-based // SkipWhitespace guarantees at least one buffered byte here byte b = _buffer[_pos]; switch (b) { case (byte)'=': ConsumeFlat(1); SetDelimiter(slot, TokenType.Equals); return; case (byte)'{': ConsumeFlat(1); SetDelimiter(slot, TokenType.BracketOpen); return; case (byte)'}': ConsumeFlat(1); SetDelimiter(slot, TokenType.BracketClose); return; case (byte)'\"': ReadQuoted(slot); return; default: ReadBare(slot); // comments are dropped, same as before: a token starting with '#' is not emitted if (slot.Length > 0 && FirstByte(slot) == (byte)'#') continue; return; } } } /// Single-character tokens carry no text of their own. private static void SetDelimiter(Slot slot, TokenType type) { slot.Type = type; slot.Length = 0; slot.InScratch = false; } /// Reads an unquoted token, which ends at the first delimiter byte. private void ReadBare(Slot slot) { StartText(slot); while (true) { var span = _buffer.AsSpan(_pos, _length - _pos); // one vectorized search replaces a loop over the token's bytes int end = span.IndexOfAny(TokenEnd); if (end >= 0) { // the delimiter itself is left for the next Scan to classify AppendText(slot, span[..end]); ConsumeFlat(end); return; } // token runs to the end of the buffer and continues in the next one AppendText(slot, span); ConsumeFlat(span.Length); if (!Fill()) return; } } /// /// Reads a quoted token. Everything up to the next " is text, braces included; /// the format has no escape sequences, so a quote always closes the string. /// private void ReadQuoted(Slot slot) { ConsumeFlat(1); // opening quote StartText(slot); while (true) { if (_pos >= _length && !Fill()) return; // unterminated string at end of file var span = _buffer.AsSpan(_pos, _length - _pos); int end = span.IndexOf((byte)'\"'); if (end >= 0) { AppendText(slot, span[..end]); Consume(end + 1); // content plus the closing quote return; } // Consume, not ConsumeFlat: a quoted value is allowed to span several lines AppendText(slot, span); Consume(span.Length); } } /// Begins a text token at the current read position. private void StartText(Slot slot) { slot.Type = TokenType.StringOrNumber; slot.Start = _pos; slot.Length = 0; slot.InScratch = false; } /// Extends a text token by one chunk of bytes taken from the buffer. private void AppendText(Slot slot, ReadOnlySpan chunk) { if (!slot.InScratch) { // chunks always continue where the previous one ended, so the range just grows // still contiguous in the buffer, nothing to copy if (slot.Length == 0) slot.Start = _pos; slot.Length += chunk.Length; return; } // the start of this token is already out of the buffer, so the rest has to follow it EnsureScratch(slot, slot.Length + chunk.Length); chunk.CopyTo(slot.Scratch.AsSpan(slot.Length)); slot.Length += chunk.Length; } /// First byte of a token, wherever its text currently lives. Used to spot comments. private byte FirstByte(Slot slot) => slot.InScratch ? slot.Scratch[0] : _buffer[slot.Start]; private static void EnsureScratch(Slot slot, int size) { if (slot.Scratch.Length >= size) return; // doubling keeps a token that is appended chunk by chunk from resizing on every chunk int capacity = Math.Max(size, slot.Scratch.Length * 2); Array.Resize(ref slot.Scratch, capacity); } /// Moves the read position onto the next non-whitespace byte. /// false at end of file private bool SkipWhitespace() { while (true) { if (_pos >= _length && !Fill()) return false; var span = _buffer.AsSpan(_pos, _length - _pos); int i = span.IndexOfAnyExcept(Whitespace); if (i < 0) { // whitespace to the end of the buffer, keep going in the next one Consume(span.Length); continue; } if (i > 0) Consume(i); return true; } } /// Refills the buffer from the stream. /// false if the stream is exhausted private bool Fill() { if (_eof) return false; // text of live tokens lives in the buffer, so it has to be copied out before overwriting MaterializeSlots(); // a short read is fine, the next Fill picks up the rest _length = _stream.Read(_buffer, 0, _buffer.Length); _pos = 0; if (_length > 0) return true; _eof = true; _length = 0; return false; } /// /// Copies every token that still points into the buffer out to its own scratch array. /// Called just before a refill, which is the only moment that text can be lost. /// private void MaterializeSlots() { foreach (var slot in _slots) { // delimiters and empty tokens own no text, and a copied one needs no second copy if (slot.InScratch || slot.Length == 0 || slot.Type != TokenType.StringOrNumber) continue; EnsureScratch(slot, slot.Length); _buffer.AsSpan(slot.Start, slot.Length).CopyTo(slot.Scratch); slot.InScratch = true; } } /// Consumes bytes that cannot contain a line break. private void ConsumeFlat(int count) { _pos += count; _column += count; } /// Consumes bytes that may contain line breaks, keeping line and column exact. private void Consume(int count) { var slice = _buffer.AsSpan(_pos, count); // the last break decides the column, and only then is counting all of them worth it int lastBreak = slice.LastIndexOf((byte)'\n'); if (lastBreak < 0) { _column += count; } else { _line += slice.Count((byte)'\n'); // bytes left after the final break _column = count - lastBreak - 1; } _pos += count; } }