插件开发攻略
各种链接
- 官方文档:code.visualstudio.com/api
- vscode 插件的各种 api:vscode-api-cn.js.org/modules/vsc…
- 关于项目初始化和一些准备工作我之前的文章也写过了,也不在此赘述:juejin.cn/post/696604…
- 本项目的 git 地址:github.com/hancao97/pr…
- tree-view 以及 web-view 开发参考【我觉得写的不错】:blog.csdn.net/weixin_4227…
下面我的开发攻略以本次的逻辑代码为主~
工具方法
获取工作区根目录
先来个简单的,获取工作区根目录其实就是利用 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-view
和web-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
以提升判断单词是否拼写错误的执行效率。
mistakeInfoMap
和 mistakeWordMap
就是上文提到用于存储的两个维度拼写错误信息的数据结构。
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 中使用 acquireVsCodeApi
与 vscode.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))
结束语
我已经很久没有写文章了,主要原因是最近一股脑投入到了 leetcode 刷题大军,以及开始尝试去创作视频来分享我的奇思妙想和一些尬到极致的脱口秀,上面的这个极其中二的图片也是我为了做视频设计的(样式参考了boss直聘的魔性广告牌)。
可以 b 站搜索“攻城狮寒草”,如果关注感激不禁☀️
回忆做一名工程师的初心,其实还是:用技术创造美好
,我想我也做了快要两年了,其实也可以去尝试更多的东西了。未来大家会看到更加多元的寒草,待到时机成熟时,我也可以把自己心中美好的,理想的东西以技术方式呈现给大家,敬请期待吧。
最后给大家以及我自己一份祝福:
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
愿我们可以活出自我
愿我们能够不负一生
极光斑斓
星河灿烂
山川层峦
水波荡漾
美好终会与我们相伴
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨