diff --git a/lib/hanami/cli/command.rb b/lib/hanami/cli/command.rb index 87014b2d..67015466 100644 --- a/lib/hanami/cli/command.rb +++ b/lib/hanami/cli/command.rb @@ -27,7 +27,7 @@ def self.new( inflector: Dry::Inflector.new, **opts ) - super(out: out, err: err, fs: fs, inflector: inflector, **opts) + super end # Returns a new command. diff --git a/lib/hanami/cli/commands/app/generate/command.rb b/lib/hanami/cli/commands/app/generate/command.rb new file mode 100644 index 00000000..b16c45a2 --- /dev/null +++ b/lib/hanami/cli/commands/app/generate/command.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "dry/inflector" +require "dry/files" +require "shellwords" +require_relative "../../../naming" +require_relative "../../../errors" + +module Hanami + module CLI + module Commands + module App + module Generate + # @since 2.2.0 + # @api private + class Command < App::Command + argument :name, required: true, desc: "Name" + option :slice, required: false, desc: "Slice name" + + attr_reader :generator + private :generator + + # @since 2.2.0 + # @api private + def initialize( + fs:, + inflector:, + generator_class: nil, + **opts + ) + raise "Provide a generator class (that takes fs and inflector)" if generator_class.nil? + + super(fs: fs, inflector: inflector, **opts) + @generator = generator_class.new(fs: fs, inflector: inflector, out: out) + end + + # @since 2.2.0 + # @api private + def call(name:, slice: nil, **) + normalized_slice = inflector.underscore(Shellwords.shellescape(slice)) if slice + generator.call(app.namespace, name, normalized_slice) + end + end + end + end + end + end +end diff --git a/lib/hanami/cli/commands/app/generate/operation.rb b/lib/hanami/cli/commands/app/generate/operation.rb index ad4fb374..dc838327 100644 --- a/lib/hanami/cli/commands/app/generate/operation.rb +++ b/lib/hanami/cli/commands/app/generate/operation.rb @@ -13,34 +13,16 @@ module App module Generate # @since 2.2.0 # @api private - class Operation < App::Command - argument :name, required: true, desc: "Operation name" - option :slice, required: false, desc: "Slice name" - + class Operation < Generate::Command example [ %(books.add (MyApp::Books::Add)), %(books.add --slice=admin (Admin::Books::Add)), ] - attr_reader :generator - private :generator - - # @since 2.2.0 - # @api private - def initialize( - fs:, inflector:, - generator: Generators::App::Operation.new(fs: fs, inflector: inflector), - **opts - ) - super(fs: fs, inflector: inflector, **opts) - @generator = generator - end # @since 2.2.0 # @api private - def call(name:, slice: nil, **) - slice = inflector.underscore(Shellwords.shellescape(slice)) if slice - - generator.call(app.namespace, name, slice) + def initialize(**opts) + super(generator_class: Generators::App::Operation, **opts) end end end diff --git a/lib/hanami/cli/commands/app/generate/slice.rb b/lib/hanami/cli/commands/app/generate/slice.rb index 042bf180..545912bb 100644 --- a/lib/hanami/cli/commands/app/generate/slice.rb +++ b/lib/hanami/cli/commands/app/generate/slice.rb @@ -12,7 +12,7 @@ module App module Generate # @since 2.0.0 # @api private - class Slice < Command + class Slice < App::Command argument :name, required: true, desc: "The slice name" option :url, required: false, type: :string, desc: "The slice URL prefix" diff --git a/lib/hanami/cli/generators/app/operation.rb b/lib/hanami/cli/generators/app/operation.rb index 52754dd1..4923fb30 100644 --- a/lib/hanami/cli/generators/app/operation.rb +++ b/lib/hanami/cli/generators/app/operation.rb @@ -2,6 +2,7 @@ require "erb" require "dry/files" +require_relative "../constants" require_relative "../../errors" module Hanami @@ -21,54 +22,80 @@ def initialize(fs:, inflector:, out: $stdout) # @since 2.2.0 # @api private - def call(app, key, slice) - context = OperationContext.new(inflector, app, slice, key) + def call(app_namespace, key, slice) + operation_name = key.split(KEY_SEPARATOR)[-1] + local_namespaces = key.split(KEY_SEPARATOR)[..-2] + container_namespace = slice || app_namespace - if slice - generate_for_slice(context, slice) - else - generate_for_app(context) - end + raise_missing_slice_error_if_missing(slice) if slice + print_namespace_recommendation(operation_name) if local_namespaces.none? + + directory = directory(slice, local_namespaces: local_namespaces) + path = fs.join(directory, "#{operation_name}.rb") + fs.mkdir(directory) + + file_contents = class_definition( + operation_name: operation_name, + container_namespace: container_namespace, + local_namespaces: local_namespaces, + ) + fs.write(path, file_contents) end private attr_reader :fs, :inflector, :out - def generate_for_slice(context, slice) - slice_directory = fs.join("slices", slice) - raise MissingSliceError.new(slice) unless fs.directory?(slice_directory) + def directory(slice = nil, local_namespaces:) + base = if slice + fs.join("slices", slice) + else + fs.join("app") + end - if context.namespaces.any? - fs.mkdir(directory = fs.join(slice_directory, context.namespaces)) - fs.write(fs.join(directory, "#{context.name}.rb"), t("nested_slice_operation.erb", context)) + if local_namespaces.any? + fs.join(base, local_namespaces) else - fs.mkdir(directory = fs.join(slice_directory)) - fs.write(fs.join(directory, "#{context.name}.rb"), t("top_level_slice_operation.erb", context)) - out.puts(" Note: We generated a top-level operation. To generate into a directory, add a namespace: `my_namespace.#{context.name}`") + fs.join(base) end end - def generate_for_app(context) - if context.namespaces.any? - fs.mkdir(directory = fs.join("app", context.namespaces)) - fs.write(fs.join(directory, "#{context.name}.rb"), t("nested_app_operation.erb", context)) - else - fs.mkdir(directory = fs.join("app")) - out.puts(" Note: We generated a top-level operation. To generate into a directory, add a namespace: `my_namespace.#{context.name}`") - fs.write(fs.join(directory, "#{context.name}.rb"), t("top_level_app_operation.erb", context)) - end + def class_definition(operation_name:, container_namespace:, local_namespaces:) + container_module = normalize(container_namespace) + + modules = local_namespaces + .map { normalize(_1) } + .compact + .prepend(container_module) + + parent_class = [container_module, "Operation"].join("::") + + RubyFileGenerator.class( + normalize(operation_name), + parent_class: parent_class, + modules: modules, + body: ["def call", "end"], + header: ["# frozen_string_literal: true"], + ) end - def template(path, context) - require "erb" + def normalize(name) + inflector.camelize(name).gsub(/[^\p{Alnum}]/, "") + end - ERB.new( - File.read(__dir__ + "/operation/#{path}") - ).result(context.ctx) + def print_namespace_recommendation(operation_name) + out.puts( + " Note: We generated a top-level operation. " \ + "To generate into a directory, add a namespace: `my_namespace.#{operation_name}`" + ) end - alias_method :t, :template + def raise_missing_slice_error_if_missing(slice) + if slice + slice_directory = fs.join("slices", slice) + raise MissingSliceError.new(slice) unless fs.directory?(slice_directory) + end + end end end end diff --git a/lib/hanami/cli/generators/app/operation/nested_app_operation.erb b/lib/hanami/cli/generators/app/operation/nested_app_operation.erb deleted file mode 100644 index f0b7a131..00000000 --- a/lib/hanami/cli/generators/app/operation/nested_app_operation.erb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module <%= camelized_app_name %> -<%= module_namespace_declaration %> -<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_app_name %>::Operation -<%= module_namespace_offset %> def call -<%= module_namespace_offset %> end -<%= module_namespace_offset %>end -<%= module_namespace_end %> -end diff --git a/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb b/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb deleted file mode 100644 index a9a448ad..00000000 --- a/lib/hanami/cli/generators/app/operation/nested_slice_operation.erb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module <%= camelized_slice_name %> -<%= module_namespace_declaration %> -<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_slice_name %>::Operation -<%= module_namespace_offset %> def call -<%= module_namespace_offset %> end -<%= module_namespace_offset %>end -<%= module_namespace_end %> -end diff --git a/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb b/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb deleted file mode 100644 index 0ce59f47..00000000 --- a/lib/hanami/cli/generators/app/operation/top_level_app_operation.erb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module <%= camelized_app_name %> -<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_app_name %>::Operation -<%= module_namespace_offset %> def call -<%= module_namespace_offset %> end -<%= module_namespace_offset %>end -end diff --git a/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb b/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb deleted file mode 100644 index 240448ab..00000000 --- a/lib/hanami/cli/generators/app/operation/top_level_slice_operation.erb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module <%= camelized_slice_name %> -<%= module_namespace_offset %>class <%= camelized_name %> < <%= camelized_slice_name %>::Operation -<%= module_namespace_offset %> def call -<%= module_namespace_offset %> end -<%= module_namespace_offset %>end -end diff --git a/lib/hanami/cli/generators/app/operation_context.rb b/lib/hanami/cli/generators/app/operation_context.rb deleted file mode 100644 index c7931a56..00000000 --- a/lib/hanami/cli/generators/app/operation_context.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require "dry/files/path" -require_relative "slice_context" -require_relative "../constants" - -module Hanami - module CLI - module Generators - # @since 2.2.0 - # @api private - module App - # @since 2.2.0 - # @api private - class OperationContext < SliceContext - # @since 2.2.0 - # @api private - attr_reader :key - - # @since 2.2.0 - # @api private - def initialize(inflector, app, slice, key) - @key = key - super(inflector, app, slice, nil) - end - - # @since 2.2.0 - # @api private - def namespaces - @namespaces ||= key.split(KEY_SEPARATOR)[..-2] - end - - # @since 2.2.0 - # @api private - def name - @name ||= key.split(KEY_SEPARATOR)[-1] - end - - # @api private - # @since 2.2.0 - # @api private - def camelized_name - inflector.camelize(name) - end - - # @since 2.2.0 - # @api private - def module_namespace_declaration - namespaces.each_with_index.map { |token, i| - "#{OFFSET}#{INDENTATION * i}module #{inflector.camelize(token)}" - }.join($/) - end - - # @since 2.2.0 - # @api private - def module_namespace_end - namespaces.each_with_index.map { |_, i| - "#{OFFSET}#{INDENTATION * i}end" - }.reverse.join($/) - end - - # @since 2.2.0 - # @api private - def module_namespace_offset - "#{OFFSET}#{INDENTATION * namespaces.count}" - end - end - end - end - end -end diff --git a/lib/hanami/cli/ruby_file_generator.rb b/lib/hanami/cli/ruby_file_generator.rb new file mode 100644 index 00000000..2652cf50 --- /dev/null +++ b/lib/hanami/cli/ruby_file_generator.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "ripper" + +module Hanami + module CLI + class RubyFileGenerator + # @api private + # @since 2.2.0 + class GeneratedUnparseableCodeError < Error + def initialize(source_code) + super( + <<~ERROR_MESSAGE + Sorry, the code we generated is not valid Ruby. + + Here's what we got: + + #{source_code} + + Please fix the errors and try again. + ERROR_MESSAGE + ) + end + end + + INDENT = " " + + def self.class(class_name, **args) + new(class_name: class_name, **args).to_s + end + + def self.module(*names, **args) + module_names = if names.first.is_a?(Array) + names.first + else + names + end + + new(modules: module_names, class_name: nil, parent_class: nil, **args).to_s + end + + def initialize( + class_name: nil, + parent_class: nil, + modules: [], + header: [], + body: [] + ) + @class_name = class_name + @parent_class = parent_class + @modules = modules + @header = header.any? ? (header + [""]) : [] + @body = body + end + + def to_s + definition = lines(modules).map { |line| "#{line}\n" }.join + source_code = [header, definition].flatten.join("\n") + ensure_parseable!(source_code) + source_code + end + + private + + attr_reader( + :class_name, + :parent_class, + :modules, + :header, + :body + ) + + def lines(remaining_modules) + this_module, *rest_modules = remaining_modules + if this_module + with_module_lines(this_module, lines(rest_modules)) + elsif class_name + class_lines + else + body + end + end + + def with_module_lines(module_name, contents_lines) + [ + "module #{module_name}", + *contents_lines.map { |line| indent(line) }, + "end" + ] + end + + def class_lines + [ + class_definition, + *body.map { |line| indent(line) }, + "end" + ].compact + end + + def class_definition + if parent_class + "class #{class_name} < #{parent_class}" + else + "class #{class_name}" + end + end + + def indent(line) + if line.strip.empty? + "" + else + INDENT + line + end + end + + def ensure_parseable!(source_code) + unless Ripper.sexp(source_code) + raise GeneratedUnparseableCodeError.new(source_code) + end + end + end + end +end diff --git a/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb b/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb index b190f68a..76970a59 100644 --- a/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb +++ b/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb @@ -3,12 +3,12 @@ require "hanami" RSpec.describe Hanami::CLI::Commands::App::Generate::Operation, :app do - subject { described_class.new(fs: fs, inflector: inflector, generator: generator) } + subject { described_class.new(fs: fs, inflector: inflector, generator_class: generator_class, out: out) } let(:out) { StringIO.new } let(:fs) { Hanami::CLI::Files.new(memory: true, out: out) } let(:inflector) { Dry::Inflector.new } - let(:generator) { Hanami::CLI::Generators::App::Operation.new(fs: fs, inflector: inflector, out: out) } + let(:generator_class) { Hanami::CLI::Generators::App::Operation } let(:app) { Hanami.app.namespace } let(:dir) { inflector.underscore(app) } diff --git a/spec/unit/hanami/cli/ruby_file_generator_spec.rb b/spec/unit/hanami/cli/ruby_file_generator_spec.rb new file mode 100644 index 00000000..03cb662f --- /dev/null +++ b/spec/unit/hanami/cli/ruby_file_generator_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::CLI::RubyFileGenerator do + describe ".class" do + describe "without modules" do + it "generates class without parent class" do + expect( + described_class.class("Greeter") + ).to( + eq( + <<~OUTPUT + class Greeter + end + OUTPUT + ) + ) + end + + it "generates class with parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + ).to_s + ).to( + eq( + <<~OUTPUT + class Greeter < BaseService + end + OUTPUT + ) + ) + end + + it "generates class with parent class and body" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + body: %w[foo bar] + ).to_s + ).to( + eq( + <<~OUTPUT + class Greeter < BaseService + foo + bar + end + OUTPUT + ) + ) + end + end + + describe "with 1 module" do + it "generates class without parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + modules: %w[Services], + ) + ).to( + eq( + <<~OUTPUT + module Services + class Greeter + end + end + OUTPUT + ) + ) + end + + it "generates class with parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + modules: %w[Services] + ) + ).to( + eq( + <<~OUTPUT + module Services + class Greeter < BaseService + end + end + OUTPUT + ) + ) + end + + it "generates class with parent class, body and header" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + modules: %w[Services], + header: ["# hello world"], + body: %w[foo bar] + ) + ).to( + eq( + <<~OUTPUT + # hello world + + module Services + class Greeter < BaseService + foo + bar + end + end + OUTPUT + ) + ) + end + end + + describe "with two modules" do + it "generates class without parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + modules: %w[Admin Services], + ) + ).to( + eq( + <<~OUTPUT + module Admin + module Services + class Greeter + end + end + end + OUTPUT + ) + ) + end + + it "generates class with parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + modules: %w[Admin Services] + ) + ).to( + eq( + <<~OUTPUT + module Admin + module Services + class Greeter < BaseService + end + end + end + OUTPUT + ) + ) + end + end + + describe "with three modules" do + it "generates class without parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + modules: %w[Internal Admin Services] + ) + ).to( + eq( + <<~OUTPUT + module Internal + module Admin + module Services + class Greeter + end + end + end + end + OUTPUT + ) + ) + end + + it "generates class with parent class" do + expect( + Hanami::CLI::RubyFileGenerator.class( + "Greeter", + parent_class: "BaseService", + modules: %w[Internal Admin Services] + ) + ).to( + eq( + <<~OUTPUT + module Internal + module Admin + module Services + class Greeter < BaseService + end + end + end + end + OUTPUT + ) + ) + end + end + end + + describe ".module" do + describe "without frozen_string_literal" do + describe "top-level" do + it "generates module by itself" do + expect( + described_class.module("Greetable") + ).to( + eq( + <<~OUTPUT + module Greetable + end + OUTPUT + ) + ) + end + + it "generates modules nested in a module, from array" do + expect( + described_class.module(%w[External Greetable]) + ).to( + eq( + <<~OUTPUT + module External + module Greetable + end + end + OUTPUT + ) + ) + end + + it "generates modules nested in a module, from array with header and body" do + expect( + described_class.module( + %w[External Greetable], + header: ["# hello world"], + body: %w[foo bar] + ) + ).to( + eq( + <<~OUTPUT + # hello world + + module External + module Greetable + foo + bar + end + end + OUTPUT + ) + ) + end + + it "generates modules nested in a module, from list" do + expect( + described_class.module("Admin", "External", "Greetable") + ).to( + eq( + <<~OUTPUT + module Admin + module External + module Greetable + end + end + end + OUTPUT + ) + ) + end + end + end + end + + it "fails to generate unparseable ruby code" do + expect { described_class.class("%%Greeter") }.to( + raise_error(Hanami::CLI::RubyFileGenerator::GeneratedUnparseableCodeError) + ) + end +end