solve-engine 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/{Lexer-D9l4Yrk2.d.ts → Lexer-Cfm79Dak.d.cts} +18 -2
  2. package/dist/{Lexer-BJdhlnej.d.cts → Lexer-W9MBOP0V.d.ts} +18 -2
  3. package/dist/{PackageRegistry-DaH4aIKP.d.ts → PackageRegistry-CjDt-Jy_.d.ts} +60 -4
  4. package/dist/{PackageRegistry-D-Tv_7ca.d.cts → PackageRegistry-pHtKythi.d.cts} +60 -4
  5. package/dist/{Parselet-CK8cQu2a.d.cts → Parselet-Cu0bLSis.d.cts} +1 -1
  6. package/dist/{Parselet-ConOIdRO.d.ts → Parselet-DEdF9I7n.d.ts} +1 -1
  7. package/dist/{Token-0jpvUdPY.d.cts → Token-BzG5G4ja.d.cts} +44 -0
  8. package/dist/{Token-0jpvUdPY.d.ts → Token-BzG5G4ja.d.ts} +44 -0
  9. package/dist/TokenNormalizer-DGVa24Q-.d.cts +377 -0
  10. package/dist/TokenNormalizer-t_GotBxr.d.ts +377 -0
  11. package/dist/{chunk-HWSZJQCI.js → chunk-2CS6OMZK.js} +37 -15
  12. package/dist/chunk-2CS6OMZK.js.map +1 -0
  13. package/dist/{chunk-XJCMXB2E.cjs → chunk-2MV4HBKC.cjs} +187 -61
  14. package/dist/chunk-2MV4HBKC.cjs.map +1 -0
  15. package/dist/{chunk-J73SJHR3.cjs → chunk-2NPS5DQ3.cjs} +558 -341
  16. package/dist/chunk-2NPS5DQ3.cjs.map +1 -0
  17. package/dist/{chunk-QWY3VEZN.js → chunk-3MNKQZ77.js} +65 -12
  18. package/dist/chunk-3MNKQZ77.js.map +1 -0
  19. package/dist/{chunk-RZCWSXTA.cjs → chunk-43G3JT2E.cjs} +1491 -218
  20. package/dist/chunk-43G3JT2E.cjs.map +1 -0
  21. package/dist/{chunk-WQTTOGXC.cjs → chunk-4AVD7NZW.cjs} +4 -4
  22. package/dist/{chunk-WQTTOGXC.cjs.map → chunk-4AVD7NZW.cjs.map} +1 -1
  23. package/dist/{chunk-HTXVVJRA.cjs → chunk-4CVLFLOB.cjs} +112 -2
  24. package/dist/chunk-4CVLFLOB.cjs.map +1 -0
  25. package/dist/{chunk-5YEMOYSE.js → chunk-6GCKCWLB.js} +9 -3
  26. package/dist/chunk-6GCKCWLB.js.map +1 -0
  27. package/dist/{chunk-EBSPLUW4.cjs → chunk-AHCLWAM5.cjs} +28 -10
  28. package/dist/chunk-AHCLWAM5.cjs.map +1 -0
  29. package/dist/{chunk-KVILKGMS.js → chunk-AJA6LUI7.js} +36 -2
  30. package/dist/chunk-AJA6LUI7.js.map +1 -0
  31. package/dist/{chunk-EHAHVROS.cjs → chunk-CLVQBF5C.cjs} +5 -5
  32. package/dist/{chunk-EHAHVROS.cjs.map → chunk-CLVQBF5C.cjs.map} +1 -1
  33. package/dist/{chunk-34RRD7PC.js → chunk-CYFK5SY2.js} +111 -3
  34. package/dist/chunk-CYFK5SY2.js.map +1 -0
  35. package/dist/{chunk-M5LX5AOO.js → chunk-DDQVZGLO.js} +25 -12
  36. package/dist/chunk-DDQVZGLO.js.map +1 -0
  37. package/dist/{chunk-LR7YASZF.cjs → chunk-EAAHVJ4P.cjs} +3 -3
  38. package/dist/chunk-EAAHVJ4P.cjs.map +1 -0
  39. package/dist/{chunk-3PPFLFH4.js → chunk-FIFQPKBA.js} +1417 -144
  40. package/dist/chunk-FIFQPKBA.js.map +1 -0
  41. package/dist/{chunk-RIN643A3.js → chunk-GPPLSM2Z.js} +61 -2
  42. package/dist/chunk-GPPLSM2Z.js.map +1 -0
  43. package/dist/{chunk-4QADQTWS.js → chunk-KHJUSHFU.js} +231 -14
  44. package/dist/chunk-KHJUSHFU.js.map +1 -0
  45. package/dist/{chunk-NMD5VRN4.cjs → chunk-M4F66R4O.cjs} +74 -73
  46. package/dist/chunk-M4F66R4O.cjs.map +1 -0
  47. package/dist/{chunk-6NTVRDQV.cjs → chunk-M4Q47KHF.cjs} +126 -73
  48. package/dist/chunk-M4Q47KHF.cjs.map +1 -0
  49. package/dist/{chunk-LIPPNDBE.js → chunk-M5E34VG5.js} +3 -3
  50. package/dist/{chunk-LIPPNDBE.js.map → chunk-M5E34VG5.js.map} +1 -1
  51. package/dist/{chunk-53B6KDDJ.cjs → chunk-MBNQVDVC.cjs} +38 -3
  52. package/dist/{chunk-53B6KDDJ.cjs.map → chunk-MBNQVDVC.cjs.map} +1 -1
  53. package/dist/{chunk-SDGRK7EP.js → chunk-MEOHSQEH.js} +6 -5
  54. package/dist/chunk-MEOHSQEH.js.map +1 -0
  55. package/dist/{chunk-NMCRQP3Z.cjs → chunk-MTX2KVU7.cjs} +71 -70
  56. package/dist/chunk-MTX2KVU7.cjs.map +1 -0
  57. package/dist/{chunk-QNJ4ACRT.cjs → chunk-NJTXJ5AG.cjs} +37 -15
  58. package/dist/chunk-NJTXJ5AG.cjs.map +1 -0
  59. package/dist/{chunk-C4XZV6E7.cjs → chunk-NZFKROS7.cjs} +26 -20
  60. package/dist/chunk-NZFKROS7.cjs.map +1 -0
  61. package/dist/{chunk-64W6GLLZ.js → chunk-OFXOTECC.js} +24 -6
  62. package/dist/chunk-OFXOTECC.js.map +1 -0
  63. package/dist/{chunk-3YNVWKR2.cjs → chunk-OUHT5Z36.cjs} +28 -5
  64. package/dist/chunk-OUHT5Z36.cjs.map +1 -0
  65. package/dist/{chunk-EIGTWK5N.js → chunk-OWRHDUJD.js} +3 -3
  66. package/dist/chunk-OWRHDUJD.js.map +1 -0
  67. package/dist/{chunk-4MG4XKO2.js → chunk-PCPX42KL.js} +38 -3
  68. package/dist/{chunk-4MG4XKO2.js.map → chunk-PCPX42KL.js.map} +1 -1
  69. package/dist/{chunk-GW32KPCU.cjs → chunk-R24DI24X.cjs} +61 -2
  70. package/dist/chunk-R24DI24X.cjs.map +1 -0
  71. package/dist/{chunk-XVWCOTR6.js → chunk-SFQWJMKT.js} +7 -6
  72. package/dist/chunk-SFQWJMKT.js.map +1 -0
  73. package/dist/{chunk-6BKTCEUP.cjs → chunk-TY3TLZAW.cjs} +36 -2
  74. package/dist/chunk-TY3TLZAW.cjs.map +1 -0
  75. package/dist/{chunk-DM3LMRBC.js → chunk-UKPGSAZW.js} +187 -61
  76. package/dist/chunk-UKPGSAZW.js.map +1 -0
  77. package/dist/{chunk-NH2O2AUR.js → chunk-WC5FFSHB.js} +26 -4
  78. package/dist/chunk-WC5FFSHB.js.map +1 -0
  79. package/dist/{chunk-CLL7RUQV.cjs → chunk-WWOLFXTX.cjs} +40 -18
  80. package/dist/chunk-WWOLFXTX.cjs.map +1 -0
  81. package/dist/{chunk-JBSYC7BB.cjs → chunk-X2BWAZAU.cjs} +66 -53
  82. package/dist/chunk-X2BWAZAU.cjs.map +1 -0
  83. package/dist/{chunk-GOLDJNMZ.js → chunk-XBTEO4OB.js} +28 -5
  84. package/dist/chunk-XBTEO4OB.js.map +1 -0
  85. package/dist/{chunk-EPOXXJBK.js → chunk-YU2CUNFO.js} +3 -3
  86. package/dist/{chunk-EPOXXJBK.js.map → chunk-YU2CUNFO.js.map} +1 -1
  87. package/dist/constants.cjs +4 -4
  88. package/dist/constants.js +1 -1
  89. package/dist/engine.cjs +29 -30
  90. package/dist/engine.d.cts +6 -6
  91. package/dist/engine.d.ts +6 -6
  92. package/dist/engine.js +19 -20
  93. package/dist/format.cjs +7 -8
  94. package/dist/format.cjs.map +1 -1
  95. package/dist/format.js +2 -3
  96. package/dist/format.js.map +1 -1
  97. package/dist/index.cjs +28 -29
  98. package/dist/index.cjs.map +1 -1
  99. package/dist/index.d.cts +6 -6
  100. package/dist/index.d.ts +6 -6
  101. package/dist/index.js +20 -21
  102. package/dist/index.js.map +1 -1
  103. package/dist/language.cjs +74 -12
  104. package/dist/language.cjs.map +1 -1
  105. package/dist/language.d.cts +6 -6
  106. package/dist/language.d.ts +6 -6
  107. package/dist/language.js +68 -6
  108. package/dist/language.js.map +1 -1
  109. package/dist/lexer.cjs +19 -19
  110. package/dist/lexer.d.cts +3 -3
  111. package/dist/lexer.d.ts +3 -3
  112. package/dist/lexer.js +7 -7
  113. package/dist/normalizer.cjs +12 -12
  114. package/dist/normalizer.d.cts +4 -210
  115. package/dist/normalizer.d.ts +4 -210
  116. package/dist/normalizer.js +6 -6
  117. package/dist/packages.cjs +36 -37
  118. package/dist/packages.d.cts +5 -47
  119. package/dist/packages.d.ts +5 -47
  120. package/dist/packages.js +13 -14
  121. package/dist/parser.cjs +12 -12
  122. package/dist/parser.d.cts +3 -2
  123. package/dist/parser.d.ts +3 -2
  124. package/dist/parser.js +4 -4
  125. package/dist/resolvers.d.cts +1 -1
  126. package/dist/resolvers.d.ts +1 -1
  127. package/dist/uom.cjs +14 -14
  128. package/dist/uom.d.cts +12 -4
  129. package/dist/uom.d.ts +12 -4
  130. package/dist/uom.js +4 -4
  131. package/dist/vm.cjs +17 -17
  132. package/dist/vm.js +6 -6
  133. package/package.json +2 -1
  134. package/dist/NormalizerRule-BrVoVjmP.d.cts +0 -163
  135. package/dist/NormalizerRule-CEjf1FyD.d.ts +0 -163
  136. package/dist/chunk-34RRD7PC.js.map +0 -1
  137. package/dist/chunk-3PPFLFH4.js.map +0 -1
  138. package/dist/chunk-3YNVWKR2.cjs.map +0 -1
  139. package/dist/chunk-4QADQTWS.js.map +0 -1
  140. package/dist/chunk-5YEMOYSE.js.map +0 -1
  141. package/dist/chunk-64W6GLLZ.js.map +0 -1
  142. package/dist/chunk-6BKTCEUP.cjs.map +0 -1
  143. package/dist/chunk-6NTVRDQV.cjs.map +0 -1
  144. package/dist/chunk-C4XZV6E7.cjs.map +0 -1
  145. package/dist/chunk-CLL7RUQV.cjs.map +0 -1
  146. package/dist/chunk-DM3LMRBC.js.map +0 -1
  147. package/dist/chunk-EBSPLUW4.cjs.map +0 -1
  148. package/dist/chunk-EIGTWK5N.js.map +0 -1
  149. package/dist/chunk-GOLDJNMZ.js.map +0 -1
  150. package/dist/chunk-GW32KPCU.cjs.map +0 -1
  151. package/dist/chunk-HTXVVJRA.cjs.map +0 -1
  152. package/dist/chunk-HWSZJQCI.js.map +0 -1
  153. package/dist/chunk-J73SJHR3.cjs.map +0 -1
  154. package/dist/chunk-JBSYC7BB.cjs.map +0 -1
  155. package/dist/chunk-KVILKGMS.js.map +0 -1
  156. package/dist/chunk-LR7YASZF.cjs.map +0 -1
  157. package/dist/chunk-M5LX5AOO.js.map +0 -1
  158. package/dist/chunk-NH2O2AUR.js.map +0 -1
  159. package/dist/chunk-NMCRQP3Z.cjs.map +0 -1
  160. package/dist/chunk-NMD5VRN4.cjs.map +0 -1
  161. package/dist/chunk-OT6OJY7C.cjs +0 -114
  162. package/dist/chunk-OT6OJY7C.cjs.map +0 -1
  163. package/dist/chunk-QNJ4ACRT.cjs.map +0 -1
  164. package/dist/chunk-QWY3VEZN.js.map +0 -1
  165. package/dist/chunk-RFYD5TJE.js +0 -111
  166. package/dist/chunk-RFYD5TJE.js.map +0 -1
  167. package/dist/chunk-RIN643A3.js.map +0 -1
  168. package/dist/chunk-RZCWSXTA.cjs.map +0 -1
  169. package/dist/chunk-SDGRK7EP.js.map +0 -1
  170. package/dist/chunk-XJCMXB2E.cjs.map +0 -1
  171. package/dist/chunk-XVWCOTR6.js.map +0 -1
@@ -1,17 +1,18 @@
1
- import { BindingPower, BytecodeBuilder } from './chunk-5YEMOYSE.js';
2
- import { createFusedToken, isInsideRangeContext } from './chunk-64W6GLLZ.js';
1
+ import { BindingPower, BytecodeBuilder } from './chunk-6GCKCWLB.js';
2
+ import { createFusedToken, isInsideRangeContext } from './chunk-OFXOTECC.js';
3
3
  import { stripQuotes } from './chunk-PFSDGGYL.js';
4
- import { CurrencyAsyncResolver, nextInstruction } from './chunk-EPOXXJBK.js';
4
+ import { CurrencyAsyncResolver, nextInstruction } from './chunk-YU2CUNFO.js';
5
5
  import { getActiveQueryClient } from './chunk-NLNDMWZC.js';
6
- import { allocatePluginFunctionIndex, sharedGlobalVariableStore, adjustForInflation, CPI_MIN_YEAR, CPI_MAX_YEAR } from './chunk-4QADQTWS.js';
6
+ import { allocatePluginFunctionIndex, sharedGlobalVariableStore, adjustForInflation, CPI_MIN_YEAR, CPI_MAX_YEAR } from './chunk-KHJUSHFU.js';
7
7
  import { createTimeoutSignal } from './chunk-Z4X2RWOF.js';
8
- import { resolveCurrencyAlias } from './chunk-RFYD5TJE.js';
8
+ import { resolveCurrencyAlias } from './chunk-CYFK5SY2.js';
9
9
  import { errorValue, numberValue, stringValue, boolValue, uomValue, datetimeValue, timecodeUnit, ValueType } from './chunk-BVAR5J2F.js';
10
- import { getMeasure, convertUnit } from './chunk-NH2O2AUR.js';
11
- import { LexerToken } from './chunk-XVWCOTR6.js';
12
- import { tokenTypeId } from './chunk-RIN643A3.js';
13
- import { getLocale } from './chunk-GOLDJNMZ.js';
14
- import { isKnownUnit } from './chunk-LIPPNDBE.js';
10
+ import { getMeasure, convertUnit } from './chunk-WC5FFSHB.js';
11
+ import { LexerToken } from './chunk-SFQWJMKT.js';
12
+ import { tokenTypeId } from './chunk-GPPLSM2Z.js';
13
+ import { getLocale } from './chunk-XBTEO4OB.js';
14
+ import { isKnownUnit } from './chunk-M5E34VG5.js';
15
+ import { UNIT_TABLE } from './chunk-PCPX42KL.js';
15
16
  import { ErrorFactory } from './chunk-6RDSQURE.js';
16
17
 
17
18
  // src/packages/arithmetic/parselets/NumberParselet.ts
@@ -124,6 +125,19 @@ var SUFFIX_MAGNITUDE = {
124
125
  B: 9,
125
126
  T: 12
126
127
  };
128
+ var WORD_MAGNITUDE = {
129
+ thousand: 3,
130
+ thousands: 3,
131
+ million: 6,
132
+ millions: 6,
133
+ mn: 6,
134
+ billion: 9,
135
+ billions: 9,
136
+ bn: 9,
137
+ trillion: 12,
138
+ trillions: 12,
139
+ tn: 12
140
+ };
127
141
  var PLAIN_DECIMAL = /^\d+(\.\d+)?$/;
128
142
  function scaleDecimalString(raw, magnitude) {
129
143
  const dotIdx = raw.indexOf(".");
@@ -147,8 +161,8 @@ function largeNumberSuffixNormalizerRule(priority = 65) {
147
161
  const suffixToken = tokens[pos + 1];
148
162
  if (!suffixToken) return null;
149
163
  if (suffixToken.type !== "IDENT" && suffixToken.type !== "UNIT") return null;
150
- if (numberToken.offset + numberToken.text.length !== suffixToken.offset) return null;
151
- const magnitude = SUFFIX_MAGNITUDE[suffixToken.value];
164
+ const adjacent = numberToken.offset + numberToken.text.length === suffixToken.offset;
165
+ const magnitude = adjacent ? SUFFIX_MAGNITUDE[suffixToken.value] ?? WORD_MAGNITUDE[suffixToken.value.toLowerCase()] : WORD_MAGNITUDE[suffixToken.value.toLowerCase()];
152
166
  if (magnitude === void 0) return null;
153
167
  const scaled = scaleDecimalString(numberToken.text, magnitude);
154
168
  return {
@@ -177,6 +191,10 @@ var ARITHMETIC_PACKAGE = {
177
191
  ],
178
192
  infixParselets: [
179
193
  { tokenType: "PLUS", parselet: new BinaryOpParselet(BindingPower.Sum, 20 /* ADD */) },
194
+ // The word "and", which adds exactly like "+" but at a looser binding
195
+ // power so a phrase parselet can use it as a list separator. See
196
+ // Token.ts's AND_CONJ comment for why it is not simply mapped to PLUS.
197
+ { tokenType: "AND_CONJ", parselet: new BinaryOpParselet(BindingPower.Conjunction, 20 /* ADD */) },
180
198
  { tokenType: "MINUS", parselet: new BinaryOpParselet(BindingPower.Sum, 21 /* SUB */) },
181
199
  { tokenType: "STAR", parselet: new BinaryOpParselet(BindingPower.Product, 22 /* MUL */) },
182
200
  { tokenType: "SLASH", parselet: new BinaryOpParselet(BindingPower.Product, 23 /* DIV */) },
@@ -205,9 +223,167 @@ var PercentParselet = class {
205
223
  builder.emitOpcode(10 /* PUSH_NUMBER */);
206
224
  builder.emitNumber(100);
207
225
  builder.emitOpcode(23 /* DIV */);
226
+ builder.emitOpcode(74 /* TO_PERCENTAGE */);
227
+ }
228
+ };
229
+
230
+ // src/packages/percentage/parselets/IsWhatParselet.ts
231
+ var IsWhatParselet = class {
232
+ constructor() {
233
+ this.category = "Percentage";
234
+ // Below arithmetic, so the whole expression to the left is the value being
235
+ // asked about: `10 + 10 is what % of 200` asks about 20.
236
+ this.bindingPower = BindingPower.Conditional;
237
+ }
238
+ parse(parser, _left, _token, builder) {
239
+ if (nextWordIs(parser, "what")) {
240
+ parser.consume();
241
+ this.parseWhatRate(parser, builder);
242
+ return;
243
+ }
244
+ this.parseWhatBase(parser, builder);
245
+ }
246
+ /**
247
+ * `<part> is what % of|off|on <base>`, solving for the rate.
248
+ */
249
+ parseWhatRate(parser, builder) {
250
+ if (!parser.match("PERCENT")) {
251
+ throw ErrorFactory.parsing(
252
+ "IS_WHAT_EXPECTED_PERCENT",
253
+ 'Expected "%" after "is what", as in "20 is what % of 200"'
254
+ );
255
+ }
256
+ const preposition = readPreposition(parser);
257
+ if (preposition === null) {
258
+ builder.emitOpcode(74 /* TO_PERCENTAGE */);
259
+ return;
260
+ }
261
+ parser.parseExpression(BindingPower.Conditional, builder);
262
+ builder.emitOpcode(23 /* DIV */);
263
+ if (preposition === "off") {
264
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
265
+ builder.emitNumber(1);
266
+ builder.emitOpcode(2 /* SWAP */);
267
+ builder.emitOpcode(21 /* SUB */);
268
+ } else if (preposition === "on") {
269
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
270
+ builder.emitNumber(1);
271
+ builder.emitOpcode(21 /* SUB */);
272
+ }
273
+ builder.emitOpcode(74 /* TO_PERCENTAGE */);
274
+ }
275
+ /**
276
+ * `<part> is <rate> of|off|on what`, solving for the base.
277
+ */
278
+ parseWhatBase(parser, builder) {
279
+ parser.parseExpression(BindingPower.Conditional, builder);
280
+ if (parser.match("TO")) {
281
+ expectWord(parser, "what", '"81 is 9 to what power"');
282
+ expectWord(parser, "power", '"81 is 9 to what power"');
283
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
284
+ builder.emitIndex(LOG_BUILTIN);
285
+ builder.emitIndex(1);
286
+ builder.emitOpcode(2 /* SWAP */);
287
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
288
+ builder.emitIndex(LOG_BUILTIN);
289
+ builder.emitIndex(1);
290
+ builder.emitOpcode(2 /* SWAP */);
291
+ builder.emitOpcode(23 /* DIV */);
292
+ return;
293
+ }
294
+ const preposition = parser.match("OF_WHAT") ? "of" : parser.match("OFF_WHAT") ? "off" : parser.match("ON_WHAT") ? "on" : null;
295
+ if (preposition === null) {
296
+ throw ErrorFactory.parsing(
297
+ "IS_WHAT_EXPECTED_PREPOSITION",
298
+ 'Expected "of what", "off what" or "on what", as in "20 is 10% of what"'
299
+ );
300
+ }
301
+ if (preposition === "of") {
302
+ builder.emitOpcode(23 /* DIV */);
303
+ return;
304
+ }
305
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
306
+ builder.emitNumber(1);
307
+ if (preposition === "off") {
308
+ builder.emitOpcode(2 /* SWAP */);
309
+ builder.emitOpcode(21 /* SUB */);
310
+ } else {
311
+ builder.emitOpcode(20 /* ADD */);
312
+ }
313
+ builder.emitOpcode(23 /* DIV */);
314
+ }
315
+ };
316
+ var LOG_BUILTIN = 5;
317
+ function nextWordIs(parser, word) {
318
+ return (parser.peek()?.text ?? "").toLowerCase() === word;
319
+ }
320
+ function expectWord(parser, word, example) {
321
+ if (!nextWordIs(parser, word)) {
322
+ throw ErrorFactory.parsing(
323
+ "IS_WHAT_EXPECTED_WORD",
324
+ `Expected "${word}" here, as in ${example}`
325
+ );
326
+ }
327
+ parser.consume();
328
+ }
329
+ function readPreposition(parser) {
330
+ if (parser.match("OF")) return "of";
331
+ const word = (parser.peek()?.text ?? "").toLowerCase();
332
+ if (word === "off" || word === "on") {
333
+ parser.consume();
334
+ return word;
335
+ }
336
+ return null;
337
+ }
338
+
339
+ // src/packages/percentage/parselets/OnOffBaseParselet.ts
340
+ var OnOffBaseParselet = class {
341
+ /** @param sign - `1` for a markup (`on`), `-1` for a discount (`off`). */
342
+ constructor(sign) {
343
+ this.sign = sign;
344
+ this.category = "Percentage";
345
+ this.bindingPower = BindingPower.Conditional;
346
+ }
347
+ parse(parser, _left, _token, builder) {
348
+ parser.parseExpression(BindingPower.Conditional, builder);
349
+ builder.emitOpcode(2 /* SWAP */);
350
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
351
+ builder.emitNumber(1);
352
+ if (this.sign === 1) {
353
+ builder.emitOpcode(20 /* ADD */);
354
+ } else {
355
+ builder.emitOpcode(2 /* SWAP */);
356
+ builder.emitOpcode(21 /* SUB */);
357
+ }
358
+ builder.emitOpcode(22 /* MUL */);
208
359
  }
209
360
  };
210
361
 
362
+ // src/packages/percentage/normalizer/PercentOnOffNormalizerRule.ts
363
+ function percentOnOffNormalizerRule(priority = 68) {
364
+ return {
365
+ name: "percentage:on-off",
366
+ priority,
367
+ match(tokens, pos) {
368
+ if (tokens[pos]?.type !== "PERCENT") return null;
369
+ const word = tokens[pos + 1];
370
+ if (word?.type !== "IDENT") return null;
371
+ const text = (word.text ?? word.value ?? "").toLowerCase();
372
+ if (text !== "on" && text !== "off") return null;
373
+ const after = (tokens[pos + 2]?.text ?? "").toLowerCase();
374
+ if (after === "what") return null;
375
+ return {
376
+ consumed: 2,
377
+ replacement: [
378
+ tokens[pos],
379
+ createFusedToken(text === "on" ? "PCT_ON" : "PCT_OFF", text, [word])
380
+ ],
381
+ ruleName: "percentage:on-off"
382
+ };
383
+ }
384
+ };
385
+ }
386
+
211
387
  // src/packages/percentage/parselets/OfParselet.ts
212
388
  var OfParselet = class {
213
389
  constructor() {
@@ -326,11 +502,22 @@ var PERCENTAGE_PACKAGE = {
326
502
  name: "solve-percentage",
327
503
  phrases: {
328
504
  "of what is": "OF_WHAT_IS",
505
+ // Fused so that parsing the rate cannot swallow the "of": it is an infix
506
+ // operator in its own right ("10% of 200"), so a sub-expression would
507
+ // consume it and the trailing "what" before this parselet ever looked.
508
+ "of what": "OF_WHAT",
509
+ "off what": "OFF_WHAT",
510
+ "on what": "ON_WHAT",
329
511
  "on what is": "ON_WHAT_IS",
330
512
  "off what is": "OFF_WHAT_IS"
331
513
  },
332
514
  infixParselets: [
333
515
  { tokenType: "PERCENT", parselet: new PercentParselet() },
516
+ { tokenType: "IS", parselet: new IsWhatParselet() },
517
+ // "10% on 200" is 220 and "10% off 200" is 180: the percentage comes
518
+ // first, the base second. The reverse of "200 + 10%".
519
+ { tokenType: "PCT_ON", parselet: new OnOffBaseParselet(1) },
520
+ { tokenType: "PCT_OFF", parselet: new OnOffBaseParselet(-1) },
334
521
  { tokenType: "OF", parselet: new OfParselet() },
335
522
  { tokenType: "OF_WHAT_IS", parselet: new OfWhatIsParselet() },
336
523
  { tokenType: "ON_WHAT_IS", parselet: new OnOffWhatIsParselet(1) },
@@ -342,6 +529,9 @@ var PERCENTAGE_PACKAGE = {
342
529
  prefixParselets: [
343
530
  { tokenType: "INCREASE", parselet: new IncreaseDecreaseParselet(1) },
344
531
  { tokenType: "DECREASE", parselet: new IncreaseDecreaseParselet(-1) }
532
+ ],
533
+ normalizerRules: [
534
+ percentOnOffNormalizerRule()
345
535
  ]
346
536
  };
347
537
 
@@ -368,6 +558,13 @@ var builtinNameToIndex = {
368
558
  arcsin: 11,
369
559
  arccos: 12,
370
560
  arctan: 13,
561
+ // Degree-taking variants (VMBuiltins.ts 87-92).
562
+ sind: 87,
563
+ cosd: 88,
564
+ tand: 89,
565
+ asind: 90,
566
+ acosd: 91,
567
+ atand: 92,
371
568
  sinh: 15,
372
569
  cosh: 16,
373
570
  tanh: 17,
@@ -560,18 +757,18 @@ function parseIso8601(s) {
560
757
  }
561
758
  function formatIso8601Local(epochMs) {
562
759
  const d = new Date(epochMs);
563
- const pad = (n) => String(n).padStart(2, "0");
760
+ const pad3 = (n) => String(n).padStart(2, "0");
564
761
  const year = d.getFullYear();
565
- const month = pad(d.getMonth() + 1);
566
- const day = pad(d.getDate());
567
- const hours = pad(d.getHours());
568
- const minutes = pad(d.getMinutes());
569
- const seconds = pad(d.getSeconds());
762
+ const month = pad3(d.getMonth() + 1);
763
+ const day = pad3(d.getDate());
764
+ const hours = pad3(d.getHours());
765
+ const minutes = pad3(d.getMinutes());
766
+ const seconds = pad3(d.getSeconds());
570
767
  const offsetMinutesTotal = -d.getTimezoneOffset();
571
768
  const sign = offsetMinutesTotal >= 0 ? "+" : "-";
572
769
  const absOffset = Math.abs(offsetMinutesTotal);
573
- const offsetHours = pad(Math.floor(absOffset / 60));
574
- const offsetMinutes = pad(absOffset % 60);
770
+ const offsetHours = pad3(Math.floor(absOffset / 60));
771
+ const offsetMinutes = pad3(absOffset % 60);
575
772
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${sign}${offsetHours}:${offsetMinutes}`;
576
773
  }
577
774
  var MS_TIMESTAMP_THRESHOLD = 1e12;
@@ -762,7 +959,7 @@ var DurationBetweenParselet = class {
762
959
  parse(parser, token, builder) {
763
960
  const unit = token.value;
764
961
  parser.parseExpression(BindingPower.Product, builder);
765
- parser.consume("PLUS");
962
+ parser.consume("AND_CONJ");
766
963
  parser.parseExpression(BindingPower.Lowest, builder);
767
964
  builder.emitOpcode(50 /* CALL_PLUGIN */);
768
965
  builder.emitIndex(SPAN_BETWEEN_FN_IDX);
@@ -1006,6 +1203,201 @@ function dateLiteralNormalizerRule() {
1006
1203
  };
1007
1204
  }
1008
1205
 
1206
+ // src/packages/datetime/normalizer/MonthNameDateNormalizerRule.ts
1207
+ var MONTHS = {
1208
+ january: 1,
1209
+ jan: 1,
1210
+ february: 2,
1211
+ feb: 2,
1212
+ march: 3,
1213
+ mar: 3,
1214
+ april: 4,
1215
+ apr: 4,
1216
+ may: 5,
1217
+ june: 6,
1218
+ jun: 6,
1219
+ july: 7,
1220
+ jul: 7,
1221
+ august: 8,
1222
+ aug: 8,
1223
+ september: 9,
1224
+ sep: 9,
1225
+ sept: 9,
1226
+ october: 10,
1227
+ oct: 10,
1228
+ november: 11,
1229
+ nov: 11,
1230
+ december: 12,
1231
+ dec: 12
1232
+ };
1233
+ function monthOf(token) {
1234
+ if (token === void 0) return 0;
1235
+ if (token.type !== "IDENT" && token.type !== "UNIT") return 0;
1236
+ return MONTHS[(token.text ?? token.value ?? "").toLowerCase()] ?? 0;
1237
+ }
1238
+ var PLAIN_INTEGER2 = /^\d+$/;
1239
+ function looksLikeYear(digits) {
1240
+ return digits.length === 4;
1241
+ }
1242
+ function monthNameDateNormalizerRule(priority = 64) {
1243
+ return {
1244
+ name: "datetime:month-name-date",
1245
+ priority,
1246
+ match(tokens, pos) {
1247
+ const RULE = "datetime:month-name-date";
1248
+ const first = tokens[pos];
1249
+ if (first?.type === "NUMBER" && PLAIN_INTEGER2.test(first.text ?? "")) {
1250
+ const month2 = monthOf(tokens[pos + 1]);
1251
+ if (month2 === 0) return null;
1252
+ const day2 = Number(first.text);
1253
+ if (day2 < 1 || day2 > 31) return null;
1254
+ const yearToken2 = tokens[pos + 2];
1255
+ if (yearToken2?.type === "NUMBER" && PLAIN_INTEGER2.test(yearToken2.text ?? "") && looksLikeYear(yearToken2.text ?? "")) {
1256
+ return buildDateToken(day2, month2, Number(yearToken2.text), tokens.slice(pos, pos + 3), RULE);
1257
+ }
1258
+ return buildDateToken(day2, month2, (/* @__PURE__ */ new Date()).getFullYear(), tokens.slice(pos, pos + 2), RULE);
1259
+ }
1260
+ const month = monthOf(first);
1261
+ if (month === 0) return null;
1262
+ const second = tokens[pos + 1];
1263
+ if (second?.type !== "NUMBER" || !PLAIN_INTEGER2.test(second.text ?? "")) return null;
1264
+ if (looksLikeYear(second.text ?? "")) {
1265
+ return buildDateToken(1, month, Number(second.text), tokens.slice(pos, pos + 2), RULE);
1266
+ }
1267
+ const day = Number(second.text);
1268
+ const comma = tokens[pos + 2];
1269
+ const yearToken = tokens[pos + 3];
1270
+ if (comma?.type === "COMMA" && yearToken?.type === "NUMBER" && PLAIN_INTEGER2.test(yearToken.text ?? "") && looksLikeYear(yearToken.text ?? "")) {
1271
+ return buildDateToken(day, month, Number(yearToken.text), tokens.slice(pos, pos + 4), RULE);
1272
+ }
1273
+ if (comma?.type === "NUMBER" && PLAIN_INTEGER2.test(comma.text ?? "") && looksLikeYear(comma.text ?? "")) {
1274
+ return buildDateToken(day, month, Number(comma.text), tokens.slice(pos, pos + 3), RULE);
1275
+ }
1276
+ return buildDateToken(day, month, (/* @__PURE__ */ new Date()).getFullYear(), tokens.slice(pos, pos + 2), RULE);
1277
+ }
1278
+ };
1279
+ }
1280
+
1281
+ // src/packages/datetime/parselets/DaysInPeriodParselet.ts
1282
+ var DAYS_COUNT_BUILTIN = 93;
1283
+ var QUARTERS = {
1284
+ q1: [1, 3],
1285
+ q2: [4, 6],
1286
+ q3: [7, 9],
1287
+ q4: [10, 12]
1288
+ };
1289
+ function daysInMonth(year, month) {
1290
+ return new Date(year, month, 0).getDate();
1291
+ }
1292
+ function daysInYear(year) {
1293
+ return daysInMonth(year, 2) === 29 ? 366 : 365;
1294
+ }
1295
+ var DaysInPeriodParselet = class {
1296
+ constructor() {
1297
+ this.category = "Datetime";
1298
+ }
1299
+ parse(parser, _token, builder) {
1300
+ const total = this.readPeriodDays(parser);
1301
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
1302
+ builder.emitNumber(total);
1303
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
1304
+ builder.emitIndex(DAYS_COUNT_BUILTIN);
1305
+ builder.emitIndex(1);
1306
+ }
1307
+ /** The number of days in the period written after `days in`. */
1308
+ readPeriodDays(parser) {
1309
+ const next = parser.peek();
1310
+ const word = (next?.text ?? next?.value ?? "").toLowerCase();
1311
+ const quarter = QUARTERS[word];
1312
+ if (quarter !== void 0) {
1313
+ parser.consume();
1314
+ const year2 = this.readOptionalYear(parser) ?? (/* @__PURE__ */ new Date()).getFullYear();
1315
+ let total = 0;
1316
+ for (let month = quarter[0]; month <= quarter[1]; month++) {
1317
+ total += daysInMonth(year2, month);
1318
+ }
1319
+ return total;
1320
+ }
1321
+ if (next?.type === "DATETIME_LITERAL") {
1322
+ parser.consume();
1323
+ const date = new Date(Number(next.value));
1324
+ return daysInMonth(date.getFullYear(), date.getMonth() + 1);
1325
+ }
1326
+ const year = this.readOptionalYear(parser);
1327
+ if (year !== null) return daysInYear(year);
1328
+ throw ErrorFactory.parsing(
1329
+ "DAYS_IN_EXPECTED_PERIOD",
1330
+ 'Expected a month, quarter or year, as in "days in February 2020" or "days in Q3"'
1331
+ );
1332
+ }
1333
+ /** A four-digit year sitting at the cursor, consumed if present. */
1334
+ readOptionalYear(parser) {
1335
+ const token = parser.peek();
1336
+ if (token?.type !== "NUMBER") return null;
1337
+ const text = token.text ?? token.value ?? "";
1338
+ if (!/^\d{4}$/.test(text)) return null;
1339
+ parser.consume();
1340
+ return Number(text);
1341
+ }
1342
+ };
1343
+
1344
+ // src/packages/datetime/normalizer/DaysInPeriodNormalizerRule.ts
1345
+ var QUARTERS2 = /* @__PURE__ */ new Set(["q1", "q2", "q3", "q4"]);
1346
+ var MONTHS2 = /* @__PURE__ */ new Set([
1347
+ "january",
1348
+ "jan",
1349
+ "february",
1350
+ "feb",
1351
+ "march",
1352
+ "mar",
1353
+ "april",
1354
+ "apr",
1355
+ "may",
1356
+ "june",
1357
+ "jun",
1358
+ "july",
1359
+ "jul",
1360
+ "august",
1361
+ "aug",
1362
+ "september",
1363
+ "sep",
1364
+ "sept",
1365
+ "october",
1366
+ "oct",
1367
+ "november",
1368
+ "nov",
1369
+ "december",
1370
+ "dec"
1371
+ ]);
1372
+ function startsPeriod(token) {
1373
+ if (token === void 0) return false;
1374
+ if (token.type === "DATETIME_LITERAL") return true;
1375
+ const word = (token.text ?? token.value ?? "").toLowerCase();
1376
+ if (token.type === "IDENT" || token.type === "UNIT") {
1377
+ return QUARTERS2.has(word) || MONTHS2.has(word);
1378
+ }
1379
+ return token.type === "NUMBER" && /^\d{4}$/.test(token.text ?? "");
1380
+ }
1381
+ function daysInPeriodNormalizerRule(priority = 60) {
1382
+ return {
1383
+ name: "datetime:days-in-period",
1384
+ priority,
1385
+ match(tokens, pos) {
1386
+ const days = tokens[pos];
1387
+ if (days === void 0) return null;
1388
+ if ((days.text ?? days.value ?? "").toLowerCase() !== "days") return null;
1389
+ if (days.type !== "UNIT" && days.type !== "IDENT") return null;
1390
+ if (tokens[pos + 1]?.type !== "IN") return null;
1391
+ if (!startsPeriod(tokens[pos + 2])) return null;
1392
+ return {
1393
+ consumed: 2,
1394
+ replacement: [createFusedToken("DAYS_IN_PERIOD", "days in", [days, tokens[pos + 1]])],
1395
+ ruleName: "datetime:days-in-period"
1396
+ };
1397
+ }
1398
+ };
1399
+ }
1400
+
1009
1401
  // src/packages/datetime/normalizer/DailyNoteLinkNormalizerRule.ts
1010
1402
  function dailyNoteLinkNormalizerRule() {
1011
1403
  return {
@@ -1054,6 +1446,10 @@ var DATETIME_PACKAGE = {
1054
1446
  "what week is it": "WEEK_ON",
1055
1447
  "what week is it on": "WEEK_ON",
1056
1448
  "week of": "WEEK_ON",
1449
+ // The spelling Soulver documents. Same question, same handler.
1450
+ "week number on": "WEEK_ON",
1451
+ "week number of": "WEEK_ON",
1452
+ "week number": "WEEK_ON",
1057
1453
  "what week is it in": "WEEK_IN",
1058
1454
  "what week will it be in": "WEEK_IN",
1059
1455
  // Postfix day-type predicates.
@@ -1064,6 +1460,7 @@ var DATETIME_PACKAGE = {
1064
1460
  "is a business day": "IS_WORKDAY"
1065
1461
  },
1066
1462
  prefixParselets: [
1463
+ { tokenType: "DAYS_IN_PERIOD", parselet: new DaysInPeriodParselet() },
1067
1464
  { tokenType: "NOW", parselet: new NowParselet(0) },
1068
1465
  { tokenType: "TODAY", parselet: new NowParselet(0) },
1069
1466
  { tokenType: "TOMORROW", parselet: new NowParselet(1) },
@@ -1094,6 +1491,11 @@ var DATETIME_PACKAGE = {
1094
1491
  betweenUnitNormalizerRule(),
1095
1492
  workdayRateDenominatorNormalizerRule(),
1096
1493
  dateLiteralNormalizerRule(),
1494
+ // Dates with the month spelled out ("March 9, 2024"). Priority 64, just
1495
+ // under the numeric rule, so an all-numeric literal is still claimed by
1496
+ // the rule that has always claimed it.
1497
+ monthNameDateNormalizerRule(),
1498
+ daysInPeriodNormalizerRule(),
1097
1499
  dailyNoteLinkNormalizerRule()
1098
1500
  ],
1099
1501
  pluginFunctions: [
@@ -1606,7 +2008,7 @@ var TimeDifferenceParselet = class {
1606
2008
  `Expected a city or zone name after "time difference between" (e.g. "time difference between Seattle and Moscow")`
1607
2009
  );
1608
2010
  }
1609
- parser.consume("PLUS");
2011
+ parser.consume("AND_CONJ");
1610
2012
  const zone2 = tryConsumeZoneReference(parser);
1611
2013
  if (zone2 === null) {
1612
2014
  throw ErrorFactory.parsing(
@@ -1684,14 +2086,14 @@ var VideoTimecodeParselet = class {
1684
2086
  // src/packages/time/timecode/TimecodeMath.ts
1685
2087
  function framesToTimecodeString(totalFrames, fps) {
1686
2088
  const fpsWhole = Math.max(1, Math.round(fps));
1687
- const pad = (n) => String(n).padStart(2, "0");
2089
+ const pad3 = (n) => String(n).padStart(2, "0");
1688
2090
  const frameCount = Math.trunc(totalFrames);
1689
2091
  const frames = (frameCount % fpsWhole + fpsWhole) % fpsWhole;
1690
2092
  const totalSeconds = Math.floor(frameCount / fpsWhole);
1691
2093
  const hours = Math.floor(totalSeconds / 3600);
1692
2094
  const minutes = Math.floor(totalSeconds % 3600 / 60);
1693
2095
  const seconds = totalSeconds % 60;
1694
- return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}:${pad(frames)}`;
2096
+ return `${pad3(hours)}:${pad3(minutes)}:${pad3(seconds)}:${pad3(frames)}`;
1695
2097
  }
1696
2098
 
1697
2099
  // src/packages/time/parselets/FrameCountParselet.ts
@@ -1911,9 +2313,72 @@ function frameCountNormalizerRule(priority = 65) {
1911
2313
  };
1912
2314
  }
1913
2315
 
2316
+ // src/packages/time/TimespanConverters.ts
2317
+ var TIME_KIND = 14;
2318
+ var TIMESPAN_PARTS = [
2319
+ ["week", "weeks", 604800],
2320
+ ["day", "days", 86400],
2321
+ ["hour", "hours", 3600],
2322
+ ["minute", "minutes", 60],
2323
+ ["second", "seconds", 1]
2324
+ ];
2325
+ function durationSeconds(value) {
2326
+ if (value.type === 0 /* Number */) return value.toNumber();
2327
+ if (value.type !== 6 /* Uom */ || value.unit === void 0) return null;
2328
+ const entry = UNIT_TABLE[value.unit.toLowerCase()];
2329
+ if (entry === void 0 || entry[0] !== TIME_KIND) return null;
2330
+ return value.toNumber() * entry[1];
2331
+ }
2332
+ function toTimespanString(value) {
2333
+ const total = durationSeconds(value);
2334
+ if (total === null) {
2335
+ return stringValue(`"as timespan" needs a duration, got ${value.unit ?? "a non-duration"}`);
2336
+ }
2337
+ const negative = total < 0;
2338
+ let remaining = Math.abs(total);
2339
+ const parts = [];
2340
+ for (const [singular, plural, seconds] of TIMESPAN_PARTS) {
2341
+ if (seconds === 1) break;
2342
+ const count = Math.floor(remaining / seconds);
2343
+ if (count > 0) {
2344
+ parts.push(`${count} ${count === 1 ? singular : plural}`);
2345
+ remaining -= count * seconds;
2346
+ }
2347
+ }
2348
+ const secondsLeft = Math.round(remaining * 1e3) / 1e3;
2349
+ if (secondsLeft > 0 || parts.length === 0) {
2350
+ parts.push(`${secondsLeft} ${secondsLeft === 1 ? "second" : "seconds"}`);
2351
+ }
2352
+ return stringValue((negative ? "-" : "") + parts.join(" "));
2353
+ }
2354
+ function toLaptimeString(value) {
2355
+ const total = durationSeconds(value);
2356
+ if (total === null) {
2357
+ return stringValue(`"as laptime" needs a duration, got ${value.unit ?? "a non-duration"}`);
2358
+ }
2359
+ const negative = total < 0;
2360
+ const rounded = Math.round(Math.abs(total) * 1e3) / 1e3;
2361
+ const hours = Math.floor(rounded / 3600);
2362
+ const minutes = Math.floor(rounded % 3600 / 60);
2363
+ const seconds = rounded % 60;
2364
+ const whole = Math.floor(seconds);
2365
+ const fraction = Math.round((seconds - whole) * 1e3);
2366
+ const base = `${pad(hours)}:${pad(minutes)}:${pad(whole)}`;
2367
+ return stringValue((negative ? "-" : "") + (fraction > 0 ? `${base}.${String(fraction).padStart(3, "0")}` : base));
2368
+ }
2369
+ function pad(n) {
2370
+ return n < 10 ? `0${n}` : String(n);
2371
+ }
2372
+
1914
2373
  // src/packages/time/TimePackage.ts
1915
2374
  var TIME_PACKAGE = {
1916
2375
  name: "solve-time",
2376
+ asConverters: {
2377
+ // The two ways of writing a duration out. See TimespanConverters.ts.
2378
+ timespan: toTimespanString,
2379
+ laptime: toLaptimeString,
2380
+ lap: toLaptimeString
2381
+ },
1917
2382
  phrases: {
1918
2383
  "time in": "TIME_IN",
1919
2384
  "date in": "DATE_IN",
@@ -2017,7 +2482,7 @@ var keywordLedRollPattern = definePhrasePattern({
2017
2482
  // separator's token type (a bare `parser.consume()`), so it never
2018
2483
  // surfaced this; matching that real lexer behavior explicitly here
2019
2484
  // rather than the aspirational "AND" the old code's comment implied.
2020
- { kind: "keyword", tokenTypes: ["PLUS"] },
2485
+ { kind: "keyword", tokenTypes: ["AND_CONJ"] },
2021
2486
  { kind: "expr" }
2022
2487
  ],
2023
2488
  emit: emitDiceRollCall
@@ -2345,99 +2810,403 @@ function uomPossibilitiesNormalizerRule(priority = 60) {
2345
2810
  };
2346
2811
  }
2347
2812
 
2348
- // src/packages/uom/data/IngredientDensities.ts
2349
- var INGREDIENT_DENSITIES = {
2350
- water: 1,
2351
- milk: 1.03,
2352
- "whole milk": 1.03,
2353
- "skim milk": 1.035,
2354
- buttermilk: 1.03,
2355
- "heavy cream": 1,
2356
- "whipping cream": 1,
2357
- "sour cream": 0.96,
2358
- yogurt: 1.03,
2359
- "greek yogurt": 1.05,
2360
- "cream cheese": 1.01,
2361
- "condensed milk": 1.3,
2362
- "evaporated milk": 1.06,
2363
- "coconut milk": 0.97,
2364
- "almond milk": 1.03,
2365
- "soy milk": 1.03,
2366
- butter: 0.9595,
2367
- margarine: 0.96,
2368
- "vegetable oil": 0.92,
2369
- "olive oil": 0.913,
2370
- "canola oil": 0.92,
2371
- "coconut oil": 0.92,
2372
- "sunflower oil": 0.92,
2373
- "sesame oil": 0.92,
2374
- mayonnaise: 0.91,
2375
- sugar: 0.845,
2376
- "granulated sugar": 0.845,
2377
- "brown sugar": 0.93,
2378
- "powdered sugar": 0.51,
2379
- "icing sugar": 0.51,
2380
- honey: 1.42,
2381
- "maple syrup": 1.32,
2382
- "corn syrup": 1.38,
2383
- molasses: 1.4,
2384
- nutella: 1.14,
2385
- "peanut butter": 1.09,
2386
- flour: 0.53,
2387
- "all-purpose flour": 0.53,
2388
- "all purpose flour": 0.53,
2389
- "bread flour": 0.54,
2390
- "cake flour": 0.47,
2391
- "whole wheat flour": 0.55,
2392
- cornstarch: 0.53,
2393
- cornmeal: 0.65,
2394
- semolina: 0.6,
2395
- rice: 0.79,
2396
- "white rice": 0.79,
2397
- "brown rice": 0.8,
2398
- oats: 0.34,
2399
- "rolled oats": 0.38,
2400
- breadcrumbs: 0.43,
2401
- yeast: 0.68,
2402
- "dry yeast": 0.68,
2403
- salt: 1.2,
2404
- "table salt": 1.2,
2405
- "kosher salt": 0.96,
2406
- "baking powder": 0.9,
2407
- "baking soda": 0.99,
2408
- "cream of tartar": 0.68,
2409
- cinnamon: 0.56,
2410
- ginger: 0.5,
2411
- "vanilla extract": 0.88,
2412
- "lemon juice": 1.03,
2413
- "lime juice": 1.02,
2414
- "orange juice": 1.04,
2415
- vinegar: 1.01,
2416
- cocoa: 0.41,
2417
- "cocoa powder": 0.41,
2418
- "chocolate chips": 0.72,
2419
- "chocolate chunks": 0.72,
2420
- coconut: 0.35,
2421
- "shredded coconut": 0.35,
2422
- raisins: 0.68,
2423
- almonds: 0.55,
2424
- walnuts: 0.5,
2425
- pecans: 0.5,
2426
- pistachios: 0.55,
2427
- peanuts: 0.6,
2428
- "shredded cheese": 0.4,
2429
- parmesan: 0.44,
2430
- "tomato paste": 1.06,
2431
- "tomato sauce": 1.02,
2432
- ketchup: 1.14,
2433
- broth: 1,
2434
- stock: 1,
2435
- wine: 0.99,
2436
- beer: 1.01
2437
- };
2438
- var MAX_INGREDIENT_NAME_WORDS = 2;
2439
- function getIngredientDensity(name) {
2440
- return INGREDIENT_DENSITIES[name.toLowerCase().trim()];
2813
+ // src/packages/uom/normalizer/CompoundQuantityNormalizerRule.ts
2814
+ var TIME_KIND2 = 14;
2815
+ function unitEntry(token, previousKind) {
2816
+ if (token === void 0 || token.type !== "UNIT") return void 0;
2817
+ const spelling = (token.value ?? "").toLowerCase();
2818
+ if (spelling === "m" && previousKind === TIME_KIND2) {
2819
+ return { kind: TIME_KIND2, ratio: 60, spelling: "minutes" };
2820
+ }
2821
+ const entry = UNIT_TABLE[spelling];
2822
+ return entry === void 0 ? void 0 : { kind: entry[0], ratio: entry[1], spelling };
2823
+ }
2824
+ var PLAIN_NUMBER = /^\d+(\.\d+)?$/;
2825
+ function compoundQuantityNormalizerRule(priority = 63) {
2826
+ return {
2827
+ name: "uom:compound-quantity",
2828
+ priority,
2829
+ match(tokens, pos) {
2830
+ const firstNumber = tokens[pos];
2831
+ if (firstNumber?.type !== "NUMBER" || !PLAIN_NUMBER.test(firstNumber.text ?? "")) return null;
2832
+ const firstUnit = unitEntry(tokens[pos + 1]);
2833
+ if (firstUnit === void 0) return null;
2834
+ const kind = firstUnit.kind;
2835
+ let lastRatio = firstUnit.ratio;
2836
+ let total = Number(firstNumber.text) * lastRatio;
2837
+ let smallestUnit = firstUnit.spelling;
2838
+ let smallestUnitToken = tokens[pos + 1];
2839
+ let consumed = 2;
2840
+ for (; ; ) {
2841
+ const number = tokens[pos + consumed];
2842
+ if (number?.type !== "NUMBER" || !PLAIN_NUMBER.test(number.text ?? "")) break;
2843
+ const entry = unitEntry(tokens[pos + consumed + 1], kind);
2844
+ if (entry === void 0) break;
2845
+ if (entry.kind !== kind || entry.ratio >= lastRatio) break;
2846
+ total += Number(number.text) * entry.ratio;
2847
+ lastRatio = entry.ratio;
2848
+ smallestUnit = entry.spelling;
2849
+ smallestUnitToken = tokens[pos + consumed + 1];
2850
+ consumed += 2;
2851
+ }
2852
+ if (consumed === 2) return null;
2853
+ const source = tokens.slice(pos, pos + consumed);
2854
+ const scaled = total / lastRatio;
2855
+ return {
2856
+ consumed,
2857
+ replacement: [
2858
+ createFusedToken("NUMBER", String(scaled), source),
2859
+ createFusedToken("UNIT", smallestUnit, [smallestUnitToken])
2860
+ ],
2861
+ ruleName: "uom:compound-quantity"
2862
+ };
2863
+ }
2864
+ };
2865
+ }
2866
+
2867
+ // src/packages/uom/normalizer/TwoUnitConversionNormalizerRule.ts
2868
+ function isUnit(token) {
2869
+ if (token === void 0 || token.type !== "UNIT") return false;
2870
+ return UNIT_TABLE[(token.value ?? "").toLowerCase()] !== void 0;
2871
+ }
2872
+ function twoUnitConversionNormalizerRule(priority = 59) {
2873
+ return {
2874
+ name: "uom:two-unit-conversion",
2875
+ priority,
2876
+ match(tokens, pos) {
2877
+ const preposition = tokens[pos];
2878
+ if (preposition?.type !== "IN" && preposition?.type !== "TO") return null;
2879
+ if (!isUnit(tokens[pos + 1])) return null;
2880
+ if (tokens[pos + 2]?.type !== "AND_CONJ") return null;
2881
+ if (!isUnit(tokens[pos + 3])) return null;
2882
+ const major = tokens[pos + 1].value;
2883
+ const minor = tokens[pos + 3].value;
2884
+ return {
2885
+ consumed: 4,
2886
+ replacement: [
2887
+ createFusedToken("IN_TWO_UNITS", `${major} ${minor}`, tokens.slice(pos, pos + 4))
2888
+ ],
2889
+ ruleName: "uom:two-unit-conversion"
2890
+ };
2891
+ }
2892
+ };
2893
+ }
2894
+
2895
+ // src/packages/uom/normalizer/DegreeSymbolNormalizerRule.ts
2896
+ function degreeSymbolNormalizerRule(priority = 74) {
2897
+ return {
2898
+ name: "uom:degree-symbol",
2899
+ priority,
2900
+ match(tokens, pos) {
2901
+ const number = tokens[pos];
2902
+ if (number?.type !== "NUMBER") return null;
2903
+ const symbol = tokens[pos + 1];
2904
+ if (symbol === void 0 || symbol.type !== "IDENT") return null;
2905
+ if ((symbol.text ?? symbol.value ?? "") !== "\xB0") return null;
2906
+ return {
2907
+ consumed: 2,
2908
+ replacement: [number, createFusedToken("UNIT", "degrees", [symbol])],
2909
+ ruleName: "uom:degree-symbol"
2910
+ };
2911
+ }
2912
+ };
2913
+ }
2914
+
2915
+ // src/packages/uom/parselets/TwoUnitConversionParselet.ts
2916
+ var TwoUnitConversionParselet = class {
2917
+ constructor(builtinIndex) {
2918
+ this.builtinIndex = builtinIndex;
2919
+ this.category = "Uom";
2920
+ this.bindingPower = BindingPower.Conditional;
2921
+ }
2922
+ parse(_parser, _left, token, builder) {
2923
+ const [major, minor] = String(token.value).split(" ");
2924
+ builder.emitOpcode(13 /* PUSH_STRING */);
2925
+ builder.emitString(major);
2926
+ builder.emitOpcode(13 /* PUSH_STRING */);
2927
+ builder.emitString(minor);
2928
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
2929
+ builder.emitIndex(this.builtinIndex);
2930
+ builder.emitIndex(3);
2931
+ }
2932
+ };
2933
+
2934
+ // src/packages/uom/normalizer/BareRateDenominatorNormalizerRule.ts
2935
+ function isUnit2(token) {
2936
+ if (token === void 0 || token.type !== "UNIT") return false;
2937
+ return UNIT_TABLE[(token.value ?? "").toLowerCase()] !== void 0;
2938
+ }
2939
+ var PER_WORDS = /* @__PURE__ */ new Set(["per", "a", "an", "each", "every"]);
2940
+ function isCountLabel(token) {
2941
+ if (token === void 0 || token.type !== "IDENT") return false;
2942
+ const word = (token.text ?? token.value ?? "").toLowerCase();
2943
+ if (PER_WORDS.has(word)) return false;
2944
+ return /^[a-z][a-z_]{2,}$/.test(word) && UNIT_TABLE[word] === void 0;
2945
+ }
2946
+ function bareRateDenominatorNormalizerRule(priority = 75) {
2947
+ return {
2948
+ name: "uom:bare-rate-denominator",
2949
+ priority,
2950
+ match(tokens, pos) {
2951
+ const head = tokens[pos];
2952
+ if (head === void 0) return null;
2953
+ if (head.type === "NUMBER" && isCountLabel(tokens[pos + 1])) {
2954
+ const label = tokens[pos + 1];
2955
+ const after = tokens[pos + 2];
2956
+ const introduces = after?.type === "SLASH" || after?.type === "IDENT" && PER_WORDS.has((after.text ?? after.value ?? "").toLowerCase());
2957
+ if (introduces && isUnit2(tokens[pos + 3])) {
2958
+ return {
2959
+ consumed: 2,
2960
+ replacement: [head, createFusedToken("UNIT", label.value, [label])],
2961
+ ruleName: "uom:bare-rate-denominator"
2962
+ };
2963
+ }
2964
+ }
2965
+ if (!isUnit2(tokens[pos + 1])) return null;
2966
+ const isSlash = head.type === "SLASH";
2967
+ const word = (head.text ?? head.value ?? "").toLowerCase();
2968
+ const isPerWord = head.type === "IDENT" && PER_WORDS.has(word);
2969
+ if (!isSlash && !isPerWord) return null;
2970
+ const unitToken = tokens[pos + 1];
2971
+ return {
2972
+ consumed: 2,
2973
+ replacement: [
2974
+ createFusedToken("PER_UNIT", unitToken.value, [head, unitToken])
2975
+ ],
2976
+ ruleName: "uom:bare-rate-denominator"
2977
+ };
2978
+ }
2979
+ };
2980
+ }
2981
+
2982
+ // src/packages/uom/parselets/PerUnitParselet.ts
2983
+ var PerUnitParselet = class {
2984
+ constructor(builtinIndex) {
2985
+ this.builtinIndex = builtinIndex;
2986
+ this.category = "Uom";
2987
+ this.bindingPower = BindingPower.Product;
2988
+ }
2989
+ parse(_parser, _left, token, builder) {
2990
+ builder.emitOpcode(13 /* PUSH_STRING */);
2991
+ builder.emitString(String(token.value));
2992
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
2993
+ builder.emitIndex(this.builtinIndex);
2994
+ builder.emitIndex(2);
2995
+ }
2996
+ };
2997
+
2998
+ // src/packages/uom/parselets/AtRateParselet.ts
2999
+ var AtRateParselet = class {
3000
+ constructor(builtinIndex) {
3001
+ this.builtinIndex = builtinIndex;
3002
+ this.category = "Uom";
3003
+ this.bindingPower = BindingPower.Product;
3004
+ }
3005
+ parse(parser, _left, _token, builder) {
3006
+ parser.parseExpression(BindingPower.Conditional, builder);
3007
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
3008
+ builder.emitIndex(this.builtinIndex);
3009
+ builder.emitIndex(2);
3010
+ }
3011
+ };
3012
+
3013
+ // src/packages/uom/normalizer/AtRateNormalizerRule.ts
3014
+ function isUnit3(token) {
3015
+ if (token === void 0 || token.type !== "UNIT") return false;
3016
+ return UNIT_TABLE[(token.value ?? "").toLowerCase()] !== void 0;
3017
+ }
3018
+ var PER_WORDS2 = /* @__PURE__ */ new Set(["per", "a", "an", "each", "every"]);
3019
+ function hasRateAhead(tokens, from) {
3020
+ for (let i = from; i < tokens.length && i < from + 5; i++) {
3021
+ const token = tokens[i];
3022
+ if (token.type === "PER_UNIT") return true;
3023
+ if (token.type === "EOF" || token.type === "COMMA" || token.type === "RATE_AT") return false;
3024
+ const word = (token.text ?? token.value ?? "").toLowerCase();
3025
+ const introducesDenominator = token.type === "SLASH" || token.type === "IDENT" && PER_WORDS2.has(word);
3026
+ if (introducesDenominator && isUnit3(tokens[i + 1])) return true;
3027
+ }
3028
+ return false;
3029
+ }
3030
+ function atRateNormalizerRule(priority = 73) {
3031
+ return {
3032
+ name: "uom:at-rate",
3033
+ priority,
3034
+ match(tokens, pos) {
3035
+ if (tokens[pos]?.type !== "RATE_AT") return null;
3036
+ if (!hasRateAhead(tokens, pos + 1)) return null;
3037
+ return {
3038
+ consumed: 1,
3039
+ replacement: [createFusedToken("AT_RATE", "at", [tokens[pos]])],
3040
+ ruleName: "uom:at-rate"
3041
+ };
3042
+ }
3043
+ };
3044
+ }
3045
+
3046
+ // src/packages/uom/normalizer/ForDurationNormalizerRule.ts
3047
+ function isUnit4(token) {
3048
+ if (token === void 0 || token.type !== "UNIT") return false;
3049
+ return UNIT_TABLE[(token.value ?? "").toLowerCase()] !== void 0;
3050
+ }
3051
+ var ARTICLES = /* @__PURE__ */ new Set(["a", "an"]);
3052
+ function forDurationNormalizerRule(priority = 76) {
3053
+ return {
3054
+ name: "uom:for-duration",
3055
+ priority,
3056
+ match(tokens, pos) {
3057
+ const head = tokens[pos];
3058
+ if (head?.type !== "FOR_DURATION") return null;
3059
+ const article = tokens[pos + 1];
3060
+ if (article?.type !== "IDENT") return null;
3061
+ if (!ARTICLES.has((article.text ?? article.value ?? "").toLowerCase())) return null;
3062
+ if (!isUnit4(tokens[pos + 2])) return null;
3063
+ return {
3064
+ consumed: 3,
3065
+ replacement: [
3066
+ createFusedToken("STAR", "*", [head]),
3067
+ createFusedToken("NUMBER", "1", [article]),
3068
+ tokens[pos + 2]
3069
+ ],
3070
+ ruleName: "uom:for-duration"
3071
+ };
3072
+ }
3073
+ };
3074
+ }
3075
+
3076
+ // src/packages/uom/normalizer/ReversedConversionNormalizerRule.ts
3077
+ function isUnit5(token) {
3078
+ if (token === void 0 || token.type !== "UNIT") return false;
3079
+ return UNIT_TABLE[(token.value ?? "").toLowerCase()] !== void 0;
3080
+ }
3081
+ var ARTICLES2 = /* @__PURE__ */ new Set(["a", "an"]);
3082
+ function reversedConversionNormalizerRule(priority = 61) {
3083
+ return {
3084
+ name: "uom:reversed-conversion",
3085
+ priority,
3086
+ match(tokens, pos) {
3087
+ if (pos !== 0 || !isUnit5(tokens[pos])) return null;
3088
+ if (tokens[pos + 1]?.type !== "IN") return null;
3089
+ const target = tokens[pos];
3090
+ const third = tokens[pos + 2];
3091
+ if (third === void 0) return null;
3092
+ if (third.type === "NUMBER" && isUnit5(tokens[pos + 3])) {
3093
+ return {
3094
+ consumed: 4,
3095
+ replacement: [third, tokens[pos + 3], tokens[pos + 1], target],
3096
+ ruleName: "uom:reversed-conversion"
3097
+ };
3098
+ }
3099
+ const word = (third.text ?? third.value ?? "").toLowerCase();
3100
+ if (third.type === "IDENT" && ARTICLES2.has(word) && isUnit5(tokens[pos + 3])) {
3101
+ return {
3102
+ consumed: 4,
3103
+ replacement: [
3104
+ createFusedToken("NUMBER", "1", [third]),
3105
+ tokens[pos + 3],
3106
+ tokens[pos + 1],
3107
+ target
3108
+ ],
3109
+ ruleName: "uom:reversed-conversion"
3110
+ };
3111
+ }
3112
+ return null;
3113
+ }
3114
+ };
3115
+ }
3116
+
3117
+ // src/packages/uom/data/IngredientDensities.ts
3118
+ var INGREDIENT_DENSITIES = {
3119
+ water: 1,
3120
+ milk: 1.03,
3121
+ "whole milk": 1.03,
3122
+ "skim milk": 1.035,
3123
+ buttermilk: 1.03,
3124
+ "heavy cream": 1,
3125
+ "whipping cream": 1,
3126
+ "sour cream": 0.96,
3127
+ yogurt: 1.03,
3128
+ "greek yogurt": 1.05,
3129
+ "cream cheese": 1.01,
3130
+ "condensed milk": 1.3,
3131
+ "evaporated milk": 1.06,
3132
+ "coconut milk": 0.97,
3133
+ "almond milk": 1.03,
3134
+ "soy milk": 1.03,
3135
+ butter: 0.9595,
3136
+ margarine: 0.96,
3137
+ "vegetable oil": 0.92,
3138
+ "olive oil": 0.913,
3139
+ "canola oil": 0.92,
3140
+ "coconut oil": 0.92,
3141
+ "sunflower oil": 0.92,
3142
+ "sesame oil": 0.92,
3143
+ mayonnaise: 0.91,
3144
+ sugar: 0.845,
3145
+ "granulated sugar": 0.845,
3146
+ "brown sugar": 0.93,
3147
+ "powdered sugar": 0.51,
3148
+ "icing sugar": 0.51,
3149
+ honey: 1.42,
3150
+ "maple syrup": 1.32,
3151
+ "corn syrup": 1.38,
3152
+ molasses: 1.4,
3153
+ nutella: 1.14,
3154
+ "peanut butter": 1.09,
3155
+ flour: 0.53,
3156
+ "all-purpose flour": 0.53,
3157
+ "all purpose flour": 0.53,
3158
+ "bread flour": 0.54,
3159
+ "cake flour": 0.47,
3160
+ "whole wheat flour": 0.55,
3161
+ cornstarch: 0.53,
3162
+ cornmeal: 0.65,
3163
+ semolina: 0.6,
3164
+ rice: 0.79,
3165
+ "white rice": 0.79,
3166
+ "brown rice": 0.8,
3167
+ oats: 0.34,
3168
+ "rolled oats": 0.38,
3169
+ breadcrumbs: 0.43,
3170
+ yeast: 0.68,
3171
+ "dry yeast": 0.68,
3172
+ salt: 1.2,
3173
+ "table salt": 1.2,
3174
+ "kosher salt": 0.96,
3175
+ "baking powder": 0.9,
3176
+ "baking soda": 0.99,
3177
+ "cream of tartar": 0.68,
3178
+ cinnamon: 0.56,
3179
+ ginger: 0.5,
3180
+ "vanilla extract": 0.88,
3181
+ "lemon juice": 1.03,
3182
+ "lime juice": 1.02,
3183
+ "orange juice": 1.04,
3184
+ vinegar: 1.01,
3185
+ cocoa: 0.41,
3186
+ "cocoa powder": 0.41,
3187
+ "chocolate chips": 0.72,
3188
+ "chocolate chunks": 0.72,
3189
+ coconut: 0.35,
3190
+ "shredded coconut": 0.35,
3191
+ raisins: 0.68,
3192
+ almonds: 0.55,
3193
+ walnuts: 0.5,
3194
+ pecans: 0.5,
3195
+ pistachios: 0.55,
3196
+ peanuts: 0.6,
3197
+ "shredded cheese": 0.4,
3198
+ parmesan: 0.44,
3199
+ "tomato paste": 1.06,
3200
+ "tomato sauce": 1.02,
3201
+ ketchup: 1.14,
3202
+ broth: 1,
3203
+ stock: 1,
3204
+ wine: 0.99,
3205
+ beer: 1.01
3206
+ };
3207
+ var MAX_INGREDIENT_NAME_WORDS = 2;
3208
+ function getIngredientDensity(name) {
3209
+ return INGREDIENT_DENSITIES[name.toLowerCase().trim()];
2441
3210
  }
2442
3211
 
2443
3212
  // src/packages/uom/parselets/CookingPluginFunctions.ts
@@ -2548,11 +3317,21 @@ var UOM_PACKAGE = {
2548
3317
  { tokenType: "UOM_POSSIBILITIES_QUERY", parselet: new PossibilitiesParselet() }
2549
3318
  ],
2550
3319
  infixParselets: [
3320
+ { tokenType: "IN_TWO_UNITS", parselet: new TwoUnitConversionParselet(94) },
3321
+ { tokenType: "PER_UNIT", parselet: new PerUnitParselet(95) },
3322
+ { tokenType: "AT_RATE", parselet: new AtRateParselet(96) },
2551
3323
  { tokenType: "UNIT", parselet: new UomLiteralParselet() },
2552
3324
  { tokenType: "INGREDIENT_NAME", parselet: new CookingConversionParselet() }
2553
3325
  ],
2554
3326
  normalizerRules: [
2555
3327
  uomPossibilitiesNormalizerRule(),
3328
+ compoundQuantityNormalizerRule(),
3329
+ twoUnitConversionNormalizerRule(),
3330
+ degreeSymbolNormalizerRule(),
3331
+ atRateNormalizerRule(),
3332
+ forDurationNormalizerRule(),
3333
+ bareRateDenominatorNormalizerRule(),
3334
+ reversedConversionNormalizerRule(),
2556
3335
  ingredientNameNormalizerRule()
2557
3336
  ],
2558
3337
  pluginFunctions: [
@@ -3587,6 +4366,9 @@ var BUILTIN_CONVERTERS = {
3587
4366
  hex: 71 /* TO_HEX */,
3588
4367
  fraction: 140 /* TO_FRACTION */,
3589
4368
  multiplier: 141 /* TO_MULTIPLIER */,
4369
+ // "20 to 40 as x". Only reachable in converter position, immediately after
4370
+ // "as", so a variable named x is unaffected.
4371
+ x: 141 /* TO_MULTIPLIER */,
3590
4372
  sci: 142 /* TO_SCI */,
3591
4373
  scientific: 142 /* TO_SCI */,
3592
4374
  binary: 143 /* TO_BINARY */,
@@ -3594,6 +4376,11 @@ var BUILTIN_CONVERTERS = {
3594
4376
  octal: 144 /* TO_OCTAL */,
3595
4377
  oct: 144 /* TO_OCTAL */
3596
4378
  };
4379
+ var BASE_CONVERTERS = {
4380
+ 2: 143 /* TO_BINARY */,
4381
+ 8: 144 /* TO_OCTAL */,
4382
+ 16: 71 /* TO_HEX */
4383
+ };
3597
4384
  var AsConverterParselet = class {
3598
4385
  constructor() {
3599
4386
  this.category = "Converters";
@@ -3614,8 +4401,26 @@ var AsConverterParselet = class {
3614
4401
  }
3615
4402
  parser.consume();
3616
4403
  const name = nextToken.value.toLowerCase();
4404
+ if (name === "base") {
4405
+ const radixToken = parser.peek();
4406
+ const radix = radixToken?.type === "NUMBER" ? Number(radixToken.value) : NaN;
4407
+ const opcode = BASE_CONVERTERS[radix];
4408
+ if (opcode === void 0) {
4409
+ throw ErrorFactory.parsing(
4410
+ "AS_CONVERTER_UNSUPPORTED_BASE",
4411
+ `"as base ${radixToken?.value ?? "?"}": only bases ${Object.keys(BASE_CONVERTERS).join(", ")} can be written out`
4412
+ );
4413
+ }
4414
+ parser.consume();
4415
+ builder.emitOpcode(opcode);
4416
+ return;
4417
+ }
3617
4418
  const builtinOp = BUILTIN_CONVERTERS[name];
3618
4419
  if (builtinOp !== void 0) {
4420
+ if (builtinOp === 141 /* TO_MULTIPLIER */ && parser.match("OF")) {
4421
+ parser.parseExpression(BindingPower.Conditional, builder);
4422
+ builder.emitOpcode(23 /* DIV */);
4423
+ }
3619
4424
  builder.emitOpcode(builtinOp);
3620
4425
  return;
3621
4426
  }
@@ -3625,11 +4430,181 @@ var AsConverterParselet = class {
3625
4430
  }
3626
4431
  };
3627
4432
 
4433
+ // src/packages/converters/parselets/RoundingParselets.ts
4434
+ var INCREMENT_WORDS = {
4435
+ ten: 10,
4436
+ hundred: 100,
4437
+ thousand: 1e3,
4438
+ million: 1e6,
4439
+ billion: 1e9
4440
+ };
4441
+ function emitRoundToIncrement(builder, increment, direction) {
4442
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4443
+ builder.emitNumber(increment);
4444
+ builder.emitOpcode(23 /* DIV */);
4445
+ emitRoundDirection(builder, direction);
4446
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4447
+ builder.emitNumber(increment);
4448
+ builder.emitOpcode(22 /* MUL */);
4449
+ }
4450
+ function emitRoundDirection(builder, direction) {
4451
+ const builtin = direction === "up" ? CEIL_BUILTIN : direction === "down" ? FLOOR_BUILTIN : ROUND_BUILTIN;
4452
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4453
+ builder.emitIndex(builtin);
4454
+ builder.emitIndex(1);
4455
+ }
4456
+ var ROUND_BUILTIN = 8;
4457
+ var CEIL_BUILTIN = 6;
4458
+ var FLOOR_BUILTIN = 7;
4459
+ function readDirection(parser) {
4460
+ const next = parser.peek();
4461
+ const word = (next?.text ?? "").toLowerCase();
4462
+ if (word === "up") {
4463
+ parser.consume();
4464
+ return "up";
4465
+ }
4466
+ if (word === "down") {
4467
+ parser.consume();
4468
+ return "down";
4469
+ }
4470
+ return "nearest";
4471
+ }
4472
+ var RoundedParselet = class {
4473
+ constructor() {
4474
+ this.category = "Converters";
4475
+ this.bindingPower = BindingPower.Conditional;
4476
+ }
4477
+ parse(parser, _left, _token, builder) {
4478
+ const direction = readDirection(parser);
4479
+ if (parser.match("TO_NEAREST")) {
4480
+ emitRoundToIncrement(builder, readIncrement(parser), direction);
4481
+ return;
4482
+ }
4483
+ emitRoundDirection(builder, direction);
4484
+ }
4485
+ };
4486
+ var ToNearestParselet = class {
4487
+ constructor() {
4488
+ this.category = "Converters";
4489
+ this.bindingPower = BindingPower.Conditional;
4490
+ }
4491
+ parse(parser, _left, _token, builder) {
4492
+ emitRoundToIncrement(builder, readIncrement(parser), "nearest");
4493
+ }
4494
+ };
4495
+ function readIncrement(parser) {
4496
+ const next = parser.peek();
4497
+ const word = (next?.text ?? "").toLowerCase();
4498
+ const magnitude = INCREMENT_WORDS[word];
4499
+ if (magnitude !== void 0) {
4500
+ parser.consume();
4501
+ return magnitude;
4502
+ }
4503
+ if (next?.type === "NUMBER") {
4504
+ parser.consume();
4505
+ const value = Number(next.value);
4506
+ if (Number.isFinite(value) && value > 0) return value;
4507
+ }
4508
+ throw ErrorFactory.parsing(
4509
+ "INVALID_ROUNDING_INCREMENT",
4510
+ `"to nearest ${next?.value ?? "?"}": expected a positive number or one of ${Object.keys(INCREMENT_WORDS).join(", ")}`
4511
+ );
4512
+ }
4513
+ var DecimalPlacesParselet = class {
4514
+ constructor() {
4515
+ this.category = "Converters";
4516
+ this.bindingPower = BindingPower.Conditional;
4517
+ }
4518
+ parse(_parser, _left, token, builder) {
4519
+ const places = Number(token.value);
4520
+ if (!Number.isFinite(places) || places < 0 || places > 100) {
4521
+ throw ErrorFactory.parsing(
4522
+ "INVALID_DECIMAL_PLACES",
4523
+ `"to ${token.value} dp": expected a place count between 0 and 100`
4524
+ );
4525
+ }
4526
+ const scale = Math.pow(10, places);
4527
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4528
+ builder.emitNumber(scale);
4529
+ builder.emitOpcode(22 /* MUL */);
4530
+ emitRoundDirection(builder, "nearest");
4531
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4532
+ builder.emitNumber(scale);
4533
+ builder.emitOpcode(23 /* DIV */);
4534
+ }
4535
+ };
4536
+
4537
+ // src/packages/converters/normalizer/DecimalPlacesNormalizerRule.ts
4538
+ var PLACE_WORDS = /* @__PURE__ */ new Set(["dp", "dps", "decimals", "digits", "digit"]);
4539
+ var PLACE_PHRASES = /* @__PURE__ */ new Set(["decimal"]);
4540
+ function decimalPlacesNormalizerRule(priority = 66) {
4541
+ return {
4542
+ name: "converters:decimal-places",
4543
+ priority,
4544
+ match(tokens, pos) {
4545
+ if (tokens[pos]?.type !== "TO") return null;
4546
+ const count = tokens[pos + 1];
4547
+ if (count?.type !== "NUMBER") return null;
4548
+ const unitToken = tokens[pos + 2];
4549
+ if (!unitToken) return null;
4550
+ const word = (unitToken.text ?? unitToken.value ?? "").toLowerCase();
4551
+ let consumed = 3;
4552
+ if (PLACE_PHRASES.has(word)) {
4553
+ const tail = tokens[pos + 3];
4554
+ const tailWord = (tail?.text ?? "").toLowerCase();
4555
+ if (tailWord !== "place" && tailWord !== "places") return null;
4556
+ consumed = 4;
4557
+ } else if (!PLACE_WORDS.has(word)) {
4558
+ return null;
4559
+ }
4560
+ return {
4561
+ consumed,
4562
+ replacement: [
4563
+ createFusedToken("DECIMAL_PLACES", count.value, tokens.slice(pos, pos + consumed))
4564
+ ],
4565
+ ruleName: "converters:decimal-places"
4566
+ };
4567
+ }
4568
+ };
4569
+ }
4570
+
4571
+ // src/packages/converters/normalizer/ConverterPrepositionNormalizerRule.ts
4572
+ function converterPrepositionNormalizerRule(priority = 67) {
4573
+ return {
4574
+ name: "converters:preposition",
4575
+ priority,
4576
+ match(tokens, pos) {
4577
+ const preposition = tokens[pos];
4578
+ if (preposition?.type !== "IN" && preposition?.type !== "TO") return null;
4579
+ if (tokens[pos + 1]?.type !== "CONVERTER_NAME") return null;
4580
+ return {
4581
+ consumed: 1,
4582
+ replacement: [createFusedToken("AS", "as", [preposition])],
4583
+ ruleName: "converters:preposition"
4584
+ };
4585
+ }
4586
+ };
4587
+ }
4588
+
3628
4589
  // src/packages/converters/ConvertersPackage.ts
3629
4590
  var CONVERTERS_PACKAGE = {
3630
4591
  name: "solve-converters",
4592
+ phrases: {
4593
+ // A phrase, not a bare "nearest": "to" is already an infix operator
4594
+ // (percentage change), so the two words have to arrive as one token for
4595
+ // the parser to tell the grammars apart. See RoundingParselets.ts.
4596
+ "to nearest": "TO_NEAREST",
4597
+ "to the nearest": "TO_NEAREST"
4598
+ },
3631
4599
  infixParselets: [
3632
- { tokenType: "AS", parselet: new AsConverterParselet() }
4600
+ { tokenType: "AS", parselet: new AsConverterParselet() },
4601
+ { tokenType: "ROUNDED", parselet: new RoundedParselet() },
4602
+ { tokenType: "TO_NEAREST", parselet: new ToNearestParselet() },
4603
+ { tokenType: "DECIMAL_PLACES", parselet: new DecimalPlacesParselet() }
4604
+ ],
4605
+ normalizerRules: [
4606
+ decimalPlacesNormalizerRule(),
4607
+ converterPrepositionNormalizerRule()
3633
4608
  ]
3634
4609
  };
3635
4610
 
@@ -3641,10 +4616,10 @@ var VariadicAggregateParselet = class {
3641
4616
  }
3642
4617
  parse(parser, token, builder) {
3643
4618
  let argCount = 0;
3644
- parser.parseExpression(BindingPower.Lowest, builder);
4619
+ parser.parseExpression(BindingPower.Conjunction, builder);
3645
4620
  argCount++;
3646
- while (parser.match("COMMA")) {
3647
- parser.parseExpression(BindingPower.Lowest, builder);
4621
+ while (parser.match("COMMA") || parser.match("AND_CONJ")) {
4622
+ parser.parseExpression(BindingPower.Conjunction, builder);
3648
4623
  argCount++;
3649
4624
  }
3650
4625
  builder.emitOpcode(51 /* CALL_BUILTIN */);
@@ -3653,13 +4628,125 @@ var VariadicAggregateParselet = class {
3653
4628
  }
3654
4629
  };
3655
4630
 
4631
+ // src/packages/mathphrases/parselets/UnaryPhraseParselet.ts
4632
+ function unaryPhraseParselet(builtinIndex) {
4633
+ return {
4634
+ category: "MathPhrases",
4635
+ parse(parser, _token, builder) {
4636
+ parser.parseExpression(BindingPower.Lowest, builder);
4637
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4638
+ builder.emitIndex(builtinIndex);
4639
+ builder.emitIndex(1);
4640
+ }
4641
+ };
4642
+ }
4643
+
4644
+ // src/packages/mathphrases/parselets/RemainderRootParselets.ts
4645
+ var remainderOfParselet = {
4646
+ category: "MathPhrases",
4647
+ parse(parser, _token, builder) {
4648
+ parser.parseExpression(BindingPower.Product, builder);
4649
+ if (!parser.match("DIVIDE_BY") && !parser.match("SLASH")) {
4650
+ throw ErrorFactory.parsing(
4651
+ "REMAINDER_EXPECTED_DIVIDED_BY",
4652
+ 'Expected "divided by" or "/", as in "remainder of 21 divided by 5"'
4653
+ );
4654
+ }
4655
+ parser.parseExpression(BindingPower.Product, builder);
4656
+ builder.emitOpcode(24 /* MOD */);
4657
+ }
4658
+ };
4659
+ var nthRootParselet = {
4660
+ category: "MathPhrases",
4661
+ parse(parser, _token, builder) {
4662
+ parser.parseExpression(BindingPower.Product, builder);
4663
+ if (!parser.match("OF")) {
4664
+ throw ErrorFactory.parsing(
4665
+ "ROOT_EXPECTED_OF",
4666
+ 'Expected "of", as in "root 5 of 100"'
4667
+ );
4668
+ }
4669
+ parser.parseExpression(BindingPower.Conditional, builder);
4670
+ builder.emitOpcode(2 /* SWAP */);
4671
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4672
+ builder.emitNumber(1);
4673
+ builder.emitOpcode(2 /* SWAP */);
4674
+ builder.emitOpcode(23 /* DIV */);
4675
+ builder.emitOpcode(25 /* EXP */);
4676
+ }
4677
+ };
4678
+ var logBaseParselet = {
4679
+ category: "MathPhrases",
4680
+ parse(parser, _token, builder) {
4681
+ parser.parseExpression(BindingPower.Conditional, builder);
4682
+ emitNaturalLog(builder);
4683
+ if (!parser.match("LOG_BASE")) {
4684
+ throw ErrorFactory.parsing(
4685
+ "LOG_EXPECTED_BASE",
4686
+ 'Expected "base", as in "log 20 base 4"'
4687
+ );
4688
+ }
4689
+ parser.parseExpression(BindingPower.Conditional, builder);
4690
+ emitNaturalLog(builder);
4691
+ builder.emitOpcode(23 /* DIV */);
4692
+ }
4693
+ };
4694
+ var LOG_BUILTIN2 = 5;
4695
+ function emitNaturalLog(builder) {
4696
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4697
+ builder.emitIndex(LOG_BUILTIN2);
4698
+ builder.emitIndex(1);
4699
+ }
4700
+
4701
+ // src/packages/mathphrases/normalizer/FunctionPhraseNormalizerRule.ts
4702
+ function functionPhraseNormalizerRule(priority = 69) {
4703
+ return {
4704
+ name: "mathphrases:function-phrase",
4705
+ priority,
4706
+ match(tokens, pos) {
4707
+ const head = tokens[pos];
4708
+ if (head?.type !== "FUNC") return null;
4709
+ const word = (head.value ?? "").toLowerCase();
4710
+ if (word !== "root" && word !== "log") return null;
4711
+ if (tokens[pos + 1]?.type === "LPAREN") return null;
4712
+ if (word === "root") {
4713
+ return {
4714
+ consumed: 1,
4715
+ replacement: [createFusedToken("NTH_ROOT", "root", [head])],
4716
+ ruleName: "mathphrases:function-phrase"
4717
+ };
4718
+ }
4719
+ const baseAt = findBase(tokens, pos + 1);
4720
+ if (baseAt === -1) return null;
4721
+ const replacement = tokens.slice(pos, baseAt + 1).map((token, index) => {
4722
+ if (index === 0) return createFusedToken("LOG_PHRASE", "log", [token]);
4723
+ if (index === baseAt - pos) return createFusedToken("LOG_BASE", "base", [token]);
4724
+ return token;
4725
+ });
4726
+ return {
4727
+ consumed: baseAt - pos + 1,
4728
+ replacement,
4729
+ ruleName: "mathphrases:function-phrase"
4730
+ };
4731
+ }
4732
+ };
4733
+ }
4734
+ function findBase(tokens, from) {
4735
+ for (let i = from; i < tokens.length && i < from + 8; i++) {
4736
+ const token = tokens[i];
4737
+ if (token.type === "EOF" || token.type === "COMMA" || token.type === "RPAREN") return -1;
4738
+ if ((token.text ?? token.value ?? "").toLowerCase() === "base") return i;
4739
+ }
4740
+ return -1;
4741
+ }
4742
+
3656
4743
  // src/packages/mathphrases/parselets/LargerSmallerParselet.ts
3657
4744
  function largerSmallerParselet(builtinIndex) {
3658
4745
  return {
3659
4746
  category: "MathPhrases",
3660
4747
  parse(parser, token, builder) {
3661
- parser.parseExpression(BindingPower.Product, builder);
3662
- parser.consume("PLUS");
4748
+ parser.parseExpression(BindingPower.Conjunction, builder);
4749
+ parser.consume("AND_CONJ");
3663
4750
  parser.parseExpression(BindingPower.Lowest, builder);
3664
4751
  builder.emitOpcode(51 /* CALL_BUILTIN */);
3665
4752
  builder.emitIndex(builtinIndex);
@@ -3683,8 +4770,8 @@ var halfParselet = {
3683
4770
  var midpointParselet = {
3684
4771
  category: "MathPhrases",
3685
4772
  parse(parser, token, builder) {
3686
- parser.parseExpression(BindingPower.Product, builder);
3687
- parser.consume("PLUS");
4773
+ parser.parseExpression(BindingPower.Conjunction, builder);
4774
+ parser.consume("AND_CONJ");
3688
4775
  parser.parseExpression(BindingPower.Lowest, builder);
3689
4776
  builder.emitOpcode(20 /* ADD */);
3690
4777
  builder.emitOpcode(10 /* PUSH_NUMBER */);
@@ -3701,7 +4788,7 @@ var randomNumberParselet = definePhrasePattern({
3701
4788
  slots: [
3702
4789
  { kind: "keyword", tokenTypes: ["BETWEEN"] },
3703
4790
  { kind: "expr", bindingPower: BindingPower.Product },
3704
- { kind: "keyword", tokenTypes: ["PLUS"] },
4791
+ { kind: "keyword", tokenTypes: ["AND_CONJ"] },
3705
4792
  { kind: "expr" }
3706
4793
  ],
3707
4794
  emit: (builder) => {
@@ -3725,7 +4812,7 @@ var ClampParselet = class {
3725
4812
  if (mid?.type === "BETWEEN") {
3726
4813
  parser.consume();
3727
4814
  parser.parseExpression(BindingPower.Product, builder);
3728
- parser.consume("PLUS");
4815
+ parser.consume("AND_CONJ");
3729
4816
  parser.parseExpression(BindingPower.Lowest, builder);
3730
4817
  } else if (mid?.type === "FROM") {
3731
4818
  parser.consume();
@@ -3776,6 +4863,10 @@ var TOTAL = 44;
3776
4863
  var COUNT = 45;
3777
4864
  var MIN = 9;
3778
4865
  var MAX = 10;
4866
+ var GCD = 38;
4867
+ var LCM = 39;
4868
+ var SQRT = 0;
4869
+ var CBRT = 21;
3779
4870
  var MATHPHRASES_PACKAGE = {
3780
4871
  name: "solve-mathphrases",
3781
4872
  phrases: {
@@ -3784,11 +4875,20 @@ var MATHPHRASES_PACKAGE = {
3784
4875
  "total of": "TOTAL_OF",
3785
4876
  "count of": "COUNT_OF",
3786
4877
  "larger of": "LARGER_OF",
4878
+ "greater of": "LARGER_OF",
3787
4879
  "smaller of": "SMALLER_OF",
4880
+ "lesser of": "SMALLER_OF",
3788
4881
  "half of": "HALF_OF",
3789
4882
  "midpoint between": "MIDPOINT_BETWEEN",
3790
4883
  "random number": "RANDOM_NUMBER",
3791
- "is to": "IS_TO"
4884
+ "is to": "IS_TO",
4885
+ // Two-operand phrases reusing the gcd/lcm builtins.
4886
+ "remainder of": "REMAINDER_OF",
4887
+ "gcd of": "GCD_OF",
4888
+ "lcm of": "LCM_OF",
4889
+ // sqrt/cbrt spelled the way they are said.
4890
+ "square root of": "SQUARE_ROOT_OF",
4891
+ "cube root of": "CUBE_ROOT_OF"
3792
4892
  },
3793
4893
  prefixParselets: [
3794
4894
  { tokenType: "AVERAGE_OF", parselet: new VariadicAggregateParselet(AVERAGE) },
@@ -3800,29 +4900,168 @@ var MATHPHRASES_PACKAGE = {
3800
4900
  { tokenType: "HALF_OF", parselet: halfParselet },
3801
4901
  { tokenType: "MIDPOINT_BETWEEN", parselet: midpointParselet },
3802
4902
  { tokenType: "RANDOM_NUMBER", parselet: randomNumberParselet },
4903
+ { tokenType: "REMAINDER_OF", parselet: remainderOfParselet },
4904
+ { tokenType: "NTH_ROOT", parselet: nthRootParselet },
4905
+ { tokenType: "LOG_PHRASE", parselet: logBaseParselet },
4906
+ { tokenType: "GCD_OF", parselet: largerSmallerParselet(GCD) },
4907
+ { tokenType: "LCM_OF", parselet: largerSmallerParselet(LCM) },
4908
+ { tokenType: "SQUARE_ROOT_OF", parselet: unaryPhraseParselet(SQRT) },
4909
+ { tokenType: "CUBE_ROOT_OF", parselet: unaryPhraseParselet(CBRT) },
3803
4910
  { tokenType: "CLAMP", parselet: new ClampParselet() }
3804
4911
  ],
3805
4912
  infixParselets: [
3806
4913
  { tokenType: "IS_TO", parselet: new ProportionParselet() }
4914
+ ],
4915
+ normalizerRules: [
4916
+ functionPhraseNormalizerRule()
3807
4917
  ]
3808
4918
  };
3809
4919
 
4920
+ // src/packages/finance/parselets/InvestmentParselets.ts
4921
+ var PERIODS_PER_YEAR = {
4922
+ annually: 1,
4923
+ yearly: 1,
4924
+ // Semi-annual is not on Soulver's page, but it is the one interval a
4925
+ // reader of the other four would expect to exist, and leaving it out makes
4926
+ // the set look arbitrary rather than deliberate.
4927
+ "semi-annually": 2,
4928
+ quarterly: 4,
4929
+ monthly: 12,
4930
+ fortnightly: 26,
4931
+ weekly: 52,
4932
+ daily: 365
4933
+ };
4934
+ function readCompoundingInterval(parser) {
4935
+ if (!parser.match("COMPOUNDING")) return 1;
4936
+ const token = parser.peek();
4937
+ const name = (token?.text ?? token?.value ?? "").toLowerCase();
4938
+ const periods = PERIODS_PER_YEAR[name];
4939
+ if (periods === void 0) {
4940
+ throw ErrorFactory.parsing(
4941
+ "UNKNOWN_COMPOUNDING_INTERVAL",
4942
+ `compounding ${name || "?"}: expected one of ${Object.keys(PERIODS_PER_YEAR).join(", ")}`
4943
+ );
4944
+ }
4945
+ parser.consume();
4946
+ return periods;
4947
+ }
4948
+ var InvestmentGrowthParselet = class {
4949
+ /**
4950
+ * @param annualIndex - Builtin for the annual case, so the common form
4951
+ * keeps using the same code path (and the same rounding) it always did.
4952
+ * @param intervalIndex - Builtin taking an explicit periods-per-year.
4953
+ */
4954
+ constructor(annualIndex, intervalIndex) {
4955
+ this.annualIndex = annualIndex;
4956
+ this.intervalIndex = intervalIndex;
4957
+ this.category = "Finance";
4958
+ this.bindingPower = BindingPower.Conditional;
4959
+ }
4960
+ parse(parser, _left, _token, builder) {
4961
+ parser.parseExpression(BindingPower.Conditional, builder);
4962
+ if (!parser.match("RATE_AT")) parser.consume("AT");
4963
+ parser.parseExpression(BindingPower.Conditional, builder);
4964
+ const periods = readCompoundingInterval(parser);
4965
+ builder.emitOpcode(2 /* SWAP */);
4966
+ if (periods === 1) {
4967
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4968
+ builder.emitIndex(this.annualIndex);
4969
+ builder.emitIndex(3);
4970
+ return;
4971
+ }
4972
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
4973
+ builder.emitNumber(periods);
4974
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4975
+ builder.emitIndex(this.intervalIndex);
4976
+ builder.emitIndex(4);
4977
+ }
4978
+ };
4979
+ var PresentValueParselet = class {
4980
+ constructor(builtinIndex) {
4981
+ this.builtinIndex = builtinIndex;
4982
+ this.category = "Finance";
4983
+ }
4984
+ parse(parser, _token, builder) {
4985
+ parser.parseExpression(BindingPower.Conditional, builder);
4986
+ if (!parser.match("AFTER") && !parser.match("OVER") && !parser.match("IN")) {
4987
+ parser.consume("AFTER");
4988
+ }
4989
+ parser.parseExpression(BindingPower.Conditional, builder);
4990
+ if (!parser.match("RATE_AT")) parser.consume("AT");
4991
+ parser.parseExpression(BindingPower.Conditional, builder);
4992
+ builder.emitOpcode(2 /* SWAP */);
4993
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
4994
+ builder.emitIndex(this.builtinIndex);
4995
+ builder.emitIndex(3);
4996
+ }
4997
+ };
4998
+ var ReturnOnInvestmentParselet = class {
4999
+ constructor(builtinIndex) {
5000
+ this.builtinIndex = builtinIndex;
5001
+ this.category = "Finance";
5002
+ this.bindingPower = BindingPower.Conditional;
5003
+ }
5004
+ parse(parser, _left, _token, builder) {
5005
+ parser.parseExpression(BindingPower.Conditional, builder);
5006
+ parser.consume("RETURNED");
5007
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
5008
+ builder.emitIndex(this.builtinIndex);
5009
+ builder.emitIndex(2);
5010
+ }
5011
+ };
5012
+ var AnnualReturnParselet = class {
5013
+ constructor(builtinIndex) {
5014
+ this.builtinIndex = builtinIndex;
5015
+ this.category = "Finance";
5016
+ }
5017
+ parse(parser, _token, builder) {
5018
+ parser.parseExpression(BindingPower.Conditional, builder);
5019
+ parser.consume("INVESTED");
5020
+ parser.parseExpression(BindingPower.Conditional, builder);
5021
+ parser.consume("RETURNED");
5022
+ if (!parser.match("AFTER") && !parser.match("OVER") && !parser.match("IN")) {
5023
+ parser.consume("AFTER");
5024
+ }
5025
+ parser.parseExpression(BindingPower.Conditional, builder);
5026
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
5027
+ builder.emitIndex(this.builtinIndex);
5028
+ builder.emitIndex(3);
5029
+ }
5030
+ };
5031
+
3810
5032
  // src/packages/finance/parselets/CompoundInterestParselet.ts
3811
5033
  var CompoundInterestParselet = class {
3812
- constructor(builtinIndex) {
5034
+ /**
5035
+ * @param builtinIndex - Annual-compounding builtin.
5036
+ * @param intervalIndex - Builtin taking an explicit periods-per-year, used
5037
+ * when the expression ends in "compounding monthly" or similar.
5038
+ */
5039
+ constructor(builtinIndex, intervalIndex) {
3813
5040
  this.builtinIndex = builtinIndex;
5041
+ this.intervalIndex = intervalIndex;
3814
5042
  this.category = "Finance";
3815
5043
  }
3816
5044
  parse(parser, _token, builder) {
5045
+ parser.parseExpression(BindingPower.Conditional, builder);
5046
+ if (!parser.match("AFTER") && !parser.match("FOR_DURATION")) {
5047
+ parser.consume("OVER");
5048
+ }
3817
5049
  parser.parseExpression(BindingPower.Lowest, builder);
3818
- parser.consume("OVER");
3819
- parser.parseExpression(BindingPower.Lowest, builder);
3820
- parser.consume("RATE_AT");
5050
+ if (!parser.match("RATE_AT")) parser.consume("AT");
3821
5051
  parser.parseExpression(BindingPower.Lowest, builder);
5052
+ const periods = readCompoundingInterval(parser);
3822
5053
  builder.emitOpcode(2 /* SWAP */);
5054
+ if (periods === 1) {
5055
+ builder.emitOpcode(51 /* CALL_BUILTIN */);
5056
+ builder.emitIndex(this.builtinIndex);
5057
+ builder.emitIndex(3);
5058
+ return;
5059
+ }
5060
+ builder.emitOpcode(10 /* PUSH_NUMBER */);
5061
+ builder.emitNumber(periods);
3823
5062
  builder.emitOpcode(51 /* CALL_BUILTIN */);
3824
- builder.emitIndex(this.builtinIndex);
3825
- builder.emitIndex(3);
5063
+ builder.emitIndex(this.intervalIndex);
5064
+ builder.emitIndex(4);
3826
5065
  }
3827
5066
  };
3828
5067
 
@@ -3837,7 +5076,7 @@ var LoanRepaymentParselet = class {
3837
5076
  parser.parseExpression(BindingPower.Lowest, builder);
3838
5077
  parser.consume("OVER");
3839
5078
  parser.parseExpression(BindingPower.Lowest, builder);
3840
- parser.consume("RATE_AT");
5079
+ if (!parser.match("RATE_AT")) parser.consume("AT");
3841
5080
  parser.parseExpression(BindingPower.Lowest, builder);
3842
5081
  builder.emitOpcode(2 /* SWAP */);
3843
5082
  builder.emitOpcode(10 /* PUSH_NUMBER */);
@@ -3899,8 +5138,8 @@ function inflationFutureValueHandler(args) {
3899
5138
  return errorValue("INVALID_RATE", `inflationFutureValue: rate ${rate} makes (1 + rate) non-positive`);
3900
5139
  }
3901
5140
  const amount = amountValue.toNumber();
3902
- const fv = amount * Math.pow(1 + rate, years);
3903
- return amountValue.type === 6 /* Uom */ ? uomValue(fv, amountValue.unit) : numberValue(fv);
5141
+ const worth = amount / Math.pow(1 + rate, years);
5142
+ return amountValue.type === 6 /* Uom */ ? uomValue(worth, amountValue.unit) : numberValue(worth);
3904
5143
  }
3905
5144
 
3906
5145
  // src/packages/finance/parselets/InflationQueryParselet.ts
@@ -4018,8 +5257,14 @@ var COMPOUND_FV = 51;
4018
5257
  var COMPOUND_INTEREST = 52;
4019
5258
  var LOAN_REPAYMENT = 55;
4020
5259
  var LOAN_INTEREST = 56;
4021
- var TAX_ADD = 58;
5260
+ var COMPOUND_FV_EVERY = 80;
5261
+ var COMPOUND_INTEREST_EVERY = 81;
5262
+ var PRESENT_VALUE = 82;
5263
+ var ROI = 83;
5264
+ var ANNUAL_RETURN = 84;
4022
5265
  var TAX_REMOVE = 59;
5266
+ var TAX_IN = 85;
5267
+ var TAX_ON = 86;
4023
5268
  var FINANCE_PACKAGE = {
4024
5269
  name: "solve-finance",
4025
5270
  phrases: {
@@ -4035,17 +5280,29 @@ var FINANCE_PACKAGE = {
4035
5280
  "total interest on": "TOTAL_LOAN_INTEREST_ON",
4036
5281
  "tax on": "TAX_ON",
4037
5282
  "tax off": "TAX_OFF",
5283
+ // The tax already inside a gross amount. Soulver documents "VAT in",
5284
+ // "VAT of" and "VAT from" as three spellings of one thing.
5285
+ "tax in": "TAX_IN_PHRASE",
5286
+ "tax of": "TAX_IN_PHRASE",
5287
+ "tax from": "TAX_IN_PHRASE",
5288
+ "vat in": "TAX_IN_PHRASE",
5289
+ "vat of": "TAX_IN_PHRASE",
5290
+ "vat from": "TAX_IN_PHRASE",
4038
5291
  "vat on": "TAX_ON",
4039
5292
  "vat off": "TAX_OFF",
4040
5293
  "what is": "WHAT_IS",
4041
5294
  "what was": "WHAT_WAS",
5295
+ "present value of": "PRESENT_VALUE_OF",
5296
+ "annual return on": "ANNUAL_RETURN_ON",
4042
5297
  "value of": "VALUE_OF",
4043
5298
  "worth in": "WORTH_IN",
4044
5299
  "assuming": "ASSUMING"
4045
5300
  },
4046
5301
  prefixParselets: [
4047
- { tokenType: "COMPOUND_INTEREST_ON", parselet: new CompoundInterestParselet(COMPOUND_FV) },
4048
- { tokenType: "INTEREST_ON", parselet: new CompoundInterestParselet(COMPOUND_INTEREST) },
5302
+ { tokenType: "PRESENT_VALUE_OF", parselet: new PresentValueParselet(PRESENT_VALUE) },
5303
+ { tokenType: "ANNUAL_RETURN_ON", parselet: new AnnualReturnParselet(ANNUAL_RETURN) },
5304
+ { tokenType: "COMPOUND_INTEREST_ON", parselet: new CompoundInterestParselet(COMPOUND_FV, COMPOUND_FV_EVERY) },
5305
+ { tokenType: "INTEREST_ON", parselet: new CompoundInterestParselet(COMPOUND_INTEREST, COMPOUND_INTEREST_EVERY) },
4049
5306
  { tokenType: "DAILY_REPAYMENT_ON", parselet: new LoanRepaymentParselet(LOAN_REPAYMENT, 365) },
4050
5307
  { tokenType: "MONTHLY_REPAYMENT_ON", parselet: new LoanRepaymentParselet(LOAN_REPAYMENT, 12) },
4051
5308
  { tokenType: "ANNUAL_REPAYMENT_ON", parselet: new LoanRepaymentParselet(LOAN_REPAYMENT, 1) },
@@ -4054,13 +5311,20 @@ var FINANCE_PACKAGE = {
4054
5311
  { tokenType: "MONTHLY_LOAN_INTEREST_ON", parselet: new LoanRepaymentParselet(LOAN_INTEREST, 12) },
4055
5312
  { tokenType: "ANNUAL_LOAN_INTEREST_ON", parselet: new LoanRepaymentParselet(LOAN_INTEREST, 1) },
4056
5313
  { tokenType: "TOTAL_LOAN_INTEREST_ON", parselet: new LoanRepaymentParselet(LOAN_INTEREST, 0) },
4057
- { tokenType: "TAX_ON", parselet: new SalesTaxParselet(TAX_ADD) },
5314
+ { tokenType: "TAX_ON", parselet: new SalesTaxParselet(TAX_ON) },
4058
5315
  { tokenType: "TAX_OFF", parselet: new SalesTaxParselet(TAX_REMOVE) },
5316
+ { tokenType: "TAX_IN_PHRASE", parselet: new SalesTaxParselet(TAX_IN) },
4059
5317
  { tokenType: "WHAT_IS", parselet: new InflationQueryParselet("what-is") },
4060
5318
  { tokenType: "WHAT_WAS", parselet: new InflationQueryParselet("what-was") },
4061
5319
  { tokenType: "VALUE_OF", parselet: new InflationFutureValueParselet() }
4062
5320
  ],
4063
5321
  infixParselets: [
5322
+ // The documented bare forms: "$1,000 after 3 years at 7%" and the "for
5323
+ // ... compounding monthly" variant. Both pivot words behave identically;
5324
+ // Soulver uses "after" for the annual form and "for" with an interval.
5325
+ { tokenType: "AFTER", parselet: new InvestmentGrowthParselet(COMPOUND_FV, COMPOUND_FV_EVERY) },
5326
+ { tokenType: "FOR_DURATION", parselet: new InvestmentGrowthParselet(COMPOUND_FV, COMPOUND_FV_EVERY) },
5327
+ { tokenType: "INVESTED", parselet: new ReturnOnInvestmentParselet(ROI) },
4064
5328
  { tokenType: "IN_YEAR_DOLLARS", parselet: new InYearDollarsParselet() }
4065
5329
  ],
4066
5330
  normalizerRules: [
@@ -4423,6 +5687,15 @@ function requireYear(text, raw) {
4423
5687
  function tryParseDatePhrase(parser) {
4424
5688
  const first = parser.peek();
4425
5689
  if (!first) return null;
5690
+ if (first.type === "DATETIME_LITERAL") {
5691
+ parser.consume();
5692
+ const date = new Date(Number(first.value));
5693
+ if (Number.isNaN(date.getTime())) return null;
5694
+ return {
5695
+ isoDate: `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
5696
+ raw: first.text ?? first.value
5697
+ };
5698
+ }
4426
5699
  if (first.type === "NUMBER") {
4427
5700
  const g1 = parser.consume();
4428
5701
  const sepTok = parser.peek();
@@ -5093,5 +6366,5 @@ var BUILTIN_PACKAGES = [
5093
6366
  ];
5094
6367
 
5095
6368
  export { ARITHMETIC_PACKAGE, BIGINT_PACKAGE, BUILTIN_PACKAGES, CONDITIONALS_PACKAGE, CONVERTERS_PACKAGE, CURRENCY_PACKAGE, DATETIME_PACKAGE, DICE_PACKAGE, FINANCE_PACKAGE, FUNCTION_PACKAGE, LINES_PACKAGE, MAPREDUCE_PACKAGE, MATHPHRASES_PACKAGE, MATRIX_PACKAGE, PERCENTAGE_PACKAGE, SYMBOLIC_PACKAGE, TIME_PACKAGE, UOM_PACKAGE, VARIABLES_PACKAGE, VECTOR_PACKAGE, WEATHER_PACKAGE, containsSymbolicCall, createKnowledgePackage, createStocksPackage };
5096
- //# sourceMappingURL=chunk-3PPFLFH4.js.map
5097
- //# sourceMappingURL=chunk-3PPFLFH4.js.map
6369
+ //# sourceMappingURL=chunk-FIFQPKBA.js.map
6370
+ //# sourceMappingURL=chunk-FIFQPKBA.js.map