vcard: make iteration over a card explicit
each hid which half of a card was being iterated; every caller now
names it — card.lines or card.properties — and lines went public to
carry that. Dropping the generic Enumerable include also removes the
one reason VCard could not be signed inline: its signature moved into
the code, and the rbs file shrank to the two Data classes the inline
parser cannot read.

Assisted-by: GLM-5.3 via pi
change twqqxyuqxkotxlvuwormkwlpwommyrqy
commit bd318e2bf3ac75fc250ae2629edd8faa9debec59
author Alpha Chen <alpha@kejadlen.dev>
date
parent msntlxsu
diff --git a/db/migrations/002_birthdays.rb b/db/migrations/002_birthdays.rb
index 617785d..8a54ecc 100644
--- a/db/migrations/002_birthdays.rb
+++ b/db/migrations/002_birthdays.rb
@@ -42,7 +42,7 @@ Sequel.migration do
     self[:cards].order(:id).each do |row|
       # The same move a write makes, one arm per shape the stored
       # card's BDAY lines take — only the modeled single line moves.
-      case ProTacts::VCard.new(row.fetch(:vcard)).partition { it.names?("BDAY") }
+      case ProTacts::VCard.new(row.fetch(:vcard)).lines.partition { it.names?("BDAY") }
       in [[line], others]
         property = line.property
         birthday = property && ProTacts::Birthday.from_property(property)
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 152950f..9afd0cc 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -220,7 +220,7 @@ module ProTacts
       # reported rather than lost in silence.
       existing = birthday_of(id)
       birthday, stored =
-        case VCard.new(vcard).partition { it.names?("BDAY") }
+        case VCard.new(vcard).lines.partition { it.names?("BDAY") }
         in [[line], others]
           report_unrecognized_bday_lines([line])
           property = line.property
@@ -388,7 +388,7 @@ module ProTacts
     def carried_and_lost_bday_lines(vcard)
       return [[], []] if vcard.nil?
 
-      bdays, = VCard.new(vcard).partition { it.names?("BDAY") }
+      bdays, = VCard.new(vcard).lines.partition { it.names?("BDAY") }
       carried, rest = bdays.partition { |line|
         line.property && Birthday.unrendered_value?(line.property.value)
       }
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index ac9e0a3..24f44f0 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -2,20 +2,15 @@ module ProTacts
   # A card, read once: the parsed properties beside the exact bytes
   # they came from, so a caller asks questions of either without
   # re-parsing, and moves a property without a byte around it moving
-  # too. A card is Enumerable over its logical lines — each one parsed
-  # beside its verbatim bytes (#each and Line) — so the structured
-  # questions are select and reject and count, and #insert puts lines
-  # back in. vCard 3.0 (RFC 2426): escape and fold, the writer's half,
-  # are here; Parser owns the reading half, down to the line split.
-  # The line walk is lazy for the same reason Contact's parse is: the
-  # byte-moving paths never read structure.
-  # The signature lives in sig/pro_tacts/vcard.rbs: the generic
-  # Enumerable include is beyond the inline parser.
-  # @rbs skip
+  # too. Iteration is explicit over either half — #properties for the
+  # parsed card, #lines for the logical lines parsed beside their
+  # verbatim bytes (Line), where the questions that move bytes are
+  # asked — and #insert puts lines back in. vCard 3.0 (RFC 2426):
+  # escape and fold, the writer's half, are here; Parser owns the
+  # reading half, down to the line split. Both walks are lazy for the
+  # same reason Contact's parse is: the byte-moving paths never read
+  # structure.
   class VCard
-    include Enumerable
-
-
     # Folded lines must not exceed 75 octets, excluding the line break
     # (RFC 2426 section 2.6). The octet count, not character count, is
     # what matters: a continuation must never split a multibyte
@@ -52,6 +47,7 @@ module ProTacts
     #
     # The signature lives in sig/pro_tacts/vcard.rbs: a Data class has no
     # constant super class for the inline syntax to read.
+    # @rbs skip
     Property = Data.define(:group, :name, :parameters, :value)
 
     # One logical line of a card, parsed beside the exact bytes that
@@ -61,6 +57,7 @@ module ProTacts
     # included, so what moves between cards moves unchanged.
     #
     # The signature lives in sig/pro_tacts/vcard.rbs with Property's.
+    # @rbs skip
     Line = Data.define(:property, :verbatim)
 
     # Reopened rather than defined in the block above: steep reads a
@@ -71,6 +68,7 @@ module ProTacts
       # (`item1.BDAY`) counting as naming it. Asked of the line rather
       # than the property because a line that will not parse still has
       # a name in it.
+      #: (String name) -> bool
       def names?(name)
         verbatim.match?(/\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i)
       end
@@ -80,6 +78,7 @@ module ProTacts
     # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
     # normalized to the `\n` escape because a raw line break would end
     # the property line.
+    #: (String text) -> String
     def self.escape(text)
       text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
     end
@@ -90,6 +89,7 @@ module ProTacts
     # out unparsed — but a view that displays a structured value (an
     # admin screen, not this server's CardDAV responses) has to undo the
     # escaping or show the reader literal backslashes.
+    #: (String text) -> String
     def self.unescape(text)
       text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
     end
@@ -97,6 +97,7 @@ module ProTacts
     # Splits a structured value's ";"-delimited components (RFC 2426
     # section 3.2.1, e.g. ADR and N) without breaking on an escaped
     # "\;", and unescapes each component in the same pass.
+    #: (String value) -> Array[String]
     def self.split_components(value)
       value.split(/(?<!\\);/, -1).map { unescape(it) }
     end
@@ -105,6 +106,7 @@ module ProTacts
     # octets, each continuation starting with a single space (RFC 2426
     # section 2.6). The walk is character-wise so a multibyte character
     # is never split mid-sequence.
+    #: (String line) -> String
     def self.fold(line)
       return line if line.bytesize <= LINE_LIMIT
 
@@ -126,12 +128,14 @@ module ProTacts
     # rather than leaking an ArgumentError out of whatever regex first
     # trips over them. A PUT gates this with its own 412 first, and the
     # store's bind holds the same line again below the card.
+    #: (String bytes) -> void
     def initialize(bytes)
       raise ArgumentError, "not valid UTF-8: a card is text" unless bytes.valid_encoding?
 
       @bytes = bytes
     end
 
+    #: () -> String
     def bytes
       @bytes
     end
@@ -140,6 +144,7 @@ module ProTacts
     # Nil when the bytes are not a vCard — a caller is expected to carry
     # on serving the bytes (ParseError's rule, held one level up), and
     # #parseable? is the question to ask about it.
+    #: () -> Array[Property]?
     def properties
       return @properties if defined?(@properties)
 
@@ -148,6 +153,7 @@ module ProTacts
       @properties = nil
     end
 
+    #: () -> bool
     def parseable?
       !properties.nil?
     end
@@ -159,6 +165,7 @@ module ProTacts
     # lives here, where a caller that has to decide (a PUT) can ask.
     # The VERSION's value is not judged: any version is stored verbatim,
     # and this only decides whether there is a card at all.
+    #: () -> bool
     def card?
       parsed = properties
       return false if parsed.nil? || parsed.empty?
@@ -172,18 +179,22 @@ module ProTacts
     # The value of the card's UID property, if it carries one. Names
     # compare without case, as the index's NOCASE collation already
     # assumes for them.
+    #: () -> String?
     def uid
       parsed = properties
       parsed && parsed.find { it.name.casecmp?("UID") }&.value
     end
 
-    # Each logical line of the card, parsed beside its verbatim bytes —
-    # the one enumeration every structured question about a card is
-    # asked over. A continuation travels with its line, terminators
-    # attached, so no line's bytes are lost or normalized on the way
-    # through (RFC 2426 section 2.6 folding).
-    def each(&block)
-      lines.each(&block)
+    # The card's logical lines, each parsed beside its verbatim bytes —
+    # the enumeration the questions that move bytes are asked over. A
+    # continuation travels with its line, terminators attached, so no
+    # line's bytes are lost or normalized on the way through (RFC 2426
+    # section 2.6 folding).
+    #: () -> Array[Line]
+    def lines
+      return @lines if defined?(@lines)
+
+      @lines = Parser.lines(@bytes)
     end
 
     # Lines into the card immediately before END:VCARD. A line with no
@@ -191,6 +202,7 @@ module ProTacts
     # single-convention; a terminated line keeps its own, folds and
     # all. With no END to anchor to there is no envelope worth
     # respecting, and the lines are appended with CRLF.
+    #: (Array[String] lines) -> String
     def insert(lines)
       return @bytes if lines.empty?
 
@@ -207,23 +219,16 @@ module ProTacts
 
     private
 
-    # The card's logical lines, walked once on the first question
-    # asked over them. Parser's split — the bytes each property came
-    # from are #each's rule, held there.
-    def lines
-      return @lines if defined?(@lines)
-
-      @lines = Parser.lines(@bytes)
-    end
-
     # Physical lines with their terminators attached, so surgery on them
     # cannot lose or normalize a line break.
+    #: () -> Array[String]
     def physical_lines
       @bytes.split(/(?<=\n)/, -1)
     end
 
     # The line break a line ends with, for the line inserted beside it
     # to match. CRLF when it has none, being the grammar's own.
+    #: (String line) -> String
     def terminator_of(line)
       line[/\r?\n\z/] || "\r\n"
     end
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
index 2d25568..e94d8a5 100644
--- a/sig/pro_tacts/vcard.rbs
+++ b/sig/pro_tacts/vcard.rbs
@@ -1,50 +1,8 @@
-# VCard is signed here rather than inline: its Enumerable include is
-# generic, which the inline parser refuses (the rule
-# docs/plans/2026-08-20-type-checking.md records). Property and Line
-# are Data classes, which the inline syntax cannot read either.
+# Property and Line are Data classes, which the inline syntax cannot
+# read — Data.define leaves them no constant super class — so their
+# signatures live here. VCard itself is signed inline in vcard.rb.
 module ProTacts
   class VCard
-    include Enumerable[Line]
-
-    LINE_LIMIT: Integer
-    TEXT_ESCAPES: Hash[String, String]
-    END_LINE: Regexp
-
-    class ParseError < StandardError
-    end
-
-    def initialize: (String bytes) -> void
-
-    def bytes: () -> String
-
-    def properties: () -> Array[Property]?
-
-    def parseable?: () -> bool
-
-    def card?: () -> bool
-
-    def uid: () -> String?
-
-    def each: () { (Line) -> void } -> Array[Line]
-
-    def insert: (Array[String] lines) -> String
-
-    def self.escape: (String text) -> String
-
-    def self.unescape: (String text) -> String
-
-    def self.split_components: (String value) -> Array[String]
-
-    def self.fold: (String line) -> String
-
-    private
-
-    def lines: () -> Array[Line]
-
-    def physical_lines: () -> Array[String]
-
-    def terminator_of: (String line) -> String
-
     class Property
       attr_reader group: String?
       attr_reader name: String
@@ -59,8 +17,6 @@ module ProTacts
       attr_reader verbatim: String
 
       def self.new: (property: Property?, verbatim: String) -> instance
-
-      def names?: (String name) -> bool
     end
   end
 end
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index ee9e40b..6144c55 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -53,10 +53,10 @@ class VCardLinesTest < Minitest::Test
     assert_nil VCARD.new(CARD.sub("UID:ada\r\n", "")).uid
   end
 
-  ## the enumeration
+  ## lines
 
-  def test_each_yields_the_cards_lines_parsed_beside_verbatim
-    lines = VCARD.new(CARD).to_a
+  def test_lines_yield_the_cards_lines_parsed_beside_verbatim
+    lines = VCARD.new(CARD).lines
 
     assert_equal %w[BEGIN VERSION FN UID END], lines.map { it.property&.name }
     assert_equal CARD, lines.map(&:verbatim).join
@@ -64,7 +64,7 @@ class VCardLinesTest < Minitest::Test
 
   def test_partition_splits_a_card_without_losing_a_byte
     born = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-12\r\n")
-    bdays, others = VCARD.new(born).partition { it.names?("BDAY") }
+    bdays, others = VCARD.new(born).lines.partition { it.names?("BDAY") }
 
     assert_equal 1, bdays.length
     assert_equal "1985-04-12", bdays.fetch(0).property&.value
@@ -76,7 +76,7 @@ class VCardLinesTest < Minitest::Test
   # logical line it is.
   def test_a_fold_is_one_line
     folded = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-\r\n 12\r\n")
-    bday = VCARD.new(folded).find { it.names?("BDAY") }
+    bday = VCARD.new(folded).lines.find { it.names?("BDAY") }
 
     assert_equal "1985-04-12", bday&.property&.value
     assert_equal "BDAY:1985-04-\r\n 12\r\n", bday&.verbatim
@@ -88,13 +88,13 @@ class VCardLinesTest < Minitest::Test
   def test_names_judges_lines_not_properties
     grouped = CARD.sub("UID:ada\r\n", "UID:ada\r\nitem1.BDAY:1985-04-12\r\n")
 
-    assert VCARD.new(grouped).any? { it.names?("BDAY") }
+    assert VCARD.new(grouped).lines.any? { it.names?("BDAY") }
 
     lookalike = "NOTE:BDAY:1985\r\nX-BDAY:1\r\n"
 
-    refute VCARD.new(lookalike).any? { it.names?("BDAY") }
+    refute VCARD.new(lookalike).lines.any? { it.names?("BDAY") }
 
-    unparsed = VCARD.new("BDAY;=;:\r\n").first
+    unparsed = VCARD.new("BDAY;=;:\r\n").lines.first
 
     assert_nil unparsed.property
     assert unparsed.names?("BDAY")