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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Insta
7
+ # Bridges configuration from the test process to the standalone CLI.
8
+ #
9
+ # `Insta.configure` runs inside the test process (usually from the test
10
+ # helper), which the CLI never loads. Instead of asking users to maintain
11
+ # a separate config file, the test run serializes the CLI-relevant
12
+ # settings next to the snapshots at flush time, and the CLI applies them
13
+ # on startup. Machine-written state, like the pending manifests.
14
+ module ConfigManifest
15
+ FILENAME = ".insta-config.json"
16
+
17
+ SYMBOL_KEYS = [:diff_display, :diff_color].freeze #: Array[Symbol]
18
+ RAW_KEYS = [:diff_width, :snapshot_extension, :heredoc_identifier].freeze #: Array[Symbol]
19
+
20
+ #: () -> void
21
+ def self.write!
22
+ config = Insta.configuration
23
+
24
+ manifest = {} #: Hash[Symbol, untyped]
25
+ SYMBOL_KEYS.each { |key| manifest[key] = config.public_send(key)&.to_s }
26
+ RAW_KEYS.each { |key| manifest[key] = config.public_send(key) }
27
+
28
+ FileUtils.mkdir_p(config.snapshot_path)
29
+ File.write(path, JSON.pretty_generate(manifest))
30
+ end
31
+
32
+ #: () -> void
33
+ def self.apply!
34
+ return unless File.exist?(path)
35
+
36
+ manifest = JSON.parse(File.read(path), symbolize_names: true)
37
+ config = Insta.configuration
38
+
39
+ SYMBOL_KEYS.each do |key|
40
+ value = manifest[key]
41
+ config.public_send(:"#{key}=", value.to_sym) if value
42
+ end
43
+
44
+ RAW_KEYS.each do |key|
45
+ config.public_send(:"#{key}=", manifest[key]) unless manifest[key].nil?
46
+ end
47
+ rescue JSON::ParserError
48
+ nil
49
+ end
50
+
51
+ #: () -> String
52
+ def self.path
53
+ File.join(Insta.configuration.snapshot_path, FILENAME)
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,89 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Insta
2
4
  class Configuration
3
- attr_accessor :proxy_list
5
+ # @rbs!
6
+ # type snapshot_filename_resolver =
7
+ # ^(test_name: String, counter: Integer, options: Hash[Symbol, untyped]) -> String
8
+
9
+ attr_accessor :snapshot_path #: String
10
+ attr_accessor :diff_display #: Symbol
11
+ attr_accessor :diff_width #: Integer?
12
+ attr_accessor :diff_color #: Symbol
13
+ attr_accessor :update_mode #: Symbol
14
+ attr_accessor :default_serializer #: Symbol
15
+ attr_accessor :heredoc_identifier #: String
16
+ attr_accessor :ci_mode #: Symbol
17
+ attr_accessor :snapshot_extension #: String
18
+ attr_accessor :snapshot_sanitizer #: (^(String) -> String)?
19
+ attr_accessor :snapshot_filename #: snapshot_filename_resolver?
20
+ attr_accessor :snapshot_directory #: (^(test_class: String) -> String)?
21
+ attr_accessor :new_snapshot #: Symbol
22
+
23
+ #: () -> void
24
+ def initialize
25
+ @snapshot_path = "test/snapshots"
26
+ @diff_display = :side_by_side
27
+ @diff_width = nil
28
+ @diff_color = :auto
29
+ @update_mode = :auto
30
+ @default_serializer = :to_s
31
+ @heredoc_identifier = "SNAP"
32
+ @ci_mode = :auto
33
+ @snapshot_extension = ".snap"
34
+ @snapshot_sanitizer = nil
35
+ @snapshot_filename = nil
36
+ @snapshot_directory = nil
37
+ @new_snapshot = :review
38
+ end
39
+
40
+ #: () -> Symbol
41
+ def resolved_update_mode
42
+ if ENV["INSTA_UPDATE"]
43
+ case ENV.fetch("INSTA_UPDATE", nil)
44
+ when "always", "force" then :force
45
+ when "new" then :new
46
+ when "no" then :no
47
+ else :auto
48
+ end
49
+ elsif ENV["INSTA_FORCE_PASS"]
50
+ :pending
51
+ elsif resolved_ci_mode?
52
+ :no
53
+ else
54
+ @update_mode
55
+ end
56
+ end
57
+
58
+ #: () -> bool
59
+ def resolved_ci_mode?
60
+ case @ci_mode
61
+ when :auto then CI.ci?
62
+ when true then true
63
+ else false
64
+ end
65
+ end
66
+
67
+ #: () -> String
68
+ def resolved_diff_color
69
+ return "never" if ENV.key?("NO_COLOR")
70
+
71
+ case @diff_color
72
+ when :always then "always"
73
+ when :never then "never"
74
+ when :auto
75
+ $stdout.tty? ? "always" : "never"
76
+ else
77
+ "auto"
78
+ end
79
+ end
80
+
81
+ #: () -> String
82
+ def resolved_diff_display
83
+ case @diff_display
84
+ when :inline then "inline"
85
+ else "side-by-side-show-both"
86
+ end
87
+ end
4
88
  end
5
- end
89
+ end
data/lib/insta/diff.rb ADDED
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "difftastic"
4
+
5
+ module Insta
6
+ class Diff
7
+ #: () -> Difftastic::Differ
8
+ def self.differ
9
+ config = Insta.configuration
10
+
11
+ Difftastic::Differ.new(
12
+ color: config.resolved_diff_color.to_sym,
13
+ display: config.resolved_diff_display,
14
+ width: config.diff_width,
15
+ left_label: "Expected",
16
+ right_label: "Actual"
17
+ )
18
+ end
19
+
20
+ #: (String, String) -> String
21
+ def self.diff(expected, actual)
22
+ differ.diff_strings(expected, actual)
23
+ end
24
+
25
+ #: (String, String, ?file_extension: String?) -> String
26
+ def self.diff_with_language(expected, actual, file_extension: nil)
27
+ if file_extension
28
+ differ.diff_strings(expected, actual, file_extension: file_extension)
29
+ else
30
+ differ.diff_strings(expected, actual)
31
+ end
32
+ end
33
+
34
+ #: (String, String, String, ?String?, ?String?, ?file_extension: String?) -> String
35
+ def self.failure_message(expected, actual, test_name, snapshot_path = nil, line = nil, file_extension: nil)
36
+ dim = "\e[2m"
37
+ cyan = "\e[36m"
38
+ reset = "\e[0m"
39
+ divider = "#{dim}#{"─" * terminal_width}#{reset}"
40
+
41
+ message = +""
42
+ message << "#{reset}\n"
43
+ message << "Snapshot for #{cyan}\"#{test_name}\"#{reset} didn't match.\n"
44
+ message << "\n"
45
+ message << " #{dim}Snapshot:#{reset} #{snapshot_path}\n" if snapshot_path
46
+ message << " #{dim}Test:#{reset} #{caller_file_location(line)}\n" if line
47
+ message << "\n"
48
+ message << "#{divider}\n"
49
+ message << diff_with_language(expected, actual, file_extension: file_extension)
50
+ message << "\n#{divider}\n"
51
+ message << "\n"
52
+
53
+ if line
54
+ message << " #{dim}Update snapshot and re-run this test:#{reset}\n"
55
+ message << " #{cyan}INSTA_UPDATE=force #{run_test_command(line)}#{reset}\n"
56
+ message << "\n"
57
+ end
58
+
59
+ message << " #{dim}Interactively review all pending snapshots:#{reset}\n"
60
+ message << " #{cyan}bundle exec insta review#{reset}\n"
61
+ message << "\n#{divider}\n"
62
+ message << reset
63
+
64
+ message
65
+ end
66
+
67
+ #: (String) -> String
68
+ def self.caller_file_location(caller_line)
69
+ file, lineno = caller_line.split(":", 3).first(2)
70
+
71
+ file && lineno ? "#{file}:#{lineno}" : caller_line
72
+ end
73
+
74
+ #: (String) -> String
75
+ def self.run_test_command(caller_line)
76
+ file, lineno = caller_line.split(":", 3).first(2)
77
+
78
+ if rspec?
79
+ "bundle exec rspec #{file}:#{lineno}"
80
+ elsif rails?
81
+ "bin/rails test #{file}:#{lineno}"
82
+ elsif minitest_cli?
83
+ "bundle exec minitest #{file}:#{lineno}"
84
+ elsif mtest?
85
+ "bundle exec mtest #{file}:#{lineno}"
86
+ else
87
+ "bundle exec ruby -Itest #{file}"
88
+ end
89
+ end
90
+
91
+ #: () -> bool
92
+ def self.rspec?
93
+ return @rspec if defined?(@rspec)
94
+
95
+ @rspec = !!defined?(::RSpec)
96
+ end
97
+
98
+ #: () -> bool
99
+ def self.rails?
100
+ return @rails if defined?(@rails)
101
+
102
+ @rails = !!(defined?(::Rails) && defined?(::ActiveSupport::TestCase))
103
+ end
104
+
105
+ #: () -> bool
106
+ def self.minitest_cli?
107
+ return @minitest_cli if defined?(@minitest_cli)
108
+
109
+ @minitest_cli = !!(defined?(::Minitest::VERSION) && Gem::Version.new(::Minitest::VERSION) >= Gem::Version.new("6.0"))
110
+ end
111
+
112
+ #: () -> bool
113
+ def self.mtest?
114
+ return @mtest if defined?(@mtest)
115
+
116
+ spec = Gem.loaded_specs["maxitest"]
117
+ @mtest = !minitest_cli? && spec.is_a?(Gem::Specification) &&
118
+ spec.version < Gem::Version.new("7.0")
119
+ end
120
+
121
+ #: () -> Integer
122
+ def self.terminal_width
123
+ if $stdout.tty?
124
+ begin
125
+ `tput cols`.strip.to_i
126
+ rescue StandardError
127
+ 80
128
+ end
129
+ else
130
+ 80
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Insta
6
+ module Inline
7
+ class CallFinder < Prism::Visitor
8
+ METHODS = [:assert_inline_snapshot, :match_inline_snapshot].freeze #: Array[Symbol]
9
+
10
+ attr_reader :found_call #: Prism::CallNode?
11
+
12
+ #: (Integer) -> void
13
+ def initialize(target_line)
14
+ super()
15
+
16
+ @target_line = target_line
17
+ @found_call = nil
18
+ end
19
+
20
+ #: (Prism::CallNode) -> void
21
+ def visit_call_node(node)
22
+ @found_call = node if METHODS.include?(node.name) && covers_line?(node)
23
+
24
+ super
25
+ end
26
+
27
+ private
28
+
29
+ #: (Prism::Node) -> bool
30
+ def covers_line?(node)
31
+ location = node.location
32
+
33
+ @target_line.between?(location.start_line, location.end_line)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Inline
5
+ # @rbs!
6
+ # type pending_entry = { line: Integer, content: String, type: Symbol }
7
+
8
+ class Edit
9
+ attr_reader :start_offset #: Integer
10
+ attr_reader :end_offset #: Integer
11
+ attr_reader :replacement #: String
12
+
13
+ #: (Integer, Integer, String) -> void
14
+ def initialize(start_offset, end_offset, replacement)
15
+ @start_offset = start_offset
16
+ @end_offset = end_offset
17
+ @replacement = replacement
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "tempfile"
5
+
6
+ module Insta
7
+ module Inline
8
+ class FilePatcher
9
+ #: (String, Array[Inline::pending_entry]) -> String
10
+ def self.patch(file_path, pending_entries)
11
+ source = File.read(file_path)
12
+ result = Prism.parse(source)
13
+ edits = [] #: Array[Edit]
14
+
15
+ pending_entries.each do |entry|
16
+ line = entry[:line]
17
+ content = entry[:content]
18
+ type = entry[:type]
19
+
20
+ finder = CallFinder.new(line)
21
+ finder.visit(result.value)
22
+
23
+ call = finder.found_call
24
+ next unless call
25
+
26
+ edit = build_edit(source, call, content, type)
27
+ edits << edit if edit
28
+ end
29
+
30
+ apply_edits(source, edits)
31
+ end
32
+
33
+ #: (String, Array[Edit]) -> String
34
+ def self.apply_edits(source, edits)
35
+ sorted = edits.sort_by { |e| -e.start_offset }
36
+ result = source.dup
37
+
38
+ sorted.each do |edit|
39
+ before = result.byteslice(0, edit.start_offset) || ""
40
+ after = result.byteslice(edit.end_offset..) || ""
41
+ result = before + edit.replacement + after
42
+ end
43
+
44
+ result
45
+ end
46
+
47
+ #: (String, Prism::CallNode, String, Symbol) -> Edit?
48
+ def self.build_edit(source, call, content, type)
49
+ arguments = call.arguments
50
+
51
+ return build_insert_edit(source, call, content) if type == :insert || arguments.nil? || arguments.arguments.empty?
52
+
53
+ snapshot_arg = find_snapshot_argument(arguments)
54
+ return build_insert_edit(source, call, content) unless snapshot_arg
55
+
56
+ if heredoc_node?(snapshot_arg)
57
+ if single_line?(content)
58
+ build_heredoc_to_string_edit(source, _ = snapshot_arg, content)
59
+ else
60
+ build_heredoc_edit(source, _ = snapshot_arg, content)
61
+ end
62
+ elsif snapshot_arg.is_a?(Prism::StringNode)
63
+ if single_line?(content)
64
+ build_string_edit(snapshot_arg, content)
65
+ else
66
+ build_string_to_heredoc_edit(source, call, snapshot_arg, content)
67
+ end
68
+ end
69
+ end
70
+
71
+ #: (Prism::Node) -> bool
72
+ def self.heredoc_node?(node)
73
+ case node
74
+ when Prism::InterpolatedStringNode
75
+ true
76
+ when Prism::StringNode
77
+ opening = node.opening_loc
78
+
79
+ opening ? opening.slice.start_with?("<<") : false
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ #: (Prism::ArgumentsNode) -> Prism::Node?
86
+ def self.find_snapshot_argument(arguments)
87
+ args = arguments.arguments
88
+
89
+ positional_strings = args.select { |arg|
90
+ case arg
91
+ when Prism::StringNode, Prism::InterpolatedStringNode
92
+ true
93
+ else
94
+ false
95
+ end
96
+ }
97
+
98
+ positional_strings.last
99
+ end
100
+
101
+ #: (Prism::StringNode, String) -> Edit?
102
+ def self.build_string_edit(node, content)
103
+ content_location = node.content_loc
104
+ return nil unless content_location
105
+
106
+ trimmed = content.chomp
107
+
108
+ Edit.new(content_location.start_offset, content_location.end_offset, trimmed)
109
+ end
110
+
111
+ #: (String, Prism::StringNode | Prism::InterpolatedStringNode, String) -> Edit?
112
+ def self.build_heredoc_edit(source, node, content)
113
+ opening = node.opening_loc
114
+ closing = node.closing_loc
115
+ return nil unless opening && closing
116
+
117
+ content_start = source.b.index("\n".b, opening.end_offset)
118
+ return nil unless content_start
119
+
120
+ indent = closing.slice[/\A(\s*)/, 1].to_s.length
121
+ indented_content = SnapshotContent.indent(content, indent + 2)
122
+
123
+ Edit.new(content_start + 1, closing.start_offset, indented_content)
124
+ end
125
+
126
+ #: (String, Prism::StringNode | Prism::InterpolatedStringNode, String) -> Edit?
127
+ def self.build_heredoc_to_string_edit(source, node, content)
128
+ opening = node.opening_loc
129
+ closing = node.closing_loc
130
+ return nil unless opening && closing
131
+
132
+ newline_position = source.b.index("\n".b, opening.end_offset)
133
+ return nil unless newline_position
134
+
135
+ suffix = source.byteslice(opening.end_offset, newline_position - opening.end_offset) || ""
136
+ replacement = "#{content.chomp.inspect}#{suffix}\n"
137
+
138
+ Edit.new(opening.start_offset, closing.end_offset, replacement)
139
+ end
140
+
141
+ #: (String, Prism::CallNode, Prism::StringNode, String) -> Edit?
142
+ def self.build_string_to_heredoc_edit(source, call, node, content)
143
+ opening = node.opening_loc
144
+ closing_quote = node.closing_loc
145
+ return nil unless opening && closing_quote
146
+
147
+ heredoc_id = Insta.configuration.heredoc_identifier
148
+ indent = detect_indent(source, call.location.start_offset)
149
+ indented_content = SnapshotContent.indent(content, indent + 2)
150
+ newline_position = source.b.index("\n".b, closing_quote.end_offset)
151
+ return nil unless newline_position
152
+
153
+ suffix = source.byteslice(closing_quote.end_offset, newline_position - closing_quote.end_offset) || ""
154
+ replacement = "<<~#{heredoc_id}#{suffix}\n#{indented_content}#{" " * indent}#{heredoc_id}\n"
155
+
156
+ Edit.new(opening.start_offset, newline_position + 1, replacement)
157
+ end
158
+
159
+ #: (String, Prism::CallNode, String) -> Edit?
160
+ def self.build_insert_edit(source, call, content)
161
+ closing = call.closing_loc
162
+
163
+ unless closing
164
+ location = call.location
165
+ return build_bare_call_insert_edit(source, call, location, content)
166
+ end
167
+
168
+ if single_line?(content)
169
+ build_insert_string_edit(call, closing, content)
170
+ else
171
+ build_insert_heredoc_edit(source, call, closing, content)
172
+ end
173
+ end
174
+
175
+ #: (String, Prism::CallNode, Prism::Location, String) -> Edit
176
+ def self.build_bare_call_insert_edit(source, call, location, content)
177
+ if single_line?(content)
178
+ replacement = "(#{content.chomp.inspect})"
179
+ else
180
+ heredoc_id = Insta.configuration.heredoc_identifier
181
+ indent = detect_indent(source, call.location.start_offset)
182
+ indented_content = SnapshotContent.indent(content, indent + 2)
183
+ replacement = "(<<~#{heredoc_id})\n#{indented_content}#{" " * indent}#{heredoc_id}"
184
+ end
185
+
186
+ Edit.new(location.end_offset, location.end_offset, replacement)
187
+ end
188
+
189
+ #: (String) -> bool
190
+ def self.single_line?(content)
191
+ content.chomp.count("\n").zero?
192
+ end
193
+
194
+ #: (Prism::CallNode, Prism::Location, String) -> Edit
195
+ def self.build_insert_string_edit(call, closing, content)
196
+ trimmed = content.chomp.inspect
197
+ arguments = call.arguments
198
+ has_args = arguments && !arguments.arguments.empty?
199
+
200
+ replacement = has_args ? ", #{trimmed})" : "(#{trimmed})"
201
+
202
+ if has_args
203
+ Edit.new(closing.start_offset, closing.end_offset, replacement)
204
+ else
205
+ opening = call.opening_loc
206
+ start = opening ? opening.start_offset : closing.start_offset
207
+
208
+ Edit.new(start, closing.end_offset, replacement)
209
+ end
210
+ end
211
+
212
+ #: (String, Prism::CallNode, Prism::Location, String) -> Edit
213
+ def self.build_insert_heredoc_edit(source, call, closing, content)
214
+ heredoc_id = Insta.configuration.heredoc_identifier
215
+ indent = detect_indent(source, call.location.start_offset)
216
+ indented_content = SnapshotContent.indent(content, indent + 2)
217
+
218
+ arguments = call.arguments
219
+ has_args = arguments && !arguments.arguments.empty?
220
+
221
+ replacement = if has_args
222
+ ", <<~#{heredoc_id})\n#{indented_content}#{" " * indent}#{heredoc_id}"
223
+ else
224
+ "(<<~#{heredoc_id})\n#{indented_content}#{" " * indent}#{heredoc_id}"
225
+ end
226
+
227
+ if has_args
228
+ Edit.new(closing.start_offset, closing.end_offset, replacement)
229
+ else
230
+ opening = call.opening_loc
231
+ start = opening ? opening.start_offset : closing.start_offset
232
+
233
+ Edit.new(start, closing.end_offset, replacement)
234
+ end
235
+ end
236
+
237
+ #: (String, Integer) -> Integer
238
+ def self.detect_indent(source, offset)
239
+ line_start = source.b.rindex("\n".b, offset)
240
+ line_start = line_start ? line_start + 1 : 0
241
+ line_content = source.byteslice(line_start, offset - line_start) || ""
242
+
243
+ line_content.match(/^(\s*)/).to_a[1].to_s.length
244
+ end
245
+
246
+ #: (String, String) -> void
247
+ def self.atomic_write(file_path, content)
248
+ directory = File.dirname(file_path)
249
+
250
+ temp = Tempfile.new("insta", directory)
251
+ temp.write(content)
252
+ temp.close
253
+
254
+ File.rename(temp.path.to_s, file_path)
255
+ rescue StandardError
256
+ temp&.unlink
257
+ raise
258
+ end
259
+
260
+ private_class_method :build_edit, :heredoc_node?, :find_snapshot_argument,
261
+ :build_string_edit, :build_heredoc_edit,
262
+ :build_heredoc_to_string_edit, :build_string_to_heredoc_edit,
263
+ :build_insert_edit, :build_bare_call_insert_edit,
264
+ :single_line?,
265
+ :build_insert_string_edit, :build_insert_heredoc_edit,
266
+ :detect_indent
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insta
4
+ module Inline
5
+ module PendingRegistry
6
+ @pending = {} #: Hash[String, Array[Inline::pending_entry]]
7
+ @mutex = Mutex.new
8
+
9
+ #: (file: String, line: Integer, content: String, type: Symbol) -> void
10
+ def self.add(file:, line:, content:, type:)
11
+ @mutex.synchronize do
12
+ @pending[file] = @pending[file] || [] #: Array[Inline::pending_entry]
13
+ @pending[file] << { line: line, content: content, type: type }
14
+ end
15
+ end
16
+
17
+ #: () -> void
18
+ def self.flush!
19
+ entries = @mutex.synchronize do
20
+ result = @pending.dup
21
+ @pending.clear
22
+
23
+ result
24
+ end
25
+
26
+ return if entries.empty?
27
+
28
+ entries.each do |file, pending_entries|
29
+ unless File.exist?(file)
30
+ warn "insta: skipping inline snapshot update for missing file: #{file}"
31
+
32
+ next
33
+ end
34
+
35
+ patched = FilePatcher.patch(file, pending_entries)
36
+
37
+ FilePatcher.atomic_write(file, patched)
38
+ end
39
+ end
40
+
41
+ #: () -> bool
42
+ def self.any?
43
+ @mutex.synchronize { !@pending.empty? }
44
+ end
45
+
46
+ #: () -> Integer
47
+ def self.size
48
+ @mutex.synchronize { @pending.values.sum(&:length) }
49
+ end
50
+
51
+ #: () -> void
52
+ def self.clear!
53
+ @mutex.synchronize { @pending.clear }
54
+ end
55
+ end
56
+ end
57
+ end