veragem 0.1.0 → 0.2.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -34
  3. data/Gemfile.lock +254 -0
  4. data/README.md +143 -48
  5. data/lib/veragem/citation.rb +116 -0
  6. data/lib/veragem/generator.rb +105 -56
  7. data/lib/veragem/license.rb +27 -0
  8. data/lib/veragem/name.rb +110 -48
  9. data/lib/veragem/options.rb +116 -30
  10. data/lib/veragem/project.rb +200 -39
  11. data/lib/veragem/questions.rb +163 -0
  12. data/lib/veragem/summary.rb +62 -0
  13. data/lib/veragem/version.rb +2 -1
  14. data/lib/veragem.rb +12 -3
  15. data/plugins.rb +4 -0
  16. data/sig/veragem.rbs +151 -23
  17. data/template/.gitattributes +3 -0
  18. data/template/.githooks/pre-commit +2 -3
  19. data/template/.github/PULL_REQUEST_TEMPLATE.md +2 -1
  20. data/template/.github/dependabot.yml +4 -8
  21. data/template/.github/image-registries.sh +4 -3
  22. data/template/.github/workflows/dependabot-automerge.yml +29 -34
  23. data/template/.github/workflows/dependency-review.yml +19 -15
  24. data/template/.github/workflows/image.yml +65 -14
  25. data/template/.github/workflows/links.yml +35 -0
  26. data/template/.github/workflows/nix.yml +3 -3
  27. data/template/.github/workflows/release.yml +135 -22
  28. data/template/.github/workflows/ruby-ci.yml +31 -9
  29. data/template/.github/workflows/security.yml +15 -9
  30. data/template/.github/workflows/style-check-cocogitto.yml +3 -4
  31. data/template/.github/workflows/style-check-dprint.yml +10 -10
  32. data/template/.gitignore +5 -0
  33. data/template/.mutineer.yml +28 -0
  34. data/template/.rubocop.yml +49 -10
  35. data/template/.simplecov.erb +10 -0
  36. data/template/.yard-lint.yml +386 -0
  37. data/template/.yardopts +2 -0
  38. data/template/AGENTS.md.erb +76 -38
  39. data/template/CITATION.cff.erb +50 -0
  40. data/template/CODE_OF_CONDUCT.md.erb +1 -1
  41. data/template/CONTRIBUTING.md.erb +107 -48
  42. data/template/Gemfile +10 -7
  43. data/template/README.md.erb +12 -12
  44. data/template/Rakefile +167 -31
  45. data/template/Steepfile.erb +1 -1
  46. data/template/bin/setup +2 -0
  47. data/template/cliff.toml +40 -23
  48. data/template/dprint.json +1 -6
  49. data/template/flake.lock +3 -3
  50. data/template/flake.nix +25 -10
  51. data/template/lib/%path%/cli.rb.erb +19 -15
  52. data/template/lib/%path%/version.rb.erb +1 -1
  53. data/template/lib/%path%.rb.erb +3 -1
  54. data/template/sig/%path%.rbs.erb +1 -1
  55. data/template/test/test_%underscored%.rb.erb +4 -3
  56. data/template/test/test_cli.rb.erb +37 -12
  57. data/template/test/test_helper.rb.erb +15 -7
  58. metadata +16 -4
  59. data/template/.github/workflows/rubocop-autocorrect.yml +0 -114
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Veragem
4
+ # The gem's licence: one of three choices, each its SPDX identifiers.
5
+ module License
6
+ # Each choice's SPDX identifiers: the gemspec's licenses, the LICENSE-* files.
7
+ SPDX = { "mit" => ["MIT"], "apache" => ["Apache-2.0"], "dual" => ["MIT", "Apache-2.0"] }.freeze
8
+ # The choices, as --help, the question and a refusal name them.
9
+ CHOICES = "mit, apache or dual"
10
+
11
+ # The choice +value+ names: a choice, its SPDX expression (its ORs in either order) or the start
12
+ # of one choice alone, in any case (`Apache-2.0`, `apache-2.0 or mit`, `a`).
13
+ # @param value [String] valid UTF-8, the spaces around it stripped, as Project.check passes it
14
+ # @return [String] the choice
15
+ # @raise [Veragem::Error] it names none
16
+ def self.check(value)
17
+ given = value.downcase
18
+ spdx = SPDX.find { |_, ids| ids.map(&:downcase).sort == given.split(" or ").sort }&.first
19
+ return spdx if spdx
20
+
21
+ case SPDX.keys.select { it.start_with?(given) }
22
+ in [choice] then choice
23
+ else raise Error, "invalid license (#{CHOICES}) #{value.inspect}"
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/veragem/name.rb CHANGED
@@ -8,11 +8,29 @@ module Veragem
8
8
  # `bundle gem`'s names, lower-case: words of letters and digits joined by `_` (one constant,
9
9
  # `my_gem` is MyGem) or `-` (a namespace, `my-gem` is My::Gem), each constant a letter first.
10
10
  PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*(?:-[a-z][a-z0-9]*(?:_[a-z0-9]+)*)*\z/
11
+ # Windows' devices, whose names no file there takes: a path's word, of its dashes
12
+ RESERVED = /\A(?:con|prn|aux|nul|com\d|lpt\d)\z/
11
13
 
12
14
  # Its modules, as `bundle gem` names them: `my-gem_tools`' My, GemTools.
15
+ # @param name [String] one PATTERN matches
16
+ # @return [Array<String>] outermost first
13
17
  def self.modules(name) = name.split("-").map { |word| word.split("_").map(&:capitalize).join }
14
18
 
15
- # +name+, or an Error saying why a gem cannot have it.
19
+ # The file the gem requires: `my-gem`'s is my/gem.
20
+ # @param name [String] one PATTERN matches
21
+ # @return [String]
22
+ def self.path(name) = name.tr("-", "/")
23
+
24
+ # Its words joined by `_` alone: `my-gem`'s my_gem, its test's file name.
25
+ # @param name [String] one PATTERN matches
26
+ # @return [String]
27
+ def self.underscored(name) = name.tr("-", "_")
28
+
29
+ # +name+, or an Error saying why a gem cannot have it, as written: Generator.check adds what its
30
+ # modules and its kind decide.
31
+ # @param name [String] a gem's name, as typed
32
+ # @return [String] +name+
33
+ # @raise [Veragem::Error] a gem cannot have it
16
34
  def self.check(name)
17
35
  unless PATTERN.match?(name)
18
36
  raise Error, "invalid gem name #{name.inspect}: lower-case words of letters and digits, each a " \
@@ -22,90 +40,134 @@ module Veragem
22
40
  if name.length > 40 || name.count("-") > 3
23
41
  raise Error, "invalid gem name #{name}: at most 40 characters and 4 modules deep"
24
42
  end
25
- # lib/test_helper.rb would shadow the tests' own (Minitest's -Ilib:test)
26
- raise Error, "invalid gem name #{name}: its lib/test_helper.rb would shadow the tests'" if name == "test_helper"
27
43
 
28
- check_modules(name)
44
+ check_paths(name)
29
45
  name
30
46
  end
31
47
 
32
- # The gem writes lib/<path>.rb, `module` for each of its modules, and tests that name the first.
48
+ # The gem writes lib/<path>.rb, `module` for each of its modules, and tests that name the first:
49
+ # none may meet what its tools load.
50
+ # @param name [String] one Name.check takes
51
+ # @return [void]
52
+ # @raise [Veragem::Error] a module or its file would meet another, or they could not be checked
33
53
  def self.check_modules(name)
34
54
  nestings = nestings_of(name)
35
- shadowed, found, *kinds = probe(library_of(name), name.tr("-", "/"), nestings)
36
- check_shadowing(name, shadowed.to_s, found.to_s)
37
- check_defined(name, nestings.zip(kinds).take_while { |_, kind| !kind.to_s.empty? }, nestings.last)
38
- end
39
-
40
- # Its lib/<path>.rb, first on the load path, must not shadow a library its tools require
41
- # (lib/json.rb, JSON's); its tests' classes, Minitest::Test's, and its gemspec, which `gem build`
42
- # evaluates in Gem::Specification, must find its module, not one of theirs (`lifecycle_hooks`:
43
- # Minitest::Test::LifecycleHooks).
44
- def self.check_shadowing(name, shadowed, found)
45
- unless shadowed.empty?
46
- raise Error, "invalid gem name #{name}: its lib/#{name.tr("-", "/")}.rb would shadow #{shadowed}"
47
- end
48
- raise Error, "invalid gem name #{name}: #{found} for its module" unless found.empty?
55
+ answers = probe(library_of(name), path(name), nestings)
56
+ check_shadowing(name, answers.fetch(0), answers.fetch(1))
57
+ check_defined(name, nestings.zip(answers.drop(2)).take_while { |_, kind| !kind.to_s.empty? }, nestings.last)
49
58
  end
50
59
 
51
- # The +defined+ nestings, outermost first, each with its class: the gem's own module, +innermost+,
52
- # one defined already (Comparable) would reopen; an outer one that is a class (Set, Logger), it
53
- # could not open.
54
- def self.check_defined(name, defined, innermost)
55
- raise Error, "invalid gem name #{name}: #{innermost} is already defined" if defined.last&.first == innermost
60
+ # What the gem's tools load with its code, where installed: Bundler's CLI, which reads its gemspec
61
+ # (bin/setup's `bundle install`); the Rakefile's libraries and its tasks' (RuboCop's, Steep's,
62
+ # YARD's ERB); bin/console's IRB; its tests' (SimpleCov, Pbt).
63
+ LIBRARIES = %w[bundler bundler/cli rake bundler/audit/task csv json minitest/test_task rubocop/rake_task
64
+ steep/rake_task yard rubocop steep erb irb simplecov minitest/autorun pbt].freeze
65
+ # Its files' paths: lib/test_helper.rb would shadow the tests' own (Minitest's -Ilib:test); a
66
+ # device's name, whatever its extension, is no file of Windows' (lib/con.rb), nor git's there.
67
+ private_class_method def self.check_paths(name)
68
+ raise Error, "invalid gem name #{name}: its lib/test_helper.rb would shadow the tests'" if name == "test_helper"
56
69
 
57
- outer = defined.find { |_, kind| kind != "Module" }
58
- raise Error, "invalid gem name #{name}: #{outer[0]} is a #{outer[1]}, not a module" if outer
70
+ reserved = name.split("-").grep(RESERVED).first
71
+ raise Error, "invalid gem name #{name}: Windows reserves #{reserved} as a file's name" if reserved
59
72
  end
60
73
 
61
- # The gem's top-level library: `date-holidays`' date
62
- def self.library_of(name) = name.split("-").fetch(0)
63
-
64
- # My, My::Gem
65
- def self.nestings_of(name) = modules(name).then { |modules| (1..modules.size).map { modules.first(it).join("::") } }
66
-
67
74
  # The child: no stdin, a scratch directory, its arguments cleared before the libraries load, their
68
- # own output kept from the answers, no exit hooks.
69
- PROBE = <<~RUBY
75
+ # own output kept from the answers, no exit hooks (minitest/autorun's). A file shadowed in any
76
+ # case: a file system may ignore it (macOS', Windows').
77
+ PROBE = <<~RUBY.freeze
70
78
  library, path, *nestings = ARGV
71
79
  first = nestings.fetch(0)
72
80
  ARGV.clear
73
81
  answers = $stdout.dup
74
82
  $stdout.reopen($stderr)
75
- ["bundler", "bundler/cli", "rake", "minitest", library].each do |feature|
83
+ [*#{LIBRARIES.inspect}, library].each do |feature|
76
84
  require feature
77
85
  rescue StandardError, ScriptError
78
86
  nil
79
87
  end
80
- answers.puts($LOAD_PATH.resolve_feature_path(path)&.last)
88
+ files = "\#{path.gsub(/[a-z]/) { "[\#{it}\#{it.upcase}]" }}.{rb,\#{RbConfig::CONFIG["DLEXT"]}}"
89
+ shadowed = $LOAD_PATH.resolve_feature_path(path)&.last ||
90
+ $LOAD_PATH.lazy.flat_map { |dir| Dir.glob(files, base: dir).map { File.join(dir, it) } }
91
+ .first
92
+ answers.puts(shadowed)
81
93
  readers = { "its tests" => defined?(Minitest::Test) && Minitest::Test, "gem build" => Gem::Specification }
82
94
  found = readers.filter_map do |reader, context|
83
95
  owner = context && (context.ancestors - Object.ancestors).find { it.const_defined?(first, false) }
84
96
  "\#{reader} would find \#{owner}::\#{first}" if owner
85
97
  end
86
98
  answers.puts(found.first)
87
- nestings.each { answers.puts(Object.const_defined?(it, false) ? Object.const_get(it, false).class : "") }
99
+ nestings.each do |nesting|
100
+ answers.puts(Object.const_defined?(nesting, false) ? Object.const_get(nesting, false).class : "")
101
+ rescue TypeError # an outer one no module (`Noisy = 1`): nothing within it
102
+ answers.puts("")
103
+ end
88
104
  answers.flush
89
105
  exit!(0)
90
106
  RUBY
91
- private_constant :PROBE
107
+ private_constant :RESERVED, :LIBRARIES, :PROBE
108
+ # Each probe's answers: a question asked once a process
109
+ @probed = {} #: Hash[[String, String, Array[String]], Array[String]]
92
110
 
93
111
  # The file +path+ already requires, what a Minitest::Test or Gem::Specification finds for the
94
- # first of +nestings+, then each nesting's class -- Module, Class... --, each "" when none, where
95
- # the gem meets them: in Bundler's CLI, which reads its gemspec (bin/setup's `bundle install`),
96
- # in Rake and Minitest, and with its top-level +library+ (`date-holidays`: date), which its tools
97
- # may require. Asked of a Ruby of its own, so the answer is the same however this one was
98
- # started. Its answers come only once the libraries are loaded, then the child exits: one fewer
99
- # is a failure.
112
+ # first of +nestings+, then each nesting's class (Module, Class...), each "" when none, where the
113
+ # gem meets them: with what its tools load (LIBRARIES), and its top-level +library+
114
+ # (`date-holidays`: date), which they may require. Asked of a Ruby of its own, the same however
115
+ # this one was started (RUBYOPT's options would print before the answers), and once a process:
116
+ # the answer cannot change. The child answers once the libraries are loaded, then exits: fewer
117
+ # answers are a failure.
118
+ # @param library [String] the gem's top-level library
119
+ # @param path [String] the file the gem requires, lib/'s
120
+ # @param nestings [Array<String>] its modules' full names, outermost first
121
+ # @return [Array<String>] the shadowed file, what is found, then each nesting's class
122
+ # @raise [Veragem::Error] the child did not answer them all, or could not be run
100
123
  def self.probe(library, path, nestings)
124
+ @probed[[library, path, nestings]] ||= answers(library, path, nestings)
125
+ end
126
+
127
+ private_class_method def self.answers(library, path, nestings)
101
128
  command = [RbConfig.ruby, "-e", PROBE, library, path, *nestings]
129
+ env = Bundler.unbundled_env.except("RUBYOPT")
102
130
  answers = Dir.mktmpdir do |dir|
103
- IO.popen(Bundler.unbundled_env, command, unsetenv_others: true, in: File::NULL, err: File::NULL, chdir: dir,
104
- &:readlines)
131
+ IO.popen(env, command, unsetenv_others: true, in: File::NULL, err: File::NULL, chdir: dir, &:readlines)
105
132
  end
106
- raise Error, "could not check #{library}'s modules" unless answers.size == nestings.size + 2
133
+ return answers.map(&:chomp).freeze if answers.size == nestings.size + 2
134
+
135
+ # rubocop:disable-next Style/SpecialGlobalVars -- RBS types $?, and has no English for $CHILD_STATUS
136
+ unchecked(nestings, "their Ruby ended: #{$?}")
137
+ rescue SystemCallError => e
138
+ # a refusal, not a raw Errno, which Bundler would report as its own bug
139
+ unchecked(nestings, e.message)
140
+ end
141
+
142
+ private_class_method def self.unchecked(nestings, why)
143
+ raise Error, "could not check #{nestings.last} against its tools: #{why}"
144
+ end
145
+
146
+ # Its lib/<path>.rb, first on the load path, must not shadow a library its tools may require:
147
+ # this Ruby's (lib/json.rb, JSON's), a gem's they load too; its tests' classes, Minitest::Test's,
148
+ # and its gemspec, which `gem build` evaluates in Gem::Specification, must find its module, not
149
+ # one of theirs (`lifecycle_hooks`: Minitest::Test::LifecycleHooks).
150
+ private_class_method def self.check_shadowing(name, shadowed, found)
151
+ raise Error, "invalid gem name #{name}: its lib/#{path(name)}.rb would shadow #{shadowed}" unless shadowed.empty?
152
+ raise Error, "invalid gem name #{name}: #{found} for its module" unless found.empty?
153
+ end
154
+
155
+ # The +defined+ nestings, outermost first, each with its class: the gem's own module, +innermost+,
156
+ # one defined already (Comparable) would reopen; an outer one that is a class (Set, Logger), it
157
+ # could not open.
158
+ private_class_method def self.check_defined(name, defined, innermost)
159
+ raise Error, "invalid gem name #{name}: #{innermost} is already defined" if defined.last&.first == innermost
160
+
161
+ outer = defined.find { |_, kind| kind != "Module" }
162
+ raise Error, "invalid gem name #{name}: #{outer[0]} is a #{outer[1]}, not a module" if outer
163
+ end
164
+
165
+ # The gem's top-level library: `date-holidays`' date
166
+ private_class_method def self.library_of(name) = name.split("-").fetch(0)
107
167
 
108
- answers.map(&:chomp)
168
+ # Each of its modules' full names, outermost first: `my-gem`'s My, My::Gem.
169
+ private_class_method def self.nestings_of(name)
170
+ modules(name).then { |modules| (1..modules.size).map { modules.first(it).join("::") } }
109
171
  end
110
172
  end
111
173
  end
@@ -1,53 +1,139 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
3
4
  require "optparse"
4
5
 
5
6
  module Veragem
6
7
  # The command line, read into a Project; git's config gives the author, the email and the GitHub
7
- # user, as `bundle gem` reads them.
8
+ # user, as `bundle gem` reads them. With a terminal for input, what no option gives is asked for.
8
9
  module Options
10
+ # The values git's config gives, and their keys there.
9
11
  GIT_CONFIG = { author: "user.name", email: "user.email", github: "github.user" }.freeze
12
+ # The values no option and no git config need give.
13
+ DEFAULTS = { summary: "A Ruby gem.", license: "dual" }.freeze
14
+ # --help's first lines.
15
+ BANNER = "Usage: bundle veragem NAME [options]\n" \
16
+ "With a terminal for input, what no option gives (the name too) is asked for."
17
+ # Each option and its --help, in --help's order.
18
+ OPTIONS = [
19
+ ["--[no-]exe", "Add a command line, exe/NAME (default: --no-exe, a library alone)"],
20
+ ["--license=LICENSE", "#{License::CHOICES} (MIT OR Apache-2.0, the default)"],
21
+ ["--author=NAME", "The author (default: git config user.name, else required)"],
22
+ ["--[no-]email=EMAIL", "The author's email (default: git config user.email; --no-email, none)"],
23
+ ["--github=USER", "The GitHub user or organization (default: git config github.user, else required)"],
24
+ ["--summary=TEXT", "The gem's summary (default: A Ruby gem.)"],
25
+ ["--[no-]citation", "Add CITATION.cff, the citation GitHub and Zenodo read (default: --no-citation)"],
26
+ ["--family-names=NAMES", "With --citation: the author's family names (required)"],
27
+ ["--[no-]given-names=NAMES", "With --citation: the author's given names (--no-given-names: none, unasked)"],
28
+ ["--[no-]alias=ALIAS", "With --citation: the author's alias, as a handle (--no-alias: none, unasked)"],
29
+ ["--orcid=ID", "With --citation: the author's ORCID iD"],
30
+ ["--keywords=LIST", "With --citation: keywords, comma-separated"]
31
+ ].freeze #: Array[[String, String]]
32
+ private_constant :GIT_CONFIG, :DEFAULTS, :BANNER, :OPTIONS
10
33
 
11
- def self.parse(argv, config: ->(key) { git_config(key) })
12
- values = GIT_CONFIG.transform_values { config.call(it) }
13
- # an argument of invalid UTF-8 as bytes, as git's values are: OptionParser's matching raises on
14
- # it, Project refuses it
15
- names = parser.permute(argv.map { it.valid_encoding? ? it : it.b }, into: values)
16
- raise Error, "one gem name, not #{names.size} (bundle veragem --help)" unless names.size == 1
17
-
34
+ # The Project +argv+ gives, what it does not asked of +input+ when a terminal, the questions on
35
+ # +output+ (stderr, as a prompt is): at a terminal the name checked for +dir+ before any question,
36
+ # the generation's check otherwise.
37
+ # @param argv [Array<String>] the command's arguments
38
+ # @param dir [String] where the gem is to be generated
39
+ # @param config [#call] a git config key's value, nil when unset
40
+ # @param input [#gets, #tty?] where the answers are typed; asked only when a terminal
41
+ # @param output [#print, #puts] where the questions are shown
42
+ # @return [Veragem::Project]
43
+ # @raise [Veragem::Error] an argument or a value is refused, or a question has no answer
44
+ def self.parse(argv, dir: Dir.pwd, config: ->(key) { git_config(key) }, input: $stdin, output: $stderr)
45
+ names, given = arguments(argv)
46
+ defaults = { **DEFAULTS, **GIT_CONFIG.transform_values(&config).compact }
18
47
  # rubocop:disable-next Lint/NameTypo -- the project index lacks Class#new, and takes Project#nest for it
19
- Project.new(names.fetch(0), { summary: "A Ruby gem.", license: "dual", **given(values) })
48
+ return Project.new(name_of(names), required(defaults, given)) unless input.tty?
49
+
50
+ ask(names, given, defaults, Questions.new(input, output, dir, git: GIT_CONFIG))
20
51
  rescue OptionParser::ParseError => e
21
- raise Error, "#{e.message} (bundle veragem --help)"
52
+ raise Error, "#{e} (bundle veragem --help)"
53
+ end
54
+
55
+ # The names and the options given, each value checked as Project checks it: before the name, and
56
+ # any question.
57
+ private_class_method def self.arguments(argv)
58
+ given = {} #: Hash[Symbol, String | bool]
59
+ # an argument of invalid UTF-8 as bytes: OptionParser's matching raises on it, Project refuses it
60
+ names = parser.permute(argv.map { it.valid_encoding? ? it : it.b }, into: given)
61
+ # OptionParser keys --given-names as :"given-names"; Project's are :given_names
62
+ given.transform_keys! { it.name.tr("-", "_").to_sym }
63
+ # each value as Project checks it (a text's UTF-8 and stripped): the questions compare them with
64
+ # their answers
65
+ checked = given.to_h { |key, value| [key, value.is_a?(String) ? checked(key, value) : value] }
66
+ Citation.check_values(checked, checked[:citation] == true) if checked.key?(:citation)
67
+ [names, checked]
68
+ end
69
+
70
+ # +value+ as Project checks it; but "-", a question's none, is no option's value, as no answer
71
+ # can give it.
72
+ private_class_method def self.checked(key, value)
73
+ none = Project::OPTIONAL[key]
74
+ if none && Questions.none?(value)
75
+ raise Error, %(invalid #{Project.field(key)} "-": a question's none; for none, #{none})
76
+ end
77
+
78
+ Project.check(key, value)
22
79
  end
23
80
 
24
- # The values set, the author and the GitHub user required.
25
- def self.given(values)
26
- %i[author github].each { raise Error, "no #{it}: --#{it}, or git config #{GIT_CONFIG[it]}" unless values[it] }
27
- values.compact
81
+ # From a terminal: the names counted, as without one, then the name checked before any question.
82
+ private_class_method def self.ask(names, given, defaults, questions)
83
+ # a citation's values given, the citation's question takes y alone: the name is checked for one
84
+ citation = given[:citation] == true || Citation.cited?(given)
85
+ name = questions.name((name_of(names) unless names.empty?), exe: given[:exe] == true, citation:)
86
+ # rubocop:disable-next Lint/NameTypo -- the project index lacks Class#new, and takes Project#nest for it
87
+ Project.new(name, questions.values(name, given, defaults))
28
88
  end
29
89
 
30
- def self.parser
31
- OptionParser.new("Usage: bundle veragem NAME [options]") do |opts|
90
+ private_class_method def self.name_of(names)
91
+ return names.fetch(0) if names.size == 1
92
+ raise Error, "no gem name (bundle veragem --help)" if names.empty?
93
+
94
+ raise Error, "one gem name, not #{names.size} (bundle veragem --help)"
95
+ end
96
+
97
+ # The options' values, and git's where none is given: one of git's refused says so, and which
98
+ # options replace it, as a missing author or GitHub user does.
99
+ private_class_method def self.required(defaults, given)
100
+ GIT_CONFIG.except(*given.keys).each do |key, name|
101
+ value = defaults[key] or next
102
+ Project.check(key, value)
103
+ rescue Error => e
104
+ replaced = ["--#{key}", Project::OPTIONAL[key]].compact.join(" or ")
105
+ raise Error, "#{e} (git config #{name}; #{replaced} replaces it)"
106
+ end
107
+ values = defaults.merge(given) #: Hash[Symbol, String | bool | nil]
108
+ check_present(values)
109
+ values #: Project::values
110
+ end
111
+
112
+ # The author and the GitHub user, which nothing but git defaults.
113
+ private_class_method def self.check_present(values)
114
+ %i[author github].each do |key|
115
+ raise Error, "no #{Project.field(key)}: --#{key}, or git config #{GIT_CONFIG[key]}" unless values[key]
116
+ end
117
+ end
118
+
119
+ private_class_method def self.parser
120
+ OptionParser.new(BANNER) do |opts|
32
121
  opts.program_name = "bundle veragem"
33
122
  opts.version = VERSION
34
- opts.on("--exe", "Add a command line, exe/NAME")
35
- opts.on("--license=LICENSE", Project::LICENSES.keys, "mit, apache or dual (MIT OR Apache-2.0, the default)")
36
- opts.on("--author=NAME", "The author (default: git config user.name)")
37
- opts.on("--email=EMAIL", "The author's email (default: git config user.email)")
38
- opts.on("--github=USER", "The GitHub user or organization (default: git config github.user)")
39
- opts.on("--summary=TEXT", "The gem's summary (default: A Ruby gem.)")
123
+ OPTIONS.each { |switch, help| opts.on(switch, help) }
40
124
  end
41
125
  end
42
126
 
43
- # A git config value, nil when unset or git is missing: its bytes, which Project reads as UTF-8
44
- # whatever the locale.
45
- def self.git_config(key)
46
- command = ["git", "config", "--get", key]
47
- value = IO.popen(command, err: File::NULL, external_encoding: Encoding::BINARY, &:read).strip
48
- value unless value.empty?
49
- rescue SystemCallError
50
- nil
127
+ # A git config value, nil when unset (status 1): its bytes as UTF-8, whatever the locale, which
128
+ # Project refuses unless valid. A git that cannot run, or cannot read its config, is refused here,
129
+ # with git's reason, before a missing value would be blamed on the config.
130
+ private_class_method def self.git_config(key)
131
+ value, error, status = Open3.capture3("git", "config", "--get", key, binmode: true)
132
+ raise Error, "git config --get #{key}: #{error.strip}" unless status.success? || status.exitstatus == 1
133
+
134
+ value.strip.force_encoding(Encoding::UTF_8).then { it unless it.empty? }
135
+ rescue SystemCallError => e
136
+ raise Error, "cannot run git: #{e}"
51
137
  end
52
138
  end
53
139
  end