اكتب الاستدلال في jscodeshift و TypeScript
بدءًا من الإصدار 6.0 ، يدعم jscodeshift العمل مع TypeScript (TS). في عملية كتابة رمز الترميز (التحويلات) ، قد تحتاج إلى معرفة نوع المتغير الذي لا يحتوي على تعليق توضيحي صريح. لسوء الحظ ، لا يوفر 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(); }
لحسن الحظ ، تم بالفعل حل مشكلة inference type داخل برنامج التحويل البرمجي TS. يوفر برنامج التحويل البرمجي API وسيلة لاستنتاج الكتابة التي يمكنك استخدامها لكتابة التحويل.
ومع ذلك ، لا يمكنك فقط استخدام برنامج التحويل البرمجي TS واستخدامه عن طريق تجاوز محلل jscodeshift. الحقيقة هي أن jscodeshift تتوقع شجرة بناء جملة مجردة (AST) من المحللون الخارجيين في تنسيق ESTree . ومترجم TS AST ليست كذلك.
بالطبع ، يمكن للمرء استخدام برنامج التحويل البرمجي TS دون استخدام jscodeshift ، كتابة تحول من نقطة الصفر. أو استخدم إحدى الأدوات الموجودة في مجتمع TS ، على سبيل المثال ، ts-morph . لكن بالنسبة للكثيرين ، سيكون jscodeshift حلاً أكثر دراية وتعبيرية. لذلك ، سوف ندرس كذلك كيفية التغلب على هذا القيد.
تتمثل الفكرة في الحصول على التعيين من محلل jscodeshift AST (المشار إليه فيما يلي ESTree) إلى برنامج التحويل البرمجي TS AST (المشار إليه فيما يلي باسم 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
:
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- parseAndGenerateServices
وظيفة 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
بـ ClassMethod
لتجاوز أساليب الفصل (رمز الوصول إلى قيمة الإرجاع للأسلوب قد تغير أيضًا قليلاً). هذه هي تفاصيل المحلل اللغوي typescript-eslint. getReturnType
الدالة 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. من ناحية أخرى ، سيكون لديك تعيين كامل لبعض العقد للآخرين (والعكس بالعكس).
PS
تم ذكره أعلاه أنه عند استخدام محلل 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)); }
مراجع