Skip to content
This repository was archived by the owner on Jun 1, 2023. It is now read-only.

Commit 017e920

Browse files
committed
Add experimental swift-api-diagram executable
1 parent d8e4773 commit 017e920

File tree

7 files changed

+208
-0
lines changed

7 files changed

+208
-0
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ let package = Package(
2323
name: "swift-dcov",
2424
dependencies: ["SwiftSyntax", "SwiftSemantics", "SwiftMarkup", "SwiftDoc", "Commander"]
2525
),
26+
.target(
27+
name: "swift-api-diagram",
28+
dependencies: ["SwiftDoc", "SwiftSemantics", "Commander"]
29+
),
2630
.target(
2731
name: "swift-api-inventory",
2832
dependencies: ["SwiftDoc", "SwiftSemantics", "Commander"]

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ $ diff -u Alamofire-5.0.0-rc.1.txt Alamofire-5.0.0-rc.3.txt
217217

218218
</details>
219219

220+
### swift-api-diagram
221+
222+
`swift-dcov` generates a graph of APIs in [DOT format][dot]
223+
that can be rendered by [GraphViz][graphviz] into a diagram.
224+
225+
```terminal
226+
$ swift run swift-api-diagram Alamofire/Source > graph.dot
227+
$ head graph.dot
228+
digraph Anonymous {
229+
"Session" [shape=box];
230+
"NetworkReachabilityManager" [shape=box];
231+
"URLEncodedFormEncoder" [shape=box,peripheries=2];
232+
"ServerTrustManager" [shape=box];
233+
"MultipartFormData" [shape=box];
234+
235+
subgraph cluster_Request {
236+
"DataRequest" [shape=box];
237+
"Request" [shape=box];
238+
239+
$ dot -T svg graph.dot > graph.svg
240+
```
241+
242+
Here's an excerpt of the graph generated for Alamofire:
243+
244+
![Excerpt of swift-doc-api Diagram for Alamofire](https://user-images.githubusercontent.com/7659/73189318-0db0e880-40d9-11ea-8895-341a75ce873c.png)
245+
246+
220247
## Motivation
221248

222249
From its earliest days,
@@ -389,3 +416,5 @@ Mattt ([@mattt](https://twitter.com/mattt))
389416
[nshipster]: https://nshipster.com
390417
[dependency hell]: https://github.com/apple/swift-package-manager/tree/master/Documentation#dependency-hell
391418
[pcre]: https://en.wikipedia.org/wiki/Perl_Compatible_Regular_Expressions
419+
[dot]: https://en.wikipedia.org/wiki/DOT_(graph_description_language)
420+
[graphviz]: https://www.graphviz.org
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Foundation
2+
3+
extension FileHandle: TextOutputStream {
4+
public func write(_ string: String) {
5+
guard let data = string.data(using: .utf8) else { return }
6+
write(data)
7+
}
8+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SwiftDoc
2+
import SwiftSemantics
3+
4+
extension Modifiable {
5+
var nonAccessModifiers: [Modifier] {
6+
return modifiers.filter { modifier in
7+
switch modifier.name {
8+
case "private", "fileprivate", "internal", "public", "open":
9+
return false
10+
default:
11+
return true
12+
}
13+
}
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SwiftSemantics
2+
3+
protocol Generic {
4+
var genericParameters: [GenericParameter] { get }
5+
var genericRequirements: [GenericRequirement] { get }
6+
}
7+
8+
extension Class: Generic {}
9+
extension Enumeration: Generic {}
10+
extension Function: Generic {}
11+
extension Initializer: Generic {}
12+
extension Structure: Generic {}
13+
extension Subscript: Generic {}
14+
extension Typealias: Generic {}
15+
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftSemantics
2+
import SwiftDoc
3+
4+
fileprivate extension String {
5+
func indented(by spaces: Int = 2) -> String {
6+
return String(repeating: " ", count: spaces) + self
7+
}
8+
}
9+
10+
// MARK: -
11+
12+
enum GraphViz {
13+
static func diagram(of module: Module) -> String {
14+
var lines: [String] = []
15+
16+
var baseClasses: Set<String> = []
17+
var inheritanceMappings: [String: String] = [:]
18+
19+
let symbols = module.symbols.filter { $0.declaration.isPublic }
20+
21+
let classes = symbols.filter { $0.declaration is Class }
22+
for `class` in classes {
23+
if let declaration = `class`.declaration as? Class,
24+
let firstInheritedType = declaration.inheritance.first
25+
{
26+
if classes.contains(where: { $0.declaration.name.hasSuffix(firstInheritedType) }) {
27+
inheritanceMappings[`class`.declaration.qualifiedName] = firstInheritedType
28+
}
29+
} else {
30+
baseClasses.insert(`class`.declaration.qualifiedName)
31+
}
32+
}
33+
34+
var classClusters: [String: Set<String>] = [:]
35+
for baseClass in baseClasses {
36+
var cluster: Set<String> = [baseClass]
37+
38+
var previousCount = -1
39+
while cluster.count > previousCount {
40+
previousCount = cluster.count
41+
for (subclass, superclass) in inheritanceMappings {
42+
if cluster.contains(superclass) {
43+
cluster.insert(subclass)
44+
}
45+
}
46+
}
47+
48+
classClusters[baseClass] = cluster
49+
}
50+
51+
for (baseClass, cluster) in classClusters {
52+
var clusterLines: [String] = []
53+
54+
for className in cluster {
55+
if let `class` = classes.first(where: { $0.declaration.qualifiedName == className }),
56+
let declaration = `class`.declaration as? Class,
57+
declaration.modifiers.contains(where: { $0.name == "final" }) == true
58+
{
59+
clusterLines.append(#""\#(className)" [shape=box,peripheries=2];"#)
60+
} else {
61+
clusterLines.append(#""\#(className)" [shape=box];"#)
62+
}
63+
}
64+
65+
if cluster.count > 1 {
66+
for (subclassName, superclassName) in inheritanceMappings {
67+
if cluster.contains(superclassName) {
68+
clusterLines.append(#""\#(subclassName)" -> "\#(superclassName)";"#)
69+
}
70+
}
71+
72+
clusterLines = (
73+
["", "subgraph cluster_\(baseClass) {"] +
74+
clusterLines.map { $0.indented() } +
75+
["}", ""]
76+
)
77+
}
78+
79+
lines.append(contentsOf: clusterLines)
80+
}
81+
82+
lines.append("")
83+
84+
for symbol in (symbols.filter { $0.declaration is Type }) {
85+
guard let type = symbol.declaration as? Type else { continue }
86+
for item in type.inheritance {
87+
guard !inheritanceMappings.values.contains(item) else { continue }
88+
for inherited in item.split(separator: "&").map({ String($0).trimmingCharacters(in: .whitespacesAndNewlines) }) {
89+
lines.append(#""\#(type.qualifiedName)" -> "\#(inherited)";"#)
90+
}
91+
}
92+
}
93+
94+
lines = ["digraph \(module.name) {"] +
95+
lines.map { $0.indented() } +
96+
["}"]
97+
98+
return lines.joined(separator: "\n")
99+
}
100+
}

Sources/swift-api-diagram/main.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Commander
2+
import Foundation
3+
import SwiftDoc
4+
5+
let arguments = Array(ProcessInfo.processInfo.arguments.dropFirst())
6+
7+
let fileManager = FileManager.default
8+
9+
var standardOutput = FileHandle.standardOutput
10+
var standardError = FileHandle.standardError
11+
12+
command(
13+
Argument<[String]>("inputs", description: "One or more paths to Swift files", validator: { (inputs) -> [String] in
14+
inputs.filter { path in
15+
var isDirectory: ObjCBool = false
16+
return fileManager.fileExists(atPath: path, isDirectory: &isDirectory) || isDirectory.boolValue
17+
}
18+
}), { inputs in
19+
do {
20+
// TODO: Add special behavior for Package.swift manifests
21+
var sourceFiles: [SourceFile] = []
22+
for path in inputs {
23+
let directory = URL(fileURLWithPath: path)
24+
guard let directoryEnumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: nil) else { continue }
25+
for case let url as URL in directoryEnumerator {
26+
guard url.pathExtension == "swift" else { continue }
27+
sourceFiles.append(try SourceFile(file: url, relativeTo: directory))
28+
}
29+
}
30+
31+
let module = Module(sourceFiles: sourceFiles)
32+
print(GraphViz.diagram(of: module), to: &standardOutput)
33+
} catch {
34+
print("Error: \(error)", to: &standardError)
35+
exit(EXIT_FAILURE)
36+
}
37+
}).run()

0 commit comments

Comments
 (0)