insta 0.2.9 → 0.3.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/Rakefile +30 -3
  4. data/exe/insta +6 -0
  5. data/lib/insta/ansi.rb +30 -0
  6. data/lib/insta/ci.rb +21 -0
  7. data/lib/insta/cli.rb +714 -0
  8. data/lib/insta/config_manifest.rb +56 -0
  9. data/lib/insta/configuration.rb +86 -2
  10. data/lib/insta/diff.rb +134 -0
  11. data/lib/insta/inline/call_finder.rb +37 -0
  12. data/lib/insta/inline/edit.rb +21 -0
  13. data/lib/insta/inline/file_patcher.rb +269 -0
  14. data/lib/insta/inline/pending_registry.rb +57 -0
  15. data/lib/insta/inline/pending_store.rb +145 -0
  16. data/lib/insta/minitest/assertions.rb +254 -0
  17. data/lib/insta/minitest/lifecycle.rb +12 -0
  18. data/lib/insta/minitest.rb +8 -0
  19. data/lib/insta/pending_locations.rb +64 -0
  20. data/lib/insta/pending_reporter.rb +47 -0
  21. data/lib/insta/redaction/applicator.rb +89 -0
  22. data/lib/insta/redaction/selector.rb +193 -0
  23. data/lib/insta/rspec/hooks.rb +18 -0
  24. data/lib/insta/rspec/matchers.rb +248 -0
  25. data/lib/insta/rspec.rb +7 -0
  26. data/lib/insta/serializers/base.rb +31 -0
  27. data/lib/insta/serializers/inspect.rb +14 -0
  28. data/lib/insta/serializers/json.rb +16 -0
  29. data/lib/insta/serializers/string.rb +14 -0
  30. data/lib/insta/serializers/yaml.rb +16 -0
  31. data/lib/insta/snapshot.rb +74 -0
  32. data/lib/insta/snapshot_content.rb +44 -0
  33. data/lib/insta/snapshot_file.rb +64 -0
  34. data/lib/insta/snapshot_mismatch_handler.rb +34 -0
  35. data/lib/insta/snapshot_name.rb +51 -0
  36. data/lib/insta/syntax_highlight.rb +20 -0
  37. data/lib/insta/update_coordinator.rb +22 -0
  38. data/lib/insta/version.rb +3 -1
  39. data/lib/insta.rb +42 -8
  40. data/lib/minitest/insta_plugin.rb +17 -0
  41. data/sig/insta/ansi.rbs +28 -0
  42. data/sig/insta/ci.rbs +10 -0
  43. data/sig/insta/cli.rbs +150 -0
  44. data/sig/insta/config_manifest.rbs +27 -0
  45. data/sig/insta/configuration.rbs +48 -0
  46. data/sig/insta/diff.rbs +38 -0
  47. data/sig/insta/inline/call_finder.rbs +22 -0
  48. data/sig/insta/inline/edit.rbs +18 -0
  49. data/sig/insta/inline/file_patcher.rbs +55 -0
  50. data/sig/insta/inline/pending_registry.rbs +22 -0
  51. data/sig/insta/inline/pending_store.rbs +41 -0
  52. data/sig/insta/minitest/assertions.rbs +52 -0
  53. data/sig/insta/minitest/lifecycle.rbs +10 -0
  54. data/sig/insta/minitest.rbs +2 -0
  55. data/sig/insta/pending_locations.rbs +22 -0
  56. data/sig/insta/pending_reporter.rbs +14 -0
  57. data/sig/insta/redaction/applicator.rbs +22 -0
  58. data/sig/insta/redaction/selector.rbs +58 -0
  59. data/sig/insta/rspec/hooks.rbs +10 -0
  60. data/sig/insta/rspec/matchers.rbs +71 -0
  61. data/sig/insta/rspec.rbs +2 -0
  62. data/sig/insta/serializers/base.rbs +21 -0
  63. data/sig/insta/serializers/inspect.rbs +10 -0
  64. data/sig/insta/serializers/json.rbs +10 -0
  65. data/sig/insta/serializers/string.rbs +10 -0
  66. data/sig/insta/serializers/yaml.rbs +10 -0
  67. data/sig/insta/snapshot.rbs +37 -0
  68. data/sig/insta/snapshot_content.rbs +14 -0
  69. data/sig/insta/snapshot_file.rbs +27 -0
  70. data/sig/insta/snapshot_mismatch_handler.rbs +12 -0
  71. data/sig/insta/snapshot_name.rbs +17 -0
  72. data/sig/insta/syntax_highlight.rbs +8 -0
  73. data/sig/insta/update_coordinator.rbs +11 -0
  74. data/sig/insta/version.rbs +5 -0
  75. data/sig/insta.rbs +12 -0
  76. data/sig/minitest/insta_plugin.rbs +6 -0
  77. data/sig/vendor/difftastic.rbs +19 -0
  78. data/sig/vendor/irb.rbs +5 -0
  79. data/sig/vendor/psych.rbs +7 -0
  80. data/sig/vendor/rspec.rbs +9 -0
  81. data/sig/vendor/rubygems.rbs +3 -0
  82. metadata +109 -54
  83. data/.DS_Store +0 -0
  84. data/.gitignore +0 -11
  85. data/.rspec +0 -3
  86. data/.travis.yml +0 -5
  87. data/CODE_OF_CONDUCT.md +0 -74
  88. data/Gemfile +0 -6
  89. data/Gemfile.lock +0 -35
  90. data/README.md +0 -41
  91. data/bin/console +0 -14
  92. data/bin/setup +0 -8
  93. data/insta.gemspec +0 -36
  94. data/lib/.DS_Store +0 -0
  95. data/lib/insta/account.rb +0 -127
  96. data/lib/insta/api.rb +0 -88
  97. data/lib/insta/constants.rb +0 -21
  98. data/lib/insta/device.rb +0 -11852
  99. data/lib/insta/feed.rb +0 -89
  100. data/lib/insta/location.rb +0 -18
  101. data/lib/insta/proxy_manager.rb +0 -23
  102. data/lib/insta/tag.rb +0 -18
  103. data/lib/insta/user.rb +0 -103
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Redaction
5
+ class Selector
6
+ Segment = Struct.new(:type, :value, keyword_init: true) #: Struct[Symbol, untyped]
7
+
8
+ SEGMENT_TYPES = [:key, :index, :wildcard, :deep_wildcard, :full_range].freeze #: Array[Symbol]
9
+
10
+ attr_reader :segments #: Array[Segment]
11
+
12
+ #: (String) -> void
13
+ def initialize(selector_string)
14
+ @segments = parse(selector_string)
15
+ end
16
+
17
+ #: (Array[{ type: Symbol, value: untyped }]) -> bool
18
+ def matches?(path)
19
+ deep_index = segments.index { |segment| segment.type == :deep_wildcard }
20
+
21
+ if deep_index
22
+ deep_wildcard_match?(path, deep_index)
23
+ else
24
+ exact_match?(path, segments)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ #: (String) -> Array[Segment]
31
+ def parse(input)
32
+ raise ArgumentError, "Selector must not be empty" if input.empty?
33
+ raise ArgumentError, "Selector must start with '.' or '['" unless input.start_with?(".", "[")
34
+
35
+ result = [] #: Array[Segment]
36
+ position = 0
37
+
38
+ while position < input.length
39
+ case input[position]
40
+ when "."
41
+ position = parse_dot_segment(input, position + 1, result)
42
+ when "["
43
+ position = parse_bracket_segment(input, position + 1, result)
44
+ else
45
+ raise ArgumentError, "Unexpected character '#{input[position]}' at position #{position}"
46
+ end
47
+ end
48
+
49
+ result
50
+ end
51
+
52
+ #: (String, Integer, Array[Segment]) -> Integer
53
+ def parse_dot_segment(input, position, result)
54
+ if position < input.length && input[position] == "*"
55
+ parse_wildcard_segment(input, position + 1, result)
56
+ else
57
+ parse_key_segment(input, position, result)
58
+ end
59
+ end
60
+
61
+ #: (String, Integer, Array[Segment]) -> Integer
62
+ def parse_wildcard_segment(input, position, result)
63
+ if position < input.length && input[position] == "*"
64
+ raise ArgumentError, "Only one deep wildcard (**) allowed per selector" if result.any? { |segment| segment.type == :deep_wildcard }
65
+
66
+ result << Segment.new(type: :deep_wildcard, value: nil)
67
+
68
+ position + 1
69
+ else
70
+ result << Segment.new(type: :wildcard, value: nil)
71
+
72
+ position
73
+ end
74
+ end
75
+
76
+ #: (String, Integer, Array[Segment]) -> Integer
77
+ def parse_key_segment(input, position, result)
78
+ key = +""
79
+
80
+ while position < input.length && input[position] != "." && input[position] != "["
81
+ key << input[position].to_s
82
+ position += 1
83
+ end
84
+
85
+ raise ArgumentError, "Empty key in selector at position #{position}" if key.empty?
86
+
87
+ result << Segment.new(type: :key, value: key)
88
+
89
+ position
90
+ end
91
+
92
+ #: (String, Integer, Array[Segment]) -> Integer
93
+ def parse_bracket_segment(input, position, result)
94
+ if position < input.length && input[position] == "]"
95
+ result << Segment.new(type: :full_range, value: nil)
96
+
97
+ position + 1
98
+ elsif position < input.length && input[position] == '"'
99
+ parse_quoted_key_segment(input, position + 1, result)
100
+ else
101
+ parse_index_segment(input, position, result)
102
+ end
103
+ end
104
+
105
+ #: (String, Integer, Array[Segment]) -> Integer
106
+ def parse_quoted_key_segment(input, position, result)
107
+ key = +""
108
+
109
+ while position < input.length && input[position] != '"'
110
+ key << input[position].to_s
111
+ position += 1
112
+ end
113
+
114
+ raise ArgumentError, "Unterminated quoted key in selector" if position >= input.length
115
+
116
+ position += 1
117
+ raise ArgumentError, "Expected ']' after quoted key" if position >= input.length || input[position] != "]"
118
+
119
+ result << Segment.new(type: :key, value: key)
120
+
121
+ position + 1
122
+ end
123
+
124
+ #: (String, Integer, Array[Segment]) -> Integer
125
+ def parse_index_segment(input, position, result)
126
+ digits = +""
127
+
128
+ while position < input.length && input[position] =~ /\d/
129
+ digits << input[position].to_s
130
+ position += 1
131
+ end
132
+
133
+ raise ArgumentError, "Expected number or ']' in bracket" if digits.empty?
134
+ raise ArgumentError, "Expected ']' after index" if position >= input.length || input[position] != "]"
135
+
136
+ result << Segment.new(type: :index, value: digits.to_i)
137
+
138
+ position + 1
139
+ end
140
+
141
+ #: (Array[{ type: Symbol, value: untyped }], Integer) -> bool
142
+ def deep_wildcard_match?(path, deep_index)
143
+ before = segments[0...deep_index] || [] #: Array[Segment]
144
+ after = segments[(deep_index + 1)..] || [] #: Array[Segment]
145
+
146
+ return false if path.length < before.length + after.length
147
+
148
+ before.each_with_index do |segment, index|
149
+ return false unless segment_matches?(segment, path[index])
150
+ end
151
+
152
+ return true if after.empty?
153
+
154
+ after_length = after.length
155
+ path_length = path.length
156
+
157
+ after.each_with_index do |segment, index|
158
+ path_index = path_length - after_length + index
159
+ return false unless segment_matches?(segment, path[path_index])
160
+ end
161
+
162
+ true
163
+ end
164
+
165
+ #: (Array[{ type: Symbol, value: untyped }], Array[Segment]) -> bool
166
+ def exact_match?(path, expected_segments)
167
+ return false unless path.length == expected_segments.length
168
+
169
+ expected_segments.each_with_index do |segment, index|
170
+ return false unless segment_matches?(segment, path[index])
171
+ end
172
+
173
+ true
174
+ end
175
+
176
+ #: (Segment, { type: Symbol, value: untyped }) -> bool
177
+ def segment_matches?(segment, path_entry)
178
+ case segment.type
179
+ when :key
180
+ path_entry[:type] == :key && path_entry[:value].to_s == segment.value
181
+ when :index
182
+ path_entry[:type] == :index && path_entry[:value] == segment.value
183
+ when :wildcard
184
+ path_entry[:type] == :key
185
+ when :full_range
186
+ path_entry[:type] == :index
187
+ else
188
+ false
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module RSpec
5
+ module Hooks
6
+ #: () -> void
7
+ def self.install!
8
+ ::RSpec.configure do |config|
9
+ config.include(Matchers)
10
+
11
+ config.after(:suite) do
12
+ PendingReporter.flush_and_report!
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ module Insta
6
+ module RSpec
7
+ module Matchers
8
+ class MatchSnapshot
9
+ include ::RSpec::Matchers::Composable
10
+
11
+ attr_reader :failure_message #: String?
12
+
13
+ #: (String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> void
14
+ def initialize(name = nil, serializer: nil, redact: nil)
15
+ @name = name
16
+ @serializer = serializer
17
+ @redact = redact
18
+ @failure_message = nil
19
+ end
20
+
21
+ #: (untyped) -> bool
22
+ def matches?(actual)
23
+ @actual = actual
24
+ expression = actual.class.name
25
+
26
+ actual = Redaction::Applicator.apply(actual, @redact) if @redact
27
+ serializer_name = @serializer || Insta.configuration.default_serializer
28
+ serialized = Serializers.serialize(serializer_name, actual)
29
+ content = SnapshotContent.normalize(serialized)
30
+
31
+ example = ::RSpec.current_example
32
+ return record_failure("Could not determine current RSpec example") unless example
33
+
34
+ test_class = example.example_group.description || "Anonymous"
35
+ test_method = example.description || "unknown"
36
+
37
+ snapshot_file = resolve_snapshot_file(example, test_class, test_method)
38
+ path = @name ? snapshot_file.named_path(@name) : snapshot_file.path_for
39
+
40
+ metadata = { "source" => "#{test_class} #{test_method}", "expression" => expression }
41
+ existing = snapshot_file.read(path)
42
+
43
+ return handle_new_snapshot(snapshot_file, path, content, metadata) unless existing
44
+
45
+ if existing.expression && existing.expression != expression
46
+ return handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, example)
47
+ end
48
+
49
+ expected = SnapshotContent.normalize(existing.content)
50
+ return true if content == expected
51
+
52
+ caller_line = example.location
53
+ handle_mismatch(snapshot_file, path, content, metadata, expected, "#{test_class} #{test_method}", caller_line)
54
+ end
55
+
56
+ #: () -> String
57
+ def description
58
+ @name ? "match snapshot \"#{@name}\"" : "match snapshot"
59
+ end
60
+
61
+ private
62
+
63
+ #: (untyped, String, String) -> SnapshotFile
64
+ def resolve_snapshot_file(example, test_class, test_method)
65
+ if @name
66
+ SnapshotFile.new(Insta.configuration.snapshot_path, test_class, test_method)
67
+ else
68
+ example.metadata[:_insta_snapshot_file] ||= SnapshotFile.new(
69
+ Insta.configuration.snapshot_path,
70
+ test_class,
71
+ test_method
72
+ )
73
+ end
74
+ end
75
+
76
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata) -> bool
77
+ def handle_new_snapshot(snapshot_file, path, content, metadata)
78
+ if Insta.configuration.resolved_update_mode == :no
79
+ return record_failure("Snapshot file does not exist: #{path}\nRun with INSTA_UPDATE=force to create it.")
80
+ end
81
+
82
+ if Insta.configuration.new_snapshot == :review
83
+ pending_path = snapshot_file.pending_path(path)
84
+ snapshot_file.write(pending_path, content, metadata)
85
+
86
+ example = ::RSpec.current_example
87
+ caller_line = example&.location
88
+ PendingLocations.add(pending_path.to_s, caller_line) if caller_line
89
+
90
+ return true if ENV["INSTA_FORCE_PASS"]
91
+
92
+ return record_failure("New snapshot pending review: #{path}\nRun `bundle exec insta review` to accept.")
93
+
94
+ end
95
+
96
+ snapshot_file.write(path, content, metadata)
97
+
98
+ true
99
+ end
100
+
101
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, Snapshot, String?, untyped) -> bool
102
+ def handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, example)
103
+ expected = SnapshotContent.normalize(existing.content)
104
+ caller_line = example.location
105
+ result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected, caller_line: caller_line)
106
+
107
+ case result
108
+ when :updated, :force_passed
109
+ true
110
+ else
111
+ record_failure(
112
+ "Snapshot type mismatch: snapshot was #{existing.expression} but got #{expression}\n" \
113
+ "Run `bundle exec insta review` or delete the snapshot file and re-run the test to update it."
114
+ )
115
+ end
116
+ end
117
+
118
+ #: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, String, String, String?) -> bool
119
+ def handle_mismatch(snapshot_file, path, content, metadata, expected, label, caller_line = nil)
120
+ result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected,
121
+ caller_line: caller_line)
122
+
123
+ case result
124
+ when :updated, :force_passed
125
+ true
126
+ else
127
+ @failure_message = SnapshotMismatchHandler.failure_message(expected, content, label, path,
128
+ caller_line: caller_line)
129
+
130
+ false
131
+ end
132
+ end
133
+
134
+ #: (String) -> false
135
+ def record_failure(msg)
136
+ @failure_message = msg
137
+
138
+ false
139
+ end
140
+ end
141
+
142
+ class MatchInlineSnapshot
143
+ include ::RSpec::Matchers::Composable
144
+
145
+ attr_reader :failure_message #: String?
146
+
147
+ #: (String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> void
148
+ def initialize(expected = nil, serializer: nil, redact: nil)
149
+ @expected = expected
150
+ @serializer = serializer
151
+ @redact = redact
152
+ @failure_message = nil
153
+ end
154
+
155
+ #: (untyped) -> bool
156
+ def matches?(actual)
157
+ @actual = actual
158
+
159
+ actual = Redaction::Applicator.apply(actual, @redact) if @redact
160
+ serializer_name = @serializer || Insta.configuration.default_serializer
161
+ serialized = Serializers.serialize(serializer_name, actual)
162
+ content = SnapshotContent.normalize(serialized)
163
+
164
+ location = (caller_locations || []).find { |location| location.path && !location.path.include?("lib/insta") && !location.path.include?("lib/rspec") }
165
+ return record_failure("Could not determine caller location") unless location
166
+
167
+ file = location.path
168
+ line = location.lineno
169
+
170
+ return record_failure("Could not determine source file") unless file
171
+ return handle_new_inline_snapshot(file, line, content) if @expected.nil?
172
+
173
+ expected_normalized = SnapshotContent.normalize(@expected.to_s)
174
+ return true if content == expected_normalized
175
+
176
+ handle_inline_mismatch(file, line, content, expected_normalized)
177
+ end
178
+
179
+ #: () -> String
180
+ def description
181
+ "match inline snapshot"
182
+ end
183
+
184
+ private
185
+
186
+ #: (String, Integer, String) -> bool
187
+ def handle_new_inline_snapshot(file, line, content)
188
+ if Insta.configuration.new_snapshot == :review
189
+ Inline::PendingStore.add(file: file, line: line, content: content, old_content: "", type: :insert)
190
+
191
+ return true if ENV["INSTA_FORCE_PASS"]
192
+
193
+ return record_failure("New inline snapshot pending review at #{file}:#{line}\nRun `bundle exec insta review` to accept.")
194
+ end
195
+
196
+ Inline::PendingRegistry.add(file: file, line: line, content: content, type: :insert)
197
+
198
+ true
199
+ end
200
+
201
+ #: (String, Integer, String, String) -> bool
202
+ def handle_inline_mismatch(file, line, content, expected_normalized)
203
+ mode = Insta.configuration.resolved_update_mode
204
+
205
+ case mode
206
+ when :force
207
+ Inline::PendingRegistry.add(file: file, line: line, content: content, type: :replace)
208
+
209
+ true
210
+ when :no
211
+ @failure_message = Diff.failure_message(expected_normalized, content, "inline snapshot at #{file}:#{line}")
212
+
213
+ false
214
+ else
215
+ Inline::PendingStore.add(file: file, line: line, content: content, old_content: expected_normalized,
216
+ type: :replace)
217
+
218
+ if ENV["INSTA_FORCE_PASS"]
219
+ true
220
+ else
221
+ @failure_message = Diff.failure_message(expected_normalized, content,
222
+ "inline snapshot at #{file}:#{line}")
223
+
224
+ false
225
+ end
226
+ end
227
+ end
228
+
229
+ #: (String) -> false
230
+ def record_failure(msg)
231
+ @failure_message = msg
232
+
233
+ false
234
+ end
235
+ end
236
+
237
+ #: (?String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> MatchSnapshot
238
+ def match_snapshot(name = nil, serializer: nil, redact: nil)
239
+ MatchSnapshot.new(name, serializer: serializer, redact: redact)
240
+ end
241
+
242
+ #: (?String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> MatchInlineSnapshot
243
+ def match_inline_snapshot(expected = nil, serializer: nil, redact: nil)
244
+ MatchInlineSnapshot.new(expected, serializer: serializer, redact: redact)
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "insta"
4
+ require "insta/rspec/matchers"
5
+ require "insta/rspec/hooks"
6
+
7
+ Insta::RSpec::Hooks.install!
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Serializers
5
+ class Base
6
+ #: (untyped) -> String
7
+ def self.serialize(value)
8
+ raise NotImplementedError, "#{name} must implement .serialize"
9
+ end
10
+ end
11
+
12
+ REGISTRY = {} #: Hash[Symbol, singleton(Base)] # rubocop:disable Style/MutableConstant
13
+
14
+ #: (Symbol, singleton(Base)) -> void
15
+ def self.register(name, serializer)
16
+ REGISTRY[name] = serializer
17
+ end
18
+
19
+ #: (Symbol) -> singleton(Base)
20
+ def self.fetch(name)
21
+ REGISTRY.fetch(name) do
22
+ raise ArgumentError, "Unknown serializer: #{name.inspect}. Available: #{REGISTRY.keys.join(", ")}"
23
+ end
24
+ end
25
+
26
+ #: (Symbol, untyped) -> String
27
+ def self.serialize(name, value)
28
+ fetch(name).serialize(value)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Serializers
5
+ class InspectSerializer < Base
6
+ #: (untyped) -> String
7
+ def self.serialize(value)
8
+ value.inspect
9
+ end
10
+ end
11
+
12
+ register(:inspect, InspectSerializer)
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insta
6
+ module Serializers
7
+ class JSONSerializer < Base
8
+ #: (untyped) -> String
9
+ def self.serialize(value)
10
+ JSON.pretty_generate(value)
11
+ end
12
+ end
13
+
14
+ register(:json, JSONSerializer)
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Serializers
5
+ class StringSerializer < Base
6
+ #: (untyped) -> String
7
+ def self.serialize(value)
8
+ value.to_s
9
+ end
10
+ end
11
+
12
+ register(:to_s, StringSerializer)
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insta
6
+ module Serializers
7
+ class YAMLSerializer < Base
8
+ #: (untyped) -> String
9
+ def self.serialize(value)
10
+ YAML.dump(value)
11
+ end
12
+ end
13
+
14
+ register(:yaml, YAMLSerializer)
15
+ end
16
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Insta
6
+ class Snapshot
7
+ # @rbs!
8
+ # type snapshot_metadata = Hash[String, untyped]
9
+
10
+ SEPARATOR = "---" #: String
11
+
12
+ attr_reader :content #: String
13
+ attr_reader :metadata #: snapshot_metadata
14
+
15
+ #: (String, ?snapshot_metadata) -> void
16
+ def initialize(content, metadata = {})
17
+ @content = content
18
+ @metadata = metadata
19
+ end
20
+
21
+ #: (String) -> Snapshot
22
+ def self.parse(raw)
23
+ raw = raw.to_s
24
+
25
+ if raw.start_with?("#{SEPARATOR}\n")
26
+ parts = raw.split("#{SEPARATOR}\n", 3)
27
+
28
+ if parts.length >= 3
29
+ metadata = begin
30
+ YAML.safe_load(parts[1], permitted_classes: [Symbol]) || {}
31
+ rescue Psych::SyntaxError
32
+ {} #: snapshot_metadata
33
+ end
34
+
35
+ return new(parts[2].chomp.concat("\n"), metadata)
36
+ end
37
+ end
38
+
39
+ new(raw, {})
40
+ end
41
+
42
+ #: () -> String
43
+ def serialize
44
+ return @content if @metadata.empty?
45
+
46
+ YAML.dump(@metadata) + "#{SEPARATOR}\n" + @content
47
+ end
48
+
49
+ #: () -> String?
50
+ def source
51
+ @metadata["source"]
52
+ end
53
+
54
+ #: () -> String?
55
+ def input
56
+ @metadata["input"]
57
+ end
58
+
59
+ #: () -> String?
60
+ def description
61
+ @metadata["description"]
62
+ end
63
+
64
+ #: () -> String?
65
+ def expression
66
+ @metadata["expression"]
67
+ end
68
+
69
+ #: () -> Hash[String, untyped]?
70
+ def info
71
+ @metadata["info"]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module SnapshotContent
5
+ #: (String) -> String
6
+ def self.normalize(content)
7
+ content
8
+ .gsub("\r\n", "\n")
9
+ .gsub("\r", "\n")
10
+ .sub(/\n+\z/, "")
11
+ .concat("\n")
12
+ end
13
+
14
+ #: (String, Integer) -> String
15
+ def self.indent(content, level)
16
+ prefix = " " * level
17
+ content.each_line.map { |line|
18
+ if line.strip.empty?
19
+ "\n"
20
+ else
21
+ "#{prefix}#{line}"
22
+ end
23
+ }.join
24
+ end
25
+
26
+ #: (String) -> String
27
+ def self.strip_indent(content)
28
+ lines = content.split("\n", -1)
29
+ non_empty = lines.reject { |l| l.strip.empty? }
30
+ return content if non_empty.empty?
31
+
32
+ min_indent = non_empty.map { |l| l.match(/^(\s*)/).to_a[1].to_s.length }.min || 0
33
+ return content if min_indent.zero?
34
+
35
+ lines.map { |l|
36
+ if l.strip.empty?
37
+ ""
38
+ else
39
+ l[min_indent..] || ""
40
+ end
41
+ }.join("\n")
42
+ end
43
+ end
44
+ end