Skip to content

Instantly share code, notes, and snippets.

@0x000def42
Last active May 20, 2025 18:45
Show Gist options
  • Save 0x000def42/bc3778868982e95fb6bb70c821c56375 to your computer and use it in GitHub Desktop.
Save 0x000def42/bc3778868982e95fb6bb70c821c56375 to your computer and use it in GitHub Desktop.
Auto yard documentation
# frozen_string_literal: true
require "set"
PROJECT_ROOT = Dir.pwd
OVERWRITE = true # перезаписывать блок при каждом запуске
MAKE_BACKUP = false # писать .bak
MAX_SAMPLES = 10
# ───────── helpers
def project_file?(path)
return false unless path && File.file?(path)
full = File.expand_path(path)
full.start_with?(PROJECT_ROOT) && !full.include?("/spec/")
end
def valid_var?(name)
name && name.to_s.match?(/\A[a-z_]\w*\z/i)
end
def inside_factory_bot?(tp)
proxy = Object.const_get("FactoryBot::DefinitionProxy") rescue nil
proxy && tp.self.is_a?(proxy)
end
def safe_method(receiver, mname)
meth = receiver.method(mname) rescue nil
return meth if meth.is_a?(Method)
receiver.class.instance_method(mname).bind(receiver) rescue nil
end
def compress_types(types)
list = types.dup
has_nil = list.delete("nil")
return "untyped" if list.empty? && !has_nil
joined = list.join(", ")
has_nil ? (list.empty? ? "nil" : "#{joined}, nil") : joined
end
def named_class(cls)
cls = cls.superclass until cls.name && !cls.name.empty?
cls&.name || "untyped"
end
def container_type(obj)
case obj
when Array
inner = compress_types(obj.first(MAX_SAMPLES).map { |e| e.nil? ? "nil" : named_class(e.class) }.uniq)
"Array<#{inner}>"
when Hash
ks = obj.first(MAX_SAMPLES).map { |k, _| k.nil? ? "nil" : named_class(k.class) }.uniq
vs = obj.first(MAX_SAMPLES).map { |_, v| v.nil? ? "nil" : named_class(v.class) }.uniq
"Hash{#{compress_types(ks)} => #{compress_types(vs)}}"
else
obj.nil? ? "NilClass" : named_class(obj.class)
end
end
# определяем, есть ли 'yield' внутри тела метода
def method_contains_yield?(file, def_line)
lines = File.readlines(file)
i = def_line # индекс первой строки после `def`
depth = 0
while i < lines.size
line = lines[i]
depth += 1 if line.strip.start_with?("def ")
if line.strip == "end"
break if depth.zero?
depth -= 1
end
code = line.sub(/#.*$/, "") # убираем комментарии
return true if code.include?("yield")
i += 1
end
false
end
INFO = Hash.new do |h, k|
h[k] = {
params: [],
param_rt: {},
returns: Set.new,
yield_param_rt: Hash.new { |hh, kk| hh[kk] = Set.new },
yield_returns: Set.new
}
end
# ───────── TracePoint :call
TracePoint.new(:call) do |tp|
next unless project_file?(tp.path)
next if inside_factory_bot?(tp)
meth = safe_method(tp.self, tp.method_id) or next
file, row = meth.source_location
next unless project_file?(file)
entry = INFO[[file, row]]
entry[:params] = meth.parameters
meth.parameters.each do |_, name|
next unless valid_var?(name)
next if entry[:param_rt].key?(name)
if tp.binding.local_variable_defined?(name)
entry[:param_rt][name] = named_class(tp.binding.local_variable_get(name).class)
end
rescue NameError
end
end.enable
# ───────── TracePoint :return
TracePoint.new(:return) do |tp|
next unless project_file?(tp.path)
next if inside_factory_bot?(tp)
meth = safe_method(tp.self, tp.method_id) or next
file, row = meth.source_location
next unless project_file?(file)
INFO[[file, row]][:returns] << container_type(tp.return_value)
end.enable
#───────── TracePoint :b_call
TracePoint.new(:b_call) do |tp|
next unless project_file?(tp.path)
next if inside_factory_bot?(tp)
recv = tp.self
mname = tp.binding.eval("__method__") rescue nil
method = safe_method(recv, mname) or next
file, row = method.source_location
next unless project_file?(file)
entry = INFO[[file, row]]
tp.binding.local_variables.each do |lv|
entry[:yield_param_rt][lv] << container_type(tp.binding.local_variable_get(lv))
end
end.enable
#───────── TracePoint :b_return
TracePoint.new(:b_return) do |tp|
next unless project_file?(tp.path)
next if inside_factory_bot?(tp)
recv = tp.self
mname = tp.binding.eval("__method__") rescue nil
method = safe_method(recv, mname) or next
file, row = method.source_location
next unless project_file?(file)
INFO[[file, row]][:yield_returns] << container_type(tp.return_value)
end.enable
# ───────── YARD-генерация
at_exit do
INFO.group_by { |(file,_),_| file }.each do |file, group|
src = File.readlines(file)
File.write("#{file}.bak", src.join) if MAKE_BACKUP && !File.exist?("#{file}.bak")
offset = 0
group.sort_by { |(_,row),_| row }.each do |(_,row), data|
idx = row - 1 + offset
indent = src[idx][/^\s*/] || ""
# удалить старый блок
if OVERWRITE
del = idx - 1
del -= 1 while del >= 0 && src[del].match?(/^\s*#/)
del += 1
if del < idx && src[del].match?(/^\s*#\s*@\w+/)
removed = idx - del
src.slice!(del, removed)
offset -= removed
idx -= removed
end
else
prev = idx.positive? ? src[idx-1] : ""
next if prev.match?(/^\s*#\s*@\w+/)
end
has_yield = method_contains_yield?(file, row)
block = []
# @param
data[:params].each do |_, name|
disp = valid_var?(name) ? name : nil
type = data[:param_rt][name] || "untyped"
block << "#{indent}# @param [#{type}] #{disp || 'arg'}\n"
end
if has_yield
# @yieldparam
data[:yield_param_rt].each do |name, types|
block << "#{indent}# @yieldparam [#{compress_types(types.to_a)}] #{name}\n"
end
# @yieldreturn
unless data[:yield_returns].empty?
block << "#{indent}# @yieldreturn [#{compress_types(data[:yield_returns].to_a)}]\n"
end
end
# @return
rets = data[:returns].to_a
block << "#{indent}# @return [#{rets.empty? ? 'untyped' : rets.join(' | ')}]\n"
src.insert(idx, *block)
offset += block.size
end
File.write(file, src.join)
puts "✅ updated #{file}"
end
end
@0x000def42
Copy link
Author

0x000def42 commented May 20, 2025

Скрипт запоминает типы аргументов и возвращаемых значений методов из рантайма и генерирует yard комментарии к методам

Положить в spec/tp.rb
Внутри spec_helper.rb в любом месте прописать require_relative "tp"
Запустить rspec spec
Осторожно! Отредактирует все rb файлы в проекте + тесты будут идти в 2-3 раза медленнее
Проверено на ruby 3.4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment