├── bin └── minitest ├── Manifest.txt ├── Rakefile ├── test └── test_minitest_sprint.rb ├── .autotest ├── lib └── minitest │ ├── sprint_plugin.rb │ ├── complete.rb │ ├── sprint.rb │ └── path_expander.rb ├── History.rdoc └── README.rdoc /bin/minitest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S ruby 2 | 3 | require_relative "../lib/minitest/sprint" 4 | 5 | Minitest::Sprint.run 6 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | History.rdoc 3 | Manifest.txt 4 | README.rdoc 5 | Rakefile 6 | bin/minitest 7 | lib/minitest/complete.rb 8 | lib/minitest/path_expander.rb 9 | lib/minitest/sprint.rb 10 | lib/minitest/sprint_plugin.rb 11 | test/test_minitest_sprint.rb 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require "rubygems" 4 | require "hoe" 5 | 6 | Hoe.plugin :isolate 7 | Hoe.plugin :seattlerb 8 | Hoe.plugin :rdoc 9 | 10 | Hoe.spec "minitest-sprint" do 11 | developer "Ryan Davis", "ryand-ruby@zenspider.com" 12 | license "MIT" 13 | 14 | require_ruby_version ">= 3.2" 15 | 16 | dependency "prism", "~> 1.5" 17 | end 18 | 19 | # vim: syntax=ruby 20 | -------------------------------------------------------------------------------- /test/test_minitest_sprint.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "minitest/sprint" 3 | 4 | class TestMinitestSprint < Minitest::Test 5 | def test_pass 6 | assert true 7 | end 8 | 9 | def test_skip 10 | skip "nope" 11 | end 12 | 13 | if ENV["BAD"] then # allows it to pass my CI but easy to demo 14 | def test_fail 15 | flunk "write tests or I will kneecap you" 16 | end 17 | 18 | def test_error 19 | raise "nope" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require "autotest/restart" 4 | 5 | Autotest.add_hook :initialize do |at| 6 | at.testlib = "minitest/autorun" 7 | at.add_exception "tmp" 8 | 9 | # at.extra_files << "../some/external/dependency.rb" 10 | # 11 | # at.libs << ":../some/external" 12 | # 13 | # at.add_exception "vendor" 14 | # 15 | # at.add_mapping(/dependency.rb/) do |f, _| 16 | # at.files_matching(/test_.*rb$/) 17 | # end 18 | # 19 | # %w(TestA TestB).each do |klass| 20 | # at.extra_class_map[klass] = "test/test_misc.rb" 21 | # end 22 | end 23 | 24 | # Autotest.add_hook :run_command do |at| 25 | # system "rake build" 26 | # end 27 | -------------------------------------------------------------------------------- /lib/minitest/sprint_plugin.rb: -------------------------------------------------------------------------------- 1 | require "minitest" 2 | 3 | # :stopdoc: 4 | class OptionParser # unofficial embedded gem "makeoptparseworkwell" 5 | def hidden(...) = define(...).tap { |sw| def sw.summarize(*) = nil } 6 | def deprecate(from, to) = hidden(from) { abort "#{from} is deprecated. Use #{to}." } 7 | def topdict(name) = name.length > 1 ? top.long : top.short 8 | def alias(from, to) = (dict = topdict(from) and dict[to] = dict[from]) 9 | end unless OptionParser.method_defined? :hidden 10 | # :startdoc: 11 | 12 | module Minitest # :nodoc: 13 | def self.plugin_sprint_options opts, options # :nodoc: 14 | opts.on "--rake [TASK]", "Report how to re-run failures with rake." do |task| 15 | options[:sprint] = :rake 16 | options[:rake_task] = task 17 | end 18 | 19 | opts.deprecate "--binstub", "--rerun" 20 | 21 | sprint_styles = %w[rake lines names binstub] 22 | 23 | opts.on "-r", "--rerun [STYLE]", sprint_styles, "Report how to re-run failures using STYLE (names, lines)." do |style| 24 | options[:sprint] = (style || :lines).to_sym 25 | end 26 | end 27 | 28 | def self.plugin_sprint_init options 29 | require_relative "sprint" 30 | case options[:sprint] 31 | when :rake then 32 | self.reporter << Minitest::Sprint::RakeReporter.new(options[:rake_task]) 33 | when :binstub, :names then 34 | self.reporter << Minitest::Sprint::SprintReporter.new 35 | when :lines then 36 | self.reporter << Minitest::Sprint::SprintReporter.new(:lines) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/minitest/complete.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S ruby 2 | 3 | # :stopdoc: 4 | 5 | require "optparse" 6 | require "shellwords" 7 | 8 | # complete -o bashdefault -f -C 'ruby lib/minitest/complete.rb' minitest 9 | # using eg: 10 | # COMP_LINE="blah test/test_file.rb -n test_pattern" 11 | # or test directly with: 12 | # ./lib/minitest/complete.rb test/test_file.rb -n test_pattern 13 | 14 | argv = Shellwords.split ENV["COMP_LINE"] || ARGV.join(" ") 15 | comp_re = nil 16 | 17 | begin 18 | OptionParser.new do |opts| 19 | # part of my unofficial embedded gem "makeoptparseworkwell" 20 | def opts.topdict(name) = (name.length > 1 ? top.long : top.short) 21 | def opts.alias(from, to) = (dict = topdict(from) ; dict[to] = dict[from]) 22 | 23 | opts.on "-n", "--name [METHOD]", "minitest option" do |m| 24 | comp_re = Regexp.new m 25 | end 26 | 27 | opts.alias "name", "include" 28 | opts.alias "name", "exclude" 29 | opts.alias "n", "i" 30 | opts.alias "n", "e" 31 | opts.alias "n", "x" 32 | end.parse! argv 33 | rescue 34 | retry # ignore options passed to Ruby 35 | end 36 | 37 | path = argv.find_all { |f| File.file? f }.last 38 | 39 | exit unless comp_re && path 40 | 41 | require "prism" 42 | 43 | names, queue = [], [Prism.parse_file(path).value] 44 | 45 | while node = queue.shift do 46 | if node.type == :def_node then 47 | name = node.name 48 | names << name if name =~ comp_re 49 | else 50 | queue.concat node.compact_child_nodes # no need to process def body 51 | end 52 | end 53 | 54 | puts names.sort 55 | 56 | # :startdoc: 57 | -------------------------------------------------------------------------------- /History.rdoc: -------------------------------------------------------------------------------- 1 | === 1.5.0 / 2025-12-11 2 | 3 | * 10 minor enhancements: 4 | 5 | * --rerun now takes a style (lines, names) and defaults to lines. 6 | * Add --simplecov (and MT_COV=1) option to load before minitest. 7 | * Added all the filtering options to minitest/complete. 8 | * Folded BinstubReporter into SprintReporter and added line number reporting. 9 | * Folded rake_reporter.rb and sprint_reporter.rb into sprint.rb 10 | * Normalized namespacing inside of Minitest::Sprint 11 | * Renamed --binstub to --rerun and added -r shortcut. 12 | * Switched minitest/complete to prism. Should be much more solid. 13 | * Vendored latest version of path_expander. 14 | * Updated Minitest::VendoredPathExpander to 2.0.0 and cleaned up overrides. 15 | 16 | * 4 bug fixes: 17 | 18 | * -I flag now prepends onto load path. 19 | * Fixed minitest cmdline to use require_relative to test against current version. 20 | * Made RakeReporter play better with superclass. 21 | * Prepend test and lib onto load path before loading minitest. 22 | 23 | === 1.4.1 / 2025-11-18 24 | 25 | * 1 bug fix: 26 | 27 | * OOPS! I'm using Data so I need to use ruby 3.2+ 28 | 29 | === 1.4.0 / 2025-11-17 30 | 31 | * 1 minor enhancement: 32 | 33 | * Added multiple line number support: eg test.rb:42,50-100 (multiple files too) 34 | 35 | * 3 bug fixes: 36 | 37 | * Fixed exception raised when requiring default test that wasn't actually there 38 | * Fixed shebang on bin/minitest for linux 39 | * Set minimum ruby version to 3.1. 40 | 41 | === 1.3.0 / 2024-07-23 42 | 43 | * 1 minor enhancement: 44 | 45 | * Allow rake task name to be passed as an argument and repeated back in failure list. (adam12) 46 | 47 | * 1 bug fix: 48 | 49 | * Fixed wonky shebang in bin/minitest. 50 | 51 | === 1.2.2 / 2022-06-20 52 | 53 | * 1 bug fix: 54 | 55 | * Fixed print_list for --binstub and --rake. (adam12) 56 | 57 | === 1.2.1 / 2019-09-22 58 | 59 | * 1 minor enhancement: 60 | 61 | * Refactored and moved bin/minitest to Minitest::Sprint.run. 62 | 63 | * 1 bug fix: 64 | 65 | * Fixed a bug where having only options would prevent default "test" directory arg. 66 | 67 | === 1.2.0 / 2016-05-16 68 | 69 | * 1 minor enhancement: 70 | 71 | * Switched to path_expander to deal with cmdline args. See path_expander for details. 72 | 73 | === 1.1.1 / 2015-08-10 74 | 75 | * 1 bug fix: 76 | 77 | * Remove -w from bin/minitest because SOME people run dirty code. :P 78 | 79 | === 1.1.0 / 2015-03-09 80 | 81 | * 2 minor enhancements: 82 | 83 | * Added minitest/complete for cmdline test name completion. (tenderlove) 84 | * bin/minitest now directly loads tests, supports specifying files and minitest args. 85 | 86 | === 1.0.0 / 2015-01-23 87 | 88 | * 1 major enhancement 89 | 90 | * Birthday! 91 | -------------------------------------------------------------------------------- /lib/minitest/sprint.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift "test", "lib" 2 | 3 | require "simplecov" if ENV["MT_COV"] || ARGV.delete("--simplecov") 4 | require "minitest/autorun" 5 | require_relative "path_expander" 6 | 7 | ## 8 | # Runs (Get it? It's fast!) your tests and makes it easier to rerun individual 9 | # failures. 10 | 11 | module Minitest 12 | class Sprint 13 | VERSION = "1.5.0" # :nodoc: 14 | 15 | ## 16 | # Process and run minitest cmdline. 17 | 18 | def self.run args = ARGV 19 | Minitest::PathExpander.new(args).process { |f| 20 | require "./#{f}" if File.file? f 21 | } 22 | end 23 | 24 | ## 25 | # An extra minitest reporter to output how to rerun failures in 26 | # various styles. 27 | 28 | class SprintReporter < AbstractReporter 29 | ## 30 | # The style to report, either lines or regexp. Defaults to lines. 31 | attr_accessor :style 32 | attr_accessor :results # :nodoc: 33 | 34 | def initialize style = :regexp # :nodoc: 35 | self.results = [] 36 | self.style = style 37 | end 38 | 39 | def record result # :nodoc: 40 | results << result unless result.passed? or result.skipped? 41 | end 42 | 43 | def report # :nodoc: 44 | return if results.empty? 45 | 46 | puts 47 | puts "Happy Happy Sprint List:" 48 | puts 49 | print_list 50 | puts 51 | end 52 | 53 | def print_list # :nodoc: 54 | case style 55 | when :regexp 56 | results.each do |result| 57 | puts " minitest -n #{result.class_name}##{result.name}" 58 | end 59 | when :lines 60 | files = Hash.new { |h,k| h[k] = [] } 61 | results.each do |result| 62 | path, line = result.source_location 63 | path = path.delete_prefix "#{Dir.pwd}/" 64 | files[path] << line 65 | end 66 | 67 | files.sort.each do |path, lines| 68 | puts " minitest %s:%s" % [path, lines.sort.join(",")] 69 | end 70 | else 71 | raise "unsupported style: %p" % [style] 72 | end 73 | end 74 | end 75 | 76 | ## 77 | # An extra minitest reporter to output how to rerun failures using 78 | # rake. 79 | 80 | class RakeReporter < SprintReporter 81 | ## 82 | # The name of the rake task to rerun. Defaults to nil. 83 | 84 | attr_accessor :name 85 | 86 | def initialize name = nil # :nodoc: 87 | super() 88 | self.name = name 89 | end 90 | 91 | def print_list # :nodoc: 92 | results.each do |result| 93 | puts [" rake", name, "N=#{result.class_name}##{result.name}"].compact.join(" ") 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = minitest-sprint 2 | 3 | home :: https://github.com/seattlerb/minitest-sprint 4 | rdoc :: http://docs.seattlerb.org/minitest-sprint 5 | 6 | == DESCRIPTION: 7 | 8 | Runs (Get it? It's fast!) your tests and makes it easier to rerun individual 9 | failures. 10 | 11 | == FEATURES/PROBLEMS: 12 | 13 | * Run tests by line number :: 14 | * test.rb:42 15 | * test.rb:50-100 16 | * test1.rb:42,50-100,150 test2.rb:13 17 | * Uses path_expander, so you can use: 18 | * dir_arg -- expand a directory automatically 19 | * @file_of_args -- persist arguments in a file 20 | * -path_to_subtract -- ignore intersecting subsets of files/directories 21 | * Includes a script for commandline autocompletion of test names. 22 | * Includes extra plugins to print out failure re-run commands. 23 | * One for the minitest commandline runner. (--binstub) 24 | * One for rake test runners. (--rake) 25 | 26 | == SYNOPSIS: 27 | 28 | $ minitest test/test_whatever.rb -n test_thingy 29 | test_thingy_error 30 | test_thingy_error_teardown 31 | test_thingy_failing 32 | test_thingy_failing_filtered 33 | ... etc ... 34 | 35 | # Rakefile 36 | Minitest::TestTask.create do |t| 37 | t.extra_args = ["--rake"] # Or --binstub 38 | end 39 | 40 | === Tab Completion 41 | 42 | Add this to your .bashrc (or .zshrc?--someone please confirm with a PR): 43 | 44 | $ complete -o bashdefault -f -C 'ruby $(gem which minitest/complete)' minitest 45 | 46 | Running individual minitest tests will now have tab completion for the 47 | method names. When running tests, just hit tab after -n. For example: 48 | 49 | $ minitest test/test_whatever.rb -n test_thingy 50 | test_thingy_error 51 | test_thingy_error_teardown 52 | test_thingy_failing 53 | test_thingy_failing_filtered 54 | ... etc ... 55 | 56 | == REQUIREMENTS: 57 | 58 | * ruby 59 | 60 | == INSTALL: 61 | 62 | * gem install minitest-sprint 63 | 64 | == LICENSE: 65 | 66 | (The MIT License) 67 | 68 | Copyright (c) Ryan Davis, seattle.rb 69 | 70 | Permission is hereby granted, free of charge, to any person obtaining 71 | a copy of this software and associated documentation files (the 72 | 'Software'), to deal in the Software without restriction, including 73 | without limitation the rights to use, copy, modify, merge, publish, 74 | distribute, sublicense, and/or sell copies of the Software, and to 75 | permit persons to whom the Software is furnished to do so, subject to 76 | the following conditions: 77 | 78 | The above copyright notice and this permission notice shall be 79 | included in all copies or substantial portions of the Software. 80 | 81 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 82 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 83 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 84 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 85 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 86 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 87 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 88 | -------------------------------------------------------------------------------- /lib/minitest/path_expander.rb: -------------------------------------------------------------------------------- 1 | require "prism" 2 | 3 | module Minitest; end # :nodoc: 4 | 5 | ## 6 | # PathExpander helps pre-process command-line arguments expanding 7 | # directories into their constituent files. It further helps by 8 | # providing additional mechanisms to make specifying subsets easier 9 | # with path subtraction and allowing for command-line arguments to be 10 | # saved in a file. 11 | # 12 | # NOTE: this is NOT an options processor. It is a path processor 13 | # (basically everything else besides options). It does provide a 14 | # mechanism for pre-filtering cmdline options, but not with the intent 15 | # of actually processing them in PathExpander. Use OptionParser to 16 | # deal with options either before or after passing ARGV through 17 | # PathExpander. 18 | 19 | class Minitest::VendoredPathExpander 20 | # extracted version = "2.0.0" 21 | 22 | ## 23 | # The args array to process. 24 | 25 | attr_accessor :args 26 | 27 | ## 28 | # The glob used to expand dirs to files. 29 | 30 | attr_accessor :glob 31 | 32 | ## 33 | # The path to scan if no paths are found in the initial scan. 34 | 35 | attr_accessor :path 36 | 37 | ## 38 | # Create a new path expander that operates on args and expands via 39 | # glob as necessary. Takes an optional +path+ arg to fall back on if 40 | # no paths are found on the initial scan (see #process_args). 41 | 42 | def initialize args, glob, path = "." 43 | self.args = args 44 | self.glob = glob 45 | self.path = path 46 | end 47 | 48 | ## 49 | # Takes an array of paths and returns an array of paths where all 50 | # directories are expanded to all files found via the glob provided 51 | # to PathExpander. 52 | # 53 | # Paths are normalized to not have a leading "./". 54 | 55 | def expand_dirs_to_files *dirs 56 | dirs.flatten.map { |p| 57 | if File.directory? p then 58 | Dir[File.join(p, glob)].find_all { |f| File.file? f } 59 | else 60 | p 61 | end 62 | }.flatten.sort.map { |s| s.to_s.delete_prefix "./" } 63 | end 64 | 65 | ## 66 | # Process a file into more arguments. Override this to add 67 | # additional capabilities. 68 | 69 | def process_file path 70 | File.readlines(path).map(&:chomp) 71 | end 72 | 73 | ## 74 | # Enumerate over args passed to PathExpander and return a list of 75 | # files and flags to process. Arguments are processed as: 76 | # 77 | # @file_of_args :: Read the file and append to args. 78 | # -file_path :: Subtract path from file to be processed. 79 | # -dir_path :: Expand and subtract paths from files to be processed. 80 | # -not_a_path :: Add to flags to be processed. 81 | # dir_path :: Expand and add to files to be processed. 82 | # file_path :: Add to files to be processed. 83 | # - :: Add "-" (stdin) to files to be processed. 84 | # 85 | # See expand_dirs_to_files for details on how expansion occurs. 86 | # 87 | # Subtraction happens last, regardless of argument ordering. 88 | # 89 | # If no files are found (which is not the same as having an empty 90 | # file list after subtraction), then fall back to expanding on the 91 | # default #path given to initialize. 92 | 93 | def process_args 94 | pos_files = [] 95 | neg_files = [] 96 | flags = [] 97 | clean = true 98 | 99 | args.each do |arg| 100 | case arg 101 | when /^@(.*)/ then # push back on, so they can have dirs/-/@ as well 102 | clean = false 103 | args.concat process_file $1 104 | when "-" then 105 | pos_files << arg 106 | when /^-(.*)/ then 107 | if File.exist? $1 then 108 | clean = false 109 | neg_files += expand_dirs_to_files($1) 110 | else 111 | flags << arg 112 | end 113 | else 114 | root_path = File.expand_path(arg) == "/" # eg: -n /./ 115 | if File.exist? arg and not root_path then 116 | clean = false 117 | pos_files += expand_dirs_to_files(arg) 118 | else 119 | flags << arg 120 | end 121 | end 122 | end 123 | 124 | files = pos_files - neg_files 125 | files += expand_dirs_to_files(self.path) if files.empty? && clean 126 | 127 | [files, flags] 128 | end 129 | 130 | ## 131 | # Process over flags and treat any special ones here. Returns an 132 | # array of the flags you haven't processed. 133 | # 134 | # This version does nothing. Subclass and override for 135 | # customization. 136 | 137 | def process_flags flags 138 | flags 139 | end 140 | 141 | ## 142 | # Top-level method processes args. If no block is given, immediately 143 | # returns with an Enumerator for further chaining. 144 | # 145 | # Otherwise, it calls +pre_process+, +process_args+ and 146 | # +process_flags+, enumerates over the files, and then calls 147 | # +post_process+, returning self for any further chaining. 148 | # 149 | # Most of the time, you're going to provide a block to process files 150 | # and do nothing more with the result. Eg: 151 | # 152 | # PathExpander.new(ARGV).process do |f| 153 | # puts "./#{f}" 154 | # end 155 | # 156 | # or: 157 | # 158 | # PathExpander.new(ARGV).process # => Enumerator 159 | 160 | def process(&b) 161 | return enum_for(:process) unless block_given? 162 | 163 | pre_process 164 | 165 | files, flags = process_args 166 | 167 | args.replace process_flags flags 168 | 169 | files.uniq.each(&b) 170 | 171 | post_process 172 | 173 | self 174 | end 175 | 176 | def pre_process = nil 177 | def post_process = nil 178 | 179 | ## 180 | # A file filter mechanism similar to, but not as extensive as, 181 | # .gitignore files: 182 | # 183 | # + If a pattern does not contain a slash, it is treated as a shell glob. 184 | # + If a pattern ends in a slash, it matches on directories (and contents). 185 | # + Otherwise, it matches on relative paths. 186 | # 187 | # File.fnmatch is used throughout, so glob patterns work for all 3 types. 188 | # 189 | # Takes a list of +files+ and either an io or path of +ignore+ data 190 | # and returns a list of files left after filtering. 191 | 192 | def filter_files files, ignore 193 | ignore_paths = if ignore.respond_to? :read then 194 | ignore.read 195 | elsif File.exist? ignore then 196 | File.read ignore 197 | end 198 | 199 | if ignore_paths then 200 | nonglobs, globs = ignore_paths.split("\n").partition { |p| p.include? "/" } 201 | dirs, ifiles = nonglobs.partition { |p| p.end_with? "/" } 202 | dirs = dirs.map { |s| s.chomp "/" } 203 | 204 | dirs.map! { |i| File.expand_path i } 205 | globs.map! { |i| File.expand_path i } 206 | ifiles.map! { |i| File.expand_path i } 207 | 208 | only_paths = File::FNM_PATHNAME 209 | files = files.reject { |f| 210 | f = File.expand_path(f) 211 | dirs.any? { |i| File.fnmatch?(i, File.dirname(f), only_paths) } || 212 | globs.any? { |i| File.fnmatch?(i, f) } || 213 | ifiles.any? { |i| File.fnmatch?(i, f, only_paths) } 214 | } 215 | end 216 | 217 | files 218 | end 219 | end # VendoredPathExpander 220 | 221 | ## 222 | # Minitest's PathExpander to find and filter tests. 223 | 224 | class Minitest::PathExpander < Minitest::VendoredPathExpander 225 | attr_accessor :by_line # :nodoc: 226 | 227 | TEST_GLOB = "**/{test_*,*_test,spec_*,*_spec}.rb" # :nodoc: 228 | 229 | def initialize args = ARGV # :nodoc: 230 | super args, TEST_GLOB, "test" 231 | self.by_line = {} 232 | end 233 | 234 | def process_args # :nodoc: 235 | args.reject! { |arg| # this is a good use of overriding 236 | case arg 237 | when /^(.*):([\d,-]+)$/ then 238 | f, ls = $1, $2 239 | ls = ls 240 | .split(/,/) 241 | .map { |l| 242 | case l 243 | when /^\d+$/ then 244 | l.to_i 245 | when /^(\d+)-(\d+)$/ then 246 | $1.to_i..$2.to_i 247 | else 248 | raise "unhandled argument format: %p" % [l] 249 | end 250 | } 251 | next unless File.exist? f 252 | args << f # push path on lest it run whole dir 253 | by_line[f] = ls 254 | end 255 | } 256 | 257 | super 258 | end 259 | 260 | ## 261 | # Overrides PathExpander#process_flags to filter out ruby flags 262 | # from minitest flags. Only supports -I, -d, and -w for 263 | # ruby. 264 | 265 | def process_flags flags 266 | flags.reject { |flag| # all hits are truthy, so this works out well 267 | case flag 268 | when /^-I(.*)/ then 269 | $LOAD_PATH.prepend(*$1.split(/:/)) 270 | when /^-d/ then 271 | $DEBUG = true 272 | when /^-w/ then 273 | $VERBOSE = true 274 | else 275 | false 276 | end 277 | } 278 | end 279 | 280 | ## 281 | # Add additional arguments to args to handle path:line argument filtering 282 | 283 | def post_process 284 | return if by_line.empty? 285 | 286 | tests = tests_by_class 287 | 288 | exit! if handle_missing_tests? tests 289 | 290 | test_res = tests_to_regexp tests 291 | self.args << "-n" << "/#{test_res.join "|"}/" 292 | end 293 | 294 | ## 295 | # Find and return all known tests as a hash of klass => [TM...] 296 | # pairs. 297 | 298 | def all_tests 299 | Minitest.seed = 42 # minor hack to deal with runnable_methods shuffling 300 | Minitest::Runnable.runnables 301 | .to_h { |k| 302 | ms = k.runnable_methods 303 | .sort 304 | .map { |m| TM.new k, m.to_sym } 305 | .sort_by { |t| [t.path, t.line_s] } 306 | [k, ms] 307 | } 308 | .reject { |k, v| v.empty? } 309 | end 310 | 311 | ## 312 | # Returns a hash mapping Minitest runnable classes to TMs 313 | 314 | def tests_by_class 315 | all_tests 316 | .transform_values { |ms| 317 | ms.select { |m| 318 | bl = by_line[m.path] 319 | not bl or bl.any? { |l| m.include? l } 320 | } 321 | } 322 | .reject { |k, v| v.empty? } 323 | end 324 | 325 | ## 326 | # Converts +tests+ to an array of "klass#(methods+)" regexps to be 327 | # used for test selection. 328 | 329 | def tests_to_regexp tests 330 | tests # { k1 => [Test(a), ...} 331 | .transform_values { |tms| tms.map(&:name) } # { k1 => %w[a, b], ...} 332 | .map { |k, ns| # [ "k1#(?:a|b)", "k2#c", ...] 333 | if ns.size > 1 then 334 | ns.map! { |n| Regexp.escape n } 335 | "%s#\(?:%s\)" % [Regexp.escape(k.name), ns.join("|")] 336 | else 337 | "%s#%s" % [Regexp.escape(k.name), ns.first] 338 | end 339 | } 340 | end 341 | 342 | ## 343 | # Handle the case where a line number doesn't match any known tests. 344 | # Returns true to signal that running should stop. 345 | 346 | def handle_missing_tests? tests 347 | _tests = tests.values.flatten 348 | not_found = by_line 349 | .flat_map { |f, ls| ls.map { |l| [f, l] } } 350 | .reject { |f, l| 351 | _tests.any? { |t| t.path == f and t.include? l } 352 | } 353 | 354 | unless not_found.empty? then 355 | by_path = all_tests.values.flatten.group_by(&:path) 356 | 357 | puts 358 | puts "ERROR: test(s) not found at:" 359 | not_found.each do |f, l| 360 | puts " %s:%s" % [f, l] 361 | puts 362 | puts "Did you mean?" 363 | puts 364 | l = l.begin if l.is_a? Range 365 | by_path[f] 366 | .sort_by { |m| (m.line_s - l).abs } 367 | .first(2) 368 | .each do |m| 369 | puts " %-30s (dist=%+d) (%s)" % [m, m.line_s - l, m.name] 370 | end 371 | puts 372 | end 373 | true 374 | end 375 | end 376 | 377 | ## 378 | # Simple TestMethod (abbr TM) Data object. 379 | 380 | TM = Data.define :klass, :name, :path, :lines do 381 | def initialize klass:, name: 382 | method = klass.instance_method name 383 | path, line_s = method.source_location 384 | 385 | path = path.delete_prefix "#{Dir.pwd}/" 386 | 387 | line_e = line_s + TM.source_for(method).lines.size - 1 388 | 389 | lines = line_s..line_e 390 | 391 | super klass:, name:, path:, lines: 392 | end 393 | 394 | def self.source_for method 395 | path, line = method.source_location 396 | file = cache[path] ||= File.readlines(path) 397 | 398 | ruby = +"" 399 | 400 | file[line-1..].each do |l| 401 | ruby << l 402 | return ruby if Prism.parse_success? ruby 403 | end 404 | 405 | nil 406 | end 407 | 408 | def self.cache = @cache ||= {} 409 | 410 | def include?(o) = o.is_a?(Integer) ? lines.include?(o) : lines.overlap?(o) 411 | 412 | def to_s = "%s:%d-%d" % [path, lines.begin, lines.end] 413 | 414 | def line_s = lines.begin 415 | end 416 | end 417 | --------------------------------------------------------------------------------