一路解析:写一个自主托管的解析器

2021-04-23 04:27:25

我们在新的编程语言中致力于托管的内容之一是托管编译器。拥有自主主机编译器是(某些)编程语言的关键步骤:它表示语言足够舒适地舒适地实现自己。虽然这不是正确的语言(例如shell脚本),但对于Systems编程语言,这是我们的引导计划中的重要步骤。我们本周完成了自托管的ParserDesign,今天我将分享有关ItWorks如何以及它如何成为的细节。

这是为此语言实施的第三解析器。我们wrotea牺牲编译器原型预先帮助通知语言设计,第一个编译器为其解析器使用了yacc。使用YACC有助于ATFirst,因为当TheLanguage仍然经常频繁和深远的设计变化时,它使它在解析器上迭代的价格合理简单。另一个以YACC解析器开始的副作用是,当您在设计上时,它使得它非常容易表达正式的语法。这是一些Oure Oround Parser代码的偷看:

struct_type:t_struct' {' struct_fields'}' {$$。标志= 0; $$。Storage = type_struct; Allocfroom((void **)& $$。字段,& 3美元,尺寸(3美元)); } | t_union' {' struct_fields'}' {$$。标志= 0; $$。Storage = type_union; Allocfroom((void **)& $$。字段,& 3美元,尺寸(3美元)); }; struct_fields:struct_field | struct_field',' {$$ = $ 1; } | struct_field',' struct_fields {$$ = $ 1; Allocfroom((void **)& $$。下一个,& 3美元,尺寸(3美元)); }; struct_field:t_ident':'类型{$$。名称= 1美元; Allocfroom((void **)& $$。类型,& $ 3,sizeof($ 3)); $$。下一个= null; };

此方法您是否有写作已经几乎正式的语法inits itation的代码。如果我们删除C代码,我们得到以下内容:

struct_type:t_struct' {' struct_fields'}' | t_union' {' struct_fields'}' ; struct_fields:struct_field | struct_field',' | struct_field',' struct_fields; struct_field:t_ident':'类型 ;

这为我们提供了一个合理的清洁道路,可以为语言编写正式的语法(andspecification),这就是我们接下来所做的。

所有这些样本描述了结构类型。以下示例显示了在实际代码中的语法看起来像是 - 从“struct”一词开始,在最后一词上包括“}”。

为了养活我们的解析器令牌工作,我们还需要一个Lexer或词汇分析仪。这将一系列字符称为“struct”进入Asingle令牌,如在YACC代码中使用的t_struct。就像原始媒体一样使用YACC作为解析器发生器,我们也使用Lex作为lexergenerator。它只是一个正则表达式列表和令牌的令牌的名称,与正则表达式相匹配,加上一点额外的代码,以便将“1234”变成一个值为1234的int。我们的Lexer还保持线路和列向内的轨道它从输入文件中消耗了字符。

" struct" {_lineno();返回t_struct; }"联盟" {_lineno();返回t_union; }" {" {_lineno();返回' {&#39 ;; }"}" {_lineno();返回'}&#39 ;; } [A-ZA-Z] [A-ZA-Z0-9 _] * {_Lineno(); yylval.sval = strdup(yytext);返回t_identifier;}

在我们与我们的原型编译器中定居设计后,能够才能才能才能才能提供一些简单的测试程序,让我们了解我们的语言设计,将其删除,并写下规格,以及它,第二个编译器。这是新的编译器用c - 语言没有准备好ToSelf-Host - 并使用手写的递归下降解析器。

为了简化解析器,我们故意设计了一种无与伦比的LL(1)语法,意味着它(a)可以明确地解析输入的输入而不需要额外的文本,并且(b)只需要一个令牌的令牌。这使我们的ParserDesign更简单,这是语言设计的故意目标。滚动的Lexer略微复杂:它需要两个字符的oflokahead以区分“。”,“..”和“...”令牌。

我会在第二个解析器的设计上跳过深入,因为主机的摊类更有趣,无论如何都是一个非常类似的设计。让我们开始看看我们托管的Lexer。我们的Lexer用输入源(例如,文件)初始化它可以读取字符流。然后,每次想到令牌时,我们都会要求它读完下一个。它将读为多种字符,因为它需要明确识别下一个令牌,然后将其交给呼叫者。

令牌是****语法中最小的意义单位。词汇分析阶段通过将终端与incput文本匹配来处理UTF-8Source文件以生成令牌流。

令牌可以通过空白字符分隔,它被定义为“单极码 - 点U + 0009(水平标记),U + 000A(行馈送)和U + 0020(空间)。任何数量的空格字符都可以插入间位,无论是从后续代币歧视,还是为美学歧视。在词汇分析阶段丢弃该空格。

在一个令牌中,空白是有意义的。例如,脚轮文字令牌由两个引号&#34定义;包围任何字符字符。封闭的字符被认为是字符串文字令牌的一部分,并且没有丢弃其中的任何空格。

词汇分析过程从源fileInput中消耗Unicode字符,直到它耗尽,按顺序执行以下步骤:它须知,直到找到非空白空间字符,然后消耗即可消耗最长的字符序列一个令牌,并将其发出到令牌流。

有一些不同种类的令牌我们的Lexer需要处理:运营商,如“+”和“ - &#34 ;;关键词,如“结构”和“返回”;用户redigeIdentifiers,如变量名称;和常量,如字符串和numericliterals。

这样,我们的解析器不必处理空格,或区分“Integer”(Integer“(标识符)(标识符)或处理”$“的无效令牌。为了实际实现此行为,我们将从填充状态结构的初始化功能开始。

//为给定输入流初始化新的Lexer。这条道路是借来的。导出fn init(in:* io :: stream,path:str,标志:标志...)Lexer = {return lexer {in = in,path = path,loc =(1,1),un = void,rb = [void ...],}; };导出类型lexer = struct {in:* io :: stream,path:str,loc :( uint,uint),rb:[2](rune | io :: eof | void),};

//从Lexer返回下一个令牌。出口FN LEX(​​LEX:* LEXER)(令牌|错误); //一个单一词汇令牌,它代表的值,它在文件中的位置。导出类型令牌=(LTOK,值,位置); //令牌值,用于令牌,如' 1337' (整数)。导出类型值=(str | rune | i64 | U64 | F64 |空白); //源文件中的一个位置。导出类型位置= struct {path:str,行:uint,col:uint}; //一个词汇令牌课程。导出类型ltok = enum uint {下划线,中止,alloc,附加,as // ...继续... eof,};

这个想法是,当来电者需要另一个令牌时,他们会致电Lex,Andreceive令牌或错误。我们的Lex函数的目的是读出下一个字符并决定它可能是什么样的令牌,并且派遣到更具体的LEXING函数来处理每种情况。

出口FN LEX(​​LEX:* LEXER)(令牌|错误)= {LET LOC =位置{...};让RN:rune =匹配(nextw(lex)?){_:io :: eof =>返回(LTOK :: EOF,VOID,MKLOC(LEX)),RL :(符文,位置)=> {loc = rl .1; RL .0; },}; if(is_name(rn,false)){UNEGE(LEX,RN);返回lex_name(lex,loc,true); }; if(ascii :: isdigit(rn)){UNEGE(LEX,RN);返回lex_literal(Lex,LOC); };让tok:ltok = switch(rn){* =>返回syntaxerr(loc,"字符无效"),'"' ,' \'' => {UNET(LEX,RN);返回Lex_rn_str(Lex,LOC); },'。' ,'' ,'>' =>返回Lex3(Lex,Loc,RN),' ^' ,' *' ,'%' ,' /' ,' +' ,' - ' ,':' ,'!' ,'&' ,' |' ,' =' => {返回Lex2(Lex,LOC,RN); },'〜' => ltiok :: bnot,',' => LTOK ::逗号,' {' => ltiok :: lbace,' [' => ltiok :: lbracket,'(' => ltiok :: lparen,'}' ltiok :: rbrace,']' =&gt ; ltiok :: rbracket,')' => ltiok :: rppen,&#39 ;;' => ltiok ::分号,'?' => LTOK ::问题,};返回(tok,void,loc); };

除了eof案例之外,和简单的单个字符运算符,如“;”,这函数都处理自己,它的作用是向各种筹码发动机派遣工作。

fn nettw(lex:* lexer)((rune,location)| io :: eof | io ::错误)= {for(true){let loc = mkloc(lex);匹配(下一个(LEX)){E :( IO ::错误| IO :: EOF)=>返回e,r:rune => if(!ascii :: Isspace(r)){return(r,loc); }否则{免费(Lex.com); Lex.commert ="&#34 ;; },}; }; abort();};

FN UNGE(LEX:* LEXER,R:(rune | io :: eof))void = {if(!(lex.rb [0]是void)){assert(lex.rb [1]是void,&# 34; ungot太多符号"); lex.rb [1] = lex.rb [0]; }; lex.rb [0] = r;};

fn is_name(r:rune,num:bool)bool = ascii :: isalpha(r)|| r ==' _' || r ==' @' || (Num&& ASCII :: ISDIGIT(R));

子输出者处理更具体的情况。 Lex_name函数处理它们看起来像标识符的东西,包括关键字; Lex_Literal函数手柄看起来像文字(例如“1234”); Lex_rn_str Handles runeand字符串文字(例如,“Hello World”和'\ n');和Lex2和Lex3Respective处理像“&&&&&&”的两个和三个字符的运营商和“>> =”。

Lex_name是最复杂的。因为唯一一个从标识符到标识符的关键字是前者匹配字符串的特定列表,所以我们首先读取一个“名称”到缓冲区,然后从已知关键字列表中读取,以查看它是否与某些东西匹配。此,“BMAP”是一个预先排序的关键字名称数组。

const BMAP:[_] str = [//让我保持alpha排序和与LTOK枚举一致。 " _" ,"中止" ," alloc" ,"附录" ,"作为" ,"断言" ," bool" ,// ......]; fn lex_name(lex:* lexer,loc:location,locient:bool)(令牌)(错误)= {let buf = strio :: commynd();匹配(下一个(Lex)){R:rune => {assert(is_name(r,false)); Strio :: Appendrune(Buf,R); },_ :( io :: eof | io ::错误)=> abort(),//不变}; for(true)匹配(下一个(lex)?){_:io :: eof =>休息,r:rune => {if(!is_name(r,true)){UNEGE(LEX,R);休息 ; }; Strio :: Appendrune(Buf,R); },};让名称= strio :: finish(buf); if(!关键字){return(ltok :: name,name,loc); };返回匹配(排序::搜索(BMAP [relok :: last_keyword + 1],size(str),& name,& namecmp)){null => (ltok :: name,name,loc),v:* void => {推迟免费(姓名);让tok = v:uintptr - & BMAP [0]:UINTPTR; tok / = size(str):uintptr; (tok:ltok,void,loc); },}; };

其余的代码更像是相同的,但我在这里把它放在这里,如果你想读它。

让我们继续解析:我们需要将此一维TokensInto旋转为结构化表单:抽象语法树。考虑以下内容:

我们在每一步都知道各种令牌在每种情况下有效。在Wesee“让”之后,我们知道我们解析了一个绑定,所以我们查找名称(“x”)和冒号令牌,变量的类型,等于符号和初始化的表达式。要解析初始化程序,我们会看到一个标识符,“add2”,然后是一个打开的括号,所以我们知道我们处于调用表达式,我们可以才能启动解析参数。

为了使我们的解析器代码表达,并整理地处理错误,我们将映射几个辅助功能,让我们以解析器从Lexer的要求描述这些状态。我们有一些职能来完成:

//需要下一个令牌有一个匹配的LTOK。返回该令牌或错误。 fn want(lexer:* lex :: lexer,想要:lex :: ltiok ...)(Lex ::令牌|错误)= {令唱tok = lex :: lex(Lexer)? ; if(len(想要)== 0){return tok; }; for(让我= 0 z; i< len(想要); i + = 1){if(tok .0 == wants [i]){return tok; }; };让bef = strio ::动态();推迟IO ::关闭(BUF); for(让我= 0 z; i< len(想要); i + = 1){fmt :: fprintf(buf,"' {}"",Lex: :tokstr((想要[i],空白,mkloc(lexer))))))); if(i + 1< l; l lt; len(want)){fmt :: fprint(buf,&#34 ;,#34;); }; };返回syntaxerr(mkloc(lexer),"意外' {}' {}',lex :: tokstr(tok),strio :: string(buf)); }; //查找来自Lexer的匹配LTOK,如果不存在,则ONLEXSS //令牌并返回void。如果发现,令牌从Lexer消耗,并且返回。 fn try(lexer:* lex :: lexer,想要:lex :: ltiok ...)(Lex ::令牌|错误| void)= {et ketk = lex :: lex(Lexer)? ;断言(Len(想要)> 0); for(让我= 0 z; i< len(想要); i + = 1){if(tok .0 == wants [i]){return tok; }; }; Lex :: Unlex(Lexer,Tok); }; //查找来自Lexer的匹配LTOK,TENLEXES令牌,并返回//它;或者如果不是ltok,则禁用。 Fn Peek(Lexer:* Lex :: Lexer,想要:Lex :: LTOK ...)(Lex ::令牌|错误| void)= {令唱tok = lex :: lex(Lexer)? ; Lex :: Unlex(Lexer,Tok); if(len(想要)== 0){return tok; }; for(让我= 0 z; i< len(想要); i + = 1){if(tok .0 == wants [i]){return tok; }; }; };

假设我们正在寻找像我们的示例代码一样绑定以显示下一个。来自规范的CARMMAR如下:

fn绑定(Lexer:* Lex :: lexer)(AST :: expr |错误)= {const is_static:bool = try(lexer,ltok :: static)?是Lex ::令牌; const is_const = switch(想要(ltexer,ltok :: let,ltok :: const)?.0){ltok :: let => False,LTOK :: const =>真的 , };让绑定:[] AST :: BINDING = []; for(true){const name = wance(lexer,ltok :: name)? .1作为str; const btype:nullable * ast :: _type = if(尝试(lexer,ltok :: colon)?lex ::令牌){alloc(_type(lexer)?); }否定;想要(Lexer,LTOK ::等于)? ; const init = alloc(表达式(Lexer)?);附加(绑定,AST ::绑定{name = name,_type = btype,init = init,});匹配(尝试(lexer,ltok ::逗号)?){_:void =>休息,_:Lex ::令牌=>空白 , }; };返回AST :: binding_expr {is_static = is_static,is_const = is_const,绑定=绑定,}; };

希望这段规范的流程相当明显。目标是填补空调的AST结构:

//单个变量触点。例如:// // foo:int = bar导出类型绑定= struct {name:str,_type:nullable * _type,init:* expr,}; //一个变量绑定表达式。例如:// //让foo:int = bar,...导出类型binding_expr = struct {is_static:bool,is_const:bool,绑定:[]绑定,};

其余的代码非常相似,但语法的一些角落比其他角落都是毛茸茸的。一个示例是我们如何解析用于二进制表达式的INVIX运算符(例如“2 + 2”):

FN Binarithm(Lexer:* Lex :: Lexer,Lvalue :( ast :: expr | void),i:int,)(ast :: expr |错误)= {//优先级攀登parser // https:// en。 wikipedia.org/wiki/operator-precendence_parser让lvalue =匹配(lvalue){_:void =>演员(Lexer,空白)? ,expr:ast :: expr => expr,};让Tok = Lex :: Lex(Lexer)? ; for(让J =优先级(tok); j> = i; j =优先级(tok)){const op = binop_for_tok(tok);让Rvalue =施放(Lexer,void)? ; Tok = Lex :: Lex(Lexer)? ; for(让K =优先级(TOK); K> K =优先级(TOK)){Lex :: Unlex(Lexer,Tok); rvalue = binarithm(lexer,rvalue,k)? ; Tok = Lex :: Lex(Lexer)? ; };让expr = ast :: binarithm_expr {op = op,lvalue = alloc(lvalue),rvalue = alloc(rvalue),}; lvalue = expr; }; Lex :: Unlex(Lexer,Tok);返回lvalue; }; fn优先级(tok:lex ::令牌)int = switch(tok .0){ltok :: lor => 0,ltok :: lxor => 1,LTOK :: LAND => 2,LTOK :: Lequal,Ltiok :: Nequal => 3,ltok :: lell :: lesteq,ltok :: greater,ltiok :: greatereq => 4,LTOK :: Bor => 5,LTOK :: BXOR => 6,LTOK :: Band => 7,LTOK :: LSHIFT,LTOK :: Rshift => 8,LTOK :: Plus,LTOK :: minus => 9,ltok :: times,ltiok :: div,ltiok :: modulo => 10,* => - 1,};

我真的不在这个算法,说实话,但嘿,它有效。每当IWRITE一个优先攀爬解析器时,我将盯着维基百科页面15分钟,快速写一个解析器,然后立即忘记它是如何工作的。 Moteli有一天会写一篇关于它的博客文章。

无论如何,最终,这段代码生存在我们的标准库中,并用于致专用事物,包括我们(早期的开发)自主主机编译器。该模拟的一个例子是我们的文档生成器:

fn扫描(路径:str)(ast :: subonit |错误)= {const输入=匹配(OS ::打开(路径)){s:* io :: stream => s,err:fs :: error => FMT ::致命("错误读数{}:{}",path,fs :: strerror(err)),};推迟IO ::关闭(输入); Const Lexer = Lex :: init(输入,路径,Lex ::标志::评论);返回解析::亚基(& lexer)? ; };

//一个子单元,通常表示单个源文件。导出类型子单元= struct {imports:[]导入,depl:[] drev,};

非常直截了当!将其作为标准库的一部分,应该更容易地为用户构建语言感知的工具与luginiciteelf。我们还计划在STDLIB中拥有我们的类型检查器。这是我为来自Golang吸引启发的这个问题 - 在标准库中拥有大量的Hythoolchain组件使得它真的很容易写入感知工具。

所以,你有它:下一阶段在我们的语言开发。我希望你期待着它!