diff --git a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift index 5c6614fc3..53f285942 100644 --- a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift +++ b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift @@ -17,6 +17,13 @@ public struct CommandConfiguration { /// the command type to hyphen-separated lowercase words. public var commandName: String? + /// A Boolean value indicating whether to use the executable's file name + /// for the command name. + /// + /// If `commandName` or `_superCommandName` are non-`nil`, this + /// value is ignored. + public var shouldUseExecutableName: Bool + /// The name of this command's "super-command". (experimental) /// /// Use this when a command is part of a group of commands that are installed @@ -61,6 +68,9 @@ public struct CommandConfiguration { /// - commandName: The name of the command to use on the command line. If /// `commandName` is `nil`, the command name is derived by converting /// the name of the command type to hyphen-separated lowercase words. + /// - shouldUseExecutableName: A Boolean value indicating whether to + /// use the executable's file name for the command name. If `commandName` + /// is non-`nil`, this value is ignored. /// - abstract: A one-line description of the command. /// - usage: A custom usage description for the command. When you provide /// a non-`nil` string, the argument parser uses `usage` instead of @@ -82,6 +92,7 @@ public struct CommandConfiguration { /// are `-h` and `--help`. public init( commandName: String? = nil, + shouldUseExecutableName: Bool = false, abstract: String = "", usage: String? = nil, discussion: String = "", @@ -92,6 +103,7 @@ public struct CommandConfiguration { helpNames: NameSpecification? = nil ) { self.commandName = commandName + self.shouldUseExecutableName = shouldUseExecutableName self.abstract = abstract self.usage = usage self.discussion = discussion @@ -106,6 +118,7 @@ public struct CommandConfiguration { /// (experimental) public init( commandName: String? = nil, + shouldUseExecutableName: Bool = false, _superCommandName: String, abstract: String = "", usage: String? = nil, @@ -117,6 +130,7 @@ public struct CommandConfiguration { helpNames: NameSpecification? = nil ) { self.commandName = commandName + self.shouldUseExecutableName = shouldUseExecutableName self._superCommandName = _superCommandName self.abstract = abstract self.usage = usage diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index bdf211e0c..3fec45339 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -37,7 +37,9 @@ public protocol ParsableCommand: ParsableArguments { extension ParsableCommand { public static var _commandName: String { configuration.commandName ?? - String(describing: Self.self).convertedToSnakeCase(separator: "-") + (configuration.shouldUseExecutableName && configuration._superCommandName == nil + ? UsageGenerator.executableName + : String(describing: Self.self).convertedToSnakeCase(separator: "-")) } public static var configuration: CommandConfiguration { diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index cb8b9e039..ea9aead89 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -18,8 +18,7 @@ struct UsageGenerator { extension UsageGenerator { init(definition: ArgumentSet) { - let toolName = CommandLine.arguments[0].split(separator: "/").last.map(String.init) ?? "" - self.init(toolName: toolName, definition: definition) + self.init(toolName: Self.executableName, definition: definition) } init(toolName: String, parsable: ParsableArguments, visibility: ArgumentVisibility, parent: InputKey.Parent) { @@ -34,6 +33,20 @@ extension UsageGenerator { } extension UsageGenerator { + /// Will generate a tool name from the name of the executed file if possible. + /// + /// If no tool name can be generated, `""` will be returned. + static var executableName: String { + if let name = CommandLine.arguments[0].split(separator: "/").last.map(String.init) { + // We quote the name if it contains whitespace to avoid confusion with + // subcommands but otherwise leave properly quoting/escaping the command + // up to the user running the tool + return name.quotedIfContains(.whitespaces) + } else { + return "" + } + } + /// The tool synopsis. /// /// In `roff`. diff --git a/Sources/ArgumentParser/Utilities/StringExtensions.swift b/Sources/ArgumentParser/Utilities/StringExtensions.swift index 9c1deb090..eda503043 100644 --- a/Sources/ArgumentParser/Utilities/StringExtensions.swift +++ b/Sources/ArgumentParser/Utilities/StringExtensions.swift @@ -9,6 +9,8 @@ // //===----------------------------------------------------------------------===// +@_implementationOnly import Foundation + extension StringProtocol where SubSequence == Substring { func wrapped(to columns: Int, wrappingIndent: Int = 0) -> String { let columns = columns - wrappingIndent @@ -120,6 +122,32 @@ extension StringProtocol where SubSequence == Substring { return result } + /// Returns a new single-quoted string if this string contains any characters + /// from the specified character set. Any existing occurrences of the `'` + /// character will be escaped. + /// + /// Examples: + /// + /// "alone".quotedIfContains(.whitespaces) + /// // alone + /// "with space".quotedIfContains(.whitespaces) + /// // 'with space' + /// "with'quote".quotedIfContains(.whitespaces) + /// // with'quote + /// "with'quote and space".quotedIfContains(.whitespaces) + /// // 'with\'quote and space' + func quotedIfContains(_ chars: CharacterSet) -> String { + guard !isEmpty else { return "" } + + if self.rangeOfCharacter(from: chars) != nil { + // Prepend and append a single quote to self, escaping any other occurrences of the character + let quote = "'" + return quote + self.replacingOccurrences(of: quote, with: "\\\(quote)") + quote + } + + return String(self) + } + /// Returns the edit distance between this string and the provided target string. /// /// Uses the Levenshtein distance algorithm internally.