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