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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ 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.2.0] - 2026-02-19
### Added
- Added `find_all_tokens` method that recursively collects all substitution parameter names, including those nested inside method call arguments.

## [3.1.0] - 2026-01-23
### Added
- Added `to_f` formatter, which converts a string to a float value.
Expand Down Expand Up @@ -59,6 +63,7 @@ Note: This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0
### Changed
- Replace hobo_support with invoca-utils

[3.2.0]: https://github.com/Invoca/parameter_substitution/compare/v3.1.0...v3.2.0
[2.0.0]: https://github.com/Invoca/parameter_substitution/compare/v1.4.0...v2.0.0
[1.4.0]: https://github.com/Invoca/parameter_substitution/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/Invoca/parameter_substitution/compare/v1.2.0...v1.3.0
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 (3.1.0)
parameter_substitution (3.2.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
12 changes: 12 additions & 0 deletions lib/parameter_substitution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ def find_tokens(string_with_tokens, mapping: {}, context_overrides: {})
parse_expression(context).substitution_parameter_names
end

# Returns all substitution parameter names found in the input string, including
# those nested inside method call arguments.
#
# @param string_with_tokens [String] the input string containing tokens
# @param mapping [Hash] the mapping of parameters to values
# @param context_overrides [Hash] optional overrides for context attributes
# @return [Array<String>] all parameter names, including nested ones
def find_all_tokens(string_with_tokens, mapping: {}, context_overrides: {})
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think we should get rid of the find_tokens method? I see it used in one other place in web other than the mapper (in TokenReplacement). It looks like it's extracting tokens from a webhook url template. It feels like a bug that we're only finding top-level tokens in that case as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If that is a bug, we should hit the birds up to fix. I don't think we should concern ourselves with that behavior. Also, the url template might not support nested params (just a thought).

Copy link
Contributor

Choose a reason for hiding this comment

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

A method doc might be nice here.

context = build_context(string_with_tokens, mapping, context_overrides)
parse_expression(context).all_substitution_parameter_names
end

def find_formatters(string_with_tokens, mapping: {}, context_overrides: {})
context = build_context(string_with_tokens, mapping, context_overrides)
parse_expression(context).method_names
Expand Down
41 changes: 41 additions & 0 deletions lib/parameter_substitution/expression.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ def substitution_parameter_names
@expression_list.map_compact(&:parameter_name)
end

# Recursively collects all substitution parameter names, including those
# nested inside method call arguments.
#
# @example
# # For "<dclid.compare_string(<gclid>, 'a', 'b')>"
# # returns ["dclid", "gclid"]
#
# @return [Array<String>] all parameter names, including nested ones
def all_substitution_parameter_names
@expression_list.flat_map { |expr| parameter_names_from_expression(expr) }
end

def method_names
@expression_list.reduce([]) do |all_method_names, expression|
all_method_names + methods_used_by_expression(expression)
Expand Down Expand Up @@ -142,5 +154,34 @@ def missing_methods(expression)
def pluralize_text(text, amount)
text + (amount > 1 ? 's' : '')
end

# Extracts the parameter name from an expression and recursively collects
# any nested parameter names from its method call arguments.
#
# @param expr [TextExpression, SubstitutionExpression] a single expression node
# @return [Array<String>] parameter names found in this expression and its arguments
def parameter_names_from_expression(expr)
if (name = expr.parameter_name)
[name] + parameter_names_from_method_calls(expr.try(:method_calls))
else
[]
end
end

# Collects parameter names from nested Expression arguments within method calls.
#
# @param method_calls [Array<MethodCallExpression>, nil] the method calls to inspect
# @return [Array<String>] parameter names found in nested expression arguments
def parameter_names_from_method_calls(method_calls)
if method_calls
method_calls.flat_map do |method_call|
method_call.arguments.flat_map do |arg|
arg.is_a?(Expression) ? arg.all_substitution_parameter_names : []
end
end
else
[]
end
end
end
end
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 = "3.1.0"
VERSION = "3.2.0"
end
38 changes: 38 additions & 0 deletions spec/lib/parameter_substitution/expression_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,44 @@ def parse_expression(parameter_expression, context_options = {})
end
end

context "#all_substitution_parameter_names" do
it "returns top-level parameter names" do
expression = parse_expression("<simple_text><param_two>")

expect(expression.all_substitution_parameter_names).to eq(["simple_text", "param_two"])
end

it "returns nested parameter names from method call arguments" do
expression = parse_expression("<simple_text.if_nil(<another_simple_text>)>")

expect(expression.all_substitution_parameter_names).to eq(["simple_text", "another_simple_text"])
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: would you mind adding a space between the expect statements and the test setup?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not at all.

end

it "returns deeply nested parameter names" do
expression = parse_expression("<simple_text.if_nil(<another_simple_text.if_nil(<color>)>)>")

expect(expression.all_substitution_parameter_names).to eq(["simple_text", "another_simple_text", "color"])
end

it "returns nested parameter names with formatters applied" do
expression = parse_expression("<simple_text.if_nil(<another_simple_text.downcase>)>")

expect(expression.all_substitution_parameter_names).to eq(["simple_text", "another_simple_text"])
end

it "returns parameter names from multiple top-level expressions with nesting" do
expression = parse_expression("<simple_text.if_nil(<color>)><another_simple_text.if_nil(<integer>)>")

expect(expression.all_substitution_parameter_names).to eq(["simple_text", "color", "another_simple_text", "integer"])
end

it "returns empty array for plain text" do
expression = parse_expression("just plain text")

expect(expression.all_substitution_parameter_names).to eq([])
end
end

context "#method_names" do
it "return expression list method names" do
expression = parse_expression("<simple_text.do_a_barrel_roll><foo.bar>")
Expand Down
63 changes: 63 additions & 0 deletions spec/lib/parameter_substitution_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,69 @@ def self.find(_key); end
end
end

context "#find_all_tokens" do
it "returns top-level tokens the same as find_tokens" do
expect(ParameterSubstitution.find_all_tokens(expression)).to eq(['call', 'do_a_barrel_roll'])
end

it "returns nested tokens from method call arguments" do
nested_expression = "<dclid.compare_string(<gclid>, 'a', 'b')>"

expect(ParameterSubstitution.find_all_tokens(nested_expression)).to eq(['dclid', 'gclid'])
end

it "returns deeply nested tokens" do
deeply_nested = "<outer.if_nil(<middle.if_nil(<inner>)>)>"

expect(ParameterSubstitution.find_all_tokens(deeply_nested)).to eq(['outer', 'middle', 'inner'])
end

it "returns nested tokens with formatters applied to nested params" do
nested_with_formatters = "<dclid.compare_string(<gclid.downcase>, 'a', 'b')>"

expect(ParameterSubstitution.find_all_tokens(nested_with_formatters)).to eq(['dclid', 'gclid'])
end

it "returns tokens from multiple expressions with nesting" do
multi_nested = "<param1.if_nil(<param2>)><param3.if_nil(<param4>)>"

expect(ParameterSubstitution.find_all_tokens(multi_nested)).to eq(['param1', 'param2', 'param3', 'param4'])
end

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

context 'with symbol override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_all_tokens(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

context 'with string override keys' do
include_examples "passes context_overrides with symbolized keys to Context" do
subject { ParameterSubstitution.find_all_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_all_tokens(test_expression, mapping: test_mapping, context_overrides: test_context_overrides) }
end
end

context '#find_formatters' do
it "returns all formatters after first dot when no mapping is provided" do
expect(ParameterSubstitution.find_formatters(expression)).to eq(['start_time', 'blank_if_nil', 'downcase'])
Expand Down
Loading