vcard: a line folded inside its name still names it
Line#names? matched the line's bytes, so `NO\r\n TE:x` named nothing
while the parser read it as a NOTE, and replace, extract,
Contact#rows, assert_one_note and Merge's renumbering all missed it.
A line with a fold before its first `;` or `:` is now read unfolded;
every other line still matches on its bytes, so a folded PHOTO costs
no unfold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit e2bbb3684b0e2fd8508babc821b22a7eecfcabb8
author Claude <noreply@anthropic.com>
date
parent ddb35fa9
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 2d160af..c931db8 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -139,14 +139,23 @@ module ProTacts
         # listing of contacts allocated.
         NAME_PATTERNS = {} #: Hash[String, Regexp]
 
+        # A fold before the line's first `;` or `:`, inside its group or
+        # name, where the bytes alone would hide the name the parser reads.
+        HEAD_FOLD = /\A[^;:]*?(?:\r\n|[\r\n])[ \t]/ #: Regexp
+
         # Whether this line names the property, a group prefix
         # (`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.
+        # a name in it. A fold is no part of the name (RFC 2426 section
+        # 2.6), so a line folded inside it is read unfolded — only that
+        # line, the rest matching on their bytes without allocating, a
+        # folded PHOTO's base64 included.
         #: (String name) -> bool
         def names?(name)
           pattern = NAME_PATTERNS[name] ||= /\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i
-          verbatim.match?(pattern)
+          return true if verbatim.match?(pattern)
+
+          verbatim.match?(HEAD_FOLD) && Parser.unfold(verbatim).match?(pattern)
         end
 
         # Whether this line broke one of the parser's assumptions rather
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 06ef6d9..1ef49b7 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -142,6 +142,27 @@ class VCardLinesTest < Minitest::Test
     assert_equal "BDAY;=;:\r\n", unparsed.verbatim
   end
 
+  # Whether a line names a property is whether the parser reads it as
+  # that property, wherever a fold falls — inside the group or the name
+  # included, which the line's bytes alone would hide (RFC 2426
+  # section 2.6).
+  def test_names_agrees_with_the_parse_wherever_a_fold_falls
+    token = from_regex("[A-Za-z0-9-]{1,6}", fullmatch: true)
+    Hegel.test(test_cases: 1_000) do |tc|
+      group = tc.draw(optional(token))
+      name = tc.draw(one_of(token, sampled_from(%w[NOTE ADR BDAY TEL])))
+      text = "#{"#{group}." if group}#{name}#{tc.draw(sampled_from([":", ";TYPE=home:"]))}#{tc.draw(from_regex("[a-z;:,]{0,8}", fullmatch: true))}"
+      folds = tc.draw(sets(integers(min_value: 1, max_value: text.length - 1), max_size: 3)).sort.reverse
+      eol = tc.draw(sampled_from(["\r\n", "\n"]))
+      folded = folds.reduce(text) { |line, at| line.dup.insert(at, "#{eol}#{tc.draw(sampled_from([" ", "\t"]))}") }
+      line = VCARD.new("#{folded}#{eol}").lines.fetch(0)
+      asked = tc.draw(one_of(just(name), sampled_from(%w[NOTE ADR BDAY TEL]), token))
+
+      assert_equal name.casecmp?(asked), line.names?(asked)
+      assert_equal name, line.property&.name
+    end
+  end
+
   # A broken assumption and an ordinary unreadable line are both
   # errors on a line, and telling them apart belongs here rather than
   # in whoever reports it: news about this server and ordinary bad