src/sugar.js

  1. import Lens from './lens.js';
  2. import { Parser, states, actions } from '../src-cjs/tag-parser.js';
  3. const RAW_VALUE_MARK = '⦃…⦄';
  4. const MISSING_VALUE = new Error();
  5. const cacheSpaceAllocations = new Map([[null, 100]]);
  6. const lensBuilderCache = new Map();
  7. lensBuilderCache.allocated = cacheSpaceAllocations.get(null);
  8. /**
  9. * @class Sugar_CacheControl
  10. * @hideconstructor
  11. * @since 2.3.0
  12. *
  13. * @classdesc
  14. * The only instance of this class is available as {@link module:natural-lenses/sugar#cache}.
  15. */
  16. /**
  17. * @module natural-lenses/sugar
  18. * @since 2.3.0
  19. * @summary String template tag for constructing a Lens with JSONPath-like syntax
  20. * @returns {Lens} A lens constructed from the JSONPath (and intercalated values) given
  21. *
  22. * @description
  23. * This module is (when `require`d) or exports as default (when `import`ed) a
  24. * Function implementing a string template tag interpreting a subset of JSONPath
  25. * to construct a {@link Lens}. The only supported JSONPath operators
  26. * are the single dot (`.`) and the square brackets (`[...]`); all other
  27. * operators would result in a non-Lens optic. Within the square brackets,
  28. * only string literals (in single- or double-quotes), unsigned or negative
  29. * integers, and intercalated values are allowed. Use of unquoted `@` (the
  30. * JSONPath *current object/element*) in the expression is not allowed, and the
  31. * `$` (the JSONPath *root object*) is only allowed — and required — as the
  32. * first character of the expression.
  33. *
  34. * When an intercalated value is used within a subscript operator, the actual
  35. * JavaScript value — not its string representation — is used as the step in
  36. * the {@link Lens}; this allows for using [`lens.Step`]{@link Step} for
  37. * custom stepping or arbitrary JavaScript values for keys into a Map or similar
  38. * container.
  39. *
  40. * This template tag processes the raw strings used in the template (to avoid
  41. * doubling of backslashes for escape sequences); though this means a
  42. * backslash-backtick combination (since backtick by itself ends the template)
  43. * is processed as two characters, the only valid context for this to occur —
  44. * due to the JSONPath syntax — is inside a string literal within square
  45. * brackets, in which case the backslash-backtick sequence will be interpreted
  46. * as a single backtick anyway. If this causes confusion, the `\x60` escape
  47. * sequence can be used instead.
  48. *
  49. * # Examples
  50. *
  51. * ```js
  52. * const lens = require('natural-lenses'), A = require('natural-lenses/sugar');
  53. *
  54. * # Equivalent expressions
  55. *
  56. * const lensExplicit1 = lens('foo', 'bar'), lensSugar1 = A`$.foo.bar`;
  57. *
  58. * const lensExplicit2 = lens('street', 1), lensSugar2 = A`$.street[1]`;
  59. *
  60. * const marker = Symbol('marker');
  61. * const lensExplicit3 = lens('item', marker), lensSugar3 = A`$.item[${marker}]`;
  62. * ```
  63. */
  64. export default function lensFromJSONPath(stringParts, ...values) {
  65. const cacheKey = stringParts.raw.join('!');
  66. let lensBuilder = lensBuilderCache.get(cacheKey) ||
  67. lensBuilderFromTemplateStrings(stringParts.raw);
  68. // Delete before setting to implement LRU caching
  69. lensBuilderCache.delete(cacheKey);
  70. lensBuilderCache.set(cacheKey, lensBuilder);
  71. pruneLensBuilderCache();
  72. return lensBuilder(values);
  73. }
  74. const INTERCALATED_VALUE_PLACEHOLDER = Symbol('intercalated value');
  75. function lensBuilderFromTemplateStrings(stringParts) {
  76. const stringsCursor = stringParts[Symbol.iterator]();
  77. let {value: curString, done} = stringsCursor.next();
  78. if (done) {
  79. return () => new Lens();
  80. }
  81. const parser = new Parser();
  82. const steps = [], ivIndexes = [];
  83. let accum = '', charCursor = curString[Symbol.iterator](), curCharRecord;
  84. let consumed = '', captureStart;
  85. const actions = {
  86. append(ch) {
  87. if (!accum) {
  88. captureStart = consumed.length;
  89. }
  90. accum += ch;
  91. },
  92. emit_value() {
  93. steps.push(accum);
  94. accum = '';
  95. },
  96. emit_literal() {
  97. steps.push(eval(accum));
  98. accum = '';
  99. },
  100. consume_intercalated_value() {
  101. ivIndexes.push(steps.length);
  102. steps.push(INTERCALATED_VALUE_PLACEHOLDER);
  103. consumed += RAW_VALUE_MARK;
  104. curString = getNext(stringsCursor, () => {
  105. throw new Error("Too few template parts!");
  106. });
  107. charCursor = curString[Symbol.iterator]();
  108. curCharRecord = charCursor.next();
  109. parser.state = states.subscript_value_emitted;
  110. },
  111. scan() {
  112. consumed += curCharRecord.value;
  113. curCharRecord = charCursor.next();
  114. },
  115. };
  116. curCharRecord = charCursor.next();
  117. try {
  118. while(!curCharRecord.done && parser.processChar(curCharRecord.value, actions)) {
  119. if (curCharRecord.done) {
  120. parser.inputEnds(actions);
  121. }
  122. }
  123. } catch (e) {
  124. if (e !== MISSING_VALUE) throw e;
  125. }
  126. if (!parser.state) {
  127. const reducedInput = stringParts.join(RAW_VALUE_MARK),
  128. asciiArt = `\n ${reducedInput}\n ${' '.repeat(consumed.length)}^\n`;
  129. throw Object.assign(
  130. new Error("JSONPath (subset) syntax error\n" + asciiArt),
  131. { consumed, from: reducedInput }
  132. );
  133. }
  134. if (!parser.isFinal()) {
  135. const reducedInput = stringParts.join(RAW_VALUE_MARK),
  136. asciiArt = `\n\n ${reducedInput}\n ${' '.repeat(captureStart) + '^'.repeat(reducedInput.length - captureStart)}\n`;
  137. const error = new Error("Path ended prematurely!" + (
  138. accum ? asciiArt : ''
  139. ));
  140. if (accum) {
  141. error.accumulatedText = accum;
  142. }
  143. throw error;
  144. }
  145. if (!stringsCursor.next().done) {
  146. throw new Error("Too many string parts!");
  147. }
  148. return (values) => {
  149. if (values.length !== ivIndexes.length) {
  150. throw new Error(`Expected ${ivIndexes.length} values, received ${values.length}`);
  151. }
  152. const lensSteps = [...steps];
  153. ivIndexes.forEach((stepsIndex, i) => {
  154. lensSteps[stepsIndex] = values[i];
  155. });
  156. return new Lens(...lensSteps);
  157. };
  158. }
  159. function getNext(cursor, getDefault) {
  160. const { value, done } = cursor.next();
  161. if (done) {
  162. return getDefault();
  163. }
  164. return value;
  165. }
  166. function pruneLensBuilderCache() {
  167. for (const key of lensBuilderCache.keys()) {
  168. if (lensBuilderCache.size <= lensBuilderCache.allocated) {
  169. break;
  170. }
  171. lensBuilderCache.delete(key);
  172. }
  173. }
  174. /**
  175. * @member {Sugar_CacheControl} module:natural-lenses/sugar#cache
  176. * @since 2.3.0
  177. * @summary Parse cache control
  178. *
  179. * @description
  180. * This object contains methods and properties to observe and control the parse
  181. * cache for {@link module:natural-lenses/sugar}.
  182. */
  183. export const cache = {
  184. /**
  185. * @callback Sugar_CacheControl~AllocationAdjuster
  186. * @since 2.3.0
  187. * @param {number} [newSize = 0] - The new size (>= 0) for this allocation
  188. *
  189. * @description
  190. * Call this to adjust the size of the allocation (which returned this
  191. * function); setting *newSize* to 0 (the default) cancels the allocation,
  192. * though it can be reinstated by later calls to this function.
  193. */
  194. /**
  195. * @memberof Sugar_CacheControl
  196. * @since 2.3.0
  197. * @instance
  198. * @summary Create an allocation of parser cache entries
  199. * @param {Number} size - Number of cache slots to allocate
  200. * @returns {Sugar_CacheControl~AllocationAdjuster} A function to cancel or adjust the allocation
  201. *
  202. * @description
  203. * Cache allocations should be made by any package consuming this package
  204. * and making significant use of sugar syntax. It can be used to either
  205. * temporarily boost the cache size or to more permanently boost the cache
  206. * size for ongoing operations.
  207. */
  208. addCapacity(size) {
  209. size = validateCacheAllocationSize(size);
  210. const allocationKey = Symbol();
  211. cacheSpaceAllocations.set(allocationKey, size);
  212. recomputeCacheAllocation();
  213. return function adjustTo(newSize = 0) {
  214. newSize = validateCacheAllocationSize(newSize);
  215. if (newSize === 0) {
  216. cacheSpaceAllocations.delete(allocationKey);
  217. } else {
  218. cacheSpaceAllocations.set(allocationKey, newSize);
  219. }
  220. recomputeCacheAllocation();
  221. };
  222. },
  223. /**
  224. * @memberof Sugar_CacheControl
  225. * @since 2.3.0
  226. * @instance
  227. * @summary Current total of allocated cache slots
  228. * @type {number}
  229. * @readonly
  230. */
  231. get totalAllocated() {
  232. return lensBuilderCache.allocated;
  233. },
  234. /**
  235. * @memberof Sugar_CacheControl
  236. * @since 2.3.0
  237. * @instance
  238. * @summary Current number of cache slots consumed
  239. * @type {number}
  240. * @readonly
  241. */
  242. get used() {
  243. return lensBuilderCache.size;
  244. },
  245. }
  246. function recomputeCacheAllocation() {
  247. lensBuilderCache.allocated = [...cacheSpaceAllocations.values()].reduce(
  248. (r, v) => r + v,
  249. 0
  250. );
  251. if (lensBuilderCache.size > lensBuilderCache.allocated) {
  252. pruneLensBuilderCache();
  253. }
  254. }
  255. function validateCacheAllocationSize(size) {
  256. if (isNaN(size) || (size = Number(size)) < 0) {
  257. throw new Error("Cache allocation size must be a valid, non-negative number");
  258. }
  259. return size;
  260. }