• 基于栈的HTML解析器
    • 小结

    基于栈的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