@triflare/fx 2.0.0 → 2.1.0-beta.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/README.md +27 -0
- package/package.json +6 -4
- package/product.yml +1 -1
- package/src/build.js +23 -6
- package/src/catalog.js +103 -0
- package/src/cli/commands/check.js +3 -1
- package/src/cli/commands/feature.js +163 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +8 -0
- package/src/cli/commands/lint.js +274 -0
- package/src/cli/commands/test.js +186 -0
- package/src/cli/context.js +16 -1
- package/src/cli/define-command.js +8 -1
- package/src/cli/reporter.js +9 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +197 -0
- package/src/config.js +114 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +425 -0
- package/src/formatter.js +519 -0
- package/src/index.js +31 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1434 -0
- package/src/lint/config-file.js +111 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +116 -0
- package/src/parser.js +131 -16
- package/src/project.js +21 -90
- package/src/test/discover.js +82 -0
- package/src/test/parse.js +72 -0
- package/src/test/pattern.js +84 -0
- package/src/test/reporters.js +332 -0
- package/src/test/runner.js +1457 -0
- package/src/vm.js +325 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
package/src/compiler.js
CHANGED
|
@@ -137,6 +137,7 @@ export class Compiler {
|
|
|
137
137
|
this.loadAsset = loadAsset || null;
|
|
138
138
|
this.catalog = catalog ? { ...CATALOG, ...catalog } : CATALOG;
|
|
139
139
|
this.extensionUrls = extensionUrls;
|
|
140
|
+
this.requiredExtensions = new Map();
|
|
140
141
|
this.file = file;
|
|
141
142
|
/** @type {string[]} Non-fatal problems worth surfacing to the user. */
|
|
142
143
|
this.warnings = [];
|
|
@@ -252,6 +253,8 @@ export class Compiler {
|
|
|
252
253
|
// can only be given their mutation once every stack is complete.
|
|
253
254
|
for (const ctx of contexts) this.applyDynamicNextMutations(ctx);
|
|
254
255
|
|
|
256
|
+
this.validateRequiredExtensions();
|
|
257
|
+
|
|
255
258
|
return { project: this.assembleProject(contexts), assets: this.assets };
|
|
256
259
|
}
|
|
257
260
|
|
|
@@ -335,6 +338,11 @@ export class Compiler {
|
|
|
335
338
|
}
|
|
336
339
|
|
|
337
340
|
declareTargetVar(ctx, decl) {
|
|
341
|
+
if (decl.name.startsWith("$"))
|
|
342
|
+
this.fail(
|
|
343
|
+
`Names starting with "$" are reserved for temporary variables; rename ${JSON.stringify(decl.name)}.`,
|
|
344
|
+
decl.loc,
|
|
345
|
+
);
|
|
338
346
|
const global = ctx.isStage;
|
|
339
347
|
if (global && this.globals.has(decl.name)) {
|
|
340
348
|
const first = this.globals.get(decl.name);
|
|
@@ -380,6 +388,11 @@ export class Compiler {
|
|
|
380
388
|
}
|
|
381
389
|
|
|
382
390
|
declareTargetList(ctx, decl) {
|
|
391
|
+
if (decl.name.startsWith("$"))
|
|
392
|
+
this.fail(
|
|
393
|
+
`Names starting with "$" are reserved for temporary variables; rename ${JSON.stringify(decl.name)}.`,
|
|
394
|
+
decl.loc,
|
|
395
|
+
);
|
|
383
396
|
if (ctx.isStage && this.globals.has(decl.name)) {
|
|
384
397
|
const first = this.globals.get(decl.name);
|
|
385
398
|
const where = this.where(first.loc);
|
|
@@ -421,6 +434,13 @@ export class Compiler {
|
|
|
421
434
|
decl.loc,
|
|
422
435
|
);
|
|
423
436
|
}
|
|
437
|
+
for (const param of decl.params) {
|
|
438
|
+
if (param.name.startsWith("$"))
|
|
439
|
+
this.fail(
|
|
440
|
+
`Names starting with "$" are reserved for temporary variables; rename parameter ${JSON.stringify(param.name)}.`,
|
|
441
|
+
decl.loc,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
424
444
|
const argIds = decl.params.map((_, i) => `arg${i}`);
|
|
425
445
|
const proccode = decl.params.length
|
|
426
446
|
? `${decl.name} ${decl.params.map((param) => (param.boolean ? "%b" : "%s")).join(" ")}`
|
|
@@ -785,6 +805,8 @@ export class Compiler {
|
|
|
785
805
|
return this.compileForever(ctx, node);
|
|
786
806
|
case "Return":
|
|
787
807
|
return this.compileReturn(ctx, node);
|
|
808
|
+
case "TempVarDecl":
|
|
809
|
+
return this.compileTempVarDecl(ctx, node);
|
|
788
810
|
default:
|
|
789
811
|
this.fail(`Unsupported statement ${node.type}`, node.loc);
|
|
790
812
|
}
|
|
@@ -824,6 +846,13 @@ export class Compiler {
|
|
|
824
846
|
}
|
|
825
847
|
|
|
826
848
|
compileAssign(ctx, node) {
|
|
849
|
+
const sigil = /^(\$\$?)([A-Za-z_][A-Za-z0-9_]*)$/.exec(node.name);
|
|
850
|
+
if (sigil) return this.compileSigilAssign(ctx, node, sigil);
|
|
851
|
+
if (node.name.startsWith("$"))
|
|
852
|
+
this.fail(
|
|
853
|
+
`Invalid temporary variable reference ${JSON.stringify(node.name)}`,
|
|
854
|
+
node.loc,
|
|
855
|
+
);
|
|
827
856
|
const resolved = ctx.resolveValue(node.name);
|
|
828
857
|
if (!resolved || resolved.kind !== "var") {
|
|
829
858
|
this.fail(`Unknown variable ${JSON.stringify(node.name)}`, node.loc);
|
|
@@ -880,6 +909,64 @@ export class Compiler {
|
|
|
880
909
|
return id;
|
|
881
910
|
}
|
|
882
911
|
|
|
912
|
+
compileTempVarDecl(ctx, node) {
|
|
913
|
+
if (node.name.startsWith("$"))
|
|
914
|
+
this.fail(
|
|
915
|
+
'Temporary variable names cannot start with "$"; use a plain name.',
|
|
916
|
+
node.loc,
|
|
917
|
+
);
|
|
918
|
+
const name =
|
|
919
|
+
node.kind === "runtime" ? "tempvars.setRuntime" : "tempvars.setThread";
|
|
920
|
+
const entry = this.catalog[name];
|
|
921
|
+
if (!entry)
|
|
922
|
+
this.fail(
|
|
923
|
+
"Temporary variables are not available in this Fx build",
|
|
924
|
+
node.loc,
|
|
925
|
+
);
|
|
926
|
+
return this.emitCatalogCall(
|
|
927
|
+
ctx,
|
|
928
|
+
{
|
|
929
|
+
name,
|
|
930
|
+
segments: name.split("."),
|
|
931
|
+
args: [
|
|
932
|
+
{ type: "String", value: node.name, loc: node.loc },
|
|
933
|
+
node.init ?? { type: "String", value: "0", loc: node.loc },
|
|
934
|
+
],
|
|
935
|
+
loc: node.loc,
|
|
936
|
+
},
|
|
937
|
+
entry,
|
|
938
|
+
null,
|
|
939
|
+
).id;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
compileSigilAssign(ctx, node, sigil) {
|
|
943
|
+
if (node.op !== "=" && node.op !== "+=")
|
|
944
|
+
this.fail(
|
|
945
|
+
`Temporary variables only support "=" and "+="; ${JSON.stringify(node.op)} is not available.`,
|
|
946
|
+
node.loc,
|
|
947
|
+
);
|
|
948
|
+
const kind = sigil[1] === "$$" ? "Runtime" : "Thread";
|
|
949
|
+
const action = node.op === "=" ? "set" : "change";
|
|
950
|
+
const name = `tempvars.${action}${kind}`;
|
|
951
|
+
const entry = this.catalog[name];
|
|
952
|
+
if (!entry)
|
|
953
|
+
this.fail(
|
|
954
|
+
"Temporary variables are not available in this Fx build",
|
|
955
|
+
node.loc,
|
|
956
|
+
);
|
|
957
|
+
return this.emitCatalogCall(
|
|
958
|
+
ctx,
|
|
959
|
+
{
|
|
960
|
+
name,
|
|
961
|
+
segments: name.split("."),
|
|
962
|
+
args: [{ type: "String", value: sigil[2], loc: node.loc }, node.value],
|
|
963
|
+
loc: node.loc,
|
|
964
|
+
},
|
|
965
|
+
entry,
|
|
966
|
+
null,
|
|
967
|
+
).id;
|
|
968
|
+
}
|
|
969
|
+
|
|
883
970
|
compileIf(ctx, node) {
|
|
884
971
|
const id = this.addBlock(ctx, {
|
|
885
972
|
opcode: node.else ? "control_if_else" : "control_if",
|
|
@@ -976,6 +1063,11 @@ export class Compiler {
|
|
|
976
1063
|
emitCatalogCall(ctx, node, entry, parentId) {
|
|
977
1064
|
if (entry.kind === "hat")
|
|
978
1065
|
this.fail(`${node.name} is an event and cannot be called`, node.loc);
|
|
1066
|
+
if (entry.requiresExtension) {
|
|
1067
|
+
const id = entry.extension || extensionForOpcode(entry.opcode);
|
|
1068
|
+
if (id && !this.requiredExtensions.has(id))
|
|
1069
|
+
this.requiredExtensions.set(id, node.loc);
|
|
1070
|
+
}
|
|
979
1071
|
const id = this.addBlock(ctx, { opcode: entry.opcode, parent: parentId });
|
|
980
1072
|
const fieldNames = Object.keys(entry.fields || {});
|
|
981
1073
|
const inputNames = Object.keys(entry.inputs || {});
|
|
@@ -1282,6 +1374,33 @@ export class Compiler {
|
|
|
1282
1374
|
}
|
|
1283
1375
|
|
|
1284
1376
|
emitIdentifier(ctx, node) {
|
|
1377
|
+
const sigil = /^(\$\$?)([A-Za-z_][A-Za-z0-9_]*)$/.exec(node.name);
|
|
1378
|
+
if (sigil) {
|
|
1379
|
+
const name = sigil[1] === "$$" ? "tempvars.runtime" : "tempvars.thread";
|
|
1380
|
+
const entry = this.catalog[name];
|
|
1381
|
+
if (!entry)
|
|
1382
|
+
this.fail(
|
|
1383
|
+
"Temporary variables are not available in this Fx build",
|
|
1384
|
+
node.loc,
|
|
1385
|
+
);
|
|
1386
|
+
const result = this.emitCatalogCall(
|
|
1387
|
+
ctx,
|
|
1388
|
+
{
|
|
1389
|
+
name,
|
|
1390
|
+
segments: name.split("."),
|
|
1391
|
+
args: [{ type: "String", value: sigil[2], loc: node.loc }],
|
|
1392
|
+
loc: node.loc,
|
|
1393
|
+
},
|
|
1394
|
+
entry,
|
|
1395
|
+
null,
|
|
1396
|
+
);
|
|
1397
|
+
return { k: "block", id: result.id, kind: "reporter" };
|
|
1398
|
+
}
|
|
1399
|
+
if (node.name.startsWith("$"))
|
|
1400
|
+
this.fail(
|
|
1401
|
+
`Invalid temporary variable reference ${JSON.stringify(node.name)}`,
|
|
1402
|
+
node.loc,
|
|
1403
|
+
);
|
|
1285
1404
|
if (node.segments.length > 1)
|
|
1286
1405
|
this.fail(
|
|
1287
1406
|
`Unexpected dotted name ${JSON.stringify(node.name)}`,
|
|
@@ -1537,6 +1656,20 @@ export class Compiler {
|
|
|
1537
1656
|
|
|
1538
1657
|
// ------------------------------------------------------------ assembly
|
|
1539
1658
|
|
|
1659
|
+
validateRequiredExtensions() {
|
|
1660
|
+
for (const [id, loc] of this.requiredExtensions) {
|
|
1661
|
+
if (this.extensionUrls[id]) continue;
|
|
1662
|
+
const feature = id === "fxTempVars" ? "temporaryvariables" : null;
|
|
1663
|
+
const hint = feature
|
|
1664
|
+
? ` Run "fx feature ${feature} enable" to install it.`
|
|
1665
|
+
: "";
|
|
1666
|
+
this.fail(
|
|
1667
|
+
`This project uses blocks from extension ${JSON.stringify(id)}, which is not loaded.${hint}`,
|
|
1668
|
+
loc,
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1540
1673
|
assembleProject(contexts) {
|
|
1541
1674
|
const targets = [];
|
|
1542
1675
|
const extensions = new Set();
|
package/src/config-edit.js
CHANGED
|
@@ -615,3 +615,200 @@ export function removeVendorEntry(configPath, name) {
|
|
|
615
615
|
writeDocument(configPath, lines.join(""));
|
|
616
616
|
return { removed: source ?? key };
|
|
617
617
|
}
|
|
618
|
+
|
|
619
|
+
// -------------------------------------------------------------- feature edits
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Add or replace a `features:` entry without reformatting the rest of fx.yml.
|
|
623
|
+
*
|
|
624
|
+
* @param {string} configPath
|
|
625
|
+
* @param {string} name
|
|
626
|
+
* @param {string} file
|
|
627
|
+
* @returns {{added: boolean, updated: boolean}}
|
|
628
|
+
*/
|
|
629
|
+
export function addFeatureEntry(configPath, name, file) {
|
|
630
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
631
|
+
const lines = splitLines(text);
|
|
632
|
+
const eol = eolOf(text);
|
|
633
|
+
const key = String(name);
|
|
634
|
+
let added = false;
|
|
635
|
+
let updated = false;
|
|
636
|
+
const entryLine = (indent) =>
|
|
637
|
+
`${indent}${quoteKey(key)}: ${quote(file)}${eol}`;
|
|
638
|
+
|
|
639
|
+
const bounds = topLevelBounds(lines, "features");
|
|
640
|
+
if (!bounds) {
|
|
641
|
+
if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
|
|
642
|
+
lines[lines.length - 1] += eol;
|
|
643
|
+
lines.push(`features:${eol}`, entryLine(" "));
|
|
644
|
+
added = true;
|
|
645
|
+
} else if (bounds.inline) {
|
|
646
|
+
const mapping = parseValue(bounds.inline) || {};
|
|
647
|
+
updated = Object.hasOwn(mapping, key);
|
|
648
|
+
added = !updated;
|
|
649
|
+
mapping[key] = file;
|
|
650
|
+
const dumped = yaml
|
|
651
|
+
.dump({ features: mapping }, { lineWidth: -1, noRefs: true })
|
|
652
|
+
.split("\n")
|
|
653
|
+
.filter((line, index, all) => !(index === all.length - 1 && line === ""))
|
|
654
|
+
.map((line) => `${line}${eol}`);
|
|
655
|
+
if (bounds.comment) dumped[0] = `features: ${bounds.comment}${eol}`;
|
|
656
|
+
replaceRange(lines, bounds.index, bounds.index + 1, dumped);
|
|
657
|
+
} else {
|
|
658
|
+
const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
|
|
659
|
+
const existing = spans.find((span) => span.key === key);
|
|
660
|
+
if (existing) {
|
|
661
|
+
replaceRange(lines, existing.index, existing.end, [
|
|
662
|
+
entryLine(existing.indent),
|
|
663
|
+
]);
|
|
664
|
+
updated = true;
|
|
665
|
+
} else {
|
|
666
|
+
const indent = childIndent(lines, bounds.index, bounds.end);
|
|
667
|
+
insertAt(lines, bounds.end, [entryLine(indent)], eol);
|
|
668
|
+
added = true;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (added || updated) writeDocument(configPath, lines.join(""));
|
|
673
|
+
return { added, updated };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Read a raw `fx.<key>` value without normalising the rest of the config. */
|
|
677
|
+
export function readFxValue(configPath, key) {
|
|
678
|
+
const raw = yaml.load(fs.readFileSync(configPath, "utf8")) ?? {};
|
|
679
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
680
|
+
const fx = raw.fx;
|
|
681
|
+
if (!fx || typeof fx !== "object" || Array.isArray(fx)) return undefined;
|
|
682
|
+
return fx[key];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Set a scalar `fx.<key>` value while preserving the rest of the file.
|
|
687
|
+
*
|
|
688
|
+
* A nested block under the key (for example a hand-written `fx.tests:`
|
|
689
|
+
* mapping) is replaced by the scalar, so enabling and disabling a feature
|
|
690
|
+
* always leaves one canonical value behind.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} configPath
|
|
693
|
+
* @param {string} key
|
|
694
|
+
* @param {string|number|boolean} value
|
|
695
|
+
* @returns {{changed: boolean}}
|
|
696
|
+
*/
|
|
697
|
+
export function setFxScalar(configPath, key, value) {
|
|
698
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
699
|
+
const lines = splitLines(text);
|
|
700
|
+
const eol = eolOf(text);
|
|
701
|
+
const scalar = typeof value === "string" ? quote(value) : String(value);
|
|
702
|
+
const fx = topLevel(lines, "fx");
|
|
703
|
+
|
|
704
|
+
if (!fx) {
|
|
705
|
+
if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
|
|
706
|
+
lines[lines.length - 1] += eol;
|
|
707
|
+
lines.push(`fx:${eol}`, ` ${key}: ${scalar}${eol}`);
|
|
708
|
+
writeDocument(configPath, lines.join(""));
|
|
709
|
+
return { changed: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const { value: rest, comment } = splitComment(fx.rest);
|
|
713
|
+
if (rest !== "") {
|
|
714
|
+
if (!rest.startsWith("{") || !rest.endsWith("}"))
|
|
715
|
+
throw new Error("fx must be a block mapping to edit it");
|
|
716
|
+
const mapping = parseValue(rest);
|
|
717
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
718
|
+
throw new Error("fx must be a mapping to edit it");
|
|
719
|
+
if (mapping[key] === value) return { changed: false };
|
|
720
|
+
mapping[key] = value;
|
|
721
|
+
const dumped = yaml
|
|
722
|
+
.dump({ fx: mapping }, { lineWidth: -1, noRefs: true })
|
|
723
|
+
.split("\n")
|
|
724
|
+
.filter((line, index, all) => !(index === all.length - 1 && line === ""))
|
|
725
|
+
.map((line) => `${line}${eol}`);
|
|
726
|
+
if (comment) dumped[0] = `fx: ${comment}${eol}`;
|
|
727
|
+
replaceRange(lines, fx.index, fx.index + 1, dumped);
|
|
728
|
+
writeDocument(configPath, lines.join(""));
|
|
729
|
+
return { changed: true };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const end = sectionEnd(lines, fx.index);
|
|
733
|
+
const existing = mappingEntrySpans(lines, fx.index, end).find(
|
|
734
|
+
(span) => span.key === key,
|
|
735
|
+
);
|
|
736
|
+
if (existing) {
|
|
737
|
+
let entryEnd = existing.end;
|
|
738
|
+
while (
|
|
739
|
+
entryEnd > existing.index + 1 &&
|
|
740
|
+
/^(?:[ \t]*#.*|[ \t]*)$/.test(content(lines[entryEnd - 1]))
|
|
741
|
+
)
|
|
742
|
+
entryEnd--;
|
|
743
|
+
replaceRange(lines, existing.index, entryEnd, [
|
|
744
|
+
`${existing.indent}${quoteKey(key)}: ${scalar}${eol}`,
|
|
745
|
+
]);
|
|
746
|
+
} else {
|
|
747
|
+
const indent = childIndent(lines, fx.index, end);
|
|
748
|
+
insertAt(lines, end, [`${indent}${quoteKey(key)}: ${scalar}${eol}`], eol);
|
|
749
|
+
}
|
|
750
|
+
writeDocument(configPath, lines.join(""));
|
|
751
|
+
return { changed: true };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Remove a `features:` entry.
|
|
756
|
+
* @param {string} configPath
|
|
757
|
+
* @param {string} name
|
|
758
|
+
* @returns {{removed: string|null}}
|
|
759
|
+
*/
|
|
760
|
+
export function removeFeatureEntry(configPath, name) {
|
|
761
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
762
|
+
const lines = splitLines(text);
|
|
763
|
+
const eol = eolOf(text);
|
|
764
|
+
const key = String(name);
|
|
765
|
+
const bounds = topLevelBounds(lines, "features");
|
|
766
|
+
if (!bounds) return { removed: null };
|
|
767
|
+
|
|
768
|
+
if (bounds.inline) {
|
|
769
|
+
const mapping = parseValue(bounds.inline);
|
|
770
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
771
|
+
return { removed: null };
|
|
772
|
+
if (!Object.hasOwn(mapping, key)) return { removed: null };
|
|
773
|
+
const source = mapping[key];
|
|
774
|
+
delete mapping[key];
|
|
775
|
+
const rest = Object.keys(mapping).length
|
|
776
|
+
? ` ${JSON.stringify(mapping)}`
|
|
777
|
+
: " {}";
|
|
778
|
+
const comment = bounds.comment ? ` ${bounds.comment}` : "";
|
|
779
|
+
const line = lines[bounds.index];
|
|
780
|
+
lines[bounds.index] = `features:${rest}${comment}${
|
|
781
|
+
/\r?\n$/.test(line) ? ending(line) : eol
|
|
782
|
+
}`;
|
|
783
|
+
writeDocument(configPath, lines.join(""));
|
|
784
|
+
return { removed: source == null ? null : String(source) };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
|
|
788
|
+
const existing = spans.find((span) => span.key === key);
|
|
789
|
+
if (!existing) return { removed: null };
|
|
790
|
+
|
|
791
|
+
let source = null;
|
|
792
|
+
try {
|
|
793
|
+
const parsed = yaml.load(
|
|
794
|
+
[
|
|
795
|
+
content(lines[bounds.index]),
|
|
796
|
+
...lines.slice(existing.index, existing.end),
|
|
797
|
+
].join("\n"),
|
|
798
|
+
);
|
|
799
|
+
const entry = parsed?.features?.[key];
|
|
800
|
+
source =
|
|
801
|
+
typeof entry === "string" ? entry : (entry?.file ?? entry?.path ?? null);
|
|
802
|
+
} catch {
|
|
803
|
+
// The entry is removed either way.
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
replaceRange(lines, existing.index, existing.end, []);
|
|
807
|
+
const end = bounds.end - (existing.end - existing.index);
|
|
808
|
+
const remaining = mappingEntrySpans(lines, bounds.index, end);
|
|
809
|
+
if (remaining.length === 0) {
|
|
810
|
+
replaceRange(lines, bounds.index, end, [`features: {}${eol}`]);
|
|
811
|
+
}
|
|
812
|
+
writeDocument(configPath, lines.join(""));
|
|
813
|
+
return { removed: source ?? key };
|
|
814
|
+
}
|
package/src/config.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from "node:fs";
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import * as yaml from "js-yaml";
|
|
13
13
|
import { configFilename, configFilenames, productName } from "./brand.js";
|
|
14
|
+
import { DEFAULT_LINT, normalizeLintConfig } from "./lint/config.js";
|
|
14
15
|
|
|
15
16
|
export const DEFAULT_PROJECT = {
|
|
16
17
|
name: `${productName()} Project`,
|
|
@@ -25,6 +26,19 @@ export const DEFAULT_PROJECT = {
|
|
|
25
26
|
unsandboxed: [],
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic defaults for `fx test`. Virtual time is derived from
|
|
31
|
+
* `ticksPerSecond`; `seed` seeds the pseudo-random generator for the run.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_TESTS = {
|
|
34
|
+
pattern: "test/**/*.test.tfx",
|
|
35
|
+
seed: 12345,
|
|
36
|
+
timeout: 1000,
|
|
37
|
+
ticksPerSecond: 30,
|
|
38
|
+
failOnRuntimeError: true,
|
|
39
|
+
parallel: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* Fx-only settings that never reach PenguinMod's project runtime config.
|
|
30
44
|
* Kept in a separate top-level `fx:` block in fx.yml.
|
|
@@ -32,8 +46,72 @@ export const DEFAULT_PROJECT = {
|
|
|
32
46
|
export const DEFAULT_FX = {
|
|
33
47
|
partialSprites: false,
|
|
34
48
|
vendorDir: "extensions",
|
|
49
|
+
tests: false,
|
|
50
|
+
lint: { ...DEFAULT_LINT, rules: { ...DEFAULT_LINT.rules } },
|
|
35
51
|
};
|
|
36
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Normalize the `fx.tests` setting.
|
|
55
|
+
*
|
|
56
|
+
* `true` enables testing with the defaults, `false` (or an absent key)
|
|
57
|
+
* disables it, and a mapping may override any default. The mapping form is
|
|
58
|
+
* what `fx feature tests enable` writes and later edits by hand.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value
|
|
61
|
+
* @returns {false | (typeof DEFAULT_TESTS & {enabled: true})}
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeTestConfig(value) {
|
|
64
|
+
if (value == null || value === false) return false;
|
|
65
|
+
if (value === true) return { enabled: true, ...DEFAULT_TESTS };
|
|
66
|
+
if (!isPlainObject(value))
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${configFilename()}: fx.tests must be true, false, or a mapping`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const unknown = Object.keys(value).filter(
|
|
72
|
+
(key) => !Object.hasOwn(DEFAULT_TESTS, key),
|
|
73
|
+
);
|
|
74
|
+
if (unknown.length)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`${configFilename()}: unknown fx.tests setting(s): ${unknown
|
|
77
|
+
.map((key) => `"${key}"`)
|
|
78
|
+
.join(", ")}`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const tests = { enabled: true, ...DEFAULT_TESTS };
|
|
82
|
+
if (value.pattern != null) {
|
|
83
|
+
if (typeof value.pattern !== "string" || value.pattern.trim() === "")
|
|
84
|
+
throw new Error(`${configFilename()}: fx.tests.pattern must be a glob`);
|
|
85
|
+
tests.pattern = value.pattern.trim();
|
|
86
|
+
}
|
|
87
|
+
if (value.seed != null) {
|
|
88
|
+
const seed = Number(value.seed);
|
|
89
|
+
if (!Number.isFinite(seed))
|
|
90
|
+
throw new Error(`${configFilename()}: fx.tests.seed must be a number`);
|
|
91
|
+
tests.seed = seed;
|
|
92
|
+
}
|
|
93
|
+
if (value.timeout != null) {
|
|
94
|
+
const timeout = Number(value.timeout);
|
|
95
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${configFilename()}: fx.tests.timeout must be a positive number of milliseconds`,
|
|
98
|
+
);
|
|
99
|
+
tests.timeout = timeout;
|
|
100
|
+
}
|
|
101
|
+
if (value.ticksPerSecond != null) {
|
|
102
|
+
const ticks = Number(value.ticksPerSecond);
|
|
103
|
+
if (!Number.isFinite(ticks) || ticks <= 0)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${configFilename()}: fx.tests.ticksPerSecond must be positive`,
|
|
106
|
+
);
|
|
107
|
+
tests.ticksPerSecond = ticks;
|
|
108
|
+
}
|
|
109
|
+
if (value.failOnRuntimeError != null)
|
|
110
|
+
tests.failOnRuntimeError = Boolean(value.failOnRuntimeError);
|
|
111
|
+
if (value.parallel != null) tests.parallel = Boolean(value.parallel);
|
|
112
|
+
return tests;
|
|
113
|
+
}
|
|
114
|
+
|
|
37
115
|
/** Default directory that receives vendored extension sources. */
|
|
38
116
|
export const DEFAULT_VENDOR_DIR = "extensions";
|
|
39
117
|
|
|
@@ -121,6 +199,32 @@ export function normalizeVendorEntry(name, raw) {
|
|
|
121
199
|
};
|
|
122
200
|
}
|
|
123
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Normalize the top-level `features:` mapping. Values are file paths (or
|
|
204
|
+
* `{ file }` mappings), relative to the config directory.
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} raw
|
|
207
|
+
* @returns {Array<{name: string, file: string}>}
|
|
208
|
+
*/
|
|
209
|
+
export function normalizeFeatures(raw = {}) {
|
|
210
|
+
if (raw == null) return [];
|
|
211
|
+
if (!isPlainObject(raw))
|
|
212
|
+
throw new Error(`${configFilename()}: "features" must be a mapping`);
|
|
213
|
+
return Object.entries(raw).map(([name, value]) => {
|
|
214
|
+
const file =
|
|
215
|
+
typeof value === "string"
|
|
216
|
+
? value
|
|
217
|
+
: isPlainObject(value) && typeof value.file === "string"
|
|
218
|
+
? value.file
|
|
219
|
+
: null;
|
|
220
|
+
if (!file || file.trim() === "")
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${configFilename()}: features.${name} must be a file path or { file: "<path>" }`,
|
|
223
|
+
);
|
|
224
|
+
return { name: String(name), file: file.trim() };
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
124
228
|
/**
|
|
125
229
|
* Normalize the top-level `vendor:` mapping. Order is load order, so this is an
|
|
126
230
|
* array rather than a plain object.
|
|
@@ -381,7 +485,7 @@ export function normalizeProject(raw = {}) {
|
|
|
381
485
|
* @returns {typeof DEFAULT_FX}
|
|
382
486
|
*/
|
|
383
487
|
export function normalizeFx(raw = {}) {
|
|
384
|
-
const fx = { ...DEFAULT_FX };
|
|
488
|
+
const fx = { ...DEFAULT_FX, lint: { ...DEFAULT_LINT, rules: {} } };
|
|
385
489
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
386
490
|
if (raw.partialSprites != null)
|
|
387
491
|
fx.partialSprites = Boolean(raw.partialSprites);
|
|
@@ -390,6 +494,11 @@ export function normalizeFx(raw = {}) {
|
|
|
390
494
|
throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
|
|
391
495
|
fx.vendorDir = raw.vendorDir.trim();
|
|
392
496
|
}
|
|
497
|
+
if (raw.tests !== undefined) fx.tests = normalizeTestConfig(raw.tests);
|
|
498
|
+
fx.lint = normalizeLintConfig(
|
|
499
|
+
raw.lint,
|
|
500
|
+
(message) => new Error(`${configFilename()}: ${message}`),
|
|
501
|
+
);
|
|
393
502
|
}
|
|
394
503
|
return fx;
|
|
395
504
|
}
|
|
@@ -420,6 +529,7 @@ export function parseConfig(text) {
|
|
|
420
529
|
fx,
|
|
421
530
|
package: normalizePackage(raw.package || {}),
|
|
422
531
|
vendor: normalizeVendor(raw.vendor || {}),
|
|
532
|
+
features: normalizeFeatures(raw.features || {}),
|
|
423
533
|
vendorDir: fx.vendorDir,
|
|
424
534
|
raw,
|
|
425
535
|
};
|
|
@@ -459,6 +569,7 @@ export function loadConfigFrom(dir) {
|
|
|
459
569
|
fx,
|
|
460
570
|
package: pkg,
|
|
461
571
|
vendor,
|
|
572
|
+
features,
|
|
462
573
|
vendorDir,
|
|
463
574
|
raw,
|
|
464
575
|
} = parseConfig(fs.readFileSync(found, "utf8"));
|
|
@@ -467,6 +578,7 @@ export function loadConfigFrom(dir) {
|
|
|
467
578
|
fx,
|
|
468
579
|
package: pkg,
|
|
469
580
|
vendor,
|
|
581
|
+
features,
|
|
470
582
|
vendorDir,
|
|
471
583
|
raw,
|
|
472
584
|
path: found,
|
|
@@ -477,6 +589,7 @@ export function loadConfigFrom(dir) {
|
|
|
477
589
|
fx: { ...DEFAULT_FX },
|
|
478
590
|
package: normalizePackage({}),
|
|
479
591
|
vendor: [],
|
|
592
|
+
features: [],
|
|
480
593
|
vendorDir: DEFAULT_VENDOR_DIR,
|
|
481
594
|
raw: {},
|
|
482
595
|
path: null,
|
package/src/decompiler.js
CHANGED
|
@@ -49,6 +49,8 @@ const LIST_OPCODE_METHOD = {
|
|
|
49
49
|
data_listcontainsitem: { method: "contains", inputs: ["ITEM"] },
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
const SAFE_TEMP_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
53
|
+
|
|
52
54
|
function escapeString(value) {
|
|
53
55
|
return String(value)
|
|
54
56
|
.replace(/\\/g, "\\\\")
|
|
@@ -172,6 +174,14 @@ class Decompiler {
|
|
|
172
174
|
}
|
|
173
175
|
if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
|
|
174
176
|
if (block.opcode === "data_listcontents") return block.fields.LIST[0];
|
|
177
|
+
if (block.opcode === "fxTempVars_getThreadVariable") {
|
|
178
|
+
const name = this.tempVarName(block);
|
|
179
|
+
if (name != null && SAFE_TEMP_NAME.test(name)) return `$${name}`;
|
|
180
|
+
}
|
|
181
|
+
if (block.opcode === "fxTempVars_getRuntimeVariable") {
|
|
182
|
+
const name = this.tempVarName(block);
|
|
183
|
+
if (name != null && SAFE_TEMP_NAME.test(name)) return `$$${name}`;
|
|
184
|
+
}
|
|
175
185
|
if (
|
|
176
186
|
Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
|
|
177
187
|
) {
|
|
@@ -254,6 +264,27 @@ class Decompiler {
|
|
|
254
264
|
return lines.filter(Boolean).join("\n");
|
|
255
265
|
}
|
|
256
266
|
|
|
267
|
+
/** Literal name stored in a temporary-variable block's VAR input. */
|
|
268
|
+
tempVarName(block) {
|
|
269
|
+
const raw = (block.inputs || {}).VAR;
|
|
270
|
+
const inner = Array.isArray(raw) ? raw[1] : null;
|
|
271
|
+
if (!Array.isArray(inner) || ![4, 5, 6, 7, 8, 9, 10, 11].includes(inner[0]))
|
|
272
|
+
return null;
|
|
273
|
+
return inner[1] == null ? null : String(inner[1]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Render set/change on a temporary variable, with sugar when the name is safe. */
|
|
277
|
+
renderTempVarAssign(block, pad, prefix, op, catalogName) {
|
|
278
|
+
const name = this.tempVarName(block);
|
|
279
|
+
const value = this.renderExpression(
|
|
280
|
+
block.inputs.STRING ?? block.inputs.NUM,
|
|
281
|
+
);
|
|
282
|
+
if (name != null && SAFE_TEMP_NAME.test(name))
|
|
283
|
+
return `${pad}${prefix}${name} ${op} ${value};`;
|
|
284
|
+
const input = this.renderExpression(block.inputs.VAR);
|
|
285
|
+
return `${pad}tempvars.${catalogName}(${input}, ${value});`;
|
|
286
|
+
}
|
|
287
|
+
|
|
257
288
|
renderStatement(block, indent) {
|
|
258
289
|
const pad = " ".repeat(indent);
|
|
259
290
|
const sub = (firstId) => {
|
|
@@ -289,6 +320,20 @@ class Decompiler {
|
|
|
289
320
|
return `${pad}${this.renderProcedureCall(block)};`;
|
|
290
321
|
case "control_stop":
|
|
291
322
|
return `${pad}control.stop("${block.fields.STOP_OPTION[0]}");`;
|
|
323
|
+
case "fxTempVars_setThreadVariable":
|
|
324
|
+
return this.renderTempVarAssign(block, pad, "$", "=", "setThread");
|
|
325
|
+
case "fxTempVars_changeThreadVariable":
|
|
326
|
+
return this.renderTempVarAssign(block, pad, "$", "+=", "changeThread");
|
|
327
|
+
case "fxTempVars_setRuntimeVariable":
|
|
328
|
+
return this.renderTempVarAssign(block, pad, "$$", "=", "setRuntime");
|
|
329
|
+
case "fxTempVars_changeRuntimeVariable":
|
|
330
|
+
return this.renderTempVarAssign(
|
|
331
|
+
block,
|
|
332
|
+
pad,
|
|
333
|
+
"$$",
|
|
334
|
+
"+=",
|
|
335
|
+
"changeRuntime",
|
|
336
|
+
);
|
|
292
337
|
default: {
|
|
293
338
|
if (
|
|
294
339
|
Object.prototype.hasOwnProperty.call(LIST_OPCODE_METHOD, block.opcode)
|