string_interpolator 0.1

Sign up to get free protection for your applications and to get access to all the features.
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: