diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/input.rb b/lib/gitlab/ci/pipeline/expression/lexeme/input.rb new file mode 100644 index 0000000000000000000000000000000000000000..add063f71776dee7763dee3621a8e7b03bff21b6 --- /dev/null +++ b/lib/gitlab/ci/pipeline/expression/lexeme/input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Expression + module Lexeme + class Input < Lexeme::Value + PATTERN = /\$\[\[\s*inputs\.(?\w+)\s*\]\]/ + + def self.build(string) + new(string.match(PATTERN)[:name]) + end + + def evaluate(variables = {}) + inputs = variables[:inputs] || {} + + inputs = inputs.with_indifferent_access unless inputs.is_a?(ActiveSupport::HashWithIndifferentAccess) + + inputs.fetch(@value, nil) + end + + def inspect + "$[[ inputs.#{@value} ]]" + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb b/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb index 05d5043c06ea3aaeb6cabe25b634d916c8da0c61..554c82627290e4707711e6cb1a2a2eb7764e7405 100644 --- a/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb +++ b/lib/gitlab/ci/pipeline/expression/lexeme/logical_operator.rb @@ -12,6 +12,8 @@ class LogicalOperator < Lexeme::Operator # implement an Operator that takes a greater or lesser number of arguments, a # structural change or additional Operator superclass will likely be needed. + attr_reader :left, :right + def initialize(left, right) raise OperatorError, 'Invalid left operand' unless left.respond_to? :evaluate raise OperatorError, 'Invalid right operand' unless right.respond_to? :evaluate diff --git a/lib/gitlab/ci/pipeline/expression/lexer.rb b/lib/gitlab/ci/pipeline/expression/lexer.rb index ac03ef79ccbb39162abac3579faf9b5c48b3a9b8..85ff5b32244551bca9dc4d5ac06ed13f1307ff87 100644 --- a/lib/gitlab/ci/pipeline/expression/lexer.rb +++ b/lib/gitlab/ci/pipeline/expression/lexer.rb @@ -12,6 +12,7 @@ class Lexer LEXEMES = [ Expression::Lexeme::ParenthesisOpen, Expression::Lexeme::ParenthesisClose, + Expression::Lexeme::Input, Expression::Lexeme::Variable, Expression::Lexeme::String, Expression::Lexeme::Pattern, diff --git a/lib/gitlab/ci/pipeline/expression/statement.rb b/lib/gitlab/ci/pipeline/expression/statement.rb index 4b13cae792ed9b645ff661a0f6170d71a4cbba3d..b84fe26b5b122e798e08c2afdc39a92655c65232 100644 --- a/lib/gitlab/ci/pipeline/expression/statement.rb +++ b/lib/gitlab/ci/pipeline/expression/statement.rb @@ -34,6 +34,26 @@ def valid? rescue Expression::ExpressionError false end + + def input_names + collect_input_names(parse_tree).uniq + rescue Expression::ExpressionError + [] + end + + private + + def collect_input_names(node) + return [] unless node + + if node.is_a?(Lexeme::Input) + [node.value] + elsif node.is_a?(Lexeme::LogicalOperator) + collect_input_names(node.left) + collect_input_names(node.right) + else + [] + end + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..47b97c8342a626be65e0c5dae805bf8267fd6409 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/input_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Input, feature_category: :pipeline_composition do + describe '.build' do + it 'extracts the input name' do + lexeme = described_class.build('$[[ inputs.environment ]]') + + expect(lexeme.value).to eq('environment') + end + + it 'handles input names with whitespace in brackets' do + lexeme = described_class.build('$[[ inputs.environment ]]') + + expect(lexeme.value).to eq('environment') + end + end + + describe '.type' do + it 'is a value' do + expect(described_class.type).to eq :value + end + end + + describe '#evaluate' do + let(:lexeme) { described_class.new('environment') } + + it 'returns input value if it is defined' do + expect(lexeme.evaluate(inputs: { 'environment' => 'production' })).to eq 'production' + end + + it 'allows to use a symbol as an input key too' do + expect(lexeme.evaluate(inputs: { environment: 'production' })).to eq 'production' + end + + it 'returns nil if it is not defined' do + expect(lexeme.evaluate(inputs: { 'region' => 'us-west' })).to be_nil + expect(lexeme.evaluate(inputs: { region: 'us-west' })).to be_nil + end + + it 'returns nil when no inputs are provided' do + expect(lexeme.evaluate({})).to be_nil + expect(lexeme.evaluate).to be_nil + end + + it 'does not call with_indifferent_access unnecessarily' do + inputs_hash = { inputs: { environment: 'production' }.with_indifferent_access } + + expect(inputs_hash[:inputs]).not_to receive(:with_indifferent_access) + expect(lexeme.evaluate(inputs_hash)).to eq 'production' + end + end + + describe '#inspect' do + let(:lexeme) { described_class.new('environment') } + + it 'returns the input reference format' do + expect(lexeme.inspect).to eq '$[[ inputs.environment ]]' + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb index acaec07f95b9c9f06fd7e7e84e4927654d96e9ce..59ada634cd1dd1fbe41678d5e53f0234fec2c9ec 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -123,6 +123,24 @@ '("string" == ("string" || (("1" == "1") && ("2" == "3"))))' | true end + context 'with input references' do + let(:variables) { { inputs: { 'cloud_provider' => 'aws', 'environment' => 'production' } } } + + where(:expression, :value) do + '$[[ inputs.cloud_provider ]] == "aws"' | true + '$[[ inputs.cloud_provider ]] == "gcp"' | false + '$[[ inputs.cloud_provider ]] == "aws" && $[[ inputs.environment ]] == "production"' | true + end + + with_them do + let(:text) { expression } + + it "evaluates to `#{params[:value].inspect}`" do + expect(evaluate).to eq(value) + end + end + end + with_them do let(:text) { expression } @@ -193,4 +211,56 @@ end end end + + describe '#input_names' do + subject(:input_names) { statement.input_names } + + context 'with simple input expression' do + let(:text) { '$[[ inputs.cloud_provider ]] == "aws"' } + + it { is_expected.to eq(['cloud_provider']) } + end + + context 'with multiple inputs using AND' do + let(:text) { '$[[ inputs.a ]] == "x" && $[[ inputs.b ]] == "y"' } + + it { is_expected.to contain_exactly('a', 'b') } + end + + context 'with multiple inputs using OR' do + let(:text) { '$[[ inputs.env ]] == "prod" || $[[ inputs.env ]] == "stage"' } + + it 'deduplicates repeated inputs' do + expect(input_names).to eq(['env']) + end + end + + context 'with nested expressions' do + let(:text) { '($[[ inputs.a ]] == "x" && $[[ inputs.b ]] == "y") || $[[ inputs.c ]] == "z"' } + + it { is_expected.to contain_exactly('a', 'b', 'c') } + end + + context 'with inputs and variables mixed' do + let(:text) { '$[[ inputs.region ]] == "us" && $CI_COMMIT_BRANCH == "main"' } + + it 'returns only inputs, not variables' do + expect(input_names).to eq(['region']) + end + end + + context 'with no inputs' do + let(:text) { '$PRESENT_VARIABLE == "value"' } + + it { is_expected.to be_empty } + end + + context 'with invalid expression' do + let(:text) { 'invalid &&' } + + it 'returns empty array on error' do + expect(input_names).to be_empty + end + end + end end