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); } }