Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

Note: This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.0.0] - 2025-11-25
### Added
- Added optional context_overrides argument for the following methods:
- `ParameterSubstitution.find_tokens`
- `ParameterSubstitution.find_formatters`
- `ParameterSubstitution.find_warnings`

### Removed
- Removed optional `parameter_start` and `parameter_end` arguments for the following methods:
- `ParameterSubstitution.find_tokens`
- `ParameterSubstitution.find_formatters`
- `ParameterSubstitution.find_warnings`

## [2.1.0] - 2025-11-06
### Added
- Added optional `parameter_start` and `parameter_end` arguments for the following methods:
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
parameter_substitution (2.1.0)
parameter_substitution (3.0.0)
activesupport (>= 6.0)
builder (~> 3.2)
invoca-utils (~> 0.3)
Expand All @@ -22,7 +22,7 @@
tzinfo (~> 2.0)
appraisal (2.5.0)
bundler
rake

Check failure on line 25 in Gemfile.lock

View check run for this annotation

Security Scanner as a Service / Bundle Audit

Gemfile.lock#L25

thor Warning Message: https://github.com/advisories/GHSA-mqcp-p2hv-vw6x CVE: CVE-2025-54314 Severity: low
thor (>= 0.14.0)
appraisal-matrix (0.2.0)
appraisal (~> 2.2)
Expand Down
63 changes: 51 additions & 12 deletions lib/parameter_substitution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# See lib/parameter_substitution/readme.md

require 'active_support/all'
require 'set'
require "parameter_substitution/context"
require "parameter_substitution/parse_error"
require "parameter_substitution/parser"
Expand Down Expand Up @@ -63,27 +64,65 @@ def configure
ParameterSubstitution.config = config
end

def find_tokens(string_with_tokens, mapping: {}, parameter_start: "<", parameter_end: ">")
parse_expression(context_from_string(string_with_tokens, mapping, parameter_start: parameter_start, parameter_end: parameter_end)).substitution_parameter_names
def find_tokens(string_with_tokens, mapping: {}, context_overrides: {})
context = build_context(string_with_tokens, mapping, context_overrides)
parse_expression(context).substitution_parameter_names
end

def find_formatters(string_with_tokens, mapping: {}, parameter_start: "<", parameter_end: ">")
parse_expression(context_from_string(string_with_tokens, mapping, parameter_start: parameter_start, parameter_end: parameter_end)).method_names
def find_formatters(string_with_tokens, mapping: {}, context_overrides: {})
context = build_context(string_with_tokens, mapping, context_overrides)
parse_expression(context).method_names
end

def find_warnings(string_with_tokens, mapping: {}, parameter_start: "<", parameter_end: ">")
parse_expression(context_from_string(string_with_tokens, mapping, parameter_start: parameter_start, parameter_end: parameter_end)).parameter_and_method_warnings || []
def find_warnings(string_with_tokens, mapping: {}, context_overrides: {})
context = build_context(string_with_tokens, mapping, context_overrides)
parse_expression(context).parameter_and_method_warnings || []
end

private

def context_from_string(string_with_tokens, mapping, parameter_start: "<", parameter_end: ">")
ParameterSubstitution::Context.new(
VALID_CONTEXT_OVERRIDE_KEYS = %i[
required_parameters
parameter_start
parameter_end
destination_encoding
allow_unknown_replacement_parameters
allow_nil
allow_unmatched_parameter_end
].to_set
private_constant :VALID_CONTEXT_OVERRIDE_KEYS

# Build context with optional overrides
# @param [String] string_with_tokens The input string containing tokens
# @param [Hash] mapping The mapping of parameters to values
# @param [Hash] context_overrides Optional overrides for context attributes
# @return [ParameterSubstitution::Context] The constructed context
# @raise [ArgumentError] if context_overrides contains invalid keys
def build_context(string_with_tokens, mapping, context_overrides)
validate_context_overrides!(context_overrides)

base_options = {
input: string_with_tokens,
mapping:,
parameter_start:,
parameter_end:
)
mapping: mapping
}

symbolized_overrides = context_overrides.transform_keys(&:to_sym)

ParameterSubstitution::Context.new(**symbolized_overrides.merge(base_options))
end

# @param [Hash] context_overrides The overrides to validate
# @raise [ArgumentError] if context_overrides contains invalid keys
def validate_context_overrides!(context_overrides)
return if context_overrides.empty?

Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation allows 'input' and 'mapping' keys to pass through if they're present in context_overrides, but these should be rejected since they're provided as base_options. Add explicit validation to reject these reserved keys before checking against VALID_CONTEXT_OVERRIDE_KEYS.

Suggested change
reserved_keys = %i[input mapping]
present_reserved_keys = context_overrides.keys.map(&:to_sym) & reserved_keys
if present_reserved_keys.any?
raise ArgumentError, "context_overrides contains reserved keys: #{present_reserved_keys.join(', ')}. These keys are not allowed."
end

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 They aren't included in VALID_CONTEXT_OVERRIDE_KEYS though so they're going to be rejected regardless...I don't think the extra complexity is warranted here for a slightly more specific error message.

invalid_keys = context_overrides.keys.reject { |key| VALID_CONTEXT_OVERRIDE_KEYS.include?(key.to_sym) }
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation allows both string and symbol keys in context_overrides, but the merge operation at line 109 (**context_overrides.merge(base_options)) will create duplicate keys if string keys are used. For example, if context_overrides contains {'input' => 'value'}, this validation will pass (because line 117 converts it to :input), but line 109 will result in both :input and 'input' keys being passed to Context.new. Consider either converting all keys to symbols before validation and merge, or explicitly rejecting string versions of reserved keys.

Copilot uses AI. Check for mistakes.

if invalid_keys.any?
invalid_keys_list = invalid_keys.join(", ")
valid_keys_list = VALID_CONTEXT_OVERRIDE_KEYS.sort.join(", ")
raise ArgumentError, "Invalid context_overrides keys: #{invalid_keys_list}. Valid keys are: #{valid_keys_list}"
end
end

def parse_expression(context)
Expand Down
2 changes: 1 addition & 1 deletion lib/parameter_substitution/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

class ParameterSubstitution
VERSION = "2.1.0"
VERSION = "3.0.0"
end
149 changes: 123 additions & 26 deletions spec/lib/parameter_substitution_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,44 @@ def self.find(_key); end
let(:expression) { "<call.start_time.blank_if_nil><do_a_barrel_roll.downcase>" }
let(:mapping) { { 'call.start_time' => 'hello' } }

shared_examples "passes context_overrides with symbolized keys to Context" do
it "calls Context.new with the expected overrides" do
allow(ParameterSubstitution::Context).to receive(:new).and_call_original
subject
expect(ParameterSubstitution::Context).to have_received(:new).once.with(
hash_including(test_context_overrides.transform_keys(&:to_sym).merge(
input: test_expression,
mapping: test_mapping
))
)
end
end

shared_examples "validates context_overrides" do
context 'when context_overrides attempts to override base options' do
context "when input is provided in context_overrides" do
let(:test_context_overrides) { { input: "<different>" } }
it "raises error" do
expect { subject }.to raise_error(ArgumentError, /Invalid context_overrides keys: input/)
end
end

context "when mapping is provided in context_overrides" do
let(:test_context_overrides) { { mapping: { 'different' => 'value' } } }
it "raises error" do
Comment on lines +90 to +100
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects input to be rejected as an invalid key, but input is not listed in VALID_CONTEXT_OVERRIDE_KEYS (lines 84-92 in parameter_substitution.rb), so it would already be rejected by the general validation logic. The current implementation doesn't specifically check for 'base options' (input and mapping); it only validates against the allowed keys list. This test will pass, but for the wrong reason. Consider either adding explicit validation for base options that provides a clearer error message, or updating the test description to reflect that these are simply invalid keys rather than special 'base options'.

Suggested change
context 'when context_overrides attempts to override base options' do
context "when input is provided in context_overrides" do
let(:test_context_overrides) { { input: "<different>" } }
it "raises error" do
expect { subject }.to raise_error(ArgumentError, /Invalid context_overrides keys: input/)
end
end
context "when mapping is provided in context_overrides" do
let(:test_context_overrides) { { mapping: { 'different' => 'value' } } }
it "raises error" do
context 'when context_overrides contains disallowed keys' do
context "when input is provided in context_overrides" do
let(:test_context_overrides) { { input: "<different>" } }
it "raises error for invalid key" do
expect { subject }.to raise_error(ArgumentError, /Invalid context_overrides keys: input/)
end
end
context "when mapping is provided in context_overrides" do
let(:test_context_overrides) { { mapping: { 'different' => 'value' } } }
it "raises error for invalid key" do

Copilot uses AI. Check for mistakes.
expect { subject }.to raise_error(ArgumentError, /Invalid context_overrides keys: mapping/)
end
end
end

context 'when context_overrides contains invalid keys' do
let(:test_context_overrides) { { invalid_key: "value", another_invalid: "value2" } }
it "raises error with list of invalid keys" do
expect { subject }.to raise_error(ArgumentError, /Invalid context_overrides keys: invalid_key, another_invalid/)
end
end
end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cgaroutte Nice use of shared examples here!

context "#find_tokens" do
it "returns tokens up to first dot when no mapping is provided" do
expect(ParameterSubstitution.find_tokens(expression)).to eq(['call', 'do_a_barrel_roll'])
Expand All @@ -83,16 +121,39 @@ def self.find(_key); end
end

context 'with non-default delimiters' do
let(:square_expression) { "[call.start_time.blank_if_nil][do_a_barrel_roll.downcase]" }
let(:test_expression) { "[call.start_time.blank_if_nil][do_a_barrel_roll.downcase]" }
let(:test_mapping) { {} }
let(:test_context_overrides) do
{
required_parameters: ["param1", "param2"],
parameter_start: "[",
parameter_end: "]",
destination_encoding: :json,
allow_unknown_replacement_parameters: true,
allow_nil: true,
allow_unmatched_parameter_end: true
}
end

it "returns tokens up to first dot when no mapping is provided" do
expect(ParameterSubstitution.find_tokens(square_expression, parameter_start: "[", parameter_end: "]")).to eq(['call', 'do_a_barrel_roll'])
context 'with symbol override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_tokens(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

it "returns tokens that exist in mapping when one is provided" do
expect(ParameterSubstitution.find_tokens(square_expression, mapping: mapping, parameter_start: "[", parameter_end: "]")).to eq(['call.start_time', 'do_a_barrel_roll'])
context 'with string override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_tokens(test_expression, mapping: test_mapping, context_overrides: test_context_overrides.transform_keys(&:to_s)) }
end
end
end

include_examples "validates context_overrides" do
let(:test_expression) { expression }
let(:test_mapping) { mapping }
let(:test_context_overrides) { {} }
subject { ParameterSubstitution.find_tokens(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

context '#find_formatters' do
Expand All @@ -105,21 +166,44 @@ def self.find(_key); end
end

context 'with non-default delimiters' do
let(:square_expression) { "[call.start_time.blank_if_nil][do_a_barrel_roll.downcase]" }
let(:test_expression) { "[call.start_time.blank_if_nil][do_a_barrel_roll.downcase]" }
let(:test_mapping) { {} }
let(:test_context_overrides) do
{
required_parameters: ["param1", "param2"],
parameter_start: "[",
parameter_end: "]",
destination_encoding: :json,
allow_unknown_replacement_parameters: true,
allow_nil: true,
allow_unmatched_parameter_end: true
}
end

it "returns all formatters after first dot when no mapping is provided" do
expect(ParameterSubstitution.find_formatters(square_expression, parameter_start: "[", parameter_end: "]")).to eq(['start_time', 'blank_if_nil', 'downcase'])
context 'with symbol override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_formatters(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

it "returns formatters after all tokens when mapping is provided" do
expect(ParameterSubstitution.find_formatters(square_expression, mapping: mapping, parameter_start: "[", parameter_end: "]")).to eq(['blank_if_nil', 'downcase'])
context 'with string override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_formatters(test_expression, mapping: test_mapping, context_overrides: test_context_overrides.transform_keys(&:to_s)) }
end
end
end

include_examples "validates context_overrides" do
let(:test_expression) { expression }
let(:test_mapping) { mapping }
let(:test_context_overrides) { {} }
subject { ParameterSubstitution.find_formatters(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

context '#find_warnings' do
let(:expression_with_valid_params) { "<foo>" }
let(:expression_with_bad_paramss) { "<bobby><bobby2>" }
let(:expression_with_bad_params) { "<bobby><bobby2>" }
let(:expression_with_bad_methods) { "<foo.test1.test2><foo.test3.test4><black.test1.test2>" }
let(:expression_with_bad_params_and_methods) { "<bobby.test1.test2><bobby2.test3.test4>" }
let(:expression_with_mixed_bad_params_and_methods) { "<bobby.test1.test2><foo.test3.test4>" }
Expand All @@ -133,7 +217,7 @@ def self.find(_key); end

context "when there are invalid parameters" do
it "returns 2 warnings" do
expect(ParameterSubstitution.find_warnings(expression_with_bad_paramss)).to eq(["Unknown param 'bobby'", "Unknown param 'bobby2'"])
expect(ParameterSubstitution.find_warnings(expression_with_bad_params)).to eq(["Unknown param 'bobby'", "Unknown param 'bobby2'"])
end
end

Expand All @@ -153,26 +237,39 @@ def self.find(_key); end
end

context "with non-default delimiters" do
let(:square_expression_with_valid_params) { "[foo]" }
let(:square_expression_with_bad_params) { "[bobby][bobby2]" }
let(:square_expression_with_bad_methods) { "[foo.test1.test2][foo.test3.test4][black.test1.test2]" }

it "returns empty array for valid parameters" do
expect(ParameterSubstitution.find_warnings(square_expression_with_valid_params, mapping: default_mapping, parameter_start: "[", parameter_end: "]"))
.to eq([])
let(:test_expression) { "[foo]" }
let(:test_mapping) { default_mapping }
let(:test_context_overrides) do
{
required_parameters: ["param1", "param2"],
parameter_start: "[",
parameter_end: "]",
destination_encoding: :json,
allow_unknown_replacement_parameters: true,
allow_nil: true,
allow_unmatched_parameter_end: true
}
end

it "returns warnings for invalid parameters" do
expect(ParameterSubstitution.find_warnings(square_expression_with_bad_params, parameter_start: "[", parameter_end: "]"))
.to eq(["Unknown param 'bobby'", "Unknown param 'bobby2'"])
context "with symbol override keys" do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_warnings(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

it "returns warnings for invalid methods" do
expect(ParameterSubstitution.find_warnings(square_expression_with_bad_methods, mapping: default_mapping, parameter_start: "[", parameter_end: "]"))
.to eq(["Unknown methods 'test1', 'test2', 'test3', 'test4' used on parameter 'foo'",
"Unknown methods 'test1', 'test2' used on parameter 'black'"])
context "with string override keys" do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_warnings(test_expression, mapping: test_mapping, context_overrides: test_context_overrides.transform_keys(&:to_s)) }
end
end
end

include_examples "validates context_overrides" do
let(:test_expression) { expression_with_valid_params }
let(:test_mapping) { default_mapping }
let(:test_context_overrides) { {} }
subject { ParameterSubstitution.find_warnings(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end
end

Expand Down