diff --git a/readme.md b/readme.md index 989d9f24..0fff1e9f 100644 --- a/readme.md +++ b/readme.md @@ -199,6 +199,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`. + ## Programmatic API You can use the programmatic API to retrieve the diagnostics and do something with them. This can be useful to run the tests with AVA, Jest or any other testing framework. 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 95846cb6..a7f08f3e 100644 --- a/source/lib/assertions/handlers/informational.ts +++ b/source/lib/assertions/handlers/informational.ts @@ -1,6 +1,6 @@ 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. @@ -19,7 +19,7 @@ const typeToStringFormatFlags = * @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) { @@ -36,3 +36,52 @@ export const prinTypeWarning = (checker: TypeChecker, nodes: Set 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, `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/informational/expect-doc-comment/index.d.ts b/source/test/fixtures/informational/expect-doc-comment/index.d.ts new file mode 100644 index 00000000..6f3a1c51 --- /dev/null +++ b/source/test/fixtures/informational/expect-doc-comment/index.d.ts @@ -0,0 +1 @@ +export default function (foo: number): number | null; 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/print-type/index.d.ts b/source/test/fixtures/informational/print-type/index.d.ts similarity index 100% rename from source/test/fixtures/print-type/index.d.ts rename to source/test/fixtures/informational/print-type/index.d.ts 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 84% rename from source/test/fixtures/print-type/index.test-d.ts rename to source/test/fixtures/informational/print-type/index.test-d.ts index 55ac5119..efe2fd54 100644 --- a/source/test/fixtures/print-type/index.test-d.ts +++ b/source/test/fixtures/informational/print-type/index.test-d.ts @@ -1,4 +1,4 @@ -import {printType} from '../../..'; +import {printType} from '../../../..'; import {aboveZero, bigType} from '.'; printType(aboveZero); 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 3529dd0f..4c44b1e7 100644 --- a/source/test/test.ts +++ b/source/test/test.ts @@ -442,21 +442,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"`'], - [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('assertions should be identified if imported as an aliased module', async t => { const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/aliased/aliased-module')});