Class: Dms::Emitter

Inherits:
Object
  • Object
show all
Defined in:
lib/dms/emitter.rb,
lib/dms/emitter.rb

Defined Under Namespace

Classes: NodeComments

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(doc, lite: false) ⇒ Emitter

Returns a new instance of Emitter.



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
# File 'lib/dms/emitter.rb', line 85

def initialize(doc, lite: false)
  @doc = doc
  @out = []
  @lite = lite
  @comments_by_path = {}
  # Lite mode (canonical-form emit) leaves the comment + form
  # lookups empty even when `doc.comments` / `doc.original_forms`
  # are populated, so the walker emits no comments and uses
  # canonical integer/string forms. See SPEC §encode.
  unless @lite
    doc.comments.each do |ac|
      path = ac.path.is_a?(Array) ? ac.path : ac.path.to_a
      entry = (@comments_by_path[path] ||= NodeComments.empty)
      case ac.position
      when :leading then entry.leading << ac.comment
      when :inner then entry.inner << ac.comment
      when :trailing then entry.trailing << ac.comment
      else entry.floating << ac.comment
      end
    end
  end
  @forms_by_path = {}
  unless @lite
    doc.original_forms.each do |path, lit|
      path_arr = path.is_a?(Array) ? path : path.to_a
      @forms_by_path[path_arr] ||= lit
    end
  end
  # For is_flow_safe: any descendant comment forces block form.
  # Empty in lite mode (no comments emitted anyway).
  @descendant_comment_paths =
    if @lite
      []
    else
      doc.comments.map do |ac|
        ac.path.is_a?(Array) ? ac.path : ac.path.to_a
      end
    end
end

Instance Attribute Details

#outObject (readonly)

Returns the value of attribute out.



46
47
48
# File 'lib/dms/emitter.rb', line 46

def out
  @out
end

Class Method Details

._encode_full(doc) ⇒ Object

Full-mode round-trip emit (SPEC §encode). Refuses Documents containing ‘Dms::UnorderedHash` — see SPEC §“Unordered tables”.



52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/dms/emitter.rb', line 52

def self._encode_full(doc)
  if Dms._contains_unordered_table?(doc.body)
    raise EncodeError,
          "encode (full-mode round-trip) refuses Document with " \
          "Dms::UnorderedHash; unordered tables have arbitrary " \
          "iteration order — use Dms.encode_lite instead. " \
          "See SPEC §\"Unordered tables\"."
  end
  em = new(doc)
  em.emit_document
  em.out.join
end

._encode_lite(doc) ⇒ Object

Canonical-form emit (SPEC §encode). Drops comments and ‘original_forms` even when present in `doc`: integers go out in decimal, strings in basic-quoted form, no comments are written. Accepts both full-mode and lite-mode parsed `Document`s. Round-trip stability is data-only —`decode_document(encode_lite(doc))` is data-equivalent to `doc`, but lossy by design for comments and source forms.



72
73
74
75
76
# File 'lib/dms/emitter.rb', line 72

def self._encode_lite(doc)
  em = new(doc, lite: true)
  em.emit_document
  em.out.join
end

.bare_key_char?(c) ⇒ Boolean

Bare-key chars: ‘_`, `-`, ASCII alnum, or non-ASCII Unicode L/N.

Returns:

  • (Boolean)


611
612
613
614
615
616
617
618
619
620
# File 'lib/dms/emitter.rb', line 611

def self.bare_key_char?(c)
  return true if c == "_" || c == "-"
  cp = c.ord
  if cp < 128
    return (cp >= 0x30 && cp <= 0x39) ||
           (cp >= 0x41 && cp <= 0x5A) ||
           (cp >= 0x61 && cp <= 0x7A)
  end
  c.match?(/\p{L}|\p{N}/)
end

.escape_basic(s) ⇒ Object



636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
# File 'lib/dms/emitter.rb', line 636

def self.escape_basic(s)
  out = String.new
  s.each_char do |c|
    case c
    when "\\" then out << "\\\\"
    when "\"" then out << "\\\""
    when "\n" then out << "\\n"
    when "\r" then out << "\\r"
    when "\t" then out << "\\t"
    when "\b" then out << "\\b"
    when "\f" then out << "\\f"
    else
      cp = c.ord
      if cp < 0x20
        out << format("\\u%04X", cp)
      else
        out << c
      end
    end
  end
  out
end

.format_float_ryu_shape(v) ⇒ Object



622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/dms/emitter.rb', line 622

def self.format_float_ryu_shape(v)
  s = v.to_s
  if s.include?("e")
    mantissa, exp = s.split("e", 2)
    exp = exp[1..] if exp.start_with?("+")
    # Drop trailing `.0` from the mantissa so e.g. `1.0e100` -> `1e100`,
    # matching the Python ryu-shape (Python's `repr(1e100)` -> `1e+100`).
    mantissa = mantissa[0..-3] if mantissa.end_with?(".0") && mantissa.length > 2
    return "#{mantissa}e#{exp}"
  end
  return s + ".0" unless s.include?(".")
  s
end

.format_key(k) ⇒ Object



659
660
661
662
663
664
665
# File 'lib/dms/emitter.rb', line 659

def self.format_key(k)
  return k if !k.empty? && k.each_char.all? { |c| bare_key_char?(c) }
  if !k.include?("'") && !k.include?("\n") && !k.include?("\r")
    return "'#{k}'"
  end
  "\"#{escape_basic(k)}\""
end

Instance Method Details

#emit_comment_line(c, indent) ⇒ Object

—– comment emitters —————————————-



518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/dms/emitter.rb', line 518

def emit_comment_line(c, indent)
  text = c.content
  prefix = INDENT_STR * indent
  unless text.include?("\n")
    push(prefix)
    push(text)
    push("\n")
    return
  end
  lines = text.split("\n", -1)
  lines.each_with_index do |line, j|
    if j > 0
      push("\n")
    else
      push(prefix)
    end
    push(line)
  end
  push("\n")
end

#emit_documentObject



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/dms/emitter.rb', line 135

def emit_document
  # Lite mode emits no comments, so FM comments don't force a
  # `+++` block — only an explicit `meta = Some(...)` does.
  has_fm_comments = !@lite && @doc.comments.any? do |ac|
    path = ac.path.is_a?(Array) ? ac.path : ac.path.to_a
    !path.empty? && path[0] == "__fm__"
  end
  fm_present = !@doc.meta.nil?
  if fm_present || has_fm_comments
    push("+++\n")
    fm_path = ["__fm__"].freeze
    if !@doc.meta.nil?
      emit_table_block(@doc.meta, fm_path, 0)
    else
      emit_floating(fm_path, 0)
    end
    push("+++\n\n")
  end

  body = @doc.body
  body_path = [].freeze
  if body.is_a?(Hash)
    emit_table_block(body, body_path, 0)
  elsif body.is_a?(Array)
    emit_list_block(body, body_path, 0)
  else
    # Scalar root.
    entry = @comments_by_path[body_path]
    if entry
      entry.leading.each { |c| emit_comment_line(c, 0) }
    end
    emit_value_inline(body, body_path)
    emit_trailing_for(body_path)
    push("\n")
    if entry
      entry.floating.each { |c| emit_comment_line(c, 0) }
    end
  end
end

#emit_float(f) ⇒ Object



399
400
401
402
403
404
405
406
407
# File 'lib/dms/emitter.rb', line 399

def emit_float(f)
  if f.nan?
    push("nan")
  elsif f.infinite?
    push(f > 0 ? "inf" : "-inf")
  else
    push(Emitter.format_float_ryu_shape(f))
  end
end

#emit_floating(path, indent) ⇒ Object



571
572
573
574
575
576
# File 'lib/dms/emitter.rb', line 571

def emit_floating(path, indent)
  return if @lite
  entry = @comments_by_path[path]
  return unless entry
  entry.floating.each { |c| emit_comment_line(c, indent) }
end

#emit_heredoc(body, flavor, label, modifiers) ⇒ Object



441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'lib/dms/emitter.rb', line 441

def emit_heredoc(body, flavor, label, modifiers)
  # Compute kvpair indent dynamically from the buffer: count the
  # leading spaces on the line we're currently writing.
  joined = @out.join
  nl = joined.rindex("\n")
  line_start = nl ? nl + 1 : 0
  kv_indent = 0
  i = line_start
  while i < joined.length && joined[i] == " "
    kv_indent += 1
    i += 1
  end
  body_indent = " " * (kv_indent + INDENT_STR.length)
  term_indent = body_indent

  opener = (flavor == :basic_triple) ? '"""' : "'''"
  push(opener)
  push(label) if label
  modifiers.each do |m|
    push(" ")
    push(m.name)
    push("(")
    m.args.each_with_index do |a, j|
      push(", ") if j > 0
      emit_modifier_arg(a)
    end
    push(")")
  end
  push("\n")
  unless body.empty?
    # Mirror Python's `body.split("\n")` (no limit): this keeps
    # trailing empty fields, matching Ruby `split("\n", -1)`.
    body.split("\n", -1).each do |line|
      if line == ""
        push("\n")
      else
        push(body_indent)
        push(line)
        push("\n")
      end
    end
  end
  push(term_indent)
  if label
    push(label)
  else
    push(opener)
  end
end

#emit_inner_for(path) ⇒ Object



555
556
557
558
559
560
561
562
563
# File 'lib/dms/emitter.rb', line 555

def emit_inner_for(path)
  return if @lite
  entry = @comments_by_path[path]
  return unless entry
  entry.inner.each do |c|
    push(c.content)
    push(" ")
  end
end

#emit_integer(n, path) ⇒ Object



386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/dms/emitter.rb', line 386

def emit_integer(n, path)
  if @lite
    push(n.to_s)
    return
  end
  lit = @forms_by_path[path]
  if lit && lit.kind == :integer
    push(lit.integer_lit)
    return
  end
  push(n.to_s)
end

#emit_list_block(items, path, indent) ⇒ Object



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/dms/emitter.rb', line 265

def emit_list_block(items, path, indent)
  if @lite
    items.each do |v|
      push_indent(indent)
      push("+")
      if v.is_a?(Hash) && !v.empty?
        push("\n")
        emit_table_block(v, nil, indent + 1)
      elsif v.is_a?(Array) && !v.empty?
        push("\n")
        emit_list_block(v, nil, indent + 1)
      else
        push(" ")
        emit_value_inline(v, nil)
        push("\n")
      end
    end
    return
  end
  items.each_with_index do |v, i|
    child_path = (path + [i]).freeze
    entry = @comments_by_path[child_path]
    if entry
      entry.leading.each { |c| emit_comment_line(c, indent) }
    end
    push_indent(indent)
    push("+")
    has_inner = has_inner?(child_path)
    if v.is_a?(Hash) && !v.empty?
      if has_inner
        push(" ")
        emit_inner_for(child_path)
        @out.pop if !@out.empty? && @out[-1] == " "
      end
      emit_trailing_for(child_path)
      push("\n")
      emit_table_block(v, child_path, indent + 1)
    elsif v.is_a?(Array) && !v.empty?
      if has_inner
        push(" ")
        emit_inner_for(child_path)
        @out.pop if !@out.empty? && @out[-1] == " "
      end
      emit_trailing_for(child_path)
      push("\n")
      emit_list_block(v, child_path, indent + 1)
    else
      push(" ")
      emit_inner_for(child_path)
      emit_value_inline(v, child_path)
      emit_trailing_for(child_path)
      push("\n")
    end
  end
  emit_floating(path, indent)
end

#emit_modifier_arg(v) ⇒ Object



491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# File 'lib/dms/emitter.rb', line 491

def emit_modifier_arg(v)
  case v
  when true
    push("true")
  when false
    push("false")
  when Integer
    push(v.to_s)
  when Float
    emit_float(v)
  when String
    push('"')
    push(Emitter.escape_basic(v))
    push('"')
  when LocalDate, LocalTime, LocalDateTime, OffsetDateTime
    push(v.value)
  when Array
    push("[]")
  when Hash
    push("{}")
  else
    raise EncodeError, "encode: cannot emit modifier arg #{v.class.name}"
  end
end

#emit_string(s, path) ⇒ Object



409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/dms/emitter.rb', line 409

def emit_string(s, path)
  if @lite
    push('"')
    push(Emitter.escape_basic(s))
    push('"')
    return
  end
  lit = @forms_by_path[path]
  form = nil
  form = lit.string_form if lit && lit.kind == :string
  if form.nil? || form.kind == :basic
    push('"')
    push(Emitter.escape_basic(s))
    push('"')
    return
  end
  if form.kind == :literal
    push("'")
    push(s)
    push("'")
    return
  end
  # Heredoc: stored body is post-modifier. Mirror Python emitter:
  # for `_fold_paragraphs`, replace each `\n` with `\n\n` so on
  # re-parse the fold collapses paragraphs back to single lines.
  body = s
  if form.modifiers && form.modifiers.any? { |m| m.name == "_fold_paragraphs" }
    body = body.gsub("\n", "\n\n")
  end
  emit_heredoc(body, form.flavor, form.label, form.modifiers || [])
end

#emit_table_block(t, path, indent) ⇒ Object

—– block emitters ——————————————



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/dms/emitter.rb', line 177

def emit_table_block(t, path, indent)
  # Lite-mode hot path: comments_by_path / forms_by_path are empty
  # by construction, so child_path / lookups / flow_safe? are pure
  # overhead. Skipping them halves emit time on bench_realistic.
  if @lite
    pad = INDENT_STR * indent
    t.each do |k, v|
      # Inline common scalar cases (string/bool/int) to avoid the
      # emit_value_inline + emit_string/emit_integer dispatch on
      # the per-kvpair hot path. Real-world configs are dominated
      # by these three.
      kf = (k.is_a?(String) && k =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/) ? k : format_key(k)
      case v
      when String
        if v =~ /[\\"\x00-\x1F]/
          @out << "#{pad}#{kf}: \"#{Emitter.escape_basic(v)}\"\n"
        else
          @out << "#{pad}#{kf}: \"#{v}\"\n"
        end
        next
      when true
        @out << "#{pad}#{kf}: true\n"
        next
      when false
        @out << "#{pad}#{kf}: false\n"
        next
      when Integer
        @out << "#{pad}#{kf}: #{v}\n"
        next
      end
      can_block =
        (v.is_a?(Hash) && !v.empty?) || (v.is_a?(Array) && !v.empty?)
      push_indent(indent)
      push(kf)
      push(":")
      if can_block
        push("\n")
        if v.is_a?(Hash)
          emit_table_block(v, nil, indent + 1)
        else
          emit_list_block(v, nil, indent + 1)
        end
      else
        push(" ")
        emit_value_inline(v, nil)
        push("\n")
      end
    end
    return
  end
  t.each do |k, v|
    child_path = (path + [k]).freeze
    entry = @comments_by_path[child_path]
    if entry
      entry.leading.each { |c| emit_comment_line(c, indent) }
    end
    has_trailing = entry && !entry.trailing.empty?
    has_inner = has_inner?(child_path)
    can_block =
      (v.is_a?(Hash) && !v.empty?) || (v.is_a?(Array) && !v.empty?)
    needs_block = can_block && !(has_trailing && flow_safe?(v, child_path))
    push_indent(indent)
    push(format_key(k))
    push(":")
    if needs_block
      if has_inner
        push(" ")
        emit_inner_for(child_path)
        # emit_inner_for leaves a trailing space; trim it.
        @out.pop if !@out.empty? && @out[-1] == " "
      end
      push("\n")
      if v.is_a?(Hash)
        emit_table_block(v, child_path, indent + 1)
      else
        emit_list_block(v, child_path, indent + 1)
      end
    else
      push(" ")
      emit_inner_for(child_path)
      emit_value_inline(v, child_path)
      emit_trailing_for(child_path)
      push("\n")
    end
  end
  emit_floating(path, indent)
end

#emit_trailing_for(path) ⇒ Object



539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/dms/emitter.rb', line 539

def emit_trailing_for(path)
  return if @lite
  entry = @comments_by_path[path]
  return unless entry
  first = true
  entry.trailing.each do |c|
    if first
      push("  ")
      first = false
    else
      push(" ")
    end
    push(c.content)
  end
end

#emit_value_inline(v, path) ⇒ Object

—– value / scalar emitters ——————————–



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/dms/emitter.rb', line 324

def emit_value_inline(v, path)
  case v
  when true
    push("true")
  when false
    push("false")
  when Integer
    emit_integer(v, path)
  when Float
    emit_float(v)
  when LocalDate, LocalTime, LocalDateTime, OffsetDateTime
    push(v.value)
  when String
    emit_string(v, path)
  when Array
    if v.empty?
      push("[]")
    else
      push("[")
      if @lite
        v.each_with_index do |item, i|
          push(", ") if i > 0
          emit_value_inline(item, nil)
        end
      else
        v.each_with_index do |item, i|
          push(", ") if i > 0
          emit_value_inline(item, (path + [i]).freeze)
        end
      end
      push("]")
    end
  when Hash
    if v.empty?
      push("{}")
    else
      push("{")
      first = true
      if @lite
        v.each do |k, vv|
          push(", ") unless first
          first = false
          push(format_key(k))
          push(": ")
          emit_value_inline(vv, nil)
        end
      else
        v.each do |k, vv|
          push(", ") unless first
          first = false
          push(format_key(k))
          push(": ")
          emit_value_inline(vv, (path + [k]).freeze)
        end
      end
      push("}")
    end
  else
    raise EncodeError, "encode: cannot emit #{v.class.name}"
  end
end

#flow_safe?(v, path) ⇒ Boolean

—– flow-safety check —————————————

Returns:

  • (Boolean)


580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
# File 'lib/dms/emitter.rb', line 580

def flow_safe?(v, path)
  plen = path.length
  @descendant_comment_paths.each do |cpath|
    return false if cpath.length > plen && cpath[0, plen] == path
  end
  case v
  when String
    lit = @forms_by_path[path]
    if lit && lit.kind == :string && lit.string_form &&
        lit.string_form.kind == :heredoc
      return false
    end
    return true
  when Array
    v.each_with_index do |item, i|
      return false unless flow_safe?(item, (path + [i]).freeze)
    end
    return true
  when Hash
    v.each do |k, vv|
      return false unless flow_safe?(vv, (path + [k]).freeze)
    end
    return true
  else
    true
  end
end

#format_key(k) ⇒ Object

Instance forwarder so ‘format_key(k)` inside emit_* works.



670
671
672
# File 'lib/dms/emitter.rb', line 670

def format_key(k)
  Emitter.format_key(k)
end

#has_inner?(path) ⇒ Boolean

Returns:

  • (Boolean)


565
566
567
568
569
# File 'lib/dms/emitter.rb', line 565

def has_inner?(path)
  return false if @lite
  entry = @comments_by_path[path]
  entry && !entry.inner.empty?
end

#push(s) ⇒ Object

—– helpers ————————————————-



127
128
129
# File 'lib/dms/emitter.rb', line 127

def push(s)
  @out << s
end

#push_indent(indent) ⇒ Object



131
132
133
# File 'lib/dms/emitter.rb', line 131

def push_indent(indent)
  @out << (INDENT_STR * indent) if indent > 0
end