diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd27d5..ce29cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index c57c30e..daca776 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/lib/parameter_substitution.rb b/lib/parameter_substitution.rb index 24e3d00..688f8b1 100644 --- a/lib/parameter_substitution.rb +++ b/lib/parameter_substitution.rb @@ -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] all parameter names, including nested ones + def find_all_tokens(string_with_tokens, mapping: {}, context_overrides: {}) + 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 diff --git a/lib/parameter_substitution/expression.rb b/lib/parameter_substitution/expression.rb index 9afdd17..246e8e6 100644 --- a/lib/parameter_substitution/expression.rb +++ b/lib/parameter_substitution/expression.rb @@ -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 ", 'a', 'b')>" + # # returns ["dclid", "gclid"] + # + # @return [Array] 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) @@ -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] 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, nil] the method calls to inspect + # @return [Array] 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 diff --git a/lib/parameter_substitution/version.rb b/lib/parameter_substitution/version.rb index b42d1e9..660c7f8 100644 --- a/lib/parameter_substitution/version.rb +++ b/lib/parameter_substitution/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class ParameterSubstitution - VERSION = "3.1.0" + VERSION = "3.2.0" end diff --git a/spec/lib/parameter_substitution/expression_spec.rb b/spec/lib/parameter_substitution/expression_spec.rb index 6cb43b8..fcaf604 100644 --- a/spec/lib/parameter_substitution/expression_spec.rb +++ b/spec/lib/parameter_substitution/expression_spec.rb @@ -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("") + + 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(")>") + + expect(expression.all_substitution_parameter_names).to eq(["simple_text", "another_simple_text"]) + end + + it "returns deeply nested parameter names" do + expression = parse_expression(")>)>") + + 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(")>") + + 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(")>)>") + + 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("") diff --git a/spec/lib/parameter_substitution_spec.rb b/spec/lib/parameter_substitution_spec.rb index ce9ffd4..46ec422 100644 --- a/spec/lib/parameter_substitution_spec.rb +++ b/spec/lib/parameter_substitution_spec.rb @@ -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 = ", 'a', 'b')>" + + expect(ParameterSubstitution.find_all_tokens(nested_expression)).to eq(['dclid', 'gclid']) + end + + it "returns deeply nested tokens" do + deeply_nested = ")>)>" + + 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 = ", '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 = ")>)>" + + 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'])