diff --git a/lib/gitlab/ci/tags/parser.rb b/lib/gitlab/ci/tags/parser.rb new file mode 100644 index 0000000000000000000000000000000000000000..ff1296187a66692b0eb7df3c06dd4d1f8cd96456 --- /dev/null +++ b/lib/gitlab/ci/tags/parser.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Tags + class Parser + def initialize(tag_list) + @tag_list = tag_list + end + + def parse + string = @tag_list + + string = string.join(', ') if string.respond_to?(:join) + TagList.new.tap do |tag_list| + string = string.to_s.dup + + string.gsub!(double_quote_pattern) do + # Append the matched tag to the tag list + tag_list << Regexp.last_match[2] + # Return the matched delimiter ($3) to replace the matched items + '' + end + + string.gsub!(single_quote_pattern) do + # Append the matched tag ($2) to the tag list + tag_list << Regexp.last_match[2] + # Return an empty string to replace the matched items + '' + end + + # split the string by the delimiter + # and add to the tag_list + tag_list.add(string.split(Regexp.new(delimiter))) + end + end + + private + + def delimiter + ',' + end + + # ( # Tag start delimiter ($1) + # \A | # Either string start or + # #{delimiter} # a delimiter + # ) + # \s*" # quote (") optionally preceded by whitespace + # (.*?) # Tag ($2) + # "\s* # quote (") optionally followed by whitespace + # (?= # Tag end delimiter (not consumed; is zero-length lookahead) + # #{delimiter}\s* | # Either a delimiter optionally followed by whitespace or + # \z # string end + # ) + def double_quote_pattern + /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/ + end + + # ( # Tag start delimiter ($1) + # \A | # Either string start or + # #{delimiter} # a delimiter + # ) + # \s*' # quote (') optionally preceded by whitespace + # (.*?) # Tag ($2) + # '\s* # quote (') optionally followed by whitespace + # (?= # Tag end delimiter (not consumed; is zero-length lookahead) + # #{delimiter}\s* | d # Either a delimiter optionally followed by whitespace or + # \z # string end + # ) + def single_quote_pattern + /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/ + end + end + end + end +end diff --git a/lib/gitlab/ci/tags/tag_list.rb b/lib/gitlab/ci/tags/tag_list.rb new file mode 100644 index 0000000000000000000000000000000000000000..953138dc4ea8d42b0d4cbfc8700629feccdd2348 --- /dev/null +++ b/lib/gitlab/ci/tags/tag_list.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Tags + class TagList < Array + attr_accessor :owner, :parser + + def initialize(*args) + @parser = Parser + add(*args) + end + + def add(*names) + extract_and_apply_options!(names) + concat(names) + clean! + self + end + + def <<(obj) + add(obj) + end + + def +(other) + TagList.new.add(self).add(other) + end + + def concat(other_tag_list) + super(other_tag_list).clean! + self + end + + def remove(*names) + extract_and_apply_options!(names) + delete_if { |name| names.include?(name) } + self + end + + def to_s + tags = frozen? ? dup : self + tags.clean! + + tags.map do |name| + name.index(',') ? "\"#{name}\"" : name + end.join(', ') + end + + protected + + def clean! + reject!(&:blank?) + map!(&:to_s) + map!(&:strip) + uniq! + + self + end + + private + + def extract_and_apply_options!(args) + options = args.last.is_a?(Hash) ? args.pop : {} + options.assert_valid_keys :parse, :parser + + parser = options[:parser] || @parser + + args.map! { |a| parser.new(a).parse } if options[:parse] || options[:parser] + + args.flatten! + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/tags/parser_spec.rb b/spec/lib/gitlab/ci/tags/parser_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2119e6532c3b1279dd04539ac8a6b0c9262c940e --- /dev/null +++ b/spec/lib/gitlab/ci/tags/parser_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Tags::Parser, feature_category: :continuous_integration do + let(:parser) { described_class.new(input) } + + subject { parser.parse } + + context 'with an empty array' do + let(:input) { [] } + + it { is_expected.to be_empty } + end + + context 'with regular data' do + let(:input) { 'cool, data, I,have' } + + it { is_expected.to match_array(%w[cool data I have]) } + end + + context 'with multiple quoted tags' do + let(:input) { '"Ruby Monsters","eat Katzenzungen"' } + + it { is_expected.to match_array(['Ruby Monsters', 'eat Katzenzungen']) } + end + + context 'with single quotes' do + let(:input) { "'I have', cool, data" } + + it { is_expected.to match_array(['I have', 'cool', 'data']) } + end + + context 'with double quotes' do + let(:input) { '"I have",cool, data' } + + it { is_expected.to match_array(['I have', 'cool', 'data']) } + end +end diff --git a/spec/lib/gitlab/ci/tags/tag_list_spec.rb b/spec/lib/gitlab/ci/tags/tag_list_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..920bf1f40d4f0978ddbdcabf8577cd40693506aa --- /dev/null +++ b/spec/lib/gitlab/ci/tags/tag_list_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Tags::TagList, feature_category: :continuous_integration do + let(:tag_list) { described_class.new('chunky', 'bacon') } + let(:another_tag_list) { described_class.new('chunky', 'crazy', 'cut') } + + it { is_expected.to be_a_kind_of(Array) } + + describe '#add' do + it 'adds a new word' do + tag_list.add('cool') + + expect(tag_list).to include('cool') + end + + it 'adds delimited lists of words' do + tag_list.add('cool, fox', parse: true) + + expect(tag_list).to include('cool', 'fox') + end + + it 'adds delimited list of words with quoted delimiters' do + tag_list.add("'cool, wicked', \"really cool, really wicked\"", parse: true) + + expect(tag_list).to include('cool, wicked', 'really cool, really wicked') + end + + it 'handles other uses of quotation marks correctly' do + tag_list.add("john's cool car, mary's wicked toy", parse: true) + + expect(tag_list).to include("john's cool car", "mary's wicked toy") + end + + it 'is able to add an array of words' do + tag_list.add(%w[cool fox], parse: true) + + expect(tag_list).to include('cool', 'fox') + end + + it 'escapes tags with commas in them' do + tag_list.add('cool', 'rad,fox') + + expect(tag_list.to_s).to eq("chunky, bacon, cool, \"rad,fox\"") + end + end + + describe '#remove' do + it 'removes words' do + tag_list.remove('chunky') + + expect(tag_list).not_to include('chunky') + end + + it 'removes delimited lists of words' do + tag_list.remove('chunky, bacon', parse: true) + + expect(tag_list).to be_empty + end + + it 'removes an array of words' do + tag_list.remove(%w[chunky bacon], parse: true) + + expect(tag_list).to be_empty + end + end + + describe '#+' do + it 'does not have duplicate tags' do + new_tag_list = tag_list + another_tag_list + + expect(new_tag_list).to eq(%w[chunky bacon crazy cut]) + end + + it 'returns an instance of the same class' do + new_tag_list = tag_list + another_tag_list + + expect(new_tag_list).to be_an_instance_of(described_class) + end + end + + describe '#concat' do + it 'does not have duplicate tags' do + expect(tag_list.concat(another_tag_list)).to eq(%w[chunky bacon crazy cut]) + end + + it 'returns an instance of the same class' do + new_tag_list = tag_list.concat(another_tag_list) + + expect(new_tag_list).to be_an_instance_of(described_class) + end + + context 'without duplicates' do + let(:arr) { %w[crazy cut] } + let(:another_tag_list) { described_class.new(*arr) } + + it { expect(tag_list.concat(another_tag_list)).to eq(%w[chunky bacon crazy cut]) } + it { expect(tag_list.concat(arr)).to eq(%w[chunky bacon crazy cut]) } + end + end + + describe '#to_s' do + it 'gives a delimited list of words when converted to string' do + expect(tag_list.to_s).to eq('chunky, bacon') + end + end + + describe 'cleaning' do + it 'removes duplicates and empty spaces' do + tag_list = described_class.new('Ruby On Rails', ' Ruby on Rails ', 'Ruby on Rails', ' ', '') + + expect(tag_list.to_s).to eq('Ruby On Rails, Ruby on Rails') + end + end +end