@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.
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Triflare Fx source formatter.
3
+ *
4
+ * The formatter walks the parsed AST to decide structure and spacing, and
5
+ * replays the lexer's comment log to keep comments attached to the code around
6
+ * them. Formatting never compiles a project, so it cannot change generated
7
+ * project behavior, and running it twice produces no further changes.
8
+ *
9
+ * Canonical style:
10
+ * - four-space indentation, one statement per line;
11
+ * - `{` on the same line as its header, `} else {` for alternates;
12
+ * - single spaces around operators, after commas, and after colons;
13
+ * - double-quoted strings with minimal escapes;
14
+ * - unchanged declaration order and project data.
15
+ */
16
+ import { tokenizeWithComments } from "./lexer.js";
17
+ import { parseTokens } from "./parser.js";
18
+
19
+ const INDENT = " ";
20
+
21
+ /** Binding strength of binary operators; larger binds tighter. */
22
+ const PRECEDENCE = {
23
+ or: 1,
24
+ and: 2,
25
+ "==": 3,
26
+ "!=": 3,
27
+ "<": 3,
28
+ "<=": 3,
29
+ ">": 3,
30
+ ">=": 3,
31
+ "+": 4,
32
+ "-": 4,
33
+ "*": 5,
34
+ "/": 5,
35
+ "%": 5,
36
+ };
37
+
38
+ const UNARY_PRECEDENCE = 6;
39
+
40
+ function escapeString(value) {
41
+ return String(value)
42
+ .replace(/\\/g, "\\\\")
43
+ .replace(/"/g, '\\"')
44
+ .replace(/\n/g, "\\n")
45
+ .replace(/\r/g, "\\r")
46
+ .replace(/\t/g, "\\t");
47
+ }
48
+
49
+ function stringLiteral(value) {
50
+ return `"${escapeString(value)}"`;
51
+ }
52
+
53
+ function isIdentifier(name) {
54
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
55
+ }
56
+
57
+ function mapKey(key) {
58
+ return isIdentifier(key) ? key : stringLiteral(key);
59
+ }
60
+
61
+ /**
62
+ * Render an expression, adding parentheses only where the AST needs them to
63
+ * survive a round trip through the parser.
64
+ *
65
+ * @param {object} node
66
+ * @param {number} [minimum] lowest precedence allowed without parentheses
67
+ * @param {string} [parentOperator] operator receiving this expression
68
+ * @returns {string}
69
+ */
70
+ function renderExpression(node, minimum = 0, parentOperator) {
71
+ switch (node.type) {
72
+ case "Number":
73
+ return String(node.value);
74
+ case "String":
75
+ return stringLiteral(node.value);
76
+ case "Boolean":
77
+ return node.value ? "true" : "false";
78
+ case "Identifier":
79
+ return node.name;
80
+ case "Call":
81
+ return `${node.name}(${node.args
82
+ .map((arg) => renderExpression(arg))
83
+ .join(", ")})`;
84
+ case "ArrayLiteral":
85
+ return `[${node.items.map((item) => renderExpression(item)).join(", ")}]`;
86
+ case "Unary": {
87
+ const operand = renderExpression(node.operand, UNARY_PRECEDENCE, node.op);
88
+ if (node.op === "not") {
89
+ const text = `not ${operand}`;
90
+ return parentOperator && !["and", "or", "not"].includes(parentOperator)
91
+ ? `(${text})`
92
+ : text;
93
+ }
94
+ // `--x` is legal but reads like a decrement waiting to happen.
95
+ const nested = node.operand.type === "Unary" && node.operand.op === "-";
96
+ return `-${nested ? `(${operand})` : operand}`;
97
+ }
98
+ case "Binary": {
99
+ const precedence = PRECEDENCE[node.op] ?? 0;
100
+ const left = renderExpression(node.left, precedence, node.op);
101
+ // Right operands need parentheses at equal precedence to keep left
102
+ // associativity from regrouping them, e.g. `a - (b - c)`.
103
+ const right = renderExpression(node.right, precedence + 1, node.op);
104
+ const text = `${left} ${node.op} ${right}`;
105
+ return precedence < minimum ? `(${text})` : text;
106
+ }
107
+ default:
108
+ throw new Error(`Cannot format expression node ${node.type}`);
109
+ }
110
+ }
111
+
112
+ /** Render a hat event: a bare name when it takes no arguments. */
113
+ function renderEvent(event) {
114
+ if (event.args.length === 0) return event.segments.join(".");
115
+ return `${event.segments.join(".")}(${event.args
116
+ .map((arg) => renderExpression(arg))
117
+ .join(", ")})`;
118
+ }
119
+
120
+ /** Group statements so the formatter can insert stable blank lines. */
121
+ function category(node) {
122
+ switch (node.type) {
123
+ case "StageDecl":
124
+ case "SpriteDecl":
125
+ return "declaration";
126
+ case "PropertyDecl":
127
+ return "property";
128
+ case "VarDecl":
129
+ case "ListDecl":
130
+ return "data";
131
+ case "FunctionDecl":
132
+ return "function";
133
+ case "HatDecl":
134
+ return "script";
135
+ default:
136
+ return "statement";
137
+ }
138
+ }
139
+
140
+ /** Whether two adjacent statements belong in separate visual groups. */
141
+ function needsBlankLine(previous, next) {
142
+ const before = category(previous);
143
+ const after = category(next);
144
+ if (before !== after) return true;
145
+ return (
146
+ before === "declaration" || before === "function" || before === "script"
147
+ );
148
+ }
149
+
150
+ class Formatter {
151
+ /**
152
+ * @param {string} source
153
+ * @param {string} file
154
+ */
155
+ constructor(source, file) {
156
+ const { tokens, comments } = tokenizeWithComments(source, file);
157
+ this.sourceLines = source.split(/\r?\n/);
158
+ this.tokens = tokens;
159
+ this.comments = comments;
160
+ this.commentIndex = 0;
161
+ this.tokenIndexes = new Map();
162
+ tokens.forEach((token, index) => this.tokenIndexes.set(token, index));
163
+ this.out = [];
164
+ this.program = parseTokens(tokens, source, file);
165
+ }
166
+
167
+ /** Token index for a loc that the parser took straight from the lexer. */
168
+ tokenIndexOf(token) {
169
+ return this.tokenIndexes.get(token) ?? this.tokens.length - 1;
170
+ }
171
+
172
+ /** First token with `value` at or after `from`, or -1. */
173
+ findToken(from, value) {
174
+ for (let index = Math.max(0, from); index < this.tokens.length; index += 1)
175
+ if (this.tokens[index].value === value) return index;
176
+ return -1;
177
+ }
178
+
179
+ /** Index of the `}` matching the `{` at `open`. */
180
+ matchBrace(open) {
181
+ let depth = 0;
182
+ for (let index = open; index < this.tokens.length; index += 1) {
183
+ const token = this.tokens[index];
184
+ if (token.type !== "punct") continue;
185
+ if (token.value === "{") depth += 1;
186
+ else if (token.value === "}") {
187
+ depth -= 1;
188
+ if (depth === 0) return index;
189
+ }
190
+ }
191
+ return this.tokens.length - 1;
192
+ }
193
+
194
+ /** Token span of a node's block body. */
195
+ bodySpan(node) {
196
+ const open = this.findToken(this.tokenIndexOf(node.loc), "{");
197
+ return { open, close: this.matchBrace(open) };
198
+ }
199
+
200
+ /** Whether a comment can share the previous output line. */
201
+ canInline(comment) {
202
+ if (/\r(?!$)|\n/.test(comment.text)) return false;
203
+ if (this.out.length === 0) return false;
204
+ if (this.out[this.out.length - 1].trim() === "") return false;
205
+ const previous = this.tokens[comment.after - 1];
206
+ return Boolean(previous && previous.line === comment.line);
207
+ }
208
+
209
+ /** Attach only the trailing comments that belong on the previous line. */
210
+ flushInline(limit) {
211
+ while (this.commentIndex < this.comments.length) {
212
+ const comment = this.comments[this.commentIndex];
213
+ if (comment.after > limit || !this.canInline(comment)) return;
214
+ this.commentIndex += 1;
215
+ this.out[this.out.length - 1] += ` ${comment.text.trim()}`;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Emit every comment that appears before token `limit`.
221
+ *
222
+ * @param {number} limit
223
+ * @param {number} indent
224
+ */
225
+ flush(limit, indent) {
226
+ const pad = INDENT.repeat(indent);
227
+ while (this.commentIndex < this.comments.length) {
228
+ const comment = this.comments[this.commentIndex];
229
+ if (comment.after > limit) break;
230
+ this.commentIndex += 1;
231
+ // The lexer includes the CR of a CRLF terminator in line comments.
232
+ const text = comment.text.startsWith("//")
233
+ ? comment.text.replace(/\r$/, "")
234
+ : comment.text;
235
+ const lines = text
236
+ .replace(/\r\n?/g, "\n")
237
+ .replace(/[ \t]+$/gm, "")
238
+ .split("\n");
239
+ if (lines.length === 1 && this.canInline(comment)) {
240
+ this.out[this.out.length - 1] += ` ${lines[0].trim()}`;
241
+ continue;
242
+ }
243
+ this.out.push(`${pad}${lines[0]}`);
244
+ for (let index = 1; index < lines.length; index += 1)
245
+ this.out.push(lines[index]);
246
+ }
247
+ }
248
+
249
+ pushBlank() {
250
+ if (this.out.length === 0) return;
251
+ if (this.out[this.out.length - 1] === "") return;
252
+ this.out.push("");
253
+ }
254
+
255
+ /** Whether the source has a blank line strictly between two lines. */
256
+ hasBlankBetween(fromLine, toLine) {
257
+ for (let line = fromLine + 1; line < toLine; line += 1)
258
+ if ((this.sourceLines[line - 1] ?? "").trim() === "") return true;
259
+ return false;
260
+ }
261
+
262
+ /**
263
+ * Render a statement list, flushing comments between its statements.
264
+ *
265
+ * Blank lines are canonical: declarations, functions and scripts are always
266
+ * separated, an intentional single blank line in the source survives, and
267
+ * runs of blank lines collapse to one.
268
+ */
269
+ renderBody(items, indent, closeIndex) {
270
+ items.forEach((item, index) => {
271
+ const start = this.tokenIndexOf(item.loc);
272
+ if (index > 0) {
273
+ this.flushInline(start);
274
+ const previousLine = this.tokens[start - 1]?.line ?? 0;
275
+ if (
276
+ needsBlankLine(items[index - 1], item) ||
277
+ this.hasBlankBetween(previousLine, item.loc.line)
278
+ )
279
+ this.pushBlank();
280
+ }
281
+ const pending = this.comments[this.commentIndex];
282
+ this.flush(start, indent);
283
+ // Keep a file header comment separated from the first declaration, or a
284
+ // leading comment inside a block separated from the code it introduces.
285
+ if (index === 0 && pending && pending.after <= start) {
286
+ const last = this.comments[this.commentIndex - 1];
287
+ const endLine = last.line + (last.text.match(/\n/g)?.length ?? 0);
288
+ if (this.hasBlankBetween(endLine, item.loc.line)) this.pushBlank();
289
+ }
290
+ this.renderItem(item, indent);
291
+ });
292
+ this.flush(closeIndex, indent);
293
+ }
294
+
295
+ /** Render the block body of a node that owns braces. */
296
+ renderNodeBody(node, indent) {
297
+ this.renderBody(node.body, indent + 1, this.bodySpan(node).close);
298
+ }
299
+
300
+ renderItem(node, indent) {
301
+ const pad = INDENT.repeat(indent);
302
+ switch (node.type) {
303
+ case "StageDecl":
304
+ this.out.push(`${pad}stage {`);
305
+ this.renderNodeBody(node, indent);
306
+ this.out.push(`${pad}}`);
307
+ return;
308
+ case "SpriteDecl": {
309
+ const modifier = node.partial ? "partial " : "";
310
+ this.out.push(`${pad}${modifier}sprite ${stringLiteral(node.name)} {`);
311
+ this.renderNodeBody(node, indent);
312
+ this.out.push(`${pad}}`);
313
+ return;
314
+ }
315
+ case "PropertyDecl":
316
+ this.renderProperty(node, indent);
317
+ return;
318
+ case "VarDecl":
319
+ this.out.push(
320
+ `${pad}${node.cloud ? "cloud " : ""}let ${node.name}` +
321
+ `${node.init ? ` = ${renderExpression(node.init)}` : ""};`,
322
+ );
323
+ return;
324
+ case "TempVarDecl":
325
+ this.out.push(
326
+ `${pad}${node.kind} let ${node.name}` +
327
+ `${node.init ? ` = ${renderExpression(node.init)}` : ""};`,
328
+ );
329
+ return;
330
+ case "ListDecl":
331
+ this.out.push(
332
+ `${pad}list ${node.name} = [${node.items
333
+ .map((item) => renderExpression(item))
334
+ .join(", ")}];`,
335
+ );
336
+ return;
337
+ case "FunctionDecl": {
338
+ const modifiers = `${node.returns ? "returning " : ""}${
339
+ node.warp ? "warp " : ""
340
+ }`;
341
+ const params = node.params
342
+ .map((param) =>
343
+ param.type ? `${param.name}: ${param.type}` : param.name,
344
+ )
345
+ .join(", ");
346
+ this.out.push(`${pad}${modifiers}function ${node.name}(${params}) {`);
347
+ this.renderNodeBody(node, indent);
348
+ this.out.push(`${pad}}`);
349
+ return;
350
+ }
351
+ case "HatDecl":
352
+ this.out.push(`${pad}when ${renderEvent(node.event)} {`);
353
+ this.renderNodeBody(node, indent);
354
+ this.out.push(`${pad}}`);
355
+ return;
356
+ case "If":
357
+ this.renderIf(node, indent);
358
+ return;
359
+ case "Repeat":
360
+ this.out.push(`${pad}repeat (${renderExpression(node.count)}) {`);
361
+ this.renderNodeBody(node, indent);
362
+ this.out.push(`${pad}}`);
363
+ return;
364
+ case "While":
365
+ this.out.push(`${pad}while (${renderExpression(node.cond)}) {`);
366
+ this.renderNodeBody(node, indent);
367
+ this.out.push(`${pad}}`);
368
+ return;
369
+ case "Forever":
370
+ this.out.push(`${pad}forever {`);
371
+ this.renderNodeBody(node, indent);
372
+ this.out.push(`${pad}}`);
373
+ return;
374
+ case "Return":
375
+ this.out.push(
376
+ `${pad}return${node.value ? ` ${renderExpression(node.value)}` : ""};`,
377
+ );
378
+ return;
379
+ case "Assign":
380
+ this.out.push(
381
+ `${pad}${node.name} ${node.op} ${renderExpression(node.value)};`,
382
+ );
383
+ return;
384
+ case "BlockCall":
385
+ this.renderBlockCall(node, indent);
386
+ return;
387
+ default:
388
+ throw new Error(`Cannot format statement node ${node.type}`);
389
+ }
390
+ }
391
+
392
+ renderProperty(node, indent) {
393
+ const pad = INDENT.repeat(indent);
394
+ if (!Array.isArray(node.value)) {
395
+ this.out.push(`${pad}${node.key}: ${renderExpression(node.value)};`);
396
+ return;
397
+ }
398
+ if (node.value.length === 0) {
399
+ this.out.push(`${pad}${node.key}: {};`);
400
+ return;
401
+ }
402
+ const span = this.bodySpan(node);
403
+ const inner = INDENT.repeat(indent + 1);
404
+ this.out.push(`${pad}${node.key}: {`);
405
+ node.value.forEach((entry, index) => {
406
+ this.flush(this.tokenIndexOf(entry.loc), indent + 1);
407
+ const comma = index === node.value.length - 1 ? "" : ",";
408
+ this.out.push(
409
+ `${inner}${mapKey(entry.key)}: ${stringLiteral(entry.value)}${comma}`,
410
+ );
411
+ });
412
+ this.flush(span.close, indent + 1);
413
+ this.out.push(`${pad}};`);
414
+ }
415
+
416
+ /** Find the `else` token that follows a block's closing brace. */
417
+ elseIndexAfter(close) {
418
+ const index = this.findToken(close + 1, "else");
419
+ return index === close + 1 ? index : -1;
420
+ }
421
+
422
+ renderIf(node, indent, prefix = "") {
423
+ const pad = INDENT.repeat(indent);
424
+ const span = this.bodySpan(node);
425
+ this.out.push(`${pad}${prefix}if (${renderExpression(node.cond)}) {`);
426
+ this.renderBody(node.then, indent + 1, span.close);
427
+ if (!node.else) {
428
+ this.out.push(`${pad}}`);
429
+ return;
430
+ }
431
+ const elseIndex = this.elseIndexAfter(span.close);
432
+ // Comments between `}` and `else` cannot sit on their own line without
433
+ // breaking the statement, so keep them inside the preceding block.
434
+ if (elseIndex !== -1) this.flush(elseIndex, indent + 1);
435
+ if (node.else.length === 1 && node.else[0].type === "If") {
436
+ this.renderIf(node.else[0], indent, "} else ");
437
+ return;
438
+ }
439
+ const elseOpen = elseIndex === -1 ? span.close + 1 : elseIndex + 1;
440
+ const elseClose = this.matchBrace(elseOpen);
441
+ this.out.push(`${pad}} else {`);
442
+ this.renderBody(node.else, indent + 1, elseClose);
443
+ this.out.push(`${pad}}`);
444
+ }
445
+
446
+ renderBlockCall(node, indent) {
447
+ const pad = INDENT.repeat(indent);
448
+ const args = node.args.map((arg) => renderExpression(arg)).join(", ");
449
+ // `control.forever { }` reads better than `control.forever() { }`, and the
450
+ // parser treats them identically.
451
+ const head =
452
+ node.body && node.args.length === 0
453
+ ? `${pad}${node.name}`
454
+ : `${pad}${node.name}(${args})`;
455
+ if (!node.body) {
456
+ this.out.push(`${head};`);
457
+ return;
458
+ }
459
+ const span = this.bodySpan(node);
460
+ this.out.push(`${head} {`);
461
+ this.renderBody(node.body, indent + 1, span.close);
462
+ if (!node.else) {
463
+ this.out.push(`${pad}}`);
464
+ return;
465
+ }
466
+ const elseIndex = this.elseIndexAfter(span.close);
467
+ if (elseIndex !== -1) this.flush(elseIndex, indent + 1);
468
+ const elseOpen = elseIndex === -1 ? span.close + 1 : elseIndex + 1;
469
+ const elseClose = this.matchBrace(elseOpen);
470
+ this.out.push(`${pad}} else {`);
471
+ this.renderBody(node.else, indent + 1, elseClose);
472
+ this.out.push(`${pad}}`);
473
+ }
474
+
475
+ format() {
476
+ const units = [];
477
+ if (this.program.stage) units.push(this.program.stage);
478
+ units.push(...this.program.sprites, ...this.program.topItems);
479
+ // Preserve the order declarations had in this file; only separate them.
480
+ units.sort((a, b) => this.tokenIndexOf(a.loc) - this.tokenIndexOf(b.loc));
481
+ this.renderBody(units, 0, this.tokens.length - 1);
482
+ if (this.out.length === 0) return "";
483
+ return `${this.out.join("\n")}\n`;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Format Triflare Fx source into the canonical style.
489
+ *
490
+ * Throws `LexError`/`ParseError` with source locations when the input cannot
491
+ * be parsed; callers must leave such files untouched.
492
+ *
493
+ * @param {string} source
494
+ * @param {string} [file]
495
+ * @returns {string}
496
+ */
497
+ export function formatSource(source, file = "<input>") {
498
+ return new Formatter(source, file).format();
499
+ }
package/src/index.js CHANGED
@@ -8,6 +8,16 @@
8
8
  */
9
9
  export { tokenize } from "./lexer.js";
10
10
  export { parse } from "./parser.js";
11
+ export { formatSource } from "./formatter.js";
12
+ export { applyFixes, lintSources, sortDiagnostics } from "./linter.js";
13
+ export {
14
+ DEFAULT_LINT,
15
+ effectivePreset,
16
+ normalizeLintConfig,
17
+ resolveRuleSeverities,
18
+ } from "./lint/config.js";
19
+ export { parseProjectConfigText } from "./lint/config-file.js";
20
+ export { CATEGORIES, PRESETS, RULES } from "./lint/rules.js";
11
21
  export { Compiler, compile } from "./compiler.js";
12
22
  export { build, writeSb3 } from "./build.js";
13
23
  export { discoverSources, mergePrograms } from "./project.js";
package/src/lexer.js CHANGED
@@ -52,13 +52,28 @@ const isDigit = (ch) => ch >= "0" && ch <= "9";
52
52
  */
53
53
 
54
54
  /**
55
- * Tokenize a Triflare Fx source string.
55
+ * @typedef {object} Comment
56
+ * @property {string} text the raw comment, including its delimiters
57
+ * @property {number} line
58
+ * @property {number} column
59
+ * @property {number} after index of the token that follows the comment
60
+ */
61
+
62
+ /**
63
+ * Tokenize a Triflare Fx source string and record its comments.
64
+ *
65
+ * Comments stay out of the token stream, but the formatter needs them to put
66
+ * source comments back into a canonical layout. `after` is the index of the
67
+ * next token, so a comment can be re-attached between tokens even after the
68
+ * whitespace around it changes.
69
+ *
56
70
  * @param {string} source
57
71
  * @param {string} [file]
58
- * @returns {Token[]}
72
+ * @returns {{tokens: Token[], comments: Comment[]}}
59
73
  */
60
- export function tokenize(source, file = "<input>") {
74
+ export function tokenizeWithComments(source, file = "<input>") {
61
75
  const tokens = [];
76
+ const comments = [];
62
77
  let i = 0;
63
78
  let line = 1;
64
79
  let column = 1;
@@ -99,10 +114,20 @@ export function tokenize(source, file = "<input>") {
99
114
 
100
115
  // --- comments -------------------------------------------------------
101
116
  if (ch === "/" && source[i + 1] === "/") {
117
+ const startIndex = i;
118
+ const startLine = line;
119
+ const startColumn = column;
102
120
  while (i < source.length && source[i] !== "\n") advance();
121
+ comments.push({
122
+ text: source.slice(startIndex, i),
123
+ line: startLine,
124
+ column: startColumn,
125
+ after: tokens.length,
126
+ });
103
127
  continue;
104
128
  }
105
129
  if (ch === "/" && source[i + 1] === "*") {
130
+ const startIndex = i;
106
131
  const startLine = line;
107
132
  const startColumn = column;
108
133
  advance(2);
@@ -111,6 +136,12 @@ export function tokenize(source, file = "<input>") {
111
136
  if (i >= source.length)
112
137
  fail("Unterminated block comment", startLine, startColumn);
113
138
  advance(2);
139
+ comments.push({
140
+ text: source.slice(startIndex, i),
141
+ line: startLine,
142
+ column: startColumn,
143
+ after: tokens.length,
144
+ });
114
145
  continue;
115
146
  }
116
147
 
@@ -198,5 +229,15 @@ export function tokenize(source, file = "<input>") {
198
229
  }
199
230
 
200
231
  push("eof", "", line, column);
201
- return tokens;
232
+ return { tokens, comments };
233
+ }
234
+
235
+ /**
236
+ * Tokenize a Triflare Fx source string.
237
+ * @param {string} source
238
+ * @param {string} [file]
239
+ * @returns {Token[]}
240
+ */
241
+ export function tokenize(source, file = "<input>") {
242
+ return tokenizeWithComments(source, file).tokens;
202
243
  }