vcard: give a property its two readings of a value
Assisted-by: Claude Opus 5 via Claude Code
change zwqluxxkvnytrprlvymwpwttrsyuwnxk
commit c5289138b8d7400eac1403aa5c86a4d6b3bcab5b
author Alpha Chen <alpha@kejadlen.dev>
date
parent qorkpxmw
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index aff457d..c0e2a70 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -221,7 +221,7 @@ module ProTacts
     #: () -> Array[String?]?
     def name_components
       property = properties.find { it.name.casecmp?("N") }
-      property && components_of(property.value)
+      property && components_of(property)
     end
 
     # NICKNAME's value (RFC 2426 section 3.1.3), in text form — the
@@ -272,23 +272,23 @@ module ProTacts
     def text_of(property)
       return if property.nil?
 
-      value = VCard.unescape(property.value)
+      value = property.text
       value.empty? ? nil : value
     end
 
     # A structured value's components, nil in the positions the card
     # left blank: an empty component is as absent as one past the end
     # of the value, and reads the same.
-    #: (String value) -> Array[String?]
-    def components_of(value)
-      VCard.split_components(value).map { it.empty? ? nil : it }
+    #: (VCard::Parser::Property property) -> Array[String?]
+    def components_of(property)
+      property.components.map { it.empty? ? nil : it }
     end
 
     # ADR's components, with a value that is blank throughout reading
     # as no address at all.
     #: (VCard::Parser::Property property) -> Address?
     def address_of(property)
-      components = components_of(property.value)
+      components = components_of(property)
       return if components.none?
 
       po_box, extended, street, locality, region, postal_code, country = components
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 6b6914d..bb1554f 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -60,15 +60,36 @@ module ProTacts
       # same thing — both arrive here as two pairs.
       #
       # `value` is the value's text, unfolded and otherwise exactly as it
-      # was stored, still escaped. Unescaping it and splitting it on the
-      # component separator are per-property semantics, and this layer has
-      # none.
+      # was stored, still escaped — the one representation it keeps, a
+      # served card being these bytes going back out unparsed. #text and
+      # #components are the two readings of it, derived on demand rather
+      # than stored beside it: which reading applies depends on the
+      # property's value type, which this layer does not know.
       #
       # The signature lives in sig/pro_tacts/vcard/parser.rbs: a Data
       # class has no constant super class for the inline syntax to read.
       # @rbs skip
       Property = Data.define(:group, :name, :parameters, :value)
 
+      # Reopened rather than defined in the block above: steep reads a
+      # define block's self as this class's, not the constant's, so the
+      # methods could not live there.
+      class Property
+        # The whole value unescaped (RFC 2426 section 2.4.2), for a
+        # property whose value is free text. A structured value read this
+        # way comes back with its separators melted into the text, which
+        # is why the choice is the caller's.
+        #: () -> String
+        def text = VCard.unescape(value)
+
+        # The value's components, for a structured one (RFC 2426 section
+        # 3.2.1). Splitting before unescaping is the whole point of
+        # keeping `value` escaped: an escaped "\;" stays inside its
+        # component instead of ending it.
+        #: () -> Array[String]
+        def components = VCard.split_components(value)
+      end
+
       # One logical line of a card, parsed beside the exact bytes that
       # carried it: `property` is the content line it read, nil when it
       # read none (a blank line, or one that would not read), `error` the
@@ -86,9 +107,7 @@ module ProTacts
       # @rbs skip
       Line = Data.define(:property, :verbatim, :error)
 
-      # Reopened rather than defined in the block above: steep reads a
-      # define block's self as this class's, not the constant's, so the
-      # methods could not live there.
+      # Reopened for the reason Property is.
       class Line
         # Whether this line names the property, a group prefix
         # (`item1.BDAY`) counting as naming it. Asked of the line rather
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 31ace17..3a629a5 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -356,4 +356,15 @@ class VCardParserTest < Minitest::Test
       raise "parameters did not survive" unless property.parameters == values.map { [name, it] }
     end
   end
+
+  # One value, two readings, and only the caller knows which its property
+  # wants: N is structured, so the escaped "\;" is a character inside a
+  # component — read as text it comes back spelled like the separator
+  # between them.
+  def test_a_value_reads_as_text_or_as_components
+    property = properties(card("N:Smith\\; Jr.;John;;;")).fetch(0)
+
+    assert_equal "Smith; Jr.;John;;;", property.text
+    assert_equal ["Smith; Jr.", "John", "", "", ""], property.components
+  end
 end