@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.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Agent, readFileBounded, OfficeTemplateRefused, ArchiveRefused, UnsupportedFormatException, sourceKind, SourceArchive } from './chunk-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1429
|
-
|
|
1430
|
-
if (
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
|
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
|
|
1564
|
-
|
|
1565
|
-
|
|
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.
|
|
6927
|
+
var VERSION = "2.11.2";
|
|
6681
6928
|
function toU8(input) {
|
|
6682
6929
|
return input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
6683
6930
|
}
|