本章节对应仓库
1.词法分析 Github
前言
其实本章所讲的词法分析不是严格意义上的词法分析,因为Sprache库可以通过组合的方式将词法分析与语法分析拼装在一起
词法分析器
新建一个类,命名为Lexer,内容如下:
namespace SwordScript;
/// <summary>
/// 词法分析器
/// </summary>
public static class Lexer
{
}
注:若提示不支持的语法,请参照第二章,将目标语言设置为C#10以上版本
标识符解析
构建解析器
通过第一章可知,标识符的定义为:以Unicode字符或下划线开头、包含Unicode字符、下划线、数字的非保留字符
因此,我们可以根据此定义,写出匹配的正则表达式:[_\p{L}][0-9_\p{L}]*
- 正则拆解:
_下划线\p{L}Unicode字符,包括英文字母、中日俄等语言文字[_\p{L}]Unicode字符或下划线[0-9_\p{L}]Unicode字符、下划线或数字[0-9_\p{L}]*任意个Unicode字符、下划线或数字
有了正则表达式,我们就可以使用正则表达式来匹配对应的文字作为标识符。
在本篇文章中,我们将使用Sprache库解析词法和语法。入门一个库最好的方法自然是阅读库中的相关文章,当然,在本篇文章中也会对使用到的语法进行一定的讲解。
在Sprache库里,要创建一个解析器,可以从Parse静态类开始,该类方法返回Parser<T>类型的委托。T为解析器解析后返回的类型。
使用Sprache构建的标识符解析器如下:
public static readonly Parser<string> Identifier = Parse.Regex(@"[_\p{L}][0-9_\p{L}]*").Token();
Parse.Regex返回匹配符合正则语法的字符串的解析器,.Token()会在解析时进行剔除前后空格的操作。
此时,我们已经得到了一个解析器。通过对解析器调用.Parse(string input)扩展方法便可以调用解析器生成解析结果。
单元测试
打开Tests项目,使用Nuget导入Sprache包。然后新建LexerText类,内容如下:
using NUnit.Framework;
using Sprache;
using SwordScript;
namespace Tests;
public class LexerText
{
[Test]
public void Identifier()
{
Assert.AreEqual("abc", Lexer.Identifier.Parse(" abc "));
Assert.AreEqual("_abc", Lexer.Identifier.Parse(" _abc "));
Assert.AreEqual("_abc123", Lexer.Identifier.Parse(" _abc123 "));
Assert.AreEqual("变量", Lexer.Identifier.Parse(" 变量 "));
Assert.AreEqual("变量123", Lexer.Identifier.Parse(" 变量123 "));
Assert.Catch<ParseException>(() => Lexer.Identifier.Parse(" "));
Assert.Catch<ParseException>(() => Lexer.Identifier.Parse(" 123 "));
Assert.Catch<ParseException>(() => Lexer.Identifier.Parse(" 123abc "));
}
}
运行单元测试,全部通过。
整数解析
构建解析器
相比标识符,整数的解析就简单的多。我们可以认为,一连串的数字在一起,中间没有其他字符,便是整数。
使用Sprache构建的整数解析器如下:
public static readonly Parser<long> LongInteger = Parse.Chars("0123456789").AtLeastOnce().Text().Select(long.Parse).Token();
Parse.Chars匹配符合包含字符的全部字符,.AtLeastOnce()代表匹配1个或多个,.Text()会将字符组合成字符串,.Select(long.Parse)会调用委托函数,试图将字符串转换为long类型,.Token()含义如上,匹配前剔除前后空格。
单元测试
在LexerText类中,新增关于整数解析的测试函数
[Test]
public void LongInteger()
{
Assert.AreEqual(0L, Lexer.LongInteger.Parse(" 0 "));
Assert.AreEqual(1L, Lexer.LongInteger.Parse(" 1 "));
Assert.AreEqual(123L, Lexer.LongInteger.Parse(" 123 "));
Assert.AreEqual(123456789L, Lexer.LongInteger.Parse(" 123456789 "));
Assert.AreEqual(1234567890123456789L, Lexer.LongInteger.Parse(" 1234567890123456789 "));
Assert.Catch<ParseException>(() => Lexer.LongInteger.Parse(" "));
Assert.Catch<ParseException>(() => Lexer.LongInteger.Parse(" abc "));
}
运行单元测试,全部通过。
为什么不解析负数?
有细心的读者会注意到,在此处我们没有对负数进行解析。这是因为负数的解析会留到表达式解析中一并进行。
如果此处提前进行了负号解析,那么在表达式解析中就有可能出现连续解析多个负号的情况。
浮点解析
在我们的定义中,浮点数的定义也清晰明了:数字(可省略).数字便是浮点数。
于是浮点解析器如下:
public static readonly Parser<double> DoubleFloat =
(from integer in Parse.Chars("0123456789").Many().Text()
from dot in Parse.Char('.')
from fraction in Parse.Chars("0123456789").AtLeastOnce().Text()
select double.Parse($"{integer}.{fraction}")).Token();
看到剧增的代码量,或许有读者会感到有种小学数学到高数的窒息感,别慌,这其实是Sprache库里使用Linq构建连续匹配的解析器的方式。
开头的from xxx in Parser是从解析器中获取解析结果
.Many()匹配0次或多次结果
而后续的from xxx in Parser,则是SelectMany的语法糖,接连的解析内容。
最后的select double.Parse($"{integer}.{fraction}"),则是将解析结果拼成字符串并使用double.parse转换为数字。
不过实际上,以上解析器其实也可以用对应的正则表达式替换:
public static readonly Parser<double> DoubleFloat = Parse.Regex(@"[0-9]*.[0-9]+").Text().Select(double.Parse).Token();
效果是完全一致的。
单元测试
在LexerText类中,新增关于浮点解析的测试函数
[Test]
public void DoubleFloat()
{
Assert.AreEqual(0.0, Lexer.DoubleFloat.Parse(" 0.0 "), 0.00001);
Assert.AreEqual(0.5, Lexer.DoubleFloat.Parse(" 0.5 "), 0.00001);
Assert.AreEqual(.5, Lexer.DoubleFloat.Parse(" .5 "), 0.00001);
Assert.AreEqual(1.0, Lexer.DoubleFloat.Parse(" 1.0 "), 0.00001);
Assert.AreEqual(10.01, Lexer.DoubleFloat.Parse(" 10.01 "), 0.00001);
Assert.AreEqual(9999.9999, Lexer.DoubleFloat.Parse(" 9999.9999 "), 0.00001);
}
运行单元测试,全部通过。
字符串解析
字符串的定义是基础类型之中较为复杂的
根据第一章的定义,可以得出我们需要匹配引号之间的字符。且第二个引号之前不能有\号作为转义。
因此,可以写出如下正则表达式:"(\\"|[^"])*"
- 正则拆解:
[^"]除引号以外的字符\\"以\开头的"符号(\\"|[^"])*任意个除引号以外,以\开头的"符号
解析器如下:
public static readonly Parser<string> String =
Parse.Regex(@"""(\""|[^""])*""")
.Select(s => s.Substring(1, s.Length - 2)
.Replace(@"""", @"""")
.Replace(@"\", @"")
.Replace(@"\n", "\n")).Token();
此部分的知识点与标识符部分相似
需要注意的是,在C#中,由@开头的字符串代表无视转义字符,但是引号则需要用两个连续引号表达。
单元测试
在LexerText类中,新增关于字符串解析的测试函数
[Test]
public void String()
{
Assert.AreEqual("", Lexer.String.Parse(@" """" "));
Assert.AreEqual("abc", Lexer.String.Parse(@" ""abc"" "));
Assert.AreEqual("abc\ndef", Lexer.String.Parse(@" ""abc\ndef"" "));
Assert.AreEqual("abc\ndef\n", Lexer.String.Parse(@" ""abc\ndef\n"" "));
Assert.AreEqual("你好,世界", Lexer.String.Parse(@" ""你好,世界"" "));
}
运行单元测试,全部通过。
布尔值
布尔值的定义较为简单,但是其涉及了一个新的概念:保留词
在词法分析中,如果遇到了true与false两个词,那么在进行词法分析时,很有可能被标识符解析器当做标识符进行误解析。因此,在定义布尔值解析器前,需要先对标识符解析进行改造。
新建一个类,命名Words,在其中我们定义true与false。
namespace SwordScript;
public static class Words
{
public static readonly string[] ALL_RESERVED_WORDS = new[]
{
BOOLEAN_TRUE,
BOOLEAN_FALSE,
};
public const string BOOLEAN_TRUE = "true";
public const string BOOLEAN_FALSE = "false";
}
为标识符解析器添加保留词判断:
public static readonly Parser<string> Identifier = Parse.Regex(@"[_\p{L}][0-9_\p{L}]*")
.Where(t => !Words.ALL_RESERVED_WORDS.Contains(t)).Token();
注:.Contains(t)方法来自命名空间System.Linq
接下来,我们再在Lexer类中添加布尔类型解析器:
public static readonly Parser<bool> Boolean = Parse.String(Words.BOOLEAN_TRUE)
.Or(Parse.String(Words.BOOLEAN_FALSE))
.Text()
.Select(s => s == Words.BOOLEAN_TRUE)
.Token();
单元测试
[Test]
public void Boolean()
{
Assert.AreEqual(true, Lexer.Boolean.Parse(" true "));
Assert.AreEqual(false, Lexer.Boolean.Parse(" false "));
Assert.Catch<ParseException>(() => Lexer.Boolean.Parse(" "));
}
同时为标识符添加新的测试样例:
Assert.Catch<ParseException>(() => Lexer.Identifier.Parse(" true "));
Assert.Catch<ParseException>(() => Lexer.Identifier.Parse(" false "));
运行单元测试,全部通过。
空值解析
空值的解析与布尔值解析类似,为保留词数组添加null,随后添加对应的解析器即可。
Words.cs
public const string NULL = "null";
Lexer.cs
public static readonly Parser<object> Null = Parse.String(Words.NULL).Return<IEnumerable<char>,object>(null).Token();
单元测试
[Test]
public void Null()
{
Assert.AreEqual(null, Lexer.Null.Parse(" null "));
Assert.Catch<ParseException>(() => Lexer.Null.Parse(" "));
}
运行单元测试,全部通过。
注释解析
在解析完基本类型后,我们便可以在词法分析阶段进行注释的处理了。
通过观察前面的解析器,可以发现所有解析器都有一个Token功能,Token的作用是去除标识符前后的空格,同时,可以发现注释的作用,在代码中也等同于空格
因此,我们可以定义如下注释解析器:
public static readonly CommentParser Comment = new CommentParser();
public static Parser<T> SuperToken<T>(this Parser<T> parser)
{
return from leftComment in Comment.AnyComment.Token().Many()
from token in parser.Token()
from rightComment in Comment.AnyComment.Token().Many()
select token;
}
CommentParser是Sprache里提供的一个工具类,用于生成匹配注释的解析器。在不填入任何参数的情况下,默认生成C风格的注释解析器。
SuperToken是一个扩展方法,用处是将目标解析器左右侧的任意数量个注释去除掉。
随后,把其他解析器的.Token()替换为.SuperToken()即可。
单元测试
[Test]
public void Comment()
{
Assert.AreEqual("abc", Lexer.Identifier.Parse(" abc //abc "));
Assert.AreEqual(123L, Lexer.LongInteger.Parse(" 123 //123 "));
Assert.AreEqual(.5, Lexer.DoubleFloat.Parse(" .5 /* .6 */"), 0.00001);
Assert.AreEqual("abc", Lexer.String.Parse(@" /*""abc""*/ ""abc"" "));
Assert.AreEqual(true, Lexer.Boolean.Parse(" true //false "));
Assert.Catch<ParseException>(() => Lexer.Boolean.Parse(" //true "));
Assert.AreEqual("/*abc*/", Lexer.String.Parse(@" /*""abc""*/ ""/*abc*/"" "));
Assert.AreEqual("abc", Lexer.Identifier.Parse(" /* */ /* */ abc //abc "));
}
运行单元测试,全部通过。
- 注:若出现以下错误提示
System.TypeInitializationException : The type initializer for 'SwordScript.Lexer' threw an exception.
----> System.NullReferenceException : Object reference not set to an instance of an object.
这是因为初始化顺序错误,Comment没有先初始化所导致的。
请把public static readonly CommentParser Comment = new CommentParser();语句放到Lexer类开头。
结语
此时,我们已经完成了基础类型和标识符的词法分析。可以发现,只要能清晰构建出需要获取的字词类型,就能使用Sprache库写出我们需要的解析器。
这为下一章将开始的语法分析打好了基础。