Class: StringInterpolator

Inherits:
Object
  • Object
show all
Defined in:
lib/string_interpolator.rb

Overview

Super neat string interpolation library for replacing placeholders in strings, kinda like how ‘git log –pretty=format:’%H %s’‘ works.

You create an interpolator by doing something like:

i = StringInterpolator.new

To add placeholders, use the add method:

i.add(n: 'Bob', w: 'nice') # keys can also be strings

And now you’re ready to actually use your interpolator:

result = i.interpolate("Hello, %n. The weather's %w today") # returns "Hello, Bob. The weather's nice today"

You can mark placeholders as being required:

i = StringInterpolator.new
i.add(n: 'Bob', w: 'nice')
i.require(:n)
i.interpolate("Hello, the weather's %w today") # this raises an exception...
i.interpolate("Hello, %n.") # ...but this works

Both add and require return the interpolator itself, so you can chain them together:

result = StringInterpolator.new.add(n: 'Bob').require(:n).interpolate('Hello, %n.')

Interpolators use % as the character that signals the start of a placeholder by default. If you’d like to use a different character as the herald, you can do that with:

i = StringInterpolator.new('$')
i.add(n: 'Bob', w: 'nice')
i.interpolate("Hello, $n. The weather's $w today")

Heralds can be multi-character strings, if you like:

i = StringInterpolator.new('!!!')
i.add(n: 'Bob', w: 'nice')
i.interpolate("Hello, !!!n. The weather's !!!w today")

Placeholders can also be multi-character strings:

i = StringInterpolator.new
i.add(name: 'Bob', weather: 'nice')
i.require(:name)
i.interpolate("Hello, %name. The weather's %weather today")

Two percent signs (or two of whatever herald you’ve chosen) in a row can be used to insert a literal copy of the herald:

i = StringInterpolator.new
i.add(n: 'Bob')
i.interpolate("Hello, %n. Humidity's right about 60%% today") # "Hello, Bob. Humidity's right about 60% today"

You can turn off the double herald literal mechanism and add your own, if you like:

i = StringInterpolator.new(literal: false)
i.add(percent: '%')
i.interpolate('%percent') # '%'
i.interpolate('%%') # raises an exception

Ambiguous placeholders cause an exception to be raised:

i = StringInterpolator.new
i.add(foo: 'one', foobar: 'two') # raises an exception - should "%foobarbaz" be "onebarbaz" or "twobaz"?

And that’s about it.

Internally the whole thing is implemented using a prefix tree of all of the placeholders that have been added and a scanning parser that descends through the tree whenever it hits a herald to find a match. It’s therefore super fast even on long strings and with lots of placeholders, and it doesn’t get confused by things like ‘%%foo’ or ‘%%%foo’ (which should respectively output ‘%foo’ and ‘%bar’ if foo is a placeholder for ‘bar’, but a lot of other interpolation libraries that boil down to a bunch of ‘gsub`s screw one of those two up). The only downside of the current implementation is that it recurses for each character in a placeholder, so placeholders are limited to a few hundred characters in length (but their replacements aren’t). I’ll rewrite it as a bunch of loops if that ever actually becomes a problem for anyone (but I’ll probably question your sanity for using such long placeholders first).

Defined Under Namespace

Classes: Error

Instance Method Summary collapse

Constructor Details

#initialize(herald = '%', literal: true) ⇒ StringInterpolator

Create a new interpolator that uses the specified herald, or ‘%’ if one isn’t specified.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/string_interpolator.rb', line 83

def initialize(herald = '%', literal: true)
  @herald = herald
  # Yes, we're using regexes, but only because StringScanner's pretty dang fast. Don't you dare think I'm naive
  # enough to just gsub all the substitutions or something like that.
  @escaped_herald = Regexp.escape(herald)
  @required = Set.new
  @tree = nil # instead of {} because that preserves the generality of trees and replacements being the same thing.
  # This lets someone do something like Interpolator.new(literal: false).add('' => 'foo').interpolate('a % b')
  # and get back 'a foo b', which is not a thing I expect anyone to actually do, but no reason to stop them from
  # doing it if they really want.

  # Allow two heralds in a row to be used to insert a literal copy of the herald unless we've been told not to
  add(herald => herald) if literal
end

Instance Method Details

#add(substitutions) ⇒ Object

Add new substitutions to this interpolator. The keys of the specified dictionary will be used as the placeholders and the values will be used as the replacements. Keys can be either strings or symbols.

Duplicate keys and keys which are prefixes of other keys will cause an exception to be thrown.



102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/string_interpolator.rb', line 102

def add(substitutions)
  substitutions.each do |key, replacement|
    # Turn the substitution into a prefix tree. This takes a key like 'foo' and a value like 'bar' and turns it
    # into {'f' => {'o' => {'o' => 'bar'}}}. Also stringify the key in case it's a symbol.
    tree = key.to_s.reverse.chars.reduce(replacement) { |tree, char| {char => tree} }
    # Then merge it with our current tree. Not as efficient as direct insertion, but algorithmically simpler, and
    # I'll eat my hat when someone uses this in a practical application where this bit is the bottleneck.
    @tree = merge(@tree, tree)
  end

  # yay chaining!
  self
end

#interpolate(string) ⇒ Object

Interpolate the specified string, replacing placeholders that have been added to this interpolator with their replacements.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/string_interpolator.rb', line 128

def interpolate(string)
  scanner = StringScanner.new(string)
  result = ''
  unused = @required.dup

  until scanner.eos?
    # See if there's a herald at our current position
    if scanner.scan(/#{@escaped_herald}/)
      # There is, so parse a substitution where we're at, mark the key as having been used, and output the
      # replacement.
      key, replacement = parse(scanner)
      unused.delete(key)
      result << replacement
    else
      # No heralds here. Grab everything up to the next herald or end-of-string and output it. The fact that both
      # a group and a negative lookahead assertion are needed to get this right really makes me wonder if I
      # shouldn't just loop through the string character by character after all... And no, I'm not changing it to
      # a negative character class because this was literally the only line that needed to be changed to allow
      # multi-character heralds to work. Now someone go use them already.
      result << scanner.scan(/(?:(?!#{@escaped_herald}).)+/)
    end
  end

  # Blow up if any required interpolations weren't used
  unless unused.empty?
    unused_description = unused.map do |placeholder|
      "#{@herald}#{placeholder}"
    end.join(', ')

    raise Error.new("required placeholders were unused: #{unused_description}")
  end

  result
end

#require(*placeholders) ⇒ Object

Mark the specified placeholders as required. Interpolation will fail if the string to be interpolated does not include all placeholders that have been marked as required.



118
119
120
121
122
123
124
# File 'lib/string_interpolator.rb', line 118

def require(*placeholders)
  # Stringify keys in case they're symbols
  @required.merge(placeholders.map(&:to_s))

  # yay more chaining!
  self
end