承認これくしょん

my black histories

TypeScriptの言語サービスを使ってサクラエディタで入力補完してみる

TypeScriptプロジェクトにはtypescriptServices.jsが含まれており、コンパイラが持つ機能を外部から利用できます。
その中の言語サービス(Language Service API)を使ってコードの補完候補を取得し、SIでおなじみサクラエディタで表示してみました。
PowerShell + WSH(JScript)で構成されているため、標準的なWindows 7以降の環境で動作するはずです。

全体の構成

下記のts-sakura以下を、サクラエディタプラグインフォルダに置きます。
typeScriptServices.js、lib.d.tsはGitHubから取得できます。
他にも読み込みたい型定義ファイルがあればlib内にしまってください。

ts-sakura
│  plugin.def
│  ts-sakura.js
│
└─server
    │  server.ps1
    │  start.cmd
    │  typescriptServices.js
    │
    └─lib
            lib.d.ts

plugin.def

プラグインの定義ファイルです。

[Plugin]
Id=ts-sakura
Name=ts-sakura
Description=TypeScriptの簡易補完プラグイン
Type=wsh
Version=0.1
Url=http://old-horizon.hateblo.jp/
Author=

[Wsh]
UseCache=0

[Plug]
Complement=ts-sakura.js
Complement.Label=ts-sakura

[Option]
O[1].Section=Option
O[1].Key=Port
O[1].Label=サーバーのポート番号
O[1].Type=Int
O[1].Default=8081

ts-sakura.js

サクラエディタ上で実行されるスクリプトです。
PowerShellのサーバーにソース全文とキャレット位置を送信し、返された補完候補を表示します。

// サーバーのポート番号
var port = Plugin.GetOption("Option","Port");

// 現在入力中の文字列
var currentWord = Complement.GetCurrentWord();

// ソース全文を取得
Editor.SetDrawSwitch(0);
Editor.MoveHistSet();
Editor.SelectAll();
var allLines = Editor.GetSelectedString(0);
Editor.MoveHistPrev();

// キャレット位置までの文字数を取得
var lineCount = Editor.ExpandParameter("$y");
var columnCount = Editor.ExpandParameter("$x");
var position = 0;
for (var i = 1; i < lineCount; i++) {
    position += Editor.GetLineStr(i).length;
}
position += columnCount - 1;

// サーバーから補完候補を取得
var xhr = new ActiveXObject("Msxml2.XMLHTTP.6.0");
xhr.onreadystatechange = function() {
    if (xhr.readyState == 4) {
        if (xhr.status == 200) {
            var response = xhr.responseText;
            if (!response) return;
            var completions = response.split(",");
            for (var i = 0, length = completions.length; i < length; i++) {
                // 先頭一致の候補のみを表示
                if(currentWord == "." || completions[i].toLowerCase().lastIndexOf(currentWord.toLowerCase(), 0) == 0) {
                    try{
                        Complement.AddList(currentWord == "." ? "." + completions[i] : completions[i]);
                    } catch(e) {}
                }
            }
        } else {
            var shell = new ActiveXObject("WScript.Shell");
            shell.Popup("通信に失敗しました。\nサーバーが起動していない可能性があります。", 3, "ts-sakura", 48);
        }
    }
}

try {
    // リクエストを送信
    xhr.Open("POST", "http://localhost:" + port + "/", false);
    xhr.setRequestHeader("x-caretposition", position);
    xhr.send(allLines);
} catch(e) {}

server.ps1

補完候補を返す簡易HTTPサーバーです。
ScriptControlでWSHエンジンを作成し、その中でAPIを呼び出します。

# ポート番号
$port = $args[0]
# カレントディレクトリ
$currentDir = [System.IO.Path]::GetDirectoryName($MyInvocation.InvocationName)

# フルパスを取得
function getFullPath($name) {
    return $([System.IO.Path]::Combine($currentDir, $name)).Replace("\","\\")
}

$jsCode = @"
// 指定ファイルのテキストを取得
function readFile(path) {
    var sr = new ActiveXObject("ADODB.Stream");
    sr.Type = 2;
    sr.charset = "_autodetect_all";
    sr.Open();
    sr.LoadFromFile(path);
    var content = sr.ReadText(-1);
    sr.Close();
    sr = null;
    return content;
}

// eval時のエラー防止
Array.prototype.indexOf = function() {}

// TypeScriptコンパイラを読み込む
eval(readFile("$(getFullPath("typescriptServices.js"))"));

// インターフェースの実装
var LanguageServiceHostImpl = (function() {
    function LanguageServiceHostImpl() {
        this.files = {};
    }
    LanguageServiceHostImpl.prototype.getCompilationSettings = function() {
        return ts.getDefaultCompilerOptions();
    };
    LanguageServiceHostImpl.prototype.getScriptFileNames = function() {
        var fileNames = [];
        for (var fileName in this.files) {
            if (this.files.hasOwnProperty(fileName)) {
                fileNames.push(fileName);
            }
        }
        return fileNames;
    };
    LanguageServiceHostImpl.prototype.getScriptVersion = function(fileName) {
        return this.files[fileName].version.toString();
    };
    LanguageServiceHostImpl.prototype.getScriptIsOpen = function(fileName) {
        return true;
    };
    LanguageServiceHostImpl.prototype.getScriptSnapshot = function(fileName) {
        return this.files[fileName].snapshot;
    };
    LanguageServiceHostImpl.prototype.getCurrentDirectory = function() {
        return "";
    };
    LanguageServiceHostImpl.prototype.getDefaultLibFilename = function(options) {
        return "";
    };
    LanguageServiceHostImpl.prototype.log = function(s) {
    };
    LanguageServiceHostImpl.prototype.addFile = function(fileName, body) {
        var snapshot = ts.ScriptSnapshot.fromString(body);
        snapshot.getChangeRange = function (oldSnapshot) {
            return undefined;
        };
        if (this.files[fileName]) {
            this.files[fileName].version++;
            this.files[fileName].snapshot = snapshot;
        }
        else {
            this.files[fileName] = { snapshot: snapshot, version: 1 };
        }
    };
    return LanguageServiceHostImpl;
})();

var fso = new ActiveXObject("Scripting.FileSystemObject");

// libディレクトリのフルパス
var libDir = fso.BuildPath("$($currentDir.Replace("\","\\"))", "lib");

var host = new LanguageServiceHostImpl();
var service = ts.createLanguageService(host, ts.createDocumentRegistry());

// 型定義ファイルの読み込み
var e = new Enumerator(fso.GetFolder(libDir).Files);
for (; !e.atEnd(); e.moveNext()) {
    var file = e.item();
    if (/\.ts$/.test(file.Name)) {
        host.addFile(file.Name, readFile(file.Path));
    }
}

// タイムスタンプから一時ファイル名を生成
var tempName = new Date().getTime();
while (host.files[tempName + ".ts"]) {
    // ファイル名が衝突した場合はインクリメント
    tempName++;
}
tempName += ".ts";

// 補完候補を取得
function getCompletions(source, position) {
    host.addFile(tempName, source);
    var completions = service.getCompletionsAtPosition(tempName, position);
    var entries = [];
    
    if (completions) {
      for (var i = 0, length = completions.entries.length; i < length; i++) {
          entries.push(completions.entries[i].name);
      }
    }
    // カンマ区切りで一覧を返す
    return entries.join(",");
}
"@

# WSHオブジェクトを生成
$sc = New-Object -Com ScriptControl
$sc.Language = "JScript"
$sc.AddCode($jsCode)

# HTTPサーバーの起動
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.add("http://localhost:" + $port + "/")
$listener.Start()
Write-Host "ポート$($port)で起動しました"

while($listener.IsListening) {
    $context = $listener.GetContext()
    $request = $context.Request
    $response = $context.Response
    
    if ($request.HasEntityBody) {
        $body = $request.InputStream
        $reader = New-Object System.IO.StreamReader($body, $request.ContentEncoding)
        
        # ソース全文とキャレット位置から補完候補を取得
        $completions = $sc.CodeObject.getCompletions($reader.ReadToEnd(), $request.Headers["x-caretposition"])
        
        # 戻り値を出力して返す
        $writer = New-Object System.IO.StreamWriter($response.OutputStream, $request.ContentEncoding)
        $writer.Write($completions)
        $writer.Close()
        $response.OutputStream.Close()
        $response.Close()
    }
}

$listener.Stop()

start.cmd

待ち受けポートを指定してserver.ps1を実行するバッチファイルです。
ScriptControlを使う都合上、32bit版のPowerShellを起動させています。

@echo off
rem ポート番号を指定
set port=8081
if defined ProgramFiles(x86) (
  %SystemRoot%\syswow64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy RemoteSigned -File server.ps1 %port%
) else (
  powershell.exe -ExecutionPolicy RemoteSigned -File server.ps1 %port%
)

使い方

start.cmdとサクラエディタ側の設定でポート番号を合わせ、サーバーを起動すると補完候補が出るようになります。
ただ依存関係の解決をしていないし、不安定で全然ダメですね…実用するならTSServer使うべきでしょう。
JavaScriptの範囲でも少しは型推論が効きます。

参考