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