Skip to content

Commit

Permalink
Swift SymbolGraph importer (#1171)
Browse files Browse the repository at this point in the history
Add `--swift-build-tool symbolgraph` to use the Swift 5.3 symbolgraph
feature to generate docs from `.swiftmodule` files.  Add to test specs and
docs.

The importer converts the symbol graph to approximately SourceKitten
output and provides that as input to the existing code.

Only intentional change to existing code is a modification of the "do we
blame the user for this declaration not being documented" question, to
deal with symbol graph output that is sometimes missing source locations.

The modification turned out to fix #498 and showed a missed 'undocumented'
declaration in a test suite.
  • Loading branch information
johnfairh authored Nov 7, 2020
1 parent fd56cb9 commit c30bdf7
Show file tree
Hide file tree
Showing 14 changed files with 987 additions and 19 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

##### Enhancements

* None.
* Support documentation generation from `.swiftmodule` binaries using
`--swift-build-tool symbolgraph` with Swift 5.3.
[John Fairhurst](https://github.com/johnfairh)

##### Bug Fixes

Expand Down
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

Both Swift and Objective-C projects are supported.

*SwiftPM support was recently added, so please report any issues you find.*

Instead of parsing your source files, `jazzy` hooks into [Clang][clang] and
[SourceKit][sourcekit] to use the [AST][ast] representation of your code and
its comments for more accurate results. The output matches the look and feel
of Apple’s official reference documentation, post WWDC 2014.

Jazzy can also generate documentation from compiled swift modules [using their
symbol graph](#docs-from-swiftmodules-or-frameworks) instead of source code.

![Screenshot](images/screenshot.jpg)

This project adheres to the [Contributor Covenant Code of Conduct](https://realm.io/conduct).
Expand Down Expand Up @@ -49,13 +50,14 @@ succeed too!

If Jazzy generates docs for the wrong module then use `--module` to tell it which
one you'd prefer. If this doesn't help, and you're using Xcode, then try passing
extra arguments to `xcodebuild`, for example `jazzy --build-tool-arguments -target,MyTarget`.
extra arguments to `xcodebuild`, for example
`jazzy --build-tool-arguments -scheme,MyScheme,-target,MyTarget`.

You can set options for your project’s documentation in a configuration file,
`.jazzy.yaml` by default. For a detailed explanation and an exhaustive list of
all available options, run `jazzy --help config`.

### Supported keywords
### Supported documentation keywords

Swift documentation is written in markdown and supports a number of special keywords.
For a complete list and examples, see Erica Sadun's post on [*Swift header documentation in Xcode 7*](https://ericasadun.com/2015/06/14/swift-header-documentation-in-xcode-7/),
Expand Down Expand Up @@ -200,6 +202,57 @@ sourcekitten doc --objc $(pwd)/MyProject/MyProject.h \
jazzy --sourcekitten-sourcefile swiftDoc.json,objcDoc.json
```

### Docs from `.swiftmodule`s or frameworks

*This feature is new and relies on a new Swift feature: there may be crashes
and mistakes: reports welcome.*

Swift 5.3 adds support for symbol graph generation from `.swiftmodule` files.
This looks to be part of Apple's toolchain for generating their online docs.

Jazzy can use this to generate API documentation. This is faster than using
the source code directly but does have limitations: for example documentation
comments are available only for `public` declarations, and the presentation of
Swift extensions may not match the way they are written in code.

Some examples:

1. Generate docs for the Apple Combine framework for macOS:
```shell
jazzy --module Combine --swift-build-tool symbolgraph
```
The SDK's library directories are included in the search path by
default.
2. Same but for iOS:
```shell
jazzy --module Combine --swift-build-tool symbolgraph
--sdk iphoneos
--build-tool-arguments --target,arm64-apple-ios14.1
```
The `target` is the LLVM target triple and needs to match the SDK. The
default here is the target of the host system that Jazzy is running on,
something like `x86_64-apple-darwin19.6.0`.
3. Generate docs for a personal `.swiftmodule`:
```shell
jazzy --module MyMod --swift-build-tool symbolgraph
--build-tool-arguments -I,/Build/Products
```
This implies that `/Build/Products/MyMod.swiftmodule` exists. Jazzy's
`--source-directory` (default current directory) is searched by default,
so you only need the `-I` override if that's not enough.
4. For a personal framework:
```shell
jazzy --module MyMod --swift-build-tool symbolgraph
--build-tool-arguments -F,/Build/Products
```
This implies that `/Build/Products/MyMod.framework` exists and contains
a `.swiftmodule`. Again the `--source-directory` is searched by default
if `-F` is not passed in.

See `swift symbolgraph-extract --help` for all the things you can pass via
`--build-tool-arguments`: if your module has dependencies then you may need
to add various search path options to let Swift load it.

### Themes

Three themes are provided with jazzy: `apple` (default), `fullwidth` and `jony`.
Expand Down
10 changes: 5 additions & 5 deletions lib/jazzy/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,14 @@ def hide_objc?
end
end

SWIFT_BUILD_TOOLS = %w[spm xcodebuild].freeze
SWIFT_BUILD_TOOLS = %w[spm xcodebuild symbolgraph].freeze

config_attr :swift_build_tool,
command_line: "--swift-build-tool #{SWIFT_BUILD_TOOLS.join(' | ')}",
description: 'Control whether Jazzy uses Swift Package Manager or '\
'xcodebuild to build the module to be documented. By '\
'default it uses xcodebuild if there is a .xcodeproj '\
'file in the source directory.',
description: 'Control whether Jazzy uses Swift Package Manager, '\
'xcodebuild, or swift-symbolgraph to build the module '\
'to be documented. By default it uses xcodebuild if '\
'there is a .xcodeproj file in the source directory.',
parse: ->(tool) do
return tool.to_sym if SWIFT_BUILD_TOOLS.include?(tool)
raise "Unsupported swift_build_tool #{tool}, "\
Expand Down
8 changes: 7 additions & 1 deletion lib/jazzy/doc_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require 'jazzy/source_document'
require 'jazzy/source_module'
require 'jazzy/sourcekitten'
require 'jazzy/symbol_graph'

module Jazzy
# This module handles HTML generation, file writing, asset copying,
Expand Down Expand Up @@ -72,6 +73,8 @@ def self.build(options)
elsif options.podspec_configured
pod_documenter = PodspecDocumenter.new(options.podspec)
stdout = pod_documenter.sourcekitten_output(options)
elsif options.swift_build_tool == :symbolgraph
stdout = SymbolGraph.build(options)
else
stdout = Dir.chdir(options.source_directory) do
arguments = SourceKitten.arguments_from_options(options)
Expand Down Expand Up @@ -192,7 +195,10 @@ def self.write_lint_report(undocumented, options)

lint_report = {
warnings: warnings.sort_by do |w|
[w[:file], w[:line] || 0, w[:symbol], w[:symbol_kind]]
[w[:file] || Pathname(''),
w[:line] || 0,
w[:symbol],
w[:symbol_kind]]
end,
source_directory: options.source_directory,
}
Expand Down
11 changes: 5 additions & 6 deletions lib/jazzy/sourcekitten.rb
Original file line number Diff line number Diff line change
Expand Up @@ -325,17 +325,16 @@ def self.should_document_swift_extension?(doc)
end
end

def self.should_mark_undocumented(filepath)
source_directory = Config.instance.source_directory.to_s
(filepath || '').start_with?(source_directory)
# Call things undocumented if they were compiled properly
# and came from our module.
def self.should_mark_undocumented(declaration)
declaration.usr && !declaration.modulename
end

def self.process_undocumented_token(doc, declaration)
make_default_doc_info(declaration)

filepath = doc['key.filepath']

if !declaration.swift? || should_mark_undocumented(filepath)
if !declaration.swift? || should_mark_undocumented(declaration)
@stats.add_undocumented(declaration)
return nil if @skip_undocumented
declaration.abstract = undocumented_abstract
Expand Down
95 changes: 95 additions & 0 deletions lib/jazzy/symbol_graph.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require 'set'
require 'jazzy/symbol_graph/graph'
require 'jazzy/symbol_graph/constraint'
require 'jazzy/symbol_graph/symbol'
require 'jazzy/symbol_graph/relationship'
require 'jazzy/symbol_graph/sym_node'
require 'jazzy/symbol_graph/ext_node'

# This is the top-level symbolgraph driver that deals with
# figuring out arguments, running the tool, and loading the
# results.

module Jazzy
module SymbolGraph
# Run `swift symbolgraph-extract` with configured args,
# parse the results, and return as JSON in SourceKit[ten]
# format.
def self.build(config)
Dir.mktmpdir do |tmp_dir|
args = arguments(config, tmp_dir)

Executable.execute_command('swift',
args.unshift('symbolgraph-extract'),
true) # raise on error

Dir[tmp_dir + '/*.symbols.json'].map do |filename|
# The @ part is for extensions in our module (before the @)
# of types in another module (after the @).
filename =~ /(.*?)(@(.*?))?\.symbols/
module_name = Regexp.last_match[3] || Regexp.last_match[1]
{
filename =>
Graph.new(File.read(filename), module_name).to_sourcekit,
}
end.to_json
end
end

# Figure out the args to pass to symbolgraph-extract
# rubocop:disable Metrics/CyclomaticComplexity
def self.arguments(config, output_path)
if config.module_name.empty?
raise 'error: `--swift-build-tool symbolgraph` requires `--module`.'
end

user_args = config.build_tool_arguments.join

if user_args =~ /--(?:module-name|minimum-access-level|output-dir)/
raise 'error: `--build-tool-arguments` for '\
"`--swift-build-tool symbolgraph` can't use `--module`, "\
'`--minimum-access-level`, or `--output-dir`.'
end

# Default set
args = [
"--module-name=#{config.module_name}",
'--minimum-access-level=private',
"--output-dir=#{output_path}",
'--skip-synthesized-members',
]

# Things user can override
args.append("--sdk=#{sdk(config)}") unless user_args =~ /--sdk/
args.append("--target=#{target}") unless user_args =~ /--target/
args.append("-F=#{config.source_directory}") unless user_args =~ /-F(?!s)/
args.append("-I=#{config.source_directory}") unless user_args =~ /-I/

args + config.build_tool_arguments
end
# rubocop:enable Metrics/CyclomaticComplexity

# Get the SDK path. On !darwin this just isn't needed.
def self.sdk(config)
`xcrun --show-sdk-path --sdk #{config.sdk}`.chomp
end

# Guess a default LLVM target. Feels like the tool should figure this
# out from sdk + the binary somehow?
def self.target
`swift -version` =~ /Target: (.*?)$/
Regexp.last_match[1] || 'x86_64-apple-macosx10.15'
end

# This is a last-ditch fallback for when symbolgraph doesn't
# provide a name - at least conforming external types to local
# protocols.
def self.demangle(usr)
args = %w[demangle -simplified -compact].append(usr.sub(/^s:/, 's'))
output, = Executable.execute_command('swift', args, true)
return output.chomp
rescue
usr
end
end
end
94 changes: 94 additions & 0 deletions lib/jazzy/symbol_graph/constraint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
module Jazzy
module SymbolGraph
# Constraint is a tidied-up JSON object, used by both Symbol and
# Relationship, and key to reconstructing extensions.
class Constraint
attr_accessor :kind
attr_accessor :lhs
attr_accessor :rhs

private

def initialize(kind, lhs, rhs)
self.kind = kind # "==" or ":"
self.lhs = lhs
self.rhs = rhs
end

public

KIND_MAP = {
'conformance' => ':',
'superclass' => ':',
'sameType' => '==',
}.freeze

# Init from a JSON hash
def self.new_hash(hash)
kind = KIND_MAP[hash[:kind]]
raise "Unknown constraint kind '#{kind}'" unless kind
lhs = hash[:lhs].sub(/^Self\./, '')
rhs = hash[:rhs].sub(/^Self\./, '')
new(kind, lhs, rhs)
end

# Init from a Swift declaration fragment eg. 'A : B'
def self.new_declaration(decl)
decl =~ /^(.*?)\s*([:<=]+)\s*(.*)$/
new(Regexp.last_match[2],
Regexp.last_match[1],
Regexp.last_match[3])
end

def to_swift
"#{lhs} #{kind} #{rhs}"
end

# The first component of types in the constraint
def type_names
Set.new([lhs, rhs].map { |n| n.sub(/\..*$/, '') })
end

def self.new_list(hash_list)
hash_list.map { |h| Constraint.new_hash(h) }.sort.uniq
end

# Swift protocols and reqs have an implementation/hidden conformance
# to their own protocol: we don't want to think about this in docs.
def self.new_list_for_symbol(hash_list, path_components)
hash_list.map do |hash|
if hash[:lhs] == 'Self' &&
hash[:kind] == 'conformance' &&
path_components.include?(hash[:rhs])
next nil
end
Constraint.new_hash(hash)
end.compact
end

# Workaround Swift 5.3 bug with missing constraint rels
def self.new_list_from_declaration(decl)
decl.split(/\s*,\s*/).map { |cons| Constraint.new_declaration(cons) }
end

# Sort order - by Swift text
include Comparable

def <=>(other)
to_swift <=> other.to_swift
end

alias eql? ==

def hash
to_swift.hash
end
end
end
end

class Array
def to_where_clause
empty? ? '' : ' where ' + map(&:to_swift).join(', ')
end
end
Loading

0 comments on commit c30bdf7

Please sign in to comment.