@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/CHANGELOG.md CHANGED
@@ -10,6 +10,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [2.11.2] - 2026-10-28
14
+
15
+ ### Fixed
16
+
17
+ `Agent.lint()` now follows Excel's formula semantics in the places below,
18
+ ported from PHP holy-sheet 2.11.1 (plus lazy `IF`, which all three linters
19
+ gain together). What you will notice: **fewer false `#NAME?` and `#DIV/0!`
20
+ reports** on formulas Excel accepts, and **some new true positives** on
21
+ formulas Excel rejects or errors on that the linter used to pass. `lint()`'s
22
+ signature and the issue shape `{sheet, address, formula, error, hint}` are
23
+ unchanged. Nothing to do unless you assert on the exact set of issues a formula
24
+ produces. Error labels and hint texts are byte-identical to PHP and Python.
25
+
26
+ - **`IF` evaluates only the branch it takes.** `=IF(A2=0,0,1/A2)` with A2 = 0
27
+ used to lint as `#DIV/0!`; it now lints clean, as does any error in the
28
+ untaken branch. That branch is still parsed, so a syntax error or an unknown
29
+ function there is still `#NAME?`. An error in the condition still propagates,
30
+ after the whole `IF` has been parsed. `IFERROR` is not in the linter's
31
+ vocabulary and is still `#NAME?`.
32
+ - **`%` is postfix percent.** `=50%` is 0.5 and `=A1%` is A1/100; these used to
33
+ lint as `#NAME?`. A binary `%` such as `=10%2`, which the linter used to
34
+ evaluate as `10/100*2`, is now reported, because Excel rejects it.
35
+ - **`^` binds tighter than `*` and `/`.** `=2*3^2` is 18, not 36. It stays
36
+ left-associative (`=2^3^2` is 64) and below negation (`=-2^2` is 4), as in
37
+ Excel.
38
+ - **A doubled quote inside text is one quote.** `="a""b"` is the three
39
+ characters `a"b`; it used to lint as `#NAME?`. A text literal that never
40
+ closes (`="abc`) is now reported instead of passing.
41
+ - **IF reads its condition as Excel does.** Text is TRUE or FALSE only when it
42
+ spells one of them, in any case: `"false"` used to count as TRUE. Any other
43
+ text, `""` included, is now `#VALUE!` (a new true positive; it used to pass).
44
+ An error in the condition now propagates instead of picking a branch.
45
+ - **Comparisons follow Excel's type order.** Numeric text is text, and Excel
46
+ sorts text above every number: `="5">9` is TRUE and `="10"=10` is FALSE (both
47
+ used to compare as numbers). Text compares without regard to case
48
+ (`="a"="A"` is TRUE), logicals sort above text, and a blank cell equals 0,
49
+ `""` and FALSE. An error on either side now propagates instead of being
50
+ compared as text. Text is ordered by code point, as PHP and Python order it.
51
+ - **A `#NAME?` hint says which kind it is.** The label is still `#NAME?`, but
52
+ every cause used to get one hint, "references an unknown function or has a
53
+ syntax error". A malformed formula now gets `Syntax error: <what and
54
+ where>...`; an unknown function gets `Unknown function VLOOKUP. ...`; a
55
+ `#NAME?` inherited from another cell names that cell; and an exception inside
56
+ the linter itself says it is a linter defect. Match on the hint's prefix if
57
+ you need to tell them apart.
58
+ - **An unknown function is `#NAME?` wherever it sits.** `=IF(FALSE, FOO(1), 1)`
59
+ used to pass, because the error was a value that IF discarded. Excel resolves
60
+ names first, so it is `#NAME?` there and now here (a new true positive).
61
+ - **`ROUND` rounds the decimal value, half away from zero.** `=ROUND(1.005,2)`
62
+ was 1, `=ROUND(0.285,2)` 0.28 and `=ROUND(8.325,2)` 8.32, because the digits
63
+ were shifted in binary, where 1.005 is 1.00499999.... They are now 1.01, 0.29
64
+ and 8.33, as in Excel, PHP and Python.
65
+ - **The tokenizer uses PHP's ASCII character classes.** A no-break space, or a
66
+ letter such as `é` outside a text literal, is now a syntax error, as it is in
67
+ PHP; the no-break space used to be skipped as whitespace.
68
+
13
69
  ## [2.11.1] - 2026-10-27
14
70
 
15
71
  ### Fixed
@@ -1055,13 +1055,46 @@ var ERR_REF = "#REF!";
1055
1055
  var ERR_NAME = "#NAME?";
1056
1056
  var ERR_DIV0 = "#DIV/0!";
1057
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
+ ]);
1058
1077
  var SHEET_QUALIFIER = "('(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
1059
- var LinterError = class extends Error {
1060
- 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 = "") {
1061
1085
  super(errorCode);
1062
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);
1063
1092
  }
1064
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);
1065
1098
  var FormulaLinter = class {
1066
1099
  constructor() {
1067
1100
  this.index = /* @__PURE__ */ new Map();
@@ -1072,10 +1105,25 @@ var FormulaLinter = class {
1072
1105
  * `#REF!` rather than a range of blanks.
1073
1106
  */
1074
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;
1075
1121
  }
1076
1122
  lint(schema) {
1077
1123
  const workbook = new Normalizer().normalize(schema);
1078
1124
  this.sheetNames = new Map(workbook.sheets.map((sheet) => [sheet.name.toLowerCase(), sheet.name]));
1125
+ this.nameCauses = /* @__PURE__ */ new Map();
1126
+ this.skipping = 0;
1079
1127
  this.index = this.buildIndex(workbook);
1080
1128
  this.cache = /* @__PURE__ */ new Map();
1081
1129
  const issues = [];
@@ -1091,7 +1139,7 @@ var FormulaLinter = class {
1091
1139
  address,
1092
1140
  formula: cell.formula,
1093
1141
  error: result,
1094
- hint: this.hint(result, cell.formula, sheet.name)
1142
+ hint: this.hint(result, key, cell.formula, sheet.name)
1095
1143
  });
1096
1144
  }
1097
1145
  }
@@ -1111,25 +1159,39 @@ var FormulaLinter = class {
1111
1159
  return typeof v === "string" && v.length > 0 && v[0] === "#";
1112
1160
  }
1113
1161
  evaluate(formula2, defaultSheet, stack) {
1162
+ const key = stack[stack.length - 1];
1163
+ this.nameCauses.delete(key);
1114
1164
  try {
1115
1165
  const c = { tokens: this.tokenize(formula2), pos: 0 };
1116
1166
  const result = this.parseExpr(c, defaultSheet, stack);
1117
- 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
+ }
1118
1170
  return result;
1119
1171
  } catch (e) {
1120
- 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 });
1121
1177
  return ERR_NAME;
1122
1178
  }
1123
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
+ }
1124
1190
  // ---- Tokenizer ----
1125
1191
  tokenize(src) {
1126
1192
  const tokens = [];
1127
1193
  let i = 0;
1128
1194
  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
1195
  while (i < len) {
1134
1196
  const ch = src[i];
1135
1197
  if (isSpace(ch)) {
@@ -1144,10 +1206,21 @@ var FormulaLinter = class {
1144
1206
  }
1145
1207
  if (ch === '"') {
1146
1208
  i++;
1147
- const start = i;
1148
- while (i < len && src[i] !== '"') i++;
1149
- const value = src.slice(start, i);
1150
- 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
+ }
1151
1224
  tokens.push({ type: "STRING", value });
1152
1225
  continue;
1153
1226
  }
@@ -1155,7 +1228,7 @@ var FormulaLinter = class {
1155
1228
  const start = i;
1156
1229
  i++;
1157
1230
  for (; ; ) {
1158
- if (i >= len) throw new LinterError(ERR_NAME);
1231
+ if (i >= len) throw LinterError.syntax("a quoted sheet name opens with ' and never closes");
1159
1232
  if (src[i] === "'") {
1160
1233
  if (i + 1 < len && src[i + 1] === "'") {
1161
1234
  i += 2;
@@ -1188,7 +1261,9 @@ var FormulaLinter = class {
1188
1261
  i++;
1189
1262
  continue;
1190
1263
  }
1191
- 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
+ );
1192
1267
  }
1193
1268
  return tokens;
1194
1269
  }
@@ -1206,7 +1281,7 @@ var FormulaLinter = class {
1206
1281
  const op = c.tokens[c.pos].value;
1207
1282
  c.pos++;
1208
1283
  const right = this.parseConcat(c, sheet, stack);
1209
- left = this.compare(left, right, op);
1284
+ left = this.skipping > 0 ? null : this.compare(left, right, op);
1210
1285
  }
1211
1286
  return left;
1212
1287
  }
@@ -1215,7 +1290,7 @@ var FormulaLinter = class {
1215
1290
  while (this.isOp(c, "&")) {
1216
1291
  c.pos++;
1217
1292
  const right = this.parseArith(c, sheet, stack);
1218
- left = this.coerceString(left) + this.coerceString(right);
1293
+ left = this.skipping > 0 ? null : this.coerceString(left) + this.coerceString(right);
1219
1294
  }
1220
1295
  return left;
1221
1296
  }
@@ -1225,6 +1300,10 @@ var FormulaLinter = class {
1225
1300
  const op = c.tokens[c.pos].value;
1226
1301
  c.pos++;
1227
1302
  const right = this.parseTerm(c, sheet, stack);
1303
+ if (this.skipping > 0) {
1304
+ left = null;
1305
+ continue;
1306
+ }
1228
1307
  const a = this.coerceNumber(left);
1229
1308
  const b = this.coerceNumber(right);
1230
1309
  left = op === "+" ? a + b : a - b;
@@ -1232,46 +1311,68 @@ var FormulaLinter = class {
1232
1311
  return left;
1233
1312
  }
1234
1313
  parseTerm(c, sheet, stack) {
1235
- let left = this.parseUnary(c, sheet, stack);
1236
- while (this.isOp(c, "*", "/", "%", "^")) {
1314
+ let left = this.parsePower(c, sheet, stack);
1315
+ while (this.isOp(c, "*", "/")) {
1237
1316
  const op = c.tokens[c.pos].value;
1238
1317
  c.pos++;
1239
- 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
+ }
1240
1323
  const a = this.coerceNumber(left);
1241
1324
  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;
1325
+ if (op === "*") {
1326
+ left = a * b;
1327
+ } else {
1328
+ if (b === 0) throw new LinterError(ERR_DIV0);
1329
+ left = a / b;
1256
1330
  }
1257
1331
  }
1258
1332
  return left;
1259
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
+ }
1260
1348
  parseUnary(c, sheet, stack) {
1261
1349
  if (this.isOp(c, "-")) {
1262
1350
  c.pos++;
1263
1351
  const v = this.parseUnary(c, sheet, stack);
1264
- return -this.coerceNumber(v);
1352
+ return this.skipping > 0 ? null : -this.coerceNumber(v);
1265
1353
  }
1266
1354
  if (this.isOp(c, "+")) {
1267
1355
  c.pos++;
1268
1356
  return this.parseUnary(c, sheet, stack);
1269
1357
  }
1270
- 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;
1271
1372
  }
1272
1373
  parsePrimary(c, sheet, stack) {
1273
1374
  const tok = this.peek(c);
1274
- if (!tok) throw new LinterError(ERR_NAME);
1375
+ if (!tok) throw LinterError.syntax("the formula ends where a value was expected");
1275
1376
  if (tok.type === "OP" && tok.value === "(") {
1276
1377
  c.pos++;
1277
1378
  const val = this.parseExpr(c, sheet, stack);
@@ -1291,15 +1392,19 @@ var FormulaLinter = class {
1291
1392
  const next2 = c.tokens[c.pos + 2];
1292
1393
  if (next && next.type === "OP" && next.value === "!" && next2 && next2.type === "IDENT") {
1293
1394
  const sheetName2 = this.sheetNames.get(this.cleanSheetName(tok.value).toLowerCase());
1294
- if (sheetName2 === void 0) throw new LinterError(ERR_REF);
1395
+ if (sheetName2 === void 0 && this.skipping === 0) throw new LinterError(ERR_REF);
1295
1396
  c.pos += 2;
1296
1397
  const startTok = c.tokens[c.pos].value;
1297
1398
  c.pos++;
1298
- 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`);
1299
1403
  }
1300
- if (tok.type === "SHEET") throw new LinterError(ERR_NAME);
1301
1404
  if (next && next.type === "OP" && next.value === "(") {
1302
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);
1303
1408
  c.pos += 2;
1304
1409
  const args = [];
1305
1410
  if (!this.isOp(c, ")")) {
@@ -1313,7 +1418,7 @@ var FormulaLinter = class {
1313
1418
  }
1314
1419
  }
1315
1420
  this.expectOp(c, ")");
1316
- return this.callFunction(name, args);
1421
+ return this.skipping > 0 ? null : this.callFunction(name, args);
1317
1422
  }
1318
1423
  const upper = tok.value.toUpperCase();
1319
1424
  if (upper === "TRUE" || upper === "FALSE") {
@@ -1323,13 +1428,65 @@ var FormulaLinter = class {
1323
1428
  c.pos++;
1324
1429
  return this.resolveRefOrRange(tok.value, sheet, c, stack);
1325
1430
  }
1326
- 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;
1327
1479
  }
1328
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
+ }
1329
1487
  const cleanStart = this.cleanRef(startRef);
1330
1488
  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") {
1489
+ if (isRange) {
1333
1490
  const endRef = next.value;
1334
1491
  c.pos += 2;
1335
1492
  const cleanEnd = this.cleanRef(endRef);
@@ -1383,7 +1540,10 @@ var FormulaLinter = class {
1383
1540
  }
1384
1541
  expectOp(c, op) {
1385
1542
  const t = c.tokens[c.pos];
1386
- 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
+ }
1387
1547
  c.pos++;
1388
1548
  }
1389
1549
  // ---- Coercion / comparison / functions ----
@@ -1428,17 +1588,28 @@ var FormulaLinter = class {
1428
1588
  return false;
1429
1589
  }
1430
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
+ */
1431
1597
  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;
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;
1442
1613
  }
1443
1614
  callFunction(name, args) {
1444
1615
  const flat = this.flatten(args);
@@ -1456,10 +1627,8 @@ var FormulaLinter = class {
1456
1627
  return this.minMax(flat, true);
1457
1628
  case "MAX":
1458
1629
  return this.minMax(flat, false);
1459
- case "IF":
1460
- return this.ifFn(args);
1461
1630
  case "ROUND":
1462
- 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)));
1463
1632
  case "ABS":
1464
1633
  return Math.abs(this.coerceNumber(args[0] ?? 0));
1465
1634
  case "LEN":
@@ -1476,7 +1645,7 @@ var FormulaLinter = class {
1476
1645
  case "FALSE":
1477
1646
  return false;
1478
1647
  default:
1479
- return ERR_NAME;
1648
+ throw new LinterError(ERR_NAME, "function", name);
1480
1649
  }
1481
1650
  }
1482
1651
  flatten(args) {
@@ -1504,21 +1673,34 @@ var FormulaLinter = class {
1504
1673
  const vals = nums.map((v) => this.maybeNum(v));
1505
1674
  return min ? Math.min(...vals) : Math.max(...vals);
1506
1675
  }
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;
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;
1512
1694
  }
1513
1695
  // ---- Hints ----
1514
- hint(error2, formula2, sheet) {
1696
+ hint(error2, key, formula2, sheet) {
1515
1697
  switch (error2) {
1516
1698
  case ERR_VALUE:
1517
1699
  return this.hintValue(formula2, sheet);
1518
1700
  case ERR_REF:
1519
1701
  return this.hintRef(formula2);
1520
1702
  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.";
1703
+ return this.hintName(key, formula2, sheet);
1522
1704
  case ERR_DIV0:
1523
1705
  return "Division by zero \u2014 the divisor evaluated to 0.";
1524
1706
  case ERR_CIRC:
@@ -1527,6 +1709,39 @@ var FormulaLinter = class {
1527
1709
  return "Formula evaluation failed.";
1528
1710
  }
1529
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
+ }
1530
1745
  hintRef(formula2) {
1531
1746
  for (const m of formula2.matchAll(new RegExp(SHEET_QUALIFIER, "g"))) {
1532
1747
  const name = this.cleanSheetName(m[1]);
@@ -1564,9 +1779,30 @@ var FormulaLinter = class {
1564
1779
  return "A non-numeric value was used in arithmetic. Check that all referenced cells contain numbers.";
1565
1780
  }
1566
1781
  };
1567
- function phpRound(x, n) {
1568
- const factor = 10 ** n;
1569
- 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}`));
1570
1806
  }
1571
1807
 
1572
1808
  // src/schema/repairer.ts
@@ -6263,7 +6499,7 @@ var holy_sheet_schema_default = {
6263
6499
  };
6264
6500
 
6265
6501
  // src/agent.ts
6266
- var VERSION = "2.11.1";
6502
+ var VERSION = "2.11.2";
6267
6503
  function toU8(input) {
6268
6504
  return input instanceof Uint8Array ? input : new Uint8Array(input);
6269
6505
  }
@@ -6835,5 +7071,5 @@ function unencodable(reason) {
6835
7071
  }
6836
7072
 
6837
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 };
6838
- //# sourceMappingURL=chunk-NQRZFV32.js.map
6839
- //# sourceMappingURL=chunk-NQRZFV32.js.map
7074
+ //# sourceMappingURL=chunk-Y32UXRQ6.js.map
7075
+ //# sourceMappingURL=chunk-Y32UXRQ6.js.map