Вывод типов в jscodeshift и TypeScript


Начиная с версии 6.0 jscodeshift поддерживает работу с TypeScript (далее TS). В процессе написания codemode-ов (преобразований), может потребоваться узнать тип переменной, которая не имеет явной аннотации. К сожалению, jscodeshift не предоставляет средств для вывода типов «из коробки».


Рассмотрим пример. Допустим, мы хотим написать преобразование, которое добавляет явный тип возвращаемого значения для функций и методов классов. Т.е. имея на входе:


function foo(x: number) {
    return x;
}

Мы хотим получить на выходе:


function foo(x: number): number {
    return x;
}

К сожалению, в общем случае, решение такой задачи очень нетривиально. Вот лишь несколько примеров:


function toString(x: number) {
    return '' + x;
}

function toInt(str: string) {
    return parseInt(str);
}

function toIntArray(strings: string[]) {
    return strings.map(Number.parseInt);
}

class Foo1 {
    constructor(public x = 0) { }

    getX() {
        return this.x;
    }
}

class Foo2 {
    x: number;

    constructor(x = 0) {
        this.x = x;
    }

    getX() {
        return this.x;
    }
}

function foo1(foo: Foo1) {
    return foo.getX();
}

function foo2(foo: Foo2) {
    return foo.getX();
}

К счастью, задача вывода типов уже решена внутри компилятора TS. API компилятора предоставляет средства для вывода типов, которые можно использовать для написания преобразования.


Однако, просто взять и воспользоваться компилятором TS, переопределив парсер jscodeshift, нельзя. Дело в том, что jscodeshift ожидает от внешних парсеров абстрактное синтаксическое дерево (AST) в формате ESTree. А AST компилятора TS таковым не является.


Конечно, можно было бы воспользоваться компилятором TS и без использования jscodeshift, написав преобразование «с нуля». Либо же воспользоваться одним из средств, которые существуют в комьюнити TS, например, ts-morph. Но для многих jscodeshift будет более привычным и выразительным решением. Поэтому далее будет рассмотрено, как обойти это ограничение.


Идея состоит в том, чтобы получить отображение из AST парсера jscodeshift (далее ESTree) в AST компилятора TS (далее TSTree), и затем воспользоваться средствами вывода типов компилятора TS. Далее будут рассмотрены два способа реализации этой идеи.


Отображение с использованием номеров строк и столбцов


Первый способ использует номера строк и столбцов (позиции) узлов, чтобы найти отображение из TSTree в ESTree. Несмотря на то, что в общем случае позиции узлов могут не совпадать, почти всегда можно найти нужное отображение в каждом конкретном случае.


Итак, напишем преобразование, которое выполнит задачу добавления явных аннотаций. Напомню, на выходе мы должны получить следующее:


function toString(x: number): number {
    return '' + x;
}

function toInt(str: string): number {
    return parseInt(str);
}

function toIntArray(strings: string[]): number[] {
    return strings.map(Number.parseInt);
}

class Foo1 {
    constructor(public x = 0) { }

    getX(): number {
        return this.x;
    }
}

class Foo2 {
    x: number;

    constructor(x = 0) {
        this.x = x;
    }

    getX(): number {
        return this.x;
    }
}

function foo1(foo: Foo1): number {
    return foo.getX();
}

function foo2(foo: Foo2): number {
    return foo.getX();
}

Сначала, нам нужно построить TSTree и получить typeChecker компилятора TS:


const compilerOptions = {
    target: ts.ScriptTarget.Latest
};

const program = ts.createProgram([path], compilerOptions);
const sourceFile = program.getSourceFile(path);
const typeChecker = program.getTypeChecker();

Далее, построим отображение из ESTree в TSTree с использованием стартовой позиции. Для этого будем использовать двухуровневый Map (первый уровень – для строк, второй уровень – для столбцов, результат – узел TSTree):


const locToTSNodeMap = new Map();

const esTreeNodeToTSNode = ({ loc: { start: { line, column } } }) => locToTSNodeMap.has(line) ? locToTSNodeMap.get(line).get(column) : undefined;

(function buildLocToTSNodeMap(node) {
    const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
    const nextLine = line + 1;
    if (!locToTSNodeMap.has(nextLine))
        locToTSNodeMap.set(nextLine, new Map());
    locToTSNodeMap.get(nextLine).set(character, node);
    ts.forEachChild(node, buildLocToTSNodeMap);
}(sourceFile));

Необходимо скорректировать номер строки, т.к. в TSTree номера строк начинаются с нуля, а в ESTree – с единицы.


Далее нам надо обойти все функции и методы классов, проверить возвращаемый тип и если он равен null, добавить аннотацию типа:


const ast = j(source);
ast
    .find(j.FunctionDeclaration)
    .forEach(({ value }) => {
        if (value.returnType === null)
            value.returnType = getReturnType(esTreeNodeToTSNode(value));
    });
ast
    .find(j.ClassMethod, { kind: 'method' })
    .forEach(({ value }) => {
        if (value.returnType === null)
            value.returnType = getReturnType(esTreeNodeToTSNode(value).parent);
    });
return ast.toSource();

Пришлось скорректировать код для получения узла метода класса, т.к. по стартовой позиции узла метода в ESTree в TSTree находится узел идентификатора метода (поэтому мы используем parent-а).


Наконец, напишем код получения аннотации возвращаемого типа:


function getReturnTypeFromString(typeString) {
    let ret;
    j(`function foo(): ${typeString} { }`)
        .find(j.FunctionDeclaration)
        .some(({ value: { returnType } }) => ret = returnType);
    return ret;
}

function getReturnType(node) {
    return getReturnTypeFromString(
        typeChecker.typeToString(
            typeChecker.getReturnTypeOfSignature(
                typeChecker.getSignatureFromDeclaration(node)
            )
        )
    );
}

Полный листинг:


import * as ts from 'typescript';

export default function transform({ source, path }, { j }) {
    const compilerOptions = {
        target: ts.ScriptTarget.Latest
    };

    const program = ts.createProgram([path], compilerOptions);
    const sourceFile = program.getSourceFile(path);
    const typeChecker = program.getTypeChecker();

    const locToTSNodeMap = new Map();

    const esTreeNodeToTSNode = ({ loc: { start: { line, column } } }) => locToTSNodeMap.has(line) ? locToTSNodeMap.get(line).get(column) : undefined;

    (function buildLocToTSNodeMap(node) {
        const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
        const nextLine = line + 1;
        if (!locToTSNodeMap.has(nextLine))
            locToTSNodeMap.set(nextLine, new Map());
        locToTSNodeMap.get(nextLine).set(character, node);
        ts.forEachChild(node, buildLocToTSNodeMap);
    }(sourceFile));

    function getReturnTypeFromString(typeString) {
        let ret;
        j(`function foo(): ${typeString} { }`)
            .find(j.FunctionDeclaration)
            .some(({ value: { returnType } }) => ret = returnType);
        return ret;
    }

    function getReturnType(node) {
        return getReturnTypeFromString(
            typeChecker.typeToString(
                typeChecker.getReturnTypeOfSignature(
                    typeChecker.getSignatureFromDeclaration(node)
                )
            )
        );
    }

    const ast = j(source);
    ast
        .find(j.FunctionDeclaration)
        .forEach(({ value }) => {
            if (value.returnType === null)
                value.returnType = getReturnType(esTreeNodeToTSNode(value));
        });
    ast
        .find(j.ClassMethod, { kind: 'method' })
        .forEach(({ value }) => {
            if (value.returnType === null)
                value.returnType = getReturnType(esTreeNodeToTSNode(value).parent);
        });
    return ast.toSource();
}

export const parser = 'ts';

Использование парсера typescript-eslint


Как было показано выше, хоть и отображение с использованием позиций узлов работает, оно не дает точного результата и иногда требует «ручной доводки». Более общим решением было бы написать явное отображение узлов ESTree в TSTree. Именно так работает парсер проекта typescript-eslint. Воспользуемся им.


Для начала, нам нужно переопределить встроенный парсер jscodeshift на парсер typescript-eslint. В простейшем случае код выглядит так:


export const parser = {
    parse(source) {
        return typescriptEstree.parse(source);
    }
};

Однако, нам придется немного усложнить код, чтобы получить отображение узлов ESTree в TSTree и typeChecker. Для этого в typescript-eslint используется функция parseAndGenerateServices. Чтобы все заработало, мы должны передать в нее путь к .ts файлу и путь к файлу конфигурации tsconfig.json. Так как прямого способа сделать этого нет, придется воспользоваться глобальной переменной (ох!):


const parserState = {};

function parseWithServices(j, source, path, projectPath) {
    parserState.options = { filePath: path, project: projectPath };
    return {
        ast: j(source),
        services: parserState.services
    };
}

export const parser = {
    parse(source) {
        if (parserState.options !== undefined) {
            const options = parserState.options;
            delete parserState.options;
            const { ast, services } = typescriptEstree.parseAndGenerateServices(source, options);
            parserState.services = services;
            return ast;
        }
        return typescriptEstree.parse(source);
    }
};

Каждый раз, когда мы хотим получить расширенный набор средств парсера typescript-eslint, мы вызываем функцию parseWithServices, в которую передаем необходимые параметры (в остальных случаях мы по-прежнему используем функцию j):


const { ast, services: { program, esTreeNodeToTSNodeMap } } = parseWithServices(j, source, path, tsConfigPath);

const typeChecker = program.getTypeChecker();
const esTreeNodeToTSNode = ({ original }) => esTreeNodeToTSNodeMap.get(original);

Остается только написать код обхода и модификации функций и методов классов:


ast
    .find(j.FunctionDeclaration)
    .forEach(({ value }) => {
        if (value.returnType === null)
            value.returnType = getReturnType(esTreeNodeToTSNode(value));
    });
ast
    .find(j.MethodDefinition, { kind: 'method' })
    .forEach(({ value }) => {
        if (value.value.returnType === null)
            value.value.returnType = getReturnType(esTreeNodeToTSNode(value));
    });
return ast.toSource();

Надо отметить, что нам пришлось заменить селектор ClassMethod на MethodDefinition, чтобы обойти методы классов (также немного изменился код доступа к возвращаемому значению метода). Это специфика парсера typescript-eslint. Код функции getReturnType идентичен тому, что использовался ранее.


Полный листинг:


import * as typescriptEstree from '@typescript-eslint/typescript-estree';

export default function transform({ source, path }, { j }, { tsConfigPath }) {
    const { ast, services: { program, esTreeNodeToTSNodeMap } } = parseWithServices(j, source, path, tsConfigPath);

    const typeChecker = program.getTypeChecker();
    const esTreeNodeToTSNode = ({ original }) => esTreeNodeToTSNodeMap.get(original);

    function getReturnTypeFromString(typeString) {
        let ret;
        j(`function foo(): ${typeString} { }`)
            .find(j.FunctionDeclaration)
            .some(({ value: { returnType } }) => ret = returnType);
        return ret;
    }

    function getReturnType(node) {
        return getReturnTypeFromString(
            typeChecker.typeToString(
                typeChecker.getReturnTypeOfSignature(
                    typeChecker.getSignatureFromDeclaration(node)
                )
            )
        );
    }

    ast
        .find(j.FunctionDeclaration)
        .forEach(({ value }) => {
            if (value.returnType === null)
                value.returnType = getReturnType(esTreeNodeToTSNode(value));
        });
    ast
        .find(j.MethodDefinition, { kind: 'method' })
        .forEach(({ value }) => {
            if (value.value.returnType === null)
                value.value.returnType = getReturnType(esTreeNodeToTSNode(value));
        });
    return ast.toSource();
}

const parserState = {};

function parseWithServices(j, source, path, projectPath) {
    parserState.options = { filePath: path, project: projectPath };
    return {
        ast: j(source),
        services: parserState.services
    };
}

export const parser = {
    parse(source) {
        if (parserState.options !== undefined) {
            const options = parserState.options;
            delete parserState.options;
            const { ast, services } = typescriptEstree.parseAndGenerateServices(source, options);
            parserState.services = services;
            return ast;
        }
        return typescriptEstree.parse(source);
    }
};

Плюсы и минусы подходов


Подход с номерами строк и столбцов


Плюсы:


  • Не требует переопределения встроенного парсера jscodeshift.
  • Гибкость передачи конфигурации и исходных текстов (можно передавать как файлы, так и строки/объекты в памяти, см. ниже).

Минусы:


  • Отображение узлов по позициям является неточным и в некоторых случаях требует корректировки.

Подход с парсером typescript-eslint


Плюсы:


  • Точное отображение узлов из одного AST в другое.

Минусы:


  • Структура AST парсера typescript-eslint немного отличается от встроенного парсера jscodeshift.
  • Необходимость использовать файлы для передачи конфигурации TS и исходных текстов.

Заключение


Первый подход легко добавить в существующие проекты, т.к. он не требует переопределения парсера, но отображение узлов AST, скорее всего, потребует корректировки.


Решение о втором подходе лучше принимать заранее, иначе, вероятно, придется тратить время на отладку кода из-за изменившейся структуры AST. С другой стороны, у вас будет полноценное отображение одних узлов на другие (и обратно).


P.S.


Выше упоминалось, что при использовании парсера TS, можно передавать конфигурации и исходные тексты как в виде файлов, так и в виде объектов в памяти. Передача конфигурации в виде объекта и передача исходного текста в виде файла были рассмотрены в примере. Далее приводится код функций, которые позволяют прочитать конфигурацию из файла:


class TsDiagnosticError extends Error {
    constructor(err) {
        super(Array.isArray(err) ? err.map(e => e.messageText).join('\n') : err.messageText);
        this.diagnostic = err;
    }
}

function tsGetCompilerOptionsFromConfigFile(tsConfigPath, basePath = '.') {
    const { config, error } = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
    if (error)
        throw new TsDiagnosticError(error);
    const { options, errors } = ts.parseJsonConfigFileContent(config, tsGetCompilerOptionsFromConfigFile.host, basePath);
    if (errors.length !== 0)
        throw new TsDiagnosticError(errors);
    return options;
}

tsGetCompilerOptionsFromConfigFile.host = {
    fileExists: ts.sys.fileExists,
    readFile: ts.sys.readFile,
    readDirectory: ts.sys.readDirectory,
    useCaseSensitiveFileNames: true
};

И создать TS-программу из строки:


function tsCreateStringSourceCompilerHost(mockPath, source, compilerOptions, setParentNodes) {
    const host = ts.createCompilerHost(compilerOptions, setParentNodes);

    const getSourceFileOriginal = host.getSourceFile.bind(host);
    const readFileOriginal = host.readFile.bind(host);
    const fileExistsOriginal = host.fileExists.bind(host);

    host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
        return fileName === mockPath ?
            ts.createSourceFile(fileName, source, languageVersion) :
            getSourceFileOriginal(fileName, languageVersion, onError, shouldCreateNewSourceFile);
    };
    host.readFile = (fileName) => {
        return fileName === mockPath ?
            source :
            readFileOriginal(fileName);
    };
    host.fileExists = (fileName) => {
        return fileName === mockPath ?
            true :
            fileExistsOriginal(fileName);
    };

    return host;
}

function tsCreateStringSourceProgram(source, compilerOptions, mockPath = '_source.ts') {
    return ts.createProgram([mockPath], compilerOptions, tsCreateStringSourceCompilerHost(mockPath, source, compilerOptions));
}

Ссылки


Комментарии (0)