1
2
3
4
5
6
7
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
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
174
175
176
|
# frozen_string_literal: true
module Gitlab
module Ci
module Ansi2json
class Converter
def convert(stream, new_state)
@lines = []
@state = State.new(new_state, stream.size)
append = false
truncated = false
cur_offset = stream.tell
if cur_offset > @state.offset
@state.offset = cur_offset
truncated = true
else
stream.seek(@state.offset)
append = @state.offset > 0
end
start_offset = @state.offset
@state.new_line!(
style: Style.new(@state.inherited_style))
stream.each_line do |line|
consume_line(line)
end
# This must be assigned before flushing the current line
# or the @current_line.offset will advance to the very end
# of the trace. Instead we want @last_line_offset to always
# point to the beginning of last line.
@state.set_last_line_offset
flush_current_line
Gitlab::Ci::Ansi2json::Result.new(
lines: @lines,
state: @state.encode,
append: append,
truncated: truncated,
offset: start_offset,
stream: stream
)
end
private
def consume_line(line)
scanner = StringScanner.new(line)
consume_token(scanner) until scanner.eos?
end
def consume_token(scanner)
if scan_token(scanner, Gitlab::Regex.build_trace_section_regex, consume: false)
handle_section(scanner)
elsif scan_token(scanner, /\e([@-_])(.*?)([@-~])/)
handle_sequence(scanner)
elsif scan_token(scanner, /\e(([@-_])(.*?)?)?$/)
# stop scanning
scanner.terminate
elsif scan_token(scanner, /\r?\n/)
flush_current_line
elsif scan_token(scanner, /\r/)
# drop last line
@state.current_line.clear!
elsif scan_token(scanner, /.[^\e\r\ns]*/m)
# this is a join from all previous tokens and first letters
# it always matches at least one character `.`
# it matches everything that is not start of:
# `\e`, `<`, `\r`, `\n`, `s` (for section_start)
@state.current_line << scanner[0]
else
raise 'invalid parser state'
end
end
def scan_token(scanner, match, consume: true)
scanner.scan(match).tap do |result|
# we need to move offset as soon
# as we match the token
@state.offset += scanner.matched_size if consume && result
end
end
def handle_sequence(scanner)
indicator = scanner[1]
commands = scanner[2].split ';'
terminator = scanner[3]
# We are only interested in color and text style changes - triggered by
# sequences starting with '\e[' and ending with 'm'. Any other control
# sequence gets stripped (including stuff like "delete last line")
return unless indicator == '[' && terminator == 'm'
@state.update_style(commands)
end
def handle_section(scanner)
action = scanner[1]
timestamp = scanner[2]
section = scanner[3]
options = parse_section_options(scanner[4])
section_name = sanitize_section_name(section)
if action == 'start'
handle_section_start(scanner, section_name, timestamp, options)
elsif action == 'end'
handle_section_end(scanner, section_name, timestamp)
else
raise 'unsupported action'
end
end
def handle_section_start(scanner, section, timestamp, options)
# We make a new line for new section
flush_current_line
@state.open_section(section, timestamp, options)
# we need to consume match after handling
# the open of section, as we want the section
# marker to be refresh on incremental update
@state.offset += scanner.matched_size
end
def handle_section_end(scanner, section, timestamp)
return unless @state.section_open?(section)
# We flush the content to make the end
# of section to be a new line
flush_current_line
@state.close_section(section, timestamp)
# we need to consume match before handling
# as we want the section close marker
# not to be refreshed on incremental update
@state.offset += scanner.matched_size
# this flushes an empty line with `section_duration`
flush_current_line
end
def flush_current_line
unless @state.current_line.empty?
@lines << @state.current_line.to_h
end
@state.new_line!
end
def sanitize_section_name(section)
section.to_s.downcase.gsub(/[^a-z0-9]/, '-')
end
def parse_section_options(raw_options)
return unless raw_options
# We need to remove the square brackets and split
# by comma to get a list of the options
options = raw_options[1...-1].split ','
# Now split each option by equals to separate
# each in the format [key, value]
options.to_h { |option| option.split '=' }
end
end
end
end
end
|