Skip to content

Commit 3bb25ef

Browse files
Refactor to use assertion handlers for easier extension
1 parent d6d32e7 commit 3bb25ef

File tree

8 files changed

+203
-128
lines changed

8 files changed

+203
-128
lines changed

source/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import tsd from './lib';
22

3-
export * from './lib/assert';
3+
export * from './lib/assertions/assert';
44
export default tsd;
File renamed without changes.
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {TypeChecker, CallExpression} from '../../../../libraries/typescript/lib/typescript';
2+
import {Diagnostic} from '../../interfaces';
3+
4+
/**
5+
* A handler is a method which accepts the TypeScript type checker together with a set of assertion nodes. The type checker
6+
* can be used to retrieve extra type information from these nodes in order to determine a list of diagnostics.
7+
*
8+
* @param typeChecker - The TypeScript type checker.
9+
* @param nodes - List of nodes.
10+
* @returns List of diagnostics.
11+
*/
12+
export type Handler = (typeChecker: TypeChecker, nodes: Set<CallExpression>) => Diagnostic[];
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {Handler} from './handler';
2+
3+
// Handlers
4+
export {strictAssertion} from './strict-assertion';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {TypeChecker, CallExpression} from '../../../../libraries/typescript/lib/typescript';
2+
import {Diagnostic} from '../../interfaces';
3+
4+
/**
5+
* Performs strict type assertion between the argument if the assertion, and the generic type of the assertion.
6+
*
7+
* @param checker - The TypeScript type checker.
8+
* @param nodes - The `expectType` AST nodes.
9+
* @return List of custom diagnostics.
10+
*/
11+
export const strictAssertion = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
12+
const diagnostics: Diagnostic[] = [];
13+
14+
if (!nodes) {
15+
return diagnostics;
16+
}
17+
18+
for (const node of nodes) {
19+
if (!node.typeArguments) {
20+
// Skip if the node does not have generics
21+
continue;
22+
}
23+
24+
// Retrieve the type to be expected. This is the type inside the generic.
25+
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
26+
const argumentType = checker.getTypeAtLocation(node.arguments[0]);
27+
28+
if (!checker.isAssignableTo(argumentType, expectedType)) {
29+
// The argument type is not assignable to the expected type. TypeScript will catch this for us.
30+
continue;
31+
}
32+
33+
if (!checker.isAssignableTo(expectedType, argumentType)) { // tslint:disable-line:early-exit
34+
/**
35+
* At this point, the expected type is not assignable to the argument type, but the argument type is
36+
* assignable to the expected type. This means our type is too wide.
37+
*/
38+
const position = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
39+
40+
diagnostics.push({
41+
fileName: node.getSourceFile().fileName,
42+
message: `Parameter type \`${checker.typeToString(expectedType)}\` is declared too wide for argument type \`${checker.typeToString(argumentType)}\`.`,
43+
severity: 'error',
44+
line: position.line + 1,
45+
column: position.character,
46+
});
47+
}
48+
}
49+
50+
return diagnostics;
51+
};

source/lib/assertions/index.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {TypeChecker, CallExpression} from '../../../libraries/typescript/lib/typescript';
2+
import {Diagnostic} from '../interfaces';
3+
import {Handler, strictAssertion} from './handlers';
4+
5+
export enum Assertion {
6+
EXPECT_TYPE = 'expectType',
7+
EXPECT_ERROR = 'expectError'
8+
}
9+
10+
// List of diagnostic handlers attached to the assertion
11+
const assertionHandlers = new Map<string, Handler | Handler[]>([
12+
[Assertion.EXPECT_TYPE, strictAssertion]
13+
]);
14+
15+
/**
16+
* Returns a list of diagnostics based on the assertions provided.
17+
*
18+
* @param typeChecker - The TypeScript type checker.
19+
* @param assertions - Assertion map with the key being the assertion, and the value the list of all those assertion nodes.
20+
* @returns List of diagnostics.
21+
*/
22+
export const handle = (typeChecker: TypeChecker, assertions: Map<Assertion, Set<CallExpression>>): Diagnostic[] => {
23+
const diagnostics: Diagnostic[] = [];
24+
25+
for (const [assertion, nodes] of assertions) {
26+
const handler = assertionHandlers.get(assertion);
27+
28+
if (!handler) {
29+
// Ignore these assertions as no handler is found
30+
continue;
31+
}
32+
33+
const handlers = Array.isArray(handler) ? handler : [handler];
34+
35+
// Iterate over the handlers and invoke them
36+
for (const fn of handlers) {
37+
diagnostics.push(...fn(typeChecker, nodes));
38+
}
39+
}
40+
41+
return diagnostics;
42+
};

source/lib/compiler.ts

+13-127
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,11 @@ import {
33
flattenDiagnosticMessageText,
44
createProgram,
55
Diagnostic as TSDiagnostic,
6-
Program,
7-
SourceFile,
8-
Node,
9-
forEachChild,
10-
isCallExpression,
11-
Identifier,
12-
TypeChecker,
13-
CallExpression
6+
SourceFile
147
} from '../../libraries/typescript';
8+
import {extractAssertions, parseErrorAssertionToLocation} from './parser';
159
import {Diagnostic, DiagnosticCode, Context, Location} from './interfaces';
10+
import {handle} from './assertions';
1611

1712
// List of diagnostic codes that should be ignored in general
1813
const ignoredDiagnostics = new Set<number>([
@@ -30,121 +25,12 @@ const diagnosticCodesToIgnore = new Set<DiagnosticCode>([
3025
DiagnosticCode.NoOverloadMatches
3126
]);
3227

33-
/**
34-
* Extract all assertions.
35-
*
36-
* @param program - TypeScript program.
37-
*/
38-
const extractAssertions = (program: Program) => {
39-
const typeAssertions = new Set<CallExpression>();
40-
const errorAssertions = new Set<CallExpression>();
41-
42-
function walkNodes(node: Node) {
43-
if (isCallExpression(node)) {
44-
const text = (node.expression as Identifier).getText();
45-
46-
if (text === 'expectType') {
47-
typeAssertions.add(node);
48-
} else if (text === 'expectError') {
49-
errorAssertions.add(node);
50-
}
51-
}
52-
53-
forEachChild(node, walkNodes);
54-
}
55-
56-
for (const sourceFile of program.getSourceFiles()) {
57-
walkNodes(sourceFile);
58-
}
59-
60-
return {
61-
typeAssertions,
62-
errorAssertions
63-
};
64-
};
65-
66-
/**
67-
* Loop over all the `expectError` nodes and convert them to a range map.
68-
*
69-
* @param nodes - Set of `expectError` nodes.
70-
*/
71-
const extractExpectErrorRanges = (nodes: Set<Node>) => {
72-
const expectedErrors = new Map<Location, Pick<Diagnostic, 'fileName' | 'line' | 'column'>>();
73-
74-
// Iterate over the nodes and add the node range to the map
75-
for (const node of nodes) {
76-
const location = {
77-
fileName: node.getSourceFile().fileName,
78-
start: node.getStart(),
79-
end: node.getEnd()
80-
};
81-
82-
const pos = node
83-
.getSourceFile()
84-
.getLineAndCharacterOfPosition(node.getStart());
85-
86-
expectedErrors.set(location, {
87-
fileName: location.fileName,
88-
line: pos.line + 1,
89-
column: pos.character
90-
});
91-
}
92-
93-
return expectedErrors;
94-
};
95-
96-
/**
97-
* Assert the expected type from `expectType` calls with the provided type in the argument.
98-
* Returns a list of custom diagnostics.
99-
*
100-
* @param checker - The TypeScript type checker.
101-
* @param nodes - The `expectType` AST nodes.
102-
* @return List of custom diagnostics.
103-
*/
104-
const assertTypes = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
105-
const diagnostics: Diagnostic[] = [];
106-
107-
for (const node of nodes) {
108-
if (!node.typeArguments) {
109-
// Skip if the node does not have generics
110-
continue;
111-
}
112-
113-
// Retrieve the type to be expected. This is the type inside the generic.
114-
const expectedType = checker.getTypeFromTypeNode(node.typeArguments[0]);
115-
const argumentType = checker.getTypeAtLocation(node.arguments[0]);
116-
117-
if (!checker.isAssignableTo(argumentType, expectedType)) {
118-
// The argument type is not assignable to the expected type. TypeScript will catch this for us.
119-
continue;
120-
}
121-
122-
if (!checker.isAssignableTo(expectedType, argumentType)) { // tslint:disable-line:early-exit
123-
/**
124-
* At this point, the expected type is not assignable to the argument type, but the argument type is
125-
* assignable to the expected type. This means our type is too wide.
126-
*/
127-
const position = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
128-
129-
diagnostics.push({
130-
fileName: node.getSourceFile().fileName,
131-
message: `Parameter type \`${checker.typeToString(expectedType)}\` is declared too wide for argument type \`${checker.typeToString(argumentType)}\`.`,
132-
severity: 'error',
133-
line: position.line + 1,
134-
column: position.character,
135-
});
136-
}
137-
}
138-
139-
return diagnostics;
140-
};
141-
14228
/**
14329
* Check if the provided diagnostic should be ignored.
14430
*
14531
* @param diagnostic - The diagnostic to validate.
14632
* @param expectedErrors - Map of the expected errors.
147-
* @return Boolean indicating if the diagnostic should be ignored or not.
33+
* @returns Boolean indicating if the diagnostic should be ignored or not.
14834
*/
14935
const ignoreDiagnostic = (diagnostic: TSDiagnostic, expectedErrors: Map<Location, any>): boolean => {
15036
if (ignoredDiagnostics.has(diagnostic.code)) {
@@ -180,28 +66,28 @@ const ignoreDiagnostic = (diagnostic: TSDiagnostic, expectedErrors: Map<Location
18066
export const getDiagnostics = (context: Context): Diagnostic[] => {
18167
const fileNames = context.testFiles.map(fileName => path.join(context.cwd, fileName));
18268

183-
const result: Diagnostic[] = [];
69+
const diagnostics: Diagnostic[] = [];
18470

18571
const program = createProgram(fileNames, context.config.compilerOptions);
18672

187-
const diagnostics = program
73+
const tsDiagnostics = program
18874
.getSemanticDiagnostics()
18975
.concat(program.getSyntacticDiagnostics());
19076

191-
const {typeAssertions, errorAssertions} = extractAssertions(program);
77+
const assertions = extractAssertions(program);
19278

193-
const expectedErrors = extractExpectErrorRanges(errorAssertions);
79+
diagnostics.push(...handle(program.getTypeChecker(), assertions));
19480

195-
result.push(...assertTypes(program.getTypeChecker(), typeAssertions));
81+
const expectedErrors = parseErrorAssertionToLocation(assertions);
19682

197-
for (const diagnostic of diagnostics) {
83+
for (const diagnostic of tsDiagnostics) {
19884
if (!diagnostic.file || ignoreDiagnostic(diagnostic, expectedErrors)) {
19985
continue;
20086
}
20187

20288
const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start as number);
20389

204-
result.push({
90+
diagnostics.push({
20591
fileName: diagnostic.file.fileName,
20692
message: flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
20793
severity: 'error',
@@ -211,12 +97,12 @@ export const getDiagnostics = (context: Context): Diagnostic[] => {
21197
}
21298

21399
for (const [, diagnostic] of expectedErrors) {
214-
result.push({
100+
diagnostics.push({
215101
...diagnostic,
216102
message: 'Expected an error, but found none.',
217103
severity: 'error'
218104
});
219105
}
220106

221-
return result;
107+
return diagnostics;
222108
};

source/lib/parser.ts

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {Program, Node, CallExpression, forEachChild, isCallExpression, Identifier} from '../../libraries/typescript';
2+
import {Assertion} from './assertions';
3+
import {Location, Diagnostic} from './interfaces';
4+
5+
// TODO: Use Object.values() when targetting Node.js >= 8
6+
const assertionTypes = new Set<string>(Object.keys(Assertion).map(key => Assertion[key]));
7+
8+
/**
9+
* Extract all assertions.
10+
*
11+
* @param program - TypeScript program.
12+
*/
13+
export const extractAssertions = (program: Program): Map<Assertion, Set<CallExpression>> => {
14+
const assertions = new Map<Assertion, Set<CallExpression>>();
15+
16+
/**
17+
* Recursively loop over all the nodes and extract all the assertions out of the source files.
18+
*/
19+
function walkNodes(node: Node) {
20+
if (isCallExpression(node)) {
21+
const text = (node.expression as Identifier).getText();
22+
23+
// Check if the call type is a valid assertion
24+
if (assertionTypes.has(text)) {
25+
const assertion = text as Assertion;
26+
27+
const nodes = assertions.get(assertion) || new Set<CallExpression>();
28+
29+
nodes.add(node);
30+
31+
assertions.set(assertion, nodes);
32+
}
33+
}
34+
35+
forEachChild(node, walkNodes);
36+
}
37+
38+
for (const sourceFile of program.getSourceFiles()) {
39+
walkNodes(sourceFile);
40+
}
41+
42+
return assertions;
43+
};
44+
45+
/**
46+
* Loop over all the error assertion nodes and convert them to a location map.
47+
*
48+
* @param assertions - Assertion map.
49+
*/
50+
export const parseErrorAssertionToLocation = (assertions: Map<Assertion, Set<CallExpression>>) => {
51+
const nodes = assertions.get(Assertion.EXPECT_ERROR);
52+
53+
const expectedErrors = new Map<Location, Pick<Diagnostic, 'fileName' | 'line' | 'column'>>();
54+
55+
if (!nodes) {
56+
// Bail out if we don't have any error nodes
57+
return expectedErrors;
58+
}
59+
60+
// Iterate over the nodes and add the node range to the map
61+
for (const node of nodes) {
62+
const location = {
63+
fileName: node.getSourceFile().fileName,
64+
start: node.getStart(),
65+
end: node.getEnd()
66+
};
67+
68+
const pos = node
69+
.getSourceFile()
70+
.getLineAndCharacterOfPosition(node.getStart());
71+
72+
expectedErrors.set(location, {
73+
fileName: location.fileName,
74+
line: pos.line + 1,
75+
column: pos.character
76+
});
77+
}
78+
79+
return expectedErrors;
80+
};

0 commit comments

Comments
 (0)