Lezer 语法描述文件


Lezer 语法描述文件

对特定编程语言的语法规则描述编写在后缀为 .grammar 的文件里

编写 grammar 文件

使用 .grammar 后缀的文件来描述某种特定的编程语言的语法

在该文件里包含一系列的语法规则 rules,这些规则定义了相应的文法符号 terms 所需匹配内容

提示

文法符号 term 可以是 token 词元/终结符,也可以是 nonterminal 非终结符

  • token 终结符,是指解析器对文本内容进行分词操作时,分割出的最小有意义单位,它们构成了语法树的最基本的单元,即叶子节点
  • nonterminal 非终结符,是指所匹配的内容,还可以使用其他 nonterminal 或 token 对其进行细分
text
@top Program { expression }

expression { Name | Number | BinaryExpression }

BinaryExpression { "(" expression ("+" | "-") expression ")" }

@tokens {
  Name { @asciiLetter+ }
  Number { @digit+ }
}

以上示例每一行就表示一条规则,左侧以规则的名称开头(一般对应于 syntax tree 的节点名称),具体的匹配模式在后面的花括号 {} 里。其中还有一些以 @ 为前缀的单词是 Lezer 所保留的特殊指令

一些基本的编写指南:

  • 如果规则名称以大写字母开头,那么该文法符号 term 最后会出现在语法树里
    提示

    如果需要在一个不区别大小写的脚本中编写规则名称,则可以在规则名称的开头使用 _ 下划线来表示该规则不应出现在树中

    重命名

    可以为规则配置 pseudo-prop 伪属性 @name 以进行重命名

    例如 y[@name=x] { "y" }y 规则(匹配字符 "y")重命名为 x

    对于进行重命名的规则,无论重命名后的名称其首字母是否大写,规则所对应的节点都会出现在 syntax tree 上

  • 在规则中如果匹配的节点依此列出,表示要匹配的内容是要符合此先后顺序的,例如 a b 表示所匹配的内容是 a 元素,然后紧跟着 b 元素
  • 可以编写嵌套式规则 inline rule,即写在父规则里面的子规则,这些子规则只能在父规则内部使用,不会污染全局命名空间,而且可以用让文法更紧凑、更易读
    当有一个比较复杂的表达式,需要把其中的不同分支标记成不同的节点类型时,且这些节点只需要在该父规则里使用,就可以用内联规则
    text
    statement {
      ReturnStatement { Return expression } |
      IfStatement { If expression statement } |
      ExpressionStatement { expression ";" }
    }
    

    以上示例 statement 外层父规则并不会在节点树里出现,但是里面的 inline rule 分别对应于三种分支情况,内容匹配时会在节点树创建对应的三种不同的节点。这三种内联规则只能在 statement 父规则里面引用(在父规则 {} 花括号之外不能再引用)
    全局规则

    如果希望所编写的规则可以在任意地方复用,可以编写(顶级的)全局规则或模板规则

入口规则

@top 定义了入口规则,从该规则开始对代码文本进行匹配分词

在一个 .grammar 文件里,可以定义两个 @top 规则

例如官方提供的对于 JavaScript 语法的描述文件 javascript.grammar 里就有三个 top 规则

text
@top Script { Hashbang? statement* }

@top SingleExpression { expression }

@top SingleClassItem { classItem }

然后就可以在解析器 parser 里通过配置对象的属性 top,来控制采用哪个入口(默认采用 .grammar 文件里的第一个 @top 规则)来启动解析

js
LRParser.configure({ top: ... })

正则运算符

可以使用正则运算符来表示匹配内容所需满足的模式

  • * 符号:表示匹配(在该符号前面的)元素重复任意次数的内容
  • + 符号:表示匹配(在该符号前面的)元素重复一次或多次的内容
  • ? 符号:表示匹配(在该符号前面的)元素重复零次或一次的内容
  • | 符号:表示匹配在该符号左右任意一侧的条件,也可以设置多个条件 x | y | z,选择运算符在 grammar 中是上下文无关的,即两个条件互换位置 a | bb | a 所表示的匹配模式是等价的
  • () 小括号:可以用于分组,例如 x (y | z)+ 表示匹配 x 元素,然后跟随一个或多个其他元素,该元素是 yz 两者之一
  • $[] 方括号:表示所需匹配的是一组字符集里面任意一个,例如 $[.,] 表示匹配点号或逗号

parser 生成器内置了一系列字符集,分别对应了一个以 @ 为前缀的更具语义的符号,在编写语法规则时可以通过这些符号引用相应的字符集

  • @asciiLetter 表示要匹配的内容是 $[a-zA-Z] 大小写字母
  • @asciiLowercase 表示要匹配的内容是 $[a-z] 小写字母
  • @asciiUppercase 表示要匹配的内容是 $[A-Z] 大写字母
  • @digit 表示要匹配的内容是 $[0-9] 阿拉伯数字 09
  • @whitespace 表示要匹配的内容是在 Unicode 标准里所有称得上空白符的字符
  • @eof 表示要匹配的内容是文档的结尾
说明

在方括号 [] 字符集里面,各种字符仅仅表示其自身,而没有特殊的含义

例如以上例子中 . 仅表示所需匹配的内容是句号,而不是(类似正则表达式运算符)用来匹配任意字符;类似地,如果在方括号里使用 \s 仅表示所需匹配的内容是 \ 斜线或 s 字母,而不是表示空格符的转义字符

反向匹配模式

如果需要匹配反向的字符集,可以在方括号前面使用感叹号 ![]

例如 ![x] 表示匹配除了 x 之外的任意字符

以上运算符具有不同优先级别,其中 | 条件选择运算符号的优先级最低

token 词元

在语法解析器中,token 称为词法符合或词法单元,以上翻译为词元,是指解析器对文本内容进行分词操作时,分割出的最小有意义单位,它们构成了语法树的最基本的单元,即叶子节点

而相应地,其他规则称为 nonterminal rule 非终结符规则,即解析器遇到匹配这些规则的内容时并不会终止分词操作,还会对该节点继续细分,直到将它们分成一个个 token 叶子节点位置

定义 token

@token 规则块里列出词元

而在其他规则中(非 @token 规则块里),用 "" 双引号包裹字符,以字符字面量 string literal 方式来表示一个 token(例如 "+"),即自动在语法树中创建一个新的叶子节点类型

注意

在普通规则中,通过字符字面量创建的 literal token 默认并不会在节点树上生成对应的节点(这些 literal token 只用于匹配内容)

如果希望在节点树里出现相应的节点,还需要在 @tokens 规则块中显式列出这些 literal token(而且它们也支持设置 node prop)

text
@tokens {
  "("
  ")"
  "#"[someProp=ok]

  // ... other token rules
}

如果在 @token 规则块里使用字符串字面量,则不会再为基于这些字符串字面量创建细分的 token,由于这些字符串字面量是和该行的其他元素构成一个整体,作为一个 token 类型的

例如在 nonterminal rule 非终结符规则里,如何含有字符串字面量 "a" "b" "c",则表示该规则由三种 token 构成

而如果是在 @token 块里,相同的内容则表示该 token 是一个字符串 "abc"

注意

在 token 词元的规则里,可以引用其他词元来设置所需匹配的内容,但是不能构成循环引用(例如在规则里直接或间接地引用自身)

而在 nonterminal rule 里则可以构成循环引用,以构建复杂的匹配模式

字符串字面量

在 grammar 文件里的字符串字面量所遵循的规则,和 JavaScript 的字符串字面量一样,特别是使用相同的转移规则来表示特殊符号

local token group

通过 @local tokens 规则块定义一个 local token group 局部词元组,它们用于特定的上下文进行解析,以便实现「排除式」的匹配

例如要匹配具有插值的字符串(即字符串里面包含变量,以动态构建内容,例如 JavaScript 的模板字符串),用传统的正则式很难直接表达「匹配除了插值以外的任意内容」

Lezer 提供 local token group 机制,能够在特定解析上下文里定义一组 token,包括若干专门匹配特定子模式的 token,和一个「兜底」的 token(用于匹配所有不属于特定子模式的文本内容),这样就不用写复杂正则也能够实现「排除式」的匹配

只在当其他类型的词元(包括 skip token、literal token)没有匹配时,local token group 局部词元组才起作用,所以一般需要配合 @skip 来一起使用(避免全局的 @skip 规则在特定的上下中断 local token group 的解析匹配)

text
@local tokens {
  stringEnd[@name='"'] { '"' }
  StringEscape { "\\" _ }
  @else stringContent
}

@skip {} {
  String { '"' (stringContent | stringEscape)* stringEnd }
}

以上示例通过 @local tokens 规则块定义了一个 local token group 局部词元组,其中 stringEndStringEscape 这两个 token 分别匹配特定的字符串,而字符串的剩余的字符都会解析为 stringContent 字符串。为了避免 @skip 规则的干扰,还需要为 String 节点设置一个相应的 @skip 规则(不会对其内容进行跳过)

引入外部 token

.grammar 文件里定义的 tokens 词元采用类似正则表达式的模式描述所需匹配的内容,但是对于一些场景可能并不足够,Lezer 支持引入外部定义的分词器 tokenizer,通过外部 JavaScript 代码片段描述所需匹配的内容,以实现更复杂的需求

text
@external tokens insertSemicolon from "./tokens" { insertedSemicolon }

以上示例通过 @external tokens xxx from "external_file_path" 的方式引入外部定义的 token(在 external_file_path 文件里导出了名为 xxx 的变量,它是一个外部分词器 ExternalTokenizer的实例),花括号 { insertedSemicolon } 表示所生成的 token 的名称

外部 tokenizer 生成的 token 和 @tokens 规则块里定义的 token 之间的优先级,是与它们在 .grammar 文件里(定义)所出现的先后顺序相关,先定义的 token 具有较高优先级

token 优先级

Lezer 允许结构简单的 token(不含重复操作符 *+?)之间有重叠的前缀,例如 ++= 这两个 token,如果解析的内容同时匹配了这两个 token,Lezer 会默认根据最长匹配原则来解决冲突

而如果 token 具有复杂的结构,且可能出现在同一文法位置中,就需要通过设置优先级就可以解决匹配冲突

同一文法位置

同一文法位置指位于同一个规则里,例如该规则具有多个可能的子项,它们之间是可替换的关系

text
Additive { Number | Plus }

以上示例 NumberPlus 都出现在 Additive 规则里,所以它们在同一文法位置

text
@tokens {
  Divide { "/" }
  Comment { "//" ![\n]* }
  @precedence { Comment, Divide }
}

以上示例 Comment 是复杂类型的 token,如果它们可以出现在同一文法位置,就可能发生冲突(因为 // 除了可以解析为一个 Comment token,也可以将其视为由两个 Divide token 构成的),需要设置优先级解决冲突(这里 Comment token 具有更高的优先,所以会将 // 解析为注释)

@tokens 规则块中可以设置多个 @precedence 规则,以解决 token 之间的冲突

token 专化

可以使用 @specialize 符号,将特定的 token 类型在匹配到指定内容时,特化为一种新的 token

text
NewExpression { @specialize<identifier, "new"> expression }

以上例子将原本属于 identifier 类型的 token,当它的内容正好为 new 字符串时,将其 specialized 专门化为另一种新的 token,以便与其他 identifier token 区别开来

较常见的应用场景是处理编程语言里的保留字符,它们不是一般的变量,所以将它们专化为另一种特别的 token

extend

Lezer 提供另一个类似的指令 @extend

@specialize 指令将特定的 token 专门化(替换)为另一种新的 token,而 @extend 指令允许两种 token 同时存在,这会隐式启用 GLR 尝试进行多路解析,以寻找正确的语义

@extend 对于需要根据 contextual 上下文才可以准确解析的 token 很有用

支持引入外部 JavaScript 代码片段,对于 token 进行专化,以实现更复杂的需求

text
@external specialize {identifier} specializeIdent from "./tokens" {
  keyword1,
  keyword2
}

以上示例通过 @external specialize { originToken } callbackFn from "external_file_path" 的方式,从 external_file_path 文件引入外部函数 callbackFn,每次解析遇到 originToken 原始词元类型时,都会调用该函数,该函数接收匹配的内容作为入参,最后返回一个新的 token 以进行替换,或返回 -1 以表示不对该 token 进行专化。花括号 { keyword1, keyword2 } 表示执行专化操作而生成的所有可能的 token 的名称

外部 extend

类似地,@extend 指令也可以结合外部 JavaScript 代码片段进行专化

跳过内容

@skip 规则块里标注所需忽略的 token

这些标明要忽略的 token,一般是空格、换行、注释等(不影响语法意义和结构)内容,它们会自动被允许在其他(非 skip 的)token 之间出现零次或多次,

说明

通过 @skip 规则块设置了一些需要忽略的 token,就不需要在每条语法规则里手动处理这些词元(否则就要在每一条规则里去写类似于 [ \t\r\n]*("//"…)* 这样的匹配模式来处理空白和行注释,避免它们出现在语法树里),提高了语法规则的可读性和可维护性

一般在解析代码文本时,会忽略空格或注释,则可以添加以下规则

text
@skip { space | Comment }

@tokens {
  space { @whitespace+ }
  Comment { "//" ![\n]* }
  // ...
}
说明

忽略的词元也可能出现在 syntax tree 里,例如以上例子里的 Comment token 的名称开头是大写字母,则它会出现在语法树里

可以通过 skip expression 跳过表达式,设置更复杂的条件(而不仅仅是根据 token 词元)来匹配所需忽略的内容

说明

假如有某种编程语言的语法,其字符串并不是由一个简单的 token 来匹配的,字符串节点 String 还可以由多个 token 进行细分匹配(可能为了在语法树实现一些特定的结构),而对于字符串里(即细分 token 之间)的空格符一般是不会忽略的

已知为全局设置的跳过规则是 @skip { space | Comment } 即忽略 token 之间的空格和注释行

则需要添加一个针对 String 节点的 skip expression 跳过表达式,该局部的跳过规则会覆盖全局的跳过规则

text
@skip {} {
  String {
    stringOpen (stringEscape | stringContent)* stringClose
  }
}

以上 @skip 规则是为 String 节点设置不一样的局部跳过规则

  • @skip 符号后面的第一对花括号 {} 定义的是跳过规则,其内容为空所以不跳过任何内容
  • 第二对花括号 {} 里列出上述的跳过规则要应用到什么地方(即刚才那套空的跳过规则要用于解析哪些语法)
    这里列出的是与 String 相关的节点,所以当解析器进入到 String 这条规则时,会临时「关掉」全局跳过 space | Comment 节点的逻辑(因为在此上下文里,该跳过集被重写),则在字符串及其细分 token 之间的空格符得以解析保留(例如将它们归入了 stringContent 节点里)
  • 每个解析状态(解析器在分析输入时,所处的上下文,例如正在 String 节点里进行解析)只能与一个 skip expression 跳过表达式相关联,即 skip expression 的作用范围必须明确,不能由于规则含有不确定的边界(如可选或重复项)而导致到底该在哪些位置应用 skip 规则变得模糊

解决冲突

在语义模糊的位置,有多种方式解决冲突

显式启用 GLR

可以在语义模糊的位置添加标记 ~xxx(以波浪线开头,在所有可能引起语义模糊的相关位置使用相同的标记词),启用 GLR 尝试进行多路解析,以寻找正确的语义

当多路解析都可通,可以通过节点属性 node prop @dynamicPrecedence(其值可以是从 -1010 之间的值)为特定匹配规则设置动态优先级,以偏向于最终采用该语义

text
@top Program { (A | B)+ }

A[@dynamicPrecedence=1] { "!" ~ambig }

B { "!" ~ambig }

优先级

  • 如果某个/多个规则在解析匹配内容时,可能发生 shift/reduce 移入/归约冲突,可以在冲突的地方使用优先级标注词
    通过 @precedence 规则预先列出所需使用的优先级标注词(其中较前列出的优先级更高),然后在可能产生解析冲突的地方使用
    可以在 @precedence 规则中所列出的标注词后面,还可以添加符号 @left@right 以表示解析时优先偏向哪一侧;或者添加符号 @cut 以表示只考虑当前规则,而不需要考虑其他(即使匹配)规则
    例如定义两个优先级标注词 @precedence { times @left, plus @left } 其中优先级最高的标注词是 times,而且都是左相关
    然后在语义模糊可能发生解析冲突的位置使用,在标注词前面添加 ! 感叹号
    text
    expression { Number | BinaryExpression }
    
    BinaryExpression {
      expression !times "*" expression |
      expression !plus "+" expression
    }
    
    @tokens {
      Number { @digit+ }
    }
    

    那么在解析具体内容 1+2*3+4 就可以得到准确的语义 (1+(2*3))+4
  • @token 规则块里也可为 token 设置优先级,以解决多个 token 存在相同的前缀匹配而产生的冲突,具体介绍参考token 词元

模板规则

模板规则 template rule 是带有参数(用尖括号 <> 表示)的规则,以进行代码复用提高可维护性

text
commaSep<content> { "" | content ("," content)* }

上面是一个模板规则,其中 content 是参数

在使用时会根据所传递的实参拷贝创建一个真实的规则

text
commaSep<expression>

上面创建了一个规则,匹配以逗号分隔一系列 expression 的内容

说明

如果在 @tokens 块中定义模板规则,那么它会变成参数化的词元,即在其他地方调用该规则时,会根据所传递的实参创建一个新的 token

而不是像普通的模板规则那样,在使用它们时,会把传入的参数当作语法树的节点来展开

在设计模板规则时,采用 {} 花括号可以将传入参数设置为 node prop 节点属性的值

text
kw<word> { @specialize[@name={word}]<identifier, word> }

以上示例是一个模板规则,基于传入的参数 word(通过配置 pseudo-prop 伪属性 @name)创建一个同名的节点(该节点原本属于 identifier 节点,由于它的内容符合 word 字符串,被转化/专化 specialize 为一个特定的关键词节点)

追踪上下文

使用 class ContextTracker 的实例追踪上下文,它是一个对象,提供相应的方法让上下文与解析过程(shift/reduce 移入/归约)进行同步

.grammar 文件中,通过 @context 指令引入上下文追踪器

text
@context trackIndent from "./helpers.js"

node prop

可以为节点添加额外的元信息,称为 node prop 节点属性

在所需添加元信息的规则名称后面,使用 [propName=value] 形式来添加节点属性。如果属性值是简单的字符串,可以直接写出;如果是其他值,则需要采用 "" 引号包裹

说明

多种类型的规则支持设置 node prop:

  • token 规则(包括外部引入的 token)
  • @specialize@extend 规则(对 token 进行专化的规则)
  • inline rule 内嵌规则
text
StartTag[closedBy="EndTag"] { "<" }

以上例子 EndTag 是一个规则的名称(具体规则是 EndTag { '"' }),而不是一个简单的字符串,所以需要采用引号将其包裹。通过以上配置会在节点树上的 StartTag 节点加上节点属性 NodeProp.closeBy

可以通过 @external prop 指令引入在外部定义的 node prop 节点属性

text
@external prop myProp from "./props"

SomeRule[myProp=somevalue] { "ok" }

以上示例通过指令 @external prop 引入 myProp 节点属性,它是从 ./props 文件导出的一个 class NodeProp 的实例,并将其值设置为 somevalue 字符串。

重命名节点属性

可以对导入的外部节点属性进行重命名,例如以上示例在 myProp 后面添加 as otherNamemyProp 重命名为 otherName

pseudo prop

有些节点属性带有 @ 前缀,称为 pseudo-prop 伪属性,它们不是为节点添加元信息的,而是 Lezer 保留的内置符号,用于对节点执行特定的操作

  • @name 伪属性用于重命名节点,例如 y[@name=x] { "y" } 该规则将原本名为 y 的节点重命名为 x
  • @detectDelim 指令置于 grammar 文件的顶级(不在内嵌规则里),可以让 Lezer 解析器自动检测分隔符相关的 token 规则,并为它们添加相应的节点属性 openedByclosedBy
  • @isGroup 伪属性用于为(在嵌套规则里)一系列节点进行分组
    text
    statement[@isGroup=Statement] {
      IfStatement |
      ForStatement |
      ExpressionStatement |
      declaration
    }
    

    然后在使用语法树时,通过 SyntaxNodes 的方法 getChildgetChildren 从子节点里获取属于给定分组的第一个或所有节点
    let elements = parentSyntaxNode.getChildren("Statement")

node prop source

TypeScript 类型 type NodePropSource 描述的是一个函数,用于基于节点类型返回需要应用于该节点的 node prop

ts
type NodePropSource = fn(type: NodeType) → [NodeProp<any>, any] | null
用法

例如在 @lezer/highlight 模块所提供的函数 styleTags,基于配置参数生成一个符合 NodePropSource 类型的函数

然后将该返回值传递给 NodeSet.extend 方法LRParser.configure 方法 动态扩展解析器

也可以在初始化解析器时,作为其配置对象的属性 ParserConfig.props 的值

可以通过指令 @external propSource 将外部定义的 node prop source 引入 .grammar 文件中,这样 Lezer 会自动将其整合进解析器中,为相应的节点添加 node prop

text
@external propSource highlighting from "./highlight.js"

其中 highlight.js 文件导出的变量 highlighting 是一个符合 NodePropSource 类型的函数(使用 @lezer/highlight 模块所提供的函数 styleTags 生成的)

js
import {styleTags, tags} from "@lezer/highlight"

export const highlighting = styleTags({
  Identifier: tags.name,
  Number: tags.number,
  String: tags.string
})

多语言

有一些编程语言有相似性,例如 JavaScript 和 TypeScript,可以将它们的语法规则写在同一个 grammar 文件里,然后通过 @dialects 指令对不同的规则进行标注,以便在使用解析器时可以按需开启/关闭相应的规则(通过解析器的配置对象的属性 dialect,该属性值是一个字符串,如果需要同时激活多个语言,可以用空格将它们分隔)

text
@dialects { comments }

@top Document { Word+ }

@skip { space | Comment }

@tokens {
  Comment[@dialect=comments] { "//" ![\n]* }
  Word { @asciiLetter+ }
}

首先使用 @dialects 规则块列出所有可能使用的语言标记,然后给相应的规则设置 pseudo-prop 伪属性 @dialect,以标注该属性适用于哪一种语言

外部的分词器 tokenizer 可以通过解析栈 Stack.dialectEnabled 访问当前激活的语言

构建解析表

主要使用 @lezer/generator 所提供的命令行工具基于 .grammar 文件生成相应的解析表(一个 JavaScript 文件,其中导出了 parser 变量,绑定了一个 LRParser 实例)

bash
lezer-generator lang.grammar -o lang.js

以上示例命令基于 lang.grammar 文件生成 lang.js 解析表,同时还会生成一个 lang.terms.js 术语表(列出会在节点树上出现的所有节点类型,带有 @export 指令的 pseudo-prop 等)

命令行工具 lezer-generator 所支持的参数

  • --output-o 用于配置输出文件
  • --cjs 用于配置所导出的 JavaScript 采用 CommonJS 模块(默认采用 ES 模块)
  • --names 用于配置在导出的文件里包含 term 文法符号,主要用于调试,会增加文件大小
  • --export 用于配置(在导出文件里的)绑定 LRParser 实例的变量名称,默认值是 parser
  • --noTerms 用于取消生成术语表文件
  • --typescript 用于配置生成 TypeScript 文件(默认输出 JavaScript 文件)
  • --help 在终端输出 lezer-generator 命令行工具支持的所有参数
推荐

推荐使用官方所提供的一个模板 lang-example,用于构建 CodeMirror 语言包,实现 Lezer 和 CodeMirror 的整合,其中使用 rollup 作为打包器


Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes