vcard: Parser owns the whole reading half
Unfolding, the logical-line split, and both reads over it move out of
VCard, which was parsing every card twice — #lines rejoined each line
group just so Parser could unfold it and rediscover boundaries the
split already knew. Parse over the group walk preserves the
whole-card semantics (blank lines skip, a malformed line raises), so
the strict read that keeps a bad card unindexed and the tolerant one
that carries bad bytes across a rewrite now share a single splitter.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 4e7d18c..ac9e0a3 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -5,10 +5,10 @@ module ProTacts
# too. A card is Enumerable over its logical lines — each one parsed
# beside its verbatim bytes (#each and Line) — so the structured
# questions are select and reject and count, and #insert puts lines
- # back in. vCard 3.0 (RFC 2426): the line-level rules a card obeys
- # are here whichever half is being asked — Parser reads, escape and
- # fold write. The line walk is lazy for the same reason Contact's
- # parse is: the byte-moving paths never read structure.
+ # back in. vCard 3.0 (RFC 2426): escape and fold, the writer's half,
+ # are here; Parser owns the reading half, down to the line split.
+ # The line walk is lazy for the same reason Contact's parse is: the
+ # byte-moving paths never read structure.
# The signature lives in sig/pro_tacts/vcard.rbs: the generic
# Enumerable include is beyond the inline parser.
# @rbs skip
@@ -29,20 +29,6 @@ module ProTacts
"\n" => "\\n",
}.freeze #: Hash[String, String]
- # 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 parser's scanner
- # would sit on one forever, because no token can consume it.
- LINE_BREAK = /\r\n|[\r\n]/ #: Regexp
-
- # Reads like an alternation-precedence bug and is not: Ruby wraps an
- # interpolated Regexp in a non-capturing group, so this is
- # `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
- FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
-
- # A physical line that continues the one above it (RFC 2426 section
- # 2.6 folding).
- CONTINUATION = /\A[ \t]/ #: Regexp
-
# 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
@@ -135,15 +121,6 @@ module ProTacts
folded
end
- # Fold's inverse, and the first thing a parse does: RFC 2426 section
- # 2.6 has a content line unfolded before it is read. It runs over
- # the whole card rather than token by token because a fold can land
- # anywhere in a line — Contacts folds at 75 octets without regard for
- # what it splits — so there is no boundary to do it at.
- def self.unfold(card)
- card.gsub(FOLD, "")
- end
-
# A card is made of UTF-8 text, and refuses to be made of anything
# else: bytes that are not valid UTF-8 raise here, at the boundary,
# rather than leaking an ArgumentError out of whatever regex first
@@ -230,24 +207,13 @@ module ProTacts
private
- # One line group read as a property, or nil when it does not read
- # as one. The parser unfolds what it is handed, so a folded group
- # arrives as the single logical line it is.
- def parse(group)
- Parser.parse(group.join).fetch(0, nil)
- rescue ParseError
- nil
- end
-
# The card's logical lines, walked once on the first question
- # asked over them. The terminatorless trailing group the byte-level
- # split leaves behind is not a line — dropping it loses nothing,
- # an empty group joining to nothing.
+ # asked over them. Parser's split — the bytes each property came
+ # from are #each's rule, held there.
def lines
return @lines if defined?(@lines)
- groups = line_groups.reject { |group| group == [""] }
- @lines = groups.map { |group| Line.new(property: parse(group), verbatim: group.join) }
+ @lines = Parser.lines(@bytes)
end
# Physical lines with their terminators attached, so surgery on them
@@ -256,14 +222,6 @@ module ProTacts
@bytes.split(/(?<=\n)/, -1)
end
- # Logical lines: a physical line and the continuations folded under
- # it, kept together because a property and its fold are one unit.
- # Slicing before each non-continuation, rather than chunking, is
- # what attaches a continuation to the line above it.
- def line_groups
- physical_lines.slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }.to_a
- end
-
# The line break a line ends with, for the line inserted beside it
# to match. CRLF when it has none, being the grammar's own.
def terminator_of(line)
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 958c614..fe2a95f 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -5,7 +5,7 @@ require "pro_tacts/vcard"
module ProTacts
class VCard
- # Reads a stored card into VCard::Property values.
+ # Reads a stored card into VCard::Property and VCard::Line values.
#
# Deliberately shallow: a property is a name, its parameters, and the
# text of its value, and nothing here knows what any of them mean.
@@ -15,9 +15,15 @@ module ProTacts
# 6.3.2.2 requires of a CardDAV server. See
# docs/plans/2026-08-24-vcard-storage-and-groups.md.
#
- # One instance reads one card. The scanner is the whole of its state,
- # which is why it is a class and not a handful of functions passing
- # a position between them.
+ # The whole reading half of VCard lives here: unfolding, the split
+ # into logical lines, and the reads over them. One instance reads one
+ # logical line — the scanner is the whole of its state, which is why
+ # it is a class and not a handful of functions passing a position
+ # between them — and the two class-level reads differ only in
+ # strictness. parse raises on a line that will not read, which is
+ # what keeps an unparseable card served but unindexed; lines carries
+ # the same line with a nil property, which is what lets a rewrite
+ # move bytes no line could ever parse out of.
class Parser
# @rbs @scanner: StringScanner
@@ -39,20 +45,91 @@ module ProTacts
QUOTED_STRING = /"([^\x00-\x08\x0A-\x1F\x7F"]*)"/ #: Regexp
VALUE = /[^\r\n]*/ #: 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.
+ LINE_BREAK = /\r\n|[\r\n]/ #: Regexp
+
+ # Reads like an alternation-precedence bug and is not: Ruby wraps
+ # an interpolated Regexp in a non-capturing group, so this is
+ # `(?:\r\n|[\r\n])[ \t]` rather than `\r\n` or `[\r\n][ \t]`.
+ FOLD = /#{LINE_BREAK}[ \t]/ #: Regexp
+
+ # A physical line that continues the one above it (RFC 2426 section
+ # 2.6 folding).
+ CONTINUATION = /\A[ \t]/ #: Regexp
+
+ # Every content line in the card, in order. Blank lines are
+ # skipped, and BEGIN, VERSION, and END come back like any other
+ # property: deciding that a card is well-formed is not this class's
+ # job. Raises on the first line that will not read, so a caller
+ # rescuing ParseError declines the whole card at once.
#: (String card) -> Array[Property]
def self.parse(card)
- new(card).parse
+ logical_lines(card).flat_map { new(it).parse }
end
- #: (String card) -> void
- def initialize(card)
- @scanner = StringScanner.new(VCard.unfold(card))
+ # The card's logical lines, each parsed beside the exact bytes it
+ # came from, folds and terminator included. A line that will not
+ # read is a fact about the line, not an error: the property is nil
+ # and the bytes stay enumerable.
+ #: (String card) -> Array[Line]
+ def self.lines(card)
+ logical_lines(card).map { |line| Line.new(property: property_of(line), verbatim: line) }
end
- # Every content line in the card, in order. Blank lines are
- # skipped, and BEGIN, VERSION, and END come back like any other
- # property: deciding that a card is well-formed is not this class's
- # job.
+ # VCard.fold's inverse, and the first thing a read does: RFC 2426
+ # section 2.6 has a content line unfolded before it is read. It
+ # runs over the whole line rather than token by token because a
+ # fold can land anywhere in it — Contacts folds at 75 octets
+ # without regard for what it splits — so there is no boundary to
+ # do it at.
+ #: (String line) -> String
+ def self.unfold(line)
+ line.gsub(FOLD, "")
+ end
+
+ # One logical line read as its first property, or nil when it does
+ # not read as one — blank, or rejected by the grammar.
+ #: (String line) -> Property?
+ def self.property_of(line)
+ new(line).parse.fetch(0, nil)
+ rescue ParseError
+ nil
+ end
+
+ # The card's logical lines: a physical line and the continuations
+ # 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.
+ #: (String card) -> Array[String]
+ def self.logical_lines(card)
+ physical_lines(card)
+ .slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }
+ .map(&:join)
+ .reject { it == "" }
+ end
+
+ # Physical lines with their terminators attached, so the split
+ # cannot lose or normalize a line break.
+ #: (String card) -> Array[String]
+ def self.physical_lines(card)
+ card.split(/(?<=\n)/, -1)
+ end
+
+ private_class_method :property_of, :logical_lines, :physical_lines
+
+ #: (String logical_line) -> void
+ def initialize(logical_line)
+ @scanner = StringScanner.new(self.class.unfold(logical_line))
+ end
+
+ # Every content line in this logical line, in order — usually one,
+ # though a lone CR can end a content line mid-physical-line, and a
+ # blank line has none.
#: () -> Array[Property]
def parse
properties = [] #: Array[Property]
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
index 2d5ad8e..2d25568 100644
--- a/sig/pro_tacts/vcard.rbs
+++ b/sig/pro_tacts/vcard.rbs
@@ -8,9 +8,6 @@ module ProTacts
LINE_LIMIT: Integer
TEXT_ESCAPES: Hash[String, String]
- LINE_BREAK: Regexp
- FOLD: Regexp
- CONTINUATION: Regexp
END_LINE: Regexp
class ParseError < StandardError
@@ -40,18 +37,12 @@ module ProTacts
def self.fold: (String line) -> String
- def self.unfold: (String card) -> String
-
private
- def parse: (Array[String] group) -> Property?
-
def lines: () -> Array[Line]
def physical_lines: () -> Array[String]
- def line_groups: () -> Array[Array[String]]
-
def terminator_of: (String line) -> String
class Property
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 0093235..278ea0e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -3,6 +3,7 @@ require_relative "../test_helper"
require "hegel"
require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
# The writer's half of the module: nothing serves through escape and
# fold yet, and a PUT or the web editor will.
@@ -78,46 +79,20 @@ class VCardTest < Minitest::Test
value = tc.draw(text(max_size: 300))
line = "NOTE:#{ProTacts::VCard.escape(value)}"
- raise "unfold did not reverse fold" unless ProTacts::VCard.unfold(ProTacts::VCard.fold(line)) == line
+ raise "unfold did not reverse fold" unless ProTacts::VCard::Parser.unfold(ProTacts::VCard.fold(line)) == line
end
end
## The envelope
- # What a PUT checks before storing: RFC 2426 section 4 has a card
- # begin and end itself and declare a version, and lines that parse
- # into properties are not yet a card.
- CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
-
- def test_properties_with_the_envelope_are_a_card
- assert ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(CARD))
- end
-
- def test_no_properties_are_not_a_card
- refute ProTacts::VCard.card?([])
- end
-
- def test_each_piece_of_the_envelope_is_required
- no_begin = "VERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
- no_end = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\n"
- no_version = "BEGIN:VCARD\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
-
- [no_begin, no_end, no_version].each do |card|
- refute ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(card)), card
- end
- end
-
+ # The envelope tests proper live in vcard/test_lines.rb, over the
+ # card; this one covers what they do not — the names compare without
+ # case.
def test_envelope_names_compare_without_case
lowercase = "begin:vcard\r\nversion:3.0\r\nfn:Aiden\r\nuid:aiden\r\nend:vcard\r\n"
- properties = ProTacts::VCard::Parser.parse(lowercase)
-
- assert ProTacts::VCard.card?(properties)
- assert_equal "aiden", ProTacts::VCard.uid(properties)
- end
-
- def test_a_card_without_a_uid_has_none
- card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
+ card = ProTacts::VCard.new(lowercase)
- assert_nil ProTacts::VCard.uid(ProTacts::VCard::Parser.parse(card))
+ assert card.card?
+ assert_equal "aiden", card.uid
end
end
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 6554027..233a860 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -145,6 +145,47 @@ class VCardParserTest < Minitest::Test
assert_equal "abcd", parse(card("NOTE:ab", "\tcd")).first.value
end
+ ## Lines
+
+ # lines pairs each property with the exact bytes it came from — the
+ # fold travels with its line, and the verbatims join back to the card.
+ def test_lines_pair_properties_with_verbatim_bytes
+ card = "BEGIN:VCARD\r\nBDAY:1985-04-\r\n 12\r\nEND:VCARD\r\n"
+ lines = ProTacts::VCard::Parser.lines(card)
+
+ assert_equal %w[BEGIN BDAY END], lines.map { it.property&.name }
+ assert_equal "1985-04-12", lines.fetch(1).property&.value
+ assert_equal "BDAY:1985-04-\r\n 12\r\n", lines.fetch(1).verbatim
+ assert_equal card, lines.map(&:verbatim).join
+ end
+
+ def test_a_line_that_will_not_read_has_no_property_but_keeps_its_bytes
+ line = ProTacts::VCard::Parser.lines("BDAY;=;:\r\n").fetch(0)
+
+ assert_nil line.property
+ assert_equal "BDAY;=;:\r\n", line.verbatim
+ end
+
+ # The two reads' strictness split, pinned: parse declines the whole
+ # card, lines carries the same line with a nil property.
+ def test_parse_raises_where_lines_carries
+ card = "FN:A\r\nBDAY;=;:\r\n"
+
+ assert_raises(ProTacts::VCard::ParseError) { parse(card) }
+ assert_nil ProTacts::VCard::Parser.lines(card).fetch(1).property
+ end
+
+ # A blank line is a line — its bytes stay enumerable, so a rewrite
+ # carrying lines does not drop them — while parse skips it, as the
+ # grammar lets a card carry blank lines between properties.
+ def test_a_blank_line_stays_a_line_but_parses_as_none
+ lines = ProTacts::VCard::Parser.lines("FN:A\r\n\r\nUID:b\r\n")
+
+ assert_equal ["FN:A\r\n", "\r\n", "UID:b\r\n"], lines.map(&:verbatim)
+ assert_nil lines.fetch(1).property
+ assert_equal %w[FN UID], parse("FN:A\r\n\r\nUID:b\r\n").map { it.name }
+ end
+
## Refusals
def test_a_line_without_a_colon_raises