はじめに
俺々言語を作っていると「やっぱりIDE欲しいよね」ということになります。しかし、IDEを全部作るには人生の余白があまりにも少なすぎます。IDE的なものを作ろうとしたらテキストエディタの拡張を書くのが現実的でしょう。最近流行りのテキストエディタといえば VSCode です(異論はあると思います)。というわけで VSCode で俺々言語モードを作るための最小限の情報とサンプルコードをまとめておこうと思いました。
成果物は https://github.com/usm-takl/vscode-oreore-mode にあります。
スコープ
- VSCode の拡張の作り方を説明します。
- サンプルコードに徹します。
- 俺々言語の作り方は説明しません。
- 俺々言語の解析方法は説明しません。
- 「どうやって VSCode に suggestion を表示させるか」などを説明します。
- 「どうやって俺々言語の suggestion の内容を決めるか」は説明しません。
- JavaScript自体の説明はしません
情報源
情報源は公式サイトの Extension API です。これ読めばだいたいOKなはずです。英語が苦にならない方はこれ読みましょう。
すすめかた
拡張の作り方ですが、 yo
というコードジェネレータを使うのがよくある方法です。が、コードジェネレータを使う方法は、私にとって理解しにくいので、とにかく最小限の拡張を手で順に作っていく方法を採ろうと思います。
具体的には
- とにかく最小の拡張を作る
- hello world コマンドを作る
- code formatter を作る
- oreore モードを作る
- hover provider を作る
- definition provider を作る
- completion item provider を作る
- signature help provider を作る
- syntax highlight を作る
- tree data provider を作る
と進めていこうと思います。
また、題材ですが、次のテキストを使おうと思います。
def apple = 1
def banana = 2
def cherry = 3
give apple
take banana
eat cherry
特に意味のないテキストです。サンプルコード用なのでこれで十分でしょう。test.ore
という名前でどこかに保存しておいてください。
とにかく最小の拡張を作る
まずはとにかく最小の拡張をつくっていきます。
VSCode を起動して、 oreore という名前のフォルダーを作ります。そして、中身を下図のようにしてください。たぶんこれが現実的に最小の構成です。
- launch.json はデバッグ用のファイルです。不要と言えば不要ですが、無いとあまりにも不便です。
- extension.js は VSCode 拡張のエントリポイントです。実際には名前は何でも良いのですが、公開されている拡張のソースコードをいくつか見てみると、 extension.js という名前になっいるのが多い気がします。
- package.json は「これは VSCode の extension ですよー」ということを書いておくファイルです。無いと拡張になりません。
launch.json の中身は次のような感じです。
ワークスペースの .vscode 以下これがあると、 F5 を押したときに code --extensionDevelopmentPath=c:\foo\bar\oreore
みたいな感じで VSCode を起動してくれます。
{
"version": "0.2.0",
"configurations": [
{
"name": "Extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
]
}
]
}
とりあえずコレだけ用意して F5 を押してやると、新しく VSCode が立ち上がって、右下に「package.json を解析できません」というメッセージが出てきます。とりあえず拡張を読み込もうとしてくれていることはわかりますね。
次は package.json です。
{
"name": "oreore-language-extension",
"version": "0.0.1",
"engines": {
"vscode": "^1.33.1"
},
"main": "./extension.js",
"activationEvents": [
"*"
]
}
-
name
は拡張の名前です。拡張を公開するつもりなら、他と被らないようにしましょう。 -
version
は拡張のバージョンです。自分で勝手に決めてよいのですが、npm-semverに従いましょう。 -
engines
はこの拡張に必要なエディタとバージョンを指定します。私が今使っているバージョンが1.33.1
なので^1.33.1
にしました。厳密な書き方は、version と同じく、npm-semverを参照してください。 -
main
はエントリポイントを指定します。拡張を起動したときに./extension.js
を実行します、ということですね。 -
activationEvents
は「いつ拡張を起動するか」を指定します。"*"
を指定すると VSCode 起動時に拡張を起動します。その他の指定方法は公式サイトの Activation Eventsを参照してください。
この状態で起動してみても良いのですが、何も起こらないのも悲しいので、extension.js に例のアレ書いてみましょう。
console.log("hello, world");
これを用意して F5 を押すと、デバッグコンソールに hello, world
と表示されます。拡張が読み込まれました。やったね!
ちなみに表示されるのは起動された側の VSCode ではなく、起動する側(F5を押した側)の VSCode のデバッグコンソールです。
ところでこれ、ちょっと行儀が悪いコードになっています。公式サイトの Activation Eventsの下の方の Note を見てみましょう。
Note: An extension must export an activate() function from its main module and it will be invoked only once by VS Code when any of the specified activation events is emitted. Also, an extension should export a deactivate() function from its main module to perform cleanup tasks on VS Code shutdown. Extension must return a Promise from deactivate() if the cleanup process is asynchronous. An extension may return undefined from deactivate() if the cleanup runs synchronously.
要点をまとめると、
- main module は activate() 関数を export しなければならない(must)
- activate() 関数は起動条件が整ったときに VSCode から一回だけ呼ばれる
- main module は deactivate() 関数を export すべき(should)
- deactivate() 関数は VS Code が shutdown するときに呼ばれる
- deactivate() 関数の中では cleanup 処理をしてね
- cleanup 処理を非同期に行うなら deactivate() 関数は Promise を返してね
- cleanup 処理を同期的に行うなら deactivate() 関数は undefined を返してもいいよ
という感じでしょうか。
これに従って extension.js を書き換えると次のようになります。
function activate(context) {
console.log('hello, world');
}
function deactivate() {
return undefined;
}
module.exports = { activate, deactivate };
F5 を押すとやはりデバッグコンソールに hello, world
が表示されると思います。
これで一通り、最小限の拡張ができました!
余談1
余談ですが、deactivate
関数内で console.log()
しても何も表示されません。
function deactivate() {
console.log('goodbye, world'); // 何も表示されない!
return undefined;
}
理由は VSCode の issue 47881 に書かれています。結論だけ言うと 「deactivate() が呼ばれる前にデバッガとの接続が切られてしまうから」でしょうか。deactivate() 自体は確実に呼ばれるそうです。細かくはリンク先を読んで下さい。
Hello World コマンドを作る
次にコマンドを作ります。
コマンドというのはコマンドパレットから実行できるプログラムです。左下の歯車マークから選べるやつです。
こういうやつですね。
Ctrl+Shift+P
でも開けます。ここから呼べるプログラムを作ります。
やることは次の2点です。
- package.json でコマンド名を登録
- activate() 関数内でコマンドを登録
の2点です。
{
"name": "oreore-language-extension",
"version": "0.0.1",
"engines": {
"vscode": "^1.33.1"
},
"main": "./extension.js",
"activationEvents": [
"*"
],
"contributes": {
"commands": [
{
"command": "oreore.helloWorld",
"title": "OreoreMode: say hello world"
}
]
}
}
"contributes"
のところが今回追加した部分です。
-
"contributes"
は拡張が提供(contribute)する機能を列挙する部分です。今回はコマンドひとつだけです。 -
"commands"
はコマンドを提供するときに書きます。コマンド以外にも提供できる機能は色々あります。commands 以外に何があるか知りたい場合は、公式の Contribution Points を読みましょう。 - 今回提供するコマンドは一個だけなので、
"commands"
以下に一個だけ要素を書きます。"command"
がコマンド名で、"title"
がコマンドパレットに表示されるコマンド名です。
さて、この状態でコマンドパレットを起動して、oreoreと入れてみましょう。
コマンドが表示されましたね。さっそく実行してみましょう。
はい、処理が登録されていないので当然失敗します。というわけで処理を登録しましょう。
const vscode = require('vscode');
function helloWorld() {
vscode.window.showInformationMessage('Hello, world!')
}
function activate(context) {
context.subscriptions.push(vscode.commands.registerCommand('oreore.helloWorld', helloWorld));
}
function deactivate() {
return undefined;
}
module.exports = { activate, deactivate };
要点は次の4つです。
-
const vscode = require('vscode');
で vscode の機能を読み込む -
vscode.commands.registerCommand()
でコマンド名と関数を結び付ける -
context.subscriptions.push()
で cleanup 処理を登録する -
vscode.window.showInformationMessage()
でメッセージを表示する
再度コマンドパレットから OreoreMode: say hello world
を選択してみましょう。
出ましたね。
これで一応コマンドができました。
「俺々言語でコマンドを何に使うか」ですが、ビルドコマンドやテスト起動コマンドに使うといいんじゃないでしょうか。
余談2
「コマンドを登録するのは registerCommand() だけで十分じゃないの?」「package.jsonのcommandは何のためにあるの?」とか思ったのですが、色々調べてみると「package.json に記述されたコマンドを呼び出したときにはじめて拡張を読み込む」というようなこともできるので、そのためにはやっぱり package.json と registerCommand 両方が必要なのかなぁ、と思ったりしました。
余談3
context.subscriptions.push()
ですが実のところ何やってるのかよくわかりません。(どこに書いてあったのか失念したのですが)公式のドキュメントを見る限り必須っぽいです。ここに登録されたものが dispose されるのはプロセス終了直前だと思うのですが、何かやることあるんでしょうか…?実際結構サボってる拡張機能も見つかったりします。
code formatter を作る
もうひとつコマンドを作りましょう。code formatterです。
(2019-06-19 追記: code formatter を作るのは、ここに書かれている方法ではなく、registerDocumentFormattingEditProviderを使って作るのが良い作法のようです[出展]。この記事についてはサンプルコードということでご容赦ください。)
package.json の "commands" のところにもう一つコマンドを追加します。
"commands": [
{
"command": "oreore.helloWorld",
"title": "OreoreMode: say hello world"
},
{
"command": "oreore.formatFile",
"title": "OreoreMode: format file"
}
]
今度は registerTextEditorCommand という関数でコマンドを登録します。処理自体はとりあえず helloWorld を使いまわします。
function activate(context) {
context.subscriptions.push(vscode.commands.registerCommand('oreore.helloWorld', helloWorld));
context.subscriptions.push(vscode.commands.registerTextEditorCommand('oreore.formatFile', helloWorld));
}
さて、これをテキストエディタを全部閉じた状態で実行してみましょう。
するとデバッグコンソールにエラーが表示されます。
これが registerCommand
と registerTextEditorCommand
の違いです。registerTextEditorCommand
はアクティブなテキストエディタが無い状態では失敗します。アクティブなテキストエディタが無い状態で code formatter を起動しても仕方がありませんから、 code formatter を作るのであれば、 registerTextEditor
を使うのが適切でしょう。
さて、コードのフォーマットです。registerTextEditorCommand
で登録したコマンドでは第一引数に TextEditor が渡されます。これをいじるとテキストエディタの内容を書き換えることができます。
今回はサンプルコードですので、「行頭の空白は全部削除する」というフォーマッタにしましょう。次のようなコードになります。
function formatFile(textEditor, textEditorEdit) {
const wholeText = textEditor.document.getText();
const newWholeText = wholeText.split(/\r?\n/).map(line => line.replace(/^\s+/,'')).join('\n');
const wholeRange = new vscode.Range(
textEditor.document.positionAt(0),
textEditor.document.positionAt(wholeText.length));
textEditor.edit(editBuilder => editBuilder.replace(wholeRange, newWholeText));
}
function activate(context) {
context.subscriptions.push(vscode.commands.registerCommand('oreore.helloWorld', helloWorld));
context.subscriptions.push(vscode.commands.registerTextEditorCommand('oreore.formatFile', formatFile));
}
ポイントとしては
-
textEditor.document.getText()
でテキスト全体を取得できる -
new vscode.Range(textEditor.document.positionAt(0), textEditor.document.positionAt(wholeText.length))
でテキスト全体を表すRange
を作成できる -
textEditor.edit
でテキスト編集ができる - テキスト編集には
textEditor.edit
のコールバック引数のTextEditorEditを使う
あたりでしょうか。
Ctrl+Shift+P
から OreoreMode: format file
を選択すると行頭の空白が削除されます。
あとはこの辺を応用していけば code formatter が作れますね。
oreore モードを作る
ここから言語特有のモードを作っていきます。
まずはモードそのものを作ります。作り方は簡単です。 "languages" を "contributes" に追加してください。
"contributes": {
<<< 中略 >>>
"languages": [
{
"id": "oreore",
"extensions": [".ore"]
}
]
これで oreore モードが作成でき、拡張子 .ore
が oreore モードに関連付けられました!
VSCode では Ctrl+K M
で言語モードの切り替えができます。
拡張子が .ore 以外のファイルで Ctrl+K M
を押すと一覧に oreore が表示されます。
また、拡張子が .ore のファイルで Ctrl+K M
を押すと「既に oreore モードですぜ」というメッセージが出ます。
というわけで、機能は何もありませんが、oreoreモードができました!
hover providerを作る
hover provider というのは、識別子の上にマウスカーソルを合わせたときにポップアップするアレを作るやつです。型とかドキュメントとか表示してくれるやつです。
作り方は簡単で、 vscode.languages.registerHoverProvider
で provideHover
メソッドを持ったオブジェクトを登録するだけです。以下のコードを追加しましょう。
const OREORE_MODE = { scheme: 'file', language: 'oreore' };
class OreoreHoverProvider {
provideHover(document, position, token) {
let wordRange = document.getWordRangeAtPosition(position, /[a-zA-Z0-9_]+/);
if (wordRange === undefined) return Promise.reject("no word here");
let currentWord = document.lineAt(position.line).text.slice(wordRange.start.character, wordRange.end.character);
return Promise.resolve(new vscode.Hover(currentWord));
}
}
context.subscriptions.push(vscode.languages.registerHoverProvider(OREORE_MODE, new OreoreHoverProvider()));
マウスカーソルが合っているところの単語だけを表示する hover provider です。
要点は以下のような感じでしょうか。
- provideHover には現在開いているドキュメント(document)と、カーソルの位置(position)が渡される
- document.getWordRangeAtPosition で、指定位置にある正規表現に合った単語を取り出せる
- Promise.reject(msg) を返すと何も表示されない
- Promise.resolve(new vscode.Hover(msg)) を返すと hover が現れ、 msg が表示される
banana にマウスカーソルを合わせた状態ですが、このように表示されます。
実装を工夫して型やドキュメントなどを表示してやると良いと思います。
definition provider を作る
definition provider は F12 を押したときに定義に飛ぶやつです。hover providerと同じ要領で作れます。
class OreoreDefinitionProvider {
provideDefinition(document, position, token) {
const wordRange = document.getWordRangeAtPosition(position,/[a-zA-Z0-9_]+/);
if (!wordRange) return Promise.reject('No word here.');
const currentWord = document.lineAt(position.line).text.slice(wordRange.start.character, wordRange.end.character);
let line;
if (currentWord == "apple") line = 0;
else if (currentWord == "banana") line = 1;
else if (currentWord == "cherry") line = 2;
else return Promise.reject('No definition found');
const uri = vscode.Uri.file(document.fileName);
const pos = new vscode.Position(line, 4);
const loc = new vscode.Location(uri, pos);
return Promise.resolve(loc);
}
}
context.subscriptions.push(vscode.languages.registerDefinitionProvider(OREORE_MODE, new OreoreDefinitionProvider()));
hover provider と同じ要領で単語を取り出し、定義位置に飛びます。サンプルコードですので、定義位置はハードコードされています。俺々言語処理系がタグファイルを吐くようにして、javascriptでそれを読みにいくとかすると良いんじゃないかと思います。
F12 や右クリックからの「定義へ移動」「定義をここに表示」などもこれで動くようになります。
completion item provider を作る
コード補完機能です。これも要領としては前ふたつと同じです。常に apple, banana, cherry を候補として出す completion item provider です。
class OreoreCompletionItemProvider {
provideCompletionItems(document, position, token) {
const completionItems = [
{
label: 'apple',
kind: vscode.CompletionItemKind.Variable
},
{
label: 'banana',
kind: vscode.CompletionItemKind.Value
},
{
label: 'cherry',
kind: vscode.CompletionItemKind.Method
}
];
let completionList = new vscode.CompletionList(completionItems, false);
return Promise.resolve(completionList);
}
}
context.subscriptions.push(vscode.languages.registerCompletionItemProvider(OREORE_MODE, new OreoreCompletionItemProvider(), '.'));
registerCompletionItemProvider
の第三引数以降が「何のキーを押したときに補完ウィンドウを出すか」です。今回の例では次のようになります。
アイコンは kind で指定できます。指定できる種類は公式サイトのCompletionItemKindを見てください。
また、単語を途中まで打ち込むと、registerCompletionItemProvider
第三引数以降とは無関係に、マッチするものを出してくれます。コードに手は加えていません。便利です。
signature help provider を作る
signature help というのは引数の説明を出したりするアレです。
作り方はもう説明不要かと思います。コードを貼っておきます。
class OreoreSignatureHelpProvider {
provideSignatureHelp(document, position, token) {
const line = document.lineAt(position.line);
if (!line.text.substr(0, position.character).match(/\(/)) return vscode.reject('no open parenthesis before cursor');
const signatureHelp = new vscode.SignatureHelp();
signatureHelp.activeParameter = 0;
signatureHelp.activeSignature = 0;
signatureHelp.signatures = [
new vscode.SignatureInformation('Alice', 'King'),
new vscode.SignatureInformation('Bob', 'Queen'),
new vscode.SignatureInformation('Carol', 'Jack')
];
return Promise.resolve(signatureHelp);
}
}
context.subscriptions.push(vscode.languages.registerSignatureHelpProvider(OREORE_MODE, new OreoreSignatureHelpProvider(), '(', ','));
何か色々出してくれます。
ちなみに signature help provider は一個困った点があって、一度表示されてしまうとカーソルを移動させても出っぱなしになってしまいます。今回のコードでは
if (!line.text.substr(0, position.character).match(/\(/)) return vscode.reject('no open parenthesis before cursor');
で(
より前にカーソルが移動したら signature help が消えるようにしました。実用的なものを作ろうとしたら signature help を表示できる位置にあるかどうかをきっちり判定しなければならず、なかなか大変なような気がします。
syntax highlight を作る
syntax highlight は今までとは異なり、専用の文法ファイルを使います。
まず package.json の "contibutes" に "grammars" の項目を追加します。
"contributes": {
<<< 中略 >>>
"grammars": [{
"language": "oreore",
"scopeName": "source.oreore",
"path": "./oreore.tmLanguage.json"
}]
}
そして実際の定義ファイルを "path" で指定した場所に置きます。
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "oreore",
"patterns": [
{
"include": "#keywords"
}
],
"repository": {
"keywords": {
"patterns": [
{
"name": "keyword.control",
"match": "\\bdef\\b"
},
{
"name": "keyword.other",
"match": "\\b(eat|give|take)\\b"
},
{
"name": "variable",
"match": "\\b(apple|banana|cherry)\\b"
}
]
}
},
"scopeName": "source.oreore"
}
この辺はもうノリで読んで下さい。
def に keyword.control の色を、
eat, give, take に keyword.other の色を、
apple, banana, cherry に variable の色を割り当てています。
こんな感じで表示されるようになります。
参考になりそうなファイルとしてJava の tmLanguage.jsonがあります。やたら長く、凝った事をしようとすると大変なようです。
余談4
C#では同一スコープ内でクラス名と変数名に同じ名前を付けられます。すると構文解析だけでなく意味解析までして色を付けたくなります。これは semantic highlighting と言います。調べてみると、どうもこれは今のところできないようです。VSCode の issue 585が未だに Open になっています。(2020/05/16追記: vscode v1.41.0 でできるようになったみたいです)。
tree data provider を作る
tree data provider は文字通り tree data を表示する機能です。こんなのが作れます。
作り方はまず、 "viewsContainers" と "views" を定義します。
"contributes": {
<< 中略 >>
"viewsContainers": {
"activitybar": [
{
"id": "oreore-view",
"title": "oreore explorer",
"icon": "./ore.png"
}
]
},
"views": {
"oreore-view": [
{
"id": "oreore",
"name": "oreore"
}
]
}
この時点で F5 を押すと、次のようになります。
次は言われた通りデータプロバイダを用意します。
const treeData =
[
{
label: "root1",
collapsibleState: vscode.TreeItemCollapsibleState.Collapsed,
children: [
{
label: "root1/child1",
collapsibleState: vscode.TreeItemCollapsibleState.None,
command:
{
command: "oreore.helloWorld",
title: "say hello",
arguments: []
}
},
{
label: "root1/child2",
collapsibleState: vscode.TreeItemCollapsibleState.None,
command:
{
command: "oreore.helloWorld",
title: "say hello",
arguments: []
}
}
]
},
{
label: "root2",
collapsibleState: vscode.TreeItemCollapsibleState.Collapsed,
children: [
{
label: "root2/child1",
collapsibleState: vscode.TreeItemCollapsibleState.None,
command:
{
command: "oreore.helloWorld",
title: "say hello",
arguments: []
}
},
{
label: "root2/child2",
collapsibleState: vscode.TreeItemCollapsibleState.None,
command:
{
command: "oreore.helloWorld",
title: "say hello",
arguments: []
}
}
]
}
];
class OreoreTreeDataProvider {
getTreeItem(element) {
return element;
}
getChildren(element) {
if (!element) {
return treeData;
} else {
return element.children;
}
}
}
vscode.window.registerTreeDataProvider('oreore', new OreoreTreeDataProvider());
ポイントとしては以下のような感じです。
- getTreeItem() と getChildren() をもつオブジェクトが TreeDataProvider になれる
- VSCode が root の情報を欲しいとき、 undefined を引数に getChildren() を呼ぶ
- getChildren() は Item の配列を返す
- VSCode が Item の情報を欲しいとき、 getTreeItem() を呼ぶ
- getTreeItem() は label と collapsibleState を持ったオブジェクトを返す。
- Item に command というプロパティがあると、Itemをクリックしたときそのコマンドが実行される
- command の中に command がある構造なので注意
- 今回は最初の方で作った oreore.helloWorld を入れています
少し長いですが単純なツリー構造を書いているだけです。
これを実行すると次のような表示になります。
実際に child をクリックすると、右下に Hello, world! がでます。
クラス定義やメソッド定義の一覧を出したりすると嬉しいかもしれません。
余談5
png ファイルは背景が透過している png を使わなければなりません。背景が透過していないpngファイルを使うと次のようになります。
おわりに
俺々言語モードを作ろうと思ったときに欲しそうな機能を思いついただけ列挙しました。
あとはやるだけかと思います。
俺々言語モードを作ろうと思った誰かの役に立てたら幸いです。
他に思いついたら何か追記するかもしれません。