73856a381ee1b1be48fcb61a344680a516c92a45
[quassel.git] / src / common / expressionmatch.cpp
1 /***************************************************************************
2  *   Copyright (C) 2005-2018 by the Quassel Project                        *
3  *   devel@quassel-irc.org                                                 *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) version 3.                                           *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.         *
19  ***************************************************************************/
20
21 #include "expressionmatch.h"
22
23 #include <QChar>
24 #include <QDebug>
25 #include <QString>
26 #include <QStringList>
27
28 ExpressionMatch::ExpressionMatch(const QString& expression, MatchMode mode, bool caseSensitive)
29 {
30     // Store the original parameters for later reference
31     _sourceExpression = expression;
32     _sourceMode = mode;
33     _sourceCaseSensitive = caseSensitive;
34
35     // Calculate the internal regex
36     //
37     // Do this now instead of on-demand to provide immediate feedback on errors when editing
38     // highlight and ignore rules.
39     cacheRegEx();
40 }
41
42 bool ExpressionMatch::match(const QString& string, bool matchEmpty) const
43 {
44     // Handle empty expression strings
45     if (_sourceExpressionEmpty) {
46         // Match found if matching empty is allowed, otherwise no match found
47         return matchEmpty;
48     }
49
50     if (!isValid()) {
51         // Can't match on an invalid rule
52         return false;
53     }
54
55     // We have "_matchRegEx", "_matchInvertRegEx", or both due to isValid() check above
56
57     // If specified, first check inverted rules
58     if (_matchInvertRegExActive && _matchInvertRegEx.isValid()) {
59         // Check inverted match rule
60         if (_matchInvertRegEx.match(string).hasMatch()) {
61             // Inverted rule matched, the rest of the rule cannot match
62             return false;
63         }
64     }
65
66     if (_matchRegExActive && _matchRegEx.isValid()) {
67         // Check regular match rule
68         return _matchRegEx.match(string).hasMatch();
69     }
70     else {
71         // If no valid regular rules exist, due to the isValid() check there must be valid inverted
72         // rules that did not match.  Count this as properly matching (implicit wildcard).
73         return true;
74     }
75 }
76
77 QString ExpressionMatch::trimMultiWildcardWhitespace(const QString& originalRule)
78 {
79     // This gets handled in two steps:
80     //
81     // 1.  Break apart ";"-separated list into components
82     // 2.  Combine whitespace-trimmed components into wildcard expression
83     //
84     // Let's start by making the list...
85
86     // Convert a ";"-separated list into an actual list, splitting on newlines and unescaping
87     // escaped characters
88
89     // Escaped list rules (where "[\n]" represents newline):
90     // ---------------
91     // Token  | Outcome
92     // -------|--------
93     // ;      | Split
94     // \;     | Keep as "\;"
95     // \\;    | Split (keep as "\\")
96     // \\\    | Keep as "\\" + "\", set consecutive slashes to 1
97     // [\n]   | Split
98     // \[\n]  | Split (keep as "\")
99     // \\[\n] | Split (keep as "\\")
100     // ...    | Keep as "..."
101     // \...   | Keep as "\..."
102     // \\...  | Keep as "\\..."
103     //
104     // Strings are forced to end with "\n", always applying "\..." and "\\..." rules
105     // "..." also includes another "\" character
106     //
107     // All whitespace is trimmed from each component
108
109     // "\\" and "\" are not downconverted to allow for other escape codes to be detected in
110     // ExpressionMatch::wildcardToRegex
111
112     // Example:
113     //
114     // > Wildcard rule
115     // norm; norm-space ; newline-space [\n] ;escape \; sep ; slash-end-split\\; quad\\\\norm;
116     // newline-split-slash\\[\n] slash-at-end\\                       [line does not continue]
117     //
118     // > Components
119     //   norm
120     //   norm-space
121     //   newline-space
122     //   escape \; sep
123     //   slash-end-split\\          [line does not continue]
124     //   quad\\\\norm
125     //   newline-split-slash\\      [line does not continue]
126     //   slash-at-end\\             [line does not continue]
127     //
128     // > Trimmed wildcard rule
129     // norm; norm-space; newline-space[\n]escape \; sep; slash-end-split\\; quad\\\\norm;
130     // newline-split-slash\\[\n]slash-at-end\\                        [line does not continue]
131     //
132     // (Newlines are encoded as "[\n]".  Ignore linebreaks for the sake of comment wrapping.)
133
134     // Note: R"(\\)" results in the literal of "\\", two backslash characters.  Anything inside the
135     // brackets is treated as a literal.  Outside the brackets but inside the quotes is still
136     // escaped.
137     //
138     // See https://en.cppreference.com/w/cpp/language/string_literal
139
140     // Prepare to loop!
141
142     QString rule(originalRule);
143
144     // Force a termination at the end of the string to trigger a split
145     // Don't check for ";" splits as they may be escaped
146     if (!rule.endsWith("\n")) {
147         rule.append("\n");
148     }
149
150     // Result
151     QString result = {};
152     // Current character
153     QChar curChar = {};
154     // Current string
155     QString curString = {};
156     // Max length
157     int sourceLength = rule.length();
158     // Consecutive "\" characters
159     int consecutiveSlashes = 0;
160
161     // We know it's going to be the same length or smaller, so reserve the same size as the string
162     result.reserve(sourceLength);
163
164     // For every character...
165     for (int i = 0; i < sourceLength; i++) {
166         // Get the character
167         curChar = rule.at(i);
168         // Check if it's on the list of special list characters, converting to Unicode for use
169         // in the switch statement
170         //
171         // See https://doc.qt.io/qt-5/qchar.html#unicode
172         switch (curChar.unicode()) {
173         case ';':
174             // Separator found
175             switch (consecutiveSlashes) {
176             case 0:
177             case 2:
178                 // ";"   -> Split
179                 // ...or...
180                 // "\\;" -> Split (keep as "\\")
181                 // Not escaped separator, split into a new item
182
183                 // Apply the additional "\\" if needed
184                 if (consecutiveSlashes == 2) {
185                     // "\\;" -> Split (keep as "\\")
186                     curString.append(R"(\\)");
187                 }
188
189                 // Remove any whitespace, e.g. "item1; item2" -> " item2" -> "item2"
190                 curString = curString.trimmed();
191
192                 // Skip empty items
193                 if (!curString.isEmpty()) {
194                     // Add to list with the same separator used
195                     result.append(curString + "; ");
196                 }
197                 // Reset the current list item
198                 curString.clear();
199                 break;
200             case 1:
201                 // "\;" -> Keep as "\;"
202                 curString.append(R"(\;)");
203                 break;
204             default:
205                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
206                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
207                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
208                            << "character!";
209                 break;
210             }
211             consecutiveSlashes = 0;
212             break;
213         case '\\':
214             // Split escape
215             // Increase consecutive slash count
216             consecutiveSlashes++;
217             // Check if we've reached "\\\"...
218             if (consecutiveSlashes == 3) {
219                 // "\\\" -> Keep as "\\" + "\"
220                 curString.append(R"(\\)");
221                 // Set consecutive slashes to 1, recognizing the trailing "\"
222                 consecutiveSlashes = 1;
223             }
224             else if (consecutiveSlashes > 3) {
225                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
226                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
227                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
228                            << "character!";
229                 break;
230             }
231             break;
232         case '\n':
233             // Newline found
234             // Preserve the characters as they are now
235
236             // "[\n]"   -> Split
237             // "\[\n]"  -> Split (keep as "\")
238             // "\\[\n]" -> Split (keep as "\\")
239
240             switch (consecutiveSlashes) {
241             case 0:
242                 // Keep string as is
243                 break;
244             case 1:
245             case 2:
246                 // Apply the additional "\" or "\\"
247                 curString.append(QString(R"(\)").repeated(consecutiveSlashes));
248                 break;
249             default:
250                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
251                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
252                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), applying newline split anyways!";
253                 break;
254             }
255
256             // Remove any whitespace, e.g. "item1; item2" -> " item2" -> "item2"
257             curString = curString.trimmed();
258
259             // Skip empty items
260             if (!curString.isEmpty()) {
261                 // Add to list with the same separator used
262                 result.append(curString + "\n");
263             }
264             // Reset the current list item
265             curString.clear();
266             consecutiveSlashes = 0;
267             break;
268         default:
269             // Preserve the characters as they are now
270             switch (consecutiveSlashes) {
271             case 0:
272                 // "..."   -> Keep as "..."
273                 curString.append(curChar);
274                 break;
275             case 1:
276             case 2:
277                 // "\..."  -> Keep as "\..."
278                 // "\\..." -> Keep as "\\..."
279                 curString.append(QString("\\").repeated(consecutiveSlashes) + curChar);
280                 break;
281             default:
282                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
283                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
284                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring " << curChar
285                            << "char escape!";
286                 break;
287             }
288             consecutiveSlashes = 0;
289             break;
290         }
291     }
292
293     // Remove any trailing separators
294     if (result.endsWith("; ")) {
295         result.chop(2);
296     }
297
298     // Remove any trailing whitespace
299     return result.trimmed();
300 }
301
302 void ExpressionMatch::cacheRegEx()
303 {
304     _matchRegExActive = false;
305     _matchInvertRegExActive = false;
306
307     _sourceExpressionEmpty = _sourceExpression.isEmpty();
308     if (_sourceExpressionEmpty) {
309         // No need to calculate anything for empty strings
310         return;
311     }
312
313     // Convert the given expression to a regular expression based on the mode
314     switch (_sourceMode) {
315     case MatchMode::MatchPhrase:
316         // Match entire phrase, noninverted
317         // Don't trim whitespace for phrase matching as someone might want to match on " word ", a
318         // more-specific request than "word".
319         _matchRegEx = regExFactory("(?:^|\\W)" + regExEscape(_sourceExpression) + "(?:\\W|$)", _sourceCaseSensitive);
320         _matchRegExActive = true;
321         break;
322     case MatchMode::MatchMultiPhrase:
323         // Match multiple entire phrases, noninverted
324         // Convert from multiple-phrase rules
325         _matchRegEx = regExFactory(convertFromMultiPhrase(_sourceExpression), _sourceCaseSensitive);
326         _matchRegExActive = true;
327         break;
328     case MatchMode::MatchWildcard:
329         // Match as wildcard expression
330         // Convert from wildcard rules for a single wildcard
331         if (_sourceExpression.startsWith("!")) {
332             // Inverted rule: take the remainder of the string
333             // "^" + invertComponents.at(0) + "$"
334             _matchInvertRegEx = regExFactory("^" + wildcardToRegEx(_sourceExpression.mid(1)) + "$", _sourceCaseSensitive);
335             _matchInvertRegExActive = true;
336         }
337         else {
338             // Normal rule: take the whole string
339             // Account for any escaped "!" (i.e. "\!") by skipping past the "\", but don't skip past
340             // escaped "\" (i.e. "\\!")
341             _matchRegEx = regExFactory("^" + wildcardToRegEx(_sourceExpression.startsWith("\\!") ? _sourceExpression.mid(1) : _sourceExpression)
342                                            + "$",
343                                        _sourceCaseSensitive);
344             _matchRegExActive = true;
345         }
346         break;
347     case MatchMode::MatchMultiWildcard:
348         // Match as multiple wildcard expressions
349         // Convert from wildcard rules for multiple wildcards
350         // (The generator function handles setting matchRegEx/matchInvertRegEx)
351         generateFromMultiWildcard(_sourceExpression, _sourceCaseSensitive);
352         break;
353     case MatchMode::MatchRegEx:
354         // Match as regular expression
355         if (_sourceExpression.startsWith("!")) {
356             // Inverted rule: take the remainder of the string
357             _matchInvertRegEx = regExFactory(_sourceExpression.mid(1), _sourceCaseSensitive);
358             _matchInvertRegExActive = true;
359         }
360         else {
361             // Normal rule: take the whole string
362             // Account for any escaped "!" (i.e. "\!") by skipping past the "\", but don't skip past
363             // escaped "\" (i.e. "\\!")
364             _matchRegEx = regExFactory(_sourceExpression.startsWith("\\!") ? _sourceExpression.mid(1) : _sourceExpression,
365                                        _sourceCaseSensitive);
366             _matchRegExActive = true;
367         }
368         break;
369     default:
370         // This should never happen if you keep the above consistent
371         qWarning() << Q_FUNC_INFO << "Unknown MatchMode" << (int)_sourceMode << "!";
372         break;
373     }
374
375     if (!_sourceExpressionEmpty && !isValid()) {
376         // This can happen with invalid regex, so make it a bit more user-friendly.  Set it to Info
377         // level as ideally someone's not just going to leave a broken match rule around.  For
378         // MatchRegEx, they probably need to fix their regex rule.  For the other modes, there's
379         // probably a bug in the parsing routines (which should also be fixed).
380         qInfo() << "Could not parse expression match rule" << _sourceExpression << "(match mode:" << (int)_sourceMode
381                  << "), this rule will be ignored";
382     }
383 }
384
385 QRegularExpression ExpressionMatch::regExFactory(const QString& regExString, bool caseSensitive)
386 {
387     // Construct the regular expression object, setting case sensitivity as appropriate
388     QRegularExpression newRegEx = QRegularExpression(regExString,
389                                                      caseSensitive ? QRegularExpression::PatternOption::NoPatternOption
390                                                                    : QRegularExpression::PatternOption::CaseInsensitiveOption);
391
392     // Check if rule is valid
393     if (!newRegEx.isValid()) {
394         // This can happen with invalid regex, so make it a bit more user-friendly.  Keep this
395         // distinct from the main info-level message for easier debugging in case a regex component
396         // in Wildcard or Phrase mode breaks.
397         qDebug() << "Internal regular expression component" << regExString << "is invalid and will be ignored";
398     }
399     // Qt offers explicit control over when QRegularExpression objects get optimized.
400     // By default, patterns are only optimized after some number of uses as defined
401     // within Qt internals.
402     //
403     // In the context of ExpressionMatch, some regular expressions might go unused, e.g. a highlight
404     // rule might never match a channel pattern, resulting in the contents pattern being untouched.
405     // It should be safe to let Qt handle optimization, taking a non-deterministic, one-off
406     // performance penalty on optimization for the sake of saving memory usage on patterns that
407     // don't get used.
408     //
409     // If profiling shows expressions are generally used and/or the automatic optimization
410     // interferes incurs too high of a penalty (unlikely given we've created regular expression
411     // objects willy-nilly before now), this can be revisited to explicitly call...
412     //
413     // else {
414     //     // Optimize regex now
415     //     newRegEx.optimize();
416     // }
417     //
418     // NOTE: This should only be called if the expression is valid!  Apply within an "else" of the
419     // inverted isValid() check above.
420     //
421     // See https://doc.qt.io/qt-5/qregularexpression.html#optimize
422
423     return newRegEx;
424 }
425
426 QString ExpressionMatch::regExEscape(const QString& phrase)
427 {
428     // Escape the given phrase of any special regular expression characters
429     return QRegularExpression::escape(phrase);
430 }
431
432 QString ExpressionMatch::convertFromMultiPhrase(const QString& originalRule)
433 {
434     // Convert the multi-phrase rule into regular expression format
435     // Split apart the original rule into components
436     // Use QStringList instead of std::vector<QString> to make use of Qt's built-in .join() method
437     QStringList components = {};
438     // Split on "\n"
439     for (auto&& component : originalRule.split("\n", QString::SkipEmptyParts)) {
440         // Don't trim whitespace to maintain consistency with single phrase matching
441         // As trimming is not performed, empty components will already be skipped.  This means " "
442         // is considered a valid matching phrase.
443
444         // Take the whole string, escaping any regex
445         components.append(regExEscape(component));
446     }
447
448     // Create full regular expression by...
449     // > Enclosing within a non-capturing group to avoid overhead of text extraction, "(?:...)"
450     // > Flattening normal and inverted rules using the regex OR character "...|..."
451     //
452     // Before: [foo, bar, baz]
453     // After:  (?:^|\W)(?:foo|bar|baz)(?:\W|$)
454
455     if (components.count() == 1) {
456         // Single item, skip the noncapturing group
457         return "(?:^|\\W)" + components.at(0) + "(?:\\W|$)";
458     }
459     else {
460         return "(?:^|\\W)(?:" + components.join("|") + ")(?:\\W|$)";
461     }
462 }
463
464 void ExpressionMatch::generateFromMultiWildcard(const QString& originalRule, bool caseSensitive)
465 {
466     // Convert the wildcard rule into regular expression format
467     // First, reset the existing match expressions
468     _matchRegEx = {};
469     _matchInvertRegEx = {};
470     _matchRegExActive = false;
471     _matchInvertRegExActive = false;
472
473     // This gets handled in three steps:
474     //
475     // 1.  Break apart ";"-separated list into components
476     // 2.  Convert components from wildcard format into regular expression format
477     // 3.  Combine normal/invert components into normal/invert regular expressions
478     //
479     // Let's start by making the list...
480
481     // Convert a ";"-separated list into an actual list, splitting on newlines and unescaping
482     // escaped characters
483
484     // Escaped list rules (where "[\n]" represents newline):
485     // ---------------
486     // Token  | Outcome
487     // -------|--------
488     // ;      | Split
489     // \;     | Replace with ";"
490     // \\;    | Split (keep as "\\")
491     // !      | At start: mark as inverted
492     // \!     | At start: replace with "!"
493     // \\!    | At start: keep as "\\!" (replaced with "\!" in wildcard conversion)
494     // !      | Elsewhere: keep as "!"
495     // \!     | Elsewhere: keep as "\!"
496     // \\!    | Elsewhere: keep as "\\!"
497     // \\\    | Keep as "\\" + "\", set consecutive slashes to 1
498     // [\n]   | Split
499     // \[\n]  | Split (keep as "\")
500     // \\[\n] | Split (keep as "\\")
501     // ...    | Keep as "..."
502     // \...   | Keep as "\..."
503     // \\...  | Keep as "\\..."
504     //
505     // Strings are forced to end with "\n", always applying "\..." and "\\..." rules
506     // "..." also includes another "\" character
507     //
508     // All whitespace is trimmed from each component
509
510     // "\\" and "\" are not downconverted to allow for other escape codes to be detected in
511     // ExpressionMatch::wildcardToRegex
512
513     // Example:
514     //
515     // > Wildcard rule
516     // norm;!invert; norm-space ; !invert-space ;;!;\!norm-escaped;\\!slash-invert;\\\\double;
517     // escape\;sep;slash-end-split\\;quad\\\\!noninvert;newline-split[\n]newline-split-slash\\[\n]
518     // slash-at-end\\               [line does not continue]
519     //
520     // (Newlines are encoded as "[\n]".  Ignore linebreaks for the sake of comment wrapping.)
521     //
522     //
523     // > Normal components without wildcard conversion
524     //   norm
525     //   norm-space
526     //   !norm-escaped
527     //   \\!slash-invert
528     //   \\\\double
529     //   escape;sep
530     //   slash-end-split\\          [line does not continue]
531     //   quad\\\\!noninvert
532     //   newline-split
533     //   newline-split-slash\\      [line does not continue]
534     //   slash-at-end\\             [line does not continue]
535     //
536     // > Inverted components without wildcard conversion
537     //   invert
538     //   invert-space
539     //
540     //
541     // > Normal components with wildcard conversion
542     //   norm
543     //   norm\-space
544     //   \!norm\-escaped
545     //   \\\!slash\-invert
546     //   \\\\double
547     //   escape\;sep
548     //   slash\-end\-split\\        [line does not continue]
549     //   quad\\\\\!noninvert
550     //   newline\-split
551     //   newline\-split\-slash\\    [line does not continue]
552     //   slash\-at\-end\\           [line does not continue]
553     //
554     // > Inverted components with wildcard conversion
555     //   invert
556     //   invert\-space
557     //
558     //
559     // > Normal wildcard-converted regex
560     // ^(?:norm|norm\-space|\!norm\-escaped|\\\!slash\-invert|\\\\double|escape\;sep|
561     // slash\-end\-split\\|quad\\\\\!noninvert|newline\-split|newline\-split\-slash\\|
562     // slash\-at\-end\\)$
563     //
564     // > Inverted wildcard-converted regex
565     // ^(?:invert|invert\-space)$
566
567     // Note: R"(\\)" results in the literal of "\\", two backslash characters.  Anything inside the
568     // brackets is treated as a literal.  Outside the brackets but inside the quotes is still
569     // escaped.
570     //
571     // See https://en.cppreference.com/w/cpp/language/string_literal
572
573     // Prepare to loop!
574
575     QString rule(originalRule);
576
577     // Force a termination at the end of the string to trigger a split
578     // Don't check for ";" splits as they may be escaped
579     if (!rule.endsWith("\n")) {
580         rule.append("\n");
581     }
582
583     // Result, sorted into normal and inverted rules
584     // Use QStringList instead of std::vector<QString> to make use of Qt's built-in .join() method
585     QStringList normalComponents = {}, invertComponents = {};
586
587     // Current character
588     QChar curChar = {};
589     // Current string
590     QString curString = {};
591     // Max length
592     int sourceLength = rule.length();
593     // Consecutive "\" characters
594     int consecutiveSlashes = 0;
595     // Whether or not this marks an inverted rule
596     bool isInverted = false;
597     // Whether or not we're at the beginning of the rule (for detecting "!" and "\!")
598     bool isRuleStart = true;
599
600     // We know it's going to have ";"-count items or less, so reserve ";"-count items for both.
601     // Without parsing it's not easily possible to tell which are escaped or not, and among the
602     // non-escaped entries, which are inverted or not.  These get destroyed once out of scope of
603     // this function, so balancing towards performance over memory usage should be okay, hopefully.
604     int separatorCount = rule.count(";");
605     normalComponents.reserve(separatorCount);
606     invertComponents.reserve(separatorCount);
607
608     // For every character...
609     for (int i = 0; i < sourceLength; i++) {
610         // Get the character
611         curChar = rule.at(i);
612         // Check if it's on the list of special list characters, converting to Unicode for use
613         // in the switch statement
614         //
615         // See https://doc.qt.io/qt-5/qchar.html#unicode
616         switch (curChar.unicode()) {
617         case ';':
618             // Separator found
619             switch (consecutiveSlashes) {
620             case 0:
621             case 2:
622                 // ";"   -> Split
623                 // ...or...
624                 // "\\;" -> Split (keep as "\\")
625                 // Not escaped separator, split into a new item
626
627                 // Apply the additional "\\" if needed
628                 if (consecutiveSlashes == 2) {
629                     // "\\;" -> Split (keep as "\\")
630                     curString.append(R"(\\)");
631                 }
632
633                 // Remove any whitespace, e.g. "item1; item2" -> " item2" -> "item2"
634                 curString = curString.trimmed();
635
636                 // Skip empty items
637                 if (!curString.isEmpty()) {
638                     // Add to inverted/normal list
639                     if (isInverted) {
640                         invertComponents.append(wildcardToRegEx(curString));
641                     }
642                     else {
643                         normalComponents.append(wildcardToRegEx(curString));
644                     }
645                 }
646                 // Reset the current list item
647                 curString.clear();
648                 isInverted = false;
649                 isRuleStart = true;
650                 break;
651             case 1:
652                 // "\;" -> Replace with ";"
653                 curString.append(";");
654                 isRuleStart = false;
655                 break;
656             default:
657                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
658                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
659                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
660                            << "character!";
661                 isRuleStart = false;
662                 break;
663             }
664             consecutiveSlashes = 0;
665             break;
666         case '!':
667             // Rule inverter found
668             if (isRuleStart) {
669                 // Apply the inverting logic
670                 switch (consecutiveSlashes) {
671                 case 0:
672                     // "!"   -> At start: mark as inverted
673                     isInverted = true;
674                     // Don't include the "!" character
675                     break;
676                 case 1:
677                     // "\!"  -> At start: replace with "!"
678                     curString.append("!");
679                     break;
680                 case 2:
681                     // "\\!" -> At start: keep as "\\!" (replaced with "\!" in wildcard conversion)
682                     curString.append(R"(\\!)");
683                     break;
684                 default:
685                     // This shouldn't ever happen (even with invalid wildcard rules), log a warning
686                     qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
687                                << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
688                                << "character!";
689                     break;
690                 }
691             }
692             else {
693                 // Preserve the characters as they are now
694                 switch (consecutiveSlashes) {
695                 case 0:
696                     // "!"    -> Elsewhere: keep as "!"
697                     curString.append("!");
698                     break;
699                 case 1:
700                 case 2:
701                     // "\!"  -> Elsewhere: keep as "\!"
702                     // "\\!" -> Elsewhere: keep as "\\!"
703                     curString.append(QString(R"(\)").repeated(consecutiveSlashes) + "!");
704                     break;
705                 default:
706                     // This shouldn't ever happen (even with invalid wildcard rules), log a warning
707                     qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
708                                << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
709                                << "character!";
710                     break;
711                 }
712             }
713             isRuleStart = false;
714             consecutiveSlashes = 0;
715             break;
716         case '\\':
717             // Split escape
718             // Increase consecutive slash count
719             consecutiveSlashes++;
720             // Check if we've reached "\\\"...
721             if (consecutiveSlashes == 3) {
722                 // "\\\" -> Keep as "\\" + "\"
723                 curString.append(R"(\\)");
724                 // No longer at the rule start
725                 isRuleStart = false;
726                 // Set consecutive slashes to 1, recognizing the trailing "\"
727                 consecutiveSlashes = 1;
728             }
729             else if (consecutiveSlashes > 3) {
730                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
731                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
732                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring" << curChar
733                            << "character!";
734                 break;
735             }
736             // Don't set "isRuleStart" here as "\" is used in escape sequences
737             break;
738         case '\n':
739             // Newline found
740             // Preserve the characters as they are now
741
742             // "[\n]"   -> Split
743             // "\[\n]"  -> Split (keep as "\")
744             // "\\[\n]" -> Split (keep as "\\")
745
746             switch (consecutiveSlashes) {
747             case 0:
748                 // Keep string as is
749                 break;
750             case 1:
751             case 2:
752                 // Apply the additional "\" or "\\"
753                 curString.append(QString(R"(\)").repeated(consecutiveSlashes));
754                 break;
755             default:
756                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
757                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
758                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), applying newline split anyways!";
759                 break;
760             }
761
762             // Remove any whitespace, e.g. "item1; item2" -> " item2" -> "item2"
763             curString = curString.trimmed();
764
765             // Skip empty items
766             if (!curString.isEmpty()) {
767                 // Add to inverted/normal list
768                 if (isInverted) {
769                     invertComponents.append(wildcardToRegEx(curString));
770                 }
771                 else {
772                     normalComponents.append(wildcardToRegEx(curString));
773                 }
774             }
775             // Reset the current list item
776             curString.clear();
777             isInverted = false;
778             isRuleStart = true;
779             consecutiveSlashes = 0;
780             break;
781         default:
782             // Preserve the characters as they are now
783             switch (consecutiveSlashes) {
784             case 0:
785                 // "..."   -> Keep as "..."
786                 curString.append(curChar);
787                 break;
788             case 1:
789             case 2:
790                 // "\..."  -> Keep as "\..."
791                 // "\\..." -> Keep as "\\..."
792                 curString.append(QString("\\").repeated(consecutiveSlashes) + curChar);
793                 break;
794             default:
795                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
796                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << rule << "resulted in rule component" << curString
797                            << "with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring " << curChar
798                            << "char escape!";
799                 break;
800             }
801             // Don't mark as past rule start for whitespace (whitespace gets trimmed)
802             if (!curChar.isSpace()) {
803                 isRuleStart = false;
804             }
805             consecutiveSlashes = 0;
806             break;
807         }
808     }
809
810     // Clean up any duplicates
811     normalComponents.removeDuplicates();
812     invertComponents.removeDuplicates();
813
814     // Create full regular expressions by...
815     // > Anchoring to start and end of string to mimic QRegExp's .exactMatch() handling, "^...$"
816     // > Enclosing within a non-capturing group to avoid overhead of text extraction, "(?:...)"
817     // > Flattening normal and inverted rules using the regex OR character "...|..."
818     //
819     // Before: [foo, bar, baz]
820     // After:  ^(?:foo|bar|baz)$
821     //
822     // See https://doc.qt.io/qt-5/qregularexpression.html#porting-from-qregexp-exactmatch
823     // And https://regex101.com/
824
825     // Any empty/invalid regex are handled within ExpressionMatch::match()
826     if (!normalComponents.isEmpty()) {
827         // Create normal match regex
828         if (normalComponents.count() == 1) {
829             // Single item, skip the noncapturing group
830             _matchRegEx = regExFactory("^" + normalComponents.at(0) + "$", caseSensitive);
831         }
832         else {
833             _matchRegEx = regExFactory("^(?:" + normalComponents.join("|") + ")$", caseSensitive);
834         }
835         _matchRegExActive = true;
836     }
837     if (!invertComponents.isEmpty()) {
838         // Create invert match regex
839         if (invertComponents.count() == 1) {
840             // Single item, skip the noncapturing group
841             _matchInvertRegEx = regExFactory("^" + invertComponents.at(0) + "$", caseSensitive);
842         }
843         else {
844             _matchInvertRegEx = regExFactory("^(?:" + invertComponents.join("|") + ")$", caseSensitive);
845         }
846         _matchInvertRegExActive = true;
847     }
848 }
849
850 QString ExpressionMatch::wildcardToRegEx(const QString& expression)
851 {
852     // Convert the wildcard expression into regular expression format
853
854     // We're taking a little bit different of a route...
855     //
856     // Original QRegExp::Wildcard rules:
857     // --------------------------
858     // Wildcard | Regex | Outcome
859     // ---------|-------|--------
860     // *        | .*    | zero or more of any character
861     // ?        | .     | any single character
862     //
863     // NOTE 1: This is QRegExp::Wildcard, not QRegExp::WildcardUnix
864     //
865     // NOTE 2: We are ignoring the "[...]" character-class matching functionality of
866     // QRegExp::Wildcard as that feature's a bit more complex and can be handled with full-featured
867     // regexes.
868     //
869     // See https://doc.qt.io/qt-5/qregexp.html#wildcard-matching
870     //
871     // Quassel originally did not use QRegExp::WildcardUnix, which prevented escaping "*" and "?" in
872     // messages.  Unfortunately, spam messages might decide to use both, so offering a way to escape
873     // makes sense.
874     //
875     // On the flip-side, that means to match "\" requires escaping as "\\", breaking backwards
876     // compatibility.
877     //
878     // Quassel's Wildcard rules
879     // ------------------------------------------
880     // Wildcard | Regex escaped | Regex | Outcome
881     // ---------|---------------|-------|--------
882     // *        | \*            | .*    | zero or more of any character
883     // ?        | \?            | .     | any single character
884     // \*       | \\\*          | \*    | literal "*"
885     // \?       | \\\?          | \?    | literal "?"
886     // \[...]   | \\[...]       | [...] | invalid escape, ignore it
887     // \\       | \\\\          | \\    | literal "\"
888     //
889     // In essence, "*" and "?" need changed only if not escaped, "\\" collapses into "\", "\" gets
890     // ignored; other characters escape normally.
891     //
892     // Example:
893     //
894     // > Wildcard rule
895     // never?gonna*give\*you\?up\\test|y\yeah\\1\\\\2\\\1inval
896     //
897     // ("\\\\" represents "\\", "\\" represents "\", and "\\\" is valid+invalid, "\")
898     //
899     // > Regex escaped wildcard rule
900     // never\?gonna\*give\\\*you\\\?up\\\\test\|y\\yeah\\\\1\\\\\\\\2\\\\\\1inval
901     //
902     // > Expected correct regex
903     // never.gonna.*give\*you\?up\\test\|yyeah\\1\\\\2\\1inval
904     //
905     // > Undoing regex escaping of "\" as "\\" (i.e. simple replace, with special escapes intact)
906     // never.gonna.*give\*you\?up\test\|yyeah\1\\2\1inval
907
908     // Escape string according to regex
909     QString regExEscaped(regExEscape(expression));
910
911     // Fix up the result
912     //
913     // NOTE: In theory, regular expression lookbehind could solve this.  Unfortunately, QRegExp does
914     // not support lookbehind, and it's theoretically inefficient, anyways.  Just use an approach
915     // similar to that taken by QRegExp's official wildcard mode.
916     //
917     // Lookbehind example (that we can't use):
918     // (?<!abc)test    Negative lookbehind - don't match if "test" is proceeded by "abc"
919     //
920     // See https://code.qt.io/cgit/qt/qtbase.git/tree/src/corelib/tools/qregexp.cpp
921     //
922     // NOTE: We don't copy QRegExp's mode as QRegularExpression has more special characters.  We
923     // can't use the same escaping code, hence calling the appropriate QReg[...]::escape() above.
924
925     // Prepare to loop!
926
927     // Result
928     QString result = {};
929     // Current character
930     QChar curChar = {};
931     // Max length
932     int sourceLength = regExEscaped.length();
933     // Consecutive "\" characters
934     int consecutiveSlashes = 0;
935
936     // We know it's going to be the same length or smaller, so reserve the same size as the string
937     result.reserve(sourceLength);
938
939     // For every character...
940     for (int i = 0; i < sourceLength; i++) {
941         // Get the character
942         curChar = regExEscaped.at(i);
943         // Check if it's on the list of special wildcard characters, converting to Unicode for use
944         // in the switch statement
945         //
946         // See https://doc.qt.io/qt-5/qchar.html#unicode
947         switch (curChar.unicode()) {
948         case '?':
949             // Wildcard "?"
950             switch (consecutiveSlashes) {
951             case 1:
952                 // "?" -> "\?" -> "."
953                 // Convert from regex escaped "?" to regular expression
954                 result.append(".");
955                 break;
956             case 3:
957                 // "\?" -> "\\\?" -> "\?"
958                 // Convert from regex escaped "\?" to literal string
959                 result.append(R"(\?)");
960                 break;
961             default:
962                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
963                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << expression << "resulted in escaped regular expression string"
964                            << regExEscaped << " with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring"
965                            << curChar << "character!";
966                 break;
967             }
968             consecutiveSlashes = 0;
969             break;
970         case '*':
971             // Wildcard "*"
972             switch (consecutiveSlashes) {
973             case 1:
974                 // "*" -> "\*" -> ".*"
975                 // Convert from regex escaped "*" to regular expression
976                 result.append(".*");
977                 break;
978             case 3:
979                 // "\*" -> "\\\*" -> "\*"
980                 // Convert from regex escaped "\*" to literal string
981                 result.append(R"(\*)");
982                 break;
983             default:
984                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
985                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << expression << "resulted in escaped regular expression string"
986                            << regExEscaped << " with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring"
987                            << curChar << "character!";
988                 break;
989             }
990             consecutiveSlashes = 0;
991             break;
992         case '\\':
993             // Wildcard escape
994             // Increase consecutive slash count
995             consecutiveSlashes++;
996             // Check if we've hit an escape sequence
997             if (consecutiveSlashes == 4) {
998                 // "\\" -> "\\\\" -> "\\"
999                 // Convert from regex escaped "\\" to literal string
1000                 result.append(R"(\\)");
1001                 // Reset slash count
1002                 consecutiveSlashes = 0;
1003             }
1004             break;
1005         default:
1006             // Any other character
1007             switch (consecutiveSlashes) {
1008             case 0:
1009             case 2:
1010                 // "[...]"  -> "[...]"   -> "[...]"
1011                 // ...or...
1012                 // "\[...]" -> "\\[...]" -> "[...]"
1013                 // Either just print the character itself, or convert from regex-escaped invalid
1014                 // wildcard escape sequence to the character itself
1015                 //
1016                 // Both mean doing nothing, the actual character [...] gets appended below
1017                 break;
1018             case 1:
1019                 // "[...]" -> "\[...]" -> "\"
1020                 // Keep regex-escaped special character "[...]" as literal string
1021                 // (Where "[...]" represents any non-wildcard regex special character)
1022                 result.append(R"(\)");
1023                 // The actual character [...] gets appended below
1024                 break;
1025             default:
1026                 // This shouldn't ever happen (even with invalid wildcard rules), log a warning
1027                 qWarning() << Q_FUNC_INFO << "Wildcard rule" << expression << "resulted in escaped regular expression string"
1028                            << regExEscaped << " with unexpected count of consecutive '\\' (" << consecutiveSlashes << "), ignoring"
1029                            << curChar << "char escape!";
1030                 break;
1031             }
1032             consecutiveSlashes = 0;
1033             // Add the character itself
1034             result.append(curChar);
1035             break;
1036         }
1037     }
1038
1039     // Anchoring to simulate QRegExp::exactMatch() is handled in
1040     // ExpressionMatch::convertFromWildcard()
1041     return result;
1042 }