diff --git a/lib/hanami/cli/commands/app.rb b/lib/hanami/cli/commands/app.rb index 2a583a35..8e29c540 100644 --- a/lib/hanami/cli/commands/app.rb +++ b/lib/hanami/cli/commands/app.rb @@ -46,6 +46,7 @@ def self.extended(base) prefix.register "operation", Generate::Operation prefix.register "part", Generate::Part prefix.register "slice", Generate::Slice + prefix.register "struct", Generate::Struct prefix.register "view", Generate::View end end diff --git a/lib/hanami/cli/commands/app/generate/command.rb b/lib/hanami/cli/commands/app/generate/command.rb index b16c45a2..800e57ec 100644 --- a/lib/hanami/cli/commands/app/generate/command.rb +++ b/lib/hanami/cli/commands/app/generate/command.rb @@ -25,15 +25,17 @@ class Command < App::Command 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) + super @generator = generator_class.new(fs: fs, inflector: inflector, out: out) end + def generator_class + # Must be implemented by subclasses, with class that takes: + # fs:, inflector:, out: + end + # @since 2.2.0 # @api private def call(name:, slice: nil, **) diff --git a/lib/hanami/cli/commands/app/generate/operation.rb b/lib/hanami/cli/commands/app/generate/operation.rb index dc838327..14047ff8 100644 --- a/lib/hanami/cli/commands/app/generate/operation.rb +++ b/lib/hanami/cli/commands/app/generate/operation.rb @@ -1,11 +1,5 @@ # frozen_string_literal: true -require "dry/inflector" -require "dry/files" -require "shellwords" -require_relative "../../../naming" -require_relative "../../../errors" - module Hanami module CLI module Commands @@ -19,10 +13,8 @@ class Operation < Generate::Command %(books.add --slice=admin (Admin::Books::Add)), ] - # @since 2.2.0 - # @api private - def initialize(**opts) - super(generator_class: Generators::App::Operation, **opts) + def generator_class + Generators::App::Operation end end end diff --git a/lib/hanami/cli/commands/app/generate/struct.rb b/lib/hanami/cli/commands/app/generate/struct.rb new file mode 100644 index 00000000..c6a4a7b7 --- /dev/null +++ b/lib/hanami/cli/commands/app/generate/struct.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Hanami + module CLI + module Commands + module App + module Generate + # @since 2.2.0 + # @api private + class Struct < Command + argument :name, required: true, desc: "Struct name" + option :slice, required: false, desc: "Slice name" + + example [ + %(book (MyApp::Structs::Book)), + %(book/published_book (MyApp::Structs::Book::PublishedBook)), + %(book --slice=admin (Admin::Structs::Book)), + ] + + def generator_class + Generators::App::Struct + end + end + end + end + end + end +end diff --git a/lib/hanami/cli/generators/app/operation.rb b/lib/hanami/cli/generators/app/operation.rb index 4923fb30..6b55c9e7 100644 --- a/lib/hanami/cli/generators/app/operation.rb +++ b/lib/hanami/cli/generators/app/operation.rb @@ -23,79 +23,27 @@ def initialize(fs:, inflector:, out: $stdout) # @since 2.2.0 # @api private 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 - - 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) + RubyFileWriter.new( + fs: fs, + inflector: inflector, + app_namespace: app_namespace, + key: key, + slice: slice, + relative_parent_class: "Operation", + body: ["def call", "end"], + ).call - file_contents = class_definition( - operation_name: operation_name, - container_namespace: container_namespace, - local_namespaces: local_namespaces, - ) - fs.write(path, file_contents) + unless key.match?(KEY_SEPARATOR) + out.puts( + " Note: We generated a top-level operation. " \ + "To generate into a directory, add a namespace: `my_namespace.add_book`" + ) + end end private attr_reader :fs, :inflector, :out - - def directory(slice = nil, local_namespaces:) - base = if slice - fs.join("slices", slice) - else - fs.join("app") - end - - if local_namespaces.any? - fs.join(base, local_namespaces) - else - fs.join(base) - end - 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 normalize(name) - inflector.camelize(name).gsub(/[^\p{Alnum}]/, "") - end - - 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 - - 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/ruby_file_writer.rb b/lib/hanami/cli/generators/app/ruby_file_writer.rb new file mode 100644 index 00000000..e49a8f31 --- /dev/null +++ b/lib/hanami/cli/generators/app/ruby_file_writer.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "erb" +require "dry/files" +require_relative "../constants" +require_relative "../../errors" + +module Hanami + module CLI + module Generators + module App + # @since 2.2.0 + # @api private + class RubyFileWriter + # @since 2.2.0 + # @api private + def initialize( + fs:, + inflector:, + app_namespace:, + key:, + slice:, + relative_parent_class:, + extra_namespace: nil, + body: [] + ) + @fs = fs + @inflector = inflector + @app_namespace = app_namespace + @key = key + @slice = slice + @extra_namespace = extra_namespace&.downcase + @relative_parent_class = relative_parent_class + @body = body + raise_missing_slice_error_if_missing(slice) if slice + end + + # @since 2.2.0 + # @api private + def call + fs.mkdir(directory) + fs.write(path, file_contents) + end + + private + + # @since 2.2.0 + # @api private + attr_reader( + :fs, + :inflector, + :app_namespace, + :key, + :slice, + :extra_namespace, + :relative_parent_class, + :body, + ) + + # @since 2.2.0 + # @api private + def file_contents + class_definition( + class_name: class_name, + container_namespace: container_namespace, + local_namespaces: local_namespaces, + ) + end + + # @since 2.2.0 + # @api private + def class_name + key.split(KEY_SEPARATOR)[-1] + end + + # @since 2.2.0 + # @api private + def container_namespace + slice || app_namespace + end + + # @since 2.2.0 + # @api private + def local_namespaces + Array(extra_namespace) + key.split(KEY_SEPARATOR)[..-2] + end + + # @since 2.2.0 + # @api private + def directory + base = if slice + fs.join("slices", slice) + else + fs.join("app") + end + + @directory ||= if local_namespaces.any? + fs.join(base, local_namespaces) + else + fs.join(base) + end + end + + # @since 2.2.0 + # @api private + def path + fs.join(directory, "#{class_name}.rb") + end + + # @since 2.2.0 + # @api private + def class_definition(class_name:, container_namespace:, local_namespaces:) + container_module = normalize(container_namespace) + + modules = local_namespaces + .map { normalize(_1) } + .compact + .prepend(container_module) + + parent_class = [container_module, relative_parent_class].join("::") + + RubyFileGenerator.class( + normalize(class_name), + parent_class: parent_class, + modules: modules, + header: ["# frozen_string_literal: true"], + body: body + ) + end + + # @since 2.2.0 + # @api private + def normalize(name) + inflector.camelize(name).gsub(/[^\p{Alnum}]/, "") + end + + # @since 2.2.0 + # @api private + 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 + end +end diff --git a/lib/hanami/cli/generators/app/struct.rb b/lib/hanami/cli/generators/app/struct.rb new file mode 100644 index 00000000..b586587a --- /dev/null +++ b/lib/hanami/cli/generators/app/struct.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "erb" +require "dry/files" +require_relative "../constants" +require_relative "../../errors" + +module Hanami + module CLI + module Generators + module App + # @since 2.2.0 + # @api private + class Struct + # @since 2.2.0 + # @api private + def initialize(fs:, inflector:, out: $stdout) + @fs = fs + @inflector = inflector + @out = out + end + + # @since 2.2.0 + # @api private + def call(app_namespace, key, slice) + RubyFileWriter.new( + fs: fs, + inflector: inflector, + app_namespace: app_namespace, + key: key, + slice: slice, + extra_namespace: "Structs", + relative_parent_class: "DB::Struct", + ).call + end + + private + + attr_reader :fs, :inflector, :out + 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 76970a59..4f2e98ef 100644 --- a/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb +++ b/spec/unit/hanami/cli/commands/app/generate/operation_spec.rb @@ -3,17 +3,16 @@ require "hanami" RSpec.describe Hanami::CLI::Commands::App::Generate::Operation, :app do - subject { described_class.new(fs: fs, inflector: inflector, generator_class: generator_class, out: out) } + subject { described_class.new(fs: fs, inflector: inflector, out: out) } let(:out) { StringIO.new } let(:fs) { Hanami::CLI::Files.new(memory: true, out: out) } let(:inflector) { Dry::Inflector.new } - let(:generator_class) { Hanami::CLI::Generators::App::Operation } let(:app) { Hanami.app.namespace } let(:dir) { inflector.underscore(app) } def output - out.rewind && out.read.chomp + out.string.chomp end context "generating for app" do diff --git a/spec/unit/hanami/cli/commands/app/generate/struct_spec.rb b/spec/unit/hanami/cli/commands/app/generate/struct_spec.rb new file mode 100644 index 00000000..9f443b99 --- /dev/null +++ b/spec/unit/hanami/cli/commands/app/generate/struct_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +RSpec.describe Hanami::CLI::Commands::App::Generate::Struct, :app do + subject { described_class.new(fs: fs, inflector: inflector, out: out) } + + let(:out) { StringIO.new } + let(:fs) { Hanami::CLI::Files.new(memory: true, out: out) } + let(:inflector) { Dry::Inflector.new } + let(:app) { Hanami.app.namespace } + let(:dir) { inflector.underscore(app) } + + def output + out.string.chomp + end + + context "generating for app" do + it "generates a struct without a namespace" do + subject.call(name: "book") + + struct_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + module Structs + class Book < Test::DB::Struct + end + end + end + EXPECTED + + expect(fs.read("app/structs/book.rb")).to eq(struct_file) + expect(output).to include("Created app/structs/book.rb") + end + + it "generates a struct in a deep namespace with default separator" do + subject.call(name: "book.book_draft") + + struct_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + module Structs + module Book + class BookDraft < Test::DB::Struct + end + end + end + end + EXPECTED + + expect(fs.read("app/structs/book/book_draft.rb")).to eq(struct_file) + expect(output).to include("Created app/structs/book/book_draft.rb") + end + + it "generates an struct in a deep namespace with slash separators" do + subject.call(name: "book/published_book") + + struct_file = <<~EXPECTED + # frozen_string_literal: true + + module Test + module Structs + module Book + class PublishedBook < Test::DB::Struct + end + end + end + end + EXPECTED + + expect(fs.read("app/structs/book/published_book.rb")).to eq(struct_file) + expect(output).to include("Created app/structs/book/published_book.rb") + end + end + + context "generating for a slice" do + it "generates a struct in a top-level namespace" do + fs.mkdir("slices/main") + subject.call(name: "book", slice: "main") + + struct_file = <<~EXPECTED + # frozen_string_literal: true + + module Main + module Structs + class Book < Main::DB::Struct + end + end + end + EXPECTED + + expect(fs.read("slices/main/structs/book.rb")).to eq(struct_file) + expect(output).to include("Created slices/main/structs/book.rb") + end + + it "generates a struct in a nested namespace" do + fs.mkdir("slices/main") + subject.call(name: "book.draft_book", slice: "main") + + struct_file = <<~EXPECTED + # frozen_string_literal: true + + module Main + module Structs + module Book + class DraftBook < Main::DB::Struct + end + end + end + end + EXPECTED + + expect(fs.read("slices/main/structs/book/draft_book.rb")).to eq(struct_file) + expect(output).to include("Created slices/main/structs/book/draft_book.rb") + end + end +end