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

от автора

Вывод типов в 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)); }

Ссылки


ссылка на оригинал статьи https://habr.com/ru/post/480304/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *