Module: MilkTea::Lexer::Heredocs

Included in:
MilkTea::Lexer
Defined in:
lib/milk_tea/core/lexer/heredocs.rb

Overview

Heredoc lexing (<<-TAG, c<<-TAG, f<<-TAG) including content dedenting, terminator detection, and recovery.

Instance Method Summary collapse

Instance Method Details

#dedent_heredoc_content(raw_lines) ⇒ Object



182
183
184
185
186
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 182

def dedent_heredoc_content(raw_lines)
  dedent_heredoc_lines(raw_lines).each_with_index.map do |text, index|
    text + (raw_lines[index].end_with?("\n") ? "\n" : "")
  end.join
end

#dedent_heredoc_lines(raw_lines) ⇒ Object



188
189
190
191
192
193
194
195
196
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 188

def dedent_heredoc_lines(raw_lines)
  margin = heredoc_content_margin(raw_lines)

  raw_lines.map do |raw_line|
    text = raw_line.delete_suffix("\n")
    text = text.strip.empty? ? "" : text[margin..]
    text || ""
  end
end

#heredoc_content_margin(raw_lines) ⇒ Object



174
175
176
177
178
179
180
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 174

def heredoc_content_margin(raw_lines)
  raw_lines
    .map { |raw_line| raw_line.delete_suffix("\n") }
    .reject { |text| text.strip.empty? }
    .map { |text| leading_space_count(text) }
    .min || 0
end

#heredoc_context_allowed?Boolean

Returns:

  • (Boolean)


155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 155

def heredoc_context_allowed?
  allowed = %i[
    newline indent dedent
    equal plus_equal minus_equal star_equal slash_equal percent_equal
    amp_equal pipe_equal caret_equal shift_left_equal shift_right_equal
    lparen lbracket comma colon
    return defer if else while match in
    or and not out inout
    plus minus star slash percent amp pipe caret shift_left shift_right
    less less_equal greater greater_equal equal_equal bang_equal
  ]
  previous_type = @tokens.last&.type
  previous_type.nil? || allowed.include?(previous_type)
end

#heredoc_prefix(cstring: false, format: false) ⇒ Object



144
145
146
147
148
149
150
151
152
153
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 144

def heredoc_prefix(cstring: false, format: false)
  operator = "<<-"
  if cstring
    "c#{operator}"
  elsif format
    "f#{operator}"
  else
    operator
  end
end

#heredoc_start?(line, index, cstring: false, format: false) ⇒ Boolean

Returns:

  • (Boolean)


139
140
141
142
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 139

def heredoc_start?(line, index, cstring: false, format: false)
  prefix = heredoc_prefix(cstring:, format:)
  line[index, prefix.length] == prefix && identifier_start?(line[index + prefix.length])
end

#heredoc_terminator?(line, tag) ⇒ Boolean

Returns:

  • (Boolean)


170
171
172
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 170

def heredoc_terminator?(line, tag)
  line.match?(Regexp.new("\\A *#{Regexp.escape(tag)} *\\z"))
end

#lex_heredoc(lines, line_index, index, line_number, line_offset, cstring:, format: false) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
# File 'lib/milk_tea/core/lexer/heredocs.rb', line 8

def lex_heredoc(lines, line_index, index, line_number, line_offset, cstring:, format: false)
  line = lines.fetch(line_index).delete_suffix("\n").b
  prefix_length = heredoc_prefix(cstring:, format:).length
  tag_start = index + prefix_length
  tag_end = tag_start
  tag_end += 1 while tag_end < line.length && identifier_part?(line[tag_end])
  tag = line[tag_start...tag_end]
  remainder = line[tag_end..] || ""

  unless heredoc_context_allowed?
    return lex_symbol(line, index, line_number, line_offset:)
  end

  unless remainder.strip.empty?
    raise LexError.new("unexpected characters after heredoc tag", line: line_number, column: tag_end + 1, path: @path)
  end

  content_lines = []
  terminator_line = nil
  terminator_line_number = nil
  terminator_line_offset = nil
  terminator_has_newline = false
  last_line = line
  last_line_number = line_number
  last_line_offset = line_offset
  last_line_has_newline = lines.fetch(line_index).end_with?("\n")
  resync_line_offset = nil
  resync_line_number = nil
  scan_line_number = line_number + 1
  scan_line_offset = line_offset + lines.fetch(line_index).bytesize
  scan_line_index = line_index + 1

  content_min_indent = nil

  while scan_line_index < lines.length
    raw_line = lines.fetch(scan_line_index)
    raw_text = raw_line.delete_suffix("\n")
    if heredoc_terminator?(raw_text, tag)
      terminator_line = raw_text
      terminator_line_number = scan_line_number
      terminator_line_offset = scan_line_offset
      terminator_has_newline = raw_line.end_with?("\n")
      break
    end

    if @recovery_errors && content_min_indent&.positive? && top_level_resync_line?(raw_text)
      resync_line_offset = scan_line_offset
      resync_line_number = scan_line_number
      break
    end

    content_lines << raw_line
    last_line = raw_text
    last_line_number = scan_line_number
    last_line_offset = scan_line_offset
    last_line_has_newline = raw_line.end_with?("\n")
    scan_line_offset += raw_line.bytesize
    scan_line_number += 1
    scan_line_index += 1

    unless raw_text.strip.empty?
      line_indent = leading_space_count(raw_text)
      content_min_indent = line_indent if content_min_indent.nil? || line_indent < content_min_indent
    end
  end

  if terminator_line.nil? && @recovery_errors && resync_line_number.nil? && scan_line_index < lines.length
    trailing_text = lines.fetch(scan_line_index).delete_suffix("\n")
    if top_level_resync_line?(trailing_text)
      resync_line_offset = scan_line_offset
      resync_line_number = scan_line_number
    end
  end

  if terminator_line.nil?
    if @recovery_errors
      @recovery_errors << LexError.new("unterminated heredoc literal", line: line_number, column: index + 1, path: @path)
      start_offset = line_offset + index
      end_offset = resync_line_offset || scan_line_offset
      lexeme = @source.byteslice(start_offset, end_offset - start_offset)
      value = dedent_heredoc_content(content_lines)
      content_margin = heredoc_content_margin(content_lines)

      literal = if format
        parse_format_heredoc_parts(value, start_line: line_number + 1, start_column: content_margin + 1)
      else
        value
      end

      token_type = if format
        :fstring
      elsif cstring
        :cstring
      else
        :string
      end

      @tokens << token(token_type, lexeme, literal, line_number, index + 1, start_offset:, end_offset:)
      emit_line_newline(last_line, last_line_number, last_line_offset, last_line_has_newline)
      return resync_line_number ? (resync_line_number - line_number) : (scan_line_index - line_index)
    end

    raise LexError.new("unterminated heredoc literal", line: line_number, column: index + 1, path: @path)
  end

  start_offset = line_offset + index
  end_offset = terminator_line_offset + terminator_line.bytesize
  lexeme = @source.byteslice(start_offset, end_offset - start_offset)
  value = dedent_heredoc_content(content_lines)
  content_margin = heredoc_content_margin(content_lines)

  literal = if format
    parse_format_heredoc_parts(value, start_line: line_number + 1, start_column: content_margin + 1)
  else
    value
  end

  token_type = if format
    :fstring
  elsif cstring
    :cstring
  else
    :string
  end

  @tokens << token(token_type, lexeme, literal, line_number, index + 1, start_offset:, end_offset:)
  emit_line_newline(terminator_line, terminator_line_number, terminator_line_offset, terminator_has_newline)

  (scan_line_index - line_index) + 1
end