diff --git a/config/feature_flags/development/diff_line_syntax_highlighting.yml b/config/feature_flags/development/diff_line_syntax_highlighting.yml new file mode 100644 index 0000000000000000000000000000000000000000..3244dad6c24108e95d1f84fa50ff1bc52428bddd --- /dev/null +++ b/config/feature_flags/development/diff_line_syntax_highlighting.yml @@ -0,0 +1,8 @@ +--- +name: diff_line_syntax_highlighting +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56108 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/324159 +milestone: '13.10' +type: development +group: group::source code +default_enabled: false diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb index 23859e2573eb36aa14a5d3945358fa55704ea74c..8385bbbb3ded598d1fe42b9b25ff075e85503138 100644 --- a/lib/gitlab/diff/highlight.rb +++ b/lib/gitlab/diff/highlight.rb @@ -86,6 +86,41 @@ def apply_marker_ranges_highlight(diff_line, rich_line, index) def highlight_line(diff_line) return unless diff_file && diff_file.diff_refs + if Feature.enabled?(:diff_line_syntax_highlighting, project, default_enabled: :yaml) + diff_line_highlighting(diff_line) + else + blob_highlighting(diff_line) + end + end + + def diff_line_highlighting(diff_line) + rich_line = syntax_highlighter(diff_line).highlight( + diff_line.text(prefix: false), + context: { line_number: diff_line.line } + )&.html_safe + + # Only update text if line is found. This will prevent + # issues with submodules given the line only exists in diff content. + if rich_line + line_prefix = diff_line.text =~ /\A(.)/ ? Regexp.last_match(1) : ' ' + rich_line.prepend(line_prefix).concat("\n") + end + end + + def syntax_highlighter(diff_line) + path = diff_line.removed? ? diff_file.old_path : diff_file.new_path + + @syntax_highlighter ||= {} + @syntax_highlighter[path] ||= Gitlab::Highlight.new( + path, + @raw_lines, + language: repository&.gitattribute(path, 'gitlab-language') + ) + end + + # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/324159 + # ------------------------------------------------------------------------ + def blob_highlighting(diff_line) rich_line = if diff_line.unchanged? || diff_line.added? new_lines[diff_line.new_pos - 1]&.html_safe @@ -102,6 +137,7 @@ def highlight_line(diff_line) end # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/324638 + # ------------------------------------------------------------------------ def inline_diffs @inline_diffs ||= InlineDiff.for_lines(@raw_lines) end diff --git a/lib/gitlab/diff/highlight_cache.rb b/lib/gitlab/diff/highlight_cache.rb index 2192582348c8fc95d46fcce8c0f0115166f6be08..209462fd6e9dd007e726169f49566ec4e59ed14d 100644 --- a/lib/gitlab/diff/highlight_cache.rb +++ b/lib/gitlab/diff/highlight_cache.rb @@ -71,10 +71,12 @@ def key strong_memoize(:redis_key) do [ 'highlighted-diff-files', - diffable.cache_key, VERSION, + diffable.cache_key, + VERSION, diff_options, Feature.enabled?(:introduce_marker_ranges, diffable.project, default_enabled: :yaml), - Feature.enabled?(:use_marker_ranges, diffable.project, default_enabled: :yaml) + Feature.enabled?(:use_marker_ranges, diffable.project, default_enabled: :yaml), + Feature.enabled?(:diff_line_syntax_highlighting, diffable.project, default_enabled: :yaml) ].join(":") end end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 444928b4310f551375e7aa655863f9d67b231656..66f506ec3aa1f8f0a447c481fe018b6065efd7cd 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -9,8 +9,8 @@ class Line SERIALIZE_KEYS = %i(line_code rich_text text type index old_pos new_pos).freeze attr_reader :line_code, :marker_ranges - attr_writer :rich_text - attr_accessor :text, :index, :type, :old_pos, :new_pos + attr_writer :text, :rich_text + attr_accessor :index, :type, :old_pos, :new_pos def initialize(text, type, index, old_pos, new_pos, parent_file: nil, line_code: nil, rich_text: nil) @text, @type, @index = text, type, index @@ -54,6 +54,12 @@ def set_marker_ranges(marker_ranges) @marker_ranges = marker_ranges end + def text(prefix: true) + return @text if prefix + + @text&.slice(1..).to_s + end + def old_line old_pos unless added? || meta? end diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb index 40dee0142b984457edde249c92f872b2563a629e..e4527f06eff2efc5b64fb4bbebf50edfbdda7ecb 100644 --- a/lib/gitlab/highlight.rb +++ b/lib/gitlab/highlight.rb @@ -20,7 +20,9 @@ def initialize(blob_name, blob_content, language: nil) @blob_content = blob_content end - def highlight(text, continue: true, plain: false) + def highlight(text, continue: false, plain: false, context: {}) + @context = context + plain ||= text.length > MAXIMUM_TEXT_HIGHLIGHT_SIZE highlighted_text = highlight_text(text, continue: continue, plain: plain) @@ -38,6 +40,8 @@ def lexer private + attr_reader :context + def custom_language return unless @language @@ -53,13 +57,13 @@ def highlight_text(text, continue: true, plain: false) end def highlight_plain(text) - @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe + @formatter.format(Rouge::Lexers::PlainText.lex(text), context).html_safe end def highlight_rich(text, continue: true) tag = lexer.tag tokens = lexer.lex(text, continue: continue) - Timeout.timeout(timeout_time) { @formatter.format(tokens, tag: tag).html_safe } + Timeout.timeout(timeout_time) { @formatter.format(tokens, context.merge(tag: tag)).html_safe } rescue Timeout::Error => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) highlight_plain(text) diff --git a/lib/rouge/formatters/html_gitlab.rb b/lib/rouge/formatters/html_gitlab.rb index 8f18d6433e00bfd83e22c494512d46b79fa616c4..e0e9677fac7d80312c6a9eaaf1eaa7ea045d62a1 100644 --- a/lib/rouge/formatters/html_gitlab.rb +++ b/lib/rouge/formatters/html_gitlab.rb @@ -7,10 +7,11 @@ class HTMLGitlab < Rouge::Formatters::HTML # Creates a new Rouge::Formatter::HTMLGitlab instance. # - # [+tag+] The tag (language) of the lexer used to generate the formatted tokens + # [+tag+] The tag (language) of the lexer used to generate the formatted tokens + # [+line_number+] The line number used to populate line IDs def initialize(options = {}) - @line_number = 1 @tag = options[:tag] + @line_number = options[:line_number] || 1 end def stream(tokens) diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb index 8d29b001f8d5b7a012045cdb313087693e8ff15f..4c56911e6652188b6aa6ad19cbb590bdbddd5768 100644 --- a/spec/lib/gitlab/diff/highlight_cache_spec.rb +++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb @@ -238,26 +238,36 @@ subject { cache.key } it 'returns cache key' do - is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true:true") + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true:true:true") end - context 'when feature flag is disabled' do + context 'when the `introduce_marker_ranges` feature flag is disabled' do before do stub_feature_flags(introduce_marker_ranges: false) end it 'returns the original version of the cache' do - is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:false:true") + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:false:true:true") end end - context 'when use marker ranges feature flag is disabled' do + context 'when the `use_marker_ranges` feature flag is disabled' do before do stub_feature_flags(use_marker_ranges: false) end it 'returns the original version of the cache' do - is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true:false") + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true:false:true") + end + end + + context 'when the `diff_line_syntax_highlighting` feature flag is disabled' do + before do + stub_feature_flags(diff_line_syntax_highlighting: false) + end + + it 'returns the original version of the cache' do + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true:true:false") end end end diff --git a/spec/lib/gitlab/diff/line_spec.rb b/spec/lib/gitlab/diff/line_spec.rb index a40cd99f6f86e8f30dafc2d55ed659a5e736c341..949def599ae6a3474eaaef6316e2438607fd4abc 100644 --- a/spec/lib/gitlab/diff/line_spec.rb +++ b/spec/lib/gitlab/diff/line_spec.rb @@ -45,6 +45,29 @@ end end + describe '#text' do + let(:line) { described_class.new(raw_diff, 'new', 0, 0, 0) } + let(:raw_diff) { '+Hello' } + + it 'returns raw diff text' do + expect(line.text).to eq('+Hello') + end + + context 'when prefix is disabled' do + it 'returns raw diff text without prefix' do + expect(line.text(prefix: false)).to eq('Hello') + end + + context 'when diff is empty' do + let(:raw_diff) { '' } + + it 'returns an empty raw diff' do + expect(line.text(prefix: false)).to eq('') + end + end + end + end + context "when setting rich text" do it 'escapes any HTML special characters in the diff chunk header' do subject = described_class.new("", "", 0, 0, 0) diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index 9271b868e360737aa3097d03e75894a9bc7d9c42..1a929373716acd37c6ddd18ece113b20c10457b6 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -79,6 +79,21 @@ def test(input): expect(result).to eq(expected) end + + context 'when start line number is set' do + let(:expected) do + %q(+aaa ++bbb +- ccc + ddd) + end + + it 'highlights each line properly' do + result = described_class.new(file_name, content).highlight(content, context: { line_number: 10 }) + + expect(result).to eq(expected) + end + end end describe 'with CRLF' do diff --git a/spec/lib/rouge/formatters/html_gitlab_spec.rb b/spec/lib/rouge/formatters/html_gitlab_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d45c8c2a8c5674e42d02f98c77b0051bbc2088dd --- /dev/null +++ b/spec/lib/rouge/formatters/html_gitlab_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Rouge::Formatters::HTMLGitlab do + describe '#format' do + subject { described_class.format(tokens, options) } + + let(:lang) { 'ruby' } + let(:lexer) { Rouge::Lexer.find_fancy(lang) } + let(:tokens) { lexer.lex("def hello", continue: false) } + let(:options) { { tag: lang } } + + it 'returns highlighted ruby code' do + code = %q{def hello} + + is_expected.to eq(code) + end + + context 'when options are empty' do + let(:options) { {} } + + it 'returns highlighted code without language' do + code = %q{def hello} + + is_expected.to eq(code) + end + end + + context 'when line number is provided' do + let(:options) { { tag: lang, line_number: 10 } } + + it 'returns highlighted ruby code with correct line number' do + code = %q{def hello} + + is_expected.to eq(code) + end + end + end +end