refactored SearchExpression
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@@ -35,10 +34,8 @@ public static class BenchData
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The save decoded as text for the regex engines.
|
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string PdxText => _pdxText ??= Encoding.Latin1.GetString(PdxBytes);
|
public static string PdxText => _pdxText ??= SaveParserEU4.DefaultEncoding.GetString(PdxBytes);
|
||||||
|
|
||||||
private static string FindRepoRoot()
|
private static string FindRepoRoot()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class ExtractionBenchmarks
|
|||||||
private byte[] _pdx = null!;
|
private byte[] _pdx = null!;
|
||||||
private string _pdxText = null!;
|
private string _pdxText = null!;
|
||||||
private byte[] _json = null!;
|
private byte[] _json = null!;
|
||||||
private ISearchExpression _query = null!;
|
private SearchExpressionCompilation _query = null!;
|
||||||
|
|
||||||
private Regex _technologyBlock = null!;
|
private Regex _technologyBlock = null!;
|
||||||
private Regex _pathAwareCompiled = null!;
|
private Regex _pathAwareCompiled = null!;
|
||||||
@@ -32,7 +32,7 @@ public class ExtractionBenchmarks
|
|||||||
_pdx = BenchData.PdxBytes;
|
_pdx = BenchData.PdxBytes;
|
||||||
_pdxText = BenchData.PdxText;
|
_pdxText = BenchData.PdxText;
|
||||||
_json = File.ReadAllBytes(BenchData.JsonPath);
|
_json = File.ReadAllBytes(BenchData.JsonPath);
|
||||||
_query = SearchExpressionCompiler.Compile(BenchData.PdxQuery);
|
_query = new SearchExpressionCompilation(BenchData.PdxQuery);
|
||||||
|
|
||||||
_technologyBlock = new Regex(Extractors.TechnologyBlockPattern, RegexOptions.Compiled);
|
_technologyBlock = new Regex(Extractors.TechnologyBlockPattern, RegexOptions.Compiled);
|
||||||
_pathAwareCompiled = new Regex(Extractors.PathAwarePattern, RegexOptions.Compiled);
|
_pathAwareCompiled = new Regex(Extractors.PathAwarePattern, RegexOptions.Compiled);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public static partial class Extractors
|
|||||||
// ---------------------------------------------------------------- this project
|
// ---------------------------------------------------------------- this project
|
||||||
|
|
||||||
/// <summary>The parser of this repo: single streaming pass, guided by a compiled search expression.</summary>
|
/// <summary>The parser of this repo: single streaming pass, guided by a compiled search expression.</summary>
|
||||||
public static long SearchExpression(byte[] pdx, ISearchExpression query)
|
public static long SearchExpression(byte[] pdx, SearchExpressionCompilation query)
|
||||||
{
|
{
|
||||||
using var stream = new MemoryStream(pdx, false);
|
using var stream = new MemoryStream(pdx, false);
|
||||||
var root = new SaveParserEU4(stream, query).Parse();
|
var root = new SaveParserEU4(stream, query).Parse();
|
||||||
@@ -30,7 +30,7 @@ public static partial class Extractors
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Same query, but the parser reads from the given stream instead of a preloaded byte[].</summary>
|
/// <summary>Same query, but the parser reads from the given stream instead of a preloaded byte[].</summary>
|
||||||
public static long SearchExpressionFromStream(Stream stream, ISearchExpression query)
|
public static long SearchExpressionFromStream(Stream stream, SearchExpressionCompilation query)
|
||||||
{
|
{
|
||||||
var root = new SaveParserEU4(stream, query).Parse();
|
var root = new SaveParserEU4(stream, query).Parse();
|
||||||
return ChecksumOfParsedTree(root);
|
return ChecksumOfParsedTree(root);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public static class Mispairing
|
|||||||
{
|
{
|
||||||
var result = new Dictionary<string, long>();
|
var result = new Dictionary<string, long>();
|
||||||
using var stream = new MemoryStream(pdx, false);
|
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<string, object>)root["countries"])
|
foreach (var (tag, value) in (Dictionary<string, object>)root["countries"])
|
||||||
{
|
{
|
||||||
if (value is Dictionary<string, object> c
|
if (value is Dictionary<string, object> c
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ namespace ParadoxSaveParser.Benchmarks;
|
|||||||
[SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 1, iterationCount: 5)]
|
[SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 1, iterationCount: 5)]
|
||||||
public class ParserInputBenchmarks
|
public class ParserInputBenchmarks
|
||||||
{
|
{
|
||||||
private ISearchExpression _query = null!;
|
private SearchExpressionCompilation _query = null!;
|
||||||
|
|
||||||
[GlobalSetup]
|
[GlobalSetup]
|
||||||
public void Setup() => _query = SearchExpressionCompiler.Compile(BenchData.PdxQuery);
|
public void Setup() => _query = new SearchExpressionCompilation(BenchData.PdxQuery);
|
||||||
|
|
||||||
[Benchmark(Description = "SearchExpression over FileStream")]
|
[Benchmark(Description = "SearchExpression over FileStream")]
|
||||||
public long FromFile()
|
public long FromFile()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public static class Program
|
|||||||
var pdx = BenchData.PdxBytes;
|
var pdx = BenchData.PdxBytes;
|
||||||
var text = BenchData.PdxText;
|
var text = BenchData.PdxText;
|
||||||
var json = File.ReadAllBytes(BenchData.JsonPath);
|
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("SearchExpression", () => Extractors.SearchExpression(pdx, query));
|
||||||
Time("FullParse", () => Extractors.FullParseThenSelect(pdx));
|
Time("FullParse", () => Extractors.FullParseThenSelect(pdx));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ internal static partial class Modes
|
|||||||
? Console.OpenStandardOutput()
|
? Console.OpenStandardOutput()
|
||||||
: File.OpenWrite(outputPath.Value);
|
: File.OpenWrite(outputPath.Value);
|
||||||
|
|
||||||
var searchExpression = SearchExpressionCompiler.Compile(searchQuery);
|
var searchExpression = new SearchExpressionCompilation(searchQuery);
|
||||||
|
|
||||||
var parser = new SaveParserEU4(inputStream, searchExpression);
|
var parser = new SaveParserEU4(inputStream, searchExpression);
|
||||||
var parsedValue = parser.Parse();
|
var parsedValue = parser.Parse();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -66,7 +67,7 @@ public class SearchExpressionTests
|
|||||||
{
|
{
|
||||||
byte[] data = "EU4txt a={ name=\"x{y}z\" b=1 }".ToBytes();
|
byte[] data = "EU4txt a={ name=\"x{y}z\" b=1 }".ToBytes();
|
||||||
using var saveStream = new MemoryStream(data, false);
|
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<string, object>)parser.Parse()["a"];
|
var a = (Dictionary<string, object>)parser.Parse()["a"];
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
@@ -80,7 +81,7 @@ public class SearchExpressionTests
|
|||||||
{
|
{
|
||||||
byte[] data = "EU4txt a={ s=\"{{{\" } b=2".ToBytes();
|
byte[] data = "EU4txt a={ s=\"{{{\" } b=2".ToBytes();
|
||||||
using var saveStream = new MemoryStream(data, false);
|
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));
|
Assert.That(parser.Parse()["b"], Is.EqualTo(2L));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,15 +90,102 @@ public class SearchExpressionTests
|
|||||||
{
|
{
|
||||||
byte[] data = Encoding.UTF8.GetBytes("EU4txt a={ name=\"Ä\" }");
|
byte[] data = Encoding.UTF8.GetBytes("EU4txt a={ name=\"Ä\" }");
|
||||||
using var saveStream = new MemoryStream(data, false);
|
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<string, object>)parser.Parse()["a"];
|
var a = (Dictionary<string, object>)parser.Parse()["a"];
|
||||||
Assert.That(a["name"], Is.EqualTo("Ä"));
|
Assert.That(a["name"], Is.EqualTo("Ä"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A path written after a group applies to every alternative of that group.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups with more alternatives than the other tests use: a key that matches nothing,
|
||||||
|
/// a repeated key, and a literal key standing before a "*".
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<string, object>)parser.Parse()["a"];
|
||||||
|
Assert.That(a["name"], Is.EqualTo("“–”"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One compilation, two saves whose keys are written in different encodings.</summary>
|
||||||
|
[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<string, object>)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<SearchExpressionException>(() => Compile(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void EmptyQueryIsAnArgumentError()
|
||||||
|
{
|
||||||
|
// ReSharper disable once ObjectCreationAsStatement
|
||||||
|
Assert.Throws<ArgumentException>(() => new SearchExpressionCompilation(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Compiles right away, so that a malformed query throws here and not inside a parser.</summary>
|
||||||
|
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)
|
private static string Search(byte[] saveData, string query, int bufferSize = 64 * 1024)
|
||||||
{
|
{
|
||||||
using var saveStream = new MemoryStream(saveData, false);
|
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 parser = new SaveParserEU4(saveStream, se, bufferSize: bufferSize);
|
||||||
var rootNode = parser.Parse();
|
var rootNode = parser.Parse();
|
||||||
string json = JsonSerializer.Serialize(rootNode, _smallSaveSerializerOptions);
|
string json = JsonSerializer.Serialize(rootNode, _smallSaveSerializerOptions);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodings used by Paradox save files.
|
||||||
|
/// Touching this class registers the code page provider, so no consumer has to do it.
|
||||||
|
/// </summary>
|
||||||
|
public static class ParadoxEncodings
|
||||||
|
{
|
||||||
|
/// <summary>Encoding of EU4 save files.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=searchexpression/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
@@ -14,13 +14,20 @@ public class SaveParserEU4
|
|||||||
private const int DefaultBufferSize = 64 * 1024;
|
private const int DefaultBufferSize = 64 * 1024;
|
||||||
private static ReadOnlySpan<byte> Header => "EU4txt"u8;
|
private static ReadOnlySpan<byte> Header => "EU4txt"u8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows-1252 is the default encoding of the save files
|
||||||
|
/// </summary>
|
||||||
|
public static Encoding DefaultEncoding => ParadoxEncodings.Windows1252;
|
||||||
|
|
||||||
private readonly Tokenizer _tokens;
|
private readonly Tokenizer _tokens;
|
||||||
|
private readonly SearchExpressionCompilation? _query;
|
||||||
|
|
||||||
|
// step of the query the parser is currently at, compiled by Parse()
|
||||||
private ISearchExpression? _searchExprCurrent;
|
private ISearchExpression? _searchExprCurrent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encoding of the strings inside the save. Saves of different localizations use
|
/// Encoding of the strings inside the save. Saves of different localizations use
|
||||||
/// different ones, so it can be changed; <see cref="System.Text.Encoding.Latin1" /> maps
|
/// different ones, so it can be changed; <see cref="DefaultEncoding" /> is what EU4 writes.
|
||||||
/// every byte to one character and never fails, which makes it a safe default.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Encoding Encoding { get; }
|
public Encoding Encoding { get; }
|
||||||
|
|
||||||
@@ -28,21 +35,23 @@ public class SaveParserEU4
|
|||||||
/// Uncompressed stream of <c>gamestate</c> file which can be extracted from save archive
|
/// Uncompressed stream of <c>gamestate</c> file which can be extracted from save archive
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="query">
|
/// <param name="query">
|
||||||
/// 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
|
/// so you should specify what exactly you want to get from save file
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="encoding">Encoding of the strings inside the save. Latin1 by default.</param>
|
/// <param name="encoding">Encoding of the strings inside the save. <see cref="DefaultEncoding"/></param>
|
||||||
/// <param name="bufferSize">Size of the read buffer. Mostly useful for tests.</param>
|
/// <param name="bufferSize">Size of the read buffer. Mostly useful for tests.</param>
|
||||||
public SaveParserEU4(Stream savefile, ISearchExpression? query,
|
public SaveParserEU4(Stream savefile, SearchExpressionCompilation? query,
|
||||||
Encoding? encoding = null, int bufferSize = DefaultBufferSize)
|
Encoding? encoding = null, int bufferSize = DefaultBufferSize)
|
||||||
{
|
{
|
||||||
Encoding = encoding ?? Encoding.Latin1;
|
Encoding = encoding ?? DefaultEncoding;
|
||||||
_searchExprCurrent = query;
|
_query = query;
|
||||||
_tokens = new Tokenizer(savefile, bufferSize);
|
_tokens = new Tokenizer(savefile, bufferSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dictionary<string, object> Parse()
|
public Dictionary<string, object> Parse()
|
||||||
{
|
{
|
||||||
|
// compiled here, so the keys of the query are always encoded like the save
|
||||||
|
_searchExprCurrent = _query?.Compile(Encoding);
|
||||||
_tokens.ReadHeader(Header, Encoding);
|
_tokens.ReadHeader(Header, Encoding);
|
||||||
return ParseDict();
|
return ParseDict();
|
||||||
}
|
}
|
||||||
@@ -194,7 +203,7 @@ public class SaveParserEU4
|
|||||||
ISearchExpression? searchExprNext = null;
|
ISearchExpression? searchExprNext = null;
|
||||||
bool matches = _searchExprCurrent == null
|
bool matches = _searchExprCurrent == null
|
||||||
|| _searchExprCurrent.DoesMatch(
|
|| _searchExprCurrent.DoesMatch(
|
||||||
new MatchCandidate(localIndex, _tokens.Text, Encoding), out searchExprNext);
|
new MatchCandidate(localIndex, _tokens.Text), out searchExprNext);
|
||||||
string? keyStr = matches ? DecodeString(_tokens.Text) : null;
|
string? keyStr = matches ? DecodeString(_tokens.Text) : null;
|
||||||
|
|
||||||
// next token should be `=` or `{`
|
// next token should be `=` or `{`
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
namespace ParadoxSaveParser.Lib;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public readonly ref struct MatchCandidate
|
|
||||||
{
|
|
||||||
public readonly ReadOnlySpan<byte> Key;
|
|
||||||
public readonly int Index;
|
|
||||||
|
|
||||||
/// <summary>Encoding <see cref="Key" /> is written in.</summary>
|
|
||||||
public readonly Encoding Encoding;
|
|
||||||
|
|
||||||
/// <summary>A list item, which has a position but no key.</summary>
|
|
||||||
public MatchCandidate(int index)
|
|
||||||
{
|
|
||||||
Key = default;
|
|
||||||
Index = index;
|
|
||||||
Encoding = Encoding.Latin1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MatchCandidate(int index, ReadOnlySpan<byte> 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<char> chars, int i)
|
|
||||||
=> chars[i] == c && (i < 1 || chars[i - 1] != '\\') && (i < 2 || chars[i - 2] != '\\');
|
|
||||||
|
|
||||||
public static ISearchExpression Compile(ReadOnlySpan<char> query)
|
|
||||||
{
|
|
||||||
if (query.IsEmpty)
|
|
||||||
throw new ArgumentNullException(nameof(query));
|
|
||||||
|
|
||||||
if (query[0] is '(')
|
|
||||||
{
|
|
||||||
var subExprs = new List<ISearchExpression>();
|
|
||||||
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<char> 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<ISearchExpression> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>"*" — matches every node at this level.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>A literal key, matched byte for byte.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISearchExpression
|
||||||
|
{
|
||||||
|
/// <summary>Tests one node against this step of the query.</summary>
|
||||||
|
/// <param name="candidate">The node being tested, identified by its key or its position.</param>
|
||||||
|
/// <param name="nextSearchExpression">
|
||||||
|
/// On a match, the step for the node's children, or <c>null</c> if the node is kept whole.
|
||||||
|
/// Undefined when the method returns <c>false</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>true if the node is selected by this step</returns>
|
||||||
|
bool DoesMatch(MatchCandidate candidate, out ISearchExpression? nextSearchExpression);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes the literal keys of this step and of every step below it.
|
||||||
|
/// Called by <see cref="SearchExpressionCompilation" />, never during matching.
|
||||||
|
/// </summary>
|
||||||
|
void ReEncode(Encoding encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly ref struct MatchCandidate
|
||||||
|
{
|
||||||
|
/// <summary>Undecoded key of the node. Empty for list items.</summary>
|
||||||
|
public readonly ReadOnlySpan<byte> Key;
|
||||||
|
|
||||||
|
/// <summary>Position of the node among its siblings, counted from 0.</summary>
|
||||||
|
public readonly int Index;
|
||||||
|
|
||||||
|
/// <summary>A list item, which has a position but no key.</summary>
|
||||||
|
public MatchCandidate(int index)
|
||||||
|
{
|
||||||
|
Key = default;
|
||||||
|
Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A dictionary entry, which has both a key and a position.</summary>
|
||||||
|
public MatchCandidate(int index, ReadOnlySpan<byte> key)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Index = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>"[7]" — matches the node at one position.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>"(a|b)" — matches if any alternative does.</summary>
|
||||||
|
internal sealed class MultipleMatchExpression(List<ISearchExpression> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>"~" — matches nothing, so the branch is skipped.</summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ParadoxSaveParser.Lib;
|
||||||
|
|
||||||
|
/// <summary>Thrown when a query cannot be compiled because it is malformed or uses unsupported syntax.</summary>
|
||||||
|
public class SearchExpressionException : Exception
|
||||||
|
{
|
||||||
|
public SearchExpressionException(string message, ReadOnlySpan<char> part)
|
||||||
|
: base($"{message}: '{part}'")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A query such as <c>countries.*.technology</c>, and the expression tree compiled from it.
|
||||||
|
/// The tree is built on the first <see cref="Compile" /> and reused afterwards; only a change
|
||||||
|
/// of encoding touches it again, and then only to re-encode its literal keys.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <param name="query">Search query. Compiled on the first call to <see cref="Compile" />.</param>
|
||||||
|
/// <exception cref="ArgumentException">the query is empty</exception>
|
||||||
|
public SearchExpressionCompilation(string query)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(query);
|
||||||
|
_query = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="encoding">Encoding of the strings inside the save being parsed.</param>
|
||||||
|
/// <exception cref="SearchExpressionException">the query is malformed</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="chars" /> holds <paramref name="c" /> at
|
||||||
|
/// <paramref name="i" /> as syntax, not as an escaped literal.</summary>
|
||||||
|
private static bool CharEqualsAndNotEscaped(char c, ReadOnlySpan<char> 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] != '\\');
|
||||||
|
|
||||||
|
/// <summary>Compiles one path step together with everything that follows it.</summary>
|
||||||
|
/// <param name="tail">
|
||||||
|
/// Step that continues the path after this one ends, or <c>null</c> if nothing follows.
|
||||||
|
/// Used for the part written after a group, as in <c>(a|b).c</c>, where every alternative
|
||||||
|
/// of the group continues with the same <c>.c</c>.
|
||||||
|
/// </param>
|
||||||
|
private static ISearchExpression CompilePart(ReadOnlySpan<char> 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<char> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Compiles "(a|b)", and the path written after it, if there is one.</summary>
|
||||||
|
private static ISearchExpression CompileGroup(ReadOnlySpan<char> 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<ISearchExpression>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds the ')' that closes the group opened by the first character.</summary>
|
||||||
|
private static int FindGroupEnd(ReadOnlySpan<char> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
public interface ISaveDataFilter
|
public interface ISaveDataFilter
|
||||||
{
|
{
|
||||||
public string SearchString { get; }
|
public string SearchString { get; }
|
||||||
public ISearchExpression SearchExpression { get; }
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared between all parsing operations: the query is compiled once and its tree reused.
|
||||||
|
/// </summary>
|
||||||
|
public SearchExpressionCompilation SearchExpression { get; }
|
||||||
|
|
||||||
public void Apply(Dictionary<string, object> data);
|
public void Apply(Dictionary<string, object> data);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace ParadoxSaveParser.WebAPI.SaveDataFilters;
|
|||||||
public class SaveDataFilterEU4 : ISaveDataFilter
|
public class SaveDataFilterEU4 : ISaveDataFilter
|
||||||
{
|
{
|
||||||
public string SearchString { get; }
|
public string SearchString { get; }
|
||||||
public ISearchExpression SearchExpression { get; }
|
public SearchExpressionCompilation SearchExpression { get; }
|
||||||
|
|
||||||
public SaveDataFilterEU4()
|
public SaveDataFilterEU4()
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ public class SaveDataFilterEU4 : ISaveDataFilter
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
.Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace(" ", "");
|
.Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace(" ", "");
|
||||||
SearchExpression = SearchExpressionCompiler.Compile(SearchString);
|
SearchExpression = new SearchExpressionCompilation(SearchString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user