@particle-academy/holy-sheet 2.11.1 → 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
@@ -1066,13 +1066,46 @@ var ERR_REF = "#REF!";
1066
1066
  var ERR_NAME = "#NAME?";
1067
1067
  var ERR_DIV0 = "#DIV/0!";
1068
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
+ ]);
1069
1088
  var SHEET_QUALIFIER = "('(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
1070
- var LinterError = class extends Error {
1071
- 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 = "") {
1072
1096
  super(errorCode);
1073
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);
1074
1103
  }
1075
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);
1076
1109
  var FormulaLinter = class {
1077
1110
  constructor() {
1078
1111
  this.index = /* @__PURE__ */ new Map();
@@ -1083,10 +1116,25 @@ var FormulaLinter = class {
1083
1116
  * `#REF!` rather than a range of blanks.
1084
1117
  */
1085
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;
1086
1132
  }
1087
1133
  lint(schema) {
1088
1134
  const workbook = new Normalizer().normalize(schema);
1089
1135
  this.sheetNames = new Map(workbook.sheets.map((sheet) => [sheet.name.toLowerCase(), sheet.name]));
1136
+ this.nameCauses = /* @__PURE__ */ new Map();
1137
+ this.skipping = 0;
1090
1138
  this.index = this.buildIndex(workbook);
1091
1139
  this.cache = /* @__PURE__ */ new Map();
1092
1140
  const issues = [];
@@ -1102,7 +1150,7 @@ var FormulaLinter = class {
1102
1150
  address,
1103
1151
  formula: cell.formula,
1104
1152
  error: result,
1105
- hint: this.hint(result, cell.formula, sheet.name)
1153
+ hint: this.hint(result, key, cell.formula, sheet.name)
1106
1154
  });
1107
1155
  }
1108
1156
  }
@@ -1122,25 +1170,39 @@ var FormulaLinter = class {
1122
1170
  return typeof v === "string" && v.length > 0 && v[0] === "#";
1123
1171
  }
1124
1172
  evaluate(formula2, defaultSheet, stack) {
1173
+ const key = stack[stack.length - 1];
1174
+ this.nameCauses.delete(key);
1125
1175
  try {
1126
1176
  const c = { tokens: this.tokenize(formula2), pos: 0 };
1127
1177
  const result = this.parseExpr(c, defaultSheet, stack);
1128
- 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
+ }
1129
1181
  return result;
1130
1182
  } catch (e) {
1131
- 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 });
1132
1188
  return ERR_NAME;
1133
1189
  }
1134
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
+ }
1135
1201
  // ---- Tokenizer ----
1136
1202
  tokenize(src) {
1137
1203
  const tokens = [];
1138
1204
  let i = 0;
1139
1205
  const len = src.length;
1140
- const isSpace = (ch) => /\s/.test(ch);
1141
- const isDigit = (ch) => ch >= "0" && ch <= "9";
1142
- const isAlpha = (ch) => /[A-Za-z]/.test(ch);
1143
- const isAlnum = (ch) => /[A-Za-z0-9]/.test(ch);
1144
1206
  while (i < len) {
1145
1207
  const ch = src[i];
1146
1208
  if (isSpace(ch)) {
@@ -1155,10 +1217,21 @@ var FormulaLinter = class {
1155
1217
  }
1156
1218
  if (ch === '"') {
1157
1219
  i++;
1158
- const start = i;
1159
- while (i < len && src[i] !== '"') i++;
1160
- const value = src.slice(start, i);
1161
- 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
+ }
1162
1235
  tokens.push({ type: "STRING", value });
1163
1236
  continue;
1164
1237
  }
@@ -1166,7 +1239,7 @@ var FormulaLinter = class {
1166
1239
  const start = i;
1167
1240
  i++;
1168
1241
  for (; ; ) {
1169
- if (i >= len) throw new LinterError(ERR_NAME);
1242
+ if (i >= len) throw LinterError.syntax("a quoted sheet name opens with ' and never closes");
1170
1243
  if (src[i] === "'") {
1171
1244
  if (i + 1 < len && src[i + 1] === "'") {
1172
1245
  i += 2;
@@ -1199,7 +1272,9 @@ var FormulaLinter = class {
1199
1272
  i++;
1200
1273
  continue;
1201
1274
  }
1202
- 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
+ );
1203
1278
  }
1204
1279
  return tokens;
1205
1280
  }
@@ -1217,7 +1292,7 @@ var FormulaLinter = class {
1217
1292
  const op = c.tokens[c.pos].value;
1218
1293
  c.pos++;
1219
1294
  const right = this.parseConcat(c, sheet, stack);
1220
- left = this.compare(left, right, op);
1295
+ left = this.skipping > 0 ? null : this.compare(left, right, op);
1221
1296
  }
1222
1297
  return left;
1223
1298
  }
@@ -1226,7 +1301,7 @@ var FormulaLinter = class {
1226
1301
  while (this.isOp(c, "&")) {
1227
1302
  c.pos++;
1228
1303
  const right = this.parseArith(c, sheet, stack);
1229
- left = this.coerceString(left) + this.coerceString(right);
1304
+ left = this.skipping > 0 ? null : this.coerceString(left) + this.coerceString(right);
1230
1305
  }
1231
1306
  return left;
1232
1307
  }
@@ -1236,6 +1311,10 @@ var FormulaLinter = class {
1236
1311
  const op = c.tokens[c.pos].value;
1237
1312
  c.pos++;
1238
1313
  const right = this.parseTerm(c, sheet, stack);
1314
+ if (this.skipping > 0) {
1315
+ left = null;
1316
+ continue;
1317
+ }
1239
1318
  const a = this.coerceNumber(left);
1240
1319
  const b = this.coerceNumber(right);
1241
1320
  left = op === "+" ? a + b : a - b;
@@ -1243,46 +1322,68 @@ var FormulaLinter = class {
1243
1322
  return left;
1244
1323
  }
1245
1324
  parseTerm(c, sheet, stack) {
1246
- let left = this.parseUnary(c, sheet, stack);
1247
- while (this.isOp(c, "*", "/", "%", "^")) {
1325
+ let left = this.parsePower(c, sheet, stack);
1326
+ while (this.isOp(c, "*", "/")) {
1248
1327
  const op = c.tokens[c.pos].value;
1249
1328
  c.pos++;
1250
- 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
+ }
1251
1334
  const a = this.coerceNumber(left);
1252
1335
  const b = this.coerceNumber(right);
1253
- switch (op) {
1254
- case "*":
1255
- left = a * b;
1256
- break;
1257
- case "/":
1258
- if (b === 0) throw new LinterError(ERR_DIV0);
1259
- left = a / b;
1260
- break;
1261
- case "%":
1262
- left = a / 100 * b;
1263
- break;
1264
- case "^":
1265
- left = a ** b;
1266
- break;
1336
+ if (op === "*") {
1337
+ left = a * b;
1338
+ } else {
1339
+ if (b === 0) throw new LinterError(ERR_DIV0);
1340
+ left = a / b;
1267
1341
  }
1268
1342
  }
1269
1343
  return left;
1270
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
+ }
1271
1359
  parseUnary(c, sheet, stack) {
1272
1360
  if (this.isOp(c, "-")) {
1273
1361
  c.pos++;
1274
1362
  const v = this.parseUnary(c, sheet, stack);
1275
- return -this.coerceNumber(v);
1363
+ return this.skipping > 0 ? null : -this.coerceNumber(v);
1276
1364
  }
1277
1365
  if (this.isOp(c, "+")) {
1278
1366
  c.pos++;
1279
1367
  return this.parseUnary(c, sheet, stack);
1280
1368
  }
1281
- 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;
1282
1383
  }
1283
1384
  parsePrimary(c, sheet, stack) {
1284
1385
  const tok = this.peek(c);
1285
- if (!tok) throw new LinterError(ERR_NAME);
1386
+ if (!tok) throw LinterError.syntax("the formula ends where a value was expected");
1286
1387
  if (tok.type === "OP" && tok.value === "(") {
1287
1388
  c.pos++;
1288
1389
  const val = this.parseExpr(c, sheet, stack);
@@ -1302,15 +1403,19 @@ var FormulaLinter = class {
1302
1403
  const next2 = c.tokens[c.pos + 2];
1303
1404
  if (next && next.type === "OP" && next.value === "!" && next2 && next2.type === "IDENT") {
1304
1405
  const sheetName2 = this.sheetNames.get(this.cleanSheetName(tok.value).toLowerCase());
1305
- if (sheetName2 === void 0) throw new LinterError(ERR_REF);
1406
+ if (sheetName2 === void 0 && this.skipping === 0) throw new LinterError(ERR_REF);
1306
1407
  c.pos += 2;
1307
1408
  const startTok = c.tokens[c.pos].value;
1308
1409
  c.pos++;
1309
- 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`);
1310
1414
  }
1311
- if (tok.type === "SHEET") throw new LinterError(ERR_NAME);
1312
1415
  if (next && next.type === "OP" && next.value === "(") {
1313
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);
1314
1419
  c.pos += 2;
1315
1420
  const args = [];
1316
1421
  if (!this.isOp(c, ")")) {
@@ -1324,7 +1429,7 @@ var FormulaLinter = class {
1324
1429
  }
1325
1430
  }
1326
1431
  this.expectOp(c, ")");
1327
- return this.callFunction(name, args);
1432
+ return this.skipping > 0 ? null : this.callFunction(name, args);
1328
1433
  }
1329
1434
  const upper = tok.value.toUpperCase();
1330
1435
  if (upper === "TRUE" || upper === "FALSE") {
@@ -1334,13 +1439,65 @@ var FormulaLinter = class {
1334
1439
  c.pos++;
1335
1440
  return this.resolveRefOrRange(tok.value, sheet, c, stack);
1336
1441
  }
1337
- 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;
1338
1490
  }
1339
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
+ }
1340
1498
  const cleanStart = this.cleanRef(startRef);
1341
1499
  if (cleanStart === null) throw new LinterError(ERR_REF);
1342
- const next = c.tokens[c.pos + 1];
1343
- if (this.isOp(c, ":") && next && next.type === "IDENT") {
1500
+ if (isRange) {
1344
1501
  const endRef = next.value;
1345
1502
  c.pos += 2;
1346
1503
  const cleanEnd = this.cleanRef(endRef);
@@ -1394,7 +1551,10 @@ var FormulaLinter = class {
1394
1551
  }
1395
1552
  expectOp(c, op) {
1396
1553
  const t = c.tokens[c.pos];
1397
- 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
+ }
1398
1558
  c.pos++;
1399
1559
  }
1400
1560
  // ---- Coercion / comparison / functions ----
@@ -1439,17 +1599,28 @@ var FormulaLinter = class {
1439
1599
  return false;
1440
1600
  }
1441
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
+ */
1442
1608
  cmpVal(a, b) {
1443
- const an = typeof a === "number" || typeof a === "string" && isNumericString(a);
1444
- const bn = typeof b === "number" || typeof b === "string" && isNumericString(b);
1445
- if (an && bn) {
1446
- const x = Number(a);
1447
- const y = Number(b);
1448
- return x < y ? -1 : x > y ? 1 : 0;
1449
- }
1450
- const sa = String(a);
1451
- const sb = String(b);
1452
- 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;
1453
1624
  }
1454
1625
  callFunction(name, args) {
1455
1626
  const flat = this.flatten(args);
@@ -1467,10 +1638,8 @@ var FormulaLinter = class {
1467
1638
  return this.minMax(flat, true);
1468
1639
  case "MAX":
1469
1640
  return this.minMax(flat, false);
1470
- case "IF":
1471
- return this.ifFn(args);
1472
1641
  case "ROUND":
1473
- 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)));
1474
1643
  case "ABS":
1475
1644
  return Math.abs(this.coerceNumber(args[0] ?? 0));
1476
1645
  case "LEN":
@@ -1487,7 +1656,7 @@ var FormulaLinter = class {
1487
1656
  case "FALSE":
1488
1657
  return false;
1489
1658
  default:
1490
- return ERR_NAME;
1659
+ throw new LinterError(ERR_NAME, "function", name);
1491
1660
  }
1492
1661
  }
1493
1662
  flatten(args) {
@@ -1515,21 +1684,34 @@ var FormulaLinter = class {
1515
1684
  const vals = nums.map((v) => this.maybeNum(v));
1516
1685
  return min ? Math.min(...vals) : Math.max(...vals);
1517
1686
  }
1518
- ifFn(args) {
1519
- if (args.length < 2) return ERR_VALUE;
1520
- const cond = args[0];
1521
- const truthy = typeof cond === "boolean" ? cond : cond !== null && cond !== 0 && cond !== "" && cond !== "FALSE";
1522
- 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;
1523
1705
  }
1524
1706
  // ---- Hints ----
1525
- hint(error2, formula2, sheet) {
1707
+ hint(error2, key, formula2, sheet) {
1526
1708
  switch (error2) {
1527
1709
  case ERR_VALUE:
1528
1710
  return this.hintValue(formula2, sheet);
1529
1711
  case ERR_REF:
1530
1712
  return this.hintRef(formula2);
1531
1713
  case ERR_NAME:
1532
- 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);
1533
1715
  case ERR_DIV0:
1534
1716
  return "Division by zero \u2014 the divisor evaluated to 0.";
1535
1717
  case ERR_CIRC:
@@ -1538,6 +1720,39 @@ var FormulaLinter = class {
1538
1720
  return "Formula evaluation failed.";
1539
1721
  }
1540
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
+ }
1541
1756
  hintRef(formula2) {
1542
1757
  for (const m of formula2.matchAll(new RegExp(SHEET_QUALIFIER, "g"))) {
1543
1758
  const name = this.cleanSheetName(m[1]);
@@ -1575,9 +1790,30 @@ var FormulaLinter = class {
1575
1790
  return "A non-numeric value was used in arithmetic. Check that all referenced cells contain numbers.";
1576
1791
  }
1577
1792
  };
1578
- function phpRound(x, n) {
1579
- const factor = 10 ** n;
1580
- 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}`));
1581
1817
  }
1582
1818
 
1583
1819
  // src/schema/repairer.ts
@@ -6692,7 +6928,7 @@ var holy_sheet_schema_default = {
6692
6928
  };
6693
6929
 
6694
6930
  // src/agent.ts
6695
- var VERSION = "2.11.1";
6931
+ var VERSION = "2.11.2";
6696
6932
  function toU8(input) {
6697
6933
  return input instanceof Uint8Array ? input : new Uint8Array(input);
6698
6934
  }