diff --git a/readme.md b/readme.md index 1102e463..47114c69 100644 --- a/readme.md +++ b/readme.md @@ -172,6 +172,10 @@ Asserts that the type and return type of `expression` is `never`. Useful for checking that all branches are covered. +### expectDocCommentIncludes<T>(expression: any) + +Asserts that the documentation comment of `expression` includes string literal type `T`. + ## Configuration `tsd` is designed to be used with as little configuration as possible. However, if you need a bit more control, a project's `package.json` and the `tsd` CLI offer a limited set of configurations. diff --git a/source/lib/assertions/assert.ts b/source/lib/assertions/assert.ts index a9454850..88f4c863 100644 --- a/source/lib/assertions/assert.ts +++ b/source/lib/assertions/assert.ts @@ -92,3 +92,13 @@ export const expectNever = (expression: never): never => { export const printType = (expression: any) => { // Do nothing, the TypeScript compiler handles this for us }; + +/** + * Asserts that the documentation comment of `expression` includes string literal type `T`. + * + * @param expression - Expression whose documentation comment should include string literal type `T`. + */ +// @ts-expect-error +export const expectDocCommentIncludes = (expression: any) => { + // Do nothing, the TypeScript compiler handles this for us +}; diff --git a/source/lib/assertions/handlers/index.ts b/source/lib/assertions/handlers/index.ts index 01b3a47f..8386e765 100644 --- a/source/lib/assertions/handlers/index.ts +++ b/source/lib/assertions/handlers/index.ts @@ -4,4 +4,4 @@ export {Handler} from './handler'; export {isIdentical, isNotIdentical, isNever} from './identicality'; export {isNotAssignable} from './assignability'; export {expectDeprecated, expectNotDeprecated} from './expect-deprecated'; -export {prinTypeWarning} from './informational'; +export {printTypeWarning, expectDocCommentIncludes} from './informational'; diff --git a/source/lib/assertions/handlers/informational.ts b/source/lib/assertions/handlers/informational.ts index 493d6efc..a7f08f3e 100644 --- a/source/lib/assertions/handlers/informational.ts +++ b/source/lib/assertions/handlers/informational.ts @@ -1,6 +1,16 @@ -import {CallExpression, TypeChecker} from '@tsd/typescript'; +import {CallExpression, TypeChecker, TypeFormatFlags} from '@tsd/typescript'; import {Diagnostic} from '../../interfaces'; -import {makeDiagnostic} from '../../utils'; +import {makeDiagnostic, tsutils} from '../../utils'; + +/** + * Default formatting flags set by TS plus the {@link TypeFormatFlags.NoTruncation NoTruncation} flag. + * + * @see {@link https://github.dev/microsoft/TypeScript/blob/b975dfa1027d1f3073fa7cbe6f7045bf4c882785/src/compiler/checker.ts#L4717 TypeChecker.typeToString} + */ +const typeToStringFormatFlags = + TypeFormatFlags.AllowUniqueESSymbolType | + TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + TypeFormatFlags.NoTruncation; /** * Prints the type of the argument of the assertion as a warning. @@ -9,7 +19,7 @@ import {makeDiagnostic} from '../../utils'; * @param nodes - The `printType` AST nodes. * @return List of warning diagnostics containing the type of the first argument. */ -export const prinTypeWarning = (checker: TypeChecker, nodes: Set): Diagnostic[] => { +export const printTypeWarning = (checker: TypeChecker, nodes: Set): Diagnostic[] => { const diagnostics: Diagnostic[] = []; if (!nodes) { @@ -19,8 +29,58 @@ export const prinTypeWarning = (checker: TypeChecker, nodes: Set for (const node of nodes) { const argumentType = checker.getTypeAtLocation(node.arguments[0]); const argumentExpression = node.arguments[0].getText(); + const typeString = checker.typeToString(argumentType, node, typeToStringFormatFlags); + + diagnostics.push(makeDiagnostic(node, `Type for expression \`${argumentExpression}\` is: \`${typeString}\``, 'warning')); + } + + return diagnostics; +}; + +/** + * Asserts that the documentation comment for the argument of the assertion + * includes the string literal generic type of the assertion. + * + * @param checker - The TypeScript type checker. + * @param nodes - The `expectDocCommentIncludes` AST nodes. + * @return List of diagnostics. + */ +export const expectDocCommentIncludes = (checker: TypeChecker, nodes: Set): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + + if (!nodes) { + return diagnostics; + } + + for (const node of nodes) { + const expression = tsutils.expressionToString(checker, node.arguments[0]) ?? '?'; + + if (!node.typeArguments) { + diagnostics.push(makeDiagnostic(node, `Expected documentation comment for expression \`${expression}\` not specified.`)); + continue; + } + + const maybeExpectedDocComment = checker.getTypeFromTypeNode(node.typeArguments[0]); + + if (!maybeExpectedDocComment.isStringLiteral()) { + diagnostics.push(makeDiagnostic(node, `Expected documentation comment for expression \`${expression}\` should be a string literal.`)); + continue; + } + + const expectedDocComment = maybeExpectedDocComment.value; + const docComment = tsutils.resolveDocComment(checker, node.arguments[0]); + + if (!docComment) { + diagnostics.push(makeDiagnostic(node, `Documentation comment for expression \`${expression}\` not found.`)); + continue; + } + + if (docComment.includes(expectedDocComment)) { + // Do nothing + continue; + } - diagnostics.push(makeDiagnostic(node, `Type for expression \`${argumentExpression}\` is: \`${checker.typeToString(argumentType)}\``, 'warning')); + diagnostics.push(makeDiagnostic(node, `Documentation comment \`${docComment}\` for expression \`${expression}\` does not include expected \`${expectedDocComment}\`.`)); } return diagnostics; diff --git a/source/lib/assertions/index.ts b/source/lib/assertions/index.ts index a41b8024..6cf6f8e9 100644 --- a/source/lib/assertions/index.ts +++ b/source/lib/assertions/index.ts @@ -8,7 +8,8 @@ import { expectDeprecated, expectNotDeprecated, isNever, - prinTypeWarning, + printTypeWarning, + expectDocCommentIncludes, } from './handlers'; export enum Assertion { @@ -21,6 +22,7 @@ export enum Assertion { EXPECT_NOT_DEPRECATED = 'expectNotDeprecated', EXPECT_NEVER = 'expectNever', PRINT_TYPE = 'printType', + EXPECT_DOC_COMMENT_INCLUDES = 'expectDocCommentIncludes', } // List of diagnostic handlers attached to the assertion @@ -31,7 +33,8 @@ const assertionHandlers = new Map([ [Assertion.EXPECT_DEPRECATED, expectDeprecated], [Assertion.EXPECT_NOT_DEPRECATED, expectNotDeprecated], [Assertion.EXPECT_NEVER, isNever], - [Assertion.PRINT_TYPE, prinTypeWarning] + [Assertion.PRINT_TYPE, printTypeWarning], + [Assertion.EXPECT_DOC_COMMENT_INCLUDES, expectDocCommentIncludes], ]); /** diff --git a/source/lib/utils/typescript.ts b/source/lib/utils/typescript.ts index 9886edb7..7cc126c8 100644 --- a/source/lib/utils/typescript.ts +++ b/source/lib/utils/typescript.ts @@ -1,4 +1,29 @@ -import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo} from '@tsd/typescript'; +import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo, displayPartsToString} from '@tsd/typescript'; + +const resolveCommentHelper = (resolve: R) => { + type ConditionalResolveReturn = (R extends 'JSDoc' ? Map : string) | undefined; + + const handler = (checker: TypeChecker, expression: Expression): ConditionalResolveReturn => { + const ref = isCallLikeExpression(expression) ? + checker.getResolvedSignature(expression) : + checker.getSymbolAtLocation(expression); + + if (!ref) { + return; + } + + switch (resolve) { + case 'JSDoc': + return new Map(ref.getJsDocTags().map(tag => [tag.name, tag])) as ConditionalResolveReturn; + case 'DocComment': + return displayPartsToString(ref.getDocumentationComment(checker)) as ConditionalResolveReturn; + default: + return undefined; + } + }; + + return handler; +}; /** * Resolve the JSDoc tags from the expression. If these tags couldn't be found, it will return `undefined`. @@ -7,17 +32,16 @@ import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo} from '@tsd/ * @param expression - The expression to resolve the JSDoc tags for. * @return A unique Set of JSDoc tags or `undefined` if they couldn't be resolved. */ -export const resolveJSDocTags = (checker: TypeChecker, expression: Expression): Map | undefined => { - const ref = isCallLikeExpression(expression) ? - checker.getResolvedSignature(expression) : - checker.getSymbolAtLocation(expression); +export const resolveJSDocTags = resolveCommentHelper('JSDoc'); - if (!ref) { - return; - } - - return new Map(ref.getJsDocTags().map(tag => [tag.name, tag])); -}; +/** + * Resolve the documentation comment from the expression. If the comment can't be found, it will return `undefined`. + * + * @param checker - The TypeScript type checker. + * @param expression - The expression to resolve the documentation comment for. + * @return A string of the documentation comment or `undefined` if it can't be resolved. + */ +export const resolveDocComment = resolveCommentHelper('DocComment'); /** * Convert a TypeScript expression to a string. diff --git a/source/test/fixtures/print-type/index.d.ts b/source/test/fixtures/informational/expect-doc-comment/index.d.ts similarity index 100% rename from source/test/fixtures/print-type/index.d.ts rename to source/test/fixtures/informational/expect-doc-comment/index.d.ts diff --git a/source/test/fixtures/print-type/index.js b/source/test/fixtures/informational/expect-doc-comment/index.js similarity index 100% rename from source/test/fixtures/print-type/index.js rename to source/test/fixtures/informational/expect-doc-comment/index.js diff --git a/source/test/fixtures/informational/expect-doc-comment/index.test-d.ts b/source/test/fixtures/informational/expect-doc-comment/index.test-d.ts new file mode 100644 index 00000000..dced520c --- /dev/null +++ b/source/test/fixtures/informational/expect-doc-comment/index.test-d.ts @@ -0,0 +1,15 @@ +import {expectDocCommentIncludes} from '../../../..'; + +const noDocComment = 'no doc comment'; + +expectDocCommentIncludes<'no doc comment'>(noDocComment); + +/** FooBar */ +const foo = 'bar'; + +expectDocCommentIncludes(foo); +expectDocCommentIncludes(foo); +expectDocCommentIncludes<'BarFoo'>(foo); +expectDocCommentIncludes<'FooBar'>(foo); +expectDocCommentIncludes<'Foo'>(foo); +expectDocCommentIncludes<'Bar'>(foo); diff --git a/source/test/fixtures/print-type/package.json b/source/test/fixtures/informational/expect-doc-comment/package.json similarity index 100% rename from source/test/fixtures/print-type/package.json rename to source/test/fixtures/informational/expect-doc-comment/package.json diff --git a/source/test/fixtures/informational/print-type/index.d.ts b/source/test/fixtures/informational/print-type/index.d.ts new file mode 100644 index 00000000..12ee5dfd --- /dev/null +++ b/source/test/fixtures/informational/print-type/index.d.ts @@ -0,0 +1,17 @@ +export function aboveZero(foo: number): number | null; + +type SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace = { + life: 42 +}; + +export const bigType: { + prop1: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop2: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop3: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop4: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop5: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop6: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop7: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop8: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; + prop9: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; +} diff --git a/source/test/fixtures/informational/print-type/index.js b/source/test/fixtures/informational/print-type/index.js new file mode 100644 index 00000000..0d06f8dd --- /dev/null +++ b/source/test/fixtures/informational/print-type/index.js @@ -0,0 +1,3 @@ +module.exports.default = foo => { + return foo > 0 ? foo : null; +}; diff --git a/source/test/fixtures/print-type/index.test-d.ts b/source/test/fixtures/informational/print-type/index.test-d.ts similarity index 61% rename from source/test/fixtures/print-type/index.test-d.ts rename to source/test/fixtures/informational/print-type/index.test-d.ts index 9a17b216..efe2fd54 100644 --- a/source/test/fixtures/print-type/index.test-d.ts +++ b/source/test/fixtures/informational/print-type/index.test-d.ts @@ -1,5 +1,5 @@ -import {printType} from '../../..'; -import aboveZero from '.'; +import {printType} from '../../../..'; +import {aboveZero, bigType} from '.'; printType(aboveZero); printType(null); @@ -8,3 +8,4 @@ printType(null as any); printType(null as never); printType(null as unknown); printType('foo'); +printType(bigType); diff --git a/source/test/fixtures/informational/print-type/package.json b/source/test/fixtures/informational/print-type/package.json new file mode 100644 index 00000000..de6dc1db --- /dev/null +++ b/source/test/fixtures/informational/print-type/package.json @@ -0,0 +1,3 @@ +{ + "name": "foo" +} diff --git a/source/test/informational.ts b/source/test/informational.ts new file mode 100644 index 00000000..1f364d4e --- /dev/null +++ b/source/test/informational.ts @@ -0,0 +1,30 @@ +import path from 'path'; +import test from 'ava'; +import {verify} from './fixtures/utils'; +import tsd from '..'; + +test('print type', async t => { + const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/informational/print-type')}); + + verify(t, diagnostics, [ + [4, 0, 'warning', 'Type for expression `aboveZero` is: `(foo: number) => number | null`'], + [5, 0, 'warning', 'Type for expression `null` is: `null`'], + [6, 0, 'warning', 'Type for expression `undefined` is: `undefined`'], + [7, 0, 'warning', 'Type for expression `null as any` is: `any`'], + [8, 0, 'warning', 'Type for expression `null as never` is: `never`'], + [9, 0, 'warning', 'Type for expression `null as unknown` is: `unknown`'], + [10, 0, 'warning', 'Type for expression `\'foo\'` is: `"foo"`'], + [11, 0, 'warning', 'Type for expression `bigType` is: `{ prop1: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop2: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop3: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop4: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop5: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop6: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop7: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop8: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop9: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; }`'], + ]); +}); + +test('expect doc comment includes', async t => { + const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/informational/expect-doc-comment')}); + + verify(t, diagnostics, [ + [5, 0, 'error', 'Documentation comment for expression `noDocComment` not found.'], + [10, 0, 'error', 'Expected documentation comment for expression `foo` not specified.'], + [11, 0, 'error', 'Expected documentation comment for expression `foo` should be a string literal.'], + [12, 0, 'error', 'Documentation comment `FooBar` for expression `foo` does not include expected `BarFoo`.'], + ]); +}); diff --git a/source/test/test.ts b/source/test/test.ts index 174dfb4a..21ad2930 100644 --- a/source/test/test.ts +++ b/source/test/test.ts @@ -449,20 +449,6 @@ test('allow specifying `rootDir` option in `tsconfig.json`', async t => { verify(t, diagnostics, []); }); -test('prints the types of expressions passed to `printType` helper', async t => { - const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/print-type')}); - - verify(t, diagnostics, [ - [4, 0, 'warning', 'Type for expression `aboveZero` is: `(foo: number) => number | null`'], - [5, 0, 'warning', 'Type for expression `null` is: `null`'], - [6, 0, 'warning', 'Type for expression `undefined` is: `undefined`'], - [7, 0, 'warning', 'Type for expression `null as any` is: `any`'], - [8, 0, 'warning', 'Type for expression `null as never` is: `never`'], - [9, 0, 'warning', 'Type for expression `null as unknown` is: `unknown`'], - [10, 0, 'warning', 'Type for expression `\'foo\'` is: `"foo"`'], - ]); -}); - test('assertions should be identified if imported as an aliased module', async t => { const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/aliased/aliased-module')});