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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +345 -0
  6. data/exe/boondoc +6 -0
  7. data/lib/boondoc/assets.rb +112 -0
  8. data/lib/boondoc/cli.rb +99 -0
  9. data/lib/boondoc/config.rb +201 -0
  10. data/lib/boondoc/containment.rb +40 -0
  11. data/lib/boondoc/context.rb +48 -0
  12. data/lib/boondoc/core_constants.rb +23 -0
  13. data/lib/boondoc/docstring_parser.rb +119 -0
  14. data/lib/boondoc/filter.rb +34 -0
  15. data/lib/boondoc/frontend/comments.rb +67 -0
  16. data/lib/boondoc/frontend/prism_parser.rb +40 -0
  17. data/lib/boondoc/frontend/visitor.rb +359 -0
  18. data/lib/boondoc/generator.rb +116 -0
  19. data/lib/boondoc/linker.rb +175 -0
  20. data/lib/boondoc/links.rb +78 -0
  21. data/lib/boondoc/markdown.rb +90 -0
  22. data/lib/boondoc/model/code_object.rb +165 -0
  23. data/lib/boondoc/model/docstring.rb +70 -0
  24. data/lib/boondoc/model/registry.rb +150 -0
  25. data/lib/boondoc/path_map.rb +108 -0
  26. data/lib/boondoc/preview.rb +81 -0
  27. data/lib/boondoc/rake_task.rb +39 -0
  28. data/lib/boondoc/references.rb +59 -0
  29. data/lib/boondoc/renderer/base.rb +50 -0
  30. data/lib/boondoc/renderer/docstring.rb +140 -0
  31. data/lib/boondoc/renderer/index.rb +49 -0
  32. data/lib/boondoc/renderer/method_section.rb +84 -0
  33. data/lib/boondoc/renderer/namespace_page.rb +165 -0
  34. data/lib/boondoc/reporter.rb +36 -0
  35. data/lib/boondoc/stitcher.rb +89 -0
  36. data/lib/boondoc/type_parser.rb +78 -0
  37. data/lib/boondoc/version.rb +5 -0
  38. data/lib/boondoc/writer.rb +163 -0
  39. data/lib/boondoc/yard_bridge.rb +113 -0
  40. data/lib/boondoc/yardopts.rb +165 -0
  41. data/lib/boondoc.rb +48 -0
  42. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ VERSION = '0.1.0'
5
+ 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