Class: RedQuilt::Inline::EmphasisResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/red_quilt/inline/emphasis_resolver.rb

Overview

CommonMark emphasis algorithm (spec 6.2). Phase 2 of inline parsing: given the delimiter stack the linear pass collected (provisional TEXT nodes for each ‘*` / `_` / `~` run), it pairs openers with closers and rebuilds the arena subtree into EMPHASIS / STRONG / STRIKETHROUGH nodes.

Kept separate from Builder because it is a closed algorithm with a narrow interface: it only needs the arena, the set of still-provisional nodes (so consumed delimiters can be unmarked), and whether source spans are tracked. Builder owns the linear pass and bracket handling; it hands this resolver a delimiter stack to collapse.

Defined Under Namespace

Classes: Delimiter

Instance Method Summary collapse

Constructor Details

#initialize(arena, track_source:) ⇒ EmphasisResolver

Returns a new instance of EmphasisResolver.



22
23
24
25
# File 'lib/red_quilt/inline/emphasis_resolver.rb', line 22

def initialize(arena, track_source:)
  @arena = arena
  @track_source = track_source
end

Instance Method Details

#resolve(stack, provisional_nodes) ⇒ Object

Collapses ‘stack` (an Array of Delimiter) in place, removing consumed entries from `provisional_nodes`. Used both for the document-level stack and for the inner delimiters of a resolved link/image (see Builder#finalize_link).



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/red_quilt/inline/emphasis_resolver.rb', line 31

def resolve(stack, provisional_nodes)
  # NB: the CommonMark spec describes an `openers_bottom`
  # optimization keyed by closer character / length / flanking
  # flags. Implementing that correctly is subtle (a single
  # per-character bottom blocks valid matches like
  # `*foo**bar**baz*`), so the implementation here just walks
  # back to the start of the stack for every closer. This is
  # O(stack^2) in the worst case but stacks are tiny in practice.
  closer_idx = 0

  while closer_idx < stack.length
    closer = stack[closer_idx]
    unless closer.can_close
      closer_idx += 1
      next
    end

    opener_idx = closer_idx - 1
    found = false
    while opener_idx >= 0
      opener = stack[opener_idx]
      if opener.can_open && opener.char == closer.char
        skip = false
        if (opener.can_close || closer.can_open) &&
           ((opener.count + closer.count) % 3).zero? &&
           !((opener.count % 3).zero? && (closer.count % 3).zero?)
          skip = true
        end
        unless skip
          found = true
          break
        end
      end
      opener_idx -= 1
    end

    unless found
      unless closer.can_open
        provisional_nodes.delete(closer.node_id)
        stack.delete_at(closer_idx)
      end
      closer_idx += 1
      next
    end

    opener = stack[opener_idx]
    strength = [opener.count, closer.count].min >= 2 ? 2 : 1
    if closer.char == "~"
      # GFM strikethrough only forms on `~~` runs. A single `~`
      # leaves the delimiter as text; advance the cursor so future
      # `~~` pairs can still match.
      if strength < 2
        closer_idx += 1
        next
      end
      kind = NodeType::STRIKETHROUGH
    else
      kind = strength == 2 ? NodeType::STRONG : NodeType::EMPHASIS
    end

    # CommonMark spec: any delimiters strictly between this opener and
    # closer can't open or close anything in this scope, so drop them
    # from the stack before we rebuild the tree. Their arena nodes
    # stay where they are (they'll be reparented into the new emphasis
    # alongside the surrounding content), but they must no longer be
    # candidates for future iterations. Without this, the next
    # iteration would try to pair stranded delimiters that have
    # already been moved into a different parent, which corrupts the
    # sibling chain (Arena#reparent walks into @parent[-1]).
    if closer_idx > opener_idx + 1
      removed = stack.slice!((opener_idx + 1)...closer_idx)
      removed.each { |e| provisional_nodes.delete(e.node_id) }
      closer_idx = opener_idx + 1
      closer = stack[closer_idx]
    end

    opener_node = opener.node_id
    closer_node = closer.node_id

    if @track_source
      opener_match_start = @arena.source_end(opener_node) - strength
      closer_match_end = @arena.source_start(closer_node) + strength
    else
      opener_match_start = -1
      closer_match_end = 0
    end
    emphasis_id = add_node(kind, opener_match_start, closer_match_end)

    first_inside = @arena.raw_next_sibling_id(opener_node)
    last_inside = @arena.raw_prev_sibling_id(closer_node)
    if first_inside != -1 && last_inside != -1 &&
       first_inside != closer_node && last_inside != opener_node
      @arena.reparent(emphasis_id, first_inside, last_inside)
    end

    parent_id = @arena.raw_parent_id(opener_node)
    @arena.insert_before(parent_id, closer_node, emphasis_id)

    # Consume `strength` characters from the inner end of each
    # delimiter. The opener is trimmed on its right (trailing) end,
    # the closer on its left (leading) end; removing the opener from
    # the stack shifts the closer one slot left.
    closer_idx -= 1 if consume_delimiter(opener, opener_idx, stack, strength, provisional_nodes, from_start: false)
    consume_delimiter(closer, closer_idx, stack, strength, provisional_nodes, from_start: true)
  end

  stack.each { |e| provisional_nodes.delete(e.node_id) }
  stack.clear
end