scaffold_gem.rb 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. # -*- coding: utf-8 -*-
  2. # -*- frozen_string_literal: true -*-
  3. require "optparse"
  4. require "optparse/uri"
  5. require "forwardable"
  6. require "erb"
  7. require "fileutils"
  8. # The script's main function.
  9. def scaffold_gem
  10. options = ScaffoldCLI.new.parse $argv
  11. details = ScaffoldDetails.new options
  12. Scaffold.gem details
  13. end
  14. class Scaffold
  15. # Path character wildcard.
  16. WILDCARD = "*"
  17. # Global access to path, and its in-between directories.
  18. GLOB = "**"
  19. # Gem file full name.
  20. STAND_IN_NAME = "_gem_name"
  21. # Gem file name that may be scoped, like plugins.
  22. STAND_IN_BASENAME = "_gem_basename"
  23. # Default binstub alternative platform
  24. ALT_PLATFORM_ID = "-alpine"
  25. # Test file identifier
  26. TEST_IDENTIFIER = "test_"
  27. # Scaffold main entry point. Assumes __dir__ is the working directory.
  28. def self.gem details
  29. scaffold = new details, directory: __dir__
  30. scaffold.
  31. fill_templates.
  32. rename_templates.
  33. rename_gem_files.
  34. clean_up
  35. end
  36. def initialize details, parser: ERB, directory:, template_extension: ".erb"
  37. @scaffold = details
  38. @parser = parser
  39. @directory = set_scaffold_dir(directory)
  40. @template_extension = template_extension
  41. end
  42. def templates
  43. Dir[directory + GLOB + dir_div + WILDCARD + template_extension]
  44. end
  45. def fill template
  46. content = @parser.new(File.read template).result binding
  47. File.open(template, "wb") { |f| f.write content }
  48. end
  49. def fill_templates
  50. templates.each { |t| fill t }
  51. self
  52. end
  53. def rename_templates
  54. templates.each do |template|
  55. new_name = template.sub(%r/#{template_extension}\z/, "")
  56. FileUtils.mv template, new_name
  57. end
  58. self
  59. end
  60. def rename_gem_files
  61. path = directory + GLOB + dir_div + WILDCARD
  62. files =
  63. Dir[path + STAND_IN_NAME + WILDCARD] + Dir[path + STAND_IN_BASENAME + WILDCARD]
  64. files.map { |file| FileUtils.mv file, new_filename(file), force: true }
  65. self
  66. end
  67. def clean_up
  68. remove_binstub unless @scaffold.bin
  69. relocate_gem if @scaffold.plugin_namespace
  70. remove_scaffolding
  71. end
  72. private
  73. attr_reader :directory, :template_extension
  74. def new_filename file
  75. old_name = File.basename(file)
  76. new_name = old_name.include?(STAND_IN_NAME) ? @scaffold.gem_name : @scaffold.gem_basename
  77. new_name.prepend(TEST_IDENTIFIER) if old_name.start_with?(TEST_IDENTIFIER)
  78. new_name = new_name + ALT_PLATFORM_ID if old_name.end_with?(ALT_PLATFORM_ID)
  79. File.dirname(file) + dir_div + new_name + File.extname(file)
  80. end
  81. def remove_binstub
  82. %W[#{directory}/bin #{directory}/test/bin].each { |dir| FileUtils.rm_r dir }
  83. end
  84. def relocate_gem
  85. entry_point = "lib/#{@scaffold.main_basename}"
  86. FileUtils.mkdir_p "#{directory}#{entry_point}"
  87. FileUtils.mkdir_p "#{directory}test/#{entry_point}"
  88. FileUtils.mv Dir["#{directory}lib/**/*"],
  89. "#{directory}#{entry_point}", force: true
  90. FileUtils.mv Dir["#{directory}test/lib/**/*"],
  91. "#{directory}test/#{entry_point}", force: true
  92. end
  93. def remove_scaffolding
  94. %W[
  95. #{directory}.git
  96. #{directory}.scaffold-ci.yml
  97. #{directory}ReadMe.org
  98. #{directory}ChangeLog.org
  99. #{directory}scaffold_gem.rb
  100. #{directory}test/test_scaffold_gem.rb
  101. ].each { |sf| FileUtils.rm_rf sf }
  102. end
  103. def set_scaffold_dir dir
  104. dir + dir_div
  105. end
  106. def dir_div
  107. ScaffoldDetails::DIR_DIV
  108. end
  109. end
  110. class ScaffoldDetails
  111. CAMEL_CASE_REGEX = /([a-z\d])([A-Z])/
  112. CONSTANT_RESOLUTION_OPERATOR = File::PATH_SEPARATOR * 2
  113. PLUGIN_IDENTIFIER = "-"
  114. DIR_DIV = File::SEPARATOR
  115. DIR_BACKTRACK = ".."
  116. extend Forwardable
  117. def_delegators :@options, :namespace, :author, :email, :repo, :license, :bin
  118. public :binding
  119. def initialize options
  120. @options = options
  121. @formatted_name = options.formatted_name
  122. end
  123. # Gem main namespace #=> SomeGem
  124. def main_namespace
  125. split_namespace.first
  126. end
  127. # Gem plugin namespace #=> SomePlugin
  128. def plugin_namespace
  129. _, scoped, _ = split_namespace
  130. scoped
  131. rescue
  132. nil
  133. end
  134. # The gem's namespace
  135. # Given SomeGem::SomePlugin #=> SomePlugin
  136. # Given SomeGem #=> SomeGem
  137. def gem_namespace
  138. plugin_namespace or main_namespace
  139. end
  140. # The gem's name as register in Rubygems.org
  141. # Given SomeGem::SomePlugin #=> some_gem-some_plugin
  142. def gem_name
  143. snake_case = '\1_\2'
  144. @gem_name ||= namespace.
  145. gsub(CAMEL_CASE_REGEX, snake_case).
  146. downcase.
  147. gsub CONSTANT_RESOLUTION_OPERATOR, PLUGIN_IDENTIFIER
  148. end
  149. # The gem's constant
  150. # Given SomeGem #=> SOME_GEM
  151. # Given SomeGem::SomePlugin #=> SOME_PLUGIN
  152. def gem_constant
  153. @constant ||= begin
  154. main, plugin, _ = gem_name_parts.map &:upcase
  155. plugin or main
  156. end
  157. end
  158. # Gem formatted name. As display in the ReadMe.
  159. # Given SomeGem::SomePlugin #=> Some Gem - Some Plugin
  160. # Can be setup to any valid string via the options given to a new scaffold_details.
  161. def formatted_name
  162. return @formatted_name unless @formatted_name.empty?
  163. words = '\1 \2'
  164. @formatted_name = namespace.
  165. gsub(CAMEL_CASE_REGEX, words).
  166. gsub CONSTANT_RESOLUTION_OPERATOR, formatted_plugin_identifier
  167. end
  168. # Gem main basename
  169. # Whether given SomeGem or SomeGem::SomePlugin #=> some_gem
  170. def main_basename
  171. gem_name_parts[0]
  172. end
  173. # Gem plugin basename
  174. # Given SomeGem::SomePlugin #=> some_plugin
  175. def plugin_basename
  176. gem_name_parts[1]
  177. end
  178. # Gem's basename
  179. # Given SomeGem::SomePlugin #=> some_plugin
  180. # Given SomeGem #=> some_gem
  181. def gem_basename
  182. plugin_basename or main_basename
  183. end
  184. # Gem's path
  185. # Given SomeGem #=> some_gem
  186. # Given SomeGem::SomePlugin #=> some_gem/some_plugin
  187. def gem_path
  188. plugin = DIR_DIV + plugin_basename if plugin_basename
  189. "#{main_basename}#{plugin}"
  190. end
  191. # Print the gem's require statement
  192. # Whether given SomeGem or SomeGem::SomePlugin #=> require "some_gem"
  193. def require_main
  194. %Q{require "#{main_basename}"}
  195. end
  196. # Print the gem's require statement as a plugin
  197. # Given SomeGem::SomePlugin #=> require "some_gem/some_plugin"
  198. def require_plugin
  199. return unless plugin_basename
  200. %Q{require "#{gem_path}"}
  201. end
  202. # Print minitest's config helper require statement from toplevel files
  203. # Given SomeGem #=> require_relative "./../_config/minitest"
  204. # Given SomeGem::SomePlugin #=> require_relative "./../../_config/minitest"
  205. def toplevel_require_minitest_config
  206. backtrack = plugin_namespace ? double_backtrack_dir : DIR_BACKTRACK
  207. require_minitest_config backtrack
  208. end
  209. # Print minitest's config helper require statement from sublevel files
  210. # Given SomeGem #=> require_relative "./../../_config/minitest"
  211. # Given SomeGem::SomePlugin #=> require_relative "./../../../_config/minitest"
  212. def sublevel_require_minitest_config
  213. triple_backtrack = double_backtrack_dir + backtrack_again
  214. backtrack = plugin_namespace ? triple_backtrack : double_backtrack_dir
  215. require_minitest_config backtrack
  216. end
  217. private
  218. def double_backtrack_dir
  219. DIR_BACKTRACK + backtrack_again
  220. end
  221. def backtrack_again
  222. DIR_DIV + DIR_BACKTRACK
  223. end
  224. def require_minitest_config backtrack
  225. %Q{require_relative "./#{backtrack}/_config/minitest"}
  226. end
  227. def gem_name_parts
  228. @gem_name_parts ||= gem_name.split PLUGIN_IDENTIFIER
  229. end
  230. def formatted_plugin_identifier
  231. " #{PLUGIN_IDENTIFIER} "
  232. end
  233. def split_namespace
  234. namespace.split CONSTANT_RESOLUTION_OPERATOR
  235. end
  236. end
  237. class ScaffoldCLI
  238. # The scaffold CLI options. Can be initialized using keywords.
  239. # Missing members default to nil, except for bin, falling back to +false+, and
  240. # formatted_name, to an empty string.
  241. ScaffoldOptions = Struct.new :namespace, :author, :email, :repo, :license,
  242. :bin, :formatted_name, keyword_init: true do
  243. def initialize namespace:nil, author:nil, email:nil, repo:nil, license:nil,
  244. bin:nil, formatted_name:nil
  245. super
  246. self.bin ||= false
  247. self.formatted_name ||= ""
  248. end
  249. end
  250. def initialize options=ScaffoldOptions.new, notice=$stdout, err=$stderr
  251. @notice = notice
  252. @options = options
  253. @err = err
  254. end
  255. def parse argv=$argv, parser: OptionParser
  256. cli = build_argv_parser parser
  257. cli.parse!(*argv)
  258. check_mandatory_values options, cli
  259. options.freeze
  260. end
  261. private # :nodoc:
  262. attr_reader :options, :notice, :err
  263. def check_mandatory_values opts, cli
  264. all_mandatory_values =
  265. opts.namespace && opts.author && opts.email && opts.repo && opts.license
  266. unless all_mandatory_values
  267. err.puts "Must provide all mandatory options."
  268. show_help cli
  269. exit_cli status: 2
  270. end
  271. end
  272. def build_argv_parser parser
  273. parser.new do |p|
  274. set_formatted_name p
  275. set_bin p
  276. set_license p
  277. set_repo p
  278. set_email p
  279. set_author p
  280. set_namespace p
  281. display_help p
  282. end
  283. end
  284. def set_formatted_name cli
  285. desc = "Add unsupported name style convention"
  286. cli.on("-f", "--formatted-name=NAME", String, desc) { |n| options.formatted_name = n }
  287. end
  288. def set_bin cli
  289. desc = "Add binstub"
  290. cli.on("-b", "--bin", TrueClass, desc) { |b| options.bin = b }
  291. end
  292. def set_license cli
  293. desc = "Set mandatory license identifier. Details https://spdx.org/licenses"
  294. cli.on("-l", "--license=LICENSE", String, desc) { |l| options.license = l }
  295. end
  296. def set_repo cli
  297. desc = "Set mandatory gem repo URL"
  298. cli.on("-r", "--repo=REPO", URI, desc) { |r| options.repo = r.to_s }
  299. end
  300. def set_email cli
  301. desc = "Set mandatory gem contact email"
  302. # regex from https://www.owasp.org/index.php/OWASP_Validation_Regex_Repository
  303. regex = /\A[a-zA-Z0-9_+&*-]+(?:\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,7}\z/
  304. cli.on("-e", "--email=EMAIL", regex, desc) { |e| options.email = e }
  305. end
  306. def set_author cli
  307. desc = "Set mandatory gem author"
  308. cli.on("-a", "--author=AUTHOR", String, desc) { |a| options.author = a }
  309. end
  310. def set_namespace cli
  311. desc = "Set mandatory gem namespace"
  312. cli.on("-n", "--namespace=NAMESPACE", String, desc) { |n| options.namespace = n }
  313. end
  314. def display_help cli
  315. cli.on_tail("-h", "--help", "Show this message") {
  316. show_help cli
  317. exit_cli
  318. }
  319. end
  320. def show_help cli
  321. notice.puts cli
  322. end
  323. def exit_cli status: 0
  324. exit status unless ENV["GEM_SCAFFOLD_ENV"] == "test"
  325. end
  326. end
  327. # The ruby interpreter scaffold_gem when the script is run as command line program
  328. if $PROGRAM_NAME == __FILE__
  329. scaffold_gem
  330. end