diff --git a/Schemas/tool-info-v1.json b/Schemas/tool-info-v1.json new file mode 100644 index 000000000..b5f26341c --- /dev/null +++ b/Schemas/tool-info-v1.json @@ -0,0 +1,266 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ToolInfo V1 Schema", + "description": "JSON schema for Swift Argument Parser ToolInfo V1 structure", + "type": "object", + "required": ["serializationVersion", "command"], + "properties": { + "serializationVersion": { + "type": "integer", + "const": 1, + "description": "A sentinel value indicating the version of the ToolInfo struct used to generate the serialized form" + }, + "command": { + "$ref": "#/definitions/CommandInfo" + } + }, + "definitions": { + "CommandInfo": { + "type": "object", + "required": ["commandName"], + "properties": { + "superCommands": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Super commands and tools" + }, + "shouldDisplay": { + "type": "boolean", + "default": true, + "description": "Command should appear in help displays" + }, + "commandName": { + "type": "string", + "description": "Name used to invoke the command" + }, + "abstract": { + "type": "string", + "description": "Short description of the command's functionality" + }, + "discussion": { + "type": "string", + "description": "Extended description of the command's functionality" + }, + "defaultSubcommand": { + "type": "string", + "description": "Optional name of the subcommand invoked when the command is invoked with no arguments" + }, + "subcommands": { + "type": "array", + "items": { + "$ref": "#/definitions/CommandInfo" + }, + "description": "List of nested commands" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/definitions/ArgumentInfo" + }, + "description": "List of supported arguments" + } + } + }, + "ArgumentInfo": { + "type": "object", + "required": ["kind", "shouldDisplay", "isOptional", "isRepeating", "parsingStrategy"], + "properties": { + "kind": { + "$ref": "#/definitions/ArgumentKind" + }, + "shouldDisplay": { + "type": "boolean", + "description": "Argument should appear in help displays" + }, + "sectionTitle": { + "type": "string", + "description": "Custom name of argument's section" + }, + "isOptional": { + "type": "boolean", + "description": "Argument can be omitted" + }, + "isRepeating": { + "type": "boolean", + "description": "Argument can be specified multiple times" + }, + "parsingStrategy": { + "$ref": "#/definitions/ParsingStrategy" + }, + "names": { + "type": "array", + "items": { + "$ref": "#/definitions/NameInfo" + }, + "description": "All names of the argument" + }, + "preferredName": { + "$ref": "#/definitions/NameInfo", + "description": "The best name to use when referring to the argument in help displays" + }, + "valueName": { + "type": "string", + "description": "Name of argument's value" + }, + "defaultValue": { + "type": "string", + "description": "Default value of the argument is none is specified on the command line" + }, + "allValues": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of all valid values" + }, + "allValueDescriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Mapping of valid values to descriptions of the value" + }, + "completionKind": { + "$ref": "#/definitions/CompletionKind", + "description": "The type of completion to use for an argument or an option value" + }, + "abstract": { + "type": "string", + "description": "Short description of the argument's functionality" + }, + "discussion": { + "type": "string", + "description": "Extended description of the argument's functionality" + } + } + }, + "NameInfo": { + "type": "object", + "required": ["kind", "name"], + "properties": { + "kind": { + "$ref": "#/definitions/NameInfoKind" + }, + "name": { + "type": "string", + "description": "Single or multi-character name of the argument" + } + } + }, + "NameInfoKind": { + "type": "string", + "enum": ["long", "short", "longWithSingleDash"], + "description": "Kind of prefix of an argument's name" + }, + "ArgumentKind": { + "type": "string", + "enum": ["positional", "option", "flag"], + "description": "Kind of argument" + }, + "ParsingStrategy": { + "type": "string", + "enum": ["default", "scanningForValue", "unconditional", "upToNextOption", "allRemainingInput", "postTerminator", "allUnrecognized"], + "description": "Parsing strategy of the ArgumentInfo" + }, + "CompletionKind": { + "oneOf": [ + { + "type": "object", + "required": ["list"], + "properties": { + "list": { + "type": "object", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "description": "Use the specified list of completion strings" + }, + { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "object", + "required": ["extensions"], + "properties": { + "extensions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "description": "Complete file names with the specified extensions" + }, + { + "type": "object", + "required": ["directory"], + "properties": { + "directory": { + "type": "object" + } + }, + "description": "Complete directory names that match the specified pattern" + }, + { + "type": "object", + "required": ["shellCommand"], + "properties": { + "shellCommand": { + "type": "object", + "required": ["command"], + "properties": { + "command": { + "type": "string" + } + } + } + }, + "description": "Call the given shell command to generate completions" + }, + { + "type": "object", + "required": ["custom"], + "properties": { + "custom": { + "type": "object" + } + }, + "description": "Generate completions using the given three-parameter closure" + }, + { + "type": "object", + "required": ["customAsync"], + "properties": { + "customAsync": { + "type": "object" + } + }, + "description": "Generate completions using the given async three-parameter closure" + }, + { + "type": "object", + "required": ["customDeprecated"], + "properties": { + "customDeprecated": { + "type": "object" + } + }, + "description": "Generate completions using the given one-parameter closure (deprecated)" + } + ] + } + } +} \ No newline at end of file diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index 77dc92e54..05583b88e 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -35,7 +35,8 @@ add_library(ArgumentParser Parsing/ParserError.swift Parsing/SplitArguments.swift - Usage/DumpHelpGenerator.swift + Usage/DumpHelpGeneratorV0.swift + Usage/DumpHelpGeneratorV1.swift Usage/HelpCommand.swift Usage/HelpGenerator.swift Usage/MessageInfo.swift diff --git a/Sources/ArgumentParser/Parsable Properties/Errors.swift b/Sources/ArgumentParser/Parsable Properties/Errors.swift index 86c62c64f..101b091c9 100644 --- a/Sources/ArgumentParser/Parsable Properties/Errors.swift +++ b/Sources/ArgumentParser/Parsable Properties/Errors.swift @@ -71,7 +71,7 @@ public struct CleanExit: Error, CustomStringConvertible { internal enum Representation { case helpRequest(ParsableCommand.Type? = nil) case message(String) - case dumpRequest(ParsableCommand.Type? = nil) + case dumpRequest(ParsableCommand.Type? = nil, DumpHelpVersion) } internal var base: Representation @@ -113,7 +113,7 @@ public struct CleanExit: Error, CustomStringConvertible { switch self.base { case .helpRequest: return "--help" case .message(let message): return message - case .dumpRequest: return "--experimental-dump-help" + case .dumpRequest(_, let version): return "--\(version.flagName)" } } } diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index a93308b31..547721b3e 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -162,9 +162,15 @@ extension ParsableArguments { .rendered(screenWidth: columns) } - /// Returns the JSON representation of this type. + /// Returns the JSON representation of this type using the tool-info v0 (experimental-dump-help) schema. + @available(*, deprecated, renamed: "_dumpToolInfo(version:)") public static func _dumpHelp() -> String { - DumpHelpGenerator(self).rendered() + DumpHelpVersion.v0.render(self) + } + + /// Returns the JSON representation of the tool-info of this type using the provided version of the schema. + public static func _dumpToolInfo(version: DumpHelpVersion) -> String { + version.render(self) } /// Returns the exit code for the given error. diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index b3296d675..f996399a9 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -129,10 +129,12 @@ extension CommandParser { throw HelpRequested(visibility: .hidden) } - // Look for dump-help flag - guard !split.contains(Name.long("experimental-dump-help")) else { - throw CommandError( - commandStack: commandStack, parserError: .dumpHelpRequested) + // Look for dump-help flags + for version in DumpHelpVersion.allCases { + guard !split.contains(Name.long(version.flagName)) else { + throw CommandError( + commandStack: commandStack, parserError: .dumpHelpRequested(version)) + } } // Look for a version flag if any commands in the stack define a version diff --git a/Sources/ArgumentParser/Parsing/ParserError.swift b/Sources/ArgumentParser/Parsing/ParserError.swift index dd0c0e1b2..a7d0840d4 100644 --- a/Sources/ArgumentParser/Parsing/ParserError.swift +++ b/Sources/ArgumentParser/Parsing/ParserError.swift @@ -9,11 +9,47 @@ // //===----------------------------------------------------------------------===// +/// Represents supported OpenCLI schema versions. +public enum DumpHelpVersion: String, CaseIterable, Sendable { + // swift-format-ignore: AlwaysUseLowerCamelCase + case v0 = "v0" + + // swift-format-ignore: AlwaysUseLowerCamelCase + case v1 = "v1" + + public var flagName: String { + switch self { + case .v0: + "experimental-dump-help" + default: + "help-dump-tool-info-\(self.rawValue)" + } + } + + public func render(commandStack: [ParsableCommand.Type]) -> String { + switch self { + case .v0: + DumpHelpGeneratorV0(commandStack: commandStack).rendered() + case .v1: + DumpHelpGeneratorV1(commandStack: commandStack).rendered() + } + } + + public func render(_ type: any ParsableArguments.Type) -> String { + switch self { + case .v0: + DumpHelpGeneratorV0(type).rendered() + case .v1: + DumpHelpGeneratorV1(type).rendered() + } + } +} + /// Gets thrown while parsing and will be handled by the error output generation. enum ParserError: Error { case helpRequested(visibility: ArgumentVisibility) case versionRequested - case dumpHelpRequested + case dumpHelpRequested(DumpHelpVersion) case completionScriptRequested(shell: String?) case completionScriptCustomResponse(String) diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGeneratorV0.swift similarity index 99% rename from Sources/ArgumentParser/Usage/DumpHelpGenerator.swift rename to Sources/ArgumentParser/Usage/DumpHelpGeneratorV0.swift index 3ed2ac260..1fecbc6a4 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGeneratorV0.swift @@ -15,7 +15,7 @@ internal import ArgumentParserToolInfo import ArgumentParserToolInfo #endif -internal struct DumpHelpGenerator { +internal struct DumpHelpGeneratorV0 { private var toolInfo: ToolInfoV0 init(_ type: ParsableArguments.Type) { diff --git a/Sources/ArgumentParser/Usage/DumpHelpGeneratorV1.swift b/Sources/ArgumentParser/Usage/DumpHelpGeneratorV1.swift new file mode 100644 index 000000000..2c53b231f --- /dev/null +++ b/Sources/ArgumentParser/Usage/DumpHelpGeneratorV1.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +#if compiler(>=6.0) +internal import ArgumentParserToolInfo +#else +import ArgumentParserToolInfo +#endif + +internal struct DumpHelpGeneratorV1 { + private var toolInfo: ToolInfoV1 + + init(_ type: ParsableArguments.Type) { + self.init(commandStack: [type.asCommand]) + } + + init(commandStack: [ParsableCommand.Type]) { + self.toolInfo = ToolInfoV1(commandStack: commandStack) + } + + func rendered() -> String { + JSONEncoder.encode(self.toolInfo) + } +} + +extension BidirectionalCollection where Element == ParsableCommand.Type { + /// Returns the ArgumentSet for the last command in this stack, including + /// help and version flags, when appropriate. + fileprivate func allArguments() -> ArgumentSet { + guard + var arguments = self.last.map({ + ArgumentSet($0, visibility: .private, parent: nil) + }) + else { return ArgumentSet() } + self.versionArgumentDefinition().map { arguments.append($0) } + self.helpArgumentDefinition().map { arguments.append($0) } + return arguments + } +} + +extension ToolInfoV1 { + init(commandStack: [ParsableCommand.Type]) { + self.init(command: CommandInfo(commandStack: commandStack)) + // FIXME: This is a hack to inject the help command into the tool info + // instead we should try to lift this into the parseable command tree + self.command.subcommands = + (self.command.subcommands ?? []) + [ + CommandInfo(commandStack: commandStack + [HelpCommand.self]) + ] + } +} + +extension ToolInfoV1.CommandInfo { + fileprivate init(commandStack: [ParsableCommand.Type]) { + guard let command = commandStack.last else { + preconditionFailure("commandStack must not be empty") + } + + let parents = commandStack.dropLast() + var superCommands = parents.map { $0._commandName } + if let superName = parents.first?.configuration._superCommandName { + superCommands.insert(superName, at: 0) + } + + let defaultSubcommand = command.configuration.defaultSubcommand? + .configuration.commandName + let subcommands = command.configuration.subcommands + .map { subcommand -> ToolInfoV1.CommandInfo in + var commandStack = commandStack + commandStack.append(subcommand) + return ToolInfoV1.CommandInfo(commandStack: commandStack) + } + let arguments = + commandStack + .allArguments() + .compactMap(ToolInfoV1.ArgumentInfo.init) + + self = ToolInfoV1.CommandInfo( + superCommands: superCommands, + shouldDisplay: command.configuration.shouldDisplay, + commandName: command._commandName, + abstract: command.configuration.abstract, + discussion: command.configuration.discussion, + defaultSubcommand: defaultSubcommand, + subcommands: subcommands, + arguments: arguments) + } +} + +extension ToolInfoV1.ArgumentInfo { + fileprivate init?(argument: ArgumentDefinition) { + guard let kind = ToolInfoV1.ArgumentInfo.Kind(argument: argument) else { + return nil + } + + let discussion: String? + let allValueDescriptions: [String: String]? + switch argument.help.discussion { + case .none: + discussion = nil + allValueDescriptions = nil + case .staticText(let _discussion): + discussion = _discussion + allValueDescriptions = nil + case .enumerated(let _discussion, let options): + discussion = _discussion + allValueDescriptions = options.allValueDescriptions + } + + self.init( + kind: kind, + shouldDisplay: argument.help.visibility.base == .default, + sectionTitle: argument.help.parentTitle.nonEmpty, + isOptional: argument.help.options.contains(.isOptional), + isRepeating: argument.help.options.contains(.isRepeating), + parsingStrategy: ToolInfoV1.ArgumentInfo.ParsingStrategy( + argument: argument), + names: argument.names.map(ToolInfoV1.ArgumentInfo.NameInfo.init), + preferredName: argument.names.preferredName.map( + ToolInfoV1.ArgumentInfo.NameInfo.init), + valueName: argument.valueName, + defaultValue: argument.help.defaultValue, + allValueStrings: argument.help.allValueStrings, + allValueDescriptions: allValueDescriptions, + completionKind: ToolInfoV1.ArgumentInfo.CompletionKind( + completion: argument.completion), + abstract: argument.help.abstract, + discussion: discussion) + } +} + +extension ToolInfoV1.ArgumentInfo.Kind { + fileprivate init?(argument: ArgumentDefinition) { + switch argument.kind { + case .named: + switch argument.update { + case .nullary: + self = .flag + case .unary: + self = .option + } + case .positional: + self = .positional + case .default: + return nil + } + } +} + +extension ToolInfoV1.ArgumentInfo.ParsingStrategy { + fileprivate init(argument: ArgumentDefinition) { + switch argument.parsingStrategy { + case .`default`: + self = .default + case .scanningForValue: + self = .scanningForValue + case .unconditional: + self = .unconditional + case .upToNextOption: + self = .upToNextOption + case .allRemainingInput: + self = .allRemainingInput + case .postTerminator: + self = .postTerminator + case .allUnrecognized: + self = .allUnrecognized + } + } +} + +extension ToolInfoV1.ArgumentInfo.NameInfo { + fileprivate init(name: Name) { + switch name { + case .long(let n): + self.init(kind: .long, name: n) + case .short(let n, _): + self.init(kind: .short, name: String(n)) + case .longWithSingleDash(let n): + self.init(kind: .longWithSingleDash, name: n) + } + } +} + +extension ToolInfoV1.ArgumentInfo.CompletionKind { + fileprivate init?(completion: CompletionKind) { + switch completion.kind { + case .`default`: + return nil + case .list(let values): + self = .list(values: values) + case .file(let extensions): + self = .file(extensions: extensions) + case .directory: + self = .directory + case .shellCommand(let command): + self = .shellCommand(command: command) + case .custom(_): + self = .custom + case .customAsync(_): + self = .customAsync + case .customDeprecated(_): + self = .customDeprecated + } + } +} diff --git a/Sources/ArgumentParser/Usage/MessageInfo.swift b/Sources/ArgumentParser/Usage/MessageInfo.swift index 8bf4528c1..af7470a27 100644 --- a/Sources/ArgumentParser/Usage/MessageInfo.swift +++ b/Sources/ArgumentParser/Usage/MessageInfo.swift @@ -32,9 +32,8 @@ enum MessageInfo { ).rendered(screenWidth: columns)) return - case .dumpHelpRequested: - self = .help( - text: DumpHelpGenerator(commandStack: e.commandStack).rendered()) + case .dumpHelpRequested(let version): + self = .help(text: version.render(commandStack: e.commandStack)) return case .versionRequested: @@ -110,13 +109,13 @@ enum MessageInfo { text: HelpGenerator( commandStack: commandStack, visibility: .default ).rendered(screenWidth: columns)) - case .dumpRequest(let command): + case .dumpRequest(let command, let version): if let command = command { commandStack = CommandParser(type.asCommand).commandStack( for: command) } - self = .help( - text: DumpHelpGenerator(commandStack: commandStack).rendered()) + + self = .help(text: version.render(commandStack: commandStack)) case .message(let message): self = .help(text: message) } diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers.swift b/Sources/ArgumentParserTestHelpers/TestHelpers.swift index bb8a65752..7c5b9478c 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers.swift @@ -580,45 +580,52 @@ extension XCTest { file: StaticString = #filePath, line: UInt = #line ) throws { - let actual: String - do { - _ = try T.parse(["--experimental-dump-help"]) - XCTFail(file: file, line: line) - return - } catch { - actual = T.fullMessage(for: error) - } + for version in DumpHelpVersion.allCases { + let actual: String + do { + _ = try T.parse(["--\(version.flagName)"]) + XCTFail(file: file, line: line) + return + } catch { + actual = T.fullMessage(for: error) + } - let apiOutput = T._dumpHelp() - AssertEqualStrings(actual: actual, expected: apiOutput) + let apiOutput = T._dumpToolInfo(version: version) + AssertEqualStrings(actual: actual, expected: apiOutput) - let expected = try self.assertSnapshot( - actual: actual, - extension: "json", - record: record, - test: test, - file: file, - line: line) + let adjustedActual = actual.replacingOccurrences( + of: "\"serializationVersion\" : 0", with: "\"serializationVersion\" : 1" + ) + + let expected = try self.assertSnapshot( + actual: adjustedActual, + extension: "json", + record: record, + test: test, + file: file, + line: line) - guard let expected else { return } + guard let expected else { return } - try AssertJSONEqualFromString( - actual: actual, - expected: expected, - for: ToolInfoV0.self, - file: file, - line: line) + try AssertJSONEqualFromString( + actual: adjustedActual, + expected: expected, + for: ToolInfoV1.self, + file: file, + line: line) + } } public func assertDumpHelp( command: String, + version: DumpHelpVersion = .v1, record: Bool = false, test: StaticString = #function, file: StaticString = #filePath, line: UInt = #line ) throws { let actual = try AssertExecuteCommand( - command: command + " --experimental-dump-help", + command: command + " --\(version.flagName)", expected: nil, file: file, line: line) diff --git a/Sources/ArgumentParserToolInfo/CMakeLists.txt b/Sources/ArgumentParserToolInfo/CMakeLists.txt index b82adb71d..b6cc9ab3c 100644 --- a/Sources/ArgumentParserToolInfo/CMakeLists.txt +++ b/Sources/ArgumentParserToolInfo/CMakeLists.txt @@ -1,5 +1,5 @@ add_library(ArgumentParserToolInfo STATIC - ToolInfo.swift) + ToolInfoV0.swift ToolInfoV1.swift) # NOTE: workaround for CMake not setting up include flags yet set_target_properties(ArgumentParserToolInfo PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/ArgumentParserToolInfo/ToolInfo.swift b/Sources/ArgumentParserToolInfo/ToolInfoV0.swift similarity index 90% rename from Sources/ArgumentParserToolInfo/ToolInfo.swift rename to Sources/ArgumentParserToolInfo/ToolInfoV0.swift index d4b8efaf9..3c2d5e7b8 100644 --- a/Sources/ArgumentParserToolInfo/ToolInfo.swift +++ b/Sources/ArgumentParserToolInfo/ToolInfoV0.swift @@ -11,7 +11,7 @@ extension Collection { /// - returns: A non-empty collection or `nil`. - fileprivate var nonEmpty: Self? { isEmpty ? nil : self } + internal var nonEmpty: Self? { isEmpty ? nil : self } } /// Header used to validate serialization version of an encoded ToolInfo struct. @@ -27,6 +27,10 @@ public struct ToolInfoHeader: Decodable { /// Top-level structure containing serialization version and information for all /// commands in a tool. +/// +/// Note: This represents the data behind the initial experimental dump help feature. +/// For the stable version see ToolInfoV1 and its interior types. +/// public struct ToolInfoV0: Codable, Hashable { /// A sentinel value indicating the version of the ToolInfo struct used to /// generate the serialized form. @@ -41,6 +45,10 @@ public struct ToolInfoV0: Codable, Hashable { /// All information about a particular command, including arguments and /// subcommands. +/// +/// Note: This represents the data behind the initial experimental dump help feature. +/// For the stable version see ToolInfoV1 and its interior types. +/// public struct CommandInfoV0: Codable, Hashable { /// Super commands and tools. public var superCommands: [String]? @@ -106,6 +114,10 @@ public struct CommandInfoV0: Codable, Hashable { /// All information about a particular argument, including display names and /// options. +/// +/// Note: This represents the data behind the initial experimental dump help feature. +/// For the stable version see ToolInfoV1 and its interior types. +/// public struct ArgumentInfoV0: Codable, Hashable { /// Information about an argument's name. public struct NameInfoV0: Codable, Hashable { @@ -131,6 +143,10 @@ public struct ArgumentInfoV0: Codable, Hashable { } /// Kind of argument. + /// + /// Note: This represents the data behind the initial experimental dump help feature. + /// For the stable version see ToolInfoV1 and its interior types. + /// public enum KindV0: String, Codable, Hashable { /// Argument specified as a bare value on the command line. case positional @@ -140,6 +156,10 @@ public struct ArgumentInfoV0: Codable, Hashable { case flag } + /// This represents the data behind the initial experimental dump help feature. + /// + /// For the stable version see ToolInfoV1 and its interior types. + /// public enum ParsingStrategyV0: String, Codable, Hashable { /// Expect the next `SplitArguments.Element` to be a value and parse it. /// Will fail if the next input is an option. @@ -160,6 +180,10 @@ public struct ArgumentInfoV0: Codable, Hashable { case allUnrecognized } + /// This represents the data behind the initial experimental dump help feature. + /// + /// For the stable version see ToolInfoV1 and its internal types. + /// public enum CompletionKindV0: Codable, Hashable { /// Use the specified list of completion strings. case list(values: [String]) diff --git a/Sources/ArgumentParserToolInfo/ToolInfoV1.swift b/Sources/ArgumentParserToolInfo/ToolInfoV1.swift new file mode 100644 index 000000000..7057d0fea --- /dev/null +++ b/Sources/ArgumentParserToolInfo/ToolInfoV1.swift @@ -0,0 +1,255 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +/// Top-level structure containing serialization version and information for all +/// commands in a tool. +public struct ToolInfoV1: Codable, Hashable { + /// A sentinel value indicating the version of the ToolInfo struct used to + /// generate the serialized form. + public var serializationVersion = 1 + + /// Root command of the tool. + public var command: CommandInfo + + public init(command: CommandInfo) { + self.command = command + } + + /// All information about a particular command, including arguments and + /// subcommands. + public struct CommandInfo: Codable, Hashable { + /// Super commands and tools. + public var superCommands: [String]? + /// Command should appear in help displays. + public var shouldDisplay: Bool = true + + /// Name used to invoke the command. + public var commandName: String + /// Short description of the command's functionality. + public var abstract: String? + /// Extended description of the command's functionality. + public var discussion: String? + + /// Optional name of the subcommand invoked when the command is invoked with + /// no arguments. + public var defaultSubcommand: String? + /// List of nested commands. + public var subcommands: [CommandInfo]? + /// List of supported arguments. + public var arguments: [ArgumentInfo]? + + public init( + superCommands: [String], + shouldDisplay: Bool, + commandName: String, + abstract: String, + discussion: String, + defaultSubcommand: String?, + subcommands: [CommandInfo], + arguments: [ArgumentInfo] + ) { + self.superCommands = superCommands.nonEmpty + self.shouldDisplay = shouldDisplay + + self.commandName = commandName + self.abstract = abstract.nonEmpty + self.discussion = discussion.nonEmpty + + self.defaultSubcommand = defaultSubcommand?.nonEmpty + self.subcommands = subcommands.nonEmpty + self.arguments = arguments.nonEmpty + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.superCommands = try container.decodeIfPresent( + [String].self, forKey: .superCommands) + self.commandName = try container.decode(String.self, forKey: .commandName) + self.abstract = try container.decodeIfPresent( + String.self, forKey: .abstract) + self.discussion = try container.decodeIfPresent( + String.self, forKey: .discussion) + self.shouldDisplay = + try container.decodeIfPresent(Bool.self, forKey: .shouldDisplay) ?? true + self.defaultSubcommand = try container.decodeIfPresent( + String.self, forKey: .defaultSubcommand) + self.subcommands = try container.decodeIfPresent( + [CommandInfo].self, forKey: .subcommands) + self.arguments = try container.decodeIfPresent( + [ArgumentInfo].self, forKey: .arguments) + } + } + + /// All information about a particular argument, including display names and + /// options. + public struct ArgumentInfo: Codable, Hashable { + /// Information about an argument's name. + public struct NameInfo: Codable, Hashable { + /// Kind of prefix of an argument's name. + public enum Kind: String, Codable, Hashable { + /// A multi-character name preceded by two dashes. + case long + /// A single character name preceded by a single dash. + case short + /// A multi-character name preceded by a single dash. + case longWithSingleDash + } + + /// Kind of prefix the NameInfo describes. + public var kind: Kind + /// Single or multi-character name of the argument. + public var name: String + + public init(kind: NameInfo.Kind, name: String) { + self.kind = kind + self.name = name + } + } + + /// Kind of argument. + public enum Kind: String, Codable, Hashable { + /// Argument specified as a bare value on the command line. + case positional + /// Argument specified as a value prefixed by a `--flag` on the command line. + case option + /// Argument specified only as a `--flag` on the command line. + case flag + } + + public enum ParsingStrategy: String, Codable, Hashable { + /// Expect the next `SplitArguments.Element` to be a value and parse it. + /// Will fail if the next input is an option. + case `default` + /// Parse the next `SplitArguments.Element.value` + case scanningForValue + /// Parse the next `SplitArguments.Element` as a value, regardless of its type. + case unconditional + /// Parse multiple `SplitArguments.Element.value` up to the next non-`.value` + case upToNextOption + /// Parse all remaining `SplitArguments.Element` as values, regardless of its type. + case allRemainingInput + /// Collect all the elements after the terminator, preventing them from + /// appearing in any other position. + case postTerminator + /// Collect all unused inputs once recognized arguments/options/flags have + /// been parsed. + case allUnrecognized + } + + public enum CompletionKind: Codable, Hashable { + /// Use the specified list of completion strings. + case list(values: [String]) + /// Complete file names with the specified extensions. + case file(extensions: [String]) + /// Complete directory names that match the specified pattern. + case directory + /// Call the given shell command to generate completions. + case shellCommand(command: String) + /// Generate completions using the given three-parameter closure. + case custom + /// Generate completions using the given async three-parameter closure. + case customAsync + /// Generate completions using the given one-parameter closure. + @available(*, deprecated, message: "Use custom instead.") + case customDeprecated + } + + /// Kind of argument the ArgumentInfo describes. + public var kind: Kind + + /// Argument should appear in help displays. + public var shouldDisplay: Bool + /// Custom name of argument's section. + public var sectionTitle: String? + + /// Argument can be omitted. + public var isOptional: Bool + /// Argument can be specified multiple times. + public var isRepeating: Bool + + /// Parsing strategy of the ArgumentInfo. + public var parsingStrategy: ParsingStrategy + + /// All names of the argument. + public var names: [NameInfo]? + /// The best name to use when referring to the argument in help displays. + public var preferredName: NameInfo? + + /// Name of argument's value. + public var valueName: String? + /// Default value of the argument is none is specified on the command line. + public var defaultValue: String? + // NOTE: this property will not be renamed to 'allValueStrings' to avoid + // breaking compatibility with the current serialized format. + // + // This property is effectively deprecated. + /// List of all valid values. + public var allValues: [String]? + /// List of all valid values. + public var allValueStrings: [String]? { + get { self.allValues } + set { self.allValues = newValue } + } + /// Mapping of valid values to descriptions of the value. + public var allValueDescriptions: [String: String]? + + /// The type of completion to use for an argument or an option value. + /// + /// `nil` if the tool uses the default completion kind. + public var completionKind: CompletionKind? + + /// Short description of the argument's functionality. + public var abstract: String? + /// Extended description of the argument's functionality. + public var discussion: String? + + public init( + kind: Kind, + shouldDisplay: Bool, + sectionTitle: String?, + isOptional: Bool, + isRepeating: Bool, + parsingStrategy: ParsingStrategy, + names: [NameInfo]?, + preferredName: NameInfo?, + valueName: String?, + defaultValue: String?, + allValueStrings: [String]?, + allValueDescriptions: [String: String]?, + completionKind: CompletionKind?, + abstract: String?, + discussion: String? + ) { + self.kind = kind + + self.shouldDisplay = shouldDisplay + self.sectionTitle = sectionTitle + + self.isOptional = isOptional + self.isRepeating = isRepeating + + self.parsingStrategy = parsingStrategy + + self.names = names?.nonEmpty + self.preferredName = preferredName + + self.valueName = valueName?.nonEmpty + self.defaultValue = defaultValue?.nonEmpty + self.allValueStrings = allValueStrings?.nonEmpty + self.allValueDescriptions = allValueDescriptions?.nonEmpty + + self.completionKind = completionKind + + self.abstract = abstract?.nonEmpty + self.discussion = discussion?.nonEmpty + } + } +} diff --git a/Tests/ArgumentParserToolInfoTests/Examples/example0.json b/Tests/ArgumentParserToolInfoTests/Examples/example0.json index 901f01a9d..79db77dee 100644 --- a/Tests/ArgumentParserToolInfoTests/Examples/example0.json +++ b/Tests/ArgumentParserToolInfoTests/Examples/example0.json @@ -1,5 +1,5 @@ { - "serializationVersion": 0, + "serializationVersion": 1, "command": { "commandName": "empty-example" } diff --git a/Tests/ArgumentParserToolInfoTests/Examples/example1.json b/Tests/ArgumentParserToolInfoTests/Examples/example1.json index 43ae9b1e5..9de76edac 100644 --- a/Tests/ArgumentParserToolInfoTests/Examples/example1.json +++ b/Tests/ArgumentParserToolInfoTests/Examples/example1.json @@ -1,5 +1,5 @@ { - "serializationVersion": 0, + "serializationVersion": 1, "command": { "superCommands": [ "parent2", @@ -33,7 +33,7 @@ { "kind": "longWithSingleDash", "name": "frt" - }, + } ], "preferredName": { "kind": "long", @@ -47,21 +47,21 @@ "coconut" ], "abstract": "1234", - "discussion": "abcd", + "discussion": "abcd" }, { "kind": "option", "shouldDisplay": false, "isOptional": false, "isRepeating": false, - "parsingStrategy" : "default", + "parsingStrategy" : "default" }, { "kind": "flag", "shouldDisplay": false, "isOptional": false, "isRepeating": false, - "parsingStrategy" : "default", + "parsingStrategy" : "default" } ] } diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json index cb981545d..5826a4eb2 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json @@ -223,5 +223,5 @@ } ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json index 5b584df63..c13888d3a 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json @@ -112,5 +112,5 @@ } ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json index 7cbcafeea..ed74833f5 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json @@ -246,5 +246,5 @@ } ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json index 51568de7c..b1cbcf7b0 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json @@ -149,5 +149,5 @@ "math" ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json index badff7811..9cc2cf08b 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json @@ -789,5 +789,5 @@ } ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json index 7494decf6..9ac204b41 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json @@ -149,5 +149,5 @@ "math" ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 } diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json index 0a1e95164..3a9357703 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json @@ -571,5 +571,5 @@ "math" ] }, - "serializationVersion" : 0 + "serializationVersion" : 1 }