object_regex 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.md +430 -0
- data/Rakefile +46 -0
- data/VERSION +1 -0
- data/lib/object_regex.rb +4 -0
- data/lib/object_regex/implementation.rb +90 -0
- data/spec/object_regex_spec.rb +89 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +8 -0
- metadata +105 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Michael Edgar
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,430 @@
|
|
1
|
+
## Introduction
|
2
|
+
|
3
|
+
I present a small Ruby class which provides full Ruby Regexp matching on sequences of (potentially) heterogenous objects, conditioned on those objects implementing a single, no-argument method returning a String. I propose it should be used to implement the desired behavior in the Ruby standard library.
|
4
|
+
|
5
|
+
## Motivation
|
6
|
+
|
7
|
+
So I'm hammering away at [Wool (soon to be renamed Laser)](http://github.com/michaeledgar/wool/), and I come across a situation: I need to parse out comments using Ripper's output.
|
8
|
+
|
9
|
+
I decided a while ago I wouldn't use [YARD](http://yardoc.org/)'s Ripper-based parser as it returns [its own AST format](https://github.com/lsegal/yard/blob/master/lib/yard/parser/ruby/ruby_parser.rb). YARD has its own goals, so it's not surprising the standard output from Ripper was insufficient. However, I don't want to define a new AST format - we already have Ripper's, YARD's, and of course, the venerable [RubyParser/ParseTree format](http://parsetree.rubyforge.org/). I'm rambling: the point is, I'm using exact Ripper output, and there's no existing code to annotate a Ripper node with the comments immediately preceding it.
|
10
|
+
|
11
|
+
## Extracting Comments from Ruby
|
12
|
+
|
13
|
+
Since Ripper strips the comments out when you use `Ripper.sexp`, and I'm not going to switch to the SAX-model of parsing just for comments, I had to use `Ripper.lex` to grab the comments. I immediately found this would prove annoying:
|
14
|
+
|
15
|
+
{{{
|
16
|
+
pp Ripper.lex(" # some comment\n # another comment\n def abc; end")
|
17
|
+
}}}
|
18
|
+
|
19
|
+
gives
|
20
|
+
|
21
|
+
{{{
|
22
|
+
[[[1, 0], :on_sp, " "],
|
23
|
+
[[1, 2], :on_comment, "# some comment\n"],
|
24
|
+
[[2, 0], :on_sp, " "],
|
25
|
+
[[2, 2], :on_comment, "# another comment\n"],
|
26
|
+
[[3, 0], :on_sp, " "],
|
27
|
+
[[3, 1], :on_kw, "def"],
|
28
|
+
[[3, 4], :on_sp, " "],
|
29
|
+
[[3, 5], :on_ident, "abc"],
|
30
|
+
[[3, 8], :on_semicolon, ";"],
|
31
|
+
[[3, 9], :on_sp, " "],
|
32
|
+
[[3, 10], :on_kw, "end"]]
|
33
|
+
}}}
|
34
|
+
|
35
|
+
Naturally, Ripper is separating each line-comment into its own token, even those that follow on subsequent lines. I'd have to combine those comment tokens to get what a typical programmer considers one logical comment.
|
36
|
+
|
37
|
+
I didn't want to write an ugly, imperative algorithm to do this: part of the beauty of writing Ruby is you don't often have to actually write a `while` loop. I described my frustration to my roommate, and he quickly observed the obvious connection to regular expressions. That's when I remembered [Ripper.slice and Ripper.token_match](http://ruby-doc.org/ruby-1.9/classes/Ripper.html#M001274) (token_match is undocumented), which provide almost exactly what I needed:
|
38
|
+
|
39
|
+
{{{
|
40
|
+
Ripper.slice(" # some comment\n # another comment\n def abc; end",
|
41
|
+
'comment (sp? comment)*')
|
42
|
+
# => "# some comment\n # another comment\n"
|
43
|
+
}}}
|
44
|
+
|
45
|
+
A few problems: `Ripper.slice` lexes its input on each invocation and then searches it from the start for one match. I need *all* matches. `Ripper.slice` also returns the exact string, and not the location in the source text of the match, which I need - how else will I know where the comments are? The lexer output includes line and column locations, so it should be easy to retrieve.
|
46
|
+
|
47
|
+
All this means an O(N) solution was not in sight using the built-in library functions. I was about to start doing some subclassing hacks, until I peeked at the source for `Ripper.slice` and saw it was too cool to not generalize.
|
48
|
+
|
49
|
+
## Formal Origins of `Ripper.slice`
|
50
|
+
|
51
|
+
The core of regular expressions - the [actually "regular" kind](http://en.wikipedia.org/wiki/Regular_expression#Definition) - correspond directly to a [DFA](http://en.wikipedia.org/wiki/Deterministic_finite_automata) with an [alphabet](http://en.wikipedia.org/wiki/Alphabet_\(computer_science\)) equal to the character set being searched. Naturally, Ruby's `Regexp` engine offers many features that cannot be directly described by a DFA. Anyway, what I wanted was a way to perform the same searches, only with an alphabet of token types instead of characters.
|
52
|
+
|
53
|
+
We could construct a separate DFA engine for searching sequences of our new alphabet, but we'd much rather piggyback an existing (and more-featured) implementation. Since the set of token types is countable, one can create a one-to-one mapping from token types to finite strings of an alphabet that Ruby's `Regexp` class can search, namely regular old characters. If we replace each occurrence of a member of our alphabet with a member of the target, Regexp alphabet, then we should be able to use Regexp to do regex searching on our token sequence. That transformation on the token sequence is easy: just map each token's type onto some string using a 1-to-1 function. However, one important bit that remains is how the search pattern is specified. As you saw above, we used:
|
54
|
+
|
55
|
+
{{{
|
56
|
+
'comment (sp? comment)*'
|
57
|
+
}}}
|
58
|
+
|
59
|
+
to specify a search for "a comment token, followed by zero or more groups, where each group is an optional space token followed by a comment token." This departs from traditional Regexp syntax, because our alphabet is no longer composed of individual characters, it is composed of tokens. For this implementation's sake, we can observe that we require whitespace be insensitive, and that `?` and `*` operators apply to tokens, not to characters. We could specify this input however we like, as long as we can generate the correct string-searching pattern from it.
|
60
|
+
|
61
|
+
One last observation that allows us to use Regexp to search our tokens: we must be able to specify a one-to-one function from a token name to the set of tokens that it should match. In other words, no two tokens that we consider "different" can have the same token type. For a normal Regex, this is a trivial condition, as a character matches only that character. However, 'comment' must match the infinite set of all comment tokens. If we satisfy that condition, then there exists a function from a regex on token-types to a regex on strings. This is still pretty trivial to show for tokens, but later when we generalize this approach further, it becomes even more important to do correctly.
|
62
|
+
|
63
|
+
## Implementation
|
64
|
+
|
65
|
+
So, we get to Ripper's implementation:
|
66
|
+
|
67
|
+
1. Each token type is mapped to a single character in the set [a-zA-Z0-9].
|
68
|
+
2. The sequence of tokens to be searched is transformed into the sequence of characters corresponding to the token types.
|
69
|
+
3. The search pattern is transformed into a pattern that can search this mapped representation of the token sequence. Each token found in the search pattern is replaced by its corresponding single character, and whitespace is removed.
|
70
|
+
4. The new pattern runs on the mapped sequence. The result, if successful, is the start and end locations of the match in the mapped sequence.
|
71
|
+
5. Since each character in the mapped sequence corresponds to a single token, we can index into the original token sequence using the exact boundaries of the match result.
|
72
|
+
|
73
|
+
## An Example
|
74
|
+
|
75
|
+
Let's run through the previous example:
|
76
|
+
|
77
|
+
### Each token type is mapped to a single character in the set:
|
78
|
+
|
79
|
+
Ripper runs this code at load-time:
|
80
|
+
|
81
|
+
{{{
|
82
|
+
seed = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
|
83
|
+
SCANNER_EVENT_TABLE.each do |ev, |
|
84
|
+
raise CompileError, "[RIPPER FATAL] too many system token" if seed.empty?
|
85
|
+
MAP[ev.to_s.sub(/\Aon_/,'')] = seed.shift
|
86
|
+
end
|
87
|
+
}}}
|
88
|
+
|
89
|
+
I fired up an `irb` instance and checked the result:
|
90
|
+
|
91
|
+
{{{
|
92
|
+
Ripper::TokenPattern::MAP
|
93
|
+
# => {"CHAR"=>"a", "__end__"=>"b", "backref"=>"c", "backtick"=>"d",
|
94
|
+
"comma"=>"e", "comment"=>"f", "const"=>"g", "cvar"=>"h", "embdoc"=>"i",
|
95
|
+
"embdoc_beg"=>"j", "embdoc_end"=>"k", "embexpr_beg"=>"l",
|
96
|
+
"embexpr_end"=>"m", "embvar"=>"n", "float"=>"o", "gvar"=>"p",
|
97
|
+
"heredoc_beg"=>"q", "heredoc_end"=>"r", "ident"=>"s", "ignored_nl"=>"t",
|
98
|
+
"int"=>"u", "ivar"=>"v", "kw"=>"w", "label"=>"x", "lbrace"=>"y",
|
99
|
+
"lbracket"=>"z", "lparen"=>"A", "nl"=>"B", "op"=>"C", "period"=>"D",
|
100
|
+
"qwords_beg"=>"E", "rbrace"=>"F", "rbracket"=>"G", "regexp_beg"=>"H",
|
101
|
+
"regexp_end"=>"I", "rparen"=>"J", "semicolon"=>"K", "sp"=>"L",
|
102
|
+
"symbeg"=>"M", "tlambda"=>"N", "tlambeg"=>"O", "tstring_beg"=>"P",
|
103
|
+
"tstring_content"=>"Q", "tstring_end"=>"R", "words_beg"=>"S",
|
104
|
+
"words_sep"=>"T"}
|
105
|
+
}}}
|
106
|
+
|
107
|
+
This is completely implementation-dependent, but these characters are an implementation detail for the algorithm anyway.
|
108
|
+
|
109
|
+
### The sequence of tokens to be searched is transformed into the sequence of characters corresponding to the token types.
|
110
|
+
|
111
|
+
Ripper implements this as follows:
|
112
|
+
|
113
|
+
{{{
|
114
|
+
def map_tokens(tokens)
|
115
|
+
tokens.map {|pos,type,str| map_token(type.to_s.sub(/\Aon_/,'')) }.join
|
116
|
+
end
|
117
|
+
}}}
|
118
|
+
|
119
|
+
Running this on our token stream before (markdown doesn't support anchors, so scroll up if necessary), we get this:
|
120
|
+
|
121
|
+
{{{
|
122
|
+
"LfLfLwLsKLw"
|
123
|
+
}}}
|
124
|
+
|
125
|
+
This is what we will eventually run our modified Regexp against.
|
126
|
+
|
127
|
+
### The search pattern is transformed into a pattern that can search this mapped representation of the token sequence. Each token found in the search pattern is replaced by its corresponding single character, and whitespace is removed.
|
128
|
+
|
129
|
+
What we want is `comment (sp? comment)*`. In this mapped representation, a quick look at the table above shows the regex we need is
|
130
|
+
|
131
|
+
{{{
|
132
|
+
/f(L?f)*/
|
133
|
+
}}}
|
134
|
+
|
135
|
+
Ripper implements this in a somewhat roundabout fashion, as it seems they wanted to experiment with slightly different syntax. Since my implementation (which I'll present shortly) does not retain these syntax changes, I choose not to list the Ripper version here.
|
136
|
+
|
137
|
+
### The new pattern runs on the mapped sequence. The result, if successful, is the start and end locations of the match in the mapped sequence.
|
138
|
+
|
139
|
+
We run `/f(L?f)*/` on `"LfLfLwLsKLw"`. It matches `fLf` at position 1.
|
140
|
+
|
141
|
+
As expected, the implementation is quite simple for Ripper:
|
142
|
+
|
143
|
+
{{{
|
144
|
+
def match_list(tokens)
|
145
|
+
if m = @re.match(map_tokens(tokens))
|
146
|
+
then MatchData.new(tokens, m)
|
147
|
+
else nil
|
148
|
+
end
|
149
|
+
end
|
150
|
+
}}}
|
151
|
+
|
152
|
+
### Since each character in the mapped sequence corresponds to a single token, we can index into the original token sequence using the exact boundaries of the match result.
|
153
|
+
|
154
|
+
The boundaries returned were `(1..4]` in mathematical notation, or `(1...4)`/`(1..3)` as Ruby ranges. We then use this range on the original sequence, which returns:
|
155
|
+
|
156
|
+
{{{
|
157
|
+
[[[1, 2], :on_comment, "# some comment\n"],
|
158
|
+
[[2, 0], :on_sp, " "],
|
159
|
+
[[2, 2], :on_comment, "# another comment\n"]]
|
160
|
+
}}}
|
161
|
+
|
162
|
+
The implementation is again quite simple in Ripper, yet it for some reason immediately extracts the token contents:
|
163
|
+
|
164
|
+
{{{
|
165
|
+
def match(n = 0)
|
166
|
+
return [] unless @match
|
167
|
+
@tokens[@match.begin(n)...@match.end(n)].map {|pos,type,str| str }
|
168
|
+
end
|
169
|
+
}}}
|
170
|
+
|
171
|
+
## Generalization
|
172
|
+
|
173
|
+
My only complaints with Ripper's implementation, for what it intends to do, is that it lacks an API to get more than just the source code corresponding to the matched tokens. That's an API problem, and could easily be worked around.
|
174
|
+
|
175
|
+
What has been provided can be generalized, however, to work on not just tokens but sequences arbitrary, even heterogenous objects. There are a couple of properties we'll need to preserve to extend this to arbitrary sequences.
|
176
|
+
|
177
|
+
1. Alphabet Size: The alphabet for Ruby tokens is smaller than 62 elements, so we could use a single character from [A-Za-z0-9] to represent a token. If your alphabet is larger than that, we'll likely need to use a larger string for each element in the alphabet. Also, with Ruby Tokens, we knew the entire alphabet ahead of time. We don't necessarily know the whole alphabet for arbitrary sequences.
|
178
|
+
2. No two elements of the sequence which should match differently can have the same string representation. We used token types for this before, but our sequence was homogenous.
|
179
|
+
|
180
|
+
One observation makes the alphabet size issue less important: we actually only need to define a string mapping for elements in the alphabet that appear in the search pattern, not all those in the searched sequence. We can use the same string mapping for all elements in the searched sequence that don't appear in the regex pattern. If we recall that Regex features like character classes (`\w`, `\s`) and ranges (`[A-Za-z]`) are just syntactic sugar for repeated `|` operators, we'll see that in a normal regex we also only need to consider the elements of the alphabet appearing in the search pattern. All this means that if we use the same 62 characters that Ripper does, that only an input pattern with 62 different element types will require more than 1 character per element.
|
181
|
+
|
182
|
+
That said, we'll implement support for large alphabets anyway.
|
183
|
+
|
184
|
+
## General Implementation
|
185
|
+
|
186
|
+
For lack of a better name, we'll call this an `ObjectRegex`.
|
187
|
+
|
188
|
+
The full listing follows. You'll quickly notice that I haven't yet implemented the API that I actually need for Wool. Keeping focused seems incompatible with curiosity in my case, unfortunately.
|
189
|
+
|
190
|
+
{{{
|
191
|
+
class ObjectRegex
|
192
|
+
def initialize(pattern)
|
193
|
+
@map = generate_map(pattern)
|
194
|
+
@pattern = generate_pattern(pattern)
|
195
|
+
end
|
196
|
+
|
197
|
+
def mapped_value(reg_desc)
|
198
|
+
@map[reg_desc] || @map[:FAILBOAT]
|
199
|
+
end
|
200
|
+
|
201
|
+
MAPPING_CHARS = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
|
202
|
+
def generate_map(pattern)
|
203
|
+
alphabet = pattern.scan(/[A-Za-z]+/).uniq
|
204
|
+
repr_size = Math.log(alphabet.size + 1, MAPPING_CHARS.size).ceil
|
205
|
+
@item_size = repr_size + 1
|
206
|
+
|
207
|
+
map = Hash[alphabet.map.with_index do |symbol, idx|
|
208
|
+
[symbol, mapping_for_idx(repr_size, idx)]
|
209
|
+
end]
|
210
|
+
map.merge!(FAILBOAT: mapping_for_idx(repr_size, map.size))
|
211
|
+
end
|
212
|
+
|
213
|
+
def mapping_for_idx(repr_size, idx)
|
214
|
+
convert_to_mapping_radix(repr_size, idx).map do |char|
|
215
|
+
MAPPING_CHARS[char]
|
216
|
+
end.join + ';'
|
217
|
+
end
|
218
|
+
|
219
|
+
def convert_to_mapping_radix(repr_size, num)
|
220
|
+
result = []
|
221
|
+
repr_size.times do
|
222
|
+
result.unshift(num % MAPPING_CHARS.size)
|
223
|
+
num /= MAPPING_CHARS.size
|
224
|
+
end
|
225
|
+
result
|
226
|
+
end
|
227
|
+
|
228
|
+
def generate_pattern(pattern)
|
229
|
+
replace_tokens(fix_dots(remove_ranges(pattern)))
|
230
|
+
end
|
231
|
+
|
232
|
+
def remove_ranges(pattern)
|
233
|
+
pattern.gsub(/\[([A-Za-z ]*)\]/) do |match|
|
234
|
+
'(?:' + match[1..-2].split(/\s+/).join('|') + ')'
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
def fix_dots(pattern)
|
239
|
+
pattern.gsub('.', '.' * (@item_size - 1) + ';')
|
240
|
+
end
|
241
|
+
|
242
|
+
def replace_tokens(pattern)
|
243
|
+
pattern.gsub(/[A-Za-z]+/) do |match|
|
244
|
+
'(?:' + mapped_value(match) + ')'
|
245
|
+
end.gsub(/\s/, '')
|
246
|
+
end
|
247
|
+
|
248
|
+
def match(input)
|
249
|
+
new_input = input.map { |object| object.reg_desc }.
|
250
|
+
map { |desc| mapped_value(desc) }.join
|
251
|
+
if (match = new_input.match(@pattern))
|
252
|
+
start, stop = match.begin(0) / @item_size, match.end(0) / @item_size
|
253
|
+
input[start...stop]
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
}}}
|
258
|
+
|
259
|
+
## Generalized Map Generation
|
260
|
+
|
261
|
+
Generating the map is the primary interest here, so I'll start there.
|
262
|
+
|
263
|
+
First, we discover the alphabet by extracting all matches for `/[A-Za-z]+/` from the input pattern.
|
264
|
+
|
265
|
+
{{{
|
266
|
+
alphabet = pattern.scan(/[A-Za-z]+/).uniq
|
267
|
+
}}}
|
268
|
+
|
269
|
+
We figure out how many characters we need to represent that many elements, and save that for later:
|
270
|
+
|
271
|
+
{{{
|
272
|
+
# alphabet.size + 1 because of the catch-all, "not-in-pattern" mapping
|
273
|
+
repr_size = Math.log(alphabet.size + 1, MAPPING_CHARS.size).ceil
|
274
|
+
# repr_size + 1 because we will be inserting a terminator in a moment
|
275
|
+
@item_size = repr_size + 1
|
276
|
+
}}}
|
277
|
+
|
278
|
+
Now, we just calculate the [symbol, mapped\_symbol] pairs for each symbol in the input alphabet:
|
279
|
+
|
280
|
+
{{{
|
281
|
+
map = Hash[alphabet.map.with_index do |symbol, idx|
|
282
|
+
[symbol, mapping_for_idx(repr_size, idx)]
|
283
|
+
end]
|
284
|
+
}}}
|
285
|
+
|
286
|
+
We'll come back to how this works, but we must add the catch-all map entry: the entry that is triggered if we see a token in the searched sequence that didn't appear in the search pattern:
|
287
|
+
|
288
|
+
{{{
|
289
|
+
map.merge!(FAILBOAT: mapping_for_idx(repr_size, map.size))
|
290
|
+
}}}
|
291
|
+
|
292
|
+
Note that we avoid the use of the `inject({})` idiom common for constructing Hashes, since the computation of each tuple is independent from the others. `mapping_for_idx` is responsible for finding the mapped string for the given element. In Ripper, this was just an index into an array. However, if we want more than 62 possible elements in our alphabet, we instead need to convert the index into a base-62 number, first. `convert_to_mapping_radix` does this, using the size of the `MAPPING_CHARS` constant as the new radix:
|
293
|
+
|
294
|
+
{{{
|
295
|
+
# Standard radix conversion.
|
296
|
+
def convert_to_mapping_radix(repr_size, num)
|
297
|
+
result = []
|
298
|
+
repr_size.times do
|
299
|
+
result.unshift(num % MAPPING_CHARS.size)
|
300
|
+
num /= MAPPING_CHARS.size
|
301
|
+
end
|
302
|
+
result
|
303
|
+
end
|
304
|
+
}}}
|
305
|
+
|
306
|
+
If MAPPING\_CHARS.size = 62, then:
|
307
|
+
|
308
|
+
{{{
|
309
|
+
convert_to_mapping_radix(3, 12498)
|
310
|
+
# => [3, 15, 36]
|
311
|
+
}}}
|
312
|
+
|
313
|
+
After we convert each number into the necessary radix, we can then convert that array of place-value integers into a string by mapping each place value to its corresponding character in the MAPPING\_CHARS array:
|
314
|
+
|
315
|
+
{{{
|
316
|
+
def mapping_for_idx(repr_size, idx)
|
317
|
+
convert_to_mapping_radix(repr_size, idx).map { |char| MAPPING_CHARS[char] }.join + ';'
|
318
|
+
end
|
319
|
+
}}}
|
320
|
+
|
321
|
+
Notice that we added a semicolon at the end there. The choice of semicolon was arbitrary - it could be any valid character that isn't in MAPPING\_CHARS. Why'd I add that?
|
322
|
+
|
323
|
+
Imagine we were searching for a long input sequence that needed 2 characters per element in the alphabet. Perhaps the Ruby grammar has expanded and now has well over 62 token types, and `comment` tokens are represented as `ba`, while `sp` tokens are `aa`. If we search for `:sp` in the input `[:comment, :sp]`, we'll search in the string `"baaa"`. it will match halfway through the `comment` token at index 1, instead of at index 2, where the `:sp` actually lies. Thus, to avoid this, we simply pad each mapping with a semicolon. We could choose to only add the semicolon if `repr_size > 1` as an optimization, if we'd like.
|
324
|
+
|
325
|
+
## Generalized Pattern Transformation
|
326
|
+
|
327
|
+
After building the new map, constructing the corresponding search pattern is quite simple:
|
328
|
+
|
329
|
+
{{{
|
330
|
+
def generate_pattern(pattern)
|
331
|
+
replace_tokens(fix_dots(remove_ranges(pattern)))
|
332
|
+
end
|
333
|
+
|
334
|
+
def remove_ranges(pattern)
|
335
|
+
pattern.gsub(/\[([A-Za-z ]*)\]/) do |match|
|
336
|
+
'(?:' + match[1..-2].split(/\s+/).join('|') + ')'
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
def fix_dots(pattern)
|
341
|
+
pattern.gsub('.', '.' * (@item_size - 1) + ';')
|
342
|
+
end
|
343
|
+
|
344
|
+
def replace_tokens(pattern)
|
345
|
+
pattern.gsub(/[A-Za-z]+/) do |match|
|
346
|
+
'(?:' + mapped_value(match) + ')'
|
347
|
+
end.gsub(/\s/, '')
|
348
|
+
end
|
349
|
+
}}}
|
350
|
+
|
351
|
+
First, we have to account for this regex syntax:
|
352
|
+
|
353
|
+
{{{
|
354
|
+
[comment embdoc_beg int]
|
355
|
+
}}}
|
356
|
+
|
357
|
+
which we assume to mean "comment or eof or int", much like `[Acf]` means "A or c or f". Since constructs such as `A-Z` don't make sense with an arbitrary alphabet, we don't need to concern ourselves with that syntax. However, if we simply replace "comment" with its mapped string, and do the same with eof and int, we get something like this:
|
358
|
+
|
359
|
+
{{{
|
360
|
+
[f;j;u;]
|
361
|
+
}}}
|
362
|
+
|
363
|
+
which won't work: it'll match any semicolon! So we manually replace all instances of `[tok1 tok2 ... tokn]` with `tok1|tok2|...|tokn`. A simple gsub does the trick, since nested ranges don't really make much sense. This is implemented in #remove\_ranges:
|
364
|
+
|
365
|
+
{{{
|
366
|
+
def remove_ranges(pattern)
|
367
|
+
pattern.gsub(/\[([A-Za-z ]*)\]/) do |match|
|
368
|
+
'(?:' + match[1..-2].split(/\s+/).join('|') + ')'
|
369
|
+
end
|
370
|
+
end
|
371
|
+
}}}
|
372
|
+
|
373
|
+
Next, we replace the '.' matcher with a sequence of dots equal to the size of our token mapping, followed by a semicolon: this is how we properly match "any alphabet element" in our mapped form.
|
374
|
+
|
375
|
+
{{{
|
376
|
+
def fix_dots(pattern)
|
377
|
+
pattern.gsub('.', '.' * (@item_size - 1) + ';')
|
378
|
+
end
|
379
|
+
}}}
|
380
|
+
|
381
|
+
Then, we simply replace each alphabet element with its mapped value. Since those mapped values could be more than one character, we must group them for other Regex features such as `+` or `*` to work properly; since we may want to extract subexpressions, we must make the group we introduce here non-capturing. Then we just strip whitespace.
|
382
|
+
|
383
|
+
{{{
|
384
|
+
def replace_tokens(pattern)
|
385
|
+
pattern.gsub(/[A-Za-z]+/) do |match|
|
386
|
+
'(?:' + mapped_value(match) + ')'
|
387
|
+
end.gsub(/\s/, '')
|
388
|
+
end
|
389
|
+
}}}
|
390
|
+
|
391
|
+
## Generalized Matching
|
392
|
+
|
393
|
+
Lastly, we have a simple #match method:
|
394
|
+
|
395
|
+
{{{
|
396
|
+
def match(input)
|
397
|
+
new_input = input.map { |object| object.reg_desc }.map { |desc| mapped_value(desc) }.join
|
398
|
+
if (match = new_input.match(@pattern))
|
399
|
+
start, stop = match.begin(0) / @item_size, match.end(0) / @item_size
|
400
|
+
input[start...stop]
|
401
|
+
end
|
402
|
+
end
|
403
|
+
}}}
|
404
|
+
|
405
|
+
While there's many ways of extracting results from a Regex match, here we do the simplest: return the subsequence of the original sequence that matches first (using the usual leftmost, longest rule of course). Here comes the one part where you have to modify the objects that are in the sequence: in the first line, you'll see:
|
406
|
+
|
407
|
+
{{{
|
408
|
+
input.map { |object| object.reg_desc }.map { |desc| mapped_value(desc) }
|
409
|
+
}}}
|
410
|
+
|
411
|
+
This interrogates each object for its string representation: the string you typed into your search pattern if you wanted to find it. The method name (`reg_desc` in this case) is arbitrary, and this could also be implemented by providing a `Proc` to the ObjectRegex at initialization, and having the Proc be responsible for determining string representations.
|
412
|
+
|
413
|
+
We also see on the 3rd and 4th lines of the method why we stored @item\_size earlier: for boundary calculations:
|
414
|
+
|
415
|
+
{{{
|
416
|
+
start, stop = match.begin(0) / @item_size, match.end(0) / @item_size
|
417
|
+
input[start...stop]
|
418
|
+
}}}
|
419
|
+
|
420
|
+
Sometimes I wish `begin` and `end` could be local variable names in Ruby. Alas.
|
421
|
+
|
422
|
+
## Conclusion
|
423
|
+
|
424
|
+
Firstly, I won't suggest this idea is new, since DFAs with arbitrary alphabets have been around for, well, a while. Additionally, I've found a [Python library, RXPY](http://www.acooke.org/rxpy/), with a similar capability, though it's part of a larger Regex testbed library.
|
425
|
+
|
426
|
+
I've tested this both with tokens and integers (in word form) as the alphabets, with 1- and 2-character mappings. I think this technique could see use in other areas, so I'll be packaging it up as a small gem. I also think this implementation is fine for use in Ripper to achieve the tasks the existing, experimental code seeks to implement without dependence on the number of tokens in the language. A bit of optimization for the exceedingly common 1-character use-case could further support this goal.
|
427
|
+
|
428
|
+
## Copyright
|
429
|
+
|
430
|
+
Copyright (c) 2011 Michael Edgar. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "object_regex"
|
8
|
+
gem.summary = %Q{Perform regex searches on arbitrary sequences.}
|
9
|
+
gem.description = %Q{Provides regex-based searches on sequences of arbitrary objects. Developed for querying Ruby token streams, object_regex only requires that the
|
10
|
+
objects you are searching implement a single method that returns a string.}
|
11
|
+
gem.email = "michael.j.edgar@dartmouth.edu"
|
12
|
+
gem.homepage = "http://github.com/michaeledgar/object_regex"
|
13
|
+
gem.authors = ["Michael Edgar"]
|
14
|
+
gem.add_development_dependency "rspec", ">= 1.2.9"
|
15
|
+
gem.add_development_dependency "yard", ">= 0"
|
16
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
17
|
+
end
|
18
|
+
Jeweler::GemcutterTasks.new
|
19
|
+
rescue LoadError
|
20
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
21
|
+
end
|
22
|
+
|
23
|
+
require 'spec/rake/spectask'
|
24
|
+
Spec::Rake::SpecTask.new(:spec) do |spec|
|
25
|
+
spec.libs << 'lib' << 'spec'
|
26
|
+
spec.spec_files = FileList['spec/**/*_spec.rb']
|
27
|
+
end
|
28
|
+
|
29
|
+
Spec::Rake::SpecTask.new(:rcov) do |spec|
|
30
|
+
spec.libs << 'lib' << 'spec'
|
31
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
32
|
+
spec.rcov = true
|
33
|
+
end
|
34
|
+
|
35
|
+
task :spec => :check_dependencies
|
36
|
+
|
37
|
+
task :default => :spec
|
38
|
+
|
39
|
+
begin
|
40
|
+
require 'yard'
|
41
|
+
YARD::Rake::YardocTask.new
|
42
|
+
rescue LoadError
|
43
|
+
task :yardoc do
|
44
|
+
abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
|
45
|
+
end
|
46
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/lib/object_regex.rb
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# Provides general-purpose regex searching on any object implementing #reg_desc.
|
2
|
+
# See design_docs/object_regex for the mini-paper explaining it. With any luck,
|
3
|
+
# this will make it into Ripper so I won't have to do this here.
|
4
|
+
class ObjectRegex
|
5
|
+
def initialize(pattern)
|
6
|
+
@map = generate_map(pattern)
|
7
|
+
@pattern = generate_pattern(pattern)
|
8
|
+
end
|
9
|
+
|
10
|
+
def mapped_value(reg_desc)
|
11
|
+
@map[reg_desc] || @map[:FAILBOAT]
|
12
|
+
end
|
13
|
+
|
14
|
+
################## Mapping Generation #########################
|
15
|
+
|
16
|
+
TOKEN_MATCHER = /[A-Za-z][\w]*/
|
17
|
+
MAPPING_CHARS = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
|
18
|
+
def generate_map(pattern)
|
19
|
+
alphabet = pattern.scan(TOKEN_MATCHER).uniq
|
20
|
+
repr_size = Math.log(alphabet.size + 1, MAPPING_CHARS.size).ceil
|
21
|
+
@item_size = repr_size + 1
|
22
|
+
|
23
|
+
map = Hash[alphabet.map.with_index do |symbol, idx|
|
24
|
+
[symbol, mapping_for_idx(repr_size, idx)]
|
25
|
+
end]
|
26
|
+
map.merge!(FAILBOAT: mapping_for_idx(repr_size, map.size))
|
27
|
+
end
|
28
|
+
|
29
|
+
def mapping_for_idx(repr_size, idx)
|
30
|
+
convert_to_mapping_radix(repr_size, idx).map do |char|
|
31
|
+
MAPPING_CHARS[char]
|
32
|
+
end.join + ';'
|
33
|
+
end
|
34
|
+
|
35
|
+
def convert_to_mapping_radix(repr_size, num)
|
36
|
+
result = []
|
37
|
+
repr_size.times do
|
38
|
+
result.unshift(num % MAPPING_CHARS.size)
|
39
|
+
num /= MAPPING_CHARS.size
|
40
|
+
end
|
41
|
+
result
|
42
|
+
end
|
43
|
+
|
44
|
+
################## Pattern transformation #################
|
45
|
+
|
46
|
+
def generate_pattern(pattern)
|
47
|
+
replace_tokens(fix_dots(remove_ranges(pattern)))
|
48
|
+
end
|
49
|
+
|
50
|
+
def remove_ranges(pattern)
|
51
|
+
pattern.gsub(/\[([\w\t ]*)\]/) do |match|
|
52
|
+
'(?:' + match[1..-2].split(/\s+/).join('|') + ')'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def fix_dots(pattern)
|
57
|
+
pattern.gsub('.', '.' * (@item_size - 1) + ';')
|
58
|
+
end
|
59
|
+
|
60
|
+
def replace_tokens(pattern)
|
61
|
+
pattern.gsub(TOKEN_MATCHER) do |match|
|
62
|
+
'(?:' + mapped_value(match) + ')'
|
63
|
+
end.gsub(/\s/, '')
|
64
|
+
end
|
65
|
+
|
66
|
+
############# Matching ##########################
|
67
|
+
|
68
|
+
def match(input, pos=0)
|
69
|
+
new_input = mapped_input(input)
|
70
|
+
if (match = new_input.match(@pattern, pos))
|
71
|
+
start, stop = match.begin(0) / @item_size, match.end(0) / @item_size
|
72
|
+
input[start...stop]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def all_matches(input)
|
77
|
+
new_input = mapped_input(input)
|
78
|
+
result, pos = [], 0
|
79
|
+
while (match = new_input.match(@pattern, pos))
|
80
|
+
start, stop = match.begin(0) / @item_size, match.end(0) / @item_size
|
81
|
+
result << input[start...stop]
|
82
|
+
pos = match.end(0)
|
83
|
+
end
|
84
|
+
result
|
85
|
+
end
|
86
|
+
|
87
|
+
def mapped_input(input)
|
88
|
+
input.map { |object| object.reg_desc }.map { |desc| mapped_value(desc) }.join
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
if RUBY_VERSION < "1.9"
|
4
|
+
describe 'ObjectRegex' do
|
5
|
+
it 'will raise upon loading under Ruby 1.8' do
|
6
|
+
expect { require 'object_regex' }.to raise_error(RuntimeError)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
else
|
10
|
+
require 'object_regex'
|
11
|
+
class Token < Struct.new(:type, :contents)
|
12
|
+
def reg_desc
|
13
|
+
type.to_s
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class Integer
|
18
|
+
def reg_desc
|
19
|
+
'a' * self
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe ObjectRegex do
|
24
|
+
context 'with a small input alphabet' do
|
25
|
+
before do
|
26
|
+
@input = [Token.new(:str, '"hello"'),
|
27
|
+
Token.new(:str, '"there"'),
|
28
|
+
Token.new(:int, '2'),
|
29
|
+
Token.new(:str, '"worldagain"'),
|
30
|
+
Token.new(:str, '"highfive"'),
|
31
|
+
Token.new(:int, '5'),
|
32
|
+
Token.new(:str, 'jklkjl'),
|
33
|
+
Token.new(:int, '3'),
|
34
|
+
Token.new(:comment, '#lol'),
|
35
|
+
Token.new(:str, ''),
|
36
|
+
Token.new(:comment, '#no pairs'),
|
37
|
+
Token.new(:str, 'jkl'),
|
38
|
+
Token.new(:eof, '')]
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'matches a simple token stream with a simple search pattern' do
|
42
|
+
matches = ObjectRegex.new('(str int)+').all_matches(@input)
|
43
|
+
matches.should == [@input[1..2], @input[4..7]]
|
44
|
+
end
|
45
|
+
|
46
|
+
it "matches the 'anything' dot" do
|
47
|
+
ObjectRegex.new('int .').all_matches(@input).should ==
|
48
|
+
[@input[2..3], @input[5..6], @input[7..8]]
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'works with ranges ([xyz] syntax)' do
|
52
|
+
ObjectRegex.new('str [int comment]').all_matches(@input).should ==
|
53
|
+
[@input[1..2], @input[4..5], @input[6..7], @input[9..10]]
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'works with count syntax (eg {1,2})' do
|
57
|
+
ObjectRegex.new('str{2,3}').all_matches(@input).should ==
|
58
|
+
[@input[0..1], @input[3..4]]
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'works with ?, + and *' do
|
62
|
+
ObjectRegex.new('int str? (str int)+ [comment str]*').all_matches(@input).should ==
|
63
|
+
[@input[2..11]]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
context 'with a large input alphabet' do
|
68
|
+
before do
|
69
|
+
search = ''
|
70
|
+
50.upto(150) do |x|
|
71
|
+
search << x.reg_desc
|
72
|
+
if x % 2 == 1
|
73
|
+
search << '?'
|
74
|
+
end
|
75
|
+
search << ' '
|
76
|
+
end
|
77
|
+
@regex = ObjectRegex.new(search)
|
78
|
+
@input = (1..500).to_a
|
79
|
+
# remove all odd numbers divisible by 7 or 5
|
80
|
+
@input.reject! { |x| x % 2 == 1 && (x % 7 == 0 || x % 3 == 0) }
|
81
|
+
end
|
82
|
+
|
83
|
+
it 'handles searching with the large alphabet' do
|
84
|
+
expected = (50..150).to_a.reject { |x| x % 2 == 1 && (x % 7 == 0 || x % 3 == 0) }
|
85
|
+
@regex.match(@input).should == expected
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/spec/spec.opts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: object_regex
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 1
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
version: 1.0.0
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Michael Edgar
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2011-01-25 00:00:00 -05:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: rspec
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
none: false
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
segments:
|
29
|
+
- 1
|
30
|
+
- 2
|
31
|
+
- 9
|
32
|
+
version: 1.2.9
|
33
|
+
type: :development
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: yard
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
segments:
|
44
|
+
- 0
|
45
|
+
version: "0"
|
46
|
+
type: :development
|
47
|
+
version_requirements: *id002
|
48
|
+
description: |-
|
49
|
+
Provides regex-based searches on sequences of arbitrary objects. Developed for querying Ruby token streams, object_regex only requires that the
|
50
|
+
objects you are searching implement a single method that returns a string.
|
51
|
+
email: michael.j.edgar@dartmouth.edu
|
52
|
+
executables: []
|
53
|
+
|
54
|
+
extensions: []
|
55
|
+
|
56
|
+
extra_rdoc_files:
|
57
|
+
- LICENSE
|
58
|
+
- README.md
|
59
|
+
files:
|
60
|
+
- .document
|
61
|
+
- .gitignore
|
62
|
+
- LICENSE
|
63
|
+
- README.md
|
64
|
+
- Rakefile
|
65
|
+
- VERSION
|
66
|
+
- lib/object_regex.rb
|
67
|
+
- lib/object_regex/implementation.rb
|
68
|
+
- spec/object_regex_spec.rb
|
69
|
+
- spec/spec.opts
|
70
|
+
- spec/spec_helper.rb
|
71
|
+
has_rdoc: true
|
72
|
+
homepage: http://github.com/michaeledgar/object_regex
|
73
|
+
licenses: []
|
74
|
+
|
75
|
+
post_install_message:
|
76
|
+
rdoc_options:
|
77
|
+
- --charset=UTF-8
|
78
|
+
require_paths:
|
79
|
+
- lib
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
segments:
|
86
|
+
- 0
|
87
|
+
version: "0"
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
segments:
|
94
|
+
- 0
|
95
|
+
version: "0"
|
96
|
+
requirements: []
|
97
|
+
|
98
|
+
rubyforge_project:
|
99
|
+
rubygems_version: 1.3.7
|
100
|
+
signing_key:
|
101
|
+
specification_version: 3
|
102
|
+
summary: Perform regex searches on arbitrary sequences.
|
103
|
+
test_files:
|
104
|
+
- spec/object_regex_spec.rb
|
105
|
+
- spec/spec_helper.rb
|