Skip to content

Commit 08fdcec

Browse files
committed
Extract and display RBS type signatures in documentation
Add `type_signature` accessor to `MethodAttr`. During Prism parsing, extract `#:` annotation lines from comment blocks and store them on methods and attributes. Bump Marshal to v4 for RI serialization. Render type signatures in the aliki theme: below method headings for methods, inline after the `[RW]` badge for attributes. Arrows use `→` for consistency with `call_seq`. Validate annotations through `RBS::Parser` — invalid sigs emit a warning but are still displayed. Client-side JS highlighter colors type names (blue) and built-in keywords like `void`/`untyped`/`bool` (purple).
1 parent a5d0e1b commit 08fdcec

15 files changed

Lines changed: 606 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,30 @@ Please refer to `AGENTS.md` for comprehensive project documentation, including:
1010
- CI/CD information
1111

1212
All project-specific instructions and guidelines are maintained in `AGENTS.md`.
13+
14+
## Design Context
15+
16+
**Personality:** Minimal, focused, fast. A well-organized reference tool — never decorative, never in the way.
17+
18+
**References:** Elixir HexDocs, Tailwind CSS v2 docs. **Anti-references:** busy enterprise docs, heavy drop shadows, ornamental borders.
19+
20+
### Design Principles
21+
22+
1. **Types are equal partners** — Type signatures are as important as method names. Immediately visible, not hidden metadata.
23+
2. **Hierarchy through typography, not decoration** — Font size, weight, color. No badges, pills, or ornamental borders for structural info. Let whitespace do the heavy lifting.
24+
3. **Code is the content** — Method names, params, and types all use `--font-code`. Don't mix prose typography into code contexts.
25+
4. **Scan-first design** — Method entries parseable at a glance: name → type → description. Each layer visually distinct.
26+
5. **Respect the design system** — Use CSS custom properties exclusively. No hardcoded values. Dark mode and themes must work automatically.
27+
28+
### Design Tokens (Aliki Theme)
29+
30+
| Token | Light | Dark | Usage |
31+
|-------|-------|------|-------|
32+
| `--color-text-primary` | `#1c1917` | `#fafaf9` | Method names, headings |
33+
| `--color-text-secondary` | `#57534e` | `#e7e5e4` | Type signatures, descriptions |
34+
| `--color-text-tertiary` | `#78716c` | `#a8a29e` | De-emphasized metadata |
35+
| `--font-code` | ui-monospace stack | same | All code: names, params, types |
36+
| `--font-size-lg` | 18px | same | Method headings |
37+
| `--font-size-sm` | 14px | same | Type signatures |
38+
| `--font-size-xs` | 12px | same | Metadata, labels |
39+
| `--space-1` to `--space-6` | 4px–24px | same | All spacing |

lib/rdoc/code_object/any_method.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class RDoc::AnyMethod < RDoc::MethodAttr
1414
# RDoc 4.1
1515
# Added is_alias_for
1616

17-
MARSHAL_VERSION = 3 # :nodoc:
17+
MARSHAL_VERSION = 4 # :nodoc:
1818

1919
##
2020
# Don't rename \#initialize to \::new
@@ -166,6 +166,7 @@ def marshal_dump
166166
@parent.class,
167167
@section.title,
168168
is_alias_for,
169+
@type_signature,
169170
]
170171
end
171172

@@ -204,6 +205,7 @@ def marshal_load(array)
204205
@parent_title = array[13]
205206
@section_title = array[14]
206207
@is_alias_for = array[15]
208+
@type_signature = array[16]
207209

208210
array[8].each do |new_name, document|
209211
add_alias RDoc::Alias.new(nil, @name, new_name, RDoc::Comment.from_document(document), singleton: @singleton)

lib/rdoc/code_object/attr.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class RDoc::Attr < RDoc::MethodAttr
1111
# Added parent name and class
1212
# Added section title
1313

14-
MARSHAL_VERSION = 3 # :nodoc:
14+
MARSHAL_VERSION = 4 # :nodoc:
1515

1616
##
1717
# Is the attribute readable ('R'), writable ('W') or both ('RW')?
@@ -108,7 +108,8 @@ def marshal_dump
108108
@file.relative_name,
109109
@parent.full_name,
110110
@parent.class,
111-
@section.title
111+
@section.title,
112+
@type_signature,
112113
]
113114
end
114115

@@ -140,6 +141,7 @@ def marshal_load(array)
140141
@parent_name = array[8]
141142
@parent_class = array[9]
142143
@section_title = array[10]
144+
@type_signature = array[11]
143145

144146
@file = RDoc::TopLevel.new array[7] if version > 1
145147

lib/rdoc/code_object/method_attr.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ class RDoc::MethodAttr < RDoc::CodeObject
5858

5959
attr_accessor :call_seq
6060

61+
##
62+
# RBS type signature from inline #: annotations
63+
64+
attr_accessor :type_signature
65+
6166
##
6267
# The call_seq or the param_seq with method name, if there is no call_seq.
6368

@@ -86,6 +91,7 @@ def initialize(text, name, singleton: false)
8691
@block_params = nil
8792
@call_seq = nil
8893
@params = nil
94+
@type_signature = nil
8995
end
9096

9197
##

lib/rdoc/generator/template/aliki/_head.rhtml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@
145145
defer
146146
></script>
147147

148+
<script
149+
src="<%= h asset_rel_prefix %>/js/rbs_highlighter.js?v=<%= h RDoc::VERSION %>"
150+
defer
151+
></script>
152+
148153
<script
149154
src="<%= h asset_rel_prefix %>/js/aliki.js?v=<%= h RDoc::VERSION %>"
150155
defer

lib/rdoc/generator/template/aliki/class.rhtml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@
9191
<div class="method-heading attribute-method-heading">
9292
<a href="#<%= attrib.aref %>" title="Link to this attribute">
9393
<span class="method-name"><%= h attrib.name %></span>
94-
<span class="attribute-access-type">[<%= attrib.rw %>]</span>
95-
</a>
94+
<span class="attribute-access-type">[<%= attrib.rw %>]</span></a><%- if attrib.type_signature %>
95+
<span class="method-type-signature"><code><%= h attrib.type_signature %></code></span>
96+
<%- end %>
9697
</div>
9798

9899
<div class="method-description">
@@ -150,6 +151,14 @@
150151
</a>
151152
</div>
152153
<%- end %>
154+
155+
<%- if method.type_signature %>
156+
<div class="method-type-signature">
157+
<%- method.type_signature.split("\n").each do |sig| %>
158+
<code><%= h(sig).gsub('-&gt;', '&rarr;') %></code>
159+
<%- end %>
160+
</div>
161+
<%- end %>
153162
</div>
154163

155164
<%- if method.token_stream %>

lib/rdoc/generator/template/aliki/css/rdoc.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,18 @@ main h6 a:hover {
10751075
font-style: italic;
10761076
}
10771077

1078+
/* RBS Type Signature Highlighting — only types and builtins get color */
1079+
.rbs-type { color: var(--code-blue); }
1080+
.rbs-builtin { color: var(--code-purple); }
1081+
1082+
a.rbs-type {
1083+
text-decoration: none;
1084+
}
1085+
1086+
a.rbs-type:hover {
1087+
text-decoration: underline;
1088+
}
1089+
10781090
/* Emphasis */
10791091
em {
10801092
text-decoration-color: var(--color-emphasis-decoration);
@@ -1334,6 +1346,27 @@ main .method-heading .method-args {
13341346
font-weight: var(--font-weight-normal);
13351347
}
13361348

1349+
/* Type signatures — method overloads stack vertically under the name */
1350+
main .method-header .method-type-signature {
1351+
display: flex;
1352+
flex-wrap: wrap;
1353+
gap: var(--space-1);
1354+
}
1355+
1356+
main .method-type-signature code {
1357+
font-family: var(--font-code);
1358+
font-size: var(--font-size-sm);
1359+
font-weight: var(--font-weight-normal);
1360+
color: var(--color-text-secondary);
1361+
background: transparent;
1362+
}
1363+
1364+
/* Attribute type sigs render inline after the [RW] badge */
1365+
main .method-heading > .method-type-signature {
1366+
display: inline;
1367+
margin-left: var(--space-2);
1368+
}
1369+
13371370
main .method-controls {
13381371
position: absolute;
13391372
top: var(--space-3);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Client-side RBS type signature linker for RDoc
3+
*
4+
* Links type names in RBS annotations to their documentation pages
5+
* using the search index.
6+
*
7+
* NOTE: innerHTML usage is safe here — input is the element's own textContent
8+
* (not user-supplied) and all output is escaped through escapeHtml(). This
9+
* follows the same pattern as c_highlighter.js and bash_highlighter.js.
10+
*/
11+
12+
(function() {
13+
'use strict';
14+
15+
var typeLookup = null;
16+
17+
function buildTypeLookup() {
18+
var lookup = {};
19+
if (!window.search_data || !window.search_data.index) return lookup;
20+
21+
window.search_data.index.forEach(function(entry) {
22+
if (entry.type === 'class' || entry.type === 'module') {
23+
lookup[entry.full_name] = entry.path;
24+
var short = entry.name;
25+
if (!lookup[short]) lookup[short] = entry.path;
26+
}
27+
});
28+
return lookup;
29+
}
30+
31+
function escapeHtml(text) {
32+
return text
33+
.replace(/&/g, '&amp;')
34+
.replace(/</g, '&lt;')
35+
.replace(/>/g, '&gt;');
36+
}
37+
38+
function isIdentChar(ch) {
39+
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
40+
(ch >= '0' && ch <= '9') || ch === '_';
41+
}
42+
43+
function linkTypes(text) {
44+
var tokens = [];
45+
var i = 0;
46+
var len = text.length;
47+
var prefix = typeof rdoc_rel_prefix !== 'undefined' ? rdoc_rel_prefix : '';
48+
49+
while (i < len) {
50+
var ch = text[i];
51+
52+
// Uppercase identifier — possibly a type name, collect qualified name (Foo::Bar)
53+
if (ch >= 'A' && ch <= 'Z') {
54+
var end = i + 1;
55+
while (end < len && isIdentChar(text[end])) end++;
56+
while (end + 1 < len && text[end] === ':' && text[end + 1] === ':') {
57+
end += 2;
58+
while (end < len && isIdentChar(text[end])) end++;
59+
}
60+
var name = text.substring(i, end);
61+
var href = typeLookup ? typeLookup[name] : null;
62+
63+
if (href) {
64+
tokens.push('<a href="' + prefix + href + '" class="rbs-type">' + escapeHtml(name) + '</a>');
65+
} else {
66+
tokens.push(escapeHtml(name));
67+
}
68+
i = end;
69+
continue;
70+
}
71+
72+
// Any other identifier — pass through
73+
if ((ch >= 'a' && ch <= 'z') || ch === '_') {
74+
var end = i + 1;
75+
while (end < len && isIdentChar(text[end])) end++;
76+
tokens.push(escapeHtml(text.substring(i, end)));
77+
i = end;
78+
continue;
79+
}
80+
81+
tokens.push(escapeHtml(ch));
82+
i++;
83+
}
84+
85+
return tokens.join('');
86+
}
87+
88+
function initLinking() {
89+
var elements = document.querySelectorAll('.method-type-signature code');
90+
if (elements.length === 0) return;
91+
92+
typeLookup = buildTypeLookup();
93+
94+
elements.forEach(function(el) {
95+
if (el.getAttribute('data-linked') === 'true') return;
96+
el.innerHTML = linkTypes(el.textContent); // eslint-disable-line no-unsanitized/property
97+
el.setAttribute('data-linked', 'true');
98+
});
99+
}
100+
101+
if (document.readyState === 'loading') {
102+
document.addEventListener('DOMContentLoaded', initLinking);
103+
} else {
104+
initLinking();
105+
}
106+
})();

lib/rdoc/parser/prism_ruby.rb

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,39 @@ def skip_comments_until(line_no_until)
461461
def consecutive_comment(line_no)
462462
return unless @unprocessed_comments.first&.first == line_no
463463
_line_no, start_line, text = @unprocessed_comments.shift
464-
parse_comment_text_to_directives(text, start_line)
464+
type_signature = extract_type_signature!(text)
465+
result = parse_comment_text_to_directives(text, start_line)
466+
return unless result
467+
comment, directives = result
468+
[comment, directives, type_signature]
469+
end
470+
471+
# Extracts RBS type signature lines (#: ...) from raw comment text.
472+
# Mutates the input text to remove the extracted lines.
473+
# Returns the type signature string, or nil if none found.
474+
private def extract_type_signature!(text)
475+
return nil unless text.include?('#:')
476+
477+
lines = text.lines
478+
sig_lines, doc_lines = lines.partition { |l| l.match?(/\A#:\s/) }
479+
return nil if sig_lines.empty?
480+
481+
text.replace(doc_lines.join)
482+
type_sig = sig_lines.map { |l| l.sub(/\A#:\s?/, '').chomp }.join("\n")
483+
validate_type_signature(type_sig)
484+
type_sig
485+
end
486+
487+
private def validate_type_signature(sig)
488+
sig.split("\n").each do |line|
489+
# Method types contain ->, plain types (for attributes) don't
490+
error = if line.include?('->')
491+
RDoc::RbsSupport.validate_method_type(line)
492+
else
493+
RDoc::RbsSupport.validate_type(line)
494+
end
495+
@options.warn "Invalid RBS type signature: #{line.inspect}" if error
496+
end
465497
end
466498

467499
# Parses comment text and retuns a pair of RDoc::Comment and directives
@@ -594,14 +626,15 @@ def add_alias_method(old_name, new_name, line_no)
594626
# Handles `attr :a, :b`, `attr_reader :a, :b`, `attr_writer :a, :b` and `attr_accessor :a, :b`
595627

596628
def add_attributes(names, rw, line_no)
597-
comment, directives = consecutive_comment(line_no)
629+
comment, directives, type_signature = consecutive_comment(line_no)
598630
handle_code_object_directives(@container, directives) if directives
599631
return unless @container.document_children
600632

601633
names.each do |symbol|
602634
a = RDoc::Attr.new(nil, symbol.to_s, rw, comment, singleton: @singleton)
603635
a.store = @store
604636
a.line = line_no
637+
a.type_signature = type_signature
605638
record_location(a)
606639
handle_modifier_directive(a, line_no)
607640
@container.add_attribute(a) if should_document?(a)
@@ -640,7 +673,7 @@ def add_extends(names, line_no) # :nodoc:
640673

641674
def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, start_line:, args_end_line:, end_line:)
642675
receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container
643-
comment, directives = consecutive_comment(start_line)
676+
comment, directives, type_signature = consecutive_comment(start_line)
644677
handle_code_object_directives(@container, directives) if directives
645678

646679
internal_add_method(
@@ -655,11 +688,12 @@ def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility:
655688
params: params,
656689
calls_super: calls_super,
657690
block_params: block_params,
658-
tokens: tokens
691+
tokens: tokens,
692+
type_signature: type_signature
659693
)
660694
end
661695

662-
private def internal_add_method(method_name, container, comment:, dont_rename_initialize: false, directives:, modifier_comment_lines: nil, line_no:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:) # :nodoc:
696+
private def internal_add_method(method_name, container, comment:, dont_rename_initialize: false, directives:, modifier_comment_lines: nil, line_no:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, type_signature: nil) # :nodoc:
663697
meth = RDoc::AnyMethod.new(nil, method_name, singleton: singleton)
664698
meth.comment = comment
665699
handle_code_object_directives(meth, directives) if directives
@@ -680,6 +714,7 @@ def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility:
680714
meth.params ||= params || '()'
681715
meth.calls_super = calls_super
682716
meth.block_params ||= block_params if block_params
717+
meth.type_signature = type_signature
683718
record_location(meth)
684719
meth.start_collecting_tokens(:ruby)
685720
tokens.each do |token|

0 commit comments

Comments
 (0)