vcard: splice inserted lines in rather than splitting the card
Composing a card with a birthday or a lent line split the whole card
into physical lines with a lookbehind regex, three times the card's
size in garbage, and a photo makes a card 80KB. Insert now finds the
last END:VCARD line in place, and the parser splits with String#lines.
Line#names? keeps the pattern it builds for each name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
change
commit fde64f11bf0ff9fe126cedbcde99f68db1bfc8f8
author Claude <noreply@anthropic.com>
date
parent 4e216a7e
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 310151c..b74691c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -19,8 +19,10 @@ module ProTacts
     }.freeze #: Hash[String, String]
 
     # 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
+    # lands inside the envelope however bare the card is. Matched at
+    # the start of a physical line in the whole card, so #insert finds
+    # it without splitting a card's every line out to look.
+    END_LINE = /^END:VCARD/i #: Regexp
 
     # Text values escape backslash, the component separator, and the
     # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
@@ -237,12 +239,12 @@ module ProTacts
     def insert(lines)
       return self if lines.empty?
 
-      physical = Parser.physical_lines(@bytes)
-      index = physical.rindex { it.match?(END_LINE) }
+      index = @bytes.byterindex(END_LINE)
       return VCard.new(@bytes + terminate(lines).join) if index.nil?
 
-      physical.insert(index, *terminate(lines, physical.fetch(index)))
-      VCard.new(physical.join)
+      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))
     end
 
     private
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 9d2be7e..2d160af 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -133,13 +133,20 @@ module ProTacts
 
       # Reopened for the reason Property is.
       class Line
+        # #names?' pattern for each name, built on its first ask: the
+        # names are the code's own and the group schema's (ADR, NOTE),
+        # a handful, and a pattern built per call was most of what a
+        # listing of contacts allocated.
+        NAME_PATTERNS = {} #: Hash[String, 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.
         #: (String name) -> bool
         def names?(name)
-          verbatim.match?(/\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i)
+          pattern = NAME_PATTERNS[name] ||= /\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i
+          verbatim.match?(pattern)
         end
 
         # Whether this line broke one of the parser's assumptions rather
@@ -254,23 +261,14 @@ module ProTacts
       # 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.
+      # continuation to the line above it. The physical lines keep their
+      # terminators (String#lines), so no split of a card can lose or
+      # normalize a line break.
       #: (String card) -> Array[String]
       def self.logical_lines(card)
-        physical_lines(card)
+        card.lines
           .slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }
           .map(&:join)
-          .reject { it == "" }
-      end
-
-      # Physical lines with their terminators attached, so no split of
-      # a card can lose or normalize a line break. Public for VCard,
-      # whose surgery on a card's bytes wants the same split.
-      #: (String card) -> Array[String]
-      def self.physical_lines(card)
-        card.split(/(?<=\n)/, -1)
       end
 
       private_class_method :line_of, :logical_lines
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 3150ddf..c735519 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -300,6 +300,16 @@ class VCardLinesTest < Minitest::Test
       VCARD.new("not a card\r\n").insert(["BDAY:1985-04\r\n"]).to_s
   end
 
+  # Only a physical line opening on END:VCARD anchors, and the last of
+  # them: the same text inside a value or a fold's continuation is not
+  # the end.
+  def test_insert_anchors_on_the_last_line_that_opens_on_end
+    card = "BEGIN:VCARD\r\nNOTE:END:VCARD\r\n END:VCARD\r\nend:vcard"
+
+    assert_equal "BEGIN:VCARD\r\nNOTE:END:VCARD\r\n END:VCARD\r\nBDAY:1985-04\r\nend:vcard",
+      VCARD.new(card).insert(["BDAY:1985-04"]).to_s
+  end
+
   def test_insert_with_nothing_leaves_the_card_alone
     assert_equal CARD, VCARD.new(CARD).insert([]).to_s
   end