object_regex 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
@@ -0,0 +1,21 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+
21
+ ## PROJECT::SPECIFIC
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.
@@ -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.
@@ -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
@@ -0,0 +1,4 @@
1
+ if RUBY_VERSION < "1.9"
2
+ raise 'object_regex is only compatible with Ruby 1.9 or greater.'
3
+ end
4
+ require 'object_regex/implementation'
@@ -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
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,8 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ require 'spec'
4
+ require 'spec/autorun'
5
+
6
+ Spec::Runner.configure do |config|
7
+
8
+ end
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