actionpack 1.7.0 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of actionpack might be problematic. Click here for more details.

Files changed (65) hide show
  1. data/CHANGELOG +109 -0
  2. data/README +2 -2
  3. data/RUNNING_UNIT_TESTS +1 -1
  4. data/install.rb +8 -77
  5. data/lib/action_controller/assertions.rb +203 -0
  6. data/lib/action_controller/base.rb +15 -7
  7. data/lib/action_controller/benchmarking.rb +10 -4
  8. data/lib/action_controller/caching.rb +28 -17
  9. data/lib/action_controller/cgi_ext/raw_post_data_fix.rb +5 -9
  10. data/lib/action_controller/cgi_process.rb +5 -1
  11. data/lib/action_controller/cookies.rb +3 -2
  12. data/lib/action_controller/deprecated_assertions.rb +204 -0
  13. data/lib/action_controller/flash.rb +30 -36
  14. data/lib/action_controller/pagination.rb +4 -4
  15. data/lib/action_controller/request.rb +18 -2
  16. data/lib/action_controller/routing.rb +6 -2
  17. data/lib/action_controller/scaffolding.rb +1 -1
  18. data/lib/action_controller/templates/rescues/diagnostics.rhtml +1 -1
  19. data/lib/action_controller/templates/rescues/routing_error.rhtml +4 -2
  20. data/lib/action_controller/templates/rescues/template_error.rhtml +5 -4
  21. data/lib/action_controller/templates/scaffolds/list.rhtml +3 -0
  22. data/lib/action_controller/test_process.rb +60 -17
  23. data/lib/action_controller/url_rewriter.rb +3 -3
  24. data/lib/action_controller/vendor/html-scanner/html/document.rb +63 -0
  25. data/lib/action_controller/vendor/html-scanner/html/node.rb +431 -0
  26. data/lib/action_controller/vendor/html-scanner/html/tokenizer.rb +95 -0
  27. data/lib/action_controller/vendor/html-scanner/html/version.rb +11 -0
  28. data/lib/action_controller/verification.rb +13 -4
  29. data/lib/action_view/base.rb +3 -2
  30. data/lib/action_view/helpers/active_record_helper.rb +1 -1
  31. data/lib/action_view/helpers/asset_tag_helper.rb +31 -9
  32. data/lib/action_view/helpers/date_helper.rb +25 -22
  33. data/lib/action_view/helpers/form_helper.rb +6 -5
  34. data/lib/action_view/helpers/form_options_helper.rb +4 -4
  35. data/lib/action_view/helpers/javascript_helper.rb +28 -6
  36. data/lib/action_view/helpers/javascripts/prototype.js +340 -30
  37. data/lib/action_view/helpers/number_helper.rb +110 -0
  38. data/lib/action_view/helpers/pagination_helper.rb +1 -1
  39. data/lib/action_view/helpers/text_helper.rb +8 -21
  40. data/lib/action_view/helpers/url_helper.rb +21 -4
  41. data/lib/action_view/partials.rb +39 -9
  42. data/rakefile +29 -5
  43. data/test/abstract_unit.rb +1 -0
  44. data/test/controller/action_pack_assertions_test.rb +1 -2
  45. data/test/controller/active_record_assertions_test.rb +1 -1
  46. data/test/controller/cgi_test.rb +0 -1
  47. data/test/controller/cookie_test.rb +11 -1
  48. data/test/controller/helper_test.rb +0 -12
  49. data/test/controller/render_test.rb +9 -0
  50. data/test/controller/request_test.rb +44 -1
  51. data/test/controller/routing_tests.rb +4 -1
  52. data/test/controller/test_test.rb +62 -0
  53. data/test/controller/verification_test.rb +21 -0
  54. data/test/fixtures/test/_partial_only.rhtml +1 -0
  55. data/test/template/active_record_helper_test.rb +2 -2
  56. data/test/template/asset_tag_helper_test.rb +52 -4
  57. data/test/template/date_helper_test.rb +163 -32
  58. data/test/template/form_helper_test.rb +9 -6
  59. data/test/template/form_options_helper_test.rb +18 -15
  60. data/test/template/number_helper_test.rb +51 -0
  61. data/test/template/text_helper_test.rb +17 -20
  62. data/test/template/url_helper_test.rb +7 -1
  63. metadata +15 -6
  64. data/lib/action_controller/assertions/action_pack_assertions.rb +0 -260
  65. data/lib/action_controller/assertions/active_record_assertions.rb +0 -65
@@ -150,8 +150,8 @@ module ActionController
150
150
  # Returns the total number of items in the collection to be paginated for
151
151
  # the +model+ and given +conditions+. Override this method to implement a
152
152
  # custom counter.
153
- def count_collection_for_pagination(model, conditions)
154
- model.count(conditions)
153
+ def count_collection_for_pagination(model, conditions, joins)
154
+ model.count(conditions,joins)
155
155
  end
156
156
 
157
157
  # Returns a collection of items for the given +model+ and +conditions+,
@@ -166,9 +166,9 @@ module ActionController
166
166
  :find_collection_for_pagination
167
167
 
168
168
  def paginator_and_collection_for(collection_id, options) #:nodoc:
169
- klass = eval options[:class_name]
169
+ klass = options[:class_name].constantize
170
170
  page = @params[options[:parameter]]
171
- count = count_collection_for_pagination(klass, options[:conditions])
171
+ count = count_collection_for_pagination(klass, options[:conditions], options[:join])
172
172
 
173
173
  paginator = Paginator.new(self, count, options[:per_page], page)
174
174
 
@@ -74,8 +74,16 @@ module ActionController
74
74
  end
75
75
 
76
76
  def request_uri
77
- (%r{^\w+\://[^/]+(/.*|$)$} =~ env['REQUEST_URI']) ? $1 : env['REQUEST_URI'] # Remove domain, which webrick puts into the request_uri.
78
- end
77
+ unless env['REQUEST_URI'].nil?
78
+ (%r{^\w+\://[^/]+(/.*|$)$} =~ env['REQUEST_URI']) ? $1 : env['REQUEST_URI'] # Remove domain, which webrick puts into the request_uri.
79
+ else # REQUEST_URI is blank under IIS - get this from PATH_INFO and SCRIPT_NAME
80
+ script_filename = env["SCRIPT_NAME"].to_s.match(%r{[^/]+$})
81
+ request_uri = env["PATH_INFO"]
82
+ request_uri.sub!(/#{script_filename}\//, '') unless script_filename.nil?
83
+ request_uri += '?' + env["QUERY_STRING"] unless env["QUERY_STRING"].nil? || env["QUERY_STRING"].empty?
84
+ return request_uri
85
+ end
86
+ end
79
87
 
80
88
  def protocol
81
89
  env["HTTPS"] == "on" ? 'https://' : 'http://'
@@ -122,6 +130,14 @@ module ActionController
122
130
  @path_parameters ||= {}
123
131
  end
124
132
 
133
+ # Returns true if the request's "X-Requested-With" header contains
134
+ # "XMLHttpRequest". (The Prototype Javascript library sends this header with
135
+ # every Ajax request.)
136
+ def xml_http_request?
137
+ env['HTTP_X_REQUESTED_WITH'] =~ /XMLHttpRequest/i
138
+ end
139
+ alias xhr? :xml_http_request?
140
+
125
141
  #--
126
142
  # Must be implemented in the concrete request
127
143
  #++
@@ -119,7 +119,11 @@ module ActionController
119
119
  options[:controller] = controller_class.controller_path
120
120
  return nil, requirements_for(:controller) unless passes_requirements?(:controller, options[:controller])
121
121
  elsif /^\*/ =~ item.to_s
122
- value = components.empty? ? @defaults[item].clone : components.clone
122
+ if components.empty?
123
+ value = @defaults.has_key?(item) ? @defaults[item].clone : []
124
+ else
125
+ value = components.clone
126
+ end
123
127
  value.collect! {|c| CGI.unescape c}
124
128
  components = []
125
129
  def value.to_s() self.join('/') end
@@ -162,7 +166,7 @@ module ActionController
162
166
  name = name.camelize
163
167
  return nil, nil unless /^[A-Z][_a-zA-Z\d]*$/ =~ name
164
168
  controller_name = name + "Controller"
165
- return mod.const_get(controller_name), path[length..-1] if mod.const_available? controller_name
169
+ return eval("mod::#{controller_name}"), path[length..-1] if mod.const_available? controller_name
166
170
  return nil, nil unless mod.const_available? name
167
171
  [mod.const_get(name), length + 1]
168
172
  end
@@ -99,7 +99,7 @@ module ActionController
99
99
 
100
100
  module_eval <<-"end_eval", __FILE__, __LINE__
101
101
  def list#{suffix}
102
- @#{plural_name} = #{class_name}.find_all
102
+ @#{singular_name}_pages, @#{plural_name} = paginate :#{singular_name}, :per_page => 10
103
103
  render#{suffix}_scaffold "list#{suffix}"
104
104
  end
105
105
 
@@ -8,7 +8,7 @@
8
8
  <%=h @exception.class.to_s %> in
9
9
  <%=h (@request.parameters["controller"] || "<controller not set>").capitalize %>#<%=h @request.parameters["action"] || "<action not set>" %>
10
10
  </h1>
11
- <p><%=h Object.const_defined?(:RAILS_ROOT) ? @exception.message.gsub(RAILS_ROOT, "") : @exception.message %></p>
11
+ <pre><%=h Object.const_defined?(:RAILS_ROOT) ? @exception.message.gsub(RAILS_ROOT, "") : @exception.message %></pre>
12
12
 
13
13
  <% unless app_trace.empty? %><pre><code><%=h app_trace.join("\n") %></code></pre><% end %>
14
14
 
@@ -1,8 +1,10 @@
1
1
  <h1>Routing Error</h1>
2
- <p><%=h @exception.message %></p>
2
+ <p><pre><%=h @exception.message %></pre></p>
3
3
  <% unless @exception.failures.empty? %><p>
4
4
  <h2>Failure reasons:</h2>
5
+ <ol>
5
6
  <% @exception.failures.each do |route, reason| %>
6
- <%=h route.inspect.gsub('\\', '') %> failed because <%=h reason.downcase %><br />
7
+ <li><code><%=h route.inspect.gsub('\\', '') %></code> failed because <%=h reason.downcase %></li>
7
8
  <% end %>
9
+ </ol>
8
10
  </p><% end %>
@@ -1,14 +1,15 @@
1
1
  <h1>
2
2
  <%=h @exception.original_exception.class.to_s %> in
3
- <%=h @request.parameters["controller"].capitalize %>#<%=h @request.parameters["action"] %>
3
+ <%=h @request.parameters["controller"].capitalize if @request.parameters["controller"]%>#<%=h @request.parameters["action"] %>
4
4
  </h1>
5
5
 
6
6
  <p>
7
- Showing <i><%=h @exception.file_name %></i> where line <b>#<%=h @exception.line_number %></b> raised
8
- <u><%=h @exception.message %></u>
7
+ Showing <i><%=h @exception.file_name %></i> where line <b>#<%=h @exception.line_number %></b> raised:
8
+ <pre><code><%=h @exception.message %></code></pre>
9
9
  </p>
10
10
 
11
- <pre><code><%=h @exception.source_extract %></code></pre>
11
+ <p>Extracted source (around line <b>#<%=h @exception.line_number %></b>):
12
+ <pre><code><%=h @exception.source_extract %></code></pre></p>
12
13
 
13
14
  <p><%=h @exception.sub_template_message %></p>
14
15
 
@@ -19,6 +19,9 @@
19
19
  <% end %>
20
20
  </table>
21
21
 
22
+ <%= link_to "Previous page", { :page => instance_variable_get("@#{@scaffold_singular_name}_pages").current.previous } if instance_variable_get("@#{@scaffold_singular_name}_pages").current.previous %>
23
+ <%= link_to "Next page", { :page => instance_variable_get("@#{@scaffold_singular_name}_pages").current.next } if instance_variable_get("@#{@scaffold_singular_name}_pages").current.next %>
24
+
22
25
  <br />
23
26
 
24
27
  <%= link_to "New #{@scaffold_singular_name}", :action => "new#{@scaffold_suffix}" %>
@@ -1,5 +1,5 @@
1
- require File.dirname(__FILE__) + '/assertions/action_pack_assertions'
2
- require File.dirname(__FILE__) + '/assertions/active_record_assertions'
1
+ require File.dirname(__FILE__) + '/assertions'
2
+ require File.dirname(__FILE__) + '/deprecated_assertions'
3
3
 
4
4
  if defined?(RAILS_ROOT)
5
5
  # Temporary hack for getting functional tests in Rails running under 1.8.2
@@ -94,17 +94,6 @@ module ActionController #:nodoc:
94
94
  end
95
95
 
96
96
  class TestResponse < AbstractResponse #:nodoc:
97
- # the class attribute ties a TestResponse to the assertions
98
- class << self
99
- attr_accessor :assertion_target
100
- end
101
-
102
- # initializer
103
- def initialize
104
- TestResponse.assertion_target=self# if TestResponse.assertion_target.nil?
105
- super()
106
- end
107
-
108
97
  # the response code of the request
109
98
  def response_code
110
99
  headers['Status'][0,3].to_i rescue 0
@@ -126,10 +115,12 @@ module ActionController #:nodoc:
126
115
  end
127
116
 
128
117
  # was there a server-side error?
129
- def server_error?
118
+ def error?
130
119
  (500..599).include?(response_code)
131
120
  end
132
121
 
122
+ alias_method :server_error?, :error?
123
+
133
124
  # returns the redirection location or nil
134
125
  def redirect_url
135
126
  redirect? ? headers['location'] : nil
@@ -252,12 +243,15 @@ module Test
252
243
  private
253
244
  # execute the request and set/volley the response
254
245
  def process(action, parameters = nil, session = nil, flash = nil)
246
+ @html_document = nil
255
247
  @request.env['REQUEST_METHOD'] ||= "GET"
256
248
  @request.action = action.to_s
257
- @request.path_parameters = { :controller => @controller.class.controller_path }
249
+ @request.path_parameters = { :controller => @controller.class.controller_path,
250
+ :action => action.to_s }
258
251
  @request.parameters.update(parameters) unless parameters.nil?
259
252
  @request.session = ActionController::TestSession.new(session) unless session.nil?
260
253
  @request.session["flash"] = ActionController::Flash::FlashHash.new.update(flash) if flash
254
+ build_request_uri(action, parameters)
261
255
  @controller.process(@request, @response)
262
256
  end
263
257
 
@@ -279,8 +273,57 @@ module Test
279
273
  get(@response.redirected_to.delete(:action), @response.redirected_to.stringify_keys)
280
274
  end
281
275
 
282
- def assigns(name)
283
- @response.template.assigns[name.to_s]
276
+ def assigns(key = nil)
277
+ if key.nil?
278
+ @response.template.assigns
279
+ else
280
+ @response.template.assigns[key.to_s]
281
+ end
282
+ end
283
+
284
+ def session
285
+ @response.session
286
+ end
287
+
288
+ def flash
289
+ @response.flash
290
+ end
291
+
292
+ def cookies
293
+ @response.cookies
294
+ end
295
+
296
+ def redirect_to_url
297
+ @response.redirect_url
298
+ end
299
+
300
+ def build_request_uri(action, parameters)
301
+ return if @request.env['REQUEST_URI']
302
+ url = ActionController::UrlRewriter.new(@request, parameters)
303
+ @request.set_REQUEST_URI(
304
+ url.rewrite(@controller.send(:rewrite_options,
305
+ (parameters||{}).update(:only_path => true, :action=>action))))
306
+ end
307
+
308
+ def html_document
309
+ require_html_scanner
310
+ @html_document ||= HTML::Document.new(@response.body)
311
+ end
312
+
313
+ def find_tag(conditions)
314
+ html_document.find(conditions)
315
+ end
316
+
317
+ def find_all_tag(conditions)
318
+ html_document.find_all(conditions)
319
+ end
320
+
321
+ def require_html_scanner
322
+ return true if defined?(HTML::Document)
323
+ require 'html/document'
324
+ rescue LoadError
325
+ $:.unshift File.dirname(__FILE__) + "/vendor/html-scanner"
326
+ require 'html/document'
284
327
  end
285
328
  end
286
329
  end
@@ -2,7 +2,7 @@ module ActionController
2
2
  # Rewrites URLs for Base.redirect_to and Base.url_for in the controller.
3
3
 
4
4
  class UrlRewriter #:nodoc:
5
- RESERVED_OPTIONS = [:anchor, :params, :only_path, :host, :protocol, :trailing_slash]
5
+ RESERVED_OPTIONS = [:anchor, :params, :only_path, :host, :protocol, :trailing_slash, :skip_relative_url_root]
6
6
  def initialize(request, parameters)
7
7
  @request, @parameters = request, parameters
8
8
  end
@@ -23,7 +23,7 @@ module ActionController
23
23
  rewritten_url << (options[:protocol] || @request.protocol) unless options[:only_path]
24
24
  rewritten_url << (options[:host] || @request.host_with_port) unless options[:only_path]
25
25
 
26
- rewritten_url << @request.relative_url_root.to_s
26
+ rewritten_url << @request.relative_url_root.to_s unless options[:skip_relative_url_root]
27
27
  rewritten_url << path
28
28
  rewritten_url << '/' if options[:trailing_slash]
29
29
  rewritten_url << "##{options[:anchor]}" if options[:anchor]
@@ -38,7 +38,7 @@ module ActionController
38
38
  path, extras = Routing::Routes.generate(options, @request)
39
39
 
40
40
  if extras[:overwrite_params]
41
- params_copy = @request.parameters.delete_if { |k,v| ["controller","action"].include? k }
41
+ params_copy = @request.parameters.reject { |k,v| ["controller","action"].include? k }
42
42
  params_copy.update extras[:overwrite_params]
43
43
  extras.delete(:overwrite_params)
44
44
  extras.update(params_copy)
@@ -0,0 +1,63 @@
1
+ require 'html/tokenizer'
2
+ require 'html/node'
3
+
4
+ module HTML#:nodoc:
5
+
6
+ # A top-level HTMl document. You give it a body of text, and it will parse that
7
+ # text into a tree of nodes.
8
+ class Document #:nodoc:
9
+
10
+ # The root of the parsed document.
11
+ attr_reader :root
12
+
13
+ # Create a new Document from the given text.
14
+ def initialize(text)
15
+ tokenizer = Tokenizer.new(text)
16
+ @root = Node.new(nil)
17
+ node_stack = [ @root ]
18
+ while token = tokenizer.next
19
+ node = Node.parse(node_stack.last, tokenizer.line, tokenizer.position, token)
20
+
21
+ node_stack.last.children << node unless node.tag? && node.closing == :close
22
+ if node.tag? && !node.childless?
23
+ if node_stack.length > 1 && node.closing == :close
24
+ if node_stack.last.name == node.name
25
+ node_stack.pop
26
+ else
27
+ open_start = node_stack.last.position - 20
28
+ open_start = 0 if open_start < 0
29
+ close_start = node.position - 20
30
+ close_start = 0 if close_start < 0
31
+ warn <<EOF.strip
32
+ ignoring attempt to close #{node_stack.last.name} with #{node.name}
33
+ opened at byte #{node_stack.last.position}, line #{node_stack.last.line}
34
+ closed at byte #{node.position}, line #{node.line}
35
+ attributes at open: #{node_stack.last.attributes.inspect}
36
+ text around open: #{text[open_start,40].inspect}
37
+ text around close: #{text[close_start,40].inspect}
38
+ EOF
39
+ end
40
+ elsif node.closing != :close
41
+ node_stack.push node
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Search the tree for (and return) the first node that matches the given
48
+ # conditions. The conditions are interpreted differently for different node
49
+ # types, see HTML::Text#find and HTML::Tag#find.
50
+ def find(conditions)
51
+ @root.find(conditions)
52
+ end
53
+
54
+ # Search the tree for (and return) all nodes that match the given
55
+ # conditions. The conditions are interpreted differently for different node
56
+ # types, see HTML::Text#find and HTML::Tag#find.
57
+ def find_all(conditions)
58
+ @root.find_all(conditions)
59
+ end
60
+
61
+ end
62
+
63
+ end
@@ -0,0 +1,431 @@
1
+ require 'strscan'
2
+
3
+ module HTML#:nodoc:
4
+
5
+ class Conditions < Hash#:nodoc:
6
+ def initialize(hash)
7
+ super()
8
+ hash = { :content => hash } unless Hash === hash
9
+ hash = keys_to_symbols(hash)
10
+ hash.each do |k,v|
11
+ case k
12
+ when :tag, :content then
13
+ # keys are valid, and require no further processing
14
+ when :attributes then
15
+ hash[k] = keys_to_strings(v)
16
+ when :parent, :child, :ancestor, :descendant
17
+ hash[k] = Conditions.new(v)
18
+ when :children
19
+ hash[k] = v = keys_to_symbols(v)
20
+ v.each do |k,v2|
21
+ case k
22
+ when :count, :greater_than, :less_than
23
+ # keys are valid, and require no further processing
24
+ when :only
25
+ v[k] = Conditions.new(v2)
26
+ else
27
+ raise "illegal key #{k.inspect} => #{v2.inspect}"
28
+ end
29
+ end
30
+ else
31
+ raise "illegal key #{k.inspect} => #{v.inspect}"
32
+ end
33
+ end
34
+ update hash
35
+ end
36
+
37
+ private
38
+
39
+ def keys_to_strings(hash)
40
+ hash.keys.inject({}) do |h,k|
41
+ h[k.to_s] = hash[k]
42
+ h
43
+ end
44
+ end
45
+
46
+ def keys_to_symbols(hash)
47
+ hash.keys.inject({}) do |h,k|
48
+ raise "illegal key #{k.inspect}" unless k.respond_to?(:to_sym)
49
+ h[k.to_sym] = hash[k]
50
+ h
51
+ end
52
+ end
53
+ end
54
+
55
+ # The base class of all nodes, textual and otherwise, in an HTML document.
56
+ class Node#:nodoc:
57
+ # The array of children of this node. Not all nodes have children.
58
+ attr_reader :children
59
+
60
+ # The parent node of this node. All nodes have a parent, except for the
61
+ # root node.
62
+ attr_reader :parent
63
+
64
+ # The line number of the input where this node was begun
65
+ attr_reader :line
66
+
67
+ # The byte position in the input where this node was begun
68
+ attr_reader :position
69
+
70
+ # Create a new node as a child of the given parent.
71
+ def initialize(parent, line=0, pos=0)
72
+ @parent = parent
73
+ @children = []
74
+ @line, @position = line, pos
75
+ end
76
+
77
+ # Return a textual representation of the node.
78
+ def to_s
79
+ s = ""
80
+ @children.each { |child| s << child.to_s }
81
+ s
82
+ end
83
+
84
+ # Return false (subclasses must override this to provide specific matching
85
+ # behavior.) +conditions+ may be of any type.
86
+ def match(conditions)
87
+ false
88
+ end
89
+
90
+ # Search the children of this node for the first node for which #find
91
+ # returns non +nil+. Returns the result of the #find call that succeeded.
92
+ def find(conditions)
93
+ @children.each do |child|
94
+ node = child.find(conditions)
95
+ return node if node
96
+ end
97
+ nil
98
+ end
99
+
100
+ # Search for all nodes that match the given conditions, and return them
101
+ # as an array.
102
+ def find_all(conditions)
103
+ matches = []
104
+ matches << self if match(conditions)
105
+ @children.each do |child|
106
+ matches.concat child.find_all(conditions)
107
+ end
108
+ matches
109
+ end
110
+
111
+ # Returns +false+. Subclasses may override this if they define a kind of
112
+ # tag.
113
+ def tag?
114
+ false
115
+ end
116
+
117
+ def validate_conditions(conditions)
118
+ Conditions === conditions ? conditions : Conditions.new(conditions)
119
+ end
120
+
121
+ class <<self
122
+ def parse(parent, line, pos, content)
123
+ if content !~ /^<\S/
124
+ Text.new(parent, line, pos, content)
125
+ else
126
+ scanner = StringScanner.new(content)
127
+ scanner.skip(/</) or raise "expected <"
128
+ closing = ( scanner.scan(/\//) ? :close : nil )
129
+ return Text.new(parent, line, pos, content) unless name = scanner.scan(/[\w:]+/)
130
+ name.downcase!
131
+
132
+ unless closing
133
+ scanner.skip(/\s*/)
134
+ attributes = {}
135
+ while attr = scanner.scan(/[-\w:]+/)
136
+ value = true
137
+ if scanner.scan(/\s*=\s*/)
138
+ if delim = scanner.scan(/['"]/)
139
+ value = ""
140
+ while text = scanner.scan(/[^#{delim}\\]+|./)
141
+ case text
142
+ when "\\" then
143
+ value << text
144
+ value << scanner.getch
145
+ when delim
146
+ break
147
+ else value << text
148
+ end
149
+ end
150
+ else
151
+ value = scanner.scan(/[^\s>\/]+/)
152
+ end
153
+ end
154
+ attributes[attr.downcase] = value
155
+ scanner.skip(/\s*/)
156
+ end
157
+
158
+ closing = ( scanner.scan(/\//) ? :self : nil )
159
+ end
160
+
161
+ scanner.scan(/\s*>/) or raise "expected > (got #{scanner.rest.inspect} for #{content}, #{attributes.inspect})"
162
+
163
+ Tag.new(parent, line, pos, name, attributes, closing)
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ # A node that represents text, rather than markup.
170
+ class Text < Node#:nodoc:
171
+
172
+ attr_reader :content
173
+
174
+ # Creates a new text node as a child of the given parent, with the given
175
+ # content.
176
+ def initialize(parent, line, pos, content)
177
+ super(parent, line, pos)
178
+ @content = content
179
+ end
180
+
181
+ # Returns the content of this node.
182
+ def to_s
183
+ @content
184
+ end
185
+
186
+ # Returns +self+ if this node meets the given conditions. Text nodes support
187
+ # conditions of the following kinds:
188
+ #
189
+ # * if +conditions+ is a string, it must be a substring of the node's
190
+ # content
191
+ # * if +conditions+ is a regular expression, it must match the node's
192
+ # content
193
+ # * if +conditions+ is a hash, it must contain a <tt>:content</tt> key that
194
+ # is either a string or a regexp, and which is interpreted as described
195
+ # above.
196
+ def find(conditions)
197
+ match(conditions) && self
198
+ end
199
+
200
+ # Returns non-+nil+ if this node meets the given conditions, or +nil+
201
+ # otherwise. See the discussion of #find for the valid conditions.
202
+ def match(conditions)
203
+ case conditions
204
+ when String
205
+ @content.index(conditions)
206
+ when Regexp
207
+ @content =~ conditions
208
+ when Hash
209
+ conditions = validate_conditions(conditions)
210
+
211
+ # Text nodes only have :content, :parent, :ancestor
212
+ unless (conditions.keys - [:content, :parent, :ancestor]).empty?
213
+ return false
214
+ end
215
+
216
+ match(conditions[:content])
217
+ else
218
+ nil
219
+ end
220
+ end
221
+ end
222
+
223
+ # A Tag is any node that represents markup. It may be an opening tag, a
224
+ # closing tag, or a self-closing tag. It has a name, and may have a hash of
225
+ # attributes.
226
+ class Tag < Node#:nodoc:
227
+
228
+ # Either +nil+, <tt>:close</tt>, or <tt>:self</tt>
229
+ attr_reader :closing
230
+
231
+ # Either +nil+, or a hash of attributes for this node.
232
+ attr_reader :attributes
233
+
234
+ # The name of this tag.
235
+ attr_reader :name
236
+
237
+ # Create a new node as a child of the given parent, using the given content
238
+ # to describe the node. It will be parsed and the node name, attributes and
239
+ # closing status extracted.
240
+ def initialize(parent, line, pos, name, attributes, closing)
241
+ super(parent, line, pos)
242
+ @name = name
243
+ @attributes = attributes
244
+ @closing = closing
245
+ end
246
+
247
+ # A convenience for obtaining an attribute of the node. Returns +nil+ if
248
+ # the node has no attributes.
249
+ def [](attr)
250
+ @attributes ? @attributes[attr] : nil
251
+ end
252
+
253
+ # Returns non-+nil+ if this tag can contain child nodes.
254
+ def childless?
255
+ @name =~ /^(img|br|hr|link|meta|area|base|basefont|col|frame|input|isindex|param)$/o
256
+ end
257
+
258
+ # Returns a textual representation of the node
259
+ def to_s
260
+ if @closing == :close
261
+ "</#{@name}>"
262
+ else
263
+ s = "<#{@name}"
264
+ @attributes.each { |k,v| s << " #{k}='#{v.gsub(/'/,"\\\\'")}'" }
265
+ s << " /" if @closing == :self
266
+ s << ">"
267
+ @children.each { |child| s << child.to_s }
268
+ s
269
+ end
270
+ end
271
+
272
+ # If either the node or any of its children meet the given conditions, the
273
+ # matching node is returned. Otherwise, +nil+ is returned. (See the
274
+ # description of the valid conditions in the +match+ method.)
275
+ def find(conditions)
276
+ match(conditions) && self || super
277
+ end
278
+
279
+ # Returns +true+, indicating that this node represents an HTML tag.
280
+ def tag?
281
+ true
282
+ end
283
+
284
+ # Returns +true+ if the node meets any of the given conditions. The
285
+ # +conditions+ parameter must be a hash of any of the following keys
286
+ # (all are optional):
287
+ #
288
+ # * <tt>:tag</tt>: the node name must match the corresponding value
289
+ # * <tt>:attributes</tt>: a hash. The node's values must match the
290
+ # corresponding values in the hash.
291
+ # * <tt>:parent</tt>: a hash. The node's parent must match the
292
+ # corresponding hash.
293
+ # * <tt>:child</tt>: a hash. At least one of the node's immediate children
294
+ # must meet the criteria described by the hash.
295
+ # * <tt>:ancestor</tt>: a hash. At least one of the node's ancestors must
296
+ # meet the criteria described by the hash.
297
+ # * <tt>:descendant</tt>: a hash. At least one of the node's descendants
298
+ # must meet the criteria described by the hash.
299
+ # * <tt>:children</tt>: a hash, for counting children of a node. Accepts the
300
+ # keys:
301
+ # ** <tt>:count</tt>: either a number or a range which must equal (or
302
+ # include) the number of children that match.
303
+ # ** <tt>:less_than</tt>: the number of matching children must be less than
304
+ # this number.
305
+ # ** <tt>:greater_than</tt>: the number of matching children must be
306
+ # greater than this number.
307
+ # ** <tt>:only</tt>: another hash consisting of the keys to use
308
+ # to match on the children, and only matching children will be
309
+ # counted.
310
+ #
311
+ # Conditions are matched using the following algorithm:
312
+ #
313
+ # * if the condition is a string, it must be a substring of the value.
314
+ # * if the condition is a regexp, it must match the value.
315
+ # * if the condition is a number, the value must match number.to_s.
316
+ # * if the condition is +true+, the value must not be +nil+.
317
+ # * if the condition is +false+ or +nil+, the value must be +nil+.
318
+ #
319
+ # Usage:
320
+ #
321
+ # # test if the node is a "span" tag
322
+ # node.match :tag => "span"
323
+ #
324
+ # # test if the node's parent is a "div"
325
+ # node.match :parent => { :tag => "div" }
326
+ #
327
+ # # test if any of the node's ancestors are "table" tags
328
+ # node.match :ancestor => { :tag => "table" }
329
+ #
330
+ # # test if any of the node's immediate children are "em" tags
331
+ # node.match :child => { :tag => "em" }
332
+ #
333
+ # # test if any of the node's descendants are "strong" tags
334
+ # node.match :descendant => { :tag => "strong" }
335
+ #
336
+ # # test if the node has between 2 and 4 span tags as immediate children
337
+ # node.match :children => { :count => 2..4, :only => { :tag => "span" } }
338
+ #
339
+ # # get funky: test to see if the node is a "div", has a "ul" ancestor
340
+ # # and an "li" parent (with "class" = "enum"), and whether or not it has
341
+ # # a "span" descendant that contains # text matching /hello world/:
342
+ # node.match :tag => "div",
343
+ # :ancestor => { :tag => "ul" },
344
+ # :parent => { :tag => "li",
345
+ # :attributes => { :class => "enum" } },
346
+ # :descendant => { :tag => "span",
347
+ # :child => /hello world/ }
348
+ def match(conditions)
349
+ conditions = validate_conditions(conditions)
350
+
351
+ # only Text nodes have content
352
+ return false if conditions[:content]
353
+
354
+ # test the name
355
+ return false unless match_condition(@name, conditions[:tag]) if conditions[:tag]
356
+
357
+ # test attributes
358
+ (conditions[:attributes] || {}).each do |key, value|
359
+ return false unless match_condition(self[key], value)
360
+ end
361
+
362
+ # test parent
363
+ return false unless parent.match(conditions[:parent]) if conditions[:parent]
364
+
365
+ # test children
366
+ return false unless children.find { |child| child.match(conditions[:child]) } if conditions[:child]
367
+
368
+ # test ancestors
369
+ if conditions[:ancestor]
370
+ return false unless catch :found do
371
+ p = self
372
+ throw :found, true if p.match(conditions[:ancestor]) while p = p.parent
373
+ end
374
+ end
375
+
376
+ # test descendants
377
+ if conditions[:descendant]
378
+ return false unless children.find do |child|
379
+ # test the child
380
+ child.match(conditions[:descendant]) ||
381
+ # test the child's descendants
382
+ child.match(:descendant => conditions[:descendant])
383
+ end
384
+ end
385
+
386
+ # count children
387
+ if opts = conditions[:children]
388
+ matches = children
389
+ matches = matches.select { |c| c.match(opts[:only]) } if opts[:only]
390
+ opts.each do |key, value|
391
+ next if key == :only
392
+ case key
393
+ when :count
394
+ if Integer === value
395
+ return false if matches.length != value
396
+ else
397
+ return false unless value.include?(matches.length)
398
+ end
399
+ when :less_than
400
+ return false unless matches.length < value
401
+ when :greater_than
402
+ return false unless matches.length > value
403
+ else raise "unknown count condition #{key}"
404
+ end
405
+ end
406
+ end
407
+
408
+ true
409
+ end
410
+
411
+ private
412
+
413
+ # Match the given value to the given condition.
414
+ def match_condition(value, condition)
415
+ case condition
416
+ when String
417
+ value && value.index(condition)
418
+ when Regexp
419
+ value && value.match(condition)
420
+ when Numeric
421
+ value == condition.to_s
422
+ when true
423
+ !value.nil?
424
+ when false, nil
425
+ value.nil?
426
+ else
427
+ false
428
+ end
429
+ end
430
+ end
431
+ end