diff --git a/.gitignore b/.gitignore index 6a37c14b2..c719b6dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/DerivedData /Packages /*.xcodeproj .swiftpm diff --git a/Sources/ArgumentParser/Documentation.docc/Extensions/CommandConfiguration.md b/Sources/ArgumentParser/Documentation.docc/Extensions/CommandConfiguration.md index 554485286..7f4baf191 100644 --- a/Sources/ArgumentParser/Documentation.docc/Extensions/CommandConfiguration.md +++ b/Sources/ArgumentParser/Documentation.docc/Extensions/CommandConfiguration.md @@ -4,7 +4,7 @@ ### Creating a Configuration -- ``init(commandName:abstract:usage:discussion:version:shouldDisplay:subcommands:defaultSubcommand:helpNames:)`` +- ``init(commandName:shouldUseExecutableName:abstract:usage:discussion:version:shouldDisplay:subcommands:defaultSubcommand:helpNames:)`` ### Customizing the Help Screen @@ -21,6 +21,7 @@ ### Defining Command Properties - ``commandName`` +- ``shouldUseExecutableName`` - ``version`` - ``shouldDisplay`` diff --git a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift index 5c6614fc3..47847a9c9 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 @@ -60,7 +67,11 @@ public struct CommandConfiguration { /// - Parameters: /// - 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. + /// the name of the command type to hyphen-separated lowercase words or + /// by using the executable name if `shouldUseExecutableName` is `true`. + /// - 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 +93,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 +104,7 @@ public struct CommandConfiguration { helpNames: NameSpecification? = nil ) { self.commandName = commandName + self.shouldUseExecutableName = shouldUseExecutableName self.abstract = abstract self.usage = usage self.discussion = discussion @@ -106,6 +119,7 @@ public struct CommandConfiguration { /// (experimental) public init( commandName: String? = nil, + shouldUseExecutableName: Bool = false, _superCommandName: String, abstract: String = "", usage: String? = nil, @@ -117,6 +131,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 17c04fdc7..e1ef3ffb3 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 baf46c044..4cfea0af0 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -10,6 +10,7 @@ //===----------------------------------------------------------------------===// @_implementationOnly import protocol Foundation.LocalizedError +@_implementationOnly import struct Foundation.URL struct UsageGenerator { var toolName: String @@ -18,8 +19,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?) { @@ -34,6 +34,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 = URL(fileURLWithPath: CommandLine.arguments[0]).pathComponents.last { + // 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. diff --git a/Tests/ArgumentParserUnitTests/CMakeLists.txt b/Tests/ArgumentParserUnitTests/CMakeLists.txt index 02088dbb3..517a20802 100644 --- a/Tests/ArgumentParserUnitTests/CMakeLists.txt +++ b/Tests/ArgumentParserUnitTests/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(UnitTests HelpGenerationTests+GroupName.swift NameSpecificationTests.swift SplitArgumentTests.swift + StringQuoteTests.swift StringSnakeCaseTests.swift StringWrappingTests.swift TreeTests.swift diff --git a/Tests/ArgumentParserUnitTests/StringQuoteTests.swift b/Tests/ArgumentParserUnitTests/StringQuoteTests.swift new file mode 100644 index 000000000..d4ab16c41 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/StringQuoteTests.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2022 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 +// +//===----------------------------------------------------------------------===// + +import XCTest +@testable import ArgumentParser + +final class StringQuoteTests: XCTestCase {} + +extension StringQuoteTests { + func testStringQuoteWithCharacter() { + let charactersToQuote = CharacterSet.whitespaces.union(.symbols) + let quoteTests = [ + ("noSpace", "noSpace"), + ("a space", "'a space'"), + (" startingSpace", "' startingSpace'"), + ("endingSpace ", "'endingSpace '"), + (" ", "' '"), + ("\t", "'\t'"), + ("with'quote", "with'quote"), // no need to quote, so don't escape quote character either + ("with'quote and space", "'with\\'quote and space'"), // quote the string and escape the quote character within + ("'\\\\'' '''", "'\\'\\\\\\'\\' \\'\\'\\''"), + ("\"\\\\\"\" \"\"\"", "'\"\\\\\"\" \"\"\"'"), + ("word+symbol", "'word+symbol'"), + ("@£$%'^*(", "'@£$%\\'^*('") + ] + for test in quoteTests { + XCTAssertEqual(test.0.quotedIfContains(charactersToQuote), test.1) + } + } +}