Skip to content

Commit 195d801

Browse files
committed
Add tools/rdoc-to-md script
Generally the idea is: - use Prism to parse the file into AST + Comments - transform each comment block into plain RDoc (instead of RDoc in a comment) - use RDoc's ToMarkdown class to get a Markdown representation of the comment - transform the Markdown representation back into a comment - write a new file, skipping the lines that were previously RDoc comments and instead inserting the new Markdown comments A little extra work has to be down for metaprogrammed documentation because ToMarkdown turns RDoc directives into H1s. So for these cases, the directive is first split off the top before doing the ToMarkdown transformation and then added back afterwards.
1 parent c7551d0 commit 195d801

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

tools/rdoc-to-md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "optparse"
5+
require "pathname"
6+
require "strscan"
7+
8+
require "rdoc"
9+
require "prism"
10+
11+
OPTIONS = {}
12+
13+
OptionParser
14+
.new do |opts|
15+
opts.banner = "Usage: rdoc-to-md RAILS_ROOT [options]"
16+
17+
opts.on("-a", "Apply changes")
18+
opts.on("--only=FOLDERS", Array)
19+
end
20+
.parse!(into: OPTIONS)
21+
22+
RAILS_PATH = File.expand_path("..", __dir__)
23+
24+
folders = Dir["#{RAILS_PATH}/*/*.gemspec"].map { |p| Pathname.new(p).dirname }
25+
26+
unless OPTIONS[:only].nil?
27+
folders.filter! { |path| OPTIONS[:only].include?(File.basename(path)) }
28+
end
29+
30+
class Comment
31+
class << self
32+
def from(comment_nodes)
33+
comments_source_lines = source_lines_for(comment_nodes)
34+
35+
if comments_source_lines.first == "##"
36+
MetaComment
37+
else
38+
Comment
39+
end.new(comments_source_lines)
40+
end
41+
42+
private
43+
def source_lines_for(comment_nodes)
44+
comment_nodes.map { _1.location.slice }
45+
end
46+
end
47+
48+
def initialize(source_lines)
49+
@source_lines = source_lines
50+
51+
strip_hash_prefix!
52+
end
53+
54+
def write!(out, indentation)
55+
as_markdown.each_line do |new_markdown_line|
56+
out << commented(new_markdown_line, indentation).rstrip << "\n"
57+
end
58+
end
59+
60+
private
61+
attr_reader :source_lines
62+
63+
def strip_hash_prefix!
64+
source_lines.each { |line|
65+
line.delete_prefix!("#")
66+
line.delete_prefix!(" ")
67+
}
68+
end
69+
70+
def commented(markdown, indentation)
71+
(" " * indentation) + "# " + markdown
72+
end
73+
74+
def as_markdown
75+
converter.convert(source_lines.join("\n"))
76+
end
77+
78+
def converter
79+
RDoc::Markup::ToMarkdown.new
80+
end
81+
end
82+
83+
class MetaComment < Comment
84+
def write!(out, indentation)
85+
spaces = " " * indentation
86+
87+
out << spaces << "##\n" # ##
88+
out << commented(source_lines[1], indentation) << "\n" # # :method: ...
89+
90+
super
91+
end
92+
93+
private
94+
def as_markdown
95+
converter.convert(content_after_directive)
96+
end
97+
98+
def content_after_directive
99+
source_lines[2..].join("\n")
100+
end
101+
end
102+
103+
class CommentVisitor < Prism::BasicVisitor
104+
attr_reader :new_comments, :old_comment_lines
105+
106+
def initialize
107+
# starting line => full block comment
108+
@new_comments = {}
109+
@old_comment_lines = Set.new
110+
end
111+
112+
def method_missing(_, node)
113+
comments = node.location.comments
114+
process(comments) if process?(comments)
115+
116+
visit_child_nodes(node)
117+
end
118+
119+
private
120+
def process?(comments)
121+
return false if comments.empty?
122+
123+
if comments.any?(&:trailing?)
124+
return false if comments.all?(&:trailing?)
125+
126+
raise "only some comments are trailing?"
127+
end
128+
129+
true
130+
end
131+
132+
def process(comments)
133+
old_comment_range = line_range_for(comments)
134+
old_comment_range.each { @old_comment_lines << _1 }
135+
136+
@new_comments[old_comment_range.begin] = Comment.from(comments)
137+
end
138+
139+
def line_range_for(comments)
140+
comments.first.location.start_line..comments.last.location.start_line
141+
end
142+
end
143+
144+
class CodeBlockConverter
145+
def initialize(file_path)
146+
@file_path = file_path
147+
148+
@parse_result = Prism.parse_file(@file_path)
149+
@parse_result.attach_comments!
150+
151+
@cv = CommentVisitor.new
152+
@source = @parse_result.source.source
153+
154+
@parse_result.value.accept(@cv)
155+
end
156+
157+
def convert!
158+
new_source = output
159+
160+
if @source.include?(MD_DIRECTIVE) || new_source == @source
161+
$stdout.write "."
162+
else
163+
File.write(@file_path, output)
164+
$stdout.write "C"
165+
end
166+
end
167+
168+
def print
169+
if output != @source
170+
$stdout.write "C"
171+
else
172+
$stdout.write "."
173+
end
174+
end
175+
176+
private
177+
MD_DIRECTIVE = "# :markup: markdown"
178+
179+
def output
180+
out = +""
181+
182+
@source.each_line.with_index do |old_line, i|
183+
line_number = i + 1
184+
185+
out << "\n" << MD_DIRECTIVE << "\n" if line_number == 2
186+
187+
if @cv.old_comment_lines.include?(line_number)
188+
if new_comment = @cv.new_comments[line_number]
189+
indentation = old_line.index("#")
190+
191+
new_comment.write!(out, indentation)
192+
end
193+
else
194+
out << old_line
195+
end
196+
end
197+
198+
out
199+
end
200+
end
201+
202+
folders.each do |folder|
203+
ruby_files = Dir["#{folder}/{app,lib}/**/*.rb"]
204+
205+
ruby_files.each do |file_path|
206+
converter = CodeBlockConverter.new(file_path)
207+
208+
if OPTIONS[:a]
209+
converter.convert!
210+
else
211+
converter.print
212+
end
213+
end
214+
end

0 commit comments

Comments
 (0)