@sdeverywhere/compile 0.7.29 → 0.7.31

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdeverywhere/compile",
3
- "version": "0.7.29",
3
+ "version": "0.7.31",
4
4
  "description": "The core Vensim to C compiler for the SDEverywhere tool suite.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -8,9 +8,9 @@
8
8
  "@sdeverywhere/parse": "^0.1.4",
9
9
  "byline": "^5.0.0",
10
10
  "csv-parse": "^5.3.3",
11
+ "fflate": "^0.8.3",
11
12
  "ramda": "^0.27.0",
12
- "strip-bom": "^5.0.0",
13
- "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
13
+ "strip-bom": "^5.0.0"
14
14
  },
15
15
  "author": "Climate Interactive",
16
16
  "license": "MIT",
@@ -1,9 +1,9 @@
1
- import * as fs from 'node:fs'
2
1
  import util from 'util'
3
2
  import { parse as parseCsv } from 'csv-parse/sync'
4
3
  import * as R from 'ramda'
5
- import XLSX from 'xlsx'
4
+
6
5
  import B from './bufx.js'
6
+ import { readXlsx as readXlsxFile, resetXlsxCache } from './xlsx.js'
7
7
 
8
8
  import { canonicalId, canonicalVarId } from '@sdeverywhere/parse'
9
9
 
@@ -24,12 +24,6 @@ let nextLevelVarSeq = 1
24
24
  let nextAuxVarSeq = 1
25
25
  // parsed csv data cache
26
26
  let csvData = new Map()
27
- // parsed xlsx data cache
28
- let xlsxData = new Map()
29
-
30
- // Newer versions of the xlsx package require manually setting the `fs` instance
31
- // before using the `XLSX.readFile` function
32
- XLSX.set_fs(fs)
33
27
 
34
28
  // XXX: This is needed for tests due to sequence numbers being in module-level storage
35
29
  export function resetHelperState() {
@@ -40,7 +34,7 @@ export function resetHelperState() {
40
34
  nextLevelVarSeq = 1
41
35
  nextAuxVarSeq = 1
42
36
  csvData.clear()
43
- xlsxData.clear()
37
+ resetXlsxCache()
44
38
  }
45
39
 
46
40
  export let canonicalName = name => {
@@ -203,15 +197,10 @@ export let isIterable = obj => {
203
197
  }
204
198
  // Command helpers
205
199
  export let readXlsx = pathname => {
206
- // Read the XLSX file at the pathname and parse it.
207
- // Return a `XLSX.WorkBook` object that can be used to access the data.
208
- // Cache parsed files to support multiple reads from different equations.
209
- let xlsx = xlsxData.get(pathname)
210
- if (!xlsx) {
211
- xlsx = XLSX.readFile(pathname, { cellDates: true })
212
- xlsxData.set(pathname, xlsx)
213
- }
214
- return xlsx
200
+ // Read the XLSX file at the pathname and parse it. Return a workbook
201
+ // object ({ SheetNames, Sheets }) that can be used to access the data.
202
+ // The underlying reader caches parsed files by path.
203
+ return readXlsxFile(pathname)
215
204
  }
216
205
  export let readCsv = (pathname, delimiter = ',') => {
217
206
  // Read the CSV file at the pathname and parse it with the given delimiter.
@@ -0,0 +1,502 @@
1
+ // Copyright (c) 2026 Climate Interactive / New Venture Fund
2
+
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ import { strFromU8, unzipSync } from 'fflate'
6
+
7
+ //
8
+ // Minimal xlsx reader and cell-address utilities used by the `GET DIRECT ...`
9
+ // code paths in the compile pipeline. We read numeric cell values only; strings
10
+ // are surfaced where present but are not the focus of this module.
11
+ //
12
+
13
+ const A_UPPER = 65 // 'A'
14
+ const A_LOWER = 97 // 'a'
15
+
16
+ // Workbook cache, mirroring the previous SheetJS readXlsx behavior in
17
+ // helpers.js. Each xlsx file is parsed at most once per process.
18
+ const workbookCache = new Map()
19
+
20
+ /**
21
+ * Reset the workbook cache. Intended for tests that load the same path with
22
+ * different contents across runs.
23
+ */
24
+ export function resetXlsxCache() {
25
+ workbookCache.clear()
26
+ }
27
+
28
+ /**
29
+ * Read the xlsx file at the given path and return a workbook shaped like the
30
+ * SheetJS `WorkBook`:
31
+ *
32
+ * ```
33
+ * { SheetNames: string[],
34
+ * Sheets: { [name]: { [cellRef]: { v }, '!ref': 'A1:Z99' } } }
35
+ * ```
36
+ *
37
+ * Sheets are materialized lazily — the first time a sheet name is read from
38
+ * `Sheets`, its XML is parsed; subsequent reads of the same sheet return the
39
+ * cached map. Workbooks are also cached by path.
40
+ *
41
+ * @param {string} pathname The absolute path to the xlsx file.
42
+ * @returns The parsed workbook.
43
+ */
44
+ export function readXlsx(pathname) {
45
+ // Return the cached workbook if we've already parsed this file
46
+ const cached = workbookCache.get(pathname)
47
+ if (cached) {
48
+ return cached
49
+ }
50
+
51
+ // Decompress the file, keeping only the entries we actually read from
52
+ const buf = readFileSync(pathname)
53
+ const unzipped = unzipSync(buf, {
54
+ filter: file => {
55
+ const n = file.name
56
+ return (
57
+ n === 'xl/workbook.xml' ||
58
+ n === 'xl/sharedStrings.xml' ||
59
+ n === 'xl/_rels/workbook.xml.rels' ||
60
+ (n.startsWith('xl/worksheets/sheet') && n.endsWith('.xml'))
61
+ )
62
+ }
63
+ })
64
+
65
+ // Pull out the workbook, rels, and (optional) sharedStrings parts as text
66
+ const wbBytes = unzipped['xl/workbook.xml']
67
+ const relsBytes = unzipped['xl/_rels/workbook.xml.rels']
68
+ if (!wbBytes || !relsBytes) {
69
+ throw new Error(`Failed to read xlsx file (missing workbook or rels): ${pathname}`)
70
+ }
71
+ const wbXml = strFromU8(wbBytes)
72
+ const relsXml = strFromU8(relsBytes)
73
+ const ssBytes = unzipped['xl/sharedStrings.xml']
74
+
75
+ // Build the sheet definitions
76
+ const sheetDefs = parseWorkbookXml(wbXml)
77
+
78
+ // Build the rid-to-target map
79
+ const rels = parseWorkbookRels(relsXml)
80
+
81
+ // Build the shared-string table
82
+ const sharedStrings = ssBytes ? parseSharedStrings(strFromU8(ssBytes)) : []
83
+
84
+ // Resolve each sheet's rid to its worksheet XML bytes in the zip
85
+ const sheetNames = []
86
+ const sheetXmls = Object.create(null)
87
+ for (const { name, rid } of sheetDefs) {
88
+ let target = rels[rid]
89
+ if (!target) {
90
+ continue
91
+ }
92
+ // Targets are workbook-relative; normalize to the zip entry path
93
+ target = target.startsWith('/') ? target.slice(1) : 'xl/' + target
94
+ const bytes = unzipped[target]
95
+ if (!bytes) {
96
+ continue
97
+ }
98
+ sheetNames.push(name)
99
+ sheetXmls[name] = bytes
100
+ }
101
+
102
+ // Lazy materialization: parse a sheet only when first accessed
103
+ const parsedSheets = Object.create(null)
104
+ const sheetsProxy = new Proxy(
105
+ {},
106
+ {
107
+ get(_, name) {
108
+ if (typeof name !== 'string') {
109
+ return undefined
110
+ }
111
+ if (parsedSheets[name]) {
112
+ return parsedSheets[name]
113
+ }
114
+ const bytes = sheetXmls[name]
115
+ if (!bytes) {
116
+ return undefined
117
+ }
118
+ const parsed = parseSheetXml(strFromU8(bytes), sharedStrings)
119
+ parsedSheets[name] = parsed
120
+ return parsed
121
+ },
122
+ has(_, name) {
123
+ return typeof name === 'string' && name in sheetXmls
124
+ },
125
+ ownKeys() {
126
+ return sheetNames.slice()
127
+ },
128
+ getOwnPropertyDescriptor(_, name) {
129
+ if (typeof name !== 'string' || !(name in sheetXmls)) {
130
+ return undefined
131
+ }
132
+ const bytes = sheetXmls[name]
133
+ if (!parsedSheets[name]) {
134
+ parsedSheets[name] = parseSheetXml(strFromU8(bytes), sharedStrings)
135
+ }
136
+ return { enumerable: true, configurable: true, value: parsedSheets[name], writable: false }
137
+ }
138
+ }
139
+ )
140
+
141
+ // Cache the workbook by path so subsequent reads are free
142
+ const workbook = { SheetNames: sheetNames, Sheets: sheetsProxy }
143
+ workbookCache.set(pathname, workbook)
144
+ return workbook
145
+ }
146
+
147
+ //
148
+ // Cell address utilities
149
+ //
150
+
151
+ /**
152
+ * Decode an A1-style cell ref to a zero-indexed `{c, r}`.
153
+ *
154
+ * Returns `{c: -1, r: -1}` for invalid input, matching the behavior of
155
+ * `XLSX.utils.decode_cell` from SheetJS.
156
+ *
157
+ * @param {string} ref The cell reference (e.g. 'A1', 'AZ100').
158
+ * @returns The zero-indexed column and row.
159
+ */
160
+ export function decodeCell(ref) {
161
+ // Walk the leading run of letters, accumulating the 1-based column index
162
+ // in bijective base 26 (A=1, B=2, ..., Z=26, AA=27, AB=28, ...)
163
+ let c = 0
164
+ let i = 0
165
+ const len = ref.length
166
+ while (i < len) {
167
+ const code = ref.charCodeAt(i)
168
+ if (code >= 65 && code <= 90) {
169
+ c = c * 26 + (code - A_UPPER + 1)
170
+ } else if (code >= 97 && code <= 122) {
171
+ c = c * 26 + (code - A_LOWER + 1)
172
+ } else {
173
+ break
174
+ }
175
+ i++
176
+ }
177
+
178
+ // Reject input that didn't start with at least one letter
179
+ if (i === 0) {
180
+ return { c: -1, r: -1 }
181
+ }
182
+
183
+ // Parse the trailing row digits; rows are 1-based, so reject 0/non-numeric
184
+ const r = parseInt(ref.slice(i), 10)
185
+ if (!Number.isFinite(r) || r < 1) {
186
+ return { c: -1, r: -1 }
187
+ }
188
+
189
+ // Convert column and row to the zero-indexed form callers expect
190
+ return { c: c - 1, r: r - 1 }
191
+ }
192
+
193
+ /**
194
+ * Encode a zero-indexed `{c, r}` to an A1-style cell ref.
195
+ *
196
+ * @param {{c: number, r: number}} addr The zero-indexed column and row.
197
+ * @returns The A1-style cell reference (e.g. 'B7').
198
+ */
199
+ export function encodeCell({ c, r }) {
200
+ // Convert the 1-based column index to bijective base-26 letters, building
201
+ // the string right-to-left (one letter per iteration, least-significant first)
202
+ let col = ''
203
+ let n = c + 1
204
+ while (n > 0) {
205
+ const rem = (n - 1) % 26
206
+ col = String.fromCharCode(A_UPPER + rem) + col
207
+ n = Math.floor((n - 1) / 26)
208
+ }
209
+
210
+ // Rows in A1 notation are 1-based
211
+ return col + (r + 1)
212
+ }
213
+
214
+ /**
215
+ * Decode a column ref (e.g. 'AB') to a zero-indexed column number.
216
+ *
217
+ * @param {string} ref The column reference.
218
+ * @returns The zero-indexed column number, or -1 if the input is invalid.
219
+ */
220
+ export function decodeCol(ref) {
221
+ // Empty input has no valid column
222
+ if (ref.length === 0) {
223
+ return -1
224
+ }
225
+
226
+ // Same bijective base-26 walk as decodeCell, but every character must be a
227
+ // letter — anything else (including a row digit) is rejected
228
+ let c = 0
229
+ for (let i = 0; i < ref.length; i++) {
230
+ const code = ref.charCodeAt(i)
231
+ if (code >= 65 && code <= 90) {
232
+ c = c * 26 + (code - A_UPPER + 1)
233
+ } else if (code >= 97 && code <= 122) {
234
+ c = c * 26 + (code - A_LOWER + 1)
235
+ } else {
236
+ return -1
237
+ }
238
+ }
239
+
240
+ // Convert from 1-based to zero-indexed
241
+ return c - 1
242
+ }
243
+
244
+ /**
245
+ * Decode a row ref (e.g. '13') to a zero-indexed row number.
246
+ *
247
+ * @param {string} ref The row reference.
248
+ * @returns The zero-indexed row number, or -1 if the input is invalid.
249
+ */
250
+ export function decodeRow(ref) {
251
+ // Parse the 1-based row number and convert to zero-indexed; -1 on invalid input
252
+ const r = parseInt(ref, 10)
253
+ return Number.isFinite(r) && r >= 1 ? r - 1 : -1
254
+ }
255
+
256
+ //
257
+ // XML helpers
258
+ //
259
+
260
+ /**
261
+ * Pull one attribute value out of a tag's attribute list. Cheaper than a full
262
+ * attribute parser when we only need a few specific keys.
263
+ *
264
+ * @param {string} attrs The portion of a start tag containing the attributes.
265
+ * @param {string} name The attribute name to look up.
266
+ * @returns The attribute value, or undefined if the attribute is not present.
267
+ */
268
+ function getAttr(attrs, name) {
269
+ // Look for `name="` — every attribute we care about uses double quotes
270
+ const i = attrs.indexOf(name + '="')
271
+ if (i < 0) {
272
+ return undefined
273
+ }
274
+
275
+ // Slice out everything between the opening and closing quote
276
+ const start = i + name.length + 2
277
+ const end = attrs.indexOf('"', start)
278
+ return end < 0 ? undefined : attrs.slice(start, end)
279
+ }
280
+
281
+ /**
282
+ * Decode the standard XML entities (`&lt;`, `&gt;`, `&amp;`, `&quot;`,
283
+ * `&apos;`) along with numeric character references (`&#NN;` and `&#xNN;`)
284
+ * in the given text. Returns the input unchanged when no entities are present.
285
+ *
286
+ * @param {string} s The raw text from an XML element body or attribute.
287
+ * @returns The decoded string.
288
+ */
289
+ function decodeXmlText(s) {
290
+ // Fast path: most cell text contains no entities, so skip the regex chain
291
+ if (s.indexOf('&') === -1) {
292
+ return s
293
+ }
294
+
295
+ // Decode the named entities, then decimal and hex numeric refs, and finally
296
+ // `&amp;` — leaving `&amp;` last avoids accidentally producing `&lt;` etc.
297
+ // from a literal `&amp;lt;` in the source
298
+ return s
299
+ .replace(/&lt;/g, '<')
300
+ .replace(/&gt;/g, '>')
301
+ .replace(/&quot;/g, '"')
302
+ .replace(/&apos;/g, "'")
303
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
304
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
305
+ .replace(/&amp;/g, '&')
306
+ }
307
+
308
+ //
309
+ // Parsers
310
+ //
311
+
312
+ /**
313
+ * Parse the contents of `xl/sharedStrings.xml` into a positional array of
314
+ * decoded strings. A shared string may contain rich-text runs
315
+ * (`<si><r><t>foo</t></r><r><t>bar</t></r></si>`); concatenate all `<t>`
316
+ * elements within each `<si>` so the result comes back as a single string.
317
+ *
318
+ * @param {string} xml The contents of the `xl/sharedStrings.xml` file.
319
+ * @returns An array indexed by shared-string position.
320
+ */
321
+ function parseSharedStrings(xml) {
322
+ // Outer regex finds each <si> entry; inner regex finds <t> runs within it.
323
+ // The two are stateful (global) regexes, so reset the inner one per <si>.
324
+ const strings = []
325
+ const siRe = /<si\b[^>]*>([\s\S]*?)<\/si>/g
326
+ const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g
327
+ let m
328
+ while ((m = siRe.exec(xml)) !== null) {
329
+ // Concatenate every <t> chunk inside this <si> — rich-text runs collapse
330
+ // into a single plain string
331
+ const inner = m[1]
332
+ let s = ''
333
+ let tm
334
+ tRe.lastIndex = 0
335
+ while ((tm = tRe.exec(inner)) !== null) {
336
+ s += decodeXmlText(tm[1])
337
+ }
338
+ strings.push(s)
339
+ }
340
+ return strings
341
+ }
342
+
343
+ /**
344
+ * Parse the contents of `xl/workbook.xml` into the list of sheet definitions,
345
+ * each with the user-visible sheet name and the relationship id that maps to
346
+ * the sheet's XML part. Attribute spans can contain `/` (in URLs), so the
347
+ * regex matches up to the self-closing `/>` non-greedily rather than excluding
348
+ * `/` from the attribute span.
349
+ *
350
+ * @param {string} xml The contents of the `xl/workbook.xml` file.
351
+ * @returns An array of `{ name, rid }` objects in workbook order.
352
+ */
353
+ function parseWorkbookXml(xml) {
354
+ // Iterate every <sheet .../> element in workbook order
355
+ const sheets = []
356
+ const re = /<sheet\b([\s\S]*?)\/>/g
357
+ let m
358
+ while ((m = re.exec(xml)) !== null) {
359
+ // Each sheet is identified by its user-visible name and its rid pointer;
360
+ // the rid attribute is conventionally lowercase but accept both forms
361
+ const attrs = m[1]
362
+ const name = getAttr(attrs, 'name')
363
+ const rid = getAttr(attrs, 'r:id') ?? getAttr(attrs, 'r:Id')
364
+ if (name && rid) {
365
+ sheets.push({ name, rid })
366
+ }
367
+ }
368
+ return sheets
369
+ }
370
+
371
+ /**
372
+ * Parse the contents of `xl/_rels/workbook.xml.rels` into a map from
373
+ * relationship id to its target path (the part within the xlsx zip).
374
+ *
375
+ * @param {string} xml The contents of the `xl/_rels/workbook.xml.rels` file.
376
+ * @returns An object mapping relationship id to target path.
377
+ */
378
+ function parseWorkbookRels(xml) {
379
+ // Iterate every <Relationship .../> element
380
+ const rels = Object.create(null)
381
+ const re = /<Relationship\b([\s\S]*?)\/>/g
382
+ let m
383
+ while ((m = re.exec(xml)) !== null) {
384
+ // Record the Id -> Target mapping; ignore the Type and other attributes
385
+ const attrs = m[1]
386
+ const id = getAttr(attrs, 'Id')
387
+ const target = getAttr(attrs, 'Target')
388
+ if (id && target) {
389
+ rels[id] = target
390
+ }
391
+ }
392
+ return rels
393
+ }
394
+
395
+ /**
396
+ * Scan a worksheet's XML and build a sparse cell map shaped like the SheetJS
397
+ * worksheet object: `{ [cellRef]: { v }, '!ref': 'A1:Z99' }`. Skips empty
398
+ * cells, error cells (`t='e'`), and numeric cells whose cached `<v>` value
399
+ * is missing.
400
+ *
401
+ * @param {string} xml The contents of a `xl/worksheets/sheet*.xml` file.
402
+ * @param {string[]} sharedStrings The shared-string table for resolving `t='s'` cells.
403
+ * @returns The sparse cell map, including a `!ref` key if any cells were read.
404
+ */
405
+ function parseSheetXml(xml, sharedStrings) {
406
+ const cells = Object.create(null)
407
+
408
+ // Match each <c .../> or <c ...>...</c> block. The attribute span is
409
+ // non-greedy so self-closing cells (e.g. <c r="I4" s="1"/>) don't accidentally
410
+ // swallow following cells.
411
+ const cRe = /<c\b([^>]*?)(\/>|>([\s\S]*?)<\/c>)/g
412
+ let maxRow = -1
413
+ let maxCol = -1
414
+ let m
415
+ while ((m = cRe.exec(xml)) !== null) {
416
+ const attrs = m[1]
417
+ const ref = getAttr(attrs, 'r')
418
+ if (!ref) {
419
+ continue
420
+ }
421
+ if (m[2] === '/>') {
422
+ // empty cell
423
+ continue
424
+ }
425
+ const body = m[3]
426
+ if (!body) {
427
+ continue
428
+ }
429
+ const t = getAttr(attrs, 't')
430
+
431
+ let value
432
+ if (t === 's') {
433
+ // Shared string: <v>N</v> where N indexes sharedStrings
434
+ const vStart = body.indexOf('<v>')
435
+ if (vStart < 0) {
436
+ continue
437
+ }
438
+ const vEnd = body.indexOf('</v>', vStart + 3)
439
+ const idx = parseInt(body.slice(vStart + 3, vEnd), 10)
440
+ value = sharedStrings[idx]
441
+ } else if (t === 'inlineStr') {
442
+ // Inline string: <is><t>...</t></is>
443
+ const tStart = body.indexOf('<t')
444
+ if (tStart < 0) {
445
+ continue
446
+ }
447
+ const tOpenEnd = body.indexOf('>', tStart)
448
+ const tEnd = body.indexOf('</t>', tOpenEnd)
449
+ value = decodeXmlText(body.slice(tOpenEnd + 1, tEnd))
450
+ } else if (t === 'str') {
451
+ // Formula result as string: <v>...</v>
452
+ const vStart = body.indexOf('<v>')
453
+ if (vStart < 0) {
454
+ continue
455
+ }
456
+ const vEnd = body.indexOf('</v>', vStart + 3)
457
+ value = decodeXmlText(body.slice(vStart + 3, vEnd))
458
+ } else if (t === 'b') {
459
+ // Boolean: <v>0</v> or <v>1</v>
460
+ const vStart = body.indexOf('<v>')
461
+ if (vStart < 0) {
462
+ continue
463
+ }
464
+ value = body.charCodeAt(vStart + 3) === 49 // '1'
465
+ } else if (t === 'e') {
466
+ // Error cell, skip
467
+ continue
468
+ } else {
469
+ // Numeric (t === 'n' or absent). Skip any <f> formula tag and read the
470
+ // cached <v> value. If <v> is missing (e.g. an uncalculated formula),
471
+ // skip the cell so the caller's missing-cell handling kicks in.
472
+ const vStart = body.indexOf('<v>')
473
+ if (vStart < 0) {
474
+ continue
475
+ }
476
+ const vEnd = body.indexOf('</v>', vStart + 3)
477
+ const num = +body.slice(vStart + 3, vEnd)
478
+ if (Number.isNaN(num)) {
479
+ continue
480
+ }
481
+ value = num
482
+ }
483
+
484
+ // Store the cell under its A1 ref, matching the SheetJS sheet shape
485
+ cells[ref] = { v: value }
486
+
487
+ // Track the bounding row/col so we can synthesize the !ref range below
488
+ const addr = decodeCell(ref)
489
+ if (addr.r > maxRow) {
490
+ maxRow = addr.r
491
+ }
492
+ if (addr.c > maxCol) {
493
+ maxCol = addr.c
494
+ }
495
+ }
496
+
497
+ // Expose the sheet's bounding range as !ref when any cells were read
498
+ if (maxRow >= 0) {
499
+ cells['!ref'] = `A1:${encodeCell({ c: maxCol, r: maxRow })}`
500
+ }
501
+ return cells
502
+ }
@@ -1,8 +1,7 @@
1
1
  import path from 'node:path'
2
2
 
3
- import XLSX from 'xlsx'
4
-
5
3
  import { cdbl, readCsv, readXlsx } from '../_shared/helpers.js'
4
+ import { encodeCell } from '../_shared/xlsx.js'
6
5
 
7
6
  /**
8
7
  * Return a `getCellValue` function that reads the CSV or XLS[X] content.
@@ -47,7 +46,7 @@ function handleExcelWorkbook(fileOrTag, workbook, tab, dataKind, dataSource) {
47
46
  let sheet = workbook.Sheets[tab]
48
47
  if (sheet) {
49
48
  return (c, r) => {
50
- let cell = sheet[XLSX.utils.encode_cell({ c, r })]
49
+ let cell = sheet[encodeCell({ c, r })]
51
50
  if (cell == null || cell.v === '') {
52
51
  return null
53
52
  }
@@ -1,7 +1,6 @@
1
- import XLSX from 'xlsx'
2
-
3
1
  import { cartesianProductOf } from '../_shared/helpers.js'
4
2
  import { indexInSepDim, isDimension, sub } from '../_shared/subscript.js'
3
+ import { decodeCell } from '../_shared/xlsx.js'
5
4
 
6
5
  import { handleExcelOrCsvFile } from './direct-data-helpers.js'
7
6
 
@@ -73,7 +72,7 @@ export function generateDirectConstInit(variable, directData, modelDir) {
73
72
  // Read tabular data into an indexed variable for each cell.
74
73
  let numericSubscripts = lhsIndexSubscripts.map(idx => idx.map(s => sub(s).value))
75
74
  let lhsSubscripts = numericSubscripts.map(s => s.reduce((a, v) => a.concat(`[${v}]`), ''))
76
- let dataAddress = XLSX.utils.decode_cell(startCell.toUpperCase())
75
+ let dataAddress = decodeCell(startCell.toUpperCase())
77
76
  let startCol = dataAddress.c
78
77
  let startRow = dataAddress.r
79
78
  if (startCol < 0 || startRow < 0) {
@@ -387,6 +387,12 @@ function generateFunctionCall(callExpr, ctx) {
387
387
  }
388
388
  return generateAllocateAvailableCall(callExpr, ctx)
389
389
 
390
+ case '_ALLOCATE_BY_PRIORITY':
391
+ if (ctx.outFormat === 'js') {
392
+ throw new Error(`${callExpr.fnName} function not yet implemented for JS code gen`)
393
+ }
394
+ return generateAllocateByPriorityCall(callExpr, ctx)
395
+
390
396
  case '_ELMCOUNT':
391
397
  case '_SIZE': {
392
398
  // Emit the size of the dimension in place of the dimension name. Note that Vensim uses
@@ -944,6 +950,88 @@ function generateAllocateAvailableCall(callExpr, ctx) {
944
950
  return `${tmpVarId}[${allocDimId}[${allocLoopIndexVar}]]`
945
951
  }
946
952
 
953
+ /**
954
+ * Generate C/JS code for an `ALLOCATE BY PRIORITY` function call.
955
+ *
956
+ * @param {*} callExpr The function call expression from the parsed model.
957
+ * @param {GenExprContext} ctx The context used when generating code for the expression.
958
+ * @return {string} The generated C/JS code.
959
+ */
960
+ function generateAllocateByPriorityCall(callExpr, ctx) {
961
+ function validateArg(index, name) {
962
+ const arg = callExpr.args[index]
963
+ if (arg.kind === 'variable-ref') {
964
+ return arg
965
+ } else {
966
+ throw new Error(`ALLOCATE BY PRIORITY argument '${name}' must be a variable reference`)
967
+ }
968
+ }
969
+
970
+ // Given a C/JS variable reference string (e.g., '_var[i][j]'), return that
971
+ // string without the last N array index parts
972
+ function cVarRefWithoutLastIndices(arg, count) {
973
+ const varRef = ctx.cVarRef(arg)
974
+ const origIndexParts = Model.splitRefId(varRef).subscripts
975
+ if (origIndexParts < count) {
976
+ throw new Error(`ALLOCATE BY PRIORITY argument '${arg}' should have at least ${count} subscripts`)
977
+ }
978
+ const newIndexParts = origIndexParts.slice(0, -count)
979
+ if (newIndexParts.length > 0) {
980
+ return `${arg.varId}${newIndexParts.map(x => `[${x}]`).join('')}`
981
+ } else {
982
+ return arg.varId
983
+ }
984
+ }
985
+
986
+ // Process the request argument. Only include subscripts up until the last one;
987
+ // the implementation function will iterate over the requesters array.
988
+ const reqArg = validateArg(0, 'req')
989
+ const reqRef = cVarRefWithoutLastIndices(reqArg, 1)
990
+
991
+ // Process the priority argument. Only include subscripts up until the
992
+ // last one; the implementation function will iterate over the priorities
993
+ // array.
994
+ const priorityArg = validateArg(1, 'priority')
995
+ const priorityRef = cVarRefWithoutLastIndices(priorityArg, 1)
996
+
997
+ // Process the size argument
998
+ const sizeArg = generateExpr(callExpr.args[2], ctx)
999
+
1000
+ // Process the width argument
1001
+ const widthArg = generateExpr(callExpr.args[3], ctx)
1002
+
1003
+ // Process the supply argument
1004
+ const supplyArg = generateExpr(callExpr.args[4], ctx)
1005
+
1006
+ // The `ALLOCATE BY PRIORITY` function iterates over the last subscript in its first
1007
+ // argument, allocating the available quantity according to the priority values given
1008
+ // in the second argument. The `readEquation` process will have already verified that
1009
+ // the last dimension of both arguments matches the last dimension of the LHS.
1010
+ const allocDimId = reqArg.subscriptRefs[reqArg.subscriptRefs.length - 1].subId
1011
+ const allocLoopIndexVar = ctx.loopIndexVars.index(allocDimId)
1012
+
1013
+ // Generate the code that is emitted before the entire block (before any loops are opened)
1014
+ const tmpVarId = newTmpVarName()
1015
+ const numRequesters = sub(allocDimId).size
1016
+ switch (ctx.outFormat) {
1017
+ case 'c':
1018
+ ctx.emitPreInnerLoop(
1019
+ ` double* ${tmpVarId} = _ALLOCATE_BY_PRIORITY(${reqRef}, ${priorityRef}, ${sizeArg}, ${widthArg}, ${supplyArg}, ${numRequesters});`
1020
+ )
1021
+ break
1022
+ case 'js':
1023
+ ctx.emitPreInnerLoop(
1024
+ ` let ${tmpVarId} = fns.ALLOCATE_BY_PRIORITY(${reqRef}, ${priorityRef}, ${sizeArg}, ${widthArg}, ${supplyArg}, ${numRequesters});`
1025
+ )
1026
+ break
1027
+ default:
1028
+ throw new Error(`Unhandled output format '${ctx.outFormat}'`)
1029
+ }
1030
+
1031
+ // Generate the RHS expression used in the inner loop
1032
+ return `${tmpVarId}[${allocDimId}[${allocLoopIndexVar}]]`
1033
+ }
1034
+
947
1035
  /**
948
1036
  * Recursively traverse the given expression and call the function when visiting a variable ref.
949
1037
  *
@@ -1,8 +1,8 @@
1
1
  import * as R from 'ramda'
2
- import XLSX from 'xlsx'
3
2
 
4
3
  import { listConcat } from '../_shared/helpers.js'
5
4
  import { sub } from '../_shared/subscript.js'
5
+ import { decodeCell, decodeCol, decodeRow } from '../_shared/xlsx.js'
6
6
 
7
7
  import { handleExcelOrCsvFile } from './direct-data-helpers.js'
8
8
 
@@ -61,7 +61,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
61
61
  // The cell(c,r) function wraps data access by column and row.
62
62
  let lookupData = ''
63
63
  let lookupSize = 0
64
- let dataAddress = XLSX.utils.decode_cell(startCell.toUpperCase())
64
+ let dataAddress = decodeCell(startCell.toUpperCase())
65
65
  let dataCol = dataAddress.c
66
66
  let dataRow = dataAddress.r
67
67
  if (dataCol < 0 || dataRow < 0) {
@@ -71,7 +71,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
71
71
  let timeCol, timeRow, nextCell
72
72
  if (isNaN(parseInt(timeRowOrCol))) {
73
73
  // Time values are in a column.
74
- timeCol = XLSX.utils.decode_col(timeRowOrCol.toUpperCase())
74
+ timeCol = decodeCol(timeRowOrCol.toUpperCase())
75
75
  timeRow = dataRow
76
76
  dataCol += indexNum
77
77
  nextCell = () => {
@@ -81,7 +81,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
81
81
  } else {
82
82
  // Time values are in a row.
83
83
  timeCol = dataCol
84
- timeRow = XLSX.utils.decode_row(timeRowOrCol)
84
+ timeRow = decodeRow(timeRowOrCol)
85
85
  dataRow += indexNum
86
86
  nextCell = () => {
87
87
  dataCol++
@@ -481,6 +481,11 @@ function visitFunctionCall(v, callExpr, context) {
481
481
  validateCallArgs(callExpr, 3)
482
482
  break
483
483
 
484
+ case '_ALLOCATE_BY_PRIORITY':
485
+ validateCallDepth(callExpr, context)
486
+ validateCallArgs(callExpr, 5)
487
+ break
488
+
484
489
  case '_DELAY1':
485
490
  case '_DELAY1I':
486
491
  case '_DELAY3':
@@ -880,6 +885,9 @@ function visitFunctionCall(v, callExpr, context) {
880
885
  }
881
886
  }
882
887
  continue
888
+ } else if (callExpr.fnId === '_ALLOCATE_BY_PRIORITY') {
889
+ // TODO: Throw an error if the last dimension of arg0 does not match last dimension of LHS
890
+ // TODO: Throw an error if the last dimension of arg1 does not match last dimension of LHS
883
891
  }
884
892
 
885
893
  context.setArgIndex(index, argModes[index])
@@ -1,7 +1,6 @@
1
- import XLSX from 'xlsx'
2
-
3
1
  import { readCsv } from '../_shared/helpers.js'
4
2
  import { Subscript } from '../_shared/subscript.js'
3
+ import { decodeCell } from '../_shared/xlsx.js'
5
4
 
6
5
  /**
7
6
  * Read the dimension definitions from the given model.
@@ -46,7 +45,7 @@ export function readDimensionDefs(parsedModel) {
46
45
  */
47
46
  export function getDirectSubscripts(fileName, tabOrDelimiter, firstCell, lastCell) {
48
47
  // If lastCell is a column letter, scan the column, else scan the row
49
- const dataAddress = XLSX.utils.decode_cell(firstCell.toUpperCase())
48
+ const dataAddress = decodeCell(firstCell.toUpperCase())
50
49
  let col = dataAddress.c
51
50
  let row = dataAddress.r
52
51
  if (col < 0 || row < 0) {