Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new shouldUseExecutableName configuration property (Issue #295) #501

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
/.build
/DerivedData
/Packages
/*.xcodeproj
.swiftpm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -21,6 +21,7 @@
### Defining Command Properties

- ``commandName``
- ``shouldUseExecutableName``
- ``version``
- ``shouldDisplay``

Expand Down
17 changes: 16 additions & 1 deletion Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 = "",
Expand All @@ -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
Expand All @@ -106,6 +119,7 @@ public struct CommandConfiguration {
/// (experimental)
public init(
commandName: String? = nil,
shouldUseExecutableName: Bool = false,
_superCommandName: String,
abstract: String = "",
usage: String? = nil,
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Sources/ArgumentParser/Parsable Types/ParsableCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions Sources/ArgumentParser/Usage/UsageGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//===----------------------------------------------------------------------===//

@_implementationOnly import protocol Foundation.LocalizedError
@_implementationOnly import struct Foundation.URL

struct UsageGenerator {
var toolName: String
Expand All @@ -18,8 +19,7 @@ struct UsageGenerator {

extension UsageGenerator {
init(definition: ArgumentSet) {
let toolName = CommandLine.arguments[0].split(separator: "/").last.map(String.init) ?? "<command>"
self.init(toolName: toolName, definition: definition)
self.init(toolName: Self.executableName, definition: definition)
}

init(toolName: String, parsable: ParsableArguments, visibility: ArgumentVisibility, parent: InputKey?) {
Expand All @@ -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, `"<command>"` 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 "<command>"
}
}

/// The tool synopsis.
///
/// In `roff`.
Expand Down
28 changes: 28 additions & 0 deletions Sources/ArgumentParser/Utilities/StringExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Tests/ArgumentParserUnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ add_library(UnitTests
HelpGenerationTests+GroupName.swift
NameSpecificationTests.swift
SplitArgumentTests.swift
StringQuoteTests.swift
StringSnakeCaseTests.swift
StringWrappingTests.swift
TreeTests.swift
Expand Down
38 changes: 38 additions & 0 deletions Tests/ArgumentParserUnitTests/StringQuoteTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}