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