Implement type checking/validation for filters (#4364)

Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel Sage 2023-04-09 17:35:06 -04:00 committed by GitHub
parent c8e1741e47
commit 34db692895
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1452 additions and 632 deletions

View file

@ -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)

View file

@ -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

View file

@ -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<filterparser::FilterParser>(filter))
static std::unique_ptr<filters::Filter> buildFilter(const QString &filterText)
{
using namespace filters;
auto result = Filter::fromString(filterText);
if (std::holds_alternative<Filter>(result))
{
auto filter =
std::make_unique<Filter>(std::move(std::get<Filter>(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<filterparser::FilterParser>(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

View file

@ -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 @@ public:
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<filterparser::FilterParser> parser_;
const std::unique_ptr<filters::Filter> filter_;
};
using FilterRecordPtr = std::shared_ptr<FilterRecord>;

View file

@ -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))

View file

@ -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<TwitchChannel *>(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

View file

@ -0,0 +1,77 @@
#pragma once
#include "controllers/filters/lang/expressions/Expression.hpp"
#include "controllers/filters/lang/Types.hpp"
#include <QString>
#include <memory>
#include <variant>
namespace chatterino {
class Channel;
struct Message;
using MessagePtr = std::shared_ptr<const Message>;
} // 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<QString, Type> 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<Filter, FilterError>;
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

View file

@ -1,115 +1,21 @@
#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;
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()},
};
{
using namespace chatterino;
auto *tc = dynamic_cast<TwitchChannel *>(channel);
if (channel && !channel->isEmpty() && tc)
{
vars["channel.live"] = tc->isLive();
}
else
{
vars["channel.live"] = false;
}
}
return vars;
return QString("%1\n\nProblem occurred here:\n%2")
.arg(ill.message)
.arg(ill.expr->filterString());
}
FilterParser::FilterParser(const QString &text)
@ -117,11 +23,22 @@ FilterParser::FilterParser(const QString &text)
, tokenizer_(Tokenizer(text))
, builtExpression_(this->parseExpression(true))
{
}
if (!this->valid_)
{
return;
}
bool FilterParser::execute(const ContextMap &context) const
{
return this->builtExpression_->execute(context).toBool();
// 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))
{
this->errorLog(explainIllType(std::get<IllTyped>(returnType)));
return;
}
this->returnType_ = std::get<TypeClass>(returnType).type;
}
bool FilterParser::valid() const
@ -129,6 +46,18 @@ bool FilterParser::valid() const
return this->valid_;
}
Type FilterParser::returnType() const
{
return this->returnType_;
}
ExpressionPtr FilterParser::release()
{
ExpressionPtr ret;
this->builtExpression_.swap(ret);
return ret;
}
ExpressionPtr FilterParser::parseExpression(bool top)
{
auto e = this->parseAnd();
@ -379,12 +308,7 @@ const QStringList &FilterParser::errors() const
const QString FilterParser::debugString() const
{
return this->builtExpression_->debug();
return this->builtExpression_->debug(MESSAGE_TYPING_CONTEXT);
}
const QString FilterParser::filterString() const
{
return this->builtExpression_->filterString();
}
} // namespace filterparser
} // namespace chatterino::filters

View file

@ -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 @@ private:
QString text_;
Tokenizer tokenizer_;
ExpressionPtr builtExpression_;
Type returnType_ = Type::Bool;
};
} // namespace filterparser
} // namespace chatterino::filters

View file

@ -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 "<left parenthesis>";
case RP:
return "<right parenthesis>";
case LIST_START:
return "<list start>";
case LIST_END:
return "<list end>";
case COMMA:
return "<comma>";
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 "<string>";
case INT:
return "<int>";
case IDENTIFIER:
return "<identifier>";
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 "<unknown>";
}
}
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

View file

@ -1,12 +1,12 @@
#pragma once
#include "controllers/filters/parser/Types.hpp"
#include "controllers/filters/lang/Types.hpp"
#include <QMap>
#include <QRegularExpression>
#include <QString>
namespace filterparser {
namespace chatterino::filters {
static const QMap<QString, QString> validIdentifiersMap = {
{"author.badges", "author badges"},
@ -17,7 +17,7 @@ static const QMap<QString, QString> 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 @@ private:
TokenType tokenize(const QString &text);
};
} // namespace filterparser
} // namespace chatterino::filters

View file

@ -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<TypeClass>(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<TypeClass>(possible).string();
}
else
{
return std::get<IllTyped>(possible).string();
}
}
} // namespace chatterino::filters

View file

@ -0,0 +1,95 @@
#pragma once
#include <QMap>
#include <QString>
#include <QVariant>
#include <memory>
#include <set>
#include <variant>
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<QString, QVariant>;
using TypingContext = QMap<QString, Type>;
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<TypeClass, IllTyped>;
inline bool isWellTyped(const PossibleType &possible)
{
return std::holds_alternative<TypeClass>(possible);
}
inline bool isIllTyped(const PossibleType &possible)
{
return std::holds_alternative<IllTyped>(possible);
}
QString possibleTypeToString(const PossibleType &possible);
bool isList(const PossibleType &possibleType);
inline bool variantIs(const QVariant &a, QMetaType::Type type)
{
return static_cast<QMetaType::Type>(a.type()) == type;
}
inline bool variantIsNot(const QVariant &a, QMetaType::Type type)
{
return static_cast<QMetaType::Type>(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

View file

@ -1,214 +1,8 @@
#include "controllers/filters/parser/Types.hpp"
#include "controllers/filters/lang/expressions/BinaryOperation.hpp"
namespace filterparser {
#include <QRegularExpression>
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 "<unknown>";
case AND:
return "<and>";
case OR:
return "<or>";
case LP:
return "<left parenthesis>";
case RP:
return "<right parenthesis>";
case LIST_START:
return "<list start>";
case LIST_END:
return "<list end>";
case COMMA:
return "<comma>";
case PLUS:
return "<plus>";
case MINUS:
return "<minus>";
case MULTIPLY:
return "<multiply>";
case DIVIDE:
return "<divide>";
case MOD:
return "<modulus>";
case EQ:
return "<equals>";
case NEQ:
return "<not equals>";
case LT:
return "<less than>";
case GT:
return "<greater than>";
case LTE:
return "<less than equal>";
case GTE:
return "<greater than equal>";
case CONTAINS:
return "<contains>";
case STARTS_WITH:
return "<starts with>";
case ENDS_WITH:
return "<ends with>";
case MATCH:
return "<match>";
case NOT:
return "<not>";
case STRING:
return "<string>";
case INT:
return "<int>";
case IDENTIFIER:
return "<identifier>";
default:
return "<unknown>";
}
}
// 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<QVariant> 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<QMetaType::Type>(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<QMetaType::Type>(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<TypeClass>(leftSyn);
auto right = std::get<TypeClass>(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<bool>())
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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,28 @@
#pragma once
#include "controllers/filters/lang/Tokenizer.hpp"
#include "controllers/filters/lang/Types.hpp"
#include <QString>
#include <QVariant>
#include <memory>
#include <vector>
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<Expression>;
using ExpressionList = std::vector<std::unique_ptr<Expression>>;
} // namespace chatterino::filters

View file

@ -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<QVariant> 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<TypeClass> 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<TypeClass>(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

View file

@ -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

View file

@ -0,0 +1,36 @@
#include "controllers/filters/lang/expressions/RegexExpression.hpp"
namespace chatterino::filters {
RegexExpression::RegexExpression(const QString &regex, 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

View file

@ -0,0 +1,26 @@
#pragma once
#include "controllers/filters/lang/expressions/Expression.hpp"
#include "controllers/filters/lang/Types.hpp"
#include <QRegularExpression>
namespace chatterino::filters {
class RegexExpression : public Expression
{
public:
RegexExpression(const QString &regex, 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

View file

@ -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<bool>() && !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<TypeClass>(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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,168 +0,0 @@
#pragma once
#include <QRegularExpression>
#include <memory>
namespace chatterino {
struct Message;
}
namespace filterparser {
using MessagePtr = std::shared_ptr<const chatterino::Message>;
using ContextMap = QMap<QString, QVariant>;
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<Expression>;
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<std::unique_ptr<Expression>>;
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

View file

@ -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 <QLabel>
#include <QPushButton>
@ -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());

View file

@ -91,10 +91,14 @@ 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<filters::Filter>(filterResult))
{
auto f = std::move(std::get<filters::Filter>(filterResult));
if (f.returnType() == filters::Type::Bool)
{
popup.setIcon(QMessageBox::Icon::Information);
popup.setWindowTitle("Valid filter");
@ -104,10 +108,22 @@ void FiltersPage::tableCellClicked(const QModelIndex &clicked,
}
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<filters::FilterError>(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();

View file

@ -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!
)

172
tests/src/Filters.cpp Normal file
View file

@ -0,0 +1,172 @@
#include "controllers/filters/lang/Filter.hpp"
#include "controllers/filters/lang/Types.hpp"
#include <gtest/gtest.h>
#include <QColor>
#include <QVariant>
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<TestCase> 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<Filter>(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<TestCase> 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<Filter>(filterResult);
ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input)
<< " ) is invalid";
auto filter = std::move(std::get<Filter>(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<TestCase> 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<Filter>(filterResult);
ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input)
<< " ) is invalid";
auto filter = std::move(std::get<Filter>(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));
}
}