Skip to content

Commit

Permalink
Merge tsdjs#155
Browse files Browse the repository at this point in the history
  • Loading branch information
tommy-mitchell committed Sep 14, 2022
2 parents d5e3a6f + c3d0949 commit faf9ca6
Show file tree
Hide file tree
Showing 16 changed files with 190 additions and 34 deletions.
4 changes: 4 additions & 0 deletions readme.md
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions source/lib/assertions/assert.ts
Expand Up @@ -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 = <T>(expression: any) => {
// Do nothing, the TypeScript compiler handles this for us
};
2 changes: 1 addition & 1 deletion source/lib/assertions/handlers/index.ts
Expand Up @@ -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';
68 changes: 64 additions & 4 deletions 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.
Expand All @@ -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<CallExpression>): Diagnostic[] => {
export const printTypeWarning = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
const diagnostics: Diagnostic[] = [];

if (!nodes) {
Expand All @@ -19,8 +29,58 @@ export const prinTypeWarning = (checker: TypeChecker, nodes: Set<CallExpression>
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<CallExpression>): 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;
Expand Down
7 changes: 5 additions & 2 deletions source/lib/assertions/index.ts
Expand Up @@ -8,7 +8,8 @@ import {
expectDeprecated,
expectNotDeprecated,
isNever,
prinTypeWarning,
printTypeWarning,
expectDocCommentIncludes,
} from './handlers';

export enum Assertion {
Expand All @@ -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
Expand All @@ -31,7 +33,8 @@ const assertionHandlers = new Map<Assertion, Handler>([
[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],
]);

/**
Expand Down
46 changes: 35 additions & 11 deletions 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 = <R extends 'JSDoc' | 'DocComment'>(resolve: R) => {
type ConditionalResolveReturn = (R extends 'JSDoc' ? Map<string, JSDocTagInfo> : 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<string, JSDocTagInfo>(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`.
Expand All @@ -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<string, JSDocTagInfo> | undefined => {
const ref = isCallLikeExpression(expression) ?
checker.getResolvedSignature(expression) :
checker.getSymbolAtLocation(expression);
export const resolveJSDocTags = resolveCommentHelper('JSDoc');

if (!ref) {
return;
}

return new Map<string, JSDocTagInfo>(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.
Expand Down
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,15 @@
import {expectDocCommentIncludes} from '../../../..';

const noDocComment = 'no doc comment';

expectDocCommentIncludes<'no doc comment'>(noDocComment);

/** FooBar */
const foo = 'bar';

expectDocCommentIncludes(foo);
expectDocCommentIncludes<boolean>(foo);
expectDocCommentIncludes<'BarFoo'>(foo);
expectDocCommentIncludes<'FooBar'>(foo);
expectDocCommentIncludes<'Foo'>(foo);
expectDocCommentIncludes<'Bar'>(foo);
17 changes: 17 additions & 0 deletions 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;
}
3 changes: 3 additions & 0 deletions source/test/fixtures/informational/print-type/index.js
@@ -0,0 +1,3 @@
module.exports.default = foo => {
return foo > 0 ? foo : null;
};
@@ -1,5 +1,5 @@
import {printType} from '../../..';
import aboveZero from '.';
import {printType} from '../../../..';
import {aboveZero, bigType} from '.';

printType(aboveZero);
printType(null);
Expand All @@ -8,3 +8,4 @@ printType(null as any);
printType(null as never);
printType(null as unknown);
printType('foo');
printType(bigType);
3 changes: 3 additions & 0 deletions source/test/fixtures/informational/print-type/package.json
@@ -0,0 +1,3 @@
{
"name": "foo"
}
30 changes: 30 additions & 0 deletions 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`.'],
]);
});
14 changes: 0 additions & 14 deletions source/test/test.ts
Expand Up @@ -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')});

Expand Down

0 comments on commit faf9ca6

Please sign in to comment.