Кодогенерация и парсинг TypeScript с помощью typescript

от автора

Одной из интересных возможностей пакета typescript является то, что он содержит API для генерации TypeScript-кода, а также парсер для работы с написанным на TypeScript кодом. Кодогенерация часто используется для автоматического создания типов для работы с http api (типизация тела запроса, ответа, query параметров и тд.). В npm есть модули, генерирующие сервисы для работы с api на основе openapi, graphQl схем и тому подобное, и обычно возможностей существующих модулей хватает для решения большей части задач.

Но иногда возникает потребность расширить имеющийся интерфейс или даже написать свою кастомную реализацию. В этой статье мы рассмотрим основные принципы работы с инструментами для генерации и парсинга typescript кода, а так же некоторые подводные камни, на которые я наткнулся при работе с ним.

Кодогенерация

Рассмотрим для примера генерацию кода с декларацией простого union:

export type Numbers = "one" | "two" | "three";

Для начала сконфигурируем небольшой проект с помощью команды npm init. Затем установим npm пакет typescript командой npm install typescript. Также для дальнейшей работы нам нужно установить зависимость npm install @types/node --save-dev.

Для генерации указанного выше union Numbers понадобится следующий код:

// ./src/index.ts import * as ts from "typescript";  const file = ts.createSourceFile("source.ts", "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });  const one = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("one")); const two = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("two")); const three = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("three"));  const decl = ts.factory.createTypeAliasDeclaration(   [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], // modifiers (export keyword)   ts.factory.createIdentifier("Numbers"), // name   undefined, // typeParameters   ts.factory.createUnionTypeNode([ one, two, three ]), // type )  const result = printer.printNode(ts.EmitHint.Unspecified, decl, file); console.log(result);

Для компиляции и запуска этого файла добавим в проектtsconfig.json:

// ./tsconfig.json {   "compilerOptions": {     "outDir": "./dist",     "rootDir": "./src",     "sourceMap": true,     "target": "esnext",     "module": "CommonJS",     "moduleResolution": "node",     "types": [       "node"     ],     "allowSyntheticDefaultImports": true,     "esModuleInterop": true   },   "include": ["./src/**/*.ts"] }

А также скрипты build и start в package.json:

// ./package.json "scripts": {   "build": "tsc",   "start": "node dist/index.js" }

Теперь мы можем скомпилировать и выполнить код из ./src/index.ts. Последовательно запускаем команды npm run build, npm run start

Результат выполнения скрипта — вывод в консоль строки:

Кодогенерация union Numbers

Кодогенерация union Numbers

Разберем подробнее код в ./src/index.ts. Вначале импортируется пакет typescript и создается специальный объект принтера, который используется в дальнейшем для форматированного вывода построенной typescript декларации:

const file = ts.createSourceFile("source.ts", "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });  // ...  const result = printer.printNode(ts.EmitHint.Unspecified, decl, file); console.log(result);

Больший интерес представляет код, содержащийся между этими строками:

const one = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("one")); const two = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("two")); const three = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("three"));  const decl = ts.factory.createTypeAliasDeclaration(   [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], // modifiers (export keyword)   ts.factory.createIdentifier("Numbers"), // name   undefined, // typeParameters   ts.factory.createUnionTypeNode([ one, two, three ]), // type );

Алгоритм последовательно создает литералы для элементов юниона, а затем композирует их в единую декларацию, присваивая модификатор export и имя Numbers. Как видим: ничего сложного, вся сложность заключается только в идентификации сущности и нужного для ее создания конструктора. API typescript предлагает огромное множество функций для создания различных конструкций. В них очень легко запутаться, ибо из названия не всегда очевидно, что создает та или иная функция, и непонятно, что нужно в нее передавать в качестве параметров:

TypeScript api

TypeScript api

Для идентификации названий нужных typescript сущностей можно использовать API для парсинга кода. То есть сначала пишем псевдокод, парсим его средствами typescript и получаем AST (abstract syntax tree), изучаем его и создаем на основе полученных знаний нужную нам конструкцию.

Парсинг typescript

Для демонстрации API парсинга создадим файл с кодом на typescript, который будем использовать для парсинга:

// ./union.ts export type Numbers = "one" | "two" | "three";

Далее добавим в ./src/index.ts следующий код:

// ./src/index.ts import * as fs from 'node:fs'; import * as path from 'path';  const schema = ts.createSourceFile(   'x.ts',   fs.readFileSync(path.resolve(process.cwd(), './union.ts'), 'utf-8'),   ts.ScriptTarget.Latest,   undefined, );  console.log(schema.statements[0]);

Функция createSourceFile создаст AST-дерево для нашего файла union.ts и в поле statements будет массив ts сущностей, задекларированных в файле. Единственная сущность, находящаяся там — это экспортируемый юнион тип Numbers. Вывод в консоль в конце файла console.log(schema.statements[0]); отобразит структуру его AST:

NodeObject {   pos: 0,   end: 46,   flags: 0,   modifierFlagsCache: 0,   transformFlags: 1,   parent: undefined,   kind: 265,   symbol: undefined,   localSymbol: undefined,   modifiers: [     TokenObject {       pos: 0,       end: 6,       flags: 0,       modifierFlagsCache: 0,       transformFlags: 0,       parent: undefined,       kind: 95     },     pos: 0,     end: 6,     hasTrailingComma: false,     transformFlags: 0   ],   name: IdentifierObject {     pos: 11,     end: 19,     flags: 0,     modifierFlagsCache: 0,     transformFlags: 0,     parent: undefined,     kind: 80,     escapedText: 'Numbers',     jsDoc: undefined,     flowNode: undefined,     symbol: undefined   },   typeParameters: undefined,   type: NodeObject {     pos: 21,     end: 45,     flags: 0,     modifierFlagsCache: 0,     transformFlags: 1,     parent: undefined,     kind: 192,     types: [       [NodeObject],       [NodeObject],       [NodeObject],       pos: 21,       end: 45,       hasTrailingComma: false,       transformFlags: 1     ]   },   jsDoc: undefined,   locals: undefined,   nextContainer: undefined }

Анализ AST

AST удобнее анализировать в REPL-режиме, приостановив исполнение скрипта в дебагере IDE. Если вы пользуетесь VSCode, то настроить дебагер можно, добавив файл .vscode/launch.json со следующим содержимым:

// .vscode/launch.json {   "version": "0.2.0",   "configurations": [     {       "type": "node",       "request": "launch",       "name": "Run programm",       "program": "${workspaceFolder}/src/index.ts",       "preLaunchTask": "npm: build",       "sourceMaps": true,       "smartStep": true,       "internalConsoleOptions": "openOnSessionStart",       "outFiles": ["${workspaceFolder}/dist/**/*.js"],       "console": "internalConsole",       "outputCapture": "std"     }   ] }

Далее установим брейкпоинт на строке с выводом в консоль и запустим дебагер (F5 hotkey):

Затем проанализируем AST в объекте schema.statements[0].

Наибольший интерес в нем на данный момент представляют поля kind, modifiers, name и type. kind — в этом поле хранится значение из enum ts.SyntaxKind, по которому можно определить название сущности, а затем найти необходимый для его создания конструктор в пространстве ts.factory.

Как мы видим, корневой элемент нашего дерева имеет kind === 265, то есть тип TypeAliasDeclaration, и для его создания используется следующая функция в ts.factory:

createTypeAliasDeclaration(   modifiers: readonly ModifierLike[] | undefined,   name: string | Identifier,   typeParameters: readonly TypeParameterDeclaration[] | undefined,   type: TypeNode ): TypeAliasDeclaration;

, где поля modifiers, name, type, содержат как раз те сущности, которые мы видели при выводе в консоль AST для объекта schema.statements[0]. Проанализировав эти поля в REPL, можно понять, какие объекты нам нужно передавать в конструктор.

Анализ AST в REPL дает нам возможность полностью ознакомиться со структурой AST, но отнимает много времени. Если нам нужно быстро выяснить, как создать определенную сущность в виде AST, можно воспользоваться онлайн-инструментом TypeScript AST Viewer. Если в нем написать создаваемый нами union Numbers, то он выведет нам следующую конструкцию для создания его AST:

TypeScript AST Viewer

TypeScript AST Viewer

Инструмент TypeScript AST Viewer удобен в использовании и сильно экономит время для анализа AST, но есть в нем и небольшие ограничения. Он не отображает jsDoc-комментарии. При работе с ними есть определенные особенности, о которых расскажу в конце статьи.

Работа с AST

Мы посмотрели, как извлекать информацию из AST в REPL, теперь рассмотрим пример, как работать с ним в TypeScript

Предположим, что у нас есть файл со следующим содержимым:

// ./numbers.ts export interface Numbers {   one: 1   two: 2   three: 3 }

и нам нужно его распарсить и сформировать массив строк со значениями ключей интерфейса Numbers: ['one', 'two', 'three'].

Для этого добавим в ./src/index.ts:

// ./src/index.ts const numbersSource = ts.createSourceFile(   'x.ts',   fs.readFileSync(path.resolve(process.cwd(), './numbers.ts'), 'utf-8'),   ts.ScriptTarget.Latest,   undefined, );  const numbersDecl = numbersSource.statements.find((node) => {   return ts.isInterfaceDeclaration(node) && node.name.text === 'Numbers'; });  if (!numbersDecl || !ts.isInterfaceDeclaration(numbersDecl)) {   throw new Error('no interface "Numbers" in file'); }  const numbers = numbersDecl.members.reduce<string[]>((acc, node) => {   if (ts.isPropertySignature(node) && ts.isIdentifier(node.name)) {     acc.push(node.name.escapedText.toString())   }    return acc }, [])  console.log(numbers); // [ 'one', 'two', 'three' ]

Взаимодействие с AST в TypeScript не сильно отличается от работы с ним в REPL, за исключением того, что элементы statements являются инстансами NodeObject и ничего не знают о полях members, name и прочих. Для того чтобы обратиться к нужным нам полям, надо сначала проверить, что объект является инстансом конкретного класса, у которого имеются данные поля. Для этих целей в typescript есть огромное количество гардов, например, те, что мы использовали в коде выше: ts.isInterfaceDeclaration, ts.isPropertySignature и ts.isIdentifier.

Особенности работы с jsDoc

При анализе AST в REPL у инстансов NodeObject можно заметить поле jsDoc. Как видно из названия, в нем хранится информация о jsDoc-комментариях и тегах для данного узла дерева.

Парсинг jsDoc

Создадим простой файл с классом и комментарием к методу:

// ./comments.ts export class Greetings {   /**    * приветствует мир    * @deprecated метод устарел    */   hello() {     console.log('hello world')   } }

А затем распарсим его и проанализируем его AST в REPL:

// ./src/index.ts const commentsSource = ts.createSourceFile(   'x.ts',   fs.readFileSync(path.resolve(process.cwd(), './comments.ts'), 'utf-8'),   ts.ScriptTarget.Latest,   true, // setParentNodes Важно указать true, если нужно работать с jsDoc комментариями );  console.log(commentsSource.statements[0]);

Посмотрим, как выглядит AST jsDoc-комментария для метода hello класса Greetings

AST метода Greetings.hello

AST метода Greetings.hello

Как видим, в нем есть информация о тексте комментария, а также массив с тегами, состоящий из одного элемента.

Для получения jsDoc-комментариев и тегов в typescript существуют специальные методы:

// ./src/index.ts const greetingsClass = commentsSource.statements[0]; if (ts.isClassDeclaration(greetingsClass)) {   const jsDoc = ts.getJSDocCommentsAndTags(greetingsClass.members[0]);   console.log(jsDoc[0].comment); // приветствует мир    const deprecated = ts.getJSDocDeprecatedTag(greetingsClass.members[0])   console.log(deprecated.comment); // метод устарел }

Для корректной работы этих методов важно указывать при вызове createSourceFile пятый параметр метода (setParentNodes) равным true.

Генерация кода с jsDoc комментариями

При анализе AST видно, что jsDoc-комментарии хранятся в поле jsDoc у инстансов NodeObject. Но методов по добавлению этого поля к существующему объекту или конструкторов, принимающих jsDoc, в API typescript нет. Данная проблема озвучена в ишьюсе, и вот один из вариантов генерации typescript кода рассмотренного ранее класса Greetings:

// ./src/index.ts /** комментарий для метода hello */ const comment: any = ts.factory.createJSDocComment('приветствует мир', [   ts.factory.createJSDocUnknownTag(ts.factory.createIdentifier('deprecated')), // тег @deprecated ])  /** метод hello */ const methodDeclaration = ts.factory.createMethodDeclaration(   undefined,   undefined,   ts.factory.createIdentifier('hello'),   undefined,   undefined,   [],   undefined,   ts.factory.createBlock(     [       ts.factory.createExpressionStatement(         ts.factory.createCallExpression(           ts.factory.createPropertyAccessExpression(             ts.factory.createIdentifier('console'),             ts.factory.createIdentifier('log')           ),           undefined,           [ts.factory.createStringLiteral('hello world')]         )       ),     ],     true   ) )  /** класс Greetings */ const classDeclaration = ts.factory.createClassDeclaration(   [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],   'Greetings',   undefined,   undefined,   [comment, methodDeclaration] )  console.log(printer.printNode(ts.EmitHint.Unspecified, classDeclaration, file))

Вызов console.log в конце отобразит наш класс Greetings с jsDoc комментариями для метода hello:

Кодогенерация класса Greetings с jsDoc комментариями

Кодогенерация класса Greetings с jsDoc комментариями

Решение не очень элегантное, так как приходится добавлять any к объекту комментария, но все же позволяет добавить jsDoc к методу.


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


Комментарии

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

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