Last active
May 20, 2025 18:45
-
-
Save 0x000def42/bc3778868982e95fb6bb70c821c56375 to your computer and use it in GitHub Desktop.
Auto yard documentation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Скрипт запоминает типы аргументов и возвращаемых значений методов из рантайма и генерирует yard комментарии к методам
Положить в
spec/tp.rb
Внутри
spec_helper.rb
в любом месте прописатьrequire_relative "tp"
Запустить
rspec spec
Осторожно! Отредактирует все rb файлы в проекте + тесты будут идти в 2-3 раза медленнее
Проверено на ruby 3.4