From 617eab692bfdbe36eceddb08e057db64cbbc0083 Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Mon, 24 Aug 2026 19:57:35 +0300 Subject: [PATCH 01/11] Fix parser treating conditional prose as a type constraint extract_required_value matched "must be X" anywhere in a field description. For the 57 string discriminators ("Type of the media, must be photo") that is correct, but Bot API 10.3's Boolean field EphemeralMessageParameters#replace_callback_query_message reads "Pass True if ... Must be False for callback queries from ephemeral messages", where the phrase is conditional guidance, not a constraint. That produced `Types::Bool.constrained(eql: False).default(False)`, which both forbids the True the field exists to carry and emits `False` as a bare Ruby constant (NameError on eager load). Skip the pattern for booleans and route both branches through cast_default_value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Eg314icDVyoMunNjEYkVxC --- rakelib/parsers/types_parser.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/rakelib/parsers/types_parser.rb b/rakelib/parsers/types_parser.rb index 419d7d6..4d910c0 100644 --- a/rakelib/parsers/types_parser.rb +++ b/rakelib/parsers/types_parser.rb @@ -137,7 +137,7 @@ def parse_attribute(type_cell, description_cell) attribute['required'] = true unless description.start_with?('Optional') # Parse required_value (always "X" or must be X) - required_value = extract_required_value(description, description_html) + required_value = extract_required_value(description, description_html, attribute['type']) if required_value attribute['required_value'] = required_value attribute['default'] = required_value @@ -218,14 +218,17 @@ def normalize_type(type) PRIMITIVE_TYPES[type] || type end - def extract_required_value(description, description_html) + def extract_required_value(description, description_html, type) # Pattern: always "X" or always "X" (smart quotes) match = description.match(/always ["\u201c]([^"\u201d]+)["\u201d]/i) - return match[1].delete('\\') if match + return cast_default_value(match[1].delete('\\'), type) if match - # Pattern: must be X (check inner HTML) - match = description_html.match(%r{must be ([^<]+)}i) - return match[1] if match + # Pattern: must be X (check inner HTML). Only used for string discriminators: + # in boolean descriptions "must be False" is conditional prose, not a constraint. + unless type == 'boolean' + match = description_html.match(%r{must be ([^<]+)}i) + return cast_default_value(match[1], type) if match + end nil end From 7a732a679248ba426065ded9da5ebc7d1a5a3fdb Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Mon, 24 Aug 2026 19:57:44 +0300 Subject: [PATCH 02/11] Bot API 10.3 Regenerated types from the Bot API docs (rake parse:* + rebuild:*). New types: EphemeralMessageParameters, DisabledButton, RichMessageButton, RichTextButton, CommunityChatJoined, MessageGenerationStopped, RichBlockButtons, RichBlockDocument, RichBlockExpandableBlockQuotation and the three matching InputRichBlock* variants. New fields: can_send_welcome_messages on ChatAdministratorRights and ChatMemberAdministrator, disabled on InlineKeyboardButton, force_reply on InlineKeyboardMarkup and ReplyKeyboardMarkup, is_compact on RichBlockTable/InputRichBlockTable, text/entities/is_private on UniqueGiftInfo, community_chat_joined on Message, stopped_message_generation on Update, InputMediaDocument in the InputRichMessageMedia union. data/methods.json is unchanged: 10.3 adds no new methods, and endpoints map method names to return types only, so its new parameters need no code change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Eg314icDVyoMunNjEYkVxC --- data/types.json | 231 +++++++++++++++++- .../bot/types/chat_administrator_rights.rb | 1 + .../bot/types/chat_member_administrator.rb | 1 + .../bot/types/community_chat_joined.rb | 11 + lib/telegram/bot/types/disabled_button.rb | 10 + .../bot/types/ephemeral_message_parameters.rb | 13 + .../bot/types/inline_keyboard_button.rb | 1 + .../bot/types/inline_keyboard_markup.rb | 1 + lib/telegram/bot/types/input_rich_block.rb | 3 + .../bot/types/input_rich_block_buttons.rb | 13 + .../bot/types/input_rich_block_document.rb | 13 + ...t_rich_block_expandable_block_quotation.rb | 13 + .../bot/types/input_rich_block_map.rb | 6 +- .../bot/types/input_rich_block_table.rb | 1 + .../bot/types/input_rich_message_media.rb | 3 +- lib/telegram/bot/types/message.rb | 1 + .../bot/types/message_generation_stopped.rb | 13 + .../bot/types/reply_keyboard_markup.rb | 1 + lib/telegram/bot/types/rich_block.rb | 3 + lib/telegram/bot/types/rich_block_buttons.rb | 13 + lib/telegram/bot/types/rich_block_document.rb | 13 + .../rich_block_expandable_block_quotation.rb | 13 + lib/telegram/bot/types/rich_block_table.rb | 1 + lib/telegram/bot/types/rich_message_button.rb | 21 ++ lib/telegram/bot/types/rich_text.rb | 1 + lib/telegram/bot/types/rich_text_button.rb | 12 + lib/telegram/bot/types/unique_gift_info.rb | 3 + lib/telegram/bot/types/update.rb | 1 + 28 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 lib/telegram/bot/types/community_chat_joined.rb create mode 100644 lib/telegram/bot/types/disabled_button.rb create mode 100644 lib/telegram/bot/types/ephemeral_message_parameters.rb create mode 100644 lib/telegram/bot/types/input_rich_block_buttons.rb create mode 100644 lib/telegram/bot/types/input_rich_block_document.rb create mode 100644 lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb create mode 100644 lib/telegram/bot/types/message_generation_stopped.rb create mode 100644 lib/telegram/bot/types/rich_block_buttons.rb create mode 100644 lib/telegram/bot/types/rich_block_document.rb create mode 100644 lib/telegram/bot/types/rich_block_expandable_block_quotation.rb create mode 100644 lib/telegram/bot/types/rich_message_button.rb create mode 100644 lib/telegram/bot/types/rich_text_button.rb diff --git a/data/types.json b/data/types.json index d8aa276..941ed9c 100644 --- a/data/types.json +++ b/data/types.json @@ -81,6 +81,9 @@ }, "subscription": { "type": "BotSubscriptionUpdated" + }, + "stopped_message_generation": { + "type": "MessageGenerationStopped" } }, "WebhookInfo": { @@ -683,6 +686,9 @@ "community_chat_added": { "type": "CommunityChatAdded" }, + "community_chat_joined": { + "type": "CommunityChatJoined" + }, "community_chat_removed": { "type": "CommunityChatRemoved" }, @@ -961,6 +967,18 @@ "type": "string" } }, + "EphemeralMessageParameters": { + "receiver_user_id": { + "type": "integer", + "required": true + }, + "callback_query_id": { + "type": "string" + }, + "replace_callback_query_message": { + "type": "boolean" + } + }, "MessageOrigin": { "type": [ "MessageOriginUser", @@ -1828,6 +1846,19 @@ "required": true } }, + "MessageGenerationStopped": { + "chat": { + "type": "Chat", + "required": true + }, + "message_thread_id": { + "type": "integer" + }, + "draft_id": { + "type": "integer", + "required": true + } + }, "PollOptionAdded": { "poll_message": { "type": "MaybeInaccessibleMessage" @@ -2043,6 +2074,12 @@ "required": true } }, + "CommunityChatJoined": { + "community": { + "type": "Community", + "required": true + } + }, "CommunityChatRemoved": {}, "ForumTopicCreated": { "name": { @@ -2459,6 +2496,9 @@ }, "selective": { "type": "boolean" + }, + "force_reply": { + "type": "boolean" } }, "KeyboardButton": { @@ -2591,6 +2631,9 @@ "items": "InlineKeyboardButton" }, "required": true + }, + "force_reply": { + "type": "boolean" } }, "InlineKeyboardButton": { @@ -2633,6 +2676,9 @@ }, "pay": { "type": "boolean" + }, + "disabled": { + "type": "DisabledButton" } }, "LoginUrl": { @@ -2675,6 +2721,7 @@ "max_size": 256 } }, + "DisabledButton": {}, "CallbackQuery": { "id": { "type": "string", @@ -2846,6 +2893,10 @@ }, "can_manage_tags": { "type": "boolean" + }, + "can_send_welcome_messages": { + "type": "boolean", + "required": true } }, "ChatMemberUpdated": { @@ -2985,6 +3036,10 @@ "can_manage_tags": { "type": "boolean" }, + "can_send_welcome_messages": { + "type": "boolean", + "required": true + }, "custom_title": { "type": "string" } @@ -3794,6 +3849,17 @@ "type": "string", "required": true }, + "text": { + "type": "string" + }, + "entities": { + "type": "array", + "items": "MessageEntity" + }, + "is_private": { + "type": "boolean", + "default": true + }, "last_resale_currency": { "type": "string" }, @@ -5019,6 +5085,7 @@ "type": [ "InputMediaAnimation", "InputMediaAudio", + "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo", "InputMediaVoiceNote" @@ -5026,6 +5093,42 @@ "required": true } }, + "RichMessageButton": { + "text": { + "type": "RichText", + "required": true + }, + "style": { + "type": "string" + }, + "url": { + "type": "string" + }, + "callback_data": { + "type": "string" + }, + "web_app": { + "type": "WebAppInfo" + }, + "login_url": { + "type": "LoginUrl" + }, + "switch_inline_query": { + "type": "string" + }, + "switch_inline_query_current_chat": { + "type": "string" + }, + "switch_inline_query_chosen_chat": { + "type": "SwitchInlineQueryChosenChat" + }, + "copy_text": { + "type": "CopyTextButton" + }, + "disabled": { + "type": "DisabledButton" + } + }, "RichText": { "type": [ "string", @@ -5051,6 +5154,7 @@ "RichTextHashtag", "RichTextCashtag", "RichTextBotCommand", + "RichTextButton", "RichTextAnchor", "RichTextAnchorLink", "RichTextReference", @@ -5357,6 +5461,18 @@ "required": true } }, + "RichTextButton": { + "type": { + "type": "string", + "required": true, + "required_value": "button", + "default": "button" + }, + "button": { + "type": "RichMessageButton", + "required": true + } + }, "RichTextAnchor": { "type": { "type": "string", @@ -5485,14 +5601,17 @@ "RichBlockAnchor", "RichBlockList", "RichBlockBlockQuotation", + "RichBlockExpandableBlockQuotation", "RichBlockPullQuotation", "RichBlockCollage", "RichBlockSlideshow", "RichBlockTable", "RichBlockDetails", "RichBlockMap", + "RichBlockButtons", "RichBlockAnimation", "RichBlockAudio", + "RichBlockDocument", "RichBlockPhoto", "RichBlockVideo", "RichBlockVoiceNote", @@ -5615,6 +5734,21 @@ "type": "RichText" } }, + "RichBlockExpandableBlockQuotation": { + "type": { + "type": "string", + "required": true, + "required_value": "expandable_blockquote", + "default": "expandable_blockquote" + }, + "text": { + "type": "RichText", + "required": true + }, + "credit": { + "type": "RichText" + } + }, "RichBlockPullQuotation": { "type": { "type": "string", @@ -5685,6 +5819,10 @@ "type": "boolean", "default": true }, + "is_compact": { + "type": "boolean", + "default": true + }, "caption": { "type": "RichText" } @@ -5737,6 +5875,22 @@ "type": "RichBlockCaption" } }, + "RichBlockButtons": { + "type": { + "type": "string", + "required": true, + "required_value": "buttons", + "default": "buttons" + }, + "buttons": { + "type": "array", + "items": "RichMessageButton", + "required": true + }, + "align": { + "type": "string" + } + }, "RichBlockAnimation": { "type": { "type": "string", @@ -5771,6 +5925,21 @@ "type": "RichBlockCaption" } }, + "RichBlockDocument": { + "type": { + "type": "string", + "required": true, + "required_value": "document", + "default": "document" + }, + "document": { + "type": "Document", + "required": true + }, + "caption": { + "type": "RichBlockCaption" + } + }, "RichBlockPhoto": { "type": { "type": "string", @@ -5869,14 +6038,17 @@ "InputRichBlockAnchor", "InputRichBlockList", "InputRichBlockBlockQuotation", + "InputRichBlockExpandableBlockQuotation", "InputRichBlockPullQuotation", "InputRichBlockCollage", "InputRichBlockSlideshow", "InputRichBlockTable", "InputRichBlockDetails", "InputRichBlockMap", + "InputRichBlockButtons", "InputRichBlockAnimation", "InputRichBlockAudio", + "InputRichBlockDocument", "InputRichBlockPhoto", "InputRichBlockVideo", "InputRichBlockVoiceNote", @@ -5999,6 +6171,21 @@ "type": "RichText" } }, + "InputRichBlockExpandableBlockQuotation": { + "type": { + "type": "string", + "required": true, + "required_value": "expandable_blockquote", + "default": "expandable_blockquote" + }, + "text": { + "type": "RichText", + "required": true + }, + "credit": { + "type": "RichText" + } + }, "InputRichBlockPullQuotation": { "type": { "type": "string", @@ -6069,6 +6256,10 @@ "type": "boolean", "default": true }, + "is_compact": { + "type": "boolean", + "default": true + }, "caption": { "type": "RichText" } @@ -6106,21 +6297,34 @@ "required": true }, "zoom": { - "type": "integer", - "required": true + "type": "integer" }, "width": { - "type": "integer", - "required": true + "type": "integer" }, "height": { - "type": "integer", - "required": true + "type": "integer" }, "caption": { "type": "RichBlockCaption" } }, + "InputRichBlockButtons": { + "type": { + "type": "string", + "required": true, + "required_value": "buttons", + "default": "buttons" + }, + "buttons": { + "type": "array", + "items": "RichMessageButton", + "required": true + }, + "align": { + "type": "string" + } + }, "InputRichBlockAnimation": { "type": { "type": "string", @@ -6151,6 +6355,21 @@ "type": "RichBlockCaption" } }, + "InputRichBlockDocument": { + "type": { + "type": "string", + "required": true, + "required_value": "document", + "default": "document" + }, + "document": { + "type": "InputMediaDocument", + "required": true + }, + "caption": { + "type": "RichBlockCaption" + } + }, "InputRichBlockPhoto": { "type": { "type": "string", diff --git a/lib/telegram/bot/types/chat_administrator_rights.rb b/lib/telegram/bot/types/chat_administrator_rights.rb index 5a15ed6..5b24729 100644 --- a/lib/telegram/bot/types/chat_administrator_rights.rb +++ b/lib/telegram/bot/types/chat_administrator_rights.rb @@ -21,6 +21,7 @@ class ChatAdministratorRights < Base attribute? :can_manage_topics, Types::Bool attribute? :can_manage_direct_messages, Types::Bool attribute? :can_manage_tags, Types::Bool + attribute :can_send_welcome_messages, Types::Bool end end end diff --git a/lib/telegram/bot/types/chat_member_administrator.rb b/lib/telegram/bot/types/chat_member_administrator.rb index b959108..4c9f44a 100644 --- a/lib/telegram/bot/types/chat_member_administrator.rb +++ b/lib/telegram/bot/types/chat_member_administrator.rb @@ -24,6 +24,7 @@ class ChatMemberAdministrator < Base attribute? :can_manage_topics, Types::Bool attribute? :can_manage_direct_messages, Types::Bool attribute? :can_manage_tags, Types::Bool + attribute :can_send_welcome_messages, Types::Bool attribute? :custom_title, Types::String end end diff --git a/lib/telegram/bot/types/community_chat_joined.rb b/lib/telegram/bot/types/community_chat_joined.rb new file mode 100644 index 0000000..aaa4171 --- /dev/null +++ b/lib/telegram/bot/types/community_chat_joined.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class CommunityChatJoined < Base + attribute :community, Community + end + end + end +end diff --git a/lib/telegram/bot/types/disabled_button.rb b/lib/telegram/bot/types/disabled_button.rb new file mode 100644 index 0000000..2b358a1 --- /dev/null +++ b/lib/telegram/bot/types/disabled_button.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class DisabledButton < Base + end + end + end +end diff --git a/lib/telegram/bot/types/ephemeral_message_parameters.rb b/lib/telegram/bot/types/ephemeral_message_parameters.rb new file mode 100644 index 0000000..de20f67 --- /dev/null +++ b/lib/telegram/bot/types/ephemeral_message_parameters.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class EphemeralMessageParameters < Base + attribute :receiver_user_id, Types::Integer + attribute? :callback_query_id, Types::String + attribute? :replace_callback_query_message, Types::Bool + end + end + end +end diff --git a/lib/telegram/bot/types/inline_keyboard_button.rb b/lib/telegram/bot/types/inline_keyboard_button.rb index 0444d3b..b9f1908 100644 --- a/lib/telegram/bot/types/inline_keyboard_button.rb +++ b/lib/telegram/bot/types/inline_keyboard_button.rb @@ -17,6 +17,7 @@ class InlineKeyboardButton < Base attribute? :copy_text, CopyTextButton attribute? :callback_game, CallbackGame attribute? :pay, Types::Bool + attribute? :disabled, DisabledButton end end end diff --git a/lib/telegram/bot/types/inline_keyboard_markup.rb b/lib/telegram/bot/types/inline_keyboard_markup.rb index dbf21e8..1d4750c 100644 --- a/lib/telegram/bot/types/inline_keyboard_markup.rb +++ b/lib/telegram/bot/types/inline_keyboard_markup.rb @@ -5,6 +5,7 @@ module Bot module Types class InlineKeyboardMarkup < Base attribute :inline_keyboard, Types::Array.of(Types::Array.of(InlineKeyboardButton)) + attribute? :force_reply, Types::Bool def to_compact_hash hsh = super diff --git a/lib/telegram/bot/types/input_rich_block.rb b/lib/telegram/bot/types/input_rich_block.rb index 29e50f0..a50a00b 100644 --- a/lib/telegram/bot/types/input_rich_block.rb +++ b/lib/telegram/bot/types/input_rich_block.rb @@ -15,14 +15,17 @@ module Types InputRichBlockAnchor | InputRichBlockList | InputRichBlockBlockQuotation | + InputRichBlockExpandableBlockQuotation | InputRichBlockPullQuotation | InputRichBlockCollage | InputRichBlockSlideshow | InputRichBlockTable | InputRichBlockDetails | InputRichBlockMap | + InputRichBlockButtons | InputRichBlockAnimation | InputRichBlockAudio | + InputRichBlockDocument | InputRichBlockPhoto | InputRichBlockVideo | InputRichBlockVoiceNote | diff --git a/lib/telegram/bot/types/input_rich_block_buttons.rb b/lib/telegram/bot/types/input_rich_block_buttons.rb new file mode 100644 index 0000000..ef3d78f --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_buttons.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockButtons < Base + attribute :type, Types::String.constrained(eql: 'buttons').default('buttons') + attribute :buttons, Types::Array.of(RichMessageButton) + attribute? :align, Types::String + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_document.rb b/lib/telegram/bot/types/input_rich_block_document.rb new file mode 100644 index 0000000..76e6116 --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_document.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockDocument < Base + attribute :type, Types::String.constrained(eql: 'document').default('document') + attribute :document, InputMediaDocument + attribute? :caption, RichBlockCaption + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb b/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb new file mode 100644 index 0000000..63f1b3d --- /dev/null +++ b/lib/telegram/bot/types/input_rich_block_expandable_block_quotation.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class InputRichBlockExpandableBlockQuotation < Base + attribute :type, Types::String.constrained(eql: 'expandable_blockquote').default('expandable_blockquote') + attribute :text, RichText + attribute? :credit, RichText + end + end + end +end diff --git a/lib/telegram/bot/types/input_rich_block_map.rb b/lib/telegram/bot/types/input_rich_block_map.rb index 06071fb..3ed300e 100644 --- a/lib/telegram/bot/types/input_rich_block_map.rb +++ b/lib/telegram/bot/types/input_rich_block_map.rb @@ -6,9 +6,9 @@ module Types class InputRichBlockMap < Base attribute :type, Types::String.constrained(eql: 'map').default('map') attribute :location, Location - attribute :zoom, Types::Integer - attribute :width, Types::Integer - attribute :height, Types::Integer + attribute? :zoom, Types::Integer + attribute? :width, Types::Integer + attribute? :height, Types::Integer attribute? :caption, RichBlockCaption end end diff --git a/lib/telegram/bot/types/input_rich_block_table.rb b/lib/telegram/bot/types/input_rich_block_table.rb index 0179e33..6ff663a 100644 --- a/lib/telegram/bot/types/input_rich_block_table.rb +++ b/lib/telegram/bot/types/input_rich_block_table.rb @@ -8,6 +8,7 @@ class InputRichBlockTable < Base attribute :cells, Types::Array.of(Types::Array.of(RichBlockTableCell)) attribute? :is_bordered, Types::True attribute? :is_striped, Types::True + attribute? :is_compact, Types::True attribute? :caption, RichText end end diff --git a/lib/telegram/bot/types/input_rich_message_media.rb b/lib/telegram/bot/types/input_rich_message_media.rb index b0058b1..a18efa2 100644 --- a/lib/telegram/bot/types/input_rich_message_media.rb +++ b/lib/telegram/bot/types/input_rich_message_media.rb @@ -6,7 +6,8 @@ module Types class InputRichMessageMedia < Base attribute :id, Types::String.constrained(min_size: 1, max_size: 64) attribute :media, - InputMediaAnimation | InputMediaAudio | InputMediaPhoto | InputMediaVideo | InputMediaVoiceNote + InputMediaAnimation | InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | + InputMediaVoiceNote end end end diff --git a/lib/telegram/bot/types/message.rb b/lib/telegram/bot/types/message.rb index 09e2c3d..e5c2e24 100644 --- a/lib/telegram/bot/types/message.rb +++ b/lib/telegram/bot/types/message.rb @@ -96,6 +96,7 @@ class Message < Base attribute? :checklist_tasks_done, ChecklistTasksDone attribute? :checklist_tasks_added, ChecklistTasksAdded attribute? :community_chat_added, CommunityChatAdded + attribute? :community_chat_joined, CommunityChatJoined attribute? :community_chat_removed, CommunityChatRemoved attribute? :direct_message_price_changed, DirectMessagePriceChanged attribute? :forum_topic_created, ForumTopicCreated diff --git a/lib/telegram/bot/types/message_generation_stopped.rb b/lib/telegram/bot/types/message_generation_stopped.rb new file mode 100644 index 0000000..be6c784 --- /dev/null +++ b/lib/telegram/bot/types/message_generation_stopped.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class MessageGenerationStopped < Base + attribute :chat, Chat + attribute? :message_thread_id, Types::Integer + attribute :draft_id, Types::Integer + end + end + end +end diff --git a/lib/telegram/bot/types/reply_keyboard_markup.rb b/lib/telegram/bot/types/reply_keyboard_markup.rb index afddecc..3ab9262 100644 --- a/lib/telegram/bot/types/reply_keyboard_markup.rb +++ b/lib/telegram/bot/types/reply_keyboard_markup.rb @@ -10,6 +10,7 @@ class ReplyKeyboardMarkup < Base attribute? :one_time_keyboard, Types::Bool.default(false) attribute? :input_field_placeholder, Types::String.constrained(min_size: 1, max_size: 64) attribute? :selective, Types::Bool + attribute? :force_reply, Types::Bool def to_compact_hash hsh = super diff --git a/lib/telegram/bot/types/rich_block.rb b/lib/telegram/bot/types/rich_block.rb index dbed01b..0637b4c 100644 --- a/lib/telegram/bot/types/rich_block.rb +++ b/lib/telegram/bot/types/rich_block.rb @@ -15,14 +15,17 @@ module Types RichBlockAnchor | RichBlockList | RichBlockBlockQuotation | + RichBlockExpandableBlockQuotation | RichBlockPullQuotation | RichBlockCollage | RichBlockSlideshow | RichBlockTable | RichBlockDetails | RichBlockMap | + RichBlockButtons | RichBlockAnimation | RichBlockAudio | + RichBlockDocument | RichBlockPhoto | RichBlockVideo | RichBlockVoiceNote | diff --git a/lib/telegram/bot/types/rich_block_buttons.rb b/lib/telegram/bot/types/rich_block_buttons.rb new file mode 100644 index 0000000..2b38fc6 --- /dev/null +++ b/lib/telegram/bot/types/rich_block_buttons.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockButtons < Base + attribute :type, Types::String.constrained(eql: 'buttons').default('buttons') + attribute :buttons, Types::Array.of(RichMessageButton) + attribute? :align, Types::String + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_document.rb b/lib/telegram/bot/types/rich_block_document.rb new file mode 100644 index 0000000..64d95c3 --- /dev/null +++ b/lib/telegram/bot/types/rich_block_document.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockDocument < Base + attribute :type, Types::String.constrained(eql: 'document').default('document') + attribute :document, Document + attribute? :caption, RichBlockCaption + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb b/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb new file mode 100644 index 0000000..0be66be --- /dev/null +++ b/lib/telegram/bot/types/rich_block_expandable_block_quotation.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichBlockExpandableBlockQuotation < Base + attribute :type, Types::String.constrained(eql: 'expandable_blockquote').default('expandable_blockquote') + attribute :text, RichText + attribute? :credit, RichText + end + end + end +end diff --git a/lib/telegram/bot/types/rich_block_table.rb b/lib/telegram/bot/types/rich_block_table.rb index 1878c48..31a6739 100644 --- a/lib/telegram/bot/types/rich_block_table.rb +++ b/lib/telegram/bot/types/rich_block_table.rb @@ -8,6 +8,7 @@ class RichBlockTable < Base attribute :cells, Types::Array.of(Types::Array.of(RichBlockTableCell)) attribute? :is_bordered, Types::True attribute? :is_striped, Types::True + attribute? :is_compact, Types::True attribute? :caption, RichText end end diff --git a/lib/telegram/bot/types/rich_message_button.rb b/lib/telegram/bot/types/rich_message_button.rb new file mode 100644 index 0000000..5ede79b --- /dev/null +++ b/lib/telegram/bot/types/rich_message_button.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichMessageButton < Base + attribute :text, Types.deferred(:RichText) + attribute? :style, Types::String + attribute? :url, Types::String + attribute? :callback_data, Types::String + attribute? :web_app, WebAppInfo + attribute? :login_url, LoginUrl + attribute? :switch_inline_query, Types::String + attribute? :switch_inline_query_current_chat, Types::String + attribute? :switch_inline_query_chosen_chat, SwitchInlineQueryChosenChat + attribute? :copy_text, CopyTextButton + attribute? :disabled, DisabledButton + end + end + end +end diff --git a/lib/telegram/bot/types/rich_text.rb b/lib/telegram/bot/types/rich_text.rb index 2ec5d12..bce175b 100644 --- a/lib/telegram/bot/types/rich_text.rb +++ b/lib/telegram/bot/types/rich_text.rb @@ -29,6 +29,7 @@ module Types RichTextHashtag | RichTextCashtag | RichTextBotCommand | + RichTextButton | RichTextAnchor | RichTextAnchorLink | RichTextReference | diff --git a/lib/telegram/bot/types/rich_text_button.rb b/lib/telegram/bot/types/rich_text_button.rb new file mode 100644 index 0000000..061fb1a --- /dev/null +++ b/lib/telegram/bot/types/rich_text_button.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Telegram + module Bot + module Types + class RichTextButton < Base + attribute :type, Types::String.constrained(eql: 'button').default('button') + attribute :button, RichMessageButton + end + end + end +end diff --git a/lib/telegram/bot/types/unique_gift_info.rb b/lib/telegram/bot/types/unique_gift_info.rb index 6abfc1a..5c939af 100644 --- a/lib/telegram/bot/types/unique_gift_info.rb +++ b/lib/telegram/bot/types/unique_gift_info.rb @@ -6,6 +6,9 @@ module Types class UniqueGiftInfo < Base attribute :gift, UniqueGift attribute :origin, Types::String + attribute? :text, Types::String + attribute? :entities, Types::Array.of(MessageEntity) + attribute? :is_private, Types::True attribute? :last_resale_currency, Types::String attribute? :last_resale_amount, Types::Integer attribute? :owned_gift_id, Types::String diff --git a/lib/telegram/bot/types/update.rb b/lib/telegram/bot/types/update.rb index cb979c6..5be7a78 100644 --- a/lib/telegram/bot/types/update.rb +++ b/lib/telegram/bot/types/update.rb @@ -31,6 +31,7 @@ class Update < Base attribute? :removed_chat_boost, ChatBoostRemoved attribute? :managed_bot, ManagedBotUpdated attribute? :subscription, BotSubscriptionUpdated + attribute? :stopped_message_generation, MessageGenerationStopped def current_message @current_message ||= From 205a1993a348e2a5349be016571cf1f83d0cf37b Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:12:06 +0300 Subject: [PATCH 03/11] Drop the unused openapi3_parser dev dependency It is referenced nowhere in the codebase (no .rb, .rake, .md or .yml hit), and it makes the bundle unresolvable on modern Rubies: every version depends on commonmarker ~> 0.17, which has no build for Ruby 4, so `bundle exec` fails outright before any task runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- Gemfile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Gemfile b/Gemfile index e7a0b9c..421bde8 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,3 @@ gem 'rubocop', '~> 1.66.1' gem 'rubocop-performance', '~> 1.18' gem 'rubocop-rake', '~> 0.6.0' gem 'rubocop-rspec', '~> 3.1.0' - -group :development do - gem 'openapi3_parser', '~> 0.9.2' -end From 10cf0e00c35c1046662ed64caeaea28e0e0f7b95 Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:12:06 +0300 Subject: [PATCH 04/11] Fix getChatMemberCount missing from the generated endpoints parse_return_statement matched "Returns Int", but the documentation for getChatMemberCount reads "Returns Integer on success". The paragraph contains no , so it fell through all ten return-type patterns and MethodsParser dropped it silently via `result[name] = type if type`. The method was therefore absent from data/methods.json and from ENDPOINTS. Api#method_missing is gated on ENDPOINTS.key?, so api.get_chat_member_count raised NoMethodError. It was the only documented method missing from the generated list. Widen the pattern to Int(?:eger)? and regenerate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- data/methods.json | 1 + lib/telegram/bot/api/endpoints.rb | 1 + rakelib/parsers/methods_parser.rb | 4 ++-- spec/lib/telegram/bot/api_spec.rb | 11 +++++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/data/methods.json b/data/methods.json index 1b51f3f..7717e25 100644 --- a/data/methods.json +++ b/data/methods.json @@ -63,6 +63,7 @@ "leaveChat": "Boolean", "getChat": "ChatFullInfo", "getChatAdministrators": "Array", + "getChatMemberCount": "Integer", "getChatMember": "ChatMember", "getUserPersonalChatMessages": "Array", "setChatStickerSet": "Boolean", diff --git a/lib/telegram/bot/api/endpoints.rb b/lib/telegram/bot/api/endpoints.rb index 90a17bd..3a7bd98 100644 --- a/lib/telegram/bot/api/endpoints.rb +++ b/lib/telegram/bot/api/endpoints.rb @@ -68,6 +68,7 @@ class Api 'leaveChat' => Types::Bool, 'getChat' => Types::ChatFullInfo, 'getChatAdministrators' => Types::Array.of(Types::ChatMember), + 'getChatMemberCount' => Types::Integer, 'getChatMember' => Types::ChatMember, 'getUserPersonalChatMessages' => Types::Array.of(Types::Message), 'setChatStickerSet' => Types::Bool, diff --git a/rakelib/parsers/methods_parser.rb b/rakelib/parsers/methods_parser.rb index ea252ba..1b9408d 100644 --- a/rakelib/parsers/methods_parser.rb +++ b/rakelib/parsers/methods_parser.rb @@ -103,8 +103,8 @@ def parse_return_statement(paragraph) return match[1] end - # Pattern: Returns Int or Integer - return 'Integer' if html.match?(%r{Returns Int}i) + # Pattern: Returns Int / Integer + return 'Integer' if html.match?(%r{Returns Int(?:eger)?}i) # Pattern: Returns ... as String return 'String' if html.match?(%r{as String}i) diff --git a/spec/lib/telegram/bot/api_spec.rb b/spec/lib/telegram/bot/api_spec.rb index dfe95e6..4fac13f 100644 --- a/spec/lib/telegram/bot/api_spec.rb +++ b/spec/lib/telegram/bot/api_spec.rb @@ -77,6 +77,17 @@ expect(api).to respond_to(endpoint) end end + + # getChatMemberCount was missing from ENDPOINTS: the parser matched only + # "Returns Int" while the docs say "Returns Integer", so + # the endpoint silently never generated and this call raised NoMethodError. + context 'when the endpoint returns a bare Integer' do + let(:endpoint) { 'get_chat_member_count' } + + it 'responds to the endpoint' do + expect(api).to respond_to(endpoint) + end + end end describe '#getMe' do From 3c814b23f93ce2c1ff2bc4e4a5a1e21b86d96457 Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:25:40 +0300 Subject: [PATCH 05/11] Extract a Docs:: layer from the docs parsers The API page is a flat sibling stream -- 601 h4, 676 p, 544 table, nothing nested -- and both parsers re-derived "the run of nodes after this heading" by hand: first_description_paragraph, find_next_significant_sibling, find_attribute_table and MethodsParser#extract_return_type were four variations on the same walk. Field access was positional (cells[0..2] behind a `length >= 3` guard), and prose was read by regexing serialized HTML, with `description` and `description_html` threaded through every extractor. Introduce four small collaborators: Docs::Source fetch once, cache under tmp/ (was one HTTP GET per parser) Docs::Page slice the stream before every heading -> Docs::Section Docs::Table address cells by column name; raise on an unexpected shape Docs::Text text plus Docs::Marks, an offset index of inline markup Text is the load-bearing piece. Markup cannot be dropped -- "Returns True" and "Returns Message" are otherwise identical -- and neither can position, since paragraphs carry earlier prose links. Indexing the offsets keeps one representation of the text and lets a query anchor to a phrase: `emphasised_after('must be')` matches only an immediately following the phrase. That subsumes 617eab6. Its `unless type == 'boolean'` guard existed because the old pattern was case-insensitive and position-free, so a Boolean field's "Must be False" of conditional prose read as a constraint. All 57 real discriminators are lowercase and mid-sentence; the one false positive is sentence-initial. Adjacency and case now separate them, verified against every row of all 365 field tables, so the type carve-out is gone. MethodsParser's ten ordered inner_html regexes become a rule list, and a miss is now recorded and warned about instead of silently dropped -- the failure mode that hid getChatMemberCount. rake parse:methods aborts on any unmatched method. data/types.json and data/methods.json regenerate byte-identically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- rakelib/docs.rb | 9 + rakelib/docs/error.rb | 8 + rakelib/docs/marks.rb | 63 ++++ rakelib/docs/page.rb | 45 +++ rakelib/docs/section.rb | 48 +++ rakelib/docs/source.rb | 43 +++ rakelib/docs/table.rb | 39 +++ rakelib/docs/text.rb | 144 +++++++++ rakelib/parse.rake | 17 +- rakelib/parsers/attribute_parser.rb | 103 +++++++ rakelib/parsers/methods_parser.rb | 120 ++------ rakelib/parsers/return_type_rules.rb | 59 ++++ rakelib/parsers/type_value.rb | 64 ++++ rakelib/parsers/types_parser.rb | 290 ++---------------- spec/rakelib/docs/page_spec.rb | 46 +++ spec/rakelib/docs/table_spec.rb | 46 +++ spec/rakelib/docs/text_spec.rb | 107 +++++++ spec/rakelib/parsers/attribute_parser_spec.rb | 125 ++++++++ spec/rakelib/parsers/methods_parser_spec.rb | 73 +++++ .../rakelib/parsers/return_type_rules_spec.rb | 81 +++++ spec/rakelib/parsers/types_parser_spec.rb | 88 ++++-- 21 files changed, 1236 insertions(+), 382 deletions(-) create mode 100644 rakelib/docs.rb create mode 100644 rakelib/docs/error.rb create mode 100644 rakelib/docs/marks.rb create mode 100644 rakelib/docs/page.rb create mode 100644 rakelib/docs/section.rb create mode 100644 rakelib/docs/source.rb create mode 100644 rakelib/docs/table.rb create mode 100644 rakelib/docs/text.rb create mode 100644 rakelib/parsers/attribute_parser.rb create mode 100644 rakelib/parsers/return_type_rules.rb create mode 100644 rakelib/parsers/type_value.rb create mode 100644 spec/rakelib/docs/page_spec.rb create mode 100644 spec/rakelib/docs/table_spec.rb create mode 100644 spec/rakelib/docs/text_spec.rb create mode 100644 spec/rakelib/parsers/attribute_parser_spec.rb create mode 100644 spec/rakelib/parsers/methods_parser_spec.rb create mode 100644 spec/rakelib/parsers/return_type_rules_spec.rb diff --git a/rakelib/docs.rb b/rakelib/docs.rb new file mode 100644 index 0000000..be53ce9 --- /dev/null +++ b/rakelib/docs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative 'docs/error' +require_relative 'docs/marks' +require_relative 'docs/text' +require_relative 'docs/table' +require_relative 'docs/section' +require_relative 'docs/source' +require_relative 'docs/page' diff --git a/rakelib/docs/error.rb b/rakelib/docs/error.rb new file mode 100644 index 0000000..c5b7ed2 --- /dev/null +++ b/rakelib/docs/error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Docs + # Raised when the documentation no longer has the shape the parsers expect. + # Structural surprises are loud on purpose: a silent miss is how + # getChatMemberCount went missing from the generated endpoints. + Error = Class.new(StandardError) +end diff --git a/rakelib/docs/marks.rb b/rakelib/docs/marks.rb new file mode 100644 index 0000000..e254a5b --- /dev/null +++ b/rakelib/docs/marks.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Docs + # An index of a node's inline markup: which tag sits at which offset into + # the node's plain text. This is what lets a prose query be anchored to a + # phrase ("the right after 'must be'") without regexing raw HTML. + class Marks + include Enumerable + + Mark = Struct.new(:offset, :tag, :value) + + INLINE = %w[a em strong code].freeze + + def initialize(node) + @marks = [] + index(node, 0) + @marks.freeze + end + + def each(&block) + @marks.each(&block) + end + + def values_for(tag) + select { |mark| mark.tag == tag }.map(&:value) + end + + def last_before(tag, offset) + reverse_each.find { |mark| mark.tag == tag && mark.offset < offset } + end + + def first_from(offset) + find { |mark| mark.offset >= offset } + end + + def first_from_with_tag(tag, offset) + find { |mark| mark.tag == tag && mark.offset >= offset } + end + + private + + def index(node, offset) + node.children.each { |child| offset = index_child(child, offset) } + offset + end + + # Zero-length elements contribute nothing: the page carries 630 empty + # and nodes, and counting them + # would shift every offset that follows. + def index_child(child, offset) + length = child.text.length + return offset + length if child.text? || length.zero? + + if INLINE.include?(child.name) + @marks << Mark.new(offset, child.name, child.text.strip) + else + index(child, offset) + end + + offset + length + end + end +end diff --git a/rakelib/docs/page.rb b/rakelib/docs/page.rb new file mode 100644 index 0000000..641d994 --- /dev/null +++ b/rakelib/docs/page.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'nokogiri' + +require_relative 'error' +require_relative 'section' +require_relative 'source' + +module Docs + # The Bot API documentation page. + # + # Its content is a flat stream of sibling elements -- 601 h4, 676 p, 544 + # table, nothing nested -- so the only structure worth modelling is where + # one heading's run of nodes ends and the next begins. + class Page + CONTENT_SELECTOR = '#dev_page_content' + HEADING = /\Ah([1-6])\z/.freeze + + def self.load(source = Source.new) + new(source.html) + end + + def initialize(html) + @root = Nokogiri::HTML(html).at(CONTENT_SELECTOR) || + raise(Error, "#{CONTENT_SELECTOR} not found; the docs layout changed") + end + + # Every section introduced by a heading at `level`, in document order. + # Slicing before every heading is what makes an

close an open

+ # section without needing a level stack. + def sections(level: 4) + @root.element_children + .slice_before { |node| heading_level(node) } + .select { |(heading, *)| heading_level(heading) == level } + .map { |(heading, *nodes)| Section.new(heading, nodes) } + end + + private + + def heading_level(node) + found = HEADING.match(node.name) + found && found[1].to_i + end + end +end diff --git a/rakelib/docs/section.rb b/rakelib/docs/section.rb new file mode 100644 index 0000000..ca68e40 --- /dev/null +++ b/rakelib/docs/section.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'table' +require_relative 'text' + +module Docs + # A heading plus the flat run of sibling nodes that follows it, up to the + # next heading. This is the concept the parsers previously re-derived by + # hand in four separate next_element walks. + class Section + attr_reader :heading, :nodes + + def initialize(heading, nodes = []) + @heading = heading + @nodes = nodes + end + + def title + @title ||= heading.text.strip + end + + def level + @level ||= heading.name[1].to_i + end + + def paragraphs + @paragraphs ||= nodes.select { |node| node.name == 'p' }.map { |node| Text.new(node) } + end + + def intro + paragraphs.first + end + + def table + return @table if defined?(@table) + + node = nodes.find { |candidate| candidate.name == 'table' } + @table = node && Table.new(node) + end + + def items + @items ||= begin + list = nodes.find { |node| node.name == 'ul' } + list ? list.css('li').map { |item| Text.new(item) } : [] + end + end + end +end diff --git a/rakelib/docs/source.rb b/rakelib/docs/source.rb new file mode 100644 index 0000000..ccedee1 --- /dev/null +++ b/rakelib/docs/source.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'net/http' +require 'fileutils' + +module Docs + # Fetches the Bot API documentation page and caches it under tmp/, so that + # a parse run makes one request instead of one per parser, and re-running + # during development costs nothing. The cache never expires on its own; + # `rake parse:fetch` is the explicit refresh. + class Source + URL = 'https://core.telegram.org/bots/api' + CACHE_PATH = File.expand_path('../../tmp/api.html', __dir__) + + attr_reader :url, :cache_path + + def initialize(url: URL, cache_path: CACHE_PATH) + @url = url + @cache_path = cache_path + end + + def html + @html ||= cached || download + end + + def download + body = Net::HTTP.get(URI.parse(url)) + FileUtils.mkdir_p(File.dirname(cache_path)) + File.write(cache_path, body) + @html = body + end + + def cached? + File.exist?(cache_path) + end + + private + + def cached + cached? ? File.read(cache_path) : nil + end + end +end diff --git a/rakelib/docs/table.rb b/rakelib/docs/table.rb new file mode 100644 index 0000000..0a49199 --- /dev/null +++ b/rakelib/docs/table.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative 'error' +require_relative 'text' + +module Docs + # A documentation table, addressed by column name rather than cell index. + # + # The page only ever uses two shapes -- "Field | Type | Description" and + # "Parameter | Type | Required | Description" -- so declaring the expected + # columns is cheap, and it turns a docs change from silently-wrong data + # into a failed parse. + class Table + include Enumerable + + attr_reader :columns + + def initialize(node) + @node = node + @columns = node.css('th').map { |cell| cell.text.strip }.freeze + end + + def expect_columns!(expected) + return self if columns == expected + + raise Error, "expected columns #{expected.inspect}, the docs now have #{columns.inspect}" + end + + # Yields each row as a Hash of column name => Docs::Text. + def each + @node.css('tbody tr').each do |row| + cells = row.css('td') + next if cells.empty? + + yield columns.zip(cells.map { |cell| Text.new(cell) }).to_h + end + end + end +end diff --git a/rakelib/docs/text.rb b/rakelib/docs/text.rb new file mode 100644 index 0000000..b81b24f --- /dev/null +++ b/rakelib/docs/text.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require_relative 'marks' + +module Docs + # The text of a documentation node, queryable together with its inline + # markup. + # + # Markup is load-bearing: "Returns True" and "Returns Message" + # are indistinguishable once the tags are dropped. So is position -- + # paragraphs carry earlier prose links, so "the first link" is not the same + # as "the link after 'Returns'". + class Text + def initialize(node) + @node = node + end + + def raw + @raw ||= @node.text + end + + def to_s + @to_s ||= raw.strip + end + + def start_with?(*prefixes) + to_s.start_with?(*prefixes) + end + + def match(pattern) + to_s.match(pattern) + end + + def match?(pattern) + to_s.match?(pattern) + end + + def inspect + "#<#{self.class} #{to_s.inspect}>" + end + + def marks + @marks ||= Marks.new(@node) + end + + def links + marks.values_for('a') + end + + def emphasised + marks.values_for('em') + end + + # The immediately following `phrase`, with only whitespace between. + # Adjacency is the point: "The bot must be an administrator ... specify + # allowed_updates" must not read as a "must be X" + # constraint merely because an appears later in the sentence. + def after(phrase, tag) + each_occurrence(phrase) do |finish| + mark = marks.first_from(finish) + next unless mark && mark.tag == tag && raw[finish...mark.offset].strip.empty? + + return mark.value + end + nil + end + + # The first anywhere after `phrase`. Looser than `after`, for prose + # that puts arbitrary words in between, as in "Returns basic information + # about the bot in form of a User". + def following(phrase, tag) + index = start_of(phrase) + index && value_of(marks.first_from_with_tag(tag, index)) + end + + # The last that starts before `phrase`. + def before(phrase, tag) + index = start_of(phrase) + index && value_of(marks.last_before(tag, index)) + end + + def emphasised_after(phrase) + after(phrase, 'em') + end + + def link_after(phrase) + after(phrase, 'a') + end + + def link_following(phrase) + following(phrase, 'a') + end + + def link_before(phrase) + before(phrase, 'a') + end + + # always "photo" / always “photo” -> "photo" + def quoted_after(phrase) + found = to_s.match(/#{Regexp.escape(phrase)}\s+["“]([^"”]+)["”]/) + found && found[1] + end + + # Defaults to 100 -> "100" + def number_after(phrase) + found = to_s.match(/#{Regexp.escape(phrase)}\s+(\d+)/) + found && found[1] + end + + private + + def value_of(mark) + mark&.value + end + + # Offset where `phrase` (a String or Regexp) first occurs. + def start_of(phrase) + found = next_occurrence(phrase, 0) + found&.first + end + + # Yields the offset just past each occurrence of `phrase`. + def each_occurrence(phrase) + position = 0 + while (found = next_occurrence(phrase, position)) + yield found.last + position = found.first + 1 + end + end + + # [start, finish] of the next occurrence of `phrase` at or after `position`. + def next_occurrence(phrase, position) + return nil if position > raw.length + + if phrase.is_a?(Regexp) + found = phrase.match(raw, position) + found && [found.begin(0), found.end(0)] + else + index = raw.index(phrase, position) + index && [index, index + phrase.length] + end + end + end +end diff --git a/rakelib/parse.rake b/rakelib/parse.rake index c1c9ab6..8b8afca 100644 --- a/rakelib/parse.rake +++ b/rakelib/parse.rake @@ -1,10 +1,18 @@ # frozen_string_literal: true require 'json' +require_relative 'docs' require_relative 'parsers/types_parser' require_relative 'parsers/methods_parser' namespace :parse do + desc 'Download the Telegram Bot API documentation into tmp/' + task :fetch do + source = Docs::Source.new + source.download + puts "Downloaded #{source.url} to #{source.cache_path}" + end + desc 'Parse types from Telegram Bot API HTML documentation' task :types do puts 'Parsing types from Telegram Bot API...' @@ -21,11 +29,18 @@ namespace :parse do task :methods do puts 'Parsing methods from Telegram Bot API...' - result = Parsers::MethodsParser.new.parse + parser = Parsers::MethodsParser.new + result = parser.parse puts "Found #{result.keys.count} methods" File.write "#{__dir__}/../data/methods.json", JSON.pretty_generate(result) puts 'Written to data/methods.json' + + # A method the parser cannot read is a parser bug, not a missing endpoint. + abort "Unmatched return types: #{parser.unmatched.join(', ')}" if parser.unmatched.any? end + + desc 'Refresh the documentation, then parse types and methods' + task all: %i[fetch types methods] end diff --git a/rakelib/parsers/attribute_parser.rb b/rakelib/parsers/attribute_parser.rb new file mode 100644 index 0000000..eb59cbe --- /dev/null +++ b/rakelib/parsers/attribute_parser.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require_relative 'type_value' + +module Parsers + # Turns one row of a "Field | Type | Description" table into the attribute + # hash stored in data/types.json. + # + # Key insertion order is significant: the file is written with + # JSON.pretty_generate, so the order below is part of the output. + class AttributeParser + CHARACTER_RANGE = /(\d+)-(\d+) characters/.freeze + BOUNDED_RANGE = /must be between (\d+) and (\d+)/.freeze + + def initialize(row) + @type = row['Type'] + @description = row['Description'] + end + + def to_h + attribute = TypeValue.new(@type).to_h + attribute['required'] = true unless optional? + apply_constant(attribute) + apply_size(attribute) + apply_default(attribute) + attribute + end + + private + + def optional? + @description.start_with?('Optional') + end + + # A discriminator: `always "photo"` or `..., must be photo`. + # + # `must be` is matched case-sensitively and only when the follows it + # immediately. All 57 real discriminators are lowercase and mid-sentence; + # the one false positive is a sentence-initial "Must be False" of + # conditional prose. That distinction is what the pattern keys on, so no + # type-specific carve-out is needed. + def apply_constant(attribute) + value = constant(attribute['type']) + return unless value + + attribute['required_value'] = value + attribute['default'] = value + end + + def constant(type) + quoted = @description.quoted_after('always') + return cast(quoted.delete('\\'), type) if quoted + + emphasised = @description.emphasised_after('must be') + emphasised && cast(emphasised, type) + end + + def apply_size(attribute) + minimum, maximum = size_range + return unless maximum + + attribute['min_size'] = minimum if minimum + attribute['max_size'] = maximum + end + + def size_range + if (found = @description.match(CHARACTER_RANGE)) + minimum = found[1].to_i + [(minimum if minimum.positive?), found[2].to_i] + elsif (found = @description.match(BOUNDED_RANGE)) + [found[1].to_i, found[2].to_i] + else + [nil, nil] + end + end + + def apply_default(attribute) + return if attribute.key?('default') + # A documented type of "True" rather than "Boolean" means the field is + # only ever present when it is true. + return attribute['default'] = true if @type.to_s == 'True' + + value = default_value(attribute['type']) + attribute['default'] = value unless value.nil? + end + + def default_value(type) + raw = @description.quoted_after('Defaults to') || + @description.emphasised_after('Defaults to') || + @description.number_after('Defaults to') + raw && cast(raw, type) + end + + def cast(value, type) + case type + when 'integer' then value.to_i + when 'boolean' then value.downcase == 'true' + when 'number' then value.to_f + else value + end + end + end +end diff --git a/rakelib/parsers/methods_parser.rb b/rakelib/parsers/methods_parser.rb index 1b9408d..c6321ee 100644 --- a/rakelib/parsers/methods_parser.rb +++ b/rakelib/parsers/methods_parser.rb @@ -1,114 +1,46 @@ # frozen_string_literal: true -require 'nokogiri' -require 'net/http' +require_relative '../docs' +require_relative 'return_type_rules' module Parsers + # Builds data/methods.json: the return type of every documented method. class MethodsParser - API_URL = 'https://core.telegram.org/bots/api' + # Methods are camelCase; types on the same heading level are CapitalCase. + NAME = /\A[a-z][a-zA-Z0-9]*\z/.freeze - def parse - doc = fetch_document - result = {} - - method_headers(doc).each do |header| - method_name = extract_method_name(header) - next unless method_name + attr_reader :unmatched - return_type = extract_return_type(header) - result[method_name] = return_type if return_type - end - - result + def initialize(page: nil) + @page = page || Docs::Page.load + @unmatched = [] end - private - - def fetch_document - uri = URI.parse(API_URL) - response = Net::HTTP.get(uri) - Nokogiri::HTML(response) - end - - def method_headers(doc) - doc.css('h4') - end - - def extract_method_name(header) - name = header.text.strip - # Methods start with lowercase letter (camelCase) - # Types start with uppercase (PascalCase) - skip those - return nil unless name.match?(/\A[a-z][a-zA-Z0-9]*\z/) - - name - end - - def extract_return_type(header) - # Find description paragraphs after the header - sibling = header.next_element - - while sibling - break if sibling.name == 'h4' # Next section + def parse + @unmatched = [] - if sibling.name == 'p' - return_type = parse_return_statement(sibling) - return return_type if return_type - end + @page.sections.each_with_object({}) do |section, result| + next unless section.title.match?(NAME) - sibling = sibling.next_element + type = return_type(section) + type ? result[section.title] = type : record_miss(section.title) end - - nil end - def parse_return_statement(paragraph) - html = paragraph.inner_html - - # Pattern: Returns an Array of Type - if (match = html.match(%r{Returns an Array of ]*>([^<]+)}i)) - return "Array<#{match[1]}>" - end - - # Pattern: On success, an array of Type (lowercase) - if (match = html.match(%r{On success,? an array of ]*>([^<]+)}i)) - return "Array<#{match[1]}>" - end - - # Pattern: Returns a Type or Returns the Type - if (match = html.match(%r{Returns (?:a |the )?.*?]*>([^<]+)}i)) - return match[1] - end - - # Pattern: Returns basic information ... in form of a Type - if (match = html.match(%r{in form of a ]*>([^<]+)}i)) - return match[1] - end - - # Pattern: Type is returned, otherwise True is returned (union type) - # e.g., "the edited Message is returned, otherwise True is returned" - if (match = html.match(%r{]*>([^<]+) is returned,? otherwise True is returned}i)) - return "#{match[1]} | Boolean" - end - - # Pattern: Returns True or On success, True is returned - return 'Boolean' if html.match?(%r{True(?:\s+(?:is|on)|\s*\.)}i) + private - # Pattern: On success, a Type object is returned - if (match = html.match(%r{On success,? a ]*>([^<]+) object is returned}i)) - return match[1] - end + # A miss used to be indistinguishable from "absent from the docs", which + # is how getChatMemberCount went missing from ENDPOINTS. + def record_miss(name) + @unmatched << name + warn "WARNING: no return type matched for #{name}" + end - # Pattern: On success, the Type is returned / the edited/sent/stopped X is returned - if (match = html.match(%r{the (?:sent |edited |stopped )?]*>([^<]+) is returned}i)) - return match[1] + def return_type(section) + section.paragraphs.each do |paragraph| + matched = ReturnTypeRules.match(paragraph) + return matched.last if matched end - - # Pattern: Returns Int / Integer - return 'Integer' if html.match?(%r{Returns Int(?:eger)?}i) - - # Pattern: Returns ... as String - return 'String' if html.match?(%r{as String}i) - nil end end diff --git a/rakelib/parsers/return_type_rules.rb b/rakelib/parsers/return_type_rules.rb new file mode 100644 index 0000000..d1a0216 --- /dev/null +++ b/rakelib/parsers/return_type_rules.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Parsers + # A method's return type is stated in prose. Each rule pairs a shape (matched + # against the plain text) with an extractor that pulls the value out of the + # markup, so "Returns True" and "Returns Message" stay + # distinguishable. + # + # Order is significant, and only two pairs are load-bearing: + # * the Array rules must precede :returns_link, or "Array" + # degrades to "Update" + # * :edited_or_true must precede :true_flag and :object_returned, or + # "Message | Boolean" degrades to "Boolean" + # Both are asserted in the specs. + module ReturnTypeRules + Rule = Struct.new(:name, :shape, :extract) do + def call(text) + return nil unless text.match?(shape) + + value = extract.call(text) + value unless value.nil? || value.to_s.empty? + end + end + + def self.rule(name, shape, &extract) + Rule.new(name, shape, extract) + end + + ALL = [ + rule(:returns_array_of, /Returns an Array of/i) { |t| wrap_array(t.link_after(/Returns an Array of/i)) }, + rule(:success_array_of, /On success,? an array of/i) { |t| wrap_array(t.link_after(/an Array of/i)) }, + rule(:returns_link, /Returns (?:a |the )?/i) { |t| t.link_following(/Returns/i) }, + rule(:edited_or_true, /is returned,? otherwise True is returned/i) do |t| + member = t.link_before('is returned') + member && "#{member} | Boolean" + end, + rule(:true_flag, /True(?:\s+(?:is|on)|\s*\.)/) { |t| 'Boolean' if t.emphasised.include?('True') }, + rule(:success_object, /On success,? a .* object is returned/i) { |t| t.link_before('object is returned') }, + rule(:object_returned, /the (?:sent |edited |stopped )?.*? is returned/i) { |t| t.link_before('is returned') }, + rule(:returns_int, /Returns Int(?:eger)?\b/i) do |t| + 'Integer' if %w[Int Integer].include?(t.emphasised_after(/Returns/i)) + end, + rule(:as_string, /as String/i) { |t| 'String' if t.emphasised_after(/\bas/i) == 'String' } + ].freeze + + # Returns [rule_name, value], or nil when no rule matches. + def self.match(text) + ALL.each do |candidate| + value = candidate.call(text) + return [candidate.name, value] if value + end + nil + end + + def self.wrap_array(member) + member && "Array<#{member}>" + end + end +end diff --git a/rakelib/parsers/type_value.rb b/rakelib/parsers/type_value.rb new file mode 100644 index 0000000..ef4f3b7 --- /dev/null +++ b/rakelib/parsers/type_value.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Parsers + # Maps a "Type" cell to the JSON shape stored in data/types.json. + class TypeValue + PRIMITIVES = { + 'String' => 'string', + 'Integer' => 'integer', + 'Boolean' => 'boolean', + 'Float' => 'number', + 'True' => 'boolean' + }.freeze + + def initialize(cell) + @cell = cell + end + + def to_h + return { 'type' => 'array', 'items' => array_items } if array? + + { 'type' => scalar } + end + + private + + def text + @cell.to_s + end + + def links + @cell.links + end + + def array? + text.start_with?('Array of') + end + + def array_items + return nested_items if text.include?('Array of Array of') + + normalize(links.first || text.sub('Array of ', '').strip) + end + + def nested_items + inner = links.last || text.split('Array of Array of').last.strip + { 'type' => 'array', 'items' => normalize(inner) } + end + + def scalar + return union if text.include?(' or ') + + normalize(links.first || text) + end + + def union + members = links.empty? ? text.split(' or ').map(&:strip) : links + members.length == 1 ? normalize(members.first) : members.map { |member| normalize(member) } + end + + def normalize(type) + PRIMITIVES[type] || type + end + end +end diff --git a/rakelib/parsers/types_parser.rb b/rakelib/parsers/types_parser.rb index 4d910c0..2e5783a 100644 --- a/rakelib/parsers/types_parser.rb +++ b/rakelib/parsers/types_parser.rb @@ -1,286 +1,60 @@ # frozen_string_literal: true -require 'nokogiri' -require 'net/http' +require_relative '../docs' +require_relative 'attribute_parser' module Parsers + # Builds data/types.json from the "Available types" sections of the docs. class TypesParser - API_URL = 'https://core.telegram.org/bots/api' + # Types are CapitalCase; methods on the same heading level are camelCase. + NAME = /\A[A-Z][a-zA-Z0-9]*\z/.freeze + FIELD_COLUMNS = %w[Field Type Description].freeze + PLAIN_TEXT = /String for plain text/i.freeze - PRIMITIVE_TYPES = { - 'String' => 'string', - 'Integer' => 'integer', - 'Boolean' => 'boolean', - 'Float' => 'number', - 'True' => 'boolean' - }.freeze + def initialize(page: nil) + @page = page || Docs::Page.load + end def parse - doc = fetch_document - result = {} - - type_headers(doc).each do |header| - type_name = extract_type_name(header) - next unless type_name + @page.sections.each_with_object({}) do |section, result| + next unless section.title.match?(NAME) - type_data = parse_type(header, type_name) - result[type_name] = type_data if type_data + result[section.title] = parse_section(section) end - - result end private - def fetch_document - uri = URI.parse(API_URL) - response = Net::HTTP.get(uri) - Nokogiri::HTML(response) - end + def parse_section(section) + return fields(section.table) if section.table - def type_headers(doc) - doc.css('h4') - end + variants = section.items.flat_map(&:links) + return union(section, variants) if variants.any? - def extract_type_name(header) - name = header.text.strip - # Types start with uppercase letter (CapitalCase) - # Methods start with lowercase (camelCase) - skip those - return nil unless name.match?(/\A[A-Z][a-zA-Z0-9]*\z/) - - name + # A handful of types legitimately hold no information. + {} end - def parse_type(header, type_name) - # Check if this is a union type (list of types without a table) - description = first_description_paragraph(header) - next_sibling = find_next_significant_sibling(header) - - if union_type?(next_sibling) - parse_union_type(next_sibling, description, type_name) - else - parse_table_type(header) + def fields(table) + table.expect_columns!(FIELD_COLUMNS) + table.each_with_object({}) do |row, attributes| + attributes[row['Field'].to_s] = AttributeParser.new(row).to_h end end - def first_description_paragraph(header) - sibling = header.next_element - sibling if sibling&.name == 'p' - end - - def find_next_significant_sibling(header) - sibling = header.next_element - # Skip description paragraphs - sibling = sibling.next_element while sibling && sibling.name == 'p' - sibling + # Union types list their members; the prose adds any non-struct members. + def union(section, variants) + { 'type' => (implied(section) + variants).uniq } end - def union_type?(element) - element&.name == 'ul' - end + def implied(section) + description = section.intro + return [] unless description - def parse_union_type(ul_element, description, type_name) types = [] - desc_text = description&.text.to_s - types << 'string' if desc_text.match?(/String for plain text/i) - types << "array:#{type_name}" if desc_text.match?(/an Array of #{type_name}/i) - types.concat(ul_element.css('li a').map { |a| a.text.strip }) - { 'type' => types.uniq } - end - - def parse_table_type(header) - table = find_attribute_table(header) - return {} unless table - - attributes = {} - table.css('tbody tr').each do |row| - cells = row.css('td') - next unless cells.length >= 3 - - field_name = cells[0].text.strip - type_info = cells[1] - description_cell = cells[2] - - attributes[field_name] = parse_attribute(type_info, description_cell) - end - - attributes - end - - def find_attribute_table(header) - sibling = header.next_element - while sibling - return sibling if sibling.name == 'table' - # Stop if we hit another h4 (next type/method) - break if sibling.name == 'h4' - - sibling = sibling.next_element - end - nil - end - - def parse_attribute(type_cell, description_cell) - attribute = {} - raw_type = type_cell.text.strip - description = description_cell.text.strip - description_html = description_cell.inner_html - - # Parse type - type_value = parse_type_value(type_cell) - if type_value.is_a?(Hash) - attribute.merge!(type_value) - else - attribute['type'] = type_value - end - - # Parse required (absence of "Optional" at start of description) - attribute['required'] = true unless description.start_with?('Optional') - - # Parse required_value (always "X" or must be X) - required_value = extract_required_value(description, description_html, attribute['type']) - if required_value - attribute['required_value'] = required_value - attribute['default'] = required_value - end - - # Parse size constraints (N-M characters or must be between) - min_size, max_size = extract_size_constraints(description) - attribute['min_size'] = min_size if min_size - attribute['max_size'] = max_size if max_size - - # Parse default value (Defaults to X) - # If HTML type is 'True' (not 'Boolean'), it means the field only exists when true - if raw_type == 'True' && !attribute.key?('default') - attribute['default'] = true - elsif !attribute.key?('default') - default = extract_default_value(description, description_html, attribute['type']) - attribute['default'] = default unless default.nil? - end - - # Clean up: remove required if false - attribute.delete('required') unless attribute['required'] - - attribute - end - - def parse_type_value(type_cell) - text = type_cell.text.strip - links = type_cell.css('a') - - # Check for "Array of X" - if text.start_with?('Array of') - items_type = parse_array_items(type_cell, text) - return { 'type' => 'array', 'items' => items_type } - end - - # Check for union type "X or Y" - if text.include?(' or ') - types = parse_union_types(type_cell, text) - return types.length == 1 ? normalize_type(types.first) : types.map { |t| normalize_type(t) } - end - - # Single type - if links.any? - normalize_type(links.first.text.strip) - else - normalize_type(text) - end - end - - def parse_array_items(type_cell, text) - # Handle "Array of Array of X" - if text.include?('Array of Array of') - inner_type = type_cell.css('a').last&.text&.strip || text.split('Array of Array of').last.strip - return { 'type' => 'array', 'items' => normalize_type(inner_type) } - end - - # Regular "Array of X" - link = type_cell.css('a').first - if link - normalize_type(link.text.strip) - else - # Primitive array like "Array of String" - items_text = text.sub('Array of ', '').strip - normalize_type(items_text) - end - end - - def parse_union_types(type_cell, text) - links = type_cell.css('a') - if links.any? - links.map { |l| l.text.strip } - else - text.split(' or ').map(&:strip) - end - end - - def normalize_type(type) - PRIMITIVE_TYPES[type] || type - end - - def extract_required_value(description, description_html, type) - # Pattern: always "X" or always "X" (smart quotes) - match = description.match(/always ["\u201c]([^"\u201d]+)["\u201d]/i) - return cast_default_value(match[1].delete('\\'), type) if match - - # Pattern: must be X (check inner HTML). Only used for string discriminators: - # in boolean descriptions "must be False" is conditional prose, not a constraint. - unless type == 'boolean' - match = description_html.match(%r{must be ([^<]+)}i) - return cast_default_value(match[1], type) if match - end - - nil - end - - def extract_size_constraints(description) - min_size = nil - max_size = nil - - # Pattern: N-M characters (covers 0-N and 1-N cases) - if (match = description.match(/(\d+)-(\d+) characters/)) - min_val = match[1].to_i - max_size = match[2].to_i - min_size = min_val if min_val.positive? - # Pattern: must be between N and M - elsif (match = description.match(/must be between (\d+) and (\d+)/)) - min_size = match[1].to_i - max_size = match[2].to_i - end - - [min_size, max_size] - end - - def extract_default_value(description, description_html, type) - # Pattern: Defaults to "X" (with smart quotes U+201C/U+201D) - if (match = description.match(/Defaults to \u201c([^\u201d]+)\u201d/i)) - return cast_default_value(match[1], type) - end - - # Pattern: Defaults to X (check inner HTML for em-wrapped values) - if (match = description_html.match(%r{Defaults to ([^<]+)}i)) - return cast_default_value(match[1], type) - end - - # Pattern: Defaults to (plain numeric values) - if (match = description.match(/Defaults to (\d+)/i)) - return cast_default_value(match[1], type) - end - - nil - end - - def cast_default_value(value, type) - case type - when 'integer' - value.to_i - when 'boolean' - value.downcase == 'true' - when 'number' - value.to_f - else - value - end + types << 'string' if description.match?(PLAIN_TEXT) + types << "array:#{section.title}" if description.match?(/an Array of #{Regexp.escape(section.title)}/i) + types end end end diff --git a/spec/rakelib/docs/page_spec.rb b/spec/rakelib/docs/page_spec.rb new file mode 100644 index 0000000..5b5548e --- /dev/null +++ b/spec/rakelib/docs/page_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Page do + def page(body) + described_class.new("
#{body}
") + end + + describe '#sections' do + it 'gathers the nodes that follow a heading', :aggregate_failures do + sections = page('

User

An object.

').sections + + expect(sections.map(&:title)).to eq(%w[User]) + expect(sections.first.nodes.map(&:name)).to eq(%w[p table]) + end + + it 'ends a section at the next heading of the same level', :aggregate_failures do + sections = page('

User

first

Chat

second

').sections + + expect(sections.map(&:title)).to eq(%w[User Chat]) + expect(sections.map { |section| section.paragraphs.map(&:to_s) }).to eq([%w[first], %w[second]]) + end + + it 'ends a section at a heading of a higher level' do + sections = page('

User

mine

Available methods

theirs

').sections + + expect(sections.map { |section| section.paragraphs.map(&:to_s) }).to eq([%w[mine]]) + end + + it 'ignores content before the first heading' do + expect(page('

preamble

User

').sections.map(&:title)).to eq(%w[User]) + end + + it 'selects the requested heading level' do + sections = page('

Available types

intro

User

').sections(level: 3) + + expect(sections.map(&:title)).to eq(['Available types']) + end + + it 'raises when the content container is missing' do + expect { described_class.new('

nope

') } + .to raise_error(Docs::Error, /dev_page_content/) + end + end +end diff --git a/spec/rakelib/docs/table_spec.rb b/spec/rakelib/docs/table_spec.rb new file mode 100644 index 0000000..9461171 --- /dev/null +++ b/spec/rakelib/docs/table_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Table do + def table(head, *rows) + body = rows.map { |cells| "#{cells.map { |cell| "#{cell}" }.join}" }.join + html = "#{head.map { |cell| "" }.join}" \ + "#{body}
#{cell}
" + described_class.new(Nokogiri::HTML.fragment(html).at('table')) + end + + let(:fields) { table(%w[Field Type Description], ['update_id', 'Integer', 'The update id']) } + + describe '#columns' do + it 'reads the header row' do + expect(fields.columns).to eq(%w[Field Type Description]) + end + end + + describe 'row access' do + it 'addresses cells by column name', :aggregate_failures do + row = fields.first + + expect(row['Field'].to_s).to eq('update_id') + expect(row['Type'].to_s).to eq('Integer') + expect(row['Description'].to_s).to eq('The update id') + end + end + + describe '#expect_columns!' do + it 'returns the table when the columns match' do + expect(fields.expect_columns!(%w[Field Type Description])).to be(fields) + end + + # Method tables carry a fourth "Required" column. Reading one with the + # three-column layout used to silently yield "Optional" as the description. + it 'raises when handed a table of a different shape' do + parameters = table(%w[Parameter Type Required Description], %w[chat_id Integer Yes Target]) + + expect { parameters.expect_columns!(%w[Field Type Description]) } + .to raise_error(Docs::Error, /Parameter/) + end + end +end diff --git a/spec/rakelib/docs/text_spec.rb b/spec/rakelib/docs/text_spec.rb new file mode 100644 index 0000000..36784ac --- /dev/null +++ b/spec/rakelib/docs/text_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' + +RSpec.describe Docs::Text do + def text(html) + described_class.new(Nokogiri::HTML.fragment("#{html}").at('td')) + end + + describe '#to_s' do + it 'is the plain text, with markup flattened' do + expect(text('Type of the media, must be photo').to_s) + .to eq('Type of the media, must be photo') + end + + it 'strips surrounding whitespace' do + expect(text("\n hello \n").to_s).to eq('hello') + end + end + + describe '#links' do + it 'returns the text of each anchor' do + expect(text('Array of PhotoSize').links).to eq(%w[PhotoSize]) + end + + it 'ignores the empty anchors the docs use as headings targets' do + expect(text('Array of User').links) + .to eq(%w[User]) + end + end + + describe '#emphasised_after' do + it 'returns the emphasis immediately following the phrase' do + expect(text('Scope type, must be default').emphasised_after('must be')) + .to eq('default') + end + + it 'ignores emphasis that merely appears later in the sentence' do + description = text( + 'A reaction to a message was changed. The bot must be an administrator ' \ + 'and specify message_reaction in the list of allowed_updates.' + ) + + expect(description.emphasised_after('must be')).to be_nil + end + + it 'is case sensitive, so conditional prose is not a constraint' do + # The one real occurrence: a Boolean field whose description reads + # "... Must be False for callback queries from ephemeral messages". + description = text('Pass True if shown in place. Must be False for callbacks.') + + expect(description.emphasised_after('must be')).to be_nil + end + + it 'finds a later occurrence when an earlier one is not followed by emphasis' do + description = text('The bot must be an administrator. Scope type, must be chat') + + expect(description.emphasised_after('must be')).to eq('chat') + end + end + + describe '#quoted_after' do + it 'accepts typographic quotes' do + expect(text('Type of the result, always “article”').quoted_after('always')).to eq('article') + end + + it 'accepts straight quotes' do + expect(text('Type of the result, always "article"').quoted_after('always')).to eq('article') + end + + it 'does not treat emphasis as a quoted value' do + # "Always False" appears in the docs and must yield nothing, + # otherwise it would become a type constraint. + expect(text('Always False').quoted_after('always')).to be_nil + end + end + + describe '#number_after' do + it 'reads a bare numeric default' do + expect(text('Limits the number of updates. Defaults to 100.').number_after('Defaults to')) + .to eq('100') + end + end + + describe '#link_before' do + it 'returns the last link starting before the phrase' do + description = text('On success, the sent Message is returned.') + + expect(description.link_before('is returned')).to eq('Message') + end + end + + describe '#link_following' do + it 'skips intervening words that #link_after would reject' do + description = text('Returns basic information about the bot in form of a User object.') + + expect(description.link_following(/Returns/i)).to eq('User') + end + + it 'ignores links appearing before the phrase' do + description = text('See Update for details. Returns Message.') + + expect(description.link_following(/Returns/i)).to eq('Message') + end + end +end diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb new file mode 100644 index 0000000..b58db65 --- /dev/null +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/attribute_parser' + +RSpec.describe Parsers::AttributeParser do + # Builds a single "Field | Type | Description" row, the way the docs do. + def attribute(type, description, field: 'field') + html = '' \ + "
FieldTypeDescription
#{field}#{type}#{description}
" + row = Docs::Table.new(Nokogiri::HTML.fragment(html).at('table')).first + described_class.new(row).to_h + end + + describe 'requiredness' do + it 'marks a field required when the description does not open with Optional' do + expect(attribute('Integer', 'Unique identifier')).to eq('type' => 'integer', 'required' => true) + end + + it 'leaves an optional field unmarked' do + expect(attribute('Integer', 'Optional. Unique identifier')).to eq('type' => 'integer') + end + end + + describe 'types' do + it 'normalizes primitives', :aggregate_failures do + expect(attribute('String', 'Text')['type']).to eq('string') + expect(attribute('Float', 'Amount')['type']).to eq('number') + end + + it 'reads a linked struct type' do + expect(attribute('User', 'Sender')['type']).to eq('User') + end + + it 'reads an array' do + expect(attribute('Array of PhotoSize', 'Sizes')) + .to include('type' => 'array', 'items' => 'PhotoSize') + end + + it 'reads a nested array' do + expect(attribute('Array of Array of PhotoSize', 'Grid')['items']) + .to eq('type' => 'array', 'items' => 'PhotoSize') + end + + it 'reads a union of primitives' do + expect(attribute('Integer or String', 'Target')['type']).to eq(%w[integer string]) + end + end + + describe 'discriminators' do + it 'reads a quoted constant' do + expect(attribute('String', 'Type of the result, always “article”')) + .to include('required_value' => 'article', 'default' => 'article') + end + + it 'reads an emphasised constant' do + expect(attribute('String', 'Scope type, must be default')) + .to include('required_value' => 'default', 'default' => 'default') + end + + # Regression: a Boolean field reading "... Must be False for callback + # queries ..." once became Bool.constrained(eql: False), which both + # forbade the True the field exists to carry and emitted a bare constant. + it 'does not treat sentence-initial conditional prose as a constraint' do + description = 'Optional. Pass True if the ephemeral message must be shown ' \ + 'in place of the original message. Must be False for callback queries.' + + expect(attribute('Boolean', description)).not_to have_key('required_value') + end + + it 'does not treat a distant emphasis as a constraint' do + description = 'Optional. The bot must be an administrator and specify ' \ + 'message_reaction in the list of allowed updates.' + + expect(attribute('String', description)).not_to have_key('required_value') + end + end + + describe 'defaults' do + it 'reads a bare number' do + expect(attribute('Integer', 'Optional. Limit. Defaults to 100.')['default']).to eq(100) + end + + it 'reads an emphasised boolean' do + expect(attribute('Boolean', 'Optional. Defaults to true')['default']).to be(true) + end + + it 'reads a quoted string' do + expect(attribute('String', 'Optional. Defaults to “mp4”')['default']).to eq('mp4') + end + + it 'defaults a True-typed field to true, since it only exists when set' do + expect(attribute('True', 'Optional. Service message')['default']).to be(true) + end + end + + describe 'size constraints' do + it 'reads a character range' do + expect(attribute('String', 'Text, 1-64 characters')).to include('min_size' => 1, 'max_size' => 64) + end + + it 'omits a zero minimum', :aggregate_failures do + result = attribute('String', 'Quote, 0-1024 characters') + + expect(result).to include('max_size' => 1024) + expect(result).not_to have_key('min_size') + end + + it 'reads an explicit range' do + expect(attribute('Integer', 'Period, must be between 60 and 86400')) + .to include('min_size' => 60, 'max_size' => 86_400) + end + end + + describe 'key order' do + # data/types.json is written with JSON.pretty_generate, so insertion + # order is part of the file. + it 'emits type, required, constraint, size, default in a stable order' do + result = attribute('String', 'Type of the result, always “article”, 1-64 characters') + + expect(result.keys).to eq(%w[type required required_value default min_size max_size]) + end + end +end diff --git a/spec/rakelib/parsers/methods_parser_spec.rb b/spec/rakelib/parsers/methods_parser_spec.rb new file mode 100644 index 0000000..84cb0ac --- /dev/null +++ b/spec/rakelib/parsers/methods_parser_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'stringio' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/methods_parser' + +RSpec.describe Parsers::MethodsParser do + def build(body) + page = Docs::Page.new("
#{body}
") + described_class.new(page: page) + end + + def parse(body) + build(body).parse + end + + # `warn` writes to $stderr; swallow it where the warning is not the subject. + def silently + original = $stderr + $stderr = StringIO.new + yield + ensure + $stderr = original + end + + it 'reads the return type from the description' do + result = parse('

getMe

Returns basic information as a User object.

') + + expect(result).to eq('getMe' => 'User') + end + + context 'when the first paragraph says nothing about the return type' do + let(:html) do + '

sendMessage

Use this method to send text messages.

' \ + '

On success, the sent Message is returned.

' + end + + it 'scans later paragraphs' do + expect(parse(html)).to eq('sendMessage' => 'Message') + end + end + + it 'ignores CapitalCase headings, which are types' do + expect(parse('

User

Returns a User.

')).to be_empty + end + + describe 'when no rule matches' do + subject(:parser) { build('

getMysteryThing

Use this method for something new.

') } + + # The old parser dropped these silently, which is how getChatMemberCount + # went missing from the generated endpoint list. + it 'records the method as unmatched' do + silently { parser.parse } + + expect(parser.unmatched).to eq(%w[getMysteryThing]) + end + + it 'omits it from the result rather than guessing' do + expect(silently { parser.parse }).to be_empty + end + + it 'warns on stderr' do + expect { parser.parse }.to output(/getMysteryThing/).to_stderr + end + end + + it 'reports nothing unmatched when every method is understood' do + parser = build('

logOut

Returns True on success.

') + parser.parse + + expect(parser.unmatched).to be_empty + end +end diff --git a/spec/rakelib/parsers/return_type_rules_spec.rb b/spec/rakelib/parsers/return_type_rules_spec.rb new file mode 100644 index 0000000..8728e0e --- /dev/null +++ b/spec/rakelib/parsers/return_type_rules_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'nokogiri' +require_relative '../../../rakelib/docs' +require_relative '../../../rakelib/parsers/return_type_rules' + +RSpec.describe Parsers::ReturnTypeRules do + def paragraph(html) + Docs::Text.new(Nokogiri::HTML.fragment("

#{html}

").at('p')) + end + + def match(html) + described_class.match(paragraph(html)) + end + + # One example per rule, so a rule that stops working fails on its own name + # rather than somewhere downstream in data/methods.json. + { + returns_array_of: [ + 'Returns an Array of Update objects.', 'Array' + ], + success_array_of: [ + 'On success, an Array of MessageId of the sent messages is returned.', + 'Array' + ], + returns_link: [ + 'Returns basic information about the bot in form of a User object.', 'User' + ], + edited_or_true: [ + 'On success, the edited Message is returned, otherwise True is returned.', + 'Message | Boolean' + ], + true_flag: [ + 'On success, True is returned.', 'Boolean' + ], + success_object: [ + 'On success, a WebhookInfo object is returned.', 'WebhookInfo' + ], + object_returned: [ + 'On success, the sent Message is returned.', 'Message' + ], + returns_int: [ + 'Use this method to get the number of members in a chat. Returns Integer on success.', 'Integer' + ], + as_string: [ + 'Returns the new invite link as String on success.', 'String' + ] + }.each do |name, (html, expected)| + it "matches #{name}" do + expect(match(html)).to eq([name, expected]) + end + end + + it 'covers every rule' do + # A rule with no example above is either dead or untested; both are bugs. + expect(described_class::ALL.map(&:name)).to contain_exactly( + :returns_array_of, :success_array_of, :returns_link, :edited_or_true, + :true_flag, :success_object, :object_returned, :returns_int, :as_string + ) + end + + it 'returns nil when nothing matches' do + expect(match('Use this method to do something undocumented.')).to be_nil + end + + describe 'ordering' do + def index_of(name) + described_class::ALL.index { |rule| rule.name == name } + end + + it 'tries the array rules before the generic link rule', :aggregate_failures do + expect(index_of(:returns_array_of)).to be < index_of(:returns_link) + expect(index_of(:success_array_of)).to be < index_of(:returns_link) + end + + it 'tries the union rule before the plain True and object rules', :aggregate_failures do + expect(index_of(:edited_or_true)).to be < index_of(:true_flag) + expect(index_of(:edited_or_true)).to be < index_of(:object_returned) + end + end +end diff --git a/spec/rakelib/parsers/types_parser_spec.rb b/spec/rakelib/parsers/types_parser_spec.rb index 15d00dc..b5f3054 100644 --- a/spec/rakelib/parsers/types_parser_spec.rb +++ b/spec/rakelib/parsers/types_parser_spec.rb @@ -1,47 +1,41 @@ # frozen_string_literal: true -require 'nokogiri' +require_relative '../../../rakelib/docs' require_relative '../../../rakelib/parsers/types_parser' RSpec.describe Parsers::TypesParser do - subject(:parser) { described_class.new } - - describe '#parse_union_type' do - def parse_union(description_html, list_items, type_name = 'RichText') - description = Nokogiri::HTML.fragment(description_html).children.find(&:element?) - ul_html = list_items.map { |name| %(
  • #{name}
  • ) }.join - ul = Nokogiri::HTML.fragment("
      #{ul_html}
    ").at('ul') + def parse(body) + page = Docs::Page.new("
    #{body}
    ") + described_class.new(page: page).parse + end - parser.send(:parse_union_type, ul, description, type_name) - end + def union(description, members, name: 'RichText') + items = members.map { |member| %(
  • #{member}
  • ) }.join + parse("

    #{name}

    #{description}

      #{items}
    ").fetch(name) + end - it 'collects linked struct members from the union list' do - result = parse_union('

    This object represents something.

    ', %w[RichTextBold RichTextItalic]) + describe 'union types' do + it 'collects the linked members' do + result = union('This object represents something.', %w[RichTextBold RichTextItalic]) expect(result).to eq('type' => %w[RichTextBold RichTextItalic]) end - it 'prepends string when prose mentions plain text strings' do - result = parse_union( - '

    This object represents rich text. String for plain text, or one of:

    ', - %w[RichTextBold] - ) + it 'prepends string when the prose mentions plain text' do + result = union('This object represents rich text. String for plain text, or one of:', %w[RichTextBold]) expect(result['type']).to eq(%w[string RichTextBold]) end - it 'prepends array:TypeName when prose mentions an Array of the type' do - result = parse_union( - '

    This object represents rich text as an Array of RichText, or one of:

    ', - %w[RichTextBold] - ) + it 'prepends array:TypeName when the prose mentions an Array of the type' do + result = union('This object represents rich text as an Array of RichText, or one of:', %w[RichTextBold]) expect(result['type']).to eq(%w[array:RichText RichTextBold]) end it 'keeps string before array when both are described' do - result = parse_union( - '

    String for plain text, an Array of RichText for sequences, or one of:

    ', + result = union( + 'String for plain text, an Array of RichText for sequences, or one of:', %w[RichTextBold RichTextItalic] ) @@ -49,13 +43,49 @@ def parse_union(description_html, list_items, type_name = 'RichText') end it 'deduplicates members' do - result = parse_union( - '

    String for plain text or one of:

    ', - %w[string RichTextBold] - ) + result = union('String for plain text or one of:', %w[string RichTextBold]) - # "string" from prose plus a hypothetical linked "string" collapse via uniq expect(result['type']).to eq(%w[string RichTextBold]) end end + + describe 'struct types' do + let(:update) do + '

    Update

    This object represents an incoming update.

    ' \ + '' \ + '
    FieldTypeDescription
    update_idIntegerThe identifier.
    ' + end + + let(:renamed_columns) do + '

    Update

    ' \ + '' \ + '
    FieldKindNotes
    update_idIntegerx
    ' + end + + it 'reads the field table' do + expect(parse(update)).to eq('Update' => { 'update_id' => { 'type' => 'integer', 'required' => true } }) + end + + it 'raises when the field table changes shape' do + expect { parse(renamed_columns) }.to raise_error(Docs::Error, /Kind/) + end + end + + describe 'marker types' do + it 'yields no attributes for a type that holds no information' do + result = parse('

    CallbackGame

    A placeholder, currently holds no information.

    ') + + expect(result).to eq('CallbackGame' => {}) + end + end + + describe 'selection' do + it 'ignores camelCase headings, which are methods' do + expect(parse('

    sendMessage

    Use this method.

    ')).to be_empty + end + + it 'ignores prose headings' do + expect(parse('

    Formatting options

    Prose.

    ')).to be_empty + end + end end From 96c420b2e2e0cd861a5af7f95e53089b58cbf005 Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:25:40 +0300 Subject: [PATCH 06/11] Lint rakelib, and drop the parser RuboCop exclusions rakelib/parsers/**/* was excluded from Metrics/MethodLength, AbcSize, CyclomaticComplexity, PerceivedComplexity and ClassLength, so nothing pushed back as the parsers grew. With the work split across Docs:: and one class per concern, all five carve-outs can go. RuboCop::RakeTask also only ever linted {lib,spec}, so `rake rubocop` never saw rakelib/ while CI's bare `bundle exec rubocop` did. Add it, along with .rake files, so local and CI agree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- .rubocop.yml | 15 +-------------- Rakefile | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 73cb9a3..8bff107 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,6 +15,7 @@ Metrics/BlockLength: AllowedMethods: - context - describe + - namespace - task Metrics/ClassLength: @@ -22,24 +23,10 @@ Metrics/ClassLength: - lib/telegram/bot/api/endpoints.rb - lib/telegram/bot/types/message.rb - rakelib/builders/type_builder.rb - - rakelib/parsers/**/* Metrics/MethodLength: Exclude: - spec/**/* - - rakelib/parsers/**/* - -Metrics/AbcSize: - Exclude: - - rakelib/parsers/**/* - -Metrics/CyclomaticComplexity: - Exclude: - - rakelib/parsers/**/* - -Metrics/PerceivedComplexity: - Exclude: - - rakelib/parsers/**/* Layout/LineLength: Max: 120 diff --git a/Rakefile b/Rakefile index e56f475..3c79b6b 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ require 'rspec/core/rake_task' RuboCop::RakeTask.new(:rubocop) do |task| task.fail_on_error = false task.options = %w[--force-exclusion] - task.patterns = %w[{lib,spec}/**/*.rb Rakefile] + task.patterns = %w[{lib,rakelib,spec}/**/*.rb Rakefile rakelib/**/*.rake] task.requires << 'rubocop-rspec' end From 2a3e5ae9cff50be1b3d12a633ed74d3e79ccd24e Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:25:40 +0300 Subject: [PATCH 07/11] Document parse:all and the re-parse drift check Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- .claude/skills/update-api/SKILL.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.claude/skills/update-api/SKILL.md b/.claude/skills/update-api/SKILL.md index 105df17..1f76349 100644 --- a/.claude/skills/update-api/SKILL.md +++ b/.claude/skills/update-api/SKILL.md @@ -16,13 +16,21 @@ Update generated types and API endpoints from the latest Telegram Bot API docume ## Step 2: Parse -Run both parse tasks to scrape the latest Telegram Bot API docs: +Download the docs once and parse both types and methods from that snapshot: ``` -bundle exec rake parse:types -bundle exec rake parse:methods +bundle exec rake parse:all ``` +`parse:all` is `parse:fetch` (cache the page under `tmp/api.html`) followed by +`parse:types` and `parse:methods`. Re-run `parse:types` / `parse:methods` alone to +re-parse the cached page without hitting the network. + +`parse:methods` **aborts** if any documented method's return type cannot be read. +That is a parser bug, not a missing endpoint — a new phrasing has appeared in the +docs and `rakelib/parsers/return_type_rules.rb` needs a rule for it. Do not proceed +with a partial `data/methods.json`. + ## Step 3: Rebuild Before rebuilding, read the custom method files listed in Step 4 so you have a snapshot of their current content. @@ -68,3 +76,18 @@ bundle exec rake spec ``` If rubocop or tests fail, fix the issues before finishing. + +## Step 7: Verify the parser did not drift + +When the intent is *only* to pick up a new Bot API version, the parser changes in +`data/` should be exactly the documented additions. If a re-parse of an unchanged +docs page produces any diff at all, the parser has drifted and that must be +understood before shipping: + +``` +bundle exec rake parse:all +git diff data/ +``` + +This is the regression test for `rakelib/docs/` and `rakelib/parsers/`; it needs +network, so it is not part of CI. From c78653182be70bb13ca630191e60f1371be23f44 Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:35:39 +0300 Subject: [PATCH 08/11] Bring the parser specs in line with Better Specs Restructure the specs added with the Docs:: layer to follow betterspecs.org: * describe methods as '#method' / '.method', not by topic. The topical groups ('union types', 'discriminators', 'row access', 'key order') become contexts under the method they exercise. * every context reads 'when', 'with' or 'without'. * one expectation per example. Seven examples were carrying :aggregate_failures to satisfy RSpec/MultipleExpectations; they are split, and the tag is gone from the suite. * conditions move from the example description into the context, so no example description exceeds 40 characters. * subject plus let, in place of local variables built inside the example. Data now varies by overriding a let in the context, which is what collapsed the AttributeParser cases into a single table-building let. * one-line expectations use is_expected. Also drops a stubbed subject in favour of capturing $stderr, since the warning belongs to the object under test. 87 rakelib examples, 500 in the suite, rubocop clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- spec/rakelib/docs/page_spec.rb | 65 +++++-- spec/rakelib/docs/table_spec.rb | 34 ++-- spec/rakelib/docs/text_spec.rb | 119 +++++++----- spec/rakelib/parsers/attribute_parser_spec.rb | 178 +++++++++++------- spec/rakelib/parsers/methods_parser_spec.rb | 101 +++++----- .../rakelib/parsers/return_type_rules_spec.rb | 96 +++++----- spec/rakelib/parsers/types_parser_spec.rb | 125 ++++++------ 7 files changed, 417 insertions(+), 301 deletions(-) diff --git a/spec/rakelib/docs/page_spec.rb b/spec/rakelib/docs/page_spec.rb index 5b5548e..cdadb85 100644 --- a/spec/rakelib/docs/page_spec.rb +++ b/spec/rakelib/docs/page_spec.rb @@ -7,40 +7,65 @@ def page(body) described_class.new("
    #{body}
    ") end - describe '#sections' do - it 'gathers the nodes that follow a heading', :aggregate_failures do - sections = page('

    User

    An object.

    ').sections + describe '.new' do + context 'without the content container' do + subject(:build) { described_class.new('

    nope

    ') } - expect(sections.map(&:title)).to eq(%w[User]) - expect(sections.first.nodes.map(&:name)).to eq(%w[p table]) + it { expect { build }.to raise_error(Docs::Error, /dev_page_content/) } end + end + + describe '#sections' do + subject(:sections) { page(body).sections } + + context 'with a heading followed by content' do + let(:body) { '

    User

    An object.

    ' } - it 'ends a section at the next heading of the same level', :aggregate_failures do - sections = page('

    User

    first

    Chat

    second

    ').sections + it 'finds the section' do + expect(sections.map(&:title)).to eq(%w[User]) + end - expect(sections.map(&:title)).to eq(%w[User Chat]) - expect(sections.map { |section| section.paragraphs.map(&:to_s) }).to eq([%w[first], %w[second]]) + it 'gathers the nodes that follow' do + expect(sections.first.nodes.map(&:name)).to eq(%w[p table]) + end end - it 'ends a section at a heading of a higher level' do - sections = page('

    User

    mine

    Available methods

    theirs

    ').sections + context 'when a same-level heading follows' do + let(:body) { '

    User

    first

    Chat

    second

    ' } - expect(sections.map { |section| section.paragraphs.map(&:to_s) }).to eq([%w[mine]]) + it 'finds both sections' do + expect(sections.map(&:title)).to eq(%w[User Chat]) + end + + it 'ends the first section at the second' do + expect(sections.first.paragraphs.map(&:to_s)).to eq(%w[first]) + end end - it 'ignores content before the first heading' do - expect(page('

    preamble

    User

    ').sections.map(&:title)).to eq(%w[User]) + context 'when a heading of a higher level follows' do + let(:body) { '

    User

    mine

    Available methods

    theirs

    ' } + + it 'ends the section there' do + expect(sections.first.paragraphs.map(&:to_s)).to eq(%w[mine]) + end end - it 'selects the requested heading level' do - sections = page('

    Available types

    intro

    User

    ').sections(level: 3) + context 'with content before the first heading' do + let(:body) { '

    preamble

    User

    ' } - expect(sections.map(&:title)).to eq(['Available types']) + it 'ignores it' do + expect(sections.map(&:title)).to eq(%w[User]) + end end - it 'raises when the content container is missing' do - expect { described_class.new('

    nope

    ') } - .to raise_error(Docs::Error, /dev_page_content/) + context 'when another level is requested' do + subject(:sections) { page(body).sections(level: 3) } + + let(:body) { '

    Available types

    intro

    User

    ' } + + it 'selects headings at that level' do + expect(sections.map(&:title)).to eq(['Available types']) + end end end end diff --git a/spec/rakelib/docs/table_spec.rb b/spec/rakelib/docs/table_spec.rb index 9461171..d2d39be 100644 --- a/spec/rakelib/docs/table_spec.rb +++ b/spec/rakelib/docs/table_spec.rb @@ -12,35 +12,45 @@ def table(head, *rows) end let(:fields) { table(%w[Field Type Description], ['update_id', 'Integer', 'The update id']) } + let(:parameters) { table(%w[Parameter Type Required Description], %w[chat_id Integer Yes Target]) } describe '#columns' do - it 'reads the header row' do - expect(fields.columns).to eq(%w[Field Type Description]) - end + subject { fields.columns } + + it { is_expected.to eq(%w[Field Type Description]) } end - describe 'row access' do - it 'addresses cells by column name', :aggregate_failures do - row = fields.first + describe '#[]' do + subject(:row) { fields.first } + it 'reads the first column' do expect(row['Field'].to_s).to eq('update_id') + end + + it 'reads a middle column' do expect(row['Type'].to_s).to eq('Integer') + end + + it 'reads the last column' do expect(row['Description'].to_s).to eq('The update id') end end describe '#expect_columns!' do - it 'returns the table when the columns match' do - expect(fields.expect_columns!(%w[Field Type Description])).to be(fields) + subject(:assert) { subject_table.expect_columns!(%w[Field Type Description]) } + + context 'when the columns match' do + let(:subject_table) { fields } + + it { is_expected.to be(fields) } end # Method tables carry a fourth "Required" column. Reading one with the # three-column layout used to silently yield "Optional" as the description. - it 'raises when handed a table of a different shape' do - parameters = table(%w[Parameter Type Required Description], %w[chat_id Integer Yes Target]) + context 'when the table has a different shape' do + let(:subject_table) { parameters } - expect { parameters.expect_columns!(%w[Field Type Description]) } - .to raise_error(Docs::Error, /Parameter/) + it { expect { assert }.to raise_error(Docs::Error, /Parameter/) } end end end diff --git a/spec/rakelib/docs/text_spec.rb b/spec/rakelib/docs/text_spec.rb index 36784ac..3a097e8 100644 --- a/spec/rakelib/docs/text_spec.rb +++ b/spec/rakelib/docs/text_spec.rb @@ -9,99 +9,122 @@ def text(html) end describe '#to_s' do - it 'is the plain text, with markup flattened' do - expect(text('Type of the media, must be photo').to_s) - .to eq('Type of the media, must be photo') + subject { text(html).to_s } + + context 'with inline markup' do + let(:html) { 'Type of the media, must be photo' } + + it { is_expected.to eq('Type of the media, must be photo') } end - it 'strips surrounding whitespace' do - expect(text("\n hello \n").to_s).to eq('hello') + context 'with surrounding whitespace' do + let(:html) { "\n hello \n" } + + it { is_expected.to eq('hello') } end end describe '#links' do - it 'returns the text of each anchor' do - expect(text('Array of PhotoSize').links).to eq(%w[PhotoSize]) + subject { text(html).links } + + context 'with a single anchor' do + let(:html) { 'Array of PhotoSize' } + + it { is_expected.to eq(%w[PhotoSize]) } end - it 'ignores the empty anchors the docs use as headings targets' do - expect(text('Array of User').links) - .to eq(%w[User]) + context 'with empty anchor elements' do + let(:html) { 'Array of User' } + + it { is_expected.to eq(%w[User]) } end end describe '#emphasised_after' do - it 'returns the emphasis immediately following the phrase' do - expect(text('Scope type, must be default').emphasised_after('must be')) - .to eq('default') + subject { text(html).emphasised_after('must be') } + + context 'when the emphasis follows the phrase' do + let(:html) { 'Scope type, must be default' } + + it { is_expected.to eq('default') } end - it 'ignores emphasis that merely appears later in the sentence' do - description = text( + context 'when the emphasis only appears later' do + let(:html) do 'A reaction to a message was changed. The bot must be an administrator ' \ - 'and specify message_reaction in the list of allowed_updates.' - ) + 'and specify message_reaction in the list of allowed updates.' + end - expect(description.emphasised_after('must be')).to be_nil + it { is_expected.to be_nil } end - it 'is case sensitive, so conditional prose is not a constraint' do - # The one real occurrence: a Boolean field whose description reads - # "... Must be False for callback queries from ephemeral messages". - description = text('Pass True if shown in place. Must be False for callbacks.') + context 'when the phrase is capitalised' do + # "... Must be False for callback queries" is conditional prose rather + # than a constraint, and reading it as one produced a Boolean field + # constrained to eql: False. + let(:html) { 'Pass True if shown in place. Must be False for callbacks.' } - expect(description.emphasised_after('must be')).to be_nil + it { is_expected.to be_nil } end - it 'finds a later occurrence when an earlier one is not followed by emphasis' do - description = text('The bot must be an administrator. Scope type, must be chat') + context 'when an earlier phrase has no emphasis' do + let(:html) { 'The bot must be an administrator. Scope type, must be chat' } - expect(description.emphasised_after('must be')).to eq('chat') + it { is_expected.to eq('chat') } end end describe '#quoted_after' do - it 'accepts typographic quotes' do - expect(text('Type of the result, always “article”').quoted_after('always')).to eq('article') + subject { text(html).quoted_after('always') } + + context 'with typographic quotes' do + let(:html) { 'Type of the result, always “article”' } + + it { is_expected.to eq('article') } end - it 'accepts straight quotes' do - expect(text('Type of the result, always "article"').quoted_after('always')).to eq('article') + context 'with straight quotes' do + let(:html) { 'Type of the result, always "article"' } + + it { is_expected.to eq('article') } end - it 'does not treat emphasis as a quoted value' do - # "Always False" appears in the docs and must yield nothing, - # otherwise it would become a type constraint. - expect(text('Always False').quoted_after('always')).to be_nil + context 'when the value is emphasised instead' do + let(:html) { 'Always False' } + + it { is_expected.to be_nil } end end describe '#number_after' do - it 'reads a bare numeric default' do - expect(text('Limits the number of updates. Defaults to 100.').number_after('Defaults to')) - .to eq('100') - end + subject { text(html).number_after('Defaults to') } + + let(:html) { 'Limits the number of updates. Defaults to 100.' } + + it { is_expected.to eq('100') } end describe '#link_before' do - it 'returns the last link starting before the phrase' do - description = text('On success, the sent Message is returned.') + subject { text(html).link_before('is returned') } - expect(description.link_before('is returned')).to eq('Message') - end + let(:html) { 'On success, the sent Message is returned.' } + + it { is_expected.to eq('Message') } end describe '#link_following' do - it 'skips intervening words that #link_after would reject' do - description = text('Returns basic information about the bot in form of a User object.') + subject { text(html).link_following(/Returns/i) } + + context 'with words before the link' do + let(:html) { 'Returns basic information about the bot in form of a User object.' } - expect(description.link_following(/Returns/i)).to eq('User') + it { is_expected.to eq('User') } end - it 'ignores links appearing before the phrase' do - description = text('See Update for details. Returns Message.') + context 'with a link before the phrase' do + let(:html) { 'See Update for details. Returns Message.' } - expect(description.link_following(/Returns/i)).to eq('Message') + it { is_expected.to eq('Message') } end end end diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb index b58db65..3abef8e 100644 --- a/spec/rakelib/parsers/attribute_parser_spec.rb +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -5,121 +5,157 @@ require_relative '../../../rakelib/parsers/attribute_parser' RSpec.describe Parsers::AttributeParser do - # Builds a single "Field | Type | Description" row, the way the docs do. - def attribute(type, description, field: 'field') - html = '' \ - "
    FieldTypeDescription
    #{field}#{type}#{description}
    " - row = Docs::Table.new(Nokogiri::HTML.fragment(html).at('table')).first - described_class.new(row).to_h - end + describe '#to_h' do + subject(:attribute) { described_class.new(row).to_h } - describe 'requiredness' do - it 'marks a field required when the description does not open with Optional' do - expect(attribute('Integer', 'Unique identifier')).to eq('type' => 'integer', 'required' => true) + # A single "Field | Type | Description" row, the way the docs write one. + let(:row) do + html = '' \ + "
    FieldTypeDescription
    field#{type}#{description}
    " + Docs::Table.new(Nokogiri::HTML.fragment(html).at('table')).first end + let(:type) { 'String' } + let(:description) { 'Some text' } + + context 'when not marked Optional' do + let(:type) { 'Integer' } - it 'leaves an optional field unmarked' do - expect(attribute('Integer', 'Optional. Unique identifier')).to eq('type' => 'integer') + it { is_expected.to eq('type' => 'integer', 'required' => true) } end - end - describe 'types' do - it 'normalizes primitives', :aggregate_failures do - expect(attribute('String', 'Text')['type']).to eq('string') - expect(attribute('Float', 'Amount')['type']).to eq('number') + context 'when marked Optional' do + let(:type) { 'Integer' } + let(:description) { 'Optional. Unique identifier' } + + it { is_expected.to eq('type' => 'integer') } end - it 'reads a linked struct type' do - expect(attribute('User', 'Sender')['type']).to eq('User') + context 'with a String type' do + it { is_expected.to include('type' => 'string') } end - it 'reads an array' do - expect(attribute('Array of PhotoSize', 'Sizes')) - .to include('type' => 'array', 'items' => 'PhotoSize') + context 'with a Float type' do + let(:type) { 'Float' } + + it { is_expected.to include('type' => 'number') } end - it 'reads a nested array' do - expect(attribute('Array of Array of PhotoSize', 'Grid')['items']) - .to eq('type' => 'array', 'items' => 'PhotoSize') + context 'with a linked struct type' do + let(:type) { 'User' } + + it { is_expected.to include('type' => 'User') } end - it 'reads a union of primitives' do - expect(attribute('Integer or String', 'Target')['type']).to eq(%w[integer string]) + context 'with an array type' do + let(:type) { 'Array of PhotoSize' } + + it { is_expected.to include('type' => 'array', 'items' => 'PhotoSize') } end - end - describe 'discriminators' do - it 'reads a quoted constant' do - expect(attribute('String', 'Type of the result, always “article”')) - .to include('required_value' => 'article', 'default' => 'article') + context 'with a nested array type' do + let(:type) { 'Array of Array of PhotoSize' } + + it { is_expected.to include('items' => { 'type' => 'array', 'items' => 'PhotoSize' }) } + end + + context 'with a union of primitives' do + let(:type) { 'Integer or String' } + + it { is_expected.to include('type' => %w[integer string]) } + end + + context 'with a quoted discriminator' do + let(:description) { 'Type of the result, always “article”' } + + it { is_expected.to include('required_value' => 'article', 'default' => 'article') } end - it 'reads an emphasised constant' do - expect(attribute('String', 'Scope type, must be default')) - .to include('required_value' => 'default', 'default' => 'default') + context 'with an emphasised discriminator' do + let(:description) { 'Scope type, must be default' } + + it { is_expected.to include('required_value' => 'default', 'default' => 'default') } end # Regression: a Boolean field reading "... Must be False for callback - # queries ..." once became Bool.constrained(eql: False), which both - # forbade the True the field exists to carry and emitted a bare constant. - it 'does not treat sentence-initial conditional prose as a constraint' do - description = 'Optional. Pass True if the ephemeral message must be shown ' \ - 'in place of the original message. Must be False for callback queries.' + # queries ..." became Bool.constrained(eql: False), which both forbade the + # True the field exists to carry and emitted a bare Ruby constant. + context 'with sentence-initial conditional prose' do + let(:type) { 'Boolean' } + let(:description) do + 'Optional. Pass True if the ephemeral message must be shown ' \ + 'in place of the original message. Must be False for callback queries.' + end - expect(attribute('Boolean', description)).not_to have_key('required_value') + it { is_expected.not_to have_key('required_value') } end - it 'does not treat a distant emphasis as a constraint' do - description = 'Optional. The bot must be an administrator and specify ' \ - 'message_reaction in the list of allowed updates.' + context 'with emphasis far from the phrase' do + let(:description) do + 'Optional. The bot must be an administrator and specify ' \ + 'message_reaction in the list of allowed updates.' + end - expect(attribute('String', description)).not_to have_key('required_value') + it { is_expected.not_to have_key('required_value') } end - end - describe 'defaults' do - it 'reads a bare number' do - expect(attribute('Integer', 'Optional. Limit. Defaults to 100.')['default']).to eq(100) + context 'with a bare numeric default' do + let(:type) { 'Integer' } + let(:description) { 'Optional. Limit. Defaults to 100.' } + + it { is_expected.to include('default' => 100) } end - it 'reads an emphasised boolean' do - expect(attribute('Boolean', 'Optional. Defaults to true')['default']).to be(true) + context 'with an emphasised boolean default' do + let(:type) { 'Boolean' } + let(:description) { 'Optional. Defaults to true' } + + it { is_expected.to include('default' => true) } end - it 'reads a quoted string' do - expect(attribute('String', 'Optional. Defaults to “mp4”')['default']).to eq('mp4') + context 'with a quoted string default' do + let(:description) { 'Optional. Defaults to “mp4”' } + + it { is_expected.to include('default' => 'mp4') } end - it 'defaults a True-typed field to true, since it only exists when set' do - expect(attribute('True', 'Optional. Service message')['default']).to be(true) + # A documented type of True rather than Boolean means the field is only + # ever present when it is set. + context 'with a True type' do + let(:type) { 'True' } + let(:description) { 'Optional. Service message' } + + it { is_expected.to include('default' => true) } end - end - describe 'size constraints' do - it 'reads a character range' do - expect(attribute('String', 'Text, 1-64 characters')).to include('min_size' => 1, 'max_size' => 64) + context 'with a character range' do + let(:description) { 'Text, 1-64 characters' } + + it { is_expected.to include('min_size' => 1, 'max_size' => 64) } end - it 'omits a zero minimum', :aggregate_failures do - result = attribute('String', 'Quote, 0-1024 characters') + context 'with a zero-based character range' do + let(:description) { 'Quote, 0-1024 characters' } - expect(result).to include('max_size' => 1024) - expect(result).not_to have_key('min_size') + it { is_expected.to include('max_size' => 1024) } + + it { is_expected.not_to have_key('min_size') } end - it 'reads an explicit range' do - expect(attribute('Integer', 'Period, must be between 60 and 86400')) - .to include('min_size' => 60, 'max_size' => 86_400) + context 'with an explicit range' do + let(:type) { 'Integer' } + let(:description) { 'Period, must be between 60 and 86400' } + + it { is_expected.to include('min_size' => 60, 'max_size' => 86_400) } end - end - describe 'key order' do # data/types.json is written with JSON.pretty_generate, so insertion # order is part of the file. - it 'emits type, required, constraint, size, default in a stable order' do - result = attribute('String', 'Type of the result, always “article”, 1-64 characters') + context 'with every kind of annotation at once' do + let(:description) { 'Type of the result, always “article”, 1-64 characters' } - expect(result.keys).to eq(%w[type required required_value default min_size max_size]) + it 'keeps a stable key order' do + expect(attribute.keys).to eq(%w[type required required_value default min_size max_size]) + end end end end diff --git a/spec/rakelib/parsers/methods_parser_spec.rb b/spec/rakelib/parsers/methods_parser_spec.rb index 84cb0ac..be356a6 100644 --- a/spec/rakelib/parsers/methods_parser_spec.rb +++ b/spec/rakelib/parsers/methods_parser_spec.rb @@ -5,69 +5,74 @@ require_relative '../../../rakelib/parsers/methods_parser' RSpec.describe Parsers::MethodsParser do - def build(body) - page = Docs::Page.new("
    #{body}
    ") - described_class.new(page: page) - end - - def parse(body) - build(body).parse - end + describe '#parse' do + subject(:parsed) { parser.parse } - # `warn` writes to $stderr; swallow it where the warning is not the subject. - def silently - original = $stderr - $stderr = StringIO.new - yield - ensure - $stderr = original - end + let(:parser) do + page = Docs::Page.new("
    #{body}
    ") + described_class.new(page: page) + end - it 'reads the return type from the description' do - result = parse('

    getMe

    Returns basic information as a User object.

    ') + # `warn` writes to $stderr; swallow it where the warning is not the subject. + def silently + original = $stderr + $stderr = StringIO.new + yield + ensure + $stderr = original + end - expect(result).to eq('getMe' => 'User') - end + context 'when the first paragraph names it' do + let(:body) { '

    getMe

    Returns basic information as a User object.

    ' } - context 'when the first paragraph says nothing about the return type' do - let(:html) do - '

    sendMessage

    Use this method to send text messages.

    ' \ - '

    On success, the sent Message is returned.

    ' + it { is_expected.to eq('getMe' => 'User') } end - it 'scans later paragraphs' do - expect(parse(html)).to eq('sendMessage' => 'Message') + context 'when a later paragraph names it' do + let(:body) do + '

    sendMessage

    Use this method to send text messages.

    ' \ + '

    On success, the sent Message is returned.

    ' + end + + it { is_expected.to eq('sendMessage' => 'Message') } end - end - it 'ignores CapitalCase headings, which are types' do - expect(parse('

    User

    Returns a User.

    ')).to be_empty - end + context 'with a CapitalCase heading' do + let(:body) { '

    User

    Returns a User.

    ' } - describe 'when no rule matches' do - subject(:parser) { build('

    getMysteryThing

    Use this method for something new.

    ') } + it 'ignores it, since it names a type' do + expect(parsed).to be_empty + end + end - # The old parser dropped these silently, which is how getChatMemberCount - # went missing from the generated endpoint list. - it 'records the method as unmatched' do - silently { parser.parse } + context 'when every method is understood' do + let(:body) { '

    logOut

    Returns True on success.

    ' } - expect(parser.unmatched).to eq(%w[getMysteryThing]) - end + it 'reports nothing unmatched' do + parsed - it 'omits it from the result rather than guessing' do - expect(silently { parser.parse }).to be_empty + expect(parser.unmatched).to be_empty + end end - it 'warns on stderr' do - expect { parser.parse }.to output(/getMysteryThing/).to_stderr - end - end + # The old parser dropped an unreadable return type silently, which is how + # getChatMemberCount went missing from the generated endpoint list. + context 'when no rule matches' do + let(:body) { '

    getMysteryThing

    Use this method for something new.

    ' } + + it 'records the method as unmatched' do + silently { parsed } - it 'reports nothing unmatched when every method is understood' do - parser = build('

    logOut

    Returns True on success.

    ') - parser.parse + expect(parser.unmatched).to eq(%w[getMysteryThing]) + end - expect(parser.unmatched).to be_empty + it 'omits it rather than guessing' do + expect(silently { parsed }).to be_empty + end + + it 'warns on stderr' do + expect { parsed }.to output(/getMysteryThing/).to_stderr + end + end end end diff --git a/spec/rakelib/parsers/return_type_rules_spec.rb b/spec/rakelib/parsers/return_type_rules_spec.rb index 8728e0e..28df360 100644 --- a/spec/rakelib/parsers/return_type_rules_spec.rb +++ b/spec/rakelib/parsers/return_type_rules_spec.rb @@ -5,17 +5,9 @@ require_relative '../../../rakelib/parsers/return_type_rules' RSpec.describe Parsers::ReturnTypeRules do - def paragraph(html) - Docs::Text.new(Nokogiri::HTML.fragment("

    #{html}

    ").at('p')) - end - - def match(html) - described_class.match(paragraph(html)) - end - - # One example per rule, so a rule that stops working fails on its own name - # rather than somewhere downstream in data/methods.json. - { + # One example per rule, so a rule that stops working fails under its own + # name rather than somewhere downstream in data/methods.json. + examples = { returns_array_of: [ 'Returns an Array of Update objects.', 'Array' ], @@ -30,52 +22,62 @@ def match(html) 'On success, the edited Message is returned, otherwise True is returned.', 'Message | Boolean' ], - true_flag: [ - 'On success, True is returned.', 'Boolean' - ], - success_object: [ - 'On success, a WebhookInfo object is returned.', 'WebhookInfo' - ], - object_returned: [ - 'On success, the sent Message is returned.', 'Message' - ], - returns_int: [ - 'Use this method to get the number of members in a chat. Returns Integer on success.', 'Integer' - ], - as_string: [ - 'Returns the new invite link as String on success.', 'String' - ] - }.each do |name, (html, expected)| - it "matches #{name}" do - expect(match(html)).to eq([name, expected]) + true_flag: ['On success, True is returned.', 'Boolean'], + success_object: ['On success, a WebhookInfo object is returned.', 'WebhookInfo'], + object_returned: ['On success, the sent Message is returned.', 'Message'], + returns_int: ['Returns Integer on success.', 'Integer'], + as_string: ['Returns the new invite link as String on success.', 'String'] + } + + describe '.match' do + subject(:matched) { described_class.match(paragraph) } + + let(:paragraph) { Docs::Text.new(Nokogiri::HTML.fragment("

    #{html}

    ").at('p')) } + + examples.each do |name, (example_html, expected)| + context "with prose matching #{name}" do + let(:html) { example_html } + + it { is_expected.to eq([name, expected]) } + end end - end - it 'covers every rule' do - # A rule with no example above is either dead or untested; both are bugs. - expect(described_class::ALL.map(&:name)).to contain_exactly( - :returns_array_of, :success_array_of, :returns_link, :edited_or_true, - :true_flag, :success_object, :object_returned, :returns_int, :as_string - ) - end + context 'when nothing matches' do + let(:html) { 'Use this method to do something undocumented.' } - it 'returns nil when nothing matches' do - expect(match('Use this method to do something undocumented.')).to be_nil + it { is_expected.to be_nil } + end end - describe 'ordering' do + describe '::ALL' do + subject(:names) { described_class::ALL.map(&:name) } + def index_of(name) - described_class::ALL.index { |rule| rule.name == name } + names.index(name) end - it 'tries the array rules before the generic link rule', :aggregate_failures do - expect(index_of(:returns_array_of)).to be < index_of(:returns_link) - expect(index_of(:success_array_of)).to be < index_of(:returns_link) + it 'has an example for every rule' do + expect(names).to match_array(examples.keys) end - it 'tries the union rule before the plain True and object rules', :aggregate_failures do - expect(index_of(:edited_or_true)).to be < index_of(:true_flag) - expect(index_of(:edited_or_true)).to be < index_of(:object_returned) + context 'with the array rules' do + it 'tries Returns an Array of first' do + expect(index_of(:returns_array_of)).to be < index_of(:returns_link) + end + + it 'tries an array of first' do + expect(index_of(:success_array_of)).to be < index_of(:returns_link) + end + end + + context 'with the union rule' do + it 'runs before the plain True rule' do + expect(index_of(:edited_or_true)).to be < index_of(:true_flag) + end + + it 'runs before the object rule' do + expect(index_of(:edited_or_true)).to be < index_of(:object_returned) + end end end end diff --git a/spec/rakelib/parsers/types_parser_spec.rb b/spec/rakelib/parsers/types_parser_spec.rb index b5f3054..2b43c38 100644 --- a/spec/rakelib/parsers/types_parser_spec.rb +++ b/spec/rakelib/parsers/types_parser_spec.rb @@ -4,88 +4,103 @@ require_relative '../../../rakelib/parsers/types_parser' RSpec.describe Parsers::TypesParser do - def parse(body) - page = Docs::Page.new("
    #{body}
    ") - described_class.new(page: page).parse - end - - def union(description, members, name: 'RichText') - items = members.map { |member| %(
  • #{member}
  • ) }.join - parse("

    #{name}

    #{description}

      #{items}
    ").fetch(name) - end - - describe 'union types' do - it 'collects the linked members' do - result = union('This object represents something.', %w[RichTextBold RichTextItalic]) + describe '#parse' do + subject(:parsed) { described_class.new(page: page).parse } - expect(result).to eq('type' => %w[RichTextBold RichTextItalic]) + let(:page) do + Docs::Page.new("
    #{body}
    ") end - it 'prepends string when the prose mentions plain text' do - result = union('This object represents rich text. String for plain text, or one of:', %w[RichTextBold]) - - expect(result['type']).to eq(%w[string RichTextBold]) + def union_of(description, members, name: 'RichText') + items = members.map { |member| %(
  • #{member}
  • ) }.join + "

    #{name}

    #{description}

      #{items}
    " end - it 'prepends array:TypeName when the prose mentions an Array of the type' do - result = union('This object represents rich text as an Array of RichText, or one of:', %w[RichTextBold]) + context 'with a union of linked members' do + let(:body) { union_of('This object represents something.', %w[RichTextBold RichTextItalic]) } - expect(result['type']).to eq(%w[array:RichText RichTextBold]) + it { is_expected.to eq('RichText' => { 'type' => %w[RichTextBold RichTextItalic] }) } end - it 'keeps string before array when both are described' do - result = union( - 'String for plain text, an Array of RichText for sequences, or one of:', - %w[RichTextBold RichTextItalic] - ) + context 'when the prose mentions plain text' do + let(:body) do + union_of('This object represents rich text. String for plain text, or one of:', %w[RichTextBold]) + end - expect(result['type']).to eq(%w[string array:RichText RichTextBold RichTextItalic]) + it 'prepends string' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string RichTextBold]) + end end - it 'deduplicates members' do - result = union('String for plain text or one of:', %w[string RichTextBold]) + context 'when the prose mentions an Array' do + let(:body) do + union_of('This object represents rich text as an Array of RichText, or one of:', %w[RichTextBold]) + end - expect(result['type']).to eq(%w[string RichTextBold]) + it 'prepends array:TypeName' do + expect(parsed.dig('RichText', 'type')).to eq(%w[array:RichText RichTextBold]) + end end - end - describe 'struct types' do - let(:update) do - '

    Update

    This object represents an incoming update.

    ' \ - '' \ - '
    FieldTypeDescription
    update_idIntegerThe identifier.
    ' + context 'when the prose mentions both' do + let(:body) do + union_of( + 'String for plain text, an Array of RichText for sequences, or one of:', + %w[RichTextBold RichTextItalic] + ) + end + + it 'keeps string before array' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string array:RichText RichTextBold RichTextItalic]) + end end - let(:renamed_columns) do - '

    Update

    ' \ - '' \ - '
    FieldKindNotes
    update_idIntegerx
    ' + context 'when a member repeats an implied type' do + let(:body) { union_of('String for plain text or one of:', %w[string RichTextBold]) } + + it 'deduplicates them' do + expect(parsed.dig('RichText', 'type')).to eq(%w[string RichTextBold]) + end end - it 'reads the field table' do - expect(parse(update)).to eq('Update' => { 'update_id' => { 'type' => 'integer', 'required' => true } }) + context 'with a field table' do + let(:body) do + '

    Update

    This object represents an incoming update.

    ' \ + '' \ + '
    FieldTypeDescription
    update_idIntegerThe identifier.
    ' + end + + it { is_expected.to eq('Update' => { 'update_id' => { 'type' => 'integer', 'required' => true } }) } end - it 'raises when the field table changes shape' do - expect { parse(renamed_columns) }.to raise_error(Docs::Error, /Kind/) + context 'when the field table changes shape' do + let(:body) do + '

    Update

    ' \ + '' \ + '
    FieldKindNotes
    update_idIntegerx
    ' + end + + it { expect { parsed }.to raise_error(Docs::Error, /Kind/) } end - end - describe 'marker types' do - it 'yields no attributes for a type that holds no information' do - result = parse('

    CallbackGame

    A placeholder, currently holds no information.

    ') + context 'with a type that holds no information' do + let(:body) { '

    CallbackGame

    A placeholder, currently holds no information.

    ' } - expect(result).to eq('CallbackGame' => {}) + it { is_expected.to eq('CallbackGame' => {}) } end - end - describe 'selection' do - it 'ignores camelCase headings, which are methods' do - expect(parse('

    sendMessage

    Use this method.

    ')).to be_empty + context 'with a camelCase heading' do + let(:body) { '

    sendMessage

    Use this method.

    ' } + + it 'ignores it, since it names a method' do + expect(parsed).to be_empty + end end - it 'ignores prose headings' do - expect(parse('

    Formatting options

    Prose.

    ')).to be_empty + context 'with a prose heading' do + let(:body) { '

    Formatting options

    Prose.

    ' } + + it { is_expected.to be_empty } end end end From b3a62c2aeb7fdf7a67f3ca5b406fa589c13037dc Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Tue, 25 Aug 2026 23:49:02 +0300 Subject: [PATCH 09/11] Assert one fact per example in the attribute specs One `expect` per example was already the case after c786531, but five examples still made two assertions inside a single multi-key include: the array type, both discriminators, and two of the three size ranges. Split each into two examples. This also settles an inconsistency in the file -- 'with a zero-based character range' already used two `it` blocks while the range contexts on either side of it did not. Whole-object `eq` assertions are deliberately left alone. They are a single claim ("the result is exactly this"), and splitting them into `include`s would be weaker: neither half would catch an unexpected extra key, which is the property those examples exist for. 92 rakelib examples, 505 in the suite, rubocop clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- spec/rakelib/parsers/attribute_parser_spec.rb | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb index 3abef8e..a916b6e 100644 --- a/spec/rakelib/parsers/attribute_parser_spec.rb +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -49,7 +49,9 @@ context 'with an array type' do let(:type) { 'Array of PhotoSize' } - it { is_expected.to include('type' => 'array', 'items' => 'PhotoSize') } + it { is_expected.to include('type' => 'array') } + + it { is_expected.to include('items' => 'PhotoSize') } end context 'with a nested array type' do @@ -67,13 +69,17 @@ context 'with a quoted discriminator' do let(:description) { 'Type of the result, always “article”' } - it { is_expected.to include('required_value' => 'article', 'default' => 'article') } + it { is_expected.to include('required_value' => 'article') } + + it { is_expected.to include('default' => 'article') } end context 'with an emphasised discriminator' do let(:description) { 'Scope type, must be default' } - it { is_expected.to include('required_value' => 'default', 'default' => 'default') } + it { is_expected.to include('required_value' => 'default') } + + it { is_expected.to include('default' => 'default') } end # Regression: a Boolean field reading "... Must be False for callback @@ -130,7 +136,9 @@ context 'with a character range' do let(:description) { 'Text, 1-64 characters' } - it { is_expected.to include('min_size' => 1, 'max_size' => 64) } + it { is_expected.to include('min_size' => 1) } + + it { is_expected.to include('max_size' => 64) } end context 'with a zero-based character range' do @@ -145,7 +153,9 @@ let(:type) { 'Integer' } let(:description) { 'Period, must be between 60 and 86400' } - it { is_expected.to include('min_size' => 60, 'max_size' => 86_400) } + it { is_expected.to include('min_size' => 60) } + + it { is_expected.to include('max_size' => 86_400) } end # data/types.json is written with JSON.pretty_generate, so insertion From 1555a574df5da034b9e81eb35a77211b6c924ecb Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Wed, 26 Aug 2026 00:19:26 +0300 Subject: [PATCH 10/11] Act on the Copilot review: discriminators and size constraints Two changes, from the two points raised on #341. 1. Guard discriminators by type. AttributeParser#constant relied solely on `must be` being lowercase and mid-sentence to tell a real discriminator from conditional prose. That holds for all 57 in the docs, but it is an implicit contract with Telegram's doc writers. required_value becomes .constrained(eql: 'photo'), which is only coherent for a string; all 176 fields carrying one are strings. Assert that directly, keeping the adjacency check as the second guard. Output is unchanged. 2. Fix four wrong size constraints. TypeBuilder#apply_min_max! was flagged as hard to read. It is, and the placeholder substitution was hiding real bugs: constrain = '.constrained(minmax)' constrain = properties[:min_size] ? constrain.gsub('min', ...) : '' constrain = constrain.gsub('max', ...) if properties[:max_size] With min_size absent the string is reset to '', so max-only limits were silently dropped -- 24 fields' worth. Rewritten as a predicate table and a join. Emitting those 24 as-is would have been wrong too. 22 read "0-1024 characters after entities parsing": the limit applies to the text once markup is parsed, a length the client cannot compute, since "x" is 8 raw characters and 1 parsed. The parser now skips such limits rather than rejecting captions Telegram accepts. Two of them did carry a min_size and so were enforced -- InputChecklist#title and InputChecklistTask#text -- and rejected valid input. That leaves Poll#explanation and Game#text, plain character limits that should always have been enforced and now are. Separately, three Integer fields were given min_size/max_size. dry-types measures `size`, and Integer#size is the byte width -- 8 for every value -- so SuggestedPostPrice#amount accepted 1 and 999_999_999 alike despite a documented 5..100000 range. Numeric ranges now use their own min/max keys in data/types.json and become gteq/lteq. The builder also emits underscored numerals, so generated files no longer need a manual rubocop pass to satisfy Style/NumericLiterals. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- data/types.json | 86 +++++++------------ lib/telegram/bot/types/game.rb | 2 +- .../bot/types/inline_query_result_location.rb | 2 +- lib/telegram/bot/types/input_checklist.rb | 2 +- .../bot/types/input_checklist_task.rb | 2 +- .../types/input_location_message_content.rb | 2 +- lib/telegram/bot/types/poll.rb | 2 +- .../bot/types/suggested_post_price.rb | 2 +- rakelib/builders/type_builder.rb | 27 ++++-- rakelib/parsers/attribute_parser.rb | 35 +++++--- spec/rakelib/builders/type_builder_spec.rb | 46 ++++++++++ spec/rakelib/parsers/attribute_parser_spec.rb | 27 +++++- 12 files changed, 154 insertions(+), 81 deletions(-) diff --git a/data/types.json b/data/types.json index 941ed9c..f876813 100644 --- a/data/types.json +++ b/data/types.json @@ -947,8 +947,7 @@ "type": "boolean" }, "quote": { - "type": "string", - "max_size": 1024 + "type": "string" }, "quote_parse_mode": { "type": "string" @@ -1700,9 +1699,7 @@ }, "text": { "type": "string", - "required": true, - "min_size": 1, - "max_size": 100 + "required": true }, "parse_mode": { "type": "string" @@ -1715,9 +1712,7 @@ "InputChecklist": { "title": { "type": "string", - "required": true, - "min_size": 1, - "max_size": 255 + "required": true }, "parse_mode": { "type": "string" @@ -2388,8 +2383,8 @@ "amount": { "type": "integer", "required": true, - "min_size": 5, - "max_size": 100000 + "min": 5, + "max": 100000 } }, "SuggestedPostInfo": { @@ -4472,8 +4467,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4513,8 +4507,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4548,8 +4541,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4590,8 +4582,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4638,8 +4629,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4727,8 +4717,7 @@ "type": "integer" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -4768,8 +4757,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6559,8 +6547,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6615,8 +6602,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6671,8 +6657,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6719,8 +6704,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6771,8 +6755,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6814,8 +6797,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6850,8 +6832,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -6915,8 +6896,8 @@ }, "live_period": { "type": "integer", - "min_size": 60, - "max_size": 86400 + "min": 60, + "max": 86400 }, "heading": { "type": "integer" @@ -7077,8 +7058,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7116,8 +7096,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7155,8 +7134,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7220,8 +7198,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7260,8 +7237,7 @@ "type": "string" }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7300,8 +7276,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7333,8 +7308,7 @@ "required": true }, "caption": { - "type": "string", - "max_size": 1024 + "type": "string" }, "parse_mode": { "type": "string" @@ -7398,8 +7372,8 @@ }, "live_period": { "type": "integer", - "min_size": 60, - "max_size": 86400 + "min": 60, + "max": 86400 }, "heading": { "type": "integer" diff --git a/lib/telegram/bot/types/game.rb b/lib/telegram/bot/types/game.rb index 42cdaba..be18324 100644 --- a/lib/telegram/bot/types/game.rb +++ b/lib/telegram/bot/types/game.rb @@ -7,7 +7,7 @@ class Game < Base attribute :title, Types::String attribute :description, Types::String attribute :photo, Types::Array.of(PhotoSize) - attribute? :text, Types::String + attribute? :text, Types::String.constrained(max_size: 4096) attribute? :text_entities, Types::Array.of(MessageEntity) attribute? :animation, Animation end diff --git a/lib/telegram/bot/types/inline_query_result_location.rb b/lib/telegram/bot/types/inline_query_result_location.rb index 59677b7..faf29c2 100644 --- a/lib/telegram/bot/types/inline_query_result_location.rb +++ b/lib/telegram/bot/types/inline_query_result_location.rb @@ -10,7 +10,7 @@ class InlineQueryResultLocation < Base attribute :longitude, Types::Float attribute :title, Types::String attribute? :horizontal_accuracy, Types::Float - attribute? :live_period, Types::Integer.constrained(min_size: 60, max_size: 86_400) + attribute? :live_period, Types::Integer.constrained(gteq: 60, lteq: 86_400) attribute? :heading, Types::Integer attribute? :proximity_alert_radius, Types::Integer attribute? :reply_markup, InlineKeyboardMarkup diff --git a/lib/telegram/bot/types/input_checklist.rb b/lib/telegram/bot/types/input_checklist.rb index 7ec2936..ac24672 100644 --- a/lib/telegram/bot/types/input_checklist.rb +++ b/lib/telegram/bot/types/input_checklist.rb @@ -4,7 +4,7 @@ module Telegram module Bot module Types class InputChecklist < Base - attribute :title, Types::String.constrained(min_size: 1, max_size: 255) + attribute :title, Types::String attribute? :parse_mode, Types::String attribute? :title_entities, Types::Array.of(MessageEntity) attribute :tasks, Types::Array.of(InputChecklistTask) diff --git a/lib/telegram/bot/types/input_checklist_task.rb b/lib/telegram/bot/types/input_checklist_task.rb index c8c7cc2..b8a0f1e 100644 --- a/lib/telegram/bot/types/input_checklist_task.rb +++ b/lib/telegram/bot/types/input_checklist_task.rb @@ -5,7 +5,7 @@ module Bot module Types class InputChecklistTask < Base attribute :id, Types::Integer - attribute :text, Types::String.constrained(min_size: 1, max_size: 100) + attribute :text, Types::String attribute? :parse_mode, Types::String attribute? :text_entities, Types::Array.of(MessageEntity) end diff --git a/lib/telegram/bot/types/input_location_message_content.rb b/lib/telegram/bot/types/input_location_message_content.rb index 23d21b8..cfa3d66 100644 --- a/lib/telegram/bot/types/input_location_message_content.rb +++ b/lib/telegram/bot/types/input_location_message_content.rb @@ -7,7 +7,7 @@ class InputLocationMessageContent < Base attribute :latitude, Types::Float attribute :longitude, Types::Float attribute? :horizontal_accuracy, Types::Float - attribute? :live_period, Types::Integer.constrained(min_size: 60, max_size: 86_400) + attribute? :live_period, Types::Integer.constrained(gteq: 60, lteq: 86_400) attribute? :heading, Types::Integer attribute? :proximity_alert_radius, Types::Integer end diff --git a/lib/telegram/bot/types/poll.rb b/lib/telegram/bot/types/poll.rb index fb3ba72..92d4942 100644 --- a/lib/telegram/bot/types/poll.rb +++ b/lib/telegram/bot/types/poll.rb @@ -17,7 +17,7 @@ class Poll < Base attribute :members_only, Types::Bool attribute? :country_codes, Types::Array.of(Types::String) attribute? :correct_option_ids, Types::Array.of(Types::Integer) - attribute? :explanation, Types::String + attribute? :explanation, Types::String.constrained(max_size: 200) attribute? :explanation_entities, Types::Array.of(MessageEntity) attribute? :explanation_media, PollMedia attribute? :open_period, Types::Integer diff --git a/lib/telegram/bot/types/suggested_post_price.rb b/lib/telegram/bot/types/suggested_post_price.rb index 7e26de3..8288bbd 100644 --- a/lib/telegram/bot/types/suggested_post_price.rb +++ b/lib/telegram/bot/types/suggested_post_price.rb @@ -5,7 +5,7 @@ module Bot module Types class SuggestedPostPrice < Base attribute :currency, Types::String - attribute :amount, Types::Integer.constrained(min_size: 5, max_size: 100_000) + attribute :amount, Types::Integer.constrained(gteq: 5, lteq: 100_000) end end end diff --git a/rakelib/builders/type_builder.rb b/rakelib/builders/type_builder.rb index 407c754..493326d 100644 --- a/rakelib/builders/type_builder.rb +++ b/rakelib/builders/type_builder.rb @@ -100,13 +100,30 @@ def apply_required!(attr_name, properties, original_type) attributes[attr_name][:type] += ".constrained(eql: #{typecast(original_type, properties[:required_value])})" end + # String length limits use min_size/max_size; numeric ranges use + # gteq/lteq, because dry-types measures `size` and Integer#size is the + # byte width, which made every numeric range vacuously true. + CONSTRAINTS = { min_size: :min_size, max_size: :max_size, min: :gteq, max: :lteq }.freeze + def apply_min_max!(attr_name, properties) - return unless properties[:min_size] || properties[:max_size] + constrained = CONSTRAINTS.filter_map do |key, predicate| + "#{predicate}: #{numeral(properties[key])}" if properties[key] + end + return if constrained.empty? + + attributes[attr_name][:type] += ".constrained(#{constrained.join(', ')})" + end + + # Emit 86_400 rather than 86400, so generated files satisfy + # Style/NumericLiterals without a manual rubocop pass. Its MinDigits is 5, + # so shorter numbers are left alone. + NUMERIC_LITERAL_MIN_DIGITS = 5 + + def numeral(value) + digits = value.to_s + return digits if digits.length < NUMERIC_LITERAL_MIN_DIGITS - constrain = '.constrained(minmax)' - constrain = properties[:min_size] ? constrain.gsub('min', "min_size: #{properties[:min_size]}, ") : '' - constrain = constrain.gsub('max', "max_size: #{properties[:max_size]}") if properties[:max_size] - attributes[attr_name][:type] += constrain + digits.reverse.scan(/\d{1,3}/).join('_').reverse end def apply_default!(attr_name, properties, original_type) diff --git a/rakelib/parsers/attribute_parser.rb b/rakelib/parsers/attribute_parser.rb index eb59cbe..4998e72 100644 --- a/rakelib/parsers/attribute_parser.rb +++ b/rakelib/parsers/attribute_parser.rb @@ -11,6 +11,11 @@ module Parsers class AttributeParser CHARACTER_RANGE = /(\d+)-(\d+) characters/.freeze BOUNDED_RANGE = /must be between (\d+) and (\d+)/.freeze + # Limits worded this way are measured on the text after markup is parsed, + # a length the client cannot compute: "x" is 8 raw characters and 1 + # parsed. Enforcing them locally rejects input Telegram accepts, so they + # are not emitted at all. + PARSED_LENGTH = /after entities parsing/.freeze def initialize(row) @type = row['Type'] @@ -34,11 +39,12 @@ def optional? # A discriminator: `always "photo"` or `..., must be photo`. # - # `must be` is matched case-sensitively and only when the follows it - # immediately. All 57 real discriminators are lowercase and mid-sentence; - # the one false positive is a sentence-initial "Must be False" of - # conditional prose. That distinction is what the pattern keys on, so no - # type-specific carve-out is needed. + # Two independent guards, since either alone has failed before. The type + # check states what a discriminator is. `must be` is then matched + # case-sensitively and only when the follows it immediately: all 57 + # real discriminators are lowercase and mid-sentence, and the known false + # positive is a sentence-initial "Must be False" of conditional + # prose. def apply_constant(attribute) value = constant(attribute['type']) return unless value @@ -48,22 +54,31 @@ def apply_constant(attribute) end def constant(type) + # required_value becomes .constrained(eql: 'photo'), which is only + # coherent for a string discriminator; all 176 in the docs are strings. + return nil unless type == 'string' + quoted = @description.quoted_after('always') - return cast(quoted.delete('\\'), type) if quoted + return quoted.delete('\\') if quoted - emphasised = @description.emphasised_after('must be') - emphasised && cast(emphasised, type) + @description.emphasised_after('must be') end + # A string carries a length limit, a number a value range. They are + # different constraints, so they get different keys rather than leaving + # the builder to infer one from the type. def apply_size(attribute) minimum, maximum = size_range return unless maximum - attribute['min_size'] = minimum if minimum - attribute['max_size'] = maximum + low, high = attribute['type'] == 'integer' ? %w[min max] : %w[min_size max_size] + attribute[low] = minimum if minimum + attribute[high] = maximum end def size_range + return [nil, nil] if @description.match?(PARSED_LENGTH) + if (found = @description.match(CHARACTER_RANGE)) minimum = found[1].to_i [(minimum if minimum.positive?), found[2].to_i] diff --git a/spec/rakelib/builders/type_builder_spec.rb b/spec/rakelib/builders/type_builder_spec.rb index 16a2e45..1b50fdc 100644 --- a/spec/rakelib/builders/type_builder_spec.rb +++ b/spec/rakelib/builders/type_builder_spec.rb @@ -57,4 +57,50 @@ end end end + + describe '#build for size and range constraints' do + subject(:output) do + described_class.new( + 'Sized', { field: field }, + templates_dir: templates_dir, dependencies: dependencies + ).build + end + + let(:types) { { Sized: { field: field } } } + + context 'with a string length range' do + let(:field) { { type: 'string', min_size: 1, max_size: 64 } } + + it { is_expected.to include('Types::String.constrained(min_size: 1, max_size: 64)') } + end + + # This was dropped entirely: the old placeholder-substitution reset the + # constraint to '' whenever min_size was absent, so max-only limits never + # reached the generated file. + context 'with only a maximum length' do + let(:field) { { type: 'string', max_size: 200 } } + + it { is_expected.to include('Types::String.constrained(max_size: 200)') } + end + + context 'with only a minimum length' do + let(:field) { { type: 'string', min_size: 3 } } + + it { is_expected.to include('Types::String.constrained(min_size: 3)') } + end + + # min_size on an Integer compares Integer#size, the byte width, which is 8 + # for every value -- so the constraint accepted everything. + context 'with a numeric range' do + let(:field) { { type: 'integer', min: 5, max: 100_000 } } + + it { is_expected.to include('Types::Integer.constrained(gteq: 5, lteq: 100_000)') } + end + + context 'with no constraint at all' do + let(:field) { { type: 'string' } } + + it { is_expected.not_to include('constrained') } + end + end end diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb index a916b6e..93ab4f6 100644 --- a/spec/rakelib/parsers/attribute_parser_spec.rb +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -149,13 +149,34 @@ it { is_expected.not_to have_key('min_size') } end - context 'with an explicit range' do + # A number carries a value range, not a length, so it gets different keys. + # dry-types measures `size`, and Integer#size is the byte width, which + # made every numeric range vacuously true. + context 'with a numeric range' do let(:type) { 'Integer' } let(:description) { 'Period, must be between 60 and 86400' } - it { is_expected.to include('min_size' => 60) } + it { is_expected.to include('min' => 60) } - it { is_expected.to include('max_size' => 86_400) } + it { is_expected.to include('max' => 86_400) } + + it { is_expected.not_to have_key('min_size') } + end + + # These limits are measured on the text after markup is parsed, a length + # the client cannot compute, so enforcing them locally rejects input + # Telegram accepts. + context 'with a limit measured after entities parsing' do + let(:description) { 'Caption of the photo, 0-1024 characters after entities parsing' } + + it { is_expected.not_to have_key('max_size') } + end + + context 'with a non-string discriminator phrase' do + let(:type) { 'Integer' } + let(:description) { 'Identifier, always 42' } + + it { is_expected.not_to have_key('required_value') } end # data/types.json is written with JSON.pretty_generate, so insertion From 4dda3ca92d1fba64009fa0b710f119a0e7a75b4d Mon Sep 17 00:00:00 2001 From: Alexander Tipugin Date: Wed, 26 Aug 2026 00:29:57 +0300 Subject: [PATCH 11/11] Keep the enforceable half of parsed-length limits Dropping "after entities parsing" limits outright went too far: the docs state both bounds for InputChecklist#title and InputChecklistTask#text, and data/types.json stopped recording either. The two bounds are not symmetric. Markup only ever adds characters, so raw >= parsed. A parsed length at or above the minimum therefore implies a raw length at or above it, and the minimum is a valid necessary condition on the raw string -- it can only reject input Telegram would reject too. The maximum does not carry over: 102 raw characters can be 95 parsed. So min_size is kept and emitted as before, while the maximum is recorded as max_size_parsed, naming the quantity it actually measures. The builder maps constraint keys through a table, so an unrecognised key is simply not emitted; no builder change was needed. data/types.json once again records every size limit the documentation states, for all 24 affected fields. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sta5WKS94APw4xcGB61Ur2 --- data/types.json | 74 +++++++++++++------ lib/telegram/bot/types/input_checklist.rb | 2 +- .../bot/types/input_checklist_task.rb | 2 +- rakelib/parsers/attribute_parser.rb | 24 ++++-- spec/rakelib/builders/type_builder_spec.rb | 10 +++ spec/rakelib/parsers/attribute_parser_spec.rb | 16 +++- 6 files changed, 91 insertions(+), 37 deletions(-) diff --git a/data/types.json b/data/types.json index f876813..9bc7f35 100644 --- a/data/types.json +++ b/data/types.json @@ -947,7 +947,8 @@ "type": "boolean" }, "quote": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "quote_parse_mode": { "type": "string" @@ -1699,7 +1700,9 @@ }, "text": { "type": "string", - "required": true + "required": true, + "min_size": 1, + "max_size_parsed": 100 }, "parse_mode": { "type": "string" @@ -1712,7 +1715,9 @@ "InputChecklist": { "title": { "type": "string", - "required": true + "required": true, + "min_size": 1, + "max_size_parsed": 255 }, "parse_mode": { "type": "string" @@ -4467,7 +4472,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4507,7 +4513,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4541,7 +4548,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4582,7 +4590,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4629,7 +4638,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4717,7 +4727,8 @@ "type": "integer" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -4757,7 +4768,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6547,7 +6559,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6602,7 +6615,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6657,7 +6671,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6704,7 +6719,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6755,7 +6771,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6797,7 +6814,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -6832,7 +6850,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7058,7 +7077,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7096,7 +7116,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7134,7 +7155,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7198,7 +7220,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7237,7 +7260,8 @@ "type": "string" }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7276,7 +7300,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" @@ -7308,7 +7333,8 @@ "required": true }, "caption": { - "type": "string" + "type": "string", + "max_size_parsed": 1024 }, "parse_mode": { "type": "string" diff --git a/lib/telegram/bot/types/input_checklist.rb b/lib/telegram/bot/types/input_checklist.rb index ac24672..fff7ed5 100644 --- a/lib/telegram/bot/types/input_checklist.rb +++ b/lib/telegram/bot/types/input_checklist.rb @@ -4,7 +4,7 @@ module Telegram module Bot module Types class InputChecklist < Base - attribute :title, Types::String + attribute :title, Types::String.constrained(min_size: 1) attribute? :parse_mode, Types::String attribute? :title_entities, Types::Array.of(MessageEntity) attribute :tasks, Types::Array.of(InputChecklistTask) diff --git a/lib/telegram/bot/types/input_checklist_task.rb b/lib/telegram/bot/types/input_checklist_task.rb index b8a0f1e..f08d579 100644 --- a/lib/telegram/bot/types/input_checklist_task.rb +++ b/lib/telegram/bot/types/input_checklist_task.rb @@ -5,7 +5,7 @@ module Bot module Types class InputChecklistTask < Base attribute :id, Types::Integer - attribute :text, Types::String + attribute :text, Types::String.constrained(min_size: 1) attribute? :parse_mode, Types::String attribute? :text_entities, Types::Array.of(MessageEntity) end diff --git a/rakelib/parsers/attribute_parser.rb b/rakelib/parsers/attribute_parser.rb index 4998e72..587b425 100644 --- a/rakelib/parsers/attribute_parser.rb +++ b/rakelib/parsers/attribute_parser.rb @@ -11,10 +11,15 @@ module Parsers class AttributeParser CHARACTER_RANGE = /(\d+)-(\d+) characters/.freeze BOUNDED_RANGE = /must be between (\d+) and (\d+)/.freeze - # Limits worded this way are measured on the text after markup is parsed, - # a length the client cannot compute: "x" is 8 raw characters and 1 - # parsed. Enforcing them locally rejects input Telegram accepts, so they - # are not emitted at all. + # Telegram measures some limits on the text after markup is parsed, a + # length the client cannot compute: "x" is 8 raw characters and 1 + # parsed. + # + # The two bounds are not symmetric. Markup only ever adds characters, so + # raw >= parsed: a parsed length above the minimum implies a raw length + # above it too, and the minimum stays enforceable. The maximum does not + # carry over -- 102 raw characters can be 95 parsed -- so it is recorded + # under its own name and left for a caller that can measure it. PARSED_LENGTH = /after entities parsing/.freeze def initialize(row) @@ -71,14 +76,19 @@ def apply_size(attribute) minimum, maximum = size_range return unless maximum - low, high = attribute['type'] == 'integer' ? %w[min max] : %w[min_size max_size] + low, high = size_keys(attribute['type']) attribute[low] = minimum if minimum attribute[high] = maximum end - def size_range - return [nil, nil] if @description.match?(PARSED_LENGTH) + def size_keys(type) + return %w[min max] if type == 'integer' + return %w[min_size max_size_parsed] if @description.match?(PARSED_LENGTH) + %w[min_size max_size] + end + + def size_range if (found = @description.match(CHARACTER_RANGE)) minimum = found[1].to_i [(minimum if minimum.positive?), found[2].to_i] diff --git a/spec/rakelib/builders/type_builder_spec.rb b/spec/rakelib/builders/type_builder_spec.rb index 1b50fdc..ea129b8 100644 --- a/spec/rakelib/builders/type_builder_spec.rb +++ b/spec/rakelib/builders/type_builder_spec.rb @@ -102,5 +102,15 @@ it { is_expected.not_to include('constrained') } end + + # max_size_parsed is measured on the text after markup is parsed, which + # the client cannot compute, so it is recorded but never emitted. + context 'with a maximum measured after entities parsing' do + let(:field) { { type: 'string', min_size: 1, max_size_parsed: 255 } } + + it { is_expected.to include('Types::String.constrained(min_size: 1)') } + + it { is_expected.not_to include('255') } + end end end diff --git a/spec/rakelib/parsers/attribute_parser_spec.rb b/spec/rakelib/parsers/attribute_parser_spec.rb index 93ab4f6..28f6ad3 100644 --- a/spec/rakelib/parsers/attribute_parser_spec.rb +++ b/spec/rakelib/parsers/attribute_parser_spec.rb @@ -163,13 +163,21 @@ it { is_expected.not_to have_key('min_size') } end - # These limits are measured on the text after markup is parsed, a length - # the client cannot compute, so enforcing them locally rejects input - # Telegram accepts. + # Measured on the text after markup is parsed. Markup only ever adds + # characters, so raw >= parsed: the minimum still holds on the raw string, + # but the maximum does not and would reject input Telegram accepts. context 'with a limit measured after entities parsing' do - let(:description) { 'Caption of the photo, 0-1024 characters after entities parsing' } + let(:description) { 'Title of the checklist; 1-255 characters after entities parsing' } + + it 'keeps the minimum, which still holds' do + expect(attribute).to include('min_size' => 1) + end it { is_expected.not_to have_key('max_size') } + + it 'records the maximum under its own key' do + expect(attribute).to include('max_size_parsed' => 255) + end end context 'with a non-string discriminator phrase' do