mirror of
https://github.com/gitlabhq/gitlabhq.git
synced 2025-08-03 15:58:20 +00:00
607 lines
15 KiB
Ruby
607 lines
15 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# ANSI color library
|
|
#
|
|
# Implementation per http://en.wikipedia.org/wiki/ANSI_escape_code
|
|
module Gitlab
|
|
module Ci
|
|
module Ansi2html
|
|
# Timestamp line prefix format:
|
|
# <timestamp> <stream number><stream type><full line type>
|
|
# - timestamp: UTC RFC3339 timestamp
|
|
# - stream number: 1 byte (2 hex chars) stream number
|
|
# - stream type: E/O (Err or Out)
|
|
# - full line type: `+` if line is continuation of previous line, ` ` otherwise
|
|
TIMESTAMP_HEADER_REGEX = Gitlab::Ci::Trace::Stream::TIMESTAMP_HEADER_REGEX
|
|
TIMESTAMP_HEADER_DATETIME_LENGTH = Gitlab::Ci::Trace::Stream::TIMESTAMP_HEADER_DATETIME_LENGTH
|
|
TIMESTAMP_HEADER_LENGTH = Gitlab::Ci::Trace::Stream::TIMESTAMP_HEADER_LENGTH
|
|
|
|
# keys represent the trailing digit in color changing command (30-37, 40-47, 90-97. 100-107)
|
|
COLOR = {
|
|
0 => 'black', # not that this is gray in the intense color table
|
|
1 => 'red',
|
|
2 => 'green',
|
|
3 => 'yellow',
|
|
4 => 'blue',
|
|
5 => 'magenta',
|
|
6 => 'cyan',
|
|
7 => 'white' # not that this is gray in the dark (aka default) color table
|
|
}.freeze
|
|
|
|
STYLE_SWITCHES = {
|
|
bold: 0x01,
|
|
italic: 0x02,
|
|
underline: 0x04,
|
|
conceal: 0x08,
|
|
cross: 0x10
|
|
}.freeze
|
|
|
|
def self.convert(ansi, state = nil)
|
|
Converter.new.convert(ansi, state)
|
|
end
|
|
|
|
Result = Struct.new(:html, :state, :append, :truncated, :offset, :size, :total, keyword_init: true) # rubocop:disable Lint/StructNewOverride
|
|
|
|
class Converter
|
|
include EncodingHelper
|
|
|
|
STATE_PARAMS = [:offset, :n_open_tags, :fg_color, :bg_color, :style_mask, :sections, :lineno_in_section].freeze
|
|
|
|
def convert(stream, new_state)
|
|
reset_state
|
|
restore_state(new_state, stream) if new_state.present?
|
|
|
|
append = false
|
|
truncated = false
|
|
|
|
cur_offset = stream.tell
|
|
if cur_offset > @offset
|
|
@offset = cur_offset
|
|
truncated = true
|
|
else
|
|
stream.seek(@offset)
|
|
append = @offset > 0
|
|
end
|
|
|
|
start_offset = @offset
|
|
|
|
process_stream_with_lookahead(stream)
|
|
|
|
Ansi2html::Result.new(
|
|
html: @out.force_encoding(Encoding.default_external),
|
|
state: state,
|
|
append: append,
|
|
truncated: truncated,
|
|
offset: start_offset,
|
|
size: stream.tell - start_offset,
|
|
total: stream.size
|
|
)
|
|
end
|
|
|
|
def section_to_class_name(section)
|
|
section.to_s.downcase.gsub(/[^a-z0-9]/, '-')
|
|
end
|
|
|
|
private
|
|
|
|
attr_accessor :offset, :n_open_tags, :fg_color, :bg_color, :style_mask, :sections, :lineno_in_section
|
|
|
|
def process_stream_with_lookahead(stream)
|
|
# We process lines with 1-line look-back, so that we can process line continuations
|
|
previous_line = nil
|
|
|
|
stream.each_line do |line|
|
|
line = encode_utf8_no_detect(line)
|
|
handle_line(previous_line, line)
|
|
previous_line = line
|
|
end
|
|
|
|
handle_line(previous_line, nil) if previous_line
|
|
|
|
close_open_tags
|
|
end
|
|
|
|
def handle_line(line, next_line)
|
|
if line.nil?
|
|
# First line, initialize check for timestamps
|
|
@has_timestamps = next_line.match?(TIMESTAMP_HEADER_REGEX)
|
|
return
|
|
end
|
|
|
|
is_continued = @has_timestamps && next_line&.at(TIMESTAMP_HEADER_LENGTH - 1) == '+'
|
|
|
|
# Continued lines contain an ignored \n character at the end, so we can chop it off
|
|
line.delete_suffix!("\n") if is_continued
|
|
|
|
if @current_line_buffer.nil?
|
|
@current_line_buffer = line
|
|
else
|
|
# Ignore timestamp from continued line
|
|
@current_line_buffer << line[TIMESTAMP_HEADER_LENGTH..]
|
|
end
|
|
|
|
return if is_continued
|
|
|
|
consume_line(@current_line_buffer)
|
|
@current_line_buffer = nil
|
|
end
|
|
|
|
def consume_line(line)
|
|
scanner = StringScanner.new(line)
|
|
last_offset = nil
|
|
line_start_offset = @offset
|
|
|
|
# Consume tokens until end of string or no more progress
|
|
until scanner.eos? || @offset == last_offset
|
|
last_offset = @offset
|
|
consume_token(scanner, @offset == line_start_offset)
|
|
end
|
|
end
|
|
|
|
def consume_token(scanner, at_line_start)
|
|
return if at_line_start && skip_timestamp(scanner) # Avoid regex on timestamps
|
|
|
|
if scanner.scan(Gitlab::Regex.build_trace_section_regex)
|
|
handle_section(scanner)
|
|
elsif scanner.scan(/\e([@-_])(.*?)([@-~])/)
|
|
handle_sequence(scanner)
|
|
elsif scanner.scan(/\e(?:[@-_].*?)?$/)
|
|
return
|
|
elsif scanner.scan('<')
|
|
write_in_tag '<'
|
|
elsif scanner.scan(/\r*\n/)
|
|
handle_new_line
|
|
else
|
|
write_in_tag scanner.scan(/./m)
|
|
end
|
|
|
|
@offset += scanner.matched_size
|
|
end
|
|
|
|
def has_timestamp_prefix?(line)
|
|
# Avoid regex on timestamps for performance
|
|
return unless @has_timestamps && line && line.length >= TIMESTAMP_HEADER_LENGTH
|
|
|
|
line[TIMESTAMP_HEADER_DATETIME_LENGTH - 1] == 'Z' &&
|
|
line[4] == '-' && line[7] == '-' && line[10] == 'T' && line[13] == ':'
|
|
end
|
|
|
|
def skip_timestamp(scanner)
|
|
return unless @has_timestamps
|
|
|
|
line = scanner.peek(TIMESTAMP_HEADER_LENGTH + 1)
|
|
return unless has_timestamp_prefix?(line)
|
|
|
|
scanner.pos += TIMESTAMP_HEADER_LENGTH
|
|
@offset += TIMESTAMP_HEADER_LENGTH
|
|
end
|
|
|
|
def handle_new_line
|
|
write_in_tag %(<br/>)
|
|
|
|
close_open_tags if @sections.any? && @lineno_in_section == 0
|
|
@lineno_in_section += 1
|
|
end
|
|
|
|
def handle_section(scanner)
|
|
action = scanner[1]
|
|
timestamp = scanner[2]
|
|
section = scanner[3]
|
|
|
|
normalized_section = section_to_class_name(section)
|
|
|
|
case action
|
|
when 'start'
|
|
handle_section_start(normalized_section, timestamp)
|
|
when 'end'
|
|
handle_section_end(normalized_section, timestamp)
|
|
end
|
|
end
|
|
|
|
def handle_section_start(section, timestamp)
|
|
return if @sections.include?(section)
|
|
|
|
@sections << section
|
|
write_raw %(<div class="section-start" data-timestamp="#{timestamp}" data-section="#{data_section_names}" role="button"></div>)
|
|
@lineno_in_section = 0
|
|
end
|
|
|
|
def handle_section_end(section, timestamp)
|
|
return unless @sections.include?(section)
|
|
|
|
# close all sections up to section
|
|
until @sections.empty?
|
|
write_raw %(<div class="section-end" data-section="#{data_section_names}"></div>)
|
|
|
|
last_section = @sections.pop
|
|
break if section == last_section
|
|
end
|
|
end
|
|
|
|
def data_section_names
|
|
@sections.join(' ')
|
|
end
|
|
|
|
def handle_sequence(scanner)
|
|
indicator = scanner[1]
|
|
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'
|
|
|
|
close_open_tags
|
|
|
|
commands = scanner[2].split ';'
|
|
if commands.empty?
|
|
reset
|
|
return
|
|
end
|
|
|
|
evaluate_command_stack(commands)
|
|
end
|
|
|
|
def evaluate_command_stack(stack)
|
|
return unless command = stack.shift
|
|
|
|
if self.respond_to?("on_#{command}", true)
|
|
self.__send__("on_#{command}", stack) # rubocop:disable GitlabSecurity/PublicSend
|
|
end
|
|
|
|
evaluate_command_stack(stack)
|
|
end
|
|
|
|
def write_in_tag(data)
|
|
ensure_open_new_tag
|
|
@out << data
|
|
end
|
|
|
|
def write_raw(data)
|
|
close_open_tags
|
|
@out << data
|
|
end
|
|
|
|
def ensure_open_new_tag
|
|
open_new_tag if @n_open_tags == 0
|
|
end
|
|
|
|
def open_new_tag
|
|
css_classes = []
|
|
|
|
unless @fg_color.nil?
|
|
fg_color = @fg_color
|
|
# Most terminals show bold colored text in the light color variant
|
|
# Let's mimic that here
|
|
if @style_mask & STYLE_SWITCHES[:bold] != 0
|
|
fg_color.sub!(/fg-([a-z]{2,}+)/, 'fg-l-\1')
|
|
end
|
|
|
|
css_classes << fg_color
|
|
end
|
|
|
|
css_classes << @bg_color unless @bg_color.nil?
|
|
|
|
STYLE_SWITCHES.each do |css_class, flag|
|
|
css_classes << "term-#{css_class}" if @style_mask & flag != 0
|
|
end
|
|
|
|
if @sections.any?
|
|
css_classes << 'section'
|
|
|
|
css_classes << if @lineno_in_section == 0
|
|
'section-header'
|
|
else
|
|
'line'
|
|
end
|
|
|
|
css_classes += sections.map { |section| "js-s-#{section}" }
|
|
end
|
|
|
|
close_open_tags
|
|
|
|
@out << if css_classes.any?
|
|
%(<span class="#{css_classes.join(' ')}">)
|
|
else
|
|
%(<span>)
|
|
end
|
|
|
|
@n_open_tags += 1
|
|
end
|
|
|
|
def close_open_tags
|
|
while @n_open_tags > 0
|
|
@out << %(</span>)
|
|
@n_open_tags -= 1
|
|
end
|
|
end
|
|
|
|
def reset_state
|
|
@offset = 0
|
|
@n_open_tags = 0
|
|
@out = +''
|
|
@sections = []
|
|
@lineno_in_section = 0
|
|
@current_line_buffer = nil
|
|
@has_timestamps = nil
|
|
reset
|
|
end
|
|
|
|
def state
|
|
state = STATE_PARAMS.index_with do |param|
|
|
send(param) # rubocop:disable GitlabSecurity/PublicSend
|
|
end
|
|
Base64.urlsafe_encode64(state.to_json)
|
|
end
|
|
|
|
def restore_state(new_state, stream)
|
|
state = Base64.urlsafe_decode64(new_state)
|
|
state = Gitlab::Json.parse(state, symbolize_names: true)
|
|
return if state[:offset].to_i > stream.size
|
|
|
|
STATE_PARAMS.each do |param|
|
|
send("#{param}=".to_sym, state[param]) # rubocop:disable GitlabSecurity/PublicSend
|
|
end
|
|
end
|
|
|
|
def reset
|
|
@fg_color = nil
|
|
@bg_color = nil
|
|
@style_mask = 0
|
|
end
|
|
|
|
def enable(flag)
|
|
@style_mask |= flag
|
|
end
|
|
|
|
def disable(flag)
|
|
@style_mask &= ~flag
|
|
end
|
|
|
|
def set_fg_color(color_index, prefix = nil)
|
|
@fg_color = get_term_color_class(color_index, ["fg", prefix])
|
|
end
|
|
|
|
def set_bg_color(color_index, prefix = nil)
|
|
@bg_color = get_term_color_class(color_index, ["bg", prefix])
|
|
end
|
|
|
|
def get_term_color_class(color_index, prefix)
|
|
color_name = COLOR[color_index]
|
|
return if color_name.nil?
|
|
|
|
get_color_class(["term", prefix, color_name])
|
|
end
|
|
|
|
def set_fg_color_256(command_stack)
|
|
css_class = get_xterm_color_class(command_stack, "fg")
|
|
@fg_color = css_class unless css_class.nil?
|
|
end
|
|
|
|
def set_bg_color_256(command_stack)
|
|
css_class = get_xterm_color_class(command_stack, "bg")
|
|
@bg_color = css_class unless css_class.nil?
|
|
end
|
|
|
|
def get_xterm_color_class(command_stack, prefix)
|
|
# the 38 and 48 commands have to be followed by "5" and the color index
|
|
return unless command_stack.length >= 2
|
|
return unless command_stack[0] == "5"
|
|
|
|
command_stack.shift # ignore the "5" command
|
|
color_index = command_stack.shift.to_i
|
|
|
|
return unless color_index >= 0
|
|
return unless color_index <= 255
|
|
|
|
get_color_class(["xterm", prefix, color_index])
|
|
end
|
|
|
|
def get_color_class(segments)
|
|
[segments].flatten.compact.join('-')
|
|
end
|
|
|
|
def on_0(_)
|
|
reset
|
|
end
|
|
|
|
def on_1(_)
|
|
enable(STYLE_SWITCHES[:bold])
|
|
end
|
|
|
|
def on_3(_)
|
|
enable(STYLE_SWITCHES[:italic])
|
|
end
|
|
|
|
def on_4(_)
|
|
enable(STYLE_SWITCHES[:underline])
|
|
end
|
|
|
|
def on_8(_)
|
|
enable(STYLE_SWITCHES[:conceal])
|
|
end
|
|
|
|
def on_9(_)
|
|
enable(STYLE_SWITCHES[:cross])
|
|
end
|
|
|
|
def on_21(_)
|
|
disable(STYLE_SWITCHES[:bold])
|
|
end
|
|
|
|
def on_22(_)
|
|
disable(STYLE_SWITCHES[:bold])
|
|
end
|
|
|
|
def on_23(_)
|
|
disable(STYLE_SWITCHES[:italic])
|
|
end
|
|
|
|
def on_24(_)
|
|
disable(STYLE_SWITCHES[:underline])
|
|
end
|
|
|
|
def on_28(_)
|
|
disable(STYLE_SWITCHES[:conceal])
|
|
end
|
|
|
|
def on_29(_)
|
|
disable(STYLE_SWITCHES[:cross])
|
|
end
|
|
|
|
def on_30(_)
|
|
set_fg_color(0)
|
|
end
|
|
|
|
def on_31(_)
|
|
set_fg_color(1)
|
|
end
|
|
|
|
def on_32(_)
|
|
set_fg_color(2)
|
|
end
|
|
|
|
def on_33(_)
|
|
set_fg_color(3)
|
|
end
|
|
|
|
def on_34(_)
|
|
set_fg_color(4)
|
|
end
|
|
|
|
def on_35(_)
|
|
set_fg_color(5)
|
|
end
|
|
|
|
def on_36(_)
|
|
set_fg_color(6)
|
|
end
|
|
|
|
def on_37(_)
|
|
set_fg_color(7)
|
|
end
|
|
|
|
def on_38(stack)
|
|
set_fg_color_256(stack)
|
|
end
|
|
|
|
def on_39(_)
|
|
set_fg_color(9)
|
|
end
|
|
|
|
def on_40(_)
|
|
set_bg_color(0)
|
|
end
|
|
|
|
def on_41(_)
|
|
set_bg_color(1)
|
|
end
|
|
|
|
def on_42(_)
|
|
set_bg_color(2)
|
|
end
|
|
|
|
def on_43(_)
|
|
set_bg_color(3)
|
|
end
|
|
|
|
def on_44(_)
|
|
set_bg_color(4)
|
|
end
|
|
|
|
def on_45(_)
|
|
set_bg_color(5)
|
|
end
|
|
|
|
def on_46(_)
|
|
set_bg_color(6)
|
|
end
|
|
|
|
def on_47(_)
|
|
set_bg_color(7)
|
|
end
|
|
|
|
def on_48(stack)
|
|
set_bg_color_256(stack)
|
|
end
|
|
|
|
def on_49(_)
|
|
set_bg_color(9)
|
|
end
|
|
|
|
def on_90(_)
|
|
set_fg_color(0, 'l')
|
|
end
|
|
|
|
def on_91(_)
|
|
set_fg_color(1, 'l')
|
|
end
|
|
|
|
def on_92(_)
|
|
set_fg_color(2, 'l')
|
|
end
|
|
|
|
def on_93(_)
|
|
set_fg_color(3, 'l')
|
|
end
|
|
|
|
def on_94(_)
|
|
set_fg_color(4, 'l')
|
|
end
|
|
|
|
def on_95(_)
|
|
set_fg_color(5, 'l')
|
|
end
|
|
|
|
def on_96(_)
|
|
set_fg_color(6, 'l')
|
|
end
|
|
|
|
def on_97(_)
|
|
set_fg_color(7, 'l')
|
|
end
|
|
|
|
def on_99(_)
|
|
set_fg_color(9, 'l')
|
|
end
|
|
|
|
def on_100(_)
|
|
set_bg_color(0, 'l')
|
|
end
|
|
|
|
def on_101(_)
|
|
set_bg_color(1, 'l')
|
|
end
|
|
|
|
def on_102(_)
|
|
set_bg_color(2, 'l')
|
|
end
|
|
|
|
def on_103(_)
|
|
set_bg_color(3, 'l')
|
|
end
|
|
|
|
def on_104(_)
|
|
set_bg_color(4, 'l')
|
|
end
|
|
|
|
def on_105(_)
|
|
set_bg_color(5, 'l')
|
|
end
|
|
|
|
def on_106(_)
|
|
set_bg_color(6, 'l')
|
|
end
|
|
|
|
def on_107(_)
|
|
set_bg_color(7, 'l')
|
|
end
|
|
|
|
def on_109(_)
|
|
set_bg_color(9, 'l')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|