[JavaScript, TypeScript] Вывод типов в jscodeshift и TypeScript

Автор Сообщение
news_bot ®

Стаж: 1 год 11 месяцев
Сообщений: 30128

Создавать темы news_bot ® написал(а)
15-Дек-2019 03:30

Вывод типов в 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 root = j(source);
root
    .find(j.FunctionDeclaration)
    .forEach(({ value }) => {
        if (value.returnType === null)
            value.returnType = getReturnType(esTreeNodeToTSNode(value));
    });
root
    .find(j.ClassMethod, { kind: 'method' })
    .forEach(({ value }) => {
        if (value.returnType === null)
            value.returnType = getReturnType(esTreeNodeToTSNode(value).parent);
    });
return root.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 getCompilerOptionsFromConfigFile(tsConfigPath) {
    const { config, error } = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
    if (error)
        throw new TsDiagnosticError(error);
    const { options, errors } = ts.parseJsonConfigFileContent(config, getCompilerOptionsFromConfigFile.host, '.');
    if (errors.length !== 0)
        throw new TsDiagnosticError(errors);
    return options;
}
getCompilerOptionsFromConfigFile.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));
}

Ссылки

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_typescript, #_typescript, #_javascript, #_jscodeshift, #_type_checking
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 27-Янв 15:31
Часовой пояс: UTC + 5