diff --git a/IntegrationTests/Package.swift b/IntegrationTests/Package.swift index 0d7513a..6743f88 100644 --- a/IntegrationTests/Package.swift +++ b/IntegrationTests/Package.swift @@ -28,6 +28,7 @@ let package = Package( .copy("Fixtures/SingleTestTarget"), .copy("Fixtures/SingleExecutableTarget"), .copy("Fixtures/MixedTargets"), + .copy("Fixtures/TargetsWithDependencies"), .copy("Fixtures/TargetWithDocCCatalog"), .copy("Fixtures/PackageWithSnippets"), .copy("Fixtures/PackageWithConformanceSymbols"), diff --git a/IntegrationTests/Tests/CombinedDocumentationTests.swift b/IntegrationTests/Tests/CombinedDocumentationTests.swift new file mode 100644 index 0000000..91d3001 --- /dev/null +++ b/IntegrationTests/Tests/CombinedDocumentationTests.swift @@ -0,0 +1,99 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +import XCTest + +final class CombinedDocumentationTests: ConcurrencyRequiringTestCase { + func testCombinedDocumentation() throws { +#if compiler(>=6.0) + let result = try swiftPackage( + "generate-documentation", + "--enable-experimental-combined-documentation", + "--verbose", // Necessary to see the 'docc convert' calls in the log and verify their parameters. + workingDirectory: try setupTemporaryDirectoryForFixture(named: "TargetsWithDependencies") + ) + + result.assertExitStatusEquals(0) + let outputArchives = result.referencedDocCArchives + XCTAssertEqual(outputArchives.count, 1) + XCTAssertEqual(outputArchives.map(\.lastPathComponent), [ + "TargetsWithDependencies.doccarchive", + ]) + + // Verify that the combined archive contains all target's documentation + + let combinedArchiveURL = try XCTUnwrap(outputArchives.first) + let combinedDataDirectoryContents = try filesIn(.dataSubdirectory, of: combinedArchiveURL) + .map(\.relativePath) + .sorted() + + XCTAssertEqual(combinedDataDirectoryContents, [ + "documentation.json", + "documentation/innerfirst.json", + "documentation/innerfirst/somethingpublic.json", + "documentation/innersecond.json", + "documentation/innersecond/somethingpublic.json", + "documentation/nestedinner.json", + "documentation/nestedinner/somethingpublic.json", + "documentation/outer.json", + "documentation/outer/somethingpublic.json", + ]) + + // Verify that each 'docc convert' call was passed the expected dependencies + + let doccConvertCalls = result.standardOutput + .components(separatedBy: .newlines) + .filter { line in + line.hasPrefix("docc invocation: '") && line.utf8.contains("docc convert ".utf8) + }.map { line in + line.trimmingCharacters(in: CharacterSet(charactersIn: "'")) + .components(separatedBy: .whitespaces) + .drop(while: { $0 != "convert" }) + } + + XCTAssertEqual(doccConvertCalls.count, 4) + + func extractDependencyArchives(targetName: String, file: StaticString = #filePath, line: UInt = #line) throws -> [String] { + let arguments = try XCTUnwrap( + doccConvertCalls.first(where: { $0.contains(["--fallback-display-name", targetName]) }), + file: file, line: line + ) + var dependencyPaths: [URL] = [] + + var remaining = arguments[...] + while !remaining.isEmpty { + remaining = remaining.drop(while: { $0 != "--dependency" }).dropFirst(/* the '--dependency' element */) + if let path = remaining.popFirst() { + dependencyPaths.append(URL(fileURLWithPath: path)) + } + } + + return dependencyPaths.map { $0.lastPathComponent }.sorted() + } + // Outer + // ├─ InnerFirst + // ╰─ InnerSecond + // ╰─ NestedInner + + XCTAssertEqual(try extractDependencyArchives(targetName: "Outer"), [ + "InnerFirst.doccarchive", + "InnerSecond.doccarchive", + ], "The outer target has depends on both inner targets") + + XCTAssertEqual(try extractDependencyArchives(targetName: "InnerFirst"), [], "The first inner target has no dependencies") + + XCTAssertEqual(try extractDependencyArchives(targetName: "InnerSecond"), [ + "NestedInner.doccarchive", + ], "The second inner target has depends on the nested inner target") + + XCTAssertEqual(try extractDependencyArchives(targetName: "NestedInner"), [], "The nested inner target has no dependencies") +#else + XCTSkip("This test requires a Swift-DocC version that support the link-dependencies feature") +#endif + } +} diff --git a/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Package.swift b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Package.swift new file mode 100644 index 0000000..07d3091 --- /dev/null +++ b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.7 +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +import Foundation +import PackageDescription + +let package = Package( + name: "TargetsWithDependencies", + targets: [ + // Outer + // ├─ InnerFirst + // ╰─ InnerSecond + // ╰─ NestedInner + .target(name: "Outer", dependencies: [ + "InnerFirst", + "InnerSecond", + ]), + .target(name: "InnerFirst"), + .target(name: "InnerSecond", dependencies: [ + "NestedInner" + ]), + .target(name: "NestedInner"), + ] +) + +// We only expect 'swift-docc-plugin' to be a sibling when this package +// is running as part of a test. +// +// This allows the package to compile outside of tests for easier +// test development. +if FileManager.default.fileExists(atPath: "../swift-docc-plugin") { + package.dependencies += [ + .package(path: "../swift-docc-plugin"), + ] +} diff --git a/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerFirst/SomeFile.swift b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerFirst/SomeFile.swift new file mode 100644 index 0000000..9f5fa32 --- /dev/null +++ b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerFirst/SomeFile.swift @@ -0,0 +1,13 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +/// This is a public struct and should be included in the documentation for this library. +public struct SomethingPublic {} + +/// This is an internal struct and should not be included in the documentation for this library. +struct SomethingInternal {} diff --git a/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerSecond/SomeFile.swift b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerSecond/SomeFile.swift new file mode 100644 index 0000000..9f5fa32 --- /dev/null +++ b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/InnerSecond/SomeFile.swift @@ -0,0 +1,13 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +/// This is a public struct and should be included in the documentation for this library. +public struct SomethingPublic {} + +/// This is an internal struct and should not be included in the documentation for this library. +struct SomethingInternal {} diff --git a/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/NestedInner/SomeFile.swift b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/NestedInner/SomeFile.swift new file mode 100644 index 0000000..9f5fa32 --- /dev/null +++ b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/NestedInner/SomeFile.swift @@ -0,0 +1,13 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +/// This is a public struct and should be included in the documentation for this library. +public struct SomethingPublic {} + +/// This is an internal struct and should not be included in the documentation for this library. +struct SomethingInternal {} diff --git a/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/Outer/SomeFile.swift b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/Outer/SomeFile.swift new file mode 100644 index 0000000..9f5fa32 --- /dev/null +++ b/IntegrationTests/Tests/Fixtures/TargetsWithDependencies/Sources/Outer/SomeFile.swift @@ -0,0 +1,13 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors + +/// This is a public struct and should be included in the documentation for this library. +public struct SomethingPublic {} + +/// This is an internal struct and should not be included in the documentation for this library. +struct SomethingInternal {} diff --git a/Plugins/Swift-DocC Convert/SwiftDocCConvert.swift b/Plugins/Swift-DocC Convert/SwiftDocCConvert.swift index 35762e5..db5ba3a 100644 --- a/Plugins/Swift-DocC Convert/SwiftDocCConvert.swift +++ b/Plugins/Swift-DocC Convert/SwiftDocCConvert.swift @@ -46,7 +46,7 @@ import PackagePlugin if isCombinedDocumentationEnabled, doccFeatures?.contains(.linkDependencies) == false { // The developer uses the combined documentation plugin flag with a DocC version that doesn't support combined documentation. Diagnostics.error(""" - Unsupported use of '\(DocumentedFlag.enableCombinedDocumentation.names.preferred)'. \ + Unsupported use of '\(DocumentedArgument.enableCombinedDocumentation.names.preferred)'. \ DocC version at '\(doccExecutableURL.path)' doesn't support combined documentation. """) return @@ -209,20 +209,26 @@ import PackagePlugin combinedArchiveOutput = URL(fileURLWithPath: context.pluginWorkDirectory.appending(combinedArchiveName).string) } - var mergeCommandArguments = ["merge"] - mergeCommandArguments.append(contentsOf: intermediateDocumentationArchives.map(\.standardizedFileURL.path)) - mergeCommandArguments.append(contentsOf: [DocCArguments.outputPath.preferred, combinedArchiveOutput.path]) + var mergeCommandArguments = CommandLineArguments( + ["merge"] + intermediateDocumentationArchives.map(\.standardizedFileURL.path) + ) + mergeCommandArguments.insertIfMissing(DocCArguments.outputPath, value: combinedArchiveOutput.path) if let doccFeatures, doccFeatures.contains(.synthesizedLandingPageName) { - mergeCommandArguments.append(contentsOf: [DocCArguments.synthesizedLandingPageName.preferred, context.package.displayName]) - mergeCommandArguments.append(contentsOf: [DocCArguments.synthesizedLandingPageKind.preferred, "Package"]) + mergeCommandArguments.insertIfMissing(DocCArguments.synthesizedLandingPageName, value: context.package.displayName) + mergeCommandArguments.insertIfMissing(DocCArguments.synthesizedLandingPageKind, value: "Package") } // Remove the combined archive if it already exists try? FileManager.default.removeItem(at: combinedArchiveOutput) + if verbose { + let arguments = mergeCommandArguments.remainingArguments.joined(separator: " ") + print("docc invocation: '\(doccExecutableURL.path) \(arguments)'") + } + // Create a new combined archive - let process = try Process.run(doccExecutableURL, arguments: mergeCommandArguments) + let process = try Process.run(doccExecutableURL, arguments: mergeCommandArguments.remainingArguments) process.waitUntilExit() print(""" diff --git a/Sources/Snippets/Model/Snippet.swift b/Sources/Snippets/Model/Snippet.swift index cd5481f..7e06bf2 100644 --- a/Sources/Snippets/Model/Snippet.swift +++ b/Sources/Snippets/Model/Snippet.swift @@ -1,6 +1,6 @@ // This source file is part of the Swift.org open source project // -// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Copyright (c) 2022-2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -12,7 +12,7 @@ import Foundation /// /// A *snippet* is a short, focused code example that can be shown with little to no context or prose. public struct Snippet { - /// The ``URL`` of the source file for this snippet. + /// The URL of the source file for this snippet. public var sourceFile: URL /// A short abstract explaining what the snippet does. @@ -39,8 +39,7 @@ public struct Snippet { /// Create a Swift snippet by parsing a file. /// - /// - parameter sourceURL: The URL of the file to parse. - /// - parameter syntax: The name of the syntax of the source file if known. + /// - Parameter sourceFile: The URL of the file to parse. public init(parsing sourceFile: URL) throws { let source = try String(contentsOf: sourceFile) self.init(parsing: source, sourceFile: sourceFile) diff --git a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArgument.swift b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArgument.swift index 524142e..f217e5f 100644 --- a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArgument.swift +++ b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArgument.swift @@ -40,21 +40,79 @@ public struct CommandLineArgument { /// An option argument with an associated value. /// /// For example: `"--some-option", "value"` or `"--some-option=value"`. - case option(value: String) + case option(value: String, kind: Option.Kind) } - /// Creates a new command line flag with the given names. - /// - Parameters: - /// - names: The names for the new command line flag. - public static func flag(_ names: Names) -> Self { - .init(names: names, kind: .flag) + // Only create arguments from flags or options (with a value) + + init(_ flag: Flag) { + names = flag.names + kind = .flag + } + + init(_ option: Option, value: String) { + names = option.names + kind = .option(value: value, kind: option.kind) + } +} + +extension CommandLineArgument { + /// A flag argument without an associated value. + /// + /// For example: `"--some-flag"`. + public struct Flag { + /// The names of this command line flag. + public var names: Names + /// The negative names for this flag, if any. + public var inverseNames: CommandLineArgument.Names? + + /// Creates a new command line flag. + /// + /// - Parameters: + /// - preferred: The preferred name for this flag. + /// - alternatives: A collection of alternative names for this flag. + /// - inverseNames: The negative names for this flag, if any. + public init(preferred: String, alternatives: Set = [], inverseNames: CommandLineArgument.Names? = nil) { + // This is duplicating the `Names` parameters to offer a nicer initializer for the common case. + names = .init(preferred: preferred, alternatives: alternatives) + self.inverseNames = inverseNames + } } - /// Creates a new command option with the given names and associated value. - /// - Parameters: - /// - names: The names for the new command line option. - /// - value: The value that's associated with this command line option. - public static func option(_ names: Names, value: String) -> Self { - .init(names: names, kind: .option(value: value)) + /// An option argument that will eventually associated with a value. + /// + /// For example: `"--some-option", "value"` or `"--some-option=value"`. + public struct Option { + /// The names of this command line option. + public var names: Names + /// The kind of value for this command line option. + public var kind: Kind + + /// A kind of value(s) that a command line option supports. + public enum Kind { + /// An option that supports a single value. + case singleValue + /// An option that supports an array of different values. + case arrayOfValues + } + + /// Creates a new command line option. + /// + /// - Parameters: + /// - preferred: The preferred name for this option. + /// - alternatives: A collection of alternative names for this option. + /// - kind: The kind of value(s) that this option supports. + public init(preferred: String, alternatives: Set = [], kind: Kind = .singleValue) { + // This is duplicating the `Names` parameters to offer a nicer initializer for the common case. + self.init( + Names(preferred: preferred, alternatives: alternatives), + kind: kind + ) + } + + init(_ names: Names, kind: Kind = .singleValue) { + self.names = names + self.kind = kind + } } } diff --git a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArguments.swift b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArguments.swift index 8463ea3..68010f6 100644 --- a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArguments.swift +++ b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/CommandLineArguments.swift @@ -35,14 +35,15 @@ public struct CommandLineArguments { // MARK: Extract - /// Extracts the values for the command line option with the given names. + /// Extracts the values for the given command line option. /// /// Upon return, the arguments list no longer contains any elements that match any spelling of this command line option or its values. /// - /// - Parameter names: The names of a command line option. + /// - Parameter option: The command line option to extract values for. /// - Returns: The extracted values for this command line option, in the order that they appear in the arguments list. - public mutating func extractOption(named names: CommandLineArgument.Names) -> [String] { + public mutating func extract(_ option: CommandLineArgument.Option) -> [String] { var values = [String]() + let names = option.names for (index, argument) in remainingOptionsOrFlags.indexed().reversed() { guard let suffix = names.suffixAfterMatchingNamesWith(argument: argument) else { @@ -50,8 +51,14 @@ public struct CommandLineArguments { } defer { remainingOptionsOrFlags.remove(at: index) } + // "--option-name=value" + if suffix.first == "=" { + + values.append(String(suffix.dropFirst(/* the equal sign */))) + } + // "--option-name", "value" - if suffix.isEmpty { + else { let indexAfter = remainingOptionsOrFlags.index(after: index) if indexAfter < remainingOptionsOrFlags.endIndex { values.append(remainingOptionsOrFlags[indexAfter]) @@ -59,28 +66,23 @@ public struct CommandLineArguments { remainingOptionsOrFlags.remove(at: indexAfter) } } - - // "--option-name=value" - else if suffix.first == "=" { - values.append(String(suffix.dropFirst(/* the equal sign */))) - } } return values.reversed() // The values are gathered in reverse order } - /// Extracts the values for the command line flag with the given names. + /// Extracts the values for the command line flag. /// /// Upon return, the arguments list no longer contains any elements that match any spelling of this command line flag. /// - /// - Parameters: - /// - positiveNames: The positive names for a command line flag. - /// - negativeNames: The negative names for this command line flag, if any. + /// - Parameter flag: The command line flag to extract values for. /// - Returns: The extracted values for this command line flag. - public mutating func extractFlag(named positiveNames: CommandLineArgument.Names, inverseNames negativeNames: CommandLineArgument.Names? = nil) -> [Bool] { - var values = [Bool]() + public mutating func extract(_ flag: CommandLineArgument.Flag) -> [Bool] { + let positiveNames = flag.names + let negativeNames = flag.inverseNames let allNamesToCheck = positiveNames.all.union(negativeNames?.all ?? []) + var values = [Bool]() for (index, argument) in remainingOptionsOrFlags.indexed().reversed() where allNamesToCheck.contains(argument) { remainingOptionsOrFlags.remove(at: index) @@ -92,12 +94,22 @@ public struct CommandLineArguments { // MARK: Insert - /// Inserts a command line argument into the arguments list unless it already exists. - /// - Parameter argument: The command line argument (flag or option) to insert. + /// Inserts a command line option into the arguments list unless it already exists. + /// - Parameters: + /// - option: The command line option to insert. + /// - value: The value for this option. + /// - Returns: `true` if the argument was already present in the arguments list; otherwise, `false`. + @discardableResult + public mutating func insertIfMissing(_ option: CommandLineArgument.Option, value: String) -> Bool { + remainingOptionsOrFlags.appendIfMissing(.init(option, value: value)) + } + + /// Inserts a command line flag into the arguments list unless it already exists. + /// - Parameter flag: The command line flag to insert. /// - Returns: `true` if the argument was already present in the arguments list; otherwise, `false`. @discardableResult - public mutating func insertIfMissing(_ argument: CommandLineArgument) -> Bool { - remainingOptionsOrFlags.appendIfMissing(argument) + public mutating func insertIfMissing(_ flag: CommandLineArgument.Flag) -> Bool { + remainingOptionsOrFlags.appendIfMissing(.init(flag)) } /// Adds a raw string argument to the start of the arguments list @@ -105,15 +117,15 @@ public struct CommandLineArguments { remainingOptionsOrFlags.insert(rawArgument, at: 0) } - /// Inserts a command line argument into the arguments list, overriding any existing values. + /// Inserts a command line option with a new value into the arguments list, overriding any existing values. /// - Parameters: - /// - argument: The command line argument (flag or option) to insert. - /// - newValue: The new value for this command line argument. + /// - option: The command line option to insert. + /// - newValue: The new value for this option. /// - Returns: `true` if the argument was already present in the arguments list; otherwise, `false`. @discardableResult - public mutating func overrideOrInsertOption(named names: CommandLineArgument.Names, newValue: String) -> Bool { - let didRemoveArguments = !extractOption(named: names).isEmpty - remainingOptionsOrFlags.append(.option(names, value: newValue)) + public mutating func overrideOrInsert(_ option: CommandLineArgument.Option, newValue: String) -> Bool { + let didRemoveArguments = !extract(option).isEmpty + remainingOptionsOrFlags.append(.init(option, value: newValue)) return didRemoveArguments } } @@ -126,7 +138,7 @@ private extension ArraySlice { /// - Returns: `true` if the argument was already present in the slice; otherwise, `false`. @discardableResult mutating func appendIfMissing(_ argument: CommandLineArgument) -> Bool { - guard !contains(argument.names) else { + guard !contains(argument) else { return true } append(argument) @@ -139,16 +151,48 @@ private extension ArraySlice { switch argument.kind { case .flag: append(argument.names.preferred) - case .option(let value): + case .option(let value, _): append(contentsOf: [argument.names.preferred, value]) } } - /// Checks if the slice contains any of the given names. - func contains(_ names: CommandLineArgument.Names) -> Bool { - contains(where: { - names.suffixAfterMatchingNamesWith(argument: $0) != nil - }) + /// Checks if the slice contains the given argument (flag or option). + func contains(_ argument: CommandLineArgument) -> Bool { + let names = argument.names + guard case .option(let value, .arrayOfValues) = argument.kind else { + // When matching flags or single-value options, it's sufficient to check if the slice contains any of the names. + // + // The slice is considered to contain the single-value option, no matter what the existing value is. + // This is used to avoid repeating an single-value option with a different value. + return contains(where: { + names.suffixAfterMatchingNamesWith(argument: $0) != nil + }) + } + + // When matching options that support arrays of values, it's necessary to check the existing option's value. + // + // The slice is only considered to contain the array-of-values option, if the new value is found. + // This is used to allow array-of-values options to insert multiple different values into the arguments. + for (argumentIndex, argument) in indexed() { + guard let suffix = names.suffixAfterMatchingNamesWith(argument: argument) else { + continue + } + + // "--option-name", "value" + if suffix.first != "=" { + let indexAfter = index(after: argumentIndex) + if indexAfter < endIndex, self[indexAfter] == value { + return true + } + } + + // "--option-name=value" + else if suffix.dropFirst(/* the equal sign */) == value { + return true + } + } + // Non of the existing options match the new value. + return false } } diff --git a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/ParsedPluginArguments.swift b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/ParsedPluginArguments.swift index 1abcbbf..962c397 100644 --- a/Sources/SwiftDocCPluginUtilities/CommandLineArguments/ParsedPluginArguments.swift +++ b/Sources/SwiftDocCPluginUtilities/CommandLineArguments/ParsedPluginArguments.swift @@ -18,13 +18,13 @@ struct ParsedPluginArguments { /// Creates a new plugin arguments container by extracting the known plugin values from a command line argument list. init(extractingFrom arguments: inout CommandLineArguments) { enableCombinedDocumentation = arguments.extractFlag(.enableCombinedDocumentation) ?? false - disableLMDBIndex = arguments.extractFlag(.disableLMDBIndex) ?? false - verbose = arguments.extractFlag(.verbose) ?? false - help = arguments.extractFlag(named: Self.help).last ?? false + disableLMDBIndex = arguments.extractFlag(.disableLMDBIndex) ?? false + verbose = arguments.extractFlag(.verbose) ?? false + help = arguments.extract(Self.help).last ?? false } /// A common command line tool flag to print the help text instead of running the command. - static let help = CommandLineArgument.Names( + static let help = CommandLineArgument.Flag( preferred: "--help", alternatives: ["-h"] ) } @@ -45,11 +45,21 @@ struct ParsedSymbolGraphArguments { } private extension CommandLineArguments { - mutating func extractFlag(_ flag: DocumentedFlag) -> Bool? { - extractFlag(named: flag.names, inverseNames: flag.inverseNames).last + mutating func extractFlag(_ flag: DocumentedArgument) -> Bool? { + guard case .flag(let flag) = flag.argument else { + assertionFailure("Unexpectedly used flag-only API with an option \(flag.argument)") + return nil + } + return extract(flag).last } - mutating func extractOption(_ flag: DocumentedFlag) -> String? { - extractOption(named: flag.names).last + mutating func extractOption(_ flag: DocumentedArgument) -> String? { + guard case .option(let option) = flag.argument else { + assertionFailure("Unexpectedly used option-only API with a flag \(flag.argument)") + return nil + } + + assert(option.kind == .singleValue, "Unexpectedly used single-value option API with an array-of-values option \(option)") + return extract(option).last } } diff --git a/Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedFlag.swift b/Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedArgument.swift similarity index 61% rename from Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedFlag.swift rename to Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedArgument.swift index 861b1c6..0d1efcc 100644 --- a/Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedFlag.swift +++ b/Sources/SwiftDocCPluginUtilities/DocumentedPluginFlags/DocumentedArgument.swift @@ -6,30 +6,56 @@ // See https://swift.org/LICENSE.txt for license information // See https://swift.org/CONTRIBUTORS.txt for Swift project authors -/// A documented command-line flag for the plugin. +/// A documented command-line argument for the plugin. /// -/// This may include some flags that the plugin forwards to the symbol graph extract tool or to DocC. -struct DocumentedFlag { +/// This may include some arguments (flags or options) that the plugin forwards to the symbol graph extract tool or to DocC. +struct DocumentedArgument { + /// A command line argument (flag or option) that is wrapped with documentation. + enum Argument { + case flag(CommandLineArgument.Flag) + case option(CommandLineArgument.Option) + } + + /// The command line argument (flag or option) that this documentation applies to. + var argument: Argument + /// The positive names for this flag - var names: CommandLineArgument.Names - /// The possible negative names for this flag, if any. - var inverseNames: CommandLineArgument.Names? + var names: CommandLineArgument.Names { + switch argument { + case .flag(let flag): + return flag.names + case .option(let option): + return option.names + } + } - /// A short user-facing description of this flag. + /// A short user-facing description of this argument (flag or option). var abstract: String - /// An expanded user-facing description of this flag. + /// An expanded user-facing description of this argument (flag or option). var discussion: String? + + init(flag: CommandLineArgument.Flag, abstract: String, discussion: String? = nil) { + self.argument = .flag(flag) + self.abstract = abstract + self.discussion = discussion + } + + init(option: CommandLineArgument.Option, abstract: String, discussion: String? = nil) { + self.argument = .option(option) + self.abstract = abstract + self.discussion = discussion + } } // MARK: Plugin flags -extension DocumentedFlag { +extension DocumentedArgument { /// A plugin feature flag to enable building combined documentation for multiple targets. /// /// - Note: This flag requires that the `docc` executable supports ``Feature/linkDependencies``. static let enableCombinedDocumentation = Self( - names: .init(preferred: "--enable-experimental-combined-documentation"), + flag: .init(preferred: "--enable-experimental-combined-documentation"), abstract: "Create a combined DocC archive with all generated documentation.", discussion: """ Experimental feature that allows targets to link to pages in their dependencies and that \ @@ -39,7 +65,7 @@ extension DocumentedFlag { /// A plugin feature flag to skip adding the `--emit-lmdb-index` flag, that the plugin adds by default. static let disableLMDBIndex = Self( - names: .init(preferred: "--disable-indexing", alternatives: ["--no-indexing"]), + flag: .init(preferred: "--disable-indexing", alternatives: ["--no-indexing"]), abstract: "Disable indexing for the produced DocC archive.", discussion: """ Produces a DocC archive that is best-suited for hosting online but incompatible with Xcode. @@ -48,7 +74,7 @@ extension DocumentedFlag { /// A plugin feature flag to enable verbose logging. static let verbose = Self( - names: .init(preferred: "--verbose"), + flag: .init(preferred: "--verbose"), abstract: "Increase verbosity to include informational output.", discussion: nil ) @@ -58,25 +84,27 @@ extension DocumentedFlag { // MARK: Symbol graph flags -extension DocumentedFlag { - /// Include or exclude extended types in documentation archives. +extension DocumentedArgument { + /// A plugin feature flag to either include or exclude extended types in documentation archives. /// /// Enables/disables the extension block symbol format when calling the dump symbol graph API. /// /// - Note: This flag is only available starting from Swift 5.8. It should be hidden from the `--help` command for lower toolchain versions. /// However, we do not hide the flag entirely, because this enables us to give a more precise warning when accidentally used with Swift 5.7 or lower. static let extendedTypes = Self( - names: .init(preferred: "--include-extended-types"), - inverseNames: .init(preferred: "--exclude-extended-types"), + flag: .init( + preferred: "--include-extended-types", + inverseNames: .init(preferred: "--exclude-extended-types") + ), abstract: "Control whether extended types from other modules are shown in the produced DocC archive. (default: --\(Self.defaultExtendedTypesValue ? "include" : "exclude")-extended-types)", discussion: "Allows documenting symbols that a target adds to its dependencies." ) - /// Exclude synthesized symbols from the generated documentation. + /// A plugin feature flag to exclude synthesized symbols from the generated documentation. /// /// `--experimental-skip-synthesized-symbols` produces a DocC archive without compiler-synthesized symbols. static let skipSynthesizedSymbols = Self( - names: .init(preferred: "--experimental-skip-synthesized-symbols"), + flag: .init(preferred: "--experimental-skip-synthesized-symbols"), abstract: "Exclude synthesized symbols from the generated documentation.", discussion: """ Experimental feature that produces a DocC archive without compiler synthesized symbols. @@ -85,7 +113,7 @@ extension DocumentedFlag { /// The minimum access level that the symbol graph extractor will emit symbols for static let minimumAccessLevel = Self( - names: .init(preferred: "--symbol-graph-minimum-access-level"), + option: .init(preferred: "--symbol-graph-minimum-access-level"), abstract: "Include symbols with this access level or more.", discussion: """ Supported access level values are: `open`, `public`, `internal`, `private`, `fileprivate` diff --git a/Sources/SwiftDocCPluginUtilities/HelpInformation.swift b/Sources/SwiftDocCPluginUtilities/HelpInformation.swift index 56c8f7b..83087c0 100644 --- a/Sources/SwiftDocCPluginUtilities/HelpInformation.swift +++ b/Sources/SwiftDocCPluginUtilities/HelpInformation.swift @@ -44,13 +44,13 @@ public enum HelpInformation { } var supportedPluginFlags = [ - DocumentedFlag.disableLMDBIndex, - DocumentedFlag.verbose, + DocumentedArgument.disableLMDBIndex, + DocumentedArgument.verbose, ] let doccFeatures = (try? DocCFeatures(doccExecutable: doccExecutableURL)) ?? .init() if doccFeatures.contains(.linkDependencies) { - supportedPluginFlags.insert(DocumentedFlag.enableCombinedDocumentation, at: 1) + supportedPluginFlags.insert(DocumentedArgument.enableCombinedDocumentation, at: 1) } for flag in supportedPluginFlags { @@ -59,14 +59,14 @@ public enum HelpInformation { helpText += "\nSYMBOL GRAPH OPTIONS:\n" var supportedSymbolGraphFlags = [ - DocumentedFlag.skipSynthesizedSymbols, - DocumentedFlag.minimumAccessLevel, + DocumentedArgument.skipSynthesizedSymbols, + DocumentedArgument.minimumAccessLevel, ] #if swift(>=5.8) - supportedSymbolGraphFlags.insert(DocumentedFlag.extendedTypes, at: 1) + supportedSymbolGraphFlags.insert(DocumentedArgument.extendedTypes, at: 1) #else // stops 'not mutated' warning for Swift 5.7 and lower - supportedPluginFlags += [] + supportedSymbolGraphFlags += [] #endif for flag in supportedSymbolGraphFlags { @@ -129,10 +129,10 @@ public enum HelpInformation { """ } -private extension DocumentedFlag { +private extension DocumentedArgument { var helpDescription: String { var flagListText = names.listForHelpDescription - if let inverseNames { + if case .flag(let flag) = argument, let inverseNames = flag.inverseNames { flagListText += " / \(inverseNames.listForHelpDescription)" } diff --git a/Sources/SwiftDocCPluginUtilities/ParsedArguments.swift b/Sources/SwiftDocCPluginUtilities/ParsedArguments.swift index 76aa488..a76e5f3 100644 --- a/Sources/SwiftDocCPluginUtilities/ParsedArguments.swift +++ b/Sources/SwiftDocCPluginUtilities/ParsedArguments.swift @@ -16,14 +16,14 @@ struct ParsedArguments { init(_ rawArguments: [String]) { var arguments = CommandLineArguments(rawArguments) - outputDirectory = arguments.extractOption(named: DocCArguments.outputPath).last.map { + outputDirectory = arguments.extract(DocCArguments.outputPath).last.map { URL(fileURLWithPath: $0, isDirectory: true).standardizedFileURL } pluginArguments = .init(extractingFrom: &arguments) symbolGraphArguments = .init(extractingFrom: &arguments) - assert(arguments.extractOption(named: DocCArguments.outputPath).isEmpty, + assert(arguments.extract(DocCArguments.outputPath).isEmpty, "There shouldn't be any output path argument left in the remaining DocC arguments.") self.arguments = arguments } @@ -103,20 +103,20 @@ struct ParsedArguments { var arguments = self.arguments if !pluginArguments.disableLMDBIndex { - arguments.insertIfMissing(.flag(DocCArguments.emitLMDBIndex)) + arguments.insertIfMissing(DocCArguments.emitLMDBIndex) } - arguments.insertIfMissing(.option(DocCArguments.fallbackDisplayName, value: targetName)) - arguments.insertIfMissing(.option(DocCArguments.fallbackBundleIdentifier, value: targetName)) + arguments.insertIfMissing(DocCArguments.fallbackDisplayName, value: targetName) + arguments.insertIfMissing(DocCArguments.fallbackBundleIdentifier, value: targetName) - arguments.insertIfMissing(.option(DocCArguments.additionalSymbolGraphDirectory, value: symbolGraphDirectoryPath)) - arguments.insertIfMissing(.option(DocCArguments.outputPath, value: outputPath)) + arguments.insertIfMissing(DocCArguments.additionalSymbolGraphDirectory, value: symbolGraphDirectoryPath) + arguments.insertIfMissing(DocCArguments.outputPath, value: outputPath) if pluginArguments.enableCombinedDocumentation { - arguments.insertIfMissing(.flag(DocCArguments.enableExternalLinkSupport)) + arguments.insertIfMissing(DocCArguments.enableExternalLinkSupport) for dependencyArchivePath in dependencyArchivePaths { - arguments.insertIfMissing(.option(DocCArguments.externalLinkDependency, value: dependencyArchivePath)) + arguments.insertIfMissing(DocCArguments.externalLinkDependency, value: dependencyArchivePath) } } @@ -124,7 +124,7 @@ struct ParsedArguments { case .library: break case .executable: - arguments.insertIfMissing(.option(DocCArguments.fallbackDefaultModuleKind, value: "Command-line Tool")) + arguments.insertIfMissing(DocCArguments.fallbackDefaultModuleKind, value: "Command-line Tool") } // If we were given a catalog path, prepend it to the set of arguments. @@ -139,72 +139,73 @@ struct ParsedArguments { enum DocCArguments { /// A fallback value for the bundle display name, if the documentation catalog doesn't specify one or if the build has no symbol information. /// - /// The plugin defines this name so that it can pass a default value for older versions of `docc` which require this. - static let fallbackDisplayName = CommandLineArgument.Names( + /// The plugin defines this option so that it can pass a default value for older versions of `docc` which require this. + static let fallbackDisplayName = CommandLineArgument.Option( preferred: "--fallback-display-name" ) /// A fallback value for the bundle identifier, if the documentation catalog doesn't specify one or if the build has no symbol information. /// - /// The plugin defines this name so that it can pass a default value for older versions of `docc` which require this. - static let fallbackBundleIdentifier = CommandLineArgument.Names( + /// The plugin defines this option so that it can pass a default value for older versions of `docc` which require this. + static let fallbackBundleIdentifier = CommandLineArgument.Option( preferred: "--fallback-bundle-identifier" ) /// A fallback value for the "module kind" display name, if the documentation catalog doesn't specify one. /// - /// The plugin defines this name so that it can pass a default value when building documentation for executable targets. - static let fallbackDefaultModuleKind = CommandLineArgument.Names( + /// The plugin defines this option so that it can pass a default value when building documentation for executable targets. + static let fallbackDefaultModuleKind = CommandLineArgument.Option( preferred: "--fallback-default-module-kind" ) /// A directory of symbol graph files that DocC will use as input when building documentation. /// - /// The plugin defines this name so that it can pass a default value. - static let additionalSymbolGraphDirectory = CommandLineArgument.Names( + /// The plugin defines this option so that it can pass a default value. + static let additionalSymbolGraphDirectory = CommandLineArgument.Option( preferred: "--additional-symbol-graph-dir" ) /// Configures DocC to include a LMDB representation of the navigator index in the output. /// - /// The plugin defines this name so that it can pass this flag by default. - static let emitLMDBIndex = CommandLineArgument.Names( + /// The plugin defines this flag so that it can pass this flag by default. + static let emitLMDBIndex = CommandLineArgument.Flag( preferred: "--emit-lmdb-index" ) /// The directory where DocC will write the built documentation archive. /// - /// The plugin defines this name so that it can intercept it and support building documentation for multiple targets within one package build command. - static let outputPath = CommandLineArgument.Names( + /// The plugin defines this option so that it can intercept it and support building documentation for multiple targets within one package build command. + static let outputPath = CommandLineArgument.Option( preferred: "--output-path", alternatives: ["--output-dir", "-o"] ) /// A DocC feature flag to enable support for linking to documentation dependencies. /// - /// The plugin defines this name so that it can specify documentation dependencies based on target dependencies when building combined documentation for multiple targets. - static let enableExternalLinkSupport = CommandLineArgument.Names( + /// The plugin defines this flag so that it can specify documentation dependencies based on target dependencies when building combined documentation for multiple targets. + static let enableExternalLinkSupport = CommandLineArgument.Flag( preferred: "--enable-experimental-external-link-support" ) /// A DocC flag that specifies a dependency DocC archive that the current build can link to. /// - /// The plugin defines this name so that it can specify documentation dependencies based on target dependencies when building combined documentation for multiple targets. - static let externalLinkDependency = CommandLineArgument.Names( - preferred: "--dependency" + /// The plugin defines this option so that it can specify documentation dependencies based on target dependencies when building combined documentation for multiple targets. + static let externalLinkDependency = CommandLineArgument.Option( + preferred: "--dependency", + kind: .arrayOfValues ) /// A DocC flag for the "merge" command that specifies a custom display name for the synthesized landing page. /// - /// The plugin defines this name so that it can specify the package name as the display name of the default landing page when building combined documentation for multiple targets. - static let synthesizedLandingPageName = CommandLineArgument.Names( + /// The plugin defines this option so that it can specify the package name as the display name of the default landing page when building combined documentation for multiple targets. + static let synthesizedLandingPageName = CommandLineArgument.Option( preferred: "--synthesized-landing-page-name" ) /// A DocC flag for the "merge" command that specifies a custom kind for the synthesized landing page. /// - /// The plugin defines this name so that it can specify "Package" as the kind of the default landing page when building combined documentation for multiple targets. - static let synthesizedLandingPageKind = CommandLineArgument.Names( + /// The plugin defines this option so that it can specify "Package" as the kind of the default landing page when building combined documentation for multiple targets. + static let synthesizedLandingPageKind = CommandLineArgument.Option( preferred: "--synthesized-landing-page-kind" ) } diff --git a/Sources/snippet-extract/Utility/SymbolGraph+Snippet.swift b/Sources/snippet-extract/Utility/SymbolGraph+Snippet.swift index e122d79..34b2ae8 100644 --- a/Sources/snippet-extract/Utility/SymbolGraph+Snippet.swift +++ b/Sources/snippet-extract/Utility/SymbolGraph+Snippet.swift @@ -1,6 +1,6 @@ // This source file is part of the Swift.org open source project // -// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Copyright (c) 2022-2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -11,9 +11,11 @@ import Snippets import struct SymbolKit.SymbolGraph extension SymbolGraph.Symbol { - /// Create a ``SymbolGraph.Symbol`` from a ``Snippet``. + /// Create a symbol for a given snippet. /// - /// - parameter moduleName: The name to use for the package name in the snippet symbol's precise identifier. + /// - Parameters: + /// - snippet: The snippet to create a symbol for. + /// - moduleName: The name to use for the package name in the snippet symbol's precise identifier. public init(_ snippet: Snippets.Snippet, moduleName: String) throws { let basename = snippet.sourceFile.deletingPathExtension().lastPathComponent let identifier = SymbolGraph.Symbol.Identifier(precise: "$snippet__\(moduleName).\(basename)", interfaceLanguage: "swift") diff --git a/Sources/snippet-extract/Utility/URL+Status.swift b/Sources/snippet-extract/Utility/URL+Status.swift index 883db24..aed81b2 100644 --- a/Sources/snippet-extract/Utility/URL+Status.swift +++ b/Sources/snippet-extract/Utility/URL+Status.swift @@ -1,6 +1,6 @@ // This source file is part of the Swift.org open source project // -// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Copyright (c) 2022-2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -16,7 +16,7 @@ extension URL { var isDirectory: Bool { var isADirectory: ObjCBool = false - FileManager.default.fileExists(atPath: self.path, isDirectory: &isADirectory) - return isADirectory.boolValue + return FileManager.default.fileExists(atPath: self.path, isDirectory: &isADirectory) + && isADirectory.boolValue } } diff --git a/Tests/SwiftDocCPluginUtilitiesTests/CommandLineArgumentsTests.swift b/Tests/SwiftDocCPluginUtilitiesTests/CommandLineArgumentsTests.swift index 2b98f24..b5d5048 100644 --- a/Tests/SwiftDocCPluginUtilitiesTests/CommandLineArgumentsTests.swift +++ b/Tests/SwiftDocCPluginUtilitiesTests/CommandLineArgumentsTests.swift @@ -11,6 +11,9 @@ import SwiftDocCPluginUtilities import XCTest final class CommandLineArgumentsTests: XCTestCase { + typealias Flag = CommandLineArgument.Flag + typealias Option = CommandLineArgument.Option + func testExtractingRawFlagsAndOptions() { var arguments = CommandLineArguments(["--some-flag", "--this-option", "this-value", "--another-option", "another-value", "--", "--some-literal-value"]) @@ -35,35 +38,111 @@ final class CommandLineArgumentsTests: XCTestCase { // Insert - XCTAssertTrue(arguments.insertIfMissing(.flag(.init(preferred: "--some-flag"))), "Already contains '--some-flag'") + XCTAssertTrue(arguments.insertIfMissing(Flag(preferred: "--some-flag")), "Already contains '--some-flag'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--this-option", "this-value", "--", "--some-literal-value"]) - XCTAssertFalse(arguments.insertIfMissing(.flag(.init(preferred: "--other-flag"))), "Didn't previously contain '--other-flag'") + XCTAssertFalse(arguments.insertIfMissing(Flag(preferred: "--other-flag")), "Didn't previously contain '--other-flag'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--this-option", "this-value", "--other-flag", "--", "--some-literal-value"]) - XCTAssertTrue(arguments.insertIfMissing(.option(.init(preferred: "--this-option"), value: "new-value")), "Already contains '--this-option'") + XCTAssertTrue(arguments.insertIfMissing(Option(preferred: "--this-option"), value: "new-value"), "Already contains '--this-option'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--this-option", "this-value", "--other-flag", "--", "--some-literal-value"]) - XCTAssertFalse(arguments.insertIfMissing(.option(.init(preferred: "--another-option"), value: "another-value")), "Didn't previously contain '--another-option'") + XCTAssertFalse(arguments.insertIfMissing(Option(preferred: "--another-option"), value: "another-value"), "Didn't previously contain '--another-option'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--this-option", "this-value", "--other-flag", "--another-option", "another-value", "--", "--some-literal-value"]) // Override - XCTAssertTrue(arguments.overrideOrInsertOption(named: .init(preferred: "--this-option"), newValue: "new-value"), "Already contains '--this-option'") + XCTAssertTrue(arguments.overrideOrInsert(.init(preferred: "--this-option"), newValue: "new-value"), "Already contains '--this-option'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--other-flag", "--another-option", "another-value", "--this-option", "new-value", "--", "--some-literal-value"]) - XCTAssertFalse(arguments.overrideOrInsertOption(named: .init(preferred: "--yet-another-option"), newValue: "another-new-value"), "Didn't previously contain '--yet-another-option'") + XCTAssertFalse(arguments.overrideOrInsert(.init(preferred: "--yet-another-option"), newValue: "another-new-value"), "Didn't previously contain '--yet-another-option'") XCTAssertEqual(arguments.remainingArguments, ["--some-flag", "--other-flag", "--another-option", "another-value", "--this-option", "new-value", "--yet-another-option", "another-new-value", "--", "--some-literal-value"]) } + func testInsertSameSingleValueOptionWithDifferentValues() { + let optionName = "--some-option" + let optionNameAlternate = "--some-option-alternate" + let option = CommandLineArgument.Option( + preferred: optionName, + alternatives: [optionNameAlternate], + kind: .singleValue // the default + ) + + for initialArguments in [ + ["\(optionName)=first"], + [optionNameAlternate, "second"], + ] { + var arguments = CommandLineArguments(initialArguments) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "first"), "The arguments already contains some other value for this option") + XCTAssertEqual(arguments.remainingArguments, initialArguments) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "second"), "The arguments already contains some other value for this option") + XCTAssertEqual(arguments.remainingArguments, initialArguments) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "third"), "The arguments already contains some other value for this option") + XCTAssertEqual(arguments.remainingArguments, initialArguments) + } + } + + func testInsertSameArrayOfValuesOptionWithDifferentValues() { + let optionName = "--some-option" + let optionNameAlternate = "--some-option-alternate" + let option = CommandLineArgument.Option( + preferred: optionName, + alternatives: [optionNameAlternate], + kind: .arrayOfValues + ) + + var arguments = CommandLineArguments([ + "\(optionName)=first", + optionNameAlternate, "second", + ]) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "first"), "The arguments already contains this value (using the preferred option spelling)") + XCTAssertEqual(arguments.remainingArguments, [ + "\(optionName)=first", + optionNameAlternate, "second", + ]) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "second"), "The arguments already contains this value (using the alternate option spelling)") + XCTAssertEqual(arguments.remainingArguments, [ + "\(optionName)=first", + optionNameAlternate, "second", + ]) + + XCTAssertFalse(arguments.insertIfMissing(option, value: "third"), "This value is new (doesn't exist among the arguments yet)") + XCTAssertEqual(arguments.remainingArguments, [ + "\(optionName)=first", + optionNameAlternate, "second", + optionName, "third", + ]) + + XCTAssertFalse(arguments.insertIfMissing(option, value: "fourth"), "Third value us also new ... (doesn't exist among the arguments yet)") + XCTAssertEqual(arguments.remainingArguments, [ + "\(optionName)=first", + optionNameAlternate, "second", + optionName, "third", + optionName, "fourth", + ]) + + XCTAssertTrue(arguments.insertIfMissing(option, value: "fourth"), "... but now it exists (after inserting it above)") + XCTAssertEqual(arguments.remainingArguments, [ + "\(optionName)=first", + optionNameAlternate, "second", + optionName, "third", + optionName, "fourth", + ]) + } + func testExtractDifferentArgumentSpellings() { // Options do { var arguments = CommandLineArguments(["--spelling-one", "value-one", "--spelling-two=value-two", "-s", "value-three", "-s=value-four", "--spelling-one", "value-five", "--", "--spelling-one", "value-six"]) - let extractedValues = arguments.extractOption(named: - .init(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]) - ) + let extractedValues = arguments.extract(Option( + preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"] + )) XCTAssertEqual(extractedValues, ["value-one", "value-two", "value-three", "value-four", "value-five"]) XCTAssertEqual(arguments.remainingArguments, ["--", "--spelling-one", "value-six"]) } @@ -72,9 +151,9 @@ final class CommandLineArgumentsTests: XCTestCase { do { var arguments = CommandLineArguments(["--spelling-one", "--spelling-two", "-s", "--spelling-one", "--", "--spelling-one"]) - let extractedValues = arguments.extractFlag(named: - .init(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]) - ) + let extractedValues = arguments.extract(Flag( + preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"] + )) XCTAssertEqual(extractedValues, [true, true, true, true]) XCTAssertEqual(arguments.remainingArguments, ["--", "--spelling-one"]) } @@ -83,10 +162,10 @@ final class CommandLineArgumentsTests: XCTestCase { do { var arguments = CommandLineArguments(["--spelling-one", "--spelling-two", "--negative-spelling-one", "--negative-spelling-two", "-s", "--spelling-one", "-ns", "--", "--spelling-one", "--negative-spelling-two"]) - let extractedValues = arguments.extractFlag( - named: .init(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]), + let extractedValues = arguments.extract(Flag( + preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"], inverseNames: .init(preferred: "--negative-spelling-one", alternatives: ["--negative-spelling-two", "-ns"]) - ) + )) XCTAssertEqual(extractedValues, [true, true, false, false, true, true, false]) XCTAssertEqual(arguments.remainingArguments, ["--", "--spelling-one", "--negative-spelling-two"]) } @@ -98,11 +177,11 @@ final class CommandLineArgumentsTests: XCTestCase { var arguments = CommandLineArguments(existing + ["--other-flag"]) let original = arguments.remainingArguments - let option = CommandLineArgument.option(.init(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]), value: "new-value") - XCTAssertTrue(arguments.insertIfMissing(option)) + let option = CommandLineArgument.Option(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]) + XCTAssertTrue(arguments.insertIfMissing(option, value: "new-value")) XCTAssertEqual(arguments.remainingArguments, original) - XCTAssertTrue(arguments.overrideOrInsertOption(named: option.names, newValue: "new-value")) + XCTAssertTrue(arguments.overrideOrInsert(option, newValue: "new-value")) XCTAssertEqual(arguments.remainingArguments, ["--other-flag", "--spelling-one", "new-value"]) } @@ -111,20 +190,20 @@ final class CommandLineArgumentsTests: XCTestCase { var arguments = CommandLineArguments([existing, "--other-flag"]) let original = arguments.remainingArguments - XCTAssertTrue(arguments.insertIfMissing(.flag(.init(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"])))) + XCTAssertTrue(arguments.insertIfMissing(Flag(preferred: "--spelling-one", alternatives: ["--spelling-two", "-s"]))) XCTAssertEqual(arguments.remainingArguments, original) } } } -extension CommandLineArguments { +private extension CommandLineArguments { // MARK: Extract raw mutating func extractOption(rawName: String) -> [String] { - extractOption(named: .init(preferred: rawName)) + extract(CommandLineArgument.Option(preferred: rawName)) } mutating func extractFlag(rawName: String) -> [Bool] { - extractFlag(named: .init(preferred: rawName)) + extract(CommandLineArgument.Flag(preferred: rawName)) } }