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
@@ -1,77 +1,238 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Veragem
4
6
  # The gem to generate: the values given, checked, and what follows from them.
5
7
  class Project
6
- # GitHub's rule for a user or organization name.
7
- GITHUB = /\A[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\z/i
8
- # Each choice's SPDX identifiers: the gemspec's licenses, the LICENSE-* files.
9
- LICENSES = { "mit" => ["MIT"], "apache" => ["Apache-2.0"], "dual" => ["MIT", "Apache-2.0"] }.freeze
8
+ # GitHub's rule for a user or organization name: ASCII's letters, which /i would widen to Unicode's
9
+ # (ſ, the Kelvin sign).
10
+ GITHUB = /\A[a-zA-Z\d](?:[a-zA-Z\d]|-(?=[a-zA-Z\d])){0,38}\z/
11
+ # Each value a refusal names otherwise than its key.
12
+ FIELDS = { github: "GitHub user", license: "license (#{License::CHOICES})", orcid: "ORCID iD" }.freeze
13
+ # What Markdown would read as markup in a text: a character to escape, but an `_` within a word;
14
+ # and a URL's `://` or `www.`, which GFM would link, its escapes shown.
15
+ MARKUP = %r{[\\`*\[\]<~$] | (?<![[:alnum:]])_ | _(?![[:alnum:]]) | &(?=\#?[[:alnum:]]+;) | :(?=//) | (?<=www)\.}x
16
+ # The values that may be none, and how an option gives none: a question takes "-".
17
+ OPTIONAL = { email: "--no-email", given_names: "--no-given-names", alias: "--no-alias",
18
+ orcid: "leave --orcid out", keywords: "leave --keywords out" }.freeze
19
+ private_constant :GITHUB, :FIELDS, :MARKUP
20
+
21
+ # The gem's name.
22
+ # @return [String]
23
+ attr_reader :name
24
+ # Its author.
25
+ # @return [String]
26
+ attr_reader :author
27
+ # The author's email, none nil.
28
+ # @return [String, nil]
29
+ attr_reader :email
30
+ # The GitHub user or organization.
31
+ # @return [String]
32
+ attr_reader :github
33
+ # Its summary.
34
+ # @return [String]
35
+ attr_reader :summary
36
+ # Its licence's choice: mit, apache or dual.
37
+ # @return [String]
38
+ attr_reader :license
39
+ # Whether it has a command line.
40
+ # @return [Boolean]
41
+ attr_reader :exe
42
+ # Whether it has CITATION.cff.
43
+ # @return [Boolean]
44
+ attr_reader :citation
45
+ # CITATION.cff's given names, none nil.
46
+ # @return [String, nil]
47
+ attr_reader :given_names
48
+ # CITATION.cff's family names, nil without a citation.
49
+ # @return [String, nil]
50
+ attr_reader :family_names
51
+ # CITATION.cff's alias, none nil.
52
+ # @return [String, nil]
53
+ attr_reader :alias_name
54
+ # CITATION.cff's ORCID iD, its URL; none nil.
55
+ # @return [String, nil]
56
+ attr_reader :orcid
57
+ # CITATION.cff's keywords, none nil.
58
+ # @return [Array<String>, nil]
59
+ attr_reader :keywords
60
+
61
+ # The value +key+ names, as a refusal names it: `GitHub user` :github, `given names` :given_names.
62
+ # @param key [Symbol] a value's
63
+ # @return [String]
64
+ def self.field(key) = FIELDS.fetch(key) { key.name.tr("_", " ") }
65
+
66
+ # +text+ without the spaces around it, Unicode's too (String#strip's are ASCII's alone).
67
+ # @param text [String]
68
+ # @return [String]
69
+ def self.strip(text) = text.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
10
70
 
11
- attr_reader :name, :author, :email, :github, :summary, :license, :exe
71
+ # +value+ as a Ruby string literal, as written whatever the locale (String#inspect escapes
72
+ # what the locale cannot show): a quote, a backslash and an interpolation escaped.
73
+ # @param value [String] a value of the gem's, checked
74
+ # @return [String]
75
+ def self.quote(value) = %("#{value.gsub(/["\\]|#(?=[{$@])/) { "\\#{it}" }}")
76
+
77
+ # The value +key+ takes, an option's or an answer's: checked, or an Error saying why not.
78
+ # @param key [Symbol] :author, :email, :github, :summary, :license, :given_names, :family_names,
79
+ # :alias, :orcid or :keywords
80
+ # @param value [String, nil] as given; nil, none, is refused as "" is
81
+ # @return [String] the value kept: a licence its choice (`MIT` mit, `Apache-2.0` apache), an ORCID
82
+ # iD its URL, keywords joined by ", "
83
+ # @raise [Veragem::Error] +key+ cannot take it
84
+ def self.check(key, value)
85
+ value = utf8(key, value)
86
+ return Citation.check(key, value) { |name, text| line(name, text) } if Citation::KEYS.include?(key)
87
+
88
+ case key
89
+ when :github then github(strip(value))
90
+ when :license then License.check(strip(value))
91
+ when :summary then Summary.check(text(field(key), value))
92
+ else text(field(key), value)
93
+ end
94
+ end
12
95
 
13
- # +name+, and +values+: the author, email, github, summary and license (strings), exe (true or
14
- # absent), as Options reads them.
96
+ # +name+, and +values+: the author, email, github, summary, license and a citation's given and
97
+ # family names, alias, ORCID iD and keywords (strings, but an email, given names or alias false,
98
+ # --no-email's, --no-given-names', --no-alias', or nil for none), exe and citation (true for a
99
+ # command line, for CITATION.cff), as Options reads them.
100
+ # @param name [String] the gem's name, as given
101
+ # @param values [Hash{Symbol => String, Boolean, nil}] the rest, as given
102
+ # @return [void]
103
+ # @raise [Veragem::Error] a value is refused
15
104
  def initialize(name, values)
16
105
  @name = Name.check(name)
17
- @author = text("author", values[:author])
18
- @email = values[:email] && text("email", values[:email])
19
- @github = valid("GitHub user", values[:github]) { GITHUB.match?(it) }
20
- @summary = text("summary", values[:summary])
21
- @license = valid("license (mit, apache or dual)", values[:license]) { LICENSES.key?(it) }
22
- @exe = values[:exe] == true
106
+ @author = Project.check(:author, values[:author])
107
+ @email = optional(:email, values[:email])
108
+ @github = Project.check(:github, values[:github])
109
+ @summary = Project.check(:summary, values[:summary])
110
+ @license = Project.check(:license, values[:license])
111
+ extras(values)
23
112
  end
24
113
 
25
- # The file the gem requires: `my-gem`'s is my/gem.
26
- def path = name.tr("-", "/")
114
+ # The file the gem requires, lib/'s: `my-gem`'s my/gem.
115
+ # @return [String]
116
+ def path = Name.path(name)
27
117
 
28
- def underscored = name.tr("-", "_")
118
+ # Its name, its words joined by `_` alone: its test's file name.
119
+ # @return [String]
120
+ def underscored = Name.underscored(name)
29
121
 
30
122
  # Its module, as `bundle gem` names it.
123
+ # @return [String]
31
124
  def constant = modules.join("::")
32
125
 
126
+ # Its modules, outermost first.
127
+ # @return [Array<String>]
33
128
  def modules = Name.modules(name)
34
129
 
35
- def licenses = LICENSES.fetch(license)
130
+ # The SPDX identifiers of its licence.
131
+ # @return [Array<String>]
132
+ def licenses = License::SPDX.fetch(license)
36
133
 
134
+ # The copyright's year: this one.
135
+ # @return [Integer]
37
136
  def year = Time.now.year
38
137
 
39
- # +value+ as a Ruby string literal, as written whatever the locale (String#inspect escapes
40
- # what the locale cannot show): a quote, a backslash and an interpolation escaped.
41
- def quote(value) = %("#{value.gsub(/["\\]|#(?=[{$@])/) { "\\#{it}" }}")
138
+ # Today in UTC, as CFF writes a date: CITATION.cff's date-released until `rake bump` sets a
139
+ # release's, UTC's too.
140
+ # @return [String]
141
+ def today = Time.now.utc.strftime("%F")
142
+
143
+ # +value+ as a YAML double-quoted string: JSON's, which YAML reads alike.
144
+ # @param value [String] a value of the gem's, checked
145
+ # @return [String]
146
+ def yaml(value) = JSON.generate(value)
147
+
148
+ # +value+ as Markdown's text, shown as written, as RubyGems shows a summary: its markup escaped,
149
+ # one space between words, as dprint writes them.
150
+ # @param value [String] a value of the gem's, checked
151
+ # @return [String]
152
+ def markdown(value)
153
+ escaped = value.squeeze(" ").gsub(MARKUP) { "\\#{it}" }
154
+ escaped.sub(/\A(?=[#>+-])|\A\d+\K(?=[.)])/) { "\\" }
155
+ end
42
156
 
43
- # +body+ inside the gem's modules, each nested and indented, +doc+ a comment on the innermost.
157
+ # Project.quote, for the templates.
158
+ # @param value [String] a value of the gem's, checked
159
+ # @return [String]
160
+ def quote(value) = Project.quote(value)
161
+
162
+ # +body+ inside the gem's modules, each nested and indented, +doc+ a comment on the innermost and
163
+ # the outer ones each a namespace's: one file documents them, as YARD would have it.
164
+ # @param body [String] Ruby code
165
+ # @param doc [String, nil] the comment
166
+ # @return [String]
44
167
  def nest(body, doc: nil)
45
168
  *outer, inner = modules
46
169
  code = "#{"# #{doc}\n" if doc}module #{inner}\n#{indent(body)}end\n"
47
- outer.reverse.reduce(code) { |nested, mod| "module #{mod}\n#{indent(nested)}end\n" }
170
+ outer.reverse.reduce(code) do |nested, mod|
171
+ "#{"# The namespace of #{constant}.\n" if doc}module #{mod}\n#{indent(nested)}end\n"
172
+ end
48
173
  end
49
174
 
50
- private
175
+ # +value+'s bytes as UTF-8, whatever their encoding (an argument's are binary where the locale's
176
+ # encoding cannot read them), refused unless valid: every check after it takes valid UTF-8.
177
+ # None (nil) is "", which each check refuses.
178
+ private_class_method def self.utf8(key, value)
179
+ value = value.to_s.dup.force_encoding(Encoding::UTF_8)
180
+ raise Error, "invalid #{field(key)} #{value.inspect}: not UTF-8" unless value.valid_encoding?
51
181
 
52
- def indent(code) = code.gsub(/^(?=.)/, " ")
182
+ value
183
+ end
53
184
 
54
- def valid(field, value)
55
- raise Error, "invalid #{field} #{value.inspect}" unless yield value
185
+ private_class_method def self.github(value)
186
+ return value if GITHUB.match?(value)
56
187
 
57
- value
188
+ raise Error, "invalid #{field(:github)} #{value.inspect}: 1 to 39 ASCII letters, digits and inner single hyphens"
58
189
  end
59
190
 
60
- # A value a file quotes: one line of valid UTF-8, its surrounding spaces stripped (Unicode's too),
61
- # short enough for the gemspec's line (RuboCop's 120 columns), which RubyGems does not refuse as
62
- # unfinished.
63
- def text(field, value)
64
- value = value.dup.force_encoding(Encoding::UTF_8)
65
- one_line = value.valid_encoding? && !value.match?(/[[:cntrl:]\u2028\u2029]/)
66
- line = one_line ? value.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "") : ""
67
- raise Error, "invalid #{field} #{value.inspect}: one line of text" if line.empty?
191
+ # A value the gemspec quotes: short enough for its line (RuboCop's 120 columns), and not one
192
+ # RubyGems refuses as unfinished.
193
+ private_class_method def self.text(field, value)
194
+ line = line(field, value)
68
195
  raise Error, "invalid #{field}: too long, its Ruby string at most 100 characters" if quote(line).length > 100
69
- if line.match?(/\A(TODO|FIXME)/)
70
- raise Error,
71
- "invalid #{field} #{line.inspect}: RubyGems refuses one starting TODO or FIXME"
72
- end
196
+ return line unless line.match?(/\A(TODO|FIXME)/)
197
+
198
+ raise Error, "invalid #{field} #{line.inspect}: RubyGems refuses one starting TODO or FIXME"
199
+ end
200
+
201
+ # One line, its surrounding spaces stripped (Unicode's too). U+FFFE and U+FFFF are outside YAML's
202
+ # printable characters: CITATION.cff's.
203
+ private_class_method def self.line(field, value)
204
+ line = value.match?(/[[:cntrl:]\u2028\u2029\uFFFE\uFFFF]/) ? "" : strip(value)
205
+ raise Error, "invalid #{field} #{value.inspect}: one line of text" if line.empty?
73
206
 
74
207
  line
75
208
  end
209
+
210
+ private
211
+
212
+ # What the gem holds beyond the library: a command line, a citation.
213
+ def extras(values)
214
+ @exe = values[:exe] == true
215
+ @citation = values[:citation] == true
216
+ Citation.check_values(values, citation)
217
+ cite(values) if citation
218
+ end
219
+
220
+ # CITATION.cff's author is a person: the family names it needs, the rest it may have.
221
+ def cite(values)
222
+ family = values[:family_names]
223
+ raise Error, "no family names: --family-names, CITATION.cff's author" unless family
224
+
225
+ @given_names = optional(:given_names, values[:given_names])
226
+ @family_names = Project.check(:family_names, family)
227
+ @alias_name = optional(:alias, values[:alias])
228
+ @orcid = optional(:orcid, values[:orcid])
229
+ @keywords = optional(:keywords, values[:keywords])&.split(", ")
230
+ end
231
+
232
+ # A value OPTIONAL names: none, false or nil, is nil.
233
+ def optional(key, value) = (Project.check(key, value) if value)
234
+
235
+ # a blank line left blank, no trailing spaces
236
+ def indent(code) = code.gsub(/^(?=.)/, " ")
76
237
  end
77
238
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Veragem
4
+ # A terminal's questions for what no option gives: each offers its default, which an empty answer
5
+ # takes, and is asked again until the answer passes the check the project's value would.
6
+ class Questions
7
+ # Each value's question, in the order asked; the name's comes first.
8
+ QUESTIONS = {
9
+ exe: "A command line, exe/%<name>s (y/n)",
10
+ citation: "A citation file, CITATION.cff (y/n)",
11
+ license: "License (#{License::CHOICES})",
12
+ summary: "Summary",
13
+ author: "Author",
14
+ family_names: "Family names, CITATION.cff's",
15
+ given_names: "Given names, CITATION.cff's",
16
+ orcid: "ORCID iD, CITATION.cff's",
17
+ keywords: "Keywords, comma-separated, CITATION.cff's",
18
+ email: "Email",
19
+ github: "GitHub user",
20
+ alias: "Alias, CITATION.cff's"
21
+ }.freeze
22
+ private_constant :QUESTIONS
23
+
24
+ # Whether +text+ is "-", an optional question's none, its spaces around it stripped as a value's
25
+ # are (Project.strip).
26
+ # @param text [String] an answer or an option's value, its bytes as given
27
+ # @return [Boolean]
28
+ def self.none?(text)
29
+ text = text.dup.force_encoding(Encoding::UTF_8)
30
+ text.valid_encoding? && Project.strip(text) == "-"
31
+ end
32
+
33
+ # Questions on +output+, answered on +input+, for a gem generated into +dir+.
34
+ # @param input [#gets, #tty?] where the answers are typed
35
+ # @param output [#print, #puts] where the questions are shown
36
+ # @param dir [String] where the gem is to be generated
37
+ # @param git [Hash{Symbol => String}] each value's git config key, which a refusal of the default
38
+ # git gives names
39
+ # @return [void]
40
+ def initialize(input, output, dir, git: {})
41
+ @input = input
42
+ @output = output
43
+ @dir = dir
44
+ @git = git
45
+ end
46
+
47
+ # The name, +given+ or else asked for: checked as far as it, +exe+ and +citation+ decide, before
48
+ # the rest.
49
+ # @param given [String, nil] the name the arguments give
50
+ # @param exe [Boolean] whether --exe is given
51
+ # @param citation [Boolean] whether --citation, or a citation's value, is given
52
+ # @return [String]
53
+ # @raise [Veragem::Error] +given+ is refused, nothing is answered, or the terminal cannot be asked
54
+ def name(given, exe:, citation:)
55
+ return check_name(given, exe, citation) if given
56
+
57
+ ask("Gem name", check: -> { check_name(it, exe, citation) }) #: String
58
+ end
59
+
60
+ # +given+ (the options) and the answers to the rest, +defaults+ offered; a citation's values asked
61
+ # only for a citation, the author's words offered for its names, the GitHub user for its alias.
62
+ # @param name [String] the gem's, checked
63
+ # @param given [Hash{Symbol => String, Boolean}] the options' values
64
+ # @param defaults [Hash{Symbol => String}] git's and Options' own
65
+ # @return [Hash{Symbol => String, Boolean, nil}] a value for each key asked, nil for none
66
+ # @raise [Veragem::Error] nothing is answered, or the terminal cannot be asked
67
+ def values(name, given, defaults)
68
+ values = given.dup #: Hash[Symbol, String | bool | nil]
69
+ QUESTIONS.except(*given.keys).each do |key, question|
70
+ next if Citation::KEYS.include?(key) && values[:citation] != true
71
+
72
+ check = -> { answer(key, it, name, values) }
73
+ offer = default(key, defaults, values)
74
+ values[key] = ask(format(question, name:), offer,
75
+ from: source(key), check:, optional: Project::OPTIONAL.key?(key))
76
+ end
77
+ values #: Project::values
78
+ end
79
+
80
+ private
81
+
82
+ def check_name(name, exe, citation) = Generator.check(name, exe:, citation:, target: File.join(@dir, name))
83
+
84
+ def answer(key, text, name, values)
85
+ case key
86
+ when :exe then yes?(text).tap { Generator.check_name(name, it) }
87
+ when :citation
88
+ yes?(text).tap do |cited|
89
+ Citation.check_values(values, cited)
90
+ Citation.check_name(name, cited)
91
+ end
92
+ else Project.check(key, text)
93
+ end
94
+ end
95
+
96
+ # The answer, or the default, through +check+; asked again, saying why, when there is neither, or
97
+ # the check fails, but an +optional+ one is nil -- and "-" its none, which a default would take
98
+ # otherwise.
99
+ def ask(question, default = nil, check:, from: "the default", optional: false)
100
+ default = offered(question, default, from, check)
101
+ loop do
102
+ reply = read(question, default, optional) || default
103
+ return if optional && (reply.nil? || Questions.none?(reply))
104
+
105
+ value = reply.nil? ? unanswered(question) : checked(question, reply, check)
106
+ return value unless value.nil?
107
+ end
108
+ end
109
+
110
+ # +default+ if it passes +check+: one refused, said why and where it is from, is not offered.
111
+ def offered(question, default, from, check)
112
+ default unless default.nil? || checked(question, default, check, " (#{from}, not offered)").nil?
113
+ end
114
+
115
+ # A line's text, nil when empty: its bytes as UTF-8 (whatever the locale's encoding), or as bytes
116
+ # where they are no UTF-8, as an argument's; stripped after, as Project strips a value.
117
+ def read(question, default, optional)
118
+ line = terminal(question) do
119
+ @output.print "#{question}#{" (- for none)" if optional}#{" [#{default}]" if default}: "
120
+ @input.gets.tap { @output.puts unless it }
121
+ end
122
+ raise Error, "no answer to #{question}" unless line
123
+
124
+ text = line.dup.force_encoding(Encoding::UTF_8)
125
+ answer = Project.strip(text.valid_encoding? ? text : text.b)
126
+ answer unless answer.empty?
127
+ end
128
+
129
+ # neither an answer nor a default: said so, and nil, as a refusal is
130
+ def unanswered(question)
131
+ terminal(question) { @output.puts "an answer is required" }
132
+ nil
133
+ end
134
+
135
+ def checked(question, answer, check, note = "")
136
+ check.call(answer)
137
+ rescue Error => e
138
+ terminal(question) { @output.puts "#{e}#{note}" }
139
+ nil
140
+ end
141
+
142
+ # The block's reading or writing of the terminal: one that fails cannot ask +question+, unlike a
143
+ # check's own failure.
144
+ def terminal(question)
145
+ yield
146
+ rescue SystemCallError, IOError, EncodingError => e
147
+ raise Error, "cannot ask #{question}: #{e}"
148
+ end
149
+
150
+ def yes?(answer)
151
+ case answer.downcase
152
+ in "y" | "yes" then true
153
+ in "n" | "no" then false
154
+ else raise Error, "y or n"
155
+ end
156
+ end
157
+
158
+ def default(key, defaults, values) = Citation.default(key, values) || { exe: "n", **defaults }[key]
159
+
160
+ # where a default is from, as a refusal of it says
161
+ def source(key) = @git[key] ? "git config #{@git[key]}" : "the default"
162
+ end
163
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Veragem
4
+ # The gem's summary, which is its module's comment too: one the gem's own yard-lint and RuboCop take
5
+ # as its documentation.
6
+ module Summary
7
+ # What YARD reads as a tag first in a comment: its own (`@param`, `(see`), and those yard-lint
8
+ # (.yard-lint.yml) takes for one written informally.
9
+ TAG = /\A(?:@|\(see\b|(?:note|important|todo|fixme|see(?:\ also)?|warning|deprecated|author|version|since
10
+ |returns|raises|example):)/xi
11
+ # A RuboCop annotation, no documentation, as its AnnotationComment reads one: a keyword first, then
12
+ # a colon, spaces before it or not, or a space -- but a sentence's capitalized first word, a word
13
+ # after it (a summary, stripped, has one after a space).
14
+ ANNOTATION = /\A(todo|fixme|optimize|hack|review|note)\b(\s*:)?(\s+)?/i
15
+ # A magic comment -- an encoding's anywhere, lower-case (Ruby's rule); Ruby's others first (past
16
+ # the ' " : ; it skips) or within -*-, in any case, `-` for `_` (a frozen string literal's after
17
+ # code, a warning: one the tests raise) -- or a RuboCop directive, which would turn its cops off,
18
+ # or fail them.
19
+ MAGIC = /\b(?:en)?coding\s*[:=]|(?:\A['":;\s]*|-\*-.*)(?i:(?:frozen[_-]string[_-]literal|warn[_-]indent|
20
+ shareable[_-]constant[_-]value)\s*:)|(?i:\brubocop\s*:)/x
21
+ # What yard-lint's Markdown and text checks refuse: a list marker (• ·) or a code block's fence
22
+ # first, an em or en dash outside code (.yard-lint.yml's TextSubstitution).
23
+ MARKDOWN = /\A(?:[•·]|```)/
24
+ DASH = /[—–]/
25
+ private_constant :TAG, :ANNOTATION, :MAGIC, :MARKDOWN, :DASH
26
+
27
+ # +summary+, refused where it holds a tag, an annotation, a magic comment or Markdown yard-lint
28
+ # refuses, or leaves a backtick or bold open -- a `**` outside code with a non-space beside it, as
29
+ # yard-lint counts (`x ** 2` is no bold).
30
+ # @param summary [String] one line of text, as Project.check passes it
31
+ # @return [String] +summary+
32
+ # @raise [Veragem::Error] the gem's yard-lint or RuboCop would not take it
33
+ def self.check(summary)
34
+ why = refusal(summary)
35
+ raise Error, "invalid summary #{summary.inspect}: #{why}" if why
36
+
37
+ summary
38
+ end
39
+
40
+ private_class_method def self.refusal(summary)
41
+ return "YARD would read a tag" if summary.match?(TAG)
42
+ return "RuboCop or Ruby would read an annotation or a directive" if annotation?(summary) || summary.match?(MAGIC)
43
+ return "yard-lint would refuse its Markdown" if summary.match?(MARKDOWN) || prose(summary).match?(DASH)
44
+
45
+ "a backtick or ** left open" if left_open?(summary)
46
+ end
47
+
48
+ private_class_method def self.left_open?(summary)
49
+ summary.count("`").odd? || prose(summary).scan(/(?<=\S)\*\*|\*\*(?=\S)/).size.odd?
50
+ end
51
+
52
+ # Its code's spans aside, as yard-lint reads the prose.
53
+ private_class_method def self.prose(summary) = summary.gsub(/`[^`]*`/, "")
54
+
55
+ private_class_method def self.annotation?(summary)
56
+ keyword, colon, space = summary.match(ANNOTATION)&.captures
57
+ return false unless keyword && (colon || space)
58
+
59
+ !(keyword == keyword.capitalize && !colon)
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Veragem
4
- VERSION = "0.1.0"
4
+ # The plugin's version.
5
+ VERSION = "0.2.0"
5
6
  end
data/lib/veragem.rb CHANGED
@@ -3,7 +3,11 @@
3
3
  require "bundler"
4
4
  require_relative "veragem/version"
5
5
  require_relative "veragem/name"
6
+ require_relative "veragem/citation"
7
+ require_relative "veragem/license"
8
+ require_relative "veragem/summary"
6
9
  require_relative "veragem/project"
10
+ require_relative "veragem/questions"
7
11
  require_relative "veragem/options"
8
12
  require_relative "veragem/generator"
9
13
 
@@ -12,9 +16,14 @@ module Veragem
12
16
  # A refusal: Bundler prints its message alone, and exits with its status.
13
17
  class Error < Bundler::PluginError; end
14
18
 
15
- # Generates the gem +argv+ names into +dir+; returns its directory.
16
- def self.run(argv, dir: Dir.pwd)
17
- project = Options.parse(argv)
19
+ # Generates the gem +argv+ names into +dir+, what no option gives asked of +input+ when a terminal.
20
+ # @param argv [Array<String>] the command's arguments
21
+ # @param dir [String] where the gem is to be generated
22
+ # @param input [#gets, #tty?] where the answers are typed
23
+ # @return [String] the gem's directory
24
+ # @raise [Veragem::Error] veragem refuses
25
+ def self.run(argv, dir: Dir.pwd, input: $stdin)
26
+ project = Options.parse(argv, dir:, input:)
18
27
  target = File.join(dir, project.name)
19
28
  Generator.generate(project, target)
20
29
  puts "Created #{project.name}. Next: cd #{project.name} && bin/setup (CONTRIBUTING.md's first steps)"
data/plugins.rb CHANGED
@@ -8,6 +8,10 @@ module Veragem
8
8
  class Command < Bundler::Plugin::API
9
9
  command "veragem"
10
10
 
11
+ # Runs `bundle veragem ARGS`.
12
+ # @param _command [String] "veragem"
13
+ # @param args [Array<String>] the command's arguments
14
+ # @return [String] the gem's directory
11
15
  def exec(_command, args)
12
16
  Veragem.run(args)
13
17
  end