@lewishowles/lint-config 0.4.0 → 0.6.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.
@@ -0,0 +1,868 @@
1
+ import {
2
+ formatJSDocBlockStructure,
3
+ formatJSDocPunctuation,
4
+ formatJSDocTagFormatting,
5
+ formatJSDocWrapping,
6
+ hasTargetJSDocTag,
7
+ isJSDoc,
8
+ } from "../utils/jsdoc.js";
9
+
10
+ import {
11
+ getCommentNeighbours,
12
+ getCommentText,
13
+ getDisplayWidth,
14
+ getLineCommentGroups,
15
+ getLineIndent,
16
+ getLineStart,
17
+ getNewline,
18
+ isDirectiveComment,
19
+ isLeadingComment,
20
+ replaceMinimalComment,
21
+ } from "../utils/source.js";
22
+
23
+ import {
24
+ addTerminalPunctuation,
25
+ capitaliseSentence,
26
+ formatSentence,
27
+ refillCommentLines,
28
+ wrapWords,
29
+ } from "../utils/wrap.js";
30
+
31
+ // The line length this rule wraps comments to.
32
+ const maximumLineLength = 80;
33
+
34
+ /**
35
+ * Return a line comment's source text with a new value after the `//`.
36
+ *
37
+ * @param {object} sourceCode
38
+ * The Oxlint source code object.
39
+ * @param {object} comment
40
+ * The line comment token.
41
+ * @param {string} value
42
+ * The replacement comment value.
43
+ *
44
+ * @returns {string}
45
+ * The replacement comment text.
46
+ */
47
+ function replaceLineCommentValue(sourceCode, comment, value) {
48
+ // The comment's raw source text.
49
+ const commentText = getCommentText(sourceCode, comment);
50
+
51
+ return `${commentText.slice(0, 2)}${value}`;
52
+ }
53
+
54
+ /**
55
+ * Wrap a line comment to the configured maximum width.
56
+ *
57
+ * @param {object} sourceCode
58
+ * The Oxlint source code object.
59
+ * @param {object} comment
60
+ * The line comment token.
61
+ * @param {string} indentation
62
+ * The indentation shared by the comment group.
63
+ * @param {string} commentText
64
+ * The comment text to wrap.
65
+ *
66
+ * @returns {string|null}
67
+ * The wrapped comment without leading indentation, or null when it is a
68
+ * directive.
69
+ */
70
+ function formatLineComment(sourceCode, comment, indentation, commentText) {
71
+ // The available width, allowing for the indent and "// " prefix.
72
+ const width = maximumLineLength - getDisplayWidth(indentation) - 3;
73
+ // The comment's undecorated text.
74
+ const text = commentText.slice(2).trim();
75
+
76
+ if (isDirectiveComment(comment)) {
77
+ return null;
78
+ }
79
+
80
+ if (text === "") {
81
+ return "//";
82
+ }
83
+
84
+ return wrapWords(text, Math.max(1, width))
85
+ .map((line) => `// ${line}`)
86
+ .join(getNewline(sourceCode.text));
87
+ }
88
+
89
+ /**
90
+ * Return the part of a line's indentation that goes beyond the comment's own
91
+ * indentation, so nested lines keep their offset when the comment moves.
92
+ *
93
+ * @param {string} indentation
94
+ * The indentation of one line inside the comment.
95
+ * @param {string} commentIndent
96
+ * The indentation of the comment's first line.
97
+ *
98
+ * @returns {string}
99
+ * The extra indentation, or an empty string when the line does not start
100
+ * with the comment's indentation.
101
+ */
102
+ function getRelativeIndent(indentation, commentIndent) {
103
+ return indentation.startsWith(commentIndent) ? indentation.slice(commentIndent.length) : "";
104
+ }
105
+
106
+ /**
107
+ * Reindent a formatted leading comment while preserving inner indentation.
108
+ *
109
+ * @param {string} commentText
110
+ * The formatted comment source text.
111
+ * @param {string} commentIndent
112
+ * The comment's current indentation.
113
+ * @param {string} expectedIndent
114
+ * The documented code's indentation.
115
+ * @param {string} newline
116
+ * The source file's newline sequence.
117
+ *
118
+ * @returns {string}
119
+ * The reindented comment with its leading indentation.
120
+ */
121
+ function getReindentedCommentText(commentText, commentIndent, expectedIndent, newline) {
122
+ return commentText
123
+ .split(/\r\n|\n|\r/)
124
+ .map((line, lineIndex) => {
125
+ if (lineIndex === 0) {
126
+ return `${expectedIndent}${line}`;
127
+ }
128
+
129
+ // The line's current indentation.
130
+ const lineIndent = line.match(/^[ \t]*/)[0];
131
+ // The indentation to preserve relative to the comment's own indent.
132
+ const relativeIndent = getRelativeIndent(lineIndent, commentIndent);
133
+
134
+ return `${expectedIndent}${relativeIndent}${line.slice(lineIndent.length)}`;
135
+ })
136
+ .join(newline);
137
+ }
138
+
139
+ /**
140
+ * Apply a prose formatter to a block-comment line, keeping its leading `*`.
141
+ *
142
+ * @param {string} line
143
+ * The block-comment line.
144
+ * @param {function} formatProse
145
+ * The formatter for the line's prose.
146
+ *
147
+ * @returns {string}
148
+ * The formatted block-comment line.
149
+ */
150
+ function formatBlockCommentLine(line, formatProse) {
151
+ // The line's leading `*` decoration, when present.
152
+ const marker = line.match(/^\s*\*\s*/);
153
+
154
+ if (marker === null) {
155
+ return line;
156
+ }
157
+
158
+ return `${marker[0]}${formatProse(line.slice(marker[0].length).trim())}`;
159
+ }
160
+
161
+ /**
162
+ * Format prose in an ordinary block comment as complete sentences.
163
+ *
164
+ * @param {object} sourceCode
165
+ * The Oxlint source code object.
166
+ * @param {object} comment
167
+ * The block comment token.
168
+ *
169
+ * @returns {string}
170
+ * The sentence-formatted comment text.
171
+ */
172
+ function formatOrdinaryBlockComment(sourceCode, comment) {
173
+ // The comment's raw source text.
174
+ const commentText = getCommentText(sourceCode, comment);
175
+ // The indentation the comment's lines are aligned to.
176
+ const indentation = getLineIndent(sourceCode, comment.range[0]);
177
+ // The comment body, stripped of its /* */ delimiters.
178
+ const content = commentText.slice(2, -2).trim();
179
+
180
+ if (indentation === null || content === "" || isDirectiveComment(comment)) {
181
+ return commentText;
182
+ }
183
+
184
+ if (!commentText.includes("\n") && !commentText.includes("\r")) {
185
+ return `/* ${formatSentence(content)} */`;
186
+ }
187
+
188
+ // The comment's individual source lines.
189
+ const lines = commentText.split(/\r\n|\n|\r/);
190
+
191
+ // The indexes of lines carrying prose, excluding the delimiter lines.
192
+ const proseLineIndexes = lines
193
+ .slice(1, -1)
194
+ .map((line, index) => ({ index: index + 1, text: line.replace(/^\s*\*?\s?/, "").trim() }))
195
+ .filter((line) => line.text !== "")
196
+ .map((line) => line.index);
197
+
198
+ if (lines[0] === "/*" && lines.at(-1).trim() === "*/" && proseLineIndexes.length > 0) {
199
+ // The comment lines, formatted in place.
200
+ const formattedLines = [...lines];
201
+ // The first prose line index, which starts the sentence.
202
+ const firstProseLine = proseLineIndexes[0];
203
+ // The last prose line index, which ends the sentence.
204
+ const lastProseLine = proseLineIndexes.at(-1);
205
+
206
+ formattedLines[firstProseLine] = formatBlockCommentLine(
207
+ formattedLines[firstProseLine],
208
+ capitaliseSentence,
209
+ );
210
+ formattedLines[lastProseLine] = formatBlockCommentLine(
211
+ formattedLines[lastProseLine],
212
+ addTerminalPunctuation,
213
+ );
214
+
215
+ return formattedLines.join(getNewline(sourceCode.text));
216
+ }
217
+
218
+ // The comment's prose, joined into a single paragraph.
219
+ const paragraphs = content
220
+ .split(/\r\n|\n|\r/)
221
+ .map((line) => line.replace(/^\s*\*?\s?/, "").trim())
222
+ .filter(Boolean)
223
+ .join(" ");
224
+
225
+ return ["/*", `${indentation} * ${formatSentence(paragraphs)}`, `${indentation} */`].join(
226
+ getNewline(sourceCode.text),
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Wrap an ordinary block comment to the configured maximum width.
232
+ *
233
+ * @param {object} sourceCode
234
+ * The Oxlint source code object.
235
+ * @param {string} commentText
236
+ * The sentence-formatted comment text to wrap.
237
+ * @param {string|null} indentation
238
+ * The indentation used by the wrapped comment.
239
+ *
240
+ * @returns {string|null}
241
+ * The wrapped comment, or null when it is not a standalone comment.
242
+ */
243
+ function formatBlockComment(sourceCode, commentText, indentation) {
244
+ if (indentation === null) {
245
+ return null;
246
+ }
247
+
248
+ // The comment body, without its delimiters or line markers.
249
+ const text = commentText
250
+ .slice(2, -2)
251
+ .split(/\r\n|\n|\r/)
252
+ .map((line) => line.replace(/^\s*\*?\s?/, "").trim())
253
+ .filter(Boolean)
254
+ .join(" ");
255
+
256
+ // The available width, allowing for the indent and " * " prefix.
257
+ const width = maximumLineLength - getDisplayWidth(indentation) - 3;
258
+ // The comment body, rewrapped to the available width.
259
+ const lines = wrapWords(text, Math.max(1, width));
260
+
261
+ return ["/*", ...lines.map((line) => `${indentation} * ${line}`), `${indentation} */`].join(
262
+ getNewline(sourceCode.text),
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Reindent and refill an ordinary block comment.
268
+ *
269
+ * @param {string} commentText
270
+ * The sentence-formatted comment text.
271
+ * @param {string} indentation
272
+ * The comment's current indentation.
273
+ * @param {string} expectedIndent
274
+ * The indentation used by the formatted comment.
275
+ * @param {string} newline
276
+ * The source file's newline sequence.
277
+ *
278
+ * @returns {string}
279
+ * The refilled comment text.
280
+ */
281
+ function refillBlockComment(commentText, indentation, expectedIndent, newline) {
282
+ if (!commentText.includes("\n") && !commentText.includes("\r")) {
283
+ return commentText;
284
+ }
285
+
286
+ // The comment after applying the indentation used to measure its width.
287
+ const reindentedComment = getReindentedCommentText(
288
+ commentText,
289
+ indentation,
290
+ expectedIndent,
291
+ newline,
292
+ );
293
+
294
+ // The comment's individual source lines, without the first line's outer
295
+ // indent.
296
+ const lines = reindentedComment.split(/\r\n|\n|\r/);
297
+
298
+ lines[0] = lines[0].slice(expectedIndent.length);
299
+
300
+ if (lines[0] !== "/*" || lines.at(-1).trim() !== "*/") {
301
+ return commentText;
302
+ }
303
+
304
+ // The comment prose lines with their existing display prefixes.
305
+ const proseLines = lines.slice(1, -1).map((line) => {
306
+ // The line's `*` decoration and the indentation around it.
307
+ const prefix = line.match(/^\s*\*\s*/)?.[0] ?? "";
308
+
309
+ return {
310
+ prefix,
311
+ text: line.slice(prefix.length),
312
+ };
313
+ });
314
+
315
+ // The prose after moving words from early-wrapped lines.
316
+ const refilledLines = refillCommentLines(proseLines, maximumLineLength);
317
+
318
+ return [
319
+ "/*",
320
+ ...refilledLines.map(({ prefix, text }) => `${prefix}${text}`.trimEnd()),
321
+ `${expectedIndent} */`,
322
+ ].join(newline);
323
+ }
324
+
325
+ /**
326
+ * Return the display lines for a formatted block comment.
327
+ *
328
+ * @param {string} commentText
329
+ * The formatted comment text.
330
+ * @param {string} indentation
331
+ * The indentation used to measure the comment.
332
+ *
333
+ * @returns {string[]}
334
+ * The comment lines as they appear on screen.
335
+ */
336
+ function getBlockCommentDisplayLines(commentText, indentation) {
337
+ // The comment's individual source lines.
338
+ const lines = commentText.split(/\r\n|\n|\r/);
339
+
340
+ return [`${indentation}${lines[0]}`, ...lines.slice(1)];
341
+ }
342
+
343
+ /**
344
+ * Work out where a comment above code should sit: the code's indentation, and
345
+ * the end of the gap to replace so exactly one line break separates them.
346
+ *
347
+ * The gap stops before a directive comment between the comment and its code, so
348
+ * the fix never edits the directive. When an ordinary comment sits between them
349
+ * instead, only the indentation is fixed and the gap is left alone.
350
+ *
351
+ * @param {object} sourceCode
352
+ * The Oxlint source code object.
353
+ * @param {object} comment
354
+ * The first comment in the formatted unit.
355
+ * @param {object} lastComment
356
+ * The last comment in the formatted unit.
357
+ * @param {object[]} comments
358
+ * Every comment token in source order.
359
+ *
360
+ * @returns {object|null}
361
+ * Placement details with `actualIndent`, `changed`, `expectedIndent`,
362
+ * `gap`, and `rangeEnd`; or null when the comment does not sit above code.
363
+ */
364
+ function getLeadingCommentPlacement(sourceCode, comment, lastComment, comments) {
365
+ // The code token the comment documents, and the token before the comment.
366
+ const { next, previous } = getCommentNeighbours(sourceCode, comment);
367
+
368
+ if (next === null || !isLeadingComment(sourceCode, comment, previous)) {
369
+ return null;
370
+ }
371
+
372
+ // The indentation required by the documented source token.
373
+ const expectedIndent = getLineIndent(sourceCode, next.range[0]);
374
+ // The comment's current indentation.
375
+ const actualIndent = getLineIndent(sourceCode, comment.range[0]);
376
+
377
+ if (expectedIndent === null || actualIndent === null) {
378
+ return null;
379
+ }
380
+
381
+ // The next comment after this unit, when one exists.
382
+ const followingComment = comments.find((candidate) => candidate.range[0] > lastComment.range[1]);
383
+
384
+ // Whether another comment sits between this one and its documented code.
385
+ const followingCommentIntervenes =
386
+ followingComment !== undefined && followingComment.range[0] <= next.range[0];
387
+
388
+ if (followingCommentIntervenes && !isDirectiveComment(followingComment)) {
389
+ return {
390
+ actualIndent,
391
+ changed: actualIndent !== expectedIndent,
392
+ expectedIndent,
393
+ gap: "",
394
+ rangeEnd: lastComment.range[1],
395
+ };
396
+ }
397
+
398
+ // Stop before an intervening directive so the replacement never overlaps
399
+ // it.
400
+ const rangeEnd = followingCommentIntervenes ? followingComment.range[0] : next.range[0];
401
+ // The source gap after the final comment, up to the code or directive.
402
+ const sourceGap = sourceCode.text.slice(lastComment.range[1], rangeEnd);
403
+ // The gap the documented code's indentation requires.
404
+ const gap = `${getNewline(sourceCode.text)}${expectedIndent}`;
405
+
406
+ return {
407
+ actualIndent,
408
+ changed: actualIndent !== expectedIndent || sourceGap !== gap,
409
+ expectedIndent,
410
+ gap,
411
+ rangeEnd,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Return the diagnostic message for a comment formatting report.
417
+ *
418
+ * @param {boolean} sentenceChanged
419
+ * Whether the comment's sentence punctuation needs changing.
420
+ * @param {boolean} placementChanged
421
+ * Whether the comment's placement needs changing.
422
+ *
423
+ * @returns {string}
424
+ * The diagnostic message.
425
+ */
426
+ function getReportMessage(sentenceChanged, placementChanged) {
427
+ if (sentenceChanged) {
428
+ return "Comment text must be a complete sentence.";
429
+ }
430
+
431
+ if (placementChanged) {
432
+ return "Comment must be immediately before the documented code.";
433
+ }
434
+
435
+ return "Format this comment.";
436
+ }
437
+
438
+ /**
439
+ * Report line comments that trail code on their source line.
440
+ *
441
+ * @param {object} context
442
+ * The Oxlint rule context.
443
+ */
444
+ function reportTrailingLineComments(context) {
445
+ // Every comment in the file, used to find trailing line comments.
446
+ const comments = context.sourceCode.getAllComments();
447
+
448
+ for (const comment of comments) {
449
+ if (
450
+ comment.type !== "Line" ||
451
+ isDirectiveComment(comment) ||
452
+ getLineIndent(context.sourceCode, comment.range[0]) !== null
453
+ ) {
454
+ continue;
455
+ }
456
+
457
+ // The source line where the trailing comment begins.
458
+ const commentLineStart = getLineStart(context.sourceCode, comment.range[0]);
459
+ // The text before the comment on its source line.
460
+ const linePrefix = context.sourceCode.text.slice(commentLineStart, comment.range[0]);
461
+ // The indentation shared by the code and the moved comment line.
462
+ const lineIndent = linePrefix.match(/^[ \t]*/)[0];
463
+ // Whether the comment gets sentence formatting; empty comments and
464
+ // comments that start with a tag are left as written.
465
+ const formatPunctuation = comment.value.trim() !== "" && !comment.value.trim().startsWith("@");
466
+ // The comment's value after sentence formatting.
467
+ const formattedValue = formatPunctuation ? formatSentence(comment.value) : comment.value;
468
+ // The comment source text after sentence formatting.
469
+ const commentText = replaceLineCommentValue(context.sourceCode, comment, formattedValue);
470
+ // The comment text with the line's indentation for width measurement.
471
+ const reindentedText = `${lineIndent}${commentText}`;
472
+
473
+ // The comment text after wrapping it to the line's available width.
474
+ const wrappedComment =
475
+ getDisplayWidth(reindentedText) > maximumLineLength
476
+ ? (formatLineComment(context.sourceCode, comment, lineIndent, commentText) ?? commentText)
477
+ : commentText;
478
+
479
+ // The formatted comment with indentation on every wrapped line.
480
+ const formattedComment = wrappedComment
481
+ .split(getNewline(context.sourceCode.text))
482
+ .map((line) => `${lineIndent}${line}`)
483
+ .join(getNewline(context.sourceCode.text));
484
+
485
+ // Whether sentence punctuation or capitalisation changed the comment.
486
+ const sentenceChanged = formattedValue !== comment.value;
487
+
488
+ context.report({
489
+ fix: getTrailingCommentFix(
490
+ context.sourceCode,
491
+ comment,
492
+ formattedComment,
493
+ commentLineStart,
494
+ linePrefix,
495
+ ),
496
+ message: sentenceChanged
497
+ ? "Comment text must be a complete sentence."
498
+ : "Line comments must be on their own line.",
499
+ node: comment,
500
+ });
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Move a trailing line comment above its source line.
506
+ *
507
+ * @param {object} sourceCode
508
+ * The Oxlint source code object.
509
+ * @param {object} comment
510
+ * The trailing line comment.
511
+ * @param {string} formattedComment
512
+ * The formatted comment text, including line indentation.
513
+ * @param {number} commentLineStart
514
+ * The offset at which the comment's source line starts.
515
+ * @param {string} linePrefix
516
+ * The source text before the comment on its line.
517
+ *
518
+ * @returns {function}
519
+ * A fixer callback that inserts the comment above its source line and
520
+ * removes it from the code line.
521
+ */
522
+ function getTrailingCommentFix(
523
+ sourceCode,
524
+ comment,
525
+ formattedComment,
526
+ commentLineStart,
527
+ linePrefix,
528
+ ) {
529
+ // The whitespace separating the code from the comment.
530
+ const trailingWhitespace = linePrefix.match(/[ \t]*$/)[0];
531
+ // The first character removed after the code on the comment's line.
532
+ const removalStart = comment.range[0] - trailingWhitespace.length;
533
+ // The newline sequence used by the source file.
534
+ const newline = getNewline(sourceCode.text);
535
+
536
+ return (fixer) => [
537
+ fixer.replaceTextRange([commentLineStart, commentLineStart], `${formattedComment}${newline}`),
538
+ fixer.removeRange([removalStart, comment.range[1]]),
539
+ ];
540
+ }
541
+
542
+ /**
543
+ * Report line-comment groups that need punctuation, reindentation or wrapping.
544
+ *
545
+ * @param {object} context
546
+ * The Oxlint rule context.
547
+ */
548
+ function reportLineCommentGroups(context) {
549
+ // Every comment in the file, used to find what follows each group.
550
+ const comments = context.sourceCode.getAllComments();
551
+
552
+ // Groups without comments that trail code, because those comments have
553
+ // their own placement fix.
554
+ const standaloneCommentGroups = getLineCommentGroups(context.sourceCode).map((commentGroup) =>
555
+ commentGroup.filter((comment) => getLineIndent(context.sourceCode, comment.range[0]) !== null),
556
+ );
557
+
558
+ for (const commentGroup of standaloneCommentGroups) {
559
+ if (commentGroup.length === 0) {
560
+ continue;
561
+ }
562
+
563
+ // The comment whose indentation the rest of the group follows.
564
+ const firstStandaloneComment = commentGroup[0];
565
+ // The indentation applied to every standalone comment in the group.
566
+ const firstIndent = getLineIndent(context.sourceCode, firstStandaloneComment.range[0]);
567
+
568
+ // The placement of a leading group, when it documents the next token.
569
+ const placement = getLeadingCommentPlacement(
570
+ context.sourceCode,
571
+ firstStandaloneComment,
572
+ commentGroup.at(-1),
573
+ comments,
574
+ );
575
+
576
+ // The indentation applied to the comment group's replacement.
577
+ const expectedIndent = placement?.expectedIndent ?? firstIndent;
578
+
579
+ // A stand-in for a comment token: replaceMinimalComment only reads its
580
+ // range, and the range spans every standalone comment plus its
581
+ // indentation.
582
+ const groupToken = {
583
+ range: [
584
+ getLineStart(context.sourceCode, firstStandaloneComment.range[0]),
585
+ placement?.rangeEnd ?? commentGroup.at(-1).range[1],
586
+ ],
587
+ };
588
+
589
+ // The group's current source text.
590
+ const sourceText = context.sourceCode.text.slice(...groupToken.range);
591
+ // The first comment's undecorated text.
592
+ const firstText = commentGroup[0].value.trim();
593
+ // Whether sentence punctuation applies to this group.
594
+ const formatPunctuation = firstText !== "" && !firstText.startsWith("@");
595
+
596
+ // The first comment's value, formatted as a sentence when needed.
597
+ let firstValue = commentGroup[0].value;
598
+ // The last comment's value, given a full stop when punctuation applies.
599
+ let lastValue = commentGroup.at(-1).value;
600
+
601
+ if (formatPunctuation) {
602
+ if (commentGroup.length === 1) {
603
+ firstValue = formatSentence(firstValue);
604
+ lastValue = firstValue;
605
+ } else {
606
+ firstValue = capitaliseSentence(firstValue);
607
+ lastValue = addTerminalPunctuation(lastValue);
608
+ }
609
+ }
610
+
611
+ // Whether sentence punctuation changes the group.
612
+ const sentenceChanged =
613
+ formatPunctuation &&
614
+ (firstValue !== commentGroup[0].value || lastValue !== commentGroup.at(-1).value);
615
+
616
+ // The group's lines after punctuation, reindentation, and line
617
+ // wrapping.
618
+ const formattedLines = commentGroup.flatMap((comment, index) => {
619
+ // The comment's value, using the group's first and last values.
620
+ let value = comment.value;
621
+
622
+ if (index === 0) {
623
+ value = firstValue;
624
+ } else if (index === commentGroup.length - 1) {
625
+ value = lastValue;
626
+ }
627
+
628
+ // The comment's source text after sentence formatting.
629
+ const commentText = replaceLineCommentValue(context.sourceCode, comment, value);
630
+ // The comment's current indentation, falling back to the leader's.
631
+ const commentIndent = getLineIndent(context.sourceCode, comment.range[0]) ?? firstIndent;
632
+ // The comment's extra indentation beyond the group leader's.
633
+ const relativeIndent = getRelativeIndent(commentIndent, firstIndent);
634
+ // The indentation this comment gets once the group is moved.
635
+ const commentExpectedIndent = expectedIndent + relativeIndent;
636
+ // The comment's text after applying the group's indentation.
637
+ const reindentedText = `${commentExpectedIndent}${commentText}`;
638
+
639
+ // The wrapped comment text, when the reindented line exceeds the
640
+ // limit.
641
+ const formattedComment =
642
+ getDisplayWidth(reindentedText) > maximumLineLength
643
+ ? (formatLineComment(context.sourceCode, comment, commentExpectedIndent, commentText) ??
644
+ commentText)
645
+ : commentText;
646
+
647
+ return formattedComment.split(getNewline(context.sourceCode.text)).map((line) => {
648
+ // The line without its `//` marker.
649
+ const lineText = line.slice(2);
650
+ // The line's leading whitespace after the comment marker.
651
+ const leadingWhitespace = lineText.match(/^\s*/)[0];
652
+
653
+ return {
654
+ prefix: `${commentExpectedIndent}//${leadingWhitespace}`,
655
+ text: lineText.slice(leadingWhitespace.length).trimEnd(),
656
+ };
657
+ });
658
+ });
659
+
660
+ // The group's lines after refilling words from early-wrapped lines.
661
+ const refilledLines = refillCommentLines(formattedLines, maximumLineLength);
662
+
663
+ // The group's text after applying its final line formatting and
664
+ // placement gap.
665
+ const formattedText =
666
+ refilledLines
667
+ .map(({ prefix, text }) => (text === "" ? prefix.trimEnd() : `${prefix}${text}`))
668
+ .join(getNewline(context.sourceCode.text)) + (placement?.gap ?? "");
669
+
670
+ if (formattedText === sourceText) {
671
+ continue;
672
+ }
673
+
674
+ // The diagnostic message for the group's changes.
675
+ const message = getReportMessage(sentenceChanged, placement?.changed ?? false);
676
+
677
+ context.report({
678
+ /**
679
+ * Apply the group's combined formatting fix.
680
+ *
681
+ * @param {object} fixer
682
+ * The Oxlint fixer.
683
+ *
684
+ * @returns {object}
685
+ * The fix for the complete comment group.
686
+ */
687
+ fix: (fixer) => replaceMinimalComment(fixer, groupToken, sourceText, formattedText),
688
+ message,
689
+ node: firstStandaloneComment,
690
+ });
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Report block comments that need punctuation or wrapping.
696
+ *
697
+ * @param {object} context
698
+ * The Oxlint rule context.
699
+ */
700
+ function reportBlockComments(context) {
701
+ // Every comment in the file, used to find what follows each comment.
702
+ const comments = context.sourceCode.getAllComments();
703
+
704
+ for (const comment of comments) {
705
+ if (comment.type === "Shebang" || comment.type === "Line" || isDirectiveComment(comment)) {
706
+ continue;
707
+ }
708
+
709
+ // The comment's raw source text.
710
+ const commentText = getCommentText(context.sourceCode, comment);
711
+ // The placement of a leading comment, when it documents the next token.
712
+ const placement = getLeadingCommentPlacement(context.sourceCode, comment, comment, comments);
713
+ // The comment's current indentation, or an empty string for inline
714
+ // comments.
715
+ const actualIndent = getLineIndent(context.sourceCode, comment.range[0]) ?? "";
716
+ // The indentation the formatted comment should use.
717
+ const expectedIndent = placement?.expectedIndent ?? actualIndent;
718
+
719
+ // The comment with its layout and sentence punctuation fixed, before
720
+ // wrapping.
721
+ let punctuatedComment;
722
+ // Whether fixing sentence capitalisation or punctuation changed the
723
+ // comment.
724
+ let sentenceChanged;
725
+
726
+ // The indentation and newline style that the JSDoc formatters keep.
727
+ const jsdocLayout = {
728
+ indent: expectedIndent,
729
+ newline: getNewline(context.sourceCode.text),
730
+ };
731
+
732
+ if (isJSDoc(commentText)) {
733
+ // The JSDoc comment with its block structure fixed before tag
734
+ // formatting.
735
+ const structuredComment = formatJSDocBlockStructure(commentText, jsdocLayout);
736
+
737
+ // The JSDoc comment with its tags spaced, ordered, and grouped.
738
+ // Comments without the tags this formats keep their prose line
739
+ // breaks.
740
+ const laidOutComment = hasTargetJSDocTag(commentText)
741
+ ? formatJSDocTagFormatting(structuredComment, jsdocLayout)
742
+ : structuredComment;
743
+
744
+ punctuatedComment = formatJSDocPunctuation(laidOutComment, jsdocLayout);
745
+ sentenceChanged = punctuatedComment !== laidOutComment;
746
+ } else {
747
+ punctuatedComment = formatOrdinaryBlockComment(context.sourceCode, comment);
748
+ sentenceChanged = punctuatedComment !== commentText;
749
+ }
750
+
751
+ // The comment after refilling prose lines that ended early.
752
+ const refilledComment = isJSDoc(commentText)
753
+ ? punctuatedComment
754
+ : refillBlockComment(
755
+ punctuatedComment,
756
+ actualIndent,
757
+ expectedIndent,
758
+ getNewline(context.sourceCode.text),
759
+ );
760
+
761
+ // Whether refilling changed the comment's layout.
762
+ const refillChanged = refilledComment !== punctuatedComment;
763
+ // The comment lines as they appear on screen after punctuation and
764
+ // refill.
765
+ const displayLines = getBlockCommentDisplayLines(refilledComment, expectedIndent);
766
+
767
+ // The comment, rewrapped when punctuation leaves a line over the limit.
768
+ let formattedComment = refilledComment;
769
+
770
+ // Whether any formatted line exceeds the width limit.
771
+ const hasOverlongLine = displayLines.some((line) => getDisplayWidth(line) > maximumLineLength);
772
+
773
+ if (hasOverlongLine) {
774
+ if (isJSDoc(commentText)) {
775
+ formattedComment = formatJSDocWrapping(punctuatedComment, jsdocLayout);
776
+ } else {
777
+ formattedComment = formatBlockComment(context.sourceCode, refilledComment, expectedIndent);
778
+ }
779
+ }
780
+
781
+ if (formattedComment === null || formattedComment === commentText) {
782
+ if (placement === null || !placement.changed) {
783
+ continue;
784
+ }
785
+ }
786
+
787
+ // The indentation the formatted comment was built with. JSDoc and
788
+ // rewrapped comments use the new indentation; other comments keep their
789
+ // current one until they are moved below.
790
+ const formattedIndent =
791
+ isJSDoc(commentText) || hasOverlongLine || refillChanged ? expectedIndent : actualIndent;
792
+
793
+ // The formatted comment text with its leading indentation.
794
+ const replacementComment =
795
+ placement === null
796
+ ? formattedComment
797
+ : getReindentedCommentText(
798
+ formattedComment,
799
+ formattedIndent,
800
+ expectedIndent,
801
+ getNewline(context.sourceCode.text),
802
+ );
803
+
804
+ // The source range includes the comment's line start and its placement
805
+ // gap.
806
+ const replacementToken =
807
+ placement === null
808
+ ? comment
809
+ : { range: [getLineStart(context.sourceCode, comment.range[0]), placement.rangeEnd] };
810
+
811
+ // The current source text for the complete replacement.
812
+ const sourceText = getCommentText(context.sourceCode, replacementToken);
813
+ // The replacement text, including the required gap before code or a
814
+ // directive.
815
+ const replacementText = `${replacementComment}${placement?.gap ?? ""}`;
816
+ // The diagnostic message for the comment's changes.
817
+ const message = getReportMessage(sentenceChanged, placement?.changed ?? false);
818
+
819
+ context.report({
820
+ /**
821
+ * Apply the wrapped replacement to the comment.
822
+ *
823
+ * @param {object} fixer
824
+ * The Oxlint fixer.
825
+ *
826
+ * @returns {object}
827
+ * The fix to apply.
828
+ */
829
+ fix: (fixer) => replaceMinimalComment(fixer, replacementToken, sourceText, replacementText),
830
+ message,
831
+ node: comment,
832
+ });
833
+ }
834
+ }
835
+
836
+ /**
837
+ * The comment-formatting rule: punctuates comments as sentences, refills
838
+ * early-wrapped lines, reindents line-comment groups and wraps any comment past
839
+ * 80 columns, replacing each comment in one edit.
840
+ */
841
+ export default {
842
+ meta: {
843
+ docs: { description: "Format comments to the configured layout." },
844
+ fixable: "code",
845
+ type: "layout",
846
+ },
847
+ /**
848
+ * Create the rule's node visitors.
849
+ *
850
+ * @param {object} context
851
+ * The Oxlint rule context.
852
+ *
853
+ * @returns {object}
854
+ * The visitor functions for this rule.
855
+ */
856
+ createOnce(context) {
857
+ return {
858
+ /**
859
+ * Format every non-directive comment in the file.
860
+ */
861
+ Program() {
862
+ reportTrailingLineComments(context);
863
+ reportLineCommentGroups(context);
864
+ reportBlockComments(context);
865
+ },
866
+ };
867
+ },
868
+ };