kopytko-formatter 0.1.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,28 +46,54 @@ function formatText(source, config, casing = casing_1.DEFAULT_CASING_CONFIG, use
46
46
  // Pass 3 — End keyword style + function vs sub
47
47
  lines = passEndKeywordStyle(lines, config);
48
48
  lines = passFunctionVsSub(lines, config);
49
+ // Pass 3b — Return type annotations
50
+ lines = passReturnTypeAnnotations(lines, config);
51
+ // Pass 3c — Param type annotations
52
+ lines = passParamTypeAnnotations(lines, config);
49
53
  // Pass 4 — Then style + parenthesis if case + catch paren style
50
54
  lines = passThenStyle(lines, config);
51
55
  lines = passParenthesisIfCase(lines, config);
52
56
  lines = passCatchParenStyle(lines, config);
57
+ // Pass 4c — Else on new line
58
+ lines = passElseOnNewLine(lines, config);
53
59
  // Pass 5 — Print statement handling
54
60
  lines = passPrintStatement(lines, config);
61
+ // Pass 5b — Line comment position
62
+ lines = passLineCommentPosition(lines, config);
55
63
  // Pass 6 — Spacing rules
56
64
  lines = passSpacing(lines, config);
65
+ // Pass 6b — Wrap long strings
66
+ lines = passWrapLongStrings(lines, config);
67
+ // Pass 6c — String concatenation style
68
+ lines = passStringConcatStyle(lines, config);
57
69
  // Pass 7 — Casing
58
70
  lines = passCasing(lines, casing, userFuncMap);
59
71
  // Pass 7b — Split array open bracket
60
72
  lines = passSplitArrayOpenBracket(lines, config);
73
+ // Pass 7c — Associative array single-line threshold
74
+ lines = passAAThreshold(lines, config);
61
75
  // Pass 8 — Indentation
62
76
  lines = passIndentation(lines, config);
63
77
  // Pass 8b — Trailing commas
64
78
  lines = passTrailingCommas(lines, config);
79
+ // Pass 8c — Align assignments
80
+ lines = passAlignAssignments(lines, config);
81
+ // Pass 8d — Multi-line param alignment
82
+ lines = passParamAlignment(lines, config);
65
83
  // Pass 9 — Blank line rules
66
84
  lines = passBlankLines(lines, config);
85
+ // Pass 9b — Empty lines between methods
86
+ lines = passEmptyLinesBetweenMethods(lines, config);
67
87
  // Pass 10 — Trailing whitespace
68
88
  lines = passTrimTrailing(lines, config);
69
89
  // Pass 11 — Comment width
70
90
  lines = passCommentWidth(lines, config);
91
+ // Pass 12 — observeField style
92
+ lines = passObserveFieldStyle(lines, config);
93
+ // Pass 13 — m prefix style
94
+ lines = passMPrefixStyle(lines, config);
95
+ // Pass 14 — Field access consistency
96
+ lines = passFieldAccessConsistency(lines, config);
71
97
  // Assemble result
72
98
  let newText = lines.join(lineEndStr);
73
99
  if (config.insertFinalNewline && newText.length > 0 && !newText.endsWith(lineEndStr)) {
@@ -345,6 +371,118 @@ function findMatchingEnd(lines, startIdx) {
345
371
  return -1;
346
372
  }
347
373
  // ---------------------------------------------------------------------------
374
+ // Pass 3b — Return type annotations
375
+ // ---------------------------------------------------------------------------
376
+ function passReturnTypeAnnotations(lines, config) {
377
+ if (config.returnTypeAnnotations === 'preserve')
378
+ return lines;
379
+ const result = [...lines];
380
+ // Matches: optional whitespace, `function`, name, `(params)`, optional `as Type`, optional comment
381
+ const declRegex = /^(\s*function\s+\w+\s*\([^)]*\))(\s+as\s+\w+)?(\s*(?:'.*)?)?$/i;
382
+ for (let i = 0; i < result.length; i++) {
383
+ const m = declRegex.exec(result[i]);
384
+ if (!m)
385
+ continue;
386
+ const [, before, asClause, trailing] = m;
387
+ const comment = trailing ?? '';
388
+ if (config.returnTypeAnnotations === 'always') {
389
+ if (!asClause) {
390
+ result[i] = before + ' as Dynamic' + comment;
391
+ }
392
+ }
393
+ else {
394
+ // 'never' — remove the as Type clause
395
+ if (asClause) {
396
+ result[i] = before + comment;
397
+ }
398
+ }
399
+ }
400
+ return result;
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Pass 3c — Param type annotations
404
+ // ---------------------------------------------------------------------------
405
+ function passParamTypeAnnotations(lines, config) {
406
+ if (config.paramTypeAnnotations === 'preserve')
407
+ return lines;
408
+ const result = [...lines];
409
+ // Match function/sub declaration lines (single-line params)
410
+ const declRegex = /^(\s*(?:function|sub)\s+\w*\s*)\(([^)]*)\)(.*)$/i;
411
+ for (let i = 0; i < result.length; i++) {
412
+ const m = declRegex.exec(result[i]);
413
+ if (!m)
414
+ continue;
415
+ const [, prefix, paramStr, suffix] = m;
416
+ if (paramStr.trim() === '')
417
+ continue;
418
+ const newParams = transformParams(paramStr, config.paramTypeAnnotations);
419
+ result[i] = prefix + '(' + newParams + ')' + suffix;
420
+ }
421
+ // Handle multi-line params: continuation lines between `(` and `)`
422
+ for (let i = 0; i < result.length; i++) {
423
+ const openMatch = /^(\s*(?:function|sub)\s+\w*\s*)\([^)]*$/i.exec(result[i]);
424
+ if (!openMatch)
425
+ continue;
426
+ // Transform params in the opening line after `(`
427
+ const parenIdx = result[i].indexOf('(');
428
+ const afterParen = result[i].substring(parenIdx + 1);
429
+ if (afterParen.trim() !== '') {
430
+ result[i] = result[i].substring(0, parenIdx + 1) + transformParams(afterParen, config.paramTypeAnnotations);
431
+ }
432
+ // Transform continuation lines
433
+ for (let j = i + 1; j < result.length; j++) {
434
+ const line = result[j];
435
+ const closeIdx = line.indexOf(')');
436
+ if (closeIdx >= 0) {
437
+ // Line with closing paren — transform params before `)`
438
+ const beforeClose = line.substring(0, closeIdx);
439
+ if (beforeClose.trim() !== '') {
440
+ result[j] = transformParams(beforeClose, config.paramTypeAnnotations) + line.substring(closeIdx);
441
+ }
442
+ break;
443
+ }
444
+ // Pure continuation line — transform entire line preserving indent
445
+ const indentMatch = line.match(/^(\s*)/);
446
+ const indent = indentMatch ? indentMatch[1] : '';
447
+ const content = line.trim();
448
+ if (content !== '') {
449
+ result[j] = indent + transformParams(content, config.paramTypeAnnotations);
450
+ }
451
+ }
452
+ }
453
+ return result;
454
+ }
455
+ function transformParams(paramStr, mode) {
456
+ // Split on commas (respecting that param names/types don't contain commas)
457
+ const parts = paramStr.split(',');
458
+ const transformed = parts.map(part => {
459
+ const trimmed = part.trim();
460
+ if (trimmed === '')
461
+ return part;
462
+ // Pattern: name [as Type] [= default]
463
+ const paramRegex = /^(\w+)(\s+as\s+\w+)?(\s*=\s*.*)?$/i;
464
+ const m = paramRegex.exec(trimmed);
465
+ if (!m)
466
+ return part;
467
+ const [, name, asClause, defaultVal] = m;
468
+ const preservedLeading = part.match(/^(\s*)/)?.[1] ?? '';
469
+ const trailingCommaSpace = part.match(/(\s*)$/)?.[1] ?? '';
470
+ if (mode === 'always') {
471
+ if (!asClause) {
472
+ return preservedLeading + name + ' as Dynamic' + (defaultVal ?? '') + trailingCommaSpace;
473
+ }
474
+ }
475
+ else {
476
+ // 'never' — remove as Type
477
+ if (asClause) {
478
+ return preservedLeading + name + (defaultVal ?? '') + trailingCommaSpace;
479
+ }
480
+ }
481
+ return part;
482
+ });
483
+ return transformed.join(',');
484
+ }
485
+ // ---------------------------------------------------------------------------
348
486
  // Pass 4 — Then style
349
487
  // ---------------------------------------------------------------------------
350
488
  function passThenStyle(lines, config) {
@@ -487,6 +625,53 @@ function splitTrailingComment(s) {
487
625
  return { code: s, comment: '' };
488
626
  }
489
627
  // ---------------------------------------------------------------------------
628
+ // Pass 4c — Else on new line
629
+ // ---------------------------------------------------------------------------
630
+ function passElseOnNewLine(lines, config) {
631
+ // true = keep else on its own line (default, no-op)
632
+ if (config.elseOnNewLine)
633
+ return lines;
634
+ const result = [];
635
+ let i = 0;
636
+ while (i < lines.length) {
637
+ const trimmed = lines[i].trim();
638
+ // Only match `if ...` (not `else if`)
639
+ if (/^if\b/i.test(trimmed) && i + 4 < lines.length) {
640
+ const { code, comment: ifComment } = splitTrailingComment(trimmed);
641
+ const codeClean = code.trimEnd();
642
+ const endsWithThen = /\bthen\s*$/i.test(codeClean);
643
+ const hasInlineThen = /\bthen\b/i.test(codeClean) && !endsWithThen;
644
+ // Multi-line if: ends with `then` or has no `then` at all (no code after then)
645
+ if (!hasInlineThen && !ifComment) {
646
+ const thenStmt = lines[i + 1].trim();
647
+ const elseLine = lines[i + 2].trim();
648
+ const elseStmt = lines[i + 3].trim();
649
+ const endIfLine = lines[i + 4].trim();
650
+ const isSimpleStmt = (s) => {
651
+ if (s === '' || s.startsWith("'") || /^rem\b/i.test(s))
652
+ return false;
653
+ return !splitTrailingComment(s).comment;
654
+ };
655
+ if (isSimpleStmt(thenStmt) &&
656
+ /^else\s*$/i.test(elseLine) &&
657
+ isSimpleStmt(elseStmt) &&
658
+ /^(?:end\s*if|endif)\s*$/i.test(endIfLine)) {
659
+ const indent = lines[i].match(/^(\s*)/)?.[1] ?? '';
660
+ let condPart = codeClean;
661
+ if (!endsWithThen)
662
+ condPart += ' then';
663
+ result.push(indent + condPart + ' ' + thenStmt + ' else ' + elseStmt);
664
+ i += 5;
665
+ continue;
666
+ }
667
+ }
668
+ }
669
+ result.push(lines[i]);
670
+ i++;
671
+ }
672
+ return result;
673
+ }
674
+ // ---------------------------------------------------------------------------
490
675
  // Pass 5 — Print statement handling
491
676
  // ---------------------------------------------------------------------------
492
677
  function passPrintStatement(lines, config) {
@@ -495,6 +680,32 @@ function passPrintStatement(lines, config) {
495
680
  return lines.filter(line => !/^\s*(?:print|\?)\b/i.test(line));
496
681
  }
497
682
  // ---------------------------------------------------------------------------
683
+ // Pass 5b — Line comment position
684
+ // ---------------------------------------------------------------------------
685
+ function passLineCommentPosition(lines, config) {
686
+ if (config.lineCommentPosition !== 'above')
687
+ return lines;
688
+ const result = [];
689
+ for (const line of lines) {
690
+ const trimmed = line.trim();
691
+ // Skip pure comment lines and blank lines
692
+ if (trimmed === '' || trimmed.startsWith("'") || /^rem\b/i.test(trimmed)) {
693
+ result.push(line);
694
+ continue;
695
+ }
696
+ const { code, comment } = splitTrailingComment(trimmed);
697
+ if (!comment) {
698
+ result.push(line);
699
+ continue;
700
+ }
701
+ // Move trailing comment to the line above, preserving indentation
702
+ const indent = line.match(/^(\s*)/)?.[1] ?? '';
703
+ result.push(indent + comment);
704
+ result.push(indent + code);
705
+ }
706
+ return result;
707
+ }
708
+ // ---------------------------------------------------------------------------
498
709
  // Pass 6 — Spacing rules
499
710
  // ---------------------------------------------------------------------------
500
711
  function passSpacing(lines, config) {
@@ -586,7 +797,7 @@ function applySpacingToCode(code, config, fullLine) {
586
797
  return r;
587
798
  }
588
799
  /**
589
- * Applies `bracketSpacing` and `aaCommaSpacing` to a fully-assembled line
800
+ * Applies `associativeArrayBracketSpacing` and `associativeArrayCommaSpacing` to a fully-assembled line
590
801
  * (code + string-literal segments joined together).
591
802
  *
592
803
  * This runs after all code segments are assembled so that the rules work
@@ -596,8 +807,8 @@ function applySpacingToCode(code, config, fullLine) {
596
807
  * String literal contents are never modified.
597
808
  */
598
809
  function applyBracketAndCommaSpacing(line, config) {
599
- const addBracket = config.bracketSpacing;
600
- const commaMode = config.aaCommaSpacing ?? 'preserve';
810
+ const addBracket = config.associativeArrayBracketSpacing;
811
+ const commaMode = config.associativeArrayCommaSpacing ?? 'preserve';
601
812
  // Fast path: nothing to process if the line has no braces at all
602
813
  if (!line.includes('{') && !line.includes('}'))
603
814
  return line;
@@ -726,7 +937,7 @@ function passCasing(lines, casing, userFuncMap) {
726
937
  // Pass 7b — Split array open bracket
727
938
  // ---------------------------------------------------------------------------
728
939
  function passSplitArrayOpenBracket(lines, config) {
729
- if (!config.splitArrayOpenBracket)
940
+ if (!config.arraySplitOpenBracket)
730
941
  return lines;
731
942
  const result = [];
732
943
  for (let i = 0; i < lines.length; i++) {
@@ -774,7 +985,7 @@ function passTrailingCommas(lines, config) {
774
985
  const closerChar = trimmed[0];
775
986
  const isArray = closerChar === ']';
776
987
  const isAA = closerChar === '}';
777
- const itemStyle = isArray ? config.arrayCommaStyle : isAA ? config.assocArrayCommaStyle : 'preserve';
988
+ const itemStyle = isArray ? config.arrayCommaStyle : isAA ? config.associativeArrayCommaStyle : 'preserve';
778
989
  const openerChar = isArray ? '[' : '{';
779
990
  let depth = 0;
780
991
  for (let j = i; j >= 0; j--) {
@@ -935,6 +1146,108 @@ function passIndentation(lines, config) {
935
1146
  });
936
1147
  }
937
1148
  // ---------------------------------------------------------------------------
1149
+ // Pass 8c — Align assignments
1150
+ // ---------------------------------------------------------------------------
1151
+ function passAlignAssignments(lines, config) {
1152
+ if (!config.alignAssignments)
1153
+ return lines;
1154
+ const result = [];
1155
+ let group = [];
1156
+ let containerDepth = 0;
1157
+ const flushGroup = () => {
1158
+ if (group.length <= 1) {
1159
+ for (const g of group)
1160
+ result.push(lines[g.idx]);
1161
+ }
1162
+ else {
1163
+ const maxLen = Math.max(...group.map(g => g.before.length));
1164
+ for (const g of group) {
1165
+ const padding = ' '.repeat(maxLen - g.before.length);
1166
+ result.push(g.indent + g.before + padding + ' = ' + g.after);
1167
+ }
1168
+ }
1169
+ group = [];
1170
+ };
1171
+ for (let i = 0; i < lines.length; i++) {
1172
+ const trimmed = lines[i].trim();
1173
+ if (trimmed === '') {
1174
+ flushGroup();
1175
+ result.push(lines[i]);
1176
+ continue;
1177
+ }
1178
+ if (trimmed.startsWith("'") || /^rem\b/i.test(trimmed)) {
1179
+ flushGroup();
1180
+ result.push(lines[i]);
1181
+ continue;
1182
+ }
1183
+ const prevDepth = containerDepth;
1184
+ containerDepth += netContainerDepth(trimmed);
1185
+ // Inside a multi-line container — not an independent assignment
1186
+ if (prevDepth > 0) {
1187
+ flushGroup();
1188
+ result.push(lines[i]);
1189
+ continue;
1190
+ }
1191
+ const assignInfo = findSimpleAssignment(trimmed);
1192
+ if (!assignInfo) {
1193
+ flushGroup();
1194
+ result.push(lines[i]);
1195
+ continue;
1196
+ }
1197
+ const indent = lines[i].match(/^(\s*)/)?.[1] ?? '';
1198
+ group.push({ idx: i, before: assignInfo.before, after: assignInfo.after, indent });
1199
+ }
1200
+ flushGroup();
1201
+ return result;
1202
+ }
1203
+ /** Find a simple `=` assignment in a trimmed line, returning the parts before and after `=`. */
1204
+ function findSimpleAssignment(trimmed) {
1205
+ if (!/^[a-zA-Z_]/.test(trimmed))
1206
+ return null;
1207
+ if (/^(?:if|else|elseif|for|while|end|return|function|sub|print|next|try|catch|throw|exit|stop|dim|goto)\b/i.test(trimmed))
1208
+ return null;
1209
+ let inStr = false;
1210
+ let bracketDepth = 0;
1211
+ let eqPos = -1;
1212
+ for (let i = 0; i < trimmed.length; i++) {
1213
+ const ch = trimmed[i];
1214
+ if (ch === '"') {
1215
+ if (inStr && trimmed[i + 1] === '"') {
1216
+ i++;
1217
+ continue;
1218
+ }
1219
+ inStr = !inStr;
1220
+ }
1221
+ else if (!inStr) {
1222
+ if (ch === "'")
1223
+ break;
1224
+ if (ch === '(' || ch === '[' || ch === '{')
1225
+ bracketDepth++;
1226
+ else if (ch === ')' || ch === ']' || ch === '}')
1227
+ bracketDepth--;
1228
+ else if (ch === '=' && bracketDepth === 0 && i > 0) {
1229
+ const prev = trimmed[i - 1];
1230
+ const next = i + 1 < trimmed.length ? trimmed[i + 1] : '';
1231
+ if (next === '=') {
1232
+ i++;
1233
+ continue;
1234
+ }
1235
+ if (prev === '<' || prev === '>' || prev === '!' || prev === '+' || prev === '-')
1236
+ continue;
1237
+ if (eqPos !== -1)
1238
+ return null;
1239
+ eqPos = i;
1240
+ }
1241
+ }
1242
+ }
1243
+ if (eqPos < 0)
1244
+ return null;
1245
+ return {
1246
+ before: trimmed.substring(0, eqPos).trimEnd(),
1247
+ after: trimmed.substring(eqPos + 1).trimStart(),
1248
+ };
1249
+ }
1250
+ // ---------------------------------------------------------------------------
938
1251
  // Pass 9 — Blank line rules
939
1252
  // ---------------------------------------------------------------------------
940
1253
  function passBlankLines(lines, config) {
@@ -972,7 +1285,7 @@ function passBlankLines(lines, config) {
972
1285
  }
973
1286
  result = out;
974
1287
  }
975
- if (config.blankLineAfterFunctionOpen) {
1288
+ if (config.emptyLineAfterFunctionOpen) {
976
1289
  const out = [];
977
1290
  for (let i = 0; i < result.length; i++) {
978
1291
  out.push(result[i]);
@@ -983,7 +1296,7 @@ function passBlankLines(lines, config) {
983
1296
  }
984
1297
  result = out;
985
1298
  }
986
- if (config.blankLineBeforeFunctionClose) {
1299
+ if (config.emptyLineBeforeFunctionClose) {
987
1300
  const out = [];
988
1301
  for (let i = 0; i < result.length; i++) {
989
1302
  if (/^\s*(?:end\s*function|end\s*sub|endfunction|endsub)\b/i.test(result[i])) {
@@ -994,13 +1307,13 @@ function passBlankLines(lines, config) {
994
1307
  }
995
1308
  result = out;
996
1309
  }
997
- if (config.blankLineBeforeReturn) {
1310
+ if (config.emptyLineBeforeReturn) {
998
1311
  const out = [];
999
1312
  for (let i = 0; i < result.length; i++) {
1000
1313
  if (/^\s*return\b/i.test(result[i])) {
1001
1314
  const isAlone = isReturnAloneInBlock(result, i);
1002
- const shouldAdd = config.blankLineBeforeReturn === 'always' ||
1003
- (config.blankLineBeforeReturn === 'not-alone' && !isAlone);
1315
+ const shouldAdd = config.emptyLineBeforeReturn === 'always' ||
1316
+ (config.emptyLineBeforeReturn === 'not-alone' && !isAlone);
1004
1317
  if (shouldAdd && out.length > 0) {
1005
1318
  const prevTrimmed = out[out.length - 1].trim();
1006
1319
  // Skip when the preceding line is a comment — the blank line belongs before the comment.
@@ -1009,7 +1322,7 @@ function passBlankLines(lines, config) {
1009
1322
  }
1010
1323
  }
1011
1324
  // With 'not-alone', actively remove blank lines before return when it IS alone in its block.
1012
- if (config.blankLineBeforeReturn === 'not-alone' && isAlone) {
1325
+ if (config.emptyLineBeforeReturn === 'not-alone' && isAlone) {
1013
1326
  while (out.length > 0 && out[out.length - 1].trim() === '')
1014
1327
  out.pop();
1015
1328
  }
@@ -1018,7 +1331,7 @@ function passBlankLines(lines, config) {
1018
1331
  }
1019
1332
  result = out;
1020
1333
  }
1021
- if (config.blankLineBeforeComment) {
1334
+ if (config.emptyLineBeforeComment) {
1022
1335
  const out = [];
1023
1336
  for (let i = 0; i < result.length; i++) {
1024
1337
  const trimmed = result[i].trim();
@@ -1049,6 +1362,33 @@ function passBlankLines(lines, config) {
1049
1362
  return result;
1050
1363
  }
1051
1364
  // ---------------------------------------------------------------------------
1365
+ // Pass 9b — Empty lines between methods
1366
+ // ---------------------------------------------------------------------------
1367
+ function passEmptyLinesBetweenMethods(lines, config) {
1368
+ if (config.emptyLinesBetweenMethods <= 0)
1369
+ return lines;
1370
+ const methodDefPattern = /^\w+\.\w+\s*=\s*(?:function|sub)\s*\(/i;
1371
+ const endPattern = /^(?:end\s*function|end\s*sub|endfunction|endsub)\b/i;
1372
+ const out = [];
1373
+ let prevWasEndMethod = false;
1374
+ for (let i = 0; i < lines.length; i++) {
1375
+ const trimmed = lines[i].trim();
1376
+ const isMethodDef = methodDefPattern.test(trimmed);
1377
+ if (isMethodDef && prevWasEndMethod) {
1378
+ while (out.length > 0 && out[out.length - 1].trim() === '')
1379
+ out.pop();
1380
+ for (let n = 0; n < config.emptyLinesBetweenMethods; n++)
1381
+ out.push('');
1382
+ }
1383
+ out.push(lines[i]);
1384
+ // Blank lines don't reset the flag
1385
+ if (trimmed !== '') {
1386
+ prevWasEndMethod = endPattern.test(trimmed);
1387
+ }
1388
+ }
1389
+ return out;
1390
+ }
1391
+ // ---------------------------------------------------------------------------
1052
1392
  // Pass 10 — Trim trailing whitespace
1053
1393
  // ---------------------------------------------------------------------------
1054
1394
  function passTrimTrailing(lines, config) {
@@ -1100,8 +1440,397 @@ function passCommentWidth(lines, config) {
1100
1440
  return result;
1101
1441
  }
1102
1442
  // ---------------------------------------------------------------------------
1103
- // Indent tracking helpers
1443
+ // Pass 12 — observeField style
1444
+ // ---------------------------------------------------------------------------
1445
+ function passObserveFieldStyle(lines, config) {
1446
+ if (config.observeFieldStyle === 'preserve')
1447
+ return lines;
1448
+ return lines.map(line => {
1449
+ const { code, comment } = splitTrailingComment(line);
1450
+ if (comment && /\.observeField\s*\(/i.test(comment))
1451
+ return line;
1452
+ if (!/\.observeField\s*\(/i.test(code))
1453
+ return line;
1454
+ if (/\.observeFieldScoped\s*\(/i.test(code))
1455
+ return line;
1456
+ if (config.observeFieldStyle === 'always-scoped') {
1457
+ const newCode = code.replace(/\.observeField\s*\(/gi, '.observeFieldScoped(');
1458
+ return comment ? newCode + ' ' + comment : newCode;
1459
+ }
1460
+ // 'warn'
1461
+ if (comment && /TODO:.*observeFieldScoped/i.test(comment))
1462
+ return line;
1463
+ return line + " ' TODO: consider using observeFieldScoped";
1464
+ });
1465
+ }
1466
+ // ---------------------------------------------------------------------------
1467
+ // Pass 13 — m prefix style
1104
1468
  // ---------------------------------------------------------------------------
1469
+ const M_KNOWN_PROPS = new Set(['top', 'global']);
1470
+ function passMPrefixStyle(lines, config) {
1471
+ if (config.mPrefixStyle === 'preserve')
1472
+ return lines;
1473
+ return lines.map(line => {
1474
+ const { code, comment } = splitTrailingComment(line);
1475
+ let newCode = code;
1476
+ if (config.mPrefixStyle === 'dot') {
1477
+ // m["field"] → m.field
1478
+ newCode = newCode.replace(/\bm\["([a-zA-Z_]\w*)"\]/g, (_match, field) => {
1479
+ return `m.${field}`;
1480
+ });
1481
+ }
1482
+ else {
1483
+ // m.field → m["field"], but not m.top, m.global, or method calls m.func()
1484
+ newCode = newCode.replace(/\bm\.([a-zA-Z_]\w*)/g, (match, field, offset) => {
1485
+ if (M_KNOWN_PROPS.has(field.toLowerCase()))
1486
+ return match;
1487
+ const afterField = newCode.slice(offset + match.length);
1488
+ if (/^\s*\(/.test(afterField))
1489
+ return match;
1490
+ return `m["${field}"]`;
1491
+ });
1492
+ }
1493
+ return comment ? newCode + ' ' + comment : newCode;
1494
+ });
1495
+ }
1496
+ // ---------------------------------------------------------------------------
1497
+ // Pass 14 — Field access consistency
1498
+ // ---------------------------------------------------------------------------
1499
+ const FIELD_ACCESS_SKIP_METHODS = new Set([
1500
+ 'observefield', 'observefieldscoped', 'unobservefield', 'unobservefieldscoped',
1501
+ 'update', 'getchild', 'getchildren', 'getparent', 'findnode',
1502
+ 'createchild', 'removechild', 'appendchild', 'getfield', 'setfield',
1503
+ 'hasfield', 'addfield', 'addfields', 'removechildindex', 'removechildren',
1504
+ 'getchildcount', 'replacechild', 'insertchild', 'createobject',
1505
+ ]);
1506
+ function passFieldAccessConsistency(lines, config) {
1507
+ if (config.fieldAccessConsistency === 'preserve')
1508
+ return lines;
1509
+ if (config.fieldAccessConsistency === 'dot') {
1510
+ return lines.map(line => {
1511
+ const { code, comment } = splitTrailingComment(line);
1512
+ let newCode = code;
1513
+ // m.top.getField("x") → m.top.x
1514
+ newCode = newCode.replace(/\bm\.top\.getField\s*\(\s*"([a-zA-Z_]\w*)"\s*\)/gi, (_m, field) => `m.top.${field}`);
1515
+ // m.top.setField("x", val) → m.top.x = val
1516
+ newCode = newCode.replace(/\bm\.top\.setField\s*\(\s*"([a-zA-Z_]\w*)"\s*,\s*/gi, (_m, field) => `m.top.${field} = `);
1517
+ // Remove trailing ) from setField conversion
1518
+ if (newCode !== code && /\bm\.top\.\w+\s*=\s*.+\)/.test(newCode)) {
1519
+ const lastParen = newCode.lastIndexOf(')');
1520
+ if (lastParen > -1)
1521
+ newCode = newCode.slice(0, lastParen) + newCode.slice(lastParen + 1);
1522
+ }
1523
+ return comment ? newCode + ' ' + comment : newCode;
1524
+ });
1525
+ }
1526
+ // 'method' — convert dot access to method calls
1527
+ return lines.map(line => {
1528
+ const { code, comment } = splitTrailingComment(line);
1529
+ const trimmed = code.trim();
1530
+ let newCode = code;
1531
+ // Assignment: m.top.field = value → m.top.setField("field", value)
1532
+ const assignMatch = trimmed.match(/^(\s*)m\.top\.([a-zA-Z_]\w*)\s*=\s*(.+)$/i);
1533
+ if (assignMatch) {
1534
+ const [, indent, field, value] = assignMatch;
1535
+ if (!FIELD_ACCESS_SKIP_METHODS.has(field.toLowerCase())) {
1536
+ newCode = `${indent}m.top.setField("${field}", ${value})`;
1537
+ return comment ? newCode + ' ' + comment : newCode;
1538
+ }
1539
+ }
1540
+ // Read: m.top.field (not a method call, not assignment target)
1541
+ newCode = newCode.replace(/\bm\.top\.([a-zA-Z_]\w*)/gi, (match, field, offset) => {
1542
+ if (FIELD_ACCESS_SKIP_METHODS.has(field.toLowerCase()))
1543
+ return match;
1544
+ const afterField = newCode.slice(offset + match.length);
1545
+ if (/^\s*\(/.test(afterField))
1546
+ return match;
1547
+ if (/^\s*=/.test(afterField))
1548
+ return match;
1549
+ return `m.top.getField("${field}")`;
1550
+ });
1551
+ return comment ? newCode + ' ' + comment : newCode;
1552
+ });
1553
+ }
1554
+ // ---------------------------------------------------------------------------
1555
+ // Pass 6b — Wrap long strings
1556
+ // ---------------------------------------------------------------------------
1557
+ const WRAP_LONG_STRINGS_WIDTH = 120;
1558
+ function passWrapLongStrings(lines, config) {
1559
+ if (config.wrapLongStrings === 'preserve')
1560
+ return lines;
1561
+ const result = [];
1562
+ for (const line of lines) {
1563
+ if (line.length <= WRAP_LONG_STRINGS_WIDTH) {
1564
+ result.push(line);
1565
+ continue;
1566
+ }
1567
+ const indent = line.match(/^(\s*)/)?.[1] ?? '';
1568
+ const { code, comment } = splitTrailingComment(line);
1569
+ // Find a long string literal in the line
1570
+ const stringMatch = code.match(/"([^"]{40,})"/);
1571
+ if (!stringMatch) {
1572
+ result.push(line);
1573
+ continue;
1574
+ }
1575
+ const fullStr = stringMatch[0];
1576
+ const strContent = stringMatch[1];
1577
+ const strStart = code.indexOf(fullStr);
1578
+ const before = code.slice(0, strStart);
1579
+ const after = code.slice(strStart + fullStr.length);
1580
+ const childIndent = indent + ' '.repeat(4);
1581
+ const maxChunk = WRAP_LONG_STRINGS_WIDTH - childIndent.length - 6;
1582
+ if (maxChunk <= 10) {
1583
+ result.push(line);
1584
+ continue;
1585
+ }
1586
+ const chunks = [];
1587
+ let remaining = strContent;
1588
+ while (remaining.length > 0) {
1589
+ if (remaining.length <= maxChunk) {
1590
+ chunks.push(remaining);
1591
+ break;
1592
+ }
1593
+ let breakAt = remaining.lastIndexOf(' ', maxChunk);
1594
+ if (breakAt <= 0)
1595
+ breakAt = maxChunk;
1596
+ chunks.push(remaining.slice(0, breakAt + 1));
1597
+ remaining = remaining.slice(breakAt + 1);
1598
+ }
1599
+ if (chunks.length <= 1) {
1600
+ result.push(line);
1601
+ continue;
1602
+ }
1603
+ if (config.wrapLongStrings === 'plus') {
1604
+ for (let i = 0; i < chunks.length; i++) {
1605
+ const piece = `"${chunks[i]}"`;
1606
+ if (i === 0) {
1607
+ const suffix = i < chunks.length - 1 ? ' + _' : '';
1608
+ result.push(before + piece + suffix);
1609
+ }
1610
+ else if (i < chunks.length - 1) {
1611
+ result.push(childIndent + piece + ' + _');
1612
+ }
1613
+ else {
1614
+ result.push(childIndent + piece + after + (comment ? ' ' + comment : ''));
1615
+ }
1616
+ }
1617
+ }
1618
+ else {
1619
+ // array-join
1620
+ result.push(before + '[');
1621
+ for (const chunk of chunks) {
1622
+ result.push(childIndent + `"${chunk}"`);
1623
+ }
1624
+ result.push(indent + '].join("")' + after + (comment ? ' ' + comment : ''));
1625
+ }
1626
+ }
1627
+ return result;
1628
+ }
1629
+ // ---------------------------------------------------------------------------
1630
+ // Pass 6c — String concatenation style
1631
+ // ---------------------------------------------------------------------------
1632
+ function passStringConcatStyle(lines, config) {
1633
+ if (config.stringConcatStyle === 'preserve')
1634
+ return lines;
1635
+ if (config.stringConcatStyle === 'plus') {
1636
+ // Convert [a, b, c].join("") → a + b + c
1637
+ const result = [];
1638
+ let i = 0;
1639
+ while (i < lines.length) {
1640
+ const trimmed = lines[i].trim();
1641
+ // Single-line: [a, b, c].join("")
1642
+ const singleMatch = trimmed.match(/^(.*)(\[.+\])\.join\s*\(\s*""\s*\)(.*)$/);
1643
+ if (singleMatch) {
1644
+ const indent = lines[i].match(/^(\s*)/)?.[1] ?? '';
1645
+ const before = singleMatch[1];
1646
+ const arrContent = singleMatch[2];
1647
+ const after = singleMatch[3];
1648
+ // Extract items from [...]
1649
+ const inner = arrContent.slice(1, -1);
1650
+ const items = splitArrayItems(inner);
1651
+ if (items.length > 0) {
1652
+ result.push(indent + before + items.join(' + ') + after);
1653
+ i++;
1654
+ continue;
1655
+ }
1656
+ }
1657
+ result.push(lines[i]);
1658
+ i++;
1659
+ }
1660
+ return result;
1661
+ }
1662
+ // 'array-join': Convert a + b + c → [a, b, c].join("") when at least one is a string
1663
+ return lines.map(line => {
1664
+ const { code, comment } = splitTrailingComment(line);
1665
+ const indent = line.match(/^(\s*)/)?.[1] ?? '';
1666
+ // Match: expr + expr + expr (with at least one string literal)
1667
+ const plusParts = splitPlusParts(code.trim());
1668
+ if (plusParts.length < 2)
1669
+ return line;
1670
+ const hasString = plusParts.some(p => /^".*"$/.test(p.trim()));
1671
+ if (!hasString)
1672
+ return line;
1673
+ // Find the assignment prefix
1674
+ const assignMatch = code.match(/^(\s*\S+\s*=\s*)/);
1675
+ const prefix = assignMatch ? assignMatch[1] : indent;
1676
+ const items = plusParts.map(p => p.trim()).join(', ');
1677
+ const newCode = prefix + `[${items}].join("")`;
1678
+ return comment ? newCode + ' ' + comment : newCode;
1679
+ });
1680
+ }
1681
+ function splitArrayItems(inner) {
1682
+ const items = [];
1683
+ let depth = 0;
1684
+ let current = '';
1685
+ let inStr = false;
1686
+ for (let i = 0; i < inner.length; i++) {
1687
+ const ch = inner[i];
1688
+ if (ch === '"' && (i === 0 || inner[i - 1] !== '\\'))
1689
+ inStr = !inStr;
1690
+ if (!inStr) {
1691
+ if (ch === '[' || ch === '{' || ch === '(')
1692
+ depth++;
1693
+ if (ch === ']' || ch === '}' || ch === ')')
1694
+ depth--;
1695
+ if (ch === ',' && depth === 0) {
1696
+ items.push(current.trim());
1697
+ current = '';
1698
+ continue;
1699
+ }
1700
+ }
1701
+ current += ch;
1702
+ }
1703
+ if (current.trim())
1704
+ items.push(current.trim());
1705
+ return items;
1706
+ }
1707
+ function splitPlusParts(code) {
1708
+ const parts = [];
1709
+ let depth = 0;
1710
+ let current = '';
1711
+ let inStr = false;
1712
+ for (let i = 0; i < code.length; i++) {
1713
+ const ch = code[i];
1714
+ if (ch === '"' && (i === 0 || code[i - 1] !== '\\'))
1715
+ inStr = !inStr;
1716
+ if (!inStr) {
1717
+ if (ch === '[' || ch === '{' || ch === '(')
1718
+ depth++;
1719
+ if (ch === ']' || ch === '}' || ch === ')')
1720
+ depth--;
1721
+ if (ch === '+' && depth === 0 && !inStr) {
1722
+ // Check it's not +=
1723
+ if (i > 0 && code[i - 1] === ' ' || i + 1 < code.length) {
1724
+ const before = code.slice(0, i).trim();
1725
+ if (!before.endsWith('=')) {
1726
+ parts.push(current.trim());
1727
+ current = '';
1728
+ continue;
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+ current += ch;
1734
+ }
1735
+ if (current.trim())
1736
+ parts.push(current.trim());
1737
+ return parts.length > 1 ? parts : [];
1738
+ }
1739
+ // ---------------------------------------------------------------------------
1740
+ // Pass 7c — Associative array single-line threshold
1741
+ // ---------------------------------------------------------------------------
1742
+ function passAAThreshold(lines, config) {
1743
+ if (config.associativeArraySingleLineThreshold <= 0)
1744
+ return lines;
1745
+ const threshold = config.associativeArraySingleLineThreshold;
1746
+ const indentStr = config.useTabs ? '\t' : ' '.repeat(config.indentSize);
1747
+ const result = [];
1748
+ for (const line of lines) {
1749
+ const indent = line.match(/^(\s*)/)?.[1] ?? '';
1750
+ const trimmed = line.trim();
1751
+ const { code, comment } = splitTrailingComment(line);
1752
+ const codeT = code.trim();
1753
+ // Find single-line AA: { key: val, key: val }
1754
+ const aaMatch = codeT.match(/^(.*?)(\{[^{}]+\})(.*)$/);
1755
+ if (!aaMatch) {
1756
+ result.push(line);
1757
+ continue;
1758
+ }
1759
+ const [, before, aaBlock, after] = aaMatch;
1760
+ if (aaBlock.length <= threshold) {
1761
+ result.push(line);
1762
+ continue;
1763
+ }
1764
+ // Extract key-value pairs
1765
+ const inner = aaBlock.slice(1, -1).trim();
1766
+ const pairs = splitArrayItems(inner);
1767
+ if (pairs.length === 0) {
1768
+ result.push(line);
1769
+ continue;
1770
+ }
1771
+ const childIndent = indent + indentStr;
1772
+ result.push(indent + before + '{');
1773
+ for (const pair of pairs) {
1774
+ result.push(childIndent + pair.trim());
1775
+ }
1776
+ result.push(indent + '}' + after + (comment ? ' ' + comment : ''));
1777
+ }
1778
+ return result;
1779
+ }
1780
+ // ---------------------------------------------------------------------------
1781
+ // Pass 8d — Multi-line param alignment
1782
+ // ---------------------------------------------------------------------------
1783
+ function passParamAlignment(lines, config) {
1784
+ if (config.paramAlignmentStyle === 'preserve')
1785
+ return lines;
1786
+ const indentStr = config.useTabs ? '\t' : ' '.repeat(config.indentSize);
1787
+ const result = [];
1788
+ let i = 0;
1789
+ while (i < lines.length) {
1790
+ const trimmed = lines[i].trim().toLowerCase();
1791
+ // Detect function/sub declaration with ( but no closing )
1792
+ const isFuncLine = /^(?:function|sub)\s+/i.test(trimmed) || /^\s*(?:function|sub)\s+/i.test(lines[i]);
1793
+ if (!isFuncLine || !lines[i].includes('(') || lines[i].includes(')')) {
1794
+ result.push(lines[i]);
1795
+ i++;
1796
+ continue;
1797
+ }
1798
+ // Multi-line params: collect lines until we find )
1799
+ const funcLine = lines[i];
1800
+ const funcIndent = funcLine.match(/^(\s*)/)?.[1] ?? '';
1801
+ const parenCol = funcLine.indexOf('(');
1802
+ const paramLines = [funcLine];
1803
+ let j = i + 1;
1804
+ while (j < lines.length) {
1805
+ paramLines.push(lines[j]);
1806
+ if (lines[j].includes(')'))
1807
+ break;
1808
+ j++;
1809
+ }
1810
+ if (paramLines.length <= 1) {
1811
+ result.push(lines[i]);
1812
+ i++;
1813
+ continue;
1814
+ }
1815
+ if (config.paramAlignmentStyle === 'indent') {
1816
+ result.push(paramLines[0]);
1817
+ const paramIndent = funcIndent + indentStr;
1818
+ for (let k = 1; k < paramLines.length; k++) {
1819
+ result.push(paramIndent + paramLines[k].trim());
1820
+ }
1821
+ }
1822
+ else {
1823
+ // align-to-paren
1824
+ result.push(paramLines[0]);
1825
+ const alignStr = ' '.repeat(parenCol + 1);
1826
+ for (let k = 1; k < paramLines.length; k++) {
1827
+ result.push(alignStr + paramLines[k].trim());
1828
+ }
1829
+ }
1830
+ i = j + 1;
1831
+ }
1832
+ return result;
1833
+ }
1105
1834
  function isIndentLine(trimmed) {
1106
1835
  const lower = trimmed.toLowerCase();
1107
1836
  if (/^(?:if|else\s*if|elseif)\b/i.test(lower)) {
@@ -1321,8 +2050,8 @@ function splitCodeSegments(line) {
1321
2050
  return segments;
1322
2051
  }
1323
2052
  function transformCodeSegment(code, casing, userFuncMap) {
1324
- const exactMap = casing.exactCasing ?? {};
1325
- const userFuncCasing = casing.userFunctions ?? 'NoChange';
2053
+ const exactMap = casing.exact ?? {};
2054
+ const userFuncCasing = casing.userFunction ?? 'preserve';
1326
2055
  return code.replace(/\b([a-zA-Z_]\w*)\b/g, (match, _group, offset) => {
1327
2056
  const afterIdx = offset + match.length;
1328
2057
  const restAfter = code.slice(afterIdx);
@@ -1343,7 +2072,7 @@ function transformCodeSegment(code, casing, userFuncMap) {
1343
2072
  }
1344
2073
  }
1345
2074
  const effectiveCasing = (0, casing_1.resolveKeywordCasing)(category, casing);
1346
- if (effectiveCasing !== 'NoChange') {
2075
+ if (effectiveCasing !== 'preserve') {
1347
2076
  return (0, casing_1.applyCasingWithOverrides)(match, effectiveCasing, exactMap);
1348
2077
  }
1349
2078
  return match;
@@ -1355,14 +2084,14 @@ function transformCodeSegment(code, casing, userFuncMap) {
1355
2084
  if (!/^\s*\(/.test(restAfter))
1356
2085
  return match;
1357
2086
  const canonical = _builtinMap.get(lower);
1358
- if (casing.builtins !== 'NoChange') {
1359
- return (0, casing_1.applyCasingWithOverrides)(canonical, casing.builtins, exactMap);
2087
+ if (casing.builtin !== 'preserve') {
2088
+ return (0, casing_1.applyCasingWithOverrides)(canonical, casing.builtin, exactMap);
1360
2089
  }
1361
2090
  return canonical;
1362
2091
  }
1363
2092
  if (userFuncMap.has(lower)) {
1364
2093
  const definitionName = userFuncMap.get(lower);
1365
- if (userFuncCasing !== 'NoChange') {
2094
+ if (userFuncCasing !== 'preserve') {
1366
2095
  return (0, casing_1.applyCasing)(definitionName, userFuncCasing);
1367
2096
  }
1368
2097
  return definitionName;