Как проверить тип фрагмента кода TypeScript в памяти?

Я реализую поддержку TypeScript в своем приложении Data-Forge Notebook.

Мне нужно скомпилировать, проверить тип и оценить фрагменты кода TypeScript.

Компиляция, кажется, не проблема, я использую transpileModule как показано ниже, чтобы преобразовать фрагмент кода TS в код JavaScript, который можно оценить:

import { transpileModule, TranspileOptions } from "typescript";

const transpileOptions: TranspileOptions = {
    compilerOptions: {},
    reportDiagnostics: true,
};

const tsCodeSnippet = " /* TS code goes here */ ";
const jsOutput = transpileModule(tsCodeSnippet, transpileOptions);
console.log(JSON.stringify(jsOutput, null, 4));

Однако при попытке скомпилировать код TS с ошибкой возникает проблема.

Например, следующая функция имеет ошибку типа, но она передается без диагностики ошибок:

function foo(): string {
    return 5;
}

Прозрачность - это здорово, но я также хотел бы, чтобы мой пользователь мог отображать ошибки.

Поэтому мой вопрос заключается в том, как это сделать, но также выполнять проверку типов и создавать ошибки для семантических ошибок?

Обратите внимание, что я не хочу сохранять код TypeScript в файл. Это было бы ненужным бременем производительности для моего приложения. Я только хочу скомпилировать и проверить тип фрагментов кода, которые хранятся в памяти.

3 ответа

Решение

Я решил эту проблему, опираясь на оригинальную помощь Дэвида Шеррета, а затем совет Фабиана Пиркльбауэра ( создателя TypeScript Playground).

Я создал прокси-сервер CompilerHost, чтобы обернуть настоящий CompilerHost. Прокси-сервер способен возвращать код TypeScript в памяти для компиляции. Базовый реальный CompilerHost способен загружать библиотеки TypeScript по умолчанию. Библиотеки необходимы, иначе вы получите множество ошибок, связанных со встроенными типами данных TypeScript.

Код

import * as ts from "typescript";

//
// A snippet of TypeScript code that has a semantic/type error in it.
//
const code 
    = "function foo(input: number) {\n" 
    + "    console.log('Hello!');\n"
    + "};\n" 
    + "foo('x');"
    ;

//
// Result of compiling TypeScript code.
//
export interface CompilationResult {
    code?: string;
    diagnostics: ts.Diagnostic[]
};

//
// Check and compile in-memory TypeScript code for errors.
//
function compileTypeScriptCode(code: string, libs: string[]): CompilationResult {
    const options = ts.getDefaultCompilerOptions();
    const realHost = ts.createCompilerHost(options, true);

    const dummyFilePath = "/in-memory-file.ts";
    const dummySourceFile = ts.createSourceFile(dummyFilePath, code, ts.ScriptTarget.Latest);
    let outputCode: string | undefined = undefined;

    const host: ts.CompilerHost = {
        fileExists: filePath => filePath === dummyFilePath || realHost.fileExists(filePath),
        directoryExists: realHost.directoryExists && realHost.directoryExists.bind(realHost),
        getCurrentDirectory: realHost.getCurrentDirectory.bind(realHost),
        getDirectories: realHost.getDirectories.bind(realHost),
        getCanonicalFileName: fileName => realHost.getCanonicalFileName(fileName),
        getNewLine: realHost.getNewLine.bind(realHost),
        getDefaultLibFileName: realHost.getDefaultLibFileName.bind(realHost),
        getSourceFile: (fileName, languageVersion, onError, shouldCreateNewSourceFile) => fileName === dummyFilePath 
            ? dummySourceFile 
            : realHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile),
        readFile: filePath => filePath === dummyFilePath 
            ? code 
            : realHost.readFile(filePath),
        useCaseSensitiveFileNames: () => realHost.useCaseSensitiveFileNames(),
        writeFile: (fileName, data) => outputCode = data,
    };

    const rootNames = libs.map(lib => require.resolve(`typescript/lib/lib.${lib}.d.ts`));
    const program = ts.createProgram(rootNames.concat([dummyFilePath]), options, host);
    const emitResult = program.emit();
    const diagnostics = ts.getPreEmitDiagnostics(program);
    return {
        code: outputCode,
        diagnostics: emitResult.diagnostics.concat(diagnostics)
    };
}

console.log("==== Evaluating code ====");
console.log(code);
console.log();

const libs = [ 'es2015' ];
const result = compileTypeScriptCode(code, libs);

console.log("==== Output code ====");
console.log(result.code);
console.log();

console.log("==== Diagnostics ====");
for (const diagnostic of result.diagnostics) {
    console.log(diagnostic.messageText);
}
console.log();

Выход

==== Evaluating code ====
function foo(input: number) {
    console.log('Hello!');
};
foo('x');
=========================
Diagnosics:
Argument of type '"x"' is not assignable to parameter of type 'number'.

Полный рабочий пример доступен на моем Github.

Ситуация 1 - Использование только памяти - Нет доступа к файловой системе (например, в Интернете)

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

  1. Реализовать ts.CompilerHost где методы как fileExists, readFile, directoryExists, getDirectories()и т. д. читать из памяти вместо фактической файловой системы.
  2. Загрузите соответствующие файлы lib в свою файловую систему в памяти в зависимости от того, что вам нужно (например, lib.es6.d.ts или lib.dom.d.ts).
  3. Добавьте файл в памяти в файловую систему в памяти.
  4. Создать программу (используя ts.createProgram) и перейдите в свой обычай ts.CompilerHost,
  5. Вызов ts.getPreEmitDiagnostics(program) чтобы получить диагностику.

Несовершенный пример

Вот короткий несовершенный пример, который не реализует должным образом файловую систему в памяти и не загружает файлы lib (поэтому будут глобальные диагностические ошибки... их можно игнорировать или вы можете вызывать определенные методы в program Кроме как program.getGlobalDiagnostics(), Обратите внимание на поведение ts.getPreEmitDiagnostics здесь):

import * as ts from "typescript";

console.log(getDiagnosticsForText("const t: number = '';").map(d => d.messageText));

function getDiagnosticsForText(text: string) {
    const dummyFilePath = "/file.ts";
    const textAst = ts.createSourceFile(dummyFilePath, text, ts.ScriptTarget.Latest);
    const options: ts.CompilerOptions = {};
    const host: ts.CompilerHost = {
        fileExists: filePath => filePath === dummyFilePath,
        directoryExists: dirPath => dirPath === "/",
        getCurrentDirectory: () => "/",
        getDirectories: () => [],
        getCanonicalFileName: fileName => fileName,
        getNewLine: () => "\n",
        getDefaultLibFileName: () => "",
        getSourceFile: filePath => filePath === dummyFilePath ? textAst : undefined,
        readFile: filePath => filePath === dummyFilePath ? text : undefined,
        useCaseSensitiveFileNames: () => true,
        writeFile: () => {}
    };
    const program = ts.createProgram({
        options,
        rootNames: [dummyFilePath],
        host
    });

    return ts.getPreEmitDiagnostics(program);
}

Ситуация 2 - Доступ к файловой системе

Если у вас есть доступ к файловой системе, тогда это намного проще, и вы можете использовать функцию, аналогичную приведенной ниже:

import * as path from "path";

function getDiagnosticsForText(
    rootDir: string,
    text: string,
    options?: ts.CompilerOptions,
    cancellationToken?: ts.CancellationToken
) {
    options = options || ts.getDefaultCompilerOptions();
    const inMemoryFilePath = path.resolve(path.join(rootDir, "__dummy-file.ts"));
    const textAst = ts.createSourceFile(inMemoryFilePath, text, options.target || ts.ScriptTarget.Latest);
    const host = ts.createCompilerHost(options, true);

    overrideIfInMemoryFile("getSourceFile", textAst);
    overrideIfInMemoryFile("readFile", text);
    overrideIfInMemoryFile("fileExists", true);

    const program = ts.createProgram({
        options,
        rootNames: [inMemoryFilePath],
        host
    });

    return ts.getPreEmitDiagnostics(program, textAst, cancellationToken);

    function overrideIfInMemoryFile(methodName: keyof ts.CompilerHost, inMemoryValue: any) {
        const originalMethod = host[methodName] as Function;
        host[methodName] = (...args: unknown[]) => {
            // resolve the path because typescript will normalize it
            // to forward slashes on windows
            const filePath = path.resolve(args[0] as string);
            if (filePath === inMemoryFilePath)
                return inMemoryValue;
            return originalMethod.apply(host, args);
        };
    }
}

// example...
console.log(getDiagnosticsForText(
    __dirname,
    "import * as ts from 'typescript';\n const t: string = ts.createProgram;"
));

Делая это таким образом, компилятор будет искать rootDir для node_modules папку и используйте там наборы (их не нужно загружать в память другим способом).

Я хотел оценить строку, представляющую машинописный текст, и:

  • иметь видимость ошибок, связанных с типом
  • уметь использовать операторы как для исходных файлов, так и дляnode_modulesзависимости
  • иметь возможность повторно использовать настройки машинописного текста (tsconfig.jsonи т. д.) любого текущего проекта

Я достиг этого, написав временный файл и запустив его с помощью утилиты сchild_process.spawn

Это требуетts-nodeработать в текущей оболочке; возможно, вам придется сделать:

      npm install --global ts-node

или

      npm install --save-dev ts-node

Этот код использует ts-node для запуска любого фрагмента машинописного кода:

      import path from 'node:path';
import childProcess from 'node:child_process';
import fs from 'node:fs/promises';

let getTypescriptResult = async (tsSourceCode, dirFp=__dirname) => {
    // Create temporary file storing the typescript code to execute
    let tsPath = path.join(dirFp, `${Math.random().toString(36).slice(2)}.ts`);
    await fs.writeFile(tsPath, tsSourceCode);

    try {
        // Run the ts-node shell command using the temporary file
        let output = [] as Buffer[]; 
        let proc = childProcess.spawn('ts-node', [ tsPath ], { shell: true, cwd: process.cwd() });
        proc.stdout.on('data', d => output.push(d));
        proc.stderr.on('data', d => output.push(d));
        
        return {
          code: await new Promise(r => proc.on('close', r)),
          output: Buffer.concat(output).toString().trim()
        };
    } finally { await fs.rm(tsPath); } // Remove temporary file
};

Теперь я могу запустить:

      let result = await getTypescriptResult('const str: string = 123;');
console.log(result.output);

И через какое-то трехзначное число миллисекунд я вижу, чтоresult.outputпредставляет собой многострочную строку с этим значением:

      /Users/..../index.ts:859
    return new TSError(diagnosticText, diagnosticCodes, diagnostics);
            ^
TSError: ⨯ Unable to compile TypeScript:
6y4ln36ox8c.ts(2,7): error TS2322: Type 'number' is not assignable to type 'string'.

    at createTSError (/Users/..../index.ts:859:12)
    at reportTSError (/Users/..../index.ts:863:19)
    at getOutput (/Users/..../index.ts:1077:36)
    at Object.compile (/Users/..../index.ts:1433:41)
    at Module.m._compile (/Users/..../index.ts:1617:30)
    at Module._extensions..js (node:internal/modules/cjs/loader:1159:10)
    at Object.require.extensions.<computed> [as .ts] (/Users/..../index.ts:1621:12)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:827:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12) {
    diagnosticCodes: [ 2322 ]
}

Здесь должны появиться все соответствующие данные, хотя может потребоваться некоторый анализ!

Этот подход также поддерживаетimportзаявления:

      let typescript = `
import dependency from '@namespace/dependency';
import anotherDependency from './src/source-file';

doStuffWithImports(dependency, anotherDependency);
`;

let result = await getTypescriptResult(typescript, __dirname);
console.log(result.output);

Обратите внимание: если вы определяете в отдельном файле, вы можете передать__dirnameв качестве второго параметра при его вызове, чтобы разрешение модуля работало относительно текущего файла - в противном случае оно будет работать относительно файла, определяющегоgetTypescriptResult.

Другие вопросы по тегам