boondoc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +345 -0
- data/exe/boondoc +6 -0
- data/lib/boondoc/assets.rb +112 -0
- data/lib/boondoc/cli.rb +99 -0
- data/lib/boondoc/config.rb +201 -0
- data/lib/boondoc/containment.rb +40 -0
- data/lib/boondoc/context.rb +48 -0
- data/lib/boondoc/core_constants.rb +23 -0
- data/lib/boondoc/docstring_parser.rb +119 -0
- data/lib/boondoc/filter.rb +34 -0
- data/lib/boondoc/frontend/comments.rb +67 -0
- data/lib/boondoc/frontend/prism_parser.rb +40 -0
- data/lib/boondoc/frontend/visitor.rb +359 -0
- data/lib/boondoc/generator.rb +116 -0
- data/lib/boondoc/linker.rb +175 -0
- data/lib/boondoc/links.rb +78 -0
- data/lib/boondoc/markdown.rb +90 -0
- data/lib/boondoc/model/code_object.rb +165 -0
- data/lib/boondoc/model/docstring.rb +70 -0
- data/lib/boondoc/model/registry.rb +150 -0
- data/lib/boondoc/path_map.rb +108 -0
- data/lib/boondoc/preview.rb +81 -0
- data/lib/boondoc/rake_task.rb +39 -0
- data/lib/boondoc/references.rb +59 -0
- data/lib/boondoc/renderer/base.rb +50 -0
- data/lib/boondoc/renderer/docstring.rb +140 -0
- data/lib/boondoc/renderer/index.rb +49 -0
- data/lib/boondoc/renderer/method_section.rb +84 -0
- data/lib/boondoc/renderer/namespace_page.rb +165 -0
- data/lib/boondoc/reporter.rb +36 -0
- data/lib/boondoc/stitcher.rb +89 -0
- data/lib/boondoc/type_parser.rb +78 -0
- data/lib/boondoc/version.rb +5 -0
- data/lib/boondoc/writer.rb +163 -0
- data/lib/boondoc/yard_bridge.rb +113 -0
- data/lib/boondoc/yardopts.rb +165 -0
- data/lib/boondoc.rb +48 -0
- metadata +116 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Collects warnings and prints progress messages.
|
|
5
|
+
class Reporter
|
|
6
|
+
# @return [Array<String>] every warning raised so far
|
|
7
|
+
attr_reader :warnings
|
|
8
|
+
|
|
9
|
+
# @param io [IO] where messages are written
|
|
10
|
+
# @param quiet [Boolean] suppress informational messages (warnings still print)
|
|
11
|
+
def initialize(io: $stderr, quiet: false)
|
|
12
|
+
@io = io
|
|
13
|
+
@quiet = quiet
|
|
14
|
+
@warnings = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Records and prints a warning. Repeats of the same message are ignored.
|
|
18
|
+
#
|
|
19
|
+
# @param message [String]
|
|
20
|
+
# @return [void]
|
|
21
|
+
def warn(message)
|
|
22
|
+
return if @warnings.include?(message)
|
|
23
|
+
|
|
24
|
+
@warnings << message
|
|
25
|
+
@io.puts("warning: #{message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Prints an informational message unless quiet.
|
|
29
|
+
#
|
|
30
|
+
# @param message [String]
|
|
31
|
+
# @return [void]
|
|
32
|
+
def info(message)
|
|
33
|
+
@io.puts(message) unless @quiet
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Boondoc
|
|
6
|
+
# A non-API file (README, changelog, guide) that is copied into the output.
|
|
7
|
+
ExtraFile = Struct.new(:source, :output, :content, :readme, keyword_init: true) do
|
|
8
|
+
# @return [String] the first top-level heading, or the file name
|
|
9
|
+
def title
|
|
10
|
+
content[/^#\s+(.+?)\s*#*\s*$/, 1] || File.basename(source, '.*')
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Loads the extra files and keeps their relative links working after the move.
|
|
15
|
+
class Stitcher
|
|
16
|
+
MARKDOWN = /\.(?:md|markdown)\z/i
|
|
17
|
+
|
|
18
|
+
# @return [Array<ExtraFile>] the README (if any) first, then the other files
|
|
19
|
+
attr_reader :files
|
|
20
|
+
|
|
21
|
+
# @param config [Config]
|
|
22
|
+
# @param assets [Assets] the files that are copied into the output, which links follow to their copies
|
|
23
|
+
def initialize(config, assets: Assets.new(config))
|
|
24
|
+
@config = config
|
|
25
|
+
@assets = assets
|
|
26
|
+
readme = config.readme_file
|
|
27
|
+
@files = config.extra_files.map { |source| load(source, "files/#{output_name(source)}") }
|
|
28
|
+
@files.unshift(load(readme, config.index, readme: true)) if readme
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [ExtraFile, nil]
|
|
32
|
+
def readme
|
|
33
|
+
files.find(&:readme)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Finds a stitched file by path, file name, or file name without extension.
|
|
37
|
+
#
|
|
38
|
+
# @param name [String]
|
|
39
|
+
# @return [ExtraFile, nil]
|
|
40
|
+
def find(name)
|
|
41
|
+
files.find { |file| file.source == name } ||
|
|
42
|
+
files.find { |file| [File.basename(file.source), File.basename(file.source, '.*')].include?(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Rewrites relative links and image paths so they still point at the same
|
|
46
|
+
# thing from the file's new location in the output directory. A path to a
|
|
47
|
+
# file that is copied into the output points at the copy.
|
|
48
|
+
#
|
|
49
|
+
# @param file [ExtraFile]
|
|
50
|
+
# @return [String] the file's content with links adjusted
|
|
51
|
+
def relocated_content(file)
|
|
52
|
+
Links.rewrite(file.content) { |target, embedded| relocate(target, file, embedded: embedded) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def load(source, output, readme: false)
|
|
58
|
+
content = File.read(File.expand_path(source, @config.root))
|
|
59
|
+
content = "# #{File.basename(source)}\n\n```\n#{content.rstrip}\n```\n" unless MARKDOWN.match?(source)
|
|
60
|
+
ExtraFile.new(source: source, output: output, content: content, readme: readme)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sources are relative to the project root; {Config#extra_files} rejects any outside it.
|
|
64
|
+
def output_name(source)
|
|
65
|
+
MARKDOWN.match?(source) ? source.sub(MARKDOWN, '.md') : "#{source}.md"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def relocate(target, file, embedded:)
|
|
69
|
+
return target if Links::EXTERNAL.match?(target)
|
|
70
|
+
|
|
71
|
+
path, suffix = target.split(/(?=[#?])/, 2)
|
|
72
|
+
destination = destination_of(File.expand_path(path, File.dirname(source_path(file))), embedded)
|
|
73
|
+
origin = Pathname.new(File.expand_path(file.output, @config.output_dir)).dirname
|
|
74
|
+
"#{Pathname.new(destination).relative_path_from(origin)}#{suffix}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def source_path(file)
|
|
78
|
+
File.expand_path(file.source, @config.root)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A link to another stitched file or to a copied asset follows it to its new
|
|
82
|
+
# home; anything else keeps pointing at the original file in the project.
|
|
83
|
+
def destination_of(absolute, embedded)
|
|
84
|
+
stitched = files.find { |other| source_path(other) == absolute }
|
|
85
|
+
output = stitched ? stitched.output : @assets.output_for(absolute, embedded: embedded)
|
|
86
|
+
output ? File.expand_path(output, @config.output_dir) : absolute
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Splits a YARD type list such as `[String, Hash{Symbol => Array<Integer>}]`.
|
|
5
|
+
module TypeParser
|
|
6
|
+
OPENERS = { '[' => ']', '<' => '>', '(' => ')', '{' => '}' }.freeze
|
|
7
|
+
# Duck types naming an operator method that contains bracket characters.
|
|
8
|
+
OPERATOR_DUCK_TYPE = /#(?:<=>|<<|>>|[<>]=?|\[\]=?)/
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Takes a leading bracketed type list off a string.
|
|
13
|
+
#
|
|
14
|
+
# @param text [String]
|
|
15
|
+
# @return [Array(Array<String>, String), Array(nil, String)] the types (nil
|
|
16
|
+
# when the text does not start with `[`) and the remaining text
|
|
17
|
+
def extract(text)
|
|
18
|
+
text = text.lstrip
|
|
19
|
+
return [nil, text] unless text.start_with?('[')
|
|
20
|
+
|
|
21
|
+
close = closing_index(text)
|
|
22
|
+
return [nil, text] unless close
|
|
23
|
+
|
|
24
|
+
[split(text[1...close]), text[(close + 1)..].lstrip]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Splits on top-level commas, leaving nested brackets intact.
|
|
28
|
+
#
|
|
29
|
+
# @param list [String] the text between the outer brackets
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def split(list)
|
|
32
|
+
types = ['']
|
|
33
|
+
each_with_depth(list) do |char, _, depth|
|
|
34
|
+
next types << +'' if char == ',' && depth.zero?
|
|
35
|
+
|
|
36
|
+
types[-1] += char
|
|
37
|
+
end
|
|
38
|
+
types.map(&:strip).reject(&:empty?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def closing_index(text)
|
|
43
|
+
each_with_depth(text) { |_, index, depth| return index if depth.zero? }
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Yields each character with the nesting depth after it.
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
def each_with_depth(text)
|
|
51
|
+
skip = operator_indices(text)
|
|
52
|
+
depth = 0
|
|
53
|
+
text.each_char.with_index do |char, index|
|
|
54
|
+
depth += depth_change(text, char, index) unless skip.include?(index)
|
|
55
|
+
yield char, index, depth
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @api private
|
|
60
|
+
def operator_indices(text)
|
|
61
|
+
indices = []
|
|
62
|
+
text.scan(OPERATOR_DUCK_TYPE) do
|
|
63
|
+
match = Regexp.last_match
|
|
64
|
+
indices.concat((match.begin(0)...match.end(0)).to_a)
|
|
65
|
+
end
|
|
66
|
+
indices
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def depth_change(text, char, index)
|
|
71
|
+
return 1 if OPENERS.key?(char)
|
|
72
|
+
return 0 unless OPENERS.value?(char)
|
|
73
|
+
|
|
74
|
+
# The `>` of a hash rocket (`=>`) does not close anything.
|
|
75
|
+
char == '>' && index.positive? && text[index - 1] == '=' ? 0 : -1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Boondoc
|
|
6
|
+
# Writes generated pages and removes pages left over from earlier runs.
|
|
7
|
+
#
|
|
8
|
+
# A manifest in the output directory records what Boondoc wrote, so only its
|
|
9
|
+
# own files are ever deleted or silently overwritten. A page whose content has
|
|
10
|
+
# not changed is not written again.
|
|
11
|
+
class Writer
|
|
12
|
+
MANIFEST = '.boondoc-manifest'
|
|
13
|
+
|
|
14
|
+
# @param output_dir [String] absolute path of the output directory
|
|
15
|
+
# @param reporter [Reporter]
|
|
16
|
+
# @param force [Boolean] overwrite files that Boondoc did not generate
|
|
17
|
+
def initialize(output_dir, reporter, force: false)
|
|
18
|
+
@output_dir = output_dir
|
|
19
|
+
@reporter = reporter
|
|
20
|
+
@force = force
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param pages [Hash{String => String}] content keyed by path relative to the output directory
|
|
24
|
+
# @param assets [Hash{String => String}] absolute source paths of files to copy, keyed by
|
|
25
|
+
# the path relative to the output directory
|
|
26
|
+
# @return [Array<String>] stale files that were removed
|
|
27
|
+
# @raise [Error] when a page would overwrite a file Boondoc did not generate,
|
|
28
|
+
# or would be written outside the output directory or through a symlink
|
|
29
|
+
def write(pages, assets: {})
|
|
30
|
+
outputs = pages.keys + assets.keys
|
|
31
|
+
previous = manifest
|
|
32
|
+
check(outputs, previous)
|
|
33
|
+
stale = remove_stale(previous - outputs)
|
|
34
|
+
pages.each { |path, content| write_page(path, content) }
|
|
35
|
+
assets.each { |path, source| copy_asset(path, source) }
|
|
36
|
+
write_file(File.join(@output_dir, MANIFEST), "#{outputs.sort.join("\n")}\n")
|
|
37
|
+
stale
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def manifest
|
|
43
|
+
path = File.join(@output_dir, MANIFEST)
|
|
44
|
+
return [] unless File.file?(path)
|
|
45
|
+
|
|
46
|
+
File.readlines(path, chomp: true).reject(&:empty?)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check(paths, previous)
|
|
50
|
+
paths.each { |path| check_destination(path) }
|
|
51
|
+
check_duplicates(paths)
|
|
52
|
+
check_conflicts(paths, previous)
|
|
53
|
+
warn_case_collisions(paths)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A page and an asset cannot be written to the same path.
|
|
57
|
+
def check_duplicates(paths)
|
|
58
|
+
duplicate = paths.tally.find { |_, count| count > 1 }&.first
|
|
59
|
+
raise Error, "a page and an asset would both be written to #{duplicate}" if duplicate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Stale files go first: on a case-insensitive filesystem a file renamed only by case is the same
|
|
63
|
+
# file, and removing it afterwards would delete the file just written.
|
|
64
|
+
def remove_stale(paths)
|
|
65
|
+
stale = paths.select { |path| removable?(path) }
|
|
66
|
+
stale.each { |path| remove(path) }
|
|
67
|
+
stale
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_destination(path)
|
|
71
|
+
problem = unsafe(path)
|
|
72
|
+
raise Error, "refusing to write #{path}: #{problem}" if problem
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The manifest is part of the output directory and may have been edited,
|
|
76
|
+
# so stale entries get the same checks as pages before anything is deleted.
|
|
77
|
+
def removable?(path)
|
|
78
|
+
problem = unsafe(path)
|
|
79
|
+
@reporter.warn("not removing stale #{path}: #{problem}") if problem
|
|
80
|
+
problem.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] why writing or deleting `path` would touch a file
|
|
84
|
+
# outside the output directory
|
|
85
|
+
def unsafe(path)
|
|
86
|
+
full = File.expand_path(path, @output_dir)
|
|
87
|
+
return "it is outside #{@output_dir}" unless full.start_with?(File.join(@output_dir, ''))
|
|
88
|
+
|
|
89
|
+
# The symlink check below resolves `..` by name, but the file system follows a symlink first.
|
|
90
|
+
return 'it contains `..`' if path.split('/').include?('..')
|
|
91
|
+
|
|
92
|
+
link = Containment.symlink_between(full, @output_dir)
|
|
93
|
+
"#{link} is a symlink" if link
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_conflicts(paths, previous)
|
|
97
|
+
return if @force
|
|
98
|
+
|
|
99
|
+
conflicts = paths.select do |path|
|
|
100
|
+
File.exist?(File.join(@output_dir, path)) &&
|
|
101
|
+
!previous.include?(path) && !same_file_as_earlier_page?(path, previous)
|
|
102
|
+
end
|
|
103
|
+
return if conflicts.empty?
|
|
104
|
+
|
|
105
|
+
raise Error, "refusing to overwrite #{conflicts.size} file(s) that boondoc did not generate " \
|
|
106
|
+
"(#{conflicts.first(5).join(', ')}#{', ...' if conflicts.size > 5}). " \
|
|
107
|
+
'Use --force to overwrite, or choose another --output directory.'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# True when a case-insensitive filesystem maps the path onto a page Boondoc wrote before.
|
|
111
|
+
def same_file_as_earlier_page?(path, previous)
|
|
112
|
+
previous.any? do |known|
|
|
113
|
+
known.casecmp?(path) && File.identical?(File.join(@output_dir, known), File.join(@output_dir, path))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Pages for classes and modules never get here, because PathMap tells them apart. What is left
|
|
118
|
+
# are stitched files, assets and the index, whose names come from the project.
|
|
119
|
+
def warn_case_collisions(paths)
|
|
120
|
+
paths.group_by(&:downcase).each_value do |group|
|
|
121
|
+
next if group.size == 1
|
|
122
|
+
|
|
123
|
+
@reporter.warn("#{group.sort.join(' and ')} differ only by case and will collide on case-insensitive " \
|
|
124
|
+
'filesystems; rename one of them')
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_page(path, content)
|
|
129
|
+
full = File.join(@output_dir, path)
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
131
|
+
write_file(full, content)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Leaves a file that already holds this content alone, so its modification
|
|
135
|
+
# time only changes when the page does. This runs after every destination
|
|
136
|
+
# has passed {#check}, so it never reads through a symlink.
|
|
137
|
+
def write_file(full, content)
|
|
138
|
+
return if File.file?(full) && File.size(full) == content.bytesize && File.binread(full) == content.b
|
|
139
|
+
|
|
140
|
+
File.write(full, content)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Copies a file byte for byte. A copy that already matches is left alone, so
|
|
144
|
+
# its modification time only changes when the file does.
|
|
145
|
+
def copy_asset(path, source)
|
|
146
|
+
full = File.join(@output_dir, path)
|
|
147
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
148
|
+
return if File.file?(full) && FileUtils.compare_file(source, full)
|
|
149
|
+
|
|
150
|
+
FileUtils.cp(source, full)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def remove(path)
|
|
154
|
+
full = File.join(@output_dir, path)
|
|
155
|
+
FileUtils.rm_f(full)
|
|
156
|
+
directory = File.dirname(full)
|
|
157
|
+
while directory.start_with?("#{@output_dir}/") && Dir.empty?(directory)
|
|
158
|
+
Dir.rmdir(directory)
|
|
159
|
+
directory = File.dirname(directory)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Optional second pass that uses YARD to expand macros and directives.
|
|
5
|
+
#
|
|
6
|
+
# Prism stays the source of truth. YARD only contributes objects that Prism
|
|
7
|
+
# could not see (those created by `@!method`, `@!attribute`, or an attached
|
|
8
|
+
# macro) and the expanded docstrings of objects whose comment uses a directive.
|
|
9
|
+
#
|
|
10
|
+
# The `yard` gem is loaded on demand and is not a dependency of boondoc.
|
|
11
|
+
class YardBridge
|
|
12
|
+
DEFAULT_SUPERCLASSES = %w[Object BasicObject].freeze
|
|
13
|
+
# The "error" severity, which has the same value in every YARD logger implementation.
|
|
14
|
+
ERROR_LEVEL = 3
|
|
15
|
+
|
|
16
|
+
# @param registry [Model::Registry]
|
|
17
|
+
# @param reporter [Reporter]
|
|
18
|
+
def initialize(registry, reporter)
|
|
19
|
+
@registry = registry
|
|
20
|
+
@reporter = reporter
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param files [Array<String>] the Ruby files that contain YARD directives, relative to `root`
|
|
24
|
+
# @param root [String] the project directory. YARD is given absolute paths, so the
|
|
25
|
+
# working directory does not matter, and the files it records are made relative again.
|
|
26
|
+
# @return [Integer] how many objects were added from YARD
|
|
27
|
+
# @raise [Error] when the yard gem is not installed
|
|
28
|
+
def merge(files, root:)
|
|
29
|
+
load_yard
|
|
30
|
+
@root = File.expand_path(root)
|
|
31
|
+
@added = 0
|
|
32
|
+
::YARD::Registry.clear
|
|
33
|
+
paths = files.map { |file| File.expand_path(file, @root) }
|
|
34
|
+
::YARD::Logger.instance.enter_level(ERROR_LEVEL) { ::YARD.parse(paths) }
|
|
35
|
+
yard_objects.each { |object| merge_object(object) }
|
|
36
|
+
@added
|
|
37
|
+
ensure
|
|
38
|
+
::YARD::Registry.clear if defined?(::YARD::Registry)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def load_yard
|
|
44
|
+
require 'yard'
|
|
45
|
+
rescue LoadError
|
|
46
|
+
raise Error, "--yard-macros needs the yard gem. Add `gem 'yard'` to your Gemfile or run `gem install yard`."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def yard_objects
|
|
50
|
+
namespaces = ::YARD::Registry.all(:module, :class).sort_by { |object| object.path.count(':') }
|
|
51
|
+
namespaces + ::YARD::Registry.all(:constant, :method)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def merge_object(yard_object)
|
|
55
|
+
existing = @registry[yard_object.path]
|
|
56
|
+
return add(yard_object) unless existing
|
|
57
|
+
|
|
58
|
+
existing.docstring = docstring(yard_object) if existing.docstring.directives.any?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add(yard_object)
|
|
62
|
+
namespace = @registry[yard_object.namespace.path]
|
|
63
|
+
return unless namespace.is_a?(Model::NamespaceObject)
|
|
64
|
+
|
|
65
|
+
object = build(yard_object, namespace)
|
|
66
|
+
object.visibility = yard_object.visibility
|
|
67
|
+
object.docstring = docstring(yard_object)
|
|
68
|
+
object.add_file(relative(yard_object.file), yard_object.line) if yard_object.file
|
|
69
|
+
@registry.register(object)
|
|
70
|
+
@added += 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def relative(file)
|
|
74
|
+
prefix = File.join(@root, '')
|
|
75
|
+
file.start_with?(prefix) ? file.delete_prefix(prefix) : file
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build(yard_object, namespace)
|
|
79
|
+
name = yard_object.name.to_s
|
|
80
|
+
case yard_object.type
|
|
81
|
+
when :constant then Model::ConstantObject.new(name, namespace, value: yard_object.value.to_s)
|
|
82
|
+
when :method then build_method(yard_object, name, namespace)
|
|
83
|
+
else build_namespace(yard_object, name, namespace)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_namespace(yard_object, name, namespace)
|
|
88
|
+
object = Model::NamespaceObject.new(name, namespace, kind: yard_object.type)
|
|
89
|
+
superclass = yard_object.superclass&.path if yard_object.type == :class
|
|
90
|
+
object.superclass = superclass unless DEFAULT_SUPERCLASSES.include?(superclass)
|
|
91
|
+
object
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_method(yard_object, name, namespace)
|
|
95
|
+
attribute = (name.end_with?('=') ? :writer : :reader) if yard_object.is_attribute?
|
|
96
|
+
Model::MethodObject.new(name, namespace, scope: yard_object.scope, attribute: attribute,
|
|
97
|
+
parameters: parameters(yard_object))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parameters(yard_object)
|
|
101
|
+
list = yard_object.parameters.map do |name, default|
|
|
102
|
+
next name.to_s unless default
|
|
103
|
+
|
|
104
|
+
name.to_s.end_with?(':') ? "#{name} #{default}" : "#{name} = #{default}"
|
|
105
|
+
end
|
|
106
|
+
list.empty? ? nil : list.join(', ')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def docstring(yard_object)
|
|
110
|
+
DocstringParser.parse(yard_object.docstring.to_raw)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Boondoc
|
|
6
|
+
# The part of a project's `.yardopts` that Boondoc understands.
|
|
7
|
+
#
|
|
8
|
+
# Only the source globs, the extra files after `-`, `--files`, `--asset`, `--readme`,
|
|
9
|
+
# `--title`, `--private`, `--protected`, and `--exclude` are used. `--output-dir`
|
|
10
|
+
# is left out on purpose: in a `.yardopts` it names YARD's HTML directory, which is
|
|
11
|
+
# usually ignored by git, while Boondoc's output is meant to be committed.
|
|
12
|
+
# Everything else is collected in {#ignored} so the run can say what was skipped.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Boondoc::Yardopts.parse('--title Foo -o doc lib - CHANGELOG.md').settings
|
|
16
|
+
# # => { title: "Foo", sources: ["lib"], files: ["CHANGELOG.md"] }
|
|
17
|
+
class Yardopts
|
|
18
|
+
FILE = '.yardopts'
|
|
19
|
+
# Long options of the yard gem that take an argument.
|
|
20
|
+
ARGUMENT_OPTIONS = %w[
|
|
21
|
+
--load --plugin --db --exclude --api --no-api --hide-api --default-return --query --title
|
|
22
|
+
--readme --main --files --asset --output-dir --markup --markup-provider --charset --template
|
|
23
|
+
--template-path --format --locale --po-dir --tag --type-tag --type-name-tag --name-tag
|
|
24
|
+
--title-tag --hide-tag --transitive-tag --non-transitive-tag
|
|
25
|
+
].freeze
|
|
26
|
+
# Long options whose argument is optional; it is the next token unless that starts with `-`.
|
|
27
|
+
OPTIONAL_ARGUMENT_OPTIONS = %w[--use-cache --yardopts --no-yardopts --embed-mixin].freeze
|
|
28
|
+
SHORT_ARGUMENT_OPTIONS = {
|
|
29
|
+
'b' => '--db', 'e' => '--load', 'o' => '--output-dir', 'm' => '--markup', 'M' => '--markup-provider',
|
|
30
|
+
't' => '--template', 'p' => '--template-path', 'f' => '--format', 'r' => '--readme'
|
|
31
|
+
}.freeze
|
|
32
|
+
SHORT_OPTIONAL_ARGUMENT_OPTIONS = { 'c' => '--use-cache' }.freeze
|
|
33
|
+
NON_RUBY_SOURCE = /\.(?:c|cc|cpp|cxx|h|hpp|y)\z/
|
|
34
|
+
# How long a pattern from `--exclude` may take to match one path.
|
|
35
|
+
REGEXP_TIMEOUT = 1.0
|
|
36
|
+
|
|
37
|
+
# @param root [String] project directory
|
|
38
|
+
# @return [Yardopts, nil] nil when the project has no `.yardopts`
|
|
39
|
+
# @raise [Error] when the file cannot be read or parsed
|
|
40
|
+
def self.read(root)
|
|
41
|
+
path = File.join(root, FILE)
|
|
42
|
+
return nil unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
parse(File.read(path))
|
|
45
|
+
rescue SystemCallError => e
|
|
46
|
+
raise Error, "could not read #{FILE}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param text [String] the contents of a `.yardopts`
|
|
50
|
+
# @return [Yardopts]
|
|
51
|
+
# @raise [Error] when the quoting is unbalanced or an `--exclude` pattern is not a regexp
|
|
52
|
+
def self.parse(text)
|
|
53
|
+
new(Shellwords.split(text))
|
|
54
|
+
rescue ArgumentError => e
|
|
55
|
+
raise Error, "could not read #{FILE}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array<Regexp>] patterns from `--exclude`, matched against source paths
|
|
59
|
+
attr_reader :exclude
|
|
60
|
+
# @return [String, nil] the value of `--output-dir`, which Boondoc does not use
|
|
61
|
+
attr_reader :output_dir
|
|
62
|
+
# @return [Array<String>] options and sources that were read but are not used
|
|
63
|
+
attr_reader :ignored
|
|
64
|
+
|
|
65
|
+
# @param tokens [Array<String>] the file split into words, the way a shell would
|
|
66
|
+
def initialize(tokens)
|
|
67
|
+
@sources = []
|
|
68
|
+
@files = []
|
|
69
|
+
@assets = []
|
|
70
|
+
@exclude = []
|
|
71
|
+
@ignored = []
|
|
72
|
+
@settings = {}
|
|
73
|
+
read_tokens(tokens)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Hash{Symbol => Object}] Boondoc options: `sources`, `files`, `assets`, `readme`, `title`,
|
|
77
|
+
# `private`, and `protected`, for those that were given
|
|
78
|
+
def settings
|
|
79
|
+
@settings.merge({ sources: @sources, files: @files, assets: @assets }.reject { |_, list| list.empty? })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param output [String] the output directory Boondoc will use
|
|
83
|
+
# @param root [String] project directory, for comparing paths
|
|
84
|
+
# @return [Array<String>] messages telling the user what was left out
|
|
85
|
+
def notes(output:, root:)
|
|
86
|
+
notes = []
|
|
87
|
+
if output_dir && File.expand_path(output_dir, root) != File.expand_path(output, root)
|
|
88
|
+
notes << "ignoring --output-dir #{output_dir} from #{FILE}; writing to #{output} " \
|
|
89
|
+
'(set output in .boondoc.yml or pass -o to change)'
|
|
90
|
+
end
|
|
91
|
+
notes << "ignoring from #{FILE}: #{ignored.join(', ')}" if ignored.any?
|
|
92
|
+
notes
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def read_tokens(tokens)
|
|
98
|
+
tokens = tokens.dup
|
|
99
|
+
extra = false
|
|
100
|
+
until tokens.empty?
|
|
101
|
+
token = tokens.shift
|
|
102
|
+
if token == '-' then extra = true
|
|
103
|
+
elsif extra then @files << token
|
|
104
|
+
elsif token.start_with?('-') then read_option(token, tokens)
|
|
105
|
+
else add_source(token)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_option(token, rest)
|
|
111
|
+
name, value = split_option(token)
|
|
112
|
+
value ||= rest.shift if takes_argument?(name, rest)
|
|
113
|
+
apply(name, value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def takes_argument?(name, rest)
|
|
117
|
+
return false if rest.empty?
|
|
118
|
+
|
|
119
|
+
ARGUMENT_OPTIONS.include?(name) || (OPTIONAL_ARGUMENT_OPTIONS.include?(name) && !rest.first.start_with?('-'))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Array(String, String)] the canonical long name (or the token itself) and its inline value, if any
|
|
123
|
+
def split_option(token)
|
|
124
|
+
return token.split('=', 2).values_at(0, 1) if token.start_with?('--')
|
|
125
|
+
|
|
126
|
+
letter = token[1]
|
|
127
|
+
long = SHORT_ARGUMENT_OPTIONS[letter] || SHORT_OPTIONAL_ARGUMENT_OPTIONS[letter]
|
|
128
|
+
return [token, nil] unless long
|
|
129
|
+
|
|
130
|
+
[long, token.length > 2 ? token[2..] : nil]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def apply(name, value)
|
|
134
|
+
case name
|
|
135
|
+
when '--output-dir' then @output_dir = value
|
|
136
|
+
when '--title' then @settings[:title] = value
|
|
137
|
+
when '--readme', '--main' then @settings[:readme] = value
|
|
138
|
+
when '--files', '--asset' then add_list(name, value)
|
|
139
|
+
when '--private', '--protected' then @settings[name.delete_prefix('--').to_sym] = true
|
|
140
|
+
when '--exclude' then add_exclude(value)
|
|
141
|
+
else @ignored << name
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# `--files` and `--asset` take comma-separated lists, as YARD does for `--files`.
|
|
146
|
+
def add_list(name, value)
|
|
147
|
+
list = name == '--asset' ? @assets : @files
|
|
148
|
+
list.concat(value.to_s.split(','))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_source(glob)
|
|
152
|
+
if glob.match?(NON_RUBY_SOURCE)
|
|
153
|
+
@ignored << glob
|
|
154
|
+
else
|
|
155
|
+
@sources << glob
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def add_exclude(pattern)
|
|
160
|
+
@exclude << Regexp.new(pattern.to_s, timeout: REGEXP_TIMEOUT)
|
|
161
|
+
rescue RegexpError => e
|
|
162
|
+
raise ArgumentError, "--exclude #{pattern.inspect} is not a regular expression (#{e.message})"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|