Skip to content

Commit 50c1438

Browse files
committed
feat: sort numbers in unions
1 parent 172407f commit 50c1438

File tree

4 files changed

+135
-33
lines changed

4 files changed

+135
-33
lines changed

lint-rules/validate-jsdoc-codeblocks.js

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,19 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
187187
});
188188

189189
function extractTypeFromQuickInfo(quickInfo) {
190+
const {displayParts} = quickInfo;
191+
192+
// For interfaces and enums, return everything after the keyword.
193+
const keywordIndex = displayParts.findIndex(
194+
part => part.kind === 'keyword' && ['interface', 'enum'].includes(part.text),
195+
);
196+
197+
if (keywordIndex !== -1) {
198+
return displayParts.slice(keywordIndex + 1).map(part => part.text).join('').trim();
199+
}
200+
190201
let depth = 0;
191-
const separatorIndex = quickInfo.displayParts.findIndex(part => {
202+
const separatorIndex = displayParts.findIndex(part => {
192203
if (part.kind === 'punctuation') {
193204
if (['(', '{', '<'].includes(part.text)) {
194205
depth++;
@@ -204,33 +215,65 @@ function extractTypeFromQuickInfo(quickInfo) {
204215
return false;
205216
});
206217

207-
let partsToUse = quickInfo.displayParts;
208-
if (separatorIndex !== -1) {
209-
partsToUse = quickInfo.displayParts.slice(separatorIndex + 1);
210-
}
218+
// If `separatorIndex` is `-1` (not found), return the entire thing.
219+
return displayParts.slice(separatorIndex + 1).map(part => part.text).join('').trim();
220+
}
211221

212-
return partsToUse
213-
.map((part, index) => {
214-
const {kind, text} = part;
222+
function normalizeUnions(type) {
223+
const sourceFile = ts.createSourceFile(
224+
'twoslash-type.ts',
225+
`declare const test: ${type};`,
226+
ts.ScriptTarget.Latest,
227+
);
228+
229+
const typeNode = sourceFile.statements[0].declarationList.declarations[0].type;
230+
231+
const print = node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile);
232+
const isNumeric = v => v.trim() !== '' && Number.isFinite(Number(v));
233+
234+
const visit = node => {
235+
node = ts.visitEachChild(node, visit, undefined);
236+
237+
if (ts.isUnionTypeNode(node)) {
238+
const types = node.types
239+
.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,
243+
)
244+
.map(t => t[1]);
245+
246+
return ts.factory.updateUnionTypeNode(
247+
node,
248+
ts.factory.createNodeArray(types),
249+
);
250+
}
215251

216-
// Replace spaces used for indentation with tabs
217-
const previousPart = partsToUse[index - 1];
218-
if (kind === 'space' && (index === 0 || previousPart?.kind === 'lineBreak')) {
219-
return text.replaceAll(' ', '\t');
220-
}
252+
// Prefer single-line formatting for tuple types.
253+
if (ts.isTupleTypeNode(node)) {
254+
const updated = ts.factory.createTupleTypeNode(node.elements);
255+
ts.setEmitFlags(updated, ts.EmitFlags.SingleLine);
256+
return updated;
257+
}
221258

222-
// Replace double-quoted string literals with single-quoted ones
223-
if (kind === 'stringLiteral' && text.startsWith('"') && text.endsWith('"')) {
224-
return `'${text
225-
.slice(1, -1)
226-
.replaceAll(String.raw`\"`, '"')
227-
.replaceAll('\'', String.raw`\'`)}'`;
228-
}
259+
// Replace double-quoted string literals with single-quoted ones.
260+
if (ts.isStringLiteral(node)) {
261+
const updated = ts.factory.createStringLiteral(node.text, true);
262+
// Preserve non-ASCII characters like emojis.
263+
ts.setEmitFlags(updated, ts.EmitFlags.NoAsciiEscaping);
264+
return updated;
265+
}
266+
267+
return node;
268+
};
229269

230-
return text;
231-
})
232-
.join('')
233-
.trim();
270+
return print(visit(typeNode)).replaceAll(/^( +)/gm, indentation => {
271+
// Replace spaces used for indentation with tabs
272+
const spacesPerTab = 4;
273+
const tabCount = Math.floor(indentation.length / spacesPerTab);
274+
const remainingSpaces = indentation.length % spacesPerTab;
275+
return '\t'.repeat(tabCount) + ' '.repeat(remainingSpaces);
276+
});
234277
}
235278

236279
function validateTwoslashTypes(context, env, code, codeStartIndex) {
@@ -268,7 +311,7 @@ function validateTwoslashTypes(context, env, code, codeStartIndex) {
268311
const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, previousLineOffset + i);
269312

270313
if (quickInfo?.displayParts) {
271-
let expectedType = extractTypeFromQuickInfo(quickInfo);
314+
let expectedType = normalizeUnions(extractTypeFromQuickInfo(quickInfo));
272315

273316
if (expectedType.length < 80) {
274317
expectedType = expectedType

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,54 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
757757
`,
758758
)),
759759

760+
// Numbers are sorted in union
761+
exportTypeAndOption(jsdoc(fence(dedenter`
762+
import type {IntClosedRange} from 'type-fest';
763+
764+
type ZeroToNine = IntClosedRange<0, 9>;
765+
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
766+
`))),
767+
768+
// Nested union are sorted
769+
exportTypeAndOption(jsdoc(fence(dedenter`
770+
type Test = {w: 0 | 10 | 5; x: [2 | 16 | 4]; y: {z: 3 | 27 | 9}};
771+
//=> {w: 0 | 5 | 10; x: [2 | 4 | 16]; y: {z: 3 | 9 | 27}}
772+
`))),
773+
774+
// Unions inside unions are sorted
775+
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'}
778+
`))),
779+
780+
// Only numbers are sorted in union, non-numbers remain unchanged
781+
exportTypeAndOption(jsdoc(fence(dedenter`
782+
import type {ArrayElement} from 'type-fest';
783+
784+
type Tuple1 = ArrayElement<[null, string, boolean, 1, 3, 0, -2, 4, 2, -1]>;
785+
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null
786+
787+
type Tuple2 = ArrayElement<[null, 1, 3, string, 0, -2, 4, 2, boolean, -1]>;
788+
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null
789+
`))),
790+
791+
// Tuples are in single line
792+
exportTypeAndOption(jsdoc(fence(dedenter`
793+
import type {TupleOf} from 'type-fest';
794+
795+
type RGB = TupleOf<3, number>;
796+
//=> [number, number, number]
797+
798+
type TicTacToeBoard = TupleOf<3, TupleOf<3, 'X' | 'O' | null>>;
799+
//=> [['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null], ['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null], ['X' | 'O' | null, 'X' | 'O' | null, 'X' | 'O' | null]]
800+
`))),
801+
802+
// Emojis are preserved
803+
exportTypeAndOption(jsdoc(fence(dedenter`
804+
type Pets = '🦄' | '🐶' | '🐇';
805+
//=> '🦄' | '🐶' | '🐇'
806+
`))),
807+
760808
// === Different types of quick info ===
761809
// Function
762810
exportTypeAndOption(jsdoc(fence(dedenter`
@@ -786,7 +834,13 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
786834
// Interface
787835
exportTypeAndOption(jsdoc(fence(dedenter`
788836
interface Foo { foo: string; }
789-
//=> interface Foo
837+
//=> Foo
838+
`))),
839+
840+
// Generic interface
841+
exportTypeAndOption(jsdoc(fence(dedenter`
842+
interface Foo<T> { foo: T; }
843+
//=> Foo<T>
790844
`))),
791845

792846
// Parameter
@@ -831,8 +885,13 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
831885
// Enum
832886
exportTypeAndOption(jsdoc(fence(dedenter`
833887
enum Foo {}
834-
void Foo;
835-
//=> enum Foo
888+
//=> Foo
889+
`))),
890+
891+
// Const enum
892+
exportTypeAndOption(jsdoc(fence(dedenter`
893+
const enum Foo { A = 1 }
894+
//=> Foo
836895
`))),
837896

838897
// Enum Member

source/array-element.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type StringArray = ArrayElement<string[]>;
2020
2121
// Tuples
2222
type Tuple = ArrayElement<[1, 2, 3]>;
23-
//=> 3 | 1 | 2
23+
//=> 1 | 2 | 3
2424
2525
// Type-safe
2626
type NotArray = ArrayElement<{a: string}>;

source/int-closed-range.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ Use-cases:
1818
import type {IntClosedRange} from 'type-fest';
1919
2020
type Age = IntClosedRange<0, 20>;
21-
//=> 0 | 1 | 20 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19
21+
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20
2222
2323
type FontSize = IntClosedRange<10, 20>;
24-
//=> 20 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19
24+
//=> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20
2525
2626
type EvenNumber = IntClosedRange<0, 10, 2>;
2727
//=> 0 | 2 | 4 | 6 | 8 | 10
@@ -34,10 +34,10 @@ type EvenNumber = IntClosedRange<0, 10, 2>;
3434
import type {IntClosedRange} from 'type-fest';
3535
3636
type ZeroToNine = IntClosedRange<0, 9>;
37-
//=> 0 | 1 | 9 | 2 | 3 | 4 | 5 | 6 | 7 | 8
37+
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
3838
3939
type Hundreds = IntClosedRange<100, 900, 100>;
40-
//=> 100 | 900 | 200 | 300 | 400 | 500 | 600 | 700 | 800
40+
//=> 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
4141
```
4242
4343
@see {@link IntRange}

0 commit comments

Comments
 (0)