@particle-academy/holy-sheet 2.11.0 → 2.11.2

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.
@@ -780,7 +780,7 @@ var Normalizer = class {
780
780
  address,
781
781
  new Cell(
782
782
  address,
783
- this.coerceValue(rawValue, format, address, sheetName2),
783
+ this.coerceValue(rawValue, format, address, sheetName2, true),
784
784
  cellData?.formula ?? null,
785
785
  format,
786
786
  comment,
@@ -873,7 +873,7 @@ var Normalizer = class {
873
873
  const rawValue = value.value ?? null;
874
874
  return new Cell(
875
875
  address,
876
- this.coerceValue(rawValue, merged, address, sheetName2),
876
+ this.coerceValue(rawValue, merged, address, sheetName2, true),
877
877
  value.formula ?? null,
878
878
  merged,
879
879
  comment,
@@ -888,7 +888,15 @@ var Normalizer = class {
888
888
  columnFormat
889
889
  );
890
890
  }
891
- coerceValue(value, format, address = "?", sheetName2) {
891
+ /**
892
+ * `explicit`: the value came from an object cell's `value` key. An explicit
893
+ * string is the caller saying "this text, exactly", so it is never coerced
894
+ * to a number: `{value: "+1"}` stays "+1" and `{value: "007"}` stays "007".
895
+ * Only a date/datetime display format still turns a string into a serial,
896
+ * because that conversion is what the format asks for. Bare values keep
897
+ * numeric-string coercion. Mirrors PHP `Normalizer::coerceValue`.
898
+ */
899
+ coerceValue(value, format, address = "?", sheetName2, explicit = false) {
892
900
  if (value === null || value === void 0) return null;
893
901
  const df = format?.displayFormat;
894
902
  if (df === "date" || df === "datetime") {
@@ -896,6 +904,9 @@ var Normalizer = class {
896
904
  return DateConverter.toSerial(value, df === "datetime");
897
905
  }
898
906
  }
907
+ if (explicit && typeof value === "string") {
908
+ return value;
909
+ }
899
910
  if (typeof value === "string" && isNumericString(value)) {
900
911
  return numericStringToNumber(value);
901
912
  }
@@ -1044,13 +1055,46 @@ var ERR_REF = "#REF!";
1044
1055
  var ERR_NAME = "#NAME?";
1045
1056
  var ERR_DIV0 = "#DIV/0!";
1046
1057
  var ERR_CIRC = "#CIRC!";
1058
+ var FUNCTIONS = /* @__PURE__ */ new Set([
1059
+ "SUM",
1060
+ "AVERAGE",
1061
+ "AVG",
1062
+ "COUNT",
1063
+ "COUNTA",
1064
+ "MIN",
1065
+ "MAX",
1066
+ "IF",
1067
+ "ROUND",
1068
+ "ABS",
1069
+ "LEN",
1070
+ "UPPER",
1071
+ "LOWER",
1072
+ "CONCAT",
1073
+ "CONCATENATE",
1074
+ "TRUE",
1075
+ "FALSE"
1076
+ ]);
1047
1077
  var SHEET_QUALIFIER = "('(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
1048
- var LinterError = class extends Error {
1049
- constructor(errorCode) {
1078
+ var LinterError = class _LinterError extends Error {
1079
+ /**
1080
+ * @param cause Why a #NAME? arose in THIS formula: "syntax" or "function".
1081
+ * Null for an error that was computed, or inherited from another cell.
1082
+ * @param detail The syntax fault, or the unknown function's name.
1083
+ */
1084
+ constructor(errorCode, cause = null, detail = "") {
1050
1085
  super(errorCode);
1051
1086
  this.errorCode = errorCode;
1087
+ this.cause = cause;
1088
+ this.detail = detail;
1089
+ }
1090
+ static syntax(detail) {
1091
+ return new _LinterError(ERR_NAME, "syntax", detail);
1052
1092
  }
1053
1093
  };
1094
+ var isSpace = (ch) => ch === " " || ch >= " " && ch <= "\r";
1095
+ var isDigit = (ch) => ch >= "0" && ch <= "9";
1096
+ var isAlpha = (ch) => ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z";
1097
+ var isAlnum = (ch) => isAlpha(ch) || isDigit(ch);
1054
1098
  var FormulaLinter = class {
1055
1099
  constructor() {
1056
1100
  this.index = /* @__PURE__ */ new Map();
@@ -1061,10 +1105,25 @@ var FormulaLinter = class {
1061
1105
  * `#REF!` rather than a range of blanks.
1062
1106
  */
1063
1107
  this.sheetNames = /* @__PURE__ */ new Map();
1108
+ /**
1109
+ * Why a cell's own formula produced #NAME?, keyed `Sheet!A1`: a syntax
1110
+ * error, an unknown function, or a linter fault. A cell with #NAME? and no
1111
+ * entry here inherited it from a cell it references.
1112
+ */
1113
+ this.nameCauses = /* @__PURE__ */ new Map();
1114
+ /**
1115
+ * Greater than 0 while parsing an IF branch that is not taken. The parser
1116
+ * evaluates as it goes, so a skipped branch is still PARSED (its syntax
1117
+ * errors and unknown functions are reported) but nothing in it is
1118
+ * EVALUATED: no operator runs, no cell is read, and every value is null.
1119
+ */
1120
+ this.skipping = 0;
1064
1121
  }
1065
1122
  lint(schema) {
1066
1123
  const workbook = new Normalizer().normalize(schema);
1067
1124
  this.sheetNames = new Map(workbook.sheets.map((sheet) => [sheet.name.toLowerCase(), sheet.name]));
1125
+ this.nameCauses = /* @__PURE__ */ new Map();
1126
+ this.skipping = 0;
1068
1127
  this.index = this.buildIndex(workbook);
1069
1128
  this.cache = /* @__PURE__ */ new Map();
1070
1129
  const issues = [];
@@ -1080,7 +1139,7 @@ var FormulaLinter = class {
1080
1139
  address,
1081
1140
  formula: cell.formula,
1082
1141
  error: result,
1083
- hint: this.hint(result, cell.formula, sheet.name)
1142
+ hint: this.hint(result, key, cell.formula, sheet.name)
1084
1143
  });
1085
1144
  }
1086
1145
  }
@@ -1100,25 +1159,39 @@ var FormulaLinter = class {
1100
1159
  return typeof v === "string" && v.length > 0 && v[0] === "#";
1101
1160
  }
1102
1161
  evaluate(formula2, defaultSheet, stack) {
1162
+ const key = stack[stack.length - 1];
1163
+ this.nameCauses.delete(key);
1103
1164
  try {
1104
1165
  const c = { tokens: this.tokenize(formula2), pos: 0 };
1105
1166
  const result = this.parseExpr(c, defaultSheet, stack);
1106
- if (c.pos < c.tokens.length) return ERR_NAME;
1167
+ if (c.pos < c.tokens.length) {
1168
+ throw LinterError.syntax(`unexpected ${this.describeToken(c.tokens[c.pos])} after a complete expression`);
1169
+ }
1107
1170
  return result;
1108
1171
  } catch (e) {
1109
- if (e instanceof LinterError) return e.errorCode;
1172
+ if (e instanceof LinterError) {
1173
+ if (e.cause !== null) this.nameCauses.set(key, { cause: e.cause, detail: e.detail });
1174
+ return e.errorCode;
1175
+ }
1176
+ this.nameCauses.set(key, { cause: "internal", detail: e instanceof Error ? e.constructor.name : typeof e });
1110
1177
  return ERR_NAME;
1111
1178
  }
1112
1179
  }
1180
+ describeToken(tok) {
1181
+ switch (tok.type) {
1182
+ case "STRING":
1183
+ return 'text "' + tok.value + '"';
1184
+ case "NUMBER":
1185
+ return "number " + tok.value;
1186
+ default:
1187
+ return "'" + tok.value + "'";
1188
+ }
1189
+ }
1113
1190
  // ---- Tokenizer ----
1114
1191
  tokenize(src) {
1115
1192
  const tokens = [];
1116
1193
  let i = 0;
1117
1194
  const len = src.length;
1118
- const isSpace = (ch) => /\s/.test(ch);
1119
- const isDigit = (ch) => ch >= "0" && ch <= "9";
1120
- const isAlpha = (ch) => /[A-Za-z]/.test(ch);
1121
- const isAlnum = (ch) => /[A-Za-z0-9]/.test(ch);
1122
1195
  while (i < len) {
1123
1196
  const ch = src[i];
1124
1197
  if (isSpace(ch)) {
@@ -1133,10 +1206,21 @@ var FormulaLinter = class {
1133
1206
  }
1134
1207
  if (ch === '"') {
1135
1208
  i++;
1136
- const start = i;
1137
- while (i < len && src[i] !== '"') i++;
1138
- const value = src.slice(start, i);
1139
- i++;
1209
+ let value = "";
1210
+ for (; ; ) {
1211
+ if (i >= len) throw LinterError.syntax('a text literal opens with " and never closes');
1212
+ if (src[i] === '"') {
1213
+ if (i + 1 < len && src[i + 1] === '"') {
1214
+ value += '"';
1215
+ i += 2;
1216
+ continue;
1217
+ }
1218
+ i++;
1219
+ break;
1220
+ }
1221
+ value += src[i];
1222
+ i++;
1223
+ }
1140
1224
  tokens.push({ type: "STRING", value });
1141
1225
  continue;
1142
1226
  }
@@ -1144,7 +1228,7 @@ var FormulaLinter = class {
1144
1228
  const start = i;
1145
1229
  i++;
1146
1230
  for (; ; ) {
1147
- if (i >= len) throw new LinterError(ERR_NAME);
1231
+ if (i >= len) throw LinterError.syntax("a quoted sheet name opens with ' and never closes");
1148
1232
  if (src[i] === "'") {
1149
1233
  if (i + 1 < len && src[i + 1] === "'") {
1150
1234
  i += 2;
@@ -1177,7 +1261,9 @@ var FormulaLinter = class {
1177
1261
  i++;
1178
1262
  continue;
1179
1263
  }
1180
- throw new LinterError(ERR_NAME);
1264
+ throw LinterError.syntax(
1265
+ ch.charCodeAt(0) < 128 ? `unexpected character '${ch}'` : "unexpected non-ASCII character outside a text literal"
1266
+ );
1181
1267
  }
1182
1268
  return tokens;
1183
1269
  }
@@ -1195,7 +1281,7 @@ var FormulaLinter = class {
1195
1281
  const op = c.tokens[c.pos].value;
1196
1282
  c.pos++;
1197
1283
  const right = this.parseConcat(c, sheet, stack);
1198
- left = this.compare(left, right, op);
1284
+ left = this.skipping > 0 ? null : this.compare(left, right, op);
1199
1285
  }
1200
1286
  return left;
1201
1287
  }
@@ -1204,7 +1290,7 @@ var FormulaLinter = class {
1204
1290
  while (this.isOp(c, "&")) {
1205
1291
  c.pos++;
1206
1292
  const right = this.parseArith(c, sheet, stack);
1207
- left = this.coerceString(left) + this.coerceString(right);
1293
+ left = this.skipping > 0 ? null : this.coerceString(left) + this.coerceString(right);
1208
1294
  }
1209
1295
  return left;
1210
1296
  }
@@ -1214,6 +1300,10 @@ var FormulaLinter = class {
1214
1300
  const op = c.tokens[c.pos].value;
1215
1301
  c.pos++;
1216
1302
  const right = this.parseTerm(c, sheet, stack);
1303
+ if (this.skipping > 0) {
1304
+ left = null;
1305
+ continue;
1306
+ }
1217
1307
  const a = this.coerceNumber(left);
1218
1308
  const b = this.coerceNumber(right);
1219
1309
  left = op === "+" ? a + b : a - b;
@@ -1221,46 +1311,68 @@ var FormulaLinter = class {
1221
1311
  return left;
1222
1312
  }
1223
1313
  parseTerm(c, sheet, stack) {
1224
- let left = this.parseUnary(c, sheet, stack);
1225
- while (this.isOp(c, "*", "/", "%", "^")) {
1314
+ let left = this.parsePower(c, sheet, stack);
1315
+ while (this.isOp(c, "*", "/")) {
1226
1316
  const op = c.tokens[c.pos].value;
1227
1317
  c.pos++;
1228
- const right = this.parseUnary(c, sheet, stack);
1318
+ const right = this.parsePower(c, sheet, stack);
1319
+ if (this.skipping > 0) {
1320
+ left = null;
1321
+ continue;
1322
+ }
1229
1323
  const a = this.coerceNumber(left);
1230
1324
  const b = this.coerceNumber(right);
1231
- switch (op) {
1232
- case "*":
1233
- left = a * b;
1234
- break;
1235
- case "/":
1236
- if (b === 0) throw new LinterError(ERR_DIV0);
1237
- left = a / b;
1238
- break;
1239
- case "%":
1240
- left = a / 100 * b;
1241
- break;
1242
- case "^":
1243
- left = a ** b;
1244
- break;
1325
+ if (op === "*") {
1326
+ left = a * b;
1327
+ } else {
1328
+ if (b === 0) throw new LinterError(ERR_DIV0);
1329
+ left = a / b;
1245
1330
  }
1246
1331
  }
1247
1332
  return left;
1248
1333
  }
1334
+ /**
1335
+ * `^` binds tighter than `*` and `/` (=2*3^2 is 18) and, as in Excel, is
1336
+ * left-associative (=2^3^2 is 64). Negation binds tighter than `^`, so
1337
+ * =-2^2 is 4: Excel's order, not mathematics'.
1338
+ */
1339
+ parsePower(c, sheet, stack) {
1340
+ let left = this.parseUnary(c, sheet, stack);
1341
+ while (this.isOp(c, "^")) {
1342
+ c.pos++;
1343
+ const right = this.parseUnary(c, sheet, stack);
1344
+ left = this.skipping > 0 ? null : this.coerceNumber(left) ** this.coerceNumber(right);
1345
+ }
1346
+ return left;
1347
+ }
1249
1348
  parseUnary(c, sheet, stack) {
1250
1349
  if (this.isOp(c, "-")) {
1251
1350
  c.pos++;
1252
1351
  const v = this.parseUnary(c, sheet, stack);
1253
- return -this.coerceNumber(v);
1352
+ return this.skipping > 0 ? null : -this.coerceNumber(v);
1254
1353
  }
1255
1354
  if (this.isOp(c, "+")) {
1256
1355
  c.pos++;
1257
1356
  return this.parseUnary(c, sheet, stack);
1258
1357
  }
1259
- return this.parsePrimary(c, sheet, stack);
1358
+ return this.parsePercent(c, sheet, stack);
1359
+ }
1360
+ /**
1361
+ * Excel's `%` is a POSTFIX operator that divides by 100: `50%` is 0.5 and
1362
+ * `A1%` is A1/100. It binds tighter than `^`, `*` and `/`. There is no
1363
+ * binary `%`, so `10%2` leaves a stray `2` and is a syntax error.
1364
+ */
1365
+ parsePercent(c, sheet, stack) {
1366
+ let value = this.parsePrimary(c, sheet, stack);
1367
+ while (this.isOp(c, "%")) {
1368
+ c.pos++;
1369
+ value = this.skipping > 0 ? null : this.coerceNumber(value) / 100;
1370
+ }
1371
+ return value;
1260
1372
  }
1261
1373
  parsePrimary(c, sheet, stack) {
1262
1374
  const tok = this.peek(c);
1263
- if (!tok) throw new LinterError(ERR_NAME);
1375
+ if (!tok) throw LinterError.syntax("the formula ends where a value was expected");
1264
1376
  if (tok.type === "OP" && tok.value === "(") {
1265
1377
  c.pos++;
1266
1378
  const val = this.parseExpr(c, sheet, stack);
@@ -1280,15 +1392,19 @@ var FormulaLinter = class {
1280
1392
  const next2 = c.tokens[c.pos + 2];
1281
1393
  if (next && next.type === "OP" && next.value === "!" && next2 && next2.type === "IDENT") {
1282
1394
  const sheetName2 = this.sheetNames.get(this.cleanSheetName(tok.value).toLowerCase());
1283
- if (sheetName2 === void 0) throw new LinterError(ERR_REF);
1395
+ if (sheetName2 === void 0 && this.skipping === 0) throw new LinterError(ERR_REF);
1284
1396
  c.pos += 2;
1285
1397
  const startTok = c.tokens[c.pos].value;
1286
1398
  c.pos++;
1287
- return this.resolveRefOrRange(startTok, sheetName2, c, stack);
1399
+ return this.resolveRefOrRange(startTok, sheetName2 ?? tok.value, c, stack);
1400
+ }
1401
+ if (tok.type === "SHEET") {
1402
+ throw LinterError.syntax(`the quoted name ${tok.value} is not followed by ! and a cell reference`);
1288
1403
  }
1289
- if (tok.type === "SHEET") throw new LinterError(ERR_NAME);
1290
1404
  if (next && next.type === "OP" && next.value === "(") {
1291
1405
  const name = tok.value.toUpperCase();
1406
+ if (!FUNCTIONS.has(name)) throw new LinterError(ERR_NAME, "function", name);
1407
+ if (name === "IF") return this.parseIf(c, sheet, stack);
1292
1408
  c.pos += 2;
1293
1409
  const args = [];
1294
1410
  if (!this.isOp(c, ")")) {
@@ -1302,7 +1418,7 @@ var FormulaLinter = class {
1302
1418
  }
1303
1419
  }
1304
1420
  this.expectOp(c, ")");
1305
- return this.callFunction(name, args);
1421
+ return this.skipping > 0 ? null : this.callFunction(name, args);
1306
1422
  }
1307
1423
  const upper = tok.value.toUpperCase();
1308
1424
  if (upper === "TRUE" || upper === "FALSE") {
@@ -1312,13 +1428,65 @@ var FormulaLinter = class {
1312
1428
  c.pos++;
1313
1429
  return this.resolveRefOrRange(tok.value, sheet, c, stack);
1314
1430
  }
1315
- throw new LinterError(ERR_NAME);
1431
+ throw LinterError.syntax(`unexpected ${this.describeToken(tok)} where a value was expected`);
1432
+ }
1433
+ /**
1434
+ * IF, evaluated lazily as Excel does: `=IF(A2=0,0,1/A2)` with A2 = 0 is 0,
1435
+ * not #DIV/0!. Every argument is parsed, so a syntax error or an unknown
1436
+ * function anywhere in it is still reported, but only the condition and the
1437
+ * branch it selects are evaluated.
1438
+ *
1439
+ * The order of the verdicts is deliberate. A syntax error anywhere wins,
1440
+ * because Excel refuses a formula it cannot parse and computes nothing.
1441
+ * Then fewer than two arguments is #VALUE!. Then an error in the condition
1442
+ * propagates, and neither branch is evaluated.
1443
+ */
1444
+ parseIf(c, sheet, stack) {
1445
+ c.pos += 2;
1446
+ const args = [];
1447
+ let take = null;
1448
+ let conditionError = null;
1449
+ if (!this.isOp(c, ")")) {
1450
+ for (; ; ) {
1451
+ const i = args.length;
1452
+ const skip = i > 0 && i !== take;
1453
+ if (skip) this.skipping++;
1454
+ try {
1455
+ args.push(this.parseExpr(c, sheet, stack));
1456
+ } finally {
1457
+ if (skip) this.skipping--;
1458
+ }
1459
+ if (i === 0 && this.skipping === 0) {
1460
+ try {
1461
+ take = this.toLogical(args[0]) ? 1 : 2;
1462
+ } catch (e) {
1463
+ if (!(e instanceof LinterError)) throw e;
1464
+ conditionError = e;
1465
+ }
1466
+ }
1467
+ if (this.isOp(c, ",")) {
1468
+ c.pos++;
1469
+ continue;
1470
+ }
1471
+ break;
1472
+ }
1473
+ }
1474
+ this.expectOp(c, ")");
1475
+ if (this.skipping > 0) return null;
1476
+ if (args.length < 2) return ERR_VALUE;
1477
+ if (conditionError !== null) throw conditionError;
1478
+ return take === 1 ? args[1] : args[2] ?? false;
1316
1479
  }
1317
1480
  resolveRefOrRange(startRef, defaultSheet, c, stack) {
1481
+ const next = c.tokens[c.pos + 1];
1482
+ const isRange = this.isOp(c, ":") && !!next && next.type === "IDENT";
1483
+ if (this.skipping > 0) {
1484
+ if (isRange) c.pos += 2;
1485
+ return null;
1486
+ }
1318
1487
  const cleanStart = this.cleanRef(startRef);
1319
1488
  if (cleanStart === null) throw new LinterError(ERR_REF);
1320
- const next = c.tokens[c.pos + 1];
1321
- if (this.isOp(c, ":") && next && next.type === "IDENT") {
1489
+ if (isRange) {
1322
1490
  const endRef = next.value;
1323
1491
  c.pos += 2;
1324
1492
  const cleanEnd = this.cleanRef(endRef);
@@ -1372,7 +1540,10 @@ var FormulaLinter = class {
1372
1540
  }
1373
1541
  expectOp(c, op) {
1374
1542
  const t = c.tokens[c.pos];
1375
- if (!t || t.type !== "OP" || t.value !== op) throw new LinterError(ERR_NAME);
1543
+ if (!t) throw LinterError.syntax(`expected '${op}', but the formula ends`);
1544
+ if (t.type !== "OP" || t.value !== op) {
1545
+ throw LinterError.syntax(`expected '${op}', found ${this.describeToken(t)}`);
1546
+ }
1376
1547
  c.pos++;
1377
1548
  }
1378
1549
  // ---- Coercion / comparison / functions ----
@@ -1417,17 +1588,28 @@ var FormulaLinter = class {
1417
1588
  return false;
1418
1589
  }
1419
1590
  }
1591
+ /**
1592
+ * Excel's comparison. An error on either side propagates. Otherwise the
1593
+ * types rank number < text < logical, so ="5">9 is TRUE: numeric TEXT is
1594
+ * text, never a number. Text compares without regard to case. A blank
1595
+ * takes the other side's type (0, "" or FALSE); two blanks are equal.
1596
+ */
1420
1597
  cmpVal(a, b) {
1421
- const an = typeof a === "number" || typeof a === "string" && isNumericString(a);
1422
- const bn = typeof b === "number" || typeof b === "string" && isNumericString(b);
1423
- if (an && bn) {
1424
- const x = Number(a);
1425
- const y = Number(b);
1426
- return x < y ? -1 : x > y ? 1 : 0;
1427
- }
1428
- const sa = String(a);
1429
- const sb = String(b);
1430
- return sa < sb ? -1 : sa > sb ? 1 : 0;
1598
+ if (this.isError(a)) throw new LinterError(a);
1599
+ if (this.isError(b)) throw new LinterError(b);
1600
+ if (Array.isArray(a) || Array.isArray(b)) {
1601
+ return compareCodePoints(String(a), String(b));
1602
+ }
1603
+ const x = a ?? blankLike(b);
1604
+ const y = b ?? blankLike(x);
1605
+ const rank = (v) => typeof v === "boolean" ? 2 : typeof v === "string" ? 1 : 0;
1606
+ if (rank(x) !== rank(y)) return rank(x) < rank(y) ? -1 : 1;
1607
+ if (typeof x === "string") {
1608
+ return compareCodePoints(x.toLowerCase(), String(y).toLowerCase());
1609
+ }
1610
+ const p = Number(x);
1611
+ const q = Number(y);
1612
+ return p < q ? -1 : p > q ? 1 : 0;
1431
1613
  }
1432
1614
  callFunction(name, args) {
1433
1615
  const flat = this.flatten(args);
@@ -1445,10 +1627,8 @@ var FormulaLinter = class {
1445
1627
  return this.minMax(flat, true);
1446
1628
  case "MAX":
1447
1629
  return this.minMax(flat, false);
1448
- case "IF":
1449
- return this.ifFn(args);
1450
1630
  case "ROUND":
1451
- return phpRound(this.coerceNumber(args[0] ?? 0), Math.trunc(this.coerceNumber(args[1] ?? 0)));
1631
+ return excelRound(this.coerceNumber(args[0] ?? 0), Math.trunc(this.coerceNumber(args[1] ?? 0)));
1452
1632
  case "ABS":
1453
1633
  return Math.abs(this.coerceNumber(args[0] ?? 0));
1454
1634
  case "LEN":
@@ -1465,7 +1645,7 @@ var FormulaLinter = class {
1465
1645
  case "FALSE":
1466
1646
  return false;
1467
1647
  default:
1468
- return ERR_NAME;
1648
+ throw new LinterError(ERR_NAME, "function", name);
1469
1649
  }
1470
1650
  }
1471
1651
  flatten(args) {
@@ -1493,21 +1673,34 @@ var FormulaLinter = class {
1493
1673
  const vals = nums.map((v) => this.maybeNum(v));
1494
1674
  return min ? Math.min(...vals) : Math.max(...vals);
1495
1675
  }
1496
- ifFn(args) {
1497
- if (args.length < 2) return ERR_VALUE;
1498
- const cond = args[0];
1499
- const truthy = typeof cond === "boolean" ? cond : cond !== null && cond !== 0 && cond !== "" && cond !== "FALSE";
1500
- return truthy ? args[1] : args[2] ?? false;
1676
+ /**
1677
+ * Excel's reading of an IF condition: an error propagates, a blank is
1678
+ * FALSE, a number is TRUE unless it is 0, and text is TRUE or FALSE only
1679
+ * when it spells one of them (in any case). Any other text, "" included,
1680
+ * is #VALUE!.
1681
+ */
1682
+ toLogical(v) {
1683
+ if (this.isError(v)) throw new LinterError(v);
1684
+ if (typeof v === "boolean") return v;
1685
+ if (v === null) return false;
1686
+ if (typeof v === "number") return v !== 0;
1687
+ if (typeof v === "string") {
1688
+ const upper = v.toUpperCase();
1689
+ if (upper === "TRUE") return true;
1690
+ if (upper === "FALSE") return false;
1691
+ throw new LinterError(ERR_VALUE);
1692
+ }
1693
+ return true;
1501
1694
  }
1502
1695
  // ---- Hints ----
1503
- hint(error2, formula2, sheet) {
1696
+ hint(error2, key, formula2, sheet) {
1504
1697
  switch (error2) {
1505
1698
  case ERR_VALUE:
1506
1699
  return this.hintValue(formula2, sheet);
1507
1700
  case ERR_REF:
1508
1701
  return this.hintRef(formula2);
1509
1702
  case ERR_NAME:
1510
- return "The formula references an unknown function or has a syntax error. Holy Sheet supports: SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, IF, ROUND, ABS, LEN, UPPER, LOWER, CONCAT.";
1703
+ return this.hintName(key, formula2, sheet);
1511
1704
  case ERR_DIV0:
1512
1705
  return "Division by zero \u2014 the divisor evaluated to 0.";
1513
1706
  case ERR_CIRC:
@@ -1516,6 +1709,39 @@ var FormulaLinter = class {
1516
1709
  return "Formula evaluation failed.";
1517
1710
  }
1518
1711
  }
1712
+ /**
1713
+ * #NAME? has three causes in this formula, plus inheritance, and each hint
1714
+ * says which one it is. A syntax error used to be told it "references an
1715
+ * unknown function", and an agent went hunting for a function that was fine.
1716
+ * The texts are byte-identical to PHP's and Python's: see PHP
1717
+ * `FormulaLinter::hintName` for why a syntax error keeps the #NAME? label.
1718
+ */
1719
+ hintName(key, formula2, sheet) {
1720
+ const cause = this.nameCauses.get(key);
1721
+ switch (cause?.cause) {
1722
+ case "syntax":
1723
+ return `Syntax error: ${cause.detail}. Excel cannot parse this formula. Check for an unbalanced parenthesis or quote, a missing operator or argument, or a character a formula cannot contain. Text goes in double quotes, and a quote inside text is written twice: "a""b".`;
1724
+ case "function":
1725
+ return `Unknown function ${cause.detail}. Holy Sheet supports: SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, IF, ROUND, ABS, LEN, UPPER, LOWER, CONCAT.`;
1726
+ case "internal":
1727
+ return `The linter itself failed on this formula (${cause.detail}). That is a holy-sheet defect, not evidence that the formula is wrong.`;
1728
+ default:
1729
+ return this.hintInheritedName(formula2, sheet);
1730
+ }
1731
+ }
1732
+ hintInheritedName(formula2, sheet) {
1733
+ const re = new RegExp(`(?:${SHEET_QUALIFIER})?\\$?([A-Z]+)\\$?(\\d+)`, "gi");
1734
+ const sources = [];
1735
+ for (const m of formula2.matchAll(re)) {
1736
+ const sheetName2 = m[1] ? this.sheetNames.get(this.cleanSheetName(m[1]).toLowerCase()) ?? this.cleanSheetName(m[1]) : sheet;
1737
+ const a1 = m[2].toUpperCase() + m[3];
1738
+ if (this.cache.get(sheetName2 + "!" + a1) === ERR_NAME) {
1739
+ const source = m[1] ? m[1] + "!" + a1 : a1;
1740
+ if (!sources.includes(source)) sources.push(source);
1741
+ }
1742
+ }
1743
+ return sources.length === 0 ? "This formula depends on a cell whose value is #NAME?. Fix that cell first." : `This formula depends on ${sources.join(", ")}, whose value is #NAME?. Fix that cell first.`;
1744
+ }
1519
1745
  hintRef(formula2) {
1520
1746
  for (const m of formula2.matchAll(new RegExp(SHEET_QUALIFIER, "g"))) {
1521
1747
  const name = this.cleanSheetName(m[1]);
@@ -1553,9 +1779,30 @@ var FormulaLinter = class {
1553
1779
  return "A non-numeric value was used in arithmetic. Check that all referenced cells contain numbers.";
1554
1780
  }
1555
1781
  };
1556
- function phpRound(x, n) {
1557
- const factor = 10 ** n;
1558
- return Math.sign(x) * Math.round(Math.abs(x) * factor) / factor;
1782
+ function blankLike(other) {
1783
+ if (typeof other === "boolean") return false;
1784
+ if (typeof other === "string") return "";
1785
+ return 0;
1786
+ }
1787
+ function compareCodePoints(a, b) {
1788
+ const x = Array.from(a);
1789
+ const y = Array.from(b);
1790
+ const n = Math.min(x.length, y.length);
1791
+ for (let i = 0; i < n; i++) {
1792
+ const p = x[i].codePointAt(0);
1793
+ const q = y[i].codePointAt(0);
1794
+ if (p !== q) return p < q ? -1 : 1;
1795
+ }
1796
+ return x.length < y.length ? -1 : x.length > y.length ? 1 : 0;
1797
+ }
1798
+ function excelRound(x, n) {
1799
+ if (!Number.isFinite(x)) return x;
1800
+ const [mantissa, exponent] = Math.abs(x).toExponential().split("e");
1801
+ const shifted = Number(`${mantissa}e${Number(exponent) + n}`);
1802
+ if (!Number.isFinite(shifted)) return x;
1803
+ const rounded = Math.round(shifted);
1804
+ const digits = String(rounded);
1805
+ return Math.sign(x) * (digits.includes("e") ? rounded / 10 ** n : Number(`${digits}e${-n}`));
1559
1806
  }
1560
1807
 
1561
1808
  // src/schema/repairer.ts
@@ -6252,7 +6499,7 @@ var holy_sheet_schema_default = {
6252
6499
  };
6253
6500
 
6254
6501
  // src/agent.ts
6255
- var VERSION = "2.11.0";
6502
+ var VERSION = "2.11.2";
6256
6503
  function toU8(input) {
6257
6504
  return input instanceof Uint8Array ? input : new Uint8Array(input);
6258
6505
  }
@@ -6824,5 +7071,5 @@ function unencodable(reason) {
6824
7071
  }
6825
7072
 
6826
7073
  export { Agent, ArchiveRefused, ArrayBuilder, CellAddress, CsvBuilder, FormatSniffer, FormulaLinter, Inference, Normalizer, OdsReader, OfficeTemplateRefused, Repairer, SchemaException, SheetDiff, SheetOpSchema, SheetReducer, SourceArchive, Theme, UnsupportedFormatException, VERSION, Validator, XlsxReader, XlsxWriter, readFileBounded, sourceKind, unzipSync, zipSync };
6827
- //# sourceMappingURL=chunk-OL6E7DE5.js.map
6828
- //# sourceMappingURL=chunk-OL6E7DE5.js.map
7074
+ //# sourceMappingURL=chunk-Y32UXRQ6.js.map
7075
+ //# sourceMappingURL=chunk-Y32UXRQ6.js.map