@jxsuite/schema 5.5.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * One verdict per `$prototype: "Function"` entry: is it a **computed** value, read reactively, or a
3
+ * **callable**, invoked by an event, a call site or the host?
4
+ *
5
+ * Every tier reads this one classifier (the interpreter's `buildScope`, both compiled targets and
6
+ * the build-time scope), so a document means the same thing in Studio and in production. It reads
7
+ * the document, never an imported function's arity or source: a compiler has only the document, and
8
+ * `fn.toString()` of a bundled import is not a stable input (spec.md §5.3 4b, 4d).
9
+ *
10
+ * @docs framework/concepts/functions
11
+ */
12
+ import {
13
+ bodyReturnsValue,
14
+ hasStructuredBody,
15
+ isFunctionDef,
16
+ isJsonObject,
17
+ isRef,
18
+ isString,
19
+ paramNames,
20
+ } from "./guards";
21
+ import type { JsonValue, JxElement, JxFunctionDef } from "../types";
22
+
23
+ /** What a Function entry is to the tiers that read it. */
24
+ export type FunctionRole = "computed" | "callable";
25
+
26
+ /** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
27
+ export const LIFECYCLE_KEYS: ReadonlySet<string> = new Set(["onMount", "onUnmount", "onAdopted"]);
28
+
29
+ /**
30
+ * The state keys a document uses as a _callable_, at any depth: bound to an `on*` event (a
31
+ * definition's root-level handlers included, §16.1), invoked by an `$expression` `call` node
32
+ * (§19.4c), called as `state.key(…)` from a template string or another function's body, or named as
33
+ * a lifecycle hook (§16.4). `$defs` holds JSON Schema, not usage, and is skipped.
34
+ *
35
+ * Anything not used as a callable has its return value read reactively. Defaulting the other way
36
+ * round would turn a called helper into a value and break its call site.
37
+ */
38
+ function collectCallableKeys(doc: JxElement): Set<string> {
39
+ const keys = new Set<string>();
40
+ const seen = new Set<object>();
41
+
42
+ const addStateRef = (ref: string): void => {
43
+ if (ref.startsWith("#/state/")) {
44
+ /* SAFETY: `String.prototype.split` always returns at least one element (the whole string
45
+ when the separator is absent), so index 0 is present. */
46
+ keys.add(ref.slice("#/state/".length).split("/")[0] as string);
47
+ }
48
+ };
49
+
50
+ const visit = (node: JsonValue | undefined): void => {
51
+ if (isString(node)) {
52
+ // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
53
+ for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/gu)) {
54
+ /* SAFETY: the pattern's one capture group is not optional, so every match fills index 1
55
+ with the called member's name. */
56
+ keys.add(call[1] as string);
57
+ }
58
+ return;
59
+ }
60
+ if (Array.isArray(node)) {
61
+ if (!seen.has(node)) {
62
+ seen.add(node);
63
+ for (const item of node) {
64
+ visit(item);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ if (!isJsonObject(node) || seen.has(node)) {
70
+ return;
71
+ }
72
+ seen.add(node);
73
+ // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
74
+ if (node.operator === "call" && isRef(node.target)) {
75
+ addStateRef(node.target.$ref);
76
+ }
77
+ for (const [key, value] of Object.entries(node)) {
78
+ if (key === "$defs") {
79
+ continue;
80
+ }
81
+ if (key.startsWith("on") && isRef(value)) {
82
+ addStateRef(value.$ref);
83
+ }
84
+ visit(value);
85
+ }
86
+ };
87
+
88
+ // SAFETY: a Jx element is JSON data; its type names what it holds.
89
+ visit(doc as JsonValue);
90
+ for (const hook of LIFECYCLE_KEYS) {
91
+ if (isJsonObject(doc.state) && hook in doc.state) {
92
+ keys.add(hook);
93
+ }
94
+ }
95
+ return keys;
96
+ }
97
+
98
+ /**
99
+ * Does a Function declaration name both a `body` and a `$src`? The two are mutually exclusive
100
+ * (spec.md §5.3 4d), and the test is presence, as the `FunctionDef` schema's `not` is, so an empty
101
+ * `body: ""` beside a `$src` is refused too and `jx validate` agrees with every tier.
102
+ *
103
+ * @param def A Function declaration, a `state` entry or a handler written inline on an `on*` key.
104
+ */
105
+ export function declaresBodyAndSrc(def: JxFunctionDef): boolean {
106
+ return "body" in def && "$src" in def;
107
+ }
108
+
109
+ /**
110
+ * Does a Function declaration list any parameter of its own, by `parameters` or the legacy
111
+ * `arguments`? An inline handler that does not is rewritten to read `state` as the handler's alias,
112
+ * and one that does is built with {@link handlerParamNames}.
113
+ *
114
+ * @param def A Function declaration.
115
+ */
116
+ export function declaresParameters(def: JxFunctionDef): boolean {
117
+ return paramNames(def.parameters ?? def.arguments).length > 0;
118
+ }
119
+
120
+ /**
121
+ * The formal parameters a string `body` is built with, in every tier (spec.md §5.3 4d): `state`
122
+ * first, then the other declared names in declared order. Binding is by position, as ECMAScript's
123
+ * is, so a name that is not `state` receives whatever the call site passes in that position. An
124
+ * event binding calls a handler `(state, event)`, so the first other name is the event and a later
125
+ * one is `undefined` from an event (a call with too few arguments), filled by a call site that
126
+ * passes it. A declared `state` is moved to the front rather than repeated, since a duplicate
127
+ * parameter is a sloppy-mode quirk in which the later one wins and `state` would be `undefined`.
128
+ *
129
+ * @param def A Function declaration, a `state` entry or a handler written inline on an `on*` key.
130
+ */
131
+ export function handlerParamNames(def: JxFunctionDef): string[] {
132
+ const list = ["state"];
133
+ for (const name of paramNames(def.parameters ?? def.arguments)) {
134
+ if (name !== "state") {
135
+ list.push(name);
136
+ }
137
+ }
138
+ return list;
139
+ }
140
+
141
+ /** The rules in order: the first that applies decides. */
142
+ function functionEntryRole(
143
+ key: string,
144
+ def: JxFunctionDef,
145
+ callableKeys: () => Set<string>,
146
+ ): FunctionRole {
147
+ if (hasStructuredBody(def)) {
148
+ return "callable";
149
+ }
150
+ // Declared parameters or arguments make an entry callable wherever it is used (§19.4c).
151
+ if (paramNames(def.parameters ?? def.arguments).length > 0) {
152
+ return "callable";
153
+ }
154
+ if (isString(def.body)) {
155
+ return bodyReturnsValue(def.body) ? "computed" : "callable";
156
+ }
157
+ if (def.$src) {
158
+ return callableKeys().has(key) ? "callable" : "computed";
159
+ }
160
+ return "callable";
161
+ }
162
+
163
+ /**
164
+ * Classify every Function entry in a document's `state`. Other state shapes get no verdict. The
165
+ * document is the one a tier builds, after the project-state merge, so a project-global entry one
166
+ * page calls and another only reads is a function on the first and a computed on the second.
167
+ *
168
+ * The walk for usage runs at most once, and only when a bodyless `$src` entry reaches it.
169
+ *
170
+ * @param doc The document (or just `{ state }`) being built.
171
+ */
172
+ export function classifyFunctionEntries(doc: JxElement): Map<string, FunctionRole> {
173
+ const roles = new Map<string, FunctionRole>();
174
+ let callable: Set<string> | null = null;
175
+ const callableKeys = (): Set<string> => (callable ??= collectCallableKeys(doc));
176
+ for (const [key, def] of Object.entries(doc.state ?? {})) {
177
+ if (isFunctionDef(def)) {
178
+ roles.set(key, functionEntryRole(key, def, callableKeys));
179
+ }
180
+ }
181
+ return roles;
182
+ }
package/src/guards.ts CHANGED
@@ -245,6 +245,17 @@ export function bodyReturnsValue(body: string): boolean {
245
245
  return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(maskCode(body));
246
246
  }
247
247
 
248
+ /**
249
+ * Whether an object is a function entry, whose `body` is code (`spec.md` §23.1): a `Function`
250
+ * entry, or a class member, which a `.class.json` names by its `role` alone. A field is a member
251
+ * with no body. Any other `body` may be data, so it is never read as code.
252
+ *
253
+ * @param value An object in a document or class definition.
254
+ */
255
+ export function isFunctionEntry(value: JsonObject): boolean {
256
+ return value.$prototype === "Function" || (isString(value.role) && value.role !== "field");
257
+ }
258
+
248
259
  /**
249
260
  * A function body with its comments and the contents of its strings blanked, so a pattern reads
250
261
  * only code (`spec.md` §23.1). The result is the same length, every newline kept, so an offset in
@@ -282,8 +293,152 @@ export function maskCode(body: string): string {
282
293
  const masked = new Map<string, string>();
283
294
  const MASKED_LIMIT = 2048;
284
295
 
285
- /** The scan {@link maskCode} caches. */
286
- 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 {
287
442
  /* UTF-16 units, as `at` counts them. Code points (`[...body]`) made an astral character in a
288
443
  string one element for two offsets, so the result came back short and every offset after it,
289
444
  which is what a caller splices the body at, was off by one. */
@@ -295,7 +450,11 @@ function scanCode(body: string): string {
295
450
  };
296
451
  /** The brace depth of each template substitution still open, innermost last. */
297
452
  const substitutions: number[] = [];
298
- 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;
299
458
  let at = 0;
300
459
  while (at < body.length) {
301
460
  const char = body.charAt(at);
@@ -304,12 +463,18 @@ function scanCode(body: string): string {
304
463
  blank(at);
305
464
  blank(at + 1);
306
465
  at += 2;
307
- } 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.
308
468
  inTemplate = false;
469
+ before = char;
309
470
  at += 1;
310
471
  } else if (char === "$" && body.charAt(at + 1) === "{") {
472
+ if (substitutions.length === 0) {
473
+ spans?.push(at, -1);
474
+ }
311
475
  substitutions.push(0);
312
476
  inTemplate = false;
477
+ before = "{";
313
478
  at += 2;
314
479
  } else {
315
480
  blank(at);
@@ -336,10 +501,24 @@ function scanCode(body: string): string {
336
501
  blank(at);
337
502
  at += 1;
338
503
  }
504
+ before = char;
339
505
  at += 1;
340
506
  } else if (char === "`") {
341
507
  inTemplate = true;
342
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;
343
522
  } else {
344
523
  const open = substitutions.length - 1;
345
524
  if (open >= 0 && char === "{") {
@@ -348,10 +527,17 @@ function scanCode(body: string): string {
348
527
  if (substitutions[open] === 0) {
349
528
  substitutions.pop();
350
529
  inTemplate = true;
530
+ if (spans && substitutions.length === 0) {
531
+ spans[spans.length - 1] = at + 1;
532
+ }
351
533
  } else {
352
534
  substitutions[open] = (substitutions[open] ?? 1) - 1;
353
535
  }
354
536
  }
537
+ if (char.trim() !== "") {
538
+ before = char;
539
+ beforeAt = at;
540
+ }
355
541
  at += 1;
356
542
  }
357
543
  }