Lezer 语法描述文件
对特定编程语言的语法规则描述编写在后缀为 .grammar 的文件里
编写 grammar 文件
使用 .grammar 后缀的文件来描述某种特定的编程语言的语法
在该文件里包含一系列的语法规则 rules,这些规则定义了相应的文法符号 terms 所需匹配内容
提示
文法符号 term 可以是 token 词元/终结符,也可以是 nonterminal 非终结符
- token 终结符,是指解析器对文本内容进行分词操作时,分割出的最小有意义单位,它们构成了语法树的最基本的单元,即叶子节点
- nonterminal 非终结符,是指所匹配的内容,还可以使用其他 nonterminal 或 token 对其进行细分
@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,即写在父规则里面的子规则,这些子规则只能在父规则内部使用,不会污染全局命名空间,而且可以用让文法更紧凑、更易读
当有一个比较复杂的表达式,需要把其中的不同分支标记成不同的节点类型时,且这些节点只需要在该父规则里使用,就可以用内联规则textstatement { ReturnStatement { Return expression } | IfStatement { If expression statement } | ExpressionStatement { expression ";" } }
以上示例statement外层父规则并不会在节点树里出现,但是里面的 inline rule 分别对应于三种分支情况,内容匹配时会在节点树创建对应的三种不同的节点。这三种内联规则只能在statement父规则里面引用(在父规则{}花括号之外不能再引用)全局规则
如果希望所编写的规则可以在任意地方复用,可以编写(顶级的)全局规则或模板规则
入口规则
@top 定义了入口规则,从该规则开始对代码文本进行匹配分词
在一个 .grammar 文件里,可以定义两个 @top 规则
例如官方提供的对于 JavaScript 语法的描述文件 javascript.grammar 里就有三个 top 规则
@top Script { Hashbang? statement* }
@top SingleExpression { expression }
@top SingleClassItem { classItem }
然后就可以在解析器 parser 里通过配置对象的属性 top,来控制采用哪个入口(默认采用 .grammar 文件里的第一个 @top 规则)来启动解析
LRParser.configure({ top: ... })
正则运算符
可以使用正则运算符来表示匹配内容所需满足的模式
*符号:表示匹配(在该符号前面的)元素重复任意次数的内容+符号:表示匹配(在该符号前面的)元素重复一次或多次的内容?符号:表示匹配(在该符号前面的)元素重复零次或一次的内容|符号:表示匹配在该符号左右任意一侧的条件,也可以设置多个条件x | y | z,选择运算符在 grammar 中是上下文无关的,即两个条件互换位置a | b和b | a所表示的匹配模式是等价的()小括号:可以用于分组,例如x (y | z)+表示匹配x元素,然后跟随一个或多个其他元素,该元素是y或z两者之一$[]方括号:表示所需匹配的是一组字符集里面任意一个,例如$[.,]表示匹配点号或逗号
parser 生成器内置了一系列字符集,分别对应了一个以 @ 为前缀的更具语义的符号,在编写语法规则时可以通过这些符号引用相应的字符集
@asciiLetter表示要匹配的内容是$[a-zA-Z]大小写字母@asciiLowercase表示要匹配的内容是$[a-z]小写字母@asciiUppercase表示要匹配的内容是$[A-Z]大写字母@digit表示要匹配的内容是$[0-9]阿拉伯数字0至9@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)
@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 的解析匹配)
@local tokens {
stringEnd[@name='"'] { '"' }
StringEscape { "\\" _ }
@else stringContent
}
@skip {} {
String { '"' (stringContent | stringEscape)* stringEnd }
}
以上示例通过 @local tokens 规则块定义了一个 local token group 局部词元组,其中 stringEnd 和 StringEscape 这两个 token 分别匹配特定的字符串,而字符串的剩余的字符都会解析为 stringContent 字符串。为了避免 @skip 规则的干扰,还需要为 String 节点设置一个相应的 @skip 规则(不会对其内容进行跳过)
引入外部 token
在 .grammar 文件里定义的 tokens 词元采用类似正则表达式的模式描述所需匹配的内容,但是对于一些场景可能并不足够,Lezer 支持引入外部定义的分词器 tokenizer,通过外部 JavaScript 代码片段描述所需匹配的内容,以实现更复杂的需求
@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 具有复杂的结构,且可能出现在同一文法位置中,就需要通过设置优先级就可以解决匹配冲突
同一文法位置
同一文法位置指位于同一个规则里,例如该规则具有多个可能的子项,它们之间是可替换的关系
Additive { Number | Plus }
以上示例 Number 和 Plus 都出现在 Additive 规则里,所以它们在同一文法位置
@tokens {
Divide { "/" }
Comment { "//" ![\n]* }
@precedence { Comment, Divide }
}
以上示例 Comment 是复杂类型的 token,如果它们可以出现在同一文法位置,就可能发生冲突(因为 // 除了可以解析为一个 Comment token,也可以将其视为由两个 Divide token 构成的),需要设置优先级解决冲突(这里 Comment token 具有更高的优先,所以会将 // 解析为注释)
在 @tokens 规则块中可以设置多个 @precedence 规则,以解决 token 之间的冲突
token 专化
可以使用 @specialize 符号,将特定的 token 类型在匹配到指定内容时,特化为一种新的 token
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 进行专化,以实现更复杂的需求
@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]* 或 ("//"…)* 这样的匹配模式来处理空白和行注释,避免它们出现在语法树里),提高了语法规则的可读性和可维护性
一般在解析代码文本时,会忽略空格或注释,则可以添加以下规则
@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 跳过表达式,该局部的跳过规则会覆盖全局的跳过规则
@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(其值可以是从 -10 到 10 之间的值)为特定匹配规则设置动态优先级,以偏向于最终采用该语义
@top Program { (A | B)+ }
A[@dynamicPrecedence=1] { "!" ~ambig }
B { "!" ~ambig }
优先级
- 如果某个/多个规则在解析匹配内容时,可能发生 shift/reduce 移入/归约冲突,可以在冲突的地方使用优先级标注词
通过@precedence规则预先列出所需使用的优先级标注词(其中较前列出的优先级更高),然后在可能产生解析冲突的地方使用
可以在@precedence规则中所列出的标注词后面,还可以添加符号@left或@right以表示解析时优先偏向哪一侧;或者添加符号@cut以表示只考虑当前规则,而不需要考虑其他(即使匹配)规则
例如定义两个优先级标注词@precedence { times @left, plus @left }其中优先级最高的标注词是times,而且都是左相关
然后在语义模糊可能发生解析冲突的位置使用,在标注词前面添加!感叹号textexpression { 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 是带有参数(用尖括号 <> 表示)的规则,以进行代码复用提高可维护性
commaSep<content> { "" | content ("," content)* }
上面是一个模板规则,其中 content 是参数
在使用时会根据所传递的实参拷贝创建一个真实的规则
commaSep<expression>
上面创建了一个规则,匹配以逗号分隔一系列 expression 的内容
说明
如果在 @tokens 块中定义模板规则,那么它会变成参数化的词元,即在其他地方调用该规则时,会根据所传递的实参创建一个新的 token
而不是像普通的模板规则那样,在使用它们时,会把传入的参数当作语法树的节点来展开
在设计模板规则时,采用 {} 花括号可以将传入参数设置为 node prop 节点属性的值
kw<word> { @specialize[@name={word}]<identifier, word> }
以上示例是一个模板规则,基于传入的参数 word(通过配置 pseudo-prop 伪属性 @name)创建一个同名的节点(该节点原本属于 identifier 节点,由于它的内容符合 word 字符串,被转化/专化 specialize 为一个特定的关键词节点)
追踪上下文
使用 class ContextTracker 的实例追踪上下文,它是一个对象,提供相应的方法让上下文与解析过程(shift/reduce 移入/归约)进行同步
在 .grammar 文件中,通过 @context 指令引入上下文追踪器
@context trackIndent from "./helpers.js"
node prop
可以为节点添加额外的元信息,称为 node prop 节点属性
在所需添加元信息的规则名称后面,使用 [propName=value] 形式来添加节点属性。如果属性值是简单的字符串,可以直接写出;如果是其他值,则需要采用 "" 引号包裹
说明
多种类型的规则支持设置 node prop:
- token 规则(包括外部引入的 token)
@specialize或@extend规则(对 token 进行专化的规则)- inline rule 内嵌规则
StartTag[closedBy="EndTag"] { "<" }
以上例子 EndTag 是一个规则的名称(具体规则是 EndTag { '"' }),而不是一个简单的字符串,所以需要采用引号将其包裹。通过以上配置会在节点树上的 StartTag 节点加上节点属性 NodeProp.closeBy
可以通过 @external prop 指令引入在外部定义的 node prop 节点属性
@external prop myProp from "./props"
SomeRule[myProp=somevalue] { "ok" }
以上示例通过指令 @external prop 引入 myProp 节点属性,它是从 ./props 文件导出的一个 class NodeProp 的实例,并将其值设置为 somevalue 字符串。
重命名节点属性
可以对导入的外部节点属性进行重命名,例如以上示例在 myProp 后面添加 as otherName 将 myProp 重命名为 otherName
pseudo prop
有些节点属性带有 @ 前缀,称为 pseudo-prop 伪属性,它们不是为节点添加元信息的,而是 Lezer 保留的内置符号,用于对节点执行特定的操作
@name伪属性用于重命名节点,例如y[@name=x] { "y" }该规则将原本名为y的节点重命名为x@detectDelim指令置于 grammar 文件的顶级(不在内嵌规则里),可以让 Lezer 解析器自动检测分隔符相关的 token 规则,并为它们添加相应的节点属性openedBy或closedBy@isGroup伪属性用于为(在嵌套规则里)一系列节点进行分组textstatement[@isGroup=Statement] { IfStatement | ForStatement | ExpressionStatement | declaration }
然后在使用语法树时,通过SyntaxNodes的方法getChild或getChildren从子节点里获取属于给定分组的第一个或所有节点let elements = parentSyntaxNode.getChildren("Statement")
node prop source
TypeScript 类型 type NodePropSource 描述的是一个函数,用于基于节点类型返回需要应用于该节点的 node prop
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
@external propSource highlighting from "./highlight.js"
其中 highlight.js 文件导出的变量 highlighting 是一个符合 NodePropSource 类型的函数(使用 @lezer/highlight 模块所提供的函数 styleTags 生成的)
import {styleTags, tags} from "@lezer/highlight"
export const highlighting = styleTags({
Identifier: tags.name,
Number: tags.number,
String: tags.string
})
多语言
有一些编程语言有相似性,例如 JavaScript 和 TypeScript,可以将它们的语法规则写在同一个 grammar 文件里,然后通过 @dialects 指令对不同的规则进行标注,以便在使用解析器时可以按需开启/关闭相应的规则(通过解析器的配置对象的属性 dialect,该属性值是一个字符串,如果需要同时激活多个语言,可以用空格将它们分隔)
@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 实例)
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 作为打包器