基于栈的HTML解析器

点渐渐连成了线

作者:@nixzhu


在前一篇解析器组合子之后,我对它算是入了门。最近同事想写一个HTML到Attributed String的转换器,但用第三方库生成的中间数据结构不能满足要求,因此我提议用解析器组合子来做这一步,我以为一个晚上就能搞定。

结果很明显,没有成功。原因是我实现的的解析器组合子还太弱,不能处理左递归(在解析JSON时这刚好不是一个问题)。

处理左递归并不是一件简单的事(至少对我来讲),因此睡了一觉之后,我想到了第二个方案:基于栈的解析。

简单来说,HTML是一些配对标签包裹着字符串或其它配对标签的序列。在解析的过程中,判断当前Token的表意,来决定是否将其压入栈中,或者从栈中取出对应的Token来合成“值”并重新压入栈中。最后,我们将栈中的数据变成一个序列即可。

如果我们只关注特定的一些标签,例如加粗、斜体、段落、链接。那么Token的定义可如下:

  1. enum Token {
  2. case plainText(string: String)
  3. case beginBoldTag
  4. case endBoldTag
  5. case beginItalicTag
  6. case endItalicTag
  7. case beginParagraphTag
  8. case endParagraphTag
  9. case beginAnchorTag(href: String)
  10. case endAnchorTag
  11. }

解析器组合子并非一无是处,在没有左递归的场合,它依然能工作得很好。比如生成Token串的这一步。

例如解析纯文本:

  1. let plainText: Parser<Token> = {
  2. let letter = satisfy({ $0 != "<" && $0 != ">" })
  3. let string = map(many1(letter)) { String($0) }
  4. return map(string) { .plainText(string: $0) }
  5. }()

解析加粗开始标签:

  1. let beginBoldTag: Parser<Token> = map(word("<b>")) { _ in .beginBoldTag }

等等,都很容易写出。

然后利用这些小的解析器,我们就可以做tokenize了:

  1. func tokenize(_ htmlString: String) -> [Token] {
  2. var tokens: [Token] = []
  3. var remainder = htmlString.characters
  4. let parsers = [
  5. plainText,
  6. beginBoldTag,
  7. endBoldTag,
  8. beginItalicTag,
  9. endItalicTag,
  10. beginParagraphTag,
  11. endParagraphTag,
  12. beginAnchorTag,
  13. endAnchorTag
  14. ]
  15. while true {
  16. guard !remainder.isEmpty else { break }
  17. let remainderLength = remainder.count
  18. for parser in parsers {
  19. if let (token, newRemainder) = parser(remainder) {
  20. tokens.append(token)
  21. remainder = newRemainder
  22. }
  23. }
  24. let newRemainderLength = remainder.count
  25. guard newRemainderLength < remainderLength else {
  26. break
  27. }
  28. }
  29. return tokens
  30. }

接下来该做解析了,先定义解析的目标,这与我们之前对(简化的)HTML的分析一致:

  1. indirect enum Value {
  2. case plainText(string: String)
  3. case boldTag(value: Value)
  4. case italicTag(value: Value)
  5. case paragraphTag(value: Value)
  6. case anchorTag(href: String, value: Value)
  7. case sequence(values: [Value])
  8. }

不过需要考虑的是,我们压入栈中的元素可能是Token,也可能是Value,但我们也知道Swift的Array只能装同一种类型的数据。因此我们用enum包装一下:

  1. enum Element {
  2. case token(Token)
  3. case value(Value)
  4. }

这样,Stack就很容易实现了:

  1. class Stack {
  2. var array: [Element] = []
  3. func push(_ element: Element) {
  4. array.append(element)
  5. }
  6. func pop() -> Element? {
  7. guard !array.isEmpty else { return nil }
  8. return array.removeLast()
  9. }
  10. }

最后,我们编写解析函数:

  1. func parse(_ tokens: [Token]) -> Value {
  2. let stack = Stack() // # 1
  3. var next = 0
  4. func _parse() -> Bool {
  5. guard next < tokens.count else { // # 3
  6. return false
  7. }
  8. let token = tokens[next]
  9. switch token {
  10. case .plainText(let string): // # 4
  11. stack.push(.value(.plainText(string: string)))
  12. case .beginBoldTag: // # 5
  13. stack.push(.token(.beginBoldTag))
  14. case .endBoldTag: // # 6
  15. var elements: [Element] = []
  16. while let element = stack.pop() {
  17. if case .token(let value) = element {
  18. if case .beginBoldTag = value {
  19. break
  20. }
  21. }
  22. elements.append(element)
  23. }
  24. if elements.count == 1 { // # 7
  25. let element = elements[0]
  26. if let value = element.value {
  27. stack.push(.value(.boldTag(value: value)))
  28. } else {
  29. print("todo: \(elements)")
  30. }
  31. } else {
  32. stack.push(.value(.boldTag(value: .sequence(values: elements.reversed().map({ $0.value }).flatMap({ $0 })))))
  33. }
  34. case .beginItalicTag:
  35. stack.push(.token(.beginItalicTag))
  36. case .endItalicTag:
  37. // ...
  38. case .beginParagraphTag:
  39. stack.push(.token(.beginParagraphTag))
  40. case .endParagraphTag:
  41. // ...
  42. case .beginAnchorTag(let href):
  43. stack.push(.token(.beginAnchorTag(href: href)))
  44. case .endAnchorTag:
  45. // ...
  46. }
  47. return true
  48. }
  49. while true { // # 2
  50. if !_parse() {
  51. break
  52. }
  53. next += 1
  54. }
  55. return .sequence(values: stack.array.map({ $0.value }).flatMap({ $0 })) // # 8
  56. }

我将类似的部分删除了,简单说明一下:

  1. 先创建一个栈,定义next指针,它用来从tokens数组中取下一个token;
  2. 然后我们不停地调用_parse()函数,当然每次都要增加next,同时利用_parse()的返回值来做循环的退出;
  3. _parse()中,确保next不越界(刚好做退出条件),然后判断当前的token;
  4. 遇到plainText,直接压入栈中;
  5. 遇到开始标签,也压入栈中;
  6. 遇到结束标签,就从栈中取出对应到此结束标签的开始标签及之间的所有元素;
  7. 通过判断元素的个数,我们决定是将其变成某个特定标签的Value再压入栈中,还是将其变为sequence(注意顺序)作为对应标签的value;
  8. 最后将栈中的所有元素作为sequence变成Value返回。

大概的逻辑就这么多。如果要解析更多标签,可对应增加Token和Value的case以及解析判断。

你可在此处获取完整的Playground代码。

小结

写解析器是一项很好的编程活动,除了能提高代码编写技术(将多种知识结合起来),也能丰富我们的思考方式。事实上,我认为与编译器相关的技术,例如解析器、自动机、中间表示等都是很实在的技术,日常学习或重温都很有好处。同样,作为程序员,也要时时温习数据结构与算法。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog