diff --git a/CHANGELOG.md b/CHANGELOG.md index b2005f9..c59afb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `delete_missing` option to `sync_table_data!` and `sync_all!`. When set to `true`, any records in the database that are not defined in the data files will be deleted. This option defaults to `false` to preserve backward compatibility. +- Each model can now choose how its YARD docs are generated by setting `self.support_table_yard_docs` to `:full` (the default — verbose comment block per method), `:compact` (shared `@!macro` definitions plus a short `@!method`/`@!macro` pair per method, dramatically reducing comment-block size on tables with many named instances), or `:none` (skip generation entirely; previously generated docs are stripped on the next run). IDEs and `yard doc` resolve the compact form into the same per-method documentation as the verbose form. +- Attribute helper return types in the generated YARD and RBS docs are now inferred per method by inspecting the value the helper actually returns (`String`, `Integer`, `Boolean`, etc.) instead of the generic `Object`/`untyped`. Because the helpers return frozen literals from the parsed data file, the documentation tasks no longer require a database connection. +- Added opt-in RBS signature generation. The new tasks `support_table_data:rbs`, `support_table_data:rbs:verify`, and `support_table_data:rbs:remove` write/check/delete `sig/.rbs` files so the named instance helpers are visible to Ruby LSP, Steep, RubyMine, and other RBS-aware tools without polluting the model source files. The output directory can be overridden with `SupportTableData.rbs_signatures_path`. ## 1.5.2 diff --git a/README.md b/README.md index 3be372e..535693f 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ completed: In a Rails application, you can add YARD documentation for the named instance helpers by running the rake task `support_table_data:yard_docs`. This will add YARD comments to your model classes for each of the named instance helper methods defined on the model. Adding this documentation will help IDEs provide better code completion and inline documentation for the helper methods and expose the methods to AI agents. -To update a single model file, pass an optional file path argument, for example: `bundle exec rake "support_table_data:yard_docs[app/models/status.rb]"`. +To update a single model file, use the `add` task and pass an optional file path argument, for example: `bundle exec rake "support_table_data:yard_docs:add[app/models/status.rb]"`. The default behavior is to add the documentation comments at the end of the model class by reopening the class definition. If you prefer to have the documentation comments appear elsewhere in the file, you can add the following markers to your model class and the YARD documentation will be inserted between these markers. @@ -200,6 +200,30 @@ The default behavior is to add the documentation comments at the end of the mode A good practice is to add a check to your CI pipeline to ensure the documentation is always up to date. You can run the rake task `support_table_data:yard_docs:verify` to do this. It will exit with an error if any models do not have up to date documentation. +Each model can choose how its YARD docs are generated by setting `support_table_yard_docs` to one of three values: + +- `:full` — verbose comment block per generated method (the default) +- `:compact` — shared `@!macro` definitions at the top of the documentation block plus a short `@!method` / `@!macro` pair per generated method. IDEs and `yard doc` resolve the macros into the same per-method documentation as `:full`. Useful when a model has many named instances and the verbose comment block is too long to be useful inline. +- `:none` — generate no YARD docs for this model. The rake task will also strip any previously generated YARD docs from the source file. + +```ruby +class Feature < ApplicationRecord + include SupportTableData + + self.support_table_yard_docs = :compact + + add_support_table_data "features.yml" +end +``` + +Attribute helper return types (`String`, `Integer`, `Boolean`, etc.) are inferred per method by inspecting the value the helper actually returns. The values come straight from the parsed data file, so the documentation tasks do not need a database connection to run. + +#### Generating RBS Signatures + +In addition to the inline YARD comments, you can generate [RBS](https://github.com/ruby/rbs) type signatures for the named instance helpers by running `bundle exec rake support_table_data:rbs`. This writes one `sig/.rbs` file per model — for example a Rails model at `app/models/feature.rb` produces `sig/app/models/feature.rbs`. The signatures are picked up by Ruby LSP, Steep, RubyMine, and any other RBS-aware tool, and they keep the model source files free of generated content. + +Just like the YARD task, you can target a single file with `bundle exec rake "support_table_data:rbs:add[app/models/feature.rb]"`, verify they are up to date in CI with `support_table_data:rbs:verify`, and remove the generated files with `support_table_data:rbs:remove`. RBS generation is opt-in — running the YARD task alone does not produce RBS output, and vice versa. + ### Caching You can use the companion [support_table_cache gem](https://github.com/bdurand/support_table_cache) to add caching support to your models. That way your application won't need to constantly query the database for records that will never change. diff --git a/lib/support_table_data.rb b/lib/support_table_data.rb index 38f2675..8e4dfd4 100644 --- a/lib/support_table_data.rb +++ b/lib/support_table_data.rb @@ -8,7 +8,14 @@ module SupportTableData extend ActiveSupport::Concern + autoload :ValidationError, File.expand_path("support_table_data/validation_error", __dir__) + autoload :Documentation, File.expand_path("support_table_data/documentation", __dir__) + autoload :Tasks, File.expand_path("support_table_data/tasks", __dir__) + + YARD_DOC_OPTIONS = [:full, :compact, :none].freeze + @data_directory = nil + @rbs_signatures_path = nil included do # Internal variables used for memoization. @@ -31,6 +38,15 @@ class << self # value set by SupportTableData.data_directory. This is only used if relative paths are passed # in to add_support_table_data. class_attribute :support_table_data_directory, instance_accessor: false + + # Private class attribute backing `support_table_yard_docs`. Use the public + # accessor to read/write. + # @private + class_attribute :_support_table_yard_docs, instance_accessor: false, default: :full + class << self + private :_support_table_yard_docs= + private :_support_table_yard_docs + end end class_methods do @@ -48,6 +64,31 @@ def support_table_key_attribute _support_table_key_attribute || "id" end + # Get the YARD documentation mode for this model. One of: + # + # * `:full` - emit a verbose comment block per generated method (default) + # * `:compact` - emit shared @!macro definitions plus a short + # @!method/@!macro pair per generated method + # * `:none` - generate no YARD docs for this model; the rake task will + # strip any existing generated YARD docs + # + # @return [Symbol] + def support_table_yard_docs + _support_table_yard_docs + end + + # Set the YARD documentation mode for this model. See `support_table_yard_docs` + # for the supported values. + # + # @param value [Symbol] + # @return [void] + def support_table_yard_docs=(value) + unless SupportTableData::YARD_DOC_OPTIONS.include?(value) + raise ArgumentError, "support_table_yard_docs must be one of #{SupportTableData::YARD_DOC_OPTIONS.inspect} (got #{value.inspect})" + end + self._support_table_yard_docs = value + end + # Synchronize the rows in the table with the values defined in the data files added with # `add_support_table_data`. By default, rows that are no longer present in the data files # will not be deleted unless `delete_missing` is enabled. @@ -384,6 +425,14 @@ def data_directory=(value) @data_directory = value&.to_s end + # Override the directory under which generated RBS signature files are + # written. When nil (the default) signatures go to + # `/sig/.rbs`, where the project root is the + # nearest ancestor directory containing a Gemfile or .git directory. + # + # @return [String, Pathname, nil] + attr_accessor :rbs_signatures_path + # Sync all support table classes. Classes must already be loaded in order to be synced. # # You can pass in a list of classes that you want to ensure are synced. This feature @@ -496,8 +545,6 @@ def protected_instance? end end -require_relative "support_table_data/validation_error" - if defined?(Rails::Railtie) require_relative "support_table_data/railtie" end diff --git a/lib/support_table_data/documentation.rb b/lib/support_table_data/documentation.rb index 248baf5..2013c56 100644 --- a/lib/support_table_data/documentation.rb +++ b/lib/support_table_data/documentation.rb @@ -2,8 +2,10 @@ module SupportTableData module Documentation + autoload :TypeInference, File.expand_path("documentation/type_inference", __dir__) + autoload :SourceFile, File.expand_path("documentation/source_file", __dir__) + autoload :YardDoc, File.expand_path("documentation/yard_doc", __dir__) + autoload :RbsDoc, File.expand_path("documentation/rbs_doc", __dir__) + autoload :RbsFile, File.expand_path("documentation/rbs_file", __dir__) end end - -require_relative "documentation/source_file" -require_relative "documentation/yard_doc" diff --git a/lib/support_table_data/documentation/rbs_doc.rb b/lib/support_table_data/documentation/rbs_doc.rb new file mode 100644 index 0000000..70db651 --- /dev/null +++ b/lib/support_table_data/documentation/rbs_doc.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + # Generates RBS type signatures for the dynamically-defined named instance + # helper methods on a support table model. + class RbsDoc + # @param klass [Class] The model class to generate signatures for + def initialize(klass) + @klass = klass + end + + # Render the full RBS file content for the model, including the class + # declaration. Returns nil when the model has no named instances. + # + # @return [String, nil] + def signatures + instance_names = klass.instance_names + return nil if instance_names.empty? + + body_lines = [] + instance_names.sort.each_with_index do |name, idx| + body_lines << "" unless idx.zero? + body_lines.concat(instance_signatures(name)) + end + + <<~RBS + # Generated by support_table_data - do not edit by hand. + # To update, run `bundle exec rake support_table_data:rbs`. + class #{klass.name} + #{body_lines.map { |line| line.empty? ? "" : " #{line}" }.join("\n")} + end + RBS + end + + private + + attr_reader :klass + + def instance_signatures(name) + lines = [] + lines << "def self.#{name}: () -> #{klass.name}" + lines << "def #{name}?: () -> bool" + klass.support_table_attribute_helpers.each do |attribute_name| + return_type = TypeInference.rbs_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}")) + lines << "def self.#{name}_#{attribute_name}: () -> #{return_type}" + end + lines + end + end + end +end diff --git a/lib/support_table_data/documentation/rbs_file.rb b/lib/support_table_data/documentation/rbs_file.rb new file mode 100644 index 0000000..bd271df --- /dev/null +++ b/lib/support_table_data/documentation/rbs_file.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "pathname" + +module SupportTableData + module Documentation + # Manages reading/writing the per-model RBS signature file. + class RbsFile + attr_reader :klass, :source_path, :path + + # @param klass [Class] The model class + # @param source_path [Pathname] The path to the model's source `.rb` file + def initialize(klass, source_path) + @klass = klass + @source_path = Pathname.new(source_path) + @path = self.class.signatures_path_for(@source_path) + end + + # Compute where the RBS signatures should live for a given source file. + # Defaults to `/sig/.rbs`. + # The project root is found by walking upward looking for a `Gemfile` or + # `.git` directory; if neither is found, the immediate parent of the + # source file is used. + # + # @param source_path [Pathname] + # @return [Pathname] + def self.signatures_path_for(source_path) + if SupportTableData.rbs_signatures_path + base = Pathname.new(SupportTableData.rbs_signatures_path) + relative = relative_to_project_root(source_path) + return base.join("#{relative.to_s.sub(/\.rb\z/, "")}.rbs") + end + + root = project_root_for(source_path) + relative = source_path.expand_path.relative_path_from(root) + root.join("sig", "#{relative.to_s.sub(/\.rb\z/, "")}.rbs") + end + + # Project root is the nearest ancestor directory containing a Gemfile or + # a .git directory. Falls back to the source file's directory. + def self.project_root_for(source_path) + current = Pathname.new(source_path).expand_path.parent + loop do + return current if current.join("Gemfile").file? + return current if current.join(".git").exist? + + parent = current.parent + return Pathname.new(source_path).expand_path.parent if parent == current + + current = parent + end + end + + def self.relative_to_project_root(source_path) + Pathname.new(source_path).expand_path.relative_path_from(project_root_for(source_path)) + end + + # Render the desired RBS content for this model, or nil if the model has + # no named instances (in which case no file should be written). + # + # @return [String, nil] + def signatures_content + RbsDoc.new(klass).signatures + end + + # Whether the existing on-disk file matches what would be generated. + # + # @return [Boolean] + def up_to_date? + desired = signatures_content + if desired.nil? + !path.file? + else + path.file? && path.read == desired + end + end + + # Write the generated RBS content to disk, creating parent directories as + # needed. Returns true if the file was written, false if there was nothing + # to write. + # + # @return [Boolean] + def write! + content = signatures_content + return false if content.nil? + + path.parent.mkpath + path.write(content) + true + end + + # Delete the generated RBS file if it exists. Returns true if a file was + # removed. + # + # @return [Boolean] + def remove! + return false unless path.file? + + path.delete + true + end + end + end +end diff --git a/lib/support_table_data/documentation/source_file.rb b/lib/support_table_data/documentation/source_file.rb index 73cdf27..40d2ce2 100644 --- a/lib/support_table_data/documentation/source_file.rb +++ b/lib/support_table_data/documentation/source_file.rb @@ -44,7 +44,9 @@ def source_without_yard_docs # @return [String] def source_with_yard_docs yard_docs = YardDoc.new(klass).named_instance_yard_docs - return source if yard_docs.nil? + if yard_docs.nil? + return has_yard_docs? ? source_without_yard_docs : source + end existing_yard_docs = source.match(YARD_COMMENT_REGEX) if existing_yard_docs diff --git a/lib/support_table_data/documentation/type_inference.rb b/lib/support_table_data/documentation/type_inference.rb new file mode 100644 index 0000000..e30bc9e --- /dev/null +++ b/lib/support_table_data/documentation/type_inference.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + # Infers documentation types for the dynamically-defined attribute helpers + # by calling the generated method and inspecting the class of the value + # it returns. The values returned by these helpers are frozen literals from + # the parsed data file, so this does not require a database connection. + # + # This module must not be used on finder helpers (e.g. `Color.red`), which + # call `find_by!` and would hit the database. + module TypeInference + module_function + + # Determine the documentation type for an attribute helper by calling + # the method and looking at the class of the returned value. Returns + # nil when the method is not defined. + # + # @param klass [Class] The model class + # @param method_name [String, Symbol] The class method name to call + # @return [Class, nil] + def value_type(klass, method_name) + return nil unless klass.respond_to?(method_name) + + klass.public_send(method_name).class + end + + # Map a Ruby value class to a YARD type string. + def yard_type(value_class) + case value_class&.name + when "String" then "String" + when "Integer" then "Integer" + when "Float" then "Float" + when "TrueClass", "FalseClass" then "Boolean" + when "Array" then "Array" + when "Hash" then "Hash" + when "NilClass", nil then "Object" + else value_class.name + end + end + + # Map a Ruby value class to an RBS type string. + def rbs_type(value_class) + case value_class&.name + when "String" then "String" + when "Integer" then "Integer" + when "Float" then "Float" + when "TrueClass", "FalseClass" then "bool" + when "Array" then "Array[untyped]" + when "Hash" then "Hash[untyped, untyped]" + when "NilClass", nil then "untyped" + else value_class.name + end + end + end + end +end diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index 2fa6baa..cce9edd 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -3,17 +3,38 @@ module SupportTableData module Documentation class YardDoc + MACRO_FINDER = "support_table_data_finder" + MACRO_PREDICATE = "support_table_data_predicate" + MACRO_ATTRIBUTE = "support_table_data_attribute" + # @param klass [Class] The model class to generate documentation for def initialize(klass) @klass = klass end - # Generate YARD documentation class definition for the model's helper methods. + # Generate YARD documentation for the model's helper methods. The format + # is controlled by the model's `support_table_yard_docs` setting: + # + # * `:full` - verbose comment block per method (default) + # * `:compact` - shared @!macro definitions plus a short @!method/@!macro + # pair per generated method + # * `:none` - generate no docs at all # - # @return [String, nil] The YARD documentation class definition, or nil if no named instances + # @return [String, nil] The YARD documentation, or nil if no docs should + # be emitted (either because the model has no named instances or because + # `support_table_yard_docs` is `:none`). def named_instance_yard_docs + return nil if klass.support_table_yard_docs == :none + instance_names = klass.instance_names - generate_yard_docs(instance_names) + return nil if instance_names.empty? + + case klass.support_table_yard_docs + when :compact + generate_compact_yard_docs(instance_names) + else + generate_verbose_yard_docs(instance_names) + end end # Generate YARD documentation comment for named instance singleton method. @@ -48,14 +69,16 @@ def predicate_helper_yard_doc(name) # Generate YARD documentation comment for the attribute method helper for the named instance. # # @param name [String] The name of the instance method. + # @param attribute_name [String] The attribute being read. # @return [String] The YARD comment text def attribute_helper_yard_doc(name, attribute_name) + return_type = attribute_yard_return_type(name, attribute_name) <<~YARD.chomp("\n") # Get the #{attribute_name} attribute from the data file # for the named instance +#{name}+. # # @!method self.#{name}_#{attribute_name} - # @return [Object] + # @return [#{return_type}] # @!visibility public YARD end @@ -64,12 +87,9 @@ def attribute_helper_yard_doc(name, attribute_name) attr_reader :klass - def generate_yard_docs(instance_names) - return nil if instance_names.empty? - + def generate_verbose_yard_docs(instance_names) yard_lines = ["# @!group Named Instances"] - # Generate docs for each named instance instance_names.sort.each do |name| yard_lines << "" yard_lines << instance_helper_yard_doc(name) @@ -86,6 +106,77 @@ def generate_yard_docs(instance_names) yard_lines.join("\n") end + + def generate_compact_yard_docs(instance_names) + yard_lines = ["# @!group Named Instances"] + yard_lines << "" + yard_lines << compact_preamble + yard_lines << "" + yard_lines << compact_macro_definitions + + instance_names.sort.each do |name| + yard_lines << "" + yard_lines << compact_instance_block(name) + end + + yard_lines << "" + yard_lines << "# @!endgroup" + + yard_lines.join("\n") + end + + def compact_preamble + <<~YARD.chomp("\n") + # The methods in this group are dynamically defined by support_table_data + # for each named instance in the data file. The macros below are the + # documentation templates; the per-instance @!method lines that follow + # invoke them with the instance name (and attribute name, where applicable). + YARD + end + + def compact_macro_definitions + attribute_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_ATTRIBUTE} + # Get the +$2+ attribute from the data file for the named instance +$1+. + # @return [$3] + # @!visibility public + YARD + + finder_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_FINDER} + # Find the named instance +$1+ from the database. + # @return [#{klass.name}] + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!visibility public + YARD + + predicate_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_PREDICATE} + # Check if this record is the named instance +$1+. + # @return [Boolean] + # @!visibility public + YARD + + [finder_macro, "", predicate_macro, "", attribute_macro].join("\n") + end + + def compact_instance_block(name) + lines = [] + lines << "# @!method self.#{name}" + lines << "# @!macro #{MACRO_FINDER} #{name}" + lines << "# @!method #{name}?" + lines << "# @!macro #{MACRO_PREDICATE} #{name}" + klass.support_table_attribute_helpers.each do |attribute_name| + return_type = attribute_yard_return_type(name, attribute_name) + lines << "# @!method self.#{name}_#{attribute_name}" + lines << "# @!macro #{MACRO_ATTRIBUTE} #{name} #{attribute_name} #{return_type}" + end + lines.join("\n") + end + + def attribute_yard_return_type(name, attribute_name) + TypeInference.yard_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}")) + end end end end diff --git a/lib/support_table_data/tasks.rb b/lib/support_table_data/tasks.rb new file mode 100644 index 0000000..d5192ff --- /dev/null +++ b/lib/support_table_data/tasks.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module SupportTableData + module Tasks + autoload :Utils, File.expand_path("tasks/utils", __dir__) + end +end diff --git a/lib/tasks/utils.rb b/lib/support_table_data/tasks/utils.rb similarity index 80% rename from lib/tasks/utils.rb rename to lib/support_table_data/tasks/utils.rb index c7b6eec..c552bd6 100644 --- a/lib/tasks/utils.rb +++ b/lib/support_table_data/tasks/utils.rb @@ -23,7 +23,8 @@ def eager_load! # @param file_path [String, Pathname, nil] Optional file path to filter by. # @return [Array] def support_table_sources(file_path = nil) - require_relative file_path if file_path + file_path = Pathname.new(file_path) if file_path.is_a?(String) + require file_path.expand_path if file_path sources = [] @@ -51,6 +52,16 @@ def support_table_sources(file_path = nil) [] end + # Return RBS file handlers for all support table models. + # + # @param file_path [String, Pathname, nil] Optional file path to filter by. + # @return [Array] + def support_table_rbs_files(file_path = nil) + support_table_sources(file_path).map do |source| + Documentation::RbsFile.new(source.klass, source.path) + end + end + def model_file_path(klass) file_path = "#{klass.name.underscore}.rb" model_path = nil diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index 8e21bfe..7e4031e 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -1,10 +1,8 @@ # frozen_string_literal: true namespace :support_table_data do - desc "Syncronize data for all models that include SupportTableData." + desc "Synchronize data for all models that include SupportTableData." task sync: :environment do - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! logger_callback = lambda do |name, started, finished, unique_id, payload| @@ -23,14 +21,12 @@ namespace :support_table_data do end end + desc "Adds YARD documentation comments to all support table models to document the named instance methods." task yard_docs: "yard_docs:add" namespace :yard_docs do desc "Adds YARD documentation comments to models to document the named instance methods. Optional arg: file_path" task :add, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! SupportTableData::Tasks::Utils.support_table_sources(args[:file_path]).each do |source_file| next if source_file.yard_docs_up_to_date? @@ -42,9 +38,6 @@ namespace :support_table_data do desc "Removes YARD documentation comments added by support_table_data from models. Optional arg: file_path" task :remove, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! SupportTableData::Tasks::Utils.support_table_sources(args[:file_path]).each do |source_file| next unless source_file.has_yard_docs? @@ -56,9 +49,6 @@ namespace :support_table_data do desc "Verify that support table models have up to date YARD docs for named instance methods. Optional arg: file_path" task :verify, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! all_up_to_date = true @@ -80,4 +70,53 @@ namespace :support_table_data do end end end + + desc "Generates RBS signature files for named instance methods in all support table models." + task rbs: "rbs:add" + + namespace :rbs do + desc "Generates RBS signature files for named instance methods. Optional arg: file_path" + task :add, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + next if rbs_file.up_to_date? + + rbs_file.write! + puts "Wrote RBS signatures for #{rbs_file.klass.name} to #{rbs_file.path}." + end + end + + desc "Removes generated RBS signature files. Optional arg: file_path" + task :remove, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + next unless rbs_file.remove! + + puts "Removed RBS signatures for #{rbs_file.klass.name} (#{rbs_file.path})." + end + end + + desc "Verify that generated RBS signature files are up to date. Optional arg: file_path" + task :verify, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + + all_up_to_date = true + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + unless rbs_file.up_to_date? + puts "RBS signatures are not up to date for #{rbs_file.klass.name} (#{rbs_file.path})." + all_up_to_date = false + end + end + + if all_up_to_date + if args[:file_path] + puts "RBS signatures are up to date for #{args[:file_path]}." + else + puts "All support table models have up to date RBS signatures." + end + else + raise "Run bundle exec rake support_table_data:rbs to update the signatures." + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9ff17b7..fe9b1cd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,6 @@ ActiveRecord::Base.establish_connection("adapter" => "sqlite3", "database" => ":memory:") require_relative "../lib/support_table_data" -require_relative "../lib/support_table_data/documentation" SupportTableData.data_directory = File.join(__dir__, "data") diff --git a/spec/support_table_data/documentation/rbs_doc_spec.rb b/spec/support_table_data/documentation/rbs_doc_spec.rb new file mode 100644 index 0000000..27231d7 --- /dev/null +++ b/spec/support_table_data/documentation/rbs_doc_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::RbsDoc do + describe "#signatures" do + it "returns nil when the model has no named instances" do + allow(Color).to receive(:instance_names).and_return([]) + + expect(described_class.new(Color).signatures).to be_nil + end + + it "wraps signatures in a class declaration matching the model" do + result = described_class.new(Color).signatures + + expect(result).to start_with("# Generated by support_table_data - do not edit by hand.\n") + expect(result).to include("class Color\n") + expect(result).to end_with("end\n") + end + + it "emits a finder and predicate signature per named instance" do + result = described_class.new(Color).signatures + + expect(result).to include(" def self.red: () -> Color") + expect(result).to include(" def red?: () -> bool") + expect(result).to include(" def self.blue: () -> Color") + expect(result).to include(" def blue?: () -> bool") + end + + it "uses RBS-flavoured types for attribute helper signatures" do + result = described_class.new(Group).signatures + + # group_id is integer, name is string + expect(result).to include(" def self.primary_group_id: () -> Integer") + expect(result).to include(" def self.primary_name: () -> String") + end + + it "sorts named instances alphabetically" do + result = described_class.new(Color).signatures + + black_pos = result.index("def self.black:") + blue_pos = result.index("def self.blue:") + green_pos = result.index("def self.green:") + red_pos = result.index("def self.red:") + + expect(black_pos).to be < blue_pos + expect(blue_pos).to be < green_pos + expect(green_pos).to be < red_pos + end + end +end diff --git a/spec/support_table_data/documentation/rbs_file_spec.rb b/spec/support_table_data/documentation/rbs_file_spec.rb new file mode 100644 index 0000000..846e5b9 --- /dev/null +++ b/spec/support_table_data/documentation/rbs_file_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" +require "fileutils" + +RSpec.describe SupportTableData::Documentation::RbsFile do + let(:color_path) { Pathname.new(File.expand_path("../../models/color.rb", __dir__)) } + + describe ".signatures_path_for" do + it "places the sig file at /sig/.rbs" do + Dir.mktmpdir do |tmp| + root = Pathname.new(tmp) + FileUtils.touch(root.join("Gemfile")) + model_dir = root.join("app", "models") + model_dir.mkpath + source = model_dir.join("widget.rb") + source.write("class Widget; end\n") + + sig_path = described_class.signatures_path_for(source) + + expect(sig_path).to eq(root.join("sig", "app", "models", "widget.rbs")) + end + end + + it "honours SupportTableData.rbs_signatures_path when set" do + Dir.mktmpdir do |tmp| + root = Pathname.new(tmp) + FileUtils.touch(root.join("Gemfile")) + source = root.join("app", "models", "widget.rb") + source.dirname.mkpath + source.write("class Widget; end\n") + + original = SupportTableData.rbs_signatures_path + begin + SupportTableData.rbs_signatures_path = root.join("custom_sigs").to_s + sig_path = described_class.signatures_path_for(source) + + expect(sig_path).to eq(Pathname.new(root.join("custom_sigs", "app", "models", "widget.rbs").to_s)) + ensure + SupportTableData.rbs_signatures_path = original + end + end + end + end + + describe "#up_to_date?" do + it "returns false when the file is missing" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.up_to_date?).to be false + end + end + + it "returns true after writing" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + rbs_file.write! + + expect(rbs_file.up_to_date?).to be true + end + end + end + + describe "#write!" do + it "creates the parent sig directory and writes the signatures" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.write!).to be true + + expect(rbs_file.path).to exist + expect(rbs_file.path.read).to include("class Color") + expect(rbs_file.path.read).to include("def self.red: () -> Color") + end + end + + it "returns false when there are no instances to document" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + allow(Color).to receive(:instance_names).and_return([]) + rbs_file = described_class.new(Color, source) + + expect(rbs_file.write!).to be false + expect(rbs_file.path).not_to exist + end + end + end + + describe "#remove!" do + it "deletes the file when it exists" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + rbs_file.write! + expect(rbs_file.path).to exist + + expect(rbs_file.remove!).to be true + expect(rbs_file.path).not_to exist + end + end + + it "returns false when there is nothing to remove" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.remove!).to be false + end + end + end +end diff --git a/spec/support_table_data/documentation/source_file_spec.rb b/spec/support_table_data/documentation/source_file_spec.rb index 4083f5a..69f12c5 100644 --- a/spec/support_table_data/documentation/source_file_spec.rb +++ b/spec/support_table_data/documentation/source_file_spec.rb @@ -187,6 +187,73 @@ class Color < ActiveRecord::Base end end + context "when the model declares support_table_yard_docs = :none" do + around do |example| + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :none + begin + example.run + ensure + Color.support_table_yard_docs = original + end + end + + it "strips existing YARD docs from the source" do + source_with_old_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Old YARD docs + end + # End YARD docs for support_table_data + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_old_docs) + + result = source_file.source_with_yard_docs + + expect(result).not_to include("# Begin YARD docs for support_table_data") + expect(result).not_to include("# Old YARD docs") + expect(result).to include("class Color < ActiveRecord::Base") + end + + it "leaves the source unchanged when no YARD docs are present" do + source_without_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_without_docs) + + expect(source_file.source_with_yard_docs).to eq(source_without_docs) + end + + it "reports yard_docs_up_to_date? false when stale docs remain" do + source_with_old_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Old YARD docs + end + # End YARD docs for support_table_data + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_old_docs) + + expect(source_file.yard_docs_up_to_date?).to be false + end + end + describe "#yard_docs_up_to_date?" do it "returns true when YARD docs match current generated docs" do source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) diff --git a/spec/support_table_data/documentation/type_inference_spec.rb b/spec/support_table_data/documentation/type_inference_spec.rb new file mode 100644 index 0000000..4821494 --- /dev/null +++ b/spec/support_table_data/documentation/type_inference_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::TypeInference do + describe ".value_type" do + it "returns the class of the value returned by the helper method" do + # Group has named_instance_attribute_helpers :group_id, :name + expect(described_class.value_type(Group, "primary_name")).to eq(String) + expect(described_class.value_type(Group, "primary_group_id")).to eq(Integer) + end + + it "returns nil when the method is not defined" do + expect(described_class.value_type(Group, "not_a_real_method")).to be_nil + end + + it "does not call the database for finder methods" do + # Sanity check: this confirms we are calling literal-returning helpers, + # not finder methods. Calling Group.primary would hit the DB; we should + # never invoke value_type on it. + expect(Group).not_to receive(:primary) + described_class.value_type(Group, "primary_name") + end + end + + describe ".yard_type" do + it "maps common value classes to YARD type strings" do + expect(described_class.yard_type(String)).to eq("String") + expect(described_class.yard_type(Integer)).to eq("Integer") + expect(described_class.yard_type(Float)).to eq("Float") + expect(described_class.yard_type(TrueClass)).to eq("Boolean") + expect(described_class.yard_type(FalseClass)).to eq("Boolean") + expect(described_class.yard_type(Array)).to eq("Array") + expect(described_class.yard_type(Hash)).to eq("Hash") + end + + it "falls back to Object for nil or NilClass" do + expect(described_class.yard_type(nil)).to eq("Object") + expect(described_class.yard_type(NilClass)).to eq("Object") + end + + it "uses the class name for other classes" do + expect(described_class.yard_type(Date)).to eq("Date") + end + end + + describe ".rbs_type" do + it "maps common value classes to RBS type strings" do + expect(described_class.rbs_type(String)).to eq("String") + expect(described_class.rbs_type(Integer)).to eq("Integer") + expect(described_class.rbs_type(Float)).to eq("Float") + expect(described_class.rbs_type(TrueClass)).to eq("bool") + expect(described_class.rbs_type(FalseClass)).to eq("bool") + expect(described_class.rbs_type(Array)).to eq("Array[untyped]") + expect(described_class.rbs_type(Hash)).to eq("Hash[untyped, untyped]") + end + + it "falls back to untyped for nil or NilClass" do + expect(described_class.rbs_type(nil)).to eq("untyped") + expect(described_class.rbs_type(NilClass)).to eq("untyped") + end + end +end diff --git a/spec/support_table_data/documentation/yard_doc_spec.rb b/spec/support_table_data/documentation/yard_doc_spec.rb index b9fb623..a815e8b 100644 --- a/spec/support_table_data/documentation/yard_doc_spec.rb +++ b/spec/support_table_data/documentation/yard_doc_spec.rb @@ -33,6 +33,22 @@ end end + describe "#attribute_helper_yard_doc" do + it "uses the column type for the @return tag when the column is known" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.attribute_helper_yard_doc("primary", "name") + + expect(result).to include("# @return [String]") + end + + it "falls back to Object when the column is not on the table" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.attribute_helper_yard_doc("primary", "not_a_real_column") + + expect(result).to include("# @return [Object]") + end + end + describe "#named_instance_yard_docs" do it "returns nil when model has no named instances" do allow(Color).to receive(:instance_names).and_return([]) @@ -94,5 +110,95 @@ expect(blue_pos).to be < green_pos expect(green_pos).to be < red_pos end + + context "when the model declares support_table_yard_docs = :compact" do + around do |example| + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :compact + begin + example.run + ensure + Color.support_table_yard_docs = original + end + end + + it "emits the compact macro form" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.named_instance_yard_docs + + expect(result).not_to be_nil + expect(result).to include("# @!group Named Instances") + expect(result).to include("# @!endgroup") + + expect(result).to include("# @!macro [new] support_table_data_finder") + expect(result).to include("# @!macro [new] support_table_data_predicate") + expect(result).to include("# @!macro [new] support_table_data_attribute") + expect(result.scan("# @!macro [new] support_table_data_finder").size).to eq(1) + + expect(result).to include("# @!method self.red") + expect(result).to include("# @!macro support_table_data_finder red") + expect(result).to include("# @!method red?") + expect(result).to include("# @!macro support_table_data_predicate red") + expect(result).not_to include("# Find the named instance +red+ from the database.") + end + end + + context "when the model declares support_table_yard_docs = :compact with attribute helpers" do + around do |example| + original = Group.support_table_yard_docs + Group.support_table_yard_docs = :compact + begin + example.run + ensure + Group.support_table_yard_docs = original + end + end + + it "emits attribute macro invocations with column-derived types" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.named_instance_yard_docs + + # Group has attribute helpers for group_id (integer) and name (string). + expect(result).to include("# @!macro support_table_data_attribute primary group_id Integer") + expect(result).to include("# @!macro support_table_data_attribute primary name String") + end + end + + context "when the model declares support_table_yard_docs = :none" do + around do |example| + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :none + begin + example.run + ensure + Color.support_table_yard_docs = original + end + end + + it "returns nil even when the model has named instances" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + expect(doc.named_instance_yard_docs).to be_nil + end + end + end + + describe "Color.support_table_yard_docs=" do + it "rejects unknown values" do + expect { + Color.support_table_yard_docs = :verbose + }.to raise_error(ArgumentError, /support_table_yard_docs must be one of/) + end + + it "accepts :full, :compact, and :none" do + original = Color.support_table_yard_docs + begin + %i[full compact none].each do |value| + expect { Color.support_table_yard_docs = value }.not_to raise_error + expect(Color.support_table_yard_docs).to eq(value) + end + ensure + Color.support_table_yard_docs = original + end + end end end diff --git a/spec/support_table_data_spec.rb b/spec/support_table_data_spec.rb index 6d32d39..35d6f6d 100644 --- a/spec/support_table_data_spec.rb +++ b/spec/support_table_data_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "spec_helper" +require "spec_helper" describe SupportTableData do let(:red) { Color.find_by(name: "Red") } diff --git a/spec/tasks_spec.rb b/spec/tasks_spec.rb index 59d2d37..6bcc401 100644 --- a/spec/tasks_spec.rb +++ b/spec/tasks_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "spec_helper" +require "spec_helper" require "rake" RSpec.describe "support_table_data rake tasks" do @@ -35,9 +35,6 @@ describe "yard_docs:add" do it "adds YARD documentation to models with named instances" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - # Mock stdout to capture puts allow($stdout).to receive(:puts) @@ -62,9 +59,6 @@ end it "applies only to the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) written_files = [] @@ -81,9 +75,6 @@ describe "yard_docs:remove" do it "removes YARD documentation from models" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - # Mock stdout to capture puts allow($stdout).to receive(:puts) @@ -111,9 +102,6 @@ end it "applies only to the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) written_files = [] @@ -131,11 +119,81 @@ end end + describe "rbs:add" do + it "writes RBS files for models with named instances" do + allow($stdout).to receive(:puts) + + written_files = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:write!) do |instance| + written_files << {path: instance.path.to_s, klass: instance.klass.name} + true + end + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + Rake.application.invoke_task "support_table_data:rbs:add" + + expect(written_files).not_to be_empty + expect(written_files.map { |f| f[:klass] }).to include("Color") + end + + it "applies only to the specified file path when provided" do + allow($stdout).to receive(:puts) + + written_files = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:write!) do |instance| + written_files << {path: instance.path.to_s, klass: instance.klass.name} + true + end + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + Rake::Task["support_table_data:rbs:add"].invoke(color_model_path) + + expect(written_files.map { |f| f[:klass] }).to eq(["Color"]) + end + end + + describe "rbs:remove" do + it "removes generated RBS files for support table models" do + allow($stdout).to receive(:puts) + + removed = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:remove!) do |instance| + removed << instance.klass.name + true + end + + Rake.application.invoke_task "support_table_data:rbs:remove" + + expect(removed).not_to be_empty + end + end + + describe "rbs:verify" do + it "passes when all signatures are up to date" do + allow($stdout).to receive(:puts) + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(true) + + Rake.application.invoke_task "support_table_data:rbs:verify" + + expect($stdout).to have_received(:puts).with("All support table models have up to date RBS signatures.") + end + + it "raises when signatures are out of date" do + allow($stdout).to receive(:puts) + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + expect { + Rake.application.invoke_task "support_table_data:rbs:verify" + }.to raise_error(RuntimeError) + end + end + describe "yard_docs:verify" do it "verifies YARD documentation is up to date" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) allow_any_instance_of(SupportTableData::Documentation::SourceFile) .to receive(:yard_docs_up_to_date?).and_return(true) @@ -148,9 +206,6 @@ end it "raises an error if any YARD documentation is out of date" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) allow_any_instance_of(SupportTableData::Documentation::SourceFile) .to receive(:yard_docs_up_to_date?).and_return(false) @@ -165,9 +220,6 @@ end it "verifies only the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) expect_any_instance_of(SupportTableData::Documentation::SourceFile) diff --git a/support_table_data.gemspec b/support_table_data.gemspec index e906b74..43a460e 100644 --- a/support_table_data.gemspec +++ b/support_table_data.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| bin/ gemfiles/ spec/ + test_app/ ] spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } } diff --git a/test_app/app/models/status.rb b/test_app/app/models/status.rb index 9d298bb..1d24b5c 100644 --- a/test_app/app/models/status.rb +++ b/test_app/app/models/status.rb @@ -32,7 +32,7 @@ class Status # for the named instance +active+. # # @!method self.active_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +canceled+ from the database. @@ -52,7 +52,7 @@ class Status # for the named instance +canceled+. # # @!method self.canceled_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +completed+ from the database. @@ -72,7 +72,7 @@ class Status # for the named instance +completed+. # # @!method self.completed_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +failed+ from the database. @@ -92,7 +92,7 @@ class Status # for the named instance +failed+. # # @!method self.failed_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +pending+ from the database. @@ -112,7 +112,7 @@ class Status # for the named instance +pending+. # # @!method self.pending_name - # @return [Object] + # @return [String] # @!visibility public # @!endgroup