Skip to content

Commit

Permalink
fix(vue-tsx): support parsing tsx script in .vue file (#2850)
Browse files Browse the repository at this point in the history
Co-authored-by: Nico Jansen <jansennico@gmail.com>
  • Loading branch information
zeyangxu and nicojs authored May 6, 2021
1 parent cc0a83f commit dc66c28
Show file tree
Hide file tree
Showing 19 changed files with 241 additions and 40 deletions.
5 changes: 3 additions & 2 deletions packages/instrumenter/src/disable-type-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { File, Range } from '@stryker-mutator/api/core';
import { notEmpty } from '@stryker-mutator/util';

import { createParser, getFormat, ParserOptions } from './parsers';
import { AstFormat, HtmlAst, JSAst, TSAst } from './syntax';
import { AstFormat, HtmlAst, ScriptAst } from './syntax';

const commentDirectiveRegEx = /^(\s*)@(ts-[a-z-]+).*$/;
const tsDirectiveLikeRegEx = /@(ts-[a-z-]+)/;
Expand All @@ -19,6 +19,7 @@ export async function disableTypeChecks(file: File, options: ParserOptions): Pro
switch (ast.format) {
case AstFormat.JS:
case AstFormat.TS:
case AstFormat.Tsx:
return new File(file.name, disableTypeCheckingInBabelAst(ast));
case AstFormat.Html:
return new File(file.name, disableTypeCheckingInHtml(ast));
Expand All @@ -30,7 +31,7 @@ function isJSFileWithoutTSDirectives(file: File) {
return (format === AstFormat.TS || format === AstFormat.JS) && !tsDirectiveLikeRegEx.test(file.textContent);
}

function disableTypeCheckingInBabelAst(ast: JSAst | TSAst): string {
function disableTypeCheckingInBabelAst(ast: ScriptAst): string {
return prefixWithNoCheck(removeTSDirectives(ast.rawContent, ast.root.comments));
}

Expand Down
8 changes: 6 additions & 2 deletions packages/instrumenter/src/parsers/html-parser.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Element } from 'angular-html-parser/lib/compiler/src/ml_parser/ast';
import type { ParseLocation } from 'angular-html-parser/lib/compiler/src/parse_util';

import { HtmlAst, AstFormat, HtmlRootNode, TSAst, JSAst, ScriptFormat, AstByFormat } from '../syntax';
import { HtmlAst, AstFormat, HtmlRootNode, ScriptFormat, AstByFormat, ScriptAst } from '../syntax';

import { ParserContext } from './parser-context';
import { ParseError } from './parse-error';

const TSX_SCRIPT_TYPES = Object.freeze(['tsx', 'text/tsx']);
const TS_SCRIPT_TYPES = Object.freeze(['ts', 'text/typescript', 'typescript']);
const JS_SCRIPT_TYPES = Object.freeze(['js', 'text/javascript', 'javascript']);

Expand Down Expand Up @@ -38,7 +39,7 @@ async function ngHtmlParser(text: string, fileName: string, parserContext: Parse
if (errors.length !== 0) {
throw new ParseError(errors[0].msg, fileName, toSourceLocation(errors[0].span.start));
}
const scriptsAsPromised: Array<Promise<JSAst | TSAst>> = [];
const scriptsAsPromised: Array<Promise<ScriptAst>> = [];
visitAll(
new (class extends RecursiveVisitor {
public visitElement(el: Element, context: unknown): void {
Expand Down Expand Up @@ -89,6 +90,9 @@ function getScriptType(element: Element): ScriptFormat | undefined {
const type = element.attrs.find((attr) => attr.name === 'type') ?? element.attrs.find((attr) => attr.name === 'lang');
if (type) {
const typeToLower = type.value.toLowerCase();
if (TSX_SCRIPT_TYPES.includes(typeToLower)) {
return AstFormat.Tsx;
}
if (TS_SCRIPT_TYPES.includes(typeToLower)) {
return AstFormat.TS;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/instrumenter/src/parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'path';
import { AstFormat, AstByFormat } from '../syntax';

import { createParser as createJSParser } from './js-parser';
import { parse as tsParse } from './ts-parser';
import { parseTS, parseTsx } from './ts-parser';
import { parse as htmlParse } from './html-parser';
import { ParserOptions } from './parser-options';

Expand All @@ -18,8 +18,10 @@ export function createParser(
switch (format) {
case AstFormat.JS:
return jsParse(code, fileName) as Promise<AstByFormat[T]>;
case AstFormat.Tsx:
return parseTsx(code, fileName) as Promise<AstByFormat[T]>;
case AstFormat.TS:
return tsParse(code, fileName) as Promise<AstByFormat[T]>;
return parseTS(code, fileName) as Promise<AstByFormat[T]>;
case AstFormat.Html:
return htmlParse(code, fileName, { parse }) as Promise<AstByFormat[T]>;
}
Expand All @@ -38,8 +40,9 @@ export function getFormat(fileName: string, override?: AstFormat): AstFormat {
case '.cjs':
return AstFormat.JS;
case '.ts':
case '.tsx':
return AstFormat.TS;
case '.tsx':
return AstFormat.Tsx;
case '.vue':
case '.html':
case '.htm':
Expand Down
33 changes: 24 additions & 9 deletions packages/instrumenter/src/parsers/ts-parser.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { types, parseAsync } from '@babel/core';

import { AstFormat, TSAst } from '../syntax';
import { AstFormat, TSAst, TsxAst } from '../syntax';

/**
* See https://babeljs.io/docs/en/babel-preset-typescript
* @param text The text to parse
* @param fileName The name of the file
*/
export async function parse(text: string, fileName: string): Promise<TSAst> {
const isTSX = fileName.endsWith('x');
export async function parseTS(text: string, fileName: string): Promise<TSAst> {
return {
originFileName: fileName,
rawContent: text,
format: AstFormat.TS,
root: await parse(text, fileName, false),
};
}

export async function parseTsx(text: string, fileName: string): Promise<TsxAst> {
return {
root: await parse(text, fileName, true),
format: AstFormat.Tsx,
originFileName: fileName,
rawContent: text,
};
}

async function parse(text: string, fileName: string, isTSX: boolean): Promise<types.File> {
const ast = await parseAsync(text, {
filename: fileName,
parserOpts: {
Expand All @@ -23,13 +40,11 @@ export async function parse(text: string, fileName: string): Promise<TSAst> {
[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }],
],
});
if (ast === null) {
throw new Error(`Expected ${fileName} to contain a babel.types.file, but it yielded null`);
}
if (types.isProgram(ast)) {
throw new Error(`Expected ${fileName} to contain a babel.types.file, but was a program`);
}
return {
originFileName: fileName,
rawContent: text,
format: AstFormat.TS,
root: ast!,
};
return ast;
}
2 changes: 2 additions & 0 deletions packages/instrumenter/src/printers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function print(file: Ast): string {
return jsPrint(file, context);
case AstFormat.TS:
return tsPrint(file, context);
case AstFormat.Tsx:
return tsPrint(file, context);
case AstFormat.Html:
return htmlPrint(file, context);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/instrumenter/src/printers/ts-printer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import generate from '@babel/generator';

import { TSAst } from '../syntax';
import { TSAst, TsxAst } from '../syntax';

import { Printer } from '.';

export const print: Printer<TSAst> = (file) => {
export const print: Printer<TSAst | TsxAst> = (file) => {
return generate(file.root, {
decoratorsBeforeExport: true,
sourceMaps: false,
Expand Down
19 changes: 14 additions & 5 deletions packages/instrumenter/src/syntax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ export enum AstFormat {
Html = 'html',
JS = 'js',
TS = 'ts',
Tsx = 'tsx',
}

export type ScriptFormat = AstFormat.JS | AstFormat.TS;

export interface AstByFormat {
[AstFormat.Html]: HtmlAst;
[AstFormat.JS]: JSAst;
[AstFormat.TS]: TSAst;
[AstFormat.Tsx]: TsxAst;
}
export type Ast = HtmlAst | JSAst | TSAst | TsxAst;

export type ScriptFormat = AstFormat.JS | AstFormat.TS | AstFormat.Tsx;
export type ScriptAst = JSAst | TSAst | TsxAst;
export interface BaseAst {
originFileName: string;
rawContent: string;
Expand Down Expand Up @@ -48,13 +51,19 @@ export interface TSAst extends BaseAst {
root: babelTypes.File;
}

/**
* Represents a TS AST
*/
export interface TsxAst extends BaseAst {
format: AstFormat.Tsx;
root: babelTypes.File;
}

/**
* Represents the root node of an HTML AST
* We've taken a shortcut here, instead of representing the entire AST, we're only representing the script tags.
* We might need to expand this in the future if we would ever want to support mutating the actual HTML (rather than only the JS/TS)
*/
export interface HtmlRootNode {
scripts: Array<JSAst | TSAst>;
scripts: ScriptAst[];
}

export type Ast = HtmlAst | JSAst | TSAst;
8 changes: 2 additions & 6 deletions packages/instrumenter/src/transformers/babel-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ import { File } from '@babel/core';
import { placeMutants } from '../mutant-placers';
import { mutate } from '../mutators';
import { instrumentationBabelHeader, isTypeNode, isImportDeclaration, locationIncluded, locationOverlaps } from '../util/syntax-helpers';
import { AstFormat } from '../syntax';
import { ScriptFormat } from '../syntax';

import { AstTransformer } from '.';

export const transformBabel: AstTransformer<AstFormat.JS | AstFormat.TS> = (
{ root, originFileName, rawContent, offset },
mutantCollector,
{ options }
) => {
export const transformBabel: AstTransformer<ScriptFormat> = ({ root, originFileName, rawContent, offset }, mutantCollector, { options }) => {
// Wrap the AST in a `new File`, so `nodePath.buildCodeFrameError` works
// https://github.com/babel/babel/issues/11889
const file = new File({ filename: originFileName }, { code: rawContent, ast: root });
Expand Down
1 change: 1 addition & 0 deletions packages/instrumenter/src/transformers/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function transform(ast: Ast, mutantCollector: I<MutantCollector>, transfo
break;
case AstFormat.JS:
case AstFormat.TS:
case AstFormat.Tsx:
transformBabel(ast, mutantCollector, context);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ describe(`${disableTypeChecks.name} integration`, () => {
it('should be able disable type checks of a vue file', async () => {
await arrangeAndActAssert('vue-sample.vue');
});
it('should be able disable type checks of a vue tsx file', async () => {
await arrangeAndActAssert('vue-tsx-sample.vue');
});

async function arrangeAndActAssert(fileName: string, options = createInstrumenterOptions()) {
const fullFileName = resolveTestResource('disable-type-checks', fileName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ describe('instrumenter integration', () => {
it('should be able to instrument a vue sample', async () => {
await arrangeAndActAssert('vue-sample.vue');
});
it('should be able to instrument a vue tsx sample', async () => {
await arrangeAndActAssert('vue-tsx-sample.vue');
});
it('should be able to instrument super calls', async () => {
await arrangeAndActAssert('super-call.ts');
});
Expand Down
7 changes: 6 additions & 1 deletion packages/instrumenter/test/integration/parsers.it.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('parsers integration', () => {
});

it('should allow to parse a tsx file', async () => {
const actual = await actAssertTS('App.tsx');
const actual = await actAssertTsx('App.tsx');
expect(actual).to.matchSnapshot();
});

Expand Down Expand Up @@ -88,6 +88,11 @@ describe('parsers integration', () => {
expect(actual.format).eq(AstFormat.TS);
return actual as TSAst;
}
async function actAssertTsx(testResourceFileName: string, options = createParserOptions()): Promise<TSAst> {
const actual = await act(testResourceFileName, options);
expect(actual.format).eq(AstFormat.Tsx);
return actual as TSAst;
}
async function actAssertJS(testResourceFileName: string, options = createParserOptions()): Promise<JSAst> {
const actual = await act(testResourceFileName, options);
expect(actual.format).eq(AstFormat.JS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6595,7 +6595,7 @@ export default Badge;
exports[`parsers integration should allow to parse a tsx file 1`] = `
Object {
"format": "ts",
"format": "tsx",
"originFileName": "App.tsx",
"rawContent": "const app = <Html foo={bar}></Html>
",
Expand Down
2 changes: 2 additions & 0 deletions packages/instrumenter/test/unit/parsers/html-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ describe('html-parser', () => {
{ actualType: 'typescript', expectedType: AstFormat.TS },
{ actualType: 'TypeScript', expectedType: AstFormat.TS },
{ actualType: 'text/typescript', expectedType: AstFormat.TS },
{ actualType: 'text/tsx', expectedType: AstFormat.Tsx },
{ actualType: 'tsx', expectedType: AstFormat.Tsx },
];

testCases.forEach(({ actualType, expectedType }) => {
Expand Down
25 changes: 16 additions & 9 deletions packages/instrumenter/test/unit/parsers/ts-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { expect } from 'chai';

import { TSAst, AstFormat } from '../../../src/syntax';
import { parse } from '../../../src/parsers/ts-parser';
import { parseTS, parseTsx } from '../../../src/parsers/ts-parser';
import { expectAst, AstExpectation } from '../../helpers/syntax-test-helpers';

describe('ts-parser', () => {
describe(parseTS.name, () => {
it('should be able to parse simple typescript', async () => {
const expected: Omit<TSAst, 'root'> = {
format: AstFormat.TS,
originFileName: 'foo.ts',
rawContent: 'var foo: string = "bar";',
};
const { format, originFileName, root, rawContent } = await parse(expected.rawContent, expected.originFileName);
const { format, originFileName, root, rawContent } = await parseTS(expected.rawContent, expected.originFileName);
expect(format).eq(expected.format);
expect(rawContent).eq(expected.rawContent);
expect(originFileName).eq(expected.originFileName);
Expand All @@ -26,22 +26,29 @@ describe('ts-parser', () => {
await arrangeAndAssert('class A { #foo; get foo() { return this.#foo; }}', (t) => t.isPrivateName() && t.node.id.name === 'foo');
});

async function arrangeAndAssert(input: string, expectation: AstExpectation, fileName = 'test.ts') {
const { root } = await parseTS(input, fileName);
expectAst(root, expectation);
}
});

describe(parseTsx.name, () => {
it('should allow jsx if extension is tsx', async () => {
await arrangeAndAssert(
`class MyComponent extends React.Component<Props, {}> {
render() {
return <span>{this.props.foo}</span>
render() {
return <span>{this.props.foo}</span>
}
}
}
<MyComponent foo="bar" />; // ok`,
<MyComponent foo="bar" />; // ok`,
(t) => t.isJSXElement(),
'test.tsx'
);
});

async function arrangeAndAssert(input: string, expectation: AstExpectation, fileName = 'test.ts') {
const { root } = await parse(input, fileName);
const { root } = await parseTsx(input, fileName);
expectAst(root, expectation);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<template>
<div class="test">
<h1>Hello</h1>
</div>
</template>
<script lang="tsx">
// @ts-expect-error not installed
import { Vue, Component, Prop, Watch, Emit } from 'vue-property-decorator'
@Component({
name: 'example-tsx-component',
})
export default class AuditDrawerInfo extends Vue {
public renderFunction = (h, context) => {
return (
<button>button</button>
)
}
}
</script>
<style lang="scss">
</style>
Loading

0 comments on commit dc66c28

Please sign in to comment.