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
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