listing: stop reading again what the index already read
Three costs on every listed contact, found by profiling /contacts on
a 1,000-card book:

- VCard.header_of interpolated PTEXT into a regexp per parameter,
  compiling it each time. It is Parser::BARE now, built once.
- VCard#property walked the properties with casecmp? on every ask,
  and a list asks for the name, N and NICKNAME many times per contact
  as it sorts, files and labels. It reads a table of each name's
  first property now, built on the first ask.
- Store#listed_by_id wrote each indexed row out as text and parsed
  the card back. It hands VCard.of the lines with the property each
  was written from, and VCard#insert carries an already-read card's
  reading through the birthday and lent lines it composes, so neither
  parses again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZeaKvJp7q4WEwoCPqVkZb
change
commit f7706b962097b43e2d0244a366333163193074ed
author Claude <noreply@anthropic.com>
date
parent 0f979053
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index da09d3a..d506a71 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1310,8 +1310,10 @@ module ProTacts
     # one, so a list reads the same names, numbers and lent lines a
     # contact's page does without reading a picture to show a name. The
     # index holds what #reindex read off the stored card, and a line
-    # that would not read is in neither. Of a PHOTO, the first
-    # characters alone, which is all Contact.photo_head? decodes.
+    # that would not read is in neither. Each line keeps the property
+    # it was written from as its reading (VCard.of), so the card is not
+    # parsed back into what the rows already said. Of a PHOTO, the
+    # first characters alone, which is all Contact.photo_head? decodes.
     #: (Array[String]? ids) -> Hash[String, ListedContact]
     def listed_by_id(ids)
       scope = ids ? card_properties.where(card_id: ids) : card_properties
@@ -1330,16 +1332,16 @@ module ProTacts
       inherited = inherited_by_id
       (ids ? cards.where(id: ids) : cards).order(:id).select_map(:id).to_h { |raw|
         id = raw.to_s
-        text = lines.fetch(id, []).map { |row|
+        read = lines.fetch(id, []).map { |row|
           pairs = parameters.fetch("#{id} #{row.fetch(:position)}", []).map {
             [it.fetch(:name).to_s, it.fetch(:value).to_s] #: [String, String]
           }
           property = VCard::Parser::Property.new(
             group: row.fetch(:property_group)&.to_s, name: row.fetch(:name).to_s, parameters: pairs, value: row.fetch(:value).to_s,
           )
-          "#{VCard.header_of(property)}#{property.value}\r\n"
-        }.join
-        contact = Contact.new(id:, stored: VCard.new(text), birthday: birthdays[id], inherited: inherited.fetch(id, []))
+          VCard::Parser::Line.new(property:, verbatim: "#{VCard.header_of(property)}#{property.value}\r\n", error: nil)
+        }
+        contact = Contact.new(id:, stored: VCard.of(read), birthday: birthdays[id], inherited: inherited.fetch(id, []))
         head = heads[id]
         listed = ListedContact.new(contact, photo: !head.nil? && Contact.photo_head?(head))
         [id, listed] #: [String, ListedContact]
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index b65479a..d417f76 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -104,7 +104,7 @@ module ProTacts
     def self.header_of(property)
       prefix = property.group ? "#{property.group}." : ""
       parameters = property.parameters.map { |name, value|
-        bare = /\A#{Parser::PTEXT}\z/.match?(value)
+        bare = Parser::BARE.match?(value)
         ";#{name}=#{bare ? value : "\"#{value}\""}"
       }
       "#{prefix}#{property.name}#{parameters.join}:"
@@ -192,9 +192,15 @@ module ProTacts
     # The first property naming `name`, or nil where the card carries
     # none — the read every caller after a property a card holds one of
     # makes. Names match without case (RFC 2426 section 4).
+    # Off a table of each name's first property, built on the first
+    # ask: a list reads a contact's name, its N and its NICKNAME over
+    # and over as it sorts, files and labels it, and a walk of the
+    # properties for each read was most of a /contacts render. Names
+    # are ASCII (RFC 2426 section 4's iana-token and x-name), so
+    # upcasing is casecmp?'s comparison.
     #: (String name) -> Parser::Property?
     def property(name)
-      properties.find { it.name.casecmp?(name) }
+      first_by_name[name.upcase]
     end
 
     # The lines naming `name`, and the card without them: the split
@@ -275,7 +281,10 @@ module ProTacts
     #
     # A card back, like #extract's remainder: text with text put into it
     # is still text, so there is nothing here for a caller to re-read or
-    # re-judge, and one that wants the bytes asks #to_s for them.
+    # re-judge, and one that wants the bytes asks #to_s for them. A
+    # card already read hands its reading on with the new lines read
+    # into it (#read_into), since a contact composes its birthday and
+    # lent lines in this way and a list composes every contact.
     #: (Array[String] lines) -> VCard
     def insert(lines)
       return self if lines.empty?
@@ -285,11 +294,38 @@ module ProTacts
 
       eol = @bytes.byteindex("\n", index)
       neighbour = @bytes.byteslice(index, eol ? eol - index + 1 : @bytes.bytesize).to_s
-      VCard.new(@bytes.dup.bytesplice(index, 0, terminate(lines, neighbour).join))
+      inserted = terminate(lines, neighbour).join
+      VCard.new(@bytes.dup.bytesplice(index, 0, inserted), lines: (read_into(index, inserted) if defined?(@lines)))
     end
 
     private
 
+    # #property's table: each name, upcased, to the first property
+    # naming it.
+    #: () -> Hash[String, Parser::Property]
+    def first_by_name
+      return @first_by_name if defined?(@first_by_name)
+
+      first = {} #: Hash[String, Parser::Property]
+      properties.each { first[it.name.upcase] ||= it }
+      @first_by_name = first
+    end
+
+    # This card's lines with `inserted` read and put in at byte `at`,
+    # or nil where `at` starts none of them. #insert's END:VCARD starts
+    # one: `^` follows a "\n", which ends a line, and a continuation
+    # starts with whitespace, which END does not.
+    #: (Integer at, String inserted) -> Array[Parser::Line]?
+    def read_into(at, inserted)
+      start = 0
+      position = lines.index { |line|
+        found = start == at
+        start += line.verbatim.bytesize
+        found
+      }
+      position && lines.dup.insert(position, *Parser.lines(inserted))
+    end
+
     # The given lines, each with a terminator on it that has none,
     # taken from the line it lands beside — so a card written in one
     # line-break convention stays single-convention. A line that came
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index e87faa6..e182309 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -209,6 +209,12 @@ module ProTacts
       QUOTED_STRING = /"([^\x00-\x08\x0A-\x1F\x7F"]*)"/ #: Regexp
       VALUE = /[^\r\n]*/ #: Regexp
 
+      # A parameter value that is PTEXT throughout, which VCard.header_of
+      # writes bare. Built once here rather than interpolated in the
+      # method, which compiled it again for every parameter of every
+      # line a listing writes back out (Store#listed_by_id).
+      BARE = /\A#{PTEXT}\z/ #: 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.
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index f8573a3..6ebb347 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -233,4 +233,25 @@ class VCardTest < Minitest::Test
     assert card.card?
     assert_equal "aiden", card.uid
   end
+
+  # A card already read hands #insert's result its reading, which must
+  # be the one a fresh read of the bytes makes: either line break, a
+  # fold, a final break or none, lines that come with their own break
+  # and lines that take the neighbour's.
+  def test_an_inserted_cards_reading_is_a_fresh_reading
+    value = from_regex('[^\r\n\x00]{0,40}', fullmatch: true)
+    Hegel.test(test_cases: 500) do |tc|
+      eol = tc.draw(sampled_from(["\r\n", "\n"]))
+      body = tc.draw(arrays(value, max_size: 4)).map { "NOTE:#{it}#{eol}" }
+      folded = tc.draw(booleans) ? "FN:Ada#{eol} Lovelace#{eol}" : "FN:Ada Lovelace#{eol}"
+      ending = tc.draw(booleans) ? eol : ""
+      card = ProTacts::VCard.new("BEGIN:VCARD#{eol}VERSION:3.0#{eol}#{folded}#{body.join}END:VCARD#{ending}")
+      card.lines
+      inserted = tc.draw(arrays(value.map { |v| tc.draw(booleans) ? "NOTE:#{v}\r\n" : "NOTE:#{v}" }, min_size: 1, max_size: 3))
+
+      composed = card.insert(inserted)
+
+      assert_equal ProTacts::VCard::Parser.lines(composed.to_s), composed.lines
+    end
+  end
 end