Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 68 additions & 25 deletions lint-rules/validate-jsdoc-codeblocks.js
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conversion of double quotes to single quotes and spaces to tabs now happens as part of union sorting, not during quickinfo type extraction, since formatting changes made earlier would otherwise be overwritten by the sorting step.

Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,19 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
});

function extractTypeFromQuickInfo(quickInfo) {
const {displayParts} = quickInfo;

// For interfaces and enums, return everything after the keyword
const keywordIndex = displayParts.findIndex(
part => part.kind === 'keyword' && ['interface', 'enum'].includes(part.text),
);

if (keywordIndex !== -1) {
return displayParts.slice(keywordIndex + 1).map(part => part.text).join('').trim();
}

let depth = 0;
const separatorIndex = quickInfo.displayParts.findIndex(part => {
const separatorIndex = displayParts.findIndex(part => {
if (part.kind === 'punctuation') {
if (['(', '{', '<'].includes(part.text)) {
depth++;
Expand All @@ -204,33 +215,65 @@ function extractTypeFromQuickInfo(quickInfo) {
return false;
});

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

return partsToUse
.map((part, index) => {
const {kind, text} = part;
function normalizeUnions(type) {
const sourceFile = ts.createSourceFile(
'twoslash-type.ts',
`declare const test: ${type};`,
ts.ScriptTarget.Latest,
);

const typeNode = sourceFile.statements[0].declarationList.declarations[0].type;

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

const visit = node => {
node = ts.visitEachChild(node, visit, undefined);

if (ts.isUnionTypeNode(node)) {
const types = node.types
.map(t => [print(t), t])
.sort(([a], [b]) =>
// Numbers are sorted only wrt other numbers
isNumeric(a) && isNumeric(b) ? Number(a) - Number(b) : 0,
)
.map(t => t[1]);

return ts.factory.updateUnionTypeNode(
node,
ts.factory.createNodeArray(types),
);
}

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

// Replace double-quoted string literals with single-quoted ones
if (kind === 'stringLiteral' && text.startsWith('"') && text.endsWith('"')) {
return `'${text
.slice(1, -1)
.replaceAll(String.raw`\"`, '"')
.replaceAll('\'', String.raw`\'`)}'`;
}
// Replace double-quoted string literals with single-quoted ones
if (ts.isStringLiteral(node)) {
const updated = ts.factory.createStringLiteral(node.text, true);
// Preserve non-ASCII characters like emojis.
ts.setEmitFlags(updated, ts.EmitFlags.NoAsciiEscaping);
return updated;
}

return node;
};

return text;
})
.join('')
.trim();
return print(visit(typeNode)).replaceAll(/^( +)/gm, indentation => {
// Replace spaces used for indentation with tabs
const spacesPerTab = 4;
const tabCount = Math.floor(indentation.length / spacesPerTab);
const remainingSpaces = indentation.length % spacesPerTab;
return '\t'.repeat(tabCount) + ' '.repeat(remainingSpaces);
});
}

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

if (quickInfo?.displayParts) {
let expectedType = extractTypeFromQuickInfo(quickInfo);
let expectedType = normalizeUnions(extractTypeFromQuickInfo(quickInfo));

if (expectedType.length < 80) {
expectedType = expectedType
Expand Down
65 changes: 62 additions & 3 deletions lint-rules/validate-jsdoc-codeblocks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,54 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
`,
)),

// Numbers are sorted in union
exportTypeAndOption(jsdoc(fence(dedenter`
import type {IntClosedRange} from 'type-fest';

type ZeroToNine = IntClosedRange<0, 9>;
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
`))),

// Nested union are sorted
exportTypeAndOption(jsdoc(fence(dedenter`
type Test = {w: 0 | 10 | 5; x: [2 | 16 | 4]; y: {z: 3 | 27 | 9}};
//=> {w: 0 | 5 | 10; x: [2 | 4 | 16]; y: {z: 3 | 9 | 27}}
`))),

// Unions inside unions are sorted
exportTypeAndOption(jsdoc(fence(dedenter`
type Test = {a: 'foo' | 27 | 1 | {b: 2 | 1 | 8 | 4} | 9 | 3 | 'bar'};
//=> {a: 'foo' | 1 | 3 | 9 | 27 | {b: 1 | 2 | 4 | 8} | 'bar'}
`))),

// Only numbers are sorted in union, non-numbers remain unchanged
exportTypeAndOption(jsdoc(fence(dedenter`
import type {ArrayElement} from 'type-fest';

type Tuple1 = ArrayElement<[null, string, boolean, 1, 3, 0, -2, 4, 2, -1]>;
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null

type Tuple2 = ArrayElement<[null, 1, 3, string, 0, -2, 4, 2, boolean, -1]>;
//=> string | boolean | -2 | -1 | 0 | 1 | 2 | 3 | 4 | null
`))),

// Tuples are in single line
exportTypeAndOption(jsdoc(fence(dedenter`
import type {TupleOf} from 'type-fest';

type RGB = TupleOf<3, number>;
//=> [number, number, number]

type TicTacToeBoard = TupleOf<3, TupleOf<3, '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, 'X' | 'O' | null]]
`))),

// Emojis are preserved
exportTypeAndOption(jsdoc(fence(dedenter`
type Pets = '🦄' | '🐶' | '🐇';
//=> '🦄' | '🐶' | '🐇'
`))),

// === Different types of quick info ===
// Function
exportTypeAndOption(jsdoc(fence(dedenter`
Expand Down Expand Up @@ -786,7 +834,13 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
// Interface
exportTypeAndOption(jsdoc(fence(dedenter`
interface Foo { foo: string; }
//=> interface Foo
//=> Foo
`))),

// Generic interface
exportTypeAndOption(jsdoc(fence(dedenter`
interface Foo<T> { foo: T; }
//=> Foo<T>
`))),

// Parameter
Expand Down Expand Up @@ -831,8 +885,13 @@ ruleTester.run('validate-jsdoc-codeblocks', validateJSDocCodeblocksRule, {
// Enum
exportTypeAndOption(jsdoc(fence(dedenter`
enum Foo {}
void Foo;
//=> enum Foo
//=> Foo
`))),

// Const enum
exportTypeAndOption(jsdoc(fence(dedenter`
const enum Foo { A = 1 }
//=> Foo
`))),

// Enum Member
Expand Down
2 changes: 1 addition & 1 deletion source/array-element.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type StringArray = ArrayElement<string[]>;

// Tuples
type Tuple = ArrayElement<[1, 2, 3]>;
//=> 3 | 1 | 2
//=> 1 | 2 | 3

// Type-safe
type NotArray = ArrayElement<{a: string}>;
Expand Down
8 changes: 4 additions & 4 deletions source/int-closed-range.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ Use-cases:
import type {IntClosedRange} from 'type-fest';

type Age = IntClosedRange<0, 20>;
//=> 0 | 1 | 20 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20

type FontSize = IntClosedRange<10, 20>;
//=> 20 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19
//=> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20

type EvenNumber = IntClosedRange<0, 10, 2>;
//=> 0 | 2 | 4 | 6 | 8 | 10
Expand All @@ -34,10 +34,10 @@ type EvenNumber = IntClosedRange<0, 10, 2>;
import type {IntClosedRange} from 'type-fest';

type ZeroToNine = IntClosedRange<0, 9>;
//=> 0 | 1 | 9 | 2 | 3 | 4 | 5 | 6 | 7 | 8
//=> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

type Hundreds = IntClosedRange<100, 900, 100>;
//=> 100 | 900 | 200 | 300 | 400 | 500 | 600 | 700 | 800
//=> 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
```

@see {@link IntRange}
Expand Down