Skip to content

Commit c7f0d38

Browse files
committed
fix: make validation agnostic of union order
1 parent 7c82a21 commit c7f0d38

File tree

2 files changed

+137
-32
lines changed

2 files changed

+137
-32
lines changed

lint-rules/validate-jsdoc-codeblocks.js

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
186186
},
187187
});
188188

189+
function getLeftmostQuickInfo(env, line, lineOffset) {
190+
for (let i = 0; i < line.length; i++) {
191+
const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, lineOffset + i);
192+
if (quickInfo?.displayParts) {
193+
return quickInfo;
194+
}
195+
}
196+
}
197+
189198
function extractTypeFromQuickInfo(quickInfo) {
190199
const {displayParts} = quickInfo;
191200

@@ -219,7 +228,7 @@ function extractTypeFromQuickInfo(quickInfo) {
219228
return displayParts.slice(separatorIndex + 1).map(part => part.text).join('').trim();
220229
}
221230

222-
function normalizeUnions(type) {
231+
function normalizeType(type, onlySortNumbers = false) {
223232
const sourceFile = ts.createSourceFile(
224233
'twoslash-type.ts',
225234
`declare const test: ${type};`,
@@ -229,6 +238,7 @@ function normalizeUnions(type) {
229238
const typeNode = sourceFile.statements[0].declarationList.declarations[0].type;
230239

231240
const print = node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile);
241+
232242
const isNumeric = v => v.trim() !== '' && Number.isFinite(Number(v));
233243

234244
const visit = node => {
@@ -237,9 +247,12 @@ function normalizeUnions(type) {
237247
if (ts.isUnionTypeNode(node)) {
238248
const types = node.types
239249
.map(t => [print(t), t])
240-
.sort(([a], [b]) =>
241-
// Numbers are sorted only wrt other numbers
242-
isNumeric(a) && isNumeric(b) ? Number(a) - Number(b) : 0,
250+
.sort(
251+
([a], [b]) => isNumeric(a) && isNumeric(b)
252+
? Number(a) - Number(b)
253+
: (onlySortNumbers
254+
? 0 // Numbers are sorted only wrt other numbers
255+
: a.localeCompare(b)),
243256
)
244257
.map(t => t[1]);
245258

@@ -276,6 +289,20 @@ function normalizeUnions(type) {
276289
});
277290
}
278291

292+
function getCommentForType(type) {
293+
let comment = type;
294+
295+
if (type.length < 80) {
296+
comment = type
297+
.replaceAll(/\r?\n\s*/g, ' ') // Collapse into single line
298+
.replaceAll(/{\s+/g, '{') // Remove spaces after `{`
299+
.replaceAll(/\s+}/g, '}') // Remove spaces before `}`
300+
.replaceAll(/;(?=})/g, ''); // Remove semicolons before `}`
301+
}
302+
303+
return `${TWOSLASH_COMMENT} ${comment.replaceAll('\n', '\n// ')}`;
304+
}
305+
279306
function validateTwoslashTypes(context, env, code, codeStartIndex) {
280307
const sourceFile = env.languageService.getProgram().getSourceFile(FILENAME);
281308
const lines = code.split('\n');
@@ -307,31 +334,27 @@ function validateTwoslashTypes(context, env, code, codeStartIndex) {
307334
const previousLine = lines[previousLineIndex];
308335
const previousLineOffset = sourceFile.getPositionOfLineAndCharacter(previousLineIndex, 0);
309336

310-
for (let i = 0; i < previousLine.length; i++) {
311-
const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, previousLineOffset + i);
337+
const actualCommentIndex = line.indexOf(TWOSLASH_COMMENT);
312338

313-
if (quickInfo?.displayParts) {
314-
let expectedType = normalizeUnions(extractTypeFromQuickInfo(quickInfo));
339+
const actualCommentStartOffset = sourceFile.getPositionOfLineAndCharacter(index, actualCommentIndex);
340+
const actualCommentEndOffset = sourceFile.getPositionOfLineAndCharacter(actualCommentEndLine, lines[actualCommentEndLine].length);
315341

316-
if (expectedType.length < 80) {
317-
expectedType = expectedType
318-
.replaceAll(/\r?\n\s*/g, ' ') // Collapse into single line
319-
.replaceAll(/{\s+/g, '{') // Remove spaces after `{`
320-
.replaceAll(/\s+}/g, '}') // Remove spaces before `}`
321-
.replaceAll(/;(?=})/g, ''); // Remove semicolons before `}`
322-
}
342+
const start = codeStartIndex + actualCommentStartOffset;
343+
const end = codeStartIndex + actualCommentEndOffset;
323344

324-
const expectedComment = TWOSLASH_COMMENT + ' ' + expectedType.replaceAll('\n', '\n// ');
345+
const quickInfo = getLeftmostQuickInfo(env, previousLine, previousLineOffset);
325346

326-
if (actualComment !== expectedComment) {
327-
const actualCommentIndex = line.indexOf(TWOSLASH_COMMENT);
347+
if (quickInfo?.displayParts) {
348+
const rawActualType = actualComment.slice(TWOSLASH_COMMENT.length).replaceAll('\n// ', '\n');
328349

329-
const actualCommentStartOffset = sourceFile.getPositionOfLineAndCharacter(index, actualCommentIndex);
330-
const actualCommentEndOffset = sourceFile.getPositionOfLineAndCharacter(actualCommentEndLine, lines[actualCommentEndLine].length);
350+
const expectedType = normalizeType(extractTypeFromQuickInfo(quickInfo));
351+
const actualType = normalizeType(rawActualType);
331352

332-
const start = codeStartIndex + actualCommentStartOffset;
333-
const end = codeStartIndex + actualCommentEndOffset;
353+
if (actualType === expectedType) {
354+
// If the types are equal, check for formatting errors and unordered numbers in unions
355+
const expectedComment = getCommentForType(normalizeType(rawActualType, true));
334356

357+
if (actualComment !== expectedComment) {
335358
context.report({
336359
loc: {
337360
start: context.sourceCode.getLocFromIndex(start),
@@ -352,8 +375,28 @@ function validateTwoslashTypes(context, env, code, codeStartIndex) {
352375
},
353376
});
354377
}
355-
356-
break;
378+
} else {
379+
const expectedComment = getCommentForType(expectedType);
380+
381+
context.report({
382+
loc: {
383+
start: context.sourceCode.getLocFromIndex(start),
384+
end: context.sourceCode.getLocFromIndex(end),
385+
},
386+
messageId: 'typeMismatch',
387+
data: {
388+
expectedComment,
389+
actualComment,
390+
},
391+
fix(fixer) {
392+
const indent = line.slice(0, actualCommentIndex);
393+
394+
return fixer.replaceTextRange(
395+
[start, end],
396+
expectedComment.replaceAll('\n', `\n${indent}`),
397+
);
398+
},
399+
});
357400
}
358401
}
359402
}

lint-rules/validate-jsdoc-codeblocks.test.js

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -757,35 +757,47 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
757757
`,
758758
)),
759759

760-
// Numbers are sorted in union
760+
// Order of non-numeric values in unions doesn't matter
761+
exportTypeAndOption(jsdoc(fence(dedenter`
762+
type Test = 'a' | 'b' | 'c' | {w: 'd' | 'e'; x: ['f' | 'g' | 'h']; y: {z: 'i' | 'j' | 'k'}};
763+
//=> 'b' | 'a' | {
764+
// w: 'e' | 'd';
765+
// x: ['h' | 'g' | 'f'];
766+
// y: {
767+
// z: 'i' | 'k' | 'j';
768+
// };
769+
// } | 'c'
770+
`))),
771+
772+
// Numbers are sorted in unions
761773
exportTypeAndOption(jsdoc(fence(dedenter`
762774
import type {IntClosedRange} from 'type-fest';
763775
764776
type ZeroToNine = IntClosedRange<0, 9>;
765777
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
766778
`))),
767779

768-
// Nested union are sorted
780+
// Numbers in nested unions are sorted
769781
exportTypeAndOption(jsdoc(fence(dedenter`
770782
type Test = {w: 0 | 10 | 5; x: [2 | 16 | 4]; y: {z: 3 | 27 | 9}};
771783
//=> {w: 0 | 5 | 10; x: [2 | 4 | 16]; y: {z: 3 | 9 | 27}}
772784
`))),
773785

774-
// Unions inside unions are sorted
786+
// Numbers in unions inside unions are sorted, non-numbers can be in any order
775787
exportTypeAndOption(jsdoc(fence(dedenter`
776-
type Test = {a: 'foo' | 27 | 1 | {b: 2 | 1 | 8 | 4} | 9 | 3 | 'bar'};
777-
//=> {a: 'foo' | 1 | 3 | 9 | 27 | {b: 1 | 2 | 4 | 8} | 'bar'}
788+
type Test = {a: 'foo' | 27 | 1 | {b: 2 | 1 | 8 | 4} | 'baz' | 9 | 3 | 'bar'};
789+
//=> {a: 1 | 3 | 9 | 27 | {b: 1 | 2 | 4 | 8} | 'bar' | 'foo' | 'baz'}
778790
`))),
779791

780-
// Only numbers are sorted in union, non-numbers remain unchanged
792+
// Only numbers are sorted in unions, non-numbers can be in any order
781793
exportTypeAndOption(jsdoc(fence(dedenter`
782794
import type {ArrayElement} from 'type-fest';
783795
784796
type Tuple1 = ArrayElement<[null, string, boolean, 1, 3, 0, -2, 4, 2, -1]>;
785-
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null
797+
//=> string | -2 | -1 | boolean | 0 | null | 1 | 2 | 3 | 4
786798
787799
type Tuple2 = ArrayElement<[null, 1, 3, string, 0, -2, 4, 2, boolean, -1]>;
788-
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null
800+
//=> -2 | boolean | -1 | 0 | 1 | 2 | 3 | 4 | null | string
789801
`))),
790802

791803
// Tuples are in single line
@@ -1002,6 +1014,31 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
10021014
`,
10031015
},
10041016

1017+
// Incorrect spacing
1018+
{
1019+
code: dedenter`
1020+
/**
1021+
\`\`\`ts
1022+
type Foo = 'a' | 'b' | { c: 'd' } | 'e';
1023+
//=> 'a'|'b' | { c : 'd' }| 'e'
1024+
\`\`\`
1025+
*/
1026+
export type T0 = string;
1027+
`,
1028+
errors: [
1029+
typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=> \'a\'|\'b\' | { c : \'d\' }| \'e\' '}),
1030+
],
1031+
output: dedenter`
1032+
/**
1033+
\`\`\`ts
1034+
type Foo = 'a' | 'b' | { c: 'd' } | 'e';
1035+
//=> 'a' | 'b' | {c: 'd'} | 'e'
1036+
\`\`\`
1037+
*/
1038+
export type T0 = string;
1039+
`,
1040+
},
1041+
10051042
// No space in subsequent lines
10061043
{
10071044
code: dedenter`
@@ -1039,6 +1076,31 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
10391076
`,
10401077
},
10411078

1079+
// Incorrect double quotes
1080+
{
1081+
code: dedenter`
1082+
/**
1083+
\`\`\`ts
1084+
type Foo = ["a", {b: "c", d: {e: "f"}}, "g" | "h"];
1085+
//=> ["a", {b: "c"; d: {e: "f"}}, "g" | "h"]
1086+
\`\`\`
1087+
*/
1088+
export type T0 = string;
1089+
`,
1090+
errors: [
1091+
typeMismatchErrorAt({line: 4, textBeforeStart: '', target: '//=> ["a", {b: "c"; d: {e: "f"}}, "g" | "h"]'}),
1092+
],
1093+
output: dedenter`
1094+
/**
1095+
\`\`\`ts
1096+
type Foo = ["a", {b: "c", d: {e: "f"}}, "g" | "h"];
1097+
//=> ['a', {b: 'c'; d: {e: 'f'}}, 'g' | 'h']
1098+
\`\`\`
1099+
*/
1100+
export type T0 = string;
1101+
`,
1102+
},
1103+
10421104
// Multiline replace
10431105
{
10441106
code: dedenter`

0 commit comments

Comments
 (0)