Skip to content

Commit 50531dc

Browse files
Fix ANSI replay parser edge cases
Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
1 parent 41975df commit 50531dc

3 files changed

Lines changed: 299 additions & 90 deletions

File tree

lib/cli/ui/ansi.rb

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,9 @@ def strip_codes(str)
5151
str.gsub(Regexp.union(CSI_SEQUENCE, OSC_SEQUENCE, /\r/), '')
5252
end
5353

54-
# Replays the viewport-independent cursor controls in a captured
55-
# terminal stream, so repaints (spinners, progress bars) collapse
56-
# onto their final state instead of accumulating one frame per tick.
57-
#
58-
# Where +strip_codes+ deletes control sequences, this applies them.
59-
# Operations that assume a viewport -- screen-relative positioning,
60-
# display erasure, wrapping -- are ignored: a capture does not
61-
# record scrolling, so screen coordinates have no buffer row to map
62-
# onto. Alternate-screen content (a full-screen prompt, a pager) is
63-
# discarded on exit, as a terminal discards it. Commands a repaint
64-
# has no use for, from character editing to charset translation,
65-
# are dropped without effect. The stream is decoded as UTF-8
66-
# whatever its tagged encoding, replacing bytes that don't decode.
67-
# Columns hold one grapheme cluster each, using Unicode terminal
68-
# widths so wide glyphs keep their two columns when overwritten.
69-
# Trailing whitespace on every line is trimmed: a terminal renders
70-
# nothing there.
54+
# Returns the text left by applying terminal repaint controls in a
55+
# captured stream. Presentation controls are dropped and malformed
56+
# input is replaced while decoding the result as UTF-8.
7157
#
7258
# ==== Attributes
7359
#

lib/cli/ui/ansi/replay.rb

Lines changed: 187 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ module Replay
4646
# ones it can't match -- garbled, aborted, cut off, or carrying
4747
# embedded controls -- take the csi state's character loop, and both
4848
# paths funnel into the same apply, so neither can drift on dispatch.
49-
# The pass earns its keep: a 1.6MB capture holding 91k CSI sequences
50-
# replays a third again as slow without it (159ms to 206ms).
5149
CSI_BODY = /([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])/
5250
# An OSC payload runs to its end: BEL or ST terminate it, CAN and SUB
5351
# abort it, and an aborting ESC starts a sequence of its own.
@@ -67,27 +65,43 @@ module Replay
6765
# owns both cells, but only the first contributes to the output.
6866
CONTINUATION = ''
6967

70-
# A grid of lines with no viewport, each line an array of cells: one
71-
# grapheme cluster per column, a wide cluster owning its cell and a
72-
# CONTINUATION marker in the next, as terminal emulators store wide
73-
# glyphs. Height is unbounded because a capture has no scrollback to
74-
# lose, width because the writer has already truncated to the
75-
# terminal.
68+
# A grid of lines with no viewport. Lines stay compact strings while
69+
# output only appends to them; moving back to edit one promotes it to
70+
# an array with one cell per terminal column. This keeps ordinary logs
71+
# proportional to their input while preserving terminal semantics for
72+
# repainted rows. Height is unbounded because a capture has no
73+
# scrollback to lose, width because the writer has already truncated
74+
# to the terminal.
7675
class Screen
7776
#: -> void
7877
def initialize
79-
@lines = [[]] #: Array[Array[String]]
78+
@lines = [+''] #: Array[String | Array[String]]
79+
# Compact strings need their terminal width because String#length
80+
# is not a column count for wide graphemes. Promoted rows use the
81+
# cell array's length and have a nil entry here.
82+
@widths = [0] #: Array[Integer?]
83+
# Only the current compact row needs its trailing grapheme cached.
84+
# Returning to a nonempty row without a cache promotes it.
85+
@trailing_cluster = nil #: String?
8086
@row = 0 #: Integer
8187
@col = 0 #: Integer
8288
@padding = 0 #: Integer
8389
@saved = [0, 0] #: Array[Integer]
84-
@conjured = {}.compare_by_identity #: Hash[Array[String], bool]
85-
@alternate = nil #: [Array[Array[String]], Integer, Integer, Array[Integer], Integer]?
90+
@conjured = {}.compare_by_identity #: Hash[String | Array[String], bool]
91+
@alternate = nil #: [Array[String | Array[String]], Array[Integer?], Integer, Integer, Array[Integer], Integer]?
8692
end
8793

8894
#: (String text) -> void
8995
def write(text)
9096
line = materialize(@row)
97+
if line.is_a?(String) && @col == @widths.fetch(@row)
98+
if @trailing_cluster || line.empty?
99+
append(line, text)
100+
return
101+
end
102+
end
103+
104+
line = promote(@row)
91105
if text.ascii_only?
92106
# Every character in an ASCII printable run is one column
93107
# wide, so the whole run lands in one splice.
@@ -118,7 +132,7 @@ def write(text)
118132
# terminal; the gap only becomes real if something writes into it.
119133
#: (Integer rows) -> void
120134
def move_rows(rows)
121-
@row = (@row + rows).clamp(0, @lines.length - 1 + room)
135+
change_row((@row + rows).clamp(0, @lines.length - 1 + room))
122136
end
123137

124138
#: (Integer cols) -> void
@@ -131,7 +145,7 @@ def move_columns(cols)
131145
#: (Integer col) -> void
132146
def column(col)
133147
limit = MAX_COLUMN
134-
limit = [@lines.fetch(@row).length, limit].max if @row < @lines.length
148+
limit = [line_width(@row), limit].max if @row < @lines.length
135149
@col = col.clamp(0, limit)
136150
end
137151

@@ -140,8 +154,11 @@ def column(col)
140154
#: -> void
141155
def line_feed
142156
materialize(@row)
143-
@row += 1
144-
@lines << [] while @lines.length <= @row
157+
change_row(@row + 1)
158+
while @lines.length <= @row
159+
@lines << +''
160+
@widths << 0
161+
end
145162
end
146163

147164
# A capture holds the writer's bare \n, but the tty driver's ONLCR
@@ -172,7 +189,7 @@ def save_cursor
172189

173190
#: -> void
174191
def restore_cursor
175-
@row = @saved.fetch(0)
192+
change_row(@saved.fetch(0))
176193
@col = @saved.fetch(1)
177194
end
178195

@@ -182,6 +199,27 @@ def erase_line(mode)
182199

183200
# Modes a terminal doesn't define are ignored, not coerced to zero.
184201
line = @lines.fetch(@row)
202+
if line.is_a?(String)
203+
width = @widths.fetch(@row).to_i
204+
case mode
205+
when 0
206+
return if @col >= width
207+
208+
if @col.zero?
209+
line.clear
210+
@widths[@row] = 0
211+
invalidate_trailing_cluster
212+
return
213+
end
214+
when 2
215+
line.clear
216+
@widths[@row] = 0
217+
invalidate_trailing_cluster
218+
return
219+
end
220+
end
221+
222+
line = promote(@row)
185223
case mode
186224
when 0
187225
split_wide(line, @col)
@@ -200,15 +238,22 @@ def insert_lines(count)
200238
inserted = [count, room].min
201239
@padding += inserted
202240
@lines[@row, 0] = Array.new(inserted) { conjure }
241+
@widths[@row, 0] = Array.new(inserted, 0)
242+
invalidate_trailing_cluster
203243
end
204244

205245
# Deleting a conjured row hands its charge back: an insert and its
206246
# paired delete net to nothing.
207247
#: (Integer count) -> void
208248
def delete_lines(count)
209249
removed = @lines.slice!(@row, count)
250+
@widths.slice!(@row, count)
210251
removed&.each { |line| @padding -= 1 if @conjured.delete(line) }
211-
@lines << [] if @lines.empty?
252+
if @lines.empty?
253+
@lines << +''
254+
@widths << 0
255+
end
256+
invalidate_trailing_cluster
212257
end
213258

214259
# The alternate screen holds a full-screen UI -- a prompt, a pager
@@ -219,11 +264,13 @@ def delete_lines(count)
219264
def enter_alternate
220265
return if @alternate
221266

222-
@alternate = [@lines, @row, @col, @saved, @padding]
223-
@lines = [[]]
267+
@alternate = [@lines, @widths, @row, @col, @saved, @padding]
268+
@lines = [+'']
269+
@widths = [0]
224270
@row = 0
225271
@col = 0
226272
@saved = [0, 0]
273+
invalidate_trailing_cluster
227274
end
228275

229276
# Leaving discards the scratch grid and restores the saved one,
@@ -236,8 +283,9 @@ def exit_alternate
236283
return unless stash
237284

238285
@lines.each { |line| @conjured.delete(line) }
239-
@lines, @row, @col, @saved, @padding = stash
286+
@lines, @widths, @row, @col, @saved, @padding = stash
240287
@alternate = nil
288+
invalidate_trailing_cluster
241289
end
242290

243291
# Trailing whitespace is trimmed from every line: a terminal renders
@@ -249,13 +297,57 @@ def exit_alternate
249297
def to_s
250298
lines = @lines
251299
if (stash = @alternate)
252-
lines = stash.fetch(0) #: as Array[Array[String]]
300+
lines = stash.fetch(0) #: as Array[String | Array[String]]
253301
end
254-
lines.map { |line| line.join.rstrip }.join("\n")
302+
lines.map { |line| line.is_a?(String) ? line.rstrip : line.join.rstrip }.join("\n")
255303
end
256304

257305
private
258306

307+
# Appending to a compact row avoids retaining one String object per
308+
# terminal cell. Re-segment only the cached boundary grapheme because
309+
# presentation sequences can split a combining sequence.
310+
#: (String line, String text) -> void
311+
def append(line, text)
312+
if text.ascii_only?
313+
mark_content(line)
314+
line << text
315+
@col += text.length
316+
@widths[@row] = @col
317+
@trailing_cluster = text[-1]
318+
return
319+
end
320+
321+
clusters = text.grapheme_clusters
322+
previous = @trailing_cluster
323+
if previous
324+
combined = "#{previous}#{text}".grapheme_clusters
325+
if combined.first != previous
326+
old_width = TerminalWidth.grapheme_width(previous)
327+
replacement = combined.shift.to_s
328+
combined.reject! { |cluster| cluster.match?(LEADING_MARK) }
329+
line.delete_suffix!(previous)
330+
line << replacement << combined.join
331+
@col += TerminalWidth.grapheme_width(replacement) - old_width
332+
@col += combined.sum { |cluster| TerminalWidth.grapheme_width(cluster) }
333+
@widths[@row] = @col
334+
@trailing_cluster = combined.last || replacement
335+
return
336+
end
337+
end
338+
339+
clusters.reject! { |cluster| cluster.match?(LEADING_MARK) }
340+
return if clusters.empty?
341+
342+
mark_content(line)
343+
clusters.each do |cluster|
344+
line << cluster
345+
@col += TerminalWidth.grapheme_width(cluster)
346+
end
347+
@widths[@row] = @col
348+
@trailing_cluster = clusters.last
349+
end
350+
259351
# A presentation sequence can split one grapheme into separate
260352
# printable runs: "e\e[31m\u0301" is still one displayed cell.
261353
# Re-segment the new run with the glyph immediately before the
@@ -317,7 +409,7 @@ def put(line, cells)
317409
# A conjured row that receives display content is content after all:
318410
# hand its charge back. A leading combining mark at column zero is
319411
# ignored before reaching here, so it cannot spend the padding cap.
320-
#: (Array[String] line) -> void
412+
#: (String | Array[String] line) -> void
321413
def mark_content(line)
322414
@padding -= 1 if @conjured.delete(line)
323415
end
@@ -342,26 +434,66 @@ def room
342434
[MAX_PADDING - @padding, 0].max
343435
end
344436

345-
#: -> Array[String]
437+
#: -> String
346438
def conjure
347-
line = [] #: Array[String]
439+
line = +''
348440
@conjured[line] = true
349441
line
350442
end
351443

352-
# Fills in the rows a cursor move skipped, so every entry stays an
353-
# Array. The budget can overshoot by one move's worth: move_rows
354-
# clamps against the room left when it runs, which insert_lines may
355-
# since have spent.
356-
#: (Integer row) -> Array[String]
444+
# Fills in rows a cursor move skipped. The budget can overshoot by
445+
# one move's worth: move_rows clamps against the room left when it
446+
# runs, which insert_lines may since have spent.
447+
#: (Integer row) -> (String | Array[String])
357448
def materialize(row)
358449
gap = row + 1 - @lines.length
359450
if gap.positive?
360451
@padding += gap
361-
@lines << conjure while @lines.length <= row
452+
while @lines.length <= row
453+
@lines << conjure
454+
@widths << 0
455+
end
362456
end
363457
@lines.fetch(row)
364458
end
459+
460+
# Converts a compact row to terminal cells the first time an
461+
# operation needs to edit it in place.
462+
#: (Integer row) -> Array[String]
463+
def promote(row)
464+
line = @lines.fetch(row)
465+
return line unless line.is_a?(String)
466+
467+
cells = [] #: Array[String]
468+
line.grapheme_clusters.each do |cluster|
469+
cells << cluster
470+
cells << CONTINUATION if TerminalWidth.grapheme_width(cluster) == 2
471+
end
472+
@conjured[cells] = true if @conjured.delete(line)
473+
@lines[row] = cells
474+
@widths[row] = nil
475+
invalidate_trailing_cluster if row == @row
476+
cells
477+
end
478+
479+
#: (Integer row) -> Integer
480+
def line_width(row)
481+
line = @lines.fetch(row)
482+
line.is_a?(String) ? @widths.fetch(row).to_i : line.length
483+
end
484+
485+
#: (Integer row) -> void
486+
def change_row(row)
487+
return if row == @row
488+
489+
@row = row
490+
invalidate_trailing_cluster
491+
end
492+
493+
#: -> void
494+
def invalidate_trailing_cluster
495+
@trailing_cluster = nil
496+
end
365497
end
366498

367499
class << self
@@ -474,9 +606,26 @@ def escape(screen, scanner)
474606
when "\e" then next
475607
when "\x18", "\x1a" then return :ground
476608
when /[\x20-\x2f]/
477-
# nF sequences: intermediate bytes, then one final byte.
478-
scanner.skip(/[\x20-\x2f]*[\x30-\x7e]?/)
479-
return :ground
609+
return escape_intermediate(screen, scanner)
610+
when SIMPLE_CONTROL then simple_control(screen, char)
611+
else return :ground
612+
end
613+
end
614+
:ground
615+
end
616+
617+
# ESC intermediate state: collect through the final byte while
618+
# executing embedded C0 controls and ignoring DEL. Bulk-skipping this
619+
# tail would end the sequence at an embedded control, exposing its
620+
# final byte as printable text.
621+
#: (Screen screen, StringScanner scanner) -> Symbol
622+
def escape_intermediate(screen, scanner)
623+
until scanner.eos?
624+
case (char = scanner.getch.to_s)
625+
when /[\x30-\x7e]/ then return :ground
626+
when /[\x20-\x2f]/ then next
627+
when "\e" then return :escape
628+
when "\x18", "\x1a" then return :ground
480629
when SIMPLE_CONTROL then simple_control(screen, char)
481630
else return :ground
482631
end
@@ -548,7 +697,10 @@ def apply(screen, params, intermediates, final)
548697
# tracks, except the alternate screen: a full-screen UI draws
549698
# there and a terminal discards it on exit, so it must not reach
550699
# the replayed scrollback either.
551-
if params == '?1049'
700+
if params.match?(/\A\?[\d;]*\z/)
701+
modes = params.delete_prefix('?').split(';')
702+
return unless modes.include?('1049')
703+
552704
case final
553705
when 'h' then screen.enter_alternate
554706
when 'l' then screen.exit_alternate

0 commit comments

Comments
 (0)