「代码强迫症?」从0到1实现项目代码拼写检查 vscode 插件:project-spell-checker

1,538 阅读9分钟

大家好,我是寒草🌿,一只工作一年半的草系码猿 🐒
如果喜欢我的文章,可以关注 ➕ 点赞,与我一同成长吧~
加我微信:hancao97,邀你进群,一起学习交流,成为更优秀的工程师~

观前提示:本篇文章涵盖本 vscode 插件从设计到开发实现的全部阶段,故内容可能比较长。

背景介绍

其实我一直是个代码强迫症患者,我之前写过两篇关于代码整洁的文章

拼写错误虽然看似不那么“重大”,但是也可以造成很多难以预料的问题,比如:

  • 引入意料之外且难以定位的 bug
  • 代码语义不清,接手开发者难以通过错误的拼写理解程序的语义
  • 正确与错误的拼写交织,相当于一个术语存在多个单词对应,加重维护负担
  • ...

但是我们也是肉体凡胎,很难去保证我们的代码产出没有任何拼写错误,所以 Code Spell Checker 插件有 4,216,410 的超高下载量。这个插件可以做到在我们编码过程中及时纠正我们的拼写错误,十分的方便。

然而,之前在团队做 code review 的过程中发现了一些拼写错误的情况发生,而且在查看项目老代码的过程中也发现了很多的拼写错误,所以作为一个代码强迫症患者,我就试图通过一些手段,让项目中所有的拼写错误呈现在我的面前。

即使不去修改,我也想看一看整个项目拼写错误的情况,进行一下数据的统计分析。

于是我这次在清明节假期,花了一天时间(其实不止...我只是在吹牛逼)从 0 到 1 开发了一个 vscode 插件:Project Spell Checker,以辅助我检查并统计项目中所有的拼写错误。

该插件已上架 vscode 插件商店~

那么下一章节,我将为大家介绍本插件的具体功能。

功能介绍

image.png

project-spell-checker 是具备自定义能力并完成全项目拼写检查的 vscode 插件, 具体能力分为三个:

  • 通过配置文件自定义插件扫描的文件范围
  • 通过 tree-view 显示各文件下的疑似拼写错误,并提示最多六个正确单词猜想
  • 通过 web-view 显示各个疑似拼写错误出现在哪些文件

提示:tree-viewweb-view 均支持文件的快捷跳转

配置文件

配置文件 spell-checker-config.json 可以放置于 .vscode 或者 .project 目录下,插件会自动读取,新建文件后输入:project-spell-checker 按下 tab ,可以自动生成配置文件模板内容:

image.png

  • excludedFloders: 不进行扫描的目录名
  • includedFileSubfixes:需要扫描的文件后缀名
  • excludedFileNames:不进行扫描的文件名
  • whiteList:单词白名单(支持字符串数组以及 , 分割的字符串)

视图

插件上面存在两个按钮:

  • 按钮一:形如统计图的按钮用来扫描并打开 web-view
  • 按钮二:形如刷新的按钮用来扫描并打开 tree-view

image.png

tree-view

树视图的展示维度为:文件 -> 疑似拼写错误

树形结构格式基本为:

|- fileName-[suspected spelling mistake count]
|-- mistake1 --> spelling suggestions OR :(
|-- mistake2 -✓-> spelling suggestions OR :(
|-- mistake3 -✓-> spelling suggestions OR :(
|-- mistake4 -✓-> spelling suggestions OR :(

可以点击文件名以进行跳转

web-view

页面视图的展示维度为:

疑似拼写错误 -> 文件

树形结构格式基本为:

    ----|- mistake1     - file-path1
                      - file-path2
                  
root----|- mistake2     - file-path3
                         
    ----|- mistake3     - file-path4

因为上面的概览不一定能让大家认识到这个树形结构如何,于是截了一个图,展示效果为:

image.png

可以点击文件名以进行跳转

吐槽 & 最初的设想

这是我在开发之前规划的需求清单:

image.png

未来其实我还想加入文件名的拼写检查,以及对疑似拼写错误的勾选批量修改。

插件开发攻略

image.png

各种链接

下面我的开发攻略以本次的逻辑代码为主~

工具方法

获取工作区根目录

先来个简单的,获取工作区根目录其实就是利用 vscode.workspace 下的 workspaceFolders 属性

const getRootPath = () => {
    const rootInfo = vscode.workspace.workspaceFolders[0];
    if(!rootInfo) {
        vscode.window.showInformationMessage('no suspected spelling mistakes!');
        return;
    } else {
        vscode.window.showInformationMessage('start checking suspected spelling mistakes...');
        vscode.window.showInformationMessage('This may take a long time. Please be patient~');
    }
    return rootInfo.uri.fsPath;
}

获取配置信息

其实就是读取 .vscode/spell-checker-config.json 或者 .project/spell-checker-config.json 的配置信息。

需要注意的是,如果用户没有配置信息我这个方法还是会返回一个默认的配置信息。

方法不是很难,但是可能我修改了很多次,写的有点子乱

const getCheckerConfig = (rootPath) => {
    const vscodeConfigPath = path.join(rootPath, '.vscode/spell-checker-config.json');
    const projectConfigPath = path.join(rootPath, '.project/spell-checker-config.json');
    const basicWhiteList = basicWhiteWords.split(',');
    const basicConfig = {
        excludedDirNameSet: new Set(["node_modules", ".git"]),
        includedFileSuffixSet: new Set(),
        excludedFileNameSet: new Set([".DS_Store"]),
        whiteListSet: new Set(basicWhiteList)
    }
    let configPath;
    // support config file in .vscode or .project
    if(fs.existsSync(vscodeConfigPath)) {
        configPath = vscodeConfigPath;
    } else if(fs.existsSync(projectConfigPath)) {
        configPath = projectConfigPath;
    } else {
        return basicConfig;
    }
    try {
        // avoid parse error
        const config = JSON.parse(fs.readFileSync(configPath, {
            encoding: 'utf-8'
        }));
        // because of word cannot include spec chars
        // so whiteList support word connected by ‘,’ or word array
        basicConfig.excludedDirNameSet = config.excludedFloders ? new Set(config.excludedFloders) : basicConfig.excludedDirNameSet;
        basicConfig.includedFileSuffixSet = config.includedFileSubfixes ? new Set(config.includedFileSubfixes) : basicConfig.includedFileSuffixSet;
        basicConfig.excludedFileNameSet = config.excludedFileNames ? new Set(config.excludedFileNames) : basicConfig.excludedFileNameSet;
        if(config.whiteList instanceof Array) {
            basicConfig.whiteListSet = config.whiteList ? new Set(basicWhiteList.concat(config.whiteList)) : basicConfig.whiteListSet;
        } else {
            basicConfig.whiteListSet = config.whiteList ? new Set(basicWhiteList.concat(config.whiteList.split(','))) : basicConfig.whiteListSet;
        }
        return basicConfig;
    } catch(err) {
        return basicConfig;
    }
}

获取需要扫描的文件列表

获取需要扫描的文件列表主要是一个递归,如果子文件是一个目录,则递归继续扫描。

这里需要注意的是配置文件中配置的不进行扫描的目录和文件要过滤掉(以及仅记录用户指定后缀名的文件

const _isDir = (path) => {
    const state = fs.statSync(path);
    return !state.isFile();
}

const getFileList = (dirPath, checkerConfig) => {
    let dirSubItems = fs.readdirSync(dirPath);
    const fileList = [];
    for (const item of dirSubItems) {
        const childPath = path.join(dirPath, item);
        if (_isDir(childPath) && !checkerConfig.excludedDirNameSet.has(item)) {
            fileList.push(...getFileList(childPath, checkerConfig));
        } else if (!_isDir(childPath) &&(checkerConfig.includedFileSuffixSet.size == 0 || checkerConfig.includedFileSuffixSet.has(path.extname(item))) && !checkerConfig.excludedFileNameSet.has(item)) {
            fileList.push(childPath);
        }
    }
    return fileList;
}

获取拼写错误信息

这个方法其实要做的事情比较多:

  • 读取上一个方法返回的每一个文件的文件内容,并扫描出一个个的英文单词

相当于一个小型的词法分析

  • 检查拼写错误并统计信息

这里统计信息为两个维度:文件 -> 疑似拼写错误列表,疑似拼写错误 -> 文件列表。分别用于 tree-viewweb-view

相对应的,这个方法就稍微复杂一点,我们拆分成几部分来讲。

读取文件并扫描单词

大致逻辑就是大写字母和非拉丁字母都会作为单词的分割。

for (const file of fileList) {
        const content = fs.readFileSync(file, {
            encoding: 'utf-8'
        });
        for (const char of content) {
            if (/[a-z]/.test(char)) {
                currentWord += char;
            } else if (/[A-Z]/.test(char)) {
                if(/^[A-Z]+$/.test(currentWord)) {
                    currentWord += char;
                } else {
                    handleCurrentWord(file);
                    currentWord = char;
                }
            } else {
                if (currentWord) {
                    handleCurrentWord(file);
                }
            }
        }
    }

检查拼写

这里我的拼写检查使用了 www.npmjs.com/package/sim… 这个包,其实对于拼写检查功能而言,有很多包可以用,但是经过我的尝试只有这个在 vscode-extension 环境下运行的比较顺畅。

源码大家也可以去看看,这个包的实现思路及其简单

他其实有 spellCheck 方法返回布尔值,但是我在使用过程中发现了一些问题(后来发现只有 windows 在调试过程中会有问题,发布后使用就没有问题了,就很尴尬,而 mac 一直都没有问题)。

我这里用 getSuggestions 进行判断其实有几个好处:

  • 我可以把字典中的内容进行 lowerCase 后进行比较
  • 顺便我可以对建议信息进行一些处理

但是性能会有所下降(都怪 windows 调试过程有问题,嗯不是我的锅,我也是甩锅大师了)

const SpellChecker = require('simple-spellchecker');
const dictionaryGB = SpellChecker.getDictionarySync("en-GB", path.join(__dirname, '../dict'));  
const dictionaryUS = SpellChecker.getDictionarySync("en-US", path.join(__dirname, '../dict')); 

...

// it's not support windows, so change the check exe.
// by this way, we can change lowercase to compare
// if(dictionaryGB.spellCheck(word) || dictionaryUS.spellCheck(word)) {
const suggestionsGbAndUs = new Set();
dictionaryGB.getSuggestions(word, 5, 3).forEach(str => {
    if(!str.includes('\'')) {
        suggestionsGbAndUs.add(str.toLowerCase());
    }
})
dictionaryUS.getSuggestions(word, 5, 3).forEach(str => {
    if(!str.includes('\'')) {
        suggestionsGbAndUs.add(str.toLowerCase());
    }
})
if(suggestionsGbAndUs.has(word)) {
    healthWordSet.add(word);
    return;
}
suggestions = [...suggestionsGbAndUs].join('/');

完整方法

这里我简单使用了一个 healthWordSet 以提升判断单词是否拼写错误的执行效率。

mistakeInfoMapmistakeWordMap 就是上文提到用于存储的两个维度拼写错误信息的数据结构。

const getSpellingMistakeInfo =  (fileList, checkerConfig, rootPath) => {
    let currentWord = '';
    const mistakeInfoMap = new Map();
    // use set or map to improve performance
    const healthWordSet = new Set([...checkerConfig.whiteListSet]);
    // use to record word => suggestions & files reflect
    const mistakeWordMap = new Map();
    const handleCurrentWord = (file) => {
        const word = currentWord.toLowerCase();
        currentWord = '';
        let suggestions = '';
        if(word.length <= 1 || healthWordSet.has(word)) {
            return;
        } else if(mistakeWordMap.has(word)) {
            suggestions = mistakeWordMap.get(word).suggestions;
            mistakeWordMap.get(word).files.add(file.replace(rootPath, ''));
        } else {
            // it's not support windows, so change the check exe.
            // by this way, we can change lowercase to compare
            // if(dictionaryGB.spellCheck(word) || dictionaryUS.spellCheck(word)) {
            const suggestionsGbAndUs = new Set();
            dictionaryGB.getSuggestions(word, 5, 3).forEach(str => {
                if(!str.includes('\'')) {
                    suggestionsGbAndUs.add(str.toLowerCase());
                }
            })
            dictionaryUS.getSuggestions(word, 5, 3).forEach(str => {
                if(!str.includes('\'')) {
                    suggestionsGbAndUs.add(str.toLowerCase());
                }
            })
            if(suggestionsGbAndUs.has(word)) {
                healthWordSet.add(word);
                return;
            }
            suggestions = [...suggestionsGbAndUs].join('/');
            mistakeWordMap.set(word, {suggestions, files: new Set([file.replace(rootPath, '')])});
        }
        const getBasicMistake = (word) => ({
            count: 1,
            word: new Map([[word, suggestions]])
        })
        if(!mistakeInfoMap.has(file)) {
            mistakeInfoMap.set(file, getBasicMistake(word));
        } else {
            const mistake = mistakeInfoMap.get(file);
            mistake.count++;
            mistake.word.set(word, suggestions);
        }
    };
    for (const file of fileList) {
        const content = fs.readFileSync(file, {
            encoding: 'utf-8'
        });
        for (const char of content) {
            if (/[a-z]/.test(char)) {
                currentWord += char;
            } else if (/[A-Z]/.test(char)) {
                if(/^[A-Z]+$/.test(currentWord)) {
                    currentWord += char;
                } else {
                    handleCurrentWord(file);
                    currentWord = char;
                }
            } else {
                if (currentWord) {
                    handleCurrentWord(file);
                }
            }
        }
    }
    const spellingMistakeInfo = [...mistakeInfoMap].map(item => ({
        name: path.basename(item[0]),
        path: item[0],
        info: {
            path: item[0],
            count: item[1].count,
            word: [...item[1].word].map(item => ({
                original: item[0],
                suggestion: item[1]
            }))
        }
    }))
    const mistakeWordInfo = [...mistakeWordMap].map(item => ({
        name: item[0],
        children: [...item[1].files].map(child => ({
            name: child,
            type: 'path'
        }))
    }))
    return {
        spellingMistakeInfo,
        mistakeWordInfo
    }
}

vscode 交互

代码补全

代码补全也是一个老生常谈的功能了~

首先在 package.json 进行如下配置,language 就是配置在什么文件中代码补全会生效。

"contributes": {
    "snippets": [
        {
            "language": "json",
            "path": "./snippets.json"
        }
    ],
}

path 对应的 ./snippets.json 中有代码段的配置信息,文件内容如下:

{
    "project-spell-checker:Configs": {
		"prefix": "project-spell-checker",
		"body": [
            "{",
			"   \"excludedFloders\": [\"node_modules\", \".git\"],",
			"   \"includedFileSubfixes\": [],",
			"   \"excludedFileNames\": [\".DS_Store\"],",
			"   \"whiteList\": \"string,or,array\"",
			"}"
		],
		"description": "project-spell-checker:Configs"
	}
}

tree-view

上面的参考链接中有我开发 tree-view 参考过的比较通俗易懂的文章,整个流程其实就是实现两个类:

  • TreeViewProvider
  • TreeItemNode

TreeViewProvider 也是要去实现:

  • getTreeItem
  • getChildren
  • initTreeView【静态方法】

getChildren 中我判断 element 是否存在就是判断其是否为根结点,如果是根结点,那它的子结点就是文件名信息,如果 element 存在并有 info 字段就代码该结点是文件,则其子结点为该文件下的拼写错误信息。

该方法中使用了 TreeItemNode 类构造节点,他的父类 TreeItem 构造函数的参数为:

  • 节点 label
  • 节点的默认展开状态
const { TreeItem, window, TreeItemCollapsibleState, Uri } = require('vscode');
const path = require('path');

class TreeItemNode extends TreeItem {
    constructor(label, collapsibleState, info) {
        super(label, collapsibleState);
        this.info = info;
        if(!info) {
            this.iconPath = TreeItemNode.getIconUri('error');
        } else {
            this.iconPath = TreeItemNode.getIconUri('jump');
            // 绑定点击事件
            this.command = {
                title: String(this.label),
                command: 'itemClick', 
                tooltip: String(this.label),  
                arguments: [  
                    this.info,   
                ]
            }
        }
    }
    static getIconUri(name) {
        return Uri.file(path.join(__filename,'..', '..' ,`resources/${name}.svg`));
    }
}
class TreeViewProvider {
    constructor(tree) {
        this.tree = tree;
    }

    getTreeItem(element) {
        return element;
    }

    getChildren(element) {
        if(!element) {
            return this.tree.map(item => new TreeItemNode(`${item.name}-[${item.info.count} suspected]`, TreeItemCollapsibleState['Expanded'], item.info));
        } else if(element.info) {
            return element.info.word.map(item => new TreeItemNode(`${item.original} -✓-> ${item.suggestion || ':('}`, TreeItemCollapsibleState['None']))
        }
    }

    static initTreeView(tree) {
        const treeViewProvider = new TreeViewProvider(tree);
        window.createTreeView('spellCheckerTree-main', {
            treeDataProvider: treeViewProvider
        });
    }
}

web-view

主要调用 window.createWebviewPane api。

我的 html 代码通过 getHtml 方法使用模版字符串返回。

树形图我使用了 echarts

const { window, Uri } = require('vscode');
let webviewPanel;
function createWebView(context, viewColumn, data, rootPath) {
    if (webviewPanel === undefined) {
        webviewPanel = window.createWebviewPanel(
            'spelling-check-statistics',
            'spelling-check-statistics',
            viewColumn,
            {
                retainContextWhenHidden: true,
                enableScripts: true
            }
        )
    } else {
        webviewPanel.reveal();
    }
    webviewPanel.webview.html = getHtml(data);
    webviewPanel.onDidDispose(() => {
        webviewPanel = undefined;
    });
    return webviewPanel;
}

function getHtml(data) {
    const _data = {
        name: 'suspected mistakes',
        children: data
    }
    const _height = data.reduce((total, current) => {
        return total + current.children.length * 25;
    }, 0)
    return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    
    <body style="background: #fff;">
        <div id="test"></div>
        <div style="width:100%;height:100vh;overflow: auto;">
            <div id="main" style="min-width: 100%;height: ${_height}px;"></div>
        </div>
        <script src="https://cdn.jsdelivr.net/npm/echarts@5.3.2/dist/echarts.min.js"></script>
        <script>
            const vscode = acquireVsCodeApi();
            var chartDom = document.getElementById('main');
            var myChart = echarts.init(chartDom);
            var option;
            const data = ${JSON.stringify(_data)};
            option = {
                tooltip: {
                  trigger: 'item',
                  triggerOn: 'mousemove',
                  formatter: '{b}'
                },
                series: [
                  {
                    type: 'tree',
                    data: [data],
                    top: '1%',
                    left: '15%',
                    bottom: '1%',
                    right: '60%',
                    symbolSize: 7,
                    initialTreeDepth: 1,
                    label: {
                        backgroundColor: '#fff',
                        position: 'left',
                        verticalAlign: 'middle',
                        align: 'right',
                        fontSize: 16
                    },
                    leaves: {
                      label: {
                        position: 'right',
                        verticalAlign: 'middle',
                        align: 'left'
                      }
                    },
                    emphasis: {
                      focus: 'descendant'
                    },
                    expandAndCollapse: true,
                    animationDuration: 550,
                    animationDurationUpdate: 750
                  }
                ]
            };
            option && myChart.setOption(option);
        </script>
    </body>
    </html>
    `
}

web-view 向 vscode 通信

html 中使用 acquireVsCodeApivscode.postMessage

const vscode = acquireVsCodeApi();
myChart.on('click', 'series', function (params) {
    if(params.data.type == 'path') {
        vscode.postMessage({jump: params.data.name});
    }
});

vscode 中进行监听:

webviewPanel.webview.onDidReceiveMessage(message => {
    //...
}, undefined, context.subscriptions);

打开指定文件

没啥特别的,就是调用 window.showTextDocument api

vscode.window.showTextDocument(vscode.Uri.file(info.path))

结束语

image.png

我已经很久没有写文章了,主要原因是最近一股脑投入到了 leetcode 刷题大军,以及开始尝试去创作视频来分享我的奇思妙想和一些尬到极致的脱口秀,上面的这个极其中二的图片也是我为了做视频设计的(样式参考了boss直聘的魔性广告牌)。

可以 b 站搜索“攻城狮寒草”,如果关注感激不禁☀️

回忆做一名工程师的初心,其实还是:用技术创造美好,我想我也做了快要两年了,其实也可以去尝试更多的东西了。未来大家会看到更加多元的寒草,待到时机成熟时,我也可以把自己心中美好的,理想的东西以技术方式呈现给大家,敬请期待吧。

最后给大家以及我自己一份祝福:

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

愿我们可以活出自我
愿我们能够不负一生

极光斑斓
星河灿烂
山川层峦
水波荡漾

美好终会与我们相伴

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

谢谢大家支持,我的微信号是 hancao97,下次再见咯🌺