/ src / lightspeed / diff.gleam
diff.gleam
  1  //// Patch model and codec for rendered updates.
  2  
  3  import gleam/int
  4  import gleam/list
  5  import gleam/string
  6  
  7  pub const patch_stream_version = 1
  8  
  9  /// Dynamic text values injected into static markup by slot name.
 10  pub type DynamicSlot {
 11    DynamicSlot(name: String, value: String)
 12  }
 13  
 14  /// Keyed nodes used for stable collection updates.
 15  pub type KeyedNode {
 16    KeyedNode(key: String, html: String)
 17  }
 18  
 19  /// Patch model.
 20  pub type Patch {
 21    Replace(target: String, html: String)
 22    Append(target: String, html: String)
 23    Prepend(target: String, html: String)
 24    Remove(target: String)
 25    ReplaceSegments(
 26      target: String,
 27      fingerprint: String,
 28      static_html: String,
 29      dynamic_slots: List(DynamicSlot),
 30    )
 31    UpdateSegments(
 32      target: String,
 33      fingerprint: String,
 34      dynamic_slots: List(DynamicSlot),
 35    )
 36    UpsertKeyed(target: String, key: String, html: String)
 37    RemoveKeyed(target: String, key: String)
 38  }
 39  
 40  /// Decode errors for patch streams.
 41  pub type DecodeError {
 42    EmptyPayload
 43    UnknownPayloadTag(String)
 44    BadFieldCount(stage: String, expected: Int, actual: Int)
 45    InvalidInteger(String)
 46    UnsupportedVersion(Int)
 47    MissingDictionaryEntry(Int)
 48    MalformedOperation(String)
 49    InvalidEscapeSequence
 50  }
 51  
 52  /// Return the target selector or id for a patch.
 53  pub fn target(patch: Patch) -> String {
 54    case patch {
 55      Replace(target, _) -> target
 56      Append(target, _) -> target
 57      Prepend(target, _) -> target
 58      Remove(target) -> target
 59      ReplaceSegments(target, _, _, _) -> target
 60      UpdateSegments(target, _, _) -> target
 61      UpsertKeyed(target, _, _) -> target
 62      RemoveKeyed(target, _) -> target
 63    }
 64  }
 65  
 66  /// Return the patch operation name.
 67  pub fn operation(patch: Patch) -> String {
 68    case patch {
 69      Replace(_, _) -> "replace"
 70      Append(_, _) -> "append"
 71      Prepend(_, _) -> "prepend"
 72      Remove(_) -> "remove"
 73      ReplaceSegments(_, _, _, _) -> "replace_segments"
 74      UpdateSegments(_, _, _) -> "update_segments"
 75      UpsertKeyed(_, _, _) -> "upsert_keyed"
 76      RemoveKeyed(_, _) -> "remove_keyed"
 77    }
 78  }
 79  
 80  /// True when a patch carries HTML content.
 81  pub fn carries_html(patch: Patch) -> Bool {
 82    case patch {
 83      Remove(_) -> False
 84      UpdateSegments(_, _, _) -> False
 85      RemoveKeyed(_, _) -> False
 86      _ -> True
 87    }
 88  }
 89  
 90  /// Build one dynamic slot value.
 91  pub fn slot(name: String, value: String) -> DynamicSlot {
 92    DynamicSlot(name: name, value: value)
 93  }
 94  
 95  /// Build one keyed node.
 96  pub fn keyed_node(key: String, html: String) -> KeyedNode {
 97    KeyedNode(key: key, html: html)
 98  }
 99  
100  /// Build a keyed update plan from previous and current collections.
101  pub fn keyed_patch_plan(
102    target: String,
103    previous: List(KeyedNode),
104    current: List(KeyedNode),
105  ) -> List(Patch) {
106    concat(
107      keyed_upserts(target, previous, current),
108      keyed_removals(target, previous, current),
109    )
110  }
111  
112  /// Encode one patch into a versioned compressed stream.
113  pub fn encode(patch: Patch) -> String {
114    encode_stream([patch])
115  }
116  
117  /// Encode a patch stream with dictionary compression.
118  pub fn encode_stream(patches: List(Patch)) -> String {
119    let dictionary = build_dictionary(patches, [])
120    let operation_fields = encode_operations(patches, dictionary, [])
121  
122    let fields =
123      concat(
124        concat(
125          concat(
126            [
127              "ps",
128              int.to_string(patch_stream_version),
129              int.to_string(list.length(dictionary)),
130            ],
131            dictionary,
132          ),
133          [int.to_string(list.length(operation_fields))],
134        ),
135        operation_fields,
136      )
137  
138    join_fields(fields)
139  }
140  
141  /// Decode one patch from a versioned compressed stream.
142  pub fn decode(payload: String) -> Result(Patch, DecodeError) {
143    case decode_stream(payload) {
144      Error(error) -> Error(error)
145      Ok(patches) ->
146        case patches {
147          [patch] -> Ok(patch)
148          _ ->
149            Error(MalformedOperation(
150              "expected_single_patch:" <> int.to_string(list.length(patches)),
151            ))
152        }
153    }
154  }
155  
156  /// Decode a patch stream.
157  pub fn decode_stream(payload: String) -> Result(List(Patch), DecodeError) {
158    case payload {
159      "" -> Error(EmptyPayload)
160      _ ->
161        case split_fields(payload) {
162          Error(error) -> Error(error)
163          Ok(fields) -> decode_fields(fields)
164        }
165    }
166  }
167  
168  /// Stable error labels for logs and adapter failures.
169  pub fn decode_error_to_string(error: DecodeError) -> String {
170    case error {
171      EmptyPayload -> "empty_payload"
172      UnknownPayloadTag(tag) -> "unknown_payload_tag:" <> tag
173      BadFieldCount(stage, expected, actual) ->
174        "bad_field_count:"
175        <> stage
176        <> ":"
177        <> int.to_string(expected)
178        <> ":"
179        <> int.to_string(actual)
180      InvalidInteger(value) -> "invalid_integer:" <> value
181      UnsupportedVersion(version) ->
182        "unsupported_version:" <> int.to_string(version)
183      MissingDictionaryEntry(index) ->
184        "missing_dictionary_entry:" <> int.to_string(index)
185      MalformedOperation(reason) -> "malformed_operation:" <> reason
186      InvalidEscapeSequence -> "invalid_escape_sequence"
187    }
188  }
189  
190  fn keyed_upserts(
191    target: String,
192    previous: List(KeyedNode),
193    current: List(KeyedNode),
194  ) -> List(Patch) {
195    case current {
196      [] -> []
197      [KeyedNode(key, html), ..rest] ->
198        case keyed_lookup(previous, key) {
199          Ok(previous_html) ->
200            case previous_html == html {
201              True -> keyed_upserts(target, previous, rest)
202              False -> [
203                UpsertKeyed(target: target, key: key, html: html),
204                ..keyed_upserts(target, previous, rest)
205              ]
206            }
207  
208          Error(Nil) -> [
209            UpsertKeyed(target: target, key: key, html: html),
210            ..keyed_upserts(target, previous, rest)
211          ]
212        }
213    }
214  }
215  
216  fn keyed_removals(
217    target: String,
218    previous: List(KeyedNode),
219    current: List(KeyedNode),
220  ) -> List(Patch) {
221    case previous {
222      [] -> []
223      [KeyedNode(key, _), ..rest] ->
224        case keyed_lookup(current, key) {
225          Ok(_) -> keyed_removals(target, rest, current)
226          Error(Nil) -> [
227            RemoveKeyed(target: target, key: key),
228            ..keyed_removals(target, rest, current)
229          ]
230        }
231    }
232  }
233  
234  fn keyed_lookup(nodes: List(KeyedNode), key: String) -> Result(String, Nil) {
235    case nodes {
236      [] -> Error(Nil)
237      [KeyedNode(node_key, html), ..rest] ->
238        case node_key == key {
239          True -> Ok(html)
240          False -> keyed_lookup(rest, key)
241        }
242    }
243  }
244  
245  fn build_dictionary(
246    patches: List(Patch),
247    dictionary: List(String),
248  ) -> List(String) {
249    case patches {
250      [] -> dictionary
251      [patch, ..rest] ->
252        build_dictionary(rest, add_all_unique(dictionary, patch_strings(patch)))
253    }
254  }
255  
256  fn patch_strings(patch: Patch) -> List(String) {
257    case patch {
258      Replace(target, html) -> [target, html]
259      Append(target, html) -> [target, html]
260      Prepend(target, html) -> [target, html]
261      Remove(target) -> [target]
262      ReplaceSegments(target, fingerprint, static_html, dynamic_slots) ->
263        concat(
264          [target, fingerprint, static_html],
265          dynamic_slot_strings(dynamic_slots),
266        )
267      UpdateSegments(target, fingerprint, dynamic_slots) ->
268        concat([target, fingerprint], dynamic_slot_strings(dynamic_slots))
269      UpsertKeyed(target, key, html) -> [target, key, html]
270      RemoveKeyed(target, key) -> [target, key]
271    }
272  }
273  
274  fn dynamic_slot_strings(dynamic_slots: List(DynamicSlot)) -> List(String) {
275    case dynamic_slots {
276      [] -> []
277      [DynamicSlot(name, value), ..rest] -> [
278        name,
279        value,
280        ..dynamic_slot_strings(rest)
281      ]
282    }
283  }
284  
285  fn add_all_unique(
286    dictionary: List(String),
287    values: List(String),
288  ) -> List(String) {
289    case values {
290      [] -> dictionary
291      [value, ..rest] -> add_all_unique(add_unique(dictionary, value), rest)
292    }
293  }
294  
295  fn add_unique(dictionary: List(String), value: String) -> List(String) {
296    case contains_string(dictionary, value) {
297      True -> dictionary
298      False -> concat(dictionary, [value])
299    }
300  }
301  
302  fn contains_string(values: List(String), target: String) -> Bool {
303    case values {
304      [] -> False
305      [value, ..rest] ->
306        case value == target {
307          True -> True
308          False -> contains_string(rest, target)
309        }
310    }
311  }
312  
313  fn encode_operations(
314    patches: List(Patch),
315    dictionary: List(String),
316    operation_fields_rev: List(String),
317  ) -> List(String) {
318    case patches {
319      [] -> list.reverse(operation_fields_rev)
320      [patch, ..rest] ->
321        encode_operations(rest, dictionary, [
322          encode_operation(patch, dictionary),
323          ..operation_fields_rev
324        ])
325    }
326  }
327  
328  fn encode_operation(patch: Patch, dictionary: List(String)) -> String {
329    case patch {
330      Replace(target, html) ->
331        join_tokens([
332          "r",
333          encode_index(dictionary, target),
334          encode_index(dictionary, html),
335        ])
336  
337      Append(target, html) ->
338        join_tokens([
339          "a",
340          encode_index(dictionary, target),
341          encode_index(dictionary, html),
342        ])
343  
344      Prepend(target, html) ->
345        join_tokens([
346          "p",
347          encode_index(dictionary, target),
348          encode_index(dictionary, html),
349        ])
350  
351      Remove(target) -> join_tokens(["x", encode_index(dictionary, target)])
352  
353      ReplaceSegments(target, fingerprint, static_html, dynamic_slots) ->
354        join_tokens(concat(
355          [
356            "s",
357            encode_index(dictionary, target),
358            encode_index(dictionary, fingerprint),
359            encode_index(dictionary, static_html),
360            int.to_string(list.length(dynamic_slots)),
361          ],
362          encode_dynamic_slot_tokens(dynamic_slots, dictionary, []),
363        ))
364  
365      UpdateSegments(target, fingerprint, dynamic_slots) ->
366        join_tokens(concat(
367          [
368            "u",
369            encode_index(dictionary, target),
370            encode_index(dictionary, fingerprint),
371            int.to_string(list.length(dynamic_slots)),
372          ],
373          encode_dynamic_slot_tokens(dynamic_slots, dictionary, []),
374        ))
375  
376      UpsertKeyed(target, key, html) ->
377        join_tokens([
378          "k",
379          encode_index(dictionary, target),
380          encode_index(dictionary, key),
381          encode_index(dictionary, html),
382        ])
383  
384      RemoveKeyed(target, key) ->
385        join_tokens([
386          "q",
387          encode_index(dictionary, target),
388          encode_index(dictionary, key),
389        ])
390    }
391  }
392  
393  fn encode_dynamic_slot_tokens(
394    dynamic_slots: List(DynamicSlot),
395    dictionary: List(String),
396    tokens_rev: List(String),
397  ) -> List(String) {
398    case dynamic_slots {
399      [] -> list.reverse(tokens_rev)
400      [DynamicSlot(name, value), ..rest] ->
401        encode_dynamic_slot_tokens(rest, dictionary, [
402          encode_index(dictionary, value),
403          encode_index(dictionary, name),
404          ..tokens_rev
405        ])
406    }
407  }
408  
409  fn encode_index(dictionary: List(String), value: String) -> String {
410    int.to_string(index_of(dictionary, value, 0))
411  }
412  
413  fn index_of(values: List(String), target: String, index: Int) -> Int {
414    case values {
415      [] -> 0
416      [value, ..rest] ->
417        case value == target {
418          True -> index
419          False -> index_of(rest, target, index + 1)
420        }
421    }
422  }
423  
424  fn decode_fields(fields: List(String)) -> Result(List(Patch), DecodeError) {
425    case fields {
426      [tag, version_text, dictionary_count_text, ..rest] ->
427        case tag {
428          "ps" ->
429            case parse_int(version_text) {
430              Error(error) -> Error(error)
431              Ok(version) ->
432                case version == patch_stream_version {
433                  False -> Error(UnsupportedVersion(version))
434                  True ->
435                    decode_dictionary_and_operations(dictionary_count_text, rest)
436                }
437            }
438  
439          _ -> Error(UnknownPayloadTag(tag))
440        }
441  
442      _ ->
443        Error(BadFieldCount(
444          stage: "stream_header",
445          expected: 3,
446          actual: list.length(fields),
447        ))
448    }
449  }
450  
451  fn decode_dictionary_and_operations(
452    dictionary_count_text: String,
453    rest_fields: List(String),
454  ) -> Result(List(Patch), DecodeError) {
455    case parse_int(dictionary_count_text) {
456      Error(error) -> Error(error)
457      Ok(dictionary_count) ->
458        case split_n(rest_fields, dictionary_count, []) {
459          Error(error) -> Error(error)
460          Ok(#(dictionary, operation_count_and_fields)) ->
461            case operation_count_and_fields {
462              [operation_count_text, ..operation_fields] ->
463                case parse_int(operation_count_text) {
464                  Error(error) -> Error(error)
465                  Ok(operation_count) ->
466                    case list.length(operation_fields) == operation_count {
467                      False ->
468                        Error(BadFieldCount(
469                          stage: "operation_fields",
470                          expected: operation_count,
471                          actual: list.length(operation_fields),
472                        ))
473                      True -> decode_operations(operation_fields, dictionary, [])
474                    }
475                }
476  
477              _ ->
478                Error(BadFieldCount(
479                  stage: "operation_count",
480                  expected: 1,
481                  actual: 0,
482                ))
483            }
484        }
485    }
486  }
487  
488  fn split_n(
489    values: List(String),
490    count: Int,
491    acc_rev: List(String),
492  ) -> Result(#(List(String), List(String)), DecodeError) {
493    case count < 0 {
494      True -> Error(MalformedOperation("negative_count"))
495      False ->
496        case count {
497          0 -> Ok(#(list.reverse(acc_rev), values))
498          _ ->
499            case values {
500              [] -> Error(MalformedOperation("insufficient_fields"))
501              [value, ..rest] -> split_n(rest, count - 1, [value, ..acc_rev])
502            }
503        }
504    }
505  }
506  
507  fn decode_operations(
508    operation_fields: List(String),
509    dictionary: List(String),
510    patches_rev: List(Patch),
511  ) -> Result(List(Patch), DecodeError) {
512    case operation_fields {
513      [] -> Ok(list.reverse(patches_rev))
514      [operation_field, ..rest] ->
515        case decode_operation(operation_field, dictionary) {
516          Error(error) -> Error(error)
517          Ok(patch) -> decode_operations(rest, dictionary, [patch, ..patches_rev])
518        }
519    }
520  }
521  
522  fn decode_operation(
523    operation_field: String,
524    dictionary: List(String),
525  ) -> Result(Patch, DecodeError) {
526    let tokens = string.split(operation_field, ",")
527  
528    case tokens {
529      ["r", target_index, html_index] ->
530        decode_binary_patch(
531          "replace",
532          target_index,
533          html_index,
534          dictionary,
535          Replace,
536        )
537  
538      ["a", target_index, html_index] ->
539        decode_binary_patch(
540          "append",
541          target_index,
542          html_index,
543          dictionary,
544          Append,
545        )
546  
547      ["p", target_index, html_index] ->
548        decode_binary_patch(
549          "prepend",
550          target_index,
551          html_index,
552          dictionary,
553          Prepend,
554        )
555  
556      ["x", target_index] ->
557        case decode_dictionary_value(target_index, dictionary) {
558          Error(error) -> Error(error)
559          Ok(target) -> Ok(Remove(target: target))
560        }
561  
562      ["k", target_index, key_index, html_index] ->
563        case decode_dictionary_value(target_index, dictionary) {
564          Error(error) -> Error(error)
565          Ok(target) ->
566            case decode_dictionary_value(key_index, dictionary) {
567              Error(error) -> Error(error)
568              Ok(key) ->
569                case decode_dictionary_value(html_index, dictionary) {
570                  Error(error) -> Error(error)
571                  Ok(html) ->
572                    Ok(UpsertKeyed(target: target, key: key, html: html))
573                }
574            }
575        }
576  
577      ["q", target_index, key_index] ->
578        case decode_dictionary_value(target_index, dictionary) {
579          Error(error) -> Error(error)
580          Ok(target) ->
581            case decode_dictionary_value(key_index, dictionary) {
582              Error(error) -> Error(error)
583              Ok(key) -> Ok(RemoveKeyed(target: target, key: key))
584            }
585        }
586  
587      [
588        "s",
589        target_index,
590        fingerprint_index,
591        static_index,
592        slot_count,
593        ..slot_tokens
594      ] ->
595        decode_segment_patch(
596          target_index,
597          fingerprint_index,
598          static_index,
599          slot_count,
600          slot_tokens,
601          dictionary,
602        )
603  
604      ["u", target_index, fingerprint_index, slot_count, ..slot_tokens] ->
605        decode_update_segment_patch(
606          target_index,
607          fingerprint_index,
608          slot_count,
609          slot_tokens,
610          dictionary,
611        )
612  
613      _ -> Error(MalformedOperation(operation_field))
614    }
615  }
616  
617  fn decode_binary_patch(
618    patch_name: String,
619    target_index: String,
620    html_index: String,
621    dictionary: List(String),
622    constructor: fn(String, String) -> Patch,
623  ) -> Result(Patch, DecodeError) {
624    case decode_dictionary_value(target_index, dictionary) {
625      Error(error) -> Error(error)
626      Ok(target) ->
627        case decode_dictionary_value(html_index, dictionary) {
628          Error(error) -> Error(error)
629          Ok(html) -> Ok(constructor(target, html))
630        }
631    }
632    |> map_patch_name_error(patch_name)
633  }
634  
635  fn map_patch_name_error(
636    result: Result(Patch, DecodeError),
637    patch_name: String,
638  ) -> Result(Patch, DecodeError) {
639    case result {
640      Ok(patch) -> Ok(patch)
641      Error(MalformedOperation(_)) -> Error(MalformedOperation(patch_name))
642      Error(error) -> Error(error)
643    }
644  }
645  
646  fn decode_segment_patch(
647    target_index: String,
648    fingerprint_index: String,
649    static_index: String,
650    slot_count_text: String,
651    slot_tokens: List(String),
652    dictionary: List(String),
653  ) -> Result(Patch, DecodeError) {
654    case decode_dictionary_value(target_index, dictionary) {
655      Error(error) -> Error(error)
656      Ok(target) ->
657        case decode_dictionary_value(fingerprint_index, dictionary) {
658          Error(error) -> Error(error)
659          Ok(fingerprint) ->
660            case decode_dictionary_value(static_index, dictionary) {
661              Error(error) -> Error(error)
662              Ok(static_html) ->
663                case
664                  decode_dynamic_slots(slot_count_text, slot_tokens, dictionary)
665                {
666                  Error(error) -> Error(error)
667                  Ok(dynamic_slots) ->
668                    Ok(ReplaceSegments(
669                      target: target,
670                      fingerprint: fingerprint,
671                      static_html: static_html,
672                      dynamic_slots: dynamic_slots,
673                    ))
674                }
675            }
676        }
677    }
678  }
679  
680  fn decode_update_segment_patch(
681    target_index: String,
682    fingerprint_index: String,
683    slot_count_text: String,
684    slot_tokens: List(String),
685    dictionary: List(String),
686  ) -> Result(Patch, DecodeError) {
687    case decode_dictionary_value(target_index, dictionary) {
688      Error(error) -> Error(error)
689      Ok(target) ->
690        case decode_dictionary_value(fingerprint_index, dictionary) {
691          Error(error) -> Error(error)
692          Ok(fingerprint) ->
693            case decode_dynamic_slots(slot_count_text, slot_tokens, dictionary) {
694              Error(error) -> Error(error)
695              Ok(dynamic_slots) ->
696                Ok(UpdateSegments(
697                  target: target,
698                  fingerprint: fingerprint,
699                  dynamic_slots: dynamic_slots,
700                ))
701            }
702        }
703    }
704  }
705  
706  fn decode_dynamic_slots(
707    slot_count_text: String,
708    slot_tokens: List(String),
709    dictionary: List(String),
710  ) -> Result(List(DynamicSlot), DecodeError) {
711    case parse_int(slot_count_text) {
712      Error(error) -> Error(error)
713      Ok(slot_count) ->
714        case list.length(slot_tokens) == slot_count * 2 {
715          False ->
716            Error(BadFieldCount(
717              stage: "dynamic_slots",
718              expected: slot_count * 2,
719              actual: list.length(slot_tokens),
720            ))
721          True -> decode_dynamic_slot_pairs(slot_tokens, dictionary, [])
722        }
723    }
724  }
725  
726  fn decode_dynamic_slot_pairs(
727    slot_tokens: List(String),
728    dictionary: List(String),
729    dynamic_slots_rev: List(DynamicSlot),
730  ) -> Result(List(DynamicSlot), DecodeError) {
731    case slot_tokens {
732      [] -> Ok(list.reverse(dynamic_slots_rev))
733  
734      [name_index, value_index, ..rest] ->
735        case decode_dictionary_value(name_index, dictionary) {
736          Error(error) -> Error(error)
737          Ok(name) ->
738            case decode_dictionary_value(value_index, dictionary) {
739              Error(error) -> Error(error)
740              Ok(value) ->
741                decode_dynamic_slot_pairs(rest, dictionary, [
742                  DynamicSlot(name: name, value: value),
743                  ..dynamic_slots_rev
744                ])
745            }
746        }
747  
748      _ -> Error(MalformedOperation("dynamic_slot_pair"))
749    }
750  }
751  
752  fn decode_dictionary_value(
753    index_text: String,
754    dictionary: List(String),
755  ) -> Result(String, DecodeError) {
756    case parse_int(index_text) {
757      Error(error) -> Error(error)
758      Ok(index) -> dictionary_value(dictionary, index)
759    }
760  }
761  
762  fn dictionary_value(
763    dictionary: List(String),
764    index: Int,
765  ) -> Result(String, DecodeError) {
766    case index < 0 {
767      True -> Error(MissingDictionaryEntry(index))
768      False ->
769        case dictionary {
770          [] -> Error(MissingDictionaryEntry(index))
771          [entry, ..rest] ->
772            case index == 0 {
773              True -> Ok(entry)
774              False -> dictionary_value(rest, index - 1)
775            }
776        }
777    }
778  }
779  
780  fn parse_int(value: String) -> Result(Int, DecodeError) {
781    case int.parse(value) {
782      Ok(parsed) -> Ok(parsed)
783      Error(_) -> Error(InvalidInteger(value))
784    }
785  }
786  
787  fn concat(left: List(a), right: List(a)) -> List(a) {
788    case left {
789      [] -> right
790      [entry, ..rest] -> [entry, ..concat(rest, right)]
791    }
792  }
793  
794  fn join_tokens(tokens: List(String)) -> String {
795    case tokens {
796      [] -> ""
797      [token, ..rest] -> join_tokens_loop(rest, token)
798    }
799  }
800  
801  fn join_tokens_loop(tokens: List(String), acc: String) -> String {
802    case tokens {
803      [] -> acc
804      [token, ..rest] -> join_tokens_loop(rest, acc <> "," <> token)
805    }
806  }
807  
808  fn join_fields(fields: List(String)) -> String {
809    case fields {
810      [] -> ""
811      [field, ..rest] -> join_fields_loop(rest, escape_field(field))
812    }
813  }
814  
815  fn join_fields_loop(fields: List(String), acc: String) -> String {
816    case fields {
817      [] -> acc
818      [field, ..rest] -> join_fields_loop(rest, acc <> "|" <> escape_field(field))
819    }
820  }
821  
822  fn escape_field(value: String) -> String {
823    escape_chars(string.to_graphemes(value), "")
824  }
825  
826  fn escape_chars(chars: List(String), acc: String) -> String {
827    case chars {
828      [] -> acc
829      [char, ..rest] ->
830        case char {
831          "\\" -> escape_chars(rest, acc <> "\\\\")
832          "|" -> escape_chars(rest, acc <> "\\|")
833          _ -> escape_chars(rest, acc <> char)
834        }
835    }
836  }
837  
838  fn split_fields(payload: String) -> Result(List(String), DecodeError) {
839    split_chars(string.to_graphemes(payload), "", [], False)
840  }
841  
842  fn split_chars(
843    chars: List(String),
844    current: String,
845    fields_rev: List(String),
846    escaped: Bool,
847  ) -> Result(List(String), DecodeError) {
848    case chars {
849      [] ->
850        case escaped {
851          True -> Error(InvalidEscapeSequence)
852          False -> Ok(list.reverse([current, ..fields_rev]))
853        }
854  
855      [char, ..rest] ->
856        case escaped {
857          True -> split_chars(rest, current <> char, fields_rev, False)
858  
859          False ->
860            case char {
861              "\\" -> split_chars(rest, current, fields_rev, True)
862              "|" -> split_chars(rest, "", [current, ..fields_rev], False)
863              _ -> split_chars(rest, current <> char, fields_rev, False)
864            }
865        }
866    }
867  }