using System.Globalization; using System.Text; using Lua; namespace AIStudio.Tools.PluginSystem; /// Reads one exported configuration assignment as data. No Lua state is created. public sealed class ConfigurationSnippetParser { private readonly string source; private int position; private ConfigurationSnippetParser(string source) => this.source = source; public static bool TryParse(string source, string expectedSection, out LuaTable table, out string issue) { table = new LuaTable(); issue = string.Empty; if (string.IsNullOrWhiteSpace(source) || source.Length > 1_000_000) { issue = "Paste one exported configuration snippet (up to 1 MB)."; return false; } try { var parser = new ConfigurationSnippetParser(source); parser.ExpectWord("CONFIG"); var section = parser.ReadBracketedString(); if (section != expectedSection) throw new FormatException($"This is a {section} snippet. Paste a {expectedSection} snippet here."); parser.Expect('['); parser.Expect('#'); parser.ExpectWord("CONFIG"); if (parser.ReadBracketedString() != expectedSection) throw new FormatException("The configuration section names do not match."); parser.Expect('+'); parser.Expect('1'); parser.Expect(']'); parser.Expect('='); table = parser.ReadTable(0); parser.SkipTrivia(); if (parser.position != source.Length) throw new FormatException("The snippet must contain exactly one table assignment and no executable code."); return true; } catch (FormatException exception) { issue = exception.Message; return false; } } private LuaTable ReadTable(int depth) { if (depth > 32) throw new FormatException("The snippet contains too many nested tables."); this.Expect('{'); var table = new LuaTable(); var arrayIndex = 1; var names = new HashSet(StringComparer.Ordinal); while (true) { this.SkipTrivia(); if (this.Take('}')) return table; if (this.Take('[')) { var key = this.ReadString(); this.Expect(']'); this.Expect('='); if (!names.Add(key)) throw new FormatException($"The field '{key}' occurs more than once."); table[key] = this.ReadValue(depth + 1); } else table[arrayIndex++] = this.ReadValue(depth + 1); this.SkipTrivia(); if (this.Take('}')) return table; if (!this.Take(',') && !this.Take(';')) throw new FormatException($"Expected a comma or closing brace at character {this.position + 1}."); } } private LuaValue ReadValue(int depth) { this.SkipTrivia(); if (this.Peek() == '{') return this.ReadTable(depth); if (this.Peek() is '"' or '\'' || this.Peek() == '[') return this.ReadString(); if (this.TakeWord("true")) return true; if (this.TakeWord("false")) return false; if (this.TakeWord("nil")) return LuaValue.Nil; var start = this.position; if (this.Peek() == '-') this.position++; while (char.IsAsciiDigit(this.Peek())) this.position++; if (this.Peek() == '.') { this.position++; while (char.IsAsciiDigit(this.Peek())) this.position++; } if (this.position > start && double.TryParse(this.source[start..this.position], NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number)) return number; throw new FormatException($"Only literal values are allowed at character {start + 1}; executable Lua is not accepted."); } private string ReadBracketedString() { this.Expect('['); var result = this.ReadString(); this.Expect(']'); return result; } private string ReadString() { this.SkipTrivia(); var quote = this.Peek(); if (quote == '[') { this.position++; var equalsStart = this.position; while (this.Peek() == '=') this.position++; var equals = this.source[equalsStart..this.position]; this.Expect('['); if (this.Peek() is '\r' or '\n') { if (this.Take('\r')) this.Take('\n'); else this.position++; } var end = this.source.IndexOf("]" + equals + "]", this.position, StringComparison.Ordinal); if (end < 0) throw new FormatException("Unterminated long string."); var value = this.source[this.position..end]; this.position = end + equals.Length + 2; return value; } if (quote is not ('"' or '\'')) throw new FormatException($"Expected a quoted string at character {this.position + 1}."); this.position++; var builder = new StringBuilder(); while (this.position < this.source.Length) { var c = this.source[this.position++]; if (c == quote) return builder.ToString(); if (c is '\r' or '\n') throw new FormatException("A quoted string contains an unescaped newline."); if (c != '\\') { builder.Append(c); continue; } if (this.position == this.source.Length) break; c = this.source[this.position++]; builder.Append(c switch { 'n' => '\n', 'r' => '\r', 't' => '\t', 'a' => '\a', 'b' => '\b', 'f' => '\f', 'v' => '\v', '\\' => '\\', '"' => '"', '\'' => '\'', _ => throw new FormatException($"Unsupported string escape \\{c}."), }); } throw new FormatException("Unterminated quoted string."); } private void SkipTrivia() { while (this.position < this.source.Length) { if (char.IsWhiteSpace(this.source[this.position])) { this.position++; continue; } if (this.source.AsSpan(this.position).StartsWith("--")) { this.position += 2; while (this.position < this.source.Length && this.source[this.position] != '\n') this.position++; continue; } break; } } private char Peek() => this.position < this.source.Length ? this.source[this.position] : '\0'; private bool Take(char c) { this.SkipTrivia(); if (this.Peek() != c) return false; this.position++; return true; } private void Expect(char c) { if (!this.Take(c)) throw new FormatException($"Expected '{c}' at character {this.position + 1}."); } private bool TakeWord(string word) { this.SkipTrivia(); if (!this.source.AsSpan(this.position).StartsWith(word) || (this.position + word.Length < this.source.Length && (char.IsLetterOrDigit(this.source[this.position + word.Length]) || this.source[this.position + word.Length] == '_'))) return false; this.position += word.Length; return true; } private void ExpectWord(string word) { if (!this.TakeWord(word)) throw new FormatException($"Expected '{word}' at character {this.position + 1}."); } }