Skip to content

[5.6] Make the symbol graph extraction requested by plugins more robust (#3993) #3998

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 66 additions & 34 deletions Sources/Commands/SwiftPackageTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ extension SwiftPackageTool {
struct DumpSymbolGraph: SwiftCommand {
static let configuration = CommandConfiguration(
abstract: "Dump Symbol Graph")
static let defaultMinimumAccessLevel = AccessLevel.public
static let defaultMinimumAccessLevel = SymbolGraphExtract.AccessLevel.public

@OptionGroup(_hiddenFromHelp: true)
var swiftOptions: SwiftToolOptions
Expand All @@ -542,7 +542,7 @@ extension SwiftPackageTool {
@Flag(help: "Skip members inherited through classes or default implementations.")
var skipSynthesizedMembers = false

@Option(help: "Include symbols with this access level or more. Possible values: \(AccessLevel.allValueStrings.joined(separator: " | "))")
@Option(help: "Include symbols with this access level or more. Possible values: \(SymbolGraphExtract.AccessLevel.allValueStrings.joined(separator: " | "))")
var minimumAccessLevel = defaultMinimumAccessLevel

@Flag(help: "Skip emitting doc comments for members inherited through classes or default implementations.")
Expand All @@ -552,23 +552,34 @@ extension SwiftPackageTool {
var includeSPISymbols = false

func run(_ swiftTool: SwiftTool) throws {
let symbolGraphExtract = try SymbolGraphExtract(
tool: swiftTool.getToolchain().getSymbolGraphExtract())

// Build the current package.
//
// We turn build manifest caching off because we need the build plan.
let buildOp = try swiftTool.createBuildOperation(cacheBuildManifest: false)
try buildOp.build()

try symbolGraphExtract.dumpSymbolGraph(
buildPlan: buildOp.buildPlan!,
prettyPrint: prettyPrint,
skipSynthesisedMembers: skipSynthesizedMembers,
// Configure the symbol graph extractor.
let symbolGraphExtractor = try SymbolGraphExtract(
tool: swiftTool.getToolchain().getSymbolGraphExtract(),
skipSynthesizedMembers: skipSynthesizedMembers,
minimumAccessLevel: minimumAccessLevel,
skipInheritedDocs: skipInheritedDocs,
includeSPISymbols: includeSPISymbols
)
includeSPISymbols: includeSPISymbols)

// Run the tool once for every library and executable target in the root package.
let buildPlan = buildOp.buildPlan!
let symbolGraphDirectory = buildPlan.buildParameters.dataPath.appending(component: "symbolgraph")
let targets = buildPlan.graph.rootPackages.flatMap{ $0.targets }.filter{ $0.type == .library || $0.type == .executable }
for target in targets {
print("-- Emitting symbol graph for", target.name)
try symbolGraphExtractor.extractSymbolGraph(
target: target,
buildPlan: buildPlan,
logLevel: swiftTool.logLevel,
outputDirectory: symbolGraphDirectory)
}

print("Files written to", symbolGraphDirectory.pathString)
}
}

Expand Down Expand Up @@ -1217,37 +1228,58 @@ final class PluginDelegate: PluginInvocationDelegate {
}

private func createSymbolGraph(forTarget targetName: String, options: PluginInvocationSymbolGraphOptions) throws -> PluginInvocationSymbolGraphResult {
// Current implementation uses `SymbolGraphExtract()` but we can probably do better in the future.
let buildParameters = try swiftTool.buildParameters()
let buildOperation = try swiftTool.createBuildOperation(cacheBuildManifest: false) // We only get a build plan if we don't cache
try buildOperation.build(subset: .target(targetName))
let symbolGraphExtract = try SymbolGraphExtract(tool: swiftTool.getToolchain().getSymbolGraphExtract())
let minimumAccessLevel: AccessLevel
// Current implementation uses `SymbolGraphExtract()` but in the future we should emit the symbol graph while building.

// Create a build operation for building the target., skipping the the cache because we need the build plan.
let buildOperation = try swiftTool.createBuildOperation(cacheBuildManifest: false)

// Find the target in the build operation's package graph; it's an error if we don't find it.
let packageGraph = try buildOperation.getPackageGraph()
guard let target = packageGraph.allTargets.first(where: { $0.name == targetName }) else {
throw StringError("could not find a target named “\(targetName)”")
}

// Build the target, if needed.
try buildOperation.build(subset: .target(target.name))

// Configure the symbol graph extractor.
var symbolGraphExtractor = try SymbolGraphExtract(tool: swiftTool.getToolchain().getSymbolGraphExtract())
symbolGraphExtractor.skipSynthesizedMembers = !options.includeSynthesized
switch options.minimumAccessLevel {
case .private:
minimumAccessLevel = .private
symbolGraphExtractor.minimumAccessLevel = .private
case .fileprivate:
minimumAccessLevel = .fileprivate
symbolGraphExtractor.minimumAccessLevel = .fileprivate
case .internal:
minimumAccessLevel = .internal
symbolGraphExtractor.minimumAccessLevel = .internal
case .public:
minimumAccessLevel = .public
symbolGraphExtractor.minimumAccessLevel = .public
case .open:
minimumAccessLevel = .open
symbolGraphExtractor.minimumAccessLevel = .open
}

// Extract the symbol graph.
try symbolGraphExtract.dumpSymbolGraph(
buildPlan: buildOperation.buildPlan!,
prettyPrint: false,
skipSynthesisedMembers: !options.includeSynthesized,
minimumAccessLevel: minimumAccessLevel,
skipInheritedDocs: true,
includeSPISymbols: options.includeSPI)

symbolGraphExtractor.skipInheritedDocs = true
symbolGraphExtractor.includeSPISymbols = options.includeSPI

// Determine the output directory, and remove any old version if it already exists.
guard let buildPlan = buildOperation.buildPlan else {
throw StringError("could not get the build plan from the build operation")
}
guard let package = packageGraph.package(for: target) else {
throw StringError("could not determine the package for target “\(target.name)”")
}
let outputDir = buildPlan.buildParameters.dataPath.appending(components: "extracted-symbols", package.identity.description, target.name)
try localFileSystem.removeFileTree(outputDir)

// Run the symbol graph extractor on the target.
try symbolGraphExtractor.extractSymbolGraph(
target: target,
buildPlan: buildPlan,
outputRedirection: .collect,
logLevel: .warning,
outputDirectory: outputDir)

// Return the results to the plugin.
return PluginInvocationSymbolGraphResult(
directoryPath: buildParameters.symbolGraph.pathString)
return PluginInvocationSymbolGraphResult(directoryPath: outputDir.pathString)
}
}

Expand Down
127 changes: 59 additions & 68 deletions Sources/Commands/SymbolGraphExtract.swift
Original file line number Diff line number Diff line change
@@ -1,97 +1,88 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See http://swift.org/LICENSE.txt for license information
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import Foundation

import TSCBasic
import TSCUtility

import SPMBuildCore
import ArgumentParser
import Basics
import Build
import PackageGraph
import PackageModel
import SourceControl
import Workspace
import ArgumentParser
import SPMBuildCore
import TSCBasic
import TSCUtility

/// A wrapper for swift-symbolgraph-extract tool.
public struct SymbolGraphExtract {
let tool: AbsolutePath

init(tool: AbsolutePath) {
self.tool = tool

var skipSynthesizedMembers = false
var minimumAccessLevel = AccessLevel.public
var skipInheritedDocs = false
var includeSPISymbols = false
var outputFormat = OutputFormat.json(pretty: false)

/// Access control levels.
public enum AccessLevel: String, RawRepresentable, CaseIterable, ExpressibleByArgument {
// The cases reflect those found in `include/swift/AST/AttrKind.h` of the swift compiler (at commit 03f55d7bb4204ca54841218eb7cc175ae798e3bd)
case `private`, `fileprivate`, `internal`, `public`, `open`
}

public func dumpSymbolGraph(
/// Output format of the generated symbol graph.
public enum OutputFormat {
/// JSON format, optionally "pretty-printed" be more human-readable.
case json(pretty: Bool)
}

/// Creates a symbol graph for `target` in `outputDirectory` using the build information from `buildPlan`. The `outputDirection` determines how the output from the tool subprocess is handled, and `verbosity` specifies how much console output to ask the tool to emit.
public func extractSymbolGraph(
target: ResolvedTarget,
buildPlan: BuildPlan,
prettyPrint: Bool,
skipSynthesisedMembers: Bool,
minimumAccessLevel: AccessLevel,
skipInheritedDocs: Bool,
includeSPISymbols: Bool
outputRedirection: Process.OutputRedirection = .none,
logLevel: Basics.Diagnostic.Severity,
outputDirectory: AbsolutePath
) throws {
let buildParameters = buildPlan.buildParameters
let symbolGraphDirectory = buildPlan.buildParameters.symbolGraph
try localFileSystem.createDirectory(symbolGraphDirectory, recursive: true)

// Run the tool for each target in the root package.
let targets = buildPlan.graph.rootPackages.flatMap{ $0.targets }.filter{ $0.type == .library || $0.type == .executable }
for target in targets {
var args = [String]()
args += ["-module-name", target.c99name]
args += try buildParameters.targetTripleArgs(for: target)

args += buildPlan.createAPIToolCommonArgs(includeLibrarySearchPaths: true)
args += ["-module-cache-path", buildParameters.moduleCache.pathString]

args += ["-output-dir", symbolGraphDirectory.pathString]

if prettyPrint { args.append("-pretty-print") }
if skipSynthesisedMembers { args.append("-skip-synthesized-members") }
if minimumAccessLevel != SwiftPackageTool.DumpSymbolGraph.defaultMinimumAccessLevel {
args += ["-minimum-access-level", minimumAccessLevel.rawValue]
try localFileSystem.createDirectory(outputDirectory, recursive: true)

// Construct arguments for extracting symbols for a single target.
var commandLine = [self.tool.pathString]
commandLine += ["-module-name", target.c99name]
commandLine += try buildParameters.targetTripleArgs(for: target)
commandLine += buildPlan.createAPIToolCommonArgs(includeLibrarySearchPaths: true)
commandLine += ["-module-cache-path", buildParameters.moduleCache.pathString]
if logLevel <= .info {
commandLine += ["-v"]
}
commandLine += ["-minimum-access-level", minimumAccessLevel.rawValue]
if skipSynthesizedMembers {
commandLine += ["-skip-synthesized-members"]
}
if skipInheritedDocs {
commandLine += ["-skip-inherited-docs"]
}
if includeSPISymbols {
commandLine += ["-include-spi-symbols"]
}
switch outputFormat {
case .json(let pretty):
if pretty {
commandLine += ["-pretty-print"]
}
if skipInheritedDocs { args.append("-skip-inherited-docs") }
if includeSPISymbols { args.append("-include-spi-symbols") }

print("-- Emitting symbol graph for", target.name)
try runTool(args)
}
print("Files written to", symbolGraphDirectory.pathString)
}
commandLine += ["-output-dir", outputDirectory.pathString]

func runTool(_ args: [String]) throws {
let arguments = [tool.pathString] + args
// Run the extraction.
let process = Process(
arguments: arguments,
outputRedirection: .none,
verbose: verbosity != .concise
)
arguments: commandLine,
outputRedirection: outputRedirection,
verbose: logLevel <= .info)
try process.launch()
try process.waitUntilExit()
}
}

/// Access control levels.
public enum AccessLevel: String, RawRepresentable, CustomStringConvertible, CaseIterable {
// The cases reflect those found in `include/swift/AST/AttrKind.h` of the swift compiler (at commit 03f55d7bb4204ca54841218eb7cc175ae798e3bd)
case `private`, `fileprivate`, `internal`, `public`, `open`

public var description: String { rawValue }
}

extension AccessLevel: ExpressibleByArgument {}

extension BuildParameters {
/// The directory containing artifacts generated by the symbolgraph-extract tool.
var symbolGraph: AbsolutePath {
dataPath.appending(component: "symbolgraph")
}
}
86 changes: 86 additions & 0 deletions Tests/CommandsTests/PackageToolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1442,4 +1442,90 @@ final class PackageToolTests: CommandsTestCase {
}
}
}

func testCommandPluginSymbolGraphCallbacks() throws {
// Depending on how the test is running, the `swift-symbolgraph-extract` tool might be unavailable.
try XCTSkipIf((try? UserToolchain.default.getSymbolGraphExtract()) == nil, "skipping test because the `swift-symbolgraph-extract` tools isn't available")

try testWithTemporaryDirectory { tmpPath in
// Create a sample package with a library, and executable, and a plugin.
let packageDir = tmpPath.appending(components: "MyPackage")
try localFileSystem.writeFileContents(packageDir.appending(components: "Package.swift")) {
$0 <<< """
// swift-tools-version: 5.6
import PackageDescription
let package = Package(
name: "MyPackage",
targets: [
.target(
name: "MyLibrary"
),
.executableTarget(
name: "MyCommand",
dependencies: ["MyLibrary"]
),
.plugin(
name: "MyPlugin",
capability: .command(
intent: .documentationGeneration()
)
),
]
)
"""
}
try localFileSystem.writeFileContents(packageDir.appending(components: "Sources", "MyLibrary", "library.swift")) {
$0 <<< """
public func GetGreeting() -> String { return "Hello" }
"""
}
try localFileSystem.writeFileContents(packageDir.appending(components: "Sources", "MyCommand", "main.swift")) {
$0 <<< """
import MyLibrary
print("\\(GetGreeting()), World!")
"""
}
try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift")) {
$0 <<< """
import PackagePlugin
import Foundation

@main
struct MyCommandPlugin: CommandPlugin {
func performCommand(
context: PluginContext,
targets: [Target],
arguments: [String]
) throws {
// Ask for and print out the symbol graph directory for each target.
for target in targets {
let symbolGraph = try packageManager.getSymbolGraph(for: target,
options: .init(minimumAccessLevel: .public))
print("\\(target.name): \\(symbolGraph.directoryPath)")
}
}
}
"""
}

// Check that if we don't pass any target, we successfully get symbol graph information for all targets in the package, and at different paths.
do {
let result = try SwiftPMProduct.SwiftPackage.executeProcess(["generate-documentation"], packagePath: packageDir)
let output = try result.utf8Output() + result.utf8stderrOutput()
XCTAssertEqual(result.exitStatus, .terminated(code: 0), "output: \(output)")
XCTAssertMatch(output, .and(.contains("MyLibrary:"), .contains("mypackage/MyLibrary")))
XCTAssertMatch(output, .and(.contains("MyCommand:"), .contains("mypackage/MyCommand")))

}

// Check that if we pass a target, we successfully get symbol graph information for just the target we asked for.
do {
let result = try SwiftPMProduct.SwiftPackage.executeProcess(["--target", "MyLibrary", "generate-documentation"], packagePath: packageDir)
let output = try result.utf8Output() + result.utf8stderrOutput()
XCTAssertEqual(result.exitStatus, .terminated(code: 0), "output: \(output)")
XCTAssertMatch(output, .and(.contains("MyLibrary:"), .contains("mypackage/MyLibrary")))
XCTAssertNoMatch(output, .and(.contains("MyCommand:"), .contains("mypackage/MyCommand")))
}
}
}
}