- 基于栈的HTML解析器
- 小结
- 小结
基于栈的HTML解析器
点渐渐连成了线
作者:@nixzhu
在前一篇解析器组合子之后,我对它算是入了门。最近同事想写一个HTML到Attributed String的转换器,但用第三方库生成的中间数据结构不能满足要求,因此我提议用解析器组合子来做这一步,我以为一个晚上就能搞定。
结果很明显,没有成功。原因是我实现的的解析器组合子还太弱,不能处理左递归(在解析JSON时这刚好不是一个问题)。
处理左递归并不是一件简单的事(至少对我来讲),因此睡了一觉之后,我想到了第二个方案:基于栈的解析。
简单来说,HTML是一些配对标签包裹着字符串或其它配对标签的序列。在解析的过程中,判断当前Token的表意,来决定是否将其压入栈中,或者从栈中取出对应的Token来合成“值”并重新压入栈中。最后,我们将栈中的数据变成一个序列即可。
如果我们只关注特定的一些标签,例如加粗、斜体、段落、链接。那么Token的定义可如下:
enum Token {case plainText(string: String)case beginBoldTagcase endBoldTagcase beginItalicTagcase endItalicTagcase beginParagraphTagcase endParagraphTagcase beginAnchorTag(href: String)case endAnchorTag}
解析器组合子并非一无是处,在没有左递归的场合,它依然能工作得很好。比如生成Token串的这一步。
例如解析纯文本:
let plainText: Parser<Token> = {let letter = satisfy({ $0 != "<" && $0 != ">" })let string = map(many1(letter)) { String($0) }return map(string) { .plainText(string: $0) }}()
解析加粗开始标签:
let beginBoldTag: Parser<Token> = map(word("<b>")) { _ in .beginBoldTag }
等等,都很容易写出。
然后利用这些小的解析器,我们就可以做tokenize了:
func tokenize(_ htmlString: String) -> [Token] {var tokens: [Token] = []var remainder = htmlString.characterslet parsers = [plainText,beginBoldTag,endBoldTag,beginItalicTag,endItalicTag,beginParagraphTag,endParagraphTag,beginAnchorTag,endAnchorTag]while true {guard !remainder.isEmpty else { break }let remainderLength = remainder.countfor parser in parsers {if let (token, newRemainder) = parser(remainder) {tokens.append(token)remainder = newRemainder}}let newRemainderLength = remainder.countguard newRemainderLength < remainderLength else {break}}return tokens}
接下来该做解析了,先定义解析的目标,这与我们之前对(简化的)HTML的分析一致:
indirect enum Value {case plainText(string: String)case boldTag(value: Value)case italicTag(value: Value)case paragraphTag(value: Value)case anchorTag(href: String, value: Value)case sequence(values: [Value])}
不过需要考虑的是,我们压入栈中的元素可能是Token,也可能是Value,但我们也知道Swift的Array只能装同一种类型的数据。因此我们用enum包装一下:
enum Element {case token(Token)case value(Value)}
这样,Stack就很容易实现了:
class Stack {var array: [Element] = []func push(_ element: Element) {array.append(element)}func pop() -> Element? {guard !array.isEmpty else { return nil }return array.removeLast()}}
最后,我们编写解析函数:
func parse(_ tokens: [Token]) -> Value {let stack = Stack() // # 1var next = 0func _parse() -> Bool {guard next < tokens.count else { // # 3return false}let token = tokens[next]switch token {case .plainText(let string): // # 4stack.push(.value(.plainText(string: string)))case .beginBoldTag: // # 5stack.push(.token(.beginBoldTag))case .endBoldTag: // # 6var elements: [Element] = []while let element = stack.pop() {if case .token(let value) = element {if case .beginBoldTag = value {break}}elements.append(element)}if elements.count == 1 { // # 7let element = elements[0]if let value = element.value {stack.push(.value(.boldTag(value: value)))} else {print("todo: \(elements)")}} else {stack.push(.value(.boldTag(value: .sequence(values: elements.reversed().map({ $0.value }).flatMap({ $0 })))))}case .beginItalicTag:stack.push(.token(.beginItalicTag))case .endItalicTag:// ...case .beginParagraphTag:stack.push(.token(.beginParagraphTag))case .endParagraphTag:// ...case .beginAnchorTag(let href):stack.push(.token(.beginAnchorTag(href: href)))case .endAnchorTag:// ...}return true}while true { // # 2if !_parse() {break}next += 1}return .sequence(values: stack.array.map({ $0.value }).flatMap({ $0 })) // # 8}
我将类似的部分删除了,简单说明一下:
- 先创建一个栈,定义next指针,它用来从tokens数组中取下一个token;
- 然后我们不停地调用
_parse()函数,当然每次都要增加next,同时利用_parse()的返回值来做循环的退出; - 在
_parse()中,确保next不越界(刚好做退出条件),然后判断当前的token; - 遇到plainText,直接压入栈中;
- 遇到开始标签,也压入栈中;
- 遇到结束标签,就从栈中取出对应到此结束标签的开始标签及之间的所有元素;
- 通过判断元素的个数,我们决定是将其变成某个特定标签的Value再压入栈中,还是将其变为sequence(注意顺序)作为对应标签的value;
- 最后将栈中的所有元素作为sequence变成Value返回。
大概的逻辑就这么多。如果要解析更多标签,可对应增加Token和Value的case以及解析判断。
你可在此处获取完整的Playground代码。
小结
写解析器是一项很好的编程活动,除了能提高代码编写技术(将多种知识结合起来),也能丰富我们的思考方式。事实上,我认为与编译器相关的技术,例如解析器、自动机、中间表示等都是很实在的技术,日常学习或重温都很有好处。同样,作为程序员,也要时时温习数据结构与算法。
欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog
