Docscribe

Generate inline, YARD-style documentation comments for Ruby methods by analyzing your code's AST.
Docscribe inserts doc headers before method definitions, infers parameter and return types (including rescue-aware returns), and respects Ruby visibility semantics — without using YARD to parse.
- No AST reprinting. Your original code, formatting, and constructs (like
class << self,heredocs,%i[]) are preserved. - Inline-first. Comments are inserted before method headers without reprinting the AST. For methods with a leading
Sorbet
sig, new docs are inserted above the firstsig. - Heuristic type inference for params and return values, including conditional returns in rescue branches.
- Safe and aggressive update modes:
- safe mode inserts missing docs, merges existing doc-like blocks, and normalizes sortable tags;
- aggressive mode rebuilds existing doc blocks.
- Ruby 3.4+ syntax supported using Prism translation (see "Parser backend" below).
- Optional external type integrations:
- RBS via
--rbs/--sig-dir; - Sorbet via inline
sigdeclarations and RBI files with--sorbet/--rbi-dir.
- RBS via
- Optional
@!attributegeneration for:attr_reader/attr_writer/attr_accessor;Struct.newdeclarations in both constant-assigned and class-based styles.
Common workflows:
- Inspect what safe doc updates would be applied:
docscribe lib - Apply safe doc updates:
docscribe -a lib - Apply aggressive doc updates:
docscribe -A lib - Use RBS gem collection signatures:
docscribe -a --rbs-collection lib - Use RBS signatures when available:
docscribe -a --rbs --sig-dir sig lib - Use Sorbet signatures when available:
docscribe -a --sorbet --rbi-dir sorbet/rbi lib
Contents
- Docscribe
- Contents
- Installation
- Quick start
- CLI
- Update strategies
- Parser backend (Parser gem vs Prism)
- External type integrations (optional)
- Type inference
- Rescue-aware returns and @raise
- Visibility semantics
- API (library) usage
- Plugin system
- Configuration
- CI integration
- Comparison to YARD's parser
- Limitations
- Roadmap
- Contributing
- Discussion & Community
- License
Installation
Add to your Gemfile:
gem "docscribe"
Then:
bundle install
Or install globally:
gem install docscribe
Requires Ruby 2.7+.
Quick start
Given code:
class Demo
def foo(a, options: {})
42
end
def (verbose: true)
123
end
private
def self.bump
:ok
end
class << self
private
def internal; end
end
end
Run:
echo "...code above..." | docscribe --stdin
Output:
class Demo
# +Demo#foo+ -> Integer
#
# Method documentation.
#
# @param [Object] a Param documentation.
# @param [Hash] options Param documentation.
# @return [Integer]
def foo(a, options: {})
42
end
# +Demo#bar+ -> Integer
#
# Method documentation.
#
# @param [Boolean] verbose Param documentation.
# @return [Integer]
def (verbose: true)
123
end
private
# +Demo.bump+ -> Symbol
#
# Method documentation.
#
# @return [Symbol]
def self.bump
:ok
end
class << self
private
# +Demo.internal+ -> Object
#
# Method documentation.
#
# @private
# @return [Object]
def internal; end
end
end
[!NOTE]
- The tool inserts doc headers before method headers and preserves everything else.
- For methods with a leading Sorbet
sig, docs are inserted above the firstsig.- Class methods show with a dot (
+Demo.bump+,+Demo.internal+).- Methods inside
class << selfunderprivateare marked@private.
CLI
docscribe [options] [files...]
Docscribe has three main ways to run:
- Inspect mode (default): checks what safe doc updates would be applied and exits non-zero if files need changes.
- Safe autocorrect (
-a,--autocorrect): writes safe, non-destructive updates in place. - Aggressive autocorrect (
-A,--autocorrect-all): rewrites existing doc blocks more aggressively. - STDIN mode (
--stdin): reads Ruby source from STDIN and prints rewritten source to STDOUT.
If you pass no files and don’t use --stdin, Docscribe processes the current directory recursively.
Options
-a,--autocorrect
Apply safe doc updates in place.-A,--autocorrect-all
Apply aggressive doc updates in place.--rbs-collection
Auto-discover the RBS collection directory fromrbs_collection.lock.yaml.
Reads thepath:field written bybundle exec rbs collection installand adds
it to the signature search path automatically. Implies--rbs.--stdin
Read source from STDIN and print rewritten output.--verbose
Print per-file actions.--explain
Show detailed reasons for each file that would change.--rbs
Use RBS signatures for@param/@returnwhen available (falls back to inference).--sig-dir DIR
Add an RBS signature directory (repeatable). Implies--rbs.--include PATTERN
Include PATTERN (method id or file path; glob or/regex/).--exclude PATTERN
Exclude PATTERN (method id or file path; glob or/regex/). Exclude wins.--include-file PATTERN
Only process files matching PATTERN (glob or/regex/).--exclude-file PATTERN
Skip files matching PATTERN (glob or/regex/). Exclude wins.-C,--config PATH
Path to config YAML (default:docscribe.yml).-v,--version
Print version and exit.-h,--help
Show help.
Examples
Inspect a directory:
docscribe libApply safe updates:
docscribe -a libApply aggressive updates:
docscribe -A libPreview output for a single file via STDIN:
cat path/to/file.rb | docscribe --stdinUse RBS signatures:
docscribe -a --rbs --sig-dir sig libUse RBS signatures with auto-discovered gem collection:
docscribe -a --rbs-collection libCombine collection auto-discovery with a custom sig directory:
docscribe -a --rbs-collection --sig-dir sig libShow detailed reasons for files that would change:
docscribe --verbose --explain lib
Update strategies
Docscribe supports two update strategies: safe and aggressive.
Safe strategy
Used by:
- default inspect mode:
docscribe lib - safe write mode:
docscribe -a lib
Safe strategy:
- inserts docs for undocumented methods
- merges missing tags into existing doc-like blocks
- normalizes configurable tag order inside sortable tag runs
- preserves existing prose and comments where possible
This is the recommended day-to-day mode.
Aggressive strategy
Used by:
- aggressive write mode:
docscribe -A lib
Aggressive strategy:
- rebuilds existing doc blocks
- replaces existing generated documentation more fully
- is more invasive than safe mode
Use it when you want to rebaseline or regenerate docs wholesale.
Output markers
In inspect mode, Docscribe prints one character per file:
.= file is up to dateF= file would changeE= file had an error
In write modes:
.= file already OKC= file was updatedE= file had an error
With --verbose, Docscribe prints per-file statuses instead.
With --explain, Docscribe also prints detailed reasons, such as:
- missing
@param - missing
@return - missing module_function note
- unsorted tags
Parser backend (Parser gem vs Prism)
Docscribe internally works with parser-gem-compatible AST nodes and Parser::Source::* objects (so it can use
Parser::Source::TreeRewriter without changing formatting).
- On Ruby <= 3.3, Docscribe parses using the
parsergem. - On Ruby >= 3.4, Docscribe parses using Prism and translates the tree into the
parsergem's AST.
You can force a backend with an environment variable:
DOCSCRIBE_PARSER_BACKEND=parser bundle exec docscribe lib
DOCSCRIBE_PARSER_BACKEND=prism bundle exec docscribe lib
External type integrations (optional)
Docscribe can improve generated @param and @return types by reading external signatures instead of relying only on
AST inference.
[!IMPORTANT] When external type information is available, Docscribe resolves signatures in this order:
- inline Sorbet
sigdeclarations in the current Ruby source;- Sorbet RBI files;
- RBS files;
- AST inference fallback.
If an external signature cannot be loaded or parsed, Docscribe falls back to normal inference instead of failing.
RBS
Docscribe can read method signatures from .rbs files and use them to generate more accurate parameter and return
types.
CLI:
docscribe -a --rbs --sig-dir sig lib
You can pass --sig-dir multiple times:
docscribe -a --rbs --sig-dir sig --sig-dir vendor/sigs lib
Config:
rbs:
enabled: true
sig_dirs:
- sig
collapse_generics: false
Example:
# Ruby source
class Demo
def foo(verbose:, count:)
"body says String"
end
end
# sig/demo.rbs
class Demo
def foo: (verbose: bool, count: Integer) -> Integer
end
Generated docs will prefer the RBS signature over inferred Ruby types:
class Demo
# +Demo#foo+ -> Integer
#
# Method documentation.
#
# @param [Boolean] verbose Param documentation.
# @param [Integer] count Param documentation.
# @return [Integer]
def foo(verbose:, count:)
'body says String'
end
end
RBS collection auto-discovery
If your project uses rbs collection,
Docscribe can discover the installed gem signatures automatically without requiring
you to pass --sig-dir manually.
Setup:
# 1. Initialize the collection config (one-time)
bundle exec rbs collection init
# 2. Install gem signatures
bundle exec rbs collection install
This produces rbs_collection.lock.yaml and .gem_rbs_collection/ in your project root.
Usage:
docscribe -a --rbs-collection lib
Docscribe reads the path: field from rbs_collection.lock.yaml and adds the
resolved directory to the signature search path. If no path: is set, it falls
back to .gem_rbs_collection.
You can combine --rbs-collection with --sig-dir to mix gem signatures with your own:
docscribe -a --rbs-collection --sig-dir sig lib
[!NOTE]
--rbs-collectiononly improves types for methods defined in gems that ship RBS signatures. For your own classes, provide asig/directory with hand-written or generated.rbsfiles.[!IMPORTANT] If
rbs_collection.lock.yamlis missing or the collection directory does not exist on disk, Docscribe will print a warning and skip the collection. Runbundle exec rbs collection installfirst.
Sorbet
Docscribe can also read Sorbet signatures from:
- inline
sigdeclarations in Ruby source - RBI files
CLI:
docscribe -a --sorbet lib
With RBI directories:
docscribe -a --sorbet --rbi-dir sorbet/rbi lib
You can pass --rbi-dir multiple times:
docscribe -a --sorbet --rbi-dir sorbet/rbi --rbi-dir rbi lib
Config:
sorbet:
enabled: true
rbi_dirs:
- sorbet/rbi
- rbi
collapse_generics: false
Inline Sorbet example
class Demo
extend T::Sig
sig { params(verbose: T::Boolean, count: Integer).returns(Integer) }
def foo(verbose:, count:)
'body says String'
end
end
Docscribe will use the Sorbet signature instead of the inferred body type:
class Demo
extend T::Sig
# +Demo#foo+ -> Integer
#
# Method documentation.
#
# @param [Boolean] verbose Param documentation.
# @param [Integer] count Param documentation.
# @return [Integer]
sig { params(verbose: T::Boolean, count: Integer).returns(Integer) }
def foo(verbose:, count:)
'body says String'
end
end
Sorbet RBI example
# Ruby source
class Demo
def foo(verbose:, count:)
'body says String'
end
end
# sorbet/rbi/demo.rbi
class Demo
extend T::Sig
sig { params(verbose: T::Boolean, count: Integer).returns(Integer) }
def foo(verbose:, count:); end
end
With:
docscribe -a --sorbet --rbi-dir sorbet/rbi lib
Docscribe will use the RBI signature for generated docs.
Sorbet comment placement
For methods with a leading Sorbet sig, Docscribe treats the signature as part of the method header.
That means:
- new docs are inserted above the first
sig - existing docs above the
sigare recognized and merged - existing legacy docs between
siganddefare also recognized
Example input:
# demo.rb
class Demo
extend T::Sig
sig { returns(Integer) }
def foo
1
end
end
Example output:
# demo.rb
class Demo
extend T::Sig
# +Demo#foo+ -> Integer
#
# Method documentation.
#
# @return [Integer]
sig { returns(Integer) }
def foo
1
end
end
Generic type formatting
Both RBS and Sorbet integrations support collapse_generics.
When disabled:
rbs:
collapse_generics: false
sorbet:
collapse_generics: false
Docscribe preserves generic container details where possible, for example:
Array<String>Hash<Symbol, Integer>
When enabled:
rbs:
collapse_generics: true
sorbet:
collapse_generics: true
Docscribe simplifies container types to their outer names, for example:
ArrayHash
Notes and fallback behavior
- External signature support is the best effort.
- If a signature source cannot be loaded or parsed, Docscribe falls back to AST inference.
- RBS and Sorbet integrations are used only to improve generated types; Docscribe still rewrites Ruby source directly.
- Sorbet support does not require changing your documentation style — it only improves generated
@paramand@returntags when signatures are available.
Type inference
Heuristics (best-effort).
Parameters:
*args->Array**kwargs->Hash&block->Proc- keyword args:
verbose: true->Booleanoptions: {}->Hashkw:(no default) ->Object
- positional defaults:
42->Integer,1.0->Float,'x'->String,:ok->Symbol[]->Array,{}->Hash,/x/->Regexp,true/false->Boolean,nil->nil
Return values:
- For simple bodies, Docscribe looks at the last expression or explicit
return. - Unions with
nilbecome optional types (e.g.Stringornil->String?). - For control flow (
if/case), it unifies branches conservatively. - RBS core type inference: when
--rbsis enabled, Docscribe resolves return types for method calls on core types from their RBS definitions:arg.positive?(arg = 1) ->Boolean(fromInteger#positive?)arg.to_i(arg = "") ->Integer(fromString#to_i)arg.to_s.length(arg = 1) ->Integer(chained: Integer -> String -> Integer)arg.upcase(arg = "") ->String(fromString#upcase)- Rescue branches are also resolved (e.g.
"default"->String)
Rescue-aware returns and @raise
Docscribe detects exceptions and rescue branches:
Rescue exceptions become
@raisetags:rescue Foo, Bar->@raise [Foo]and@raise [Bar]- bare rescue ->
@raise [StandardError] - explicit
raise/failalso adds a tag (raise Foo->@raise [Foo],raise->@raise [StandardError])
Conditional return types for rescue branches:
- Docscribe adds
@return [Type] if ExceptionA, ExceptionBfor each rescue clause
- Docscribe adds
Visibility semantics
We match Ruby's behavior:
- A bare
private/protected/publicin a class/module body affects instance methods only. - Inside
class << self, a bare visibility keyword affects class methods only. def self.xin a class body remainspublicunlessprivate_class_methodis used, or it's insideclass << selfunderprivate.
Inline tags:
@privateis added for methods that are private in context.@protectedis added similarly for protected methods.
[!IMPORTANT]
module_function: Docscribe documents methods affected bymodule_functionas module methods (M.foo) rather than instance methods (M#foo), because that is usually the callable/public API. If a method was previously private as an instance method, Docscribe will avoid marking the generated docs as@privateafter it is promoted to a module method.
module M
private
def foo; end
module_function :foo
end
API (library) usage
require "docscribe/inline_rewriter"
code = <<~RUBY
class Demo
def foo(a, options: {}); 42; end
class << self; private; def internal; end; end
end
RUBY
# Basic insertion behavior
out = Docscribe::InlineRewriter.insert_comments(code)
puts out
# Safe merge / normalization of existing doc-like blocks
out2 = Docscribe::InlineRewriter.insert_comments(code, strategy: :safe)
# Aggressive rebuild of existing doc blocks (similar to CLI -A)
out3 = Docscribe::InlineRewriter.insert_comments(code, strategy: :aggressive)
Plugin system
Docscribe ships a plugin system that lets you extend documentation generation without modifying the gem itself.
There are two extension points:
| Type | When it runs | What it produces |
|---|---|---|
| TagPlugin | After a method is collected and its doc block is being built | Extra YARD tags appended to the block |
| CollectorPlugin | Before doc building, alongside the standard AST collector | New insertion targets for non-standard constructs |
TagPlugin
A TagPlugin receives a snapshot of everything known about a method at generation time and returns zero or more
additional YARD tags to append to the doc block.
class SincePlugin < Docscribe::Plugin::Base::TagPlugin
def initialize(version:)
@version = version
end
# @param [Docscribe::Plugin::Context] context
# @return [Array<Docscribe::Plugin::Tag>]
def call(context)
[Docscribe::Plugin::Tag.new(name: 'since', text: @version)]
end
end
The Context struct provides:
| Attribute | Type | Description |
|---|---|---|
node |
Parser::AST::Node |
The :def or :defs AST node |
container |
String |
e.g. "MyModule::MyClass" |
scope |
Symbol |
:instance or :class |
visibility |
Symbol |
:public, :protected, or :private |
method_name |
Symbol |
Method name |
inferred_params |
Hash{String => String} |
Name -> inferred type |
inferred_return |
String |
Inferred return type |
source |
String |
Raw method source text |
The Tag struct:
# Simple tag
Docscribe::Plugin::Tag.new(name: 'since', text: '1.3.0')
# => # @since 1.3.0
# Tag with types
Docscribe::Plugin::Tag.new(name: 'raise', types: ['ArgumentError'], text: 'if name is nil')
# => # @raise [ArgumentError] if name is nil
CollectorPlugin
A CollectorPlugin receives the raw AST and source buffer for each file. It walks the tree itself and returns insertion
targets that Docscribe will document according to the selected strategy.
class DefineMethodPlugin < Docscribe::Plugin::Base::CollectorPlugin
# @param [Parser::AST::Node] ast
# @param [Parser::Source::Buffer] buffer
# @return [Array<Hash>]
def collect(ast, buffer)
results = []
Docscribe::Infer::ASTWalk.walk(ast) do |node|
next unless node.type == :send
_recv, meth, name_node, *_rest = *node
next unless meth == :define_method
next unless name_node&.type == :sym
meth_name = name_node.children.first
results << {
anchor_node: node,
doc: "# Dynamic method: #{meth_name}\n# @return [Object]\n"
}
end
results
end
end
Each result hash must have:
| Key | Type | Description |
|---|---|---|
:anchor_node |
Parser::AST::Node |
Node above which to insert the doc block |
:doc |
String |
Complete doc block text including newlines |
[!NOTE] You do not need to handle indentation manually. Docscribe reads the indentation from
anchor_nodeand applies it to every line of:docautomatically.
Registering plugins
Plugins are registered at load time. The recommended pattern is to put registrations in a dedicated file and reference
it from docscribe.yml.
docscribe_plugins.rb (in your project root or lib/):
require 'docscribe/plugin'
# Tag plugin
class SincePlugin < Docscribe::Plugin::Base::TagPlugin
def call(context)
[Docscribe::Plugin::Tag.new(name: 'since', text: '1.3.0')]
end
end
# Collector plugin
class DefineMethodPlugin < Docscribe::Plugin::Base::CollectorPlugin
def collect(ast, buffer)
# ...
end
end
Docscribe::Plugin::Registry.register(SincePlugin.new)
Docscribe::Plugin::Registry.register(DefineMethodPlugin.new)
docscribe.yml:
plugins:
require:
- ./docscribe_plugins
Each entry is passed to require. The path is expanded relative to the current working directory.
Duck typing is also supported — any object responding to #call is treated as a TagPlugin, any object responding to
#collect is treated as a CollectorPlugin:
# Lambda as a TagPlugin
Docscribe::Plugin::Registry.register(
->(context) { [Docscribe::Plugin::Tag.new(name: 'api', text: 'public')] }
)
Idempotency
Docscribe handles idempotency for plugins automatically.
TagPlugin: before appending a tag, Docscribe checks whether a tag with that name already exists in the current doc block. If it does, the tag is skipped.
CollectorPlugin: idempotency depends on the selected strategy.
| Strategy | Behaviour |
|---|---|
:safe |
Skips insertion if any comment block already exists immediately above anchor_node |
:aggressive |
Removes the existing comment block above anchor_node and inserts a fresh doc block |
This means a CollectorPlugin-generated block will not be duplicated on repeated safe runs, and will be fully rebuilt
on aggressive runs.
Plugin examples
Sample plugin available at examples
Configuration
Docscribe can be configured via a YAML file (docscribe.yml by default, or pass --config PATH).
Filtering
Docscribe can filter both files and methods.
File filtering (recommended for excluding specs, vendor code, etc.):
filter:
files:
exclude: [ "spec" ]
Method filtering matches method ids like:
MyModule::MyClass#instance_methodMyModule::MyClass.class_method
Example:
filter:
exclude:
- "*#initialize"
CLI overrides are available too:
# Method filtering (matches method ids like A#foo / A.bar)
docscribe --exclude '*#initialize' lib
docscribe --include '/^MyModule::.*#(foo|bar)$/' lib
# File filtering (matches paths relative to the project root)
docscribe --exclude-file 'spec' lib spec
docscribe --exclude-file '/^spec\//' lib
[!NOTE]
/regex/passed to--include/--excludeis treated as a method-id pattern. Use--include-file/--exclude-filefor file regex filters.
Enable attribute-style documentation generation with:
emit:
attributes: true
When enabled, Docscribe can generate YARD @!attribute docs for:
attr_readerattr_writerattr_accessorStruct.newdeclarations
attr_* example
[!NOTE]
- Attribute docs are inserted above the
attr_*call, not above generated methods (since they don’t exist asdefnodes).- If RBS is enabled, Docscribe will try to use the RBS return type of the reader method as the attribute type.
class User
attr_accessor :name
end
Generated docs:
class User
# @!attribute [rw] name
# @return [Object]
# @param [Object] value
attr_accessor :name
end
Struct.new examples
Docscribe supports both common Struct.new declaration styles.
Constant-assigned struct
User = Struct.new(:name, :email, keyword_init: true)
Generated docs:
# @!attribute [rw] name
# @return [Object]
# @param [Object] value
#
# @!attribute [rw] email
# @return [Object]
# @param [Object] value
User = Struct.new(:name, :email, keyword_init: true)
Class-based struct
class User < Struct.new(:name, :email, keyword_init: true)
end
Generated docs:
# @!attribute [rw] name
# @return [Object]
# @param [Object] value
#
# @!attribute [rw] email
# @return [Object]
# @param [Object] value
class User < Struct.new(:name, :email, keyword_init: true)
end
Docscribe preserves the original declaration style and does not rewrite one form into the other.
Merge behavior
Struct member docs use the same attribute documentation pipeline as attr_* macros, which means they participate in the
normal safe/aggressive rewrite flow.
In safe mode, Docscribe can:
- insert full
@!attributedocs when no doc-like block exists - append missing struct member docs into an existing doc-like block
Param tag style
Generated writer-style attribute docs respect doc.param_tag_style.
For example, with:
doc:
param_tag_style: "type_name"
writer params are emitted as:
# @param [Object] value
With:
doc:
param_tag_style: "name_type"
they are emitted as:
# @param value [Object]
Create a starter config
Create docscribe.yml in the current directory:
docscribe init
Write to a custom path:
docscribe init --config config/docscribe.yml
Overwrite if it already exists:
docscribe init --force
Print the template to stdout:
docscribe init --stdout
Generate a plugin skeleton
Docscribe can scaffold a plugin file so you don't have to write boilerplate by hand.
Generate a TagPlugin:
docscribe generate tag MyPlugin
# Created: my_plugin.rb
Generate a CollectorPlugin:
docscribe generate collector MyCollector
# Created: my_collector.rb
Write to a specific directory:
docscribe generate tag SincePlugin --output lib/plugins
# Created: lib/plugins/since_plugin.rb
Print to STDOUT instead of writing a file:
docscribe generate tag SincePlugin --stdout
The generated file contains:
- the correct base class (
Base::TagPluginorBase::CollectorPlugin) - inline comments describing every available
Contextattribute (TagPlugin) or the expected return shape (CollectorPlugin) - TODO markers showing exactly where to add your logic
- registration and next-steps instructions printed to the terminal
[!NOTE] The class name must be a valid Ruby constant (
MyPlugin,My::Plugin). The output filename is the snake_case equivalent (my_plugin.rb,my/plugin.rb).
CI integration
Fail the build if files would need safe updates:
- name: Check inline docs
run: docscribe lib
Apply safe fixes before the test stage:
- name: Apply safe inline docs
run: docscribe -a lib
Aggressively rebuild docs:
- name: Rebuild inline docs
run: docscribe -A lib
Comparison to YARD's parser
Docscribe and YARD solve different parts of the documentation problem:
- Docscribe inserts/updates inline comments by rewriting source.
- YARD can generate HTML docs based on inline comments.
Recommended workflow:
- Use Docscribe to seed and maintain inline docs with inferred tags/types.
- Optionally use YARD (dev-only) to render HTML from those comments:
yard doc -o docs
Limitations
- Safe mode only merges into existing doc-like comment blocks. Ordinary comments that are not recognized as documentation are preserved and treated conservatively.
- Type inference is heuristic. Complex flows and meta-programming will fall back to
Objector best-effort types. - Aggressive mode (
-A) replaces existing doc blocks and should be reviewed carefully.
Roadmap
- Method behavior inference from AST;
- YAML-based plugin configuration;
- Effective config dump;
- JSON output;
- Overload-aware signature selection;
- Manual
@!attributemerge policy; - Richer inference for common APIs;
- Editor integration.
Contributing
bundle exec rspec
bundle exec rubocop
Discussion & Community
License
MIT