2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Toy言語のLanguage Serverを作る(1): 補完、シンタックスハイライト、変数定義を実装

2
Posted at

TL; DR

  • Toy言語のLanguage Serverを作成中
  • 以下を実装済み
    • シンタックスハイライト
    • エラー表示
    • 定義移動
    • hoverの定義表示
    • 補完
  • エラートレラントパーサーを使用

はじめに

LLVM使ってプログラミング言語作ろう :smile:
→せっかくならMLIRで抽象化しよう
→まずはチュートリアルのToy言語やろう
→Toy言語書きやすくしたいな...Language Server作るか←いまここ

テスト前だけ部屋が綺麗になることってあるよね

Toy言語とは?

おふざけはこのくらいにして本題に入ります。

MLIRはLLVMプロジェクトの1つで、LLVMよりも抽象的な中間表現です。

MLIRを使うことで、様々なコンパイラが内部表現を0から実装しなくても済むようになります。

......などと偉そうなことを言いましたが、私はまだ入門したてで良く分かっていません。
そんな初心者でも雰囲気がつかめるように、MLIRには 「Toy」というチュートリアル用の言語 が用意されています。

MLIRが得意なベクトル、行列計算の抽象化を学べるよう、多次元配列(テンソル)を第一級オブジェクトとして扱えます。

行列
var a = [[1, 2], [3, 4]];

一方、言語仕様を簡単にするため構文や言語機能は簡略化されています。

  • 型は以下のみ
    • float
    • floatのテンソル
    • 構造体
  • if文、for文なし
  • 再代入不可
  • 組み込み関数は print と transpose (転置)のみ
サンプルコード(公式ドキュメントより引用、コメントは拙訳)
def main() {
  # 変数 `a` をshape(※各次元の要素数) <2, 3>で定義し、リテラル値で初期化する。
  # shapeは与えられたリテラルから推論される。
  var a = [[1, 2, 3], [4, 5, 6]];

  # bはaと同一であり、リテラルのテンソルは暗黙的に「reshape」(異なるshapeに変換)される:
  # 新しい変数を定義を定義することでテンソルを「reshape」できる(要素数が一致する必要がある)。
  var b<2, 3> = [1, 2, 3, 4, 5, 6];

  # 組み込み関数はtranspose(), print()のみである。以下では結果を表示する前に
  # aとbを転置し要素ごとに掛け算している
  print(transpose(a) * transpose(b));
}

作った機能

現在このToy言語のLanguage Serverを開発しています。

TypeScript製で、基本的にVSCode向けです。
まだ作成中ですが、完成したらExtensionとして公開予定です。

シンタックスハイライト

image.png

変数、関数定義

image.png

(画像では伝えられないのですが、クリックすると定義個所へジャンプします)

補完

image.png

エラー表示

image.png

image.png

実装

VSCode公式のLanguage Server開発ガイドを参考に実装しました。

lexer

まずはlexerで字句解析をします。

実装: https://github.com/Syuparn/toy_lsp/blob/main/server/src/lexer.ts

凝ったことはしていませんが、この後の処理のために

  • COMMENT コメント
  • UNKNOWN 処理失敗した良く分からない文字列

も有効なトークンとして扱います。
コメントはシンタックスハイライトのため、UNKNOWN は処理を打ち切らないためです。

通常のコンパイラやインタプリタであれば、変なソースコードにはエラーを出して即時終了して構いません。一方、Language Serverの場合、書きかけの状態や1つのtypoで補完やシンタックスハイライトが使えなくなっては困ります。そのため、不正な入力でも止まらない設計(エラートレラント)にする必要があります。
この後紹介するパーサーも同様です。

シンタックスハイライト

Semantic Tokensを使用します。詳細は以下の記事が分かりやすかったです。

Toyでは構文上で役割が変わるトークンがほぼ無いため、lexerさえあれば実装できました。

実装: https://github.com/Syuparn/toy_lsp/blob/367b2d74b085961bebab3cb193e231f58775b9ba/server/src/server.ts#L242

server/src/server.ts
  // ソースコード
  const text = document.getText();
  // lexerでトークンを得る
  const tokens = tokenizeAll(params.textDocument.uri, text);

  // ...

  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    const nextToken = i + 1 < tokens.length ? tokens[i + 1] : undefined;

    // Language Server上のToy処理系のトークンをSemantic Tokenの型に変換
    const tokenType = getTokenType(token.kind, nextToken?.kind);
    if (tokenType === -1) {
      continue; // unknownは色を付けない
    }

    const line = token.location.range.start.line;
    const char = token.location.range.start.character;
    const length = token.text.length;

    builder.push(line, char, length, tokenType, 0);
  }

1つだけ考慮すべきトークンが IDENTIFIER でした。変数の場合 (foo) と関数呼び出し(foo())の場合で色を分ける必要があります。
今回は素朴に、「後ろに ( が来る場合は関数呼び出し」というふうに実装しました1。

server/src/server.ts
    if (kind === 'IDENTIFIER') {
      if (nextKind === '(') {
        return tokenTypes.indexOf('function');
      }
      return tokenTypes.indexOf('variable');
    }

parser

続いてパーサーの実装です。先ほども触れましたが、構文エラーが起きても最後まで処理を続ける(エラートレラント)ようにしています。

実装: https://github.com/Syuparn/toy_lsp/blob/main/server/src/parser.ts

実装にはRubyのパーサーPrismを参考にしています。

Prismのエラートレラントパーサーについては以前別記事にもまとめています。

ざっくりいうと、構文エラー(=直前のトークンと比較して想定外のトークンを見つけた)の場合、エラーを記録してパースを続けます。

expect

トークンを消費する際に、期待した型であるかどうかをチェックします。
期待通りでなければ診断(エラーメッセージ)を追加し消費はしません。

server/src/parser.ts
  expect(tokenKinds: TokenKind[], errMessage: string) {
    if (!tokenKinds.includes(this.peekToken.kind)) {
      this.addDiagnostic(errMessage);
      this.curToken = this.dummyToken(this.curToken);
      return;
    }

こうすることで、トークンが欠けている場合でも最後までパースを続けることができます。

構文エラー例(閉じカッコがない)
def f( {}
エラーメッセージ
')' is missing

実装は以下の通りです。

server/src/parser.ts
parseFunction() {
  // ...
  if (this.match(['IDENTIFIER'])) {
    this.nextToken();
    name = this.curToken.text;
  } else {
    // ...
  }

  this.expect(['('], "'(' is missing");
  const params = this.parseParameters();
  // ここで ) が抜けててexpectを満たさない(エラーメッセージのみ記録し次に進む)
  this.expect([')'], "')' is missing"); 
  // 次はexpectを満たすので正常動作に戻る
  this.expect(['{'], "'{' is missing");
  const body = this.parseBody();
  this.expect(['}'], "'}' is missing");

  return new FunctionAST(loc, name, params, body);
}

expectの後も処理が続くため、エラーが起こってもASTが生成されます。

パースした結果のAST
FunctionAST(
  loc, // location
  'f', // 関数名
  [], // 仮引数一覧
  [], // ボディの文一覧
)

missing node

一方、間違いが多すぎて復旧できない場合もあります。その場合は、ASTに MissingNode というダミーのノードを挿入し、構文の終了トークンまで読み飛ばします。

server/src/parser.ts
  // 関数定義のボディ中の文をパース
  parseStatement() {
    switch (this.curToken.kind) {
      // ...
      case 'def': {
        const loc = this.curToken.location;
        // Toyにはクロージャはないため、関数定義中にdefが出てきたら復帰不能と判断し "}" まで読み飛ばす
        this.addDiagnostic('function cannot be defined inside functions');
        while (!this.match(['}'])) {
          this.nextToken();
        }
        return new ExprStmtAST(loc, new MissingAST(loc));
      }
      // ...
    }
  }

エラー表示

先ほどパーサーが記録した診断結果をそのまま返します。

実装: https://github.com/Syuparn/toy_lsp/blob/367b2d74b085961bebab3cb193e231f58775b9ba/server/src/server.ts#L145

server/src/server.ts
connection.languages.diagnostics.on(async (params) => {
  const document = documents.get(params.textDocument.uri);
  if (document !== undefined) {
    // 診断結果(エラー表示)
    return {
      kind: DocumentDiagnosticReportKind.Full,
      items: await validateTextDocument(document),
    } satisfies DocumentDiagnosticReport;
  } else {
    // ...
  }
});

// 診断の本体
async function validateTextDocument(textDocument: TextDocument): Promise<Diagnostic[]> {
  const settings = await getDocumentSettings(textDocument.uri);

  const text = textDocument.getText();
  const tokenizer = new Tokenizer(textDocument.uri, text);
  const parser = new Parser(tokenizer);
  parser.parseModule();

  // パーサーの診断結果をLanguage Serverの形式に変換
  const diagnostics: Diagnostic[] = parser.diagnostics
    .slice(0, settings.maxNumberOfProblems)
    .map((d: ToyDiagnostic) => ({
      severity: DiagnosticSeverity.Error,
      range: d.location.range,
      message: d.message,
      source: 'ex',
    }));

  return diagnostics;
}

定義の場所へ移動

実装: https://github.com/Syuparn/toy_lsp/blob/main/server/src/declarations.ts

ASTから対象ノードを検索し、位置情報を取得します。
取得対象は以下の2つです。

  • 変数定義
  • 関数定義

まず最初に、探すスコープを特定するため、VSCodeのカーソルがASTのどの要素にいるのかを特定します。

    const current = this.findCurrentAST(cursor);
    if (!current) {
      // current position is not an identifier
      return [];
    }

続いて、関数、変数それぞれについて同じ名前の要素を検索します。

関数定義

Toyにはクロージャがないため、素朴にトップレベルの関数定義ASTから名前が一致するものを検索します。

server/src/declarations.ts
  findFuncDecl(expr: CallExprAST): ExprAST[] {
    for (const func of this.ast.funcs) {
      if (func.name === expr.callee) {
        return [func];
      }
    }
    return [];
  }

変数定義

変数定義は

  • 変数定義文
  • 仮引数

の2つの可能性があるため、それぞれ探します。いずれも探索範囲は(エディタのカーソルと)同じ関数内のため、引数で現在の関数定義を受け取っています。

server/src/declarations.ts
  findVariableDecl(expr: VariableExprAST, func: FunctionAST): ExprAST[] {
    // NOTE: since toy does not have closure, variable should be defined in the same function
    const param = this.findParam(expr.name, func);
    if (param) {
      return [param];
    }
    const variable = this.findVarDecl(expr.name, func);
    if (variable) {
      return [variable];
    }
    return [];
  }

関数定義の場合、仮引数一覧を名前比較します。

server/src/declarations.ts
  findParam(name: string, func: FunctionAST) {
    for (const param of func.params) {
      if (param.name === name) {
        return param;
      }
    }
  }

変数定義文の場合、ボディの文一覧から、同名の変数を定義する変数定義文を探します。

server/src/declarations.ts
  findVarDecl(name: string, func: FunctionAST) {
    for (const stmt of func.body) {
      if (stmt instanceof VarDeclStmtAST && stmt.name === name) {
        return stmt;
      }
    }
  }

後は上記をLanguage Serverの connection.onDefinition に組み込めば完成です。

server/src/server.ts
connection.onDefinition((params: DefinitionParams): Location[] => {
  const document = documents.get(params.textDocument.uri);
  if (!document) {
    return [];
  }

  const text = document.getText();
  const tokenizer = new Tokenizer(params.textDocument.uri, text);
  const parser = new Parser(tokenizer);
  const ast = parser.parseModule();
  return new DefinitionFinder(ast).findLoc(params.position);
});

定義表示

大まかな処理は「定義の場所へ移動」と同じで、位置の代わりに定義の文字列表示を返します。

最後に、Definition同様に、Language Serverの connection.onHover に組み込みます。

補完

こちらも同様の流れで、名前が部分一致する変数名、関数名一覧を返しています。
ただし、組み込み関数の transpose, print も補完候補に加えています。

同様にLanguage Serverの connection.onCompletion に組み込みます。

おわりに

以上、Toy言語のLanguage Serverの紹介でした。
山場の意味解析が残っているので、まとまった時間が取れたら改めて挑戦したいと思います。

  1. Toyの関数は第一級ではないため、関数オブジェクトが変数に代入されることは考慮不要です(もし考慮が必要な場合、構文解析と意味解析も必要)。 ↩

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?