JS风格的词法分析😎
工具
定义
让我们开始编程吧!
一件
这仅仅是个开始。
这篇文章摘自我的博客,记得去看看,那里有更多最新内容哦😉
这篇文章是AIM 项目系列的续篇,所以如果您还没有阅读过之前的文章,我建议您先阅读之前的文章,以解答任何关于“如何做?”和“为什么做?”的问题。
本文将正式开始编写 AIM 语言!首先,我们将创建一个词法分析器(lexer)。词法分析器,或者如果你不喜欢这些酷炫的名字,也可以称之为分词器(tokenizer ),是一种将人类可读的文本转换为词元列表以便后续处理的工具。它不仅用于创建编程语言,还用于文本处理和其他各种用途。所以,需要注意的是,它的应用范围并不局限于创建编程语言。现在,请看下面的示例:
"128 + 428"
两个数字最基本的、极其简单的加法运算。现在让我们看看如何将其转换为标记的形式:
["128", "+", "428"]
词法单元不一定非得是字符串。例如,它们可以是包含额外元数据以供后续使用的对象。本教程将展示如何实现一个基本的词法分析器,用于在上述两种形式之间进行转换。
工具
当然,这类工具有很多库,甚至还有更大型的开发项目。最流行的包括moo和lex。甚至还有完整的工具包可以帮助你创建词法分析器和语法分析器,例如nearley和jison。此外,对于其他更专业的语言(例如 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() {}
}
让我们进一步研究这段样板代码,稍后再介绍所列方法的代码。
开头导入了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
首先,内部方法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;
}
// ...
loadDefinition()`and`函数loadGrammar()负责加载匹配GrammarStruct项,即将它们组合成单个匹配表达式。`loads`loadDefinition()函数加载单个匹配GrammarStruct项(匹配器定义),而loadGrammar()`loads` 函数加载匹配项数组(整个语法)。`loads`this函数返回一个数组,以便于链式调用(也适用于其他方法)。
// ...
public loadData(data: string) {
this.data += data;
return this;
}
// ...
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];
}
}
// ...
next()这种方法比之前的任何方法都稍微复杂一些。但它也没有什么神奇之处。它只是使用正则表达式匹配数据中的下一个标记,对其进行处理,并Token根据生成的数据(即位置、长度和ID)将新标记添加到列表中。此外,它还会检查是否存在换行符和空格(默认情况下已预定义了匹配器Lexer),并正确处理它们以计算每个标记的位置(行号和列号)。
// ...
public processAll() {
for (let i = 0; i < Infinity; i++) {
const token = this.next();
if (!token) break;
}
return this.tokens;
}
// ...
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;
}
// ...
update()是游戏中另一个重要的参与者。它以简洁高效的方式对标记数组进行排序和排列。首先,它会过滤掉空Token标记(即没有值的标记)。接下来,根据标记的位置进行排序。最后,它会映射标记,使它们从第 1 行第 1 列开始排列,这涉及到对换行符和空格的检查。此方法在大多数类方法中都有用到。
// ...
public empty() {
this.data = "";
this.line = 1;
this.column = 1;
this.index = 0;
this.tokens = [];
return this;
}
// ...
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() {}
}
首先,我们导入了一个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;
}
// ...
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;
}
// ...
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;
}
// ...
set()用于通过一次调用设置令牌的不同值。
// ...
public remove() {
this.value = undefined;
this.id = undefined;
this.line = undefined;
this.column = undefined;
this.length = undefined;
this.lexer.update();
}
// ...
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;
在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()
现在,我本可以就此结束这个故事,但还有一点需要注意。词法分析器(lexer)的作用仅仅是将线性文本处理成词元数组。而正确读取和处理这些词元则需要另一个工具——解析器(parser) ——来完成。与此问题密切相关的一点是我们语法中字符串的实现。这主要是因为我们想在 AIM 中创建类似 JavaScript 模板字面量的东西。如何用一个正则表达式匹配所有可能的情况,例如转义值、字符和锚点呢?
"text\$${value}text"
1
答案很简单:不行。也许对你们中的一些人来说,解决方案显而易见,但对我来说却需要一番深入思考(很可能是我不够开放)。你必须逐个字符地处理字符串(至少我是这么想的)。例如,看看我的语法定义数组的一部分。
[
// ...
{
id: "char",
match: `(?<=(?:(?:\\b|^)["'\`])|[\\x00-\\x7F])[\\x00-\\x7F](?=(?:[\\x00-\\x7F]+)?["'\`](?:\\b|$))`
},
// ...
// Anchors and brackets
{
id: "string_anchor",
match: "['`\"]"
}
// ...
]
我把字符串拆分成了锚点和字符。这样,当我把它和任何给定的字符串进行匹配时,就会得到许多不同的标记,包括id字符和……这完全没问题!之后我可以用解析器把它们处理成最终美观的抽象语法树(AST)形式。
这仅仅是个开始。
与语法分析器和编译器相比,词法分析器简直是小菜一碟。但确保所有环节都万无一失至关重要。只有地基稳固,塔楼才不会倒塌。话虽如此,我认为词法分析器的代码可能会有一些改动(主要是在编写语法分析器的时候),但核心思路应该不会改变。
再次提醒,如果您想查看完整代码,请访问AIM代码库。如果您想更密切地关注 AIM 的开发过程,请考虑收藏该代码库或在Twitter上关注我。💡
文章来源:https://dev.to/areknawo/lexing-in-js-style--b5e