vcard: Parser owns the whole reading half
Unfolding, the logical-line split, and both reads over it move out of
VCard, which was parsing every card twice — #lines rejoined each line
group just so Parser could unfold it and rediscover boundaries the
split already knew. Parse over the group walk preserves the
whole-card semantics (blank lines skip, a malformed line raises), so
the strict read that keeps a bad card unindexed and the tolerant one
that carries bad bytes across a rewrite now share a single splitter.

Assisted-by: GLM-5.3 via pi
change msntlxsukvnumtnntpxnomkrowxmvmus
commit dfac03e4b2630b9c2af682471f5258eca01f82b0
author Alpha Chen <alpha@kejadlen.dev>
date
parent tzkqyvqu
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 4e7d18c..ac9e0a3 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -5,10 +5,10 @@ module ProTacts
   # 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): the line-level rules a card obeys
-  # are here whichever half is being asked — Parser reads, escape and
-  # fold write. The line walk is lazy for the same reason Contact's
-  # parse is: the byte-moving paths never read structure.
+  # 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
@@ -29,20 +29,6 @@ module ProTacts
       "\n" => "\\n",
     }.freeze #: Hash[String, String]
 
-    # A bare CR is not a line break in the grammar, but it ends a value
-    # and has to end a line here too: without it the parser's scanner
-    # would sit on one forever, because no token can consume it.
-    LINE_BREAK = /\r\n|[\r\n]/ #: Regexp
-
-    # Reads like an alternation-precedence bug and is not: Ruby wraps an
-    # interpolated Regexp in a non-capturing group, so this is
-    # `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
-    FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
-
-    # A physical line that continues the one above it (RFC 2426 section
-    # 2.6 folding).
-    CONTINUATION = /\A[ \t]/ #: Regexp
-
     # The line inserted lines go immediately before, so a property
     # lands inside the envelope however bare the card is.
     END_LINE = /\AEND:VCARD/i #: Regexp
@@ -135,15 +121,6 @@ module ProTacts
       folded
     end
 
-    # Fold's inverse, and the first thing a parse does: RFC 2426 section
-    # 2.6 has a content line unfolded before it is read. It runs over
-    # the whole card rather than token by token because a fold can land
-    # anywhere in a line — Contacts folds at 75 octets without regard for
-    # what it splits — so there is no boundary to do it at.
-    def self.unfold(card)
-      card.gsub(FOLD, "")
-    end
-
     # A card is made of UTF-8 text, and refuses to be made of anything
     # else: bytes that are not valid UTF-8 raise here, at the boundary,
     # rather than leaking an ArgumentError out of whatever regex first
@@ -230,24 +207,13 @@ module ProTacts
 
     private
 
-    # One line group read as a property, or nil when it does not read
-    # as one. The parser unfolds what it is handed, so a folded group
-    # arrives as the single logical line it is.
-    def parse(group)
-      Parser.parse(group.join).fetch(0, nil)
-    rescue ParseError
-      nil
-    end
-
     # The card's logical lines, walked once on the first question
-    # asked over them. The terminatorless trailing group the byte-level
-    # split leaves behind is not a line — dropping it loses nothing,
-    # an empty group joining to nothing.
+    # 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)
 
-      groups = line_groups.reject { |group| group == [""] }
-      @lines = groups.map { |group| Line.new(property: parse(group), verbatim: group.join) }
+      @lines = Parser.lines(@bytes)
     end
 
     # Physical lines with their terminators attached, so surgery on them
@@ -256,14 +222,6 @@ module ProTacts
       @bytes.split(/(?<=\n)/, -1)
     end
 
-    # Logical lines: a physical line and the continuations folded under
-    # it, kept together because a property and its fold are one unit.
-    # Slicing before each non-continuation, rather than chunking, is
-    # what attaches a continuation to the line above it.
-    def line_groups
-      physical_lines.slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
-    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.
     def terminator_of(line)
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 958c614..fe2a95f 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -5,7 +5,7 @@ require "pro_tacts/vcard"
 
 module ProTacts
   class VCard
-    # Reads a stored card into VCard::Property values.
+    # Reads a stored card into VCard::Property and VCard::Line values.
     #
     # Deliberately shallow: a property is a name, its parameters, and the
     # text of its value, and nothing here knows what any of them mean.
@@ -15,9 +15,15 @@ module ProTacts
     # 6.3.2.2 requires of a CardDAV server. See
     # docs/plans/2026-08-24-vcard-storage-and-groups.md.
     #
-    # One instance reads one card. The scanner is the whole of its state,
-    # which is why it is a class and not a handful of functions passing
-    # a position between them.
+    # The whole reading half of VCard lives here: unfolding, the split
+    # into logical lines, and the reads over them. One instance reads one
+    # logical line — the scanner is the whole of its state, which is why
+    # it is a class and not a handful of functions passing a position
+    # between them — and the two class-level reads differ only in
+    # strictness. parse raises on a line that will not read, which is
+    # what keeps an unparseable card served but unindexed; lines carries
+    # the same line with a nil property, which is what lets a rewrite
+    # move bytes no line could ever parse out of.
     class Parser
       # @rbs @scanner: StringScanner
 
@@ -39,20 +45,91 @@ module ProTacts
       QUOTED_STRING = /"([^\x00-\x08\x0A-\x1F\x7F"]*)"/ #: Regexp
       VALUE = /[^\r\n]*/ #: Regexp
 
+      # A bare CR is not a line break in the grammar, but it ends a value
+      # and has to end a line here too: without it the scanner would sit
+      # on one forever, because no token can consume it.
+      LINE_BREAK = /\r\n|[\r\n]/ #: Regexp
+
+      # Reads like an alternation-precedence bug and is not: Ruby wraps
+      # an interpolated Regexp in a non-capturing group, so this is
+      # `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
+      FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
+
+      # A physical line that continues the one above it (RFC 2426 section
+      # 2.6 folding).
+      CONTINUATION = /\A[ \t]/ #: Regexp
+
+      # Every content line in the card, in order. Blank lines are
+      # skipped, and BEGIN, VERSION, and END come back like any other
+      # property: deciding that a card is well-formed is not this class's
+      # job. Raises on the first line that will not read, so a caller
+      # rescuing ParseError declines the whole card at once.
       #: (String card) -> Array[Property]
       def self.parse(card)
-        new(card).parse
+        logical_lines(card).flat_map { new(it).parse }
       end
 
-      #: (String card) -> void
-      def initialize(card)
-        @scanner = StringScanner.new(VCard.unfold(card))
+      # The card's logical lines, each parsed beside the exact bytes it
+      # came from, folds and terminator included. A line that will not
+      # read is a fact about the line, not an error: the property is nil
+      # and the bytes stay enumerable.
+      #: (String card) -> Array[Line]
+      def self.lines(card)
+        logical_lines(card).map { |line| Line.new(property: property_of(line), verbatim: line) }
       end
 
-      # Every content line in the card, in order. Blank lines are
-      # skipped, and BEGIN, VERSION, and END come back like any other
-      # property: deciding that a card is well-formed is not this class's
-      # job.
+      # VCard.fold's inverse, and the first thing a read does: RFC 2426
+      # section 2.6 has a content line unfolded before it is read. It
+      # runs over the whole line rather than token by token because a
+      # fold can land anywhere in it — Contacts folds at 75 octets
+      # without regard for what it splits — so there is no boundary to
+      # do it at.
+      #: (String line) -> String
+      def self.unfold(line)
+        line.gsub(FOLD, "")
+      end
+
+      # One logical line read as its first property, or nil when it does
+      # not read as one — blank, or rejected by the grammar.
+      #: (String line) -> Property?
+      def self.property_of(line)
+        new(line).parse.fetch(0, nil)
+      rescue ParseError
+        nil
+      end
+
+      # The card's logical lines: a physical line and the continuations
+      # folded under it, kept together because a property and its fold
+      # are one unit (RFC 2426 section 2.6). Slicing before each
+      # non-continuation, rather than chunking, is what attaches a
+      # continuation to the line above it. The empty string the
+      # byte-level split leaves behind is not a line — dropping it loses
+      # nothing, an empty group joining to nothing.
+      #: (String card) -> Array[String]
+      def self.logical_lines(card)
+        physical_lines(card)
+          .slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }
+          .map(&:join)
+          .reject { it == "" }
+      end
+
+      # Physical lines with their terminators attached, so the split
+      # cannot lose or normalize a line break.
+      #: (String card) -> Array[String]
+      def self.physical_lines(card)
+        card.split(/(?<=\n)/, -1)
+      end
+
+      private_class_method :property_of, :logical_lines, :physical_lines
+
+      #: (String logical_line) -> void
+      def initialize(logical_line)
+        @scanner = StringScanner.new(self.class.unfold(logical_line))
+      end
+
+      # Every content line in this logical line, in order — usually one,
+      # though a lone CR can end a content line mid-physical-line, and a
+      # blank line has none.
       #: () -> Array[Property]
       def parse
         properties = [] #: Array[Property]
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
index 2d5ad8e..2d25568 100644
--- a/sig/pro_tacts/vcard.rbs
+++ b/sig/pro_tacts/vcard.rbs
@@ -8,9 +8,6 @@ module ProTacts
 
     LINE_LIMIT: Integer
     TEXT_ESCAPES: Hash[String, String]
-    LINE_BREAK: Regexp
-    FOLD: Regexp
-    CONTINUATION: Regexp
     END_LINE: Regexp
 
     class ParseError < StandardError
@@ -40,18 +37,12 @@ module ProTacts
 
     def self.fold: (String line) -> String
 
-    def self.unfold: (String card) -> String
-
     private
 
-    def parse: (Array[String] group) -> Property?
-
     def lines: () -> Array[Line]
 
     def physical_lines: () -> Array[String]
 
-    def line_groups: () -> Array[Array[String]]
-
     def terminator_of: (String line) -> String
 
     class Property
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 0093235..278ea0e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -3,6 +3,7 @@ require_relative "../test_helper"
 require "hegel"
 
 require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
 
 # The writer's half of the module: nothing serves through escape and
 # fold yet, and a PUT or the web editor will.
@@ -78,46 +79,20 @@ class VCardTest < Minitest::Test
       value = tc.draw(text(max_size: 300))
       line = "NOTE:#{ProTacts::VCard.escape(value)}"
 
-      raise "unfold did not reverse fold" unless ProTacts::VCard.unfold(ProTacts::VCard.fold(line)) == line
+      raise "unfold did not reverse fold" unless ProTacts::VCard::Parser.unfold(ProTacts::VCard.fold(line)) == line
     end
   end
 
   ## The envelope
 
-  # What a PUT checks before storing: RFC 2426 section 4 has a card
-  # begin and end itself and declare a version, and lines that parse
-  # into properties are not yet a card.
-  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
-
-  def test_properties_with_the_envelope_are_a_card
-    assert ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(CARD))
-  end
-
-  def test_no_properties_are_not_a_card
-    refute ProTacts::VCard.card?([])
-  end
-
-  def test_each_piece_of_the_envelope_is_required
-    no_begin = "VERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
-    no_end = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\n"
-    no_version = "BEGIN:VCARD\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
-
-    [no_begin, no_end, no_version].each do |card|
-      refute ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(card)), card
-    end
-  end
-
+  # The envelope tests proper live in vcard/test_lines.rb, over the
+  # card; this one covers what they do not — the names compare without
+  # case.
   def test_envelope_names_compare_without_case
     lowercase = "begin:vcard\r\nversion:3.0\r\nfn:Aiden\r\nuid:aiden\r\nend:vcard\r\n"
-    properties = ProTacts::VCard::Parser.parse(lowercase)
-
-    assert ProTacts::VCard.card?(properties)
-    assert_equal "aiden", ProTacts::VCard.uid(properties)
-  end
-
-  def test_a_card_without_a_uid_has_none
-    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
+    card = ProTacts::VCard.new(lowercase)
 
-    assert_nil ProTacts::VCard.uid(ProTacts::VCard::Parser.parse(card))
+    assert card.card?
+    assert_equal "aiden", card.uid
   end
 end
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 6554027..233a860 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -145,6 +145,47 @@ class VCardParserTest < Minitest::Test
     assert_equal "abcd", parse(card("NOTE:ab", "\tcd")).first.value
   end
 
+  ## Lines
+
+  # lines pairs each property with the exact bytes it came from — the
+  # fold travels with its line, and the verbatims join back to the card.
+  def test_lines_pair_properties_with_verbatim_bytes
+    card = "BEGIN:VCARD\r\nBDAY:1985-04-\r\n 12\r\nEND:VCARD\r\n"
+    lines = ProTacts::VCard::Parser.lines(card)
+
+    assert_equal %w[BEGIN BDAY END], lines.map { it.property&.name }
+    assert_equal "1985-04-12", lines.fetch(1).property&.value
+    assert_equal "BDAY:1985-04-\r\n 12\r\n", lines.fetch(1).verbatim
+    assert_equal card, lines.map(&:verbatim).join
+  end
+
+  def test_a_line_that_will_not_read_has_no_property_but_keeps_its_bytes
+    line = ProTacts::VCard::Parser.lines("BDAY;=;:\r\n").fetch(0)
+
+    assert_nil line.property
+    assert_equal "BDAY;=;:\r\n", line.verbatim
+  end
+
+  # The two reads' strictness split, pinned: parse declines the whole
+  # card, lines carries the same line with a nil property.
+  def test_parse_raises_where_lines_carries
+    card = "FN:A\r\nBDAY;=;:\r\n"
+
+    assert_raises(ProTacts::VCard::ParseError) { parse(card) }
+    assert_nil ProTacts::VCard::Parser.lines(card).fetch(1).property
+  end
+
+  # A blank line is a line — its bytes stay enumerable, so a rewrite
+  # carrying lines does not drop them — while parse skips it, as the
+  # grammar lets a card carry blank lines between properties.
+  def test_a_blank_line_stays_a_line_but_parses_as_none
+    lines = ProTacts::VCard::Parser.lines("FN:A\r\n\r\nUID:b\r\n")
+
+    assert_equal ["FN:A\r\n", "\r\n", "UID:b\r\n"], lines.map(&:verbatim)
+    assert_nil lines.fetch(1).property
+    assert_equal %w[FN UID], parse("FN:A\r\n\r\nUID:b\r\n").map { it.name }
+  end
+
   ## Refusals
 
   def test_a_line_without_a_colon_raises