hexp 0.0.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. data/.travis.yml +12 -3
  2. data/Changelog.md +9 -0
  3. data/Gemfile +3 -5
  4. data/Gemfile.devtools +20 -18
  5. data/Gemfile.lock +97 -84
  6. data/Rakefile +16 -0
  7. data/config/flay.yml +2 -2
  8. data/config/flog.yml +1 -1
  9. data/config/reek.yml +42 -18
  10. data/config/rubocop.yml +31 -0
  11. data/config/yardstick.yml +39 -1
  12. data/examples/from_nokogiri.rb +77 -0
  13. data/examples/selector_rewriter_chaining.rb +14 -0
  14. data/examples/todo.rb +138 -0
  15. data/examples/widget.rb +64 -0
  16. data/hexp.gemspec +8 -3
  17. data/lib/hexp.rb +103 -2
  18. data/lib/hexp/builder.rb +256 -0
  19. data/lib/hexp/css_selector.rb +205 -0
  20. data/lib/hexp/css_selector/parser.rb +74 -0
  21. data/lib/hexp/css_selector/sass_parser.rb +22 -0
  22. data/lib/hexp/dom.rb +0 -2
  23. data/lib/hexp/dsl.rb +27 -0
  24. data/lib/hexp/errors.rb +21 -0
  25. data/lib/hexp/h.rb +5 -2
  26. data/lib/hexp/list.rb +67 -9
  27. data/lib/hexp/node.rb +197 -41
  28. data/lib/hexp/node/attributes.rb +176 -0
  29. data/lib/hexp/node/children.rb +44 -0
  30. data/lib/hexp/node/css_selection.rb +73 -0
  31. data/lib/hexp/node/domize.rb +52 -6
  32. data/lib/hexp/node/normalize.rb +19 -9
  33. data/lib/hexp/node/pp.rb +32 -0
  34. data/lib/hexp/node/rewriter.rb +52 -0
  35. data/lib/hexp/node/selector.rb +59 -0
  36. data/lib/hexp/nokogiri/equality.rb +61 -0
  37. data/lib/hexp/nokogiri/reader.rb +27 -0
  38. data/lib/hexp/sass/selector_parser.rb +4 -0
  39. data/lib/hexp/text_node.rb +129 -9
  40. data/lib/hexp/version.rb +1 -1
  41. data/notes +34 -0
  42. data/spec/shared_helper.rb +6 -0
  43. data/spec/spec_helper.rb +2 -6
  44. data/spec/unit/hexp/builder_spec.rb +101 -0
  45. data/spec/unit/hexp/css_selector/attribute_spec.rb +137 -0
  46. data/spec/unit/hexp/css_selector/class_spec.rb +15 -0
  47. data/spec/unit/hexp/css_selector/comma_sequence_spec.rb +20 -0
  48. data/spec/unit/hexp/css_selector/element_spec.rb +11 -0
  49. data/spec/unit/hexp/css_selector/parser_spec.rb +51 -0
  50. data/spec/unit/hexp/css_selector/simple_sequence_spec.rb +48 -0
  51. data/spec/unit/hexp/dsl_spec.rb +55 -0
  52. data/spec/unit/hexp/h_spec.rb +38 -0
  53. data/spec/unit/hexp/list_spec.rb +19 -0
  54. data/spec/unit/hexp/node/attr_spec.rb +55 -0
  55. data/spec/unit/hexp/node/attributes_spec.rb +125 -0
  56. data/spec/unit/hexp/node/children_spec.rb +33 -0
  57. data/spec/unit/hexp/node/class_spec.rb +37 -0
  58. data/spec/unit/hexp/node/css_selection_spec.rb +86 -0
  59. data/spec/unit/hexp/node/normalize_spec.rb +12 -6
  60. data/spec/unit/hexp/node/rewrite_spec.rb +67 -30
  61. data/spec/unit/hexp/node/selector_spec.rb +78 -0
  62. data/spec/unit/hexp/node/text_spec.rb +7 -0
  63. data/spec/unit/hexp/node/to_dom_spec.rb +1 -1
  64. data/spec/unit/hexp/nokogiri/reader_spec.rb +8 -0
  65. data/spec/unit/hexp/parse_spec.rb +23 -0
  66. data/spec/unit/hexp/text_node_spec.rb +25 -0
  67. data/spec/unit/hexp_spec.rb +33 -0
  68. metadata +129 -16
  69. data/lib/hexp/format_error.rb +0 -8
@@ -0,0 +1,176 @@
1
+ module Hexp
2
+ class Node
3
+ # Node API methods that deal with attributes
4
+ #
5
+ module Attributes
6
+ # Attribute getter/setter
7
+ #
8
+ # When called with one argument : return the attribute value with that name.
9
+ # When called with two arguments : return a new Node with the attribute set.
10
+ # When the second argument is nil : return a new Node with the attribute unset.
11
+ #
12
+ # @example
13
+ # H[:p, class: 'hello'].attr('class') # => "hello"
14
+ # H[:p, class: 'hello'].attr('id', 'para1') # => H[:p, {"class"=>"hello", "id"=>"para1"}]
15
+ # H[:p, class: 'hello'].attr('class', nil) # => H[:p]
16
+ #
17
+ # @return [String|Hexp::Node]
18
+ # @api private
19
+ #
20
+ def attr(*args)
21
+ arity = args.count
22
+ attr_name = args[0].to_s
23
+
24
+ case arity
25
+ when 1
26
+ attributes[attr_name]
27
+ when 2
28
+ set_attr(*args)
29
+ else
30
+ raise ArgumentError, "wrong number of arguments(#{arity} for 1..2)"
31
+ end
32
+ end
33
+
34
+ # Is an attribute present
35
+ #
36
+ # This will also return true if the attribute is present but empty.
37
+ #
38
+ # @example
39
+ # H[:option].has_attr?('selected') #=> false
40
+ #
41
+ # @param name [String|Symbol] the name of the attribute
42
+ # @return [Boolean]
43
+ # @api public
44
+ #
45
+ def has_attr?(name)
46
+ attributes.has_key? name.to_s
47
+ end
48
+
49
+ # Check for the presence of a class
50
+ #
51
+ # @example
52
+ # H[:span, class: "banner strong"].class?("strong") #=> true
53
+ #
54
+ # @param klass [String] the name of the class to check for
55
+ # @return [Boolean] true if the class is present, false otherwise
56
+ # @api public
57
+ #
58
+ def class?(klass)
59
+ attr('class') && attr('class').split(' ').include?(klass.to_s)
60
+ end
61
+
62
+ # Add a CSS class to the element
63
+ #
64
+ # @example
65
+ # H[:div].add_class('foo') #=> H[:div, class: 'foo']
66
+ #
67
+ # @param klass [#to_s] The class to add
68
+ # @return [Hexp::Node]
69
+ # @api public
70
+ #
71
+ def add_class(klass)
72
+ attr('class', [attr('class'), klass].compact.join(' '))
73
+ end
74
+
75
+ # The CSS classes of this element as an array
76
+ #
77
+ # Convenience method so you don't have to split the class list yourself.
78
+ #
79
+ # @return [Array<String>]
80
+ # @api public
81
+ #
82
+ def class_list
83
+ @class_list ||= (attr('class') || '').split(' ').freeze
84
+ end
85
+
86
+ # Remove a CSS class from this element
87
+ #
88
+ # If the resulting class list is empty, the class attribute will be
89
+ # removed. If the class is present several times all instances will
90
+ # be removed. If it's not present at all, the class list will be
91
+ # unmodified.
92
+ #
93
+ # Calling this on a node with a class attribute that is equal to an
94
+ # empty string will result in the class attribute being removed.
95
+ #
96
+ # @param klass [#to_s] The class to be removed
97
+ # @return [Hexp::Node] A node that is identical to this one, but with
98
+ # the given class removed
99
+ # @api public
100
+ #
101
+ def remove_class(klass)
102
+ return self unless has_attr?('class')
103
+ new_list = class_list - [klass.to_s]
104
+ return remove_attr('class') if new_list.empty?
105
+ attr('class', new_list.join(' '))
106
+ end
107
+
108
+ # Set or override multiple attributes using a hash syntax
109
+ #
110
+ # @param attrs[Hash]
111
+ # @return [Hexp::Node]
112
+ # @api public
113
+ #
114
+ def set_attrs(attrs)
115
+ H[
116
+ self.tag,
117
+ self.attributes.merge(Hash[*attrs.flat_map{|k,v| [k.to_s, v]}]),
118
+ self.children
119
+ ]
120
+ end
121
+ alias :% :set_attrs
122
+ alias :add_attributes :set_attrs
123
+
124
+ # Remove an attribute by name
125
+ #
126
+ # @param name [#to_s] The attribute to be removed
127
+ # @return [Hexp::Node] a new node with the attribute removed
128
+ # @api public
129
+ #
130
+ def remove_attr(name)
131
+ H[
132
+ self.tag,
133
+ self.attributes.reject {|key,_| key == name.to_s},
134
+ self.children
135
+ ]
136
+ end
137
+
138
+ # Attribute accessor
139
+ #
140
+ # @param attribute_name [#to_s] The name of the attribute
141
+ # @return [String] The value of the attribute
142
+ # @api public
143
+ #
144
+ def [](attr_name)
145
+ self.attributes[attr_name.to_s]
146
+ end
147
+
148
+ # Merge attributes into this Hexp
149
+ #
150
+ # Class attributes are treated special : the class lists are merged, rather
151
+ # than being overwritten. See {set_attrs} for a more basic version.
152
+ #
153
+ # This method is analoguous with {Hash#merge}. As argument it can take a
154
+ # Hash, or another Hexp element, in which case that element's attributes
155
+ # are used.
156
+ #
157
+ # @param node_or_hash [#to_hexp|Hash]
158
+ # @return [Hexp::Node]
159
+ # @api public
160
+ #
161
+ def merge_attrs(node_or_hash)
162
+ hash = node_or_hash.respond_to?(:to_hexp) ?
163
+ node_or_hash.to_hexp.attributes : node_or_hash
164
+ result = self
165
+ hash.each do |key,value|
166
+ result = if key.to_s == 'class'
167
+ result.add_class(value)
168
+ else
169
+ result.attr(key, value)
170
+ end
171
+ end
172
+ result
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,44 @@
1
+ module Hexp
2
+ class Node
3
+ # Node API methods that deal with child_nodes
4
+ #
5
+ module Children
6
+ # Is this node an empty node
7
+ #
8
+ # H[:p, class: 'foo'].empty? #=> true
9
+ # H[:p, [H[:span]].empty? #=> false
10
+ #
11
+ # @return [Boolean] true if this node has no children
12
+ # @api public
13
+ #
14
+ def empty?
15
+ children.empty?
16
+ end
17
+
18
+ def add_child(child)
19
+ H[
20
+ self.tag,
21
+ self.attributes,
22
+ self.children + [child]
23
+ ]
24
+ end
25
+ alias :add :add_child
26
+ alias :<< :add_child
27
+
28
+ def text
29
+ children.map do |node|
30
+ node.text? ? node : node.text
31
+ end.join
32
+ end
33
+
34
+ def set_children(new_children)
35
+ H[tag, attributes, new_children]
36
+ end
37
+
38
+ def map_children(&blk)
39
+ return to_enum(:map_children) unless block_given?
40
+ H[tag, attributes, children.map(&blk)]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ module Hexp
2
+ class Node
3
+ # Select nodes using CSS selectors
4
+ #
5
+ class CssSelection < Selector
6
+ def initialize(node, css_selector)
7
+ @node, @css_selector = node, css_selector
8
+ end
9
+
10
+ def inspect
11
+ "#<#{self.class} @node=#{@node.inspect} @css_selector=#{@css_selector.inspect} matches=#{node_matches?}>"
12
+ end
13
+
14
+ def each(&block)
15
+ return to_enum(:each) unless block_given?
16
+
17
+ @node.children.each do |child|
18
+ next_selection_for(child).each(&block)
19
+ end
20
+ yield @node if node_matches?
21
+ end
22
+
23
+ def rewrite(&block)
24
+ return @node if @node.text?
25
+
26
+ new_node = H[
27
+ @node.tag,
28
+ @node.attributes,
29
+ @node.children.flat_map do |child|
30
+ next_selection_for(child).rewrite(&block)
31
+ end
32
+ ]
33
+ node_matches? ? block.call(new_node) : new_node
34
+ end
35
+
36
+ private
37
+
38
+ def comma_sequence
39
+ @comma_sequence ||= coerce_to_comma_sequence(@css_selector)
40
+ end
41
+
42
+ def coerce_to_comma_sequence(css_selector)
43
+ return css_selector if css_selector.is_a? CssSelector::CommaSequence
44
+ CssSelector::Parser.call(@css_selector)
45
+ end
46
+
47
+ def node_matches?
48
+ comma_sequence.matches?(@node)
49
+ end
50
+
51
+ # returns a new commasequence with the parts removed that have been consumed by matching
52
+ # against this node. If no part matches, return nil
53
+ def next_comma_sequence
54
+ @next_comma_sequence ||= CssSelector::CommaSequence.new(consume_matching_heads)
55
+ end
56
+
57
+ def next_selection_for(child)
58
+ self.class.new(child, next_comma_sequence)
59
+ end
60
+
61
+ def consume_matching_heads
62
+ comma_sequence.members.flat_map do |sequence|
63
+ if sequence.head_matches? @node
64
+ [sequence, sequence.drop_head]
65
+ else
66
+ [sequence]
67
+ end
68
+ end.reject(&:empty?)
69
+ end
70
+
71
+ end
72
+ end
73
+ end
@@ -2,22 +2,54 @@ module Hexp
2
2
  class Node
3
3
  # Turn nodes into DOM objects
4
4
  class Domize
5
+ DEFAULT_OPTIONS = {
6
+ :include_doctype => true
7
+ }.freeze
8
+
9
+ # The resulting DOM Document
10
+ #
11
+ # @return [Nokogiri::HTML::Document]
12
+ # @api private
13
+ #
5
14
  attr_reader :dom
6
15
 
7
- def initialize(hexp, dom = Hexp::DOM)
8
- @raw = hexp
9
- @dom = dom
16
+ # Instanitiate a Domizer
17
+ #
18
+ # @param hexp [Hexp::Node]
19
+ # @param options [Hash] :include_doctype defaults to true
20
+ # @api private
21
+ #
22
+ def initialize(hexp, options = {})
23
+ @dom = Hexp::DOM
24
+ @raw = hexp
25
+ @options = DEFAULT_OPTIONS.merge(options).freeze
10
26
  end
11
27
 
28
+ # Turn the hexp into a DOM
29
+ #
30
+ # @return [Nokogiri::HTML::Document]
31
+ # @api private
32
+ #
12
33
  def call
13
- dom::Document.new.tap do |doc|
14
- @doc = doc
15
- doc << domize(@raw)
34
+ @doc = dom::Document.new
35
+ @root = domize(@raw)
36
+ @doc << @root
37
+
38
+ if @options[:include_doctype]
39
+ @doc
40
+ else
41
+ @root
16
42
  end
17
43
  end
18
44
 
19
45
  private
20
46
 
47
+ # Turn a Hexp::Node into a Document
48
+ #
49
+ # @param hexp [Hexp::Node]
50
+ # @return [Nokogiri::HTML::Document]
51
+ # @api private
52
+ #
21
53
  def domize(hexp)
22
54
  dom::Node.new(hexp.tag.to_s, @doc).tap do |node|
23
55
  set_attributes(node, hexp.attributes)
@@ -25,12 +57,26 @@ module Hexp
25
57
  end
26
58
  end
27
59
 
60
+ # Set attributes on a DOM node
61
+ #
62
+ # @param node [Nokogiri::XML::Element]
63
+ # @param attributes [Hash]
64
+ # @return [void]
65
+ # @api private
66
+ #
28
67
  def set_attributes(node, attributes)
29
68
  attributes.each do |key,value|
30
69
  node[key] = value
31
70
  end
32
71
  end
33
72
 
73
+ # Set children on the DOM node
74
+ #
75
+ # @param node [Nokogiri::XML::Element]
76
+ # @param children [Hexp::List]
77
+ # @return [void]
78
+ # @api private
79
+ #
34
80
  def set_children(node, children)
35
81
  children.each do |child|
36
82
  if child.instance_of?(TextNode)
@@ -40,6 +40,12 @@ module Hexp
40
40
  {}
41
41
  end
42
42
 
43
+ # Returns the attributes hash with key and value converted to strings
44
+ #
45
+ # @return [Hash]
46
+ #
47
+ # @api private
48
+ #
43
49
  def normalized_attributes
44
50
  Hash[*
45
51
  attributes.flat_map do |key, value|
@@ -55,10 +61,14 @@ module Hexp
55
61
  # @api private
56
62
  #
57
63
  def children
58
- @raw[1..2].each do |arg|
59
- return Array(arg) unless [Symbol, Hash].any?{|klz| arg.instance_of?(klz)}
64
+ last = @raw.last
65
+ if last.respond_to? :to_ary
66
+ last.to_ary
67
+ elsif @raw.count < 2 || last.instance_of?(Hash)
68
+ []
69
+ else
70
+ [last]
60
71
  end
61
- []
62
72
  end
63
73
 
64
74
  # Normalize the third element of a hexp node, the list of children
@@ -75,14 +85,14 @@ module Hexp
75
85
  child
76
86
  when String, TextNode
77
87
  Hexp::TextNode.new(child)
88
+ when ->(ch) { ch.respond_to? :to_hexp }
89
+ response = child.to_hexp
90
+ raise FormatError, "to_hexp must return a Hexp::Node, got #{response.inspect}" unless response.instance_of?(Hexp::Node)
91
+ response
78
92
  when Array
79
- Hexp::Node[*child]
93
+ Hexp::Node[*child.map(&:freeze)]
80
94
  else
81
- if child.respond_to? :to_hexp
82
- response = child.to_hexp
83
- raise FormatError, "to_hexp must return a Hexp::Node, got #{response.inspect}" unless response.instance_of?(Hexp::Node)
84
- response
85
- end
95
+ raise FormatError, "Invalid value in Hexp literal : #{child.inspect} (#{child.class}) does not implement #to_hexp ; #{children.inspect}"
86
96
  end
87
97
  end
88
98
  ]
@@ -2,10 +2,20 @@ module Hexp
2
2
  class Node
3
3
  # Pretty-print a node and its contents
4
4
  class PP
5
+ # Create a new pretty-printer
6
+ #
7
+ # @param node [Hexp::Node] The node to represent
8
+ # @api private
9
+ #
5
10
  def initialize(node)
6
11
  @node = node
7
12
  end
8
13
 
14
+ # Perform the pretty-printing
15
+ #
16
+ # @return [String] The pp output
17
+ # @api private
18
+ #
9
19
  def call
10
20
  [
11
21
  @node.class.inspect_name,
@@ -14,22 +24,44 @@ module Hexp
14
24
  ].join
15
25
  end
16
26
 
27
+ # Format the node tag
28
+ #
29
+ # @return [String]
30
+ # @api private
31
+ #
17
32
  def pp_tag
18
33
  "[#{@node.tag.inspect}"
19
34
  end
20
35
 
36
+ # Format the node attributes
37
+ #
38
+ # @return [String]
39
+ # @api private
40
+ #
21
41
  def pp_attributes
22
42
  attrs = @node.attributes
23
43
  return '' if attrs.empty?
24
44
  ', ' + attrs.inspect
25
45
  end
26
46
 
47
+ # Format the node children
48
+ #
49
+ # @return [String]
50
+ # @api private
51
+ #
27
52
  def pp_children
28
53
  children = @node.children
29
54
  return ']' if children.empty?
30
55
  ", [\n#{ children.map(&:pp).join(",\n") }]]"
31
56
  end
32
57
 
58
+ # Indent a multiline string with a number of spaces
59
+ #
60
+ # @param string [String] The string to indent
61
+ # @param indent [Integer] The number of spaces to use for indentation
62
+ # @return [String]
63
+ # @api private
64
+ #
33
65
  def self.indent(string, indent = 2)
34
66
  string.lines.map {|line| " "*indent + line}.join
35
67
  end