发布于 2026-01-06 1 阅读
0

JS风格的词法分析😎 工具定义 开始编码! 一气呵成 这仅仅是个开始

JS风格的词法分析😎

工具

定义

让我们开始编程吧!

一件

这仅仅是个开始。

这篇文章摘自我的博客,记得去看看,那里有更多最新内容哦😉

这篇文章是AIM 项目系列的续篇,所以如果您还没有阅读过之前的文章,我建议您先阅读之前的文章,以解答任何关于“如何做?”“为什么做?”的问题。

本文将正式开始编写 AIM 语言!首先,我们将创建一个词法分析器(lexer)。词法分析器,或者如果你不喜欢这些酷炫的名字,也可以称之为分词器(tokenizer ),是一种将人类可读的文本转换为词元列表以便后续处理的工具。它不仅用于创建编程语言,还用于文本处理和其他各种用途。所以,需要注意的是,它的应用范围并不局限于创建编程语言。现在,请看下面的示例:

"128 + 428"
Enter fullscreen mode Exit fullscreen mode

两个数字最基本的、极其简单的加法运算。现在让我们看看如何将其转换为标记的形式

["128", "+", "428"]
Enter fullscreen mode Exit fullscreen mode

词法单元不一定非得是字符串。例如,它们可以是包含额外元数据以供后续使用的对象。本教程将展示如何实现一个基本的词法分析器,用于在上述两种形式之间进行转换。

工具

当然,这类工具有很多库,甚至还有更大型的开发项目。最流行的包括moolex。甚至还有完整的工具包可以帮助你创建词法分析器和语法分析器,例如nearleyjison。此外,对于其他更专业的语言(例如 C/C++),这类工具的列表会更长,但这次我们只讨论 JavaScript,更准确地说是 TypeScript。😀 利用这些工具,你可以轻松快速地完成工作。但是,本教程以及整个AIM 项目的目的并非仅仅是使用不同的库。不,我们将从零开始自行实现。现在——让我们开始吧!

定义

我们先来定义一下词法分析器应该是什么样子。
它应该

  • 以可移植和可扩展的形式实现AIM所有语法;
  • 逐个扫描给定的文本标记;
  • 有很好的方式来遍历已处理的标记;
  • 提供代币及其列表的基本编辑方法。

这些都是非常基础的内容——一个构建完善的词法分析器应该具备的所有功能。接下来,我们需要决定如何具体地创建词法分析器。这类软件通常有三种标准解决方案:

  • 通过使用多个正则表达式;
  • 仅使用单个正则表达式;
  • 逐个字符地读取文本。

我们选择第二种方案。首先,使用正则表达式处理文本非常容易。它们允许我们随时随地轻松扩展语法。此外,当需要修改或开发语法时,逐个字符读取文本并非最佳方案。最后,与第一种方案相比,单个正则表达式应该能提供更好的性能。

让我们开始编程吧!

我决定将代码分为 3 个基本文件:

  • grammar.ts - 定义语法以供后续使用的文件,
  • lexer.ts - 一个基础类库的地方Lexer
  • token.ts -类所在的地方Token

lexer.ts

我先来定义一下Lexer类及其方法:

import Token from "./token";

export interface GrammarStruct {
  id: string;
  match: string;
}

export default class Lexer {
  private index: number = 0;
  private expr: string = "";
  private regex?: RegExp;
  public tokens: Token[] = [];
  public column: number = 1;
  public line: number = 1;
  public data: string = "";
  public grammar: GrammarStruct[] = [
    {
      id: "newline",
      match: "\\n"
    },
    {
      id: "whitespace",
      match: "\\s"
    }
  ];


  private getRegex() {}
  public loadDefinition(def: GrammarStruct) {}
  public loadGrammar(grammar: GrammarStruct[]) {}
  public loadData(data: string) {}
  public next() {}
  public processAll() {}
  public update() {}
  public empty() {}
}
Enter fullscreen mode Exit fullscreen mode

让我们进一步研究这段样板代码,稍后再介绍所列方法的代码。

开头导入了Token类,并定义了用于GrammarStruct指定单词匹配正则表达式容器结构的接口。接下来是包含几个属性的类,这些属性的名称本身就说明了它们的作用Lexer其中三个属性被标记为私有因为它们由词法分析器处理,不应在外部使用。现在,让我们来看看这些方法。indexexprregex

// ...
private getRegex() {
    if (!this.regex) {
      this.regex = new RegExp(this.expr, "gmu");
      console.log(this.regex);
    }
    this.regex.lastIndex = this.index;
    return this.regex;
  }
// ...

12345678910
Enter fullscreen mode Exit fullscreen mode

首先,内部方法getRegex()用于从传递的expr(由连接的匹配器生成的GrammarStruct)生成单个正则表达式,并确保在需要重新生成正则表达式时(添加新项时)正确设置 lastIndex GrammarStruct

// ...
public loadDefinition(def: GrammarStruct) {
    if (this.expr.length > 0) this.expr += "|";
    this.expr += `(${def.match})`;
    this.regex = undefined;
    this.grammar.push(def);

    return this;
}

public loadGrammar(grammar: GrammarStruct[]) {
    for (const def of grammar) {
      this.loadDefinition(def);
    }

    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

loadDefinition()`and`函数loadGrammar()负责加载匹配GrammarStruct项,即将它们组合成单个匹配表达式。`loads`loadDefinition()函数加载单个匹配GrammarStruct项(匹配器定义),而loadGrammar()`loads` 函数加载匹配项数组(整个语法)。`loads`this函数返回一个数组,以便于链式调用(也适用于其他方法)。

// ...
public loadData(data: string) {
    this.data += data;

    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

loadData()顾名思义,它的作用是为词法分析器加载更多数据。数据只是一个字符串,附加到词法分析器内部已有的较长字符串之后。

// ...
public next() {
    const regex = this.getRegex();
    const match = regex.exec(this.data);
    if (match) {
      const length = match[0].length;
      const token = this.grammar[match.indexOf(match[0], 1) - 1];
      const id = token.id;
      this.index += length;
      this.tokens.push(
        new Token(
          {
            column: this.column,
            line: this.line,
            value: match[0],
            length,
            id
          },
          this
        )
      );
      if (id === "newline") {
        this.column = 1;
        this.line++;
      } else if (id === "whitespace") {
        this.column++;
      } else {
        this.column += length;
      }

      return this.tokens[this.tokens.length - 1];
    }
}
// ...
Enter fullscreen mode Exit fullscreen mode

next()这种方法比之前的任何方法都稍微复杂一些。但它也没有什么神奇之处。它只是使用正则表达式匹配数据中的下一个标记,对其进行处理,并Token根据生成的数据(即位置长度ID)将新标记添加到列表中。此外,它还会检查是否存在换行符空格(默认情况下已预定义了匹配器Lexer),并正确处理它们以计算每个标记的位置(行号和列号)。

// ...
public processAll() {
    for (let i = 0; i < Infinity; i++) {
      const token = this.next();
      if (!token) break;
    }

    return this.tokens;
}
// ...
Enter fullscreen mode Exit fullscreen mode

processAll()只是该next()方法的一个衍生版本。它的基本功能是匹配提供的数据中所有可能的标记,直到找不到任何标记为止,然后一次性返回所有匹配的标记列表。

// ...
public update() {
    this.tokens = this.tokens
      .filter(token => {
        return token.value && token.value !== "";
      })
      .sort((a, b) => {
        const line = a.line - b.line;
        const column = a.column - b.column;
        return line === 0 ? column : line;
      })
      .map((token, index, tokens) => {
        if (index > 0) {
          const previous = tokens[index - 1];
          if (previous.id === "newline") {
            return token.moveTo(previous.line + 1, 1, false);
          }
          return token.moveTo(
            previous.line,
            previous.column + previous.length,
            false
          );
        } else {
          return token.moveTo(1, 1, false);
        }
      });

    return this;
  }
// ...
Enter fullscreen mode Exit fullscreen mode

update()是游戏中另一个重要的参与者。它以简洁高效的方式对标记数组进行排序和排列。首先,它会过滤掉Token标记(即没有值的标记)。接下来,根据标记的位置进行排序。最后,它会映射标记,使它们从第 1 行第 1 列开始排列,这涉及到对换行符和空格的检查。此方法在大多数类方法中都有用到。

// ...
public empty() {
    this.data = "";
    this.line = 1;
    this.column = 1;
    this.index = 0;
    this.tokens = [];

    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

empty()Lexer该方法会关闭列表。它会执行清空列表数据以便重用的繁琐工作(语法定义仍然加载)。

课程就到此结束Lexer!其实并不复杂——甚至可以说一点都不复杂!事情就该如此简单——为什么要把这么容易解决的问题搞得这么复杂呢?当然,或许还可以做一些改进,但基本思路不变。

token.ts

该文件中声明了一个更为简单的Token类。它的基本结构如下:

import Lexer from "./lexer";

interface TokenData {
  value: string;
  id: string;
  line: number;
  column: number;
  length: number;
}
export default class Token implements TokenData {
  public value: string;
  public id: string;
  public line: number;
  public column: number;
  public length: number;
  private lexer: Lexer;

  public constructor(params: TokenData, ctx: Lexer) {
    this.lexer = ctx;
    this.set(params, false);
  }
  public setValue(newValue: string, update = true) {}
  public moveTo(line?: number, column?: number, update = true) {}
  public moveBy(line?: number, column?: number, update = true) {}
  public set(params: Partial<TokenData>, update = true) {}
  public remove() {}
}
Enter fullscreen mode Exit fullscreen mode

首先,我们导入了一个Lexer用于类型定义和接口声明的类TokenData,该接口定义了创建新令牌所需的所有值。Token该类只是一个简单的基本数据收集器,包含一些辅助函数。Lexer它需要作为所谓的上下文传递,以便后续在其方法和TokenAPI 之间进行交互。

// ...
public setValue(newValue: string, update = true) {
    this.value = newValue;
    this.length = newValue.length;
    if (update) {
      this.lexer.update();
    }
    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

setValue()它的功能正如其名——设置标记的值及其长度。这是众多标记编辑方法之一,可用于对生成的标记进行基本编辑(可选)。它的第二个参数(默认值为空)true指示是否应在所有其他任务完成后Lexer调用该方法。update()

// ...
public moveTo(line?: number, column?: number, update = true) {
    line && (this.line = line);
    column && (this.column = column);
    if (update) {
      this.lexer.update();
    }
    return this;
}

public moveBy(line?: number, column?: number, update = true) {
    line && (this.line += line);
    column && (this.column += column);
    if (update) {
      this.lexer.update();
    }
    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

moveTo()`move_token` 和 ` moveBy()move_token` 是用于重新定位已匹配标记的实用方法。`move_token`moveTo()将标记移动到指定的行和列,` move_token` 则按指定的行数和列数移动标记。指定移动位置后,标记会在数组中通过`move_token`方法moveBy()进行移动。Lexerupdate()

// ...
public set(params: Partial<TokenData>, update = true) {
    this.value = params.value || this.value;
    this.id = params.id || this.id;
    this.line = params.line || this.line;
    this.column = params.column || this.column;
    this.length = params.length || this.length;
    if (update) {
      this.lexer.update();
    }
    return this;
}
// ...
Enter fullscreen mode Exit fullscreen mode

set()用于通过一次调用设置令牌的不同值。

// ...
public remove() {
    this.value = undefined;
    this.id = undefined;
    this.line = undefined;
    this.column = undefined;
    this.length = undefined;
    this.lexer.update();
 }
 // ...
Enter fullscreen mode Exit fullscreen mode

remove()删除所有标记的值并运行该update()方法,由于该标记缺少值,因此将其从列表中过滤掉。

因此,Token该类主要包含一些用于编辑其数据的方法。虽然可能并非总是需要,但具备这些功能还是很有帮助的。

grammar.ts

import { GrammarStruct } from "./lexer";

const grammar: GrammarStruct[] = [
  // Comments
  {
    id: "single_line_comment_begin",
    match: ">>>"
  },
  {
    id: "multi_line_comment_begin",
    match: ">>"
  },
  {
    id: "multi_line_comment_end",
    match: "<<"
  }
  // ...
]

export default grammar;
Enter fullscreen mode Exit fullscreen mode

grammar.ts文件中,我们以对象数组的形式定义语法。我们提供id一个标识符来表示匹配的标记类型,并match提供一个字符串形式的正则表达式,以便后续进行连接。这里需要注意一点:由于我们的完整正则表达式是线性生成的,因此GrammarStruct必须保持匹配器的正确顺序。

一件

将以上所有代码整合在一起后(您可以在AIM多仓库的核心包中找到完整的源代码),就可以使用这个成果了!一切都归结于以下代码:

import Lexer from "../src/lexer";
import grammar from "../src/grammar";

const AIMLexer = new Lexer().loadGrammar(grammar);
AIMLexer.loadData("public variable #int32 = 1")
AIMLexer.processAll()
Enter fullscreen mode Exit fullscreen mode

现在,我本可以就此结束这个故事,但还有一点需要注意。词法分析器(lexer)的作用仅仅是将线性文本处理成词元数组。而正确读取和处理这些词元则需要另一个工具——解析器(parser) ——来完成。与此问题密切相关的一点是我们语法中字符串的实现。这主要是因为我们想在 AIM 中创建类似 JavaScript 模板字面量的东西。如何用一个正则表达式匹配所有可能的情况,例如转义值、字符和锚点呢?

"text\$${value}text"

1
Enter fullscreen mode Exit fullscreen mode

答案很简单:不行。也许对你们中的一些人来说,解决方案显而易见,但对我来说却需要一番深入思考(很可能是我不够开放)。你必须逐个字符地处理字符串(至少我是这么想的)。例如,看看我的语法定义数组的一部分。

[
    // ...
    {
        id: "char",
        match: `(?<=(?:(?:\\b|^)["'\`])|[\\x00-\\x7F])[\\x00-\\x7F](?=(?:[\\x00-\\x7F]+)?["'\`](?:\\b|$))`
    },
    // ...
    // Anchors and brackets
    {
        id: "string_anchor",
        match: "['`\"]"
    }
    // ...
]
Enter fullscreen mode Exit fullscreen mode

我把字符串拆分成了锚点和字符。这样,当我把它和任何给定的字符串进行匹配时,就会得到许多不同的标记,包括id字符……这完全没问题!之后我可以用解析器把它们处理成最终美观的抽象语法树(AST)形式。

这仅仅是个开始。

与语法分析器和编译器相比,词法分析器简直是小菜一碟。但确保所有环节都万无一失至关重要。只有地基稳固,塔楼才不会倒塌。话虽如此,我认为词法分析器的代码可能会有一些改动(主要是在编写语法分析器的时候),但核心思路应该不会改变。

再次提醒,如果您想查看完整代码,请访问AIM代码。如果您想更密切地关注 AIM 的开发过程,请考虑收藏该代码库或在Twitter上关注我。💡

文章来源:https://dev.to/areknawo/lexing-in-js-style--b5e