From d36865518e36acaedbc8ed971c89a46554a63e2f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:14:49 +0200 Subject: [PATCH 01/15] [Game] Extract makeGameJoinLink helper for cockatrice://joingame links (#7133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline URL building in GameSelector's copy-link action moves into a shared helper so every invite/copy site produces the same link format. The helper embeds the game description as an extra "game" query item (percent-encoded); links without it stay valid — the receiving parser ignores unknown query items. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../interface/widgets/server/game_link.cpp | 23 +++++++++++ .../src/interface/widgets/server/game_link.h | 41 +++++++++++++++++++ .../widgets/server/game_selector.cpp | 16 ++------ 4 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 cockatrice/src/interface/widgets/server/game_link.cpp create mode 100644 cockatrice/src/interface/widgets/server/game_link.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 6fd683461..9ea463eef 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -246,6 +246,7 @@ set(cockatrice_SOURCES src/interface/widgets/replay/replay_widget.cpp src/interface/widgets/server/chat_view/chat_view.cpp src/interface/widgets/server/game_filter_configs.cpp + src/interface/widgets/server/game_link.cpp src/interface/widgets/server/game_selector.cpp src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp src/interface/widgets/server/games_model.cpp diff --git a/cockatrice/src/interface/widgets/server/game_link.cpp b/cockatrice/src/interface/widgets/server/game_link.cpp new file mode 100644 index 000000000..c866f6571 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.cpp @@ -0,0 +1,23 @@ +#include "game_link.h" + +#include +#include + +QString makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description) +{ + QUrl url; + url.setScheme("cockatrice"); + url.setHost("joingame"); + QUrlQuery query; + query.addQueryItem("hostname", hostname); + query.addQueryItem("port", QString::number(port)); + query.addQueryItem("roomid", QString::number(roomId)); + query.addQueryItem("gameid", QString::number(gameId)); + if (!description.isEmpty()) { + // addQueryItem percent-encodes, so arbitrary descriptions (quotes, + // ampersands, non-ASCII…) survive the trip through chat. + query.addQueryItem("game", description); + } + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); +} diff --git a/cockatrice/src/interface/widgets/server/game_link.h b/cockatrice/src/interface/widgets/server/game_link.h new file mode 100644 index 000000000..d57bbd6d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.h @@ -0,0 +1,41 @@ +/** + * @file game_link.h + * @ingroup UI + * @brief Builds cockatrice://joingame links that let another user join a server game. + */ + +#ifndef GAME_LINK_H +#define GAME_LINK_H + +#include + +/** + * Builds a cockatrice://joingame link for the given server game. The receiver's + * client opens it through the intent chain (connect -> join room -> join game). + * @p description, when non-empty, is embedded in the link as the URL-encoded + * "game" query item so the receiving client can name the game in its confirm + * prompt and chat anchor instead of only its numeric id. Links built without it + * stay valid: the parser and chat renderer fall back to the id alone. + */ +QString +makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description = QString()); + +/** + * One game the inviter is currently in and can invite another user to. + * @p label is meant for display in menus, @p url is the ready-made invite link. + * @p description is the raw game description for building tr()-wrapped invite + * messages (the label already embeds it, but the send sites need the raw value). + * @p onlyBuddies and @p creatorName mirror the server game's room settings so + * callers can gate the invite to the creator's buddies. + */ +struct GameInviteOption +{ + int gameId = 0; + QString label; + QString url; + QString description; + bool onlyBuddies = false; + QString creatorName; +}; + +#endif // GAME_LINK_H diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 6580f0262..a1a2fb577 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -7,6 +7,7 @@ #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "game_link.h" #include "games_model.h" #include "user/user_list_manager.h" @@ -18,8 +19,6 @@ #include #include #include -#include -#include #include #include #include @@ -323,16 +322,9 @@ void GameSelector::customContextMenu(const QPoint &point) QAction copyLink(tr("Copy Game Link")); connect(©Link, &QAction::triggered, this, [=, this]() { const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); - QUrl url; - url.setScheme("cockatrice"); - url.setHost("joingame"); - QUrlQuery query; - query.addQueryItem("hostname", client->serverName()); - query.addQueryItem("port", QString::number(client->serverPort())); - query.addQueryItem("roomid", QString::number(gameInfo.room_id())); - query.addQueryItem("gameid", QString::number(gameInfo.game_id())); - url.setQuery(query); - QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded)); + QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), + gameInfo.room_id(), gameInfo.game_id(), + QString::fromStdString(gameInfo.description()))); }); QMenu menu; From 078e67c56fd65f34e2e9771ec8a414a93681eee6 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:03:29 +0200 Subject: [PATCH 02/15] [Client] Name the game in the join-game password prompt (#7141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/widgets/server/game_selector.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index a1a2fb577..2ccf18e5d 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -370,7 +370,12 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) QString password; if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) { bool ok; - password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok); + // Games without a description have no sensible label — fall back to the + // game id so the prompt still tells the user which game they're entering. + const QString gameLabel = QString::fromStdString(game.description()); + const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id()) + : tr("Password for \"%1\":").arg(gameLabel); + password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok); if (!ok) { return; } From 94943f7ff3954325f9153b10861364c970fee8a0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:20:36 +0200 Subject: [PATCH 03/15] [Client] Add copy-game-link action to the game menu (#7139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../widgets/server/game_selector.cpp | 2 +- .../src/interface/widgets/tabs/tab_game.cpp | 20 +++++++++++++++++++ .../src/interface/widgets/tabs/tab_game.h | 5 +++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2ccf18e5d..e9efc8663 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -319,7 +319,7 @@ void GameSelector::customContextMenu(const QPoint &point) dlg.exec(); }); - QAction copyLink(tr("Copy Game Link")); + QAction copyLink(tr("Cop&y game link")); connect(©Link, &QAction::triggered, this, [=, this]() { const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 82d99b605..3f165c1d5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,6 +20,7 @@ #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" #include "../interface/widgets/utility/line_edit_completer.h" @@ -33,6 +34,8 @@ #include "tab_supervisor.h" #include +#include +#include #include #include #include @@ -331,6 +334,9 @@ void TabGame::retranslateUi() if (aGameInfo) { aGameInfo->setText(tr("Game &information")); } + if (aCopyGameLink) { + aCopyGameLink->setText(tr("Cop&y game link")); + } if (aConcede) { if (game->getPlayerManager()->isMainPlayerConceded()) { aConcede->setText(tr("Un&concede")); @@ -498,6 +504,15 @@ void TabGame::actGameInfo() dlg.exec(); } +void TabGame::actCopyGameLink() +{ + const QString link = + makeGameJoinLink(tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), + game->getGameMetaInfo()->proto().room_id(), game->getGameMetaInfo()->gameId(), + QString::fromStdString(game->getGameMetaInfo()->proto().description())); + QApplication::clipboard()->setText(link); +} + void TabGame::actConcede() { PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); @@ -986,6 +1001,9 @@ void TabGame::createMenuItems() connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW); aGameInfo = new QAction(this); connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo); + aCopyGameLink = new QAction(this); + aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty()); + connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink); aConcede = new QAction(this); connect(aConcede, &QAction::triggered, this, &TabGame::actConcede); if (!game->getGameMetaInfo()->started()) { @@ -1024,6 +1042,7 @@ void TabGame::createMenuItems() gameMenu->addAction(aRotateViewCCW); gameMenu->addSeparator(); gameMenu->addAction(aGameInfo); + gameMenu->addAction(aCopyGameLink); gameMenu->addAction(aConcede); gameMenu->addAction(aFocusChat); gameMenu->addAction(aLeaveGame); @@ -1046,6 +1065,7 @@ void TabGame::createReplayMenuItems() aRotateViewCCW = nullptr; aResetLayout = nullptr; aGameInfo = nullptr; + aCopyGameLink = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index fc51817c6..b6555deef 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -83,8 +83,8 @@ private: QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; - QAction *aGameInfo, *aConcede, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn, - *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, + *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; QAction *aFocusChat; QList phaseActions; QAction *aCardMenu; @@ -148,6 +148,7 @@ private slots: void actGameInfo(); void actConcede(); + void actCopyGameLink(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); From 6c8fcf7d197dacc7b49a86cbff7c8dd6282608b2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:20:47 +0200 Subject: [PATCH 04/15] [Client] Confirm before joining a full game as a spectator (#7140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../interface/widgets/server/game_selector.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index e9efc8663..28e2ae607 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -364,9 +364,22 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) return; } - bool spectator = asSpectator || game.player_count() == game.max_players(); - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + + // Joining a full game without override privileges silently becomes a + // spectator join, so ask first instead of surprising the player. + const bool gameFull = game.player_count() == game.max_players(); + if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { + const QMessageBox::StandardButton answer = + QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + } + + bool spectator = asSpectator || gameFull; + QString password; if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) { bool ok; From f466a25893663136a2435390dfb9d18fd56cf42b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:16:55 +0200 Subject: [PATCH 05/15] [Client] Keep the message draft and notify when the recipient is offline (#7142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep the message draft and notify when the recipient is offline * Don't blindly assume a user is online. --------- Co-authored-by: Lukas Brübach --- .../interface/widgets/tabs/tab_message.cpp | 31 ++++++++++++++++--- .../src/interface/widgets/tabs/tab_message.h | 7 +++-- .../interface/widgets/tabs/tab_supervisor.cpp | 4 ++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9e9dbce1c..cee7da589 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -23,9 +23,10 @@ TabMessage::TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo) + const ServerInfo_User &_otherUserInfo, + bool _userOnline) : Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)), - otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true) + otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline) { chatView = new ChatView(tabSupervisor, 0, true); connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); @@ -96,7 +97,14 @@ void TabMessage::closeEvent(QCloseEvent *event) void TabMessage::sendMessage() { - if (sayEdit->text().isEmpty() || !userOnline) { + if (sayEdit->text().isEmpty()) { + return; + } + + if (!userOnline) { + // Keep the draft: the user may be back momentarily, and the typed text + // should not be lost to a transient offline spell. + notifyUserOffline(); return; } @@ -105,17 +113,27 @@ void TabMessage::sendMessage() cmd.set_message(sayEdit->text().toStdString()); PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(sayEdit->text()); connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); client->sendCommand(pend); sayEdit->clear(); } -void TabMessage::messageSent(const Response &response) +void TabMessage::messageSent(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) { if (response.response_code() == Response::RespInIgnoreList) { chatView->appendMessage(tr( "This user is ignoring you, they cannot see your messages in main chat and you cannot join their games.")); + } else if (response.response_code() == Response::RespNameNotFound) { + // The recipient went offline before the command reached the server: restore the draft. + userOnline = false; + if (sayEdit->text().isEmpty()) { + sayEdit->setText(extraData.toString()); + } + notifyUserOffline(); } } @@ -175,3 +193,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo) userOnline = true; *otherUserInfo = _userInfo; } + +void TabMessage::notifyUserOffline() +{ + chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index 0472bb061..f7d15b4f6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -19,6 +19,7 @@ class LineEditUnfocusable; class Event_UserMessage; class Response; class ServerInfo_User; +class CommandContainer; class TabMessage : public Tab { @@ -39,7 +40,7 @@ signals: void maximizeClient(); private slots: void sendMessage(); - void messageSent(const Response &response); + void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); void addMentionTag(QString mentionTag); void messageClicked(); @@ -50,7 +51,8 @@ public: TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo); + const ServerInfo_User &_otherUserInfo, + bool _userOnline); ~TabMessage() override; void retranslateUi() override; void tabActivated() override; @@ -65,6 +67,7 @@ public: private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); + void notifyUserOffline(); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 4100e124a..f72542832 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -904,8 +904,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus } ServerInfo_User otherUser; + bool userOnline = false; if (auto user = userListManager->getOnlineUser(receiverName)) { otherUser = ServerInfo_User(*user); + userOnline = true; } else { otherUser.set_name(receiverName.toStdString()); } @@ -919,7 +921,7 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } - tab = new TabMessage(this, client, *userInfo, otherUser); + tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); myAddTab(tab); From fe53f9c3ebee0bd9c8fb2e8c7a577bbe224e6920 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:26:33 +0200 Subject: [PATCH 06/15] [Chat] Render game link buttons (#7135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Chat] Render cockatrice://joingame links in chat as clickable buttons Words starting with cockatrice:// become button-style anchors labelled with the game description, id and server (falling back to id + server for links built without a description). The description is spliced via the multi-arg arg() overloads so a title containing "%…" cannot corrupt the label. Keyboard link access is enabled so the anchors are reachable without a mouse. * [Chat] Fix percent-encoding and scheme gating in game-link chat labels Game descriptions containing '%' were rendered as '%25' in the chat button label because QUrlQuery's default decode leaves %25 untouched. Use QUrl::FullyDecoded for the description item, and restrict the invite-button treatment to cockatrice://joingame links; any other cockatrice:// scheme now falls through to plain text. --------- Co-authored-by: Lukas Brübach --- .../widgets/server/chat_view/chat_view.cpp | 55 ++++++++++++++++++- .../widgets/server/chat_view/chat_view.h | 1 + 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index 869df4cf3..ae39e688e 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -49,7 +51,7 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho viewport()->setCursor(Qt::IBeamCursor); setReadOnly(true); - setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse); + setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); setOpenLinks(false); connect(this, &ChatView::anchorClicked, this, &ChatView::openLink); @@ -219,6 +221,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url) cursor.setCharFormat(oldFormat); } +void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url) +{ + const QUrl gameUrl(url); + const QUrlQuery query(gameUrl); + const QString hostname = query.queryItemValue("hostname"); + // FullyDecoded undoes every %XX escape, so a description that itself + // contains "%" cannot end up displayed as "%25" in the label. + const QString description = query.queryItemValue("game", QUrl::FullyDecoded); + const int gameId = query.queryItemValue("gameid").toInt(); + + QString label; + if (gameId > 0 && !hostname.isEmpty()) { + // Links built before the description was embedded stay readable: the + // id + server fallback below is identical to the old anchor text. + if (!description.isEmpty()) { + // Multi-arg .arg() replaces all placeholders in a single pass, so a + // description containing "%…" cannot corrupt later placeholders. + label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname); + } else { + label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname); + } + } else { + label = tr("Join game"); + } + + QTextCharFormat oldFormat = cursor.charFormat(); + QTextCharFormat gameLinkFormat = oldFormat; + gameLinkFormat.setForeground(linkColor); + gameLinkFormat.setFontWeight(QFont::Bold); + gameLinkFormat.setAnchor(true); + gameLinkFormat.setAnchorHref(url); + QColor background = palette().highlight().color(); + background.setAlpha(40); + gameLinkFormat.setBackground(background); + + cursor.setCharFormat(gameLinkFormat); + cursor.insertText(label); + cursor.setCharFormat(oldFormat); +} + void ChatView::appendMessage(QString message, RoomMessageTypeFlags messageType, const ServerInfo_User &userInfo, @@ -503,6 +545,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message) } } + if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) { + // Only links to a game (cockatrice://joingame) become invite buttons; + // any other cockatrice:// scheme falls through to plain text below. + const QUrl gameLink(fullWordUpToSpaceOrEnd); + if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) { + appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd); + cursor.insertText(rest, defaultFormat); + return; + } + } + // check word mentions for (const QString &word : highlightedWords) { if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) { diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 646aa6a80..9a8b29b52 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -71,6 +71,7 @@ private: void scrollToBottom(); void appendCardTag(QTextCursor &cursor, const QString &cardName); void appendUrlTag(QTextCursor &cursor, QString url); + void appendGameLinkTag(QTextCursor &cursor, const QString &url); static QColor getCustomMentionColor(); static QColor getCustomHighlightColor(); void showSystemPopup(const QString &userName); From 3de7882f0c9f9bb9d76375d123f54df3a07991ba Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:53:43 +0200 Subject: [PATCH 07/15] [UserList] Fix context menu crash by correctly parenting (#7145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Took 5 minutes Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 11 ++++++----- .../interface/widgets/server/user/user_list_widget.h | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 7a82b0c76..afcd3f87a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -336,11 +336,12 @@ constexpr int UserInfo = Qt::UserRole + 2; // rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; -UserListItemDelegate::UserListItemDelegate(QTreeWidget *tree, +UserListItemDelegate::UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap) - : QStyledItemDelegate(tree), tree(tree), avatarCache(avatarCache), cardArtCache(cardArtCache), + : QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache), cardArtParamsMap(cardArtParamsMap) { } @@ -353,7 +354,7 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); return true; } } @@ -593,8 +594,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); - itemDelegate = - new UserListItemDelegate(userTree, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); + itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(), + &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); userTree->hideColumn(1); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 298a5f8d8..e048c7fb7 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -37,6 +37,7 @@ class QPlainTextEdit; class Response; class CommandContainer; class UserContextMenu; +class UserListWidget; class QShowEvent; class BanDialog : public QDialog @@ -105,12 +106,14 @@ public: class UserListItemDelegate : public QStyledItemDelegate { QTreeWidget *tree; + UserListWidget *owner; const QMap *avatarCache; const QMap *cardArtCache; const QMap *cardArtParamsMap; public: - explicit UserListItemDelegate(QTreeWidget *tree, + explicit UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap); From 60ee81cfbe027dc083e487f470fd14ee07edf348 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:03:37 +0200 Subject: [PATCH 08/15] [Client] Route cockatrice:// link clicks from chat to the intent chain (#7136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Route cockatrice:// link clicks from chat to the intent chain A cockatrice:// link clicked in chat is currently handed to the OS (or does nothing in-process). Clicks now emit a cockatriceLinkActivated signal that travels ChatView -> Tab -> TabSupervisor -> MainWindow, which feeds the URL through the same IntentUrlParser the OS activation path uses, so the join runs entirely in-process. card/user schemes and all other links behave as before. * [Client] Route cockatrice:// link clicks from the in-game chat to the intent chain * [Client] Reuse one IntentUrlParser instance for cockatrice:// links Took 59 seconds --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/server/chat_view/chat_view.cpp | 5 +++++ .../src/interface/widgets/server/chat_view/chat_view.h | 1 + cockatrice/src/interface/widgets/tabs/tab.h | 1 + cockatrice/src/interface/widgets/tabs/tab_game.cpp | 1 + cockatrice/src/interface/widgets/tabs/tab_message.cpp | 1 + cockatrice/src/interface/widgets/tabs/tab_room.cpp | 1 + cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp | 3 +++ cockatrice/src/interface/widgets/tabs/tab_supervisor.h | 1 + cockatrice/src/interface/window_main.cpp | 8 ++++++++ cockatrice/src/interface/window_main.h | 3 +++ 10 files changed, 25 insertions(+) diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index ae39e688e..e62195c2f 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -777,6 +777,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event) void ChatView::openLink(const QUrl &link) { + if (link.scheme() == "cockatrice") { + emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded)); + return; + } + if ((link.scheme() == "card") || (link.scheme() == "user")) { return; } diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 9a8b29b52..c58efa2c6 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -122,6 +122,7 @@ signals: void addMentionTag(QString mentionTag); void messageClickedSignal(); void showMentionPopup(const QString &userName); + void cockatriceLinkActivated(const QString &url); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab.h b/cockatrice/src/interface/widgets/tabs/tab.h index 6ea1f5077..bddf325e2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.h +++ b/cockatrice/src/interface/widgets/tabs/tab.h @@ -20,6 +20,7 @@ class Tab : public QMainWindow signals: void userEvent(bool globalEvent = true); void tabTextChanged(Tab *tab, const QString &newTabText); + void cockatriceLinkActivated(const QString &url); protected: TabSupervisor *tabSupervisor; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 3f165c1d5..513b7c926 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1292,6 +1292,7 @@ void TabGame::createMessageDock(bool bReplay) qOverload(&CardInfoFrameWidget::setCard)); connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup); connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup); + connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated); if (!bReplay) { connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog); diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index cee7da589..9eccea7a2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -32,6 +32,7 @@ TabMessage::TabMessage(TabSupervisor *_tabSupervisor, connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated); sayEdit = new LineEditUnfocusable; sayEdit->setMaxLength(MAX_TEXT_LENGTH); connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage); diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 9b09ba7bb..508d5a048 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -70,6 +70,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index f72542832..1ab812c54 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -406,6 +406,7 @@ int TabSupervisor::myAddTab(Tab *tab, QAction *manager) { connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent); connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText); + connect(tab, &TabGame::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); QString tabText = tab->getTabText(); int idx = addTab(tab, sanitizeTabName(tabText)); @@ -851,6 +852,7 @@ void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab); + connect(tab, &TabRoom::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); roomTabs.insert(info.room_id(), tab); if (setCurrent) { @@ -924,6 +926,7 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); + connect(tab, &TabMessage::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); messageTabs.insert(receiverName, tab); if (focus) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 0c3542cf3..5ac3eb365 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -169,6 +169,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index c083dccf8..199a2d952 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -40,6 +40,7 @@ #include "intents/intent_connect_to_server.h" #include "intents/intent_login.h" #include "intents/intent_open_server_room_by_name.h" +#include "intents/url_parser.h" #include "logger.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" @@ -497,6 +498,7 @@ MainWindow::MainWindow(QWidget *parent) pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); createActions(); createMenus(); @@ -508,6 +510,7 @@ MainWindow::MainWindow(QWidget *parent) connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu); connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded); connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden); + connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink); connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor, &TabSupervisor::start); connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor, @@ -861,6 +864,11 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 73b7c42c5..fc0791832 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -56,6 +56,7 @@ class TabSupervisor; class WndSets; class DlgTipOfTheDay; struct ContextConnectToServer; +class IntentUrlParser; class MainWindow : public QMainWindow { @@ -84,6 +85,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); + void handleCockatriceLink(const QString &url); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -139,6 +141,7 @@ private: *aOpenSettingsFolder; TabSupervisor *tabSupervisor; + IntentUrlParser *urlParser; WndSets *wndSets; ConnectionController *connectionController; LocalServer *localServer; From b2cdf44bbd2808a4e8432ec9eaefd816b868e310 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:58:41 +0200 Subject: [PATCH 09/15] [UserList] Show amount of online buddies (#7126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [UserList] Show amount of online buddies Took 11 minutes * [UserList] Replace early return with if-else in updateSectionDivider RickyRister nit: the code is easier to follow with a standard if-else branch instead of an early return for the Buddy section. Took 1 minute --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index afcd3f87a..be52b9871 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1639,15 +1639,27 @@ void UserListWidget::updateSectionDivider(Section section) return; } int visible = 0; + int online = 0; for (int i = 0; i < divider->childCount(); ++i) { - if (!divider->child(i)->isHidden()) { + QTreeWidgetItem *child = divider->child(i); + if (!child->isHidden()) { ++visible; + if (child->data(0, UserListRoles::Online).toBool()) { + ++online; + } } } // The tree draws no branches (rows are flush), so the divider carries its // own collapse arrow glyph. const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8"); - divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + if (section == Section::Buddy) { + // The buddy divider reports how many of the shown buddies are online, + // mirroring the "Buddies online: %1 / %2" title of the non-sectioned + // buddy list. + divider->setText(0, tr("%1 %2 (%3/%4)").arg(arrow, sectionTitle(section)).arg(online).arg(visible)); + } else { + divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + } } void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded) From 776f917ffc9e49048f81313b4f7e544a5bd81bc2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:37:51 +0200 Subject: [PATCH 10/15] [Client] Confirm before joining a game opened from a game link (#7137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Confirm before joining a game opened from a game link Joining a game from a cockatrice://joingame link is a navigation decision, so restate what will be joined and ask before acting: the confirm names the game description when the link carries one (falling back to the room name and numeric id for older links), and reports the host:port so links that point at a different server are obvious. The intent chain is only started after confirmation. Took 3 minutes * [Client] Extract the join-game confirm message into a helper Took 3 minutes # Commit time for manual adjustment: # Took 6 seconds * Proper fwd declare. Took 3 minutes --------- Co-authored-by: Lukas Brübach --- .../src/interface/intents/url_parser.cpp | 47 +++++++++++++++++++ cockatrice/src/interface/intents/url_parser.h | 3 ++ 2 files changed, 50 insertions(+) diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 8b5309603..509390611 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,5 +1,7 @@ #include "url_parser.h" +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" #include "../window_main.h" #include "contexts/context_join_game.h" #include "intent_join_server_game.h" @@ -9,6 +11,7 @@ #include #include #include +#include #include IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) @@ -71,6 +74,15 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) return; } + const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); + const QString message = generateJoinGameMessage(*ctx, gameDescription); + + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return; + } + // The join game intent owns the context and the credential lookup; once the // chain finishes (or fails) it deletes the whole tree. ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; @@ -87,3 +99,38 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) getLoginCredentialsIntent->execute(); } + +QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) +{ + const QString hostname = context.roomContext.serverContext.hostname; + const QString port = context.roomContext.serverContext.port; + const int roomId = context.roomContext.roomId; + const int gameId = context.gameId; + const QString server = QStringLiteral("%1:%2").arg(hostname, port); + + // Prefer the room name over the raw numeric id: it means something to the + // user. The name is only known when we are already connected to the same + // server and sitting in that room — otherwise fall back to a plain prompt. + AbstractClient *client = mainWindow->getTabSupervisor()->getClient(); + const bool sameServer = client != nullptr && client->getStatus() == StatusLoggedIn && + hostname.compare(client->serverName(), Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; + TabRoom *roomTab = sameServer ? mainWindow->getTabSupervisor()->getRoomTabs().value(roomId) : nullptr; + + const QString gameIdStr = QString::number(gameId); + // Links built by newer clients embed the game description ("game" item); + // restate it in the confirm so it matches what the chat anchor showed. + // Unknown query items are ignored, so old links without it keep working. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + // FullyDecoded undoes every %XX escape and must match the chat anchor's + // decode mode, so a description containing "%" reads identically in both. + if (gameDescription.isEmpty()) { + return roomTab ? tr("Join game #%1 in \"%2\" on %3?").arg(gameIdStr, roomTab->getRoomName(), server) + : tr("Join game #%1 on %2?").arg(gameIdStr, server); + } + + return roomTab ? tr("Join game \"%1\" (#%2) in \"%3\" on %4?") + .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) + : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index bac0e3d25..6d705e013 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -4,6 +4,7 @@ #include class MainWindow; +struct ContextJoinGame; class IntentUrlParser : public QObject { Q_OBJECT @@ -14,6 +15,8 @@ public: void handleJoinGame(const QUrlQuery &query); private: + QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + MainWindow *mainWindow; }; From 765ebf8fb1be894194343ae2f257a9ac471bd55b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:19 +0200 Subject: [PATCH 11/15] [UserList] Context menu invite (#7138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Send game invites from the user context menu via a private message The user context menu gains an "Invite to Game" submenu listing the inviteable games in the room (the inviter's own games, honoring the buddy-only setting). Picking one opens a private message to the target user with a cockatrice://joingame link naming the game, so the target gets a clickable invite instead of a raw URL. Multi-game rooms offer a picker; a single inviteable game sends directly. Sending a message to an offline user no longer swallows the draft — it reports that the user is offline and keeps the typed text. Took 50 seconds Took 3 minutes * [Client] Extract sendPrivateMessage() to fix invite message draft overwrite sendInviteMessage() was calling sayEdit->setText(text) then sendMessage(), which overwrites any text the user had typed. Extract the command-building and sending logic into a new sendPrivateMessage(const QString &text) method that takes the text directly. sendMessage() now calls it after its guards and clears sayEdit; sendInviteMessage() calls it directly without touching the input field at all. Took 33 minutes * Rename method, address comments. Took 5 minutes --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_context_menu.cpp | 67 ++++++++++++++++++- .../widgets/server/user/user_context_menu.h | 30 ++++++++- .../widgets/server/user/user_list_widget.cpp | 5 ++ .../widgets/server/user/user_list_widget.h | 3 + .../interface/widgets/tabs/tab_message.cpp | 29 +++++--- .../src/interface/widgets/tabs/tab_message.h | 3 + .../src/interface/widgets/tabs/tab_room.cpp | 5 ++ .../interface/widgets/tabs/tab_supervisor.cpp | 48 +++++++++++++ .../interface/widgets/tabs/tab_supervisor.h | 3 + 9 files changed, 179 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 5c4a88974..372dbfc19 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -355,6 +355,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, { QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr; aUserName->setText(userName); + const bool anotherUser = userName != userListProxy->getOwnUsername(); auto *menu = new QMenu(static_cast(parent())); menu->addAction(aUserName); @@ -366,6 +367,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aDetails); menu->addAction(aShowGames); menu->addAction(aChat); + const QList inviteOptions = inviteOptionsForUser(userName); + if (!inviteOptions.isEmpty()) { + auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu); + for (const GameInviteOption &option : inviteOptions) { + QAction *inviteAction = inviteMenu->addAction(option.label); + inviteAction->setEnabled(anotherUser && online); + connect(inviteAction, &QAction::triggered, this, + [this, userName, option] { execInvite(userName, option); }); + } + menu->addMenu(inviteMenu); + } if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) { menu->addSeparator(); if (userListProxy->isUserBuddy(userName)) { @@ -416,7 +428,6 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aPromoteToJudge); } } - bool anotherUser = userName != userListProxy->getOwnUsername(); aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online); aShowGames->setEnabled(online); @@ -480,6 +491,60 @@ void UserContextMenu::execChat(const QString &userName) emit openMessageDialog(userName, true); } +QList UserContextMenu::inviteOptionsForUser(const QString &userName) const +{ + if (!gameInviteLinkProvider) { + return {}; + } + const QList options = gameInviteLinkProvider(); + QList result; + for (const GameInviteOption &option : options) { + // Buddy-only games accept invites only from their creator, and only to + // users on the creator's buddy list. + if (option.onlyBuddies && + (option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) { + continue; + } + result.append(option); + } + return result; +} + +void UserContextMenu::execInvite(const QString &userName) +{ + const QList options = inviteOptionsForUser(userName); + if (options.isEmpty()) { + return; + } + + if (options.size() == 1) { + execInvite(userName, options.first()); + return; + } + + // More than one game in the room — let the user pick which one to invite to. + auto *menu = new QMenu(static_cast(parent())); + for (const GameInviteOption &option : options) { + QAction *action = menu->addAction(option.label); + connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); }); + } + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->popup(QCursor::pos()); +} + +void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option) +{ + // Name the game by description first, then its id — "Join my game 'Magic' + // (#123)" — so a description-less fallback still identifies the game. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + const QString prefix = + option.description.isEmpty() + ? tr("Join my game (#%1):").arg(option.gameId) + : tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId)); + tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url); +} + void UserContextMenu::execDetails(const QString &userName) { auto *w = new UserInfoBox(client, false, static_cast(parent()), diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 00fdc51fe..70bbff977 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,9 +7,12 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H -#include -#include +#include "../../interface/widgets/server/game_link.h" +#include +#include +#include +#include class AbstractGame; class UserListProxy; class AbstractClient; @@ -43,6 +46,7 @@ private: QAction *aPromoteToJudge, *aDemoteFromJudge; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -80,9 +84,28 @@ public: return userListProxy; } + void setGameInviteLinkProvider(std::function()> provider) + { + gameInviteLinkProvider = std::move(provider); + } + + /** + * The games currently inviteable for @p userName, honoring the room's + * buddy-only setting (the inviter must be the game's creator and the + * target a buddy of theirs). Empty when there is no live provider. + */ + QList inviteOptionsForUser(const QString &userName) const; + + /** Whether at least one invite link is currently available for @p userName. */ + bool hasGameInviteLink(const QString &userName) const + { + return !inviteOptionsForUser(userName).isEmpty(); + } + // Individual action entry points — used by UserInfoPopup to trigger // actions without re-running the full context menu flow. void execChat(const QString &userName); + void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); void execAddToBuddy(const QString &userName); @@ -97,6 +120,9 @@ public: void execAdminNotes(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); + +private: + void execInvite(const QString &userName, const GameInviteOption &option); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index be52b9871..b63457169 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1838,3 +1838,8 @@ void UserListWidget::finishSectionedMutation() applyFilter(); userTree->viewport()->update(); } + +void UserListWidget::setGameInviteLinkProvider(std::function()> provider) +{ + userContextMenu->setGameInviteLinkProvider(std::move(provider)); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index e048c7fb7..d97843264 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -8,6 +8,7 @@ #define USERLIST_H #include "../../cards/card_info_picture_art_crop_widget.h" +#include "../../interface/widgets/server/game_link.h" #include "user_avatar_provider.h" #include "user_card_art_provider.h" #include "user_info_popup.h" @@ -22,6 +23,7 @@ #include #include #include +#include #include #include @@ -271,6 +273,7 @@ public: } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + void setGameInviteLinkProvider(std::function()> provider); protected: void hideEvent(QHideEvent *e) override; diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9eccea7a2..d482d3dd7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -96,6 +96,18 @@ void TabMessage::closeEvent(QCloseEvent *event) event->accept(); } +void TabMessage::sendPrivateMessage(const QString &text) +{ + Command_Message cmd; + cmd.set_user_name(otherUserInfo->name()); + cmd.set_message(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(text); + connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); + client->sendCommand(pend); +} + void TabMessage::sendMessage() { if (sayEdit->text().isEmpty()) { @@ -103,24 +115,19 @@ void TabMessage::sendMessage() } if (!userOnline) { - // Keep the draft: the user may be back momentarily, and the typed text - // should not be lost to a transient offline spell. notifyUserOffline(); return; } - Command_Message cmd; - cmd.set_user_name(otherUserInfo->name()); - cmd.set_message(sayEdit->text().toStdString()); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(sayEdit->text()); - connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); - client->sendCommand(pend); - + sendPrivateMessage(sayEdit->text()); sayEdit->clear(); } +bool TabMessage::isUserOnline() const +{ + return userOnline; +} + void TabMessage::messageSent(const Response &response, const CommandContainer & /*commandContainer*/, const QVariant &extraData) diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index f7d15b4f6..e9b987ce2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -64,6 +64,9 @@ public: void processUserLeft(); void processUserJoined(const ServerInfo_User &_userInfo); + [[nodiscard]] bool isUserOnline() const; + void sendPrivateMessage(const QString &text); + private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 508d5a048..6245b5301 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/game_selector.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_panel_widget.h" @@ -30,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +68,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, userList = userListPanel->getUserList(); connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); + const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; + userList->setGameInviteLinkProvider(gameInviteLinkProvider); + chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 1ab812c54..77b93802a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -3,6 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../interface/pixel_map_generator.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" @@ -950,6 +951,53 @@ void TabSupervisor::talkLeft(TabMessage *tab) removeTab(indexOf(tab)); } +QList TabSupervisor::getGameInviteLinksForRoom(int roomId) const +{ + QList options; + if (isLocalGame) { + return options; + } + + // The inviter may be in several games of the same room (hosting one and + // spectating another, for example). Return every game so the caller can + // let the user choose which one to invite to. + for (TabGame *tab : gameTabs) { + GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); + if (metaInfo->proto().room_id() != roomId) { + continue; + } + // A closed game is a dead end — drop it. Started/full games stay + // listed: an invite to them is a legitimate "come spectate" offer. + if (metaInfo->proto().closed()) { + continue; + } + + const int gameId = metaInfo->gameId(); + const QString description = QString::fromStdString(metaInfo->proto().description()); + + GameInviteOption option{ + .gameId = gameId, + .label = + description.isEmpty() ? tr("Game #%1").arg(gameId) : tr("Game #%1 — %2").arg(gameId).arg(description), + .url = makeGameJoinLink(client->serverName(), client->serverPort(), roomId, gameId, description), + .description = description, + .onlyBuddies = metaInfo->proto().only_buddies(), + .creatorName = QString::fromStdString(metaInfo->proto().creator_info().name()), + }; + options.append(option); + } + + return options; +} + +void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) +{ + TabMessage *tab = addMessageTab(userName, true); + if (tab && tab->isUserOnline()) { + tab->sendPrivateMessage(inviteText); + } +} + /** * Creates a new deck editor tab and loads the deck into it. * Creates either a classic or visual deck editor tab depending on settings diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 5ac3eb365..81ad22f54 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -9,6 +9,7 @@ #define TAB_SUPERVISOR_H #include "../../deck_loader/deck_loader.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_proxy.h" #include "abstract_tab_deck_editor.h" #include "api/archidekt/tab_archidekt.h" @@ -160,6 +161,8 @@ public: { return deckEditorTabs; } + [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; + void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); From a8bacc529668a26054db3e5f2c7522416e52decc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:20:26 +0200 Subject: [PATCH 12/15] [VDS] Add async-scanned model and sort/filter proxy (#7105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VDS] Add async-scanned model and sort/filter proxy (model layer) Took 1 minute Took 27 seconds * Address comments --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 + .../visual_deck_storage_model.cpp | 503 ++++++++++++++++++ .../visual_deck_storage_model.h | 156 ++++++ ...l_deck_storage_sort_filter_proxy_model.cpp | 275 ++++++++++ ...ual_deck_storage_sort_filter_proxy_model.h | 98 ++++ 5 files changed, 1034 insertions(+) create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9ea463eef..1c2755f71 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -301,8 +301,10 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp new file mode 100644 index 000000000..bf2c49604 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -0,0 +1,503 @@ +#include "visual_deck_storage_model.h" + +#include "../../deck_loader/deck_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief The result of a background directory scan: the deck rows in scan order + * plus the sorted list of subfolder paths. + */ +struct DeckScanResult +{ + QList decks; ///< Deck rows in scan order. + QStringList folderPaths; ///< Sorted list of subfolder paths. +}; + +/** + * @brief The result of a background deck file load: the parsed deck plus the + * file's modification time, so the disk stat happens off the UI thread. + */ +struct DeckLoadResult +{ + LoadedDeck deck; ///< The parsed deck. + QDateTime lastModified; ///< File modification time at load. +}; + +/** + * @brief The path of \a path relative to the deck root, or empty if \a path + * is not below it. + */ +QString relativePathFromDeckRoot(const QString &path, const QString &deckPath) +{ + if (!path.startsWith(deckPath)) { + return {}; + } + QString relativePath = path.mid(deckPath.length()); + if (relativePath.startsWith('/')) { + relativePath.remove(0, 1); + } + return relativePath; +} + +/** + * @brief The path of \a filePath relative to \a deckPath, or the bare file name + * if \a filePath is not below \a deckPath. + */ +QString relativeFilePathFor(const QString &filePath, const QString &deckPath) +{ + if (filePath.startsWith(deckPath)) { + return filePath.mid(deckPath.length()); + } + + return QFileInfo(filePath).fileName(); +} + +/** + * @brief The directory of \a filePath relative to \a deckPath, or empty if the + * file sits directly in the deck root. + */ +QString folderPathFor(const QString &filePath, const QString &deckPath) +{ + return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath); +} + +/** + * @brief Scans a deck directory on a worker thread, returning discovered deck + * files and subfolder paths. + */ +DeckScanResult scanDeckDirectory(const QString &deckPath) +{ + DeckScanResult result; + + QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (fileIt.hasNext()) { + const QString filePath = fileIt.next(); + DeckPreviewData data; + data.filePath = filePath; + data.relativeFilePath = relativeFilePathFor(filePath, deckPath); + data.folderPath = folderPathFor(filePath, deckPath); + data.lastModified = QFileInfo(filePath).lastModified(); + result.decks.append(std::move(data)); + } + + QSet seenFolders; + QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (folderIt.hasNext()) { + const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath); + if (!folderPath.isEmpty() && !seenFolders.contains(folderPath)) { + seenFolders.insert(folderPath); + result.folderPaths.append(folderPath); + } + } + result.folderPaths.sort(); + + return result; +} +} // namespace + +VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : decks.size(); +} + +QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) { + return {}; + } + + const DeckPreviewData &data = decks.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case VisualDeckStorageRoles::DisplayNameRole: + return data.displayName; + case VisualDeckStorageRoles::FilePathRole: + return data.filePath; + case VisualDeckStorageRoles::RelativeFilePathRole: + return data.relativeFilePath; + case VisualDeckStorageRoles::FolderPathRole: + return data.folderPath; + case VisualDeckStorageRoles::TagsRole: + return data.tags; + case VisualDeckStorageRoles::ColorIdentityRole: + return data.colorIdentity; + case VisualDeckStorageRoles::LastModifiedRole: + return data.lastModified; + case VisualDeckStorageRoles::LastLoadedRole: + return data.lastLoaded; + case VisualDeckStorageRoles::BannerCardNameRole: + return data.bannerCard.name; + case VisualDeckStorageRoles::BannerCardProviderIdRole: + return data.bannerCard.providerId; + default: + return {}; + } +} + +void VisualDeckStorageModel::setDeckPath(const QString &path) +{ + QString cleanedPath = QDir::cleanPath(path); + if (cleanedPath == ".") { + cleanedPath.clear(); + } + deckPath = cleanedPath; + startScan(); +} + +void VisualDeckStorageModel::refresh() +{ + startScan(); +} + +const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const +{ + static const DeckPreviewData emptyData; + if (row < 0 || row >= decks.size()) { + return emptyData; + } + return decks.at(row); +} + +const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const +{ + return dataForRow(row).deck; +} + +int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const +{ + for (int i = 0; i < decks.size(); ++i) { + if (decks.at(i).filePath == filePath) { + return i; + } + } + return -1; +} + +void VisualDeckStorageModel::startScan() +{ + ++scanGeneration; + beginResetModel(); + decks.clear(); + folderPaths.clear(); + endResetModel(); + + if (deckPath.isEmpty()) { + return; + } + + const QString currentDeckPath = deckPath; + const int generation = scanGeneration; + + // The scan (directory walk + one stat per file) runs on a worker thread so that + // constructing the widget never stalls the UI thread on a large deck folder. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // A newer scan started while this one was running; drop the stale result. + } + + const DeckScanResult result = watcher->result(); + folderPaths = result.folderPaths; + + if (result.decks.isEmpty()) { + return; + } + + beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); + decks = result.decks; + endInsertRows(); + + for (int row = 0; row < decks.size(); ++row) { + beginLoad(row); + } + }); + + watcher->setFuture( + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); +} + +void VisualDeckStorageModel::beginLoad(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) { + return; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = true; + + const QString filePath = data.filePath; + const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); + const int generation = scanGeneration; + + auto *watcher = new QFutureWatcher>(this); + connect(watcher, &QFutureWatcher>::finished, this, + [this, watcher, filePath, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // The deck list was re-scanned while this load was running; drop the stale result. + } + + const int row = rowForFilePath(filePath); + if (row == -1) { + return; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = false; + + std::optional result = watcher->result(); + if (!result) { + return; // Leave the row unloaded; it stays visible but without deck data. + } + + data.deck = std::move(result->deck); + data.loadSucceeded = true; + data.lastModified = result->lastModified; + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + }); + + watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { + std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); + if (!deck) { + return std::nullopt; + } + return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()}; + })); +} + +/** + * @brief Computes the color identity of a deck in WUBRG order. + */ +static QString computeColorIdentity(const LoadedDeck &deck) +{ + QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); + if (currentCard) { + const QString colors = currentCard->getColors(); // Something like "WUB" + for (const QChar &color : colors) { + colorSet.insert(color); + } + } + } + + // Ensure the color identity is in WUBRG order + QString colorIdentity; + const QString wubrgOrder = "WUBRG"; + for (const QChar &color : wubrgOrder) { + if (colorSet.contains(color)) { + colorIdentity.append(color); + } + } + + return colorIdentity; +} + +/** + * @brief Recomputes all derived metadata of a row from its loaded deck. + */ +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) +{ + const DeckList &deckList = data.deck.deckList; + + data.deckName = deckList.getName(); + data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName(); + data.tags = deckList.getTags(); + data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); + data.bannerCard = deckList.getBannerCard(); + data.colorIdentity = computeColorIdentity(data.deck); +} + +void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) +{ + if (row < 0 || row >= decks.size()) { + return; + } + + DeckPreviewData &data = decks[row]; + data.filePath = newFilePath; + data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath); + data.folderPath = folderPathFor(newFilePath, deckPath); +} + +bool VisualDeckStorageModel::renameDeck(int row, const QString &newName) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setName(newName); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + recomputeDeckMetadata(data); + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole}); + return true; +} + +bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName) +{ + if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QFileInfo info(data.filePath); + if (newBaseName == info.baseName()) { + return false; + } + + QString newFileName = newBaseName; + if (!info.suffix().isEmpty()) { + newFileName += "." + info.suffix(); + } + + const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); + if (!QFile::rename(info.filePath(), newFilePath)) { + return false; + } + + const QString oldFilePath = data.filePath; + data.deck.lastLoadInfo.fileName = newFilePath; + setFilePathForRow(row, newFilePath); + data.lastModified = QFileInfo(newFilePath).lastModified(); + + emit dataChanged(index(row), index(row)); + emit deckFilePathChanged(oldFilePath, newFilePath); + return true; +} + +bool VisualDeckStorageModel::deleteFile(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + const QString filePath = decks.at(row).filePath; + if (!QFile::remove(QFileInfo(filePath).filePath())) { + return false; + } + + beginRemoveRows(QModelIndex(), row, row); + decks.removeAt(row); + endRemoveRows(); + return true; +} + +bool VisualDeckStorageModel::setTags(int row, const QStringList &tags) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setTags(tags); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.tags = tags; + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::TagsRole}); + return true; +} + +bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setBannerCard(cardRef); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.bannerCard = cardRef; + emit dataChanged(index(row), index(row), + {VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole}); + return true; +} + +bool VisualDeckStorageModel::convertToCockatriceFormat(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QString oldFilePath = data.filePath; + if (!DeckLoader::convertToCockatriceFormat(data.deck)) { + return false; + } + + setFilePathForRow(row, data.deck.lastLoadInfo.fileName); + data.lastModified = QFileInfo(data.filePath).lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + if (oldFilePath != data.filePath) { + emit deckFilePathChanged(oldFilePath, data.filePath); + } + return true; +} + +bool VisualDeckStorageModel::reloadIfModified(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + DeckPreviewData &data = decks[row]; + QFileInfo fileInfo(data.filePath); + const QDateTime newLastModified = fileInfo.lastModified(); + if (!newLastModified.isValid() || newLastModified <= data.lastModified) { + return false; + } + + std::optional result = + DeckLoader::loadFromFile(data.filePath, DeckFileFormat::getFormatFromName(data.filePath), false); + if (!result) { + return false; + } + + data.deck = *result; + data.loadSucceeded = true; + data.lastModified = fileInfo.lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + return true; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h new file mode 100644 index 000000000..a44e7412d --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -0,0 +1,156 @@ +/** + * @file visual_deck_storage_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Source model for the Visual Deck Storage: the deck files on disk. + * + * The model owns the deck metadata (name, tags, color identity, banner card, + * modification times) and the parsed deck list, loading each deck file in the + * background. Views read through the roles or the direct accessors, and all + * mutations (rename, tags, banner card, delete, conversion) go through this + * class so the view layer never touches the filesystem directly. + */ + +#ifndef VISUAL_DECK_STORAGE_MODEL_H +#define VISUAL_DECK_STORAGE_MODEL_H + +#include "../../deck_loader/loaded_deck.h" + +#include +#include +#include +#include +#include +#include + +namespace VisualDeckStorageRoles +{ +/** + * @brief Custom roles exposed by the VisualDeckStorageModel. + */ +enum +{ + FilePathRole = Qt::UserRole + 1, /**< Absolute file path of the deck. */ + RelativeFilePathRole, /**< File path relative to the deck folder. */ + FolderPathRole, /**< Directory of the deck relative to the deck folder ("" for root). */ + DisplayNameRole, /**< Deck name, or the file name if the deck has no name. */ + TagsRole, /**< The deck's tags. */ + ColorIdentityRole, /**< The deck's color identity (WUBRG order). */ + LastModifiedRole, /**< QDateTime of the deck file's last modification. */ + LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */ + BannerCardNameRole, /**< Name of the deck's banner card. */ + BannerCardProviderIdRole /**< Provider id of the deck's banner card. */ +}; +} // namespace VisualDeckStorageRoles + +/** + * @brief One deck file as seen by the Visual Deck Storage. + * + * The metadata is computed once when the deck loads and refreshed on reloads + * and mutations, so filters and sorts never re-read the file from disk. + */ +struct DeckPreviewData +{ + QString filePath; ///< Absolute file path. + QString relativeFilePath; ///< File path relative to the deck folder. + QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself). + QString deckName; ///< The deck name as stored in the file (may be empty). + QString displayName; ///< Deck name, or the file name if the deck has no name. + QStringList tags; ///< The deck's tags. + QString colorIdentity; ///< The deck's color identity in WUBRG order. + QDateTime lastModified; ///< File modification time at last check. + QDateTime lastLoaded; ///< When the deck was last loaded from the file. + CardRef bannerCard; ///< The deck's banner card (name + provider id). + LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded. + bool loadSucceeded = false; ///< Whether the deck file finished loading successfully. + bool loadInProgress = false; ///< Whether the deck file is currently being loaded. +}; + +/** + * @brief The list model backing the Visual Deck Storage widget tree. + * + * Rows are in filesystem scan order; ordering and filtering are handled by + * VisualDeckStorageSortFilterProxyModel on top of this model. + */ +class VisualDeckStorageModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit VisualDeckStorageModel(QObject *parent = nullptr); + + /// @name Qt model overrides + ///@{ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + ///@} + + /** + * @brief Sets the folder to scan for deck files and starts (re)loading. + * Clears the model immediately (modelReset), then populates it asynchronously + * as the background scan discovers deck files. + */ + void setDeckPath(const QString &path); + + /** + * @brief Re-scans the current deck folder, reloading every deck file. + */ + void refresh(); + + [[nodiscard]] QString getDeckPath() const + { + return deckPath; + } + + /** + * @brief The relative paths of all subdirectories of the deck folder, one level at a time. + * Used by the view to build the folder tree. Sorted for deterministic order. + */ + [[nodiscard]] QStringList getFolderPaths() const + { + return folderPaths; + } + + /// @name Data accessors + ///@{ + [[nodiscard]] const DeckPreviewData &dataForRow(int row) const; + [[nodiscard]] const LoadedDeck &deckForRow(int row) const; + [[nodiscard]] int rowForFilePath(const QString &filePath) const; + ///@} + + /// @name Mutations (persist to disk and update the row) + ///@{ + bool renameDeck(int row, const QString &newName); + bool renameFile(int row, const QString &newBaseName); + bool deleteFile(int row); + bool setTags(int row, const QStringList &tags); + bool setBannerCard(int row, const CardRef &cardRef); + bool convertToCockatriceFormat(int row); + bool reloadIfModified(int row); + ///@} + +signals: + /** + * @brief Emitted when a deck file finishes loading. + * @param row The row of the deck that finished loading. + */ + void deckLoaded(int row); + + /** + * @brief Emitted when a deck's file path changes (rename file, conversion). + * @param oldFilePath The previous file path. + * @param newFilePath The new file path. + */ + void deckFilePathChanged(const QString &oldFilePath, const QString &newFilePath); + +private: + void startScan(); + void beginLoad(int row); + static void recomputeDeckMetadata(DeckPreviewData &data); + void setFilePathForRow(int row, const QString &newFilePath); + + QString deckPath; + QList decks; + QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. + int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. +}; + +#endif // VISUAL_DECK_STORAGE_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp new file mode 100644 index 000000000..8968f6cb3 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -0,0 +1,275 @@ +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include "../../filters/deck_filter_string.h" + +#include +#include + +VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(false); +} + +void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) +{ + if (QAbstractItemModel *oldModel = sourceModel()) { + disconnect(oldModel, &QAbstractItemModel::modelReset, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + QSortFilterProxyModel::setSourceModel(model); + + if (model) { + connect(model, &QAbstractItemModel::modelReset, this, &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + resizeMatchLists(); +} + +void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text) +{ + if (searchText == text) { + return; + } + + searchText = text; + updateSearchMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet &newSelectedTags, + const QSet &newExcludedTags) +{ + if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) { + return; + } + + selectedTags = newSelectedTags; + excludedTags = newExcludedTags; + updateTagMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet &colors) +{ + if (colorFilterMode == mode && activeColors == colors) { + return; + } + + colorFilterMode = mode; + activeColors = colors; + updateColorMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order) +{ + // No equality guard: the initial reapply (with the default order) must still + // trigger sort(0), since without a sort the proxy would show scan order. + sortOrder = order; + sort(0); +} + +void VisualDeckStorageSortFilterProxyModel::reapplyFilters() +{ + const QList oldSearchMatches = searchMatches; + const QList oldTagMatches = tagMatches; + const QList oldColorMatches = colorMatches; + + updateSearchMatches(); + updateTagMatches(); + updateColorMatches(); + + if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) { + invalidate(); + } + + if (sortOrder == ByName || sortOrder == ByLastLoaded) { + // These orders depend on data that only becomes available when a deck finishes loading. + sort(0); + } +} + +void VisualDeckStorageSortFilterProxyModel::resort() +{ + sort(0); +} + +bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + if (sourceParent.isValid()) { + return true; + } + + // If the match lists aren't sized to the current model yet, don't hide anything. + if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() || + sourceRow >= colorMatches.size()) { + return true; + } + + return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow); +} + +bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const +{ + const auto *source = deckSourceModel(); + if (!source) { + return false; + } + + const DeckPreviewData &leftData = source->dataForRow(left.row()); + const DeckPreviewData &rightData = source->dataForRow(right.row()); + + switch (sortOrder) { + case ByName: + return leftData.deckName < rightData.deckName; + case Alphabetical: + return QString::localeAwareCompare(QFileInfo(leftData.filePath).fileName(), + QFileInfo(rightData.filePath).fileName()) < 0; + case ByLastModified: + return leftData.lastModified > rightData.lastModified; + case ByLastLoaded: + return leftData.lastLoaded > rightData.lastLoaded; + } + + return false; +} + +void VisualDeckStorageSortFilterProxyModel::resizeMatchLists() +{ + const int count = sourceModel() ? sourceModel()->rowCount() : 0; + searchMatches.resize(count); + searchMatches.fill(true); + tagMatches.resize(count); + tagMatches.fill(true); + colorMatches.resize(count); + colorMatches.fill(true); +} + +void VisualDeckStorageSortFilterProxyModel::updateSearchMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + searchMatches.clear(); + return; + } + + const int count = source->rowCount(); + searchMatches.resize(count); + if (searchText.isEmpty()) { + searchMatches.fill(true); + return; + } + + DeckFilterString filterString(searchText); + for (int row = 0; row < count; ++row) { + const DeckPreviewData &data = source->dataForRow(row); + + // isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false + // but the deck remains empty. Using loadInProgress alone would pass failed decks + // to DeckFilterString::check, which requires a non-empty deck. + if (data.deck.isEmpty()) { + searchMatches[row] = true; + continue; + } + + DeckSearchData searchData{ + .deck = &data.deck, + .filePath = data.filePath, + .displayName = data.displayName, + .relativeFilePath = data.relativeFilePath, + }; + searchMatches[row] = filterString.check(searchData); + } +} + +void VisualDeckStorageSortFilterProxyModel::updateTagMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + tagMatches.clear(); + return; + } + + const int count = source->rowCount(); + tagMatches.resize(count); + + if (selectedTags.isEmpty() && excludedTags.isEmpty()) { + tagMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QStringList deckTags = source->dataForRow(row).tags; + + const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + + tagMatches[row] = hasAllSelected && !hasAnyExcluded; + } +} + +void VisualDeckStorageSortFilterProxyModel::updateColorMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + colorMatches.clear(); + return; + } + + const int count = source->rowCount(); + colorMatches.resize(count); + + if (activeColors.isEmpty()) { + colorMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QString colorIdentity = source->dataForRow(row).colorIdentity; + + bool matches = true; + switch (colorFilterMode) { + case ExactMatch: { + QSet activeColorSet; + for (const QChar &color : activeColors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : colorIdentity) { + colorIdentitySet.insert(color.toUpper()); + } + + matches = activeColorSet == colorIdentitySet; + break; + } + case Includes: + matches = std::all_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + case Excludes: + matches = std::none_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + } + + colorMatches[row] = matches; + } +} + +const VisualDeckStorageModel *VisualDeckStorageSortFilterProxyModel::deckSourceModel() const +{ + return qobject_cast(sourceModel()); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h new file mode 100644 index 000000000..d2842a02f --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -0,0 +1,98 @@ +/** + * @file visual_deck_storage_sort_filter_proxy_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Sorting and filtering proxy on top of VisualDeckStorageModel. + * + * Owns all search / tag / color filter state and the sort order. Filtering is + * evaluated against the model's data (never against widgets), so it can run + * before any view exists and re-evaluate whenever deck data finishes loading. + */ + +#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H +#define VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H + +#include "visual_deck_storage_model.h" + +#include +#include +#include + +class VisualDeckStorageSortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + /** + * @brief The order in which decks are sorted. Values must match the + * entries of the sort widget's combo box and the stored settings value. + */ + enum SortOrder + { + ByName, + Alphabetical, + ByLastModified, + ByLastLoaded, + }; + Q_ENUM(SortOrder) + + /** + * @brief How the color identity filter is applied. + */ + enum FilterMode + { + ExactMatch, + Includes, + Excludes + }; + Q_ENUM(FilterMode) + + explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr); + + void setSourceModel(QAbstractItemModel *model) override; + + /// @name Filter input setters (each re-evaluates the affected matches) + ///@{ + void setSearchText(const QString &text); + void setTagFilter(const QSet &newSelectedTags, const QSet &newExcludedTags); + void setColorFilter(FilterMode mode, const QSet &colors); + ///@} + + /** + * @brief Sets the sort order and applies it immediately. + */ + void setSortOrder(SortOrder order); + + /** + * @brief Re-evaluates all matches against the current model data and + * re-applies filtering and sorting. Called after deck data changes. + */ + void reapplyFilters(); + + /** + * @brief Re-applies the current sort order without touching the filters. + */ + void resort(); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; + +private: + void resizeMatchLists(); + void updateSearchMatches(); + void updateTagMatches(); + void updateColorMatches(); + [[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const; + + QString searchText; + QSet selectedTags; + QSet excludedTags; + FilterMode colorFilterMode = ExactMatch; + QSet activeColors; + SortOrder sortOrder = Alphabetical; + + QList searchMatches; ///< Per-row search match, sized like the source model. + QList tagMatches; ///< Per-row tag match. + QList colorMatches; ///< Per-row color identity match. +}; + +#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H From 08d6b51db98fdb435db87e05b7ad0c39abb127bf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:21:25 +0200 Subject: [PATCH 13/15] [Server] Add deck validation strategy interface (#7129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../network/server/remote/CMakeLists.txt | 1 + .../game/server_deck_validation_strategy.h | 45 +++++++++++++++++++ .../server/remote/game/server_game.cpp | 8 +++- .../network/server/remote/game/server_game.h | 12 +++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 80a80e1ae..fb4fd3155 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -11,6 +11,7 @@ set(HEADERS game/server_cardzone.h game/server_counter.h game/game_config.h + game/server_deck_validation_strategy.h game/server_game.h game/server_player.h game/server_spectator.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h new file mode 100644 index 000000000..8298214b4 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h @@ -0,0 +1,45 @@ +#ifndef SERVER_DECK_VALIDATION_STRATEGY_H +#define SERVER_DECK_VALIDATION_STRATEGY_H + +#include + +class DeckList; +class Server_Game; +class Server_Player; +class ResponseContainer; + +/** + * @brief Strategy for validating a player's deck before it is loaded into a game. + * + * Subclasses decide whether a deck may be accepted; the default implementation + * accepts every deck. + */ +class Server_DeckValidationStrategy +{ +public: + virtual ~Server_DeckValidationStrategy() = default; + + /** + * @brief Validate @p deck for @p player in @p game. + * + * @p rc is an out parameter used to attach the response details for a rejected + * deck (e.g. an error response extension via ResponseContainer::setResponseExtension). + * @return Response::RespOk when the deck is accepted, an error code otherwise. + */ + virtual Response::ResponseCode + validate(Server_Game *game, Server_Player *player, DeckList *deck, ResponseContainer &rc) = 0; +}; + +/** + * @brief Default deck validation strategy that accepts every deck. + */ +class Server_DefaultDeckValidationStrategy : public Server_DeckValidationStrategy +{ +public: + Response::ResponseCode validate(Server_Game *, Server_Player *, DeckList *, ResponseContainer &) override + { + return Response::RespOk; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 60d11ead1..069a10463 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -62,7 +62,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), - turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), gameMutex() + turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + deckValidationStrategy(new Server_DefaultDeckValidationStrategy), gameMutex() { currentReplay = new GameReplay; currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId()); @@ -886,3 +887,8 @@ void Server_Game::returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPl } } } + +void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strategy) +{ + deckValidationStrategy.reset(strategy); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 60b5398f2..da316975d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -22,11 +22,13 @@ #include "../server_response_containers.h" #include "game_config.h" +#include "server_deck_validation_strategy.h" #include #include #include #include +#include #include #include #include @@ -79,6 +81,8 @@ private: QList replayList; GameReplay *currentReplay; + QScopedPointer deckValidationStrategy; + void createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -208,6 +212,14 @@ public: GameEventStorageItem::SendToOthers, int privatePlayerId = -1); void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); + + /** @brief Get the current deck validation strategy (non-owning). */ + Server_DeckValidationStrategy *getDeckValidationStrategy() const + { + return deckValidationStrategy.data(); + } + /** @brief Replace the deck validation strategy; takes ownership of @p strategy. */ + void setDeckValidationStrategy(Server_DeckValidationStrategy *strategy); }; #endif From 7c550ee505c36cc7febc60feaaf0d420ab9fe0f2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:37:15 +0200 Subject: [PATCH 14/15] [Game] Add an invite button to non-started and not full games (#7143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Send game invites from the user context menu via a private message The user context menu gains an "Invite to Game" submenu listing the inviteable games in the room (the inviter's own games, honoring the buddy-only setting). Picking one opens a private message to the target user with a cockatrice://joingame link naming the game, so the target gets a clickable invite instead of a raw URL. Multi-game rooms offer a picker; a single inviteable game sends directly. Sending a message to an offline user no longer swallows the draft — it reports that the user is offline and keeps the typed text. Took 1 minute * [Client] Add invite-to-game dialog to the game window Took 15 seconds * [Client] Open the invite dialog taller by default without enforcing a minimum size * Move button to bottom Took 3 minutes * Address comments. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_invite_to_game.cpp | 112 ++++++++ .../widgets/dialogs/dlg_invite_to_game.h | 46 ++++ .../widgets/server/user/user_list_widget.cpp | 252 +++++++++++++----- .../widgets/server/user/user_list_widget.h | 21 +- .../src/interface/widgets/tabs/tab_game.cpp | 77 +++++- .../src/interface/widgets/tabs/tab_game.h | 5 + .../interface/widgets/tabs/tab_message.cpp | 6 + .../src/interface/widgets/tabs/tab_message.h | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 4 +- 10 files changed, 449 insertions(+), 76 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 1c2755f71..56f5b89f9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -36,6 +36,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp src/interface/widgets/dialogs/dlg_forgot_password_request.cpp src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp + src/interface/widgets/dialogs/dlg_invite_to_game.cpp src/interface/widgets/dialogs/dlg_load_deck.cpp src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp new file mode 100644 index 000000000..b5451bb2c --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp @@ -0,0 +1,112 @@ +#include "dlg_invite_to_game.h" + +#include "../server/user/user_list_manager.h" +#include "../server/user/user_list_widget.h" +#include "../tabs/tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent) + : QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies), + excludeUserNames(_excludeUserNames) +{ + setModal(true); + + searchEdit = new QLineEdit(this); + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged); + + // The embedded list is the real room user list without the hover popup: + // same manager, same delegate/painter, same sections, live via manager + // signals while the modal loop runs. + UserListManager *manager = tabSupervisor->getUserListManager(); + userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this, + /*hasUserInfoPopup=*/false); + userList->setUserFilter([this, manager](const QString &name, bool online) { + return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name); + }); + if (onlyBuddies) { + userList->setSectioned({UserListWidget::Section::Buddy}); + } else { + userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online}); + } + userList->bind(manager); + userList->rebuild(); + + connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser); + connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) { + currentUserName = userName; + inviteButton->setEnabled(!userName.isEmpty()); + }); + + inviteButton = new QPushButton(this); + inviteButton->setEnabled(false); + inviteButton->setDefault(true); + connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); }); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + auto *buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + buttonRow->addWidget(inviteButton); + buttonRow->addWidget(cancelButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(searchEdit); + layout->addWidget(userList, 1); + layout->addLayout(buttonRow); + + retranslateUi(); + + // Default to a comfortably tall dialog so the list has room to breathe, + // capped by the available screen. No minimum is enforced: small screens + // and manual resizing can go shorter than this. + const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry(); + resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5)); +} + +void DlgInviteToGame::searchTextChanged(const QString &text) +{ + userList->setFilterText(text); +} + +void DlgInviteToGame::inviteCurrentUser(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + // The invite link carries the game's id and, when the game has one, its + // description (makeGameJoinLink embeds both). Read them back so the prefix + // names the game by description first, then its id — identical to the + // context-menu invite so recipients see one consistent message style. + const QUrl inviteUrlObj(inviteUrl); + const QUrlQuery inviteQuery(inviteUrlObj); + const int gameId = inviteQuery.queryItemValue("gameid").toInt(); + const QString gameDescription = inviteQuery.queryItemValue("game"); + const QString prefix = gameDescription.isEmpty() + ? tr("Join my game (#%1):").arg(gameId) + : tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId); + tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl); + accept(); +} + +void DlgInviteToGame::retranslateUi() +{ + setWindowTitle(tr("Invite to Game")); + searchEdit->setPlaceholderText(tr("Search users...")); + inviteButton->setText(tr("Invite")); + cancelButton->setText(tr("Cancel")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h new file mode 100644 index 000000000..bbb589bdb --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h @@ -0,0 +1,46 @@ +/** + * @file dlg_invite_to_game.h + * @ingroup RoomDialogs + */ +//! \todo Document this file. + +#ifndef DLG_INVITE_TO_GAME_H +#define DLG_INVITE_TO_GAME_H + +#include +#include + +class QLineEdit; +class QPushButton; +class TabSupervisor; +class UserListWidget; + +class DlgInviteToGame : public QDialog +{ + Q_OBJECT +public: + DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent = nullptr); + +private slots: + void searchTextChanged(const QString &text); + void inviteCurrentUser(const QString &userName); + +private: + TabSupervisor *tabSupervisor; + QString inviteUrl; + bool onlyBuddies; + QStringList excludeUserNames; + QString currentUserName; + QLineEdit *searchEdit; + UserListWidget *userList; + QPushButton *inviteButton; + QPushButton *cancelButton; + + void retranslateUi(); +}; + +#endif diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index b63457169..685325f50 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -354,7 +354,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } @@ -578,8 +581,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent) - : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) + QWidget *parent, + bool _hasUserInfoPopup) + : QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client), + type(_type), onlineCount(0) { avatarProvider = new UserAvatarProvider(client, this); cardArtProvider = new UserCardArtProvider(this); @@ -605,15 +610,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); userTree->header()->setStretchLastSection(true); - // ── Hover popup ─────────────────────────────────────────────────────────── - userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), - &cardArtProvider->cache(), &cardArtParamsMap, - window()); // parented to main window so it floats above siblings - - userInfoPopup->hide(); - userInfoPopup->setWindowOpacity(0.0); - userInfoPopup->installEventFilter(this); - + // Always create timers so callers never segfault on a null deref; + // showPopupForUser / hidePopup already guard against a null userInfoPopup. showPopupTimer = new QTimer(this); showPopupTimer->setSingleShot(true); showPopupTimer->setInterval(280); @@ -639,65 +637,104 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, // The hover ends when the cursor leaves the user row. Empty list // space, a section divider and anything outside the tree all close // the popup, while the popup itself keeps it alive. - if (!popupPinned && !userInfoPopup->underMouse() && (hoveredUser.isEmpty() || !userTree->underMouse())) { + if (!popupPinned && userInfoPopup && !userInfoPopup->underMouse() && + (hoveredUser.isEmpty() || !userTree->underMouse())) { hidePopup(); } }); - connectPopupSignals(); + if (hasUserInfoPopup) { + // ── Hover popup ─────────────────────────────────────────────────────── + userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + } userTree->setMouseTracking(true); userTree->viewport()->setMouseTracking(true); userTree->viewport()->installEventFilter(this); userTree->installEventFilter(this); // keyboard handling for section dividers - // Clicking anywhere outside the list clears its selection and closes the - // popup. The filter watches all widgets because the press can land on any - // part of the window, on another list or on the popup itself. - qApp->installEventFilter(this); + if (hasUserInfoPopup) { + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); - // Pin on item click - connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { - // Clicking a section divider toggles it - if (sectioned && item->type() == SectionItemType) { - setExpandedProgrammatically(item, !item->isExpanded()); - handleSectionExpansion(item, item->isExpanded()); - return; - } - if (!SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - if (item->type() != QTreeWidgetItem::Type) { - return; // divider rows have no user popup - } - popupPinned = false; // reset so showPopupForUser can update - showPopupForUser(static_cast(item)); - popupPinned = true; // pin after showing - }); + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing + }); - connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this](const QItemSelection &sel, const QItemSelection &) { - if (sel.isEmpty() && popupPinned) { - popupPinned = false; - hidePopup(); - } - }); + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && popupPinned) { + popupPinned = false; + hidePopup(); + } + }); - // Keyboard selection: show the popup for the current row and hide it when - // the focus moves to a section divider or leaves the list entirely. The - // popup therefore follows arrow key navigation exactly like mouse hover. - // When it was pinned by a click it stays open and follows the selection. - connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { - if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - if (current && current->type() == QTreeWidgetItem::Type) { - showPopupForUser(static_cast(current)); - } else { - popupPinned = false; - hidePopup(); - } - }); + // Keyboard selection: the popup is a mouse surface, so keyboard + // navigation shows no floating popup. A pinned (clicked) popup still + // follows the selection so it does not strand on a stale user while + // arrows move the cursor. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!popupPinned) { + return; // keyboard navigation shows no popup + } + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestAvatarsForVisibleItems(); + }); + + // Forward join requests from popup upward + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + } else { + // Dialog mode: keyboard selection drives the Invite button. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + const QString userName = (current && current->type() == QTreeWidgetItem::Type) + ? current->data(2, Qt::UserRole).toString() + : QString(); + emit currentUserChanged(userName); + }); + + // Keep the popup-less scroll path alive for avatar prefetch. + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this] { requestAvatarsForVisibleItems(); }); + } // Section dividers can be collapsed/expanded by the user. Surface those // changes only from real user interaction. Programmatic expansion is @@ -707,16 +744,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, connect(userTree, &QTreeWidget::itemCollapsed, this, [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); - // Hide popup when list scrolls (reference row has moved) - connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - showPopupTimer->stop(); - hidePopup(true); - requestAvatarsForVisibleItems(); - }); - - // Forward join requests from popup upward - connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); - connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader); connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader); @@ -839,6 +866,9 @@ void UserListWidget::bind(UserListManager *mgr) void UserListWidget::refreshVisibleUserHeader(const QString &name) { userTree->viewport()->update(); + if (!userInfoPopup) { + return; + } if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { userInfoPopup->refreshHeader(); } @@ -846,6 +876,9 @@ void UserListWidget::refreshVisibleUserHeader(const QString &name) void UserListWidget::refreshPopupButtons(const QString &userName) { + if (!userInfoPopup) { + return; + } UserListTWI *item = users.value(userName); if (!item) { return; @@ -863,6 +896,9 @@ void UserListWidget::refreshPopupButtons(const QString &userName) void UserListWidget::hideEvent(QHideEvent *e) { QGroupBox::hideEvent(e); + if (!userInfoPopup) { + return; + } showPopupTimer->stop(); hidePopupTimer->stop(); hidePopup(true); @@ -871,6 +907,9 @@ void UserListWidget::hideEvent(QHideEvent *e) void UserListWidget::showEvent(QShowEvent *e) { QGroupBox::showEvent(e); + if (!userInfoPopup) { + return; + } requestAvatarsForVisibleItems(); } @@ -943,6 +982,24 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) } } + // Keyboard entry to the user context menu: the Menu key (or Shift+F10) + // pops the same menu the right-click shows, anchored to the focused row. + // Divider rows have no menu. Mouse-triggered context events are NOT handled + // here — the delegate's right-press path already pops the menu, and + // handling both would open two menus on one right-click. + if (hasUserInfoPopup && (obj == userTree || obj == userTree->viewport()) && event->type() == QEvent::ContextMenu) { + auto *contextEvent = static_cast(event); + if (contextEvent->reason() == QContextMenuEvent::Keyboard) { + QTreeWidgetItem *current = userTree->currentItem(); + if (current && current->type() == QTreeWidgetItem::Type) { + const QPoint globalPos = userTree->viewport()->mapToGlobal(userTree->visualItemRect(current).center()); + showContextMenu(globalPos, userTree->indexFromItem(current)); + return true; + } + return false; // divider rows: no menu + } + } + // Keyboard navigation of the section dividers. // The dividers are selectable so arrow keys land on them. When one is the // current item, Enter/Space toggle it (like a button) and Left/Right follow @@ -964,7 +1021,7 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) } } - if (obj == userTree->viewport()) { + if (hasUserInfoPopup && obj == userTree->viewport()) { if (event->type() == QEvent::MouseMove) { if (!SettingsCache::instance().appearance().getStyleUserList()) { return QGroupBox::eventFilter(obj, event); @@ -1004,6 +1061,9 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) void UserListWidget::showPopupForUser(UserListTWI *item) { + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -1062,6 +1122,9 @@ void UserListWidget::showPopupForUser(UserListTWI *item) void UserListWidget::positionPopup(UserListTWI *item) { + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -1116,6 +1179,9 @@ void UserListWidget::positionPopup(UserListTWI *item) void UserListWidget::hidePopup(bool immediate) { + if (!userInfoPopup) { + return; + } showPopupTimer->stop(); hidePopupTimer->stop(); if (!userInfoPopup->isVisible()) { @@ -1476,8 +1542,10 @@ void UserListWidget::applyFilter() int visible = 0; for (int i = 0; i < divider->childCount(); ++i) { auto *child = static_cast(divider->child(i)); - const bool match = - !searching || QString::fromStdString(child->getUserInfo().name()).toLower().contains(lower); + const QString name = QString::fromStdString(child->getUserInfo().name()); + const bool passesFilter = + !userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool()); + const bool match = passesFilter && (!searching || name.toLower().contains(lower)); child->setHidden(!match); if (match) { ++visible; @@ -1497,6 +1565,7 @@ void UserListWidget::applyFilter() } requestAvatarsForVisibleItems(); userTree->viewport()->update(); + emit userListChanged(); return; } @@ -1514,6 +1583,7 @@ void UserListWidget::applyFilter() requestAvatarsForVisibleItems(); userTree->viewport()->update(); + emit userListChanged(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) @@ -1521,7 +1591,38 @@ void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) if (item->type() != QTreeWidgetItem::Type) { return; // divider rows open no chat } - emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); + const QString userName = item->data(2, Qt::UserRole).toString(); + if (hasUserInfoPopup) { + emit openMessageDialog(userName, true); + } else { + emit userActivated(userName); + } +} + +int UserListWidget::visibleUserRowCount() const +{ + int count = 0; + if (sectioned) { + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider || divider->isHidden()) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++count; + } + } + } + return count; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = userTree->topLevelItem(i); + if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) { + ++count; + } + } + return count; } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) @@ -1770,6 +1871,13 @@ UserListTWI *UserListWidget::ensureSectionMembership(Section section, const Serv updateCardArtParams(user, userName); + // Dialog mode: rows that fail the user filter never exist. applyFilter() + // re-checks the predicate on every pass so a live state change (e.g. the + // user being ignored mid-dialog) hides an already created row. + if (userFilter && !userFilter(userName, online)) { + return nullptr; + } + QTreeWidgetItem *divider = sectionItems.value(section); if (!divider) { return nullptr; diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index d97843264..0407ad8ca 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -173,6 +173,8 @@ private: QString hoveredUser; bool popupPinned = false; bool bulkLoading = false; + bool hasUserInfoPopup = true; + std::function userFilter; /** * Popup functions are anchored on the row, not the user name. In sectioned @@ -242,12 +244,19 @@ signals: void removeIgnore(const QString &userName); void joinGameRequested(int gameId, int roomId, bool asSpectator); void sectionExpanded(Section section, bool expanded); + /** Dialog mode: the user activated (Enter/double-click) the given row. */ + void userActivated(const QString &userName); + /** Dialog mode: the current row changed; empty string means no user row. */ + void currentUserChanged(const QString &userName); + /** The set of visible rows changed (filter, search or a live mutation). */ + void userListChanged(); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent = nullptr); + QWidget *parent = nullptr, + bool hasUserInfoPopup = true); ~UserListWidget() override; void bind(UserListManager *mgr); void applyDisplayMode(); @@ -263,6 +272,16 @@ public: void setShowTitle(bool showTitle); void setSectioned(const QList
&ids); void setSectionExpanded(Section section, bool expanded); + /** Dialog mode: rows that fail the predicate are never shown. */ + void setUserFilter(std::function filter) + { + userFilter = std::move(filter); + } + [[nodiscard]] int visibleUserRowCount() const; + [[nodiscard]] bool getHasUserInfoPopup() const + { + return hasUserInfoPopup; + } [[nodiscard]] const QList
&getSectionIds() const { return sectionIds; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 513b7c926..dbf4a5a4a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,6 +20,7 @@ #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" +#include "../interface/widgets/dialogs/dlg_invite_to_game.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" @@ -43,10 +44,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -296,6 +299,9 @@ void TabGame::retranslateUi() QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId()); updatePlayerListDockTitle(); + if (inviteButton) { + inviteButton->setText(tr("Invite")); + } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); if (replayDock) { @@ -337,6 +343,9 @@ void TabGame::retranslateUi() if (aCopyGameLink) { aCopyGameLink->setText(tr("Cop&y game link")); } + if (aInviteToGame) { + aInviteToGame->setText(tr("Invite to Game...")); + } if (aConcede) { if (game->getPlayerManager()->isMainPlayerConceded()) { aConcede->setText(tr("Un&concede")); @@ -513,6 +522,47 @@ void TabGame::actCopyGameLink() QApplication::clipboard()->setText(link); } +void TabGame::updateInviteButtonState() +{ + // The dock button stays conservative (pre-start, not full); the menu action + // additionally covers started/full games, which are legitimate spectate + // invites, so it only needs the server-linked + not-closed conditions. + const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() && + !game->getGameMetaInfo()->started() && + game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers(); + if (inviteButton) { + inviteButton->setVisible(canInvite); + } + if (aInviteToGame) { + aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed()); + } +} + +void TabGame::actInviteToGame() +{ + if (!tabSupervisor || tabSupervisor->getIsLocalGame()) { + return; + } + + GameMetaInfo *metaInfo = game->getGameMetaInfo(); + const QString inviteUrl = makeGameJoinLink( + tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(), + metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description())); + + QStringList excludeUserNames; + excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername(); + for (auto player : game->getPlayerManager()->getPlayers()) { + excludeUserNames << player->getPlayerInfo()->getName(); + } + for (auto it = game->getPlayerManager()->getSpectators().cbegin(); + it != game->getPlayerManager()->getSpectators().cend(); ++it) { + excludeUserNames << QString::fromStdString(it.value().name()); + } + + DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this); + dlg.exec(); +} + void TabGame::actConcede() { PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); @@ -1004,6 +1054,8 @@ void TabGame::createMenuItems() aCopyGameLink = new QAction(this); aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty()); connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink); + aInviteToGame = new QAction(this); + connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame); aConcede = new QAction(this); connect(aConcede, &QAction::triggered, this, &TabGame::actConcede); if (!game->getGameMetaInfo()->started()) { @@ -1043,6 +1095,7 @@ void TabGame::createMenuItems() gameMenu->addSeparator(); gameMenu->addAction(aGameInfo); gameMenu->addAction(aCopyGameLink); + gameMenu->addAction(aInviteToGame); gameMenu->addAction(aConcede); gameMenu->addAction(aFocusChat); gameMenu->addAction(aLeaveGame); @@ -1051,6 +1104,9 @@ void TabGame::createMenuItems() aCardMenu = gameMenu->addMenu(new QMenu(this)); + // Sync the new action with the same state the dock button already shows. + updateInviteButtonState(); + addTabMenu(gameMenu); } @@ -1066,6 +1122,7 @@ void TabGame::createReplayMenuItems() aResetLayout = nullptr; aGameInfo = nullptr; aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1264,11 +1321,29 @@ void TabGame::createPlayerListDock(bool bReplay) } playerListWidget->setFocusPolicy(Qt::NoFocus); + auto *playerListBox = new QWidget(this); + auto *vbox = new QVBoxLayout(playerListBox); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + + vbox->addWidget(playerListWidget); + + if (!bReplay) { + inviteButton = new QPushButton(tr("Invite"), playerListBox); + inviteButton->setVisible(false); + connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame); + vbox->addWidget(inviteButton); + + connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState); + connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState); + updateInviteButtonState(); + } + playerListDock = new QDockWidget(this); playerListDock->setObjectName("playerListDock"); playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - playerListDock->setWidget(playerListWidget); + playerListDock->setWidget(playerListBox); playerListDock->setFloating(false); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index b6555deef..a05b49a9f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -37,6 +37,7 @@ class CardInfoFrameWidget; class QTimer; class QSplitter; class QLabel; +class QPushButton; class QToolButton; class QMenu; class ZoneViewLayout; @@ -69,6 +70,7 @@ private: CardInfoFrameWidget *cardInfoFrameWidget; PlayerListWidget *playerListWidget; + QPushButton *inviteButton = nullptr; QLabel *timeElapsedLabel; MessageLogWidget *messageLog; QLabel *sayLabel; @@ -86,6 +88,7 @@ private: QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; QAction *aFocusChat; + QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; @@ -128,6 +131,7 @@ private: void createPlayAreaWidget(bool bReplay = false); void createDeckViewContainerWidget(bool bReplay = false); void createReplayDock(GameReplay *replay); + void updateInviteButtonState(); signals: void gameClosing(TabGame *tab); void containerProcessingStarted(const GameEventContext &context); @@ -147,6 +151,7 @@ private slots: void setCardMenu(CardMenu *menu); void actGameInfo(); + void actInviteToGame(); void actConcede(); void actCopyGameLink(); void actRemoveLocalArrows(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index d482d3dd7..9506d96f3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -128,6 +128,12 @@ bool TabMessage::isUserOnline() const return userOnline; } +void TabMessage::sendInviteMessage(const QString &text) +{ + sayEdit->setText(text); + sendMessage(); +} + void TabMessage::messageSent(const Response &response, const CommandContainer & /*commandContainer*/, const QVariant &extraData) diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index e9b987ce2..0e6d66d4c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -66,6 +66,7 @@ public: [[nodiscard]] bool isUserOnline() const; void sendPrivateMessage(const QString &text); + void sendInviteMessage(const QString &text); private: bool shouldShowSystemPopup(const Event_UserMessage &event); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 77b93802a..016d96434 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -993,8 +993,8 @@ QList TabSupervisor::getGameInviteLinksForRoom(int roomId) con void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) { TabMessage *tab = addMessageTab(userName, true); - if (tab && tab->isUserOnline()) { - tab->sendPrivateMessage(inviteText); + if (tab) { + tab->sendInviteMessage(inviteText); } } From 06762ea7b498ef43ffab5d562f07e78dc5365d55 Mon Sep 17 00:00:00 2001 From: tooomm Date: Mon, 17 Aug 2026 22:52:30 +0200 Subject: [PATCH 15/15] Update `peglib` to v1.16.0 (#7134) --- .../libcockatrice/utility/peglib.h | 1669 ++++++++++++++--- 1 file changed, 1451 insertions(+), 218 deletions(-) diff --git a/libcockatrice_utility/libcockatrice/utility/peglib.h b/libcockatrice_utility/libcockatrice/utility/peglib.h index e7e558dff..a67a86549 100644 --- a/libcockatrice_utility/libcockatrice/utility/peglib.h +++ b/libcockatrice_utility/libcockatrice/utility/peglib.h @@ -1,4 +1,4 @@ -// +// // peglib.h // // Copyright (c) 2022 Yuji Hirose. All rights reserved. @@ -7,6 +7,9 @@ #pragma once +#define CPPPEGLIB_VERSION "1.16.0" +#define CPPPEGLIB_VERSION_NUM "0x011000" + /* * Configuration */ @@ -46,6 +49,8 @@ namespace peg { +struct GrammarBlob; + /*----------------------------------------------------------------------------- * scope_exit *---------------------------------------------------------------------------*/ @@ -335,6 +340,14 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { r += ']'; i++; break; + case '^': + r += '^'; + i++; + break; + case '-': + r += '-'; + i++; + break; case '\\': r += '\\'; i++; @@ -361,6 +374,46 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { return r; } +/* + * Predefined character classes (ASCII semantics) + */ +inline const std::vector> * +predefined_character_class(std::string_view name) { + static const std::map>> + table = { + {"alnum", {{'0', '9'}, {'A', 'Z'}, {'a', 'z'}}}, + {"alpha", {{'A', 'Z'}, {'a', 'z'}}}, + {"ascii", {{0x00, 0x7F}}}, + {"blank", {{'\t', '\t'}, {' ', ' '}}}, + {"cntrl", {{0x00, 0x1F}, {0x7F, 0x7F}}}, + {"digit", {{'0', '9'}}}, + {"graph", {{0x21, 0x7E}}}, + {"lower", {{'a', 'z'}}}, + {"print", {{0x20, 0x7E}}}, + {"punct", {{0x21, 0x2F}, {0x3A, 0x40}, {0x5B, 0x60}, {0x7B, 0x7E}}}, + {"space", {{'\t', '\r'}, {' ', ' '}}}, + {"upper", {{'A', 'Z'}}}, + {"word", {{'0', '9'}, {'A', 'Z'}, {'_', '_'}, {'a', 'z'}}}, + {"xdigit", {{'0', '9'}, {'A', 'F'}, {'a', 'f'}}}, + }; + auto it = table.find(name); + return it != table.end() ? &it->second : nullptr; +} + +// Ranges must be sorted and non-overlapping. +inline std::vector> complement_character_ranges( + const std::vector> &ranges) { + std::vector> r; + char32_t next = 0; + for (const auto &[lo, hi] : ranges) { + if (lo > next) { r.emplace_back(next, lo - 1); } + next = hi + 1; + } + if (next <= 0x10FFFF) { r.emplace_back(next, 0x10FFFF); } + return r; +} + /*----------------------------------------------------------------------------- * token_to_number_ - This function should be removed eventually *---------------------------------------------------------------------------*/ @@ -448,6 +501,7 @@ public: size_t items_count() const { return items_count_; } friend struct ComputeFirstSet; + friend struct GrammarBlob; private: struct Info { @@ -506,7 +560,7 @@ inline constexpr unsigned int str2tag(std::string_view sv) { namespace udl { -inline constexpr unsigned int operator"" _(const char *s, size_t l) { +inline constexpr unsigned int operator""_(const char *s, size_t l) { return str2tag_core(s, l, 0); } @@ -724,6 +778,25 @@ inline bool fail(size_t len) { return len == static_cast(-1); } using Log = std::function; +/* + * ErrorReport - structured error information passed to an ErrorReporter. + * Unlike Log, nothing is flattened into a display string, so applications + * can map errors to their own error types, localize messages, or feed + * diagnostics to IDEs. + */ +struct ErrorReport { + size_t line = 0; // 1-based + size_t col = 1; // 1-based + size_t position = 0; // byte offset in the input + std::string unexpected_token; // heuristic token at the error position + std::vector expected_literals; + std::vector expected_rules; // rules starting with '_' excluded + std::string message; // custom error_message if any (placeholders resolved) + std::string label; // rule name or recovery label the error belongs to +}; + +using ErrorReporter = std::function; + /* * ErrorInfo */ @@ -752,7 +825,11 @@ struct ErrorInfo { expected_tokens.emplace_back(error_literal, error_rule); } - void output_log(const Log &log, const char *s, size_t n); + void output_log(const Log &log, const char *s, size_t n) { + output_log(log, nullptr, s, n); + } + void output_log(const Log &log, const ErrorReporter &reporter, const char *s, + size_t n); private: int cast_char(char c) const { return static_cast(c); } @@ -808,6 +885,131 @@ using TracerLeave = std::function; +// Packrat memoization table: open-addressing hash map keyed by the fused +// (position * rule count + rule id) index. The insert-heavy access pattern +// makes node-based containers a bottleneck, so keys and lengths live in one +// flat array of 16-byte POD slots probed linearly; semantic values go into a +// parallel array that is never allocated when no cached result carries a +// value. Erased slots become tombstones (erase only happens during +// left-recursion cache invalidation). +class PackratCache { +public: + explicit PackratCache(size_t expected_entries) { + while (initial_capacity_ < expected_entries) { + initial_capacity_ *= 2; + } + } + + bool find(size_t key, size_t &len, std::any &val) const { + if (slots_.empty()) { return false; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + len = slot.len; + if (!vals_.empty()) { + val = vals_[i]; + } else { + val.reset(); + } + return true; + } + if (slot.key == kEmpty) { return false; } + i = (i + 1) & mask; + } + } + + void insert_or_assign(size_t key, size_t len, const std::any &val) { + if (slots_.empty() || (used_ + 1) * 4 > slots_.size() * 3) { grow(); } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + auto insert_pos = kEmpty; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + insert_pos = i; + break; + } + if (slot.key == kTombstone) { + if (insert_pos == kEmpty) { insert_pos = i; } + } else if (slot.key == kEmpty) { + if (insert_pos == kEmpty) { insert_pos = i; } + if (slots_[insert_pos].key == kEmpty) { used_++; } + break; + } + i = (i + 1) & mask; + } + auto &dest = slots_[insert_pos]; + dest.key = key; + dest.len = len; + if (val.has_value()) { + if (vals_.empty()) { vals_.resize(slots_.size()); } + vals_[insert_pos] = val; + } else if (!vals_.empty()) { + vals_[insert_pos].reset(); + } + } + + void erase(size_t key) { + if (slots_.empty()) { return; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + slot.key = kTombstone; + if (!vals_.empty()) { vals_[i].reset(); } + return; + } + if (slot.key == kEmpty) { return; } + i = (i + 1) & mask; + } + } + +private: + static constexpr size_t kEmpty = static_cast(-1); + static constexpr size_t kTombstone = static_cast(-2); + + struct Slot { + size_t key = kEmpty; + size_t len = 0; + }; + + static size_t mix(size_t key) { + // Mix in 64 bits so `h >> 32` stays well-defined where size_t is 32-bit + // (wasm32); on 64-bit targets this is bit-identical to the size_t mix. + auto h = static_cast(key) * 0x9E3779B97F4A7C15ull; + return static_cast(h ^ (h >> 32)); + } + + void grow() { + auto new_cap = slots_.empty() ? initial_capacity_ : slots_.size() * 2; + std::vector old_slots = std::move(slots_); + std::vector old_vals = std::move(vals_); + slots_.assign(new_cap, Slot{}); + if (!old_vals.empty()) { vals_.assign(new_cap, std::any()); } + used_ = 0; + auto mask = new_cap - 1; + for (size_t j = 0; j < old_slots.size(); j++) { + auto &slot = old_slots[j]; + if (slot.key == kEmpty || slot.key == kTombstone) { continue; } + auto i = mix(slot.key) & mask; + while (slots_[i].key != kEmpty) { + i = (i + 1) & mask; + } + slots_[i] = slot; + if (!old_vals.empty()) { vals_[i] = std::move(old_vals[j]); } + used_++; + } + } + + size_t initial_capacity_ = 1024; + std::vector slots_; + std::vector vals_; + size_t used_ = 0; // occupied + tombstone slots +}; + class Context { public: const char *path; @@ -817,11 +1019,18 @@ public: ErrorInfo error_info; bool recovered = false; - std::vector> value_stack; + std::vector> value_stack; size_t value_stack_size = 0; std::vector rule_stack; - std::vector>> args_stack; + + // One frame per rule reference: the macro arguments in scope, and the + // instantiation they identify (0 for anything but a left-recursive macro). + struct ArgsFrame { + std::vector> args; + size_t macro_inst = 0; + }; + std::vector args_stack; size_t in_token_boundary_count = 0; @@ -836,68 +1045,115 @@ public: const size_t def_count; const bool enablePackratParsing; + const std::vector *packrat_index; // def_id -> cache slot or -1 + size_t packrat_cached_count; // number of memoized rules std::vector cache_registered; std::vector cache_success; + // Innermost active start position per rule; re-entry guard for rules that + // are not memoized (replaces the per-position bitvector for them). + std::vector active_pos; - std::map, std::tuple> - cache_values; + PackratCache cache_values; // Left recursion support struct LRMemo { size_t len = static_cast(-1); std::any val; }; - std::map, LRMemo> lr_memo; + + // A left-recursive rule instance: the definition plus, for a macro, the + // instantiation it was invoked with (0 for a plain rule). Two + // instantiations of the same macro grow independent seeds. + using LRRule = std::pair; + using LRKey = std::pair; + + std::map lr_memo; // Rules whose lr_memo was hit during the current parse scope. // Used to track LR cycle membership. - std::set lr_refs_hit; + std::set lr_refs_hit; // Rules currently in their seeding/growing phase at a given position. // Protected from having their lr_memo erased by inner growers. - std::set> lr_active_seeds; + std::set lr_active_seeds; + + // Interned macro instantiations: (definition, resolved arguments) -> id. + std::map, size_t> macro_inst_ids; + size_t next_macro_inst_ = 1; + + // Map a def_id to its slot in the cache tables, or -1 for guard-only + // rules (not memoized). + int32_t cache_slot(size_t def_id) const { + if (!packrat_index) { return static_cast(def_id); } + return def_id < packrat_index->size() ? (*packrat_index)[def_id] : -1; + } void clear_packrat_cache(const char *pos, size_t def_id) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = static_cast(pos - s); - auto idx = def_count * col + def_id; + auto idx = packrat_cached_count * col + static_cast(slot); if (idx < cache_registered.size()) { cache_registered[idx] = false; cache_success[idx] = false; } - cache_values.erase(std::make_pair(col, def_id)); + cache_values.erase(idx); } void write_packrat_cache(const char *pos, size_t def_id, size_t len, const std::any &val) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = pos - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (idx >= cache_registered.size()) { return; } cache_registered[idx] = true; cache_success[idx] = true; - auto key = std::pair(col, def_id); - cache_values[key] = std::pair(len, val); + cache_values.insert_or_assign(idx, len, val); } TracerEnter tracer_enter; TracerLeave tracer_leave; + const bool has_tracer; std::any trace_data; const bool verbose_trace; + // Byte-wise tolower frozen at parse start, so case-insensitive matching + // avoids a locale-sensitive libc call per input byte. + unsigned char tolower_table[256]; + Log log; + ErrorReporter error_reporter; Context(const char *path, const char *s, size_t l, size_t def_count, std::shared_ptr whitespaceOpe, std::shared_ptr wordOpe, bool enablePackratParsing, TracerEnter tracer_enter, TracerLeave tracer_leave, std::any trace_data, bool verbose_trace, - Log log) + Log log, ErrorReporter error_reporter = nullptr, + const std::vector *packrat_index = nullptr, + size_t packrat_cached_count = 0) : path(path), s(s), l(l), whitespaceOpe(whitespaceOpe), wordOpe(wordOpe), def_count(def_count), enablePackratParsing(enablePackratParsing), - cache_registered(enablePackratParsing ? def_count * (l + 1) : 0), - cache_success(enablePackratParsing ? def_count * (l + 1) : 0), + packrat_index(packrat_index), + packrat_cached_count(packrat_index ? packrat_cached_count : def_count), + cache_registered( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + cache_success( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + active_pos(enablePackratParsing ? def_count : 0, nullptr), + cache_values(enablePackratParsing ? (packrat_index ? l / 8 + 16 : l / 2) + : 0), tracer_enter(tracer_enter), tracer_leave(tracer_leave), - trace_data(trace_data), verbose_trace(verbose_trace), log(log) { + has_tracer(tracer_enter && tracer_leave), trace_data(trace_data), + verbose_trace(verbose_trace), log(log), error_reporter(error_reporter) { + + for (size_t i = 0; i < 256; i++) { + tolower_table[i] = + static_cast(std::tolower(static_cast(i))); + } push_args({}); } @@ -918,11 +1174,6 @@ public: }; std::vector *packrat_stats = nullptr; - // Per-rule packrat filter: if set, only rules with filter[def_id]=true - // use full memoization (cache_values map). Others use bitvector-only - // re-entry guard. - const std::vector *packrat_rule_filter = nullptr; - template void packrat(const char *a_s, size_t def_id, size_t &len, std::any &val, T fn) { @@ -931,23 +1182,47 @@ public: return; } + auto slot = cache_slot(def_id); + if (slot < 0) { + // Guard-only rule: no memoization. Recursion at the same position is + // caught by the per-rule active-position guard. + if (active_pos[def_id] == a_s) { + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].hits++; + } + len = static_cast(-1); + return; + } + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].misses++; + } + auto save = active_pos[def_id]; + active_pos[def_id] = a_s; + fn(val); + active_pos[def_id] = save; + return; + } + auto col = a_s - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (cache_registered[idx]) { if (packrat_stats && def_id < packrat_stats->size()) { (*packrat_stats)[def_id].hits++; } if (cache_success[idx]) { - auto key = std::pair(col, def_id); - std::tie(len, val) = cache_values[key]; + if (!cache_values.find(idx, len, val)) { + len = 0; + val.reset(); + } return; } else { len = static_cast(-1); return; } } else { - // Pre-register as failure (re-entry guard for all rules) + // Pre-register as failure (re-entry guard + failure memoization) cache_registered[idx] = true; cache_success[idx] = false; @@ -957,15 +1232,7 @@ public: fn(val); - bool full_memo = - !packrat_rule_filter || (def_id < packrat_rule_filter->size() && - (*packrat_rule_filter)[def_id]); - if (full_memo) { - if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } - } else { - // Guard-only: undo registration so future calls re-parse - cache_registered[idx] = false; - } + if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } return; } } @@ -974,7 +1241,7 @@ public: SemanticValues &push_semantic_values_scope() { assert(value_stack_size <= value_stack.size()); if (value_stack_size == value_stack.size()) { - value_stack.emplace_back(std::make_shared(this)); + value_stack.emplace_back(std::make_unique(this)); } else { auto &vs = *value_stack[value_stack_size]; if (!vs.empty()) { @@ -996,14 +1263,31 @@ public: void pop_semantic_values_scope() { value_stack_size--; } // Arguments - void push_args(std::vector> &&args) { - args_stack.emplace_back(std::move(args)); + void push_args(std::vector> &&args, + size_t macro_inst = 0) { + args_stack.push_back({std::move(args), macro_inst}); } void pop_args() { args_stack.pop_back(); } const std::vector> &top_args() const { - return args_stack[args_stack.size() - 1]; + return args_stack[args_stack.size() - 1].args; + } + + size_t top_macro_inst() const { + return args_stack[args_stack.size() - 1].macro_inst; + } + + // Identify a macro invocation by what its resolved arguments denote (see + // macro_inst_key). `Sum(A)` inside `Sum(N)`'s own body resolves A back to + // the argument the outer call was given, so both invocations intern to the + // same id and the inner one finds the outer's seed — which is what makes + // growing terminate. + size_t intern_macro_inst(std::vector &&key) { + auto [it, inserted] = + macro_inst_ids.emplace(std::move(key), next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; } // Snapshot/Rollback @@ -1169,8 +1453,8 @@ private: lower_heap.reset(new char[id_len]); lower = lower_heap.get(); } - std::transform(s, s + id_len, lower, [](unsigned char ch) { - return static_cast(std::tolower(ch)); + std::transform(s, s + id_len, lower, [&c](unsigned char ch) { + return static_cast(c.tolower_table[ch]); }); std::string_view lower_sv(lower, id_len); @@ -1241,7 +1525,8 @@ public: const auto &fs = first_sets_[id]; if (!fs.any_char && !fs.can_be_empty && !fs.chars.test(static_cast(*s))) { - if (c.log && (fs.first_literal || fs.first_rule)) { + if ((c.log || c.error_reporter) && + (fs.first_literal || fs.first_rule)) { if (c.error_info.error_pos <= s) { if (c.error_info.error_pos < s || !(id > 0)) { c.error_info.error_pos = s; @@ -1517,6 +1802,8 @@ public: void accept(Visitor &v) override; friend struct ComputeFirstSet; + friend struct GrammarBlob; + friend struct OpeSignature; bool is_ascii_only() const { return is_ascii_only_; } const std::bitset<256> &ascii_bitset() const { return ascii_bitset_; } @@ -1799,6 +2086,10 @@ public: std::shared_ptr atom_; std::shared_ptr binop_; BinOpeInfo info_; + // Owned backing storage for info_ keys when this node is built by + // GrammarBlob::deserialize. Grammars parsed from source leave this empty and + // point info_ keys into the retained grammar text instead. + std::vector info_keys_; const Definition &rule_; private: @@ -2179,11 +2470,35 @@ struct DetectLeftRecursion : public TraversalVisitor { const char *error_s = nullptr; - std::shared_ptr resolve_macro_arg(size_t iarg) const; + // What a bare parameter reference denotes, plus the frame it was found at + // -- see visit_in_defining_scope. + struct ResolvedArg { + std::shared_ptr ope; + size_t depth = 0; + }; + ResolvedArg resolve_macro_arg(size_t iarg) const; + void visit_in_defining_scope(const ResolvedArg &arg); + + // A macro's body depends on its arguments, so "already visited" has to be + // per instantiation, not per name: in `A <- W('z') / W(A)`, visiting W with + // 'z' says nothing about W with A. Instantiations are identified by their + // resolved arguments, the same way as at parse time. + size_t intern_macro_inst(const Reference &ope); + + // A macro that instantiates itself with a growing argument + // (`M(s) <- M(s / 'x')`) has no finite set of instantiations. Stop + // descending instead of looping forever; the rule is then reported as + // non-left-recursive, which is what this analysis did for every macro + // before it became instantiation-aware. The bound is on nesting depth in + // general, not self-recursion specifically, so it also caps any other + // chain of nested macro calls -- generously, for real grammars. + static const size_t max_macro_inst_depth = 32; private: std::string name_; - std::unordered_set refs_; + std::set> refs_; + std::map, size_t> macro_inst_ids_; + size_t next_macro_inst_ = 1; bool done_ = false; std::vector> *> macro_args_stack_; }; @@ -2221,6 +2536,104 @@ struct ComputeCanBeEmpty : public TraversalVisitor { void visit(Cut &) override { result = false; } }; +// Structural signature of an Ope. Two alternatives whose first k elements +// have equal signatures consume the same text, so their (k+1)-th elements +// start at the same position. Opes whose state cannot be serialized get +// their address instead: that only ever reads as "these differ", which +// costs an optimization rather than adding one. +struct OpeSignature : public Ope::Visitor { + using Ope::Visitor::visit; + std::string s; + + void visit(Sequence &ope) override { group("seq", ope.opes_); } + void visit(PrioritizedChoice &ope) override { group("cho", ope.opes_); } + void visit(Repetition &ope) override { + s += "(rep " + std::to_string(ope.min_) + " " + + (ope.max_ == std::numeric_limits::max() + ? std::string("inf") + : std::to_string(ope.max_)); + wrap(*ope.ope_); + } + void visit(AndPredicate &ope) override { unary("and", *ope.ope_); } + void visit(NotPredicate &ope) override { unary("not", *ope.ope_); } + void visit(CaptureScope &ope) override { unary("cps", *ope.ope_); } + void visit(Capture &ope) override { unary("cap", *ope.ope_); } + void visit(TokenBoundary &ope) override { unary("tok", *ope.ope_); } + void visit(Ignore &ope) override { unary("ign", *ope.ope_); } + void visit(Whitespace &ope) override { unary("wsp", *ope.ope_); } + void visit(Recovery &ope) override { unary("rec", *ope.ope_); } + // A rule is named, never expanded — that is what keeps a recursive + // grammar's signature finite. WeakHolder only ever wraps a Holder, so + // descending through it lands on a name too. + void visit(Holder &ope) override { s += "(hld " + ope.name() + ")"; } + void visit(WeakHolder &ope) override { + if (auto p = ope.weak_.lock()) { + unary("wek", *p); + } else { + opaque(&ope); + } + } + void visit(Reference &ope) override { + s += "(ref " + ope.name_; + for (auto &arg : ope.args_) { + s += ' '; + arg->accept(*this); + } + s += ')'; + } + void visit(LiteralString &ope) override { + s += "(lit " + std::to_string(ope.ignore_case_) + " " + ope.lit_ + ")"; + } + void visit(CharacterClass &ope) override { + s += "(cls " + std::to_string(ope.negated_) + " " + + std::to_string(ope.ignore_case_); + for (const auto &[lo, hi] : ope.ranges_) { + s += " " + std::to_string(static_cast(lo)) + "-" + + std::to_string(static_cast(hi)); + } + s += ')'; + } + void visit(Character &ope) override { + s += "(chr " + std::to_string(static_cast(ope.ch_)) + ")"; + } + void visit(AnyCharacter &) override { s += "(any)"; } + void visit(Dictionary &ope) override { opaque(&ope); } + void visit(User &ope) override { opaque(&ope); } + void visit(BackReference &ope) override { opaque(&ope); } + void visit(PrecedenceClimbing &ope) override { opaque(&ope); } + void visit(Cut &ope) override { opaque(&ope); } + + static std::string get(Ope &ope) { + OpeSignature vis; + ope.accept(vis); + return std::move(vis.s); + } + +private: + void group(const char *tag, const std::vector> &v) { + s += '('; + s += tag; + for (const auto &op : v) { + s += ' '; + op->accept(*this); + } + s += ')'; + } + void unary(const char *tag, Ope &inner) { + s += '('; + s += tag; + wrap(inner); + } + void wrap(Ope &inner) { + s += ' '; + inner.accept(*this); + s += ')'; + } + void opaque(const void *p) { + s += "(opq " + std::to_string(reinterpret_cast(p)) + ")"; + } +}; + struct HasEmptyElement : public TraversalVisitor { using TraversalVisitor::visit; @@ -2571,6 +2984,7 @@ struct SetupFirstSets : public TraversalVisitor { if (cc && cc->is_ascii_only()) { ope.span_bitset_ = &cc->ascii_bitset(); } } void visit(Reference &ope) override; + void visit(Holder &ope) override; private: ComputeFirstSet::FirstSetCache first_set_cache_; @@ -2617,37 +3031,40 @@ public: } Result parse(const char *s, size_t n, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } - Result parse(const char *s, const char *path = nullptr, - Log log = nullptr) const { + Result parse(const char *s, const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, path, log); + return parse(s, n, path, log, error_reporter); } Result parse(const char *s, size_t n, std::any &dt, - const char *path = nullptr, Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } Result parse(const char *s, std::any &dt, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, dt, path, log); + return parse(s, n, dt, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2656,17 +3073,18 @@ public: template Result parse_and_get_value(const char *s, T &val, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, val, path, log); + return parse_and_get_value(s, n, val, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2675,10 +3093,10 @@ public: template Result parse_and_get_value(const char *s, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, dt, val, path, log); + return parse_and_get_value(s, n, dt, val, path, log, error_reporter); } #if defined(__cpp_lib_char8_t) @@ -2789,6 +3207,10 @@ public: std::string error_message; bool no_ast_opt = false; + bool no_whitespace = false; // Disable %whitespace skipping inside this rule + // (like a token boundary, without capturing) + std::string ast_name; // When non-empty, AST nodes produced by this rule carry + // this name/tag instead of the rule's own name bool eoi_check = true; @@ -2816,7 +3238,8 @@ private: void initialize_packrat_filter() const; Result parse_core(const char *s, size_t n, SemanticValues &vs, std::any &dt, - const char *path, Log log) const { + const char *path, Log log, + ErrorReporter error_reporter = nullptr) const { initialize_definition_ids(); std::shared_ptr ope = holder_; @@ -2827,22 +3250,28 @@ private: if (tracer_end) { tracer_end(trace_data); } }); + const std::vector *packrat_index = nullptr; + size_t packrat_cached_count = 0; + if (enablePackratParsing) { + initialize_packrat_filter(); + if (!packrat_index_.empty()) { + packrat_index = &packrat_index_; + packrat_cached_count = packrat_cached_count_; + } else { + packrat_cached_count = definition_ids_.size(); + } + } + Context c(path, s, n, definition_ids_.size(), whitespaceOpe, wordOpe, enablePackratParsing, tracer_enter, tracer_leave, trace_data, - verbose_trace, log); + verbose_trace, log, error_reporter, packrat_index, + packrat_cached_count); if (collect_packrat_stats) { packrat_stats_.resize(definition_ids_.size()); c.packrat_stats = &packrat_stats_; } - if (enablePackratParsing) { - initialize_packrat_filter(); - if (!packrat_filter_.empty()) { - c.packrat_rule_filter = &packrat_filter_; - } - } - size_t i = 0; if (whitespaceOpe) { @@ -2881,7 +3310,8 @@ private: mutable std::once_flag definition_ids_init_; mutable std::unordered_map definition_ids_; mutable std::once_flag packrat_filter_init_; - mutable std::vector packrat_filter_; + mutable std::vector packrat_index_; // def_id -> cache slot or -1 + mutable size_t packrat_cached_count_ = 0; }; /* @@ -2895,9 +3325,11 @@ inline size_t parse_literal(const char *s, size_t n, SemanticValues &vs, size_t i = 0; for (; i < lit.size(); i++) { if (i >= n || - (ignore_case ? (static_cast(std::tolower( - static_cast(s[i]))) != lower_lit[i]) - : (s[i] != lit[i]))) { + (ignore_case + ? (static_cast( + c.tolower_table[static_cast(s[i])]) != + lower_lit[i]) + : (s[i] != lit[i]))) { c.set_error_pos(s, lit.data()); return static_cast(-1); } @@ -2950,14 +3382,15 @@ inline std::pair SemanticValues::line_info() const { return c_->line_info(sv_.data()); } -inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { +inline void ErrorInfo::output_log(const Log &log, const ErrorReporter &reporter, + const char *s, size_t n) { if (message_pos) { if (message_pos > last_output_pos) { last_output_pos = message_pos; auto line = line_info(s, message_pos); std::string msg; - if (auto unexpected_token = heuristic_error_token(s, n, message_pos); - !unexpected_token.empty()) { + auto unexpected_token = heuristic_error_token(s, n, message_pos); + if (!unexpected_token.empty()) { msg = replace_all(message, "%t", unexpected_token); auto unexpected_char = unexpected_token.substr( @@ -2968,13 +3401,28 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } else { msg = message; } - log(line.first, line.second, msg, label); + if (reporter) { + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(message_pos - s); + report.unexpected_token = unexpected_token; + report.message = msg; + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } else if (error_pos) { if (error_pos > last_output_pos) { last_output_pos = error_pos; auto line = line_info(s, error_pos); + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(error_pos - s); + std::string msg; if (expected_tokens.empty()) { msg = "syntax error."; @@ -2987,6 +3435,7 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += ", unexpected '"; msg += unexpected_token; msg += "'"; + report.unexpected_token = unexpected_token; } auto first_item = true; @@ -3001,9 +3450,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += "'"; msg += error_literal; msg += "'"; + report.expected_literals.emplace_back(error_literal); } else { msg += "<" + error_rule->name + ">"; if (label.empty()) { label = error_rule->name; } + report.expected_rules.emplace_back(error_rule->name); } first_item = false; } @@ -3012,7 +3463,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } msg += "."; } - log(line.first, line.second, msg, label); + if (reporter) { + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } } @@ -3027,7 +3482,7 @@ inline size_t Context::skip_whitespace(const char *a_s, size_t n, } inline void Context::set_error_pos(const char *a_s, const char *literal) { - if (log) { + if (log || error_reporter) { if (error_info.error_pos <= a_s) { if (error_info.error_pos < a_s || !error_info.keep_previous_token) { error_info.error_pos = a_s; @@ -3074,7 +3529,7 @@ inline void Context::trace_leave(const Ope &ope, const char *a_s, size_t n, } inline bool Context::is_traceable(const Ope &ope) const { - if (tracer_enter && tracer_leave) { + if (has_tracer) { if (ignore_trace_state) { return false; } return !dynamic_cast(&ope); } @@ -3169,14 +3624,56 @@ inline size_t TokenBoundary::parse_core(const char *s, size_t n, return len; } +// Resolve `%{name}` placeholders in a custom error message against the +// named captures recorded so far ($name<...>). Unknown names resolve to an +// empty string. `%t` / `%c` are resolved later, at log-output time. +inline std::string resolve_capture_placeholders(const std::string &msg, + const Context &c) { + auto pos = msg.find("%{"); + if (pos == std::string::npos) { return msg; } + + std::string r; + size_t i = 0; + while (pos != std::string::npos) { + auto end = msg.find('}', pos + 2); + if (end == std::string::npos) { break; } + r.append(msg, i, pos - i); + auto name = std::string_view(msg).substr(pos + 2, end - (pos + 2)); + for (auto it = c.capture_entries.rbegin(); it != c.capture_entries.rend(); + ++it) { + if (it->first == name) { + // The captured span can include whitespace skipped after a token + // boundary; trim it for display. + auto v = std::string_view(it->second); + while (!v.empty() && + std::isspace(static_cast(v.back()))) { + v.remove_suffix(1); + } + while (!v.empty() && + std::isspace(static_cast(v.front()))) { + v.remove_prefix(1); + } + r += v; + break; + } + } + i = end + 1; + pos = msg.find("%{", i); + } + r.append(msg, i, msg.size() - i); + return r; +} + inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { if (!ope_) { throw std::logic_error("Uninitialized definition ope was used..."); } - // Macro reference - if (outer_->is_macro) { + // Macro reference. A left-recursive macro cannot take this path: it needs + // the seed-growing below, which in turn needs its own semantic value scope + // to memoise. Such a macro forms a scope like a plain rule does. + if (outer_->is_macro && !outer_->is_left_recursive) { c.rule_stack.push_back(outer_); auto len = ope_->parse(s, n, vs, c, dt); c.rule_stack.pop_back(); @@ -3201,7 +3698,23 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }); c.rule_stack.push_back(outer_); - parse_len = ope_->parse(s, n, chvs, c, dt); + if (outer_->no_whitespace) { + { + c.in_token_boundary_count++; + auto se2 = scope_exit([&]() { c.in_token_boundary_count--; }); + parse_len = ope_->parse(s, n, chvs, c, dt); + } + if (success(parse_len)) { + auto wl = c.skip_whitespace(s + parse_len, n - parse_len, chvs, dt); + if (fail(wl)) { + parse_len = wl; + } else { + parse_len += wl; + } + } + } else { + parse_len = ope_->parse(s, n, chvs, c, dt); + } c.rule_stack.pop_back(); if (success(parse_len)) { @@ -3221,7 +3734,8 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, std::any predicate_data; if (outer_->predicate) { if (!outer_->predicate(chvs, dt, msg, predicate_data)) { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; @@ -3233,17 +3747,19 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, if (success(parse_len)) { if (!c.recovered) { parse_val = reduce(chvs, dt, predicate_data); } } else { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; } } } else { - if (c.log && !outer_->error_message.empty() && + if ((c.log || c.error_reporter) && !outer_->error_message.empty() && c.error_info.message_pos < s) { c.error_info.message_pos = s; - c.error_info.message = outer_->error_message; + c.error_info.message = + resolve_capture_placeholders(outer_->error_message, c); c.error_info.label = outer_->name; } } @@ -3252,7 +3768,10 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }; if (outer_->is_left_recursive) { - auto lr_key = std::make_pair(outer_, s); + // A macro grows one seed per instantiation: Sum(D) and Sum(L) are + // different rules as far as the memo is concerned. + auto lr_rule = Context::LRRule(outer_, c.top_macro_inst()); + auto lr_key = Context::LRKey(lr_rule, s); // Check LR memo first auto it = c.lr_memo.find(lr_key); @@ -3265,7 +3784,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } // Record that this rule's lr_memo was accessed. // Any LR rule currently seeding will know we're in its cycle. - c.lr_refs_hit.insert(outer_); + c.lr_refs_hit.insert(lr_rule); } else { // Seed with FAIL c.lr_memo[lr_key] = {static_cast(-1), {}}; @@ -3287,7 +3806,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // the cycle, so add self — this lets parent seeders see us as // a transitive cycle member. auto cycle_rules = c.lr_refs_hit; - if (!cycle_rules.empty()) { cycle_rules.insert(outer_); } + if (!cycle_rules.empty()) { cycle_rules.insert(lr_rule); } // Restore parent's refs and propagate cycle info upward c.lr_refs_hit = std::move(saved_refs); @@ -3303,15 +3822,17 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, c.lr_memo[lr_key] = {len, val}; while (true) { - // Clear this rule's packrat cache - c.clear_packrat_cache(s, outer_->id); + // Clear this rule's packrat cache. A macro is never written there + // (that cache is keyed by rule id alone, which cannot tell two + // instantiations apart), so there is nothing to clear for one. + if (!outer_->is_macro) { c.clear_packrat_cache(s, outer_->id); } // Clear lr_memo for cycle-dependent rules at this position, // but NOT for rules currently in their own seeding phase // (lr_active_seeds) — those are outer growers we must not // interfere with. for (auto memo_it = c.lr_memo.begin(); memo_it != c.lr_memo.end();) { - if (memo_it->first.second == s && memo_it->first.first != outer_ && + if (memo_it->first.second == s && memo_it->first.first != lr_rule && cycle_rules.count(memo_it->first.first) && !c.lr_active_seeds.count(memo_it->first)) { memo_it = c.lr_memo.erase(memo_it); @@ -3334,7 +3855,9 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // Write final result to packrat cache (lr_memo entry is kept as // the primary lookup for LR rules at this position) - if (success(len)) { c.write_packrat_cache(s, outer_->id, len, val); } + if (success(len) && !outer_->is_macro) { + c.write_packrat_cache(s, outer_->id, len, val); + } } } else { if (c.enablePackratParsing) { @@ -3348,7 +3871,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } else { // Without packrat, use lr_memo as re-entry guard to prevent // stack overflow from undetected left recursion. - auto guard_key = std::make_pair(outer_, s); + auto guard_key = Context::LRKey({outer_, c.top_macro_inst()}, s); if (c.lr_memo.count(guard_key)) { len = static_cast(-1); } else { @@ -3390,6 +3913,23 @@ inline const std::string &Holder::trace_name() const { return trace_name_; } +// Key a macro instantiation by what each argument denotes rather than by the +// node that spells it: `M(N)` written at two call sites builds two Reference +// nodes for the same rule N, and those are the same instantiation. +inline std::vector +macro_inst_key(const Definition *def, + const std::vector> &args) { + std::vector key; + key.reserve(args.size() + 1); + key.push_back(def); + for (const auto &arg : args) { + auto ref = dynamic_cast(arg.get()); + key.push_back(ref && ref->rule_ ? static_cast(ref->rule_) + : static_cast(arg.get())); + } + return key; +} + inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { auto save_ignore_trace_state = c.ignore_trace_state; @@ -3412,7 +3952,10 @@ inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, args.emplace_back(std::move(vis.found_ope)); } - c.push_args(std::move(args)); + auto inst = rule_->is_left_recursive + ? c.intern_macro_inst(macro_inst_key(rule_, args)) + : 0; + c.push_args(std::move(args), inst); auto se = scope_exit([&]() { c.pop_args(); }); return rule_->holder_->parse(s, n, vs, c, dt); } else { @@ -3548,11 +4091,12 @@ inline size_t Recovery::parse_core(const char *s, size_t n, const auto &rule = dynamic_cast(*ope_); // Custom error message - if (c.log) { + if (c.log || c.error_reporter) { auto label = dynamic_cast(rule.args_[0].get()); if (label && !label->rule_->error_message.empty()) { c.error_info.message_pos = s; - c.error_info.message = label->rule_->error_message; + c.error_info.message = + resolve_capture_placeholders(label->rule_->error_message, c); c.error_info.label = label->rule_->name; } } @@ -3561,8 +4105,13 @@ inline size_t Recovery::parse_core(const char *s, size_t n, auto len = static_cast(-1); { auto save_log = c.log; + auto save_reporter = c.error_reporter; c.log = nullptr; - auto se = scope_exit([&]() { c.log = save_log; }); + c.error_reporter = nullptr; + auto se = scope_exit([&]() { + c.log = save_log; + c.error_reporter = save_reporter; + }); SemanticValues dummy_vs; std::any dummy_dt; @@ -3573,8 +4122,8 @@ inline size_t Recovery::parse_core(const char *s, size_t n, if (success(len)) { c.recovered = true; - if (c.log) { - c.error_info.output_log(c.log, c.s, c.l); + if (c.log || c.error_reporter) { + c.error_info.output_log(c.log, c.error_reporter, c.s, c.l); c.error_info.clear(); } } @@ -3662,32 +4211,37 @@ inline void ComputeCanBeEmpty::visit(Reference &ope) { } inline void DetectLeftRecursion::visit(Reference &ope) { + // Macro parameter reference: what it denotes lives in an enclosing + // instantiation (e.g. B(X) <- C(X) where X is itself a param ref). + auto param = !ope.rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ope.iarg_) + : ResolvedArg{}; + if (ope.name_ == name_) { error_s = ope.s_; - } else if (!ope.rule_ && !macro_args_stack_.empty()) { - // Macro parameter reference: resolve through nested macro arg - // stacks (e.g. B(X) <- C(X) where X is itself a param ref). - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { - resolved->accept(*this); - if (done_ == false) { return; } - } - } else if (!refs_.count(ope.name_)) { - refs_.insert(ope.name_); - if (ope.rule_) { - if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } - ope.rule_->accept(*this); - if (ope.is_macro_) { macro_args_stack_.pop_back(); } - if (done_ == false) { return; } - } + } else if (param.ope) { + visit_in_defining_scope(param); + if (done_ == false) { return; } + } else if (ope.is_macro_ && + macro_args_stack_.size() >= max_macro_inst_depth) { + // Unbounded instantiation chain; stop descending. + } else if (ope.rule_ && + refs_ + .emplace(ope.rule_, ope.is_macro_ ? intern_macro_inst(ope) : 0) + .second) { + if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } + ope.rule_->accept(*this); + if (ope.is_macro_) { macro_args_stack_.pop_back(); } + if (done_ == false) { return; } } // If the referenced rule can match empty, don't mark as done — // the sequence may continue past this element to find LR. if (!ope.rule_ && !macro_args_stack_.empty()) { - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { + if (param.ope) { + // ComputeCanBeEmpty never consults the frame stack, so the scope it + // runs in cannot matter. ComputeCanBeEmpty cbe; - resolved->accept(cbe); + param.ope->accept(cbe); done_ = !cbe.result; } else { done_ = true; @@ -3697,20 +4251,51 @@ inline void DetectLeftRecursion::visit(Reference &ope) { } } -inline std::shared_ptr +inline size_t DetectLeftRecursion::intern_macro_inst(const Reference &ope) { + // Resolve bare parameter references to what the enclosing instantiation was + // given, so a macro passing its own parameter through interns to the same + // instantiation instead of a fresh one at every nesting level. + std::vector> args; + args.reserve(ope.args_.size()); + for (const auto &arg : ope.args_) { + auto ref = dynamic_cast(arg.get()); + auto resolved = ref && !ref->rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ref->iarg_).ope + : nullptr; + args.push_back(resolved ? resolved : arg); + } + auto [it, inserted] = macro_inst_ids_.emplace(macro_inst_key(ope.rule_, args), + next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; +} + +inline void +DetectLeftRecursion::visit_in_defining_scope(const ResolvedArg &arg) { + // The frames below the one holding it are the scope it was written in. + // `W(X) <- Y(X / 'x')` passes Y an argument whose own `X` means W's + // parameter, not Y's -- leaving Y's frame visible would resolve that `X` + // right back to `X / 'x'`, forever. + auto saved = macro_args_stack_; + auto se = scope_exit([&]() { macro_args_stack_ = std::move(saved); }); + macro_args_stack_.resize(arg.depth); + arg.ope->accept(*this); +} + +inline DetectLeftRecursion::ResolvedArg DetectLeftRecursion::resolve_macro_arg(size_t iarg) const { for (int i = static_cast(macro_args_stack_.size()) - 1; i >= 0; i--) { auto &args = *macro_args_stack_[i]; - if (iarg >= args.size()) { return nullptr; } + if (iarg >= args.size()) { return {}; } auto ref = dynamic_cast(args[iarg].get()); if (ref && !ref->rule_) { // Another param ref — resolve using parent level's args iarg = ref->iarg_; continue; } - return args[iarg]; + return {args[iarg], static_cast(i)}; } - return nullptr; + return {}; } inline void HasEmptyElement::visit(Sequence &ope) { @@ -3863,8 +4448,16 @@ inline void ComputeFirstSet::visit(Reference &ope) { inline void SetupFirstSets::visit(Reference &ope) { if (!ope.rule_) { return; } - if (!visited_rules_.insert(ope.rule_).second) { return; } - ope.rule_->accept(*this); + ope.rule_->accept(*this); // re-entry is guarded at the rule's Holder +} + +// Guard rule setup by Definition so a SetupFirstSets shared across all rules +// visits each rule's body at most once for the whole grammar. Without this the +// per-rule setup re-walks every reachable rule once per referencing rule, which +// is O(N^2) for grammars with dense cross-references. +inline void SetupFirstSets::visit(Holder &ope) { + if (!visited_rules_.insert(ope.outer_).second) { return; } + ope.ope_->accept(*this); } inline void SetupFirstSets::visit(Sequence &ope) { @@ -3982,17 +4575,47 @@ inline void Definition::initialize_packrat_filter() const { auto def_count = definition_ids_.size(); if (def_count == 0) { return; } - // Collect rule IDs reachable from an Ope subtree (bitvector indexed by - // def_id) - struct CollectReachableRules : public TraversalVisitor { + // Collect rule IDs that can be invoked at the *same start position* as + // the given Ope subtree (leftmost reachability). A packrat cache hit + // requires the same rule to be queried twice at the same position, and + // in a PEG that only happens when alternatives of a choice share a + // leftmost prefix — rules reachable only past a consuming element can + // never be re-queried by a sibling alternative. + struct CollectLeftmostRules : public TraversalVisitor { using TraversalVisitor::visit; std::vector reachable; // indexed by def_id + std::vector + visited_rules; // indexed by def_id; guards Holder cycles - CollectReachableRules(size_t n) : reachable(n, false) {} + CollectLeftmostRules(size_t n) + : reachable(n, false), visited_rules(n, false) {} + // Collect from the position element `from` starts at: element `from` + // itself, plus what follows for as long as elements can match empty — + // only up to (and including) the first one that must consume input. + void collect(const std::vector> &opes, size_t from) { + for (auto i = from; i < opes.size(); i++) { + opes[i]->accept(*this); + ComputeCanBeEmpty empty_vis; + opes[i]->accept(empty_vis); + if (!empty_vis.result) { break; } + } + } + + void visit(Sequence &ope) override { collect(ope.opes_, 0); } void visit(Holder &ope) override { auto id = ope.outer_->id; - if (id < reachable.size()) { reachable[id] = true; } + if (id < reachable.size()) { + reachable[id] = true; + + // Grammars built directly via the combinator API embed rules through + // WeakHolder rather than Reference, so a recursive rule forms a + // Holder cycle with no Reference to break it. Guard re-entry to avoid + // infinite recursion (reachability is monotone, so revisiting a rule + // we have already traversed adds nothing). + if (visited_rules[id]) { return; } + visited_rules[id] = true; + } ope.ope_->accept(*this); } void visit(Reference &ope) override { @@ -4004,7 +4627,8 @@ inline void Definition::initialize_packrat_filter() const { } }; - // Find rules that benefit: reachable from 2+ alternatives of same choice + // Find rules that benefit: queried by 2+ alternatives of the same choice + // at the same position std::vector benefits(def_count, false); struct FindBacktrackRules : public TraversalVisitor { @@ -4016,24 +4640,60 @@ inline void Definition::initialize_packrat_filter() const { FindBacktrackRules(std::vector &b, size_t n) : benefits(b), def_count(n), visited_rules(n, false) {} - void visit(PrioritizedChoice &ope) override { - // For each alternative, collect reachable rules as bitvectors - std::vector> alt_reachable; - for (auto &op : ope.opes_) { - CollectReachableRules crr(def_count); - op->accept(crr); - alt_reachable.push_back(std::move(crr.reachable)); - } + using Elements = std::vector>; - // Mark rules reachable from 2+ alternatives + // An alternative's top-level elements, so a shared prefix can be walked + // element by element. By value: this runs once per grammar. + static Elements elements_of(const std::shared_ptr &alt) { + if (auto *seq = dynamic_cast(alt.get())) { + return seq->opes_; + } + return {alt}; + } + + // `group` holds alternatives that agree on their first `k` elements, so + // every one of them reaches element k at the same input position — that + // is exactly when a packrat cache entry can hit. k == 0 is the plain + // "alternatives of one choice" case; deeper k is what a shared prefix + // like `'(' _ PATTERN _ ',' _` hides. + void mark_aligned(const std::vector &group, size_t k) { + if (group.size() < 2) { return; } + + std::vector> reachable; + reachable.reserve(group.size()); + for (const auto &seq : group) { + CollectLeftmostRules clr(def_count); + clr.collect(seq, k); + reachable.push_back(std::move(clr.reachable)); + } for (size_t id = 0; id < def_count; id++) { size_t count = 0; - for (auto &alt : alt_reachable) { + for (const auto &alt : reachable) { if (alt[id]) { count++; } } if (count >= 2) { benefits[id] = true; } } + // Only alternatives that also agree on element k stay aligned past it. + std::map> aligned; + for (const auto &seq : group) { + if (k < seq.size()) { + aligned[OpeSignature::get(*seq[k])].push_back(seq); + } + } + for (const auto &[sig, sub] : aligned) { + mark_aligned(sub, k + 1); + } + } + + void visit(PrioritizedChoice &ope) override { + std::vector group; + group.reserve(ope.opes_.size()); + for (const auto &op : ope.opes_) { + group.push_back(elements_of(op)); + } + mark_aligned(group, 0); + // Recurse into alternatives for (auto &op : ope.opes_) { op->accept(*this); @@ -4056,7 +4716,23 @@ inline void Definition::initialize_packrat_filter() const { if (whitespaceOpe) { whitespaceOpe->accept(finder); } if (wordOpe) { wordOpe->accept(finder); } - packrat_filter_ = std::move(benefits); + // Left-recursive rules read and write the packrat cache directly during + // seed-growing, so they must stay in the cached set. Macros are the + // exception: they use lr_memo only, keyed by instantiation. + for (const auto &[ptr, id] : definition_ids_) { + auto *def = static_cast(ptr); + if (def->is_left_recursive && !def->is_macro && id < def_count) { + benefits[id] = true; + } + } + + // Compact index: def_id -> slot in the cache tables (-1 = guard only) + packrat_index_.assign(def_count, -1); + int32_t k = 0; + for (size_t id = 0; id < def_count; id++) { + if (benefits[id]) { packrat_index_[id] = k++; } + } + packrat_cached_count_ = static_cast(k); }); } @@ -4094,6 +4770,419 @@ inline void FindReference::visit(Reference &ope) { found_ope = ope.shared_from_this(); } +/*----------------------------------------------------------------------------- + * Grammar serialization + * + * Serialize a compiled Grammar (the operator tree) to a byte blob and back, + * letting an application skip the meta-parse on startup by embedding a + * prebuilt blob. Structure only: semantic callbacks (actions / enter / leave / + * predicate, attached by enable_ast() etc.) are NOT serialized and must be + * re-applied after deserialize. References resolve by name (no pointer fixup); + * first-sets and keyword guards are recomputed on load (O(N)). The + * `precedence` instruction is supported (its operator table is structural). + * Grammars using the `User` operator or a Capture with a match action are + * rejected. The blob is specific to this peglib version's layout. + *---------------------------------------------------------------------------*/ + +struct GrammarBlob { + enum Tag : uint8_t { + T_Sequence, + T_Choice, + T_Repetition, + T_And, + T_Not, + T_Dictionary, + T_Literal, + T_CharClass, + T_Char, + T_AnyChar, + T_CaptureScope, + T_Capture, + T_TokenBoundary, + T_Ignore, + T_BackRef, + T_Reference, + T_Whitespace, + T_Recovery, + T_Cut, + T_PrecedenceClimbing, + T_Null + }; + + struct Writer { + std::vector b; + void u8(uint8_t v) { b.push_back(v); } + void u32(uint32_t v) { + for (int i = 0; i < 4; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void u64(uint64_t v) { + for (int i = 0; i < 8; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void str(const std::string &s) { + u32((uint32_t)s.size()); + b.insert(b.end(), s.begin(), s.end()); + } + }; + + static void write_ope(Writer &w, const std::shared_ptr &o) { + if (!o) { + w.u8(T_Null); + return; + } + Ope *p = o.get(); + if (auto x = dynamic_cast(p)) { + w.u8(T_Sequence); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Choice); + w.u8(x->for_label_ ? 1 : 0); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Repetition); + w.u64(x->min_); + w.u64(x->max_); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_And); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Not); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Dictionary); + w.u8(x->trie_.ignore_case_ ? 1 : 0); + // Recover words in their original choice-index order. The Trie stores + // each full word's id (its index in the constructor vector), which + // parse_core reports as vs.choice(). Iterating dic_ directly yields + // sorted key order and would renumber the choices, so place each word at + // its id. + std::vector words(x->trie_.items_count()); + for (auto &kv : x->trie_.dic_) + if (kv.second.match && kv.second.id < words.size()) + words[kv.second.id] = kv.first; + w.u32((uint32_t)words.size()); + for (auto &s : words) + w.str(s); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Literal); + w.u8(x->ignore_case_ ? 1 : 0); + w.str(x->lit_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CharClass); + w.u8(x->negated_ ? 1 : 0); + w.u8(x->ignore_case_ ? 1 : 0); + w.u32((uint32_t)x->ranges_.size()); + for (auto &r : x->ranges_) { + w.u32((uint32_t)r.first); + w.u32((uint32_t)r.second); + } + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Char); + w.u32((uint32_t)x->ch_); + } else if (dynamic_cast(p)) { + w.u8(T_AnyChar); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CaptureScope); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + if (x->match_action_) { + throw std::runtime_error( + "GrammarBlob: Capture with a match action is not serializable"); + } + w.u8(T_Capture); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_TokenBoundary); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Ignore); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_BackRef); + w.str(x->name_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Reference); + w.u8(x->is_macro_ ? 1 : 0); + w.str(x->name_); + w.u32((uint32_t)x->args_.size()); + for (auto &a : x->args_) + write_ope(w, a); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Whitespace); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Recovery); + write_ope(w, x->ope_); + } else if (dynamic_cast(p)) { + w.u8(T_Cut); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_PrecedenceClimbing); + write_ope(w, x->atom_); + write_ope(w, x->binop_); + w.u32((uint32_t)x->info_.size()); + for (auto &[key, pri] : x->info_) { + w.str(std::string(key)); + w.u64((uint64_t)pri.first); + w.u8((uint8_t)pri.second); + } + } else { + throw std::runtime_error( + "GrammarBlob: operator not serializable (a custom User operator or " + "a Capture with a match action)"); + } + } + + struct Reader { + const uint8_t *p, *end; + uint8_t u8() { + if (p >= end) + throw std::runtime_error("GrammarBlob: unexpected end of blob"); + return *p++; + } + uint32_t u32() { + uint32_t v = 0; + for (int i = 0; i < 4; i++) + v |= (uint32_t)u8() << (8 * i); + return v; + } + uint64_t u64() { + uint64_t v = 0; + for (int i = 0; i < 8; i++) + v |= (uint64_t)u8() << (8 * i); + return v; + } + std::string str() { + uint32_t n = u32(); + std::string s((const char *)p, (const char *)p + n); + p += n; + return s; + } + }; + + static std::shared_ptr read_ope(Reader &r, Grammar &g, + Definition *owner) { + switch (r.u8()) { + case T_Null: return nullptr; + case T_Sequence: { + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + return std::make_shared(std::move(v)); + } + case T_Choice: { + bool fl = r.u8(); + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + auto c = std::make_shared(std::move(v)); + c->for_label_ = fl; + return c; + } + case T_Repetition: { + uint64_t mn = r.u64(), mx = r.u64(); + auto o = read_ope(r, g, owner); + return std::make_shared(o, mn, mx); + } + case T_And: return std::make_shared(read_ope(r, g, owner)); + case T_Not: return std::make_shared(read_ope(r, g, owner)); + case T_Dictionary: { + bool ic = r.u8(); + uint32_t n = r.u32(); + std::vector words; + for (uint32_t i = 0; i < n; i++) + words.push_back(r.str()); + return std::make_shared(words, ic); + } + case T_Literal: { + bool ic = r.u8(); + std::string s = r.str(); + return std::make_shared(std::move(s), ic); + } + case T_CharClass: { + bool neg = r.u8(), ic = r.u8(); + uint32_t n = r.u32(); + std::vector> ranges; + for (uint32_t i = 0; i < n; i++) { + auto lo = r.u32(), hi = r.u32(); + ranges.emplace_back((char32_t)lo, (char32_t)hi); + } + return std::make_shared(ranges, neg, ic); + } + case T_Char: return std::make_shared((char32_t)r.u32()); + case T_AnyChar: return std::make_shared(); + case T_CaptureScope: + return std::make_shared(read_ope(r, g, owner)); + case T_Capture: { + auto o = read_ope(r, g, owner); + return std::make_shared(o, nullptr); + } + case T_TokenBoundary: + return std::make_shared(read_ope(r, g, owner)); + case T_Ignore: return std::make_shared(read_ope(r, g, owner)); + case T_BackRef: return std::make_shared(r.str()); + case T_Reference: { + bool im = r.u8(); + std::string nm = r.str(); + uint32_t n = r.u32(); + std::vector> args; + for (uint32_t i = 0; i < n; i++) + args.push_back(read_ope(r, g, owner)); + return std::make_shared(g, nm, nullptr, im, args); + } + case T_Whitespace: + return std::make_shared(read_ope(r, g, owner)); + case T_Recovery: return std::make_shared(read_ope(r, g, owner)); + case T_Cut: return std::make_shared(); + case T_PrecedenceClimbing: { + if (!owner) { + throw std::runtime_error( + "GrammarBlob: 'precedence' operator outside a rule body"); + } + auto atom = read_ope(r, g, owner); + auto binop = read_ope(r, g, owner); + uint32_t n = r.u32(); + auto pc = std::make_shared( + atom, binop, PrecedenceClimbing::BinOpeInfo{}, *owner); + // info_ keys are string_views; back them with owned strings whose + // addresses stay stable (reserve avoids reallocation, and the node is + // never moved once held by shared_ptr). + pc->info_keys_.reserve(n); + for (uint32_t i = 0; i < n; i++) { + std::string key = r.str(); + auto level = (size_t)r.u64(); + auto assoc = (char)r.u8(); + pc->info_keys_.push_back(std::move(key)); + pc->info_[pc->info_keys_.back()] = std::pair(level, assoc); + } + return pc; + } + default: throw std::runtime_error("GrammarBlob: bad operator tag"); + } + } + + static const uint32_t MAGIC = 0x50454732; // "PEG2" + + static std::vector serialize(const Grammar &g, + const std::string &start) { + Writer w; + w.u32(MAGIC); + w.str(start); + w.u32((uint32_t)g.size()); + // Grammar is an unordered_map, whose iteration order is implementation + // defined: walking it directly yields different bytes for the same grammar + // on different standard libraries, so a blob generated on one platform + // cannot be byte-compared on another. Emit the definitions by name. + // deserialize() rebuilds the map from the names, so the order carries no + // meaning of its own. + std::vector defs; + defs.reserve(g.size()); + for (auto &kv : g) + defs.push_back(&kv); + std::sort(defs.begin(), defs.end(), + [](const auto *a, const auto *b) { return a->first < b->first; }); + for (auto *kv : defs) { + const auto &name = kv->first; + const auto &def = kv->second; + w.str(name); + uint8_t flags = + (def.ignoreSemanticValue ? 1 : 0) | (def.is_macro ? 2 : 0) | + (def.no_ast_opt ? 4 : 0) | (def.eoi_check ? 8 : 0) | + (def.enablePackratParsing ? 16 : 0) | + (def.is_left_recursive ? 32 : 0) | (def.can_be_empty ? 64 : 0) | + (def.disable_action ? 128 : 0); + w.u8(flags); + uint8_t flags2 = (def.no_whitespace ? 1 : 0); + w.u8(flags2); + w.u32((uint32_t)def.params.size()); + for (auto &s : def.params) + w.str(s); + w.str(def.ast_name); + w.str(def.error_message); + write_ope(w, const_cast(def).get_core_operator()); + } + return std::move(w.b); + } + + static std::shared_ptr deserialize(const std::vector &blob, + std::string &start_out) { + Reader r{blob.data(), blob.data() + blob.size()}; + if (r.u32() != MAGIC) + throw std::runtime_error("GrammarBlob: bad magic / not a grammar blob"); + start_out = r.str(); + uint32_t ndef = r.u32(); + auto g = std::make_shared(); + // Create each Definition before reading its body: a PrecedenceClimbing node + // needs a stable reference to its owning rule at construction. Grammar is a + // node-based map, so references stay valid as later rules are inserted. + for (uint32_t i = 0; i < ndef; i++) { + std::string name = r.str(); + uint8_t flags = r.u8(); + uint8_t flags2 = r.u8(); + uint32_t np = r.u32(); + std::vector params; + for (uint32_t k = 0; k < np; k++) + params.push_back(r.str()); + std::string ast_name = r.str(); + std::string err = r.str(); + + auto &def = (*g)[name]; + def.name = name; + def.ignoreSemanticValue = flags & 1; + def.is_macro = flags & 2; + def.no_ast_opt = flags & 4; + def.eoi_check = flags & 8; + def.enablePackratParsing = flags & 16; + def.is_left_recursive = flags & 32; + def.can_be_empty = flags & 64; + def.disable_action = flags & 128; + def.no_whitespace = flags2 & 1; + def.params = std::move(params); + def.ast_name = std::move(ast_name); + def.error_message = std::move(err); + + auto body = read_ope(r, *g, &def); + def <= body; + } + for (auto &x : *g) { + LinkReferences vis(*g, x.second.params); + x.second.accept(vis); + // TraversalVisitor descends only into a PrecedenceClimbing's atom_. In + // the from-source path binop_ is linked while the body is still a + // Sequence, before precedence lowering; a deserialized node is built + // already lowered so its binop_ reference must be linked explicitly here. + auto core = x.second.get_core_operator(); + if (auto pc = std::dynamic_pointer_cast(core)) { + pc->binop_->accept(vis); + } + } + { + SetupFirstSets vis; // shared across rules -> O(N) + for (auto &x : *g) + x.second.accept(vis); + } + // Re-derive automatic whitespace/word skipping on the start rule from the + // %whitespace / %word definitions, exactly as ParserGenerator does. Sharing + // the (already linked and first-set) definition operators avoids leaving + // references inside the skipping ope unlinked, and keeps the blob smaller. + if (g->count(WHITESPACE_DEFINITION_NAME)) { + (*g)[start_out].whitespaceOpe = + wsp((*g)[WHITESPACE_DEFINITION_NAME].get_core_operator()); + } + if (g->count(WORD_DEFINITION_NAME)) { + (*g)[start_out].wordOpe = (*g)[WORD_DEFINITION_NAME].get_core_operator(); + } + return g; + } +}; + /*----------------------------------------------------------------------------- * PEG parser generator *---------------------------------------------------------------------------*/ @@ -4140,6 +5229,17 @@ private: ParserGenerator() { make_grammar(); setup_actions(); + // Apply First-Set filtering to the bootstrap meta-grammar itself so that + // parsing a grammar (the bulk of load_grammar) skips alternatives whose + // next byte cannot match. This is safe -- First-Set filtering only skips + // alternatives that would have failed anyway, so no semantic action that + // would have committed is skipped (unlike packrat, which is unsound here). + { + SetupFirstSets vis; + for (auto &x : g) { + x.second.accept(vis); + } + } } struct Instruction { @@ -4181,11 +5281,13 @@ private: void make_grammar() { // Setup PEG syntax parser g["Grammar"] <= seq(g["Spacing"], oom(g["Definition"]), g["EndOfFile"]); - g["Definition"] <= - cho(seq(g["Ignore"], g["IdentCont"], g["Parameters"], g["LEFTARROW"], - g["Expression"], opt(g["Instruction"])), - seq(g["Ignore"], g["Identifier"], g["LEFTARROW"], g["Expression"], - opt(g["Instruction"]))); + // Left-factored: parse the rule name (IdentCont) once, then optionally the + // macro parameter list. `opt(Parameters)` pushes a value only for a macro + // (so the value layout matches the old two-alternative form), and Spacing + // (~, no value) consumes the gap before LEFTARROW that Identifier used to. + g["Definition"] <= seq(g["Ignore"], g["IdentCont"], opt(g["Parameters"]), + g["Spacing"], g["LEFTARROW"], g["Expression"], + opt(g["Instruction"])); g["Expression"] <= seq(g["Sequence"], zom(seq(g["SLASH"], g["Sequence"]))); g["Sequence"] <= zom(cho(g["CUT"], g["Prefix"])); g["Prefix"] <= seq(opt(cho(g["AND"], g["NOT"])), g["SuffixWithLabel"]); @@ -4193,17 +5295,19 @@ private: seq(g["Suffix"], opt(seq(g["LABEL"], g["Identifier"]))); g["Suffix"] <= seq(g["Primary"], opt(g["Loop"])); g["Loop"] <= cho(g["QUESTION"], g["STAR"], g["PLUS"], g["Repetition"]); - g["Primary"] <= cho(seq(g["Ignore"], g["IdentCont"], g["Arguments"], - npd(g["LEFTARROW"])), - seq(g["Ignore"], g["Identifier"], - npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), - seq(g["OPEN"], g["Expression"], g["CLOSE"]), - seq(g["BeginTok"], g["Expression"], g["EndTok"]), - g["CapScope"], - seq(g["BeginCap"], g["Expression"], g["EndCap"]), - g["BackRef"], g["DictionaryI"], g["LiteralI"], - g["Dictionary"], g["Literal"], g["NegatedClassI"], - g["NegatedClass"], g["ClassI"], g["Class"], g["DOT"]); + // Left-factored: a macro reference (`Name(args)`) and a plain reference + // (`Name`) share the leading `Ignore IdentCont`, so parse it once and let + // `opt(Arguments)` decide. opt() pushes the argument list only for a macro + // reference, so vs.size() distinguishes the two in the action. + g["Primary"] <= + cho(seq(g["Ignore"], g["IdentCont"], opt(g["Arguments"]), g["Spacing"], + npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), + seq(g["OPEN"], g["Expression"], g["CLOSE"]), + seq(g["BeginTok"], g["Expression"], g["EndTok"]), g["CapScope"], + seq(g["BeginCap"], g["Expression"], g["EndCap"]), g["BackRef"], + g["DictionaryI"], g["LiteralI"], g["Dictionary"], g["Literal"], + g["NegatedClassI"], g["NegatedClass"], g["ClassI"], g["Class"], + g["DOT"]); g["Identifier"] <= seq(g["IdentCont"], g["Spacing"]); g["IdentCont"] <= tok(seq(g["IdentStart"], zom(g["IdentRest"]))); @@ -4252,8 +5356,12 @@ private: // NOTE: This is different from The original Brian Ford's paper, and this // modification allows us to specify `[+-]` as a valid char class. - g["Range"] <= - cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), g["Char"]); + g["Range"] <= cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), + g["ClassEscape"], g["PosixClass"], g["Char"]); + + g["ClassEscape"] <= seq(chr('\\'), cls("dDwWsS")); + g["PosixClass"] <= + seq(lit("[:"), opt(chr('^')), oom(cls("a-z")), lit(":]")); g["Char"] <= cho(seq(chr('\\'), cls("fnrtv'\"[]\\^-")), @@ -4323,8 +5431,8 @@ private: opt(seq(g["InstructionItem"], zom(seq(g["InstructionItemSeparator"], g["InstructionItem"])))), g["EndBracket"]); - g["InstructionItem"] <= - cho(g["PrecedenceClimbing"], g["ErrorMessage"], g["NoAstOpt"]); + g["InstructionItem"] <= cho(g["PrecedenceClimbing"], g["ErrorMessage"], + g["NoAstOpt"], g["NoWhitespace"], g["AstName"]); ~g["InstructionItemSeparator"] <= seq(chr(';'), g["Spacing"]); ~g["SpacesZom"] <= zom(g["Space"]); @@ -4357,6 +5465,13 @@ private: // No Ast node optimization instruction g["NoAstOpt"] <= seq(lit("no_ast_opt"), g["SpacesZom"]); + // No whitespace skipping instruction + g["NoWhitespace"] <= seq(lit("no_whitespace"), g["SpacesZom"]); + + // AST node name override instruction: `{ ast_name: NodeTag }` + g["AstName"] <= seq(lit("ast_name"), g["SpacesZom"], lit(":"), + g["SpacesZom"], g["Identifier"], g["SpacesZom"]); + // Set definition names for (auto &x : g) { x.second.name = x.first; @@ -4367,7 +5482,10 @@ private: g["Definition"] = [&](const SemanticValues &vs, std::any &dt) { auto &data = *std::any_cast(dt); - auto is_macro = vs.choice() == 0; + // Macro iff the optional Parameters matched: its value (the parameter + // name list) then sits at vs[2]. A plain definition has LEFTARROW's value + // there instead. + auto is_macro = vs[2].type() == typeid(std::vector); auto ignore = std::any_cast(vs[0]); auto name = std::any_cast(vs[1]); @@ -4393,9 +5511,6 @@ private: if (types.find(type) == types.end()) { data.instructions[name].push_back(instruction); types.insert(instruction.type); - if (type == "declare_symbol" || type == "check_symbol") { - if (!TokenChecker::is_token(*ope)) { ope = tok(ope); } - } } else { data.duplicates_of_instruction.emplace_back(type, instruction.sv.data()); @@ -4414,7 +5529,9 @@ private: rule.is_macro = is_macro; rule.params = params; - if (data.start.empty()) { + // Reserved `%`-prefixed rules (%whitespace, %word, ...) are directives, + // not parseable entry points, so they must not become the start rule. + if (data.start.empty() && name[0] != '%') { data.start = rule.name; data.start_pos = rule.s_; } @@ -4531,9 +5648,9 @@ private: auto &data = *std::any_cast(dt); switch (vs.choice()) { - case 0: // Macro Reference - case 1: { // Reference - auto is_macro = vs.choice() == 0; + case 0: { // Reference / Macro reference (left-factored) + // Macro reference iff opt(Arguments) matched and pushed the arg list. + auto is_macro = vs.size() > 2; auto ignore = std::any_cast(vs[0]); const auto &ident = std::any_cast(vs[1]); @@ -4551,16 +5668,16 @@ private: return ope; } } - case 2: { // (Expression) + case 1: { // (Expression) return std::any_cast>(vs[0]); } - case 3: { // TokenBoundary + case 2: { // TokenBoundary return tok(std::any_cast>(vs[0])); } - case 4: { // CaptureScope + case 3: { // CaptureScope return csc(std::any_cast>(vs[0])); } - case 5: { // Capture + case 4: { // Capture const auto &name = std::any_cast(vs[0]); auto ope = std::any_cast>(vs[1]); @@ -4607,23 +5724,36 @@ private: return resolve_escape_sequence(tok.data(), tok.size()); }; - g["Class"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges); + // A Range produces either a single range (std::pair) or a range list + // (std::vector) for `\d`-style escapes and POSIX classes. + auto collect_ranges = [](const SemanticValues &vs) { + std::vector> ranges; + for (const auto &v : vs) { + if (v.type() == typeid(std::pair)) { + ranges.push_back(std::any_cast>(v)); + } else { + const auto &vec = + std::any_cast> &>( + v); + ranges.insert(ranges.end(), vec.begin(), vec.end()); + } + } + return ranges; }; - g["ClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges, true); + + g["Class"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs)); }; - g["NegatedClass"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges); + g["ClassI"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs), true); }; - g["NegatedClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges, true); + g["NegatedClass"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs)); }; - g["Range"] = [](const SemanticValues &vs) { + g["NegatedClassI"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs), true); + }; + g["Range"] = [](const SemanticValues &vs) -> std::any { switch (vs.choice()) { case 0: { auto s1 = std::any_cast(vs[0]); @@ -4636,7 +5766,10 @@ private: } return std::pair(cp1, cp2); } - case 1: { + case 1: // ClassEscape + case 2: // PosixClass + return vs[0]; + case 3: { auto s = std::any_cast(vs[0]); auto cp = decode_codepoint(s.data(), s.length()); return std::pair(cp, cp); @@ -4644,6 +5777,33 @@ private: } return std::pair(0, 0); }; + g["ClassEscape"] = [](const SemanticValues &vs) { + auto ch = vs.sv()[1]; + const char *name = nullptr; + switch (ch) { + case 'd': + case 'D': name = "digit"; break; + case 's': + case 'S': name = "space"; break; + default: name = "word"; break; + } + auto ranges = *predefined_character_class(name); + if (ch == 'D' || ch == 'S' || ch == 'W') { + ranges = complement_character_ranges(ranges); + } + return ranges; + }; + g["PosixClass"] = [](const SemanticValues &vs) { + auto sv = vs.sv(); // `[:name:]` or `[:^name:]` + auto negated = sv[2] == '^'; + auto name = sv.substr(negated ? 3 : 2, sv.size() - (negated ? 5 : 4)); + auto ranges = predefined_character_class(name); + if (!ranges) { + auto msg = "invalid POSIX character class '" + std::string(name) + "'"; + throw SyntaxErrorException(msg.c_str(), vs.line_info()); + } + return negated ? complement_character_ranges(*ranges) : *ranges; + }; g["Char"] = [](const SemanticValues &vs) { return resolve_escape_sequence(vs.sv().data(), vs.sv().length()); }; @@ -4773,6 +5933,21 @@ private: return instruction; }; + g["NoWhitespace"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "no_whitespace"; + instruction.sv = vs.sv(); + return instruction; + }; + + g["AstName"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "ast_name"; + instruction.data = std::any_cast(vs[0]); + instruction.sv = vs.sv(); + return instruction; + }; + g["Instruction"] = [](const SemanticValues &vs) { return vs.transform(); }; @@ -5076,14 +6251,23 @@ private: rule.error_message = std::any_cast(instruction.data); } else if (instruction.type == "no_ast_opt") { rule.no_ast_opt = true; + } else if (instruction.type == "no_whitespace") { + rule.no_whitespace = true; + } else if (instruction.type == "ast_name") { + rule.ast_name = std::any_cast(instruction.data); } } } - // Setup First-Set and ISpan optimizations - for (auto &x : grammar) { + // Setup First-Set and ISpan optimizations. A single visitor is shared + // across all rules so its first-set cache and visited-rule set persist: + // each rule's first-sets are computed once (O(N)) instead of re-walking + // every reachable rule once per referencing rule (O(N^2)). + { SetupFirstSets vis; - x.second.accept(vis); + for (auto &x : grammar) { + x.second.accept(vis); + } } return {data.grammar, start, data.enablePackratParsing}; @@ -5117,21 +6301,24 @@ template struct AstBase : public Annotation { AstBase(const char *path, size_t line, size_t column, const char *name, const std::vector> &nodes, size_t position = 0, size_t length = 0, size_t choice_count = 0, - size_t choice = 0) + size_t choice = 0, bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(false), nodes(nodes) {} + tag(str2tag(name)), original_tag(tag), is_token(false), + preserve_position(preserve_position), nodes(nodes) {} AstBase(const char *path, size_t line, size_t column, const char *name, const std::string_view &token, size_t position = 0, size_t length = 0, - size_t choice_count = 0, size_t choice = 0) + size_t choice_count = 0, size_t choice = 0, + bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(true), token(token) {} + tag(str2tag(name)), original_tag(tag), is_token(true), + preserve_position(preserve_position), token(token) {} AstBase(const AstBase &ast, const char *original_name, size_t position = 0, size_t length = 0, size_t original_choice_count = 0, @@ -5142,7 +6329,8 @@ template struct AstBase : public Annotation { original_choice_count(original_choice_count), original_choice(original_choice), tag(ast.tag), original_tag(str2tag(original_name)), is_token(ast.is_token), - token(ast.token), nodes(ast.nodes), parent(ast.parent) {} + preserve_position(ast.preserve_position), token(ast.token), + nodes(ast.nodes), parent(ast.parent) {} const std::string path; const size_t line = 1; @@ -5160,6 +6348,7 @@ template struct AstBase : public Annotation { const unsigned int original_tag; const bool is_token; + const bool preserve_position; const std::string_view token; std::vector>> nodes; @@ -5222,8 +6411,10 @@ struct AstOptimizer { if (opt && original->nodes.size() == 1) { auto child = optimize(original->nodes[0], parent); - auto ast = std::make_shared(*child, original->name.data(), - original->position, original->length, + auto pos = + child->preserve_position ? child->position : original->position; + auto len = child->preserve_position ? child->length : original->length; + auto ast = std::make_shared(*child, original->name.data(), pos, len, original->choice_count, original->choice); for (auto &node : ast->nodes) { node->parent = ast; @@ -5253,18 +6444,23 @@ template void add_ast_action(Definition &rule) { rule.action = [&](const SemanticValues &vs) { auto line = vs.line_info(); + // `{ ast_name: X }` overrides the node's name/tag (falls back to the + // rule's own name when unset). + const char *node_name = + rule.ast_name.empty() ? rule.name.data() : rule.ast_name.data(); + if (rule.is_token()) { return std::make_shared( - vs.path, line.first, line.second, rule.name.data(), vs.token(), + vs.path, line.first, line.second, node_name, vs.token(), std::distance(vs.ss, vs.sv().data()), vs.sv().length(), - vs.choice_count(), vs.choice()); + vs.choice_count(), vs.choice(), rule.no_ast_opt); } - auto ast = - std::make_shared(vs.path, line.first, line.second, rule.name.data(), - vs.transform>(), - std::distance(vs.ss, vs.sv().data()), - vs.sv().length(), vs.choice_count(), vs.choice()); + auto ast = std::make_shared(vs.path, line.first, line.second, node_name, + vs.transform>(), + std::distance(vs.ss, vs.sv().data()), + vs.sv().length(), vs.choice_count(), + vs.choice(), rule.no_ast_opt); for (auto &node : ast->nodes) { node->parent = ast; @@ -5461,10 +6657,32 @@ public: return load_grammar(sv.data(), sv.size(), Rules(), start); } + // Serialize the loaded grammar to a portable byte blob (see GrammarBlob). + // Semantic callbacks are not included; throws if the grammar is not + // serializable (uses the `User` operator or a Capture with a match action). + std::vector serialize_grammar() const { + return GrammarBlob::serialize(*grammar_, start_); + } + + // Load a grammar from a blob produced by serialize_grammar() / GrammarBlob, + // skipping the meta-parse. Re-apply enable_ast() etc. afterwards as needed. + bool load_blob(const std::vector &blob) { + try { + grammar_ = GrammarBlob::deserialize(blob, start_); + } catch (const std::exception &) { return false; } + if (grammar_ != nullptr) { + // Symmetry with load_grammar(): restore the parser-level packrat flag + // from the blob so a later enable_packrat_parsing() re-applies it + // instead of resetting the start rule to the false member default. + enablePackratParsing_ = (*grammar_)[start_].enablePackratParsing; + } + return grammar_ != nullptr; + } + bool parse_n(const char *s, size_t n, const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, path, log_); + auto result = rule.parse(s, n, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5474,7 +6692,7 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, dt, path, log_); + auto result = rule.parse(s, n, dt, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5485,7 +6703,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5496,7 +6715,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, dt, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, dt, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5613,6 +6833,12 @@ public: void set_logger(Log log) { log_ = log; } + // Receive structured error information instead of (or in addition to) the + // formatted string passed to the logger. + void set_error_reporter(ErrorReporter reporter) { + error_reporter_ = reporter; + } + void set_logger( std::function log) { @@ -5622,14 +6848,20 @@ public: private: bool post_process(const char *s, size_t n, Definition::Result &r) const { - if (log_ && !r.ret) { r.error_info.output_log(log_, s, n); } + if ((log_ || error_reporter_) && !r.ret) { + r.error_info.output_log(log_, error_reporter_, s, n); + } return r.ret && !r.recovered; } std::vector get_no_ast_opt_rules() const { std::vector rules; for (auto &[name, rule] : *grammar_) { - if (rule.no_ast_opt) { rules.push_back(name); } + // The optimizer keeps nodes by their emitted name, so honor the + // `ast_name` override when present (else the rule's own name). + if (rule.no_ast_opt) { + rules.push_back(rule.ast_name.empty() ? name : rule.ast_name); + } } return rules; } @@ -5639,6 +6871,7 @@ private: bool enableLeftRecursion_ = true; bool enablePackratParsing_ = false; Log log_; + ErrorReporter error_reporter_; }; /*-----------------------------------------------------------------------------