上一篇(《如何编写简单的parser(基础篇)》)中介绍了编写一个parser所需具有的基础知识,接下来,咱们要动手实践一个简单的
parser,既然是“简单”的parser,那么,咱们就要为这个parser划定范围,不然,完整的JavaScript语言parser的复杂度就不是那么简单的了。javascript
基于可以编写简单实用的JavaScript程序
和具有基础语法的解释能力
这两点考虑,咱们将parser的规则范围划分以下:html
若是用一句话来划分的话,即一个能解析包括声明、赋值、加减乘除、条件判断
的解析器。java
基于上一篇中介绍的JavaScript语言由词组(token)组成表达式(expression),由表达式组成语句(statement)的模式,咱们将parser划分为——负责解析词法的TokenSteam
模块,负责解析表达式和语句的Parser
,另外,负责记录读取代码位置的InputSteam
模块。node
这里,有两点须要进行说明:git
InputSteam负责读取和记录当前代码的位置,并把读取到的代码交给TokenSteam处理,其意义在于,当传递给TokenSteam的代码须要进行判读猜想时,可以记录当前读取的位置,并在接下来的操做汇总回滚到以前的读取位置,也能在发生语法错误时,准确指出错误发生在代码段的第几行第几个字符。github
该模块是功能最简洁的模块,咱们只需建立一个相似“流”的对象便可,其中主要包含如下几个方法:正则表达式
peek()
—— 阅读下一个代码,可是不会将当前读取位置迁移,主要用于存在不肯定性状况下的判读;next()
—— 阅读下一个代码,并移动读取位置到下一个代码,主要用于肯定性的语法读取;eof()
—— 判断是否到当前代码的结束部分;croak(msg)
—— 抛出读取代码的错误。接下来,咱们看一下这几个方法的实现:express
function InputStream(input) { var pos = 0, line = 1, col = 0; return { next : next, peek : peek, eof : eof, croak : croak, }; function next() { var ch = input.charAt(pos++); if (ch == "\n") line++, col = 0; else col++; return ch; } function peek() { return input.charAt(pos); } function eof() { return peek() == ""; } function croak(msg) { throw new Error(msg + " (" + line + ":" + col + ")"); } }
咱们依据一开始划定的规则范围 —— 一个能解析包括声明、赋值、加减乘除、条件判断
的解析器,来给TokenSteam划定词法解析的范围:segmentfault
变量声明 & 函数声明
:包含了变量、“var”关键字、“function”关键字、“{}”符号、“()”符号、“,”符号的识别;赋值操做
:包含了“=”操做符的识别;加减操做 & 乘除操做
:包含了“+”、“-”、“*”、“/”操做符的识别;if语句
:包含了“if”关键字的识别;字面量(毕竟没有字面量也没办法赋值)
:包括了数字字面量和字符串字面量。接下来,TokenSteam主要使用InputSteam读取并判读代码,将代码段解析为符合ECMAScript标准的词组流,返回的词组流大体以下:babel
{ type: "punc", value: "(" } // 符号,包含了()、{}、, { type: "num", value: 5 } // 数字字面量 { type: "str", value: "Hello World!" } // 字符串字面量 { type: "kw", value: "function" } // 关键字,包含了function、var、if { type: "var", value: "a" } // 标识符/变量 { type: "op", value: "!=" } // 操做符,包含+、-、*、/、=
其中,不包含空白符和注释,空白符用于分隔词组,对于已经解析了的词组流来讲并没有意义,至于注释,在咱们简单的parser中,就不须要解析注释来提升复杂度了。
有了须要判读的词组,咱们只需根据ECMAScript标准的定义,进行适当的简化,便能抽取出对应词组须要的判读规则,大体逻辑以下:
以上的,便是TokenSteam工做的主要逻辑了,咱们只需不断重复以上的判断,即能成功将一段代码,解析成为词组流了,将该逻辑整理为代码以下:
function read_next() { read_while(is_whitespace); if (input.eof()) return null; var ch = input.peek(); if (ch == '"') return read_string(); if (is_digit(ch)) return read_number(); if (is_id_start(ch)) return read_ident(); if (is_punc(ch)) return { type : "punc", value : input.next() }; if (is_op_char(ch)) return { type : "op", value : read_while(is_op_char) }; input.croak("Can't handle character: " + ch); }
主逻辑相似于一个分发器(dispatcher),识别了接下来可能的工做以后,便将工做分发给对应的处理函数如read_string、read_number等,处理完成后,便将返回结果吐出。
须要注意的是,咱们并不须要一次将全部代码所有解析完成,每次咱们只需将一个词组吐给parser模块进行处理便可,以免尚未解析完词组,就出现了parser的错误。
为了使你们更清晰的明确词法解析器的工做,咱们列出数字字面量的解析逻辑以下:
// 使用正则来判读数字 function is_digit(ch) { return /[0-9]/i.test(ch); } // 读取数字字面量 function read_number() { var has_dot = false; var number = read_while(function(ch){ if (ch == ".") { if (has_dot) return false; has_dot = true; return true; } return is_digit(ch); }); return { type: "num", value: parseFloat(number) }; }
其中read_while函数在主逻辑和数字字面量中都出现了,该函数主要负责读取符合格则的一系列代码,该函数的代码以下:
function read_while(predicate) { var str = ""; while (!input.eof() && predicate(input.peek())) str += input.next(); return str; }
最后,TokenSteam须要将解析的词组吐给Parser模块进行处理,咱们经过next()方法,将读取下一个词组的功能暴露给parser模块,另外,相似TokenSteam须要判读下一个代码的功能,parser模块在解析表达式和语句的时候,也须要经过下一个词组的类型来判读解析表达式和语句的类型,咱们将该方法也命名为peek()。
function TokenStream(input) { var current = null; function peek() { return current || (current = read_next()); } function next() { var tok = current; current = null; return tok || read_next(); } function eof() { return peek() == null; } // 主代码逻辑 function read_next() { //.... } // ... return { next : next, peek : peek, eof : eof, croak : input.croak }; }
在next()函数中,须要注意的是,由于有可能在以前的peek()判读中,已经调用read_next()来进行判读了,因此,须要用一个current变量来保存当前正在读的词组,以便在调用next()的时候,将其吐出。
最后,在Parser模块中,咱们对TokenSteam模块读取的词组进行解析,这里,咱们先讲一下最后Parser模块输出的内容,也就是上一篇当中讲到的抽象语法树(AST)
,这里,咱们依然参考babel-parser的AST语法标准,在该标准中,代码段都是被包裹在Program节点中的(其实也是大部分AST标准的模式),这也为咱们Parser模块的工做指明了方向,即自顶向下
的解析模式:
function parse_toplevel() { var prog = []; while (!input.eof()) { prog.push(parse_statement()); } return { type: "prog", prog: prog }; }
该parse_toplevel函数,便是Parser模块的主逻辑了,逻辑也很简单,代码段既然是有语句(statements)组成的,那么咱们就不停地将词组流解析为语句便可。
和TokenSteam相似的是,parse_statement也是一个相似于分发器(dispatcher)
的函数,咱们根据一个词组来判读接下来的工做:
function parse_statement() { if(is_punc(";")) skip_punc(";"); else if (is_punc("{")) return parse_block(); else if (is_kw("var")) return parse_var_statement(); else if (is_kw("if")) return parse_if_statement(); else if (is_kw("function")) return parse_func_statement(); else if (is_kw("return")) return parse_ret_statement(); else return parse_expression(); }
固然,这样的分发模式,也是只限定于咱们在最开始划定的规则范围,得益于规则范围小的优点,parse_statement函数的逻辑得以简化,另外,虽然语句(statements)
是由表达式(expressions)
组成的,可是,表达式(expression)
依然能单独存在于代码块中,因此,在parse_statement的最后,不符合全部语句条件的状况,咱们仍是以表达式进行解析。
在语句的解析中,咱们拿函数的的解析来做一个例子,依据AST标准的定义以及ECMAScript标准的定义,函数的解析规则变得很简单:
function parse_function(isExpression) { skip_kw("function"); return { type: isExpression?"FunctionExpression":"FunctionDeclaration", id: is_punc("(")?null:parse_identifier(), params: delimited("(", ")", ",", parse_identifier), body: parse_block() }; }
对于函数的定义:
关键字“function”
开头;()
”中,以“,
”间隔;在代码中,解析参数的函数delimited
是依据传入规则,在起始符与结束符之间,以间隔符隔断的代码段来进行解析的函数,其代码以下:
function delimited(start, stop, separator, parser) { var res = [], first = true; skip_punc(start); while (!input.eof()) { if (is_punc(stop)) break; if (first) first = false; else skip_punc(separator); if (is_punc(stop)) break; res.push(parser()); } skip_punc(stop); return res; }
至于函数体的解析,就比较简单了,由于函数体便是多段语句,和程序体的解析是一致的,ECMAScript标准的定义也很清晰:
function parse_block() { var body = []; skip_punc("{"); while (!is_punc("}")) { var sts = parse_statement() sts && body.push(sts); } skip_punc("}"); return { type: "BlockStatement", body: body } }
接下来,语句的解析能力具有了,该轮到解析表达式了,这部分,也是整个Parser比较难理解的一部分,这也是为何将这部分放到最后的缘由。由于在解析表达式的时候,会遇到一些不肯定
的过程,好比如下的代码:
(function(a){return a;})(a)
当咱们解析完成第一对“()
”中的函数表达式后,若是此时直接返回一个函数表达式,那么后面的一对括号,则会被解析为单独的标识符。显然这样的解析模式是不符合
JavaScript语言的解析模式的,这时,每每咱们须要在解析完一个表达式后,继续日后进行尝试性的解析。这一点,在parse_atom
和parse_expression
中都有所体现。
回到正题,parse_atom
也是一个分发器(dispatcher)
,主要负责表达式层面上的解析分发,主要逻辑以下:
function parse_atom() { return maybe_call(function(){ if (is_punc("(")) { input.next(); var exp = parse_expression(); skip_punc(")"); return exp; } if (is_kw("function")) return parse_function(true) var tok = input.next(); if (tok.type == "var" || tok.type == "num" || tok.type == "str") return tok; unexpected(); }); }
该函数一开头即是以一个猜想性的maybe_call函数开头,正如上咱们解释的缘由,maybe_call主要是对于调用表达式的一个猜想,一会咱们在来看这个maybe_call的实现。parse_atom识别了位于“()”符号中的表达式、函数表达式、标识符、数字和字符串字面量,若都不符合以上要求,则会抛出一个语法错误。
parse_expression的实现,主要处理了咱们在最开始规则中定义的加减乘除操做
的规则,具体实现以下:
function parse_expression() { return maybe_call(function(){ return maybe_binary(parse_atom(), 0); }); }
这里又出现了一个maybe_binary
的函数,该函数主要处理了加减乘除
的操做,这里看到maybe
开头,便能知道,这里也有不肯定的判断因素,因此,接下来,咱们统一讲一下这些maybe开头的函数。
这些以maybe
开头的函数,如咱们以上讲的,为了处理表达式的不肯定性
,须要向表达式后续的语法进行试探性的解析
。
maybe_call
函数的处理很是简单,它接收一个用于解析当前表达式的函数,并对该表达式后续词组进行判读,若是后续词组是一个“(
”符号词组,那么该表达式必定是一个调用表达式(CallExpression)
,那么,咱们就将其交给parse_call函数
来进行处理,这里,咱们又用到以前分隔解析的函数delimited
。
// 推测表达式是否为调用表达式 function maybe_call(expr) { expr = expr(); return is_punc("(") ? parse_call(expr) : expr; } // 解析调用表达式 function parse_call(func) { return { type: "call", func: func, args: delimited("(", ")", ",", parse_expression), }; }
因为解析加、减、乘、除
操做时,涉及到不一样操做符的优先级,不能使用正常的从左至右进行解析,使用了一种二元表达式
的模式进行解析,一个二元表达式包含了一个左值
,一个右值
,一个操做符
,其中,左右值能够为其余的表达式,在后续的解析中,咱们就能根据操做符的优先级
,来决定二元的树状结构,而二元的树状结构,就决定了操做的优先级,具体的优先级和maybe_binary
的代码以下:
// 操做符的优先级,值越大,优先级越高 var PRECEDENCE = { "=": 1, "||": 2, "&&": 3, "<": 7, ">": 7, "<=": 7, ">=": 7, "==": 7, "!=": 7, "+": 10, "-": 10, "*": 20, "/": 20, "%": 20, }; // 推测是不是二元表达式,即看该左值接下来是不是操做符 function maybe_binary(left, my_prec) { var tok = is_op(); if (tok) { var his_prec = PRECEDENCE[tok.value]; if (his_prec > my_prec) { input.next(); return maybe_binary({ type : tok.value == "=" ? "assign" : "binary", operator : tok.value, left : left, right : maybe_binary(parse_atom(), his_prec) }, my_prec); } } return left; }
须要注意的是,maybe_binary
是一个递归
处理的函数,在返回以前,须要将当前的表达式以当前操做符的优先级进行二元表达式的解析,以便包含在另外一个优先级较高的二元表达式中。
为了让你们更方便理解二元的树状结构如何决定优先级,这里举两个例子:
// 表达式一 1+2*3 // 表达式二 1*2+3
这两段加法乘法表达式使用上面的方法解析后,分别获得以下的AST:
// 表达式一 { type : "binary", operator : "+", left : 1, right : { type: "binary", operator: "*", left: 2, // 这里简化了左右值的结构 right: 3 } } // 表达式二 { type : "binary", operator : "+", left : { type : "binary", operator : "*", left : 1, right : 2 }, right : 3 }
能够看到,通过优先级的处理后,优先级较为低的操做都被处理到了外层,而优先级高的部分,则被处理到了内部,若是你还感到迷惑的话,能够试着本身拿几个表达式进行处理,而后一步一步的追踪代码的执行过程,便能明白了。
其实,说到底,简单的parser复杂度远比完整版的parser低不少,若是想要更进一步的话,能够尝试去阅读babel-parser的源码,相信,有了这两篇文章的铺垫,babel的源码阅读起来也会轻松很多。另外,在文章的最后,附上该篇文章的demo。
几篇能够参考的原文,推荐大伙看看:
标准以及文献: