@jxsuite/schema 5.6.0 → 6.0.0

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/src/guards.ts CHANGED
@@ -293,8 +293,152 @@ export function maskCode(body: string): string {
293
293
  const masked = new Map<string, string>();
294
294
  const MASKED_LIMIT = 2048;
295
295
 
296
- /** The scan {@link maskCode} caches. */
297
- function scanCode(body: string): string {
296
+ /**
297
+ * One `${…}` of a template string: `start` is the offset of its `$`, `end` is one past its closing
298
+ * `}`, and `expr` is the expression between the braces, exactly as written.
299
+ */
300
+ export interface TemplateExpression {
301
+ readonly start: number;
302
+ readonly end: number;
303
+ readonly expr: string;
304
+ }
305
+
306
+ /**
307
+ * Each top-level `${…}` of a template string (`spec.md` §8.4), in order.
308
+ *
309
+ * A template string is the inside of a JavaScript template literal, and its expressions are code,
310
+ * so where one ends is a question about code: a `}` inside a string, a comment, an object literal
311
+ * or a nested template literal does not close it. This is the {@link maskCode} scan started inside
312
+ * a template, so every reader of a template agrees with the engine that evaluates it. A pattern
313
+ * such as `[^}]+` cuts `${f(x, { a: 1 })}` at the object's brace, and a brace count alone ends `${a
314
+ * ? "}" : ""}` inside the string.
315
+ *
316
+ * Two things this scan does that {@link maskCode} does not, because an expression is short and the
317
+ * cost of misreading one is a binding shown or stored wrong:
318
+ *
319
+ * - A regular expression literal is recognised where an operand may start, so a quote or a brace
320
+ * inside `/"/g` is not read as code.
321
+ * - An expression the scan cannot close (an unterminated string) is closed by brace depth instead,
322
+ * and one with no closing brace at all is not reported: its `${` is literal text, and the scan
323
+ * goes on after it, so a complete expression later in the string is still found.
324
+ *
325
+ * The result is shared and must not be changed.
326
+ *
327
+ * @param template The template string, without the backticks of a literal.
328
+ */
329
+ export function templateExpressions(template: string): readonly TemplateExpression[] {
330
+ if (!template.includes("${")) {
331
+ return NO_EXPRESSIONS;
332
+ }
333
+ const known = scanned.get(template);
334
+ if (known !== undefined) {
335
+ return known;
336
+ }
337
+ const found: TemplateExpression[] = [];
338
+ let from = 0;
339
+ while (from < template.length) {
340
+ const spans: number[] = [];
341
+ scanCode(template.slice(from), spans);
342
+ let unclosed = -1;
343
+ for (let i = 0; i < spans.length; i += 2) {
344
+ const start = from + (spans[i] ?? 0);
345
+ const end = spans[i + 1] ?? -1;
346
+ if (end === -1) {
347
+ unclosed = start;
348
+ break;
349
+ }
350
+ found.push(expressionAt(template, start, from + end));
351
+ }
352
+ if (unclosed === -1) {
353
+ break;
354
+ }
355
+ // The scan lost its place (a quote it could not pair): brace depth closes this one expression.
356
+ const end = closeByBraceDepth(template, unclosed);
357
+ if (end === -1) {
358
+ // Never closed, so this `${` is text; a complete expression may still follow it.
359
+ from = unclosed + 2;
360
+ } else {
361
+ found.push(expressionAt(template, unclosed, end));
362
+ from = end;
363
+ }
364
+ }
365
+ if (scanned.size >= MASKED_LIMIT) {
366
+ scanned.clear();
367
+ }
368
+ scanned.set(template, found);
369
+ return found;
370
+ }
371
+
372
+ const NO_EXPRESSIONS: readonly TemplateExpression[] = [];
373
+
374
+ /** Templates already scanned: a renderer asks about the same template on every evaluation. */
375
+ const scanned = new Map<string, readonly TemplateExpression[]>();
376
+
377
+ function expressionAt(template: string, start: number, end: number): TemplateExpression {
378
+ return { end, expr: template.slice(start + 2, end - 1), start };
379
+ }
380
+
381
+ /** One past the `}` that balances the `${` at `start` by brace depth alone, or -1 for none. */
382
+ function closeByBraceDepth(template: string, start: number): number {
383
+ let depth = 0;
384
+ for (let at = start + 1; at < template.length; at += 1) {
385
+ const char = template.charAt(at);
386
+ if (char === "{") {
387
+ depth += 1;
388
+ } else if (char === "}") {
389
+ depth -= 1;
390
+ if (depth === 0) {
391
+ return at + 1;
392
+ }
393
+ }
394
+ }
395
+ return -1;
396
+ }
397
+
398
+ /** The characters after which a `/` starts a regular expression literal, not a division. */
399
+ const BEFORE_OPERAND = new Set("(,=:[!&|?{;+-*%<>~^");
400
+
401
+ /**
402
+ * Whether `before`, the operator character read at `at`, is the second half of `++` or `--`. A `/`
403
+ * after one of those divides (`a++ / 2`): read as a regular expression it ran to the next `/` on
404
+ * the line, which in `"${a++ / 2} and ${b / 3}"` is in the NEXT expression, and the two were
405
+ * reported as one.
406
+ */
407
+ function endsUpdateOperator(body: string, before: string, at: number): boolean {
408
+ return (before === "+" || before === "-") && body.charAt(at - 1) === before;
409
+ }
410
+
411
+ /**
412
+ * One past the end of the regular expression literal opening at `at`, or -1 when the `/` there is
413
+ * not one: it does not close on its line. A `/` inside a character class does not close it.
414
+ */
415
+ function regexLiteralEnd(body: string, at: number): number {
416
+ let inClass = false;
417
+ for (let i = at + 1; i < body.length; i += 1) {
418
+ const char = body.charAt(i);
419
+ if (char === "\n") {
420
+ return -1;
421
+ }
422
+ if (char === "\\") {
423
+ i += 1;
424
+ } else if (char === "[") {
425
+ inClass = true;
426
+ } else if (char === "]") {
427
+ inClass = false;
428
+ } else if (char === "/" && !inClass) {
429
+ return i + 1;
430
+ }
431
+ }
432
+ return -1;
433
+ }
434
+
435
+ /**
436
+ * The scan {@link maskCode} caches. With `spans` it reads `body` as a template string instead of
437
+ * code (it starts inside the template), recognises regular expression literals, and records each
438
+ * top-level substitution for {@link templateExpressions} as a pair of offsets: its `$`, then one
439
+ * past its `}`, which is -1 while it is still open.
440
+ */
441
+ function scanCode(body: string, spans?: number[]): string {
298
442
  /* UTF-16 units, as `at` counts them. Code points (`[...body]`) made an astral character in a
299
443
  string one element for two offsets, so the result came back short and every offset after it,
300
444
  which is what a caller splices the body at, was off by one. */
@@ -306,7 +450,11 @@ function scanCode(body: string): string {
306
450
  };
307
451
  /** The brace depth of each template substitution still open, innermost last. */
308
452
  const substitutions: number[] = [];
309
- let inTemplate = false;
453
+ let inTemplate = spans !== undefined;
454
+ /** The last character of code read that is not white space: what a `/` follows. */
455
+ let before = "";
456
+ /** Where `before` was read, when it is an operator or part of a name; stale otherwise. */
457
+ let beforeAt = -1;
310
458
  let at = 0;
311
459
  while (at < body.length) {
312
460
  const char = body.charAt(at);
@@ -315,12 +463,18 @@ function scanCode(body: string): string {
315
463
  blank(at);
316
464
  blank(at + 1);
317
465
  at += 2;
318
- } else if (char === "`") {
466
+ } else if (char === "`" && !(spans && substitutions.length === 0)) {
467
+ // A template STRING has no closing backtick: one in its own text is text.
319
468
  inTemplate = false;
469
+ before = char;
320
470
  at += 1;
321
471
  } else if (char === "$" && body.charAt(at + 1) === "{") {
472
+ if (substitutions.length === 0) {
473
+ spans?.push(at, -1);
474
+ }
322
475
  substitutions.push(0);
323
476
  inTemplate = false;
477
+ before = "{";
324
478
  at += 2;
325
479
  } else {
326
480
  blank(at);
@@ -347,10 +501,24 @@ function scanCode(body: string): string {
347
501
  blank(at);
348
502
  at += 1;
349
503
  }
504
+ before = char;
350
505
  at += 1;
351
506
  } else if (char === "`") {
352
507
  inTemplate = true;
353
508
  at += 1;
509
+ } else if (
510
+ spans &&
511
+ char === "/" &&
512
+ BEFORE_OPERAND.has(before) &&
513
+ !endsUpdateOperator(body, before, beforeAt) &&
514
+ regexLiteralEnd(body, at) > 0
515
+ ) {
516
+ const stop = regexLiteralEnd(body, at);
517
+ for (at += 1; at < stop - 1; at += 1) {
518
+ blank(at);
519
+ }
520
+ before = "/";
521
+ at = stop;
354
522
  } else {
355
523
  const open = substitutions.length - 1;
356
524
  if (open >= 0 && char === "{") {
@@ -359,10 +527,17 @@ function scanCode(body: string): string {
359
527
  if (substitutions[open] === 0) {
360
528
  substitutions.pop();
361
529
  inTemplate = true;
530
+ if (spans && substitutions.length === 0) {
531
+ spans[spans.length - 1] = at + 1;
532
+ }
362
533
  } else {
363
534
  substitutions[open] = (substitutions[open] ?? 1) - 1;
364
535
  }
365
536
  }
537
+ if (char.trim() !== "") {
538
+ before = char;
539
+ beforeAt = at;
540
+ }
366
541
  at += 1;
367
542
  }
368
543
  }