MoonBit 构建系统教程

moon 是 MoonBit 语言的构建系统,目前基于 n2 项目。moon 支持并行构建和增量构建,此外它还支持管理和构建 mooncakes.io 上的第三方包。

准备工作

在开始之前,请确保安装好以下内容:

  1. MoonBit CLI 工具: 从这里下载。该命令行工具用于创建和管理 MoonBit 项目。

    使用 moon help 命令可查看使用说明。

    $ moon help
    ...
    
  2. Moonbit Language Visual Studio Code 插件: 可以从 VS Code 市场安装。该插件为 MoonBit 提供了丰富的开发环境,包括语法高亮、代码补全、交互式除错和测试等功能。

安装完成后,让我们开始创建一个新的 MoonBit 模块。

创建一个新模块

使用 moon new 创建一个新项目,默认的配置是:

$ moon new
Enter the path to create the project (. for current directory): my-project
Select the create mode: exec
Enter your username: username
Enter your project name: hello
Enter your license: Apache-2.0
Created my-project

这会在 my-project 下创建一个名为 username/hello 的新模块。上述过程也可以使用 moon new my-project 代替。

了解模块目录结构

上一步所创建的模块目录结构如下所示:

my-project
├── LICENSE
├── README.md
├── moon.mod.json
└── src
    ├── lib
    │   ├── hello.mbt
    │   ├── hello_test.mbt
    │   └── moon.pkg.json
    └── main
        ├── main.mbt
        └── moon.pkg.json

这里简单解释一下目录结构:

  • moon.mod.json 用来标记这个目录是一个模块。它包含了模块的元信息,例如模块名、版本等。

    {
      "name": "username/hello",
      "version": "0.1.0",
      "readme": "README.md",
      "repository": "",
      "license": "Apache-2.0",
      "keywords": [],
      "description": "",
      "source": "src"
    }
    

    source 字段指定了模块的源代码目录,默认值是 src。该字段存在的原因是因为 MoonBit 模块中的包名与文件路径相关。例如,当前模块名为 username/hello,而其中一个包所在目录为 lib/moon.pkg.json,那么在导入该包时,需要写包的全名为 username/hello/lib。有时,为了更好地组织项目结构,我们想把源码放在 src 目录下,例如 src/lib/moon.pkg.json,这时需要使用 username/hello/src/lib 导入该包。但一般来说我们并不希望 src 出现在包的路径中,此时可以通过指定 "source": "src" 来忽略 src 这层目录,便可使用 username/hello/lib 导入该包。

  • src/libsrc/main 目录:这是模块内的包。每个包可以包含多个 .mbt 文件,这些文件是 MoonBit 语言的源代码文件。但是,无论一个包有多少 .mbt 文件,它们都共享一个 moon.pkg.json 文件。lib/*_test.mbtlib 包中的独立测试文件,这些文件用于黑盒测试,无法直接访问 lib 包的私有成员。

  • moon.pkg.json 是包描述文件。它定义了包的属性,例如,是否是 main 包,所导入的其他包。

    • main/moon.pkg.json:

      {
        "is_main": true,
        "import": [
          "username/hello/lib"
        ]
      }
      

    这里,"is_main: true" 表示这个包需要被链接成目标文件。在 wasm/wasm-gc 后端,会被链接成一个 wasm 文件,在 js 后端,会被链接成一个 js 文件。

    • lib/moon.pkg.json:

      {}
      

    这个文件只是为了告诉构建系统当前目录是一个包。

如何使用包

我们的 username/hello 模块包含两个包:username/hello/libusername/hello/main

username/hello/lib 包含 hello.mbthello_test.mbt 文件:

hello.mbt

pub fn hello() -> String {
    "Hello, world!"
}

hello_test.mbt

test "hello" {
  if @lib.hello() != "Hello, world!" {
    fail!("@lib.hello() != \"Hello, world!\"")
  }
}

username/hello/main 只包含一个 main.mbt 文件:

fn main {
  println(@lib.hello())
}

为了执行程序,需要在 moon run 命令中指定 username/hello/main 包的文件系统路径

$ moon run ./src/main
Hello, world!

你也可以省略 ./

$ moon run src/main
Hello, world!

你可以使用 moon test 命令进行测试:

$ moon test
Total tests: 1, passed: 1, failed: 0.

如何导入包

在 MoonBit 的构建系统中,模块名用于引用其内部包。

如果想在 src/main/main.mbt 中使用 username/hello/lib 包,你需要在 src/main/moon.pkg.json 中指定:

{
  "is_main": true,
  "import": [
    "username/hello/lib"
  ]
}

这里,username/hello/lib 指定了从 username/hello 模块导入 username/hello/lib 包,所以你可以在 main/main.mbt 中使用 @lib.hello()

注意,src/main/moon.pkg.json 中导入的包名是 username/hello/lib,在 src/main/main.mbt 中使用 @lib 来引用这个包。这里的导入实际上为包名 username/hello/lib 生成了一个默认别名。在接下来的章节中,你将学习如何为包自定义别名。

创建和使用包

首先,在 lib 下创建一个名为 fib 的新目录:

mkdir src/lib/fib

现在,你可以在 src/lib/fib 下创建新文件:

a.mbt:

pub fn fib(n : Int) -> Int {
  match n {
    0 => 0
    1 => 1
    _ => fib(n - 1) + fib(n - 2)
  }
}

b.mbt:

pub fn fib2(num : Int) -> Int {
  fn aux(n, acc1, acc2) {
    match n {
      0 => acc1
      1 => acc2
      _ => aux(n - 1, acc2, acc1 + acc2)
    }
  }

  aux(num, 0, 1)
}

moon.pkg.json:

{}

在创建完这些文件后,你的目录结构应该如下所示:

my-project
├── LICENSE
├── README.md
├── moon.mod.json
└── src
    ├── lib
    │   ├── fib
    │   │   ├── a.mbt
    │   │   ├── b.mbt
    │   │   └── moon.pkg.json
    │   ├── hello.mbt
    │   ├── hello_test.mbt
    │   └── moon.pkg.json
    └── main
        ├── main.mbt
        └── moon.pkg.json

src/main/moon.pkg.json 文件中,导入 username/hello/lib/fib 包,并自定义别名为 my_awesome_fibonacci

{
  "is_main": true,
  "import": [
    "username/hello/lib",
    {
      "path": "username/hello/lib/fib",
      "alias": "my_awesome_fibonacci"
    }
  ]
}

这行导入了 username/hello/lib/fib 包。导入后,你可以在 main/main.mbt 中使用 fib 包了。

main/main.mbt 的文件内容替换为:

fn main {
  let a = @my_awesome_fibonacci.fib(10)
  let b = @my_awesome_fibonacci.fib2(11)
  println("fib(10) = \{a}, fib(11) = \{b}")

  println(@lib.hello())
}

为了执行程序,需要在 moon run 命令中指定 username/hello/main 包的文件系统路径:

$ moon run ./src/main
fib(10) = 55, fib(11) = 89
Hello, world!

添加测试

让我们添加一些测试来验证我们的 fib 实现。在 src/lib/fib/a.mbt 中添加以下内容:

src/lib/fib/a.mbt

test {
  assert_eq!(fib(1), 1)
  assert_eq!(fib(2), 1)
  assert_eq!(fib(3), 2)
  assert_eq!(fib(4), 3)
  assert_eq!(fib(5), 5)
}

这些代码测试了斐波那契数列的前五项。test { ... } 定义了一个内联测试块。内联测试块中的代码在测试模式下执行。

内联测试块在非测试模式下(moon buildmoon run)会被丢弃,因此不会导致生成的代码大小膨胀。

用于黑盒测试的独立测试文件

除了内联测试,MoonBit 还支持独立测试文件。以 _test.mbt 结尾的源文件被认为是黑盒测试的测试文件。例如,在 src/lib/fib 目录中,创建一个名为 fib_test.mbt 的文件,并粘贴以下代码:

src/lib/fib/fib_test.mbt

test {
  assert_eq!(@fib.fib(1), 1)
  assert_eq!(@fib.fib2(2), 1)
  assert_eq!(@fib.fib(3), 2)
  assert_eq!(@fib.fib2(4), 3)
  assert_eq!(@fib.fib(5), 5)
}

注意,构建系统会自动为以 _test.mbt 结尾的文件创建一个新的包,用于黑盒测试,并且自动导入当前包。因此,在测试块中需要使用 @fib 来引用 username/hello/lib/fib 包,而不需要在 moon.pkg.json 中显式地导入该包。

最后,使用 moon test 命令,它会扫描整个项目,识别并运行所有内联测试以及以 _test.mbt 结尾的文件。如果一切正常,你会看到:

$ moon test
Total tests: 3, passed: 3, failed: 0.
$ moon test -v
test username/hello/lib/hello_test.mbt::hello ok
test username/hello/lib/fib/a.mbt::0 ok
test username/hello/lib/fib/fib_test.mbt::0 ok
Total tests: 3, passed: 3, failed: 0.

包配置

moon 使用 moon.pkg.json 文件来识别、描述一个包。

包名

包名不可配置,它由包的目录名决定。

is-main 字段

is-main 字段用于指定一个包是否需要被链接成一个可执行的文件。

链接所生成的产物与后端相关,当该字段为 true 时:

  • 对于 wasmwasm-gc 后端,将会生成一个可以独立运行的 WebAssembly 模块。
  • 对于 js 后端,将会生成一个可以独立运行的 JavaScript 文件。

import 字段

import 字段用于指定一个包所依赖的其他包。

test-import 字段

test-import 字段用于指定该包对应的黑盒测试包所依赖的其他包。

wbtest-import字段

wbtest-import 字段用于指定该包对应的白盒测试包所依赖的其他包。

链接选项

moon 默认只会链接 is-maintrue 的包,如果需要链接其他包,可以通过 link 选项指定。

link 选项用于指定链接选项,它的值可以为布尔值或一个对象。

  • link 值为 true 时,表示需要链接该包。构建时所指定的后端不同,产物也不同。

    {
      "link": true
    }
    
  • link 值为对象时,表示需要链接该包,并且可以指定链接选项,详细配置请查看对应后端的子页面。

wasm 后端链接选项

可配置选项

  • exports 选项用于指定 wasm 后端导出的函数名。

    例如,如下配置将当前包中的 hello 函数导出为 wasm 模块的 hello 函数, foo 函数导出为 wasm 模块的 foo 函数。在 wasm 宿主中,可以通过 hellobar 函数来调用当前包中的 hellofoo 函数。

    {
      "link": {
        "wasm": {
          "exports": [
            "hello",
            "foo:bar"
          ]
        },
      }
    }
    
  • heap_start_address 选项用于指定 moonc 编译到 wasm 后端时能够使用的线性内存的起始地址。

    例如,如下配置将线性内存的起始地址设置为 1024。

    {
      "link": {
          "wasm": {
            "heap_start_address": 1024
        },
      }
    }
    
  • import-memory 选项用于指定 wasm 模块导入的线性内存。

    例如,如下配置将 wasm 模块导入的线性内存指定为 env 模块的 memory 变量。

    {
      "link": {
        "wasm": {
          "import-memory": {
            "module": "env",
            "name": "memory"
          }
        },
      }
    }
    
  • export-memory-name 选项用于指定 wasm 模块导出的线性内存名称。

    {
      "link": {
        "wasm": {
          "export-memory-name": "memory"
        },
      }
    }
    

wasm-gc 后端链接选项

wasm-gc 后端与 wasm 后端的链接选项类似,只不过没有 heap-start-address 选项。

js 后端链接选项

可配置选项

  • exports 选项用于指定 JavaScript 模块的导出函数名。

    例如,如下配置将当前包中的 hello 函数导出为 JavaScript 模块的 hello 函数。在 JavaScript 宿主中,可以通过 hello 函数来调用当前包中的 hello 函数。

    {
      "link": {
        "js": {
          "exports": [
            "hello"
          ]
        }
      }
    }
    
  • format 选项用于指定 JavaScript 模块的输出格式。

    目前支持的格式有:

    • esm
    • cjs
    • iife

    例如,如下配置将当前包的输出格式指定为 ES Module。

    {
      "link": {
        "js": {
          "format": "esm"
        }
      }
    }
    

warn 列表

关闭对应的编译器预设警告编号。

例如,如下配置中 -2 代表关闭编号为 2(Unused variable) 的警告

{
  "warn_list": "-2",
}

可用 moonc build-package -warn-help 查看编译器预设的警告编号

$ moonc -v                      
v0.1.20240914+b541585d3

$ moonc build-package -warn-help
Available warnings: 
  1 Unused function.
  2 Unused variable.
  3 Unused type declaration.
  4 Redundant case in a pattern matching (unused match case).
  5 Unused function argument.
  6 Unused constructor.
  7 Unused module declaration.
  8 Unused struct field.
 10 Unused generic type variable.
 11 Partial pattern matching.
 12 Unreachable code.
 13 Unresolved type variable.
 14 Lowercase type name.
 15 Unused mutability.
 16 Parser inconsistency.
 18 Useless loop expression.
 19 Top-level declaration is not left aligned.
 20 Invalid pragma
 21 Some arguments of constructor are omitted in pattern.
 22 Ambiguous block.
 23 Useless try expression.
 24 Useless error type.
 26 Useless catch all.
  A all warnings

alert 列表

关闭用户预设 alter。

{
  "alert_list": "-alert_1-alert_2"
}

条件编译

条件编译的最小单位是文件。

在条件编译表达式中,支持三种逻辑操作符:andornot,其中 or 操作符可以省略不写。

例如,["or", "wasm", "wasm-gc"] 可以简写为 ["wasm", "wasm-gc"]

条件表达式中的条件可以分为后端和优化级别:

  • 后端条件"wasm""wasm-gc""js"
  • 优化等级条件"debug""release"

条件表达式支持嵌套。

如果一个文件未在 "targets" 中列出,它将默认在所有条件下编译。

示例:

{
    "targets": {
        "only_js.mbt": ["js"],
        "not_js.mbt": ["not", "js"],
        "only_debug.mbt": ["and", "debug"],
        "js_and_release.mbt": ["and", "js", "release"],
        "js_only_test.mbt": ["js"],
        "complex.mbt": ["or", ["and", "wasm", "release"], ["and", "js", "debug"]]
    }
}

预构建命令

字段 "pre-build" 用于指定预构建命令,预构建命令会在 moon check|build|test 等构建命令之前执行。

"pre-build"是一个数组,数组中的每个元素是一个对象,对象中包含 inputoutputcommand 三个字段,inputoutput 可以是字符串或者字符串数组,command 是字符串,command 中可以使用任意命令行命令,以及 $input$output 变量,分别代表输入文件、输出文件,如果是数组默认使用空格分割。

目前内置了一个特殊命令 :embed,用于将文件转换为 MoonBit 源码,--text 参数用于嵌入文本文件,--binary 用于嵌入二进制文件,--text 为默认值,可省略不写。--name 用于指定生成的变量名,默认值为 resource。命令的执行目录为当前 moon.pkg.json 所在目录。

{
  "pre-build": [
    {
      "input": "a.txt",
      "output": "a.mbt",
      "command": ":embed -i ${input} -o ${output}"
    }
  ]
}

如果当前包目录下的 a.txt 的内容为

hello,
world

执行 moon build 后,在此 moon.pkg.json 所在目录下生成如下 a.mbt 文件

let resource : String =
  #|hello,
  #|world
  #|