Class: StringInterpolator
- Inherits:
-
Object
- Object
- StringInterpolator
- 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
-
#add(substitutions) ⇒ Object
Add new substitutions to this interpolator.
-
#initialize(herald = '%', literal: true) ⇒ StringInterpolator
constructor
Create a new interpolator that uses the specified herald, or ‘%’ if one isn’t specified.
-
#interpolate(string) ⇒ Object
Interpolate the specified string, replacing placeholders that have been added to this interpolator with their replacements.
-
#require(*placeholders) ⇒ Object
Mark the specified placeholders as required.
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 |