Visual Studio CodeのExtensionのコードを読む (TS/JS postfix completion)

Pocket

Visual Studio CodeでGo言語のソースコードを書く時間が増え、Postfix Code Completionの機能が欲しいなと思った。残念ながら現在そのようなExtensionは存在していないようなので、自分でExtensionを書こうと思い参考になりそうなものを調べていたところ、TypeScript / JavaScript用のPostfix Code CompletionのExtensionであるTS/JS postfix completion Extension が見つかった。

Issuesを見てみると、ちょうど「他の言語もサポートしてよ」というIssueが上がっていた。それに対する作者の返答が「今のところTS/JSしか書かないので、興味はないけどこのExtensionはオープンソースだから自分でやってみたら?」となっていたので、僕も自分でやってみようかと思ってソースコードを読んでみることにした。

TS/JS postfix completionについて

このExtensionは、TypeScript/JavaScriptのコード入力中にPostfix Code Completionの機能を実現するためのExtensionだ。Postfix Code Completionについては一言で説明が難しいが、Extensionの説明ページのスクリーンキャストを見ればどういうことかわかると思う。

ipatalas/vscode-postfix-ts

Extensionとしての大まかな構造の把握

まずはExtensionの定義が記述されているpackage.jsonから。

"activationEvents": [
    "*"
],

Activation Eventsに*が指定されているため、このExtensionはVS Code起動時に毎回アクティベートされる。*はドキュメントによると、

will be activated whenever VS Code starts up…(略)…please use this activation event in your extension only when no other activation events combination works in your use-case

とのこと。このExtensionは基本的にはJS/TSの時のみ動作するので、”onLanguage:javascript”, “onLanguage:typescript”を指定しても良さそうだが、config次第で他の言語にも対応できるような仕組みになっているため*を指定しているのだろう。

contributesにはconfigurationのみが定義されており、カスタムテンプレート追加のための定義が存在する。コマンドパレットから呼び出せるコマンドなどはなし。

次に、エントリーポイントが記述されているextension.ts。

export function activate (context: vsc.ExtensionContext) {
  registerCompletionProvider(context)

  context.subscriptions.push(vsc.commands.registerTextEditorCommand(NOT_COMMAND, (editor: vsc.TextEditor, _: vsc.TextEditorEdit, ...args: any[]) => {
    let [position, suffix, ...expressions] = args

    notCommand(editor, position, suffix, expressions)
  }))

  context.subscriptions.push(vsc.workspace.onDidChangeConfiguration(() => {
    if (completionProvider) {
      let idx = context.subscriptions.indexOf(completionProvider)
      context.subscriptions.splice(idx, 1)
      completionProvider.dispose()
    }

    registerCompletionProvider(context)
  }))
}

Extensionがアクティベートされたタイミング(このExtensionの場合は、VS Codeの起動直後)で呼ばれるactivateメソッド内では3つのことをしている。

function registerCompletionProvider (context: vsc.ExtensionContext) {
  const provider = new PostfixCompletionProvider()

  let DOCUMENT_SELECTOR: vsc.DocumentSelector =
    process.env.NODE_ENV === 'test' ? 'postfix' : vsc.workspace.getConfiguration('postfix').get('languages')

  completionProvider = vsc.languages.registerCompletionItemProvider(DOCUMENT_SELECTOR, provider, '.')
  context.subscriptions.push(completionProvider)
}

まず、registerCompletionProviderではvsc.languages.registerCompletionItemProviderを呼んで、CompletionItemProviderを登録している。CompletionItemProviderはエディタのコード補完のための候補のリストアップとその候補が選択された時の処理の機能を提供するためのインターフェイスで、今回のExtensionの場合はPostfixCompletionProviderクラスでその機能が実装されている。

registerCompletionItemProviderの最初の引数はDocumentSelectorで、ここで登録するProviderがどのドキュメントタイプの時に動作すべきかを指定する。このExtensionでは、configでこのドキュメントタイプを設定可能になっており、デフォルトの設定(package.jsonに定義されているデフォルトの設定)ではjavascriptとtypescriptのファイルが対象となるようなSelectorになっている。最後の引数はトリガーとなる文字でドット(.)が指定されている。

つまり、javascript / typescriptのファイルを開いている時にドット(.)が入力されたら、PostfixCompletionProviderによるコード補完の候補のリストアップが行われる、という形になっている。

2個目のregisterTextEditorCommandの前に、最後のonDidChangeConfigurationを見ていく。こちらは、コンフィグが変更された時にCompletionItemProviderを登録し直す処理となっている。

registerTextEditorCommandで登録している内容は若干分かりづらい(名前的にも余計に)。このメソッドはText editor command(アクティブなエディタがある時のみ実行可能なコマンド)を登録するなのだが、ここで登録しているコマンド(NOT_COMMAND)はNotTemplateというバイナリー式を反転させるテンプレート(補完のパターン)から呼びだされるのだが、他のテンプレートからは使われないちょっと特殊なものなので一旦スルーしておく。

というわけで、このExtensionの根幹となる部分の構成を再確認しておくと、

  1. 対象となるドキュメント (デフォルトではjavascript / typescript)を編集中
  2. 「.」が押されると
  3. PostfixCompletionProviderによるコード補完が開始される

ということになる。

PostfixCompletionProviderを読む

コード補完の肝となるPostfixCompletionProviderはCompletionItemProviderに準拠している。インターフェイスCompletionItemProviderには下記の二つのメソッドが定義されている。

  1. provideCompletionItems(document: TextDocument, position: Position, token: CancellationToken, context: CompletionContext): ProviderResult<CompletionItem[] | CompletionList>
  2. resolveCompletionItem(item: CompletionItem, token: CancellationToken): ProviderResult<CompletionItem>

activateで設定したトリガー(今回の場合はドット)により、コード補完機能が呼び出されると、CompletionItemProviderは補完の候補の一覧をCompletionItemの配列で返すように求められる。候補の一覧はprovideCompletionItemsメソッドで返さなければいけない。ただし、CompletionItem配列の生成に時間がかかってしまい動作が重くなってしまうような場合は、最初に暫定のCompletionItemの配列をprovideCompletionItemsで返しておいて、その後各要素の詳細な情報(detail, documentationプロパティ)をresolveCompletionItemで返す、というようなことができるようになっている。

resolveCompletionItem (item: vsc.CompletionItem, token: vsc.CancellationToken): vsc.ProviderResult {
  currentSuggestion = item.label
  return item
}

PostfixCompletionProviderにもprovideCompletionItemsとresolveCompletionItemの両方が実装されているのだが、どうやらここではそういう目的では使われておらず、テストのためのハックとして使われているようだ。

さて、候補の一覧を返すprovideCompletionItemsの中身を順に読んでいく。

let line = document.lineAt(position.line)
let dotIdx = line.text.lastIndexOf('.', position.character)

if (dotIdx === -1) {
  return []
}

let codePiece = line.text.substring(line.firstNonWhitespaceCharacterIndex, dotIdx)

まず、カーソルがある行のトリガーとなったドットより前の部分を切り出している。その際、行頭にホワイトスペースがある場合(インデントされている場合など)はホワイトスペースを取り除いている。もしその行にドットが見つからない場合(ドットの入力がトリガーとなるので、通常はあり得ないが)はそこで処理を終了し、空の配列を返している。

let source = ts.createSourceFile('test.ts', codePiece, ts.ScriptTarget.ES5, true)

そして、切り出した部分をTypeScriptとしてパースしている。(JavaScriptはTypeScriptとしてパースしても問題ないのでこういう時に便利だ)なお、この後にsource.statements[0]を参照しているコードがあるが、これは使われていないので古いコードの消し忘れだろう。

import * as ts from 'typescript'

パースにはtypescriptモジュールを使用しているようだ。

let currentNode = findNodeAtPosition(source, dotIdx - line.firstNonWhitespaceCharacterIndex - 1)
if (!currentNode) {
  return []
}

TypeScriptとしてパースした結果から、ドットの手前の部分にあるノードのツリーを取得する。findNodeAtPositionの中身は再帰的な処理が含まれるため若干読みにくいが、カーソルがある行の先頭からドットの手前の部分までに存在するノードのパターンをリスト化し、そのノードの長さ+深さでのソートを行なっているのだが、細かく見ていくと長くなる割にExtensionとしての大筋を理解する上ではここの詳細の理解は重要ではないので省略する。

return this.templates
  .filter(t => t.canUse(currentNode))
  .map(t => t.buildCompletionItem(code, position, currentNode, line.text.substring(dotIdx, position.character)))

最後に、登録されているテンプレートの一覧から、そのノードに対応したテンプレートを取り出し、そのテンプレートからCompletionItemを生成し、補完の候補一覧として返している。

private loadBuiltinTemplates = () => {
  let files = glob.sync('./templates/*.js', { cwd: __dirname })
  files.forEach(path => {
    let builder: () => IPostfixTemplate | IPostfixTemplate[] = require(path).build
    if (builder) {
      let tpls = builder()
      if (Array.isArray(tpls)) {
        this.templates.push(...tpls)
      } else {
        this.templates.push(tpls)
      }
    }
  })
}

テンプレートのリスト(templates)は、PostfixCompletionProviderの初期化のタイミングでloadBuiltinTemplatesとloadCustomTemplatesという二つのメソッドでファイルから読み込んでいる。デフォルトのテンプレート(BuiltinTemplate)はsrc/templatesディレクトリ内に存在するtsファイルが対象だ。(正確には、src/templatesファイルにあるtsファイルがコンパイルされてjsファイルになったものが読み込まれる)。カスタムテンプレートの読み込みの説明については省略。

以上を踏まえて、PostfixCompletionProviderの処理の流れをまとめると、以下のようになる。

    1. コード補完が呼び出された行のドットより前の部分の文字列を切り出す
    2. 切り出した文字列をTypeScriptとしてパースしてノードを取得する
    3. そのノードに対応したテンプレートを抽出する
    4. 対応したテンプレートに応じたCompletionItemの配列を作って返す

テンプレートを読む

src/templatesフォルダには、補完のテンプレートがいくつか定義されている。

export class IfTemplate extends BaseExpressionTemplate {
  buildCompletionItem (code: string, position: vsc.Position) {
    return CompletionItemBuilder
      .create('if', code)
      .description(`if (expr)`)
      .replace(`if ({{expr}}) {\n${getIndentCharacters()}\${0}\n}`, position, true)
      .build()
  }
}

If文を追加するIfTemplateを例に実装を読んでみる。補完の候補リスト生成の際には、各テンプレートのcanUseメソッドが呼ばれてその結果により絞り込みが行われるが、IfTemplateのcanUseメソッドは継承元のBaseExpressionTemplate内で下記のように定義されている。

export abstract class BaseExpressionTemplate extends BaseTemplate {
  abstract buildCompletionItem (code: string, position: vsc.Position, node: ts.Node)

  canUse (node: ts.Node) {
    return node.parent &&
      !this.inReturnStatement(node.parent) &&
      !this.inIfStatement(node.parent) &&
      (this.isExpression(node.parent) ||
        this.isUnaryExpression(node.parent) ||
        this.isBinaryExpression(node.parent) ||
        this.isCallExpression(node.parent))
  }
}

補完を行おうとしているノードがreturnステートメントの中にあったり、すでにifステートメントの内部だったりした場合にはこのテンプレートは使えないことを示す(false)が返り、Expressionだった場合にはtrueが返るようになっている。

protected inReturnStatement = (node: ts.Node) => node.kind === ts.SyntaxKind.ReturnStatement || (node.parent && this.inReturnStatement(node.parent))

なお、このノードの判定は再帰的に行われるようになっており、カーソルの位置のノードに限らず、その親のノードがreturnステートメントだった場合にもちゃんと正しく判定されるようになっている。ただし、このExtensionではノードの切り取りはカーソル位置と同じ行のみなので、ステートメントが複数行にまたがっている場合は正しく判定できなそうな気がする。

canUseがtrueを返したテンプレートについては、その後buildCompletionItemが呼ばれ、ここでVS Codeエディタ側が実際に候補としてユーザに表示するCompletionItemを返すことになる。

export class CompletionItemBuilder {
  private item: vsc.CompletionItem

  constructor (private keyword: string, private code: string) {
    this.item = new vsc.CompletionItem(keyword, vsc.CompletionItemKind.Snippet)
    this.item.detail = COMPLETION_ITEM_TITLE
  }

  public static create = (keyword: string, code: string) => new CompletionItemBuilder(keyword, code)

  public description = (description: string): CompletionItemBuilder => {
    this.item.documentation = description

    return this
  }

buildCompletionItem内部ではCompletionItemBuilderというクラスを使ってCompletionItemのインスタンス生成が行われる。

CompletionItemBuilder
  .create('if', code)
  .description(`if (expr)`)
  .replace(`if ({{expr}}) {\n${getIndentCharacters()}\${0}\n}`, position, true)

createとdescriptionでCompletionItemのラベル、説明を設定している。補完の種類(CompletionItemKind)はSnippetとして登録される。CompletionItemKindについてはドキュメントに詳しい説明がないが、おそらく候補のリスト表示時のアイコンなどに影響があるのみで、それ以上の違いはないと思われる。

  public replace = (replacement: string, position: vsc.Position, useSnippets?: boolean): CompletionItemBuilder => {
    const dotIdx = this.code.lastIndexOf('.')
    const codeBeforeTheDot = this.code.substr(0, dotIdx)

    if (useSnippets) {
      const escapedCode = codeBeforeTheDot.replace('$', '\\$')

      this.item.insertText = new vsc.SnippetString(replacement.replace(new RegExp('{{expr}}', 'g'), escapedCode))
    } else {
      this.item.insertText = replacement.replace(new RegExp('{{expr}}', 'g'), codeBeforeTheDot)
    }

    this.item.additionalTextEdits = [
      vsc.TextEdit.delete(new vsc.Range(position.translate(0, -codeBeforeTheDot.length - 1), position))
    ]

    return this
  }

ここが補完の処理の一番重要な部分となる。

大枠から見ていくと、最終的にCompletionItemに設定しているのはinsertTextプロパティとadditionalTextEditsプロパティの二つ。

insertTextプロパティはこのCompletionItemが選択された場合にドキュメントに挿入される文字列。

if ({{expr}}) {\n${getIndentCharacters()}\${0}\n}

IfTemplateの場合は、この{{expr}}の部分にエディタのカーソルのある行のドットより前の文字列が代入される。

replacement.replace(new RegExp('{{expr}}', 'g'), codeBeforeTheDot)

if文のブロック内部はインデントを下げたいが、インデントの設定(タブを使うか、スペースを使うか、スペースの数は?)は環境によって異なる。getIndentCharacters()の中ではエディタのタブの設定を尊重してインデントのための文字列を生成している。

export const getIndentCharacters = () => {
  if (vsc.window.activeTextEditor.options.insertSpaces) {
    return ' '.repeat(vsc.window.activeTextEditor.options.tabSize as number)
  } else {
    return '\t'
  }
}

${0}はプレースホルダー。こうしておくことでコード補完後に自動的にここにフォーカスがある状態になる。

this.item.additionalTextEdits = [
  vsc.TextEdit.delete(new vsc.Range(position.translate(0, -codeBeforeTheDot.length - 1), position))
]

通常の補完処理は基本的にカーソル位置よりも後ろ側にコードを生成するものだろうから、コードをinsertするだけで済む場合が多いと思われるが、このExtensionがやっているPostfix Code Completionはカーソルよりも手前の部分に対して補完を行うことになるため、insertするだけでなく補完前の文字列を消去する必要がある。ここでは、additionalTextEditsの機能を使ってそれを実現している。additionalTextEditsにはこのCompletionItemが選択された場合に実行するTextEdit処理を複数指定でき、ここではドットより前の文字列(変換の対象の文字列)を削除するための処理が記述されている。

public build = () => this.item

最後に、build()メソッドでCompletionItemを返し、コード補完のリスト作成は完了だ。他のテンプレートも、Notテンプレートだけ若干特殊なことをやっているが、基本的にはこのような形でコード補完が実装されている。

最後に

基本的に綺麗でとてもわかりやすいソースコードだった。Visual Studio Code Extensionのドキュメントも初めて読んだが、比較的シンプルなAPIでわかりやすい。とても参考になった。作者に礼を言いたい。

Go言語でこれを実現する場合は構文解析の部分がネックになりそうだが、あとで試してみようと思う。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です