string_interpolator 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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:
|