@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 +56 -0
- package/dist/{chunk-NQRZFV32.js → chunk-Y32UXRQ6.js} +308 -72
- package/dist/chunk-Y32UXRQ6.js.map +1 -0
- package/dist/cli.cjs +306 -70
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +306 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -3
- package/dist/index.d.ts +62 -3
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-NQRZFV32.js.map +0 -1
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1433
|
-
|
|
1434
|
-
if (
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
const
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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
|
|
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
|
|
1568
|
-
|
|
1569
|
-
|
|
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.
|
|
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-
|
|
6839
|
-
//# sourceMappingURL=chunk-
|
|
7074
|
+
//# sourceMappingURL=chunk-Y32UXRQ6.js.map
|
|
7075
|
+
//# sourceMappingURL=chunk-Y32UXRQ6.js.map
|