From 5b1b8f4ce5b7522e1a15cbea7360bab3d337ce15 Mon Sep 17 00:00:00 2001 From: Timerix Date: Tue, 15 Sep 2026 13:18:09 +0200 Subject: [PATCH] refactored SearchExpression --- ParadoxSaveParser.Benchmarks/BenchData.cs | 5 +- .../ExtractionBenchmarks.cs | 4 +- ParadoxSaveParser.Benchmarks/Extractors.cs | 4 +- ParadoxSaveParser.Benchmarks/Mispairing.cs | 2 +- .../ParserInputBenchmarks.cs | 4 +- ParadoxSaveParser.Benchmarks/Program.cs | 2 +- ParadoxSaveParser.CLI/Modes/Search.cs | 2 +- .../SearchExpressionTests.cs | 96 ++++++++- ParadoxSaveParser.Lib/ParadoxEncodings.cs | 19 ++ .../ParadoxSaveParser.Lib.csproj.DotSettings | 2 + ParadoxSaveParser.Lib/SaveParserEU4.cs | 25 ++- ParadoxSaveParser.Lib/SearchExpression.cs | 188 ----------------- .../SearchExpression/AnyMatchExpression.cs | 13 ++ .../SearchExpression/ExactMatchExpression.cs | 37 ++++ .../SearchExpression/ISearchExpression.cs | 50 +++++ .../SearchExpression/IndexMatchExpression.cs | 19 ++ .../MultipleMatchExpression.cs | 22 ++ .../SearchExpression/NoMatchExpression.cs | 23 ++ .../SearchExpressionCompilation.cs | 198 ++++++++++++++++++ .../SaveDataFilters/ISaveDataFilter.cs | 6 +- .../SaveDataFilters/SaveFilterEU4.cs | 4 +- 21 files changed, 509 insertions(+), 216 deletions(-) create mode 100644 ParadoxSaveParser.Lib/ParadoxEncodings.cs create mode 100644 ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj.DotSettings delete mode 100644 ParadoxSaveParser.Lib/SearchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/AnyMatchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/ExactMatchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/ISearchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/IndexMatchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/MultipleMatchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/NoMatchExpression.cs create mode 100644 ParadoxSaveParser.Lib/SearchExpression/SearchExpressionCompilation.cs diff --git a/ParadoxSaveParser.Benchmarks/BenchData.cs b/ParadoxSaveParser.Benchmarks/BenchData.cs index 7eac775..15fa659 100644 --- a/ParadoxSaveParser.Benchmarks/BenchData.cs +++ b/ParadoxSaveParser.Benchmarks/BenchData.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -35,10 +34,8 @@ public static class BenchData /// /// The save decoded as text for the regex engines. - /// Latin1 is used because EU4 saves contain non-UTF8 bytes inside dynasty names, - /// and it maps every byte to exactly one char. /// - public static string PdxText => _pdxText ??= Encoding.Latin1.GetString(PdxBytes); + public static string PdxText => _pdxText ??= SaveParserEU4.DefaultEncoding.GetString(PdxBytes); private static string FindRepoRoot() { diff --git a/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs b/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs index b175b84..705d1b1 100644 --- a/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs +++ b/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs @@ -18,7 +18,7 @@ public class ExtractionBenchmarks private byte[] _pdx = null!; private string _pdxText = null!; private byte[] _json = null!; - private ISearchExpression _query = null!; + private SearchExpressionCompilation _query = null!; private Regex _technologyBlock = null!; private Regex _pathAwareCompiled = null!; @@ -32,7 +32,7 @@ public class ExtractionBenchmarks _pdx = BenchData.PdxBytes; _pdxText = BenchData.PdxText; _json = File.ReadAllBytes(BenchData.JsonPath); - _query = SearchExpressionCompiler.Compile(BenchData.PdxQuery); + _query = new SearchExpressionCompilation(BenchData.PdxQuery); _technologyBlock = new Regex(Extractors.TechnologyBlockPattern, RegexOptions.Compiled); _pathAwareCompiled = new Regex(Extractors.PathAwarePattern, RegexOptions.Compiled); diff --git a/ParadoxSaveParser.Benchmarks/Extractors.cs b/ParadoxSaveParser.Benchmarks/Extractors.cs index 25ee59d..73b2bdb 100644 --- a/ParadoxSaveParser.Benchmarks/Extractors.cs +++ b/ParadoxSaveParser.Benchmarks/Extractors.cs @@ -22,7 +22,7 @@ public static partial class Extractors // ---------------------------------------------------------------- this project /// The parser of this repo: single streaming pass, guided by a compiled search expression. - public static long SearchExpression(byte[] pdx, ISearchExpression query) + public static long SearchExpression(byte[] pdx, SearchExpressionCompilation query) { using var stream = new MemoryStream(pdx, false); var root = new SaveParserEU4(stream, query).Parse(); @@ -30,7 +30,7 @@ public static partial class Extractors } /// Same query, but the parser reads from the given stream instead of a preloaded byte[]. - public static long SearchExpressionFromStream(Stream stream, ISearchExpression query) + public static long SearchExpressionFromStream(Stream stream, SearchExpressionCompilation query) { var root = new SaveParserEU4(stream, query).Parse(); return ChecksumOfParsedTree(root); diff --git a/ParadoxSaveParser.Benchmarks/Mispairing.cs b/ParadoxSaveParser.Benchmarks/Mispairing.cs index 475ee47..fcdf8fb 100644 --- a/ParadoxSaveParser.Benchmarks/Mispairing.cs +++ b/ParadoxSaveParser.Benchmarks/Mispairing.cs @@ -18,7 +18,7 @@ public static class Mispairing { var result = new Dictionary(); using var stream = new MemoryStream(pdx, false); - var root = new SaveParserEU4(stream, SearchExpressionCompiler.Compile(BenchData.PdxQuery)).Parse(); + var root = new SaveParserEU4(stream, new SearchExpressionCompilation(BenchData.PdxQuery)).Parse(); foreach (var (tag, value) in (Dictionary)root["countries"]) { if (value is Dictionary c diff --git a/ParadoxSaveParser.Benchmarks/ParserInputBenchmarks.cs b/ParadoxSaveParser.Benchmarks/ParserInputBenchmarks.cs index 642a164..27e254d 100644 --- a/ParadoxSaveParser.Benchmarks/ParserInputBenchmarks.cs +++ b/ParadoxSaveParser.Benchmarks/ParserInputBenchmarks.cs @@ -15,10 +15,10 @@ namespace ParadoxSaveParser.Benchmarks; [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 1, iterationCount: 5)] public class ParserInputBenchmarks { - private ISearchExpression _query = null!; + private SearchExpressionCompilation _query = null!; [GlobalSetup] - public void Setup() => _query = SearchExpressionCompiler.Compile(BenchData.PdxQuery); + public void Setup() => _query = new SearchExpressionCompilation(BenchData.PdxQuery); [Benchmark(Description = "SearchExpression over FileStream")] public long FromFile() diff --git a/ParadoxSaveParser.Benchmarks/Program.cs b/ParadoxSaveParser.Benchmarks/Program.cs index 3f7f681..d0bb695 100644 --- a/ParadoxSaveParser.Benchmarks/Program.cs +++ b/ParadoxSaveParser.Benchmarks/Program.cs @@ -39,7 +39,7 @@ public static class Program var pdx = BenchData.PdxBytes; var text = BenchData.PdxText; var json = File.ReadAllBytes(BenchData.JsonPath); - var query = SearchExpressionCompiler.Compile(BenchData.PdxQuery); + var query = new SearchExpressionCompilation(BenchData.PdxQuery); Time("SearchExpression", () => Extractors.SearchExpression(pdx, query)); Time("FullParse", () => Extractors.FullParseThenSelect(pdx)); diff --git a/ParadoxSaveParser.CLI/Modes/Search.cs b/ParadoxSaveParser.CLI/Modes/Search.cs index b4c89cd..736b083 100644 --- a/ParadoxSaveParser.CLI/Modes/Search.cs +++ b/ParadoxSaveParser.CLI/Modes/Search.cs @@ -33,7 +33,7 @@ internal static partial class Modes ? Console.OpenStandardOutput() : File.OpenWrite(outputPath.Value); - var searchExpression = SearchExpressionCompiler.Compile(searchQuery); + var searchExpression = new SearchExpressionCompilation(searchQuery); var parser = new SaveParserEU4(inputStream, searchExpression); var parsedValue = parser.Parse(); diff --git a/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs b/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs index 10ce9b5..07578cd 100644 --- a/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs +++ b/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -66,7 +67,7 @@ public class SearchExpressionTests { byte[] data = "EU4txt a={ name=\"x{y}z\" b=1 }".ToBytes(); using var saveStream = new MemoryStream(data, false); - var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("a")); + var parser = new SaveParserEU4(saveStream, Compile("a")); var a = (Dictionary)parser.Parse()["a"]; Assert.Multiple(() => { @@ -80,7 +81,7 @@ public class SearchExpressionTests { byte[] data = "EU4txt a={ s=\"{{{\" } b=2".ToBytes(); using var saveStream = new MemoryStream(data, false); - var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("b")); + var parser = new SaveParserEU4(saveStream, Compile("b")); Assert.That(parser.Parse()["b"], Is.EqualTo(2L)); } @@ -89,15 +90,102 @@ public class SearchExpressionTests { byte[] data = Encoding.UTF8.GetBytes("EU4txt a={ name=\"Ä\" }"); using var saveStream = new MemoryStream(data, false); - var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("a"), Encoding.UTF8); + var parser = new SaveParserEU4(saveStream, Compile("a", Encoding.UTF8), Encoding.UTF8); var a = (Dictionary)parser.Parse()["a"]; Assert.That(a["name"], Is.EqualTo("Ä")); } + /// + /// A path written after a group applies to every alternative of that group. + /// + [TestCase("a.(b|zz).c", "a={ b={ c=0 } }")] + [TestCase("(a).b.(d|e)", "a={ b={ d=1 e=2 } }")] + [TestCase("a.(b|f).c", "a={ b={ c=0 } f=3 }")] + [TestCase("(a.(b|zz)|yy).(c|d)", "a={ b={ c=0 d=1 } }")] + public void PathAfterGroupContinuesEveryAlternative(string input, string expectedOutput) + { + Assert.That(Search(_smallSaveData, input), Is.EqualTo(expectedOutput)); + } + + /// + /// Groups with more alternatives than the other tests use: a key that matches nothing, + /// a repeated key, and a literal key standing before a "*". + /// + [TestCase("a.b.(c|d|e|zz)", "a={ b={ c=0 d=1 e=2 } }")] + [TestCase("a.b.(c|c|c|c)", "a={ b={ c=0 } }")] + [TestCase("a.(b|zz|yy|xx|*)", "a={ b={ c=0 d=1 e=2 } f=3 }")] + public void GroupWithManyAlternatives(string input, string expectedOutput) + { + Assert.That(Search(_smallSaveData, input), Is.EqualTo(expectedOutput)); + } + + /// + /// Bytes 0x80-0x9F are punctuation in Windows-1252 and control characters in Latin1, + /// and the parser strips control characters, so the two encodings are told apart here. + /// + [Test] + public void DefaultEncodingIsWindows1252() + { + // 93 and 94 are curly quotes, 96 is an en dash + byte[] data = [.. "EU4txt a={ name=\""u8, 0x93, 0x96, 0x94, .. "\" }"u8]; + using var saveStream = new MemoryStream(data, false); + var parser = new SaveParserEU4(saveStream, Compile("a")); + var a = (Dictionary)parser.Parse()["a"]; + Assert.That(a["name"], Is.EqualTo("“–”")); + } + + /// One compilation, two saves whose keys are written in different encodings. + [Test] + public void CompilationReEncodesKeysForAnotherEncoding() + { + var compilation = new SearchExpressionCompilation("ä"); + foreach (var encoding in new[] { SaveParserEU4.DefaultEncoding, Encoding.UTF8, SaveParserEU4.DefaultEncoding }) + { + byte[] data = encoding.GetBytes("EU4txt ä={ b=1 }"); + using var saveStream = new MemoryStream(data, false); + // the parser re-encodes the shared compilation to its own encoding + var parser = new SaveParserEU4(saveStream, compilation, encoding); + var found = (Dictionary)parser.Parse()["ä"]; + Assert.That(found["b"], Is.EqualTo(1L), encoding.EncodingName); + } + } + + [TestCase("a..b")] // empty step + [TestCase(".b")] // empty first step + [TestCase("a.")] // trailing point + [TestCase("(a||b)")] // empty alternative + [TestCase("(a|b")] // unclosed group + [TestCase("(a|b))")] // extra closing bracket + [TestCase("(a|b)c")] // group not followed by '.' + [TestCase("(a|b).")] // group followed by nothing + [TestCase("a.[1")] // index step without ']' + [TestCase("a.[x]")] // index that is not a number + [TestCase("a.[-1]")] // negative index + [TestCase("a.b*c")] // wildcard inside a key + public void MalformedQueryIsReported(string query) + { + Assert.Throws(() => Compile(query)); + } + + [Test] + public void EmptyQueryIsAnArgumentError() + { + // ReSharper disable once ObjectCreationAsStatement + Assert.Throws(() => new SearchExpressionCompilation("")); + } + + /// Compiles right away, so that a malformed query throws here and not inside a parser. + private static SearchExpressionCompilation Compile(string query, Encoding? encoding = null) + { + var compilation = new SearchExpressionCompilation(query); + compilation.Compile(encoding ?? SaveParserEU4.DefaultEncoding); + return compilation; + } + private static string Search(byte[] saveData, string query, int bufferSize = 64 * 1024) { using var saveStream = new MemoryStream(saveData, false); - var se = SearchExpressionCompiler.Compile(query); + var se = Compile(query); var parser = new SaveParserEU4(saveStream, se, bufferSize: bufferSize); var rootNode = parser.Parse(); string json = JsonSerializer.Serialize(rootNode, _smallSaveSerializerOptions); diff --git a/ParadoxSaveParser.Lib/ParadoxEncodings.cs b/ParadoxSaveParser.Lib/ParadoxEncodings.cs new file mode 100644 index 0000000..1083602 --- /dev/null +++ b/ParadoxSaveParser.Lib/ParadoxEncodings.cs @@ -0,0 +1,19 @@ +namespace ParadoxSaveParser.Lib; + +/// +/// Encodings used by Paradox save files. +/// Touching this class registers the code page provider, so no consumer has to do it. +/// +public static class ParadoxEncodings +{ + /// Encoding of EU4 save files. + public static Encoding Windows1252 { get; } + + static ParadoxEncodings() + { + // .NET ships only ASCII, Latin1 and the Unicode encodings; code pages come from this provider + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + // assigned here and not in an initializer, which would run before the provider is registered + Windows1252 = Encoding.GetEncoding(1252); + } +} diff --git a/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj.DotSettings b/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj.DotSettings new file mode 100644 index 0000000..d022f3a --- /dev/null +++ b/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SaveParserEU4.cs b/ParadoxSaveParser.Lib/SaveParserEU4.cs index f2dd1f0..5d431fc 100644 --- a/ParadoxSaveParser.Lib/SaveParserEU4.cs +++ b/ParadoxSaveParser.Lib/SaveParserEU4.cs @@ -14,13 +14,20 @@ public class SaveParserEU4 private const int DefaultBufferSize = 64 * 1024; private static ReadOnlySpan Header => "EU4txt"u8; + /// + /// Windows-1252 is the default encoding of the save files + /// + public static Encoding DefaultEncoding => ParadoxEncodings.Windows1252; + private readonly Tokenizer _tokens; + private readonly SearchExpressionCompilation? _query; + + // step of the query the parser is currently at, compiled by Parse() private ISearchExpression? _searchExprCurrent; /// /// Encoding of the strings inside the save. Saves of different localizations use - /// different ones, so it can be changed; maps - /// every byte to one character and never fails, which makes it a safe default. + /// different ones, so it can be changed; is what EU4 writes. /// public Encoding Encoding { get; } @@ -28,21 +35,23 @@ public class SaveParserEU4 /// Uncompressed stream of gamestate file which can be extracted from save archive /// /// - /// Parsing whole save takes 10 seconds on mid pc and takes 1GB of RAM, + /// Parsing whole save may take a few seconds on mid pc and takes 1GB of RAM, /// so you should specify what exactly you want to get from save file /// - /// Encoding of the strings inside the save. Latin1 by default. + /// Encoding of the strings inside the save. /// Size of the read buffer. Mostly useful for tests. - public SaveParserEU4(Stream savefile, ISearchExpression? query, + public SaveParserEU4(Stream savefile, SearchExpressionCompilation? query, Encoding? encoding = null, int bufferSize = DefaultBufferSize) { - Encoding = encoding ?? Encoding.Latin1; - _searchExprCurrent = query; + Encoding = encoding ?? DefaultEncoding; + _query = query; _tokens = new Tokenizer(savefile, bufferSize); } public Dictionary Parse() { + // compiled here, so the keys of the query are always encoded like the save + _searchExprCurrent = _query?.Compile(Encoding); _tokens.ReadHeader(Header, Encoding); return ParseDict(); } @@ -194,7 +203,7 @@ public class SaveParserEU4 ISearchExpression? searchExprNext = null; bool matches = _searchExprCurrent == null || _searchExprCurrent.DoesMatch( - new MatchCandidate(localIndex, _tokens.Text, Encoding), out searchExprNext); + new MatchCandidate(localIndex, _tokens.Text), out searchExprNext); string? keyStr = matches ? DecodeString(_tokens.Text) : null; // next token should be `=` or `{` diff --git a/ParadoxSaveParser.Lib/SearchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression.cs deleted file mode 100644 index ef1a55b..0000000 --- a/ParadoxSaveParser.Lib/SearchExpression.cs +++ /dev/null @@ -1,188 +0,0 @@ -namespace ParadoxSaveParser.Lib; - -/// -/// The node a search expression is tested against: its key inside the parent dictionary, -/// still in the raw bytes of the save, and its position inside the parent list. -/// Keys stay undecoded so that a key rejected by the query never becomes a string. -/// -public readonly ref struct MatchCandidate -{ - public readonly ReadOnlySpan Key; - public readonly int Index; - - /// Encoding is written in. - public readonly Encoding Encoding; - - /// A list item, which has a position but no key. - public MatchCandidate(int index) - { - Key = default; - Index = index; - Encoding = Encoding.Latin1; - } - - public MatchCandidate(int index, ReadOnlySpan key, Encoding encoding) - { - Key = key; - Index = index; - Encoding = encoding; - } -} - -public interface ISearchExpression -{ - bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression); -} - -public static class SearchExpressionCompiler -{ - private static bool CharEqualsAndNotEscaped(char c, ReadOnlySpan chars, int i) - => chars[i] == c && (i < 1 || chars[i - 1] != '\\') && (i < 2 || chars[i - 2] != '\\'); - - public static ISearchExpression Compile(ReadOnlySpan query) - { - if (query.IsEmpty) - throw new ArgumentNullException(nameof(query)); - - if (query[0] is '(') - { - var subExprs = new List(); - int supExprBegin = 1; - int bracketBalance = 1; - int i = supExprBegin; - for (; i < query.Length && bracketBalance != 0; i++) - { - if (CharEqualsAndNotEscaped('(', query, i)) - { - bracketBalance++; - } - else if (CharEqualsAndNotEscaped(')', query, i)) - { - bracketBalance--; - } - else if (bracketBalance == 1 && CharEqualsAndNotEscaped('|', query, i)) - { - var subPart = query.Slice(supExprBegin, i - supExprBegin); - var subExpr = Compile(subPart); - subExprs.Add(subExpr); - supExprBegin = i + 1; - } - } - - if (i != query.Length) - throw new NotImplementedException("Expressions after ')' are not supported"); - - if (bracketBalance > 0) - throw new Exception("Too many opening brackets"); - if (bracketBalance < 0) - throw new Exception("Too many closing brackets"); - - var subPartLast = query.Slice(supExprBegin, i - 1 - supExprBegin); - var subExprLast = Compile(subPartLast); - subExprs.Add(subExprLast); - return new MultipleMatchExpression(subExprs); - } - - int partBeforePointLength = 0; - while (partBeforePointLength < query.Length) - { - if (CharEqualsAndNotEscaped('.', query, partBeforePointLength)) - break; - partBeforePointLength++; - } - - var part = query.Slice(0, partBeforePointLength); - ReadOnlySpan remaining = default; - if (partBeforePointLength < query.Length) - remaining = query.Slice(partBeforePointLength + 1); - if (part is "*") - return new AnyMatchExpression(remaining.IsEmpty ? null : Compile(remaining)); - if (part is "~") - return new NoMatchExpression(); - - for (int j = 0; j < part.Length; j++) - if (CharEqualsAndNotEscaped('*', part, j)) - throw new NotImplementedException("pattern matching other than '*' is not implemented yet"); - - if (part[0] is '[') - { - part = part.Slice(1, part.Length - 2); - return new IndexMatchExpression(int.Parse(part), remaining.IsEmpty ? null : Compile(remaining)); - } - - return new ExactMatchExpression(part.ToString(), remaining.IsEmpty ? null : Compile(remaining)); - } - - - private record AnyMatchExpression(ISearchExpression? next) : ISearchExpression - { - public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) - { - nextSearchExpression = next; - return true; - } - } - - private record NoMatchExpression : ISearchExpression - { - public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) - { - nextSearchExpression = null; - return false; - } - } - - private record MultipleMatchExpression(List subExprs) : ISearchExpression - { - public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) - { - foreach (var e in subExprs) - if (e.DoesMatch(candidate, out nextSearchExpression)) - return true; - - nextSearchExpression = null; - return false; - } - } - - private record IndexMatchExpression(int index, ISearchExpression? next) : ISearchExpression - { - public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) - { - if (candidate.Index == index) - { - nextSearchExpression = next; - return true; - } - - nextSearchExpression = null; - return false; - } - } - - private record ExactMatchExpression(string key, ISearchExpression? next) : ISearchExpression - { - // the key is compared as bytes, so it is encoded once for whatever encoding the - // parser reads the save in - private byte[]? _keyBytes; - private Encoding? _keyEncoding; - - public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) - { - if (!ReferenceEquals(_keyEncoding, candidate.Encoding)) - { - _keyEncoding = candidate.Encoding; - _keyBytes = candidate.Encoding.GetBytes(key); - } - - if (candidate.Key.SequenceEqual(_keyBytes)) - { - nextSearchExpression = next; - return true; - } - - nextSearchExpression = null; - return false; - } - } -} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/AnyMatchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/AnyMatchExpression.cs new file mode 100644 index 0000000..57d71ba --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/AnyMatchExpression.cs @@ -0,0 +1,13 @@ +namespace ParadoxSaveParser.Lib; + +/// "*" — matches every node at this level. +internal sealed class AnyMatchExpression(ISearchExpression? next) : ISearchExpression +{ + public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) + { + nextSearchExpression = next; + return true; + } + + public void ReEncode(Encoding encoding) => next?.ReEncode(encoding); +} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/ExactMatchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/ExactMatchExpression.cs new file mode 100644 index 0000000..c77b6e7 --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/ExactMatchExpression.cs @@ -0,0 +1,37 @@ +namespace ParadoxSaveParser.Lib; + +/// A literal key, matched byte for byte. +internal sealed class ExactMatchExpression : ISearchExpression +{ + public string Key { get; } + + // the key as it appears in the save, so matching needs no encoding at all + public byte[] KeyBytes { get; private set; } + public ISearchExpression? Next { get; } + + public ExactMatchExpression(string key, Encoding encoding, ISearchExpression? next) + { + Key = key; + Next = next; + KeyBytes = encoding.GetBytes(key); + } + + public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) + { + // comparing bytes keeps the candidate's key from becoming a string + if (candidate.Key.SequenceEqual(KeyBytes)) + { + nextSearchExpression = Next; + return true; + } + + nextSearchExpression = null; + return false; + } + + public void ReEncode(Encoding encoding) + { + KeyBytes = encoding.GetBytes(Key); + Next?.ReEncode(encoding); + } +} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/ISearchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/ISearchExpression.cs new file mode 100644 index 0000000..382583f --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/ISearchExpression.cs @@ -0,0 +1,50 @@ +namespace ParadoxSaveParser.Lib; + +/// +/// One step of a compiled query. Each step points at the step its matched nodes +/// are searched with, so the query runs in a single pass down the tree. +/// +public interface ISearchExpression +{ + /// Tests one node against this step of the query. + /// The node being tested, identified by its key or its position. + /// + /// On a match, the step for the node's children, or null if the node is kept whole. + /// Undefined when the method returns false. + /// + /// true if the node is selected by this step + bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression); + + /// + /// Encodes the literal keys of this step and of every step below it. + /// Called by , never during matching. + /// + void ReEncode(Encoding encoding); +} + +/// +/// A node the query is tested against: its raw key and its position in the parent. +/// The key stays undecoded, so a rejected key never becomes a string. +/// +public readonly ref struct MatchCandidate +{ + /// Undecoded key of the node. Empty for list items. + public readonly ReadOnlySpan Key; + + /// Position of the node among its siblings, counted from 0. + public readonly int Index; + + /// A list item, which has a position but no key. + public MatchCandidate(int index) + { + Key = default; + Index = index; + } + + /// A dictionary entry, which has both a key and a position. + public MatchCandidate(int index, ReadOnlySpan key) + { + Key = key; + Index = index; + } +} diff --git a/ParadoxSaveParser.Lib/SearchExpression/IndexMatchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/IndexMatchExpression.cs new file mode 100644 index 0000000..b54730f --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/IndexMatchExpression.cs @@ -0,0 +1,19 @@ +namespace ParadoxSaveParser.Lib; + +/// "[7]" — matches the node at one position. +internal sealed class IndexMatchExpression(int index, ISearchExpression? next) : ISearchExpression +{ + public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) + { + if (candidate.Index == index) + { + nextSearchExpression = next; + return true; + } + + nextSearchExpression = null; + return false; + } + + public void ReEncode(Encoding encoding) => next?.ReEncode(encoding); +} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/MultipleMatchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/MultipleMatchExpression.cs new file mode 100644 index 0000000..3721251 --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/MultipleMatchExpression.cs @@ -0,0 +1,22 @@ +namespace ParadoxSaveParser.Lib; + +/// "(a|b)" — matches if any alternative does. +internal sealed class MultipleMatchExpression(List subExprs) : ISearchExpression +{ + public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) + { + // the first matching alternative wins + foreach (var e in subExprs) + if (e.DoesMatch(candidate, out nextSearchExpression)) + return true; + + nextSearchExpression = null; + return false; + } + + public void ReEncode(Encoding encoding) + { + foreach (var e in subExprs) + e.ReEncode(encoding); + } +} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/NoMatchExpression.cs b/ParadoxSaveParser.Lib/SearchExpression/NoMatchExpression.cs new file mode 100644 index 0000000..7de84e7 --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/NoMatchExpression.cs @@ -0,0 +1,23 @@ +namespace ParadoxSaveParser.Lib; + +/// "~" — matches nothing, so the branch is skipped. +internal sealed class NoMatchExpression : ISearchExpression +{ + // stateless, so one instance serves every "~" in every query + public static readonly NoMatchExpression Instance = new(); + + private NoMatchExpression() + { + } + + public bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression) + { + nextSearchExpression = null; + return false; + } + + // no keys and no children, and shared between queries, so nothing may be stored here + public void ReEncode(Encoding encoding) + { + } +} \ No newline at end of file diff --git a/ParadoxSaveParser.Lib/SearchExpression/SearchExpressionCompilation.cs b/ParadoxSaveParser.Lib/SearchExpression/SearchExpressionCompilation.cs new file mode 100644 index 0000000..6e7ac20 --- /dev/null +++ b/ParadoxSaveParser.Lib/SearchExpression/SearchExpressionCompilation.cs @@ -0,0 +1,198 @@ +using System.Threading; + +namespace ParadoxSaveParser.Lib; + +/// Thrown when a query cannot be compiled because it is malformed or uses unsupported syntax. +public class SearchExpressionException : Exception +{ + public SearchExpressionException(string message, ReadOnlySpan part) + : base($"{message}: '{part}'") + { + } +} + +/// +/// A query such as countries.*.technology, and the expression tree compiled from it. +/// The tree is built on the first and reused afterwards; only a change +/// of encoding touches it again, and then only to re-encode its literal keys. +/// +public class SearchExpressionCompilation +{ + private readonly string _query; + + // guards the tree while it is built or re-encoded, so parsers on several threads + // can share one compilation + private readonly Lock _compileLock = new(); + + private ISearchExpression? _rootNode; + private Encoding? _currentEncoding; + + /// Search query. Compiled on the first call to . + /// the query is empty + public SearchExpressionCompilation(string query) + { + ArgumentException.ThrowIfNullOrEmpty(query); + _query = query; + } + + /// + /// Returns the expression tree, with its literal keys encoded the way the save is. + /// Compiles the query on the first call, and re-encodes the tree when the encoding changes. + /// + /// Encoding of the strings inside the save being parsed. + /// the query is malformed + public ISearchExpression Compile(Encoding encoding) + { + lock (_compileLock) + { + if (_rootNode is null) + _rootNode = CompilePart(_query, encoding, null); + // keys are stored as bytes, so another encoding means encoding them again + else if (!encoding.Equals(_currentEncoding)) + _rootNode.ReEncode(encoding); + + _currentEncoding = encoding; + return _rootNode; + } + } + + /// True if holds at + /// as syntax, not as an escaped literal. + private static bool CharEqualsAndNotEscaped(char c, ReadOnlySpan chars, int i) + // escaped if a backslash stands in either of the two preceding positions + => chars[i] == c && (i < 1 || chars[i - 1] != '\\') && (i < 2 || chars[i - 2] != '\\'); + + /// Compiles one path step together with everything that follows it. + /// + /// Step that continues the path after this one ends, or null if nothing follows. + /// Used for the part written after a group, as in (a|b).c, where every alternative + /// of the group continues with the same .c. + /// + private static ISearchExpression CompilePart(ReadOnlySpan query, Encoding encoding, + ISearchExpression? tail) + { + // empty alternative, as in "(a||b)" + if (query.IsEmpty) + throw new SearchExpressionException("Empty expression", query); + + // "(a|b.c)" — a group of alternatives + if (query[0] is '(') + return CompileGroup(query, encoding, tail); + + // a plain step: find the '.' that ends it + int partBeforePointLength = 0; + while (partBeforePointLength < query.Length) + { + if (CharEqualsAndNotEscaped('.', query, partBeforePointLength)) + break; + partBeforePointLength++; + } + + // this step, and the rest of the path that becomes its child expression + var part = query.Slice(0, partBeforePointLength); + // empty step, as in "a..b" or ".b" + if (part.IsEmpty) + throw new SearchExpressionException("Empty path step", query); + + ReadOnlySpan remaining = default; + if (partBeforePointLength < query.Length) + { + remaining = query.Slice(partBeforePointLength + 1); + if (remaining.IsEmpty) + throw new SearchExpressionException("Path ends with '.'", query); + } + + // when nothing follows this step, the path continues with the tail, which may be null too + var next = remaining.IsEmpty ? tail : CompilePart(remaining, encoding, tail); + + // "*" — any node at this level + if (part is "*") + return new AnyMatchExpression(next); + // "~" — nothing at this level + if (part is "~") + return NoMatchExpression.Instance; + + // a '*' anywhere else would be a wildcard inside a key + for (int j = 0; j < part.Length; j++) + if (CharEqualsAndNotEscaped('*', part, j)) + throw new SearchExpressionException("Pattern matching other than '*' is not supported", part); + + // "[7]" — match by position + if (part[0] is '[') + { + if (part[^1] is not ']') + throw new SearchExpressionException("Index step has no closing ']'", part); + // strip the brackets + var indexText = part.Slice(1, part.Length - 2); + if (!int.TryParse(indexText, out int index) || index < 0) + throw new SearchExpressionException("Index must be a non-negative number", part); + return new IndexMatchExpression(index, next); + } + + // an ordinary key + return new ExactMatchExpression(part.ToString(), encoding, next); + } + + /// Compiles "(a|b)", and the path written after it, if there is one. + private static ISearchExpression CompileGroup(ReadOnlySpan query, Encoding encoding, + ISearchExpression? tail) + { + int close = FindGroupEnd(query); + + // "(a|b).c" — what stands after the group continues every alternative of it + var afterGroup = query.Slice(close + 1); + if (!afterGroup.IsEmpty) + { + if (CharEqualsAndNotEscaped(')', afterGroup, 0)) + throw new SearchExpressionException("Too many closing brackets", query); + if (!CharEqualsAndNotEscaped('.', afterGroup, 0)) + throw new SearchExpressionException("Group must be followed by '.'", query); + + var rest = afterGroup.Slice(1); + if (rest.IsEmpty) + throw new SearchExpressionException("Path ends with '.'", query); + tail = CompilePart(rest, encoding, tail); + } + + // cut the group into alternatives and compile each of them with that same tail + var subExprs = new List(); + int begin = 1; + int depth = 0; + for (int i = 1; i < close; i++) + { + if (CharEqualsAndNotEscaped('(', query, i)) + depth++; + else if (CharEqualsAndNotEscaped(')', query, i)) + depth--; + // a deeper '|' belongs to a nested group and is compiled with it + else if (depth == 0 && CharEqualsAndNotEscaped('|', query, i)) + { + subExprs.Add(CompilePart(query.Slice(begin, i - begin), encoding, tail)); + begin = i + 1; + } + } + + // the last alternative has no '|' after it + subExprs.Add(CompilePart(query.Slice(begin, close - begin), encoding, tail)); + return new MultipleMatchExpression(subExprs); + } + + /// Finds the ')' that closes the group opened by the first character. + private static int FindGroupEnd(ReadOnlySpan query) + { + int depth = 0; + for (int i = 0; i < query.Length; i++) + { + if (CharEqualsAndNotEscaped('(', query, i)) + depth++; + else if (CharEqualsAndNotEscaped(')', query, i)) + { + depth--; + if (depth == 0) + return i; + } + } + + throw new SearchExpressionException("Too many opening brackets", query); + } +} \ No newline at end of file diff --git a/ParadoxSaveParser.WebAPI/SaveDataFilters/ISaveDataFilter.cs b/ParadoxSaveParser.WebAPI/SaveDataFilters/ISaveDataFilter.cs index 8aed66c..c0bb883 100644 --- a/ParadoxSaveParser.WebAPI/SaveDataFilters/ISaveDataFilter.cs +++ b/ParadoxSaveParser.WebAPI/SaveDataFilters/ISaveDataFilter.cs @@ -3,7 +3,11 @@ public interface ISaveDataFilter { public string SearchString { get; } - public ISearchExpression SearchExpression { get; } + + /// + /// Shared between all parsing operations: the query is compiled once and its tree reused. + /// + public SearchExpressionCompilation SearchExpression { get; } public void Apply(Dictionary data); } \ No newline at end of file diff --git a/ParadoxSaveParser.WebAPI/SaveDataFilters/SaveFilterEU4.cs b/ParadoxSaveParser.WebAPI/SaveDataFilters/SaveFilterEU4.cs index 1188c37..4e7d1e0 100644 --- a/ParadoxSaveParser.WebAPI/SaveDataFilters/SaveFilterEU4.cs +++ b/ParadoxSaveParser.WebAPI/SaveDataFilters/SaveFilterEU4.cs @@ -6,7 +6,7 @@ namespace ParadoxSaveParser.WebAPI.SaveDataFilters; public class SaveDataFilterEU4 : ISaveDataFilter { public string SearchString { get; } - public ISearchExpression SearchExpression { get; } + public SearchExpressionCompilation SearchExpression { get; } public SaveDataFilterEU4() { @@ -32,7 +32,7 @@ public class SaveDataFilterEU4 : ISaveDataFilter ) """ .Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace(" ", ""); - SearchExpression = SearchExpressionCompiler.Compile(SearchString); + SearchExpression = new SearchExpressionCompilation(SearchString); }