Class: SpecsFor

Inherits:
Object
  • Object
show all
Defined in:
lib/specs_for.rb,
lib/specs_for/finder.rb,
lib/specs_for/runner.rb,
lib/specs_for/version.rb

Overview

Finds and runs RSpec spec files corresponding to a given set of source files. rubocop:disable Metrics/ClassLength

Defined Under Namespace

Classes: Finder, Runner

Constant Summary collapse

PROG =
File.basename($PROGRAM_NAME)
VALID_TAG =
/\A[a-zA-Z_][a-zA-Z0-9_:]*\z/
RELEASES =
[
  ["0.1.0", "2026-03-25", "Initial release"],
  ["0.2.0", "2026-05-06", "Add support for tags and improved file search"],
  ["0.2.1", "2026-05-06", "Replace specs-for with specs_for in the url"]
]
VERSION =
RELEASES.last.first

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(files: [], specs: [], opts: nil) ⇒ SpecsFor

Returns a new instance of SpecsFor.



14
15
16
17
18
19
# File 'lib/specs_for.rb', line 14

def initialize(files: [], specs: [], opts: nil)
  @file_args = []
  @files     = files
  @specs     = specs
  @opts      = opts
end

Instance Attribute Details

#file_argsObject (readonly)

Returns the value of attribute file_args.



21
22
23
# File 'lib/specs_for.rb', line 21

def file_args
  @file_args
end

#filesObject (readonly)

Returns the value of attribute files.



21
22
23
# File 'lib/specs_for.rb', line 21

def files
  @files
end

#specsObject (readonly)

Returns the value of attribute specs.



21
22
23
# File 'lib/specs_for.rb', line 21

def specs
  @specs
end

Instance Method Details

#changed?Boolean

Returns:

  • (Boolean)


30
# File 'lib/specs_for.rb', line 30

def changed?;              opts[:changed];            end

#collect_spec_for(file) ⇒ Object

Converts a source file path to its spec path and appends it to @specs if the spec file exists. Handles app/*/.rb, lib/*/.rb, and the component monorepo layout components/NAME/app/*/.rb / components/NAME/lib/*/.rb, preserving the component prefix.



130
131
132
133
134
135
136
137
138
139
140
# File 'lib/specs_for.rb', line 130

def collect_spec_for(file)
  spec_file = file
    .sub(%r{(?<=/|^)((?:components/\w+/)?)(?:app|lib)/}, '\1spec/')
    .sub(/\.rb$/, '_spec.rb')
  if File.exist?(spec_file)
    @specs << spec_file
    warn "==> Found spec for #{file}" if verbose?
  elsif verbose?
    warn "==> No spec file for #{file}"
  end
end

#collect_specsObject



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/specs_for.rb', line 142

def collect_specs
  not_found = []
  files.each do |file|
    if file.end_with?('_spec.rb')
      @specs << file
    elsif file.match?(%r{(?:^|/)(?:components/\w+/)?(?:app|lib)/})
      collect_spec_for(file)
    elsif Dir.exist?(file)
      # skip directories
    else
      not_found << file if verbose?
    end
  end
  warn "==> No spec file for:\n  #{not_found.join("\n  ")}" if verbose? && not_found.any?
  @specs = @specs.uniq.sort
end

#debug?Boolean

Returns:

  • (Boolean)


26
# File 'lib/specs_for.rb', line 26

def debug?;                opts[:debug];              end

#exact?Boolean

Returns:

  • (Boolean)


23
# File 'lib/specs_for.rb', line 23

def exact?;                opts[:exact];              end

#extract_filename(str) ⇒ Object

Parses either a quoted path (allows spaces) or an unquoted path from the start of str, returning [filename, remainder] or nil if nothing matched.



121
122
123
124
# File 'lib/specs_for.rb', line 121

def extract_filename(str)
  match = str.match(/^\s*(?:"([^"]*)"|(\S+))\s*(.*)$/) if str
  [match[1] || match[2], match[3]] if match
end

#filenames_from_stdin?Boolean

Returns:

  • (Boolean)


31
# File 'lib/specs_for.rb', line 31

def filenames_from_stdin?; opts[:filenames_from_stdin]; end

#filter_git_status(status_output) ⇒ Object

Parses a ‘git status -s` output block and returns the relevant filenames.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/specs_for.rb', line 104

def filter_git_status(status_output)
  status_output.each_line(chomp: true).filter_map do |line|
    # git status -s uses a fixed two-char XY status field followed by a space
    status = line[0, 2].strip
    rest   = line[3..]
    next if rest.nil? || rest.empty?

    case status
    when 'M', 'A', '??' then rest
    when 'R'            then rest.split(' -> ', 2).last  # "old -> new", take new name
    when 'D'            then nil                         # deleted — skip
    end
  end
end

#norun?Boolean

Returns:

  • (Boolean)


24
# File 'lib/specs_for.rb', line 24

def norun?;                opts[:norun];              end

#optsObject



85
86
87
88
# File 'lib/specs_for.rb', line 85

def opts
  parse_options unless @opts
  @opts
end

#parse_options(args = ARGV) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/specs_for.rb', line 33

def parse_options(args = ARGV)
  @opts = { tags: [] }
  parser = OptionParser.new do |opt|
    opt.banner = <<~BANNER
      Usage: #{PROG} [options] [- | FILE ...]

      Finds and runs RSpec specs for the given Ruby source files. Pass '-' to
      read filenames from STDIN. Spec files are passed through unchanged; source
      files are mapped to spec/ paths by replacing app/ or lib/ with spec/.

      Uses 'ag' for file search when available, otherwise Dir[].
      Prefixes rspec with 'asdf exec' when ruby is managed by asdf.

      Examples:
        #{PROG} lib/foo.rb               # run spec for one file
        #{PROG} -c                       # run specs for git-changed files
        #{PROG} -c -I                    # same, with --tag integration
        git diff --name-only | #{PROG} - # pipe filenames from stdin

      Options:
    BANNER
    opt.on('-h', '--help')                                              { warn opt; exit }
    opt.on('-c', '--changed',              'Use git-changed files')
    opt.on('-d', '--debug',                'Debug mode (opens pry if available)')
    opt.on('-e', '--exact',                'Match FILE exactly')
    opt.on('-I', '--integration',          'Add --tag integration')    { add_tag 'integration' }
    opt.on('-M', '--manual-integration',   'Add --tag manual_integration') { add_tag 'manual_integration' }
    opt.on('-n', '--norun',                'Show command, do not execute')
    opt.on('-r', '--run',                  'Force run rspec (default unless -n or -s)')
    opt.on('-s', '--show',                 'Print spec paths, do not run rspec')
    opt.on('-TNAME', '--tag NAME',         'Add --tag NAME') { |name| add_tag name }
    opt.on('-v', '--verbose',              'Be verbose')
  end
  parser.parse!(args, into: @opts)
  @opts[:filenames_from_stdin] = true if args.delete('-')
  @file_args = args
  if file_args.empty? && !filenames_from_stdin? && !changed?
    warn "Error: at least one FILE argument is required. Use '-' to read filenames from STDIN."
    warn parser
    exit 1
  end
  @opts[:run] = true if !@opts[:norun] && !@opts[:show] && @opts[:run].nil?
  if @opts[:debug]
    begin
      require 'pry'
    rescue LoadError
      warn "debug: pry not available, continuing without it"
      @opts[:debug] = false
    end
  end
end

#rspec_commandObject



167
168
169
170
171
# File 'lib/specs_for.rb', line 167

def rspec_command
  argv = ['bundle', 'exec', 'rspec', *tag_argv, *specs]
  argv.unshift('asdf', 'exec') if use_asdf?
  argv
end

#run(args = ARGV) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/specs_for.rb', line 90

def run(args = ARGV)
  parse_options(args)
  collect_file_names
  if file_args.empty?
    return if changed?
    warn "Error: no input filenames provided. Pass FILE args or pipe at least one filename to '-'."
    exit 1
  end
  collect_files
  collect_specs
  run? ? run_specs : show_specs
end

#run?Boolean

Returns:

  • (Boolean)


29
# File 'lib/specs_for.rb', line 29

def run?;                  opts[:run];                end

#show?Boolean

Returns:

  • (Boolean)


27
# File 'lib/specs_for.rb', line 27

def show?;                 opts[:show];               end

#tag_optsObject



159
160
161
# File 'lib/specs_for.rb', line 159

def tag_opts
  tag_argv.shelljoin.strip
end

#tagsObject



28
# File 'lib/specs_for.rb', line 28

def tags;                  opts[:tags];               end

#use_asdf?Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/specs_for.rb', line 163

def use_asdf?
  ruby_path.include?('/.asdf/')
end

#verbose?Boolean

Returns:

  • (Boolean)


25
# File 'lib/specs_for.rb', line 25

def verbose?;              opts[:verbose];            end