Skip to content

Commit

Permalink
feat: assert api detection
Browse files Browse the repository at this point in the history
  • Loading branch information
skarab42 committed Jul 10, 2022
1 parent ac9bf40 commit 7618b52
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 11 deletions.
24 changes: 24 additions & 0 deletions src/api/tsd/index.ts
@@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { API_PROPERTY_KEY } from '../../common/internal';

// TODO: add docblock !!!
export function expectType<ExpectedType>(_value: ExpectedType) {}
export function expectNotType<ExpectedType>(_value: any) {}
export function expectAssignable<ExpectedType>(_value: ExpectedType) {}
export function expectNotAssignable<ExpectedType>(_value: any) {}
export function expectError<ExpectedType = any>(_value: ExpectedType) {}
export function expectDeprecated(_expression: any) {}
export function expectNotDeprecated(_expression: any) {}
export function printType(_expression: any) {}

expectType[API_PROPERTY_KEY] = 'tsd:expectType' as const;
expectNotType[API_PROPERTY_KEY] = 'tsd:expectNotType' as const;
expectAssignable[API_PROPERTY_KEY] = 'tsd:expectAssignable' as const;
expectNotAssignable[API_PROPERTY_KEY] = 'tsd:expectNotAssignable' as const;
expectError[API_PROPERTY_KEY] = 'tsd:expectError' as const;
expectDeprecated[API_PROPERTY_KEY] = 'tsd:expectDeprecated' as const;
expectNotDeprecated[API_PROPERTY_KEY] = 'tsd:expectNotDeprecated' as const;
printType[API_PROPERTY_KEY] = 'tsd:printType' as const;
24 changes: 24 additions & 0 deletions src/api/tssert/index.ts
@@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { API_PROPERTY_KEY } from '../../common/internal';

// TODO: add docblock !!!
function assignableTo<TargetType>(_target?: unknown) {}
function notAssignableTo<TargetType>(_target?: unknown) {}

expectType[API_PROPERTY_KEY] = 'tssert:expectType' as const;
assignableTo[API_PROPERTY_KEY] = 'tssert:assignableTo' as const;
notAssignableTo[API_PROPERTY_KEY] = 'tssert:notAssignableTo' as const;

const api = {
assignableTo,
not: {
assignableTo: notAssignableTo,
},
} as const;

// TODO: add docblock !!!
export function expectType<SourceType>(_source?: unknown) {
return api;
}
1 change: 1 addition & 0 deletions src/common/internal.ts
@@ -0,0 +1 @@
export const API_PROPERTY_KEY = 'vite-plugin-vitest-typescript-assert-api-property-key'; // Symbol('api-property-key');
82 changes: 82 additions & 0 deletions src/plugin/diagnostics.ts
@@ -0,0 +1,82 @@
import ts from 'byots';
import type { ErrorObject } from './types';
import type MagicString from 'magic-string';
import { newLine } from '../typescript/util';
import { testWrapperIdentifiers } from './identifiers';

export interface TestWrapper {
name: string;
handler: ts.Expression;
expression: ts.Expression;
}

export function searchTestWrapperFromPosition(sourceFile: ts.SourceFile, position: number): TestWrapper | undefined {
let wrapper: TestWrapper | undefined = undefined;

function visit(node: ts.Node): void {
if (node.getStart() > position) {
return;
}

if (ts.isCallExpression(node)) {
const expression = node.expression;
const identifier = expression.getText();

if (testWrapperIdentifiers.includes(identifier)) {
const name = node.arguments[0]?.getText().slice(1, -1);
const handler = node.arguments[1];

if (name && handler) {
wrapper = { name, expression, handler };
}
}
}

node.forEachChild(visit);
}

visit(sourceFile);

return wrapper;
}

export function reportDiagnostics(diagnostics: readonly ts.Diagnostic[], newCode: MagicString, fileName: string): void {
diagnostics.forEach(({ messageText, start, file }) => {
if (file) {
const startPosition = start ?? 0;
const token = searchTestWrapperFromPosition(file, startPosition);
const { line, character } = ts.getLineAndCharacterOfPosition(file, startPosition);
const message = ts.flattenDiagnosticMessageText(messageText, newLine).replace(/`/g, '\\`');

if (token) {
const position = token.handler.getEnd() - 1;
newCode.appendLeft(position, createErrorString({ message, file: fileName, line, column: character }));
} else {
newCode.append(createErrorString({ message, file: fileName, line, column: character }));
}
}
});
}

export function createErrorString({ message, file, line, column }: ErrorObject): string {
return `
// error generated by vite-plugin-vitest-typescript-assert
throw (() => {
const err = new TypeError(\`${message}\`);
err.name = "TypeError";
err.stack = "";
err.stackStr = "";
err.stacks = [{
file: "${file}",
line: ${line + 1},
column: ${column + 1},
sourcePos: {
source: "${file}",
line: ${line + 1},
column: ${column + 1}
}
}];
return err;
})()
`;
}
28 changes: 28 additions & 0 deletions src/plugin/identifiers.ts
@@ -0,0 +1,28 @@
// TODO: should be set by user config?!
export const testWrapperIdentifiers = [
'it',
'it.skip',
'it.skipIf',
'it.runIf',
'it.only',
'it.concurrent',
'it.todo',
'it.fails',
'it.each',
'test',
'test.skip',
'test.skipIf',
'test.runIf',
'test.only',
'test.concurrent',
'test.todo',
'test.fails',
'test.each',
'describe',
'describe.skip',
'describe.only',
'describe.concurrent',
'describe.shuffle',
'describe.todo',
'describe.each',
];
69 changes: 66 additions & 3 deletions src/plugin/transform.ts
@@ -1,9 +1,72 @@
import ts from 'byots';
import MagicString from 'magic-string';
import type { TransformResult } from 'vite';
import type { TransformSettings } from './types';
import { reportDiagnostics } from './diagnostics';
import type { Compiler } from '../typescript/types';
import { API_PROPERTY_KEY } from '../common/internal';
import { createCompiler } from '../typescript/compiler';
import type { Assertion, TransformSettings } from './types';

export function transform({ code, fileName, report, typescript }: TransformSettings): TransformResult {
const compiler = createCompiler({ config: typescript.config, fileName });
const newCode = new MagicString(code);

if (report.includes('type-error')) {
reportDiagnostics(compiler.diagnostics, newCode, fileName);
}

if (report.includes('type-assertion')) {
const assertions = getAssertions(compiler.sourceFile, compiler.typeChecker);
const diagnostics = processAssertions(assertions, compiler);

if (diagnostics.length) {
reportDiagnostics(diagnostics, newCode, fileName);
}
}

return {
code: newCode.toString(),
map: newCode.generateMap({ hires: true }),
};
}

function getAssertions(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker): Assertion[] {
const assertions: Assertion[] = [];

function visit(node: ts.Node): void {
if (ts.isCallExpression(node)) {
const expression = node.expression;
const expressionType = typeChecker.getTypeAtLocation(expression);
const assertionProperty = expressionType.getProperty(API_PROPERTY_KEY);

if (!assertionProperty) {
return;
}

const assertionPropertyType = typeChecker.getTypeOfSymbolAtLocation(assertionProperty, expression);
const assertionPropertyValue = typeChecker.typeToString(assertionPropertyType).slice(1, -1);
const [apiName, functionName] = assertionPropertyValue.split(':');

if (!apiName || !functionName) {
return;
}

assertions.push({ apiName, functionName, node });
}

node.forEachChild(visit);
}

visit(sourceFile);

return assertions;
}

function processAssertions(assertions: Assertion[], compiler: Compiler): ts.Diagnostic[] {
const diagnostics: ts.Diagnostic[] = [];

// eslint-disable-next-line no-console
console.log({ code, fileName, report, typescript });
console.log({ assertions, compiler });

return { code, map: null };
return diagnostics;
}
13 changes: 13 additions & 0 deletions src/plugin/types.ts
Expand Up @@ -24,3 +24,16 @@ export interface TransformSettings {
options: TypeScriptConfigOptions;
};
}

export interface ErrorObject {
message: string;
file: string;
line: number;
column: number;
}

export interface Assertion {
apiName: string;
functionName: string;
node: ts.Node;
}
2 changes: 1 addition & 1 deletion src/typescript/builder.ts → src/typescript/compiler.ts
Expand Up @@ -2,7 +2,7 @@ import ts from 'byots';
import { createError, ErrorCode } from '../common/error';
import type { CompilerSettings, Compiler } from './types';

export function createBuilder({ fileName, config }: CompilerSettings): Compiler {
export function createCompiler({ fileName, config }: CompilerSettings): Compiler {
const compilerOptions = { ...ts.getDefaultCompilerOptions(), ...config.options };
const program = ts.createProgram([fileName], compilerOptions);
const diagnostics = ts.getPreEmitDiagnostics(program);
Expand Down
7 changes: 0 additions & 7 deletions test/index.test.ts

This file was deleted.

21 changes: 21 additions & 0 deletions test/tsd.test.ts
@@ -0,0 +1,21 @@
// import { test, expect, describe } from 'vitest';
// import { expectType, expectType as pouet, expectNotType } from '../src/api/tsd';
// import * as tsd from '../src/api/tsd';

// describe('describe-1', () => {
// test('test-1', () => {
// pouet<string>('hello');
// expectType<string>('hello');
// expectNotType<number>('hello');
// tsd.expectType<string>('hello');

// expect(42).toBe(42);
// // expect("life").toBe(42);
// });
// });
import { expectType, expectType as assertType } from '../src/api/tsd';
import * as tsd from '../src/api/tsd';

expectType<string>('hello');
assertType<string>('hello');
tsd.expectType<string>('hello');
26 changes: 26 additions & 0 deletions test/tssert.test.ts
@@ -0,0 +1,26 @@
// import { test, expect, describe } from 'vitest';
// import { expectType, expectType as prout } from '../src/api/tssert';
// import * as tssert from '../src/api/tssert';

// describe('describe-1', () => {
// test('test-1', () => {
// prout<string>().assignableTo('hello');
// expectType<string>().assignableTo('hello');
// expectType<number>().not.assignableTo('hello');
// tssert.expectType<number>().not.assignableTo('hello');

// expect(42).toBe(42);
// // expect("life").toBe(42);
// });
// });

import { expectType, expectType as assertType } from '../src/api/tssert';
import * as tssert from '../src/api/tssert';

expectType<string>('hello');
assertType<string>('hello');
tssert.expectType<string>('hello');

expectType<string>().assignableTo('hello');
expectType<number>().not.assignableTo('hello');
tssert.expectType<number>().not.assignableTo('hello');

0 comments on commit 7618b52

Please sign in to comment.