Langium -- 新的语言工程工具(一)

3,602 阅读6分钟

写在前面

实现特定领域语言(Domain-Specific Language)之前最方便的技术路线大概就是:Xtext。因为,Xtext可以实现语言服务协议(language service protocol):简单且粗糙的来说,就是在编辑器上实现关键字高亮、语法智能提醒和语法错误提醒等一般编辑器该有的那样特性。同时,Xtext也能实现将DSL生成为类似java、c、python等可编译的语言。

简而言之,Xtext可以使我们的DSL能在编辑器中,像普通程序语言一样被友好的书写;同时,也可以像python、c等程序一样被执行。

不过,随着VS Code的盛行,Xtext多少有点不那么好用了。首先,Xtext最初的目的是给Eclipse平台做语言服务插件的,虽然现在也可以开发VS Code的扩展,但总觉不是那么好用。其次,Xtext的技术栈是Java,这个对js/ts开发者来说,就真的有点头秃了呀。最后,最后就是真的不想学Java啊😭

现在,Xtext团队就推出了新一代的语言工程工具 —— Langium!

  • Xtext能实现的功能,Langium都可以实现!
  • 已有的Xtext工程 Langium 也提供了工具,能将它转换成Langium 工程(这部分,我只看到了相关文档,并没有去try 一try,好不好用还得另说);
  • Langium可以用 TS 来开发 DSL

简单的Try一Try吧

以下这些操作都是跟着 Langium官方文档 敲出来的,如果大家对Langium感兴趣的话,可以去仔细看一看。

前置条件

  • Node >= 12

  • 安装 Yeoman 和 Langium 扩展生成器

    // 安装 Yeoman 和 Langium 扩展生成器
    npm i -g yo generator-langium
    
    // 创建第一个DSL
    yo langium
    
    // 扩展名称:hello-world
    // Language name:Hello World
    // 文件扩展名: .hello
    

    用vs code打开 hello-world文件夹,按F5启动扩展。在新打开的扩展实例窗口中打开一个文件夹并新建一个扩展名为.hello的新文件,并写入以下内容:

    person Alice
    Hello Alice!
    
    person Bob
    Hello Bob!
    

    此时在vscode的显示情况就是:

2023-02-17-11-41-46-image.png

    就这样,一个很简单的DSL编辑器就有模有样的跑起来了😄

简单的看一看代码

    在实现这一块,我就按照以下这四个方面简单的说一下我自己的理解。

  1. Langium 语法
  2. 合法性校验
  3. 自定义CLI
  4. 代码生成

Langium 语法

我们要实现的语言(比如,以上实现的Hello World语言是DSL)的语法规则,需要用langium的语法规则来描述。(langium本身也是一种DSL)。

通过 yo langium 指令生成的工程中,我们可以在 src/language-server/hello-world.langium 中看到,langium语言是如何描述Hello World语言的语法规则的。

grammar HelloWorld

entry Model:
    (persons+=Person | greetings+=Greeting)*;

Person:
    'person' name=ID;

Greeting:
    'Hello' person=[Person:ID] '!';

hidden terminal WS: /\s+/;
terminal ID: /[_a-zA-Z][\w_]*/;
terminal INT returns number: /[0-9]+/;
terminal STRING: /"[^"]*"|'[^']*'/;

hidden terminal ML_COMMENT: //*[\s\S]*?*//;
hidden terminal SL_COMMENT: ///[^\n\r]*/;

以上代码,

  1. 语法声明,简单的描述了DSL的名称(我们这里就是HelloWorld)。

  2. entry就是解析器的入口,Model 可以理解为是抽象语法树的根节点。在这里,定义了一组重复的可选方案 (persons+=Person | greetings+=Greeting)。

    (个人理解:解析器在工作的时候,会遍历用户输入的文本,并将其中的符合person/greetings语法规则的元素挂在AST上)。

  3. Person 的规则是:以关键字 'Penson' 开头,然后跟上一个ID来指代name

  4. Greeting的规则:以关键字 'Hello' 开头,然后交叉引用了Person,([] 里输入交叉引用的变量),最后要以 '!' 做结尾

  5. 定义的终端,会用其定义的正则表达式来解析用户输入的语法文本的一部分。(这个我也不是特别明白,那就硬翻吧!)

(如果了解Xtext的话,应该能看出来Langium的语法规则跟Xtext的语法规则还是很像的!

合法性校验

    合法性校验主要是实现:当用户输入相同函数名、关键字输入错误...等一系列不符合我们的语法规则时,编辑器能给出相应的错误提醒。

src/language-server/hello-world-validator.ts

import { ValidationAcceptor, ValidationChecks } from 'langium';
import { HelloWorldAstType, Person } from './generated/ast';
import type { HelloWorldServices } from './hello-world-module';

/**
 * Register custom validation checks.
 */
export function registerValidationChecks(services: HelloWorldServices) {
    const registry = services.validation.ValidationRegistry;
    const validator = services.validation.HelloWorldValidator;
    const checks: ValidationChecks<HelloWorldAstType> = {
        Person: validator.checkPersonStartsWithCapital
    };
    registry.register(checks, validator);
}

/**
 * Implementation of custom validations.
 */
export class HelloWorldValidator {

    checkPersonStartsWithCapital(person: Person, accept: ValidationAcceptor): void {
        if (person.name) {
            const firstChar = person.name.substring(0, 1);
            if (firstChar.toUpperCase() !== firstChar) {
                accept('warning', 'Person name should start with a capital.', { node: person, property: 'name' });
            }
        }
    }

}
  1. 在 registerValidationChecks 函数中注册校验服务
  2. 校验器(也就是具体的检查逻辑)被写在 HelloWorldValidator 中
  3. 在 checks 中注册具体的校验逻辑

如,新增Greeting的校验,就看起来像下面一样。

const checks: ValidationChecks<MiniLogoAstType> = {
     Person: validator.checkPersonStartsWithCapital
     Greeting: validator.checkGreetingStartsWithCapital
};

export class HelloWorldValidator {

    checkPersonStartsWithCapital(): void {
        ...
    }
    checkGreetingStartsWithCapital(): void {
        ...
    }
}

自定义CLI

  • 概述
  • 关于命令行界面
  • 添加解析和验证操作
  • 构建和运行 CLI

概述

当描述清楚语法规则,并且添加了一些验证了之后,我们接下来就可以期待代码能按照我们的预期跑起来。

在 src/cli/index.ts 文件中可以找到自定义的 CLI。在默认的情况下,CLI提供一个命令,即generate命令。

generate命令,它允许你获得用 DSL 编写的程序(读取用户输入的文档)、解析它并遍历AST 以生成某种输出。有关 generate 将在后面介绍。

添加解析和验证操作

  1. 首先,让我们编写一个自定义操作,以允许我们用我们的语言解析验证 程序

    在此,我们把我们之前写的语法和基本的验证链接到 CLI 操作,以使其工作。

  2. 添加新命令:在 index.ts 文件中的默认导出中注册它。

        在这个函数中,有一个命令对象,它是我们的 CLI 命令的集合。让我们调用我们的command generate ,并给它一些额外的细节,比如

  • arguments: 表示它需要一个文件
  • description:详细说明此操作的作用的描述
  • action:执行实际解析和验证的操作

我们可以像这样注册我们的 解析和验证 操作

 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 source file')
        .action(generateAction);

最后,我们需要实现 generate 函数本身。这将使我们能够解析和验证我们的程序,但不会产生任何输出。我们只想知道,我们的程序何时在我们的语言实现的约束下是“正确的”

import { extraceDocument } from './cli-util';

export const generateAction = async (fileName: string, opts: GenerateOptions): Promise<void> => {
    const services = createHelloWorldServices(NodeFileSystem).HelloWorld;
    const model = await extractAstNode<Model>(fileName, services);
    const generatedFilePath = generateJavaScript(model, fileName, opts.destination);
    console.log(chalk.green(`JavaScript code generated successfully: ${generatedFilePath}`));
};

生成代码

生成代码,就是将 AST 从基于 Langium 的语言转换成为某个输出目标。这里可能是另一种具有类似功能(转译)的语言、一种较低级别的语言(编译),或者生成一些将被另一个应用使用的文件/数据。

这一部分是DSL语言最核心的一部分,因为这决定了DSL语言是否能被编译执行。

这一部分,以后有空在记录吧!

最后

这些主要是看Langium官方文档后做了一些记录(感觉,还有很多地方没有理解清楚,目前就这样吧!😭)。 最后,未完待续。。。