Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model_path>.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

Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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/<model_path>.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.
Expand Down
51 changes: 49 additions & 2 deletions lib/support_table_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
# `<project_root>/sig/<model_path>.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
Expand Down Expand Up @@ -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
8 changes: 5 additions & 3 deletions lib/support_table_data/documentation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
52 changes: 52 additions & 0 deletions lib/support_table_data/documentation/rbs_doc.rb
Original file line number Diff line number Diff line change
@@ -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
104 changes: 104 additions & 0 deletions lib/support_table_data/documentation/rbs_file.rb
Original file line number Diff line number Diff line change
@@ -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 `<project_root>/sig/<source_path_minus_extension>.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
4 changes: 3 additions & 1 deletion lib/support_table_data/documentation/source_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions lib/support_table_data/documentation/type_inference.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading