@xnoxs/flux-lang 3.1.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +1089 -0
  3. package/bin/flux.js +1397 -0
  4. package/dist/flux.cjs.js +6664 -0
  5. package/dist/flux.esm.js +6674 -0
  6. package/dist/flux.min.js +263 -0
  7. package/index.d.ts +202 -0
  8. package/index.js +26 -0
  9. package/package.json +77 -0
  10. package/scripts/build.js +76 -0
  11. package/src/bundler.js +216 -0
  12. package/src/checker.js +322 -0
  13. package/src/codegen.js +785 -0
  14. package/src/css-preprocessor.js +399 -0
  15. package/src/formatter.js +140 -0
  16. package/src/jsx.js +480 -0
  17. package/src/lexer.js +518 -0
  18. package/src/linter.js +758 -0
  19. package/src/mangler.js +280 -0
  20. package/src/parser.js +1671 -0
  21. package/src/self/bundler.flux +167 -0
  22. package/src/self/bundler.js +187 -0
  23. package/src/self/checker.flux +249 -0
  24. package/src/self/checker.js +338 -0
  25. package/src/self/codegen.flux +555 -0
  26. package/src/self/codegen.js +784 -0
  27. package/src/self/css-preprocessor.flux +373 -0
  28. package/src/self/css-preprocessor.js +387 -0
  29. package/src/self/formatter.flux +93 -0
  30. package/src/self/formatter.js +114 -0
  31. package/src/self/jsx.flux +430 -0
  32. package/src/self/jsx.js +396 -0
  33. package/src/self/lexer.flux +529 -0
  34. package/src/self/lexer.js +709 -0
  35. package/src/self/lexer.stage2.js +700 -0
  36. package/src/self/linter.flux +515 -0
  37. package/src/self/linter.js +804 -0
  38. package/src/self/mangler.flux +253 -0
  39. package/src/self/mangler.js +348 -0
  40. package/src/self/parser.flux +1146 -0
  41. package/src/self/parser.js +1571 -0
  42. package/src/self/sourcemap.flux +66 -0
  43. package/src/self/sourcemap.js +72 -0
  44. package/src/self/stdlib.flux +356 -0
  45. package/src/self/stdlib.js +396 -0
  46. package/src/self/test-runner.flux +201 -0
  47. package/src/self/test-runner.js +132 -0
  48. package/src/self/transpiler.flux +123 -0
  49. package/src/self/transpiler.js +83 -0
  50. package/src/self/type-checker.flux +821 -0
  51. package/src/self/type-checker.js +1106 -0
  52. package/src/sourcemap.js +82 -0
  53. package/src/stdlib.js +436 -0
  54. package/src/test-runner.js +239 -0
  55. package/src/transpiler.js +172 -0
  56. package/src/type-checker.js +1206 -0
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ // ── CSS property shorthands ───────────────────────────────────────────────────
4
+ const CSS_PROP_MAP = {
5
+ bg: 'background', fg: 'color', p: 'padding',
6
+ px: 'padding-inline', py: 'padding-block',
7
+ pt: 'padding-top', pb: 'padding-bottom', pl: 'padding-left', pr: 'padding-right',
8
+ m: 'margin', mx: 'margin-inline', my: 'margin-block',
9
+ mt: 'margin-top', mb: 'margin-bottom', ml: 'margin-left', mr: 'margin-right',
10
+ radius: 'border-radius',
11
+ w: 'width', h: 'height',
12
+ 'min-w': 'min-width', 'max-w': 'max-width', 'min-h': 'min-height', 'max-h': 'max-height',
13
+ gap: 'gap', 'col-gap': 'column-gap', 'row-gap': 'row-gap',
14
+ text: 'font-size', font: 'font-family', weight: 'font-weight',
15
+ tracking: 'letter-spacing', leading: 'line-height',
16
+ shadow: 'box-shadow', opacity: 'opacity',
17
+ border: 'border', outline: 'outline',
18
+ transition: 'transition', cursor: 'cursor', overflow: 'overflow',
19
+ 'overflow-x': 'overflow-x', 'overflow-y': 'overflow-y',
20
+ z: 'z-index', transform: 'transform', content: 'content',
21
+ resize: 'resize', appearance: 'appearance',
22
+ 'accent-color': 'accent-color', 'object-fit': 'object-fit',
23
+ direction: 'flex-direction', wrap: 'flex-wrap',
24
+ align: 'align-items', justify: 'justify-content',
25
+ 'align-self': 'align-self', 'justify-self': 'justify-self',
26
+ grow: 'flex-grow', shrink: 'flex-shrink', basis: 'flex-basis',
27
+ order: 'order', cols: 'grid-template-columns', rows: 'grid-template-rows',
28
+ 'col-span': 'grid-column', 'row-span': 'grid-row',
29
+ 'place-items': 'place-items', 'place-content': 'place-content',
30
+ 'list-style': 'list-style', 'text-align': 'text-align',
31
+ decoration: 'text-decoration', 'text-transform': 'text-transform',
32
+ 'white-space': 'white-space', 'word-break': 'word-break',
33
+ 'user-select': 'user-select', 'pointer-events': 'pointer-events',
34
+ 'vertical-align': 'vertical-align', 'backdrop': 'backdrop-filter',
35
+ filter: 'filter', clip: 'clip-path', animation: 'animation',
36
+ position: 'position', top: 'top', right: 'right', bottom: 'bottom',
37
+ left: 'left', inset: 'inset', color: 'color', background: 'background',
38
+ };
39
+
40
+ // ── Boolean shorthands (no value needed) ─────────────────────────────────────
41
+ const CSS_BOOL_MAP = {
42
+ flex: 'display: flex', grid: 'display: grid',
43
+ block: 'display: block', inline: 'display: inline',
44
+ 'inline-flex': 'display: inline-flex', 'inline-block': 'display: inline-block',
45
+ 'inline-grid': 'display: inline-grid',
46
+ bold: 'font-weight: 700', italic: 'font-style: italic',
47
+ relative: 'position: relative', absolute: 'position: absolute',
48
+ fixed: 'position: fixed', sticky: 'position: sticky',
49
+ hidden: 'display: none', pointer: 'cursor: pointer',
50
+ underline: 'text-decoration: underline',
51
+ 'line-through': 'text-decoration: line-through',
52
+ capitalize: 'text-transform: capitalize',
53
+ uppercase: 'text-transform: uppercase',
54
+ lowercase: 'text-transform: lowercase',
55
+ truncate: 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap',
56
+ 'select-none': 'user-select: none',
57
+ 'no-wrap': 'white-space: nowrap',
58
+ 'no-list': 'list-style: none',
59
+ 'no-outline': 'outline: none',
60
+ 'no-border': 'border: none',
61
+ 'no-bg': 'background: transparent',
62
+ 'no-padding': 'padding: 0',
63
+ 'no-margin': 'margin: 0',
64
+ 'no-resize': 'resize: none',
65
+ center: 'text-align: center',
66
+ 'items-center': 'align-items: center',
67
+ 'justify-center': 'justify-content: center',
68
+ 'place-center': 'place-items: center',
69
+ 'flex-col': 'flex-direction: column',
70
+ 'flex-row': 'flex-direction: row',
71
+ 'flex-wrap': 'flex-wrap: wrap',
72
+ 'flex-1': 'flex: 1',
73
+ 'w-full': 'width: 100%',
74
+ 'h-full': 'height: 100%',
75
+ 'w-screen': 'width: 100vw',
76
+ 'h-screen': 'height: 100vh',
77
+ 'box-border': 'box-sizing: border-box',
78
+ };
79
+
80
+ function expandProp(name) {
81
+ const t = name.trim();
82
+ return CSS_PROP_MAP[t] || t;
83
+ }
84
+
85
+ function stripQuotes(val) {
86
+ const v = val.trim();
87
+ if (v.length >= 2 &&
88
+ ((v[0] === '"' && v[v.length - 1] === '"') ||
89
+ (v[0] === "'" && v[v.length - 1] === "'"))) {
90
+ return v.slice(1, -1);
91
+ }
92
+ return v;
93
+ }
94
+
95
+ // Resolve a child selector against its parent
96
+ function resolveSelector(sel, parent) {
97
+ sel = sel.trim();
98
+ if (!parent) return sel;
99
+ // At-rules and @keyframe frames inside @keyframes don't get prefixed
100
+ if (sel.startsWith('@')) return sel;
101
+ if (parent.startsWith('@keyframes') || parent.startsWith('@font-face')) return sel;
102
+ if (sel.includes('&')) return sel.replace(/&/g, parent);
103
+ return parent + ' ' + sel;
104
+ }
105
+
106
+ // Expand a single declaration string (possibly multiple semicolon-separated)
107
+ function expandDecls(text) {
108
+ const result = [];
109
+ text.split(';').forEach(part => {
110
+ const d = part.trim();
111
+ if (!d || d.startsWith('//')) return;
112
+ if (CSS_BOOL_MAP[d]) {
113
+ CSS_BOOL_MAP[d].split(';').forEach(bd => { const t = bd.trim(); if (t) result.push(t); });
114
+ return;
115
+ }
116
+ const ci = d.indexOf(':');
117
+ if (ci !== -1) {
118
+ const prop = expandProp(d.slice(0, ci));
119
+ const val = stripQuotes(d.slice(ci + 1));
120
+ if (val !== '') result.push(prop + ': ' + val);
121
+ }
122
+ });
123
+ return result;
124
+ }
125
+
126
+ // ── Parse CSS block content into a flat CSS string ────────────────────────────
127
+ // Supports: nested selectors, &:pseudo, @keyframes, @media, boolean shorthands
128
+ function parseCssBlockContent(content, indentLevel) {
129
+ const ind = ' '.repeat(indentLevel || 0);
130
+ const ind1 = ind + ' ';
131
+ const lines = content.split('\n');
132
+
133
+ // We output in two passes:
134
+ // 1. Regular flattened rules (selector → [decls]) in a Map (preserves order)
135
+ // 2. At-rule blocks (@keyframes, @media, etc.) collected separately
136
+
137
+ const flatRules = new Map(); // selector → string[]
138
+ const atRuleSegs = []; // raw at-rule CSS strings (output after flat rules)
139
+ const selStack = [];
140
+
141
+ function curSel() { return selStack.length ? selStack[selStack.length - 1] : null; }
142
+
143
+ function addDecl(sel, d) {
144
+ if (!flatRules.has(sel)) flatRules.set(sel, []);
145
+ flatRules.get(sel).push(d);
146
+ }
147
+
148
+ function addDeclarationLine(line, sel) {
149
+ // Support multiple declarations on one line: "p: 10px; m: 0; bold"
150
+ if (line.includes(';')) {
151
+ line.split(';').forEach(part => {
152
+ const t = part.trim();
153
+ if (t) addDeclarationLine(t, sel);
154
+ });
155
+ return;
156
+ }
157
+ // Boolean shorthand?
158
+ if (CSS_BOOL_MAP[line]) {
159
+ CSS_BOOL_MAP[line].split(';').forEach(bd => { const t = bd.trim(); if (t) addDecl(sel, t); });
160
+ return;
161
+ }
162
+ const ci = line.indexOf(':');
163
+ if (ci !== -1) {
164
+ const prop = expandProp(line.slice(0, ci));
165
+ const val = stripQuotes(line.slice(ci + 1));
166
+ if (val !== '') addDecl(sel, prop + ': ' + val);
167
+ }
168
+ }
169
+
170
+ // ── Collect a balanced brace block from `lines` starting at index i ──────
171
+ // Returns { inner: string, nextI: number }
172
+ function collectBlock(startI) {
173
+ let depth = 1;
174
+ let inner = '';
175
+ let j = startI;
176
+ while (j < lines.length && depth > 0) {
177
+ const ln = lines[j++];
178
+ for (const ch of ln) {
179
+ if (ch === '{') depth++;
180
+ else if (ch === '}') depth--;
181
+ }
182
+ if (depth > 0) inner += ln + '\n';
183
+ else {
184
+ // Last line: take content before final }
185
+ const ci = ln.lastIndexOf('}');
186
+ if (ci > 0) inner += ln.slice(0, ci) + '\n';
187
+ }
188
+ }
189
+ return { inner, nextI: j };
190
+ }
191
+
192
+ let i = 0;
193
+ while (i < lines.length) {
194
+ const raw = lines[i++];
195
+ const line = raw.trim();
196
+ if (!line || line.startsWith('//')) continue;
197
+
198
+ const ob = line.indexOf('{');
199
+ const cb = line.lastIndexOf('}');
200
+
201
+ // ── @keyframes / @font-face — emit as nested block (not flattened) ──────
202
+ if (line.startsWith('@keyframes') || line.startsWith('@font-face')) {
203
+ const atSel = ob !== -1 ? line.slice(0, ob).trim() : line.trim();
204
+ let atBody = '';
205
+
206
+ if (ob !== -1 && cb !== -1 && cb > ob) {
207
+ // Inline: @keyframes name { from { ... } to { ... } }
208
+ atBody = line.slice(ob + 1, cb);
209
+ } else if (ob !== -1) {
210
+ const res = collectBlock(i);
211
+ i = res.nextI;
212
+ atBody = res.inner;
213
+ }
214
+
215
+ // Expand property shorthands inside keyframe body (simple line pass)
216
+ const expandedBody = atBody.split('\n').map(ln => {
217
+ const t = ln.trim();
218
+ if (!t || t === '{' || t === '}') return ln;
219
+ const colonIdx = t.indexOf(':');
220
+ if (colonIdx !== -1 && !t.slice(0, colonIdx).includes('{')) {
221
+ const prop = expandProp(t.slice(0, colonIdx));
222
+ const val = stripQuotes(t.slice(colonIdx + 1));
223
+ return ln.replace(t, prop + ': ' + val);
224
+ }
225
+ return ln;
226
+ }).join('\n');
227
+
228
+ atRuleSegs.push(`${ind}${atSel} {\n${expandedBody}${ind}}\n`);
229
+ continue;
230
+ }
231
+
232
+ // ── @media / @supports — emit as nested block, process inner rules ───────
233
+ if (line.startsWith('@media') || line.startsWith('@supports') || line.startsWith('@layer')) {
234
+ const atSel = ob !== -1 ? line.slice(0, ob).trim() : line.trim();
235
+ let atBody = '';
236
+
237
+ if (ob !== -1 && cb !== -1 && cb > ob) {
238
+ atBody = line.slice(ob + 1, cb);
239
+ } else if (ob !== -1) {
240
+ const res = collectBlock(i);
241
+ i = res.nextI;
242
+ atBody = res.inner;
243
+ }
244
+
245
+ const innerCss = parseCssBlockContent(atBody, (indentLevel || 0) + 1);
246
+ atRuleSegs.push(`${ind}${atSel} {\n${innerCss}${ind}}\n`);
247
+ continue;
248
+ }
249
+
250
+ // ── Inline rule: selector { decl; decl } on a single line ──────────────
251
+ if (ob !== -1 && cb !== -1 && cb > ob) {
252
+ const sel = line.slice(0, ob).trim();
253
+ const inner = line.slice(ob + 1, cb).trim();
254
+ const full = resolveSelector(sel, curSel());
255
+ if (!flatRules.has(full)) flatRules.set(full, []);
256
+ if (inner) expandDecls(inner).forEach(d => addDecl(full, d));
257
+ continue;
258
+ }
259
+
260
+ // ── Block opener: selector { ─────────────────────────────────────────────
261
+ if (ob !== -1) {
262
+ const sel = line.slice(0, ob).trim();
263
+ const full = resolveSelector(sel, curSel());
264
+ selStack.push(full);
265
+ if (!flatRules.has(full)) flatRules.set(full, []);
266
+ // Handle any declarations on the same line after {
267
+ const tail = line.slice(ob + 1).trim();
268
+ if (tail) expandDecls(tail).forEach(d => addDecl(full, d));
269
+ continue;
270
+ }
271
+
272
+ // ── Closing brace ────────────────────────────────────────────────────────
273
+ if (line === '}' || line === '};') {
274
+ selStack.pop();
275
+ continue;
276
+ }
277
+
278
+ // ── Declaration or boolean shorthand inside a rule block ─────────────────
279
+ if (selStack.length > 0) {
280
+ addDeclarationLine(line, curSel());
281
+ }
282
+ }
283
+
284
+ // ── Emit flat rules ───────────────────────────────────────────────────────
285
+ let css = '';
286
+ for (const [sel, decls] of flatRules) {
287
+ if (decls.length === 0) continue;
288
+ css += `${ind}${sel} {\n`;
289
+ for (const d of decls) css += `${ind1}${d};\n`;
290
+ css += `${ind}}\n`;
291
+ }
292
+ // ── Emit at-rule blocks (after flat rules) ────────────────────────────────
293
+ for (const seg of atRuleSegs) css += seg;
294
+
295
+ return css;
296
+ }
297
+
298
+ // ── Main source preprocessor: replace css { } with CSS string ──────────────
299
+ class CssPreprocessor {
300
+ constructor(src) {
301
+ this.src = src;
302
+ this.pos = 0;
303
+ this.out = '';
304
+ }
305
+
306
+ transform() {
307
+ while (this.pos < this.src.length) {
308
+ this.scan();
309
+ }
310
+ return this.out;
311
+ }
312
+
313
+ scan() {
314
+ const c = this.src[this.pos];
315
+
316
+ // Line comment — pass through unchanged
317
+ if (c === '/' && this.src[this.pos + 1] === '/') {
318
+ const end = this.src.indexOf('\n', this.pos);
319
+ if (end === -1) { this.out += this.src.slice(this.pos); this.pos = this.src.length; }
320
+ else { this.out += this.src.slice(this.pos, end + 1); this.pos = end + 1; }
321
+ return;
322
+ }
323
+
324
+ // Block comment — pass through
325
+ if (c === '/' && this.src[this.pos + 1] === '*') {
326
+ const end = this.src.indexOf('*/', this.pos + 2);
327
+ if (end === -1) { this.out += this.src.slice(this.pos); this.pos = this.src.length; }
328
+ else { this.out += this.src.slice(this.pos, end + 2); this.pos = end + 2; }
329
+ return;
330
+ }
331
+
332
+ // String literals — pass through
333
+ if (c === '"' || c === "'" || c === '`') {
334
+ this.passString(c);
335
+ return;
336
+ }
337
+
338
+ // Detect standalone css keyword followed (across whitespace) by {
339
+ if (c === 'c' && this.src.slice(this.pos, this.pos + 3) === 'css') {
340
+ const charBefore = this.pos > 0 ? this.src[this.pos - 1] : '\n';
341
+ const charAfter = this.src[this.pos + 3] || '';
342
+ // Must not be part of a longer identifier
343
+ if (!/[a-zA-Z0-9_]/.test(charBefore) && !/[a-zA-Z0-9_]/.test(charAfter)) {
344
+ let j = this.pos + 3;
345
+ // Skip whitespace/newlines looking for {
346
+ while (j < this.src.length &&
347
+ (this.src[j] === ' ' || this.src[j] === '\t' ||
348
+ this.src[j] === '\n' || this.src[j] === '\r')) j++;
349
+
350
+ if (this.src[j] === '{') {
351
+ j++; // consume opening {
352
+ let depth = 1;
353
+ let blockStart = j;
354
+
355
+ // Find matching closing }
356
+ while (j < this.src.length && depth > 0) {
357
+ const ch = this.src[j];
358
+ if (ch === '"' || ch === "'" || ch === '`') {
359
+ const q = ch; j++;
360
+ while (j < this.src.length && this.src[j] !== q) {
361
+ if (this.src[j] === '\\') j++;
362
+ j++;
363
+ }
364
+ j++;
365
+ } else if (ch === '{') { depth++; j++; }
366
+ else if (ch === '}') { depth--; j++; }
367
+ else j++;
368
+ }
369
+
370
+ const blockContent = this.src.slice(blockStart, j - 1);
371
+ const css = parseCssBlockContent(blockContent);
372
+ // Emit as backtick string (Flux raw multiline string, no interpolation)
373
+ this.out += '`' + css.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`';
374
+ this.pos = j;
375
+ return;
376
+ }
377
+ }
378
+ }
379
+
380
+ this.out += c;
381
+ this.pos++;
382
+ }
383
+
384
+ passString(quote) {
385
+ this.out += quote; this.pos++;
386
+ while (this.pos < this.src.length) {
387
+ const c = this.src[this.pos];
388
+ if (c === '\\') { this.out += c + (this.src[this.pos + 1] || ''); this.pos += 2; continue; }
389
+ if (c === quote) { this.out += c; this.pos++; return; }
390
+ this.out += c; this.pos++;
391
+ }
392
+ }
393
+ }
394
+
395
+ function transformCss(src) {
396
+ return new CssPreprocessor(src).transform();
397
+ }
398
+
399
+ module.exports = { transformCss, parseCssBlockContent, CSS_PROP_MAP, CSS_BOOL_MAP };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ // ── Flux Code Formatter ───────────────────────────────────────────────────────
4
+ // Normalizes Flux source code:
5
+ // • 4-space indentation
6
+ // • Strips trailing whitespace
7
+ // • Max 1 consecutive blank line
8
+ // • Blank line after top-level fn/class/type declarations
9
+ // • Spacing around binary operators
10
+ // • Consistent colon / arrow spacing
11
+
12
+ function format(source) {
13
+ const lines = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
14
+
15
+ // ── 1. Detect indentation unit from the first indented line ─────────────
16
+ let indentUnit = 4;
17
+ for (const line of lines) {
18
+ const m = line.match(/^( +)\S/);
19
+ if (m) {
20
+ const w = m[1].length;
21
+ if (w > 0 && w < indentUnit) indentUnit = w;
22
+ break;
23
+ }
24
+ }
25
+ if (indentUnit < 1) indentUnit = 4;
26
+
27
+ // ── 2. Strip trailing whitespace + normalize indent to 4 spaces ─────────
28
+ const normalized = lines.map(line => {
29
+ const stripped = line.trimEnd();
30
+ if (!stripped) return '';
31
+ const m = stripped.match(/^( *)/);
32
+ const spaces = m ? m[1].length : 0;
33
+ const level = Math.round(spaces / indentUnit);
34
+ const content = stripped.trimStart();
35
+
36
+ // Normalize spacing around operators in the content (only when not a string literal line)
37
+ const formatted = content.startsWith('//') || content.startsWith('*')
38
+ ? content
39
+ : normalizeOperators(content);
40
+
41
+ return ' '.repeat(level) + formatted;
42
+ });
43
+
44
+ // ── 3. Collapse multiple blank lines → max 1 ────────────────────────────
45
+ const collapsed = [];
46
+ let blankRun = 0;
47
+ for (const line of normalized) {
48
+ if (line === '') {
49
+ blankRun++;
50
+ if (blankRun <= 1) collapsed.push('');
51
+ } else {
52
+ blankRun = 0;
53
+ collapsed.push(line);
54
+ }
55
+ }
56
+
57
+ // ── 4. Add blank line after top-level fn/class/type/async fn blocks ──────
58
+ const withSpacing = [];
59
+ for (let i = 0; i < collapsed.length; i++) {
60
+ withSpacing.push(collapsed[i]);
61
+ const cur = collapsed[i].trimStart();
62
+ const next = (collapsed[i + 1] || '').trimStart();
63
+ const indent = collapsed[i].match(/^( *)/)[1].length;
64
+
65
+ // After dedented fn/class/type at indent=0 or indent=4, add blank line
66
+ if (indent <= 4 && /^(fn |async fn |class |type )/.test(cur)) {
67
+ // No blank needed if next line is blank already
68
+ if (next !== '' && !/^(fn |async fn |class |type )/.test(next)) {
69
+ // skip — next content comes right after declaration line, part of fn
70
+ }
71
+ }
72
+
73
+ // After closing a fn/class block at top level (no indent dedent signal,
74
+ // but we detect when a non-indented line follows an indented block)
75
+ if (i > 0) {
76
+ const prevIndent = collapsed[i - 1].match(/^( *)/)[1].length;
77
+ if (prevIndent >= 4 && indent === 0 && cur !== '') {
78
+ // We just came back to top-level — add blank if last added wasn't blank
79
+ if (withSpacing[withSpacing.length - 2] !== '') {
80
+ withSpacing.splice(withSpacing.length - 1, 0, '');
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // ── 5. Trim leading/trailing blank lines + ensure single trailing newline ─
87
+ let out = withSpacing;
88
+ while (out.length > 0 && out[0] === '') out.shift();
89
+ while (out.length > 0 && out[out.length - 1] === '') out.pop();
90
+ out.push('');
91
+
92
+ return out.join('\n');
93
+ }
94
+
95
+ // ── Operator spacing normalizer ──────────────────────────────────────────────
96
+ // Ensures spaces around binary operators without touching strings/comments
97
+ function normalizeOperators(line) {
98
+ // Skip lines that look like CSS / strings (heuristic: too many : or { })
99
+ if ((line.match(/:/g) || []).length > 2) return line;
100
+
101
+ let result = line;
102
+
103
+ // Add space around = (but not ==, !=, <=, >=, ->, =>, +=, -=, *=, /=, %=)
104
+ result = result.replace(/([^=!<>+\-*/%])=(?!=|>)/g, '$1 = ');
105
+ result = result.replace(/\s{2,}=/g, ' =');
106
+
107
+ // Normalize multiple spaces inside (but not at start = indentation)
108
+ result = result.replace(/([^\s]) +/g, '$1 ');
109
+
110
+ // Ensure space after comma
111
+ result = result.replace(/,(?!\s)/g, ', ');
112
+
113
+ // Ensure space before/after -> and =>
114
+ result = result.replace(/\s*->\s*/g, ' -> ');
115
+ result = result.replace(/\s*=>\s*/g, ' => ');
116
+
117
+ // Trim end
118
+ result = result.trimEnd();
119
+
120
+ return result;
121
+ }
122
+
123
+ // ── Diff output ──────────────────────────────────────────────────────────────
124
+ function diff(original, formatted) {
125
+ const origLines = original.split('\n');
126
+ const fmtLines = formatted.split('\n');
127
+ const changes = [];
128
+
129
+ const maxLen = Math.max(origLines.length, fmtLines.length);
130
+ for (let i = 0; i < maxLen; i++) {
131
+ const o = origLines[i];
132
+ const f = fmtLines[i];
133
+ if (o !== f) {
134
+ changes.push({ line: i + 1, original: o, formatted: f });
135
+ }
136
+ }
137
+ return changes;
138
+ }
139
+
140
+ module.exports = { format, diff };