@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.
package/dist/cli.cjs CHANGED
@@ -791,7 +791,7 @@ var Normalizer = class {
791
791
  address,
792
792
  new Cell(
793
793
  address,
794
- this.coerceValue(rawValue, format, address, sheetName2),
794
+ this.coerceValue(rawValue, format, address, sheetName2, true),
795
795
  cellData?.formula ?? null,
796
796
  format,
797
797
  comment,
@@ -884,7 +884,7 @@ var Normalizer = class {
884
884
  const rawValue = value.value ?? null;
885
885
  return new Cell(
886
886
  address,
887
- this.coerceValue(rawValue, merged, address, sheetName2),
887
+ this.coerceValue(rawValue, merged, address, sheetName2, true),
888
888
  value.formula ?? null,
889
889
  merged,
890
890
  comment,
@@ -899,7 +899,15 @@ var Normalizer = class {
899
899
  columnFormat
900
900
  );
901
901
  }
902
- coerceValue(value, format, address = "?", sheetName2) {
902
+ /**
903
+ * `explicit`: the value came from an object cell's `value` key. An explicit
904
+ * string is the caller saying "this text, exactly", so it is never coerced
905
+ * to a number: `{value: "+1"}` stays "+1" and `{value: "007"}` stays "007".
906
+ * Only a date/datetime display format still turns a string into a serial,
907
+ * because that conversion is what the format asks for. Bare values keep
908
+ * numeric-string coercion. Mirrors PHP `Normalizer::coerceValue`.
909
+ */
910
+ coerceValue(value, format, address = "?", sheetName2, explicit = false) {
903
911
  if (value === null || value === void 0) return null;
904
912
  const df = format?.displayFormat;
905
913
  if (df === "date" || df === "datetime") {
@@ -907,6 +915,9 @@ var Normalizer = class {
907
915
  return DateConverter.toSerial(value, df === "datetime");
908
916
  }
909
917
  }
918
+ if (explicit && typeof value === "string") {
919
+ return value;
920
+ }
910
921
  if (typeof value === "string" && isNumericString(value)) {
911
922
  return numericStringToNumber(value);
912
923
  }
@@ -1055,13 +1066,46 @@ var ERR_REF = "#REF!";
1055
1066
  var ERR_NAME = "#NAME?";
1056
1067
  var ERR_DIV0 = "#DIV/0!";
1057
1068
  var ERR_CIRC = "#CIRC!";
1069
+ var FUNCTIONS = /* @__PURE__ */ new Set([
1070
+ "SUM",
1071
+ "AVERAGE",
1072
+ "AVG",
1073
+ "COUNT",
1074
+ "COUNTA",
1075
+ "MIN",
1076
+ "MAX",
1077
+ "IF",
1078
+ "ROUND",
1079
+ "ABS",
1080
+ "LEN",
1081
+ "UPPER",
1082
+ "LOWER",
1083
+ "CONCAT",
1084
+ "CONCATENATE",
1085
+ "TRUE",
1086
+ "FALSE"
1087
+ ]);
1058
1088
  var SHEET_QUALIFIER = "('(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
1059
- var LinterError = class extends Error {
1060
- constructor(errorCode) {
1089
+ var LinterError = class _LinterError extends Error {
1090
+ /**
1091
+ * @param cause Why a #NAME? arose in THIS formula: "syntax" or "function".
1092
+ * Null for an error that was computed, or inherited from another cell.
1093
+ * @param detail The syntax fault, or the unknown function's name.
1094
+ */
1095
+ constructor(errorCode, cause = null, detail = "") {
1061
1096
  super(errorCode);
1062
1097
  this.errorCode = errorCode;
1098
+ this.cause = cause;
1099
+ this.detail = detail;
1100
+ }
1101
+ static syntax(detail) {
1102
+ return new _LinterError(ERR_NAME, "syntax", detail);
1063
1103
  }
1064
1104
  };
1105
+ var isSpace = (ch) => ch === " " || ch >= " " && ch <= "\r";
1106
+ var isDigit = (ch) => ch >= "0" && ch <= "9";
1107
+ var isAlpha = (ch) => ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z";
1108
+ var isAlnum = (ch) => isAlpha(ch) || isDigit(ch);
1065
1109
  var FormulaLinter = class {
1066
1110
  constructor() {
1067
1111
  this.index = /* @__PURE__ */ new Map();
@@ -1072,10 +1116,25 @@ var FormulaLinter = class {
1072
1116
  * `#REF!` rather than a range of blanks.
1073
1117
  */
1074
1118
  this.sheetNames = /* @__PURE__ */ new Map();
1119
+ /**
1120
+ * Why a cell's own formula produced #NAME?, keyed `Sheet!A1`: a syntax
1121
+ * error, an unknown function, or a linter fault. A cell with #NAME? and no
1122
+ * entry here inherited it from a cell it references.
1123
+ */
1124
+ this.nameCauses = /* @__PURE__ */ new Map();
1125
+ /**
1126
+ * Greater than 0 while parsing an IF branch that is not taken. The parser
1127
+ * evaluates as it goes, so a skipped branch is still PARSED (its syntax
1128
+ * errors and unknown functions are reported) but nothing in it is
1129
+ * EVALUATED: no operator runs, no cell is read, and every value is null.
1130
+ */
1131
+ this.skipping = 0;
1075
1132
  }
1076
1133
  lint(schema) {
1077
1134
  const workbook = new Normalizer().normalize(schema);
1078
1135
  this.sheetNames = new Map(workbook.sheets.map((sheet) => [sheet.name.toLowerCase(), sheet.name]));
1136
+ this.nameCauses = /* @__PURE__ */ new Map();
1137
+ this.skipping = 0;
1079
1138
  this.index = this.buildIndex(workbook);
1080
1139
  this.cache = /* @__PURE__ */ new Map();
1081
1140
  const issues = [];
@@ -1091,7 +1150,7 @@ var FormulaLinter = class {
1091
1150
  address,
1092
1151
  formula: cell.formula,
1093
1152
  error: result,
1094
- hint: this.hint(result, cell.formula, sheet.name)
1153
+ hint: this.hint(result, key, cell.formula, sheet.name)
1095
1154
  });
1096
1155
  }
1097
1156
  }
@@ -1111,25 +1170,39 @@ var FormulaLinter = class {
1111
1170
  return typeof v === "string" && v.length > 0 && v[0] === "#";
1112
1171
  }
1113
1172
  evaluate(formula2, defaultSheet, stack) {
1173
+ const key = stack[stack.length - 1];
1174
+ this.nameCauses.delete(key);
1114
1175
  try {
1115
1176
  const c = { tokens: this.tokenize(formula2), pos: 0 };
1116
1177
  const result = this.parseExpr(c, defaultSheet, stack);
1117
- if (c.pos < c.tokens.length) return ERR_NAME;
1178
+ if (c.pos < c.tokens.length) {
1179
+ throw LinterError.syntax(`unexpected ${this.describeToken(c.tokens[c.pos])} after a complete expression`);
1180
+ }
1118
1181
  return result;
1119
1182
  } catch (e) {
1120
- if (e instanceof LinterError) return e.errorCode;
1183
+ if (e instanceof LinterError) {
1184
+ if (e.cause !== null) this.nameCauses.set(key, { cause: e.cause, detail: e.detail });
1185
+ return e.errorCode;
1186
+ }
1187
+ this.nameCauses.set(key, { cause: "internal", detail: e instanceof Error ? e.constructor.name : typeof e });
1121
1188
  return ERR_NAME;
1122
1189
  }
1123
1190
  }
1191
+ describeToken(tok) {
1192
+ switch (tok.type) {
1193
+ case "STRING":
1194
+ return 'text "' + tok.value + '"';
1195
+ case "NUMBER":
1196
+ return "number " + tok.value;
1197
+ default:
1198
+ return "'" + tok.value + "'";
1199
+ }
1200
+ }
1124
1201
  // ---- Tokenizer ----
1125
1202
  tokenize(src) {
1126
1203
  const tokens = [];
1127
1204
  let i = 0;
1128
1205
  const len = src.length;
1129
- const isSpace = (ch) => /\s/.test(ch);
1130
- const isDigit = (ch) => ch >= "0" && ch <= "9";
1131
- const isAlpha = (ch) => /[A-Za-z]/.test(ch);
1132
- const isAlnum = (ch) => /[A-Za-z0-9]/.test(ch);
1133
1206
  while (i < len) {
1134
1207
  const ch = src[i];
1135
1208
  if (isSpace(ch)) {
@@ -1144,10 +1217,21 @@ var FormulaLinter = class {
1144
1217
  }
1145
1218
  if (ch === '"') {
1146
1219
  i++;
1147
- const start = i;
1148
- while (i < len && src[i] !== '"') i++;
1149
- const value = src.slice(start, i);
1150
- i++;
1220
+ let value = "";
1221
+ for (; ; ) {
1222
+ if (i >= len) throw LinterError.syntax('a text literal opens with " and never closes');
1223
+ if (src[i] === '"') {
1224
+ if (i + 1 < len && src[i + 1] === '"') {
1225
+ value += '"';
1226
+ i += 2;
1227
+ continue;
1228
+ }
1229
+ i++;
1230
+ break;
1231
+ }
1232
+ value += src[i];
1233
+ i++;
1234
+ }
1151
1235
  tokens.push({ type: "STRING", value });
1152
1236
  continue;
1153
1237
  }
@@ -1155,7 +1239,7 @@ var FormulaLinter = class {
1155
1239
  const start = i;
1156
1240
  i++;
1157
1241
  for (; ; ) {
1158
- if (i >= len) throw new LinterError(ERR_NAME);
1242
+ if (i >= len) throw LinterError.syntax("a quoted sheet name opens with ' and never closes");
1159
1243
  if (src[i] === "'") {
1160
1244
  if (i + 1 < len && src[i + 1] === "'") {
1161
1245
  i += 2;
@@ -1188,7 +1272,9 @@ var FormulaLinter = class {
1188
1272
  i++;
1189
1273
  continue;
1190
1274
  }
1191
- throw new LinterError(ERR_NAME);
1275
+ throw LinterError.syntax(
1276
+ ch.charCodeAt(0) < 128 ? `unexpected character '${ch}'` : "unexpected non-ASCII character outside a text literal"
1277
+ );
1192
1278
  }
1193
1279
  return tokens;
1194
1280
  }
@@ -1206,7 +1292,7 @@ var FormulaLinter = class {
1206
1292
  const op = c.tokens[c.pos].value;
1207
1293
  c.pos++;
1208
1294
  const right = this.parseConcat(c, sheet, stack);
1209
- left = this.compare(left, right, op);
1295
+ left = this.skipping > 0 ? null : this.compare(left, right, op);
1210
1296
  }
1211
1297
  return left;
1212
1298
  }
@@ -1215,7 +1301,7 @@ var FormulaLinter = class {
1215
1301
  while (this.isOp(c, "&")) {
1216
1302
  c.pos++;
1217
1303
  const right = this.parseArith(c, sheet, stack);
1218
- left = this.coerceString(left) + this.coerceString(right);
1304
+ left = this.skipping > 0 ? null : this.coerceString(left) + this.coerceString(right);
1219
1305
  }
1220
1306
  return left;
1221
1307
  }
@@ -1225,6 +1311,10 @@ var FormulaLinter = class {
1225
1311
  const op = c.tokens[c.pos].value;
1226
1312
  c.pos++;
1227
1313
  const right = this.parseTerm(c, sheet, stack);
1314
+ if (this.skipping > 0) {
1315
+ left = null;
1316
+ continue;
1317
+ }
1228
1318
  const a = this.coerceNumber(left);
1229
1319
  const b = this.coerceNumber(right);
1230
1320
  left = op === "+" ? a + b : a - b;
@@ -1232,46 +1322,68 @@ var FormulaLinter = class {
1232
1322
  return left;
1233
1323
  }
1234
1324
  parseTerm(c, sheet, stack) {
1235
- let left = this.parseUnary(c, sheet, stack);
1236
- while (this.isOp(c, "*", "/", "%", "^")) {
1325
+ let left = this.parsePower(c, sheet, stack);
1326
+ while (this.isOp(c, "*", "/")) {
1237
1327
  const op = c.tokens[c.pos].value;
1238
1328
  c.pos++;
1239
- const right = this.parseUnary(c, sheet, stack);
1329
+ const right = this.parsePower(c, sheet, stack);
1330
+ if (this.skipping > 0) {
1331
+ left = null;
1332
+ continue;
1333
+ }
1240
1334
  const a = this.coerceNumber(left);
1241
1335
  const b = this.coerceNumber(right);
1242
- switch (op) {
1243
- case "*":
1244
- left = a * b;
1245
- break;
1246
- case "/":
1247
- if (b === 0) throw new LinterError(ERR_DIV0);
1248
- left = a / b;
1249
- break;
1250
- case "%":
1251
- left = a / 100 * b;
1252
- break;
1253
- case "^":
1254
- left = a ** b;
1255
- break;
1336
+ if (op === "*") {
1337
+ left = a * b;
1338
+ } else {
1339
+ if (b === 0) throw new LinterError(ERR_DIV0);
1340
+ left = a / b;
1256
1341
  }
1257
1342
  }
1258
1343
  return left;
1259
1344
  }
1345
+ /**
1346
+ * `^` binds tighter than `*` and `/` (=2*3^2 is 18) and, as in Excel, is
1347
+ * left-associative (=2^3^2 is 64). Negation binds tighter than `^`, so
1348
+ * =-2^2 is 4: Excel's order, not mathematics'.
1349
+ */
1350
+ parsePower(c, sheet, stack) {
1351
+ let left = this.parseUnary(c, sheet, stack);
1352
+ while (this.isOp(c, "^")) {
1353
+ c.pos++;
1354
+ const right = this.parseUnary(c, sheet, stack);
1355
+ left = this.skipping > 0 ? null : this.coerceNumber(left) ** this.coerceNumber(right);
1356
+ }
1357
+ return left;
1358
+ }
1260
1359
  parseUnary(c, sheet, stack) {
1261
1360
  if (this.isOp(c, "-")) {
1262
1361
  c.pos++;
1263
1362
  const v = this.parseUnary(c, sheet, stack);
1264
- return -this.coerceNumber(v);
1363
+ return this.skipping > 0 ? null : -this.coerceNumber(v);
1265
1364
  }
1266
1365
  if (this.isOp(c, "+")) {
1267
1366
  c.pos++;
1268
1367
  return this.parseUnary(c, sheet, stack);
1269
1368
  }
1270
- return this.parsePrimary(c, sheet, stack);
1369
+ return this.parsePercent(c, sheet, stack);
1370
+ }
1371
+ /**
1372
+ * Excel's `%` is a POSTFIX operator that divides by 100: `50%` is 0.5 and
1373
+ * `A1%` is A1/100. It binds tighter than `^`, `*` and `/`. There is no
1374
+ * binary `%`, so `10%2` leaves a stray `2` and is a syntax error.
1375
+ */
1376
+ parsePercent(c, sheet, stack) {
1377
+ let value = this.parsePrimary(c, sheet, stack);
1378
+ while (this.isOp(c, "%")) {
1379
+ c.pos++;
1380
+ value = this.skipping > 0 ? null : this.coerceNumber(value) / 100;
1381
+ }
1382
+ return value;
1271
1383
  }
1272
1384
  parsePrimary(c, sheet, stack) {
1273
1385
  const tok = this.peek(c);
1274
- if (!tok) throw new LinterError(ERR_NAME);
1386
+ if (!tok) throw LinterError.syntax("the formula ends where a value was expected");
1275
1387
  if (tok.type === "OP" && tok.value === "(") {
1276
1388
  c.pos++;
1277
1389
  const val = this.parseExpr(c, sheet, stack);
@@ -1291,15 +1403,19 @@ var FormulaLinter = class {
1291
1403
  const next2 = c.tokens[c.pos + 2];
1292
1404
  if (next && next.type === "OP" && next.value === "!" && next2 && next2.type === "IDENT") {
1293
1405
  const sheetName2 = this.sheetNames.get(this.cleanSheetName(tok.value).toLowerCase());
1294
- if (sheetName2 === void 0) throw new LinterError(ERR_REF);
1406
+ if (sheetName2 === void 0 && this.skipping === 0) throw new LinterError(ERR_REF);
1295
1407
  c.pos += 2;
1296
1408
  const startTok = c.tokens[c.pos].value;
1297
1409
  c.pos++;
1298
- return this.resolveRefOrRange(startTok, sheetName2, c, stack);
1410
+ return this.resolveRefOrRange(startTok, sheetName2 ?? tok.value, c, stack);
1411
+ }
1412
+ if (tok.type === "SHEET") {
1413
+ throw LinterError.syntax(`the quoted name ${tok.value} is not followed by ! and a cell reference`);
1299
1414
  }
1300
- if (tok.type === "SHEET") throw new LinterError(ERR_NAME);
1301
1415
  if (next && next.type === "OP" && next.value === "(") {
1302
1416
  const name = tok.value.toUpperCase();
1417
+ if (!FUNCTIONS.has(name)) throw new LinterError(ERR_NAME, "function", name);
1418
+ if (name === "IF") return this.parseIf(c, sheet, stack);
1303
1419
  c.pos += 2;
1304
1420
  const args = [];
1305
1421
  if (!this.isOp(c, ")")) {
@@ -1313,7 +1429,7 @@ var FormulaLinter = class {
1313
1429
  }
1314
1430
  }
1315
1431
  this.expectOp(c, ")");
1316
- return this.callFunction(name, args);
1432
+ return this.skipping > 0 ? null : this.callFunction(name, args);
1317
1433
  }
1318
1434
  const upper = tok.value.toUpperCase();
1319
1435
  if (upper === "TRUE" || upper === "FALSE") {
@@ -1323,13 +1439,65 @@ var FormulaLinter = class {
1323
1439
  c.pos++;
1324
1440
  return this.resolveRefOrRange(tok.value, sheet, c, stack);
1325
1441
  }
1326
- throw new LinterError(ERR_NAME);
1442
+ throw LinterError.syntax(`unexpected ${this.describeToken(tok)} where a value was expected`);
1443
+ }
1444
+ /**
1445
+ * IF, evaluated lazily as Excel does: `=IF(A2=0,0,1/A2)` with A2 = 0 is 0,
1446
+ * not #DIV/0!. Every argument is parsed, so a syntax error or an unknown
1447
+ * function anywhere in it is still reported, but only the condition and the
1448
+ * branch it selects are evaluated.
1449
+ *
1450
+ * The order of the verdicts is deliberate. A syntax error anywhere wins,
1451
+ * because Excel refuses a formula it cannot parse and computes nothing.
1452
+ * Then fewer than two arguments is #VALUE!. Then an error in the condition
1453
+ * propagates, and neither branch is evaluated.
1454
+ */
1455
+ parseIf(c, sheet, stack) {
1456
+ c.pos += 2;
1457
+ const args = [];
1458
+ let take = null;
1459
+ let conditionError = null;
1460
+ if (!this.isOp(c, ")")) {
1461
+ for (; ; ) {
1462
+ const i = args.length;
1463
+ const skip = i > 0 && i !== take;
1464
+ if (skip) this.skipping++;
1465
+ try {
1466
+ args.push(this.parseExpr(c, sheet, stack));
1467
+ } finally {
1468
+ if (skip) this.skipping--;
1469
+ }
1470
+ if (i === 0 && this.skipping === 0) {
1471
+ try {
1472
+ take = this.toLogical(args[0]) ? 1 : 2;
1473
+ } catch (e) {
1474
+ if (!(e instanceof LinterError)) throw e;
1475
+ conditionError = e;
1476
+ }
1477
+ }
1478
+ if (this.isOp(c, ",")) {
1479
+ c.pos++;
1480
+ continue;
1481
+ }
1482
+ break;
1483
+ }
1484
+ }
1485
+ this.expectOp(c, ")");
1486
+ if (this.skipping > 0) return null;
1487
+ if (args.length < 2) return ERR_VALUE;
1488
+ if (conditionError !== null) throw conditionError;
1489
+ return take === 1 ? args[1] : args[2] ?? false;
1327
1490
  }
1328
1491
  resolveRefOrRange(startRef, defaultSheet, c, stack) {
1492
+ const next = c.tokens[c.pos + 1];
1493
+ const isRange = this.isOp(c, ":") && !!next && next.type === "IDENT";
1494
+ if (this.skipping > 0) {
1495
+ if (isRange) c.pos += 2;
1496
+ return null;
1497
+ }
1329
1498
  const cleanStart = this.cleanRef(startRef);
1330
1499
  if (cleanStart === null) throw new LinterError(ERR_REF);
1331
- const next = c.tokens[c.pos + 1];
1332
- if (this.isOp(c, ":") && next && next.type === "IDENT") {
1500
+ if (isRange) {
1333
1501
  const endRef = next.value;
1334
1502
  c.pos += 2;
1335
1503
  const cleanEnd = this.cleanRef(endRef);
@@ -1383,7 +1551,10 @@ var FormulaLinter = class {
1383
1551
  }
1384
1552
  expectOp(c, op) {
1385
1553
  const t = c.tokens[c.pos];
1386
- if (!t || t.type !== "OP" || t.value !== op) throw new LinterError(ERR_NAME);
1554
+ if (!t) throw LinterError.syntax(`expected '${op}', but the formula ends`);
1555
+ if (t.type !== "OP" || t.value !== op) {
1556
+ throw LinterError.syntax(`expected '${op}', found ${this.describeToken(t)}`);
1557
+ }
1387
1558
  c.pos++;
1388
1559
  }
1389
1560
  // ---- Coercion / comparison / functions ----
@@ -1428,17 +1599,28 @@ var FormulaLinter = class {
1428
1599
  return false;
1429
1600
  }
1430
1601
  }
1602
+ /**
1603
+ * Excel's comparison. An error on either side propagates. Otherwise the
1604
+ * types rank number < text < logical, so ="5">9 is TRUE: numeric TEXT is
1605
+ * text, never a number. Text compares without regard to case. A blank
1606
+ * takes the other side's type (0, "" or FALSE); two blanks are equal.
1607
+ */
1431
1608
  cmpVal(a, b) {
1432
- const an = typeof a === "number" || typeof a === "string" && isNumericString(a);
1433
- const bn = typeof b === "number" || typeof b === "string" && isNumericString(b);
1434
- if (an && bn) {
1435
- const x = Number(a);
1436
- const y = Number(b);
1437
- return x < y ? -1 : x > y ? 1 : 0;
1438
- }
1439
- const sa = String(a);
1440
- const sb = String(b);
1441
- return sa < sb ? -1 : sa > sb ? 1 : 0;
1609
+ if (this.isError(a)) throw new LinterError(a);
1610
+ if (this.isError(b)) throw new LinterError(b);
1611
+ if (Array.isArray(a) || Array.isArray(b)) {
1612
+ return compareCodePoints(String(a), String(b));
1613
+ }
1614
+ const x = a ?? blankLike(b);
1615
+ const y = b ?? blankLike(x);
1616
+ const rank = (v) => typeof v === "boolean" ? 2 : typeof v === "string" ? 1 : 0;
1617
+ if (rank(x) !== rank(y)) return rank(x) < rank(y) ? -1 : 1;
1618
+ if (typeof x === "string") {
1619
+ return compareCodePoints(x.toLowerCase(), String(y).toLowerCase());
1620
+ }
1621
+ const p = Number(x);
1622
+ const q = Number(y);
1623
+ return p < q ? -1 : p > q ? 1 : 0;
1442
1624
  }
1443
1625
  callFunction(name, args) {
1444
1626
  const flat = this.flatten(args);
@@ -1456,10 +1638,8 @@ var FormulaLinter = class {
1456
1638
  return this.minMax(flat, true);
1457
1639
  case "MAX":
1458
1640
  return this.minMax(flat, false);
1459
- case "IF":
1460
- return this.ifFn(args);
1461
1641
  case "ROUND":
1462
- return phpRound(this.coerceNumber(args[0] ?? 0), Math.trunc(this.coerceNumber(args[1] ?? 0)));
1642
+ return excelRound(this.coerceNumber(args[0] ?? 0), Math.trunc(this.coerceNumber(args[1] ?? 0)));
1463
1643
  case "ABS":
1464
1644
  return Math.abs(this.coerceNumber(args[0] ?? 0));
1465
1645
  case "LEN":
@@ -1476,7 +1656,7 @@ var FormulaLinter = class {
1476
1656
  case "FALSE":
1477
1657
  return false;
1478
1658
  default:
1479
- return ERR_NAME;
1659
+ throw new LinterError(ERR_NAME, "function", name);
1480
1660
  }
1481
1661
  }
1482
1662
  flatten(args) {
@@ -1504,21 +1684,34 @@ var FormulaLinter = class {
1504
1684
  const vals = nums.map((v) => this.maybeNum(v));
1505
1685
  return min ? Math.min(...vals) : Math.max(...vals);
1506
1686
  }
1507
- ifFn(args) {
1508
- if (args.length < 2) return ERR_VALUE;
1509
- const cond = args[0];
1510
- const truthy = typeof cond === "boolean" ? cond : cond !== null && cond !== 0 && cond !== "" && cond !== "FALSE";
1511
- return truthy ? args[1] : args[2] ?? false;
1687
+ /**
1688
+ * Excel's reading of an IF condition: an error propagates, a blank is
1689
+ * FALSE, a number is TRUE unless it is 0, and text is TRUE or FALSE only
1690
+ * when it spells one of them (in any case). Any other text, "" included,
1691
+ * is #VALUE!.
1692
+ */
1693
+ toLogical(v) {
1694
+ if (this.isError(v)) throw new LinterError(v);
1695
+ if (typeof v === "boolean") return v;
1696
+ if (v === null) return false;
1697
+ if (typeof v === "number") return v !== 0;
1698
+ if (typeof v === "string") {
1699
+ const upper = v.toUpperCase();
1700
+ if (upper === "TRUE") return true;
1701
+ if (upper === "FALSE") return false;
1702
+ throw new LinterError(ERR_VALUE);
1703
+ }
1704
+ return true;
1512
1705
  }
1513
1706
  // ---- Hints ----
1514
- hint(error2, formula2, sheet) {
1707
+ hint(error2, key, formula2, sheet) {
1515
1708
  switch (error2) {
1516
1709
  case ERR_VALUE:
1517
1710
  return this.hintValue(formula2, sheet);
1518
1711
  case ERR_REF:
1519
1712
  return this.hintRef(formula2);
1520
1713
  case ERR_NAME:
1521
- 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.";
1714
+ return this.hintName(key, formula2, sheet);
1522
1715
  case ERR_DIV0:
1523
1716
  return "Division by zero \u2014 the divisor evaluated to 0.";
1524
1717
  case ERR_CIRC:
@@ -1527,6 +1720,39 @@ var FormulaLinter = class {
1527
1720
  return "Formula evaluation failed.";
1528
1721
  }
1529
1722
  }
1723
+ /**
1724
+ * #NAME? has three causes in this formula, plus inheritance, and each hint
1725
+ * says which one it is. A syntax error used to be told it "references an
1726
+ * unknown function", and an agent went hunting for a function that was fine.
1727
+ * The texts are byte-identical to PHP's and Python's: see PHP
1728
+ * `FormulaLinter::hintName` for why a syntax error keeps the #NAME? label.
1729
+ */
1730
+ hintName(key, formula2, sheet) {
1731
+ const cause = this.nameCauses.get(key);
1732
+ switch (cause?.cause) {
1733
+ case "syntax":
1734
+ 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".`;
1735
+ case "function":
1736
+ return `Unknown function ${cause.detail}. Holy Sheet supports: SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, IF, ROUND, ABS, LEN, UPPER, LOWER, CONCAT.`;
1737
+ case "internal":
1738
+ return `The linter itself failed on this formula (${cause.detail}). That is a holy-sheet defect, not evidence that the formula is wrong.`;
1739
+ default:
1740
+ return this.hintInheritedName(formula2, sheet);
1741
+ }
1742
+ }
1743
+ hintInheritedName(formula2, sheet) {
1744
+ const re = new RegExp(`(?:${SHEET_QUALIFIER})?\\$?([A-Z]+)\\$?(\\d+)`, "gi");
1745
+ const sources = [];
1746
+ for (const m of formula2.matchAll(re)) {
1747
+ const sheetName2 = m[1] ? this.sheetNames.get(this.cleanSheetName(m[1]).toLowerCase()) ?? this.cleanSheetName(m[1]) : sheet;
1748
+ const a1 = m[2].toUpperCase() + m[3];
1749
+ if (this.cache.get(sheetName2 + "!" + a1) === ERR_NAME) {
1750
+ const source = m[1] ? m[1] + "!" + a1 : a1;
1751
+ if (!sources.includes(source)) sources.push(source);
1752
+ }
1753
+ }
1754
+ 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.`;
1755
+ }
1530
1756
  hintRef(formula2) {
1531
1757
  for (const m of formula2.matchAll(new RegExp(SHEET_QUALIFIER, "g"))) {
1532
1758
  const name = this.cleanSheetName(m[1]);
@@ -1564,9 +1790,30 @@ var FormulaLinter = class {
1564
1790
  return "A non-numeric value was used in arithmetic. Check that all referenced cells contain numbers.";
1565
1791
  }
1566
1792
  };
1567
- function phpRound(x, n) {
1568
- const factor = 10 ** n;
1569
- return Math.sign(x) * Math.round(Math.abs(x) * factor) / factor;
1793
+ function blankLike(other) {
1794
+ if (typeof other === "boolean") return false;
1795
+ if (typeof other === "string") return "";
1796
+ return 0;
1797
+ }
1798
+ function compareCodePoints(a, b) {
1799
+ const x = Array.from(a);
1800
+ const y = Array.from(b);
1801
+ const n = Math.min(x.length, y.length);
1802
+ for (let i = 0; i < n; i++) {
1803
+ const p = x[i].codePointAt(0);
1804
+ const q = y[i].codePointAt(0);
1805
+ if (p !== q) return p < q ? -1 : 1;
1806
+ }
1807
+ return x.length < y.length ? -1 : x.length > y.length ? 1 : 0;
1808
+ }
1809
+ function excelRound(x, n) {
1810
+ if (!Number.isFinite(x)) return x;
1811
+ const [mantissa, exponent] = Math.abs(x).toExponential().split("e");
1812
+ const shifted = Number(`${mantissa}e${Number(exponent) + n}`);
1813
+ if (!Number.isFinite(shifted)) return x;
1814
+ const rounded = Math.round(shifted);
1815
+ const digits = String(rounded);
1816
+ return Math.sign(x) * (digits.includes("e") ? rounded / 10 ** n : Number(`${digits}e${-n}`));
1570
1817
  }
1571
1818
 
1572
1819
  // src/schema/repairer.ts
@@ -6681,7 +6928,7 @@ var holy_sheet_schema_default = {
6681
6928
  };
6682
6929
 
6683
6930
  // src/agent.ts
6684
- var VERSION = "2.11.0";
6931
+ var VERSION = "2.11.2";
6685
6932
  function toU8(input) {
6686
6933
  return input instanceof Uint8Array ? input : new Uint8Array(input);
6687
6934
  }