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