diff --git a/readme.md b/readme.md index aaf0aa3e..a3b2520b 100644 --- a/readme.md +++ b/readme.md @@ -170,6 +170,14 @@ Check if the function call has argument type errors. Check if a value is of the provided type `T`. +### expectDeprecated(value) + +Check that `value` is marked a [`@deprecated`](https://jsdoc.app/tags-deprecated.html). + +### expectNotDeprecated(value) + +Check that `value` is not marked a [`@deprecated`](https://jsdoc.app/tags-deprecated.html). + ## Programmatic API diff --git a/source/lib/assertions/assert.ts b/source/lib/assertions/assert.ts index 95442fdb..318c8880 100644 --- a/source/lib/assertions/assert.ts +++ b/source/lib/assertions/assert.ts @@ -37,3 +37,23 @@ export const expectNotAssignable = (value: any) => { // tslint:disable-line: export const expectError = (value: T) => { // tslint:disable-line:no-unused // Do nothing, the TypeScript compiler handles this for us }; + +/** + * Assert that the `expression` provided is marked as `@deprecated`. + * + * @param expression - Expression that should be marked as `@deprecated`. + */ +// @ts-ignore +export const expectDeprecated = (expression: any) => { // tslint:disable-line:no-unused + // Do nothing, the TypeScript compiler handles this for us +}; + +/** + * Assert that the `expression` provided is not marked as `@deprecated`. + * + * @param expression - Expression that should not be marked as `@deprecated`. + */ +// @ts-ignore +export const expectNotDeprecated = (expression: any) => { // tslint:disable-line:no-unused + // Do nothing, the TypeScript compiler handles this for us +}; diff --git a/source/lib/assertions/handlers/expect-deprecated.ts b/source/lib/assertions/handlers/expect-deprecated.ts new file mode 100644 index 00000000..10afcf4a --- /dev/null +++ b/source/lib/assertions/handlers/expect-deprecated.ts @@ -0,0 +1,63 @@ +import {JSDocTagInfo} from '../../../../libraries/typescript/lib/typescript'; +import {Diagnostic} from '../../interfaces'; +import {Handler} from './handler'; +import {makeDiagnostic, tsutils} from '../../utils'; + +interface Options { + filter(tags: Map): boolean; + message(signature: string): string; +} + +const expectDeprecatedHelper = (options: Options): Handler => { + return (checker, nodes) => { + const diagnostics: Diagnostic[] = []; + + if (!nodes) { + // Bail out if we don't have any nodes + return diagnostics; + } + + for (const node of nodes) { + const argument = node.arguments[0]; + + const tags = tsutils.resolveJSDocTags(checker, argument); + + if (!tags || !options.filter(tags)) { + // Bail out if not tags couldn't be resolved or when the node matches the filter expression + continue; + } + + const message = tsutils.expressionToString(checker, argument); + + diagnostics.push(makeDiagnostic(node, options.message(message || '?'))); + } + + return diagnostics; + }; +}; + +/** + * Assert that the argument from the `expectDeprecated` statement is marked as `@deprecated`. + * If it's not marked as `@deprecated`, an error diagnostic is returned. + * + * @param checker - The TypeScript type checker. + * @param nodes - The `expectDeprecated` AST nodes. + * @return List of diagnostics. + */ +export const expectDeprecated = expectDeprecatedHelper({ + filter: tags => !tags.has('deprecated'), + message: signature => `Expected \`${signature}\` to be marked as \`@deprecated\`` +}); + +/** + * Assert that the argument from the `expectNotDeprecated` statement is not marked as `@deprecated`. + * If it's marked as `@deprecated`, an error diagnostic is returned. + * + * @param checker - The TypeScript type checker. + * @param nodes - The `expectNotDeprecated` AST nodes. + * @return List of diagnostics. + */ +export const expectNotDeprecated = expectDeprecatedHelper({ + filter: tags => tags.has('deprecated'), + message: signature => `Expected \`${signature}\` to not be marked as \`@deprecated\`` +}); diff --git a/source/lib/assertions/handlers/index.ts b/source/lib/assertions/handlers/index.ts index fc77fc57..31d18101 100644 --- a/source/lib/assertions/handlers/index.ts +++ b/source/lib/assertions/handlers/index.ts @@ -3,3 +3,4 @@ export {Handler} from './handler'; // Handlers export {strictAssertion} from './strict-assertion'; export {isNotAssignable} from './assignability'; +export {expectDeprecated, expectNotDeprecated} from './expect-deprecated'; diff --git a/source/lib/assertions/index.ts b/source/lib/assertions/index.ts index 75bce292..8a9a700d 100644 --- a/source/lib/assertions/index.ts +++ b/source/lib/assertions/index.ts @@ -1,20 +1,23 @@ import {CallExpression} from '../../../libraries/typescript/lib/typescript'; import {TypeChecker} from '../entities/typescript'; import {Diagnostic} from '../interfaces'; -import {Handler, strictAssertion} from './handlers'; -import {isNotAssignable} from './handlers/assignability'; +import {Handler, strictAssertion, isNotAssignable, expectDeprecated, expectNotDeprecated} from './handlers'; export enum Assertion { EXPECT_TYPE = 'expectType', EXPECT_ERROR = 'expectError', EXPECT_ASSIGNABLE = 'expectAssignable', - EXPECT_NOT_ASSIGNABLE = 'expectNotAssignable' + EXPECT_NOT_ASSIGNABLE = 'expectNotAssignable', + EXPECT_DEPRECATED = 'expectDeprecated', + EXPECT_NOT_DEPRECATED = 'expectNotDeprecated' } // List of diagnostic handlers attached to the assertion const assertionHandlers = new Map([ [Assertion.EXPECT_TYPE, strictAssertion], - [Assertion.EXPECT_NOT_ASSIGNABLE, isNotAssignable] + [Assertion.EXPECT_NOT_ASSIGNABLE, isNotAssignable], + [Assertion.EXPECT_DEPRECATED, expectDeprecated], + [Assertion.EXPECT_NOT_DEPRECATED, expectNotDeprecated] ]); /** diff --git a/source/lib/utils/index.ts b/source/lib/utils/index.ts index c52dc01d..68fd1be9 100644 --- a/source/lib/utils/index.ts +++ b/source/lib/utils/index.ts @@ -1,7 +1,9 @@ import makeDiagnostic from './make-diagnostic'; import getJSONPropertyPosition from './get-json-property-position'; +import * as tsutils from './typescript'; export { getJSONPropertyPosition, - makeDiagnostic + makeDiagnostic, + tsutils }; diff --git a/source/lib/utils/typescript.ts b/source/lib/utils/typescript.ts new file mode 100644 index 00000000..8558c2cf --- /dev/null +++ b/source/lib/utils/typescript.ts @@ -0,0 +1,47 @@ +import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo} from '../../../libraries/typescript/lib/typescript'; + +/** + * Resolve the JSDoc tags from the expression. If these tags couldn't be found, it will return `undefined`. + * + * @param checker - The TypeScript type checker. + * @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); + + if (!ref) { + return; + } + + return new Map(ref.getJsDocTags().map(tag => [tag.name, tag])); +}; + +/** + * Convert a TypeScript expression to a string. + * + * @param checker - The TypeScript type checker. + * @param expression - The expression to convert. + * @return The string representation of the expression or `undefined` if it couldn't be resolved. + */ +export const expressionToString = (checker: TypeChecker, expression: Expression): string | undefined => { + if (isCallLikeExpression(expression)) { + const signature = checker.getResolvedSignature(expression); + + if (!signature) { + return; + } + + return checker.signatureToString(signature); + } + + const symbol = checker.getSymbolAtLocation(expression); + + if (!symbol) { + return; + } + + return checker.symbolToString(symbol, expression); +}; diff --git a/source/test/deprecated.ts b/source/test/deprecated.ts new file mode 100644 index 00000000..dba41749 --- /dev/null +++ b/source/test/deprecated.ts @@ -0,0 +1,26 @@ +import * as path from 'path'; +import test from 'ava'; +import {verify} from './fixtures/utils'; +import tsd from '..'; + +test('deprecated', async t => { + const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/deprecated/expect-deprecated')}); + + verify(t, diagnostics, [ + [6, 0, 'error', 'Expected `(foo: number, bar: number): number` to be marked as `@deprecated`'], + [15, 0, 'error', 'Expected `Options.delimiter` to be marked as `@deprecated`'], + [19, 0, 'error', 'Expected `Unicorn.RAINBOW` to be marked as `@deprecated`'], + [34, 0, 'error', 'Expected `RainbowClass` to be marked as `@deprecated`'] + ]); +}); + +test('not deprecated', async t => { + const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/deprecated/expect-not-deprecated')}); + + verify(t, diagnostics, [ + [5, 0, 'error', 'Expected `(foo: string, bar: string): string` to not be marked as `@deprecated`'], + [14, 0, 'error', 'Expected `Options.separator` to not be marked as `@deprecated`'], + [18, 0, 'error', 'Expected `Unicorn.UNICORN` to not be marked as `@deprecated`'], + [33, 0, 'error', 'Expected `UnicornClass` to not be marked as `@deprecated`'] + ]); +}); diff --git a/source/test/fixtures/deprecated/expect-deprecated/index.d.ts b/source/test/fixtures/deprecated/expect-deprecated/index.d.ts new file mode 100644 index 00000000..4ea93aac --- /dev/null +++ b/source/test/fixtures/deprecated/expect-deprecated/index.d.ts @@ -0,0 +1,26 @@ +export interface Options { + /** + * @deprecated + */ + readonly separator: string; + readonly delimiter: string; +} + +declare const concat: { + /** + * @deprecated + */ + (foo: string, bar: string): string; + (foo: string, bar: string, options: Options): string; + (foo: number, bar: number): number; +}; + +export const enum Unicorn { + /** + * @deprecated + */ + UNICORN = '🦄', + RAINBOW = '🌈' +} + +export default concat; \ No newline at end of file diff --git a/source/test/fixtures/deprecated/expect-deprecated/index.js b/source/test/fixtures/deprecated/expect-deprecated/index.js new file mode 100644 index 00000000..f17717f5 --- /dev/null +++ b/source/test/fixtures/deprecated/expect-deprecated/index.js @@ -0,0 +1,3 @@ +module.exports.default = (foo, bar) => { + return foo + bar; +}; diff --git a/source/test/fixtures/deprecated/expect-deprecated/index.test-d.ts b/source/test/fixtures/deprecated/expect-deprecated/index.test-d.ts new file mode 100644 index 00000000..5bbdff5e --- /dev/null +++ b/source/test/fixtures/deprecated/expect-deprecated/index.test-d.ts @@ -0,0 +1,34 @@ +import {expectDeprecated} from '../../../..'; +import concat, {Unicorn, Options} from '.'; + +// Methods +expectDeprecated(concat('foo', 'bar')); +expectDeprecated(concat(1, 2)); + +// Properties +const options: Options = { + separator: ',', + delimiter: '/' +}; + +expectDeprecated(options.separator); +expectDeprecated(options.delimiter); + +// ENUM +expectDeprecated(Unicorn.UNICORN); +expectDeprecated(Unicorn.RAINBOW); + +// Classes +/** + * @deprecated + */ +class UnicornClass { + readonly key = '🦄'; +} + +class RainbowClass { + readonly key = '🌈'; +} + +expectDeprecated(UnicornClass); +expectDeprecated(RainbowClass); diff --git a/source/test/fixtures/deprecated/expect-deprecated/package.json b/source/test/fixtures/deprecated/expect-deprecated/package.json new file mode 100644 index 00000000..de6dc1db --- /dev/null +++ b/source/test/fixtures/deprecated/expect-deprecated/package.json @@ -0,0 +1,3 @@ +{ + "name": "foo" +} diff --git a/source/test/fixtures/deprecated/expect-not-deprecated/index.d.ts b/source/test/fixtures/deprecated/expect-not-deprecated/index.d.ts new file mode 100644 index 00000000..30e498fe --- /dev/null +++ b/source/test/fixtures/deprecated/expect-not-deprecated/index.d.ts @@ -0,0 +1,26 @@ +export interface Options { + /** + * @deprecated + */ + readonly separator: string; + readonly delimiter: string; +} + +declare const concat: { + /** + * @deprecated + */ + (foo: string, bar: string): string; + (foo: string, bar: string, options: Options): string; + (foo: number, bar: number): number; +}; + +export const enum Unicorn { + /** + * @deprecated + */ + UNICORN = '🦄', + RAINBOW = '🌈' +} + +export default concat; diff --git a/source/test/fixtures/deprecated/expect-not-deprecated/index.js b/source/test/fixtures/deprecated/expect-not-deprecated/index.js new file mode 100644 index 00000000..f17717f5 --- /dev/null +++ b/source/test/fixtures/deprecated/expect-not-deprecated/index.js @@ -0,0 +1,3 @@ +module.exports.default = (foo, bar) => { + return foo + bar; +}; diff --git a/source/test/fixtures/deprecated/expect-not-deprecated/index.test-d.ts b/source/test/fixtures/deprecated/expect-not-deprecated/index.test-d.ts new file mode 100644 index 00000000..7255827e --- /dev/null +++ b/source/test/fixtures/deprecated/expect-not-deprecated/index.test-d.ts @@ -0,0 +1,34 @@ +import {expectNotDeprecated} from '../../../..'; +import concat, {Unicorn, Options} from '.'; + +// Methods +expectNotDeprecated(concat('foo', 'bar')); +expectNotDeprecated(concat(1, 2)); + +// Properties +const options: Options = { + separator: ',', + delimiter: '/' +}; + +expectNotDeprecated(options.separator); +expectNotDeprecated(options.delimiter); + +// ENUM +expectNotDeprecated(Unicorn.UNICORN); +expectNotDeprecated(Unicorn.RAINBOW); + +// Classes +/** + * @deprecated + */ +class UnicornClass { + readonly key = '🦄'; +} + +class RainbowClass { + readonly key = '🌈'; +} + +expectNotDeprecated(UnicornClass); +expectNotDeprecated(RainbowClass); diff --git a/source/test/fixtures/deprecated/expect-not-deprecated/package.json b/source/test/fixtures/deprecated/expect-not-deprecated/package.json new file mode 100644 index 00000000..de6dc1db --- /dev/null +++ b/source/test/fixtures/deprecated/expect-not-deprecated/package.json @@ -0,0 +1,3 @@ +{ + "name": "foo" +}