wyreframe 0.3.0 → 0.4.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,588 @@
1
+ // Fixer.res
2
+ // Auto-fix functionality for common wireframe errors and warnings
3
+
4
+ open FixTypes
5
+
6
+ // ============================================================================
7
+ // String Manipulation Helpers
8
+ // ============================================================================
9
+
10
+ /**
11
+ * Split text into lines (preserving empty lines)
12
+ */
13
+ let splitLines = (text: string): array<string> => {
14
+ text->String.split("\n")
15
+ }
16
+
17
+ /**
18
+ * Join lines back into text
19
+ */
20
+ let joinLines = (lines: array<string>): string => {
21
+ lines->Array.join("\n")
22
+ }
23
+
24
+ /**
25
+ * Get a specific line from text (0-indexed)
26
+ */
27
+ let getLine = (lines: array<string>, row: int): option<string> => {
28
+ lines->Array.get(row)
29
+ }
30
+
31
+ /**
32
+ * Replace a specific line in the array (0-indexed)
33
+ */
34
+ let replaceLine = (lines: array<string>, row: int, newLine: string): array<string> => {
35
+ lines->Array.mapWithIndex((line, idx) => {
36
+ if idx === row {
37
+ newLine
38
+ } else {
39
+ line
40
+ }
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Insert characters at a specific position in a string
46
+ */
47
+ let insertAt = (str: string, col: int, chars: string): string => {
48
+ let before = str->String.slice(~start=0, ~end=col)
49
+ let after = str->String.sliceToEnd(~start=col)
50
+ before ++ chars ++ after
51
+ }
52
+
53
+ /**
54
+ * Remove characters at a specific position in a string
55
+ */
56
+ let removeAt = (str: string, col: int, count: int): string => {
57
+ let before = str->String.slice(~start=0, ~end=col)
58
+ let after = str->String.sliceToEnd(~start=col + count)
59
+ before ++ after
60
+ }
61
+
62
+ /**
63
+ * Replace a character at a specific position
64
+ */
65
+ let replaceCharAt = (str: string, col: int, char: string): string => {
66
+ let before = str->String.slice(~start=0, ~end=col)
67
+ let after = str->String.sliceToEnd(~start=col + 1)
68
+ before ++ char ++ after
69
+ }
70
+
71
+ // ============================================================================
72
+ // Fix Strategies for Each Error Type
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Fix MisalignedPipe - adjust column position of '|' character
77
+ *
78
+ * Before: | content | (pipe at wrong column)
79
+ * After: | content | (pipe at correct column)
80
+ *
81
+ * Note: position.row is 1-indexed (from error messages), convert to 0-indexed for array access
82
+ */
83
+ let fixMisalignedPipe = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
84
+ switch error.code {
85
+ | MisalignedPipe({position, expectedCol, actualCol}) => {
86
+ let lines = splitLines(text)
87
+ // Convert 1-indexed row to 0-indexed for array access
88
+ let rowIndex = position.row - 1
89
+
90
+ switch getLine(lines, rowIndex) {
91
+ | None => None
92
+ | Some(line) => {
93
+ // Calculate how many spaces to add or remove
94
+ let diff = expectedCol - actualCol
95
+
96
+ let newLine = if diff > 0 {
97
+ // Need to add spaces before the pipe
98
+ insertAt(line, actualCol, String.repeat(" ", diff))
99
+ } else {
100
+ // Need to remove spaces before the pipe
101
+ let removeCount = Math.Int.abs(diff)
102
+ // Make sure we're removing spaces, not content
103
+ let beforePipe = line->String.slice(~start=actualCol + diff, ~end=actualCol)
104
+ if beforePipe->String.trim === "" {
105
+ removeAt(line, actualCol + diff, removeCount)
106
+ } else {
107
+ line // Can't safely remove non-space characters
108
+ }
109
+ }
110
+
111
+ if newLine !== line {
112
+ let newLines = replaceLine(lines, rowIndex, newLine)
113
+ let fixedText = joinLines(newLines)
114
+
115
+ Some((
116
+ fixedText,
117
+ {
118
+ original: error,
119
+ description: `Aligned pipe at line ${Int.toString(position.row)} to column ${Int.toString(expectedCol + 1)}`,
120
+ line: position.row,
121
+ column: expectedCol + 1,
122
+ },
123
+ ))
124
+ } else {
125
+ None
126
+ }
127
+ }
128
+ }
129
+ }
130
+ | _ => None
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Fix MisalignedClosingBorder - adjust closing '|' position
136
+ *
137
+ * Note: position.row is 1-indexed (from error messages), convert to 0-indexed for array access
138
+ */
139
+ let fixMisalignedClosingBorder = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
140
+ switch error.code {
141
+ | MisalignedClosingBorder({position, expectedCol, actualCol}) => {
142
+ let lines = splitLines(text)
143
+ // Convert 1-indexed row to 0-indexed for array access
144
+ let rowIndex = position.row - 1
145
+
146
+ switch getLine(lines, rowIndex) {
147
+ | None => None
148
+ | Some(line) => {
149
+ let diff = expectedCol - actualCol
150
+
151
+ let newLine = if diff > 0 {
152
+ // Need to add spaces before the closing pipe
153
+ insertAt(line, actualCol, String.repeat(" ", diff))
154
+ } else {
155
+ // Need to remove spaces before the closing pipe
156
+ let removeCount = Math.Int.abs(diff)
157
+ let beforePipe = line->String.slice(~start=actualCol + diff, ~end=actualCol)
158
+ if beforePipe->String.trim === "" {
159
+ removeAt(line, actualCol + diff, removeCount)
160
+ } else {
161
+ line
162
+ }
163
+ }
164
+
165
+ if newLine !== line {
166
+ let newLines = replaceLine(lines, rowIndex, newLine)
167
+ let fixedText = joinLines(newLines)
168
+
169
+ Some((
170
+ fixedText,
171
+ {
172
+ original: error,
173
+ description: `Aligned closing border at line ${Int.toString(position.row)} to column ${Int.toString(expectedCol + 1)}`,
174
+ line: position.row,
175
+ column: expectedCol + 1,
176
+ },
177
+ ))
178
+ } else {
179
+ None
180
+ }
181
+ }
182
+ }
183
+ }
184
+ | _ => None
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Fix UnusualSpacing - replace tabs with spaces
190
+ *
191
+ * Note: position.row is 1-indexed (from error messages), convert to 0-indexed for array access
192
+ */
193
+ let fixUnusualSpacing = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
194
+ switch error.code {
195
+ | UnusualSpacing({position, issue}) => {
196
+ // Check if the issue is about tabs
197
+ if issue->String.includes("tab") || issue->String.includes("Tab") {
198
+ let lines = splitLines(text)
199
+ // Convert 1-indexed row to 0-indexed for array access
200
+ let rowIndex = position.row - 1
201
+
202
+ switch getLine(lines, rowIndex) {
203
+ | None => None
204
+ | Some(line) => {
205
+ // Replace tabs with 2 spaces (common convention)
206
+ let newLine = line->String.replaceAll("\t", " ")
207
+
208
+ if newLine !== line {
209
+ let newLines = replaceLine(lines, rowIndex, newLine)
210
+ let fixedText = joinLines(newLines)
211
+
212
+ Some((
213
+ fixedText,
214
+ {
215
+ original: error,
216
+ description: `Replaced tabs with spaces at line ${Int.toString(position.row)}`,
217
+ line: position.row,
218
+ column: position.col + 1,
219
+ },
220
+ ))
221
+ } else {
222
+ None
223
+ }
224
+ }
225
+ }
226
+ } else {
227
+ None
228
+ }
229
+ }
230
+ | _ => None
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Fix UnclosedBracket - add closing ']' at end of line
236
+ *
237
+ * Note: opening.row is 1-indexed (from error messages), convert to 0-indexed for array access
238
+ */
239
+ let fixUnclosedBracket = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
240
+ switch error.code {
241
+ | UnclosedBracket({opening}) => {
242
+ let lines = splitLines(text)
243
+ // Convert 1-indexed row to 0-indexed for array access
244
+ let rowIndex = opening.row - 1
245
+
246
+ switch getLine(lines, rowIndex) {
247
+ | None => None
248
+ | Some(line) => {
249
+ // Find the content after '[' and close it
250
+ let trimmedLine = line->String.trimEnd
251
+
252
+ // Only add ']' if the line doesn't already end with it
253
+ if !(trimmedLine->String.endsWith("]")) {
254
+ // Add ' ]' with proper spacing
255
+ let newLine = trimmedLine ++ " ]"
256
+ let newLines = replaceLine(lines, rowIndex, newLine)
257
+ let fixedText = joinLines(newLines)
258
+
259
+ Some((
260
+ fixedText,
261
+ {
262
+ original: error,
263
+ description: `Added closing bracket at line ${Int.toString(opening.row)}`,
264
+ line: opening.row,
265
+ column: String.length(trimmedLine) + 2,
266
+ },
267
+ ))
268
+ } else {
269
+ None
270
+ }
271
+ }
272
+ }
273
+ }
274
+ | _ => None
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Fix MismatchedWidth - extend the shorter border to match the longer one
280
+ *
281
+ * Note: topLeft.row is 1-indexed (from error messages), convert to 0-indexed for array access
282
+ */
283
+ let fixMismatchedWidth = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
284
+ switch error.code {
285
+ | MismatchedWidth({topLeft, topWidth, bottomWidth}) => {
286
+ let lines = splitLines(text)
287
+ let diff = topWidth - bottomWidth
288
+ // Convert 1-indexed row to 0-indexed for array access
289
+ let topLeftRowIndex = topLeft.row - 1
290
+
291
+ if diff === 0 {
292
+ None
293
+ } else {
294
+ // Find the bottom border line
295
+ // We need to trace down from topLeft to find the bottom
296
+ // Note: row here is 0-indexed array index
297
+ let rec findBottomRow = (row: int): option<int> => {
298
+ if row >= Array.length(lines) {
299
+ None
300
+ } else {
301
+ switch getLine(lines, row) {
302
+ | None => None
303
+ | Some(line) => {
304
+ // Check if this line has a '+' at the same column as topLeft
305
+ let col = topLeft.col
306
+ if col < String.length(line) {
307
+ let char = line->String.charAt(col)
308
+ if char === "+" && row > topLeftRowIndex {
309
+ Some(row)
310
+ } else {
311
+ findBottomRow(row + 1)
312
+ }
313
+ } else {
314
+ findBottomRow(row + 1)
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ switch findBottomRow(topLeftRowIndex + 1) {
322
+ | None => None
323
+ | Some(bottomRowIndex) => {
324
+ switch getLine(lines, bottomRowIndex) {
325
+ | None => None
326
+ | Some(bottomLine) => {
327
+ if diff > 0 {
328
+ // Bottom is shorter, need to extend it
329
+ // Find the closing '+' and add dashes before it
330
+ let closingPlusCol = topLeft.col + bottomWidth - 1
331
+ if closingPlusCol >= 0 && closingPlusCol < String.length(bottomLine) {
332
+ let before = bottomLine->String.slice(~start=0, ~end=closingPlusCol)
333
+ let after = bottomLine->String.sliceToEnd(~start=closingPlusCol)
334
+ let dashes = String.repeat("-", diff)
335
+ let newLine = before ++ dashes ++ after
336
+
337
+ let newLines = replaceLine(lines, bottomRowIndex, newLine)
338
+ let fixedText = joinLines(newLines)
339
+
340
+ Some((
341
+ fixedText,
342
+ {
343
+ original: error,
344
+ description: `Extended bottom border at line ${Int.toString(bottomRowIndex + 1)} by ${Int.toString(diff)} characters`,
345
+ line: bottomRowIndex + 1,
346
+ column: closingPlusCol + 1,
347
+ },
348
+ ))
349
+ } else {
350
+ None
351
+ }
352
+ } else {
353
+ // Top is shorter, need to extend it
354
+ // This is trickier as it affects content alignment
355
+ // For now, we extend the top border
356
+ switch getLine(lines, topLeftRowIndex) {
357
+ | None => None
358
+ | Some(topLine) => {
359
+ let closingPlusCol = topLeft.col + topWidth - 1
360
+ if closingPlusCol >= 0 && closingPlusCol < String.length(topLine) {
361
+ let before = topLine->String.slice(~start=0, ~end=closingPlusCol)
362
+ let after = topLine->String.sliceToEnd(~start=closingPlusCol)
363
+ let dashes = String.repeat("-", Math.Int.abs(diff))
364
+ let newLine = before ++ dashes ++ after
365
+
366
+ let newLines = replaceLine(lines, topLeftRowIndex, newLine)
367
+ let fixedText = joinLines(newLines)
368
+
369
+ Some((
370
+ fixedText,
371
+ {
372
+ original: error,
373
+ description: `Extended top border at line ${Int.toString(topLeft.row)} by ${Int.toString(Math.Int.abs(diff))} characters`,
374
+ line: topLeft.row,
375
+ column: closingPlusCol + 1,
376
+ },
377
+ ))
378
+ } else {
379
+ None
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ | _ => None
391
+ }
392
+ }
393
+
394
+ // ============================================================================
395
+ // Main Fix Function
396
+ // ============================================================================
397
+
398
+ /**
399
+ * List of all fix strategies in order of application
400
+ */
401
+ let fixStrategies: array<(string, ErrorTypes.errorCode => bool, (string, ErrorTypes.t) => option<(string, fixedIssue)>)> = [
402
+ ("MisalignedPipe", code => {
403
+ switch code {
404
+ | MisalignedPipe(_) => true
405
+ | _ => false
406
+ }
407
+ }, fixMisalignedPipe),
408
+ ("MisalignedClosingBorder", code => {
409
+ switch code {
410
+ | MisalignedClosingBorder(_) => true
411
+ | _ => false
412
+ }
413
+ }, fixMisalignedClosingBorder),
414
+ ("UnusualSpacing", code => {
415
+ switch code {
416
+ | UnusualSpacing(_) => true
417
+ | _ => false
418
+ }
419
+ }, fixUnusualSpacing),
420
+ ("UnclosedBracket", code => {
421
+ switch code {
422
+ | UnclosedBracket(_) => true
423
+ | _ => false
424
+ }
425
+ }, fixUnclosedBracket),
426
+ ("MismatchedWidth", code => {
427
+ switch code {
428
+ | MismatchedWidth(_) => true
429
+ | _ => false
430
+ }
431
+ }, fixMismatchedWidth),
432
+ ]
433
+
434
+ /**
435
+ * Try to fix a single error using available strategies
436
+ */
437
+ let tryFixError = (text: string, error: ErrorTypes.t): option<(string, fixedIssue)> => {
438
+ // Find the first strategy that can fix this error
439
+ fixStrategies->Array.reduce(None, (acc, (_, canFix, apply)) => {
440
+ switch acc {
441
+ | Some(_) => acc // Already found a fix
442
+ | None => {
443
+ if canFix(error.code) {
444
+ apply(text, error)
445
+ } else {
446
+ None
447
+ }
448
+ }
449
+ }
450
+ })
451
+ }
452
+
453
+ /**
454
+ * Check if an error code is fixable
455
+ */
456
+ let isFixable = (code: ErrorTypes.errorCode): bool => {
457
+ fixStrategies->Array.some(((_, canFix, _)) => canFix(code))
458
+ }
459
+
460
+ /**
461
+ * Main fix function - attempts to fix all errors in the text
462
+ *
463
+ * This function:
464
+ * 1. Parses the text to get errors/warnings
465
+ * 2. Attempts to fix each fixable error
466
+ * 3. Re-parses after each fix to get updated positions
467
+ * 4. Returns the fixed text and list of applied fixes
468
+ *
469
+ * @param text The wireframe markdown text
470
+ * @returns FixResult with fixed text and details
471
+ */
472
+ @genType
473
+ let fix = (text: string): fixResult => {
474
+ // Maximum iterations to prevent infinite loops
475
+ let maxIterations = 100
476
+
477
+ // Recursive fix loop
478
+ let rec fixLoop = (currentText: string, fixedSoFar: array<fixedIssue>, iteration: int): fixResult => {
479
+ if iteration >= maxIterations {
480
+ // Too many iterations, return what we have
481
+ Ok({
482
+ text: currentText,
483
+ fixed: fixedSoFar,
484
+ remaining: [],
485
+ })
486
+ } else {
487
+ // Parse current text to get errors
488
+ let parseResult = Parser.parse(currentText)
489
+
490
+ switch parseResult {
491
+ | Ok((_ast, warnings)) => {
492
+ // Parse succeeded, only warnings remain
493
+ // Try to fix warnings
494
+ let fixableWarnings = warnings->Array.filter(w => isFixable(w.code))
495
+
496
+ if Array.length(fixableWarnings) === 0 {
497
+ // No more fixable issues
498
+ Ok({
499
+ text: currentText,
500
+ fixed: fixedSoFar,
501
+ remaining: warnings->Array.filter(w => !isFixable(w.code)),
502
+ })
503
+ } else {
504
+ // Try to fix the first fixable warning
505
+ switch fixableWarnings->Array.get(0) {
506
+ | None => Ok({
507
+ text: currentText,
508
+ fixed: fixedSoFar,
509
+ remaining: warnings,
510
+ })
511
+ | Some(warning) => {
512
+ switch tryFixError(currentText, warning) {
513
+ | None => {
514
+ // Couldn't fix, move on
515
+ Ok({
516
+ text: currentText,
517
+ fixed: fixedSoFar,
518
+ remaining: warnings,
519
+ })
520
+ }
521
+ | Some((newText, fixedIssue)) => {
522
+ // Fixed! Continue with remaining
523
+ let newFixed = fixedSoFar->Array.concat([fixedIssue])
524
+ fixLoop(newText, newFixed, iteration + 1)
525
+ }
526
+ }
527
+ }
528
+ }
529
+ }
530
+ }
531
+ | Error(errors) => {
532
+ // Parse failed, try to fix errors
533
+ let fixableErrors = errors->Array.filter(e => isFixable(e.code))
534
+
535
+ if Array.length(fixableErrors) === 0 {
536
+ // No fixable errors, return what we have
537
+ Ok({
538
+ text: currentText,
539
+ fixed: fixedSoFar,
540
+ remaining: errors->Array.filter(e => !isFixable(e.code)),
541
+ })
542
+ } else {
543
+ // Try to fix the first fixable error
544
+ switch fixableErrors->Array.get(0) {
545
+ | None => Ok({
546
+ text: currentText,
547
+ fixed: fixedSoFar,
548
+ remaining: errors,
549
+ })
550
+ | Some(error) => {
551
+ switch tryFixError(currentText, error) {
552
+ | None => {
553
+ // Couldn't fix, return remaining errors
554
+ Ok({
555
+ text: currentText,
556
+ fixed: fixedSoFar,
557
+ remaining: errors,
558
+ })
559
+ }
560
+ | Some((newText, fixedIssue)) => {
561
+ // Fixed! Continue fixing
562
+ let newFixed = fixedSoFar->Array.concat([fixedIssue])
563
+ fixLoop(newText, newFixed, iteration + 1)
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ // Start the fix loop
575
+ fixLoop(text, [], 0)
576
+ }
577
+
578
+ /**
579
+ * Convenience function - fix and return just the fixed text
580
+ * Returns the original text if nothing was fixed
581
+ */
582
+ @genType
583
+ let fixOnly = (text: string): string => {
584
+ switch fix(text) {
585
+ | Ok({text: fixedText, _}) => fixedText
586
+ | Error(_) => text
587
+ }
588
+ }