rawKeyValue.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import { not } from "fp-ts/Predicate"
  2. import { pipe, flow } from "fp-ts/function"
  3. import * as Str from "fp-ts/string"
  4. import * as RA from "fp-ts/ReadonlyArray"
  5. import * as A from "fp-ts/Array"
  6. import * as O from "fp-ts/Option"
  7. import * as E from "fp-ts/Either"
  8. import * as P from "parser-ts/Parser"
  9. import * as S from "parser-ts/string"
  10. import * as C from "parser-ts/char"
  11. import { recordUpdate } from "./utils/record"
  12. /**
  13. * Special characters in the Raw Key Value Grammar
  14. */
  15. const SPECIAL_CHARS = ["#", ":"] as const
  16. export type RawKeyValueEntry = {
  17. key: string
  18. value: string
  19. active: boolean
  20. }
  21. /* Beginning of Parser Definitions */
  22. const wsSurround = P.surroundedBy(S.spaces)
  23. const stringArrayJoin = (sep: string) => (input: string[]) => input.join(sep)
  24. const stringTakeUntilChars = (chars: C.Char[]) => pipe(
  25. P.takeUntil((c: C.Char) => chars.includes(c)),
  26. P.map(stringArrayJoin("")),
  27. )
  28. const stringTakeUntilCharsInclusive = flow(
  29. stringTakeUntilChars,
  30. P.chainFirst(() => P.sat(() => true)),
  31. )
  32. const quotedString = pipe(
  33. S.doubleQuotedString,
  34. P.map((x) => JSON.parse(`"${x}"`))
  35. )
  36. const key = pipe(
  37. wsSurround(quotedString),
  38. P.alt(() =>
  39. pipe(
  40. stringTakeUntilChars([":", "\n"]),
  41. P.map(Str.trim)
  42. )
  43. )
  44. )
  45. const value = pipe(
  46. wsSurround(quotedString),
  47. P.alt(() =>
  48. pipe(
  49. stringTakeUntilChars(["\n"]),
  50. P.map(Str.trim)
  51. )
  52. )
  53. )
  54. const commented = pipe(
  55. S.maybe(S.string("#")),
  56. P.map(not(Str.isEmpty))
  57. )
  58. const line = pipe(
  59. wsSurround(commented),
  60. P.bindTo("commented"),
  61. P.bind("key", () => wsSurround(key)),
  62. P.chainFirst(() => C.char(":")),
  63. P.bind("value", () => value),
  64. )
  65. const lineWithNoColon = pipe(
  66. wsSurround(commented),
  67. P.bindTo("commented"),
  68. P.bind("key", () => P.either(
  69. stringTakeUntilCharsInclusive(["\n"]),
  70. () => pipe(
  71. P.manyTill(P.sat((_: string) => true), P.eof()),
  72. P.map(flow(
  73. RA.toArray,
  74. stringArrayJoin("")
  75. ))
  76. )
  77. )),
  78. P.map(flow(
  79. O.fromPredicate(({ key }) => !Str.isEmpty(key))
  80. ))
  81. )
  82. const file = pipe(
  83. P.manyTill(wsSurround(line), P.eof()),
  84. )
  85. /**
  86. * This Raw Key Value parser ignores the key value pair (no colon) issues
  87. */
  88. const tolerantFile = pipe(
  89. P.manyTill(
  90. P.either(
  91. pipe(line, P.map(O.some)),
  92. () => pipe(
  93. lineWithNoColon,
  94. P.map(flow(
  95. O.map((a) => ({ ...a, value: "" }))
  96. ))
  97. )
  98. ),
  99. P.eof()
  100. ),
  101. P.map(flow(
  102. RA.filterMap(flow(
  103. O.fromPredicate(O.isSome),
  104. O.map((a) => a.value)
  105. ))
  106. ))
  107. )
  108. /* End of Parser Definitions */
  109. /**
  110. * Detect whether the string needs to have escape characters in raw key value strings
  111. * @param input The string to check against
  112. */
  113. const stringNeedsEscapingForRawKVString = (input: string) => {
  114. // If there are any of our special characters, it needs to be escaped definitely
  115. if (SPECIAL_CHARS.some((x) => input.includes(x)))
  116. return true
  117. // The theory behind this impl is that if we apply JSON.stringify on a string
  118. // it does escaping and then return a JSON string representation.
  119. // We remove the quotes of the JSON and see if it can be matched against the input string
  120. const stringified = JSON.stringify(input)
  121. const y = stringified
  122. .substring(1, stringified.length - 1)
  123. .trim()
  124. return y !== input
  125. }
  126. /**
  127. * Applies Raw Key Value escaping (via quotes + escape chars) if needed
  128. * @param input The input to apply escape on
  129. * @returns If needed, the escaped string, else the input string itself
  130. */
  131. const applyEscapeIfNeeded = (input: string) =>
  132. stringNeedsEscapingForRawKVString(input)
  133. ? JSON.stringify(input)
  134. : input
  135. /**
  136. * Converts Raw Key Value Entries to the file string format
  137. * @param entries The entries array
  138. * @returns The entries in string format
  139. */
  140. export const rawKeyValueEntriesToString = (entries: RawKeyValueEntry[]) =>
  141. pipe(
  142. entries,
  143. A.map(
  144. flow(
  145. recordUpdate("key", applyEscapeIfNeeded),
  146. recordUpdate("value", applyEscapeIfNeeded),
  147. ({ key, value, active }) =>
  148. active ? `${(key)}: ${value}` : `# ${key}: ${value}`
  149. )
  150. ),
  151. stringArrayJoin("\n")
  152. )
  153. /**
  154. * Parses raw key value entries string to array
  155. * @param s The file string to parse from
  156. * @returns Either the parser fail result or the raw key value entries
  157. */
  158. export const parseRawKeyValueEntriesE = (s: string) =>
  159. pipe(
  160. tolerantFile,
  161. S.run(s),
  162. E.mapLeft((err) => ({
  163. message: `Expected ${err.expected.map((x) => `'${x}'`).join(", ")}`,
  164. expected: err.expected,
  165. pos: err.input.cursor,
  166. })),
  167. E.map(
  168. ({ value }) => pipe(
  169. value,
  170. RA.map(({ key, value, commented }) =>
  171. <RawKeyValueEntry>{
  172. active: !commented,
  173. key,
  174. value
  175. }
  176. )
  177. )
  178. )
  179. )
  180. /**
  181. * Less error tolerating version of `parseRawKeyValueEntriesE`
  182. * @param s The file string to parse from
  183. * @returns Either the parser fail result or the raw key value entries
  184. */
  185. export const strictParseRawKeyValueEntriesE = (s: string) =>
  186. pipe(
  187. file,
  188. S.run(s),
  189. E.mapLeft((err) => ({
  190. message: `Expected ${err.expected.map((x) => `'${x}'`).join(", ")}`,
  191. expected: err.expected,
  192. pos: err.input.cursor,
  193. })),
  194. E.map(
  195. ({ value }) => pipe(
  196. value,
  197. RA.map(({ key, value, commented }) =>
  198. <RawKeyValueEntry>{
  199. active: !commented,
  200. key,
  201. value
  202. }
  203. )
  204. )
  205. )
  206. )
  207. /**
  208. * Kept for legacy code compatibility, parses raw key value entries.
  209. * If failed, it returns an empty array
  210. * @deprecated Use `parseRawKeyValueEntriesE` instead
  211. */
  212. export const parseRawKeyValueEntries = flow(
  213. parseRawKeyValueEntriesE,
  214. E.map(RA.toArray),
  215. E.getOrElse(() => [] as RawKeyValueEntry[])
  216. )