@triflare/fx 2.0.0 → 2.1.0-beta.1
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 +4 -2
- package/product.yml +1 -1
- package/src/catalog.js +103 -0
- package/src/cli/commands/feature.js +114 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +6 -0
- package/src/cli/commands/lint.js +270 -0
- package/src/cli/context.js +13 -0
- 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 +119 -0
- package/src/config.js +37 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +339 -0
- package/src/formatter.js +499 -0
- package/src/index.js +10 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1414 -0
- package/src/lint/config-file.js +97 -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 +109 -0
- package/src/parser.js +53 -13
- package/src/project.js +7 -87
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sourceExtension } from "../../brand.js";
|
|
4
|
+
import { discoverSources } from "../../project.js";
|
|
5
|
+
import { applyFixes, lintSources } from "../../linter.js";
|
|
6
|
+
import {
|
|
7
|
+
effectivePreset,
|
|
8
|
+
normalizeLintConfig,
|
|
9
|
+
parseRuleOption,
|
|
10
|
+
} from "../../lint/config.js";
|
|
11
|
+
import { PRESET_NAMES, ruleById } from "../../lint/rules.js";
|
|
12
|
+
import { loadProjectState, resolveInput } from "../context.js";
|
|
13
|
+
import { outcome } from "../define-command.js";
|
|
14
|
+
import { EXIT, UsageError } from "../errors.js";
|
|
15
|
+
import { plural } from "../format.js";
|
|
16
|
+
|
|
17
|
+
const EXTENSION = sourceExtension();
|
|
18
|
+
|
|
19
|
+
/** Repeatable `--rule` values are collected into an array. */
|
|
20
|
+
function collect(value, previous = []) {
|
|
21
|
+
return [...previous, value];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function displayPath(cwd, file) {
|
|
25
|
+
const relativePath = path.relative(cwd, file);
|
|
26
|
+
return relativePath.startsWith("..") || path.isAbsolute(relativePath)
|
|
27
|
+
? file
|
|
28
|
+
: relativePath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve `--preset` and `--rule` on top of the project's `fx.lint` block.
|
|
33
|
+
* A bare `--rule FX201` enables the rule at its default severity; `off`
|
|
34
|
+
* disables it, and `error`/`warn` override the severity.
|
|
35
|
+
*/
|
|
36
|
+
function resolveOptions(options, loaded, product) {
|
|
37
|
+
const lintConfig = normalizeLintConfig(loaded.fx?.lint ?? {}, (message) => {
|
|
38
|
+
throw new UsageError(message, {
|
|
39
|
+
hint: "check the lint block in fx.yml",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (options.preset != null) {
|
|
44
|
+
const preset = String(options.preset).trim().toLowerCase();
|
|
45
|
+
if (!PRESET_NAMES.includes(preset))
|
|
46
|
+
throw new UsageError(`unknown lint preset: ${options.preset}`, {
|
|
47
|
+
hint: `use one of: ${PRESET_NAMES.join(", ")}`,
|
|
48
|
+
});
|
|
49
|
+
lintConfig.extends = preset;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const overrides = {};
|
|
53
|
+
for (const raw of options.rule ?? []) {
|
|
54
|
+
const parsed = parseRuleOption(raw);
|
|
55
|
+
const preset = parsed.id.toLowerCase();
|
|
56
|
+
if (!parsed.severity && PRESET_NAMES.includes(preset)) {
|
|
57
|
+
lintConfig.extends = preset;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const rule = ruleById(parsed.id);
|
|
61
|
+
if (!rule)
|
|
62
|
+
throw new UsageError(`unknown lint rule: ${parsed.id}`, {
|
|
63
|
+
hint: `run "${product.command} lint --help" for rule selection`,
|
|
64
|
+
});
|
|
65
|
+
if (!parsed.severity) {
|
|
66
|
+
overrides[rule.id] = rule.severity;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!["error", "warning", "off"].includes(parsed.severity))
|
|
70
|
+
throw new UsageError(
|
|
71
|
+
`invalid severity for ${rule.id}: ${String(parsed.severity)}`,
|
|
72
|
+
{ hint: "use error, warn or off" },
|
|
73
|
+
);
|
|
74
|
+
overrides[rule.id] = parsed.severity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { lintConfig, overrides };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Strip fix internals and display paths relative to the working directory. */
|
|
81
|
+
function toJsonDiagnostic(diagnostic, cwd) {
|
|
82
|
+
return {
|
|
83
|
+
ruleId: diagnostic.ruleId,
|
|
84
|
+
severity: diagnostic.severity,
|
|
85
|
+
category: diagnostic.category,
|
|
86
|
+
message: diagnostic.message,
|
|
87
|
+
file: diagnostic.file ? displayPath(cwd, diagnostic.file) : null,
|
|
88
|
+
line: diagnostic.line,
|
|
89
|
+
column: diagnostic.column,
|
|
90
|
+
endLine: diagnostic.endLine,
|
|
91
|
+
endColumn: diagnostic.endColumn,
|
|
92
|
+
explanation: diagnostic.explanation,
|
|
93
|
+
fixable: diagnostic.fixable,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reportDiagnostics(reporter, diagnostics, cwd) {
|
|
98
|
+
let current = null;
|
|
99
|
+
for (const diagnostic of diagnostics) {
|
|
100
|
+
const file = diagnostic.file
|
|
101
|
+
? displayPath(cwd, diagnostic.file)
|
|
102
|
+
: "<input>";
|
|
103
|
+
if (file !== current) {
|
|
104
|
+
current = file;
|
|
105
|
+
reporter.text(reporter.bold(file));
|
|
106
|
+
}
|
|
107
|
+
const mark =
|
|
108
|
+
diagnostic.severity === "error"
|
|
109
|
+
? "error"
|
|
110
|
+
: diagnostic.severity === "warning"
|
|
111
|
+
? "warn "
|
|
112
|
+
: "info ";
|
|
113
|
+
reporter.text(
|
|
114
|
+
` ${mark} ${diagnostic.line}:${diagnostic.column} ${diagnostic.ruleId} ${diagnostic.message}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const lintCommand = {
|
|
120
|
+
name: "lint",
|
|
121
|
+
group: "Core Commands:",
|
|
122
|
+
summary: "Report source and project problems",
|
|
123
|
+
description: `Statically analyse a file, or every ${EXTENSION} file in a directory
|
|
124
|
+
(defaults to the working directory), plus the merged project. Rules come from
|
|
125
|
+
the "lint" block under "fx" in fx.yml; --preset and --rule override them.
|
|
126
|
+
|
|
127
|
+
Exit status is 1 when errors are reported, or when warnings are reported with
|
|
128
|
+
--strict. Linting never changes a file unless --fix is passed.`,
|
|
129
|
+
arguments: [`[input${EXTENSION}|dir]`],
|
|
130
|
+
options: [
|
|
131
|
+
{ flags: "--fix", description: "Apply safe, explicit fixes" },
|
|
132
|
+
{
|
|
133
|
+
flags: "--strict",
|
|
134
|
+
description: "Use the strict preset and fail on warnings",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
flags: "--preset <name>",
|
|
138
|
+
description: `Use a preset: ${PRESET_NAMES.join(", ")}`,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
flags: "--rule <id[=severity]>",
|
|
142
|
+
description: "Enable, disable or set one rule (repeatable)",
|
|
143
|
+
parser: collect,
|
|
144
|
+
defaultValue: [],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
examples: [
|
|
148
|
+
"fx lint",
|
|
149
|
+
"fx lint src/main.tfx",
|
|
150
|
+
"fx lint --strict",
|
|
151
|
+
"fx lint --json",
|
|
152
|
+
"fx lint --rule FX201=off",
|
|
153
|
+
"fx lint --fix",
|
|
154
|
+
],
|
|
155
|
+
async run({ args, cwd, options, reporter, product }) {
|
|
156
|
+
const target = resolveInput(args[0], cwd);
|
|
157
|
+
const files = target.file ? [target.file] : discoverSources(target.dir);
|
|
158
|
+
if (files.length === 0)
|
|
159
|
+
throw new UsageError(`No ${EXTENSION} files found under ${target.dir}`);
|
|
160
|
+
|
|
161
|
+
const state = await loadProjectState({
|
|
162
|
+
input: args[0],
|
|
163
|
+
cwd,
|
|
164
|
+
options,
|
|
165
|
+
reporter,
|
|
166
|
+
includeExtensions: true,
|
|
167
|
+
migrateLegacy: false,
|
|
168
|
+
});
|
|
169
|
+
const { lintConfig, overrides } = resolveOptions(
|
|
170
|
+
options,
|
|
171
|
+
state.loaded,
|
|
172
|
+
product,
|
|
173
|
+
);
|
|
174
|
+
const preset = effectivePreset(lintConfig, Boolean(options.strict));
|
|
175
|
+
|
|
176
|
+
const lintOptions = {
|
|
177
|
+
config: state.loaded,
|
|
178
|
+
configPath: state.loaded.path,
|
|
179
|
+
configDir: state.configDir,
|
|
180
|
+
rootDir: state.configDir ?? cwd,
|
|
181
|
+
extensionCatalog: state.catalog,
|
|
182
|
+
lintConfig,
|
|
183
|
+
strict: Boolean(options.strict),
|
|
184
|
+
overrides,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Analyse the whole project so cross-file declarations resolve, but keep
|
|
188
|
+
// the selected files separate for reporting, counts and --fix writes.
|
|
189
|
+
// Diagnostics whose file is not a source (assets, fx.yml) stay visible.
|
|
190
|
+
const projectDir = state.configDir ?? target.dir;
|
|
191
|
+
const analysisFiles = discoverSources(projectDir);
|
|
192
|
+
for (const file of files)
|
|
193
|
+
if (!analysisFiles.includes(file)) analysisFiles.push(file);
|
|
194
|
+
const analysisSet = new Set(analysisFiles);
|
|
195
|
+
const selected = new Set(files);
|
|
196
|
+
const inScope = (diagnostic) =>
|
|
197
|
+
diagnostic.file == null ||
|
|
198
|
+
selected.has(diagnostic.file) ||
|
|
199
|
+
!analysisSet.has(diagnostic.file);
|
|
200
|
+
|
|
201
|
+
let sources = analysisFiles.map((file) => ({
|
|
202
|
+
file,
|
|
203
|
+
source: fs.readFileSync(file, "utf8"),
|
|
204
|
+
}));
|
|
205
|
+
let result = lintSources({ sources, ...lintOptions });
|
|
206
|
+
let diagnostics = result.diagnostics.filter(inScope);
|
|
207
|
+
let fixed = 0;
|
|
208
|
+
|
|
209
|
+
if (options.fix) {
|
|
210
|
+
const applied = applyFixes(sources, diagnostics);
|
|
211
|
+
fixed = applied.fixed;
|
|
212
|
+
if (fixed > 0) {
|
|
213
|
+
for (const file of applied.files)
|
|
214
|
+
fs.writeFileSync(file, applied.sources.get(file));
|
|
215
|
+
sources = sources.map(({ file, source }) => ({
|
|
216
|
+
file,
|
|
217
|
+
source: applied.sources.get(file) ?? source,
|
|
218
|
+
}));
|
|
219
|
+
result = lintSources({ sources, ...lintOptions });
|
|
220
|
+
diagnostics = result.diagnostics.filter(inScope);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const summary = {
|
|
225
|
+
errors: diagnostics.filter((entry) => entry.severity === "error").length,
|
|
226
|
+
warnings: diagnostics.filter((entry) => entry.severity === "warning")
|
|
227
|
+
.length,
|
|
228
|
+
suppressed: result.summary.suppressed,
|
|
229
|
+
files: files.length,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (!reporter.json) {
|
|
233
|
+
reportDiagnostics(reporter, diagnostics, cwd);
|
|
234
|
+
if (fixed > 0) reporter.info(`Fixed ${plural(fixed, "problem")}.`);
|
|
235
|
+
const { errors, warnings } = summary;
|
|
236
|
+
if (errors > 0 || warnings > 0) {
|
|
237
|
+
const parts = [];
|
|
238
|
+
if (errors > 0) parts.push(plural(errors, "error"));
|
|
239
|
+
if (warnings > 0) parts.push(plural(warnings, "warning"));
|
|
240
|
+
reporter.error(
|
|
241
|
+
`${parts.join(", ")} in ${plural(files.length, "file")} (${preset} preset)`,
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
reporter.success(
|
|
245
|
+
`Linted ${plural(files.length, "file")}: no problems (${preset} preset)`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const failsOnWarnings = Boolean(options.strict);
|
|
251
|
+
const exitCode =
|
|
252
|
+
summary.errors > 0 || (failsOnWarnings && summary.warnings > 0)
|
|
253
|
+
? EXIT.FAILURE
|
|
254
|
+
: EXIT.OK;
|
|
255
|
+
|
|
256
|
+
return outcome({
|
|
257
|
+
exitCode,
|
|
258
|
+
data: {
|
|
259
|
+
preset,
|
|
260
|
+
strict: failsOnWarnings,
|
|
261
|
+
fixed,
|
|
262
|
+
files: files.map((file) => displayPath(cwd, file)),
|
|
263
|
+
summary,
|
|
264
|
+
diagnostics: diagnostics.map((diagnostic) =>
|
|
265
|
+
toJsonDiagnostic(diagnostic, cwd),
|
|
266
|
+
),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
package/src/cli/context.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import { configFilename, outputDirectory, sourceExtension } from "../brand.js";
|
|
12
12
|
import { loadConfigFrom, parseConfig } from "../config.js";
|
|
13
13
|
import { loadExtensions } from "../extensions.js";
|
|
14
|
+
import { loadFeatureExtensions } from "../features.js";
|
|
14
15
|
import { loadVendorExtensions, migrateProjectExtensions } from "../vendor.js";
|
|
15
16
|
import { UsageError } from "./errors.js";
|
|
16
17
|
|
|
@@ -49,6 +50,7 @@ export function loadProjectConfig({ cwd = process.cwd(), searchDir, options }) {
|
|
|
49
50
|
fx: null,
|
|
50
51
|
package: null,
|
|
51
52
|
vendor: [],
|
|
53
|
+
features: [],
|
|
52
54
|
vendorDir: "extensions",
|
|
53
55
|
path: null,
|
|
54
56
|
};
|
|
@@ -166,6 +168,17 @@ export async function loadProjectExtensions({ configDir, loaded, reporter }) {
|
|
|
166
168
|
for (const warning of vendored.warnings) reporter.warn(warning);
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
if (loaded?.features && loaded.features.length > 0) {
|
|
172
|
+
const featureExtensions = await loadFeatureExtensions({
|
|
173
|
+
configDir,
|
|
174
|
+
config: loaded,
|
|
175
|
+
});
|
|
176
|
+
Object.assign(catalog, featureExtensions.catalog);
|
|
177
|
+
Object.assign(extensionUrls, featureExtensions.extensionUrls);
|
|
178
|
+
unsandboxed.push(...featureExtensions.unsandboxed);
|
|
179
|
+
for (const warning of featureExtensions.warnings) reporter.warn(warning);
|
|
180
|
+
}
|
|
181
|
+
|
|
169
182
|
if (config.extensions && config.extensions.length > 0) {
|
|
170
183
|
warnLegacyExtensions(loaded, reporter);
|
|
171
184
|
const legacy = await loadExtensions(config.extensions, {
|
|
@@ -70,7 +70,14 @@ function configure(command, spec) {
|
|
|
70
70
|
if (spec.group) command.helpGroup(spec.group);
|
|
71
71
|
for (const argument of spec.arguments ?? []) command.argument(argument);
|
|
72
72
|
for (const option of spec.options ?? []) {
|
|
73
|
-
if (option.
|
|
73
|
+
if (option.parser)
|
|
74
|
+
command.option(
|
|
75
|
+
option.flags,
|
|
76
|
+
option.description,
|
|
77
|
+
option.parser,
|
|
78
|
+
option.defaultValue,
|
|
79
|
+
);
|
|
80
|
+
else if (option.defaultValue === undefined)
|
|
74
81
|
command.option(option.flags, option.description);
|
|
75
82
|
else command.option(option.flags, option.description, option.defaultValue);
|
|
76
83
|
}
|
package/src/cli/reporter.js
CHANGED
|
@@ -151,6 +151,15 @@ export function createReporter({
|
|
|
151
151
|
stdout.write(`${message}\n`);
|
|
152
152
|
},
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Unmodified product output, such as formatted source. Quiet does not
|
|
156
|
+
* suppress it because it is the command's result; JSON mode does.
|
|
157
|
+
*/
|
|
158
|
+
raw(text) {
|
|
159
|
+
if (json) return;
|
|
160
|
+
stdout.write(String(text));
|
|
161
|
+
},
|
|
162
|
+
|
|
154
163
|
/** Quiet, de-emphasized line for incidental notes. */
|
|
155
164
|
note(message) {
|
|
156
165
|
if (!human) return;
|
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,122 @@ 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
|
+
/**
|
|
677
|
+
* Remove a `features:` entry.
|
|
678
|
+
* @param {string} configPath
|
|
679
|
+
* @param {string} name
|
|
680
|
+
* @returns {{removed: string|null}}
|
|
681
|
+
*/
|
|
682
|
+
export function removeFeatureEntry(configPath, name) {
|
|
683
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
684
|
+
const lines = splitLines(text);
|
|
685
|
+
const eol = eolOf(text);
|
|
686
|
+
const key = String(name);
|
|
687
|
+
const bounds = topLevelBounds(lines, "features");
|
|
688
|
+
if (!bounds) return { removed: null };
|
|
689
|
+
|
|
690
|
+
if (bounds.inline) {
|
|
691
|
+
const mapping = parseValue(bounds.inline);
|
|
692
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
693
|
+
return { removed: null };
|
|
694
|
+
if (!Object.hasOwn(mapping, key)) return { removed: null };
|
|
695
|
+
const source = mapping[key];
|
|
696
|
+
delete mapping[key];
|
|
697
|
+
const rest = Object.keys(mapping).length
|
|
698
|
+
? ` ${JSON.stringify(mapping)}`
|
|
699
|
+
: " {}";
|
|
700
|
+
const comment = bounds.comment ? ` ${bounds.comment}` : "";
|
|
701
|
+
const line = lines[bounds.index];
|
|
702
|
+
lines[bounds.index] = `features:${rest}${comment}${
|
|
703
|
+
/\r?\n$/.test(line) ? ending(line) : eol
|
|
704
|
+
}`;
|
|
705
|
+
writeDocument(configPath, lines.join(""));
|
|
706
|
+
return { removed: source == null ? null : String(source) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const spans = mappingEntrySpans(lines, bounds.index, bounds.end);
|
|
710
|
+
const existing = spans.find((span) => span.key === key);
|
|
711
|
+
if (!existing) return { removed: null };
|
|
712
|
+
|
|
713
|
+
let source = null;
|
|
714
|
+
try {
|
|
715
|
+
const parsed = yaml.load(
|
|
716
|
+
[
|
|
717
|
+
content(lines[bounds.index]),
|
|
718
|
+
...lines.slice(existing.index, existing.end),
|
|
719
|
+
].join("\n"),
|
|
720
|
+
);
|
|
721
|
+
const entry = parsed?.features?.[key];
|
|
722
|
+
source =
|
|
723
|
+
typeof entry === "string" ? entry : (entry?.file ?? entry?.path ?? null);
|
|
724
|
+
} catch {
|
|
725
|
+
// The entry is removed either way.
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
replaceRange(lines, existing.index, existing.end, []);
|
|
729
|
+
const end = bounds.end - (existing.end - existing.index);
|
|
730
|
+
const remaining = mappingEntrySpans(lines, bounds.index, end);
|
|
731
|
+
if (remaining.length === 0) {
|
|
732
|
+
replaceRange(lines, bounds.index, end, [`features: {}${eol}`]);
|
|
733
|
+
}
|
|
734
|
+
writeDocument(configPath, lines.join(""));
|
|
735
|
+
return { removed: source ?? key };
|
|
736
|
+
}
|