Class: Ibex::Frontend::Formatter

Inherits:
Object
  • Object
show all
Defined in:
lib/ibex/frontend/formatter.rb,
sig/ibex/frontend/formatter.rbs

Overview

Deterministically formats grammar trivia and rejects any semantic change. rubocop:disable Metrics/ClassLength -- token layout policy stays together so adjacency rules remain auditable.

Constant Summary collapse

DECLARATION_STARTS =

Returns:

  • (Array[Symbol])
%i[
  PRAGMA INCLUDE IMPORT TOKEN PRECHIGH PRECLOW OPTIONS EXPECT EXPECT_RR START RECOVER ON_ERROR_REDUCE TEST LEXER
  CONVERT DISPLAY TYPE PARAM PRINTER PARSER RULE
].freeze
ASSOCIATIONS =

Signature:

  • Array[Symbol]

Returns:

  • (Array[Symbol])
%i[LEFT RIGHT NONASSOC].freeze
CALLABLES =

Signature:

  • Array[Symbol]

Returns:

  • (Array[Symbol])
%i[LHS PARAMETERIZED_REFERENCE SEPARATED_LIST SEPARATED_NONEMPTY_LIST].freeze
SUFFIXES =

Signature:

  • Array[Symbol]

Returns:

  • (Array[String])
%w[? * +].freeze
CLOSERS =

Signature:

  • Array[String]

Returns:

  • (Array[String])
%w[) , ;].freeze
ADJACENT_AFTER =

Signature:

  • Array[String]

Returns:

  • (Array[String])
["::", "("].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(mode: :default) ⇒ Formatter

Returns a new instance of Formatter.

RBS:

  • (?mode: Symbol) -> void

Parameters:

  • mode: (Symbol) (defaults to: :default)


43
44
45
46
47
# File 'lib/ibex/frontend/formatter.rb', line 43

def initialize(mode: :default)
  raise ArgumentError, "mode must be :default or :extended" unless %i[default extended].include?(mode)

  @mode = mode
end

Class Method Details

.format(source, file: "(grammar)", mode: :default) ⇒ String

RBS:

  • (String source, ?file: String, ?mode: Symbol) -> String

Parameters:

  • source (String)
  • file: (String) (defaults to: "(grammar)")
  • mode: (Symbol) (defaults to: :default)

Returns:

  • (String)


38
39
40
# File 'lib/ibex/frontend/formatter.rb', line 38

def self.format(source, file: "(grammar)", mode: :default)
  new(mode: mode).format(source, file: file)
end

Instance Method Details

#adjacent_tokens?(previous_external, current_external, previous_role, current_role) ⇒ Boolean

RBS:

  • (external_token? previous_external, external_token? current_external, Symbol? previous_role, Symbol? current_role) -> bool

Parameters:

  • previous_external (external_token, nil)
  • current_external (external_token, nil)
  • previous_role (Symbol, nil)
  • current_role (Symbol, nil)

Returns:

  • (Boolean)


333
334
335
336
337
338
339
340
# File 'lib/ibex/frontend/formatter.rb', line 333

def adjacent_tokens?(previous_external, current_external, previous_role, current_role)
  return true if current_role == :named_reference || previous_role == :named_reference
  return true if current_external == "::"
  return true if CLOSERS.include?(current_external) || SUFFIXES.include?(current_external)
  return true if ADJACENT_AFTER.include?(previous_external)

  current_external == "(" && CALLABLES.include?(previous_external)
end

#advance_conversion_state(external, role, state) ⇒ void

This method returns an undefined value.

RBS:

  • (external_token external, Symbol? role, formatter_state state) -> void

Parameters:

  • external (external_token)
  • role (Symbol, nil)
  • state (formatter_state)


237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/ibex/frontend/formatter.rb', line 237

def advance_conversion_state(external, role, state)
  if external == :CONVERT
    state[:conversion] = true
    state[:conversion_name] = true
  elsif role == :conversion_end
    state[:conversion] = false
  elsif state.fetch(:conversion) && role == :conversion_entry
    state[:conversion_name] = false
  elsif state.fetch(:conversion) && !state.fetch(:conversion_name)
    state[:conversion_name] = true
  end
end

#advance_parser_state(external, role, state) ⇒ void

This method returns an undefined value.

RBS:

  • (external_token external, Symbol? role, formatter_state state) -> void

Parameters:

  • external (external_token)
  • role (Symbol, nil)
  • state (formatter_state)


216
217
218
219
220
221
222
223
224
225
# File 'lib/ibex/frontend/formatter.rb', line 216

def advance_parser_state(external, role, state)
  if external == :PARSER
    state[:parser] = true
    state[:parser_key] = true
  elsif role == :parser_end
    state[:parser] = false
  elsif state.fetch(:parser)
    state[:parser_key] = !state.fetch(:parser_key)
  end
end

#advance_precedence_state(external, role, state) ⇒ void

This method returns an undefined value.

RBS:

  • (external_token external, Symbol? role, formatter_state state) -> void

Parameters:

  • external (external_token)
  • role (Symbol, nil)
  • state (formatter_state)


228
229
230
231
232
233
234
# File 'lib/ibex/frontend/formatter.rb', line 228

def advance_precedence_state(external, role, state)
  if role == :declaration && %i[PRECHIGH PRECLOW].include?(external)
    state[:precedence_closer] = external == :PRECHIGH ? :PRECLOW : :PRECHIGH
  elsif role == :precedence_end
    state[:precedence_closer] = nil
  end
end

#advance_role_state(external, role, state) ⇒ void

This method returns an undefined value.

RBS:

  • (external_token external, Symbol? role, formatter_state state) -> void

Parameters:

  • external (external_token)
  • role (Symbol, nil)
  • state (formatter_state)


208
209
210
211
212
213
# File 'lib/ibex/frontend/formatter.rb', line 208

def advance_role_state(external, role, state)
  advance_precedence_state(external, role, state)
  advance_conversion_state(external, role, state)
  advance_parser_state(external, role, state)
  advance_rule_state(external, role, state)
end

#advance_rule_state(external, role, state) ⇒ void

This method returns an undefined value.

RBS:

  • (external_token external, Symbol? role, formatter_state state) -> void

Parameters:

  • external (external_token)
  • role (Symbol, nil)
  • state (formatter_state)


251
252
253
254
255
256
257
# File 'lib/ibex/frontend/formatter.rb', line 251

def advance_rule_state(external, role, state)
  state[:section] = :rules if external == :RULE
  state[:rule_colon] = true if external == :LHS
  state[:rule_colon] = false if role == :rule_colon
  state[:depth] += 1 if external == "("
  state[:depth] -= 1 if external == ")"
end

#annotate_comment_indentation(elements) ⇒ void

This method returns an undefined value.

RBS:

  • (Array[formatter_element] elements) -> void

Parameters:

  • elements (Array[formatter_element])


132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/ibex/frontend/formatter.rb', line 132

def annotate_comment_indentation(elements)
  following_role = nil #: Symbol?
  elements.reverse_each do |element|
    segment = element.fetch(:segment)
    if comment?(segment)
      element[:role] = :rule_comment if following_role == :rule_start
      element[:role] = :alternative_comment if following_role == :alternative
    elsif element.fetch(:external)
      following_role = element.fetch(:role)
    end
  end
end

#annotate_roles(elements) ⇒ void

This method returns an undefined value.

RBS:

  • (Array[formatter_element] elements) -> void

Parameters:

  • elements (Array[formatter_element])


116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/ibex/frontend/formatter.rb', line 116

def annotate_roles(elements)
  state = {
    section: :declarations, precedence_closer: nil, conversion: false,
    conversion_name: true, parser: false, parser_key: true, depth: 0, rule_colon: false
  } #: formatter_state
  elements.each do |element|
    external = element.fetch(:external)
    next unless external

    element[:role] = token_role(external, state)
    advance_role_state(external, element.fetch(:role), state)
  end
  annotate_comment_indentation(elements)
end

#append_semantic_children(pending, left, right) ⇒ void

This method returns an undefined value.

RBS:

  • (Array[[Object?, Object?]] pending, Object? left, Object? right) -> void

Parameters:

  • pending (Array[[ Object?, Object? ]])
  • left (Object, nil)
  • right (Object, nil)


424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/ibex/frontend/formatter.rb', line 424

def append_semantic_children(pending, left, right)
  case left
  when Struct
    return unless right.is_a?(Struct)

    semantic_members(left).reverse_each { |member| pending << [left[member], right[member]] }
  when Array
    return unless right.is_a?(Array)

    left.each_index.reverse_each { |index| pending << [left[index], right[index]] }
  when Hash
    return unless right.is_a?(Hash)

    semantic_keys(left).reverse_each { |key| pending << [left[key], right[key]] }
  end
end

#build_elements(document) ⇒ Array[formatter_element]

RBS:

  • (SourceDocument document) -> Array[formatter_element]

Parameters:

Returns:

  • (Array[formatter_element])


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/ibex/frontend/formatter.rb', line 82

def build_elements(document)
  external_types = external_types(document.tokens)
  pending = [] #: Array[Segment]
  elements = document.cst.each_with_object([]) do |segment, result|
    if %i[whitespace newline].include?(segment.kind)
      pending << segment
      next
    end

    result << {
      segment: segment,
      external: segment.token_index && external_types[segment.token_index],
      role: nil,
      gap: pending
    }
    pending = []
  end
  annotate_roles(elements)
  elements
end

#comment?(segment) ⇒ Boolean

RBS:

  • (Segment segment) -> bool

Parameters:

Returns:

  • (Boolean)


297
298
299
# File 'lib/ibex/frontend/formatter.rb', line 297

def comment?(segment)
  %i[line_comment block_comment].include?(segment.kind)
end

#comment_separator(previous, current, gap) ⇒ String

RBS:

  • (formatter_element previous, formatter_element current, Array[Segment] gap) -> String

Parameters:

  • previous (formatter_element)
  • current (formatter_element)
  • gap (Array[Segment])

Returns:

  • (String)


302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/ibex/frontend/formatter.rb', line 302

def comment_separator(previous, current, gap)
  previous_segment = previous.fetch(:segment)
  current_segment = current.fetch(:segment)
  if text_ends_with_newline?(previous_segment.text)
    newlines = gap.select { |segment| segment.kind == :newline }.map(&:text)
    return newlines.join + (" " * indentation(current))
  end
  if previous_segment.kind == :line_comment || newline_in?(gap)
    return render_separator(:line, gap, indentation(current))
  end
  return " " if current_segment.kind == :line_comment || previous_segment.kind == :block_comment ||
                current_segment.kind == :block_comment

  render_separator(:space, gap, indentation(current))
end

#comparable_semantic_hashes?(left, right) ⇒ Boolean

RBS:

  • (Hash[Object?, Object?] left, Object? right) -> bool

Parameters:

  • left (Hash[Object?, Object?])
  • right (Object, nil)

Returns:

  • (Boolean)


415
416
417
418
419
420
421
# File 'lib/ibex/frontend/formatter.rb', line 415

def comparable_semantic_hashes?(left, right)
  return false unless right.is_a?(Hash)

  left_keys = semantic_keys(left)
  right_keys = semantic_keys(right)
  left_keys.length == right_keys.length && left_keys.all? { |key| right_keys.include?(key) }
end

#comparable_semantic_values?(left, right) ⇒ Boolean

RBS:

  • (Object? left, Object? right) -> bool

Parameters:

  • left (Object, nil)
  • right (Object, nil)

Returns:

  • (Boolean)


402
403
404
405
406
407
408
409
410
411
412
# File 'lib/ibex/frontend/formatter.rb', line 402

def comparable_semantic_values?(left, right)
  if left.is_a?(Struct)
    return false unless right.is_a?(Struct) && left.instance_of?(right.class)

    return left.members == right.members
  end
  return right.is_a?(Array) && left.length == right.length if left.is_a?(Array)
  return comparable_semantic_hashes?(left, right) if left.is_a?(Hash)

  left == right
end

#direct_opaque_pair?(previous, current) ⇒ Boolean

RBS:

  • (Segment previous, Segment current) -> bool

Parameters:

Returns:

  • (Boolean)


291
292
293
294
# File 'lib/ibex/frontend/formatter.rb', line 291

def direct_opaque_pair?(previous, current)
  (previous.kind == :user_code_marker && current.kind == :user_code_body) ||
    (previous.kind == :user_code_body && current.kind == :user_code_marker)
end

#document_token_role(external, state) ⇒ Symbol?

RBS:

  • (external_token external, formatter_state state) -> Symbol?

Parameters:

  • external (external_token)
  • state (formatter_state)

Returns:

  • (Symbol, nil)


154
155
156
157
158
159
160
161
162
163
164
# File 'lib/ibex/frontend/formatter.rb', line 154

def document_token_role(external, state)
  return :user_code if external == :USER_CODE

  nested_role = nested_declaration_role(external, state)
  return nested_role if nested_role
  return :conversion_entry if state.fetch(:conversion) && state.fetch(:conversion_name)
  return :rule_keyword if external == :RULE
  return :declaration if DECLARATION_STARTS.include?(external)

  nil
end

#external_types(tokens) ⇒ Hash[Integer, external_token]

RBS:

  • (Array[Token] tokens) -> Hash[Integer, external_token]

Parameters:

Returns:

  • (Hash[Integer, external_token])


104
105
106
107
108
109
110
111
112
113
# File 'lib/ibex/frontend/formatter.rb', line 104

def external_types(tokens)
  adapter = TokenAdapter.new(tokens, extended: @mode == :extended)
  types = {} #: Hash[Integer, external_token]
  index = 0
  while (classified = adapter.next_token)
    types[index] = classified.fetch(0)
    index += 1
  end
  types
end

#format(source, file: "(grammar)") ⇒ String

RBS:

  • (String source, ?file: String) -> String

Parameters:

  • source (String)
  • file: (String) (defaults to: "(grammar)")

Returns:

  • (String)


50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/ibex/frontend/formatter.rb', line 50

def format(source, file: "(grammar)")
  document = Parser.new(source, file: file, mode: @mode).parse_source_document
  formatted = render(document)
  reparsed = Parser.new(formatted, file: file, mode: @mode).parse_source_document
  unless same_semantic_projection?(document.ast, reparsed.ast)
    raise Ibex::Error, "#{file}: formatting would change grammar semantics"
  end

  formatted
rescue Ibex::Error
  raise
rescue StandardError => e
  raise Ibex::Error, "#{file}: formatting failed: #{e.message}"
end

#indentation(element) ⇒ Integer

RBS:

  • (formatter_element element) -> Integer

Parameters:

  • element (formatter_element)

Returns:

  • (Integer)


357
358
359
360
361
362
363
# File 'lib/ibex/frontend/formatter.rb', line 357

def indentation(element)
  case element.fetch(:role)
  when :rule_start, :precedence_level, :conversion_entry, :parser_setting, :rule_comment then 2
  when :alternative, :alternative_comment then 4
  else 0
  end
end

#leading_separator(current, gap) ⇒ String

RBS:

  • (formatter_element current, Array[Segment] gap) -> String

Parameters:

  • current (formatter_element)
  • gap (Array[Segment])

Returns:

  • (String)


275
276
277
278
279
# File 'lib/ibex/frontend/formatter.rb', line 275

def leading_separator(current, gap)
  return "" unless newline_in?(gap)

  render_separator(:line, gap, indentation(current))
end

#line_boundary?(previous_role, current_role, previous, current) ⇒ Boolean

RBS:

  • (Symbol? previous_role, Symbol? current_role, formatter_element previous, formatter_element current) -> bool

Parameters:

  • previous_role (Symbol, nil)
  • current_role (Symbol, nil)
  • previous (formatter_element)
  • current (formatter_element)

Returns:

  • (Boolean)


344
345
346
347
348
349
350
351
352
353
354
# File 'lib/ibex/frontend/formatter.rb', line 344

def line_boundary?(previous_role, current_role, previous, current)
  return false if current_role == :rule_start && previous.fetch(:external) == :INLINE

  return true if %i[declaration rule_keyword precedence_level precedence_end conversion_entry
                    conversion_end parser_setting parser_end rule_start alternative grammar_end
                    user_code].include?(current_role)
  return true if previous_role == :rule_keyword
  return false unless current.fetch(:segment).kind == :user_code_marker

  previous.fetch(:segment).kind != :user_code_body
end

#nested_declaration_role(external, state) ⇒ Symbol?

RBS:

  • (external_token external, formatter_state state) -> Symbol?

Parameters:

  • external (external_token)
  • state (formatter_state)

Returns:

  • (Symbol, nil)


167
168
169
170
171
172
173
174
175
# File 'lib/ibex/frontend/formatter.rb', line 167

def nested_declaration_role(external, state)
  return precedence_role(external, state) if %i[PRECHIGH PRECLOW].include?(external)
  return :precedence_level if ASSOCIATIONS.include?(external) && state[:precedence_closer]
  return :parser_end if external == :END && state.fetch(:parser)
  return :parser_setting if state.fetch(:parser) && state.fetch(:parser_key)
  return section_end_role(state) if external == :END

  nil
end

#newline_in?(gap) ⇒ Boolean

RBS:

  • (Array[Segment] gap) -> bool

Parameters:

Returns:

  • (Boolean)


376
377
378
# File 'lib/ibex/frontend/formatter.rb', line 376

def newline_in?(gap)
  gap.any? { |segment| segment.kind == :newline }
end

#precedence_role(external, state) ⇒ Symbol

RBS:

  • (external_token external, formatter_state state) -> Symbol

Parameters:

  • external (external_token)
  • state (formatter_state)

Returns:

  • (Symbol)


201
202
203
204
205
# File 'lib/ibex/frontend/formatter.rb', line 201

def precedence_role(external, state)
  return :precedence_end if state[:precedence_closer] == external

  :declaration
end

#render(document) ⇒ String

RBS:

  • (SourceDocument document) -> String

Parameters:

Returns:

  • (String)


68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/ibex/frontend/formatter.rb', line 68

def render(document)
  @default_newline = document.source.match(/\r\n|\n/)&.[](0) || "\n"
  elements = build_elements(document)
  output = +""
  previous = nil #: formatter_element?
  elements.each do |current|
    output << separator(previous, current)
    output << current.fetch(:segment).text
    previous = current
  end
  output
end

#render_separator(kind, gap, indentation) ⇒ String

RBS:

  • (Symbol kind, Array[Segment] gap, Integer indentation) -> String

Parameters:

  • kind (Symbol)
  • gap (Array[Segment])
  • indentation (Integer)

Returns:

  • (String)


366
367
368
369
370
371
372
373
# File 'lib/ibex/frontend/formatter.rb', line 366

def render_separator(kind, gap, indentation)
  return "" if kind == :none
  return " " if kind == :space

  newlines = gap.select { |segment| segment.kind == :newline }.map(&:text)
  newlines = [@default_newline] if newlines.empty?
  newlines.join + (" " * indentation)
end

#rule_token_role(external, state) ⇒ Symbol?

RBS:

  • (external_token external, formatter_state state) -> Symbol?

Parameters:

  • external (external_token)
  • state (formatter_state)

Returns:

  • (Symbol, nil)


186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/ibex/frontend/formatter.rb', line 186

def rule_token_role(external, state)
  return :rule_start if external == :INLINE && state.fetch(:section) == :rules
  return :rule_start if external == :LHS
  return :alternative if external == "|" && state.fetch(:section) == :rules && state.fetch(:depth).zero?

  if external == ":"
    return :rule_colon if state.fetch(:rule_colon) && state.fetch(:depth).zero?

    return :named_reference
  end

  nil
end

#same_semantic_projection?(left, right) ⇒ Boolean

RBS:

  • (Object? left, Object? right) -> bool

Parameters:

  • left (Object, nil)
  • right (Object, nil)

Returns:

  • (Boolean)


386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/ibex/frontend/formatter.rb', line 386

def same_semantic_projection?(left, right)
  pending = [[left, right]] #: Array[[Object?, Object?]]
  until pending.empty?
    pair = pending.pop
    next unless pair

    left_value, right_value = pair
    next if left_value.equal?(right_value)
    return false unless comparable_semantic_values?(left_value, right_value)

    append_semantic_children(pending, left_value, right_value)
  end
  true
end

#section_end_role(state) ⇒ Symbol?

RBS:

  • (formatter_state state) -> Symbol?

Parameters:

  • state (formatter_state)

Returns:

  • (Symbol, nil)


178
179
180
181
182
183
# File 'lib/ibex/frontend/formatter.rb', line 178

def section_end_role(state)
  return :conversion_end if state.fetch(:conversion)
  return :grammar_end if state.fetch(:section) == :rules

  nil
end

#semantic_keys(value) ⇒ Array[Object?]

RBS:

  • (Hash[Object?, Object?] value) -> Array[Object?]

Parameters:

  • value (Hash[Object?, Object?])

Returns:

  • (Array[Object?])


447
448
449
# File 'lib/ibex/frontend/formatter.rb', line 447

def semantic_keys(value)
  value.keys.reject { |key| %i[loc span].include?(key) }
end

#semantic_members(value) ⇒ Array[Symbol]

RBS:

  • (Struct[Object?] value) -> Array[Symbol]

Parameters:

  • value (Struct[Object?])

Returns:

  • (Array[Symbol])


442
443
444
# File 'lib/ibex/frontend/formatter.rb', line 442

def semantic_members(value)
  value.members.reject { |member| %i[loc span extended_loc].include?(member) }
end

#separator(previous, current) ⇒ String

RBS:

  • (formatter_element? previous, formatter_element current) -> String

Parameters:

  • previous (formatter_element, nil)
  • current (formatter_element)

Returns:

  • (String)


260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/ibex/frontend/formatter.rb', line 260

def separator(previous, current)
  gap = current.fetch(:gap)
  return leading_separator(current, gap) unless previous

  previous_segment = previous.fetch(:segment)
  current_segment = current.fetch(:segment)
  return "" if direct_opaque_pair?(previous_segment, current_segment)
  return trailing_separator(previous, gap) if current_segment.kind == :eof
  return comment_separator(previous, current, gap) if comment?(previous_segment) || comment?(current_segment)

  kind = separator_kind(previous, current)
  render_separator(kind, gap, indentation(current))
end

#separator_kind(previous, current) ⇒ Symbol

RBS:

  • (formatter_element previous, formatter_element current) -> Symbol

Parameters:

  • previous (formatter_element)
  • current (formatter_element)

Returns:

  • (Symbol)


319
320
321
322
323
324
325
326
327
328
329
# File 'lib/ibex/frontend/formatter.rb', line 319

def separator_kind(previous, current)
  previous_external = previous.fetch(:external)
  current_external = current.fetch(:external)
  previous_role = previous.fetch(:role)
  current_role = current.fetch(:role)

  return :line if line_boundary?(previous_role, current_role, previous, current)
  return :none if adjacent_tokens?(previous_external, current_external, previous_role, current_role)

  :space
end

#text_ends_with_newline?(text) ⇒ Boolean

RBS:

  • (String text) -> bool

Parameters:

  • text (String)

Returns:

  • (Boolean)


381
382
383
# File 'lib/ibex/frontend/formatter.rb', line 381

def text_ends_with_newline?(text)
  text.end_with?("\n")
end

#token_role(external, state) ⇒ Symbol?

RBS:

  • (external_token external, formatter_state state) -> Symbol?

Parameters:

  • external (external_token)
  • state (formatter_state)

Returns:

  • (Symbol, nil)


146
147
148
149
150
151
# File 'lib/ibex/frontend/formatter.rb', line 146

def token_role(external, state)
  document_role = document_token_role(external, state)
  return document_role if document_role

  rule_token_role(external, state)
end

#trailing_separator(previous, gap) ⇒ String

RBS:

  • (formatter_element previous, Array[Segment] gap) -> String

Parameters:

  • previous (formatter_element)
  • gap (Array[Segment])

Returns:

  • (String)


282
283
284
285
286
287
288
# File 'lib/ibex/frontend/formatter.rb', line 282

def trailing_separator(previous, gap)
  segment = previous.fetch(:segment)
  return "" if segment.kind == :user_code_body
  return render_separator(:line, gap, 0) if previous.fetch(:role) == :grammar_end

  render_separator(gap.any? { |item| item.kind == :newline } ? :line : :none, gap, 0)
end