ラベル Langium の投稿を表示しています。 すべての投稿を表示
ラベル Langium の投稿を表示しています。 すべての投稿を表示

2022年6月1日水曜日

Langium で DSL と LSP サーバーを作る(2) AST をもとにコード生成を行う

前回 の続き。 AST からコード生成を行う。

目標

以下の定義を元に、

person mikoto {
    age: 18;
    rank: 1;
}

person makoto {
    age: 19;
    rank: 2;
}

person mokoto {
    age: 20;
    rank: 3;
}

以下のコードを生成する。

export type Person = {
    age : number;
    rank : number;
}

const mikoto : Person = {
    age: 18,
    rank: 1
}

const makoto : Person = {
    age: 19,
    rank: 2
}

const mokoto : Person = {
    age: 20,
    rank: 3
}

export const allPersons = [mikoto, makoto, mokoto];

前提

  • OS: Arch Linux
  • Docker: Docker version 20.10.16, build aa7e414fdc
  • 使用する Docker イメージ: node:18

作業用コンテナ起動

docker run -it --rm -v "$(pwd):/work" --workdir /work node:18 bash

DLS 開発(コード生成まで)

コード生成機能実装

src/cli/generator.ts を作成し、「person 定義ごとに定数を作り、全 person が含まれた配列を作成」というコードを生成する実装を行う。

src/cli/generator.ts

import fs from 'fs';
import { CompositeGeneratorNode, NL, processGeneratorNode } from 'langium';
import path from 'path';
import { Model } from '../language-server/generated/ast';
import { extractDestinationAndName } from './cli-util';

// Person 型の定義
const TYPE_PERSON = `export type Person = {
    age : number;
    rank : number;
}`;

export function generateTypeScript(model: Model, filePath: string, destination: string | undefined): string {
    const data = extractDestinationAndName(filePath, destination);
    const generatedFilePath = `${path.join(data.destination, data.name)}.ts`;

    // これから生成するファイルの内容を表すインスタンスを生成
    const fileNode = new CompositeGeneratorNode();

    // 型定義コードを追加
    // TYPE_REASON 定数と、改行をふたつ
    fileNode.append(TYPE_PERSON, NL, NL);

    // DSL の person 定義ごとに Person 型の定数を追加
    model.persons.forEach(person => {
        fileNode.append(`const ${person.name} : Person = {
    age: ${person.age},
    rank: ${person.age}
}`, NL, NL)
    });

    // 全 person が含まれた Array を追加
    const allPersons = model.persons.map(person => person.name).join(', ');
    fileNode.append(`export const allPersons = [${allPersons}];`);


    // 生成先ディレクトリがなければ作る
    if (!fs.existsSync(data.destination)) {
        fs.mkdirSync(data.destination, { recursive: true });
    }

    // 生成先にファイル書き出し
    fs.writeFileSync(generatedFilePath, processGeneratorNode(fileNode));

    // 生成したファイルのファイルパスを返却
    return generatedFilePath;
}

cli からコード生成機能を呼び出す実装を追加

src/cli/index.ts

diff --git a/src/cli/index.ts b/src/cli/index.ts
index e0a4230..40d2e4c 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -4,6 +4,14 @@ import { Model } from '../language-server/generated/ast';
 import { FirststepLanguageMetaData } from '../language-server/generated/module';
 import { createFirststepServices } from '../language-server/firststep-module';
 import { extractAstNode } from './cli-util';
+import { generateTypeScript } from './generator';
+
+export const generateAction = async (fileName: string, opts: GenerateOptions): Promise<void> => {
+    const services = createFirststepServices().Firststep;
+    const model = await extractAstNode<Model>(fileName, services);
+    const generatedFilePath = generateTypeScript(model, fileName, opts.destination);
+    console.log(colors.green(`TypeScript code generated successfully: ${generatedFilePath}`));
+};
 
 export const testAction = async (fileName: string, opts: GenerateOptions): Promise<void> => {
     const services = createFirststepServices().Firststep;
@@ -24,6 +32,13 @@ export default function(): void {
         .version(require('../../package.json').version);
 
     const fileExtensions = FirststepLanguageMetaData.fileExtensions.join(', ');
+    program
+        .command('generate')
+        .argument('<file>', `source file (possible file extensions: ${fileExtensions})`)
+        .option('-d, --destination <dir>', 'destination directory of generating')
+        .description('generates TypeScript code that all Person array.')
+        .action(generateAction);
+
     program
         .command('test')
         .argument('<file>', `source file (possible file extensions: ${fileExtensions})`)
@@ -32,3 +47,4 @@ export default function(): void {
 
     program.parse(process.argv);
 }

相対パスでファイルを指定するとデータ名が空になる問題の修正

src/cli/cli-util.ts を修正。

diff --git a//src/cli/cli-util.ts b//src/cli/cli-uti
l.ts
index 53f6a94..b67cab6 100644
--- a//src/cli/cli-util.ts
+++ b//src/cli/cli-util.ts
@@ -43,7 +43,7 @@ interface FilePathData {
 }
 
 export function extractDestinationAndName(filePath: string, destination: string | undefined): FilePathDat
a {
-    filePath = filePath.replace(/\..*$/, '').replace(/[.-]/g, '');
+    filePath = path.basename(filePath, path.extname(filePath)).replace(/[.-]/g, '');
     return {
         destination: destination ?? path.join(path.dirname(filePath), 'generated'),
         name: path.basename(filePath)

ビルド

npm run build

コード生成動作確認

「目標」で示した DSL 定義ファイルを作成し、コード生成を行う。

cat << EOF >> test.firststep
person mikoto {
    age: 18;
    rank: 1;
}

person makoto {
    age: 19;
    rank: 2;
}

person mokoto {
    age: 20;
    rank: 3;
}
EOF

node bin/cli generate -d ./generated_code ./test.firststep

これで、 generated_code/test.ts が生成される。

# cat generated_code/test.ts
export type Person = {
    age : number;
    rank : number;
}

const mikoto : Person = {
    age: 18,
    rank: 18
}

const makoto : Person = {
    age: 19,
    rank: 19
}

const mokoto : Person = {
    age: 20,
    rank: 20
}

export const allPersons = [mikoto, makoto, mokoto];

今回はここまで。

参考資料

2022年5月26日木曜日

Langium で DSL と LSP サーバーを作る(1) 文法定義から文法チェックまで

DSL を定義すると、 LSP サーバーの実装を吐き出してくれるツール Langium を試す。

目標

以下の文法の DSL を作る。

person <NAME> {
    age: <INTEGER>;
    rank: <INTEGER>;
}
  • <NAME>: 任意の名前, 半角小文字アルファベットのみ許容
  • <INTEGER>: 任意の整数

前提

  • OS: Arch Linux
  • Docker: Docker version 20.10.16, build aa7e414fdc
  • 使用する Docker イメージ: node:18

作業用コンテナ起動

docker run -it --rm -v "$(pwd):/work" --workdir /work node:18 bash

Langium のジェネレーターをインストール

npm i -g yo generator-langium

Langium のプロジェクトを作成

# su - node
$ cd /work
$ yo langium
? ==========================================================================
We're constantly looking for ways to make yo better! 
May we anonymously report usage statistics to improve the tool over time? 
More info: https://github.com/yeoman/insight & http://yeoman.io
========================================================================== Yes
┌─────┐ ─┐
┌───┐    │  ╶─╮ ┌─╮ ╭─╮ ╷ ╷ ╷ ┌─┬─╮
│ ,´     │  ╭─┤ │ │ │ │ │ │ │ │ │ │
│╱       ╰─ ╰─┘ ╵ ╵ ╰─┤ ╵ ╰─╯ ╵ ╵ ╵
`                   ╶─╯

Welcome to Langium! This tool generates a VS Code extension with a "Hello World" language to get started 
quickly. The extension name is an identifier used in the extension marketplace or package registry.
? Your extension name: firststep
The language name is used to identify your language in VS Code. Please provide a name to be shown in the 
UI. CamelCase and kebab-case variants will be created and used in different parts of the extension and 
language server.
? Your language name: firststep
Source files of your language are identified by their file name extension. You can specify multiple file 
extensions separated by commas.
? File extensions: .firststep
   create firststep/langium-config.json
   create firststep/langium-quickstart.md
   create firststep/language-configuration.json
   create firststep/package.json
   create firststep/tsconfig.json
   create firststep/bin/cli
   create firststep/src/extension.ts
   create firststep/src/cli/cli-util.ts
   create firststep/src/cli/generator.ts
   create firststep/src/cli/index.ts
   create firststep/src/language-server/firststep-module.ts
   create firststep/src/language-server/firststep-validator.ts
   create firststep/src/language-server/firststep.langium
   create firststep/src/language-server/main.ts
   create firststep/.vscode/extensions.json
   create firststep/.vscode/launch.json
   create firststep/.eslintrc.json
   create firststep/.vscodeignore

added 173 packages, and audited 174 packages in 23s

27 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
npm notice 
npm notice New minor version of npm available! 8.9.0 -> 8.11.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v8.11.0
npm notice Run npm install -g npm@8.11.0 to update!
npm notice 

> firststep@0.0.1 langium:generate
> langium generate

Reading config from langium-config.json
src/language-server/firststep.langium:14:10 - This rule is declared but never referenced.
src/language-server/firststep.langium:15:10 - This rule is declared but never referenced.
Writing generated files to /work/firststep/src/language-server/generated
Writing textmate grammar to /work/firststep/syntaxes/firststep.tmLanguage.json
Langium generator finished successfully in 218ms

> firststep@0.0.1 build
> tsc -b tsconfig.json


No change to package.json was detected. No package manager install will be executed.

DSL 開発(文法チェックまで)

grammar コードの実装

以下コマンドで修正をウォッチしながら、src/language-server/firststep.langium を修正する。

npm run langium:watch

src/language-server/firststep.langium

// DSL の文法名
grammar Firststep

// モデル定義。
// 「この DSL ではゼロから複数の Person を定義できる」という定義をしている
entry Model:
    (persons+=Person)*;

/**
 * 以下形式の文法を定義。
 *
 * person <NAME> {
 *   age: <INTEGER>;
 *   rank: <INTEGER>;
 * }
 *
 * ※ このとき、 AST 上の person ノードは、 `age``rank` のパラメーターを持つ。

 */
Person:
    'person' name=NAME '{'
        'age' ':' age=INTEGER ';'
        'rank' ':' rank=INTEGER ';'
    '}'
    ;


// `<NAME>`: 任意の名前, 半角の小文字アルファベットのみ許容
terminal NAME: /[a-z]+/;

// `<INTEGER>`: 任意の整数
terminal INTEGER returns number: /[0-9]+/;

// 空白文字は AST に含めない
hidden terminal WS: /\s+/;

// マルチラインコメント(`/* XXX */`)  AST に含めない
hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;

// シングルラインコメント(`// XXX`)  AST に含めない
hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;

grammar コードからパーサーコード(TypeScript)の生成

npm run langium:watch していればすでに生成済みのはずだが、ワンショットで再生成したい場合には以下コマンドを実行。

npm run langium:generate

パーサーコード(TypeScript)のビルド

以下コマンドでビルド。

npm run build

Langium プロジェクト生成時に実装済みの Person, Greeting 文法用のコードになっているため、ビルドに失敗する。

今回自分が実装した Person 文法用の実装に修正しなければならない。

Person, Greeting 文法用のコード削除

src/cli/generator.ts, src/language-server/firststep-validator.ts の削除

generator は、「Greeting 情報を読み込んでコンソールに出力する TypeScript コードを生成する」という実装になっているため、削除。

firststep-validator は、「Person の ID の先頭が大文字でないとだめ」という実装になっているため、削除。

rm src/cli/generator.ts
rm src/language-server/firststep-validator.ts

src/cli/index.ts の修正

src/cli/generator.ts を削除したことで、ビルドが失敗するようになるので解消。

diff --git a/firststep/src/cli/index.ts b/firststep/src/cli/index.ts
index eeafcac..4322246 100644
--- a/firststep/src/cli/index.ts
+++ b/firststep/src/cli/index.ts
@@ -1,17 +1,9 @@
-import colors from 'colors';
+//import colors from 'colors';
 import { Command } from 'commander';
-import { Model } from '../language-server/generated/ast';
-import { FirststepLanguageMetaData } from '../language-server/generated/module';
-import { createFirststepServices } from '../language-server/firststep-module';
-import { extractAstNode } from './cli-util';
-import { generateJavaScript } from './generator';
-
-export const generateAction = async (fileName: string, opts: GenerateOptions): Promise<void> => {
-    const services = createFirststepServices().Firststep;
-    const model = await extractAstNode<Model>(fileName, services);
-    const generatedFilePath = generateJavaScript(model, fileName, opts.destination);
-    console.log(colors.green(`JavaScript code generated successfully: ${generatedFilePath}`));
-};
+//import { Model } from '../language-server/generated/ast';
+//import { FirststepLanguageMetaData } from '../language-server/generated/module';
+//import { createFirststepServices } from '../language-server/firststep-module';
+//import { extractAstNode } from './cli-util';
 
 export type GenerateOptions = {
     destination?: string;
@@ -24,13 +16,7 @@ export default function(): void {
         // eslint-disable-next-line @typescript-eslint/no-var-requires
         .version(require('../../package.json').version);
 
-    const fileExtensions = FirststepLanguageMetaData.fileExtensions.join(', ');
-    program
-        .command('generate')
-        .argument('<file>', `source file (possible file extensions: ${fileExtensions})`)
-        .option('-d, --destination <dir>', 'destination directory of generating')
-        .description('generates JavaScript code that prints "Hello, {name}!" for each greeting in a sourc
e file')
-        .action(generateAction);
+    // const fileExtensions = FirststepLanguageMetaData.fileExtensions.join(', ');
 
     program.parse(process.argv);
 }

src/language-server/firststep-module.ts の修正

src/language-server/firststep-validator.ts を削除したことで、ビルドが失敗するようになるので解消。

--- a/firststep/src/language-server/firststep-module.ts
+++ b/firststep/src/language-server/firststep-module.ts
@@ -3,14 +3,12 @@ import {
     LangiumServices, LangiumSharedServices, Module, PartialLangiumServices
 } from 'langium';
 import { FirststepGeneratedModule, FirststepGeneratedSharedModule } from './generated/module';
-import { FirststepValidationRegistry, FirststepValidator } from './firststep-validator';
 
 /**
  * Declaration of custom services - add your own service classes here.
  */
 export type FirststepAddedServices = {
     validation: {
-        FirststepValidator: FirststepValidator
     }
 }
 
@@ -27,8 +25,6 @@ export type FirststepServices = LangiumServices & FirststepAddedServices
  */
 export const FirststepModule: Module<FirststepServices, PartialLangiumServices & FirststepAddedServices> 
= {
     validation: {
-        ValidationRegistry: (services) => new FirststepValidationRegistry(services),
-        FirststepValidator: () => new FirststepValidator()
     }
 };

パースに成功したかどうかを確認するためのサブコマンドを追加

src/cli/index.ts に、処理を実装する。

以下実装で、成功したときは「文法チェック OK」と表示し、失敗したときは、パーサーのエラーメッセージを表示するようになる。

diff --git a/firststep/src/cli/index.ts b/firststep/src/cli/index.ts
index 4322246..e0a4230 100644
--- a/firststep/src/cli/index.ts
+++ b/firststep/src/cli/index.ts
@@ -1,9 +1,16 @@
-//import colors from 'colors';
+import colors from 'colors';
 import { Command } from 'commander';
-//import { Model } from '../language-server/generated/ast';
-//import { FirststepLanguageMetaData } from '../language-server/generated/module';
-//import { createFirststepServices } from '../language-server/firststep-module';
-//import { extractAstNode } from './cli-util';
+import { Model } from '../language-server/generated/ast';
+import { FirststepLanguageMetaData } from '../language-server/generated/module';
+import { createFirststepServices } from '../language-server/firststep-module';
+import { extractAstNode } from './cli-util';
+
+export const testAction = async (fileName: string, opts: GenerateOptions): Promise<void> => {
+    const services = createFirststepServices().Firststep;
+    await extractAstNode<Model>(fileName, services);
+    console.log(colors.green(`文法チェック OK`));
+    // ※ extractAstNode から呼ばれる `extractDocument` 内で、 `process.exit(1)` されるので、 try-catch 
しない
+};
 
 export type GenerateOptions = {
     destination?: string;
@@ -16,7 +23,12 @@ export default function(): void {
         // eslint-disable-next-line @typescript-eslint/no-var-requires
         .version(require('../../package.json').version);
 
-    // const fileExtensions = FirststepLanguageMetaData.fileExtensions.join(', ');
+    const fileExtensions = FirststepLanguageMetaData.fileExtensions.join(', ');
+    program
+        .command('test')
+        .argument('<file>', `source file (possible file extensions: ${fileExtensions})`)
+        .description('文法チェック')
+        .action(testAction);
 
     program.parse(process.argv);
 }

動作確認

パーサーの動作確認

test.firststep ファイルを作って、先程実装した test サブコマンドで確認する。

$ node bin/cli test ./test-ok.firststep
文法チェック OK
$ node bin/cli test ./test-ng.firststep
There are validation errors:
line 1: Expecting keyword '{' but found `000`. [000]

OK.

今回はここまで。

コミット順序は前後するが、ここまでの修正を MiscellaneousStudy/Langium/firststep/firststep - GitHub に格納した。

あとは AST からコード生成などの所望の処理を行う実装をする感じ。

VSCode 拡張機能の動作確認

  1. npm run build でビルド
  2. Langium プロジェクトを、 ~/.vscode/extensions-oss にコピーして VSCode を起動
  3. 拡張子が .fiststep の空ファイルを作成し、 VSCode で開く
  4. grammar で実装した通りの補完やバリデーションの警告が出ることを確認

参考資料