From c07660f16ea54d5aada2ebcbe2d45e12c14bb6fb Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Tue, 4 Oct 2022 19:43:48 +0100 Subject: [PATCH 1/8] Adds a new CommandConfiguration property, addressing #295 This adds the `shouldUseExecutableName` property, allowing the command name to be derived from the executable's file name. The property defaults to false, both because subcommands using it is probably undesirable and to preserve existing behaviour after updating the package. --- .../Parsable Types/CommandConfiguration.swift | 14 ++++++++++ .../Parsable Types/ParsableCommand.swift | 4 ++- .../ArgumentParser/Usage/UsageGenerator.swift | 17 +++++++++-- .../Utilities/StringExtensions.swift | 28 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) 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. From 20b685aa64e6210c107532ffaca8781e779788a2 Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Tue, 4 Oct 2022 20:03:05 +0100 Subject: [PATCH 2/8] Expose the property in the documentation --- .../Documentation.docc/Extensions/CommandConfiguration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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`` From 3737f85728614cb3e54b61476db04a32e04c10c4 Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Tue, 4 Oct 2022 20:05:23 +0100 Subject: [PATCH 3/8] Add tests for StringProtocol quotedIfContains(_:) --- Tests/ArgumentParserUnitTests/CMakeLists.txt | 1 + .../StringQuoteTests.swift | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Tests/ArgumentParserUnitTests/StringQuoteTests.swift 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..f9a371225 --- /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) + } + } +} From de0984ac365de2e4c7f5116471aeac2bf0e07779 Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Tue, 4 Oct 2022 20:06:26 +0100 Subject: [PATCH 4/8] Add DerivedData to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4b8005bc6..d133d51a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/DerivedData /Packages /*.xcodeproj .swiftpm From 3795cdcaf7ef4cf98d82d639789d332f964deb9b Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Tue, 4 Oct 2022 21:12:09 +0100 Subject: [PATCH 5/8] Make executable name portable --- Sources/ArgumentParser/Usage/UsageGenerator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index ea9aead89..3f1fb0d1a 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -37,7 +37,7 @@ extension UsageGenerator { /// /// 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) { + 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 From 301a9e599d4a16db7f6a1fab35950a078c7e9c50 Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Fri, 11 Nov 2022 14:00:30 +0000 Subject: [PATCH 6/8] Update commandName parameter description --- .../ArgumentParser/Parsable Types/CommandConfiguration.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift index 53f285942..47847a9c9 100644 --- a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift +++ b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift @@ -67,7 +67,8 @@ 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. From 90776051b9d05597caab2868b3777443719c3bbb Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Sat, 23 Sep 2023 14:56:57 +0100 Subject: [PATCH 7/8] Remove unnecessary backslashes in test cases This doesn't change test behaviour in any way. Single quotes were escaped unnecessarily for some tests, these escapes have been removed to make them consistent and clearer. --- Tests/ArgumentParserUnitTests/StringQuoteTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ArgumentParserUnitTests/StringQuoteTests.swift b/Tests/ArgumentParserUnitTests/StringQuoteTests.swift index f9a371225..d4ab16c41 100644 --- a/Tests/ArgumentParserUnitTests/StringQuoteTests.swift +++ b/Tests/ArgumentParserUnitTests/StringQuoteTests.swift @@ -26,10 +26,10 @@ extension StringQuoteTests { ("\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) From d84fb0daa97ea4f410a6b1304cb4cb3dda14de56 Mon Sep 17 00:00:00 2001 From: Simon Manning <367343+QuaqSim@users.noreply.github.com> Date: Sat, 23 Sep 2023 16:24:46 +0100 Subject: [PATCH 8/8] Add `Foundation.URL` import to UsageGenerator.swift Foundation imports were reduced to only the necessary protocols or types in the main branch. After merging, we need to import `Foundation.URL` for use in `executableName`. --- Sources/ArgumentParser/Usage/UsageGenerator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index 6956624ff..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