require 'json'
require 'pathname'
require 'shellwords'
require 'xcinvoke'
require 'cgi'
require 'rexml/document'
require 'jazzy/config'
require 'jazzy/executable'
require 'jazzy/highlighter'
require 'jazzy/source_declaration'
require 'jazzy/source_mark'
require 'jazzy/stats'
ELIDED_AUTOLINK_TOKEN = '36f8f5912051ae747ef441d6511ca4cb'.freeze
def autolink_regex(middle_regex, after_highlight)
start_tag_re, end_tag_re =
if after_highlight
[//, '']
else
['', '
']
end
/(#{start_tag_re})[ \t]*(#{middle_regex})[ \t]*(#{end_tag_re})/
end
class String
def autolink_block(doc_url, middle_regex, after_highlight)
gsub(autolink_regex(middle_regex, after_highlight)) do
original = Regexp.last_match(0)
start_tag, raw_name, end_tag = Regexp.last_match.captures
link_target = yield(CGI.unescape_html(raw_name))
if link_target &&
!link_target.type.extension? &&
link_target.url &&
link_target.url != doc_url.split('#').first && # Don't link to parent
link_target.url != doc_url # Don't link to self
start_tag +
"" +
raw_name + '' + end_tag
else
original
end
end
end
def unindent(count)
gsub(/^#{' ' * count}/, '')
end
end
module Jazzy
# This module interacts with the sourcekitten command-line executable
module SourceKitten
def self.undocumented_abstract
@undocumented_abstract ||= Markdown.render(
Config.instance.undocumented_text,
).freeze
end
# Group root-level docs by custom categories (if any) and type
def self.group_docs(docs)
custom_categories, docs = group_custom_categories(docs)
type_categories, uncategorized = group_type_categories(
docs, custom_categories.any? ? 'Other ' : ''
)
custom_categories + type_categories + uncategorized
end
def self.group_custom_categories(docs)
group = Config.instance.custom_categories.map do |category|
children = category['children'].flat_map do |name|
docs_with_name, docs = docs.partition { |doc| doc.name == name }
if docs_with_name.empty?
STDERR.puts 'WARNING: No documented top-level declarations match ' \
"name \"#{name}\" specified in categories file"
end
docs_with_name
end
# Category config overrides alphabetization
children.each.with_index { |child, i| child.nav_order = i }
make_group(children, category['name'], '')
end
[group.compact, docs]
end
def self.group_type_categories(docs, type_category_prefix)
group = SourceDeclaration::Type.all.map do |type|
children, docs = docs.partition { |doc| doc.type == type }
make_group(
children,
type_category_prefix + type.plural_name,
"The following #{type.plural_name.downcase} are available globally.",
type_category_prefix + type.plural_url_name,
)
end
[group.compact, docs]
end
def self.make_group(group, name, abstract, url_name = nil)
group.reject! { |doc| doc.name.empty? }
unless group.empty?
SourceDeclaration.new.tap do |sd|
sd.type = SourceDeclaration::Type.overview
sd.name = name
sd.url_name = url_name
sd.abstract = Markdown.render(abstract)
sd.children = group
end
end
end
def self.sanitize_filename(doc)
unsafe_filename = doc.url_name || doc.name
sanitzation_enabled = Config.instance.use_safe_filenames
if sanitzation_enabled && !doc.type.name_controlled_manually?
return CGI.escape(unsafe_filename).gsub('_', '%5F').tr('%', '_')
else
return unsafe_filename
end
end
# rubocop:disable Metrics/MethodLength
# Generate doc URL by prepending its parents URLs
# @return [Hash] input docs with URLs
def self.make_doc_urls(docs)
docs.each do |doc|
if !doc.parent_in_docs || doc.children.count > 0
# Create HTML page for this doc if it has children or is root-level
doc.url = (
subdir_for_doc(doc) +
[sanitize_filename(doc) + '.html']
).join('/')
doc.children = make_doc_urls(doc.children)
else
# Don't create HTML page for this doc if it doesn't have children
# Instead, make its link a hash-link on its parent's page
if doc.typename == '<>'
warn 'A compile error prevented ' + doc.fully_qualified_name +
' from receiving a unique USR. Documentation may be ' \
'incomplete. Please check for compile errors by running ' \
'`xcodebuild ' \
"#{Config.instance.xcodebuild_arguments.shelljoin}`."
end
id = doc.usr
unless id
id = doc.name || 'unknown'
warn "`#{id}` has no USR. First make sure all modules used in " \
'your project have been imported. If all used modules are ' \
'imported, please report this problem by filing an issue at ' \
'https://github.com/realm/jazzy/issues along with your Xcode ' \
'project. If this token is declared in an `#if` block, please ' \
'ignore this message.'
end
doc.url = doc.parent_in_docs.url + '#/' + id
end
end
end
# rubocop:enable Metrics/MethodLength
# Determine the subdirectory in which a doc should be placed
def self.subdir_for_doc(doc)
# We always want to create top-level subdirs according to type (Struct,
# Class, etc).
top_level_decl = doc.namespace_path.first
if top_level_decl && top_level_decl.type && top_level_decl.type.name
# File program elements under top ancestor’s type (Struct, Class, etc.)
[top_level_decl.type.plural_url_name] +
doc.namespace_ancestors.map(&:name)
else
# Categories live in their own directory
[]
end
end
# returns all subdirectories of specified path
def self.rec_path(path)
path.children.collect do |child|
if child.directory?
rec_path(child) + [child]
end
end.select { |x| x }.flatten(1)
end
# Builds SourceKitten arguments based on Jazzy options
def self.arguments_from_options(options)
arguments = ['doc']
arguments += if options.objc_mode
objc_arguments_from_options(options)
elsif !options.module_name.empty?
['--module-name', options.module_name, '--']
else
['--']
end
arguments + options.xcodebuild_arguments
end
def self.objc_arguments_from_options(options)
arguments = []
if options.xcodebuild_arguments.empty?
arguments += ['--objc', options.umbrella_header.to_s, '--', '-x',
'objective-c', '-isysroot',
`xcrun --show-sdk-path --sdk #{options.sdk}`.chomp,
'-I', options.framework_root.to_s,
'-fmodules']
end
# add additional -I arguments for each subdirectory of framework_root
unless options.framework_root.nil?
rec_path(Pathname.new(options.framework_root.to_s)).collect do |child|
if child.directory?
arguments += ['-I', child.to_s]
end
end
end
arguments
end
# Run sourcekitten with given arguments and return STDOUT
def self.run_sourcekitten(arguments)
if swift_version = Config.instance.swift_version
unless xcode = XCInvoke::Xcode.find_swift_version(swift_version)
raise "Unable to find an Xcode with swift version #{swift_version}."
end
env = xcode.as_env
else
env = ENV
end
bin_path = Pathname(__FILE__) + '../../../bin/sourcekitten'
output, = Executable.execute_command(bin_path, arguments, true, env: env)
output
end
def self.make_default_doc_info(declaration)
# @todo: Fix these
declaration.line = nil
declaration.column = nil
declaration.abstract = ''
declaration.parameters = []
declaration.children = []
end
def self.availability_attribute?(doc)
return false unless doc['key.attributes']
!doc['key.attributes'].select do |attribute|
attribute.values.first == 'source.decl.attribute.available'
end.empty?
end
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
def self.should_document?(doc)
return false if doc['key.doc.comment'].to_s.include?(':nodoc:')
# Always document Objective-C declarations.
return true if Config.instance.objc_mode
# Don't document @available declarations with no USR, since it means
# they're unavailable.
if availability_attribute?(doc) && !doc['key.usr']
return false
end
# Document extensions & enum elements, since we can't tell their ACL.
type = SourceDeclaration::Type.new(doc['key.kind'])
return true if type.swift_enum_element?
if type.swift_extension?
return Array(doc['key.substructure']).any? do |subdoc|
subtype = SourceDeclaration::Type.new(subdoc['key.kind'])
!subtype.mark? && should_document?(subdoc)
end
end
acl_ok = SourceDeclaration::AccessControlLevel.from_doc(doc) >= @min_acl
acl_ok.tap { @stats.add_acl_skipped unless acl_ok }
end
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/PerceivedComplexity
def self.should_mark_undocumented(filepath)
source_directory = Config.instance.source_directory.to_s
(filepath || '').start_with?(source_directory)
end
def self.process_undocumented_token(doc, declaration)
make_default_doc_info(declaration)
filepath = doc['key.filepath']
objc = Config.instance.objc_mode
if objc || should_mark_undocumented(filepath)
@stats.add_undocumented(declaration)
return nil if @skip_undocumented
declaration.abstract = undocumented_abstract
else
declaration.abstract = Markdown.render(doc['key.doc.comment'] || '',
Highlighter.default_language)
end
declaration
end
def self.parameters(doc, discovered)
(doc['key.doc.parameters'] || []).map do |p|
name = p['name']
{
name: name,
discussion: discovered[name],
}
end.reject { |param| param[:discussion].nil? }
end
def self.make_doc_info(doc, declaration)
return unless should_document?(doc)
if Config.instance.objc_mode
declaration.declaration =
Highlighter.highlight(doc['key.parsed_declaration'])
declaration.other_language_declaration =
Highlighter.highlight(doc['key.swift_declaration'], 'swift')
else
declaration.declaration =
Highlighter.highlight(make_swift_declaration(doc, declaration))
end
make_deprecation_info(doc, declaration)
unless doc['key.doc.full_as_xml']
return process_undocumented_token(doc, declaration)
end
declaration.abstract = Markdown.render(doc['key.doc.comment'] || '',
Highlighter.default_language)
declaration.discussion = ''
declaration.return = Markdown.rendered_returns
declaration.parameters = parameters(doc, Markdown.rendered_parameters)
@stats.add_documented
end
def self.make_deprecation_info(doc, declaration)
if declaration.deprecated
declaration.deprecation_message =
Markdown.render(doc['key.deprecation_message'] || '')
end
if declaration.unavailable
declaration.unavailable_message =
Markdown.render(doc['key.unavailable_message'] || '')
end
end
# Strip tags and convert entities
def self.xml_to_text(xml)
document = REXML::Document.new(xml)
REXML::XPath.match(document.root, '//text()').map(&:value).join
rescue
''
end
# Regexp to match an @attribute. Complex to handle @available().
def self.attribute_regexp(name)
qstring = /"(?:[^"\\]*|\\.)*"/
%r{@#{name} # @attr name
(?:\s*\( # optionally followed by spaces + parens,
(?: # containing any number of either..
[^")]*| # normal characters or...
#{qstring} # quoted strings.
)* # (end parens content)
\))? # (end optional parens)
}x
end
# Get all attributes of some name
def self.extract_attributes(declaration, name = '\w+')
attrs = declaration.scan(attribute_regexp(name))
# Rouge #806 workaround, use unicode lookalike for ')' inside attributes.
attrs.map { |str| str.gsub(/\)(?!\s*$)/, "\ufe5a") }
end
def self.extract_availability(declaration)
extract_attributes(declaration, 'available')
end
# Split leading attributes from a decl, returning both parts.
def self.split_decl_attributes(declaration)
declaration =~ /^((?:#{attribute_regexp('\w+')}\s*)*)(.*)$/m
Regexp.last_match.captures
end
def self.prefer_parsed_decl?(parsed, annotated)
annotated.empty? ||
parsed &&
(annotated.include?(' = default') || # SR-2608
parsed.match('@autoclosure|@escaping') || # SR-6321
parsed.include?("\n"))
end
# Replace the fully qualified name of a type with its base name
def self.unqualify_name(annotated_decl, declaration)
annotated_decl.gsub(declaration.fully_qualified_name_regexp,
declaration.name)
end
# Find the best Swift declaration
def self.make_swift_declaration(doc, declaration)
# From compiler 'quick help' style
annotated_decl_xml = doc['key.annotated_decl']
return nil unless annotated_decl_xml
annotated_decl_attrs, annotated_decl_body =
split_decl_attributes(xml_to_text(annotated_decl_xml))
# From source code
parsed_decl = doc['key.parsed_declaration']
decl =
if prefer_parsed_decl?(parsed_decl, annotated_decl_body)
# Strip any attrs captured by parsed version
inline_attrs, parsed_decl_body = split_decl_attributes(parsed_decl)
parsed_decl_body.unindent(inline_attrs.length)
else
# Strip ugly references to decl type name
unqualify_name(annotated_decl_body, declaration)
end
# @available attrs only in compiler 'interface' style
available_attrs = extract_availability(doc['key.doc.declaration'] || '')
available_attrs.concat(extract_attributes(annotated_decl_attrs))
.push(decl)
.join("\n")
end
def self.make_substructure(doc, declaration)
declaration.children = if doc['key.substructure']
make_source_declarations(
doc['key.substructure'],
declaration,
)
else
[]
end
end
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
def self.make_source_declarations(docs, parent = nil, mark = SourceMark.new)
declarations = []
current_mark = mark
Array(docs).each do |doc|
if doc.key?('key.diagnostic_stage')
declarations += make_source_declarations(
doc['key.substructure'], parent
)
next
end
declaration = SourceDeclaration.new
declaration.parent_in_code = parent
declaration.type = SourceDeclaration::Type.new(doc['key.kind'])
declaration.typename = doc['key.typename']
declaration.objc_name = doc['key.name']
documented_name = if Config.instance.hide_declarations == 'objc' &&
doc['key.swift_name']
doc['key.swift_name']
else
declaration.objc_name
end
if declaration.type.task_mark?(documented_name)
current_mark = SourceMark.new(documented_name)
end
if declaration.type.swift_enum_case?
# Enum "cases" are thin wrappers around enum "elements".
declarations += make_source_declarations(
doc['key.substructure'], parent, current_mark
)
next
end
next unless declaration.type.should_document?
unless declaration.type.name
raise 'Please file an issue at ' \
'https://github.com/realm/jazzy/issues about adding support ' \
"for `#{declaration.type.kind}`."
end
declaration.file = Pathname(doc['key.filepath']) if doc['key.filepath']
declaration.usr = doc['key.usr']
declaration.modulename = doc['key.modulename']
declaration.name = documented_name
declaration.mark = current_mark
declaration.access_control_level =
SourceDeclaration::AccessControlLevel.from_doc(doc)
declaration.line = doc['key.doc.line']
declaration.column = doc['key.doc.column']
declaration.start_line = doc['key.parsed_scope.start']
declaration.end_line = doc['key.parsed_scope.end']
declaration.deprecated = doc['key.always_deprecated']
declaration.unavailable = doc['key.always_unavailable']
next unless make_doc_info(doc, declaration)
make_substructure(doc, declaration)
next if declaration.type.extension? && declaration.children.empty?
declarations << declaration
end
declarations
end
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/MethodLength
# Expands extensions of nested types declared at the top level into
# a tree so they can be deduplicated properly
def self.expand_extensions(decls)
decls.map do |decl|
next decl unless decl.type.extension? && decl.name.include?('.')
name_parts = decl.name.split('.')
decl.name = name_parts.pop
expand_extension(decl, name_parts, decls)
end
end
def self.expand_extension(extension, name_parts, decls)
return extension if name_parts.empty?
name = name_parts.shift
candidates = decls.select { |decl| decl.name == name }
SourceDeclaration.new.tap do |decl|
make_default_doc_info(decl)
decl.name = name
decl.type = extension.type
decl.mark = extension.mark
decl.usr = candidates.first.usr unless candidates.empty?
child = expand_extension(extension,
name_parts,
candidates.flat_map(&:children).uniq)
child.parent_in_code = decl
decl.children = [child]
end
end
# Merges multiple extensions of the same entity into a single document.
#
# Merges extensions into the protocol/class/struct/enum they extend, if it
# occurs in the same project.
#
# Merges redundant declarations when documenting podspecs.
def self.deduplicate_declarations(declarations)
duplicate_groups = declarations
.group_by { |d| deduplication_key(d, declarations) }
.values
duplicate_groups.map do |group|
# Put extended type (if present) before extensions
merge_declarations(group)
end
end
# Returns true if an Objective-C declaration is mergeable.
def self.mergeable_objc?(decl, root_decls)
decl.type.objc_class? \
|| (decl.type.objc_category? \
&& name_match(decl.objc_category_name[0], root_decls))
end
# Two declarations get merged if they have the same deduplication key.
def self.deduplication_key(decl, root_decls)
if decl.type.swift_extensible? || decl.type.swift_extension?
[decl.usr, decl.name]
elsif mergeable_objc?(decl, root_decls)
name, _ = decl.objc_category_name || decl.name
[name, :objc_class_and_categories]
else
[decl.usr, decl.name, decl.type.kind]
end
end
# rubocop:disable Metrics/MethodLength
# Merges all of the given types and extensions into a single document.
def self.merge_declarations(decls)
extensions, typedecls = decls.partition { |d| d.type.extension? }
if typedecls.size > 1
warn 'Found conflicting type declarations with the same name, which ' \
'may indicate a build issue or a bug in Jazzy: ' +
typedecls.map { |t| "#{t.type.name.downcase} #{t.name}" }
.join(', ')
end
typedecl = typedecls.first
if typedecl
if typedecl.type.swift_protocol?
merge_default_implementations_into_protocol(typedecl, extensions)
mark_members_from_protocol_extension(extensions)
extensions.reject! { |ext| ext.children.empty? }
end
merge_declaration_marks(typedecl, extensions)
end
decls = typedecls + extensions
decls.first.tap do |merged|
merged.children = deduplicate_declarations(
decls.flat_map(&:children).uniq,
)
merged.children.each do |child|
child.parent_in_code = merged
end
end
end
# rubocop:enable Metrics/MethodLength
# If any of the extensions provide default implementations for methods in
# the given protocol, merge those members into the protocol doc instead of
# keeping them on the extension. These get a “Default implementation”
# annotation in the generated docs.
def self.merge_default_implementations_into_protocol(protocol, extensions)
protocol.children.each do |proto_method|
extensions.each do |ext|
defaults, ext.children = ext.children.partition do |ext_member|
ext_member.name == proto_method.name
end
unless defaults.empty?
proto_method.default_impl_abstract =
defaults.flat_map { |d| [d.abstract, d.discussion] }.join
end
end
end
end
# Protocol methods provided only in an extension and not in the protocol
# itself are a special beast: they do not use dynamic dispatch. These get an
# “Extension method” annotation in the generated docs.
def self.mark_members_from_protocol_extension(extensions)
extensions.each do |ext|
ext.children.each do |ext_member|
ext_member.from_protocol_extension = true
end
end
end
# Customize marks associated with to-be-merged declarations
def self.merge_declaration_marks(typedecl, extensions)
if typedecl.type.objc_class?
# Mark children merged from categories with the name of category
# (unless they already have a mark)
extensions.each do |ext|
_, category_name = ext.objc_category_name
ext.children.each { |c| c.mark.name ||= category_name }
end
else
# If the Swift extension has a mark and the first child doesn't
# then copy the mark contents down so it still shows up.
extensions.each do |ext|
child = ext.children.first
if child && child.mark.empty?
child.mark.copy(ext.mark)
end
end
end
end
# Apply filtering based on the "included" and "excluded" flags.
def self.filter_files(json)
json = filter_included_files(json) if Config.instance.included_files.any?
json = filter_excluded_files(json) if Config.instance.excluded_files.any?
json.map do |doc|
key = doc.keys.first
doc[key]
end.compact
end
# Filter based on the "included" flag.
def self.filter_included_files(json)
included_files = Config.instance.included_files
json.map do |doc|
key = doc.keys.first
doc if included_files.detect do |include|
File.fnmatch?(include, key)
end
end.compact
end
# Filter based on the "excluded" flag.
def self.filter_excluded_files(json)
excluded_files = Config.instance.excluded_files
json.map do |doc|
key = doc.keys.first
doc unless excluded_files.detect do |exclude|
File.fnmatch?(exclude, key)
end
end.compact
end
def self.name_match(name_part, docs)
return nil unless name_part
wildcard_expansion = Regexp.escape(name_part)
.gsub('\.\.\.', '[^)]*')
.gsub(/<.*>/, '')
whole_name_pat = /\A#{wildcard_expansion}\Z/
docs.find do |doc|
whole_name_pat =~ doc.name
end
end
# Find the first ancestor of doc whose name matches name_part.
def self.ancestor_name_match(name_part, doc)
doc.namespace_ancestors.reverse_each do |ancestor|
if match = name_match(name_part, ancestor.children)
return match
end
end
nil
end
def self.name_traversal(name_parts, doc)
while doc && !name_parts.empty?
next_part = name_parts.shift
doc = name_match(next_part, doc.children)
end
doc
end
# Links recognized top-level declarations within
# - inlined code within docs
# - method signatures after they've been processed by the highlighter
#
# The `after_highlight` flag is used to differentiate between the two modes.
def self.autolink_text(text, doc, root_decls, after_highlight = false)
text.autolink_block(doc.url, '[^\s]+', after_highlight) do |raw_name|
parts = raw_name
.split(/(?