Skip to content

Commit 8387d9e

Browse files
Add --show-diff CLI option (#165)
Co-authored-by: Tommy <[email protected]>
1 parent 621aad9 commit 8387d9e

File tree

15 files changed

+289
-21
lines changed

15 files changed

+289
-21
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@tsd/typescript": "~4.9.5",
4343
"eslint-formatter-pretty": "^4.1.0",
4444
"globby": "^11.0.1",
45+
"jest-diff": "^29.0.3",
4546
"meow": "^9.0.0",
4647
"path-exists": "^4.0.0",
4748
"read-pkg-up": "^7.0.0"

source/cli.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ const cli = meow(`
1010
The given directory must contain a package.json and a typings file.
1111
1212
Info
13-
--help Display help text
14-
--version Display version info
13+
--help Display help text
14+
--version Display version info
1515
1616
Options
17-
--typings -t Type definition file to test [Default: "types" property in package.json]
18-
--files -f Glob of files to test [Default: '/path/test-d/**/*.test-d.ts' or '.tsx']
17+
--typings -t Type definition file to test [Default: "types" property in package.json]
18+
--files -f Glob of files to test [Default: '/path/test-d/**/*.test-d.ts' or '.tsx']
19+
--show-diff Show type error diffs [Default: don't show]
1920
2021
Examples
2122
$ tsd /path/to/project
@@ -37,21 +38,23 @@ const cli = meow(`
3738
alias: 'f',
3839
isMultiple: true,
3940
},
41+
showDiff: {
42+
type: 'boolean',
43+
},
4044
},
4145
});
4246

4347
(async () => {
4448
try {
4549
const cwd = cli.input.length > 0 ? cli.input[0] : process.cwd();
46-
const typingsFile = cli.flags.typings;
47-
const testFiles = cli.flags.files;
50+
const {typings: typingsFile, files: testFiles, showDiff} = cli.flags;
4851

4952
const options = {cwd, typingsFile, testFiles};
5053

5154
const diagnostics = await tsd(options);
5255

5356
if (diagnostics.length > 0) {
54-
throw new Error(formatter(diagnostics));
57+
throw new Error(formatter(diagnostics, showDiff));
5558
}
5659
} catch (error: unknown) {
5760
const potentialError = error as Error | undefined;

source/lib/assertions/handlers/assignability.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {CallExpression, TypeChecker} from '@tsd/typescript';
22
import {Diagnostic} from '../../interfaces';
3-
import {makeDiagnostic} from '../../utils';
3+
import {makeDiagnosticWithDiff} from '../../utils';
44

55
/**
66
* Asserts that the argument of the assertion is not assignable to the generic type of the assertion.
@@ -24,13 +24,19 @@ export const isNotAssignable = (checker: TypeChecker, nodes: Set<CallExpression>
2424

2525
// Retrieve the type to be expected. This is the type inside the generic.
2626
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
27-
const argumentType = checker.getTypeAtLocation(node.arguments[0]);
27+
const receivedType = checker.getTypeAtLocation(node.arguments[0]);
2828

29-
if (checker.isTypeAssignableTo(argumentType, expectedType)) {
29+
if (checker.isTypeAssignableTo(receivedType, expectedType)) {
3030
/**
3131
* The argument type is assignable to the expected type, we don't want this so add a diagnostic.
3232
*/
33-
diagnostics.push(makeDiagnostic(node, `Argument of type \`${checker.typeToString(argumentType)}\` is assignable to parameter of type \`${checker.typeToString(expectedType)}\`.`));
33+
diagnostics.push(makeDiagnosticWithDiff({
34+
message: 'Argument of type `{receivedType}` is assignable to parameter of type `{expectedType}`.',
35+
expectedType,
36+
receivedType,
37+
checker,
38+
node,
39+
}));
3440
}
3541
}
3642

source/lib/assertions/handlers/identicality.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {CallExpression, TypeChecker, TypeFlags} from '@tsd/typescript';
22
import {Diagnostic} from '../../interfaces';
3-
import {makeDiagnostic} from '../../utils';
3+
import {makeDiagnostic, makeDiagnosticWithDiff} from '../../utils';
44

55
/**
66
* Asserts that the argument of the assertion is identical to the generic type of the assertion.
@@ -26,25 +26,37 @@ export const isIdentical = (checker: TypeChecker, nodes: Set<CallExpression>): D
2626
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
2727

2828
// Retrieve the argument type. This is the type to be checked.
29-
const argumentType = checker.getTypeAtLocation(node.arguments[0]);
29+
const receivedType = checker.getTypeAtLocation(node.arguments[0]);
3030

31-
if (!checker.isTypeAssignableTo(argumentType, expectedType)) {
31+
if (!checker.isTypeAssignableTo(receivedType, expectedType)) {
3232
// The argument type is not assignable to the expected type. TypeScript will catch this for us.
3333
continue;
3434
}
3535

36-
if (!checker.isTypeAssignableTo(expectedType, argumentType)) {
36+
if (!checker.isTypeAssignableTo(expectedType, receivedType)) {
3737
/**
3838
* The expected type is not assignable to the argument type, but the argument type is
3939
* assignable to the expected type. This means our type is too wide.
4040
*/
41-
diagnostics.push(makeDiagnostic(node, `Parameter type \`${checker.typeToString(expectedType)}\` is declared too wide for argument type \`${checker.typeToString(argumentType)}\`.`));
42-
} else if (!checker.isTypeIdenticalTo(expectedType, argumentType)) {
41+
diagnostics.push(makeDiagnosticWithDiff({
42+
message: 'Parameter type `{expectedType}` is declared too wide for argument type `{receivedType}`.',
43+
expectedType,
44+
receivedType,
45+
checker,
46+
node,
47+
}));
48+
} else if (!checker.isTypeIdenticalTo(expectedType, receivedType)) {
4349
/**
4450
* The expected type and argument type are assignable in both directions. We still have to check
4551
* if the types are identical. See https://github.com/Microsoft/TypeScript/blob/master/doc/spec.md#3.11.2.
4652
*/
47-
diagnostics.push(makeDiagnostic(node, `Parameter type \`${checker.typeToString(expectedType)}\` is not identical to argument type \`${checker.typeToString(argumentType)}\`.`));
53+
diagnostics.push(makeDiagnosticWithDiff({
54+
message: 'Parameter type `{expectedType}` is not identical to argument type `{receivedType}`.',
55+
expectedType,
56+
receivedType,
57+
checker,
58+
node,
59+
}));
4860
}
4961
}
5062

source/lib/assertions/handlers/informational.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {CallExpression, TypeChecker, TypeFormatFlags} from '@tsd/typescript';
22
import {Diagnostic} from '../../interfaces';
3-
import {makeDiagnostic, tsutils} from '../../utils';
3+
import {makeDiagnostic, makeDiagnosticWithDiff, tsutils} from '../../utils';
44

55
/**
66
* Default formatting flags set by TS plus the {@link TypeFormatFlags.NoTruncation NoTruncation} flag.
@@ -80,7 +80,13 @@ export const expectDocCommentIncludes = (checker: TypeChecker, nodes: Set<CallEx
8080
continue;
8181
}
8282

83-
diagnostics.push(makeDiagnostic(node, `Documentation comment \`${docComment}\` for expression \`${expression}\` does not include expected \`${expectedDocComment}\`.`));
83+
diagnostics.push(makeDiagnosticWithDiff({
84+
message: `Documentation comment \`{receivedType}\` for expression \`${expression}\` does not include expected \`{expectedType}\`.`,
85+
expectedType: expectedDocComment,
86+
receivedType: docComment,
87+
checker,
88+
node,
89+
}));
8490
}
8591

8692
return diagnostics;

source/lib/formatter.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
import formatter from 'eslint-formatter-pretty';
22
import {Diagnostic} from './interfaces';
3+
import {diffStringsUnified} from 'jest-diff';
34

45
interface FileWithDiagnostics {
56
filePath: string;
67
errorCount: number;
78
warningCount: number;
89
messages: Diagnostic[];
10+
diff?: {
11+
expected: string;
12+
received: string;
13+
};
914
}
1015

1116
/**
1217
* Format the TypeScript diagnostics to a human readable output.
1318
*
1419
* @param diagnostics - List of TypeScript diagnostics.
20+
* @param showDiff - Display difference between expected and received types.
1521
* @returns Beautiful diagnostics output
1622
*/
17-
export default (diagnostics: Diagnostic[]): string => {
23+
export default (diagnostics: Diagnostic[], showDiff = false): string => {
1824
const fileMap = new Map<string, FileWithDiagnostics>();
1925

2026
for (const diagnostic of diagnostics) {
@@ -31,6 +37,19 @@ export default (diagnostics: Diagnostic[]): string => {
3137
fileMap.set(diagnostic.fileName, entry);
3238
}
3339

40+
if (showDiff && diagnostic.diff) {
41+
let difference = diffStringsUnified(
42+
diagnostic.diff.expected,
43+
diagnostic.diff.received,
44+
{omitAnnotationLines: true}
45+
);
46+
47+
if (difference) {
48+
difference = difference.split('\n').map(line => ` ${line}`).join('\n');
49+
diagnostic.message = `${diagnostic.message}\n\n${difference}`;
50+
}
51+
}
52+
3453
entry.errorCount++;
3554
entry.messages.push(diagnostic);
3655
}

source/lib/interfaces.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export interface Diagnostic {
6060
severity: 'error' | 'warning';
6161
line?: number;
6262
column?: number;
63+
diff?: {
64+
expected: string;
65+
received: string;
66+
};
6367
}
6468

6569
export interface Location {

source/lib/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import makeDiagnostic from './make-diagnostic';
2+
import makeDiagnosticWithDiff from './make-diagnostic-with-diff';
23
import getJSONPropertyPosition from './get-json-property-position';
34
import * as tsutils from './typescript';
45

56
export {
67
getJSONPropertyPosition,
78
makeDiagnostic,
9+
makeDiagnosticWithDiff,
810
tsutils
911
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {Node, Type, TypeChecker, TypeFormatFlags} from '@tsd/typescript';
2+
import {Diagnostic} from '../interfaces';
3+
4+
interface DiagnosticWithDiffOptions {
5+
checker: TypeChecker;
6+
node: Node;
7+
message: string;
8+
expectedType: Type | string;
9+
receivedType: Type | string;
10+
severity?: Diagnostic['severity'];
11+
}
12+
13+
const typeToStringFormatFlags =
14+
TypeFormatFlags.NoTruncation |
15+
TypeFormatFlags.WriteArrayAsGenericType |
16+
TypeFormatFlags.UseStructuralFallback |
17+
TypeFormatFlags.UseAliasDefinedOutsideCurrentScope |
18+
TypeFormatFlags.NoTypeReduction |
19+
TypeFormatFlags.AllowUniqueESSymbolType |
20+
TypeFormatFlags.InArrayType |
21+
TypeFormatFlags.InElementType |
22+
TypeFormatFlags.InFirstTypeArgument |
23+
TypeFormatFlags.InTypeAlias;
24+
25+
/**
26+
* Create a diagnostic with type error diffs from the given `options`, see {@link DiagnosticWithDiffOptions}.
27+
*
28+
* @param options - Options for creating the diagnostic.
29+
* @returns Diagnostic with diffs
30+
*/
31+
export default (options: DiagnosticWithDiffOptions): Diagnostic => {
32+
const {checker, node, expectedType, receivedType} = options;
33+
const position = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
34+
const message = options.message
35+
.replace('{expectedType}', typeof expectedType === 'string' ? expectedType : checker.typeToString(expectedType))
36+
.replace('{receivedType}', typeof receivedType === 'string' ? receivedType : checker.typeToString(receivedType));
37+
38+
return {
39+
fileName: node.getSourceFile().fileName,
40+
message,
41+
severity: options.severity ?? 'error',
42+
line: position.line + 1,
43+
column: position.character,
44+
diff: {
45+
expected: typeof expectedType === 'string' ? expectedType : checker.typeToString(expectedType, node, typeToStringFormatFlags),
46+
received: typeof receivedType === 'string' ? receivedType : checker.typeToString(receivedType, node, typeToStringFormatFlags)
47+
}
48+
};
49+
};

source/test/diff.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {verifyWithDiff} from './fixtures/utils';
2+
import execa, {ExecaError} from 'execa';
3+
import path from 'path';
4+
import test from 'ava';
5+
import tsd from '..';
6+
7+
test('diff', async t => {
8+
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/diff')});
9+
10+
verifyWithDiff(t, diagnostics, [
11+
[8, 0, 'error', 'Parameter type `{ life?: number | undefined; }` is declared too wide for argument type `{ life: number; }`.', {
12+
expected: '{ life?: number | undefined; }',
13+
received: '{ life: number; }',
14+
}],
15+
[9, 0, 'error', 'Parameter type `FooFunction` is not identical to argument type `() => number`.', {
16+
expected: '(x?: string | undefined) => number',
17+
received: '() => number',
18+
}],
19+
[10, 0, 'error', 'Parameter type `FooType` is declared too wide for argument type `Required<FooType>`.', {
20+
expected: '{ foo?: "foo" | undefined; }',
21+
received: '{ foo: "foo"; }',
22+
}],
23+
[11, 0, 'error', 'Parameter type `Partial<FooInterface>` is declared too wide for argument type `Required<FooInterface>`.', {
24+
expected: '{ [x: string]: unknown; readonly protected?: boolean | undefined; fooType?: FooType | undefined; id?: "foo-interface" | undefined; }',
25+
received: '{ [x: string]: unknown; readonly protected: boolean; fooType: FooType; id: "foo-interface"; }',
26+
}],
27+
[13, 0, 'error', 'Argument of type `{ life: number; }` is assignable to parameter of type `{ life?: number | undefined; }`.', {
28+
expected: '{ life?: number | undefined; }',
29+
received: '{ life: number; }',
30+
}],
31+
[18, 0, 'error', 'Documentation comment `This is a comment.` for expression `commented` does not include expected `This is not the same comment!`.', {
32+
expected: 'This is not the same comment!',
33+
received: 'This is a comment.',
34+
}]
35+
]);
36+
});
37+
38+
test('diff cli', async t => {
39+
const file = path.join(__dirname, 'fixtures/diff');
40+
41+
const {exitCode, stderr} = await t.throwsAsync<ExecaError>(execa('dist/cli.js', [file, '--show-diff']));
42+
43+
t.is(exitCode, 1);
44+
45+
const expectedLines = [
46+
'✖ 8:0 Parameter type { life?: number | undefined; } is declared too wide for argument type { life: number; }.',
47+
'',
48+
'- { life?: number | undefined; }',
49+
'+ { life: number; }',
50+
'✖ 9:0 Parameter type FooFunction is not identical to argument type () => number.',
51+
'',
52+
'- (x?: string | undefined) => number',
53+
'+ () => number',
54+
'✖ 10:0 Parameter type FooType is declared too wide for argument type Required<FooType>.',
55+
'',
56+
'- { foo?: "foo" | undefined; }',
57+
'+ { foo: "foo"; }',
58+
'✖ 11:0 Parameter type Partial<FooInterface> is declared too wide for argument type Required<FooInterface>.',
59+
'',
60+
'- { [x: string]: unknown; readonly protected?: boolean | undefined; fooType?: FooType | undefined; id?: "foo-interface" | undefined; }',
61+
'+ { [x: string]: unknown; readonly protected: boolean; fooType: FooType; id: "foo-interface"; }',
62+
'✖ 13:0 Argument of type { life: number; } is assignable to parameter of type { life?: number | undefined; }.',
63+
'',
64+
'- { life?: number | undefined; }',
65+
'+ { life: number; }',
66+
'✖ 18:0 Documentation comment This is a comment. for expression commented does not include expected This is not the same comment!.',
67+
'',
68+
'- This is not the same comment!',
69+
'+ This is a comment.',
70+
'',
71+
'6 errors'
72+
];
73+
74+
// NOTE: If lines are added to the output in the future startLine and endLine should be adjusted.
75+
const startLine = 2; // Skip tsd error message and file location.
76+
const endLine = startLine + expectedLines.length; // Grab diff output only and skip stack trace.
77+
78+
const receivedLines = stderr.trim().split('\n').slice(startLine, endLine).map(line => line.trim());
79+
80+
t.deepEqual(receivedLines, expectedLines);
81+
});

source/test/fixtures/diff/index.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type FooType = {foo?: 'foo'};
2+
3+
export interface FooInterface {
4+
[x: string]: unknown;
5+
readonly protected: boolean;
6+
fooType?: FooType;
7+
id: 'foo-interface';
8+
}
9+
10+
export type FooFunction = (x?: string) => number;
11+
12+
declare const foo: <T>(foo: T) => T;
13+
14+
export default foo;

source/test/fixtures/diff/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports.default = (foo) => {
2+
return foo;
3+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {expectDocCommentIncludes, expectNotAssignable, expectType} from '../../..';
2+
import foo, { FooFunction, FooInterface, FooType } from '.';
3+
4+
// Should pass
5+
expectType<{life: number}>(foo({life: 42}));
6+
7+
// Should fail
8+
expectType<{life?: number}>(foo({life: 42}));
9+
expectType<FooFunction>(() => 42);
10+
expectType<FooType>({} as Required<FooType>);
11+
expectType<Partial<FooInterface>>({} as Required<FooInterface>);
12+
13+
expectNotAssignable<{life?: number}>(foo({life: 42}));
14+
15+
/** This is a comment. */
16+
const commented = 42;
17+
18+
expectDocCommentIncludes<'This is not the same comment!'>(commented);

0 commit comments

Comments
 (0)