vcard, import: split after an escaped backslash, drop a lone label
Two bugs Hegel properties found. A ";" or "," after an escaped
backslash ("C:\;John") was read as escaped, so a structured value
(N, ADR, ORG) or a CATEGORIES list whose component ended in a
backslash merged with the next one. The split now counts the run of
backslashes before a separator in pairs. And an X-ABLabel alone in its
group, with no line for it to name, came in with an import, though
Import::Vcf's comment has a group holding only a label stay behind.

The properties: escaped components split back as written, a rendered
header reads back as its property, CATEGORIES reads back the tags
written, a file reads as the cards it was made of, a stray line
refuses the file, and a label goes wherever its row goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit 5dfb61bb884e2be68c0e77c377db4ec697ba0add
author Claude <noreply@anthropic.com>
date
parent 5c164b42
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index 02d73e6..e524c71 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -156,7 +156,7 @@ module ProTacts
       #: (VCard card) -> Array[String]
       def self.categories(card)
         card.properties.select { it.name.casecmp?(CATEGORIES) }.flat_map { |property|
-          property.value.split(/(?<!\\),/).map { VCard.unescape(it).strip }
+          property.value.split(VCard::VALUE_SEPARATOR).map { VCard.unescape(it).strip }
         }.reject(&:empty?).uniq
       end
 
@@ -194,9 +194,12 @@ module ProTacts
           next if property.nil?
 
           group = property.group
-          next if group.nil? || property.name.casecmp?(LABEL)
+          next if group.nil?
 
-          anchored[group] = anchored.fetch(group, false) || known?(property.name)
+          # A label is in its group without anchoring it, so a group
+          # holding nothing else is one with nothing but a label in it.
+          anchors = !property.name.casecmp?(LABEL) && known?(property.name)
+          anchored[group] = anchored.fetch(group, false) || anchors
         end
         anchored.reject { |_group, kept| kept }.keys
       end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d8ef090..a02aaa8 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -24,6 +24,13 @@ module ProTacts
     # it without splitting a card's every line out to look.
     END_LINE = /^END:VCARD/i #: Regexp
 
+    # A ";" after an even run of backslashes (#split_raw_components),
+    # and a "," after one: the separator of a list of text values,
+    # CATEGORIES's tags (RFC 2426 section 3.6.1), escaped by the same
+    # backslash for the same reason.
+    COMPONENT_SEPARATOR = /(?<!\\)(?:\\\\)*\K;/ #: Regexp
+    VALUE_SEPARATOR = /(?<!\\)(?:\\\\)*\K,/ #: Regexp
+
     # Text values escape backslash, the component separator, and the
     # 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
@@ -60,9 +67,15 @@ module ProTacts
     # byte for byte, where unescape-then-re-escape is not byte-stable
     # (#unescape leaves an unrecognized escape like "\x" alone, and
     # #escape would double its backslash).
+    #
+    # A separator is escaped by the backslash before it only when that
+    # backslash is not itself the second half of a "\\": a component
+    # ending in an escaped backslash ends at the ";" after it. So the
+    # split counts the run of backslashes in pairs, and \K leaves the
+    # pairs on the component they close.
     #: (String value) -> Array[String]
     def self.split_raw_components(value)
-      value.split(/(?<!\\);/, -1)
+      value.split(COMPONENT_SEPARATOR, -1)
     end
 
     # split_raw_components with each component unescaped — the
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 714958c..f635d82 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -1,11 +1,15 @@
 require_relative "../../test_helper"
 
+require "hegel"
+
 require "pro_tacts/import/vcf"
 
 # Reading an uploaded .vcf: the split into cards, and the survey of
 # what this address book does not show
 # (docs/plans/2026-09-21-import-a-vcf.md).
 class ImportVcfTest < Minitest::Test
+  include Hegel::Syntax::Methods
+
   Vcf = ProTacts::Import::Vcf
 
   JANE = <<~CARD.gsub("\n", "\r\n")
@@ -205,6 +209,15 @@ class ImportVcfTest < Minitest::Test
     assert_includes reading.dropped.map { Vcf.summary(it) }, "item2.X-ABLabel:_$!<Spouse>!$_"
   end
 
+  # A label alone in its group names nothing at all, so it stays
+  # behind like one whose line is leaving: the case the property below
+  # shrank to.
+  def test_a_label_with_no_line_in_its_group_stays_behind
+    reading = Vcf.read(Vcf.cards(JANE.sub("END:VCARD", "item9.X-ABLabel:orphan\r\nEND:VCARD")).fetch(0))
+
+    refute_includes reading.card.to_s, "orphan"
+  end
+
   # There is no property name to have shown on a screen and nothing
   # anyone could have decided about it, so it rides along rather than
   # being thrown away unnamed.
@@ -270,4 +283,92 @@ class ImportVcfTest < Minitest::Test
     assert_empty Vcf.unknown([card])
   end
 
+  # A tag ending in a backslash escapes it, and the comma after that
+  # escaped backslash still separates.
+  def test_a_tag_ending_in_a_backslash_ends_at_the_comma
+    card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\nCATEGORIES:C:\\\\,school\r\nEND:VCARD\r\n").fetch(0)
+
+    assert_equal ["C:\\", "school"], Vcf.categories(card)
+  end
+
+  ## Property tests
+
+  # Lines from an Apple export's vocabulary: rows this book reads and
+  # rows it does not, labels hung off both by group, a label whose
+  # group holds nothing else, and the noise and tags read apart.
+  LINES = [
+    "N:Booles;Jane;;;", "FN:Jane Booles", "TEL:+1 555 0100", "item1.TEL:+1 555 0101",
+    "item1.X-ABLabel:_$!<Mobile>!$_", "item2.X-ABRELATEDNAMES:Sam", "item2.X-ABLabel:_$!<Spouse>!$_",
+    "item3.X-ABLabel:orphan", "item4.ADR:;;1 Main St;;;;", "item4.X-ABRELATEDNAMES:Pat",
+    "item4.X-ABLabel:_$!<Home>!$_", "X-SOCIALPROFILE:x", "PRODID:-//Apple", "CATEGORIES:Family",
+  ].freeze #: Array[String]
+
+  def card_text
+    arrays(sampled_from(LINES), max_size: 8).map { |lines|
+      ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD"].map { "#{it}\r\n" }.join
+    }
+  end
+
+  # A file is the cards it holds, one after another, maybe with blank
+  # lines between them, and reads as those cards byte for byte.
+  def test_a_file_reads_as_the_cards_it_was_made_of
+    Hegel.test(test_cases: 1_000) do |tc|
+      cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
+      file = cards.map { it + tc.draw(sampled_from(["", "\r\n", "\n", "\r\n\r\n"])) }.join
+
+      assert_equal cards, Vcf.cards(file).map(&:to_s)
+    end
+  end
+
+  # A line anywhere outside a card, between two or after the last,
+  # refuses the whole file rather than bringing in the cards around it.
+  def test_a_line_outside_a_card_refuses_the_file
+    Hegel.test(test_cases: 1_000) do |tc|
+      cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
+      stray = tc.draw(one_of(sampled_from(LINES), from_regex("[A-Za-z][^\r\n]{0,10}", fullmatch: true)))
+      tc.assume(!stray.match?(/\ABEGIN/i))
+      at = tc.draw(integers(min_value: 1, max_value: cards.length))
+
+      assert_raises(Vcf::Invalid) { Vcf.cards(cards.insert(at, "#{stray}\r\n").join) }
+    end
+  end
+
+  # The groups a reading's card keeps a label in, and the groups it
+  # keeps anything else in.
+  def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map(&:group).uniq
+
+  def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map(&:group).uniq
+
+  # A label names the line beside it, so none comes in without one.
+  def test_no_label_comes_in_without_a_line_to_name
+    Hegel.test(test_cases: 1_000) do |tc|
+      card = Vcf.read(Vcf.cards(tc.draw(card_text)).fetch(0)).card
+
+      assert_empty labeled_groups(card) - anchored_groups(card)
+    end
+  end
+
+  # And the other half: a row coming in brings its label.
+  def test_a_row_coming_in_brings_its_label
+    Hegel.test(test_cases: 1_000) do |tc|
+      original = Vcf.cards(tc.draw(card_text)).fetch(0)
+      card = Vcf.read(original).card
+
+      assert_equal labeled_groups(original) & anchored_groups(card), labeled_groups(card)
+    end
+  end
+
+  # Tags written into a CATEGORIES line, each escaped as RFC 2426
+  # section 2.4.2 has a text value escaped, read back as the tags they
+  # were — blank ones and repeats aside, which name no group.
+  def test_categories_read_back_as_the_tags_written
+    tag = one_of(text(max_size: 10), arrays(sampled_from([",", "\\", ";", " ", "a"]), max_size: 5).map(&:join))
+    Hegel.test(test_cases: 1_000) do |tc|
+      tags = tc.draw(arrays(tag, min_size: 1, max_size: 5))
+      line = "CATEGORIES:#{tags.map { ProTacts::VCard.escape(it) }.join(",")}"
+      card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\n#{line}\r\nEND:VCARD\r\n").fetch(0)
+
+      assert_equal tags.map { it.gsub(/\r\n|\r/, "\n").strip }.reject(&:empty?).uniq, Vcf.categories(card)
+    end
+  end
 end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 696089a..28cb73d 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -62,6 +62,13 @@ class VCardTest < Minitest::Test
     assert_equal ["Smith; Jr.", "John"], ProTacts::VCard.split_components("Smith\\; Jr.;John")
   end
 
+  # The backslash before this ";" is the second half of an escaped
+  # backslash, so the separator is a separator: the case the property
+  # below shrank to.
+  def test_split_components_ends_a_component_at_an_escaped_backslash
+    assert_equal ["C:\\", "John"], ProTacts::VCard.split_components("C:\\\\;John")
+  end
+
   # The third component is the case the byte-stability argument on
   # VCard.split_raw_components turns on: an escape neither half of the
   # pair recognizes, which a round trip through them would corrupt.
@@ -72,6 +79,41 @@ class VCardTest < Minitest::Test
       ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
   end
 
+  # Text heavy in the characters the escapes give meaning to, which a
+  # draw from all of Unicode reaches too rarely to land beside one
+  # another: a separator after a backslash, a backslash at a
+  # component's end, an escape neither half recognizes.
+  ESCAPEY = %W[; , \\ \\; \\\\ \\n \\x \n \r\n a].freeze #: Array[String]
+
+  def escapey_text = one_of(text(max_size: 20), arrays(sampled_from(ESCAPEY), max_size: 8).map(&:join))
+
+  # The two halves of a structured value agree: components escaped and
+  # joined by the writer read back, through the reader's split, as the
+  # components they were — whatever text they hold, a trailing
+  # backslash included. One empty component is the empty value, the
+  # same bytes as none, and reads as none; every caller pads to the
+  # positions it splices (Admin::CardForm).
+  def test_escaped_components_split_back_as_they_were
+    Hegel.test(test_cases: 1_000) do |tc|
+      components = tc.draw(arrays(escapey_text, min_size: 1, max_size: 7))
+      value = components.map { ProTacts::VCard.escape(it) }.join(";")
+      expected = value.empty? ? [] : components.map { it.gsub(/\r\n|\r/, "\n") }
+
+      assert_equal expected, ProTacts::VCard.split_components(value)
+    end
+  end
+
+  # The byte-stability a splice relies on: any value, escapes the
+  # writer never makes included, joins back from its raw components as
+  # the bytes it was.
+  def test_raw_components_join_back_byte_for_byte
+    Hegel.test(test_cases: 1_000) do |tc|
+      value = tc.draw(escapey_text)
+
+      assert_equal value, ProTacts::VCard.split_raw_components(value).join(";")
+    end
+  end
+
   # The writer's line header, for a value-only edit: group, name,
   # and every parameter re-render as they read, so a save swaps the
   # value and keeps what the form never modeled. The macOS triple
@@ -107,6 +149,27 @@ class VCardTest < Minitest::Test
     assert_equal 'TEL;X-FOO="a:b,c":', ProTacts::VCard.header_of(quoted)
   end
 
+  # Whatever a parse could have read, header_of renders a header that
+  # reads back as it: group, name, and every parameter, a value the
+  # grammar must quote included. A parameter value is anything but the
+  # controls (tab aside) and DQUOTE, which neither spelling can hold.
+  def test_a_rendered_header_reads_back_as_the_property
+    token = from_regex("[A-Za-z0-9-]{1,10}", fullmatch: true)
+    parameter_value = from_regex('[^\x00-\x08\x0a-\x1f\x7f"]{0,12}', fullmatch: true)
+    value = from_regex('[^\r\n]{0,40}', fullmatch: true)
+    Hegel.test(test_cases: 1_000) do |tc|
+      property = ProTacts::VCard::Parser::Property.new(
+        group: tc.draw(optional(token)),
+        name: tc.draw(token),
+        parameters: tc.draw(arrays(tuples(token, parameter_value), max_size: 4)),
+        value: tc.draw(value),
+      )
+      line = "#{ProTacts::VCard.header_of(property)}#{property.value}\r\n"
+
+      assert_equal [property], ProTacts::VCard::Parser.lines(line).map(&:property)
+    end
+  end
+
   ## The card's bytes
 
   # Relabelling bytes is not converting them, so bytes that are not