hexp 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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