steep 0.40.0 → 0.41.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/bin/output_rebaseline.rb +15 -30
  4. data/bin/output_test.rb +17 -57
  5. data/lib/steep.rb +4 -0
  6. data/lib/steep/cli.rb +9 -3
  7. data/lib/steep/drivers/check.rb +106 -14
  8. data/lib/steep/drivers/diagnostic_printer.rb +11 -11
  9. data/lib/steep/expectations.rb +159 -0
  10. data/lib/steep/project.rb +6 -0
  11. data/lib/steep/version.rb +1 -1
  12. data/smoke/alias/test_expectations.yml +96 -0
  13. data/smoke/and/test_expectations.yml +31 -0
  14. data/smoke/array/test_expectations.yml +103 -0
  15. data/smoke/block/test_expectations.yml +125 -0
  16. data/smoke/case/test_expectations.yml +47 -0
  17. data/smoke/class/test_expectations.yml +120 -0
  18. data/smoke/const/test_expectations.yml +139 -0
  19. data/smoke/diagnostics-rbs-duplicated/test_expectations.yml +13 -0
  20. data/smoke/diagnostics-rbs/test_expectations.yml +229 -0
  21. data/smoke/diagnostics/test_expectations.yml +477 -0
  22. data/smoke/dstr/test_expectations.yml +13 -0
  23. data/smoke/ensure/test_expectations.yml +62 -0
  24. data/smoke/enumerator/test_expectations.yml +135 -0
  25. data/smoke/extension/test_expectations.yml +61 -0
  26. data/smoke/hash/test_expectations.yml +81 -0
  27. data/smoke/hello/test_expectations.yml +25 -0
  28. data/smoke/if/test_expectations.yml +34 -0
  29. data/smoke/implements/test_expectations.yml +23 -0
  30. data/smoke/initialize/test_expectations.yml +1 -0
  31. data/smoke/integer/test_expectations.yml +101 -0
  32. data/smoke/interface/test_expectations.yml +23 -0
  33. data/smoke/kwbegin/test_expectations.yml +17 -0
  34. data/smoke/lambda/test_expectations.yml +39 -0
  35. data/smoke/literal/test_expectations.yml +106 -0
  36. data/smoke/map/test_expectations.yml +1 -0
  37. data/smoke/method/test_expectations.yml +90 -0
  38. data/smoke/module/test_expectations.yml +75 -0
  39. data/smoke/regexp/test_expectations.yml +615 -0
  40. data/smoke/regression/test_expectations.yml +43 -0
  41. data/smoke/rescue/test_expectations.yml +79 -0
  42. data/smoke/self/test_expectations.yml +23 -0
  43. data/smoke/skip/test_expectations.yml +23 -0
  44. data/smoke/stdout/test_expectations.yml +1 -0
  45. data/smoke/super/test_expectations.yml +79 -0
  46. data/smoke/toplevel/test_expectations.yml +15 -0
  47. data/smoke/tsort/test_expectations.yml +43 -0
  48. data/smoke/type_case/test_expectations.yml +48 -0
  49. data/smoke/yield/test_expectations.yml +68 -0
  50. metadata +41 -44
  51. data/smoke/alias/test.yaml +0 -73
  52. data/smoke/and/test.yaml +0 -24
  53. data/smoke/array/test.yaml +0 -80
  54. data/smoke/block/test.yaml +0 -96
  55. data/smoke/broken/Steepfile +0 -5
  56. data/smoke/broken/broken.rb +0 -0
  57. data/smoke/broken/broken.rbs +0 -0
  58. data/smoke/broken/test.yaml +0 -6
  59. data/smoke/case/test.yaml +0 -36
  60. data/smoke/class/test.yaml +0 -89
  61. data/smoke/const/test.yaml +0 -96
  62. data/smoke/diagnostics-rbs-duplicated/test.yaml +0 -10
  63. data/smoke/diagnostics-rbs/test.yaml +0 -142
  64. data/smoke/diagnostics/test.yaml +0 -333
  65. data/smoke/dstr/test.yaml +0 -10
  66. data/smoke/ensure/test.yaml +0 -47
  67. data/smoke/enumerator/test.yaml +0 -100
  68. data/smoke/extension/test.yaml +0 -50
  69. data/smoke/hash/test.yaml +0 -62
  70. data/smoke/hello/test.yaml +0 -18
  71. data/smoke/if/test.yaml +0 -27
  72. data/smoke/implements/test.yaml +0 -16
  73. data/smoke/initialize/test.yaml +0 -4
  74. data/smoke/integer/test.yaml +0 -66
  75. data/smoke/interface/test.yaml +0 -16
  76. data/smoke/kwbegin/test.yaml +0 -14
  77. data/smoke/lambda/test.yaml +0 -28
  78. data/smoke/literal/test.yaml +0 -79
  79. data/smoke/map/test.yaml +0 -4
  80. data/smoke/method/test.yaml +0 -71
  81. data/smoke/module/test.yaml +0 -51
  82. data/smoke/regexp/test.yaml +0 -372
  83. data/smoke/regression/test.yaml +0 -38
  84. data/smoke/rescue/test.yaml +0 -60
  85. data/smoke/self/test.yaml +0 -16
  86. data/smoke/skip/test.yaml +0 -16
  87. data/smoke/stdout/test.yaml +0 -4
  88. data/smoke/super/test.yaml +0 -52
  89. data/smoke/toplevel/test.yaml +0 -12
  90. data/smoke/tsort/test.yaml +0 -32
  91. data/smoke/type_case/test.yaml +0 -33
  92. data/smoke/yield/test.yaml +0 -49
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 725a6432404ca94fa643ba89ed2028d7b3923ece4031512ebea88ebbdc4fd01e
4
- data.tar.gz: 957a95acbda38474f1a796f829f8cf098b69484a8ddf42bd39e2c2be935a961e
3
+ metadata.gz: 7fe01659cf0cfc1c7063fede02ec238b5772bb6c6fdf44cf501ca555c719c47c
4
+ data.tar.gz: d74ebe84a911514eef46cd92a14dfedc0b90e491aa9bf284f82325a9c4a9c2b4
5
5
  SHA512:
6
- metadata.gz: 363a04610a81d4652541c9e5856705f7317a8b4e92a2074dc460b5f34e19f8eb5bfd080826c3a61a41414668d22d0352e4b300d1ce09c659243ef8c06492fc22
7
- data.tar.gz: 33b8d47d5290acea68f1f07ee74a6129ad0a2568fff2ddc783b7910f2ed55ae2d6d6b6f0b1b90b8dea773bfab0c9cc7ccc70723ab5aee75db3e3a0f997b93fe9
6
+ metadata.gz: cd2459353ab367c913f0308b1cb0dd95d297cce18ea3bb4ff537612edc0b8adc2ef4530d4bf8203c6c777fe57b8bccfbf8a8714ebc0ede67400a030e6ea1348f
7
+ data.tar.gz: b3ee38f5e340f05420783dc3f8a31ac662f475ec7bcef82222e5f07360faac1235249fa903c721a1d322b7999034b74e7f812fa47c3d3dec8a9f5763fa2693fb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.41.0 (2021-02-07)
6
+
7
+ * Add `--with-expectations` and `--save-expectations` option ([#303](https://github.com/soutaro/steep/pull/303))
8
+
5
9
  ## 0.40.0 (2021-01-31)
6
10
 
7
11
  * Report progress with dots ([#287](https://github.com/soutaro/steep/pull/287))
@@ -9,41 +9,26 @@ else
9
9
  test_dirs = ARGV.map {|p| Pathname.pwd + p }
10
10
  end
11
11
 
12
- test_dirs.each do |dir|
13
- test = dir + "test.yaml"
14
-
15
- if test.file?
16
- content = YAML.load_file(test)
17
- else
18
- content = { "test" => {} }
19
- end
12
+ failed_tests = []
20
13
 
14
+ test_dirs.each do |dir|
21
15
  puts "Rebaselining #{dir}..."
22
16
 
23
- command = content["command"] || "steep check"
24
- puts " command: #{command}"
25
-
26
- output, _ = Open3.capture2(command, chdir: dir.to_s)
17
+ command = %w(steep check --save-expectations=test_expectations.yml)
18
+ puts " command: #{command.join(" ")}"
27
19
 
28
- diagnostics = output.split(/\n\n/).each.with_object({}) do |message, hash|
29
- if message =~ /\A([^:]+):\d+:\d+:/
30
- path = $1
31
- hash[path] ||= { "diagnostics" => [] }
32
- hash[path]["diagnostics"] << message.chomp + "\n"
33
- end
34
- end
20
+ output, status = Open3.capture2(*command, chdir: dir.to_s)
35
21
 
36
- content["test"].each_key do |path|
37
- unless diagnostics.key?(path)
38
- diagnostics[path] = { "diagnostics" => [] }
39
- end
40
- end
41
-
42
- content["test"] = diagnostics.keys.sort.each.with_object({}) do |key, hash|
43
- hash[key] = diagnostics[key]
22
+ unless status.success?
23
+ puts "Error!!! 👺"
24
+ failed_tests << dir.basename
44
25
  end
26
+ end
45
27
 
46
- test.open("w") do |io|
47
- YAML.dump(content, io, header: false)
48
- end
28
+ if failed_tests.empty?
29
+ puts "Successfully updated output expectations! 🤡"
30
+ else
31
+ puts "Failed to update the following tests! 💀"
32
+ puts " #{failed_tests.join(", ")}"
33
+ exit 1
49
34
  end
data/bin/output_test.rb CHANGED
@@ -14,21 +14,27 @@ else
14
14
  test_dirs = ARGV.map {|p| Pathname.pwd + p }
15
15
  end
16
16
 
17
- success = true
17
+ failed_tests = []
18
18
 
19
19
  test_dirs.each do |dir|
20
- test = dir + "test.yaml"
21
-
22
- next unless test.file?
23
-
24
20
  puts "Running test #{dir}..."
25
21
 
26
- content = YAML.load_file(test)
22
+ unless (dir + "test_expectations.yml").file?
23
+ puts "Skipped ⛹️‍♀️"
24
+ next
25
+ end
27
26
 
28
- command = content["command"] || "steep check"
29
- puts " command: #{command}"
27
+ command = %w(steep check --with-expectations=test_expectations.yml)
28
+ puts " command: #{command.join(" ")}"
30
29
 
31
- output, _ = Open3.capture2(command, chdir: dir.to_s)
30
+ output, status = Open3.capture2(*command, chdir: dir.to_s)
31
+
32
+ unless status.success?
33
+ failed_tests << dir.basename
34
+ puts " Failed! 🤕"
35
+ else
36
+ puts " Succeed! 👍"
37
+ end
32
38
 
33
39
  if @verbose
34
40
  puts " Raw output:"
@@ -36,58 +42,12 @@ test_dirs.each do |dir|
36
42
  puts " > #{line.chomp}"
37
43
  end
38
44
  end
39
-
40
- diagnostics = output.split(/\n\n/).each.with_object({}) do |d, hash|
41
- if d =~ /\A([^:]+):\d+:\d+:/
42
- path = $1
43
- hash[path] ||= []
44
- hash[path] << (d.chomp + "\n")
45
- end
46
- end
47
-
48
- content["test"].each do |path, test|
49
- puts " Checking: #{path}..."
50
-
51
- fail_expected = test["fail"] || false
52
-
53
- expected_diagnostics = test["diagnostics"]
54
- reported_diagnostics = (diagnostics[path] || [])
55
-
56
- puts " # of expected: #{expected_diagnostics.size}, # of reported: #{reported_diagnostics.size}"
57
-
58
- unexpected_diagnostics = reported_diagnostics.reject {|d| expected_diagnostics.include?(d) }
59
- missing_diagnostics = expected_diagnostics.reject {|d| reported_diagnostics.include?(d) }
60
-
61
- unexpected_diagnostics.each do |d|
62
- puts " Unexpected diagnostics:"
63
- d.split(/\n/).each do |line|
64
- puts " + #{line.chomp}"
65
- end
66
- end
67
-
68
- missing_diagnostics.each do |d|
69
- puts " Missing diagnostics:"
70
- d.split(/\n/).each do |line|
71
- puts " - #{line.chomp}"
72
- end
73
- end
74
-
75
- if unexpected_diagnostics.empty? && missing_diagnostics.empty?
76
- puts " 👍"
77
- else
78
- if fail_expected
79
- puts " 🚨 (expected failure)"
80
- else
81
- puts " 🚨"
82
- success = false
83
- end
84
- end
85
- end
86
45
  end
87
46
 
88
- if success
47
+ if failed_tests.empty?
89
48
  puts "All tests ok! 👏"
90
49
  else
91
50
  puts "Errors detected! 🤮"
51
+ puts " #{failed_tests.join(", ")}"
92
52
  exit 1
93
53
  end
data/lib/steep.rb CHANGED
@@ -4,6 +4,7 @@ require "pathname"
4
4
  require "parser/ruby27"
5
5
  require "ast_utils"
6
6
  require "active_support/core_ext/object/try"
7
+ require "active_support/core_ext/string/inflections"
7
8
  require "logger"
8
9
  require "active_support/tagged_logging"
9
10
  require "rainbow"
@@ -13,6 +14,7 @@ require "etc"
13
14
  require "open3"
14
15
  require "stringio"
15
16
  require 'uri'
17
+ require "yaml"
16
18
 
17
19
  require "rbs"
18
20
 
@@ -102,6 +104,8 @@ require "steep/project/file_loader"
102
104
  require "steep/project/hover_content"
103
105
  require "steep/project/completion_provider"
104
106
  require "steep/project/stats_calculator"
107
+
108
+ require "steep/expectations"
105
109
  require "steep/drivers/utils/driver_helper"
106
110
  require "steep/drivers/check"
107
111
  require "steep/drivers/stats"
data/lib/steep/cli.rb CHANGED
@@ -51,15 +51,15 @@ module Steep
51
51
  end
52
52
 
53
53
  def handle_logging_options(opts)
54
- opts.on("--log-level=[debug,info,warn,error,fatal]") do |level|
54
+ opts.on("--log-level=LEVEL", "Specify log level: debug, info, warn, error, fatal") do |level|
55
55
  Steep.logger.level = level
56
56
  end
57
57
 
58
- opts.on("--log-output=[PATH]") do |file|
58
+ opts.on("--log-output=PATH", "Print logs to given path") do |file|
59
59
  Steep.log_output = file
60
60
  end
61
61
 
62
- opts.on("--verbose") do
62
+ opts.on("--verbose", "Set log level to debug") do
63
63
  Steep.logger.level = Logger::DEBUG
64
64
  end
65
65
  end
@@ -83,6 +83,12 @@ module Steep
83
83
  opts.banner = "Usage: steep check [options] [sources]"
84
84
 
85
85
  opts.on("--steepfile=PATH") {|path| check.steepfile = Pathname(path) }
86
+ opts.on("--with-expectations[=PATH]", "Type check with expectations saved in PATH (or steep_expectations.yml)") do |path|
87
+ check.with_expectations_path = Pathname(path || "steep_expectations.yml")
88
+ end
89
+ opts.on("--save-expectations[=PATH]", "Save expectations with current type check result to PATH (or steep_expectations.yml)") do |path|
90
+ check.save_expectations_path = Pathname(path || "steep_expectations.yml")
91
+ end
86
92
  handle_logging_options opts
87
93
  end.parse!(argv)
88
94
 
@@ -6,6 +6,8 @@ module Steep
6
6
  attr_reader :stdout
7
7
  attr_reader :stderr
8
8
  attr_reader :command_line_patterns
9
+ attr_accessor :with_expectations_path
10
+ attr_accessor :save_expectations_path
9
11
 
10
12
  include Utils::DriverHelper
11
13
 
@@ -57,7 +59,7 @@ module Steep
57
59
  shutdown_id = -1
58
60
  client_writer.write({ method: :shutdown, id: shutdown_id })
59
61
 
60
- responses = []
62
+ diagnostic_notifications = []
61
63
  error_messages = []
62
64
  client_reader.read do |response|
63
65
  case
@@ -68,7 +70,7 @@ module Steep
68
70
  else
69
71
  stdout.print "F"
70
72
  end
71
- responses << response[:params]
73
+ diagnostic_notifications << response[:params]
72
74
  stdout.flush
73
75
  when response[:method] == "window/showMessage"
74
76
  # Assuming ERROR message means unrecoverable error.
@@ -89,30 +91,120 @@ module Steep
89
91
  stdout.puts
90
92
  stdout.puts
91
93
 
92
- case
93
- when responses.all? {|res| res[:diagnostics].empty? } && error_messages.empty?
94
+ if error_messages.empty?
95
+ case
96
+ when with_expectations_path
97
+ print_expectations(project: project,
98
+ expectations_path: with_expectations_path,
99
+ notifications: diagnostic_notifications)
100
+ when save_expectations_path
101
+ save_expectations(project: project,
102
+ expectations_path: save_expectations_path,
103
+ notifications: diagnostic_notifications)
104
+ else
105
+ print_result(project: project, notifications: diagnostic_notifications)
106
+ end
107
+ else
108
+ stdout.puts Rainbow("Unexpected error reported. 🚨").red.bold
109
+ 1
110
+ end
111
+ end
112
+
113
+ def print_expectations(project:, expectations_path:, notifications:)
114
+ expectations = Expectations.load(path: expectations_path, content: expectations_path.read)
115
+
116
+ expected_count = 0
117
+ unexpected_count = 0
118
+ missing_count = 0
119
+
120
+ ns = notifications.each.with_object({}) do |notification, hash|
121
+ path = project.relative_path(Pathname(URI.parse(notification[:uri]).path))
122
+ hash[path] = notification[:diagnostics]
123
+ end
124
+
125
+ (project.all_source_files + project.all_signature_files).sort.each do |path|
126
+ test = expectations.test(path: path, diagnostics: ns[path] || [])
127
+
128
+ buffer = RBS::Buffer.new(name: path, content: path.read)
129
+ printer = DiagnosticPrinter.new(buffer: buffer, stdout: stdout)
130
+
131
+ test.each_diagnostics.each do |type, diag|
132
+ case type
133
+ when :expected
134
+ expected_count += 1
135
+ when :unexpected
136
+ unexpected_count += 1
137
+ printer.print(diag, prefix: Rainbow("+ ").green)
138
+ when :missing
139
+ missing_count += 1
140
+ printer.print(diag, prefix: Rainbow("- ").red)
141
+ end
142
+ end
143
+ end
144
+
145
+ if unexpected_count > 0 || missing_count > 0
146
+ stdout.puts
147
+
148
+ stdout.puts Rainbow("Expectations unsatisfied:").bold.red
149
+ stdout.puts " #{expected_count} expected #{"diagnostic".pluralize(expected_count)}"
150
+ stdout.puts Rainbow(" + #{unexpected_count} unexpected #{"diagnostic".pluralize(unexpected_count)}").green
151
+ stdout.puts Rainbow(" - #{missing_count} missing #{"diagnostic".pluralize(missing_count)}").red
152
+ 1
153
+ else
154
+ stdout.puts Rainbow("Expectations satisfied:").bold.green
155
+ stdout.puts " #{expected_count} expected #{"diagnostic".pluralize(expected_count)}"
156
+ 0
157
+ end
158
+ end
159
+
160
+ def save_expectations(project:, expectations_path:, notifications:)
161
+ expectations = if expectations_path.file?
162
+ Expectations.load(path: expectations_path, content: expectations_path.read)
163
+ else
164
+ Expectations.empty()
165
+ end
166
+
167
+ ns = notifications.each.with_object({}) do |notification, hash|
168
+ path = project.relative_path(Pathname(URI.parse(notification[:uri]).path))
169
+ hash[path] = notification[:diagnostics]
170
+ end
171
+
172
+ (project.all_source_files + project.all_signature_files).sort.each do |path|
173
+ ds = ns[path] || []
174
+
175
+ if ds.empty?
176
+ expectations.diagnostics.delete(path)
177
+ else
178
+ expectations.diagnostics[path] = ds
179
+ end
180
+ end
181
+
182
+ expectations_path.write(expectations.to_yaml)
183
+ stdout.puts Rainbow("Saved expectations in #{expectations_path}...").bold
184
+ 0
185
+ end
186
+
187
+ def print_result(project:, notifications:)
188
+ if notifications.all? {|notification| notification[:diagnostics].empty? }
94
189
  emoji = %w(🫖 🫖 🫖 🫖 🫖 🫖 🫖 🫖 🍵 🧋 🧉).sample
95
190
  stdout.puts Rainbow("No type error detected. #{emoji}").green.bold
96
191
  0
97
- when !error_messages.empty?
98
- stdout.puts Rainbow("Unexpected error reported. 🚨").red.bold
99
- 1
100
192
  else
101
- errors = responses.reject {|res| res[:diagnostics].empty? }
102
- total = errors.sum {|res| res[:diagnostics].size }
103
- stdout.puts Rainbow("Detected #{total} problems from #{errors.size} files").red.bold
104
- stdout.puts
193
+ errors = notifications.reject {|notification| notification[:diagnostics].empty? }
194
+ total = errors.sum {|notification| notification[:diagnostics].size }
105
195
 
106
- errors.each do |resp|
107
- path = project.relative_path(Pathname(URI.parse(resp[:uri]).path))
196
+ errors.each do |notification|
197
+ path = project.relative_path(Pathname(URI.parse(notification[:uri]).path))
108
198
  buffer = RBS::Buffer.new(name: path, content: path.read)
109
199
  printer = DiagnosticPrinter.new(buffer: buffer, stdout: stdout)
110
200
 
111
- resp[:diagnostics].each do |diag|
201
+ notification[:diagnostics].each do |diag|
112
202
  printer.print(diag)
113
203
  stdout.puts
114
204
  end
115
205
  end
206
+
207
+ stdout.puts Rainbow("Detected #{total} #{"problem".pluralize(total)} from #{errors.size} #{"file".pluralize(errors.size)}").red.bold
116
208
  1
117
209
  end
118
210
  end
@@ -50,28 +50,28 @@ module Steep
50
50
  Rainbow("#{path}:#{start[:line]+1}:#{start[:character]}").magenta
51
51
  end
52
52
 
53
- def print(diagnostic)
53
+ def print(diagnostic, prefix: "")
54
54
  header, *rest = diagnostic[:message].split(/\n/)
55
55
 
56
- stdout.puts "#{location(diagnostic)}: [#{severity_message(diagnostic[:severity])}] #{Rainbow(header).underline}"
56
+ stdout.puts "#{prefix}#{location(diagnostic)}: [#{severity_message(diagnostic[:severity])}] #{Rainbow(header).underline}"
57
57
 
58
58
  unless rest.empty?
59
59
  rest.each do |message|
60
- stdout.puts "│ #{message}"
60
+ stdout.puts "#{prefix}│ #{message}"
61
61
  end
62
62
  end
63
63
 
64
64
  if diagnostic[:code]
65
- stdout.puts "│" unless rest.empty?
66
- stdout.puts "│ Diagnostic ID: #{diagnostic[:code]}"
65
+ stdout.puts "#{prefix}│" unless rest.empty?
66
+ stdout.puts "#{prefix}│ Diagnostic ID: #{diagnostic[:code]}"
67
67
  end
68
68
 
69
- stdout.puts "│"
69
+ stdout.puts "#{prefix}│"
70
70
 
71
- print_source_line(diagnostic)
71
+ print_source_line(diagnostic, prefix: prefix)
72
72
  end
73
73
 
74
- def print_source_line(diagnostic)
74
+ def print_source_line(diagnostic, prefix: "")
75
75
  start_pos = diagnostic[:range][:start]
76
76
  end_pos = diagnostic[:range][:end]
77
77
 
@@ -80,14 +80,14 @@ module Steep
80
80
  leading = line[0...start_pos[:character]]
81
81
  if start_pos[:line] == end_pos[:line]
82
82
  subject = line[start_pos[:character]...end_pos[:character]]
83
- trailing = line[end_pos[:character]...].chomp
83
+ trailing = (line[end_pos[:character]...] || "").chomp
84
84
  else
85
85
  subject = line[start_pos[:character]...].chomp
86
86
  trailing = ""
87
87
  end
88
88
 
89
- stdout.puts "└ #{leading}#{color_severity(subject, severity: diagnostic[:severity])}#{trailing}"
90
- stdout.puts " #{" " * leading.size}#{"~" * subject.size}"
89
+ stdout.puts "#{prefix}└ #{leading}#{color_severity(subject, severity: diagnostic[:severity])}#{trailing}"
90
+ stdout.puts "#{prefix} #{" " * leading.size}#{"~" * subject.size}"
91
91
  end
92
92
  end
93
93
  end