Wrote a vscode plugin: automatically add optional chains

A few days ago a friend asked me a question:

Is it possible to automatically turn attribute access into an optional chain through the eslint plug-in.

This is of course possible, let's implement it:

mkdir auto-optional-chain

cd auto-optional-chain

npm init -y

Create a project and create a new package.json

Install the eslint package

npm install --save eslint

Then write such a piece of code in src/index.js:

const { ESLint } = require("eslint");

const engine = new ESLint({
    fix: false,
    overrideConfig: {
        parser: "@babel/eslint-parser",
        rules: {
            'semi': ['error', 'never']
        }
    },
    useEslintrc: false
});

async function main() {
    const results = await engine.lintText(`
        function handleRes(data) {
            const res = data.a.b.c + data.e.f.g;

        }
    `)
    
    console.log(results);
}
main();

eslint generally uses the command line, of course, it also has the API method.

We new the ESLint object, specify the configuration, and do not automatically fix it.

Then use lintText to check a piece of code and print the result.

Here we use a rule, which is to check the semicolon at the end, and set it to not add a semicolon.

@babel/eslint-parser is used here, install it:

npm install @babel/eslint-parser

Then create the babel configuration file:

Then run:

You can see that there is indeed an error.

The expansion looks like this:

There is an extra semicolon at line 3, column 48.

But this looks too laborious, let's format it:

const formatter = await engine.loadFormatter('stylish');
const resultText = formatter.format(results);
console.log(resultText);

eslint has some built-in formatters, use it to format and then print:

This error format is the one we often see.

Then we change fix to true, that is, automatic repair:

Print result[0].output, which is the result after the first error is automatically repaired:

You can see that the trailing semicolon has been removed.

This is the usage of eslint's api method.

Let's write a plugin that automatically adds optional chains.

New src/auto-optional-chain.js

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        return {
           BlockStatement(node) {
           }
       }
   }
};

The meta part is to specify the meta information of this plug-in, such as documentation, whether it can be automatically fixed, etc.

The create part is the implementation logic of the plug-in, specifying what to do with what node.

So what AST nodes are we dealing with?

You can use astexplorer.net to see:

Select javascript, parse it with @babel/parser, and you can see the parsed AST later.

As you can see, the syntax of this data.name is called MemberExpression member expression.

如果多个 . 的话就是 MemberExpression 嵌套了:

那 data?.name 呢?

可以看到,叫做 OptionalMemberExpression

也就是说我们找到 MemberExpression,给它报个错,然后 fix 的时候修复为可选链的方式就好了。

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        return {
            MemberExpression(node) {
                context.report({
                    node,
                    loc: {
                        line: 111,
                        column: 222
                    },
                    message: '应该用可选链'
                })
            }
       }
   }
};

指定 rulePaths 也就是去哪里找 rule,然后配置这个 rule 为 error 级别:

测试下:

可以看到,确实是报了 6 个错误。

只不过现在的位置不太对。

拿到 . 的位置需要用 token 相关的 api。

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                context.report({
                    node,
                    loc: {
                        line: 111,
                        column: 222
                    },
                    message: '应该用可选链'
                })
            }
       }
   }
};

我们断点调试下:

创建 launch.json

创建 node 类型的调试配置:

在代码里打个断点:

点击调试启动:

代码会在断点处断住:

可以看到有 7 个 token,分别是 data 和 . 和 a 和 b 和 . 和 c

那我们取哪个 . 的 loc 呢?

可以看到,第一次断住是这样的:

第二次是这样的:

第三次是这样的:

也就是说 data.a.b.c 是从右向左解析的,所以我们要拿到的是最后一个 . 的 token 的位置。

取倒数第二个,可以用数组的 at 方法:

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                context.report({
                    node,
                    loc: tokens.at(-2).loc,
                    message: '应该用可选链'
                })
            }
       }
   }
};

现在的位置就都对了:

光报错意义不大,我们再实现自动 fix。

eslint 的 fix 是基于字符串替换实现的,它提供了一个 fixer api。

打断点看看:

很明显,这里比较适合用 insertTextBefore 来做。

也就是这样:

context.report({
    node,
    loc: dotToken.loc,
    message: '应该用可选链',
    fix: fixer => {
        return fixer.insertTextBefore(dotToken, '?')
    }
})

但要注意,fix 之后会再次 lint,这时候拿到的 token 就这样了:

这种应该不再 fix,直接跳过:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                const dotToken = tokens.at(-2);

                if(dotToken.value === '?.'){
                    return;
                }

                context.report({
                    node,
                    loc: dotToken.loc,
                    message: '应该用可选链',
                    fix: fixer => {
                        return fixer.insertTextBefore(dotToken, '?')
                    }
                })
            }
       }
   }
};

测试下:

修复后的代码是对的。

只要项目里用到了这个 rule,开启自动 fix 就可以自动加上可选链。

但这样其实有个问题:不是所有的 data.xxx 都需要变成可选链的方式,而现在这个 eslint rule 是把所有的 data.xxx 都自动 fix 了。

也可以写个 babel 插件来做这件事情,不修改源代码,只是在编译的时候做:

const { transformSync } = require('@babel/core');

function autoOptionalPlugin() {
    return {
        visitor: {
            MemberExpression(path, state) {
                const text = path.toString();

                path.replaceWithSourceString(text.replace(/\./g, '?.'));
            }
        }
    }
}

const res = transformSync(`
    function handleRes(data) {
        const res = data.a.b.c + data.e.f.g;

    }
`, {
    plugins: [autoOptionalPlugin]
});

console.log(res.code);

用 transformSync 来编译源代码为目标代码,过程中调用 autoOptionalPlugin。

插件里处理 MemberExpression,拿到代码对应的字符串,然后把 . 改成 ?. 再替换回去。

效果和 eslint 插件是一样的:

babel 插件的好处是不修改源码,可以在编译过程中无感的做这件事情。

那我如果就是想把代码改了,但是还不能全部改,而是我选中哪部分就自动修复哪部分代码呢?

这种就要用 vscode 插件来做了。

安装 vscode 插件的脚手架:

npm install -g yo generator-code

生成 vscode 插件项目:

yo code 

生成的项目是这样的:

它已经配置好了调试配置,点击就可以调试:

它会启动一个新的 vscode 窗口,然后输入 hello world 命令,右下角会有提示框:

这就代表 vscode 插件运行成功了。

然后想一下我们的插件要做成什么样子:

选中一段代码,右键菜单里会有转换为可选链的选项,点击就可以转换。

或者选中之后,按快捷键也可以转换。

在 src/extention.ts 里实现下命令的注册:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
            vscode.window.showInformationMessage('转换成功!');
    });

    context.subscriptions.push(transformCommand);
}

然后在 package.json 里也要声明:

"contributes": {
    "commands": [
      {
        "command": "transformToOptionalChain",
        "title": "xxx"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "transformToOptionalChain"
        }
      ]
    }
},

commands 里声明这个 command,指定 title。

menus 声明 editor/context 也就是编辑器的上下文菜单,添加一个。

测试下:

确实多了一个菜单项,点击之后会执行 command 的逻辑:

回过头来看下这段配置:

这里的 editor/context 是注册编辑器的右键菜单,当然,还有很多别的地方的菜单可以注册:

这个菜单项还可以指定出现的时机,显示的分组:

比如 1_modification 就是这里:

而 navigation 就是这里:

这个分组在文档里也有写:

然后指定菜单项出现的时机:

当语言类型 为 js 或者 ts,并且选中文本的时候才出现:

这样在非 js、ts 文件里是没这个菜单的:

在 js、ts 里不选中也是没有的:

只有在 js、ts 文件,并且选中文本,才会出现这个菜单项:

然后我们就可以写具体的逻辑了:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {

	const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
		const editor = vscode.window.activeTextEditor;

		if(editor) {
			const selectedText = editor.document.getText(editor.selection);
		
			editor.edit(builder => {
				builder.replace(editor.selection, selectedText.toUpperCase());
			});
			vscode.window.showInformationMessage('转换成功!');
		}
	});

	context.subscriptions.push(transformCommand);
}

通过 vscode.window.activeTextEditor 拿到当前的 editor,然后拿到选中区域的文本,执行替换。

这里只是替换为了大写。

打个断点试试:

代码执行到这里会断住:

可以看到,拿到的文本就是选中的。

那我们把之前用 babel 插件做代码转换的逻辑拿过来就好了。

安装用到的 @babel/core 包和它的 ts 类型包:

npm install --save @babel/core

npm i --save-dev @types/babel__core

然后用它来做下选中代码的转换:

import * as vscode from 'vscode';
import * as babel from '@babel/core';
import type { NodePath, types} from '@babel/core';

function transform(code: string): string{
	function autoOptionalPlugin() {
		return {
			visitor: {
				MemberExpression(path: NodePath<types.MemberExpression>) {
					const text = path.toString();

					path.replaceWithSourceString(text.replace(/\./g, '?.'));
				}
			}
		}
	}

	const res = babel.transformSync(code, {
		plugins: [autoOptionalPlugin]
	});

	return res?.code || '';
}

export function activate(context: vscode.ExtensionContext) {

	const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
		const editor = vscode.window.activeTextEditor;

		if(editor) {
			const selectedText = editor.document.getText(editor.selection);
		
			if(!selectedText) {
				return;
			}

			editor.edit(builder => {
				builder.replace(editor.selection, transform(selectedText));
			});
			vscode.window.showInformationMessage('转换成功!');
		}
	});

	context.subscriptions.push(transformCommand);
}

这里的 transform 方法就是前面讲过的用 babel 做代码转换的实现。

测试下:

只有选中的代码才会做转换,没选中的不会:

还可以把这个功能注册成快捷键:

"keybindings": [
  {
    "command": "transformToOptionalChain",
    "key": "ctrl+y",
    "mac": "cmd+y",
    "when": "(resourceLangId == javascript || resourceLangId == typescript) && editorHasSelection"
  }
]

在 windows 下是 ctrl + y,在 mac 下是 command + y

是不是用起来超级方便?

总结

我们想自动把代码里的 data.xxx 转成可选链的形式 data?.xxx。

于是写了 eslint 插件、babel 插件来做这件事。

eslint 插件的 fix 是通过字符串替换的方式修改源码。

babel 插件是通过 ast 的方式修改代码,而且只是改了编译后的代码。

但这俩都是全局替换的,还是自己选择替换哪部分更好,所以我们又写了一个 vscode 插件。

vscode 插件可以在 package.json 的 contributes 里配置 commands、menus、keybindings。

我们注册了一个命令,配置了编辑器右键菜单,并且绑定了快捷键。

当执行这个命令的时候,拿到选中的文本内容,通过 babel 插件来做转换,之后替换回去。

写完这个 vscode 插件以后,再遇到这种情况,只要选中文本,按个快捷键就可以搞定。

Guess you like

Origin juejin.im/post/7264012271364554810