# frozen_string_literal: true # encoding:utf-8 #-- # Copyright (C) Bob Aman # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. #++ require "addressable/version" require "addressable/uri" module Addressable ## # This is an implementation of a URI template based on # RFC 6570 (http://tools.ietf.org/html/rfc6570). class Template # Constants used throughout the template code. anything = Addressable::URI::CharacterClasses::RESERVED + Addressable::URI::CharacterClasses::UNRESERVED variable_char_class = Addressable::URI::CharacterClasses::ALPHA + Addressable::URI::CharacterClasses::DIGIT + '_' var_char = "(?:(?:[#{variable_char_class}]|%[a-fA-F0-9][a-fA-F0-9])+)" RESERVED = "(?:[#{anything}]|%[a-fA-F0-9][a-fA-F0-9])" UNRESERVED = "(?:[#{ Addressable::URI::CharacterClasses::UNRESERVED }]|%[a-fA-F0-9][a-fA-F0-9])" variable = "(?:#{var_char}(?:\\.?#{var_char})*)" varspec = "(?:(#{variable})(\\*|:\\d+)?)" VARNAME = /^#{variable}$/ VARSPEC = /^#{varspec}$/ VARIABLE_LIST = /^#{varspec}(?:,#{varspec})*$/ operator = "+#./;?&=,!@|" EXPRESSION = /\{([#{operator}])?(#{varspec}(?:,#{varspec})*)\}/ LEADERS = { '?' => '?', '/' => '/', '#' => '#', '.' => '.', ';' => ';', '&' => '&' } JOINERS = { '?' => '&', '.' => '.', ';' => ';', '&' => '&', '/' => '/' } ## # Raised if an invalid template value is supplied. class InvalidTemplateValueError < StandardError end ## # Raised if an invalid template operator is used in a pattern. class InvalidTemplateOperatorError < StandardError end ## # Raised if an invalid template operator is used in a pattern. class TemplateOperatorAbortedError < StandardError end ## # This class represents the data that is extracted when a Template # is matched against a URI. class MatchData ## # Creates a new MatchData object. # MatchData objects should never be instantiated directly. # # @param [Addressable::URI] uri # The URI that the template was matched against. def initialize(uri, template, mapping) @uri = uri.dup.freeze @template = template @mapping = mapping.dup.freeze end ## # @return [Addressable::URI] # The URI that the Template was matched against. attr_reader :uri ## # @return [Addressable::Template] # The Template used for the match. attr_reader :template ## # @return [Hash] # The mapping that resulted from the match. # Note that this mapping does not include keys or values for # variables that appear in the Template, but are not present # in the URI. attr_reader :mapping ## # @return [Array] # The list of variables that were present in the Template. # Note that this list will include variables which do not appear # in the mapping because they were not present in URI. def variables self.template.variables end alias_method :keys, :variables alias_method :names, :variables ## # @return [Array] # The list of values that were captured by the Template. # Note that this list will include nils for any variables which # were in the Template, but did not appear in the URI. def values @values ||= self.variables.inject([]) do |accu, key| accu << self.mapping[key] accu end end alias_method :captures, :values ## # Accesses captured values by name or by index. # # @param [String, Symbol, Fixnum] key # Capture index or name. Note that when accessing by with index # of 0, the full URI will be returned. The intention is to mimic # the ::MatchData#[] behavior. # # @param [#to_int, nil] len # If provided, an array of values will be returend with the given # parameter used as length. # # @return [Array, String, nil] # The captured value corresponding to the index or name. If the # value was not provided or the key is unknown, nil will be # returned. # # If the second parameter is provided, an array of that length will # be returned instead. def [](key, len = nil) if len to_a[key, len] elsif String === key or Symbol === key mapping[key.to_s] else to_a[key] end end ## # @return [Array] # Array with the matched URI as first element followed by the captured # values. def to_a [to_s, *values] end ## # @return [String] # The matched URI as String. def to_s uri.to_s end alias_method :string, :to_s # Returns multiple captured values at once. # # @param [String, Symbol, Fixnum] *indexes # Indices of the captures to be returned # # @return [Array] # Values corresponding to given indices. # # @see Addressable::Template::MatchData#[] def values_at(*indexes) indexes.map { |i| self[i] } end ## # Returns a String representation of the MatchData's state. # # @return [String] The MatchData's state, as a String. def inspect sprintf("#<%s:%#0x RESULT:%s>", self.class.to_s, self.object_id, self.mapping.inspect) end ## # Dummy method for code expecting a ::MatchData instance # # @return [String] An empty string. def pre_match "" end alias_method :post_match, :pre_match end ## # Creates a new Addressable::Template object. # # @param [#to_str] pattern The URI Template pattern. # # @return [Addressable::Template] The initialized Template object. def initialize(pattern) if !pattern.respond_to?(:to_str) raise TypeError, "Can't convert #{pattern.class} into String." end @pattern = pattern.to_str.dup.freeze end ## # Freeze URI, initializing instance variables. # # @return [Addressable::URI] The frozen URI object. def freeze self.variables self.variable_defaults self.named_captures super end ## # @return [String] The Template object's pattern. attr_reader :pattern ## # Returns a String representation of the Template object's state. # # @return [String] The Template object's state, as a String. def inspect sprintf("#<%s:%#0x PATTERN:%s>", self.class.to_s, self.object_id, self.pattern) end ## # Returns true if the Template objects are equal. This method # does NOT normalize either Template before doing the comparison. # # @param [Object] template The Template to compare. # # @return [TrueClass, FalseClass] # true if the Templates are equivalent, false # otherwise. def ==(template) return false unless template.kind_of?(Template) return self.pattern == template.pattern end ## # Addressable::Template makes no distinction between `==` and `eql?`. # # @see #== alias_method :eql?, :== ## # Extracts a mapping from the URI using a URI Template pattern. # # @param [Addressable::URI, #to_str] uri # The URI to extract from. # # @param [#restore, #match] processor # A template processor object may optionally be supplied. # # The object should respond to either the restore or # match messages or both. The restore method should # take two parameters: `[String] name` and `[String] value`. # The restore method should reverse any transformations that # have been performed on the value to ensure a valid URI. # The match method should take a single # parameter: `[String] name`. The match method should return # a String containing a regular expression capture group for # matching on that particular variable. The default value is `".*?"`. # The match method has no effect on multivariate operator # expansions. # # @return [Hash, NilClass] # The Hash mapping that was extracted from the URI, or # nil if the URI didn't match the template. # # @example # class ExampleProcessor # def self.restore(name, value) # return value.gsub(/\+/, " ") if name == "query" # return value # end # # def self.match(name) # return ".*?" if name == "first" # return ".*" # end # end # # uri = Addressable::URI.parse( # "http://example.com/search/an+example+search+query/" # ) # Addressable::Template.new( # "http://example.com/search/{query}/" # ).extract(uri, ExampleProcessor) # #=> {"query" => "an example search query"} # # uri = Addressable::URI.parse("http://example.com/a/b/c/") # Addressable::Template.new( # "http://example.com/{first}/{second}/" # ).extract(uri, ExampleProcessor) # #=> {"first" => "a", "second" => "b/c"} # # uri = Addressable::URI.parse("http://example.com/a/b/c/") # Addressable::Template.new( # "http://example.com/{first}/{-list|/|second}/" # ).extract(uri) # #=> {"first" => "a", "second" => ["b", "c"]} def extract(uri, processor=nil) match_data = self.match(uri, processor) return (match_data ? match_data.mapping : nil) end ## # Extracts match data from the URI using a URI Template pattern. # # @param [Addressable::URI, #to_str] uri # The URI to extract from. # # @param [#restore, #match] processor # A template processor object may optionally be supplied. # # The object should respond to either the restore or # match messages or both. The restore method should # take two parameters: `[String] name` and `[String] value`. # The restore method should reverse any transformations that # have been performed on the value to ensure a valid URI. # The match method should take a single # parameter: `[String] name`. The match method should return # a String containing a regular expression capture group for # matching on that particular variable. The default value is `".*?"`. # The match method has no effect on multivariate operator # expansions. # # @return [Hash, NilClass] # The Hash mapping that was extracted from the URI, or # nil if the URI didn't match the template. # # @example # class ExampleProcessor # def self.restore(name, value) # return value.gsub(/\+/, " ") if name == "query" # return value # end # # def self.match(name) # return ".*?" if name == "first" # return ".*" # end # end # # uri = Addressable::URI.parse( # "http://example.com/search/an+example+search+query/" # ) # match = Addressable::Template.new( # "http://example.com/search/{query}/" # ).match(uri, ExampleProcessor) # match.variables # #=> ["query"] # match.captures # #=> ["an example search query"] # # uri = Addressable::URI.parse("http://example.com/a/b/c/") # match = Addressable::Template.new( # "http://example.com/{first}/{+second}/" # ).match(uri, ExampleProcessor) # match.variables # #=> ["first", "second"] # match.captures # #=> ["a", "b/c"] # # uri = Addressable::URI.parse("http://example.com/a/b/c/") # match = Addressable::Template.new( # "http://example.com/{first}{/second*}/" # ).match(uri) # match.variables # #=> ["first", "second"] # match.captures # #=> ["a", ["b", "c"]] def match(uri, processor=nil) uri = Addressable::URI.parse(uri) mapping = {} # First, we need to process the pattern, and extract the values. expansions, expansion_regexp = parse_template_pattern(pattern, processor) return nil unless uri.to_str.match(expansion_regexp) unparsed_values = uri.to_str.scan(expansion_regexp).flatten if uri.to_str == pattern return Addressable::Template::MatchData.new(uri, self, mapping) elsif expansions.size > 0 index = 0 expansions.each do |expansion| _, operator, varlist = *expansion.match(EXPRESSION) varlist.split(',').each do |varspec| _, name, modifier = *varspec.match(VARSPEC) mapping[name] ||= nil case operator when nil, '+', '#', '/', '.' unparsed_value = unparsed_values[index] name = varspec[VARSPEC, 1] value = unparsed_value value = value.split(JOINERS[operator]) if value && modifier == '*' when ';', '?', '&' if modifier == '*' if unparsed_values[index] value = unparsed_values[index].split(JOINERS[operator]) value = value.inject({}) do |acc, v| key, val = v.split('=') val = "" if val.nil? acc[key] = val acc end end else if (unparsed_values[index]) name, value = unparsed_values[index].split('=') value = "" if value.nil? end end end if processor != nil && processor.respond_to?(:restore) value = processor.restore(name, value) end if processor == nil if value.is_a?(Hash) value = value.inject({}){|acc, (k, v)| acc[Addressable::URI.unencode_component(k)] = Addressable::URI.unencode_component(v) acc } elsif value.is_a?(Array) value = value.map{|v| Addressable::URI.unencode_component(v) } else value = Addressable::URI.unencode_component(value) end end if !mapping.has_key?(name) || mapping[name].nil? # Doesn't exist, set to value (even if value is nil) mapping[name] = value end index = index + 1 end end return Addressable::Template::MatchData.new(uri, self, mapping) else return nil end end ## # Expands a URI template into another URI template. # # @param [Hash] mapping The mapping that corresponds to the pattern. # @param [#validate, #transform] processor # An optional processor object may be supplied. # @param [Boolean] normalize_values # Optional flag to enable/disable unicode normalization. Default: true # # The object should respond to either the validate or # transform messages or both. Both the validate and # transform methods should take two parameters: name and # value. The validate method should return true # or false; true if the value of the variable is valid, # false otherwise. An InvalidTemplateValueError # exception will be raised if the value is invalid. The transform # method should return the transformed variable value as a String. # If a transform method is used, the value will not be percent # encoded automatically. Unicode normalization will be performed both # before and after sending the value to the transform method. # # @return [Addressable::Template] The partially expanded URI template. # # @example # Addressable::Template.new( # "http://example.com/{one}/{two}/" # ).partial_expand({"one" => "1"}).pattern # #=> "http://example.com/1/{two}/" # # Addressable::Template.new( # "http://example.com/{?one,two}/" # ).partial_expand({"one" => "1"}).pattern # #=> "http://example.com/?one=1{&two}/" # # Addressable::Template.new( # "http://example.com/{?one,two,three}/" # ).partial_expand({"one" => "1", "three" => 3}).pattern # #=> "http://example.com/?one=1{&two}&three=3" def partial_expand(mapping, processor=nil, normalize_values=true) result = self.pattern.dup mapping = normalize_keys(mapping) result.gsub!( EXPRESSION ) do |capture| transform_partial_capture(mapping, capture, processor, normalize_values) end return Addressable::Template.new(result) end ## # Expands a URI template into a full URI. # # @param [Hash] mapping The mapping that corresponds to the pattern. # @param [#validate, #transform] processor # An optional processor object may be supplied. # @param [Boolean] normalize_values # Optional flag to enable/disable unicode normalization. Default: true # # The object should respond to either the validate or # transform messages or both. Both the validate and # transform methods should take two parameters: name and # value. The validate method should return true # or false; true if the value of the variable is valid, # false otherwise. An InvalidTemplateValueError # exception will be raised if the value is invalid. The transform # method should return the transformed variable value as a String. # If a transform method is used, the value will not be percent # encoded automatically. Unicode normalization will be performed both # before and after sending the value to the transform method. # # @return [Addressable::URI] The expanded URI template. # # @example # class ExampleProcessor # def self.validate(name, value) # return !!(value =~ /^[\w ]+$/) if name == "query" # return true # end # # def self.transform(name, value) # return value.gsub(/ /, "+") if name == "query" # return value # end # end # # Addressable::Template.new( # "http://example.com/search/{query}/" # ).expand( # {"query" => "an example search query"}, # ExampleProcessor # ).to_str # #=> "http://example.com/search/an+example+search+query/" # # Addressable::Template.new( # "http://example.com/search/{query}/" # ).expand( # {"query" => "an example search query"} # ).to_str # #=> "http://example.com/search/an%20example%20search%20query/" # # Addressable::Template.new( # "http://example.com/search/{query}/" # ).expand( # {"query" => "bogus!"}, # ExampleProcessor # ).to_str # #=> Addressable::Template::InvalidTemplateValueError def expand(mapping, processor=nil, normalize_values=true) result = self.pattern.dup mapping = normalize_keys(mapping) result.gsub!( EXPRESSION ) do |capture| transform_capture(mapping, capture, processor, normalize_values) end return Addressable::URI.parse(result) end ## # Returns an Array of variables used within the template pattern. # The variables are listed in the Array in the order they appear within # the pattern. Multiple occurrences of a variable within a pattern are # not represented in this Array. # # @return [Array] The variables present in the template's pattern. def variables @variables ||= ordered_variable_defaults.map { |var, val| var }.uniq end alias_method :keys, :variables alias_method :names, :variables ## # Returns a mapping of variables to their default values specified # in the template. Variables without defaults are not returned. # # @return [Hash] Mapping of template variables to their defaults def variable_defaults @variable_defaults ||= Hash[*ordered_variable_defaults.reject { |k, v| v.nil? }.flatten] end ## # Coerces a template into a `Regexp` object. This regular expression will # behave very similarly to the actual template, and should match the same # URI values, but it cannot fully handle, for example, values that would # extract to an `Array`. # # @return [Regexp] A regular expression which should match the template. def to_regexp _, source = parse_template_pattern(pattern) Regexp.new(source) end ## # Returns the source of the coerced `Regexp`. # # @return [String] The source of the `Regexp` given by {#to_regexp}. # # @api private def source self.to_regexp.source end ## # Returns the named captures of the coerced `Regexp`. # # @return [Hash] The named captures of the `Regexp` given by {#to_regexp}. # # @api private def named_captures self.to_regexp.named_captures end ## # Generates a route result for a given set of parameters. # Should only be used by rack-mount. # # @param params [Hash] The set of parameters used to expand the template. # @param recall [Hash] Default parameters used to expand the template. # @param options [Hash] Either a `:processor` or a `:parameterize` block. # # @api private def generate(params={}, recall={}, options={}) merged = recall.merge(params) if options[:processor] processor = options[:processor] elsif options[:parameterize] # TODO: This is sending me into fits trying to shoe-horn this into # the existing API. I think I've got this backwards and processors # should be a set of 4 optional blocks named :validate, :transform, # :match, and :restore. Having to use a singleton here is a huge # code smell. processor = Object.new class <validate or # transform messages or both. Both the validate and # transform methods should take two parameters: name and # value. The validate method should return true # or false; true if the value of the variable is valid, # false otherwise. An InvalidTemplateValueError exception # will be raised if the value is invalid. The transform method # should return the transformed variable value as a String. If a # transform method is used, the value will not be percent encoded # automatically. Unicode normalization will be performed both before and # after sending the value to the transform method. # # @return [String] The expanded expression def transform_partial_capture(mapping, capture, processor = nil, normalize_values = true) _, operator, varlist = *capture.match(EXPRESSION) vars = varlist.split(",") if operator == "?" # partial expansion of form style query variables sometimes requires a # slight reordering of the variables to produce a valid url. first_to_expand = vars.find { |varspec| _, name, _ = *varspec.match(VARSPEC) mapping.key?(name) && !mapping[name].nil? } vars = [first_to_expand] + vars.reject {|varspec| varspec == first_to_expand} if first_to_expand end vars. inject("".dup) do |acc, varspec| _, name, _ = *varspec.match(VARSPEC) next_val = if mapping.key? name transform_capture(mapping, "{#{operator}#{varspec}}", processor, normalize_values) else "{#{operator}#{varspec}}" end # If we've already expanded at least one '?' operator with non-empty # value, change to '&' operator = "&" if (operator == "?") && (next_val != "") acc << next_val end end ## # Transforms a mapped value so that values can be substituted into the # template. # # @param [Hash] mapping The mapping to replace captures # @param [String] capture # The expression to replace # @param [#validate, #transform] processor # An optional processor object may be supplied. # @param [Boolean] normalize_values # Optional flag to enable/disable unicode normalization. Default: true # # # The object should respond to either the validate or # transform messages or both. Both the validate and # transform methods should take two parameters: name and # value. The validate method should return true # or false; true if the value of the variable is valid, # false otherwise. An InvalidTemplateValueError exception # will be raised if the value is invalid. The transform method # should return the transformed variable value as a String. If a # transform method is used, the value will not be percent encoded # automatically. Unicode normalization will be performed both before and # after sending the value to the transform method. # # @return [String] The expanded expression def transform_capture(mapping, capture, processor=nil, normalize_values=true) _, operator, varlist = *capture.match(EXPRESSION) return_value = varlist.split(',').inject([]) do |acc, varspec| _, name, modifier = *varspec.match(VARSPEC) value = mapping[name] unless value == nil || value == {} allow_reserved = %w(+ #).include?(operator) # Common primitives where the .to_s output is well-defined if Numeric === value || Symbol === value || value == true || value == false value = value.to_s end length = modifier.gsub(':', '').to_i if modifier =~ /^:\d+/ unless (Hash === value) || value.respond_to?(:to_ary) || value.respond_to?(:to_str) raise TypeError, "Can't convert #{value.class} into String or Array." end value = normalize_value(value) if normalize_values if processor == nil || !processor.respond_to?(:transform) # Handle percent escaping if allow_reserved encode_map = Addressable::URI::CharacterClasses::RESERVED + Addressable::URI::CharacterClasses::UNRESERVED else encode_map = Addressable::URI::CharacterClasses::UNRESERVED end if value.kind_of?(Array) transformed_value = value.map do |val| if length Addressable::URI.encode_component(val[0...length], encode_map) else Addressable::URI.encode_component(val, encode_map) end end unless modifier == "*" transformed_value = transformed_value.join(',') end elsif value.kind_of?(Hash) transformed_value = value.map do |key, val| if modifier == "*" "#{ Addressable::URI.encode_component( key, encode_map) }=#{ Addressable::URI.encode_component( val, encode_map) }" else "#{ Addressable::URI.encode_component( key, encode_map) },#{ Addressable::URI.encode_component( val, encode_map) }" end end unless modifier == "*" transformed_value = transformed_value.join(',') end else if length transformed_value = Addressable::URI.encode_component( value[0...length], encode_map) else transformed_value = Addressable::URI.encode_component( value, encode_map) end end end # Process, if we've got a processor if processor != nil if processor.respond_to?(:validate) if !processor.validate(name, value) display_value = value.kind_of?(Array) ? value.inspect : value raise InvalidTemplateValueError, "#{name}=#{display_value} is an invalid template value." end end if processor.respond_to?(:transform) transformed_value = processor.transform(name, value) if normalize_values transformed_value = normalize_value(transformed_value) end end end acc << [name, transformed_value] end acc end return "" if return_value.empty? join_values(operator, return_value) end ## # Takes a set of values, and joins them together based on the # operator. # # @param [String, Nil] operator One of the operators from the set # (?,&,+,#,;,/,.), or nil if there wasn't one. # @param [Array] return_value # The set of return values (as [variable_name, value] tuples) that will # be joined together. # # @return [String] The transformed mapped value def join_values(operator, return_value) leader = LEADERS.fetch(operator, '') joiner = JOINERS.fetch(operator, ',') case operator when '&', '?' leader + return_value.map{|k,v| if v.is_a?(Array) && v.first =~ /=/ v.join(joiner) elsif v.is_a?(Array) v.map{|inner_value| "#{k}=#{inner_value}"}.join(joiner) else "#{k}=#{v}" end }.join(joiner) when ';' return_value.map{|k,v| if v.is_a?(Array) && v.first =~ /=/ ';' + v.join(";") elsif v.is_a?(Array) ';' + v.map{|inner_value| "#{k}=#{inner_value}"}.join(";") else v && v != '' ? ";#{k}=#{v}" : ";#{k}" end }.join else leader + return_value.map{|k,v| v}.join(joiner) end end ## # Takes a set of values, and joins them together based on the # operator. # # @param [Hash, Array, String] value # Normalizes keys and values with IDNA#unicode_normalize_kc # # @return [Hash, Array, String] The normalized values def normalize_value(value) unless value.is_a?(Hash) value = value.respond_to?(:to_ary) ? value.to_ary : value.to_str end # Handle unicode normalization if value.kind_of?(Array) value.map! { |val| Addressable::IDNA.unicode_normalize_kc(val) } elsif value.kind_of?(Hash) value = value.inject({}) { |acc, (k, v)| acc[Addressable::IDNA.unicode_normalize_kc(k)] = Addressable::IDNA.unicode_normalize_kc(v) acc } else value = Addressable::IDNA.unicode_normalize_kc(value) end value end ## # Generates a hash with string keys # # @param [Hash] mapping A mapping hash to normalize # # @return [Hash] # A hash with stringified keys def normalize_keys(mapping) return mapping.inject({}) do |accu, pair| name, value = pair if Symbol === name name = name.to_s elsif name.respond_to?(:to_str) name = name.to_str else raise TypeError, "Can't convert #{name.class} into String." end accu[name] = value accu end end ## # Generates the Regexp that parses a template pattern. # # @param [String] pattern The URI template pattern. # @param [#match] processor The template processor to use. # # @return [Regexp] # A regular expression which may be used to parse a template pattern. def parse_template_pattern(pattern, processor=nil) # Escape the pattern. The two gsubs restore the escaped curly braces # back to their original form. Basically, escape everything that isn't # within an expansion. escaped_pattern = Regexp.escape( pattern ).gsub(/\\\{(.*?)\\\}/) do |escaped| escaped.gsub(/\\(.)/, "\\1") end expansions = [] # Create a regular expression that captures the values of the # variables in the URI. regexp_string = escaped_pattern.gsub( EXPRESSION ) do |expansion| expansions << expansion _, operator, varlist = *expansion.match(EXPRESSION) leader = Regexp.escape(LEADERS.fetch(operator, '')) joiner = Regexp.escape(JOINERS.fetch(operator, ',')) combined = varlist.split(',').map do |varspec| _, name, modifier = *varspec.match(VARSPEC) result = processor && processor.respond_to?(:match) ? processor.match(name) : nil if result "(?<#{name}>#{ result })" else group = case operator when '+' "#{ RESERVED }*?" when '#' "#{ RESERVED }*?" when '/' "#{ UNRESERVED }*?" when '.' "#{ UNRESERVED.gsub('\.', '') }*?" when ';' "#{ UNRESERVED }*=?#{ UNRESERVED }*?" when '?' "#{ UNRESERVED }*=#{ UNRESERVED }*?" when '&' "#{ UNRESERVED }*=#{ UNRESERVED }*?" else "#{ UNRESERVED }*?" end if modifier == '*' "(?<#{name}>#{group}(?:#{joiner}?#{group})*)?" else "(?<#{name}>#{group})?" end end end.join("#{joiner}?") "(?:|#{leader}#{combined})" end # Ensure that the regular expression matches the whole URI. regexp_string = "^#{regexp_string}$" return expansions, Regexp.new(regexp_string) end end end