Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 @@ -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
Expand All @@ -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 = "",
Expand All @@ -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
Expand All @@ -106,6 +118,7 @@ public struct CommandConfiguration {
/// (experimental)
public init(
commandName: String? = nil,
shouldUseExecutableName: Bool = false,
_superCommandName: String,
abstract: String = "",
usage: String? = nil,
Expand All @@ -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
Expand Down
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
17 changes: 15 additions & 2 deletions Sources/ArgumentParser/Usage/UsageGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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.Parent) {
Expand All @@ -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, `"<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)
}
}
}