diff --git a/CHANGELOG.md b/CHANGELOG.md index 287b096324c..b540c6b8670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) - Minor: Added the ability to reply to a message by `Shift + Right Click`ing the username. (#4424) +- Minor: Added better filter validation and error messages. (#4364) - Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e5bb4741384..a6de2b92eae 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -74,12 +74,26 @@ set(SOURCE_FILES controllers/filters/FilterRecord.hpp controllers/filters/FilterSet.cpp controllers/filters/FilterSet.hpp - controllers/filters/parser/FilterParser.cpp - controllers/filters/parser/FilterParser.hpp - controllers/filters/parser/Tokenizer.cpp - controllers/filters/parser/Tokenizer.hpp - controllers/filters/parser/Types.cpp - controllers/filters/parser/Types.hpp + controllers/filters/lang/expressions/Expression.cpp + controllers/filters/lang/expressions/Expression.hpp + controllers/filters/lang/expressions/BinaryOperation.cpp + controllers/filters/lang/expressions/BinaryOperation.hpp + controllers/filters/lang/expressions/ListExpression.cpp + controllers/filters/lang/expressions/ListExpression.hpp + controllers/filters/lang/expressions/RegexExpression.cpp + controllers/filters/lang/expressions/RegexExpression.hpp + controllers/filters/lang/expressions/UnaryOperation.hpp + controllers/filters/lang/expressions/UnaryOperation.cpp + controllers/filters/lang/expressions/ValueExpression.cpp + controllers/filters/lang/expressions/ValueExpression.hpp + controllers/filters/lang/Filter.cpp + controllers/filters/lang/Filter.hpp + controllers/filters/lang/FilterParser.cpp + controllers/filters/lang/FilterParser.hpp + controllers/filters/lang/Tokenizer.cpp + controllers/filters/lang/Tokenizer.hpp + controllers/filters/lang/Types.cpp + controllers/filters/lang/Types.hpp controllers/highlights/BadgeHighlightModel.cpp controllers/highlights/BadgeHighlightModel.hpp diff --git a/src/controllers/filters/FilterRecord.cpp b/src/controllers/filters/FilterRecord.cpp index 24bd22f6212..409aa64fa32 100644 --- a/src/controllers/filters/FilterRecord.cpp +++ b/src/controllers/filters/FilterRecord.cpp @@ -1,21 +1,40 @@ #include "controllers/filters/FilterRecord.hpp" +#include "controllers/filters/lang/Filter.hpp" + namespace chatterino { -FilterRecord::FilterRecord(const QString &name, const QString &filter) - : name_(name) - , filter_(filter) - , id_(QUuid::createUuid()) - , parser_(std::make_unique(filter)) +static std::unique_ptr buildFilter(const QString &filterText) +{ + using namespace filters; + auto result = Filter::fromString(filterText); + if (std::holds_alternative(result)) + { + auto filter = + std::make_unique(std::move(std::get(result))); + + if (filter->returnType() != Type::Bool) + { + // Only accept Bool results + return nullptr; + } + + return filter; + } + + return nullptr; +} + +FilterRecord::FilterRecord(QString name, QString filter) + : FilterRecord(std::move(name), std::move(filter), QUuid::createUuid()) { } -FilterRecord::FilterRecord(const QString &name, const QString &filter, - const QUuid &id) - : name_(name) - , filter_(filter) +FilterRecord::FilterRecord(QString name, QString filter, const QUuid &id) + : name_(std::move(name)) + , filterText_(std::move(filter)) , id_(id) - , parser_(std::make_unique(filter)) + , filter_(buildFilter(this->filterText_)) { } @@ -26,7 +45,7 @@ const QString &FilterRecord::getName() const const QString &FilterRecord::getFilter() const { - return this->filter_; + return this->filterText_; } const QUuid &FilterRecord::getId() const @@ -36,12 +55,13 @@ const QUuid &FilterRecord::getId() const bool FilterRecord::valid() const { - return this->parser_->valid(); + return this->filter_ != nullptr; } -bool FilterRecord::filter(const filterparser::ContextMap &context) const +bool FilterRecord::filter(const filters::ContextMap &context) const { - return this->parser_->execute(context); + assert(this->valid()); + return this->filter_->execute(context).toBool(); } bool FilterRecord::operator==(const FilterRecord &other) const diff --git a/src/controllers/filters/FilterRecord.hpp b/src/controllers/filters/FilterRecord.hpp index bb6eeeff327..c5f120040a8 100644 --- a/src/controllers/filters/FilterRecord.hpp +++ b/src/controllers/filters/FilterRecord.hpp @@ -1,6 +1,6 @@ #pragma once -#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/lang/Filter.hpp" #include "util/RapidjsonHelpers.hpp" #include "util/RapidJsonSerializeQString.hpp" @@ -16,9 +16,9 @@ namespace chatterino { class FilterRecord { public: - FilterRecord(const QString &name, const QString &filter); + FilterRecord(QString name, QString filter); - FilterRecord(const QString &name, const QString &filter, const QUuid &id); + FilterRecord(QString name, QString filter, const QUuid &id); const QString &getName() const; @@ -28,16 +28,16 @@ class FilterRecord bool valid() const; - bool filter(const filterparser::ContextMap &context) const; + bool filter(const filters::ContextMap &context) const; bool operator==(const FilterRecord &other) const; private: - QString name_; - QString filter_; - QUuid id_; + const QString name_; + const QString filterText_; + const QUuid id_; - std::unique_ptr parser_; + const std::unique_ptr filter_; }; using FilterRecordPtr = std::shared_ptr; diff --git a/src/controllers/filters/FilterSet.cpp b/src/controllers/filters/FilterSet.cpp index 8bd20414cf5..a6dbc1059d6 100644 --- a/src/controllers/filters/FilterSet.cpp +++ b/src/controllers/filters/FilterSet.cpp @@ -38,8 +38,7 @@ bool FilterSet::filter(const MessagePtr &m, ChannelPtr channel) const if (this->filters_.size() == 0) return true; - filterparser::ContextMap context = - filterparser::buildContextMap(m, channel.get()); + filters::ContextMap context = filters::buildContextMap(m, channel.get()); for (const auto &f : this->filters_.values()) { if (!f->valid() || !f->filter(context)) diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp new file mode 100644 index 00000000000..99b569c3c79 --- /dev/null +++ b/src/controllers/filters/lang/Filter.cpp @@ -0,0 +1,161 @@ +#include "controllers/filters/lang/Filter.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/filters/lang/FilterParser.hpp" +#include "messages/Message.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" + +namespace chatterino::filters { + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +{ + auto watchingChannel = chatterino::getApp()->twitch->watchingChannel.get(); + + /* + * Looking to add a new identifier to filters? Here's what to do: + * 1. Update validIdentifiersMap in Tokenizer.hpp + * 2. Add the identifier to the list below + * 3. Add the type of the identifier to MESSAGE_TYPING_CONTEXT in Filter.hpp + * 4. Add the value for the identifier to the ContextMap returned by this function + * + * List of identifiers: + * + * author.badges + * author.color + * author.name + * author.no_color + * author.subbed + * author.sub_length + * + * channel.name + * channel.watching + * + * flags.highlighted + * flags.points_redeemed + * flags.sub_message + * flags.system_message + * flags.reward_message + * flags.first_message + * flags.elevated_message + * flags.cheer_message + * flags.whisper + * flags.reply + * flags.automod + * + * message.content + * message.length + * + */ + + using MessageFlag = chatterino::MessageFlag; + + QStringList badges; + badges.reserve(m->badges.size()); + for (const auto &e : m->badges) + { + badges << e.key_; + } + + bool watching = !watchingChannel->getName().isEmpty() && + watchingChannel->getName().compare( + m->channelName, Qt::CaseInsensitive) == 0; + + bool subscribed = false; + int subLength = 0; + for (const auto &subBadge : {"subscriber", "founder"}) + { + if (!badges.contains(subBadge)) + { + continue; + } + subscribed = true; + if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) + { + subLength = m->badgeInfos.at(subBadge).toInt(); + } + } + ContextMap vars = { + {"author.badges", std::move(badges)}, + {"author.color", m->usernameColor}, + {"author.name", m->displayName}, + {"author.no_color", !m->usernameColor.isValid()}, + {"author.subbed", subscribed}, + {"author.sub_length", subLength}, + + {"channel.name", m->channelName}, + {"channel.watching", watching}, + + {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, + {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, + {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, + {"flags.system_message", m->flags.has(MessageFlag::System)}, + {"flags.reward_message", + m->flags.has(MessageFlag::RedeemedChannelPointReward)}, + {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, + {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, + {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, + {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, + {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, + {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, + + {"message.content", m->messageText}, + {"message.length", m->messageText.length()}, + }; + { + auto *tc = dynamic_cast(channel); + if (channel && !channel->isEmpty() && tc) + { + vars["channel.live"] = tc->isLive(); + } + else + { + vars["channel.live"] = false; + } + } + return vars; +} + +FilterResult Filter::fromString(const QString &str) +{ + FilterParser parser(str); + + if (parser.valid()) + { + auto exp = parser.release(); + auto typ = parser.returnType(); + return Filter(std::move(exp), typ); + } + + return FilterError{parser.errors().join("\n")}; +} + +Filter::Filter(ExpressionPtr expression, Type returnType) + : expression_(std::move(expression)) + , returnType_(returnType) +{ +} + +Type Filter::returnType() const +{ + return this->returnType_; +} + +QVariant Filter::execute(const ContextMap &context) const +{ + return this->expression_->execute(context); +} + +QString Filter::filterString() const +{ + return this->expression_->filterString(); +} + +QString Filter::debugString(const TypingContext &context) const +{ + return this->expression_->debug(context); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp new file mode 100644 index 00000000000..6b492a6a116 --- /dev/null +++ b/src/controllers/filters/lang/Filter.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +#include +#include + +namespace chatterino { + +class Channel; +struct Message; +using MessagePtr = std::shared_ptr; + +} // namespace chatterino + +namespace chatterino::filters { + +// MESSAGE_TYPING_CONTEXT maps filter variables to their expected type at evaluation. +// For example, flags.highlighted is a boolean variable, so it is marked as Type::Bool +// below. These variable types will be used to check whether a filter "makes sense", +// i.e. if all the variables and operators being used have compatible types. +static const QMap MESSAGE_TYPING_CONTEXT = { + {"author.badges", Type::StringList}, + {"author.color", Type::Color}, + {"author.name", Type::String}, + {"author.no_color", Type::Bool}, + {"author.subbed", Type::Bool}, + {"author.sub_length", Type::Int}, + {"channel.name", Type::String}, + {"channel.watching", Type::Bool}, + {"channel.live", Type::Bool}, + {"flags.highlighted", Type::Bool}, + {"flags.points_redeemed", Type::Bool}, + {"flags.sub_message", Type::Bool}, + {"flags.system_message", Type::Bool}, + {"flags.reward_message", Type::Bool}, + {"flags.first_message", Type::Bool}, + {"flags.elevated_message", Type::Bool}, + {"flags.cheer_message", Type::Bool}, + {"flags.whisper", Type::Bool}, + {"flags.reply", Type::Bool}, + {"flags.automod", Type::Bool}, + {"message.content", Type::String}, + {"message.length", Type::Int}, +}; + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); + +class Filter; +struct FilterError { + QString message; +}; + +using FilterResult = std::variant; + +class Filter +{ +public: + static FilterResult fromString(const QString &str); + + Type returnType() const; + QVariant execute(const ContextMap &context) const; + + QString filterString() const; + QString debugString(const TypingContext &context) const; + +private: + Filter(ExpressionPtr expression, Type returnType); + + ExpressionPtr expression_; + Type returnType_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/lang/FilterParser.cpp similarity index 67% rename from src/controllers/filters/parser/FilterParser.cpp rename to src/controllers/filters/lang/FilterParser.cpp index 00c5bd6b76c..00e3bf77618 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/lang/FilterParser.cpp @@ -1,132 +1,61 @@ -#include "FilterParser.hpp" +#include "controllers/filters/lang/FilterParser.hpp" -#include "Application.hpp" -#include "common/Channel.hpp" -#include "controllers/filters/parser/Types.hpp" -#include "messages/Message.hpp" -#include "providers/twitch/TwitchBadge.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/expressions/ListExpression.hpp" +#include "controllers/filters/lang/expressions/RegexExpression.hpp" +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" +#include "controllers/filters/lang/expressions/ValueExpression.hpp" +#include "controllers/filters/lang/Filter.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace filterparser { +namespace chatterino::filters { -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +QString explainIllType(const IllTyped &ill) { - auto watchingChannel = chatterino::getApp()->twitch->watchingChannel.get(); - - /* Known Identifiers - * - * author.badges - * author.color - * author.name - * author.no_color - * author.subbed - * author.sub_length - * - * channel.name - * channel.watching - * - * flags.highlighted - * flags.points_redeemed - * flags.sub_message - * flags.system_message - * flags.reward_message - * flags.first_message - * flags.elevated_message - * flags.cheer_message - * flags.whisper - * flags.reply - * flags.automod - * - * message.content - * message.length - * - */ - - using MessageFlag = chatterino::MessageFlag; + return QString("%1\n\nProblem occurred here:\n%2") + .arg(ill.message) + .arg(ill.expr->filterString()); +} - QStringList badges; - badges.reserve(m->badges.size()); - for (const auto &e : m->badges) +FilterParser::FilterParser(const QString &text) + : text_(text) + , tokenizer_(Tokenizer(text)) + , builtExpression_(this->parseExpression(true)) +{ + if (!this->valid_) { - badges << e.key_; + return; } - bool watching = !watchingChannel->getName().isEmpty() && - watchingChannel->getName().compare( - m->channelName, Qt::CaseInsensitive) == 0; - - bool subscribed = false; - int subLength = 0; - for (const auto &subBadge : {"subscriber", "founder"}) + // safety: returnType must not live longer than the parsed expression. See + // comment on IllTyped::expr. + auto returnType = + this->builtExpression_->synthesizeType(MESSAGE_TYPING_CONTEXT); + if (isIllTyped(returnType)) { - if (!badges.contains(subBadge)) - { - continue; - } - subscribed = true; - if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) - { - subLength = m->badgeInfos.at(subBadge).toInt(); - } + this->errorLog(explainIllType(std::get(returnType))); + return; } - ContextMap vars = { - {"author.badges", std::move(badges)}, - {"author.color", m->usernameColor}, - {"author.name", m->displayName}, - {"author.no_color", !m->usernameColor.isValid()}, - {"author.subbed", subscribed}, - {"author.sub_length", subLength}, - {"channel.name", m->channelName}, - {"channel.watching", watching}, - - {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, - {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, - {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, - {"flags.system_message", m->flags.has(MessageFlag::System)}, - {"flags.reward_message", - m->flags.has(MessageFlag::RedeemedChannelPointReward)}, - {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, - {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, - {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, - {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, - {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, - {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, - - {"message.content", m->messageText}, - {"message.length", m->messageText.length()}, - }; - { - using namespace chatterino; - auto *tc = dynamic_cast(channel); - if (channel && !channel->isEmpty() && tc) - { - vars["channel.live"] = tc->isLive(); - } - else - { - vars["channel.live"] = false; - } - } - return vars; + this->returnType_ = std::get(returnType).type; } -FilterParser::FilterParser(const QString &text) - : text_(text) - , tokenizer_(Tokenizer(text)) - , builtExpression_(this->parseExpression(true)) +bool FilterParser::valid() const { + return this->valid_; } -bool FilterParser::execute(const ContextMap &context) const +Type FilterParser::returnType() const { - return this->builtExpression_->execute(context).toBool(); + return this->returnType_; } -bool FilterParser::valid() const +ExpressionPtr FilterParser::release() { - return this->valid_; + ExpressionPtr ret; + this->builtExpression_.swap(ret); + return ret; } ExpressionPtr FilterParser::parseExpression(bool top) @@ -379,12 +308,7 @@ const QStringList &FilterParser::errors() const const QString FilterParser::debugString() const { - return this->builtExpression_->debug(); -} - -const QString FilterParser::filterString() const -{ - return this->builtExpression_->filterString(); + return this->builtExpression_->debug(MESSAGE_TYPING_CONTEXT); } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.hpp b/src/controllers/filters/lang/FilterParser.hpp similarity index 62% rename from src/controllers/filters/parser/FilterParser.hpp rename to src/controllers/filters/lang/FilterParser.hpp index 70037993e70..3f0344627fd 100644 --- a/src/controllers/filters/parser/FilterParser.hpp +++ b/src/controllers/filters/lang/FilterParser.hpp @@ -1,28 +1,22 @@ #pragma once -#include "controllers/filters/parser/Tokenizer.hpp" -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace chatterino { - -class Channel; - -} // namespace chatterino - -namespace filterparser { - -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); +namespace chatterino::filters { class FilterParser { public: FilterParser(const QString &text); - bool execute(const ContextMap &context) const; + bool valid() const; + Type returnType() const; + ExpressionPtr release(); const QStringList &errors() const; const QString debugString() const; - const QString filterString() const; private: ExpressionPtr parseExpression(bool top = false); @@ -41,5 +35,7 @@ class FilterParser QString text_; Tokenizer tokenizer_; ExpressionPtr builtExpression_; + Type returnType_ = Type::Bool; }; -} // namespace filterparser + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.cpp b/src/controllers/filters/lang/Tokenizer.cpp similarity index 71% rename from src/controllers/filters/parser/Tokenizer.cpp rename to src/controllers/filters/lang/Tokenizer.cpp index e1bb28bcb2a..e8d02e03486 100644 --- a/src/controllers/filters/parser/Tokenizer.cpp +++ b/src/controllers/filters/lang/Tokenizer.cpp @@ -1,8 +1,79 @@ -#include "controllers/filters/parser/Tokenizer.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" #include "common/QLogging.hpp" -namespace filterparser { +namespace chatterino::filters { + +QString tokenTypeToInfoString(TokenType type) +{ + switch (type) + { + case AND: + return "And"; + case OR: + return "Or"; + case LP: + return ""; + case RP: + return ""; + case LIST_START: + return ""; + case LIST_END: + return ""; + case COMMA: + return ""; + case PLUS: + return "Plus"; + case MINUS: + return "Minus"; + case MULTIPLY: + return "Multiply"; + case DIVIDE: + return "Divide"; + case MOD: + return "Mod"; + case EQ: + return "Eq"; + case NEQ: + return "NotEq"; + case LT: + return "LessThan"; + case GT: + return "GreaterThan"; + case LTE: + return "LessThanEq"; + case GTE: + return "GreaterThanEq"; + case CONTAINS: + return "Contains"; + case STARTS_WITH: + return "StartsWith"; + case ENDS_WITH: + return "EndsWith"; + case MATCH: + return "Match"; + case NOT: + return "Not"; + case STRING: + return ""; + case INT: + return ""; + case IDENTIFIER: + return ""; + case CONTROL_START: + case CONTROL_END: + case BINARY_START: + case BINARY_END: + case UNARY_START: + case UNARY_END: + case MATH_START: + case MATH_END: + case OTHER_START: + case NONE: + default: + return ""; + } +} Tokenizer::Tokenizer(const QString &text) { @@ -190,4 +261,4 @@ bool Tokenizer::typeIsMathOp(TokenType token) return token > TokenType::MATH_START && token < TokenType::MATH_END; } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp similarity index 70% rename from src/controllers/filters/parser/Tokenizer.hpp rename to src/controllers/filters/lang/Tokenizer.hpp index 59f4b9cefff..63c310b0183 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -1,12 +1,12 @@ #pragma once -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/Types.hpp" #include #include #include -namespace filterparser { +namespace chatterino::filters { static const QMap validIdentifiersMap = { {"author.badges", "author badges"}, @@ -17,7 +17,7 @@ static const QMap validIdentifiersMap = { {"author.sub_length", "author sub length"}, {"channel.name", "channel name"}, {"channel.watching", "/watching channel?"}, - {"channel.live", "Channel live?"}, + {"channel.live", "channel live?"}, {"flags.highlighted", "highlighted?"}, {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, @@ -42,6 +42,58 @@ static const QRegularExpression tokenRegex( ); // clang-format on +enum TokenType { + // control + CONTROL_START = 0, + AND = 1, + OR = 2, + LP = 3, + RP = 4, + LIST_START = 5, + LIST_END = 6, + COMMA = 7, + CONTROL_END = 19, + + // binary operator + BINARY_START = 20, + EQ = 21, + NEQ = 22, + LT = 23, + GT = 24, + LTE = 25, + GTE = 26, + CONTAINS = 27, + STARTS_WITH = 28, + ENDS_WITH = 29, + MATCH = 30, + BINARY_END = 49, + + // unary operator + UNARY_START = 50, + NOT = 51, + UNARY_END = 99, + + // math operators + MATH_START = 100, + PLUS = 101, + MINUS = 102, + MULTIPLY = 103, + DIVIDE = 104, + MOD = 105, + MATH_END = 149, + + // other types + OTHER_START = 150, + STRING = 151, + INT = 152, + IDENTIFIER = 153, + REGULAR_EXPRESSION = 154, + + NONE = 200 +}; + +QString tokenTypeToInfoString(TokenType type); + class Tokenizer { public: @@ -74,4 +126,4 @@ class Tokenizer TokenType tokenize(const QString &text); }; -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.cpp b/src/controllers/filters/lang/Types.cpp new file mode 100644 index 00000000000..66a715960ad --- /dev/null +++ b/src/controllers/filters/lang/Types.cpp @@ -0,0 +1,101 @@ +#include "controllers/filters/lang/Types.hpp" + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +bool isList(const PossibleType &possibleType) +{ + using T = Type; + if (isIllTyped(possibleType)) + { + return false; + } + + auto typ = std::get(possibleType); + return typ == T::List || typ == T::StringList || + typ == T::MatchingSpecifier; +} + +QString typeToString(Type type) +{ + using T = Type; + switch (type) + { + case T::String: + return "String"; + case T::Int: + return "Int"; + case T::Bool: + return "Bool"; + case T::Color: + return "Color"; + case T::RegularExpression: + return "RegularExpression"; + case T::List: + return "List"; + case T::StringList: + return "StringList"; + case T::MatchingSpecifier: + return "MatchingSpecifier"; + case T::Map: + return "Map"; + default: + return "Unknown"; + } +} + +QString TypeClass::string() const +{ + return typeToString(this->type); +} + +bool TypeClass::operator==(Type t) const +{ + return this->type == t; +} + +bool TypeClass::operator==(const TypeClass &t) const +{ + return this->type == t.type; +} + +bool TypeClass::operator==(const IllTyped &t) const +{ + return false; +} + +bool TypeClass::operator!=(Type t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const TypeClass &t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const IllTyped &t) const +{ + return true; +} + +QString IllTyped::string() const +{ + return "IllTyped"; +} + +QString possibleTypeToString(const PossibleType &possible) +{ + if (isWellTyped(possible)) + { + return std::get(possible).string(); + } + else + { + return std::get(possible).string(); + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.hpp b/src/controllers/filters/lang/Types.hpp new file mode 100644 index 00000000000..8debaa6975c --- /dev/null +++ b/src/controllers/filters/lang/Types.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace chatterino::filters { + +class Expression; + +enum class Type { + String, + Int, + Bool, + Color, + RegularExpression, + List, + StringList, // List of only strings + MatchingSpecifier, // 2-element list in {RegularExpression, Int} form + Map +}; + +using ContextMap = QMap; +using TypingContext = QMap; + +QString typeToString(Type type); + +struct IllTyped; + +struct TypeClass { + Type type; + + QString string() const; + + bool operator==(Type t) const; + bool operator==(const TypeClass &t) const; + bool operator==(const IllTyped &t) const; + bool operator!=(Type t) const; + bool operator!=(const TypeClass &t) const; + bool operator!=(const IllTyped &t) const; +}; + +struct IllTyped { + // Important nuance to expr: + // During type synthesis, should an error occur and an IllTyped PossibleType be + // returned, expr is a pointer to an Expression that exists in the Expression + // tree that was parsed. Therefore, you cannot hold on to this pointer longer + // than the Expression tree exists. Be careful! + const Expression *expr; + QString message; + + QString string() const; +}; + +using PossibleType = std::variant; + +inline bool isWellTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +inline bool isIllTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +QString possibleTypeToString(const PossibleType &possible); + +bool isList(const PossibleType &possibleType); + +inline bool variantIs(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) == type; +} + +inline bool variantIsNot(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) != type; +} + +inline bool convertVariantTypes(QVariant &a, QVariant &b, int type) +{ + return a.convert(type) && b.convert(type); +} + +inline bool variantTypesMatch(QVariant &a, QVariant &b, QMetaType::Type type) +{ + return variantIs(a, type) && variantIs(b, type); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.cpp b/src/controllers/filters/lang/expressions/BinaryOperation.cpp similarity index 53% rename from src/controllers/filters/parser/Types.cpp rename to src/controllers/filters/lang/expressions/BinaryOperation.cpp index 159e89ce9af..d3dbac4fe9c 100644 --- a/src/controllers/filters/parser/Types.cpp +++ b/src/controllers/filters/lang/expressions/BinaryOperation.cpp @@ -1,214 +1,8 @@ -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" -namespace filterparser { +#include -bool convertVariantTypes(QVariant &a, QVariant &b, int type) -{ - return a.convert(type) && b.convert(type); -} - -bool variantTypesMatch(QVariant &a, QVariant &b, QVariant::Type type) -{ - return a.type() == type && b.type() == type; -} - -QString tokenTypeToInfoString(TokenType type) -{ - switch (type) - { - case CONTROL_START: - case CONTROL_END: - case BINARY_START: - case BINARY_END: - case UNARY_START: - case UNARY_END: - case MATH_START: - case MATH_END: - case OTHER_START: - case NONE: - return ""; - case AND: - return ""; - case OR: - return ""; - case LP: - return ""; - case RP: - return ""; - case LIST_START: - return ""; - case LIST_END: - return ""; - case COMMA: - return ""; - case PLUS: - return ""; - case MINUS: - return ""; - case MULTIPLY: - return ""; - case DIVIDE: - return ""; - case MOD: - return ""; - case EQ: - return ""; - case NEQ: - return ""; - case LT: - return ""; - case GT: - return ""; - case LTE: - return ""; - case GTE: - return ""; - case CONTAINS: - return ""; - case STARTS_WITH: - return ""; - case ENDS_WITH: - return ""; - case MATCH: - return ""; - case NOT: - return ""; - case STRING: - return ""; - case INT: - return ""; - case IDENTIFIER: - return ""; - default: - return ""; - } -} - -// ValueExpression - -ValueExpression::ValueExpression(QVariant value, TokenType type) - : value_(value) - , type_(type){}; - -QVariant ValueExpression::execute(const ContextMap &context) const -{ - if (this->type_ == TokenType::IDENTIFIER) - { - return context.value(this->value_.toString()); - } - return this->value_; -} - -TokenType ValueExpression::type() -{ - return this->type_; -} - -QString ValueExpression::debug() const -{ - return this->value_.toString(); -} - -QString ValueExpression::filterString() const -{ - switch (this->type_) - { - case INT: - return QString::number(this->value_.toInt()); - case STRING: - return QString("\"%1\"").arg( - this->value_.toString().replace("\"", "\\\"")); - case IDENTIFIER: - return this->value_.toString(); - default: - return ""; - } -} - -// RegexExpression - -RegexExpression::RegexExpression(QString regex, bool caseInsensitive) - : regexString_(regex) - , caseInsensitive_(caseInsensitive) - , regex_(QRegularExpression( - regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption - : QRegularExpression::NoPatternOption)){}; - -QVariant RegexExpression::execute(const ContextMap &) const -{ - return this->regex_; -} - -QString RegexExpression::debug() const -{ - return this->regexString_; -} - -QString RegexExpression::filterString() const -{ - auto s = this->regexString_; - return QString("%1\"%2\"") - .arg(this->caseInsensitive_ ? "ri" : "r") - .arg(s.replace("\"", "\\\"")); -} - -// ListExpression - -ListExpression::ListExpression(ExpressionList list) - : list_(std::move(list)){}; - -QVariant ListExpression::execute(const ContextMap &context) const -{ - QList results; - bool allStrings = true; - for (const auto &exp : this->list_) - { - auto res = exp->execute(context); - if (allStrings && res.type() != QVariant::Type::String) - { - allStrings = false; - } - results.append(res); - } - - // if everything is a string return a QStringList for case-insensitive comparison - if (allStrings) - { - QStringList strings; - strings.reserve(results.size()); - for (const auto &val : results) - { - strings << val.toString(); - } - return strings; - } - else - { - return results; - } -} - -QString ListExpression::debug() const -{ - QStringList debugs; - for (const auto &exp : this->list_) - { - debugs.append(exp->debug()); - } - return QString("{%1}").arg(debugs.join(", ")); -} - -QString ListExpression::filterString() const -{ - QStringList strings; - for (const auto &exp : this->list_) - { - strings.append(QString("(%1)").arg(exp->filterString())); - } - return QString("{%1}").arg(strings.join(", ")); -} - -// BinaryOperation +namespace chatterino::filters { BinaryOperation::BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right) @@ -225,7 +19,8 @@ QVariant BinaryOperation::execute(const ContextMap &context) const switch (this->op_) { case PLUS: - if (left.type() == QVariant::Type::String && + if (static_cast(left.type()) == + QMetaType::QString && right.canConvert(QMetaType::QString)) { return left.toString().append(right.toString()); @@ -260,14 +55,14 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return left.toBool() && right.toBool(); return false; case EQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) == 0; } return left == right; case NEQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) != 0; @@ -290,20 +85,20 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return left.toInt() >= right.toInt(); return false; case CONTAINS: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { return left.toStringList().contains(right.toString(), Qt::CaseInsensitive); } - if (left.type() == QVariant::Type::Map && + if (variantIs(left.type(), QMetaType::QVariantMap) && right.canConvert(QMetaType::QString)) { return left.toMap().contains(right.toString()); } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().contains(right); } @@ -317,16 +112,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case STARTS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left.type(), QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.first().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().startsWith(right); } @@ -341,16 +136,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case ENDS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left.type(), QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.last().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().endsWith(right); } @@ -371,14 +166,14 @@ QVariant BinaryOperation::execute(const ContextMap &context) const auto matching = left.toString(); - switch (right.type()) + switch (static_cast(right.type())) { - case QVariant::Type::RegularExpression: { + case QMetaType::QRegularExpression: { return right.toRegularExpression() .match(matching) .hasMatch(); } - case QVariant::Type::List: { + case QMetaType::QVariantList: { auto list = right.toList(); // list must be two items @@ -386,19 +181,19 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; // list must be a regular expression and an int - if (list.at(0).type() != - QVariant::Type::RegularExpression || - list.at(1).type() != QVariant::Type::Int) + if (variantIsNot(list.at(0), + QMetaType::QRegularExpression) || + variantIsNot(list.at(1), QMetaType::Int)) return false; auto match = list.at(0).toRegularExpression().match(matching); - // if matched, return nth capture group. Otherwise, return false + // if matched, return nth capture group. Otherwise, return "" if (match.hasMatch()) return match.captured(list.at(1).toInt()); else - return false; + return ""; } default: return false; @@ -409,11 +204,105 @@ QVariant BinaryOperation::execute(const ContextMap &context) const } } -QString BinaryOperation::debug() const +PossibleType BinaryOperation::synthesizeType(const TypingContext &context) const { - return QString("(%1 %2 %3)") - .arg(this->left_->debug(), tokenTypeToInfoString(this->op_), - this->right_->debug()); + auto leftSyn = this->left_->synthesizeType(context); + auto rightSyn = this->right_->synthesizeType(context); + + // Return if either operand is ill-typed + if (isIllTyped(leftSyn)) + { + return leftSyn; + } + else if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto left = std::get(leftSyn); + auto right = std::get(rightSyn); + + switch (this->op_) + { + case PLUS: + if (left == Type::String) + return TypeClass{Type::String}; // String concatenation + else if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Int}; + + return IllTyped{this, "Can only add Ints or concatenate a String"}; + case MINUS: + case MULTIPLY: + case DIVIDE: + case MOD: + if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Int}; + + return IllTyped{this, "Can only perform operation with Ints"}; + case OR: + case AND: + if (left == Type::Bool && right == Type::Bool) + return TypeClass{Type::Bool}; + + return IllTyped{this, + "Can only perform logical operations with Bools"}; + case EQ: + case NEQ: + // equals/not equals always produces a valid output + return TypeClass{Type::Bool}; + case LT: + case GT: + case LTE: + case GTE: + if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Bool}; + + return IllTyped{this, "Can only perform comparisons with Ints"}; + case STARTS_WITH: + case ENDS_WITH: + if (isList(left)) + return TypeClass{Type::Bool}; + if (left == Type::String && right == Type::String) + return TypeClass{Type::Bool}; + + return IllTyped{ + this, + "Can only perform starts/ends with a List or two Strings"}; + case CONTAINS: + if (isList(left) || left == Type::Map) + return TypeClass{Type::Bool}; + if (left == Type::String && right == Type::String) + return TypeClass{Type::Bool}; + + return IllTyped{ + this, + "Can only perform contains with a List, a Map, or two Strings"}; + case MATCH: { + if (left != Type::String) + return IllTyped{this, + "Left argument of match must be a String"}; + + if (right == Type::RegularExpression) + return TypeClass{Type::Bool}; + if (right == Type::MatchingSpecifier) // group capturing + return TypeClass{Type::String}; + + return IllTyped{this, "Can only match on a RegularExpression or a " + "MatchingSpecifier"}; + } + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString BinaryOperation::debug(const TypingContext &context) const +{ + return QString("BinaryOp[%1](%2 : %3, %4 : %5)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->left_->debug(context)) + .arg(possibleTypeToString(this->left_->synthesizeType(context))) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); } QString BinaryOperation::filterString() const @@ -456,57 +345,14 @@ QString BinaryOperation::filterString() const case MATCH: return "match"; default: - return QString(); + return ""; } }(); - return QString("(%1) %2 (%3)") + return QString("(%1 %2 %3)") .arg(this->left_->filterString()) .arg(opText) .arg(this->right_->filterString()); } -// UnaryOperation - -UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) - : op_(op) - , right_(std::move(right)) -{ -} - -QVariant UnaryOperation::execute(const ContextMap &context) const -{ - auto right = this->right_->execute(context); - switch (this->op_) - { - case NOT: - if (right.canConvert()) - return !right.toBool(); - return false; - default: - return false; - } -} - -QString UnaryOperation::debug() const -{ - return QString("(%1 %2)").arg(tokenTypeToInfoString(this->op_), - this->right_->debug()); -} - -QString UnaryOperation::filterString() const -{ - const auto opText = [&]() -> QString { - switch (this->op_) - { - case NOT: - return "!"; - default: - return QString(); - } - }(); - - return QString("%1(%2)").arg(opText).arg(this->right_->filterString()); -} - -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/BinaryOperation.hpp b/src/controllers/filters/lang/expressions/BinaryOperation.hpp new file mode 100644 index 00000000000..b42f81bf5ad --- /dev/null +++ b/src/controllers/filters/lang/expressions/BinaryOperation.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class BinaryOperation : public Expression +{ +public: + BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr left_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.cpp b/src/controllers/filters/lang/expressions/Expression.cpp new file mode 100644 index 00000000000..74f987f2d0a --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.cpp @@ -0,0 +1,25 @@ +#include "controllers/filters/lang/expressions/Expression.hpp" + +namespace chatterino::filters { + +QVariant Expression::execute(const ContextMap & /*context*/) const +{ + return false; +} + +PossibleType Expression::synthesizeType(const TypingContext & /*context*/) const +{ + return IllTyped{this, "Not implemented"}; +} + +QString Expression::debug(const TypingContext & /*context*/) const +{ + return ""; +} + +QString Expression::filterString() const +{ + return ""; +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.hpp b/src/controllers/filters/lang/expressions/Expression.hpp new file mode 100644 index 00000000000..d08fa6ef2ee --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include +#include + +#include +#include + +namespace chatterino::filters { + +class Expression +{ +public: + virtual ~Expression() = default; + + virtual QVariant execute(const ContextMap &context) const; + virtual PossibleType synthesizeType(const TypingContext &context) const; + virtual QString debug(const TypingContext &context) const; + virtual QString filterString() const; +}; + +using ExpressionPtr = std::unique_ptr; +using ExpressionList = std::vector>; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.cpp b/src/controllers/filters/lang/expressions/ListExpression.cpp new file mode 100644 index 00000000000..cd57a64ecfd --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.cpp @@ -0,0 +1,94 @@ +#include "controllers/filters/lang/expressions/ListExpression.hpp" + +namespace chatterino::filters { + +ListExpression::ListExpression(ExpressionList &&list) + : list_(std::move(list)){}; + +QVariant ListExpression::execute(const ContextMap &context) const +{ + QList results; + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto res = exp->execute(context); + if (allStrings && variantIsNot(res.type(), QMetaType::QString)) + { + allStrings = false; + } + results.append(res); + } + + // if everything is a string return a QStringList for case-insensitive comparison + if (allStrings) + { + QStringList strings; + strings.reserve(results.size()); + for (const auto &val : results) + { + strings << val.toString(); + } + return strings; + } + + return results; +} + +PossibleType ListExpression::synthesizeType(const TypingContext &context) const +{ + std::vector types; + types.reserve(this->list_.size()); + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto typSyn = exp->synthesizeType(context); + if (isIllTyped(typSyn)) + { + return typSyn; // Ill-typed + } + + auto typ = std::get(typSyn); + + if (typ != Type::String) + { + allStrings = false; + } + + types.push_back(typ); + } + + if (types.size() == 2 && types[0] == Type::RegularExpression && + types[1] == Type::Int) + { + // Specific {RegularExpression, Int} form + return TypeClass{Type::MatchingSpecifier}; + } + + return allStrings ? TypeClass{Type::StringList} : TypeClass{Type::List}; +} + +QString ListExpression::debug(const TypingContext &context) const +{ + QStringList debugs; + for (const auto &exp : this->list_) + { + debugs.append( + QString("%1 : %2") + .arg(exp->debug(context)) + .arg(possibleTypeToString(exp->synthesizeType(context)))); + } + + return QString("List(%1)").arg(debugs.join(", ")); +} + +QString ListExpression::filterString() const +{ + QStringList strings; + for (const auto &exp : this->list_) + { + strings.append(exp->filterString()); + } + return QString("{%1}").arg(strings.join(", ")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.hpp b/src/controllers/filters/lang/expressions/ListExpression.hpp new file mode 100644 index 00000000000..6de6a46eecd --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ListExpression : public Expression +{ +public: + ListExpression(ExpressionList &&list); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + ExpressionList list_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.cpp b/src/controllers/filters/lang/expressions/RegexExpression.cpp new file mode 100644 index 00000000000..8481757348c --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.cpp @@ -0,0 +1,36 @@ +#include "controllers/filters/lang/expressions/RegexExpression.hpp" + +namespace chatterino::filters { + +RegexExpression::RegexExpression(const QString ®ex, bool caseInsensitive) + : regexString_(regex) + , caseInsensitive_(caseInsensitive) + , regex_(QRegularExpression( + regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption + : QRegularExpression::NoPatternOption)){}; + +QVariant RegexExpression::execute(const ContextMap & /*context*/) const +{ + return this->regex_; +} + +PossibleType RegexExpression::synthesizeType( + const TypingContext & /*context*/) const +{ + return TypeClass{Type::RegularExpression}; +} + +QString RegexExpression::debug(const TypingContext & /*context*/) const +{ + return QString("RegEx(%1)").arg(this->regexString_); +} + +QString RegexExpression::filterString() const +{ + auto s = this->regexString_; + return QString("%1\"%2\"") + .arg(this->caseInsensitive_ ? "ri" : "r") + .arg(s.replace("\"", "\\\"")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.hpp b/src/controllers/filters/lang/expressions/RegexExpression.hpp new file mode 100644 index 00000000000..75fa5a08863 --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +namespace chatterino::filters { + +class RegexExpression : public Expression +{ +public: + RegexExpression(const QString ®ex, bool caseInsensitive); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QString regexString_; + bool caseInsensitive_; + QRegularExpression regex_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.cpp b/src/controllers/filters/lang/expressions/UnaryOperation.cpp new file mode 100644 index 00000000000..11f487aa1f2 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.cpp @@ -0,0 +1,69 @@ +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" + +namespace chatterino::filters { + +UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) + : op_(op) + , right_(std::move(right)) +{ +} + +QVariant UnaryOperation::execute(const ContextMap &context) const +{ + auto right = this->right_->execute(context); + switch (this->op_) + { + case NOT: + return right.canConvert() && !right.toBool(); + default: + return false; + } +} + +PossibleType UnaryOperation::synthesizeType(const TypingContext &context) const +{ + auto rightSyn = this->right_->synthesizeType(context); + if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto right = std::get(rightSyn); + + switch (this->op_) + { + case NOT: + if (right == Type::Bool) + { + return TypeClass{Type::Bool}; + } + return IllTyped{this, "Can only negate boolean values"}; + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString UnaryOperation::debug(const TypingContext &context) const +{ + return QString("UnaryOp[%1](%2 : %3)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); +} + +QString UnaryOperation::filterString() const +{ + const auto opText = [&]() -> QString { + switch (this->op_) + { + case NOT: + return "!"; + default: + return ""; + } + }(); + + return QString("(%1%2)").arg(opText).arg(this->right_->filterString()); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.hpp b/src/controllers/filters/lang/expressions/UnaryOperation.hpp new file mode 100644 index 00000000000..155a78b7119 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class UnaryOperation : public Expression +{ +public: + UnaryOperation(TokenType op, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.cpp b/src/controllers/filters/lang/expressions/ValueExpression.cpp new file mode 100644 index 00000000000..23244613de3 --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.cpp @@ -0,0 +1,70 @@ +#include "controllers/filters/lang/expressions/ValueExpression.hpp" + +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +ValueExpression::ValueExpression(QVariant value, TokenType type) + : value_(std::move(value)) + , type_(type) +{ +} + +QVariant ValueExpression::execute(const ContextMap &context) const +{ + if (this->type_ == TokenType::IDENTIFIER) + { + return context.value(this->value_.toString()); + } + return this->value_; +} + +PossibleType ValueExpression::synthesizeType(const TypingContext &context) const +{ + switch (this->type_) + { + case TokenType::IDENTIFIER: { + auto it = context.find(this->value_.toString()); + if (it != context.end()) + { + return TypeClass{it.value()}; + } + + return IllTyped{this, "Unbound identifier"}; + } + case TokenType::INT: + return TypeClass{Type::Int}; + case TokenType::STRING: + return TypeClass{Type::String}; + default: + return IllTyped{this, "Invalid value type"}; + } +} + +TokenType ValueExpression::type() +{ + return this->type_; +} + +QString ValueExpression::debug(const TypingContext & /*context*/) const +{ + return QString("Val(%1)").arg(this->value_.toString()); +} + +QString ValueExpression::filterString() const +{ + switch (this->type_) + { + case INT: + return QString::number(this->value_.toInt()); + case STRING: + return QString("\"%1\"").arg( + this->value_.toString().replace("\"", "\\\"")); + case IDENTIFIER: + return this->value_.toString(); + default: + return ""; + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.hpp b/src/controllers/filters/lang/expressions/ValueExpression.hpp new file mode 100644 index 00000000000..56cbf80c43b --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ValueExpression : public Expression +{ +public: + ValueExpression(QVariant value, TokenType type); + TokenType type(); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QVariant value_; + TokenType type_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.hpp b/src/controllers/filters/parser/Types.hpp deleted file mode 100644 index d6fcd7c9ecb..00000000000 --- a/src/controllers/filters/parser/Types.hpp +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include - -#include - -namespace chatterino { - -struct Message; - -} - -namespace filterparser { - -using MessagePtr = std::shared_ptr; -using ContextMap = QMap; - -enum TokenType { - // control - CONTROL_START = 0, - AND = 1, - OR = 2, - LP = 3, - RP = 4, - LIST_START = 5, - LIST_END = 6, - COMMA = 7, - CONTROL_END = 19, - - // binary operator - BINARY_START = 20, - EQ = 21, - NEQ = 22, - LT = 23, - GT = 24, - LTE = 25, - GTE = 26, - CONTAINS = 27, - STARTS_WITH = 28, - ENDS_WITH = 29, - MATCH = 30, - BINARY_END = 49, - - // unary operator - UNARY_START = 50, - NOT = 51, - UNARY_END = 99, - - // math operators - MATH_START = 100, - PLUS = 101, - MINUS = 102, - MULTIPLY = 103, - DIVIDE = 104, - MOD = 105, - MATH_END = 149, - - // other types - OTHER_START = 150, - STRING = 151, - INT = 152, - IDENTIFIER = 153, - REGULAR_EXPRESSION = 154, - - NONE = 200 -}; - -bool convertVariantTypes(QVariant &a, QVariant &b, int type); -QString tokenTypeToInfoString(TokenType type); - -class Expression -{ -public: - virtual ~Expression() = default; - - virtual QVariant execute(const ContextMap &) const - { - return false; - } - - virtual QString debug() const - { - return "(false)"; - } - - virtual QString filterString() const - { - return ""; - } -}; - -using ExpressionPtr = std::unique_ptr; - -class ValueExpression : public Expression -{ -public: - ValueExpression(QVariant value, TokenType type); - TokenType type(); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QVariant value_; - TokenType type_; -}; - -class RegexExpression : public Expression -{ -public: - RegexExpression(QString regex, bool caseInsensitive); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QString regexString_; - bool caseInsensitive_; - QRegularExpression regex_; -}; - -using ExpressionList = std::vector>; - -class ListExpression : public Expression -{ -public: - ListExpression(ExpressionList list); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - ExpressionList list_; -}; - -class BinaryOperation : public Expression -{ -public: - BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr left_; - ExpressionPtr right_; -}; - -class UnaryOperation : public Expression -{ -public: - UnaryOperation(TokenType op, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr right_; -}; - -} // namespace filterparser diff --git a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp index 682328f43b0..068cc64a826 100644 --- a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp +++ b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp @@ -1,6 +1,6 @@ -#include "ChannelFilterEditorDialog.hpp" +#include "widgets/dialogs/ChannelFilterEditorDialog.hpp" -#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" #include #include @@ -99,7 +99,8 @@ ChannelFilterEditorDialog::ValueSpecifier::ValueSpecifier() this->typeCombo_->insertItems( 0, {"Constant Text", "Constant Number", "Variable"}); - this->varCombo_->insertItems(0, filterparser::validIdentifiersMap.values()); + + this->varCombo_->insertItems(0, filters::validIdentifiersMap.values()); this->layout_->addWidget(this->typeCombo_); this->layout_->addWidget(this->varCombo_, 1); @@ -141,7 +142,7 @@ void ChannelFilterEditorDialog::ValueSpecifier::setValue(const QString &value) if (this->typeCombo_->currentIndex() == 2) { this->varCombo_->setCurrentText( - filterparser::validIdentifiersMap.value(value)); + filters::validIdentifiersMap.value(value)); } else { @@ -164,7 +165,7 @@ QString ChannelFilterEditorDialog::ValueSpecifier::expressionText() case 1: // number return this->valueInput_->text(); case 2: // variable - return filterparser::validIdentifiersMap.key( + return filters::validIdentifiersMap.key( this->varCombo_->currentText()); default: return ""; @@ -221,7 +222,7 @@ QString ChannelFilterEditorDialog::BinaryOperationSpecifier::expressionText() return this->left_->expressionText(); } - return QString("(%1) %2 (%3)") + return QString("(%1 %2 %3)") .arg(this->left_->expressionText()) .arg(opText) .arg(this->right_->expressionText()); diff --git a/src/widgets/settingspages/FiltersPage.cpp b/src/widgets/settingspages/FiltersPage.cpp index 1268ff132e8..e28c7c5a754 100644 --- a/src/widgets/settingspages/FiltersPage.cpp +++ b/src/widgets/settingspages/FiltersPage.cpp @@ -91,23 +91,39 @@ void FiltersPage::tableCellClicked(const QModelIndex &clicked, { QMessageBox popup(this->window()); - filterparser::FilterParser f( - view->getModel()->data(clicked.siblingAtColumn(1)).toString()); + auto filterText = + view->getModel()->data(clicked.siblingAtColumn(1)).toString(); + auto filterResult = filters::Filter::fromString(filterText); - if (f.valid()) + if (std::holds_alternative(filterResult)) { - popup.setIcon(QMessageBox::Icon::Information); - popup.setWindowTitle("Valid filter"); - popup.setText("Filter is valid"); - popup.setInformativeText( - QString("Parsed as:\n%1").arg(f.filterString())); + auto f = std::move(std::get(filterResult)); + if (f.returnType() == filters::Type::Bool) + { + popup.setIcon(QMessageBox::Icon::Information); + popup.setWindowTitle("Valid filter"); + popup.setText("Filter is valid"); + popup.setInformativeText( + QString("Parsed as:\n%1").arg(f.filterString())); + } + else + { + popup.setIcon(QMessageBox::Icon::Warning); + popup.setWindowTitle("Invalid filter"); + popup.setText(QString("Unexpected filter return type")); + popup.setInformativeText( + QString("Expected %1 but got %2") + .arg(filters::typeToString(filters::Type::Bool)) + .arg(filters::typeToString(f.returnType()))); + } } else { + auto err = std::move(std::get(filterResult)); popup.setIcon(QMessageBox::Icon::Warning); popup.setWindowTitle("Invalid filter"); popup.setText(QString("Parsing errors occurred:")); - popup.setInformativeText(f.errors().join("\n")); + popup.setInformativeText(err.message); } popup.exec(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7c9afa29d9d..a3cb5ef0e59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp ${CMAKE_CURRENT_LIST_DIR}/src/BttvLiveUpdates.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Updates.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Filters.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp # Add your new file above this line! ) diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp new file mode 100644 index 00000000000..97763dc1dce --- /dev/null +++ b/tests/src/Filters.cpp @@ -0,0 +1,172 @@ +#include "controllers/filters/lang/Filter.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include +#include +#include + +using namespace chatterino; +using namespace chatterino::filters; + +TypingContext typingContext = MESSAGE_TYPING_CONTEXT; + +namespace chatterino::filters { + +std::ostream &operator<<(std::ostream &os, Type t) +{ + os << qUtf8Printable(typeToString(t)); + return os; +} + +} // namespace chatterino::filters + +TEST(Filters, Validity) +{ + struct TestCase { + QString input; + bool valid; + }; + + // clang-format off + std::vector tests{ + {"", false}, + {R".(1 + 1).", true}, + {R".(1 + ).", false}, + {R".(1 + 1)).", false}, + {R".((1 + 1).", false}, + {R".(author.name contains "icelys").", true}, + {R".(author.color == "#ff0000").", true}, + {R".(author.name - 5).", false}, // can't perform String - Int + {R".(message.content match {r"(\d\d)/(\d\d)/(\d\d\d\d)", 3}).", true}, + {R".("abc" + 123 == "abc123").", true}, + {R".(123 + "abc" == "hello").", false}, + {R".(flags.reply && flags.automod).", true}, + {R".(unknown.identifier).", false}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", true}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + EXPECT_EQ(isValid, expected) + << "Filter::fromString( " << qUtf8Printable(input) + << " ) should be " << (expected ? "valid" : "invalid"); + } +} + +TEST(Filters, TypeSynthesis) +{ + using T = Type; + struct TestCase { + QString input; + T type; + }; + + // clang-format off + std::vector tests + { + {R".(1 + 1).", T::Int}, + {R".(author.color).", T::Color}, + {R".(author.name).", T::String}, + {R".(!author.subbed).", T::Bool}, + {R".(author.badges).", T::StringList}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", T::Bool}, + {R".(message.content match {r"(\d\d)/(\d\d)/(\d\d\d\d)", 3}).", T::String}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) + << " ) is invalid"; + + auto filter = std::move(std::get(filterResult)); + T type = filter.returnType(); + EXPECT_EQ(type, expected) + << "Filter{ " << qUtf8Printable(input) << " } has type " << type + << " instead of " << expected << ".\nDebug: " + << qUtf8Printable(filter.debugString(typingContext)); + } +} + +TEST(Filters, Evaluation) +{ + struct TestCase { + QString input; + QVariant output; + }; + + ContextMap contextMap = { + {"author.name", QVariant("icelys")}, + {"author.color", QVariant(QColor("#ff0000"))}, + {"author.subbed", QVariant(false)}, + {"message.content", QVariant("hey there :) 2038-01-19 123 456")}, + {"channel.name", QVariant("forsen")}, + {"author.badges", QVariant(QStringList({"moderator", "staff"}))}}; + + // clang-format off + std::vector tests + { + // Evaluation semantics + {R".(1 + 1).", QVariant(2)}, + {R".(!(1 == 1)).", QVariant(false)}, + {R".(2 + 3 * 4).", QVariant(20)}, // math operators have the same precedence + {R".(1 > 2 || 3 >= 3).", QVariant(true)}, + {R".(1 > 2 && 3 > 1).", QVariant(false)}, + {R".("abc" + 123).", QVariant("abc123")}, + {R".("abc" + "456").", QVariant("abc456")}, + {R".(3 - 4).", QVariant(-1)}, + {R".(3 * 4).", QVariant(12)}, + {R".(8 / 3).", QVariant(2)}, + {R".(7 % 3).", QVariant(1)}, + {R".(5 == 5).", QVariant(true)}, + {R".(5 == "5").", QVariant(true)}, + {R".(5 != 7).", QVariant(true)}, + {R".(5 == "abc").", QVariant(false)}, + {R".("ABC123" == "abc123").", QVariant(true)}, // String comparison is case-insensitive + {R".("Hello world" contains "Hello").", QVariant(true)}, + {R".("Hello world" contains "LLO W").", QVariant(true)}, // Case-insensitive + {R".({"abc", "def"} contains "abc").", QVariant(true)}, + {R".({"abc", "def"} contains "ABC").", QVariant(true)}, // Case-insensitive when list is all strings + {R".({123, "def"} contains "DEF").", QVariant(false)}, // Case-sensitive if list not all strings + {R".({"a123", "b456"} startswith "a123").", QVariant(true)}, + {R".({"a123", "b456"} startswith "A123").", QVariant(true)}, + {R".({} startswith "A123").", QVariant(false)}, + {R".("Hello world" startswith "Hello").", QVariant(true)}, + {R".("Hello world" startswith "world").", QVariant(false)}, + {R".({"a123", "b456"} endswith "b456").", QVariant(true)}, + {R".({"a123", "b456"} endswith "B456").", QVariant(true)}, + {R".("Hello world" endswith "world").", QVariant(true)}, + {R".("Hello world" endswith "Hello").", QVariant(false)}, + // Context map usage + {R".(author.name).", QVariant("icelys")}, + {R".(!author.subbed).", QVariant(true)}, + {R".(author.color == "#ff0000").", QVariant(true)}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", QVariant(true)}, + {R".(message.content match {r"(\d\d\d\d)\-(\d\d)\-(\d\d)", 3}).", QVariant("19")}, + {R".(message.content match r"HEY THERE").", QVariant(false)}, + {R".(message.content match ri"HEY THERE").", QVariant(true)}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) + << " ) is invalid"; + + auto filter = std::move(std::get(filterResult)); + auto result = filter.execute(contextMap); + + EXPECT_EQ(result, expected) + << "Filter{ " << qUtf8Printable(input) << " } evaluated to " + << qUtf8Printable(result.toString()) << " instead of " + << qUtf8Printable(expected.toString()) << ".\nDebug: " + << qUtf8Printable(filter.debugString(typingContext)); + } +}