string_interpolator 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/string_interpolator.rb +224 -0
- data/spec/string_interpolator_spec.rb +67 -0
- metadata +45 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4e35d0d291e4ac72fc0184dc3ab27931c40fc5a2
|
4
|
+
data.tar.gz: 91f59c0061a791d22c38ae24b9177d7e451ad0ed
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c2168c67fcd6e933d31cef92e4dc4f6fb7f354d2992f9a4b4555c57c8f5584dfb362c911029d6f1d6f20b67273f55225354bd2be0bfe39f92d78e6fa0cbea7cb
|
7
|
+
data.tar.gz: 0b2dd2b837a2c07de9b4f09996c7b3579ac507a6d0dfdaee56800ab4838a330262942a0232be59493a1ce7862108da795a479a30d6e1c7a706f4f6c2226d355a
|
@@ -0,0 +1,224 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'strscan'
|
3
|
+
|
4
|
+
# Super neat string interpolation library for replacing placeholders in strings, kinda like how
|
5
|
+
# `git log --pretty=format:'%H %s'` works.
|
6
|
+
#
|
7
|
+
# You create an interpolator by doing something like:
|
8
|
+
#
|
9
|
+
# i = StringInterpolator.new
|
10
|
+
#
|
11
|
+
# To add placeholders, use the add method:
|
12
|
+
#
|
13
|
+
# i.add(n: 'Bob', w: 'nice') # keys can also be strings
|
14
|
+
#
|
15
|
+
# And now you're ready to actually use your interpolator:
|
16
|
+
#
|
17
|
+
# result = i.interpolate("Hello, %n. The weather's %w today") # returns "Hello, Bob. The weather's nice today"
|
18
|
+
#
|
19
|
+
# You can mark placeholders as being required:
|
20
|
+
#
|
21
|
+
# i = StringInterpolator.new
|
22
|
+
# i.add(n: 'Bob', w: 'nice')
|
23
|
+
# i.require(:n)
|
24
|
+
# i.interpolate("Hello, the weather's %w today") # this raises an exception...
|
25
|
+
# i.interpolate("Hello, %n.") # ...but this works
|
26
|
+
#
|
27
|
+
# Both add and require return the interpolator itself, so you can chain them together:
|
28
|
+
#
|
29
|
+
# result = StringInterpolator.new.add(n: 'Bob').require(:n).interpolate('Hello, %n.')
|
30
|
+
#
|
31
|
+
# Interpolators use % as the character that signals the start of a placeholder by default. If you'd like to use a
|
32
|
+
# different character as the herald, you can do that with:
|
33
|
+
#
|
34
|
+
# i = StringInterpolator.new('$')
|
35
|
+
# i.add(n: 'Bob', w: 'nice')
|
36
|
+
# i.interpolate("Hello, $n. The weather's $w today")
|
37
|
+
#
|
38
|
+
# Heralds can be multi-character strings, if you like:
|
39
|
+
#
|
40
|
+
# i = StringInterpolator.new('!!!')
|
41
|
+
# i.add(n: 'Bob', w: 'nice')
|
42
|
+
# i.interpolate("Hello, !!!n. The weather's !!!w today")
|
43
|
+
#
|
44
|
+
# Placeholders can also be multi-character strings:
|
45
|
+
#
|
46
|
+
# i = StringInterpolator.new
|
47
|
+
# i.add(name: 'Bob', weather: 'nice')
|
48
|
+
# i.require(:name)
|
49
|
+
# i.interpolate("Hello, %name. The weather's %weather today")
|
50
|
+
#
|
51
|
+
# Two percent signs (or two of whatever herald you've chosen) in a row can be used to insert a literal copy of the
|
52
|
+
# herald:
|
53
|
+
#
|
54
|
+
# i = StringInterpolator.new
|
55
|
+
# i.add(n: 'Bob')
|
56
|
+
# i.interpolate("Hello, %n. Humidity's right about 60%% today") # "Hello, Bob. Humidity's right about 60% today"
|
57
|
+
#
|
58
|
+
# You can turn off the double herald literal mechanism and add your own, if you like:
|
59
|
+
#
|
60
|
+
# i = StringInterpolator.new(literal: false)
|
61
|
+
# i.add(percent: '%')
|
62
|
+
# i.interpolate('%percent') # '%'
|
63
|
+
# i.interpolate('%%') # raises an exception
|
64
|
+
#
|
65
|
+
# Ambiguous placeholders cause an exception to be raised:
|
66
|
+
#
|
67
|
+
# i = StringInterpolator.new
|
68
|
+
# i.add(foo: 'one', foobar: 'two') # raises an exception - should "%foobarbaz" be "onebarbaz" or "twobaz"?
|
69
|
+
#
|
70
|
+
# And that's about it.
|
71
|
+
#
|
72
|
+
# Internally the whole thing is implemented using a prefix tree of all of the placeholders that have been added and a
|
73
|
+
# scanning parser that descends through the tree whenever it hits a herald to find a match. It's therefore super fast
|
74
|
+
# even on long strings and with lots of placeholders, and it doesn't get confused by things like '%%foo' or '%%%foo'
|
75
|
+
# (which should respectively output '%foo' and '%bar' if foo is a placeholder for 'bar', but a lot of other
|
76
|
+
# interpolation libraries that boil down to a bunch of `gsub`s screw one of those two up). The only downside of the
|
77
|
+
# current implementation is that it recurses for each character in a placeholder, so placeholders are limited to a few
|
78
|
+
# hundred characters in length (but their replacements aren't). I'll rewrite it as a bunch of loops if that ever
|
79
|
+
# actually becomes a problem for anyone (but I'll probably question your sanity for using such long placeholders
|
80
|
+
# first).
|
81
|
+
class StringInterpolator
|
82
|
+
# Create a new interpolator that uses the specified herald, or '%' if one isn't specified.
|
83
|
+
def initialize(herald = '%', literal: true)
|
84
|
+
@herald = herald
|
85
|
+
# Yes, we're using regexes, but only because StringScanner's pretty dang fast. Don't you dare think I'm naive
|
86
|
+
# enough to just gsub all the substitutions or something like that.
|
87
|
+
@escaped_herald = Regexp.escape(herald)
|
88
|
+
@required = Set.new
|
89
|
+
@tree = nil # instead of {} because that preserves the generality of trees and replacements being the same thing.
|
90
|
+
# This lets someone do something like Interpolator.new(literal: false).add('' => 'foo').interpolate('a % b')
|
91
|
+
# and get back 'a foo b', which is not a thing I expect anyone to actually do, but no reason to stop them from
|
92
|
+
# doing it if they really want.
|
93
|
+
|
94
|
+
# Allow two heralds in a row to be used to insert a literal copy of the herald unless we've been told not to
|
95
|
+
add(herald => herald) if literal
|
96
|
+
end
|
97
|
+
|
98
|
+
# Add new substitutions to this interpolator. The keys of the specified dictionary will be used as the placeholders
|
99
|
+
# and the values will be used as the replacements. Keys can be either strings or symbols.
|
100
|
+
#
|
101
|
+
# Duplicate keys and keys which are prefixes of other keys will cause an exception to be thrown.
|
102
|
+
def add(substitutions)
|
103
|
+
substitutions.each do |key, replacement|
|
104
|
+
# Turn the substitution into a prefix tree. This takes a key like 'foo' and a value like 'bar' and turns it
|
105
|
+
# into {'f' => {'o' => {'o' => 'bar'}}}. Also stringify the key in case it's a symbol.
|
106
|
+
tree = key.to_s.reverse.chars.reduce(replacement) { |tree, char| {char => tree} }
|
107
|
+
# Then merge it with our current tree. Not as efficient as direct insertion, but algorithmically simpler, and
|
108
|
+
# I'll eat my hat when someone uses this in a practical application where this bit is the bottleneck.
|
109
|
+
@tree = merge(@tree, tree)
|
110
|
+
end
|
111
|
+
|
112
|
+
# yay chaining!
|
113
|
+
self
|
114
|
+
end
|
115
|
+
|
116
|
+
# Mark the specified placeholders as required. Interpolation will fail if the string to be interpolated does not
|
117
|
+
# include all placeholders that have been marked as required.
|
118
|
+
def require(*placeholders)
|
119
|
+
# Stringify keys in case they're symbols
|
120
|
+
@required.merge(placeholders.map(&:to_s))
|
121
|
+
|
122
|
+
# yay more chaining!
|
123
|
+
self
|
124
|
+
end
|
125
|
+
|
126
|
+
# Interpolate the specified string, replacing placeholders that have been added to this interpolator with their
|
127
|
+
# replacements.
|
128
|
+
def interpolate(string)
|
129
|
+
scanner = StringScanner.new(string)
|
130
|
+
result = ''
|
131
|
+
unused = @required.dup
|
132
|
+
|
133
|
+
until scanner.eos?
|
134
|
+
# See if there's a herald at our current position
|
135
|
+
if scanner.scan(/#{@escaped_herald}/)
|
136
|
+
# There is, so parse a substitution where we're at, mark the key as having been used, and output the
|
137
|
+
# replacement.
|
138
|
+
key, replacement = parse(scanner)
|
139
|
+
unused.delete(key)
|
140
|
+
result << replacement
|
141
|
+
else
|
142
|
+
# No heralds here. Grab everything up to the next herald or end-of-string and output it. The fact that both
|
143
|
+
# a group and a negative lookahead assertion are needed to get this right really makes me wonder if I
|
144
|
+
# shouldn't just loop through the string character by character after all... And no, I'm not changing it to
|
145
|
+
# a negative character class because this was literally the only line that needed to be changed to allow
|
146
|
+
# multi-character heralds to work. Now someone go use them already.
|
147
|
+
result << scanner.scan(/(?:(?!#{@escaped_herald}).)+/)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Blow up if any required interpolations weren't used
|
152
|
+
unless unused.empty?
|
153
|
+
unused_description = unused.map do |placeholder|
|
154
|
+
"#{@herald}#{placeholder}"
|
155
|
+
end.join(', ')
|
156
|
+
|
157
|
+
raise Error.new("required placeholders were unused: #{unused_description}")
|
158
|
+
end
|
159
|
+
|
160
|
+
result
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
# Merge two trees and return the result. Neither tree is modified in the process. Duplicate keys and keys that
|
166
|
+
# give rise to ambiguities will result in much yelling and exceptions.
|
167
|
+
def merge(first, second, prefix = '')
|
168
|
+
if first.nil?
|
169
|
+
# Probably because we're merging hashes and the first one didn't have a value for this key. Use the other one.
|
170
|
+
second
|
171
|
+
elsif second.nil?
|
172
|
+
# Ditto
|
173
|
+
first
|
174
|
+
elsif first.is_a?(Hash) && second.is_a?(Hash)
|
175
|
+
# Both are hashes, so recursively merge their values
|
176
|
+
first.merge(second) { |k, left, right| merge(left, right, prefix + k) }
|
177
|
+
elsif first.is_a?(Hash) || second.is_a?(Hash)
|
178
|
+
# One of them's a hash and the other's a substitution, which means that there's a substitution that's a prefix
|
179
|
+
# of another one. We'll construct arbitrary placeholders from the two side because we like giving users
|
180
|
+
# informative error messages (and note that only one side will be a tree, but our algorithm's nice and general
|
181
|
+
# and will give us the right answer for both sides), then raise an exception that includes them.
|
182
|
+
first_key = prefix + pick_key(first)
|
183
|
+
second_key = prefix + pick_key(second)
|
184
|
+
raise Error.new("conflicting placeholders: #{@herald}#{first_key} and #{@herald}#{second_key}")
|
185
|
+
else
|
186
|
+
# They're both substitutions for the same prefix, so we've got duplicates.
|
187
|
+
raise Error.new("duplicate placeholder: #{@herald}#{prefix}")
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
# Pick an arbitrary key out of the specified tree. Smart enough to hand back an empty string when given a
|
192
|
+
# substitution instead of a tree. Not used in your typical day-to-day operation, only used to produce more
|
193
|
+
# informative error messages when the user tries to add conflicting substitutions.
|
194
|
+
def pick_key(tree)
|
195
|
+
return '' unless tree.is_a? Hash
|
196
|
+
key = tree.keys.first
|
197
|
+
key + pick_key(tree[key])
|
198
|
+
end
|
199
|
+
|
200
|
+
# Parse a single placeholder from the given scanner. The key and the replacement will be returned. If there's no
|
201
|
+
# such substitution, an exception will be raised and the scanner will be left at an arbitrary position, so don't
|
202
|
+
# try to use it again if that happens. An exception will also be raised if the placeholder runs off the edge of
|
203
|
+
# the string.
|
204
|
+
def parse(scanner, tree = @tree, prefix = '')
|
205
|
+
if tree.is_a? Hash
|
206
|
+
# Still more levels down the tree to go before we find the placeholder they're looking for, so grab a character
|
207
|
+
# from the scanner...
|
208
|
+
char = scanner.getch
|
209
|
+
# ...raise if we just hit the end of the string...
|
210
|
+
raise Error.new("incomplete placeholder at end of string: #{@herald}#{prefix}") unless char
|
211
|
+
# ...and if we didn't, look the character up in the tree and recurse.
|
212
|
+
parse(scanner, tree[char], prefix + char)
|
213
|
+
elsif tree.nil?
|
214
|
+
# Ran off the edge of the tree, so we didn't know about the placeholder we were given.
|
215
|
+
raise Error.new("invalid placeholder: #{@herald}#{prefix}")
|
216
|
+
else
|
217
|
+
# Found a replacement! Return it and the key we built up.
|
218
|
+
[prefix, tree]
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
class Error < Exception
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'string_interpolator'
|
2
|
+
|
3
|
+
RSpec.describe StringInterpolator do
|
4
|
+
describe '#interpolate' do
|
5
|
+
let(:subject) { described_class.new.add(a: 'one', b: 'two') }
|
6
|
+
|
7
|
+
it 'interpolates' do
|
8
|
+
expect(subject.interpolate('%a - %b')).to eq('one - two')
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'complains when the string contains a dangling percent sign' do
|
12
|
+
expect { subject.interpolate('%') }.to raise_error(StringInterpolator::Error)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'complains when a nonexistent placeholder is used' do
|
16
|
+
expect { subject.interpolate('%x') }.to raise_error(StringInterpolator::Error)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'includes a literal percent sign when encountering %%' do
|
20
|
+
expect(subject.interpolate('%%')).to eq('%')
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'works in pathological cases' do
|
24
|
+
expect(subject.interpolate('%a')).to eq('one')
|
25
|
+
expect(subject.interpolate('%%a')).to eq('%a')
|
26
|
+
expect(subject.interpolate('%%%a')).to eq('%one')
|
27
|
+
expect(subject.interpolate('%%%%a')).to eq('%%a')
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'required placeholders' do
|
31
|
+
let(:subject) { described_class.new.add(a: 'one', b: 'two').require(:a) }
|
32
|
+
|
33
|
+
it "complains when they aren't used" do
|
34
|
+
expect { subject.interpolate('%b') }.to raise_error(StringInterpolator::Error)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'works when they are used' do
|
38
|
+
expect(subject.interpolate('%a')).to eq('one')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context 'with herald literals disabled' do
|
43
|
+
let(:subject) { described_class.new(literal: false).add(a: 'one') }
|
44
|
+
|
45
|
+
it "doesn't allow herald literals" do
|
46
|
+
expect { subject.interpolate('%%') }.to raise_error(StringInterpolator::Error)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'with a multi-character herald' do
|
51
|
+
let(:subject) { described_class.new('!!!').add(a: 'one') }
|
52
|
+
|
53
|
+
it 'works' do
|
54
|
+
expect(subject.interpolate('!!!a')).to eq('one')
|
55
|
+
expect(subject.interpolate('!!a')).to eq('!!a')
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
it "doesn't allow conflicting literals" do
|
61
|
+
expect { described_class.new.add(foo: 'one', foobar: 'two') }.to raise_error(StringInterpolator::Error)
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'allows setting an empty placeholder with herald literals disabled' do
|
65
|
+
expect(described_class.new(literal: false).add('' => 'two').interpolate('one%three')).to eq('onetwothree')
|
66
|
+
end
|
67
|
+
end
|
metadata
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: string_interpolator
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alex Boyd
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-07-20 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email: aboyd@instructure.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- lib/string_interpolator.rb
|
20
|
+
- spec/string_interpolator_spec.rb
|
21
|
+
homepage: https://github.com/instructure/string_interpolator
|
22
|
+
licenses: []
|
23
|
+
metadata: {}
|
24
|
+
post_install_message:
|
25
|
+
rdoc_options: []
|
26
|
+
require_paths:
|
27
|
+
- lib
|
28
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
34
|
+
requirements:
|
35
|
+
- - ">="
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
requirements: []
|
39
|
+
rubyforge_project:
|
40
|
+
rubygems_version: 2.2.2
|
41
|
+
signing_key:
|
42
|
+
specification_version: 4
|
43
|
+
summary: String interpolator that doesn't use gsub
|
44
|
+
test_files: []
|
45
|
+
has_rdoc:
|