用Haskell进行解析(第一部分)——用Alex做词法

353 阅读18分钟

用Haskell进行解析(第一部分)——用Alex进行词法分析

这是我们用Haskell进行解析系列的两部分中的第一部分。 想找第二部分吗?你可以在这里找到它。

这两部分教程将探讨两个经常被Haskellers用来解析程序的工具。我们将用它们来解析一个小型的编程语言,从头开始。

Alex和Happy都是工业级的工具,功能强大,甚至可以解析Haskell本身。 在本教程的底部,你会发现GHC的词法分析器和解析器的链接,如果你想看看它们的样子的话。

本教程是使用GHC 9.0.2版本、Stack resolver LTS-19.8、Alex 3.2.7.1版本和Happy 1.20.0版本编写的。

我们的语法:MiniML

在本教程中,我们将介绍一种叫做MiniML(读作 "最小")的小型语法,它是基于ML的。 顾名思义,它具有让你开始使用Alex和Happy所需的最小语法。然而,我们希望介绍足够的概念,使你能够为你的编程语言创建一个有用的语法,并有效地使用这些工具。

本系列教程将支持的一些功能是:

  • 变量、常量、函数声明、函数应用、局部绑定和运算符。
  • 一些简单的类型,以及箭头类型(函数)。
  • 条件表达式:if-then-else和if-then。
  • 块注释。

此外,练习将指导你用行注释和有理数来扩展语言。稍后,在第二部分,练习将介绍模式、列表访问和模式匹配。

下面的片段演示了一个用MiniML编写的程序:

let the_answer : int =
  let a = 20 in
  let b = 1 in
  let c = 2 in
  a * c + b * c

let main (unit : ()) : () =
  print ("The answer is: " + the_answer)

在解析过程的最后,我们将建立一个抽象语法树(AST),这是一个代表程序的数据结构。 与非结构化数据(如字符串)相比,AST更容易操作。 你可以用它来编写一个解释器,转译成其他语言,等等。

下面是上面的片段的(简化和美化的)AST在第二部分结束时的样子:

[ Dec _ (Name _ "the_answer") [] (Just (TVar _ (Name _ "int")))
  (ELetIn _ (Dec _ (Name _ "a") [] Nothing (EInt _ 20))
  (ELetIn _ (Dec _ (Name _ "b") [] Nothing (EInt _ 1))
  (ELetIn _ (Dec _ (Name _ "c") [] Nothing (EInt _ 2))
  (EBinOp _ (EBinOp _ (EVar _ (Name _ "a")) (Times _) (EVar _ (Name _ "c"))) (Plus _) (EBinOp _ (EVar _ (Name _ "b")) (Times _) (EVar _ (Name _ "c")))))))
, Dec _ (Name _ "main") [Argument _ (Name _ "unit") (Just (TUnit _))] (Just (TUnit _))
  (EApp _ (EVar _ (Name _ "print")) (EPar _ (EBinOp _ (EString _ "\"The answer is: \"") (Plus _) (EVar _ (Name _ "the_answer")))))
]

注:通配符(_)将包含每个被解析的树节点的范围,为了简洁起见,这里省略了。

因此,我们将非结构化数据(字符串)转化为结构化数据,这就更容易操作了。 这个树状结构已经准备好进行类型检查、转译或解释了。

用Alex进行词法分析

在开始解析之前,我们应该先为语法编写一个词法分析器,也就是词法分析器扫描器标记器

根据A. W. Appel在Modern Compiler Implementation in ML(第14页)中的说法。

词法分析器接收一个字符流,并产生一个名称、关键词和标点符号流;它抛弃了标记之间的空白和注释。如果在每个可能的点上都要考虑到可能的空白和注释,就会使分析器过于复杂;这就是将词汇分析与分析分离的主要原因。

我们将使用Alex作为工具,为我们的语法生成词法分析器。

本节旨在对Alex进行介绍,以及如何使用它与Happy一起做有用的事情。

在本教程系列的第二部分,我们将使用Happy生成一个解析器,该解析器将消费标记流。 Happy将尝试根据我们将描述的特定规则来匹配标记,并产生一个代表有效MiniML程序的树状结构。

尽管Alex和Happy经常被一起使用,但它们是独立的工具。它们可以与其他技术相结合,因此,如果你愿意,你可以把Alex和Megaparsec一起使用。

使用分析器组合器制作一个词典是相当可行的,也是可以管理的。 同时,使用Alex是一个比较复杂的过程,但它的优点是性能会更加可预测。除此之外,它可以很容易地与Happy集成,一次输出一个标记,这可能会避免首先在内存中创建一个大量的标记列表。

亚历克斯

Alex是一个生成词库的Haskell工具。它类似于C和C++的工具lexflex ,它是我们编程语言语法分析的第一步。它将接受一个代表用户编写的程序的输入字符流(一个String ,或者在我们的例子中,一个ByteString ),并生成一个令牌流(一个列表),这一点将在后面深入解释。

请注意,我们提到的是Alex将生成一个词法分析器,而不是说Alex本身就是一个词法分析器。Alex将读取我们创建的指定如何匹配词法的.x 文件,然后创建一个.hs 文件,这将是生成的词法分析器。

创建项目

我们将使用Stack来建立项目。Alex和Happy使用的文件的扩展名分别是.x.y, ,Stack可以自动检测这些文件格式并建立它们。

n.b.: 使用Stack并不是绝对必要的。你也可以使用Cabal或普通的Alex和Happy文件,它也可以工作。

开始时,创建一个空的Stack项目:

$ stack new mini-ml simple
$ cd mini-ml

你还需要在你的机器上安装Alex和Happy:

$ stack install alex
$ stack install happy

我们将为我们的项目配置创建一个package.yaml 文件。使用你喜欢的文本编辑器,用以下内容初始化package.yaml

name: mini-ml
version: 0.1.0.0

extra-source-files:
- README.md
- LICENSE

default-extensions:
- OverloadedStrings

library:
  source-dirs: src
  dependencies:
  - array
  - base
  - bytestring
  build-tools:
  - alex
  - happy

n.b.: 如果你选择使用Stack,你可能需要使用hpack --force 来覆盖当前的Cabal文件。

你可能想删除src/Main.hs ,因为我们将不需要它。

它是如何工作的

在本节中,区分词素标记这两个术语是很重要的。

词素是语法中的一个有效原子,比如一个关键词(in,if, 等),一个运算符(+,/, 等),一个整数字头,一个字符串字头,一个左括号或右括号,一个标识符,等等。你可以把它看作是输入字符串中的任何单词、标点符号、数字等。

同时,一个令牌由一个令牌和一个可选的令牌值组成。 令牌名是该词素所属的词汇类别的名称,而令牌值是实现定义的。

我们可以将令牌表示为Haskell的sum数据类型,其中数据构造器名称对应于令牌名称,构造器参数对应于令牌值,可选择扫描的词素。例如,In,If,Plus,Divide,Integer 42,String "\"foo\"",LPar,RPar, 和Identifier "my_function" 都是为其相应的词素生成的令牌。

请注意,在某些情况下,标记还带有它的词素值,就像Integer,String, 和Identifier 的情况一样。

如果你的目标是从词素到令牌,你如何辨别不同的词素?为此,词典员通常使用确定性的有限自动机(DFA)来实现,这是一种状态机。

例如,假设我们的语法只有关键词ifin ,还有只由小写字母组成的标识符,那么它的小型自动机可以是这样的:

                 ┌─┐
                 ▼ │ [a-z]
                ┌──┴┐
                │┌─┐│
        ┌──────►││4││◄──────┬───────┐
        │       │└─┘│       │       │
        │       └───┘       │       │
        │         ▲         │       │
        │ [a-z]   │ [a-z]   │       │
        │         │         │ [a-z] │
      ┌─┴─┐     ┌─┴─┐     ┌─┴─┐     │
      │   │ i   │┌─┐│ n   │┌─┐│     │
─────►│ 0 ├────►││1│├────►││2││     │
      │   │     │└─┘│     │└─┘│     │
      └───┘     └─┬─┘     └───┘     │
                  │                 │
                  │f                │
                  ▼                 │
                ┌───┐               │
                │┌─┐│ [a-z]         │
                ││3│├───────────────┘
                │└─┘│
                └───┘

为了理解如何使用上图,想象这是一个城市,每个编号的方块是你可以开车去的地方,每个箭头代表一条单行道。 从哪里来的箭头到0的方块是你开始开车的地方,而里面有另一个方块的方块是你可以停车的地方。在这种情况下,你不能把车停在带0的广场上。

箭头中也有方向,由你的GPS引导。你的GPS可能会告诉你直接开进一个i ,然后在一个[a-z] ,然后停下来,比如说。然而,你的GPS也可能要求你非法转弯,例如,在一个@ ,这总是会把你带到一个死胡同,你会被卡在那里。

为了细化术语,城市是由状态转换图表示的自动机(复数:自动机),每个编号的方块被称为一个状态,每个箭头被称为一个过渡。 当一个过渡从哪里进入一个状态时,我们说这个状态是起始状态,在这个例子中是状态0。 你可以停车的地方被称为接受状态,即1、2、3和4。

GPS代表自动机的输入(字符串)。 死胡同代表每个DFA中隐含的错误状态,你可以把它看作是一个隐藏的5号状态,从每个状态到它的所有无效的转换。

为了简化问题,在模棱两可的转换上,如i[a-z] ,假设以单字母的转换为优先。

例如,为了检查 "int "这个表达式,我们将遵循这个路径。0 → 1 → 2 → 4.

现在,我们需要给每个状态赋予意义,这样这个自动机才会有用,我们将这样做:

  • 0:错误
  • 1:标识符
  • 2:在
  • 3:如果
  • 4: 标识符
  • 5(隐式)错误

由于我们停在了4,结果是 "int "是一个标识符,所以我们得到的标记是Identifier "int" 。如果我们停在2(对于表达式 "in"),得到的标记将是In

如果我们没有消耗任何输入并停在状态0,或者输入是像 "Foo123 "这样的东西,我们会达到隐含的状态5呢? 在这种情况下,我们会停止并指出一个词法错误。

如前所述,我们不会从头开始写我们的词法分析器。相反,我们将使用Alex来生成一个词法分析器,该词法分析器会创建一个令牌流,Happy生成的解析器会对其进行解析。

正则表达式

Alex使用正则表达式(regexes)来定义模式。 我们将保持相当简单,并解释其中一些我们将使用的模式。

[0-9] 这样的语法意味着09 (包括)之间的任何数字字符都将被匹配。同样,[a-zA-Z] 意味着任何小写或大写的字符都可以被匹配。字符也可以被排除,例如,[^\"] 读作 "除双引号外的任何字符"。

* (称为Kleene star)意味着该表达式可以被匹配0次或更多次。类似地,+ 意味着它可以被匹配一次或多次。例如,[0-9]+ 可以匹配1234 。你也可以找到? ,这意味着一个表达式可以匹配零次或一次(即,它是可选的)。

点(.)表示 "匹配除换行以外的任何内容"。你也可以使用常见的转义代码,如\n ,它将匹配换行。

一个双引号的字符串,如">=" ,将完全匹配引号内的字符串。一个转义字符,如\? 将完全匹配该字符。

你可以通过连接它们或在它们之间插入一个管道(|)来分组铰链。

当你连接这些词组时,比如在[a-z][A-Za-z0-9]* ,这意味着Alex应该依次匹配每一组字符串。可以用这个词组匹配的一个例子是myVariable1

管子意味着Alex可以交替选择,例如0(x[0-9a-fA-F]+ | o[0-7]+) ,这是一个可以匹配十六进制或八进制数字的词组。

圆括号用于将铰链组合在一起,Alex忽略了空白,除非它们被转义。例如,foo barfoobar 都只能被foobar 匹配,如果你想明确要求它们之间有一个空格,你可以写foo\ bar

我们的第一个分析器

首先创建一个新的文件:src/Lexer.x 。现在,让我们为这个文件添加一个 "骨架",我们将用它来让Stack编译这个文件。 不要担心,我很快就会在那里解释一切。:)

{
-- At the top of the file, we define the module and its imports, similarly to Haskell.
module Lexer
  ( -- * Invoking Alex
    Alex
  , AlexPosn (..)
  , alexGetInput
  , alexError
  , runAlex
  , alexMonadScan

  , Range (..)
  , RangedToken (..)
  , Token (..)
  ) where
}
-- In the middle, we insert our definitions for the lexer, which will generate the lexemes for our grammar.
%wrapper "monadUserState-bytestring"

tokens :-

<0> $white+ ;

{
-- At the bottom, we may insert more Haskell definitions, such as data structures, auxiliary functions, etc.
data AlexUserState = AlexUserState
  {
  }

alexInitUserState :: AlexUserState
alexInitUserState = AlexUserState

alexEOF :: Alex RangedToken
alexEOF = do
  (pos, _, _, _) <- alexGetInput
  pure $ RangedToken EOF (Range pos pos)

data Range = Range
  { start :: AlexPosn
  , stop :: AlexPosn
  } deriving (Eq, Show)

data RangedToken = RangedToken
  { rtToken :: Token
  , rtRange :: Range
  } deriving (Eq, Show)

data Token
  = EOF
  deriving (Eq, Show)
}

运行stack build --fast --file-watch ,将构建该项目,Stack将自动把Alex文件编译成一个Haskell文件。

{} 之间提供了顶部和底部的部分,所有的Haskell代码都将在生成的Stack文件中被内联。

好奇地想看看生成的文件吗?有两种方法可以让你访问它:

  1. 如果你运行alex src/Lexer.x ,它将生成src/Lexer.hs ,所有生成的Alex代码将在这里,还有你提供的代码片段。
  2. Stack自动生成一个文件,其路径因操作系统和Cabal版本而异,但在我的机器上,它在.stack-work/dist/x86_64-linux-tinfo6/Cabal-3.4.1.0/build/Lexer.hs 。你也可以用$(stack path --dist-dir)/build/Lexer.hs 来访问这个文件。

现在,在我们文件的顶部,我们只声明了模块的名称,但我们很快就会增加我们的导出列表并添加更多的导入。

在中间部分,我们首先声明我们的包装器,它表示Alex应该为我们生成的代码类型(monadUserState ,它允许我们保存自定义状态)和输入类型(bytestring ,但我们可以使用普通的HaskellStrings代替)。如果你想看其他的包装器,请查阅Alex用户指南中的包装器。

然后我们有tokens :- ,在这里我们将列出所有由正则表达式定义的模式,以匹配我们语法中的词素,再加上一个关于词法器应该如何处理匹配词素的操作。我们提供的第一个定义是<0> $white+ ; ,它简单地表示应该跳过所有白色空间。

底部包含一些Alex要求我们写的模板,这些包括:

  • 一个需要被称为AlexUserState 的数据类型。
  • 一个具有初始状态的值,称为alexInitUserState
  • 一个叫做alexEOF 的值,它指示Alex如何建立EOF(文件结束)标记。当Alex完成了输入字符串的词法运算后,它就会到达。
  • 一些额外的数据类型。Range,RangedToken, 和Token, 我们将在整个文章中使用它们来描述我们已经成功创建的标记,以及它们的位置。保存这些范围是不必要的,但它们在报告错误时是有用的。

最后,对于EOF标记,我们使用alexGetInput 动作来检索扫描仪的当前位置并提供给标记。

我们应该为语法中的每个词类提供一个令牌名称给Token 。你可以根据自己的需要自由地添加新的令牌名称。

在本教程中,我们将有以下内容:运算符、关键字、字面意义(字符串和整数)、标识符、代表文件结束(EOF)的标记,等等。

我们的令牌数据类型是这样的:

data Token
  -- Identifiers
  = Identifier ByteString
  -- Constants
  | String ByteString
  | Integer Integer
  -- Keywords
  | Let
  | In
  | If
  | Then
  | Else
  -- Arithmetic operators
  | Plus
  | Minus
  | Times
  | Divide
  -- Comparison operators
  | Eq
  | Neq
  | Lt
  | Le
  | Gt
  | Ge
  -- Logical operators
  | And
  | Or
  -- Parenthesis
  | LPar
  | RPar
  -- Lists
  | Comma
  | LBrack
  | RBrack
  -- Types
  | Colon
  | Arrow
  -- EOF
  | EOF
  deriving (Eq, Show)

在文件的顶部,确保包括适当的导入:

import Data.ByteString.Lazy.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BS

标识符的词典化

让我们从一个简单的开始:标识符。 我们可以自由选择标识符的规范,但让我们使用下面的规范。

一个标识符是由字母数字字符、素数(')、问号(?)和下划线(_)组成的序列。 标识符必须以字母或下划线开始。

为了使标识符的regex模式更容易编写,我们可以使用字符集宏和regex宏。


字符集和regex宏

字符集宏是一种捷径,你可以用它来避免重复的字符集。我们使用$NAME = CHARACTER_SET 来定义一个字符集宏:

%wrapper "monadUserState-bytestring"

$digit = [0-9]
$alpha = [a-zA-Z]

tokens :-

我们也可以将多个regex宏组合成一个,它将在其对应的正则表达式中被适当展开。 例如,这里我们可以匹配一个标识符:

$digit = [0-9]
$alpha = [a-zA-Z]

@id = ($alpha | \_) ($alpha | $digit | \_ | \' | \?)*

tokens :-

我们使用@NAME = REGEX 来定义一个正则表达式宏。注意,宏不能是递归的。


让我们根据我们的标识符来定义我们的第一个词组:

tokens :-

<0> $white+ ;

<0> @id     { tokId }

语法<START_CODE> REGEX { CODE } 意味着如果亚历克斯已经成功地匹配了一个模式REGEX ,并且它从START_CODE 状态开始,它应该执行代码CODE

开始代码的想法是相当有价值的。 Alex作为一个状态机工作,而0 表示机器开始的初始状态。在这种情况下,当我们在状态0 开始时,我们可以匹配空白和标识符。以后我们将创建其他的开始代码。

CODE 可以包含任何Haskell表达式,因为它将被逐字纳入生成的代码中。对于空白,我们可以使用 ,而不是给它一个明确的动作,这只是意味着Alex应该对它不做任何有趣的事情。这和写 是一样的。; { skip }

Alex期望这个表达式的类型是AlexAction RangedToken 。下面是使用monadUserState-bytestring 时自动生成的AlexAction 的定义。

type AlexAction result = AlexInput -> Int64 -> Alex result

其中result 在我们的例子中是RangedTokenInt64 对应于输入的长度。知道AlexInput 的样子也会很有用:

type AlexInput = (AlexPosn,    -- current position,
                  Char,        -- previous char
                  ByteString,  -- current input string
                  Int64)       -- bytes consumed so far

让我们创建我们的tokId 函数。我们在文件的底部插入这个定义,在这里我把Token 作为一个锚点,建议你把新函数放在这里:

data Token  -- anchor: don't copy and paste this
  ...
  | EOF
  deriving (Eq, Show)

mkRange :: AlexInput -> Int64 -> Range
mkRange (start, _, str, _) len = Range{start = start, stop = stop}
  where
    stop = BS.foldl' alexMove start $ BS.take len str

tokId :: AlexAction RangedToken
tokId inp@(_, _, str, _) len =
  pure RangedToken
    { rtToken = Identifier $ BS.take len str
    , rtRange = mkRange inp len
    }

tokId 将从输入字符串中提取词素,这将是 中的第一个 字符。然后,该范围将由 处理,该函数使用 将位置相应地推进到输入字符串中的每个看到的字符。Alex会自动生成这个函数,你不需要在你的代码中包含它。str len mkRange alexMove

我们应该检查我们的代码是否有效。这里有一个小函数,我们可以用它来测试。不要忘记导出它!

tokId = ...  -- anchor, don't copy and paste this

scanMany :: ByteString -> Either String [RangedToken]
scanMany input = runAlex input go
  where
    go = do
      output <- alexMonadScan
      if rtToken output == EOF
        then pure [output]
        else (output :) <$> go

启动GHCi(运行stack ghci ),让我们检查一下这是否工作。它的结果应该是这样的(为了便于可视化,稍加美化)。

>>> runAlex "my_identifier" alexMonadScan
Right (RangedToken {rtToken = Identifier "my_identifier", rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 13 1 14}})
>>> scanMany "my_identifier other'identifier ALL_CAPS"
Right
  [ RangedToken {rtToken = Identifier "my_identifier", rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 13 1 14}}
  , RangedToken {rtToken = Identifier "other'identifier", rtRange = Range {start = AlexPn 14 1 15, stop = AlexPn 30 1 31}}
  , RangedToken {rtToken = Identifier "ALL_CAPS", rtRange = Range {start = AlexPn 31 1 32, stop = AlexPn 39 1 40}}
  , RangedToken {rtToken = EOF, rtRange = Range {start = AlexPn 39 1 40, stop = AlexPn 39 1 40}}
  ]

AlexPn 的值分别表示以下内容:标记前的字符数、行号和行列。

很酷的东西。

如果你被卡住了,或者想检查一下我们是否在同一页上,到这一节为止的词典代码可以在这里找到。

关键字和运算符的词法

我们现在可以对标识符进行词法分析了,但是编程语言的内容通常远不止这些。 现在让我们来扫描一下我们定义的其他关键字。 幸好,这些关键字都很简单。

tokens :-

<0> $white+ ;

-- Keywords
<0> let     { tok Let }
<0> in      { tok In }
<0> if      { tok If }
<0> then    { tok Then }
<0> else    { tok Else }

-- Arithmetic operators
<0> "+"     { tok Plus }
<0> "-"     { tok Minus }
<0> "*"     { tok Times }
<0> "/"     { tok Divide }

-- Comparison operators
<0> "="     { tok Eq }
<0> "<>"    { tok Neq }
<0> "<"     { tok Lt }
<0> "<="    { tok Le }
<0> ">"     { tok Gt }
<0> ">="    { tok Ge }

-- Logical operators
<0> "&"     { tok And }
<0> "|"     { tok Or }

-- Parenthesis
<0> "("     { tok LPar }
<0> ")"     { tok RPar }

-- Lists
<0> "["     { tok LBrack }
<0> "]"     { tok RBrack }
<0> ","     { tok Comma }

-- Types
<0> ":"     { tok Colon }
<0> "->"    { tok Arrow }

-- Identifiers
<0> @id     { tokId }

我们还需要定义tok

tokId = ...  -- anchor

tok :: Token -> AlexAction RangedToken
tok ctor inp len =
  pure RangedToken
    { rtToken = ctor
    , rtRange = mkRange inp len
    }

这个函数应该没有什么神秘之处。它只是简单地插入提供的Token ,并为其创建一个范围。

我们现在可以在GHCi中检查它了:

>>> scanMany "if true then foo else (bar baz)"
Right
  [ RangedToken {rtToken = If, rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 2 1 3}}
  , RangedToken {rtToken = Identifier "true", rtRange = Range {start = AlexPn 3 1 4, stop = AlexPn 7 1 8}}
  , RangedToken {rtToken = Then, rtRange = Range {start = AlexPn 8 1 9, stop = AlexPn 12 1 13}}
  , RangedToken {rtToken = Identifier "foo", rtRange = Range {start = AlexPn 13 1 14, stop = AlexPn 16 1 17}}
  , RangedToken {rtToken = Else, rtRange = Range {start = AlexPn 17 1 18, stop = AlexPn 21 1 22}}
  , RangedToken {rtToken = LPar, rtRange = Range {start = AlexPn 22 1 23, stop = AlexPn 23 1 24}}
  , RangedToken {rtToken = Identifier "bar", rtRange = Range {start = AlexPn 23 1 24, stop = AlexPn 26 1 27}}
  , RangedToken {rtToken = Identifier "baz", rtRange = Range {start = AlexPn 27 1 28, stop = AlexPn 30 1 31}}
  , RangedToken {rtToken = RPar, rtRange = Range {start = AlexPn 30 1 31, stop = AlexPn 31 1 32}}
  , RangedToken {rtToken = EOF, rtRange = Range {start = AlexPn 31 1 32, stop = AlexPn 31 1 32}}
  ]

对整数进行命名

词典化整数是非常简单的。我们可以只使用一个或多个数字的序列:

-- Identifiers
<0> @id     { tokId }

-- Constants
<0> $digit+ { tokInteger }

当然,我们也需要定义tokInteger

tok = ...  -- anchor

tokInteger :: AlexAction RangedToken
tokInteger inp@(_, _, str, _) len =
  pure RangedToken
    { rtToken = Integer $ read $ BS.unpack $ BS.take len str
    , rtRange = mkRange inp len
    }

检查它是否工作:

>>> scanMany "42"
Right
  [ RangedToken {rtToken = Integer 42, rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 2 1 3}}
  , RangedToken {rtToken = EOF, rtRange = Range {start = AlexPn 2 1 3, stop = AlexPn 2 1 3}}
  ]

词典化注释

词典化注释将要求我们除了使用0 ,还要使用一个额外的开始代码。当然,我们可以做一个像下面这样简单的东西。

<0> $white+ ;

<0> "(*" (. | \n)* "*)" ;

-- Keywords

但是这将导致一些问题,例如,如果注释没有被关闭怎么办?

>>> scanMany "(* my comment"
Right
  [ RangedToken {rtToken = LPar, rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 1 1 2}}
  , RangedToken {rtToken = Times, rtRange = Range {start = AlexPn 1 1 2, stop = AlexPn 2 1 3}}
  , RangedToken {rtToken = Identifier "my", rtRange = Range {start = AlexPn 3 1 4, stop = AlexPn 5 1 6}}
  , RangedToken {rtToken = Identifier "comment", rtRange = Range {start = AlexPn 6 1 7, stop = AlexPn 13 1 14}}
  , RangedToken {rtToken = EOF, rtRange = Range {start = AlexPn 13 1 14, stop = AlexPn 13 1 14}}
  ]

我们想检测这种情况并支持嵌套注释。我们将创建两个辅助函数,nestCommentunnestComment ,它们将跟踪我们有多少层注释,并允许我们检测我们是否已经到达文件的末尾有一个未关闭的注释。 但在我们实现这两个函数之前,让我们首先编写Alex代码来处理识别注释以及一些实用工具。

此外,Alex还提供了andBegin 组合器,它将执行一个表达式并改变当前的起始代码:

<0> $white+ ;

<0>       "(*" { nestComment `andBegin` comment }
<0>       "*)" { \_ _ -> alexError "Error: unexpected closing comment" }
<comment> "(*" { nestComment }
<comment> "*)" { unnestComment }
<comment> .    ;
<comment> \n   ;

-- Keywords

需要注意的关键是,我们可以在任何地方开始一个注释,因为它的开始代码是0 ,但是我们只有在开始匹配一个注释的时候才能匹配一个结束的注释对,这要感谢comment 的开始代码。

请注意,在开始代码0 ,检查*) 词素是不必要的。如果没有它,它将被排放为一个乘法,然后是一个结束的小括号,但解析器会在以后抓住它。 像Haskell一样,MiniML将支持将运算符作为一级函数传递,如let add = (+) 。然而,这有与OCaml相同的缺陷:(*) 被识别为一个注释。 OCaml接受( *) ,但会发出一条信息:"警告:这不是注释的结束。"正因为如此,我们会向用户发出一个使用alexError 的错误,并期望用( * ) 来代替引用乘法运算符。另外,你也可以在这里把注释的语法改成别的东西,比如/* */ ,并避免这种模糊性,但这不会让人觉得太像ML。

接下来,我们将改变AlexUserState ,使它记住我们的注释的嵌套级别。我们将把它初始化为0。我们还定义了一些辅助函数来处理我们的状态:

tokInteger = ...  -- anchor

data AlexUserState = AlexUserState
  { nestLevel :: Int
  }

alexInitUserState :: AlexUserState
alexInitUserState = AlexUserState
  { nestLevel = 0
  }

get :: Alex AlexUserState
get = Alex $ \s -> Right (s, alex_ust s)

put :: AlexUserState -> Alex ()
put s' = Alex $ \s -> Right (s{alex_ust = s'}, ())

modify :: (AlexUserState -> AlexUserState) -> Alex ()
modify f = Alex $ \s -> Right (s{alex_ust = f (alex_ust s)}, ())

n.b.: 如果你熟悉 mtl库,你可能更喜欢定义instance MonadState Alex

我们简单地增加嵌套值,跳过消耗的输入来嵌套一个注释。注意,skip input len 只是alexMonadScan ,它要求扫描下一个标记。不嵌套是类似的,除了我们减少级别,如果我们达到0级,我们必须返回我们的初始启动代码 (0),因为这意味着我们退出了注释。 不要忘记从Control.Monad 中导入when

tokInteger = ...  -- anchor

nestComment, unnestComment :: AlexAction RangedToken
nestComment input len = do
  modify $ \s -> s{nestLevel = nestLevel s + 1}
  skip input len
unnestComment input len = do
  state <- get
  let level = nestLevel state - 1
  put state{nestLevel = level}
  when (level == 0) $
    alexSetStartCode 0
  skip input len

最后,让我们改变一下alexEOF ,如果我们最终得到了一个未关闭的注释,通过检查我们在到达EOF时是否正在解析一个注释,来发出一个错误:

alexEOF :: Alex RangedToken
alexEOF = do
  startCode <- alexGetStartCode
  when (startCode == comment) $
    alexError "Error: unclosed comment"
  (pos, _, _, _) <- alexGetInput
  pure $ RangedToken EOF (Range pos pos)

在GHCi中尝试一下,会产生预期的结果。

>>> scanMany "(* my comment"
Left "Error: unclosed comment"
>>> scanMany "if (* my (* nested *) \n comment *) x"
Right
  [ RangedToken {rtToken = If, rtRange = Range {start = AlexPn 0 1 1, stop = AlexPn 2 1 3}}
  , RangedToken {rtToken = Identifier "x", rtRange = Range {start = AlexPn 35 2 13, stop = AlexPn 36 2 14}}
  , RangedToken {rtToken = EOF, rtRange = Range {start = AlexPn 36 2 14, stop = AlexPn 36 2 14}}
  ]

字符串分析

字符串的词法应该不会有什么惊喜。只要把这个放在你的词典里就可以了:

-- Constants
<0> $digit+ { tokInteger }
<0> \"[^\"]*\" { tokString }

而我们使用这个定义tokString

tokInteger = ...  -- anchor

tokString :: AlexAction RangedToken
tokString inp@(_, _, str, _) len =
  pure RangedToken
    { rtToken = String $ BS.take len str
    , rtRange = mkRange inp len
    }

这是一个相当基本的字符串词法分析器。它不接受转义字符或嵌套的引号。 我们邀请读者在下面的练习中扩展字符串词法器,使其更加有用。

你可以在这里找到这个词法器的完整代码。

练习

  1. 我们用(* *) ,支持块状注释。改变扫描器以接受行注释,如# foo ,当发现换行或EOF时就结束。

  2. 改变字符串分析器以接受下列转义字符。\\,\n, 和\t 。请自由添加任何你能想到的其他转义代码。

温馨提示AlexUserState 中创建一个缓冲区,在看到字符时添加字符,以及两个辅助的enterStringexitString 函数。输入字符串,保存状态中的当前位置,将\" 添加到缓冲区,并开始一个新的起始代码。对于与\\n,\\t\\\\ 匹配的转义字符,将\n,\t, 或\\ 添加到缓冲区,并使用一个新的emit 函数。 对于与. 匹配的普通字符,用新的emitCurrent 函数将其添加到当前的缓冲区。别忘了将\\\" 匹配为转义引号,并与emit 添加。在退出字符串状态时,将字符串作为一个新的标记发出,重置字符串状态,别忘了添加结束引号。确保结束位置也提前一个字符。 不要忘了像我们为块状注释所做的那样,检查是否有一个未封闭的字符串。

为了获得这方面的灵感,你可以使用Alex的Tiger例子作为指导。

你可以在这里找到两个练习的解决方案。

结论和进一步阅读

在本教程的这一部分,我们演示了如何使用Alex建立一个词库,将词素转化为标记,使用启动代码和一个足以满足MiniML的用户状态。

你在这个旅程中的下一步是使用Alex生成的标记来创建一个解析器。 你可以继续阅读本系列的第二部分,在那里你将被介绍给Happy来创建语法的解析器。