@velarscript/web 0.27.3 → 0.28.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/dist/analyzer.d.ts +59 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +486 -18
- package/dist/analyzer.js.map +1 -1
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +14 -1
- package/dist/compiler.js.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +10 -0
- package/dist/emitter.js.map +1 -1
- package/dist/look-static.d.ts.map +1 -1
- package/dist/look-static.js +13 -0
- package/dist/look-static.js.map +1 -1
- package/dist/look.d.ts +2 -2
- package/dist/look.d.ts.map +1 -1
- package/dist/look.js +21 -2
- package/dist/look.js.map +1 -1
- package/dist/runtime-foundation.d.ts.map +1 -1
- package/dist/runtime-foundation.js +161 -20
- package/dist/runtime-foundation.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +24 -0
- package/dist/runtime.js.map +1 -1
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +22 -6
- package/dist/semantic.js.map +1 -1
- package/package.json +3 -3
package/dist/analyzer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mechanicalEdits, mechanicalFix, semanticTypeIdentity } from "@velarscript/compiler";
|
|
2
|
-
import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
|
|
2
|
+
import { Analyzer, anyType, boolType, describeType, expressionContainsDirectAwait, invalidType, isInvalidType, isAssignable, isReadonlyView, mutatingCollectionMethods, nullType, nonOptional, numberType, optionalOf, spanIdentity, stringType, unknownType, } from "@velarscript/compiler/extension";
|
|
3
3
|
import { BROWSER_TEST_MODULE, BROWSER_TEST_SOURCE_SUFFIX, browserTestImportGuidance } from "./browser-test.js";
|
|
4
4
|
import { cssTokens } from "./css-tokens.js";
|
|
5
5
|
import { LOOK_ABSENT_MEDIA_SUBJECTS, LOOK_ARITHMETIC_HINT, LOOK_ANIMATION_DIRECTIONS, LOOK_ANIMATION_EASINGS, LOOK_ANIMATION_FILLS, LOOK_BORDER_STYLE_NAMES, LOOK_BUILDER_NUMERIC_RANGES, LOOK_BUILDER_SIGNATURES, LOOK_BUILDERS, LOOK_EXCLUDED_PROPERTIES, LOOK_HOOKS, LOOK_CSS_WIDE_KEYWORDS, LOOK_COLOR_KEYWORDS, LOOK_LARGE_KEYWORD_SETS, LOOK_LENGTH_BUILDERS, LOOK_MEDIA_LENGTH_UNITS, LOOK_MEDIA_SUBJECTS, LOOK_NUMERIC_TYPE_NAMES, LOOK_NON_ANIMATABLE_PROPERTIES, LOOK_PARTIAL_KEYWORD_PROPERTIES, LOOK_PROPERTIES, LOOK_PROPERTY_KEYWORDS, LOOK_PROPERTY_VALUE_KINDS, LOOK_SHARED_METRIC_KEYWORDS, LOOK_TARGETS, LOOK_TOKEN_NAME_RULE, LOOK_TOKEN_NO_FALLBACK_GUIDANCE, LOOK_UNIT_TYPES, LOOK_UNITLESS_PROPERTIES, isLookTokenName, isLookVarReference, lookOwnKeywords, lookShorthandOverlap, lookShorthandParts, lookTokenReference, lookVarReferenceName, nearestLookName, } from "./look.js";
|
|
@@ -72,6 +72,7 @@ const lookColor = { kind: "named", name: "Color" };
|
|
|
72
72
|
const lookImage = { kind: "named", name: "Image" };
|
|
73
73
|
const lookBorder = { kind: "named", name: "Border" };
|
|
74
74
|
const lookShadow = { kind: "named", name: "Shadow" };
|
|
75
|
+
const lookFilter = { kind: "named", name: "Filter" };
|
|
75
76
|
const lookDuration = { kind: "named", name: "Duration" };
|
|
76
77
|
const lookAngle = { kind: "named", name: "Angle" };
|
|
77
78
|
const lookTrackList = { kind: "named", name: "TrackList" };
|
|
@@ -101,7 +102,7 @@ const lookPropertyType = (kind) => {
|
|
|
101
102
|
case "shadow": return { kind: "union", members: [lookShadow, stringType] };
|
|
102
103
|
case "track": return { kind: "union", members: [lookTrackList, stringType] };
|
|
103
104
|
case "transition": return { kind: "union", members: [lookTransition, stringType] };
|
|
104
|
-
case "filter":
|
|
105
|
+
case "filter": return { kind: "union", members: [lookFilter, stringType] };
|
|
105
106
|
case "keyword":
|
|
106
107
|
case "text":
|
|
107
108
|
case "transform":
|
|
@@ -428,6 +429,147 @@ function lookShorthandStringGuidance(name, value) {
|
|
|
428
429
|
}
|
|
429
430
|
return null;
|
|
430
431
|
}
|
|
432
|
+
const lookSimpleFilterBuilders = new Map([
|
|
433
|
+
["blur", { builder: "blur", argument: "length" }],
|
|
434
|
+
["brightness", { builder: "brightness", argument: "number" }],
|
|
435
|
+
["contrast", { builder: "contrast", argument: "number" }],
|
|
436
|
+
["grayscale", { builder: "grayscale", argument: "number" }],
|
|
437
|
+
["hue-rotate", { builder: "hueRotate", argument: "angle" }],
|
|
438
|
+
["invert", { builder: "invert", argument: "number" }],
|
|
439
|
+
["opacity", { builder: "filterOpacity", argument: "number" }],
|
|
440
|
+
["saturate", { builder: "saturate", argument: "number" }],
|
|
441
|
+
["sepia", { builder: "sepia", argument: "number" }],
|
|
442
|
+
]);
|
|
443
|
+
function cssFunctionValues(text) {
|
|
444
|
+
const values = [];
|
|
445
|
+
let cursor = 0;
|
|
446
|
+
while (cursor < text.length) {
|
|
447
|
+
while (cursor < text.length && /\s/u.test(text[cursor]))
|
|
448
|
+
cursor += 1;
|
|
449
|
+
if (cursor === text.length)
|
|
450
|
+
break;
|
|
451
|
+
const matched = /^([a-z-]+)\(/iu.exec(text.slice(cursor));
|
|
452
|
+
if (!matched)
|
|
453
|
+
return null;
|
|
454
|
+
const name = matched[1].toLowerCase();
|
|
455
|
+
const start = cursor + matched[0].length;
|
|
456
|
+
let depth = 1;
|
|
457
|
+
let quote = null;
|
|
458
|
+
let escaped = false;
|
|
459
|
+
cursor = start;
|
|
460
|
+
while (cursor < text.length && depth > 0) {
|
|
461
|
+
const character = text[cursor];
|
|
462
|
+
if (escaped)
|
|
463
|
+
escaped = false;
|
|
464
|
+
else if (quote && character === "\\")
|
|
465
|
+
escaped = true;
|
|
466
|
+
else if (quote && character === quote)
|
|
467
|
+
quote = null;
|
|
468
|
+
else if (!quote && (character === "\"" || character === "'"))
|
|
469
|
+
quote = character;
|
|
470
|
+
else if (!quote && character === "(")
|
|
471
|
+
depth += 1;
|
|
472
|
+
else if (!quote && character === ")")
|
|
473
|
+
depth -= 1;
|
|
474
|
+
cursor += 1;
|
|
475
|
+
}
|
|
476
|
+
if (depth !== 0 || quote)
|
|
477
|
+
return null;
|
|
478
|
+
values.push({ name, arguments: text.slice(start, cursor - 1).trim() });
|
|
479
|
+
if (cursor < text.length && !/\s/u.test(text[cursor]))
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return values.length > 0 ? values : null;
|
|
483
|
+
}
|
|
484
|
+
function topLevelWords(text) {
|
|
485
|
+
const words = [];
|
|
486
|
+
let start = 0;
|
|
487
|
+
let depth = 0;
|
|
488
|
+
for (let cursor = 0; cursor <= text.length; cursor += 1) {
|
|
489
|
+
const character = text[cursor];
|
|
490
|
+
if (character === "(")
|
|
491
|
+
depth += 1;
|
|
492
|
+
else if (character === ")") {
|
|
493
|
+
depth -= 1;
|
|
494
|
+
if (depth < 0)
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
if ((character === undefined || /\s/u.test(character)) && depth === 0) {
|
|
498
|
+
const word = text.slice(start, cursor).trim();
|
|
499
|
+
if (word)
|
|
500
|
+
words.push(word);
|
|
501
|
+
start = cursor + 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return depth === 0 ? words : null;
|
|
505
|
+
}
|
|
506
|
+
function filterLength(text) {
|
|
507
|
+
return /^(?:0|\+?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|vw|vh|vmin|vmax))$/u.test(text)
|
|
508
|
+
? lookBuilderToken(text)
|
|
509
|
+
: null;
|
|
510
|
+
}
|
|
511
|
+
function filterNumericArgumentsHold(builder, values) {
|
|
512
|
+
const ranges = LOOK_BUILDER_NUMERIC_RANGES.get(builder);
|
|
513
|
+
return values.every((value, index) => {
|
|
514
|
+
const range = ranges?.[index];
|
|
515
|
+
const numeric = Number(value);
|
|
516
|
+
return Number.isFinite(numeric) && (!range || (numeric >= range[1] && numeric <= range[2]));
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
function filterColor(text) {
|
|
520
|
+
if (/^#[0-9a-f]{3,8}$/iu.test(text) || /^[a-z]+$/iu.test(text)) {
|
|
521
|
+
return { call: `color(${JSON.stringify(text)})`, builders: ["color"] };
|
|
522
|
+
}
|
|
523
|
+
const functional = /^(rgb|rgba)\((.*)\)$/iu.exec(text);
|
|
524
|
+
if (!functional)
|
|
525
|
+
return null;
|
|
526
|
+
const channels = functional[2].split(",").map((channel) => channel.trim());
|
|
527
|
+
const expected = functional[1].toLowerCase() === "rgb" ? 3 : 4;
|
|
528
|
+
if (channels.length !== expected || channels.some((channel) => !/^\+?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(channel)))
|
|
529
|
+
return null;
|
|
530
|
+
const builder = functional[1].toLowerCase();
|
|
531
|
+
if (!filterNumericArgumentsHold(builder, channels))
|
|
532
|
+
return null;
|
|
533
|
+
return { call: `${builder}(${channels.join(", ")})`, builders: [builder] };
|
|
534
|
+
}
|
|
535
|
+
function filterFunctionRewrite(value) {
|
|
536
|
+
const simple = lookSimpleFilterBuilders.get(value.name);
|
|
537
|
+
if (simple) {
|
|
538
|
+
const argument = simple.argument === "length"
|
|
539
|
+
? filterLength(value.arguments)
|
|
540
|
+
: simple.argument === "angle"
|
|
541
|
+
? (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|turn)$/u.test(value.arguments) ? value.arguments : null)
|
|
542
|
+
: (/^\+?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(value.arguments)
|
|
543
|
+
&& filterNumericArgumentsHold(simple.builder, [value.arguments]) ? value.arguments : null);
|
|
544
|
+
return argument === null ? null : { call: `${simple.builder}(${argument})`, builders: [simple.builder] };
|
|
545
|
+
}
|
|
546
|
+
if (value.name !== "drop-shadow")
|
|
547
|
+
return null;
|
|
548
|
+
const words = topLevelWords(value.arguments);
|
|
549
|
+
if (!words || words.length !== 4)
|
|
550
|
+
return null;
|
|
551
|
+
const lengths = words.slice(0, 3).map(filterLength);
|
|
552
|
+
const color = filterColor(words[3]);
|
|
553
|
+
if (lengths.some((length) => length === null) || !color)
|
|
554
|
+
return null;
|
|
555
|
+
return {
|
|
556
|
+
call: `dropShadow(${lengths.join(", ")}, ${color.call})`,
|
|
557
|
+
builders: ["dropShadow", ...color.builders],
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function lookFilterRewrite(text) {
|
|
561
|
+
const functions = cssFunctionValues(text.trim());
|
|
562
|
+
if (!functions)
|
|
563
|
+
return null;
|
|
564
|
+
const rewritten = functions.map(filterFunctionRewrite);
|
|
565
|
+
if (rewritten.some((item) => item === null))
|
|
566
|
+
return null;
|
|
567
|
+
const calls = rewritten;
|
|
568
|
+
const builders = [...new Set(calls.flatMap((item) => item.builders))];
|
|
569
|
+
if (calls.length === 1)
|
|
570
|
+
return { call: calls[0].call, builders };
|
|
571
|
+
return { call: `filters(${calls.map((item) => item.call).join(", ")})`, builders: ["filters", ...builders] };
|
|
572
|
+
}
|
|
431
573
|
/**
|
|
432
574
|
* D104 rule 2 — what a refusal says when two entries in one scope write the
|
|
433
575
|
* same CSS declaration. Written once because two positions raise it: a Look
|
|
@@ -1039,6 +1181,169 @@ function watchSubjectPath(expression) {
|
|
|
1039
1181
|
return false;
|
|
1040
1182
|
}
|
|
1041
1183
|
}
|
|
1184
|
+
/**
|
|
1185
|
+
* D114 W: a reactive place written as one comparable key, so "the write and the
|
|
1186
|
+
* subject name the same place" is one string equality.
|
|
1187
|
+
*
|
|
1188
|
+
* It is deliberately narrower than `renderWatchSubject`, which reconstructs any
|
|
1189
|
+
* expression for a message. A key has to *decide*, so only the parts that name
|
|
1190
|
+
* the same place on two evaluations are allowed into one: names, fields, and an
|
|
1191
|
+
* index that is either a literal or another such path. `items[next()]` renders
|
|
1192
|
+
* perfectly well and answers a different element every call, so it has no key
|
|
1193
|
+
* and the shapes below stay silent on it — which is the right answer for a
|
|
1194
|
+
* refusal that has to be right every time.
|
|
1195
|
+
*/
|
|
1196
|
+
function reactiveWritePath(expression) {
|
|
1197
|
+
switch (expression.kind) {
|
|
1198
|
+
case "IdentifierExpression":
|
|
1199
|
+
return expression.name;
|
|
1200
|
+
case "MemberExpression": {
|
|
1201
|
+
if (expression.optional)
|
|
1202
|
+
return null;
|
|
1203
|
+
const object = reactiveWritePath(expression.object);
|
|
1204
|
+
return object === null ? null : `${object}.${expression.property}`;
|
|
1205
|
+
}
|
|
1206
|
+
case "IndexExpression": {
|
|
1207
|
+
if (expression.optional)
|
|
1208
|
+
return null;
|
|
1209
|
+
const object = reactiveWritePath(expression.object);
|
|
1210
|
+
if (object === null)
|
|
1211
|
+
return null;
|
|
1212
|
+
const index = expression.index.kind === "LiteralExpression"
|
|
1213
|
+
? (typeof expression.index.value === "string" ? JSON.stringify(expression.index.value) : expression.index.raw)
|
|
1214
|
+
: reactiveWritePath(expression.index);
|
|
1215
|
+
return index === null ? null : `${object}[${index}]`;
|
|
1216
|
+
}
|
|
1217
|
+
default:
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/** The root name a reactive path starts from, which is the binding it resolves through. */
|
|
1222
|
+
function reactivePathRoot(expression) {
|
|
1223
|
+
switch (expression.kind) {
|
|
1224
|
+
case "IdentifierExpression":
|
|
1225
|
+
return expression.name;
|
|
1226
|
+
case "MemberExpression":
|
|
1227
|
+
case "IndexExpression":
|
|
1228
|
+
return reactivePathRoot(expression.object);
|
|
1229
|
+
default:
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
function bindingPatternBinds(pattern, name) {
|
|
1234
|
+
switch (pattern.kind) {
|
|
1235
|
+
case "NameBindingPattern":
|
|
1236
|
+
return pattern.name === name;
|
|
1237
|
+
case "ObjectBindingPattern":
|
|
1238
|
+
return pattern.rest?.name === name || pattern.entries.some((entry) => bindingPatternBinds(entry.pattern, name));
|
|
1239
|
+
case "ListBindingPattern":
|
|
1240
|
+
return pattern.rest?.name === name
|
|
1241
|
+
|| pattern.elements.some((element) => element !== null && bindingPatternBinds(element, name));
|
|
1242
|
+
default:
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* D114 W: whether a body statement introduces its own binding of `name`. From
|
|
1248
|
+
* that statement on, the spelling names something else, and a write through it
|
|
1249
|
+
* is not a write of the watched place. The scan stops there rather than
|
|
1250
|
+
* guessing which of the two a later line meant.
|
|
1251
|
+
*/
|
|
1252
|
+
function statementBindsName(statement, name) {
|
|
1253
|
+
switch (statement.kind) {
|
|
1254
|
+
case "VariableDeclaration":
|
|
1255
|
+
return bindingPatternBinds(statement.pattern, name);
|
|
1256
|
+
case "UsingDeclaration":
|
|
1257
|
+
case "FunctionDeclaration":
|
|
1258
|
+
case "ClassDeclaration":
|
|
1259
|
+
return statement.name === name;
|
|
1260
|
+
default:
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* D114 W: the call a body statement makes when the statement is nothing but
|
|
1266
|
+
* that call. `detach` is included because it is how a synchronous watch body
|
|
1267
|
+
* starts asynchronous work — the tour and four charter fences spell the reload
|
|
1268
|
+
* that way — so a refusal that only saw the bare call would miss the shape it
|
|
1269
|
+
* exists for. Everything else (a call inside an `if`, an argument, an assigned
|
|
1270
|
+
* result) is not a plain top-level call and is not offered here.
|
|
1271
|
+
*/
|
|
1272
|
+
function topLevelCall(statement) {
|
|
1273
|
+
const expression = statement.kind === "ExpressionStatement" ? statement.expression
|
|
1274
|
+
: statement.kind === "DetachStatement" ? statement.expression
|
|
1275
|
+
: null;
|
|
1276
|
+
return expression !== null && expression.kind === "CallExpression" ? expression : null;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* D114 W: whether one plain body statement writes the reactive place `path`.
|
|
1280
|
+
* An assignment or a compound assignment to it is one; so is a call of a
|
|
1281
|
+
* mutating collection method on it, because a watch on a collection fires on
|
|
1282
|
+
* its deep mutation and `mutating` is the compiler's own roster of the calls
|
|
1283
|
+
* that mutate.
|
|
1284
|
+
*/
|
|
1285
|
+
function reactiveWriteOf(statement, path, mutating) {
|
|
1286
|
+
if (statement.kind === "AssignmentStatement")
|
|
1287
|
+
return reactiveWritePath(statement.target) === path;
|
|
1288
|
+
if (mutating === null)
|
|
1289
|
+
return false;
|
|
1290
|
+
const call = statement.kind === "ExpressionStatement" && statement.expression.kind === "CallExpression"
|
|
1291
|
+
? statement.expression
|
|
1292
|
+
: null;
|
|
1293
|
+
if (call === null || call.callee.kind !== "MemberExpression" || call.callee.optional)
|
|
1294
|
+
return false;
|
|
1295
|
+
return mutating.has(call.callee.property) && reactiveWritePath(call.callee.object) === path;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* D114 W A2(b): whether an `action` or `async def` writes `path` at its own top
|
|
1299
|
+
* level, unconditionally. One hop: what the callee itself calls is not
|
|
1300
|
+
* followed. A parameter of the callee's own that is spelled like the path's
|
|
1301
|
+
* root, or a binding it declares before the write, means the write is not of
|
|
1302
|
+
* the watched place and the answer is no.
|
|
1303
|
+
*/
|
|
1304
|
+
function writerWritesPath(writer, path, root, mutating) {
|
|
1305
|
+
if (writer.parameters.includes(root))
|
|
1306
|
+
return false;
|
|
1307
|
+
for (const statement of writer.body) {
|
|
1308
|
+
if (statementBindsName(statement, root))
|
|
1309
|
+
return false;
|
|
1310
|
+
if (reactiveWriteOf(statement, path, mutating))
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
function collectReactiveWriters(program) {
|
|
1316
|
+
const writers = new Map();
|
|
1317
|
+
const claim = (name, declaration) => {
|
|
1318
|
+
writers.set(name, writers.has(name) ? null : declaration);
|
|
1319
|
+
};
|
|
1320
|
+
const record = (statements) => {
|
|
1321
|
+
for (const statement of statements) {
|
|
1322
|
+
if (statement.kind === "FunctionDeclaration") {
|
|
1323
|
+
claim(statement.name, statement.asynchronous
|
|
1324
|
+
? { spelling: "async def", parameters: statement.parameters.map((parameter) => parameter.name), body: statement.body }
|
|
1325
|
+
: null);
|
|
1326
|
+
record(statement.body);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
if (!isWebStatement(statement))
|
|
1330
|
+
continue;
|
|
1331
|
+
if (statement.kind === "ExtensionStatement:web:action") {
|
|
1332
|
+
claim(statement.name, {
|
|
1333
|
+
spelling: "action",
|
|
1334
|
+
parameters: statement.parameters.map((parameter) => parameter.name),
|
|
1335
|
+
body: statement.body,
|
|
1336
|
+
});
|
|
1337
|
+
record(statement.body);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
if (statement.kind === "ExtensionStatement:web:component")
|
|
1341
|
+
record(statement.body);
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
record(program.body);
|
|
1345
|
+
return writers;
|
|
1346
|
+
}
|
|
1042
1347
|
/** The escapes a text literal carries back into source (`scanStringEscape`). */
|
|
1043
1348
|
const WATCH_SUBJECT_TEXT_ESCAPES = {
|
|
1044
1349
|
"\\": "\\\\",
|
|
@@ -1640,6 +1945,15 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
1640
1945
|
* name resolves to even where a narrowed copy answers the lookup.
|
|
1641
1946
|
*/
|
|
1642
1947
|
computedBindingSpans = new Set();
|
|
1948
|
+
/**
|
|
1949
|
+
* D114 W: the declaration spans of every `resource` in scope, kept the way
|
|
1950
|
+
* `computedBindingSpans` keeps derived values — the question is asked of the
|
|
1951
|
+
* binding a name resolves to, so a local shadow of a resource's name is not
|
|
1952
|
+
* one.
|
|
1953
|
+
*/
|
|
1954
|
+
resourceBindingSpans = new Set();
|
|
1955
|
+
/** D114 W A2(b): this module's `action` and `async def` bodies by name. */
|
|
1956
|
+
reactiveWriters = new Map();
|
|
1643
1957
|
/** Local names bound to an imported `export computed`, from the Web interface. */
|
|
1644
1958
|
importedComputedNames;
|
|
1645
1959
|
/** The resolved spans of those imports, so a local shadow of the name is not one. */
|
|
@@ -1678,6 +1992,7 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
1678
1992
|
this.keyedListSources.clear();
|
|
1679
1993
|
this.keyedListRebuilds.length = 0;
|
|
1680
1994
|
this.moduleFunctions = collectModuleFunctions(program);
|
|
1995
|
+
this.reactiveWriters = collectReactiveWriters(program);
|
|
1681
1996
|
super.analyze(program);
|
|
1682
1997
|
this.reportStaticJsxKeys();
|
|
1683
1998
|
this.reportRetiredComputedFunction();
|
|
@@ -1714,6 +2029,11 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
1714
2029
|
* to `WEB_OWNED_TYPE_NAMES` extends this protection with it. The last time
|
|
1715
2030
|
* this family was repaired by listing names instead of deriving them, the
|
|
1716
2031
|
* list drifted; D57 rule 135 is the same repair on the Core roster.
|
|
2032
|
+
*
|
|
2033
|
+
* Core now says the same sentence about its own built-in type names —
|
|
2034
|
+
* `builtinTypeNameDeclarationMessage` in packages/compiler/src/analyzer.ts,
|
|
2035
|
+
* reported as VEL3007. The rosters differ; the wording is meant to read
|
|
2036
|
+
* alike, so a change to either sentence belongs in both.
|
|
1717
2037
|
*/
|
|
1718
2038
|
rejectWebOwnedTypeNames(program) {
|
|
1719
2039
|
const reject = (name, errorSpan, noun) => {
|
|
@@ -1850,7 +2170,9 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
1850
2170
|
// the body only runs on a later change, so its reads are deferred
|
|
1851
2171
|
// for the module-initialization-cycle classification.
|
|
1852
2172
|
const watched = this.inferExpression(statement.expression);
|
|
1853
|
-
this.rejectFrozenWatchSubject(statement.expression, watched, statement.currentName, statement.previousName)
|
|
2173
|
+
if (this.rejectFrozenWatchSubject(statement.expression, watched, statement.currentName, statement.previousName)) {
|
|
2174
|
+
this.rejectWatchCycle(statement.expression, watched, statement.body);
|
|
2175
|
+
}
|
|
1854
2176
|
this.enterScope();
|
|
1855
2177
|
if (statement.currentName)
|
|
1856
2178
|
this.declareBinding(statement.currentName, false, watched, statement.span);
|
|
@@ -1985,6 +2307,15 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
1985
2307
|
const binding = this.lookup(name);
|
|
1986
2308
|
return binding !== null && this.computedBindingSpans.has(spanIdentity(binding.span));
|
|
1987
2309
|
}
|
|
2310
|
+
/**
|
|
2311
|
+
* True when `name` resolves to a `resource` declaration. Asked of the binding
|
|
2312
|
+
* rather than of the spelling, for the reason `isComputedBinding` is: a local
|
|
2313
|
+
* `state` may shadow a resource's name, and the shadow is not a resource.
|
|
2314
|
+
*/
|
|
2315
|
+
isResourceBinding(name) {
|
|
2316
|
+
const binding = this.lookup(name);
|
|
2317
|
+
return binding !== null && this.resourceBindingSpans.has(spanIdentity(binding.span));
|
|
2318
|
+
}
|
|
1988
2319
|
isImportedComputedBinding(name) {
|
|
1989
2320
|
const binding = this.lookup(name);
|
|
1990
2321
|
return binding !== null && this.importedComputedSpans.has(spanIdentity(binding.span));
|
|
@@ -2273,14 +2604,14 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
2273
2604
|
const name = expression.kind === "IdentifierExpression" ? expression.name : null;
|
|
2274
2605
|
if (name !== null && this.reactiveBindingKind(name) === null && this.zeroArgumentReader(watched)) {
|
|
2275
2606
|
this.diagnostics.push(diagnostic("VEL5064", `'${name}' is the reader itself, so watching it watches a value that never changes; declare the derived value — 'computed name = ${name}()' — then 'watch name:'`, expression.span));
|
|
2276
|
-
return;
|
|
2607
|
+
return false;
|
|
2277
2608
|
}
|
|
2278
2609
|
if (this.frozenWatchSubject(expression)) {
|
|
2279
2610
|
this.diagnostics.push(diagnostic("VEL5064", `This watch subject never changes, so its body can never run${name === null ? "" : ` — '${name}' is not a reactive source`}; watch a 'state', a 'computed', a prop, or a resource field, or move these statements to where they should run`, expression.span));
|
|
2280
|
-
return;
|
|
2611
|
+
return false;
|
|
2281
2612
|
}
|
|
2282
2613
|
if (watchSubjectPath(expression))
|
|
2283
|
-
return;
|
|
2614
|
+
return true;
|
|
2284
2615
|
// D69's own shape, `watch total()`, is a called `computed`, and VEL5063 has
|
|
2285
2616
|
// already named it with the one-character edit that makes this subject
|
|
2286
2617
|
// legal. Stacking the shape rule on top would report one mistake twice and
|
|
@@ -2288,7 +2619,7 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
2288
2619
|
// VEL5063 in its turn. The same reason the frozen rule is asked first.
|
|
2289
2620
|
if (this.diagnostics.some((item) => item.code === "VEL5063"
|
|
2290
2621
|
&& item.span.start === expression.span.start && item.span.end === expression.span.end))
|
|
2291
|
-
return;
|
|
2622
|
+
return false;
|
|
2292
2623
|
const derived = currentName ?? "value";
|
|
2293
2624
|
const watchLine = currentName === null
|
|
2294
2625
|
? `watch ${derived}:`
|
|
@@ -2297,6 +2628,82 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
2297
2628
|
this.diagnostics.push(diagnostic("VEL5071", rendered === null
|
|
2298
2629
|
? `A watch subject names what to watch, not what to compute. Declare the value — 'computed ${derived} = ...' — then '${watchLine}'`
|
|
2299
2630
|
: `A watch subject names what to watch, not what to compute: '${rendered}' computes a value. Declare it — 'computed ${derived} = ${rendered}' — then '${watchLine}'`, expression.span));
|
|
2631
|
+
return false;
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* D114 W: the three watch shapes a compile can prove re-trigger the watch
|
|
2635
|
+
* itself. D90 R21 removed the analysis of *who writes what* across calls, and
|
|
2636
|
+
* nothing here brings it back: two watches writing one state are still an
|
|
2637
|
+
* ordinary program, a write reached through an ordinary helper is still
|
|
2638
|
+
* silent, and a write under `if`, `match`, a loop, `try`, a nested `def` or an
|
|
2639
|
+
* arrow is still the author's converging correction to make.
|
|
2640
|
+
*
|
|
2641
|
+
* What is refused is only what is decided at the top of the body, with no
|
|
2642
|
+
* condition to end it:
|
|
2643
|
+
*
|
|
2644
|
+
* - **B** the body writes the watched place itself (`watch count: count =
|
|
2645
|
+
* count + 1`), assignment, compound assignment, or a mutating collection
|
|
2646
|
+
* call on the watched collection;
|
|
2647
|
+
* - **A2(a)** the subject is a `resource` field and the body reloads that
|
|
2648
|
+
* same resource — a reload writes exactly those fields, so every completed
|
|
2649
|
+
* load re-triggers the watch;
|
|
2650
|
+
* - **A2(b)** the body starts an `action` or an `async def` of this module
|
|
2651
|
+
* whose own top level writes the watched place. One hop, one module, no
|
|
2652
|
+
* condition on either end; anything further is the runtime budget's.
|
|
2653
|
+
*
|
|
2654
|
+
* One diagnostic per watch, at the first statement that earns it. A watch with
|
|
2655
|
+
* two of these has two mistakes, and the second is read after the first is
|
|
2656
|
+
* fixed, exactly as two errors on one line are.
|
|
2657
|
+
*/
|
|
2658
|
+
rejectWatchCycle(subject, watched, body) {
|
|
2659
|
+
const root = reactivePathRoot(subject);
|
|
2660
|
+
if (root === null)
|
|
2661
|
+
return;
|
|
2662
|
+
const path = reactiveWritePath(subject);
|
|
2663
|
+
const collection = nonOptional(this.expandAliases(watched));
|
|
2664
|
+
const mutating = collection.kind === "list" || collection.kind === "map"
|
|
2665
|
+
|| collection.kind === "set" || collection.kind === "record"
|
|
2666
|
+
? mutatingCollectionMethods(collection.kind)
|
|
2667
|
+
: null;
|
|
2668
|
+
// A resource publishes `value`, `loading`, `ready` and `error`, and
|
|
2669
|
+
// `reload` is the one member of the five that is not one of them. Asking it
|
|
2670
|
+
// that way keeps `analyzeResourceDeclaration`'s field map the only roster:
|
|
2671
|
+
// a field added there is a field this recognises, with nothing to update.
|
|
2672
|
+
const resource = subject.kind === "MemberExpression" && !subject.optional
|
|
2673
|
+
&& subject.object.kind === "IdentifierExpression" && subject.property !== "reload"
|
|
2674
|
+
&& this.isResourceBinding(subject.object.name)
|
|
2675
|
+
? subject.object.name
|
|
2676
|
+
: null;
|
|
2677
|
+
for (const statement of body) {
|
|
2678
|
+
if (statementBindsName(statement, root))
|
|
2679
|
+
return;
|
|
2680
|
+
if (path !== null && reactiveWriteOf(statement, path, mutating)) {
|
|
2681
|
+
// A derived value is offered only where it could be declared. A field
|
|
2682
|
+
// or an element has no `computed` spelling of its own, so naming one
|
|
2683
|
+
// would hand the author a line that does not compile.
|
|
2684
|
+
const derived = subject.kind === "IdentifierExpression"
|
|
2685
|
+
? `declare 'computed ${path} = ...' instead`
|
|
2686
|
+
: "write this value where it is produced instead";
|
|
2687
|
+
this.diagnostics.push(diagnostic("VEL5077", `This watch writes its own subject '${path}' at the top of its body, so every run re-triggers it and the runtime stops the loop after 100 rounds; write the condition that ends it, or watch the input this value follows and ${derived}`, statement.span));
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
const call = topLevelCall(statement);
|
|
2691
|
+
if (call === null)
|
|
2692
|
+
continue;
|
|
2693
|
+
if (resource !== null && call.callee.kind === "MemberExpression" && !call.callee.optional
|
|
2694
|
+
&& call.callee.property === "reload" && call.callee.object.kind === "IdentifierExpression"
|
|
2695
|
+
&& call.callee.object.name === resource) {
|
|
2696
|
+
this.diagnostics.push(diagnostic("VEL5078", `This watch reloads '${resource}' — the resource it watches — so every completed load re-triggers it; watch the input the load reads instead, as 'watch userId:' with 'detach ${resource}.reload()' in its body`, call.span));
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
if (path === null || call.callee.kind !== "IdentifierExpression")
|
|
2700
|
+
continue;
|
|
2701
|
+
const writer = this.reactiveWriters.get(call.callee.name) ?? null;
|
|
2702
|
+
if (writer === null || !writerWritesPath(writer, path, root, mutating))
|
|
2703
|
+
continue;
|
|
2704
|
+
this.diagnostics.push(diagnostic("VEL5079", `This watch starts '${call.callee.name}', which writes '${path}' — the reactive value this watch is on — so each completed run re-triggers the watch; make the write conditional, or watch the input '${call.callee.name}' reads`, call.span));
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2300
2707
|
}
|
|
2301
2708
|
/**
|
|
2302
2709
|
* D89 A4: records `list = list.map(item => {…})`, React's immutable update,
|
|
@@ -2695,6 +3102,7 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
2695
3102
|
["reload", { kind: "function", parameters: [], requiredParameters: 0, result: { kind: "promise", value: nullType } }],
|
|
2696
3103
|
]);
|
|
2697
3104
|
this.declareBinding(statement.name, false, { kind: "object", fields }, statement.span);
|
|
3105
|
+
this.resourceBindingSpans.add(spanIdentity(statement.span));
|
|
2698
3106
|
}
|
|
2699
3107
|
actionType(statement) {
|
|
2700
3108
|
const declaredResult = this.resolvedAsyncResult(this.inferredFunctionResult(statement));
|
|
@@ -2783,7 +3191,9 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
2783
3191
|
this.flowFrameDepth += 1;
|
|
2784
3192
|
this.synchronousReactiveDepth += 1;
|
|
2785
3193
|
const watched = this.inferExpression(item.expression);
|
|
2786
|
-
this.rejectFrozenWatchSubject(item.expression, watched, item.currentName, item.previousName)
|
|
3194
|
+
if (this.rejectFrozenWatchSubject(item.expression, watched, item.currentName, item.previousName)) {
|
|
3195
|
+
this.rejectWatchCycle(item.expression, watched, item.body);
|
|
3196
|
+
}
|
|
2787
3197
|
this.enterScope();
|
|
2788
3198
|
if (item.currentName)
|
|
2789
3199
|
this.declareBinding(item.currentName, false, watched, item.span);
|
|
@@ -3090,10 +3500,15 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
3090
3500
|
if (range && literal !== null && (literal < range[1] || literal > range[2])) {
|
|
3091
3501
|
this.diagnostics.push(diagnostic("VEL5042", `${range[0]} must be from ${range[1]} through ${range[2]}; ${builder} received ${literal}`, argument.span));
|
|
3092
3502
|
}
|
|
3503
|
+
const nonNegativeBlur = (builder === "blur" && position === 0) || (builder === "dropShadow" && position === 2);
|
|
3504
|
+
if (nonNegativeBlur && folded?.kind === "unit" && folded.value < 0) {
|
|
3505
|
+
this.diagnostics.push(diagnostic("VEL5042", `${builder} blur cannot be negative`, argument.span));
|
|
3506
|
+
}
|
|
3093
3507
|
// LOK-D3, builder half: a unitless number in a length position is dead
|
|
3094
3508
|
// CSS exactly as it is on a property. Zero is the one unitless length.
|
|
3095
3509
|
if (LOOK_LENGTH_BUILDERS.has(builder) && literal !== null && literal !== 0
|
|
3096
|
-
&& !(builder === "border" && position !== 0) && !(builder === "shadow" && position === 5)
|
|
3510
|
+
&& !(builder === "border" && position !== 0) && !(builder === "shadow" && position === 5)
|
|
3511
|
+
&& !(builder === "dropShadow" && position === 3)) {
|
|
3097
3512
|
this.diagnostics.push(diagnostic("VEL5042", `${builder} composes CSS lengths, so ${literal} requires a unit; write a unit value such as ${literal}px or ${literal}rem (only 0 is unitless)`, argument.span));
|
|
3098
3513
|
}
|
|
3099
3514
|
if (builder === "border" && position === 2 && argument.kind === "LiteralExpression" && typeof argument.value === "string"
|
|
@@ -3118,6 +3533,9 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
3118
3533
|
if (builder === "tracks" && expression.arguments.length > 1024) {
|
|
3119
3534
|
this.diagnostics.push(diagnostic("VEL5042", "tracks cannot contain more than 1024 values", expression.span));
|
|
3120
3535
|
}
|
|
3536
|
+
if (builder === "filters" && expression.arguments.length > 64) {
|
|
3537
|
+
this.diagnostics.push(diagnostic("VEL5042", "filters cannot compose more than 64 values", expression.span));
|
|
3538
|
+
}
|
|
3121
3539
|
}
|
|
3122
3540
|
/**
|
|
3123
3541
|
* D103 rules 1 and 5 — what a checked token reference has to be at the site
|
|
@@ -3196,15 +3614,7 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
3196
3614
|
}
|
|
3197
3615
|
/** The one edit that gives this module a `token` import, in the shape D103's migration needs. */
|
|
3198
3616
|
lookTokenImportEdit() {
|
|
3199
|
-
|
|
3200
|
-
const specifiers = [...(site.declaration?.specifiers ?? []), { imported: "token", local: "token" }];
|
|
3201
|
-
const line = `import {${[...specifiers]
|
|
3202
|
-
.sort((left, right) => byCodeUnit(left.imported, right.imported))
|
|
3203
|
-
.map((specifier) => specifier.imported === specifier.local ? specifier.imported : `${specifier.imported} as ${specifier.local}`)
|
|
3204
|
-
.join(", ")}} from "velar/look"`;
|
|
3205
|
-
if (site.declaration)
|
|
3206
|
-
return { span: site.declaration.span, text: line };
|
|
3207
|
-
return { span: { start: site.insertAt, end: site.insertAt }, text: site.leadingBlankLine ? `${line}\n\n` : `\n${line}` };
|
|
3617
|
+
return this.lookBuilderImportEdit(["token"]);
|
|
3208
3618
|
}
|
|
3209
3619
|
checkAnimateBuilderCall(expression) {
|
|
3210
3620
|
const argument = (name, position) => {
|
|
@@ -3401,6 +3811,7 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
3401
3811
|
return false;
|
|
3402
3812
|
}
|
|
3403
3813
|
const site = { property: name, entrySpan, directive };
|
|
3814
|
+
this.adviseLookFilterSpelling(name, value, site);
|
|
3404
3815
|
this.adviseLookTokenSpelling(name, value, site);
|
|
3405
3816
|
if (!this.validateLookStringVocabulary(name, value, undefined, site))
|
|
3406
3817
|
return false;
|
|
@@ -3411,6 +3822,63 @@ export class VelarWebAnalyzer extends Analyzer {
|
|
|
3411
3822
|
return value.kind === "CallExpression" && value.callee.kind === "IdentifierExpression"
|
|
3412
3823
|
&& this.lookBuilderNames.get(value.callee.name) === "token";
|
|
3413
3824
|
}
|
|
3825
|
+
/**
|
|
3826
|
+
* A complete CSS filter function whose arguments fit the checked Look
|
|
3827
|
+
* builders has one equivalent source spelling. The parser deliberately
|
|
3828
|
+
* stops at the first function or argument grammar it cannot prove, leaving
|
|
3829
|
+
* arbitrary CSS and externally defined filter functions as ordinary text.
|
|
3830
|
+
*/
|
|
3831
|
+
adviseLookFilterSpelling(name, value, site) {
|
|
3832
|
+
if (LOOK_PROPERTY_VALUE_KINDS.get(name) !== "filter")
|
|
3833
|
+
return;
|
|
3834
|
+
if (value.kind !== "LiteralExpression" || typeof value.value !== "string")
|
|
3835
|
+
return;
|
|
3836
|
+
const rewrite = lookFilterRewrite(value.value);
|
|
3837
|
+
if (!rewrite)
|
|
3838
|
+
return;
|
|
3839
|
+
const localized = this.localizeLookBuilderCall(rewrite);
|
|
3840
|
+
this.advise("A16", `Look property '${name}' accepts CSS filter text, but this complete filter list has the checked equivalent ${localized.call}`, value.span, mechanicalEdits(this.lookBuilderRewrite(value, localized.call, site, localized.missingImports), `Use ${localized.call}`));
|
|
3841
|
+
}
|
|
3842
|
+
/** Uses an existing alias when present and lists only builders the fix must import. */
|
|
3843
|
+
localizeLookBuilderCall(rewrite) {
|
|
3844
|
+
let call = rewrite.call;
|
|
3845
|
+
const missingImports = [];
|
|
3846
|
+
for (const builder of rewrite.builders) {
|
|
3847
|
+
const imported = [...this.lookBuilderNames].find(([, candidate]) => candidate === builder);
|
|
3848
|
+
const local = imported?.[0] ?? builder;
|
|
3849
|
+
if (!imported)
|
|
3850
|
+
missingImports.push(builder);
|
|
3851
|
+
call = call.replace(new RegExp(`\\b${builder}(?=\\()`, "gu"), local);
|
|
3852
|
+
}
|
|
3853
|
+
return { call, missingImports };
|
|
3854
|
+
}
|
|
3855
|
+
/** Rewrites either a Look entry expression or the synthetic value of a JSX Look directive. */
|
|
3856
|
+
lookBuilderRewrite(value, call, site, missingImports) {
|
|
3857
|
+
const wholeAttribute = site.directive !== null
|
|
3858
|
+
&& value.span.start === site.entrySpan.start && value.span.end === site.entrySpan.end;
|
|
3859
|
+
const edit = {
|
|
3860
|
+
span: value.span,
|
|
3861
|
+
text: wholeAttribute ? `${site.directive}:${site.property}={${call}}` : call,
|
|
3862
|
+
};
|
|
3863
|
+
return missingImports.length === 0 ? [edit] : [this.lookBuilderImportEdit(missingImports), edit];
|
|
3864
|
+
}
|
|
3865
|
+
/** Adds missing builders to the module's one velar/look import in stable order. */
|
|
3866
|
+
lookBuilderImportEdit(builders) {
|
|
3867
|
+
const site = this.lookImport ?? { declaration: null, insertAt: 0, leadingBlankLine: true };
|
|
3868
|
+
const existing = site.declaration?.specifiers ?? [];
|
|
3869
|
+
const imported = new Set(existing.map((specifier) => specifier.imported));
|
|
3870
|
+
const specifiers = [
|
|
3871
|
+
...existing,
|
|
3872
|
+
...builders.filter((builder) => !imported.has(builder)).map((builder) => ({ imported: builder, local: builder })),
|
|
3873
|
+
];
|
|
3874
|
+
const line = `import {${specifiers
|
|
3875
|
+
.sort((left, right) => byCodeUnit(left.imported, right.imported))
|
|
3876
|
+
.map((specifier) => specifier.imported === specifier.local ? specifier.imported : `${specifier.imported} as ${specifier.local}`)
|
|
3877
|
+
.join(", ")}} from "velar/look"`;
|
|
3878
|
+
if (site.declaration)
|
|
3879
|
+
return { span: site.declaration.span, text: line };
|
|
3880
|
+
return { span: { start: site.insertAt, end: site.insertAt }, text: site.leadingBlankLine ? `${line}\n\n` : `\n${line}` };
|
|
3881
|
+
}
|
|
3414
3882
|
/** How this module spells a call to the token builder, honouring an aliased import. */
|
|
3415
3883
|
lookTokenCallText(referenced) {
|
|
3416
3884
|
const local = [...this.lookBuilderNames].find(([, builder]) => builder === "token")?.[0] ?? null;
|