Skip to content

Commit e013865

Browse files
authored
Make snippet-extract take individual input/output files instead of a directory (#39)
This does not change the experience for package authors but allows for better integration with other tools that require sandboxing of file access. rdar://103034070
1 parent 713ff31 commit e013865

File tree

6 files changed

+301
-89
lines changed

6 files changed

+301
-89
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ let package = Package(
5656
dependencies: [
5757
"Snippets",
5858
"SwiftDocCPluginUtilities",
59+
"snippet-extract",
5960
],
6061
resources: [
6162
.copy("Test Fixtures"),

Sources/SwiftDocCPluginUtilities/Snippets/SnippetExtractor.swift

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class SnippetExtractor {
1515

1616
enum SymbolGraphExtractionResult {
1717
case packageDoesNotProduceSnippets
18-
case packageContainsSnippets(symbolGraphDirectory: URL)
18+
case packageContainsSnippets(symbolGraphFile: URL)
1919
}
2020

2121
private let snippetTool: URL
@@ -65,6 +65,27 @@ public class SnippetExtractor {
6565
var _fileExists: (_ path: String) -> Bool = { path in
6666
return FileManager.default.fileExists(atPath: path)
6767
}
68+
69+
/// Returns all of the `.swift` files under a directory recursively.
70+
///
71+
/// Provided for testing.
72+
var _findSnippetFilesInDirectory: (_ directory: URL) -> [String] = { directory -> [String] in
73+
guard let snippetEnumerator = FileManager.default.enumerator(at: directory,
74+
includingPropertiesForKeys: nil,
75+
options: [.skipsHiddenFiles]) else {
76+
return []
77+
78+
}
79+
var snippetInputFiles = [String]()
80+
for case let potentialSnippetURL as URL in snippetEnumerator {
81+
guard potentialSnippetURL.pathExtension.lowercased() == "swift" else {
82+
continue
83+
}
84+
snippetInputFiles.append(potentialSnippetURL.path)
85+
}
86+
87+
return snippetInputFiles
88+
}
6889

6990
/// Generate snippets for the given package.
7091
///
@@ -79,16 +100,15 @@ public class SnippetExtractor {
79100
/// The snippet extractor will look for a `Snippets` subdirectory
80101
/// within this directory.
81102
///
82-
/// - Returns: A URL for the directory containing the generated snippets or nil if
83-
/// no snippets were produced.
103+
/// - Returns: A URL for the output file of the generated snippets symbol graph JSON file.
84104
public func generateSnippets(
85105
for packageIdentifier: PackageIdentifier,
86106
packageDisplayName: String,
87107
packageDirectory: URL
88108
) throws -> URL? {
89109
switch snippetSymbolGraphExtractionResults[packageIdentifier] {
90-
case .packageContainsSnippets(symbolGraphDirectory: let symbolGraphDirectory):
91-
return symbolGraphDirectory
110+
case .packageContainsSnippets(symbolGraphFile: let symbolGraphFile):
111+
return symbolGraphFile
92112
case .packageDoesNotProduceSnippets:
93113
return nil
94114
case .none:
@@ -100,26 +120,34 @@ public class SnippetExtractor {
100120
snippetSymbolGraphExtractionResults[packageIdentifier] = .packageDoesNotProduceSnippets
101121
return nil
102122
}
123+
124+
let snippetInputFiles = _findSnippetFilesInDirectory(snippetsDirectory)
125+
126+
guard !snippetInputFiles.isEmpty else {
127+
snippetSymbolGraphExtractionResults[packageIdentifier] = .packageDoesNotProduceSnippets
128+
return nil
129+
}
103130

104131
let outputDirectory = snippetsOutputDirectory(
105132
in: workingDirectory,
106133
packageIdentifier: packageIdentifier,
107134
packageDisplayName: packageDisplayName
108135
)
136+
137+
let outputFile = outputDirectory.appendingPathComponent("\(packageDisplayName)-snippets.symbols.json")
109138

110139
let process = Process()
111140
process.executableURL = snippetTool
112141
process.arguments = [
113-
snippetsDirectory.path,
114-
outputDirectory.path,
115-
packageDisplayName,
116-
]
117-
142+
"--output", outputFile.path,
143+
"--module-name", packageDisplayName,
144+
] + snippetInputFiles
145+
118146
try _runProcess(process)
119147

120-
if _fileExists(outputDirectory.path) {
121-
snippetSymbolGraphExtractionResults[packageIdentifier] = .packageContainsSnippets(symbolGraphDirectory: outputDirectory)
122-
return outputDirectory
148+
if _fileExists(outputFile.path) {
149+
snippetSymbolGraphExtractionResults[packageIdentifier] = .packageContainsSnippets(symbolGraphFile: outputFile)
150+
return outputFile
123151
} else {
124152
snippetSymbolGraphExtractionResults[packageIdentifier] = .packageDoesNotProduceSnippets
125153
return nil

Sources/snippet-extract/SnippetBuildCommand.swift

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,92 @@ import SymbolKit
1212

1313
@main
1414
struct SnippetExtractCommand {
15-
var snippetsDir: String
16-
var outputDir: String
15+
enum OptionName: String {
16+
case moduleName = "--module-name"
17+
case outputFile = "--output"
18+
}
19+
20+
enum Argument {
21+
case moduleName(String)
22+
case outputFile(String)
23+
case inputFile(String)
24+
}
25+
26+
enum ArgumentError: Error, CustomStringConvertible {
27+
case missingOption(OptionName)
28+
case missingOptionValue(OptionName)
29+
case snippetNotContainedInSnippetsDirectory(URL)
30+
31+
var description: String {
32+
switch self {
33+
case .missingOption(let optionName):
34+
return "Missing required option \(optionName.rawValue)"
35+
case .missingOptionValue(let optionName):
36+
return "Missing required option value for \(optionName.rawValue)"
37+
case .snippetNotContainedInSnippetsDirectory(let snippetFileURL):
38+
return "Snippet file '\(snippetFileURL.path)' is not contained in a directory called 'Snippets' at any level, so this tool is not able to compute the path components that would be used for linking to the snippet. It may exist in a subdirectory, but one of its parent directories must be named 'Snippets'."
39+
}
40+
}
41+
}
42+
43+
var snippetFiles = [String]()
44+
var outputFile: String
1745
var moduleName: String
1846

1947
static func printUsage() {
2048
let usage = """
21-
USAGE: snippet-extract <snippet directory> <output directory> <module name>
49+
USAGE: snippet-extract --output <output file> --module-name <module name> <input files>
2250
2351
ARGUMENTS:
24-
<snippet directory> - The directory containing Swift snippets
25-
<output directory> - The diretory in which to place Symbol Graph JSON file(s) representing the snippets
26-
<module name> - The module name to use for the Symbol Graph (typically should be the package name)
52+
<output file> (Required)
53+
The path of the output Symbol Graph JSON file representing the snippets for the a module or package
54+
<module name> (Required)
55+
The module name to use for the Symbol Graph (typically should be the package name)
56+
<input files>
57+
One or more absolute paths to snippet files to interpret as snippets
2758
"""
2859
print(usage)
2960
}
3061

62+
init(arguments: [String]) throws {
63+
var arguments = arguments
64+
65+
var parsedOutputFile: String? = nil
66+
var parsedModuleName: String? = nil
67+
68+
while let argument = try arguments.parseSnippetArgument() {
69+
switch argument {
70+
case .inputFile(let inputFile):
71+
snippetFiles.append(inputFile)
72+
case .moduleName(let moduleName):
73+
parsedModuleName = moduleName
74+
case .outputFile(let outputFile):
75+
parsedOutputFile = outputFile
76+
}
77+
}
78+
79+
guard let parsedOutputFile else {
80+
throw ArgumentError.missingOption(.outputFile)
81+
}
82+
self.outputFile = parsedOutputFile
83+
84+
guard let parsedModuleName else {
85+
throw ArgumentError.missingOption(.moduleName)
86+
}
87+
self.moduleName = parsedModuleName
88+
}
89+
3190
func run() throws {
32-
let snippets = try loadSnippets(from: URL(fileURLWithPath: snippetsDir))
91+
let snippets = try snippetFiles.map {
92+
try Snippet(parsing: URL(fileURLWithPath: $0))
93+
}
3394
guard snippets.count > 0 else { return }
34-
let symbolGraphFilename = URL(fileURLWithPath: outputDir)
35-
.appendingPathComponent("\(moduleName)-snippets.symbols.json")
95+
let symbolGraphFilename = URL(fileURLWithPath: outputFile)
3696
try emitSymbolGraph(for: snippets, to: symbolGraphFilename, moduleName: moduleName)
3797
}
3898

3999
func emitSymbolGraph(for snippets: [Snippet], to emitFilename: URL, moduleName: String) throws {
40-
let snippetSymbols = snippets.map { SymbolGraph.Symbol($0, moduleName: moduleName, inDirectory: URL(fileURLWithPath: snippetsDir).absoluteURL) }
100+
let snippetSymbols = try snippets.map { try SymbolGraph.Symbol($0, moduleName: moduleName) }
41101
let metadata = SymbolGraph.Metadata(formatVersion: .init(major: 0, minor: 1, patch: 0), generator: "snippet-extract")
42102
let module = SymbolGraph.Module(name: moduleName, platform: .init(architecture: nil, vendor: nil, operatingSystem: nil, environment: nil), isVirtual: true)
43103
let symbolGraph = SymbolGraph(metadata: metadata, module: module, symbols: snippetSymbols, relationships: [])
@@ -72,28 +132,42 @@ struct SnippetExtractCommand {
72132
.filter { $0.isDirectory }
73133
}
74134

75-
func loadSnippets(from snippetsDirectory: URL) throws -> [Snippet] {
76-
guard snippetsDirectory.isDirectory else {
77-
return []
78-
}
79-
80-
let snippetFiles = try files(in: snippetsDirectory, withExtension: "swift") +
81-
subdirectories(in: snippetsDirectory)
82-
.flatMap { subdirectory -> [URL] in
83-
try files(in: subdirectory, withExtension: "swift")
84-
}
85-
86-
return try snippetFiles.map { try Snippet(parsing: $0) }
87-
}
88-
89135
static func main() throws {
90-
if CommandLine.arguments.count < 4 {
136+
if CommandLine.arguments.count == 1 || CommandLine.arguments.contains("-h") || CommandLine.arguments.contains("--help") {
91137
printUsage()
92138
exit(0)
93139
}
94-
let snippetExtract = SnippetExtractCommand(snippetsDir: CommandLine.arguments[1],
95-
outputDir: CommandLine.arguments[2],
96-
moduleName: CommandLine.arguments[3])
97-
try snippetExtract.run()
140+
do {
141+
let snippetExtract = try SnippetExtractCommand(arguments: Array(CommandLine.arguments.dropFirst(1)))
142+
try snippetExtract.run()
143+
} catch let error as ArgumentError {
144+
printUsage()
145+
throw error
146+
}
147+
}
148+
}
149+
150+
extension Array where Element == String {
151+
mutating func parseSnippetArgument() throws -> SnippetExtractCommand.Argument? {
152+
guard let thisArgument = first else {
153+
return nil
154+
}
155+
removeFirst()
156+
switch thisArgument {
157+
case "--module-name":
158+
guard let nextArgument = first else {
159+
throw SnippetExtractCommand.ArgumentError.missingOptionValue(.moduleName)
160+
}
161+
removeFirst()
162+
return .moduleName(nextArgument)
163+
case "--output":
164+
guard let nextArgument = first else {
165+
throw SnippetExtractCommand.ArgumentError.missingOptionValue(.outputFile)
166+
}
167+
removeFirst()
168+
return .outputFile(nextArgument)
169+
default:
170+
return .inputFile(thisArgument)
171+
}
98172
}
99173
}

Sources/snippet-extract/Utility/SymbolGraph+Snippet.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,19 @@ extension SymbolGraph.Symbol {
1414
/// Create a ``SymbolGraph.Symbol`` from a ``Snippet``.
1515
///
1616
/// - parameter moduleName: The name to use for the package name in the snippet symbol's precise identifier.
17-
public init(_ snippet: Snippets.Snippet, moduleName: String, inDirectory snippetsDirectory: URL) {
17+
public init(_ snippet: Snippets.Snippet, moduleName: String) throws {
1818
let basename = snippet.sourceFile.deletingPathExtension().lastPathComponent
1919
let identifier = SymbolGraph.Symbol.Identifier(precise: "$snippet__\(moduleName).\(basename)", interfaceLanguage: "swift")
2020
let names = SymbolGraph.Symbol.Names.init(title: basename, navigator: nil, subHeading: nil, prose: nil)
2121

2222
var pathComponents = snippet.sourceFile.absoluteURL.deletingPathExtension().pathComponents[...]
23-
for component in snippetsDirectory.absoluteURL.pathComponents {
24-
guard pathComponents.first == component else {
25-
break
26-
}
27-
pathComponents = pathComponents.dropFirst(1)
23+
24+
guard let snippetsPathComponentIndex = pathComponents.firstIndex(where: {
25+
$0 == "Snippets"
26+
}) else {
27+
throw SnippetExtractCommand.ArgumentError.snippetNotContainedInSnippetsDirectory(snippet.sourceFile)
2828
}
29+
pathComponents = pathComponents[snippetsPathComponentIndex...]
2930

3031
let docComment = SymbolGraph.LineList(snippet.explanation
3132
.split(separator: "\n", maxSplits: Int.max, omittingEmptySubsequences: false)

0 commit comments

Comments
 (0)