virginity 0.3.31

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/lib/virginity.rb +6 -0
  3. data/lib/virginity/api_extensions.rb +87 -0
  4. data/lib/virginity/api_extensions/fields_to_json.rb +82 -0
  5. data/lib/virginity/api_extensions/fields_to_xml.rb +151 -0
  6. data/lib/virginity/bnf.rb +84 -0
  7. data/lib/virginity/dir_info.rb +93 -0
  8. data/lib/virginity/dir_info/content_line.rb +146 -0
  9. data/lib/virginity/dir_info/line_folding.rb +60 -0
  10. data/lib/virginity/dir_info/param.rb +208 -0
  11. data/lib/virginity/dir_info/query.rb +144 -0
  12. data/lib/virginity/encoding_decoding.rb +177 -0
  13. data/lib/virginity/encodings.rb +36 -0
  14. data/lib/virginity/fixes.rb +230 -0
  15. data/lib/virginity/vcard.rb +244 -0
  16. data/lib/virginity/vcard/base_field.rb +126 -0
  17. data/lib/virginity/vcard/categories.rb +57 -0
  18. data/lib/virginity/vcard/cleaning.rb +364 -0
  19. data/lib/virginity/vcard/field.rb +22 -0
  20. data/lib/virginity/vcard/field/params.rb +93 -0
  21. data/lib/virginity/vcard/field_values.rb +10 -0
  22. data/lib/virginity/vcard/field_values/binary.rb +22 -0
  23. data/lib/virginity/vcard/field_values/boolean.rb +14 -0
  24. data/lib/virginity/vcard/field_values/case_insensitive_value.rb +13 -0
  25. data/lib/virginity/vcard/field_values/date.rb +16 -0
  26. data/lib/virginity/vcard/field_values/integer.rb +15 -0
  27. data/lib/virginity/vcard/field_values/optional_structured_text.rb +35 -0
  28. data/lib/virginity/vcard/field_values/separated_text.rb +59 -0
  29. data/lib/virginity/vcard/field_values/structured_text.rb +71 -0
  30. data/lib/virginity/vcard/field_values/text.rb +23 -0
  31. data/lib/virginity/vcard/field_values/uri.rb +15 -0
  32. data/lib/virginity/vcard/fields.rb +284 -0
  33. data/lib/virginity/vcard/fields_osx.rb +95 -0
  34. data/lib/virginity/vcard/fields_soocial.rb +45 -0
  35. data/lib/virginity/vcard/name_handler.rb +151 -0
  36. data/lib/virginity/vcard/patching.rb +262 -0
  37. data/lib/virginity/vcard21.rb +2 -0
  38. data/lib/virginity/vcard21/base.rb +30 -0
  39. data/lib/virginity/vcard21/parser.rb +359 -0
  40. data/lib/virginity/vcard21/reader.rb +103 -0
  41. data/lib/virginity/vcard21/writer.rb +139 -0
  42. metadata +111 -0
@@ -0,0 +1,146 @@
1
+ require 'virginity/encoding_decoding'
2
+ require 'virginity/dir_info/param'
3
+
4
+ module Virginity
5
+
6
+ # raised when merging will fail
7
+ class MergeError < Error; end
8
+
9
+ # rfc 2425 describes a content line as this:
10
+ # contentline = [group "."] name *(";" param) ":" value CRLF
11
+ # See also: DirectoryInformation
12
+ #
13
+ # A ContentLine is a tuple of one group (optional), a name, zero or more parameters and a value.
14
+
15
+ # Type names and parameter names are case insensitive (e.g., the type
16
+ # name "fn" is the same as "FN" and "Fn"). Parameter values MAY be case
17
+ # sensitive or case insensitive, depending on their definition.
18
+ class ContentLine
19
+ attr_accessor :group, :name, :value
20
+ attr_writer :params
21
+ alias_method :raw_value, :value
22
+
23
+ extend Encodings
24
+ include Encodings
25
+
26
+ # create a ContentLine by specifying all its parts
27
+ def initialize(name = "X-FOO", value = nil, params = [], group = nil, options = {})
28
+ @group = group
29
+ @name = name.to_s
30
+ @params = options[:no_deep_copy] ? (params) : Param.deep_copy(params)
31
+ @value = value.to_s
32
+ end
33
+
34
+ # decode a line
35
+ def self.parse(line = "X-FOO:bar")
36
+ group, name, params, value = line_parts(line.to_s)
37
+ # optimization: since params is deep_copied, constructing many objects and we know for certain that params can safely be used without copying, we put it in later.
38
+ new(name, value, params, group, :no_deep_copy => true)
39
+ end
40
+
41
+ class << self
42
+ alias_method :from_line, :parse
43
+ end
44
+
45
+ # the combination of two content lines
46
+ # This method will raise a MergeError if names, groups or values are conflicting
47
+ def self.merger(left, right)
48
+ raise MergeError, "group, #{left.group} != #{right.group}" unless left.group == right.group or left.group.nil? or left.group.nil?
49
+ raise MergeError, "name, #{left.name} != #{right.name}" unless left.has_name?(right.name)
50
+ raise MergeError, "value, #{left.raw_value} != #{right.raw_value}" unless left.raw_value == right.raw_value
51
+ ContentLine.new(left.name, left.raw_value, (left.params + right.params).uniq, left.group || right.group)
52
+ end
53
+
54
+ # combine with new values from another line.
55
+ # This method will raise a MergeError if names, groups or values are conflicting
56
+ def merge_with!(other)
57
+ raise MergeError, "group, #{group} != #{other.group}" unless group == other.group or group.nil? or other.group.nil?
58
+ raise MergeError, "name, #{name} != #{other.name}" unless has_name?(other.name)
59
+ raise MergeError, "value, #{raw_value} != #{other.raw_value}" unless raw_value == other.raw_value
60
+ self.group = group || other.group
61
+ self.params = (params + other.params).uniq
62
+ self
63
+ end
64
+
65
+ GROUP_DELIMITER = "."
66
+ COLON_CHAR = ":"
67
+ def encode #(options = {})
68
+ line = ""
69
+ line << group << GROUP_DELIMITER unless group.nil?
70
+ line << name << params_to_s << COLON_CHAR << raw_value
71
+ end
72
+ alias_method :to_s, :encode
73
+
74
+ def pretty_print(q)
75
+ q.text({:line => { group: @group, name: @name, params: params_to_s, value: @value }}.to_yaml)
76
+ end
77
+
78
+ def has_name?(name)
79
+ @name.casecmp(name) == 0
80
+ end
81
+
82
+ # if key is given, return only matching parameters
83
+ def params(key = nil)
84
+ if key.nil?
85
+ @params
86
+ else
87
+ @params.select { |param| param.has_key?(key) } # case insensitive by design!
88
+ end
89
+ end
90
+
91
+ # convenience method to grab only the values of the parameters (without the keys)
92
+ def param_values(key = nil)
93
+ params(key).map { |param| param.value }
94
+ end
95
+
96
+ def ==(other)
97
+ group == other.group &&
98
+ has_name?(other.name) &&
99
+ params == other.params &&
100
+ raw_value == other.raw_value
101
+ end
102
+
103
+ def <=>(other)
104
+ str_diff(group, other.group) || str_diff(name, other.name) || (to_s <=> other.to_s)
105
+ end
106
+
107
+ def hash
108
+ [group, name, params, raw_value].hash
109
+ end
110
+
111
+ def eql?(other)
112
+ group == other.group &&
113
+ has_name?(other.name) &&
114
+ params == other.params &&
115
+ raw_value == other.raw_value
116
+ end
117
+
118
+ GROUP = /#{Bnf::NAME}\./
119
+ NAME = /#{Bnf::NAME}/
120
+ # decode a contentline, returns the four parts [group, name, params, value]
121
+ def self.line_parts(line)
122
+ scanner = StringScanner.new(line)
123
+ if group = scanner.scan(GROUP)
124
+ group.chomp!(".")
125
+ group
126
+ end
127
+ name = scanner.scan(NAME)
128
+ name.upcase! # FIXME: names should be case insensitive when compared... only when compared
129
+ [group, name, Param::scan_params(scanner), scanner.rest]
130
+ rescue InvalidEncoding
131
+ raise
132
+ rescue => e
133
+ raise InvalidEncoding, "#{scanner.string.inspect}, at pos #{scanner.pos} (original error: #{e})"
134
+ end
135
+
136
+ def params_to_s
137
+ Param::params_to_s(params)
138
+ end
139
+
140
+ private
141
+ def str_diff(left, right)
142
+ diff = (left <=> right)
143
+ diff == 0 ? nil : diff
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,60 @@
1
+ require 'strscan'
2
+ $KCODE = 'U' unless defined? Encoding::UTF_8
3
+
4
+ module Virginity
5
+
6
+ module LineFolding
7
+ LINE_ENDING = /\r\n|\n|\r/ # the order is important!
8
+ FOLD = /(#{LINE_ENDING})[\t\ ]/ # we accept unix-newlines and mac-newlines too (spec says only windows newlines, \r\n, are okay)
9
+
10
+ # 5.8.1. Line delimiting and folding.
11
+ # A logical line MAY be continued on the next physical line anywhere between two characters by inserting a CRLF immediately followed by a single white space character (space, ASCII decimal 32, or horizontal tab, ASCII decimal 9). At least one character must be present on the folded line. Any sequence of CRLF followed immediately by a single white space character is ignored (removed) when processing the content type.
12
+ def self.unfold(card)
13
+ card.gsub(FOLD, '')
14
+ end
15
+
16
+ def self.unfold_and_split(string)
17
+ unfold(string).split(LINE_ENDING)
18
+ end
19
+
20
+ # TODO: option to encode with "\r\n" instead of "\n"?
21
+ # not multibyte-safe but very safe for ascii
22
+ def self.fold_ascii(card, width = 75)
23
+ return card unless width > 0
24
+ # binary should already be encoded to a width that is smaller than width
25
+ card.gsub(/.{#{width}}/, "\\0\n ") # "\\0" is the matched string
26
+ end
27
+
28
+ # utf-8 safe folding:
29
+ # TODO: I think this is a good candidate to be ported to C
30
+ def self.fold(card, width = 75)
31
+ return card unless width > 0
32
+ # binary fields should already be encoded to a width that is smaller than width
33
+ scanner = StringScanner.new(card)
34
+ folded = ""
35
+ line_pos = 0
36
+ while !scanner.eos?
37
+ char = scanner.getch
38
+ charsize = char.size
39
+ if line_pos + charsize > width
40
+ folded << "\n "
41
+ line_pos = 0
42
+ end
43
+ folded << char
44
+ char == "\n" ? line_pos = 0 : line_pos += charsize
45
+ end
46
+ folded
47
+ end
48
+
49
+ # Content lines SHOULD be folded to a maximum width of 75 octets, excluding the
50
+ # line break. Multi-octet characters MUST remain contiguous.
51
+ # So, we're doing it wrong, we should count octets... bytes.
52
+
53
+ # This is way faster than the method above and unicode-safe
54
+ # it is slightly different: it does not count bytes, it counts characters
55
+ def self.fold(card)
56
+ card.gsub(/.{75}(?=.)/, "\\0\n ") # "\\0" is the matched string
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,208 @@
1
+ module Virginity
2
+
3
+ # A directory information parameter is basically a key-value pair.
4
+ # An instance of this class represents such a pair. It can deal with comparison and encoding. The class contains some methods to deal with lists of parameters
5
+ # Param keys are case insensitive
6
+ class Param
7
+ attr_reader :key
8
+ attr_accessor :value
9
+
10
+ def initialize(key, value)
11
+ self.key = key
12
+ @value = value.to_s
13
+ end
14
+
15
+ # convenience method, the same as calling Param.new("TYPE", value)
16
+ def self.type(value)
17
+ new('TYPE', value)
18
+ end
19
+
20
+ def self.pref
21
+ new('TYPE', 'PREF')
22
+ end
23
+
24
+ # convenience method, the same as calling Param.new("CHARSET", value)
25
+ def self.charset(value)
26
+ new('CHARSET', value)
27
+ end
28
+
29
+ # convenience method, the same as calling Param.new("ENCODING", value)
30
+ def self.encoding(value)
31
+ new('ENCODING', value)
32
+ end
33
+
34
+ # convenience method, the same as calling Param.new("ETAG", value)
35
+ def self.etag(value)
36
+ new('ETAG', value)
37
+ end
38
+
39
+ def to_s
40
+ "#{@key}=#{escaped_value}"
41
+ end
42
+ alias_method :inspect, :to_s
43
+
44
+ # param-name = x-name / iana-token
45
+ # iana-token = 1*(ALPHA / DIGIT / "-")
46
+ # ; identifier registered with IANA
47
+ PARAM_NAME_CHECK = /^((X|x)\-)?(\w|\-)+$/
48
+ def key=(key)
49
+ raise "Invalid param-key: #{key.inspect}" unless key =~ PARAM_NAME_CHECK
50
+ @key = key.upcase
51
+ end
52
+
53
+ def hash
54
+ [@key, @value].hash
55
+ end
56
+
57
+ def eql?(other)
58
+ other.is_a?(Param) && has_key?(other.key)&& @value == other.value
59
+ end
60
+
61
+ def has_key?(other_key)
62
+ @key.casecmp(other_key) == 0
63
+ end
64
+
65
+ # NB. Other doesn't necessarily have to be a Param
66
+ def ==(other)
67
+ has_key?(other.key) && @value == other.value
68
+ rescue
69
+ false
70
+ end
71
+
72
+ def <=>(other)
73
+ if has_key?(other.key)
74
+ @value <=> other.value
75
+ else
76
+ @key <=> other.key
77
+ end
78
+ end
79
+
80
+ # A semi-colon in a property parameter value must be escaped with a Blackslash character.
81
+ # commas too I think, since these are used to separate the param-values
82
+ # param-value = ptext / quoted-string
83
+ # ptext = *SAFE-CHAR
84
+ # SAFE-CHAR = WSP / %x21 / %x23-2B / %x2D-39 / %x3C-7E / NON-ASCII
85
+ # ; Any character except CTLs, DQUOTE, ";", ":", ","
86
+ # quoted-string = DQUOTE *QSAFE-CHAR DQUOTE
87
+ # QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-ASCII
88
+ # ; Any character except CTLs, DQUOTE
89
+ ESCAPE_CHARS = /\\|\;|\,|\"/
90
+ LF = "\n"
91
+ ESCAPED_LF = "\\n"
92
+ ESCAPE_CHARS_WITH_LF = /\\|\;|\,|\"|\n/
93
+ ESCAPE_HASH = {
94
+ '\\' => '\\\\',
95
+ ';' => '\;',
96
+ ',' => '\,',
97
+ '"' => '\"',
98
+ "\n" => '\n'
99
+ }
100
+ def escaped_value
101
+ if @value =~ ESCAPE_CHARS_WITH_LF
102
+ # put quotes around the escaped string
103
+ "\"#{@value.gsub(ESCAPE_CHARS) { |char| "\\#{char}" }.gsub(LF, ESCAPED_LF)}\""
104
+ # "\"#{@value.gsub(ESCAPE_CHARS_WITH_LF, ESCAPE_HASH)}\"" # ruby1.9
105
+ else
106
+ @value
107
+ end
108
+ end
109
+
110
+ def self.decode_value(val)
111
+ if Bnf::QUOTED_STRING =~ val
112
+ EncodingDecoding::decode_text($2)
113
+ else
114
+ val
115
+ end
116
+ end
117
+
118
+ # encodes an array of params
119
+ def self.simple_params_to_s(params)
120
+ return "" if params.empty?
121
+ ";" + params.uniq.sort.join(";")
122
+ end
123
+
124
+ # encodes an array of params
125
+ # param = param-name "=" param-value *("," param-value)
126
+ def self.params_to_s(params)
127
+ return "" if params.empty?
128
+ s = []
129
+ params.map {|p| p.key }.uniq.sort!.each do |key|
130
+ values = params.select {|p| p.has_key? key }.map {|p| p.escaped_value }.uniq.sort!
131
+ s << "#{key}=#{values.join(",")}"
132
+ end
133
+ ";#{s.join(";")}"
134
+ end
135
+
136
+ # encodes an array of params
137
+ def self.params_to_s(params)
138
+ return "" if params.empty?
139
+ s = ""
140
+ params.map { |p| p.key }.uniq.sort!.each do |key|
141
+ values = params.select {|p| p.has_key? key }.map {|p| p.escaped_value }.uniq.sort!
142
+ s << ";#{key}=#{values.join(",")}"
143
+ end
144
+ s
145
+ end
146
+
147
+ COLON = /:/
148
+ SEMICOLON = /;/
149
+ COMMA = /,/
150
+ EQUALS = /=/
151
+ KEY = /#{Bnf::NAME}/
152
+ PARAM_NAME = /((X|x)\-)?(\w|\-)+/
153
+ PARAM_VALUE = /#{Bnf::PVALUE}/
154
+
155
+ BASE64_OR_B = /^(BASE64)|(B)$/i
156
+ XSYNTHESIS_REF = /^X-Synthesis-Ref\d*$/i
157
+
158
+ # scans all params at the given position from a StringScanner including the pending colon
159
+ def self.scan_params(scanner)
160
+ unless scanner.skip(SEMICOLON)
161
+ scanner.skip(COLON)
162
+ return []
163
+ end
164
+ params = []
165
+ until scanner.skip(COLON) # a colon indicates the end of the paramlist
166
+ key = scanner.scan(PARAM_NAME)
167
+ unless scanner.skip(EQUALS)
168
+ # it's not a proper DirInfo param but nevertheless some companies (Apple) put vCard2.1 shorthand params without a key in they vCard3.0. Therefore we include support for these special cases.
169
+ case key
170
+ when Vcard21::QUOTED_PRINTABLE
171
+ params << Param.new('ENCODING', key)
172
+ when BASE64_OR_B
173
+ params << Param.new('ENCODING', 'B')
174
+ when XSYNTHESIS_REF
175
+ # we ignore this crap.
176
+ when *Vcard21::KNOWNTYPES
177
+ params << Param.new('TYPE', key)
178
+ else
179
+ raise InvalidEncoding, "encountered paramkey #{key.inspect} without a paramvalue"
180
+ end
181
+ end
182
+ key.upcase!
183
+ begin
184
+ if value = scanner.scan(PARAM_VALUE)
185
+ params << Param.new(key, Param::decode_value(value))
186
+ end
187
+ break if scanner.skip(SEMICOLON) # after a semicolon, we expect key=(value)+
188
+ end until scanner.skip(COMMA).nil? # a comma indicates another values (TYPE=HOME,WORK)
189
+ end
190
+ params
191
+ rescue InvalidEncoding
192
+ raise
193
+ rescue => e
194
+ raise InvalidEncoding, "#{scanner.string.inspect} at character #{scanner.pos}\noriginal error: #{e}"
195
+ end
196
+
197
+ def self.params_from_string(string)
198
+ scan_params(StringScanner.new(string))
199
+ end
200
+
201
+ def self.deep_copy(paramlist)
202
+ return [] if paramlist.nil? or paramlist.empty?
203
+ # params_from_string(simple_params_to_s(paramlist)) # slower
204
+ Marshal::load(Marshal::dump(paramlist)) # faster
205
+ end
206
+
207
+ end # class Param
208
+ end
@@ -0,0 +1,144 @@
1
+ module Virginity
2
+
3
+ # Helper methods to perform queries on lines in a DirectoryInformation instance
4
+ #
5
+ # query format: <tt>[group.][name][:value]</tt>
6
+ #
7
+ # examples:
8
+ # * "FN" searches for FN-fields
9
+ # * "FN:John Smith" searches for an FN-field with the value "John Smith"
10
+ # * ":John Smith" searches for any field with the value "John Smith"
11
+ # * "item1.:John Smith" searches for any field with the value "John Smith" and group item1
12
+ module Query
13
+
14
+ # if a query cannot be parsed an InvalidQuery is raised
15
+ # the query-decoder expects correct input, it will not attempt to find errors or even correct them for you
16
+ class InvalidQuery < Error; end
17
+
18
+ GROUP = /#{Bnf::NAME}\./
19
+ NAME = /#{Bnf::NAME}/
20
+ SEMICOLON = /\;/
21
+ COLON = /\:/
22
+ def self.decode_query(query)
23
+ scanner = StringScanner.new(query)
24
+ # does the query start with a name that ends in a dot?
25
+ if group = scanner.scan(GROUP)
26
+ group.chomp!(".")
27
+ end
28
+ name = scanner.scan(NAME) # could be nil
29
+ params = params(scanner)
30
+ value = nil
31
+ if scanner.skip(COLON)
32
+ value = scanner.rest
33
+ end
34
+ [group, name, params, value]
35
+ end
36
+
37
+ COMMA = /,/
38
+ EQUALS = /=/
39
+ KEY = /#{Bnf::NAME}/
40
+ PARAM_NAME = /((X|x)\-)?(\w|\-)+/
41
+ PARAM_VALUE = /#{Bnf::PVALUE}/
42
+ def self.params(scanner)
43
+ return nil unless scanner.skip(SEMICOLON)
44
+ params = []
45
+ until scanner.check(COLON) or scanner.eos? # <--- check of end of string! and *check* for colon
46
+ key = scanner.scan(PARAM_NAME)
47
+ raise InvalidQuery unless scanner.skip(EQUALS)
48
+ begin
49
+ if value = scanner.scan(PARAM_VALUE)
50
+ params << Param.new(key, Param::decode_value(value))
51
+ end
52
+ break if scanner.skip(SEMICOLON) # after a semicolon, we expect key=(value)+
53
+ end until scanner.skip(COMMA).nil? # a comma indicates another value (TYPE=HOME,WORK)
54
+ end
55
+ params
56
+ end
57
+
58
+ def query(query = "")
59
+ group, name, params, value = Query::decode_query(query)
60
+ lines.select do |line|
61
+ # to_s is used here to match a nil-group to the empty-group-query: "."
62
+ (group.nil? || group == line.group.to_s) &&
63
+ (params.nil? || params.all? { |p| line.params.include? p }) &&
64
+ (name.nil? || line.has_name?(name)) &&
65
+ (value.nil? || value == line.raw_value)
66
+ end
67
+ end
68
+
69
+ # return the first match for query q. By definition this is equivalent to query(q).first but it is faster.
70
+ def first_match(query = "")
71
+ group, name, params, value = Query::decode_query(query)
72
+ lines.detect do |line|
73
+ # to_s is used here to match a nil-group to the empty-group-query: "."
74
+ (group.nil? || group == line.group.to_s) &&
75
+ (params.nil? || params.all? { |p| line.params.include?(p) }) &&
76
+ (name.nil? || line.has_name?(name)) &&
77
+ (value.nil? || value == line.raw_value)
78
+ end
79
+ end
80
+
81
+ def lines_with_name(name = "")
82
+ lines.select do |line|
83
+ line.has_name?(name)
84
+ end
85
+ end
86
+
87
+ def line_matches_query?(line, q, v)
88
+ raise ArgumentError, "query cannot be nil { #{q.inspect} => #{v.inspect} }?" if v.nil?
89
+ case q
90
+ when :name
91
+ line.has_name?(v)
92
+ when :raw_value
93
+ v == line.raw_value
94
+ when :text
95
+ return false unless line.respond_to? :text
96
+ v == line.text
97
+ when :sha1
98
+ return false unless line.respond_to? :sha1
99
+ v == line.sha1
100
+ when :group
101
+ v == line.group
102
+ when :values
103
+ raise ArgumentError, "expected an array of values { #{q.inspect} => #{v.inspect} }?" unless v.is_a? Array
104
+ if line.respond_to? :values
105
+ if line.respond_to? :components # stuctured text, values are ordered and the lenght of the array is guaranteed to be correct.
106
+ line.values == v
107
+ else
108
+ (line.values - v).empty?
109
+ end
110
+ else
111
+ false
112
+ end
113
+ when :has_param
114
+ # true if one param matches the array we feed it
115
+ line.params.include? Param.new(v.first, v.last)
116
+ when :has_param_with_key
117
+ # true if one param matches the array we feed it
118
+ line.params.any? { |p| p.has_key?(v) }
119
+ when :has_type
120
+ # true if one param matches the array we feed it
121
+ line.params.include? Param.new('TYPE', v)
122
+ when :params
123
+ # all params match v, and no param is not matching
124
+ raise NotImplementedError
125
+ else
126
+ raise ArgumentError, "what do you expect me to do with { #{q.inspect} => #{v.inspect} }?"
127
+ end
128
+ end
129
+
130
+ def where(query = {})
131
+ lines.select do |line|
132
+ query.all? { |q,v| line_matches_query?(line, q, v) }
133
+ end
134
+ end
135
+
136
+ def find_first(query = {})
137
+ lines.detect do |line|
138
+ query.all? { |q,v| line_matches_query?(line, q, v) }
139
+ end
140
+ end
141
+
142
+ alias_method(:/, :query) #/# kate's syntax highlighter is (slightly) broken so I put a slash here
143
+ end
144
+ end