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 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: