seeing_is_believing 3.0.0.beta.4 → 3.0.0.beta.5

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -8
  3. data/Rakefile +1 -1
  4. data/Readme.md +65 -25
  5. data/bin/seeing_is_believing +1 -0
  6. data/docs/sib-streaming.gif +0 -0
  7. data/features/deprecated-flags.feature +62 -2
  8. data/features/errors.feature +12 -7
  9. data/features/examples.feature +143 -4
  10. data/features/flags.feature +89 -29
  11. data/features/regression.feature +58 -14
  12. data/features/support/env.rb +4 -0
  13. data/features/xmpfilter-style.feature +181 -36
  14. data/lib/seeing_is_believing.rb +44 -33
  15. data/lib/seeing_is_believing/binary.rb +31 -88
  16. data/lib/seeing_is_believing/binary/align_chunk.rb +30 -11
  17. data/lib/seeing_is_believing/binary/annotate_end_of_file.rb +10 -16
  18. data/lib/seeing_is_believing/binary/annotate_every_line.rb +5 -25
  19. data/lib/seeing_is_believing/binary/annotate_marked_lines.rb +136 -0
  20. data/lib/seeing_is_believing/binary/comment_lines.rb +8 -10
  21. data/lib/seeing_is_believing/binary/commentable_lines.rb +20 -26
  22. data/lib/seeing_is_believing/binary/config.rb +392 -0
  23. data/lib/seeing_is_believing/binary/data_structures.rb +57 -0
  24. data/lib/seeing_is_believing/binary/engine.rb +104 -0
  25. data/lib/seeing_is_believing/binary/{comment_formatter.rb → format_comment.rb} +6 -6
  26. data/lib/seeing_is_believing/binary/remove_annotations.rb +29 -28
  27. data/lib/seeing_is_believing/binary/rewrite_comments.rb +42 -43
  28. data/lib/seeing_is_believing/code.rb +105 -49
  29. data/lib/seeing_is_believing/debugger.rb +6 -5
  30. data/lib/seeing_is_believing/error.rb +6 -17
  31. data/lib/seeing_is_believing/evaluate_by_moving_files.rb +78 -129
  32. data/lib/seeing_is_believing/event_stream/consumer.rb +114 -64
  33. data/lib/seeing_is_believing/event_stream/events.rb +169 -11
  34. data/lib/seeing_is_believing/event_stream/handlers/debug.rb +57 -0
  35. data/lib/seeing_is_believing/event_stream/handlers/record_exitstatus.rb +18 -0
  36. data/lib/seeing_is_believing/event_stream/handlers/stream_json_events.rb +45 -0
  37. data/lib/seeing_is_believing/event_stream/handlers/update_result.rb +39 -0
  38. data/lib/seeing_is_believing/event_stream/producer.rb +25 -24
  39. data/lib/seeing_is_believing/hash_struct.rb +206 -0
  40. data/lib/seeing_is_believing/result.rb +20 -3
  41. data/lib/seeing_is_believing/the_matrix.rb +20 -12
  42. data/lib/seeing_is_believing/version.rb +1 -1
  43. data/lib/seeing_is_believing/wrap_expressions.rb +55 -115
  44. data/lib/seeing_is_believing/wrap_expressions_with_inspect.rb +14 -0
  45. data/seeing_is_believing.gemspec +1 -1
  46. data/spec/binary/alignment_specs.rb +27 -0
  47. data/spec/binary/comment_lines_spec.rb +3 -2
  48. data/spec/binary/config_spec.rb +657 -0
  49. data/spec/binary/engine_spec.rb +97 -0
  50. data/spec/binary/{comment_formatter_spec.rb → format_comment_spec.rb} +2 -2
  51. data/spec/binary/marker_spec.rb +71 -0
  52. data/spec/binary/options_spec.rb +0 -0
  53. data/spec/binary/remove_annotations_spec.rb +31 -18
  54. data/spec/binary/rewrite_comments_spec.rb +26 -11
  55. data/spec/code_spec.rb +190 -6
  56. data/spec/debugger_spec.rb +4 -0
  57. data/spec/evaluate_by_moving_files_spec.rb +38 -20
  58. data/spec/event_stream_spec.rb +265 -116
  59. data/spec/hash_struct_spec.rb +514 -0
  60. data/spec/seeing_is_believing_spec.rb +108 -46
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/wrap_expressions_spec.rb +207 -172
  63. metadata +30 -18
  64. data/docs/for-presentations +0 -33
  65. data/lib/seeing_is_believing/binary/annotate_xmpfilter_style.rb +0 -128
  66. data/lib/seeing_is_believing/binary/interpret_flags.rb +0 -156
  67. data/lib/seeing_is_believing/binary/parse_args.rb +0 -263
  68. data/lib/seeing_is_believing/event_stream/update_result.rb +0 -24
  69. data/lib/seeing_is_believing/inspect_expressions.rb +0 -21
  70. data/lib/seeing_is_believing/parser_helpers.rb +0 -82
  71. data/spec/binary/interpret_flags_spec.rb +0 -332
  72. data/spec/binary/parse_args_spec.rb +0 -415
@@ -1,17 +1,175 @@
1
+ require 'seeing_is_believing/hash_struct'
2
+
1
3
  class SeeingIsBelieving
2
4
  module EventStream
5
+ Event = HashStruct.anon do # one superclass to rule them all!
6
+ def self.event_name
7
+ raise NotImplementedError, "Subclass should have defined this!"
8
+ end
9
+
10
+ def event_name
11
+ self.class.event_name
12
+ end
13
+
14
+ def as_json
15
+ [event_name, to_h]
16
+ end
17
+ end
18
+
3
19
  module Events
4
- LineResult = Struct.new(:type, :line_number, :inspected)
5
- UnrecordedResult = Struct.new(:type, :line_number)
6
- Stdout = Struct.new(:value)
7
- Stderr = Struct.new(:value)
8
- MaxLineCaptures = Struct.new(:value)
9
- Filename = Struct.new(:value)
10
- NumLines = Struct.new(:value)
11
- SiBVersion = Struct.new(:value)
12
- RubyVersion = Struct.new(:value)
13
- Exitstatus = Struct.new(:value)
14
- Exception = Struct.new(:line_number, :class_name, :message, :backtrace)
20
+ # A line was printed to stdout.
21
+ class Stdout < Event
22
+ def self.event_name
23
+ :stdout
24
+ end
25
+ attributes :value
26
+ end
27
+
28
+ # A line was printed to stderr.
29
+ class Stderr < Event
30
+ def self.event_name
31
+ :stderr
32
+ end
33
+ attributes :value
34
+ end
35
+
36
+ # The program will not record more results than this for a line.
37
+ # Note that if this is hit, it will emit an unrecorded_result.
38
+ class MaxLineCaptures < Event
39
+ def self.event_name
40
+ :max_line_captures
41
+ end
42
+ def as_json
43
+ value, is_infinity = if self.value == Float::INFINITY
44
+ [-1, true]
45
+ else
46
+ [self.value, false]
47
+ end
48
+ [event_name, {value: value, is_infinity: is_infinity}]
49
+ end
50
+ attribute :value
51
+ end
52
+
53
+ # Name of the file being evaluated.
54
+ class Filename < Event
55
+ def self.event_name
56
+ :filename
57
+ end
58
+ attributes :value
59
+ end
60
+
61
+ # Number of lines in the program.
62
+ class NumLines < Event
63
+ def self.event_name
64
+ :num_lines
65
+ end
66
+ attributes :value
67
+ end
68
+
69
+ # Version of SeeingIsBelieving used to evaluate the code.
70
+ # Equivalent to `SeeingIsBelieving::VERSION`, and `seeing_is_believing --version`
71
+ class SiBVersion < Event
72
+ def self.event_name
73
+ :sib_version
74
+ end
75
+ attributes :value
76
+ end
77
+
78
+ # Version of Ruby being used to evaluate the code.
79
+ # Equivalent to `RUBY_VERSION`
80
+ class RubyVersion < Event
81
+ def self.event_name
82
+ :ruby_version
83
+ end
84
+ attributes :value
85
+ end
86
+
87
+ # The process' exitstatus.
88
+ class Exitstatus < Event
89
+ def self.event_name
90
+ :exitstatus
91
+ end
92
+ attributes :value
93
+ end
94
+
95
+ # Emitted when the process invokes exec.
96
+ # Note that this could be a child process,
97
+ # so it does not necessarily mean there won't be any more line results
98
+ class Exec < Event
99
+ def self.event_name
100
+ :exec
101
+ end
102
+ attributes :args
103
+ end
104
+
105
+ # A line was executed, and its result recorded.
106
+ # Currently, type will either be :inspect, or :pp
107
+ # :pp is used by AnnotateMarkedLines to facilitate xmpfilter style.
108
+ # If you're consuming the event stream, it's safe to assume type will always be :inspect
109
+ # If you're using the library, it's whatever you've recorded it as (if you haven't changed this, it's :inspect)
110
+ class LineResult < Event
111
+ def self.event_name
112
+ :line_result
113
+ end
114
+ attributes :type, :line_number, :inspected
115
+ end
116
+
117
+ # There were more results than we are emitting for this line / type of recording
118
+ # See LineResult for explanation of types
119
+ # This would occur because the line was executed more times than the max.
120
+ class ResultsTruncated < Event
121
+ def self.event_name
122
+ :results_truncated
123
+ end
124
+ attributes :type, :line_number
125
+ end
126
+
127
+ # The program raised an exception and did not catch it.
128
+ # Note that currently `ExitStatus` exceptions are not emitted.
129
+ # That could change at some point as it seems like the stream consumer
130
+ # should decide whether they care about that rather than the producer.
131
+ class Exception < Event
132
+ def self.event_name
133
+ :exception
134
+ end
135
+ attributes :line_number, :class_name, :message, :backtrace
136
+ end
137
+
138
+ # The process's stdout stream was closed, there will be no more Stdout events.
139
+ # "side" will either be :producer or :consumer
140
+ class StdoutClosed < Event
141
+ def self.event_name
142
+ :stdout_closed
143
+ end
144
+ attributes :side
145
+ end
146
+
147
+ # The process's stderr stream was closed, there will be no more Stderr events.
148
+ # "side" will either be :producer or :consumer
149
+ class StderrClosed < Event
150
+ def self.event_name
151
+ :stderr_closed
152
+ end
153
+ attributes :side
154
+ end
155
+
156
+ # The process's event stream was closed, there will be no more events that come via the stream.
157
+ # Currently, that's all events except Stdout, StdoutClosed, Stderr, StdoutClosed, ExitStatus, and Finished
158
+ class EventStreamClosed < Event
159
+ def self.event_name
160
+ :event_stream_closed
161
+ end
162
+ attributes :side
163
+ end
164
+
165
+ # All streams are closed and the exit status is known.
166
+ # There will be no more events.
167
+ class Finished < Event
168
+ def self.event_name
169
+ :finished
170
+ end
171
+ attributes []
172
+ end
15
173
  end
16
174
  end
17
175
  end
@@ -0,0 +1,57 @@
1
+ class SeeingIsBelieving
2
+ module EventStream
3
+ module Handlers
4
+ class Debug
5
+ def initialize(debugger, handler)
6
+ @debugger = debugger
7
+ @handler = handler
8
+ @seen = ""
9
+ @line_width = 150 # debugger is basically for me, so giving it a nice wide width
10
+ @name_width = 20
11
+ @attr_width = @line_width - @name_width
12
+ end
13
+
14
+ def call(event)
15
+ observe event
16
+ finish if event.kind_of? Events::Finished
17
+ @handler.call event
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :debugger, :handler
23
+
24
+ def finish
25
+ @debugger.context("EVENTS:") { @seen }
26
+ end
27
+
28
+ def observe(event)
29
+ name = event.class.name.split("::").last
30
+ lines = event.to_h
31
+ .map { |attribute, value|
32
+ case attribute
33
+ when :side then "#{attribute}: #{value}"
34
+ when :value then value.to_s.chomp
35
+ when :backtrace then indented = value.map { |v| "- #{v}" }
36
+ ["backtrace:", *indented]
37
+ else "#{attribute}: #{value.inspect}"
38
+ end
39
+ }
40
+ .flatten
41
+ joined = lines.join ", "
42
+ if joined.size < @attr_width
43
+ @seen << sprintf("%-#{@name_width}s%s\n", name, joined)
44
+ elsif lines.size == 1
45
+ @seen << sprintf("%-#{@name_width}s%s...\n", name, lines.first[0...@attr_width-3])
46
+ else
47
+ @seen << "#{name}\n"
48
+ lines.each { |line|
49
+ line = line[0...@line_width-5] << "..." if @line_width < line.length + 2
50
+ @seen << sprintf("| %s\n", line)
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,18 @@
1
+ class SeeingIsBelieving
2
+ module EventStream
3
+ module Handlers
4
+ class RecordExitStatus
5
+ attr_reader :exitstatus
6
+
7
+ def initialize(next_observer)
8
+ @next_observer = next_observer
9
+ end
10
+
11
+ def call(event)
12
+ @exitstatus = event.value if event.event_name == :exitstatus
13
+ @next_observer.call(event)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ require 'json'
2
+ class SeeingIsBelieving
3
+ module EventStream
4
+ module Handlers
5
+ class StreamJsonEvents
6
+ attr_reader :stream
7
+
8
+ def initialize(stream)
9
+ @flush = true if stream.respond_to? :flush
10
+ @stream = stream
11
+ @has_exception = false
12
+ @exitstatus = :not_yet_seen
13
+ end
14
+
15
+ def call(event)
16
+ write_event event
17
+ record_outcome event
18
+ end
19
+
20
+ def has_exception?
21
+ true
22
+ end
23
+
24
+ def exitstatus
25
+ @exitstatus
26
+ end
27
+
28
+ private
29
+
30
+ def write_event(event)
31
+ @stream << JSON.dump(event.as_json)
32
+ @stream << "\n"
33
+ @stream.flush if @flush
34
+ end
35
+
36
+ def record_outcome(event)
37
+ case event
38
+ when Events::Exception then @has_exception = true
39
+ when Events::Exitstatus then @exitstatus = event.value
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ require 'seeing_is_believing/event_stream/events'
2
+ class SeeingIsBelieving
3
+ module EventStream
4
+ module Handlers
5
+ class UpdateResult
6
+ include EventStream::Events
7
+
8
+ attr_reader :result
9
+
10
+ def initialize(result)
11
+ @result = result
12
+ end
13
+
14
+ def call(event)
15
+ case event
16
+ when LineResult then result.record_result(event.type, event.line_number, event.inspected)
17
+ when ResultsTruncated then result.record_result(event.type, event.line_number, '...') # <-- is this really what I want?
18
+ when Exception then result.record_exception event.line_number, event.class_name, event.message, event.backtrace
19
+ when Stdout then result.stdout << event.value
20
+ when Stderr then result.stderr << event.value
21
+ when MaxLineCaptures then result.max_line_captures = event.value
22
+ when Exitstatus then result.exitstatus = event.value
23
+ when NumLines then result.num_lines = event.value
24
+ when SiBVersion then result.sib_version = event.value
25
+ when RubyVersion then result.ruby_version = event.value
26
+ when Filename then result.filename = event.value
27
+ when Exec,
28
+ Finished,
29
+ StdoutClosed,
30
+ StderrClosed,
31
+ EventStreamClosed
32
+ # no op
33
+ else raise "Unknown event: #{event.inspect}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -11,13 +11,11 @@ class SeeingIsBelieving
11
11
  def shift() end
12
12
  end
13
13
 
14
- attr_accessor :exitstatus, :max_line_captures, :num_lines, :filename
14
+ attr_accessor :max_line_captures, :filename
15
15
 
16
16
  def initialize(resultstream)
17
17
  self.filename = nil
18
- self.exitstatus = 0
19
18
  self.max_line_captures = Float::INFINITY
20
- self.num_lines = 0
21
19
  self.recorded_results = []
22
20
  self.queue = Queue.new
23
21
  self.producer_thread = Thread.new do
@@ -31,9 +29,9 @@ class SeeingIsBelieving
31
29
  rescue IOError, Errno::EPIPE
32
30
  queue.clear
33
31
  ensure
32
+ self.queue = NullQueue
34
33
  resultstream.flush rescue nil
35
34
  end
36
- self.queue = NullQueue
37
35
  end
38
36
  end
39
37
 
@@ -56,7 +54,6 @@ class SeeingIsBelieving
56
54
  StackErrors = [SystemStackError]
57
55
  StackErrors << Java::JavaLang::StackOverflowError if defined?(RUBY_PLATFORM) && RUBY_PLATFORM == 'java'
58
56
  def record_result(type, line_number, value)
59
- self.num_lines = line_number if num_lines < line_number
60
57
  counts = recorded_results[line_number] ||= Hash.new(0)
61
58
  count = counts[type]
62
59
  recorded_results[line_number][type] = count.next
@@ -83,25 +80,24 @@ class SeeingIsBelieving
83
80
  value
84
81
  end
85
82
 
83
+ # records the exception, returns the exitstatus for that exception
86
84
  def record_exception(line_number, exception)
87
- self.exitstatus = (exception.kind_of?(SystemExit) ? exception.status : 1)
88
- if line_number
89
- self.num_lines = line_number if num_lines < line_number
90
- elsif filename
91
- begin
92
- line_number = exception.backtrace.grep(/#{filename}/).first[/:\d+/][1..-1].to_i
93
- rescue Exception
85
+ return exception.status if exception.kind_of? SystemExit
86
+ if !line_number && filename
87
+ begin line_number = exception.backtrace.grep(/#{filename}/).first[/:\d+/][1..-1].to_i
88
+ rescue NoMethodError
94
89
  end
95
90
  end
96
91
  line_number ||= -1
97
- queue << "exception"
98
- queue << " line_number #{line_number}"
99
- queue << " class_name #{to_string_token exception.class.name}"
100
- queue << " message #{to_string_token exception.message}"
101
- exception.backtrace.each { |line|
102
- queue << " backtrace #{to_string_token line}"
103
- }
104
- queue << "end"
92
+ queue << [
93
+ "exception",
94
+ line_number,
95
+ to_string_token(exception.class.name),
96
+ to_string_token(exception.message),
97
+ exception.backtrace.size,
98
+ *exception.backtrace.map { |line| to_string_token line }
99
+ ].join(" ")
100
+ 1 # exit status
105
101
  end
106
102
 
107
103
  def record_filename(filename)
@@ -109,11 +105,16 @@ class SeeingIsBelieving
109
105
  queue << "filename #{to_string_token filename}"
110
106
  end
111
107
 
112
- # note that producer will continue reading until stream is closed
113
- def finish!
108
+ def record_exec(args)
109
+ queue << "exec #{to_string_token args.inspect}"
110
+ end
111
+
112
+ def record_num_lines(num_lines)
114
113
  queue << "num_lines #{num_lines}"
115
- queue << "exitstatus #{exitstatus}"
116
- queue << :break
114
+ end
115
+
116
+ def finish!
117
+ queue << :break # note that consumer will continue reading until stream is closed
117
118
  producer_thread.join
118
119
  end
119
120
 
@@ -0,0 +1,206 @@
1
+ class SeeingIsBelieving
2
+ HashStruct = Class.new
3
+
4
+ class << HashStruct
5
+ NoDefault = Module.new
6
+
7
+ def init_blocks
8
+ @init_blocks ||= {}
9
+ end
10
+
11
+ def attribute(name, value=NoDefault, &init_block)
12
+ init_blocks.key?(name) && raise(ArgumentError, "#{name} was already defined")
13
+ name.kind_of?(Symbol) || raise(ArgumentError, "#{name.inspect} should have been a symbol")
14
+
15
+ init_block ||= lambda do |hash_struct|
16
+ if value == NoDefault
17
+ raise ArgumentError, "Must provide a value for #{name.inspect}"
18
+ else
19
+ value
20
+ end
21
+ end
22
+ init_blocks[name] = init_block
23
+ define_method(name) { self[name] }
24
+ define_method(:"#{name}=") { |val| self[name] = val }
25
+
26
+ self
27
+ end
28
+
29
+ def attributes(*names_or_pairs)
30
+ names_or_pairs.each do |norp|
31
+ case norp
32
+ when Symbol then attribute(norp)
33
+ else norp.each { |name, default| attribute name, default }
34
+ end
35
+ end
36
+ self
37
+ end
38
+
39
+ def predicate(name, *rest, &b)
40
+ attribute name, *rest, &b
41
+ define_method(:"#{name}?") { !!self[name] }
42
+ self
43
+ end
44
+
45
+ def predicates(*names_or_pairs)
46
+ names_or_pairs.each do |name_or_pairs|
47
+ name = pairs = name_or_pairs
48
+ name_or_pairs.kind_of?(Symbol) ?
49
+ predicate(name) :
50
+ pairs.each { |name, default| predicate name, default }
51
+ end
52
+ self
53
+ end
54
+
55
+ def anon(&block)
56
+ Class.new self, &block
57
+ end
58
+
59
+ def for(*attributes_args, &block)
60
+ anon(&block).attributes(*attributes_args)
61
+ end
62
+
63
+ def for?(*predicate_args, &block)
64
+ anon(&block).predicates(*predicate_args)
65
+ end
66
+ end
67
+
68
+ class HashStruct
69
+ def self.inspect
70
+ name || "HashStruct.anon"
71
+ end
72
+
73
+ # This could support dynamic attributes very easily
74
+ # ie they are calculated, but appear as a value (e.g. in to_hash)
75
+ # not sure how to deal with the fact that they could be assigned, though
76
+ class Attr
77
+ def initialize(instance, value=nil, &block)
78
+ @instance = instance
79
+ @block = block if block
80
+ @value = value unless block
81
+ end
82
+ def value
83
+ return @value if defined? @value
84
+ @value = @block.call(@instance)
85
+ end
86
+ end
87
+
88
+ # The aggressivenes of this is kind of annoying when you're trying to build up a large hash of values
89
+ # maybe new vs new! one validates arg presence,
90
+ # maybe a separate #validate! method for that?
91
+ def initialize(initial_values={}, &initializer)
92
+ initial_values.respond_to?(:each) ||
93
+ raise(ArgumentError, "#{self.class.inspect} expects to be initialized with a hash-like object, but got #{initial_values.inspect}")
94
+ @attributes = self
95
+ .class
96
+ .ancestors
97
+ .take_while { |ancestor| ancestor != HashStruct }
98
+ .map(&:init_blocks)
99
+ .reverse
100
+ .inject({}, :merge)
101
+ .each_with_object({}) { |(name, block), attrs| attrs[name] = Attr.new(self, &block) }
102
+ initial_values.each { |key, value| self[key] = value }
103
+ initializer.call self if initializer
104
+ each { } # access each key to see if it blows up
105
+ end
106
+
107
+ include Enumerable
108
+ def each(&block)
109
+ return to_enum :each unless block
110
+ @attributes.keys.each do |name|
111
+ block.call(name, self[name])
112
+ end
113
+ end
114
+
115
+ def [](key)
116
+ @attributes[internalize! key].value
117
+ end
118
+
119
+ def []=(key, value)
120
+ @attributes[internalize! key] = Attr.new(self, value)
121
+ end
122
+
123
+ def fetch(key, ignored=nil)
124
+ self[key]
125
+ end
126
+
127
+ def to_hash
128
+ Hash[to_a]
129
+ end
130
+ alias to_h to_hash
131
+
132
+ def merge(overrides)
133
+ self.class.new(to_h.merge overrides)
134
+ end
135
+
136
+ def keys
137
+ to_a.map(&:first)
138
+ end
139
+
140
+ def values
141
+ to_a.map(&:last)
142
+ end
143
+
144
+ def inspect
145
+ classname = self.class.name ? "HashStruct #{self.class.name}" : self.class.inspect
146
+ inspected_attrs = map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
147
+ "#<#{classname}: {#{inspected_attrs}}>"
148
+ end
149
+
150
+ def pretty_print(pp)
151
+ pp.text self.class.name || 'HashStruct.anon { ... }'
152
+ pp.text '.new('
153
+ pp.group 2 do
154
+ pp.breakable '' # place inside so that if we break, we are indented
155
+ last_key = keys.last
156
+ each do |key, value|
157
+ # text-space-value, or text-neline-indent-value
158
+ pp.text "#{key}:"
159
+ pp.group 2 do
160
+ pp.breakable " "
161
+ pp.pp value
162
+ end
163
+ # all lines end in a comma, and can have a newline, except the last
164
+ pp.comma_breakable unless key == last_key
165
+ end
166
+ end
167
+ pp.breakable ''
168
+ pp.text ')'
169
+ end
170
+
171
+ def key?(key)
172
+ key.respond_to?(:to_sym) && @attributes.key?(key.to_sym)
173
+ end
174
+ alias has_key? key?
175
+ alias include? key? # b/c Hash does this
176
+ alias member? key? # b/c Hash does this
177
+
178
+ def ==(other)
179
+ if equal? other
180
+ true
181
+ elsif other.kind_of? Hash
182
+ to_h == other
183
+ elsif other.respond_to?(:to_h)
184
+ to_h == other.to_h
185
+ else
186
+ false
187
+ end
188
+ end
189
+ alias eql? ==
190
+
191
+ # this might be pretty expensive
192
+ def hash
193
+ to_h.hash
194
+ end
195
+
196
+ private
197
+
198
+ def internalize!(key)
199
+ internal = key.to_sym
200
+ @attributes.key?(internal) || raise(KeyError)
201
+ internal
202
+ rescue NoMethodError, KeyError
203
+ raise KeyError, "#{key.inspect} is not an attribute, should be in #{@attributes.keys.inspect}"
204
+ end
205
+ end
206
+ end