1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
require "digest"
require "strscan"

require "pro_tacts/vcard"

module ProTacts
  class VCard
    # Reads a card's bytes into Lines.
    #
    # Deliberately shallow: a property is a name, its parameters, and
    # the text of its value, and nothing here knows what any of them
    # mean. That is not a shortcut — a property this code has never
    # heard of has to travel through untouched (RFC 6352 section
    # 6.3.2.2, and docs/plans/2026-08-24-vcard-storage-and-groups.md
    # for why the stored card is the authority and this only a
    # projection of it).
    #
    # The whole reading half of VCard lives here — unfolding, the split
    # into logical lines, the reads over them, and the values they come
    # back as — and it has one entry point: lines. 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.
    #
    # A line that will not read is a Line like any other, carrying the
    # error rather than raising it, and no read here raises on a card's
    # behalf. Nothing above handles one either: VCard and Contact answer
    # from the lines that read, the index holds what this server
    # understood, and a rewrite moves the bytes regardless. The error
    # rides along for one reader, ProTacts::Web, which reports a broken
    # assumption at the arrival that carried it.
    class Parser
      # @rbs @scanner: StringScanner

      # A line whose bytes are not a content line at all. Never raised
      # past #lines, which catches it and hands it back on the Line it
      # happened to: what a line failing to read costs is that line's
      # index rows, and the card is served from its bytes regardless.
      class ParseError < StandardError; end

      # Something this parser was built to assume it would never read. Not
      # a judgment on the card: the assumptions are about the Apple
      # clients this address book serves (test/fixtures/macos-exchange is
      # the evidence for each), and a card can break one while being a
      # perfectly good vCard. Raising is how the assumption gets checked
      # instead of merely held.
      #
      # A ParseError so it rides the same rails as any line that will
      # not read — the bytes are still served, index rows are what is
      # lost — and its own class so ProTacts::Web can report it at the
      # arrival, a wrong assumption being news rather than ordinary bad
      # input.
      class BrokenAssumption < ParseError; end

      # One parsed content line.
      #
      # `parameters` is a list of name-and-value pairs rather than a hash
      # because a parameter can repeat, and RFC 2426 section 3.3.1 makes
      # `TYPE=work;TYPE=voice` and `TYPE=work,voice` two spellings of the
      # same thing — both arrive here as two pairs.
      #
      # `value` is the value's text, unfolded and otherwise exactly as it
      # was stored, still escaped — the one representation it keeps, a
      # served card being these bytes going back out unparsed. #text and
      # #components are the two readings of it, derived on demand rather
      # than stored beside it: which reading applies depends on the
      # property's value type, which this layer does not know.
      #
      # The signature lives in sig/pro_tacts/vcard/parser.rbs: a Data
      # class has no constant super class for the inline syntax to read.
      # @rbs skip
      Property = Data.define(:group, :name, :parameters, :value)

      # Reopened rather than defined in the block above: steep reads a
      # define block's self as this class's, not the constant's, so the
      # methods could not live there.
      class Property
        # The whole value unescaped (RFC 2426 section 2.4.2), for a
        # property whose value is free text. A structured value read this
        # way comes back with its separators melted into the text, which
        # is why the choice is the caller's.
        #: () -> String
        def text = VCard.unescape(value)

        # The value's components, for a structured one (RFC 2426 section
        # 3.2.1). Splitting before unescaping is the whole point of
        # keeping `value` escaped: an escaped "\;" stays inside its
        # component instead of ending it.
        #: () -> Array[String]
        def components = VCard.split_components(value)

        # Names match without case (RFC 2426 section 4), and a
        # repeat arrives as two pairs (see above) — so this is the
        # first of them, and a caller wanting both reads `parameters`.
        #: (String name) -> String?
        def parameter(name)
          parameters
            .filter_map { |parameter, value| value if parameter.casecmp?(name) }
            .first
        end

        # The TYPE values naming a kind of line (RFC 2426 section
        # 3.3.1), casefolded because the spelling is the card's and
        # every reader wants one. `pref` is not one of them: it ranks a
        # line rather than naming a kind, and the client adds it on its
        # own (docs/apple-contacts.md, "The client rewrites every card
        # it touches").
        #: () -> Array[String]
        def types
          parameters
            .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
            .uniq - ["pref"]
        end
      end

      # One logical line of a card, parsed beside the exact bytes that
      # carried it: `property` is the content line it read, nil when it
      # read none (a blank line, or one that would not read), `error` the
      # ParseError the failure raised, nil when it read, and `verbatim`
      # the line itself, folds and terminator included, so what moves
      # between cards moves unchanged.
      #
      # One property, not a list of them: a logical line can only hold a
      # second content line when a bare CR ends the first, and that is
      # refused outright (BrokenAssumption). That is what lets a caller
      # move a line's bytes knowing it moves exactly one property.
      #
      # The signature lives in sig/pro_tacts/vcard/parser.rbs with
      # Property's.
      # @rbs skip
      Line = Data.define(:property, :verbatim, :error)

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

        # A fold before the line's first `;` or `:`, inside its group or
        # name, where the bytes alone would hide the name the parser reads.
        HEAD_FOLD = /\A[^;:]*?(?:\r\n|[\r\n])[ \t]/ #: 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. A fold is no part of the name (RFC 2426 section
        # 2.6), so a line folded inside it is read unfolded — only that
        # line, the rest matching on their bytes without allocating, a
        # folded PHOTO's base64 included.
        #: (String name) -> bool
        def names?(name)
          pattern = NAME_PATTERNS[name] ||= /\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i
          return true if verbatim.match?(pattern)

          verbatim.match?(HEAD_FOLD) && Parser.unfold(verbatim).match?(pattern)
        end

        # Whether this line broke one of the parser's assumptions rather
        # than merely failing to read. Asked here so no caller has to
        # know the error taxonomy to tell the two apart.
        #: () -> bool
        def broke_assumption? = error.is_a?(BrokenAssumption)

        # Whether this line failed to read without breaking an
        # assumption: ordinary bad input, against the news a broken
        # assumption is. Asked here for broke_assumption?'s reason.
        #: () -> bool
        def unreadable? = !error.nil? && !broke_assumption?

        # The line's address for a save that names it, over the
        # verbatim bytes, folds and terminator included — why a digest
        # and not the bytes is docs/plans/2026-09-05-web-card-editor.md.
        # Blind to one thing: identical bytes digest alike, which is
        # the one pair of lines no address can tell apart.
        #: () -> String
        def digest = Digest::SHA256.hexdigest(verbatim)

        # The line as a group holds one: unfolded and shorn of its
        # terminator, the unit CardDiff records a write in and the
        # shape db/migrations/004_groups.rb's own rows are written in.
        # The bytes are the ones that arrived, not a re-render under an
        # old header — a relabel is one of the edits a member's save
        # carries, and rebuilding the header would propagate the value
        # and drop the label
        # (docs/plans/2026-09-09-group-edits-propagate.md, "What a
        # propagated edit stores").
        #: () -> String
        def content = Parser.unfold(verbatim).sub(/#{LINE_BREAK}\z/, "")
      end

      # The content line, RFC 2426 section 4:
      #
      #   contentline = [group "."] name *(";" param) ":" value CRLF
      #   group       = 1*(ALPHA / DIGIT / "-")
      #   name        = iana-token / x-name
      #   param       = param-name "=" param-value *("," param-value)
      #
      # A group, a property name, and a parameter name are all the same
      # token. SAFE-CHAR — an unquoted parameter value — is anything but
      # a control character, DQUOTE, ";", ":", or ","; QSAFE-CHAR, inside
      # quotes, allows everything but the controls and DQUOTE;
      # VALUE-CHAR is everything that is not a line break.
      TOKEN = /[A-Za-z0-9-]+/ #: Regexp
      GROUP = /#{TOKEN}(?=\.)/ #: Regexp
      PTEXT = /[^\x00-\x08\x0A-\x1F\x7F";:,]*/ #: Regexp
      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.
      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 card's logical lines, each parsed beside the exact bytes it
      # came from, folds and terminator included — the one walk every
      # read of a card derives from. Blank lines are lines, and BEGIN,
      # VERSION, and END read like any other property: deciding that a
      # card is well-formed is not this class's job. A line that will
      # not read is a fact about the line, not an error: the error it
      # raised rides on it, and the bytes stay enumerable.
      #: (String card) -> Array[Line]
      def self.lines(card)
        logical_lines(card).map { |logical_line| line_of(logical_line) }
      end

      # The first thing a read does: RFC 2426 section 2.6 has a content
      # line unfolded before it is read. It stands alone, with no folder
      # opposite it — a served card is stored bytes going out untouched,
      # so nothing here has a logical line to fold. It runs over the
      # whole line rather than token by token because the RFC puts no
      # constraint on where a fold lands — mid-token and mid-escape
      # included — so there is no boundary to do it at.
      #
      # The recorded macOS session folds nothing: it sent a 443-octet
      # X-ADDRESSING-GRAMMAR line and an 81-octet ADR unbroken, both
      # past the 75-octet limit, with no continuation in the card
      # (test/fixtures/macos-exchange/10-put-contact-edit). The PHOTO
      # bodies that would settle the other half stayed unpromoted
      # (test/photo_card.rb builds their shapes) and they settle it
      # both ways: a picture's payload arrives folded, a space then 76
      # octets a continuation, while its parameter section arrives not
      # folded at all — 1,683 octets on one line for a memoji. A whole-
      # line unfold reads both without caring which it was given.
      #: (String line) -> String
      def self.unfold(line)
        line.gsub(FOLD, "")
      end

      # A blank line is neither a property nor an error.
      #: (String logical_line) -> Line
      def self.line_of(logical_line)
        Line.new(property: new(logical_line).parse, verbatim: logical_line, error: nil)
      rescue ParseError => error
        Line.new(property: nil, verbatim: logical_line, error:)
      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 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)
        card.lines
          .slice_when { |_line, next_line| !next_line.match?(CONTINUATION) }
          .map(&:join)
      end

      private_class_method :line_of, :logical_lines

      #: (String logical_line) -> void
      def initialize(logical_line)
        @scanner = StringScanner.new(self.class.unfold(logical_line))
      end

      # The one content line this logical line carries, or nil when it
      # carries none — a blank line reads as nothing.
      #
      # A second content line means a bare CR ended the first (see
      # LINE_BREAK), and that is refused rather than read: it is a line
      # ending no recorded macOS session sends, and reading it would
      # leave a property whose bytes cannot move without taking another
      # property's along.
      #: () -> Property?
      def parse
        nil while @scanner.skip(LINE_BREAK)
        return nil if @scanner.eos?

        property = content_line
        # Line breaks with nothing between them are no second content
        # line, after the first as before it: `NOTE:a\r\r\n` carries one.
        nil while @scanner.skip(LINE_BREAK)
        return property if @scanner.eos?

        raise BrokenAssumption, "a bare CR packed a second content line into one line #{here}"
      end

      private

      #: () -> Property
      def content_line
        group = @scanner.scan(GROUP)
        @scanner.skip(/\./) if group

        name = @scanner.scan(TOKEN)
        raise ParseError, "expected a property name #{here}" if name.nil?

        parameters = [] #: Array[[String, String]]
        parameters.concat(parameter) while @scanner.skip(/;/)

        raise ParseError, "expected ':' after #{name} #{here}" unless @scanner.skip(/:/)

        value = @scanner.scan(VALUE).to_s
        @scanner.skip(LINE_BREAK)

        Property.new(group:, name:, parameters:, value:)
      end

      # One `;`-delimited parameter, as a pair per value.
      #: () -> Array[[String, String]]
      def parameter
        name = @scanner.scan(TOKEN)
        raise ParseError, "expected a parameter name #{here}" if name.nil?

        # vCard 2.1 let a parameter be written as a bare value with its
        # name left implicit. The 3.0 grammar does not, and guessing
        # which parameter was meant is the kind of invention that loses
        # data.
        raise ParseError, "parameter #{name} has no value #{here}" unless @scanner.skip(/=/)

        values = [parameter_value]
        values << parameter_value while @scanner.skip(/,/)
        values.map {
          [name, it] #: [String, String]
        }
      end

      # The quotes around a quoted-string are syntax, so what comes back
      # is what they contained.
      #: () -> String
      def parameter_value
        return @scanner[1].to_s if @scanner.scan(QUOTED_STRING)

        @scanner.scan(PTEXT).to_s
      end

      #: () -> String
      def here
        "at offset #{@scanner.pos}"
      end
    end
  end
end