zard-doc 0.0.1 → 0.0.2

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.
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Zard
6
+ module Doc
7
+ class BuildRenderer
8
+ attr_reader :diagnostics, :links
9
+
10
+ def initialize(scope_documents:, scope_files:, scopes:, layout:, include_undocumented: false)
11
+ @diagnostics = []
12
+ @links = {}
13
+ @scopes = scopes
14
+ @renderers = scope_documents.keys.sort.to_h do |name|
15
+ [name, ScopeRenderer.new(name, scope_documents.fetch(name), scope_files.fetch(name), scopes.fetch(name), layout, self, include_undocumented: include_undocumented)]
16
+ end
17
+ end
18
+
19
+ def render
20
+ pages = {}
21
+ add_page(pages, "index.md", "# API documentation\n\n" + @renderers.map { |name, renderer| "- [#{renderer.label(name)}](#{renderer.url(renderer.index_path)})" }.join("\n") + "\n")
22
+ @renderers.each_value do |renderer|
23
+ renderer.pages.each { |path, text| add_page(pages, path, text) }
24
+ end
25
+ pages
26
+ end
27
+
28
+ def dependencies(name)
29
+ @scopes.fetch(name).fetch(:references, []).uniq.filter_map { |dependency| @renderers[dependency] }
30
+ end
31
+
32
+ def export_declarations
33
+ @renderers.transform_values(&:export_declarations)
34
+ end
35
+
36
+ def record_link(from, path, anchor)
37
+ targets = (@links[from] ||= [])
38
+ target = {"path" => path, "anchor" => anchor}
39
+ targets << target unless targets.include?(target)
40
+ end
41
+
42
+ def warn_ambiguity(reference, origin, candidates)
43
+ locations = candidates.map { |renderer, declaration| "#{renderer.name}:#{declaration.span.path}:#{declaration.span.start_line}" }
44
+ message = "warning: ambiguous reference #{reference.inspect} at #{origin.span.path}:#{origin.span.start_line}; candidates: #{locations.join(", ")}"
45
+ @diagnostics << message unless @diagnostics.include?(message)
46
+ end
47
+
48
+ def warn_relationship(kind, expression, origin)
49
+ message = "warning: unresolved #{kind} documentation relationship #{expression.inspect} at #{origin.span.path}:#{origin.span.start_line}"
50
+ @diagnostics << message unless @diagnostics.include?(message)
51
+ end
52
+
53
+ private
54
+
55
+ def add_page(pages, path, text)
56
+ raise BuildError, "output collision: #{path}" if pages.key?(path) || pages.keys.any? { |other| other.start_with?("#{path}/") || path.start_with?("#{other}/") }
57
+
58
+ pages[path] = text
59
+ end
60
+
61
+ class ScopeRenderer < Renderer
62
+ attr_reader :name, :index_path
63
+
64
+ def initialize(name, documents, files, config, layout, build, include_undocumented: false)
65
+ @name = name
66
+ @build = build
67
+ @layout = layout
68
+ @destination = config.fetch(:destination)
69
+ @include_undocumented = include_undocumented
70
+ @index_path = join_path("index.md")
71
+ @declarations = documents.sort_by(&:path).flat_map(&:declarations)
72
+ @container_names = @declarations.select { |declaration| %i[class module constant].include?(declaration.kind) }.map { |declaration| qualified_name(declaration) }
73
+ @targets = Hash.new { |hash, key| hash[key] = [] }
74
+ @declarations.each { |declaration| reference_names(declaration).each { |reference| @targets[reference] << declaration } }
75
+ @paths = {}
76
+ @anchors = {}
77
+ @text = {}
78
+ @descriptions = {}
79
+ @page_owners = {}
80
+ identities = @declarations.group_by { |declaration| anchor_identity(declaration) }
81
+ ordinals = Hash.new(0)
82
+ @declarations.each do |declaration|
83
+ identity_key = anchor_identity(declaration)
84
+ source = files.fetch(File.expand_path(declaration.span.path))
85
+ ordinals[[identity_key, source]] += 1
86
+ next unless renderable?(declaration)
87
+
88
+ owner = namespace_page(declaration)
89
+ relative = (layout == "file") ? files.fetch(File.expand_path(declaration.span.path)) : "#{owner.gsub("::", "/")}.md"
90
+ path = join_path(relative)
91
+ identity = (layout == "file") ? File.expand_path(declaration.span.path) : owner
92
+ raise BuildError, "output collision: #{path}" if path == @index_path || (@page_owners.key?(path) && @page_owners[path] != identity)
93
+
94
+ @page_owners[path] = identity
95
+ @paths[declaration] = path
96
+ key = identity_key
97
+ key += "\0#{source}\0#{ordinals[[identity_key, source]]}" if identities.fetch(identity_key).length > 1
98
+ @anchors[declaration] = "zard-#{key.bytes.map { |byte| "%02x" % byte }.join}"
99
+ end
100
+ end
101
+
102
+ def pages
103
+ @paths.each_key do |declaration|
104
+ text = declaration.documentation.select { |tag| tag.name == :text }.map(&:description).join("\n")
105
+ @text[declaration] = render_prose(text, declaration)
106
+ @descriptions[declaration] = declaration.documentation.to_h do |tag|
107
+ description = case tag.name
108
+ when :example, :raw, :text then tag.description
109
+ when :see then render_reference(tag.description, declaration)
110
+ else render_prose(tag.description, declaration)
111
+ end
112
+ [tag, description]
113
+ end
114
+ end
115
+ result = @paths.keys.group_by { |declaration| @paths.fetch(declaration) }.sort.to_h do |path, declarations|
116
+ sections = declarations.map do |declaration|
117
+ "#{render_declaration(declaration)}\n\nSource: #{label("#{declaration.span.path}:#{declaration.span.start_line}")}."
118
+ end
119
+ [path, "#{sections.join("\n\n")}\n"]
120
+ end
121
+ navigation = result.keys.map { |path| "- [#{label(Pathname.new(path).relative_path_from(Pathname.new(File.dirname(index_path))).to_s)}](#{relative_url(index_path, path)})" }
122
+ result[index_path] = "# #{label(name)}\n\n#{navigation.join("\n")}\n"
123
+ result
124
+ end
125
+
126
+ def lookup(candidates, path, containers: false)
127
+ path = path.delete_prefix("::")
128
+ candidates.each do |candidate|
129
+ targets = @targets[candidate].uniq
130
+ unless targets.empty?
131
+ if (@layout == "namespace" || containers) && targets.map(&:kind).uniq.one? && targets.all? { |target| %i[class module].include?(target.kind) }
132
+ published = targets.find { |target| renderable?(target) }
133
+ return [:found, [published]] if published
134
+ end
135
+ return [:found, targets]
136
+ end
137
+ if path.start_with?("#", ".") && @container_names.include?(candidate.split(/[.#]/, 2).first)
138
+ return [:shadowed, []]
139
+ end
140
+ first_constant = path.split(/::|[.#]/).first.to_s
141
+ local_constant = "#{candidate.delete_suffix(path)}#{first_constant}"
142
+ return [:shadowed, []] if !first_constant.empty? && @container_names.include?(local_constant)
143
+ end
144
+ [:missing, []]
145
+ end
146
+
147
+ def destination(declaration)
148
+ [@paths.fetch(declaration), @anchors.fetch(declaration)]
149
+ end
150
+
151
+ def export_declarations
152
+ @paths.map do |declaration, path|
153
+ descriptions = @descriptions.fetch(declaration).to_h do |tag, description|
154
+ [tag, (tag.name == :text) ? render_prose(tag.description, declaration) : description]
155
+ end
156
+ {declaration: declaration, page: path, anchor: @anchors.fetch(declaration),
157
+ display_name: reference_names(declaration).first || display_name(declaration),
158
+ references: reference_names(declaration), descriptions: descriptions, relationships: relationship_entries(declaration)}
159
+ end
160
+ end
161
+
162
+ def relationship_targets(declaration)
163
+ @targets.fetch(qualified_name(declaration)).select { |item| %i[class module].include?(item.kind) && published?(item) }.map do |item|
164
+ path, anchor = destination(item)
165
+ {scope: name, declaration_id: "#{name}:#{anchor}", path: path, anchor: anchor}
166
+ end
167
+ end
168
+
169
+ def published?(declaration)
170
+ renderable?(declaration)
171
+ end
172
+
173
+ def label(value)
174
+ escape_label(value)
175
+ end
176
+
177
+ def url(path)
178
+ path.bytes.map { |byte| byte.chr.match?(/[A-Za-z0-9\-._~\/]/) ? byte.chr : "%%%02X" % byte }.join
179
+ end
180
+
181
+ private
182
+
183
+ def renderable?(declaration)
184
+ declaration.visibility == :public && (@include_undocumented || super)
185
+ end
186
+
187
+ def join_path(path)
188
+ Pathname.new(File.join(@destination, path)).cleanpath.to_s
189
+ end
190
+
191
+ def anchor_identity(declaration)
192
+ reference = reference_names(declaration).first
193
+ [declaration.kind, reference || display_name(declaration), declaration.refinement].join("\0")
194
+ end
195
+
196
+ def namespace_page(declaration)
197
+ return qualified_name(declaration) if %i[class module].include?(declaration.kind)
198
+
199
+ if declaration.kind.to_s.start_with?("singleton_") && !declaration.refinement
200
+ owner = reference_owner(declaration)
201
+ return owner.delete_prefix("::") if owner&.match?(/\A(?:::)?[[:upper:]\p{Lt}][[:word:]]*(?:::[[:upper:]\p{Lt}][[:word:]]*)*\z/)
202
+ end
203
+
204
+ declaration.namespace || "_top-level"
205
+ end
206
+
207
+ def relative_url(from, to)
208
+ url(Pathname.new(to).relative_path_from(Pathname.new(File.dirname(from))).to_s)
209
+ end
210
+
211
+ def resolve_scoped(reference, declaration, outer: false, containers: false)
212
+ return if !reference || declaration.refinement
213
+
214
+ path = reference.sub(/\A((?:::)?[[:upper:]\p{Lt}][[:word:]]*(?:::[[:upper:]\p{Lt}][[:word:]]*)*)?(?:\?\.|\.#)/) { "#{$1}." }
215
+ candidates = if path.start_with?("::")
216
+ [path.delete_prefix("::")]
217
+ elsif path.start_with?("#", ".")
218
+ owner = declaration.kind.to_s.start_with?("singleton_") ? reference_owner(declaration) : declaration.namespace
219
+ owner = qualified_name(declaration) if %i[class module].include?(declaration.kind)
220
+ ["#{owner&.delete_prefix("::")}#{path}"]
221
+ else
222
+ namespace = (!outer && %i[class module].include?(declaration.kind)) ? qualified_name(declaration) : declaration.namespace
223
+ lexical_candidates(path, namespace)
224
+ end
225
+ status, targets = lookup(candidates, path, containers: containers)
226
+ matches = targets.map { |target| [self, target] }
227
+ if status == :missing
228
+ matches = @build.dependencies(name).flat_map do |renderer|
229
+ _, dependency_targets = renderer.lookup(candidates, path, containers: containers)
230
+ dependency_targets.map { |target| [renderer, target] }
231
+ end
232
+ end
233
+ if matches.length > 1
234
+ @build.warn_ambiguity(reference, declaration, matches)
235
+ return
236
+ end
237
+ match = matches.first
238
+ return unless match
239
+
240
+ match if match.first.published?(match.last)
241
+ end
242
+
243
+ def relationship_entries(declaration)
244
+ return [] unless %i[class module].include?(declaration.kind) && !declaration.refinement
245
+
246
+ entries = []
247
+ entries << [:superclass, declaration.superclass, declaration.superclass_span] if declaration.superclass
248
+ entries << [:container_builder, declaration.container_builder, declaration.container_builder_span] if declaration.container_builder
249
+ entries.concat(declaration.mixins.map { |mixin| [mixin.kind, mixin.target, mixin.span] })
250
+ entries.map do |kind, expression, span|
251
+ constant = expression.match?(/\A(?:::)?[[:upper:]\p{Lt}][[:word:]]*(?:::[[:upper:]\p{Lt}][[:word:]]*)*\z/)
252
+ match = resolve_scoped(expression, declaration, outer: kind == :superclass, containers: true) if constant && kind != :container_builder
253
+ expected_kind = (kind == :superclass) ? :class : :module
254
+ match = nil if match && match.last.kind != expected_kind
255
+ targets = match ? match.first.relationship_targets(match.last) : []
256
+ targets.each { |target| @build.record_link(@paths.fetch(declaration), target.fetch(:path), target.fetch(:anchor)) }
257
+ status = if !targets.empty?
258
+ :resolved
259
+ elsif constant && kind != :container_builder
260
+ :unresolved
261
+ else
262
+ :expression
263
+ end
264
+ @build.warn_relationship(kind, expression, declaration) unless status == :resolved
265
+ {kind: kind, expression: expression, span: span, status: status, targets: targets}
266
+ end
267
+ end
268
+
269
+ def link_destination(match, declaration)
270
+ path, anchor = match.first.destination(match.last)
271
+ @build.record_link(@paths.fetch(declaration), path, anchor)
272
+ "#{relative_url(@paths.fetch(declaration), path)}##{anchor}"
273
+ end
274
+
275
+ def render_prose(description, declaration)
276
+ ReferenceMarkup.rewrite(description) do |scheme, reference, label|
277
+ yield if block_given?
278
+ match = resolve_scoped(reference, declaration)
279
+ next unless match
280
+ next if (scheme == "c") != %i[class module].include?(match.last.kind)
281
+
282
+ title = label || reference
283
+ unless label && ReferenceMarkup.code_label?(label)
284
+ title = title.gsub(/\\([\\\[\]])/, '\1') if label
285
+ title = escape_label(title)
286
+ end
287
+ "[#{title}](#{link_destination(match, declaration)})"
288
+ end
289
+ end
290
+
291
+ def render_reference(description, declaration)
292
+ explicit = false
293
+ prose = render_prose(description, declaration) { explicit = true }
294
+ return prose if explicit
295
+
296
+ reference, label = description.strip.split(/\s+/, 2)
297
+ return description unless reference
298
+ return super if reference.match?(/\A(?:https?:\/\/|mailto:)/)
299
+
300
+ match = resolve_scoped(reference, declaration)
301
+ match ? "[#{escape_label(label || reference)}](#{link_destination(match, declaration)})" : description
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
data/lib/zard/doc/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "optparse"
4
4
  require "zard/doc"
5
+ require "zard/doc/build"
5
6
 
6
7
  module Zard
7
8
  module Doc
@@ -28,8 +29,12 @@ module Zard
28
29
  lint_command
29
30
  when "render"
30
31
  render_command
32
+ when "format"
33
+ format_command
34
+ when "build"
35
+ build_command
31
36
  else
32
- usage_error("Expected the lint or render command.")
37
+ usage_error("Expected the lint, render, format, or build command.")
33
38
  end
34
39
  rescue OptionParser::ParseError => error
35
40
  usage_error(error.message)
@@ -37,6 +42,56 @@ module Zard
37
42
 
38
43
  private
39
44
 
45
+ def build_command
46
+ config = nil
47
+ layout = nil
48
+ format = "markdown"
49
+ scopes = []
50
+ lens_root = nil
51
+ rigor_command = nil
52
+ lens_workers = 4
53
+ lens_config = nil
54
+ OptionParser.new do |parser|
55
+ parser.on("--config FILE", "Read the documentation build configuration") { |value| config = value }
56
+ parser.on("--layout LAYOUT", %w[namespace file], "Override page organization") { |value| layout = value }
57
+ parser.on("--format FORMAT", %w[markdown json], "Write Markdown pages or export JSON to stdout") { |value| format = value }
58
+ parser.on("--scope NAME", "Build a scope and its documentation dependencies (repeatable)") { |value| scopes << value }
59
+ parser.on("--lens-root DIR", "Enrich JSON using experimental Rigor lens (requires zard-rigor)") { |value| lens_root = value }
60
+ parser.on("--rigor-command FILE", "Supported Rigor executable (default: rigor)") { |value| rigor_command = value }
61
+ parser.on("--lens-workers N", Integer, "Lens workers (default: 4)") { |value| lens_workers = value }
62
+ parser.on("--lens-config FILE", "Rigor configuration, relative to lens root") { |value| lens_config = value }
63
+ end.parse!(@argv)
64
+ return usage_error("Pass --config FILE without positional arguments.") unless config && @argv.empty?
65
+
66
+ return usage_error("--lens-root requires --format json.") if lens_root && format != "json"
67
+ return usage_error("--rigor-command requires --lens-root.") if rigor_command && !lens_root
68
+ return usage_error("--lens-config requires --lens-root.") if lens_config && !lens_root
69
+ return usage_error("--lens-workers must be non-negative.") if lens_workers.negative?
70
+
71
+ enricher = lens_enricher(lens_root, rigor_command || "rigor", lens_workers, lens_config) if lens_root
72
+ builder = Build.new(config, layout: layout, scopes: scopes, format: format, stdout: @stdout, enricher: enricher)
73
+ builder.run
74
+ rescue BuildError => error
75
+ @stderr.puts error.message
76
+ error.status
77
+ ensure
78
+ builder&.diagnostics&.each { |diagnostic| @stderr.puts diagnostic }
79
+ end
80
+
81
+ def lens_enricher(root, command, workers, config)
82
+ require "zard/rigor"
83
+ adapter = Zard::Rigor::Lens.new(root: root, command: command, workers: workers, config: config)
84
+ lambda do |data, base:|
85
+ adapter.enrich(data, base: base)
86
+ rescue Zard::Rigor::Error => error
87
+ raise BuildError, error.message
88
+ end
89
+ rescue LoadError => error
90
+ raise BuildError, "Install the optional zard-rigor gem to use --lens-root: #{error.message}"
91
+ rescue SystemCallError => error
92
+ raise BuildError, "Invalid lens project root: #{error.message}"
93
+ end
94
+
40
95
  def lint_command
41
96
  lint_option_parser.parse!(@argv)
42
97
  return usage_error("Pass at least one Ruby source file.") if @argv.empty?
@@ -72,6 +127,46 @@ module Zard
72
127
  end.uniq.sort
73
128
  end
74
129
 
130
+ def format_command
131
+ check = false
132
+ write = false
133
+ OptionParser.new do |parser|
134
+ parser.on("--check", "Check whether formatting would change source") { check = true }
135
+ parser.on("--write", "Write formatted source to files") { write = true }
136
+ end.parse!(@argv)
137
+ return usage_error("Choose either --check or --write.") if check && write
138
+ return usage_error("Pass at least one Ruby source file.") if @argv.empty?
139
+
140
+ paths = source_paths(@argv)
141
+ return usage_error("No Ruby source files were found.") if paths.empty?
142
+ return usage_error("Standard input cannot be used with --write.") if write && paths.include?("-")
143
+ return usage_error("Pass one input for stdout, or use --check or --write.") if !check && !write && paths.length != 1
144
+
145
+ results = paths.map do |path|
146
+ source = read_source(path)
147
+ return 2 unless source
148
+
149
+ [path, Zard.format(source, path: path)]
150
+ end
151
+ results.each do |_, result|
152
+ result.diagnostics.each { |diagnostic| @stderr.puts format_diagnostic(diagnostic) }
153
+ end
154
+ return 1 if results.any? { |_, result| result.diagnostics.any? { |diagnostic| diagnostic.severity == :error } }
155
+ return (results.any? { |_, result| result.changed? }) ? 1 : 0 if check
156
+
157
+ if write
158
+ results.each do |path, result|
159
+ File.binwrite(path, result.source) if result.changed?
160
+ rescue SystemCallError => error
161
+ @stderr.puts "#{path}: #{error.message}"
162
+ return 2
163
+ end
164
+ else
165
+ @stdout.print results.first[1].source
166
+ end
167
+ 0
168
+ end
169
+
75
170
  def lint(paths)
76
171
  failed = false
77
172
  input_error = false
@@ -106,7 +201,7 @@ module Zard
106
201
  diagnostics.each { |diagnostic| @stderr.puts format_diagnostic(diagnostic) }
107
202
  return 1 if diagnostics.any? { |diagnostic| diagnostic.severity == :error }
108
203
 
109
- markdown = documents.map { |document| Zard::Doc.render(document) }.reject(&:empty?).join("\n")
204
+ markdown = Renderer.render_documents(documents)
110
205
  @stdout.print markdown
111
206
  0
112
207
  end
@@ -145,6 +240,9 @@ module Zard
145
240
  Usage:
146
241
  zard-doc lint [--fail-on error|warning] PATH...
147
242
  zard-doc render PATH...
243
+ zard-doc format [--check | --write] PATH...
244
+ zard-doc build --config FILE [--scope NAME ...] [--layout namespace|file] [--format markdown|json]
245
+ [--lens-root DIR [--rigor-command FILE] [--lens-workers N] [--lens-config FILE]]
148
246
  USAGE
149
247
  end
150
248
  end