diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index 77dc92e54..9a28c1b53 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -35,6 +35,7 @@ add_library(ArgumentParser Parsing/ParserError.swift Parsing/SplitArguments.swift + Usage/CommandSearcher.swift Usage/DumpHelpGenerator.swift Usage/HelpCommand.swift Usage/HelpGenerator.swift @@ -49,7 +50,7 @@ add_library(ArgumentParser Utilities/StringExtensions.swift Utilities/SwiftExtensions.swift Utilities/Tree.swift - + Validators/CodingKeyValidator.swift Validators/NonsenseFlagsValidator.swift Validators/ParsableArgumentsValidation.swift diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index 264a2d418..64de9a8f8 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -152,74 +152,6 @@ extension String { self.replacing("-", with: "_") } - func replacing(_ old: Self, with new: Self) -> Self { - guard !old.isEmpty else { return self } - - var result = "" - var startIndex = self.startIndex - - // Look for occurrences of the old string. - while let matchRange = self.firstMatch(of: old, at: startIndex) { - // Add the substring before the match. - result.append(contentsOf: self[startIndex.. (start: Self.Index, end: Self.Index)? { - guard !match.isEmpty else { return nil } - guard match.count <= self.count else { return nil } - - var startIndex = startIndex - while startIndex < self.endIndex { - // Check if theres a match. - if let endIndex = self.matches(match, at: startIndex) { - // Return the match. - return (startIndex, endIndex) - } - - // Move to the next of index. - self.formIndex(after: &startIndex) - } - - return nil - } - - func matches( - _ match: Self, - at startIndex: Self.Index - ) -> Self.Index? { - var selfIndex = startIndex - var matchIndex = match.startIndex - - while true { - // Only continue checking if there is more match to check - guard matchIndex < match.endIndex else { return selfIndex } - - // Exit early if there is no more "self" to check. - guard selfIndex < self.endIndex else { return nil } - - // Check match and self are the the same. - guard self[selfIndex] == match[matchIndex] else { return nil } - - // Move to the next pair of indices. - self.formIndex(after: &selfIndex) - match.formIndex(after: &matchIndex) - } - } } extension CommandInfoV0 { diff --git a/Sources/ArgumentParser/Usage/CommandSearcher.swift b/Sources/ArgumentParser/Usage/CommandSearcher.swift new file mode 100644 index 000000000..130b83f14 --- /dev/null +++ b/Sources/ArgumentParser/Usage/CommandSearcher.swift @@ -0,0 +1,577 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +#if compiler(>=6.0) +#if canImport(FoundationEssentials) +internal import FoundationEssentials +#else +internal import Foundation +#endif +#else +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +#endif + +// MARK: - String Search Helpers + +extension String { + /// Find the range of a substring (case-insensitive). + fileprivate func rangeOfSubstring(_ substring: String) -> Range? + { + #if canImport(FoundationEssentials) + // FoundationEssentials doesn't include String.range(of:) + // Use the existing firstMatch implementation from CompletionsGenerator.swift + let lowercased = self.lowercased() + let lowercasedSubstring = substring.lowercased() + + guard + let match = lowercased.firstMatch( + of: lowercasedSubstring, at: lowercased.startIndex) + else { + return nil + } + + return match.start.. String { + guard enabled && !term.isEmpty else { return text } + + let lowercasedText = text.lowercased() + let lowercasedTerm = term.lowercased() + + var result = "" + var searchStartIndex = text.startIndex + + while searchStartIndex < text.endIndex { + let searchRange = searchStartIndex.. + + /// The command stack leading to the root node. + var commandStack: [ParsableCommand.Type] + + /// The visibility level for arguments. + var visibility: ArgumentVisibility + + /// Search for the given term and return all matches. + /// + /// - Parameter term: The search term (case-insensitive substring match). + /// - Returns: An array of search results, ordered by relevance. + func search(for term: String) -> [SearchResult] { + guard !term.isEmpty else { return [] } + + let lowercasedTerm = term.lowercased() + var results: [SearchResult] = [] + + // Traverse the tree starting from rootNode + traverseTree( + node: rootNode, currentPath: commandStack.map { $0._commandName }, + term: lowercasedTerm, results: &results) + + // Sort results: command matches first, then argument matches + return results.sorted { lhs, rhs in + if lhs.isCommandMatch != rhs.isCommandMatch { + return lhs.isCommandMatch + } + return lhs.commandPath.joined(separator: " ") + < rhs.commandPath.joined(separator: " ") + } + } + + /// Recursively traverse the command tree and collect matches. + private func traverseTree( + node: Tree, + currentPath: [String], + term: String, + results: inout [SearchResult] + ) { + let command = node.element + let configuration = command.configuration + + // Don't search commands that shouldn't be displayed + guard configuration.shouldDisplay else { return } + + // Track if we've found any match for this command + var matchFound = false + var bestMatchType: SearchResult.MatchType? + var bestSnippet = "" + + // Check 1: Search command name (highest priority) + let commandName = command._commandName + if commandName.lowercased().contains(term) { + bestMatchType = .commandName(matchedText: commandName) + bestSnippet = + configuration.abstract.isEmpty ? commandName : configuration.abstract + matchFound = true + } + + // Check 2: Search command aliases (if name didn't match) + if !matchFound { + for alias in configuration.aliases { + if alias.lowercased().contains(term) { + bestMatchType = .commandName(matchedText: alias) + bestSnippet = + configuration.abstract.isEmpty ? alias : configuration.abstract + matchFound = true + break + } + } + } + + // Check 3: Search command abstract (if name/aliases didn't match) + if !matchFound && !configuration.abstract.isEmpty + && configuration.abstract.lowercased().contains(term) + { + let snippet = extractSnippet(from: configuration.abstract, around: term) + bestMatchType = .commandDescription(matchedText: snippet) + bestSnippet = snippet + matchFound = true + } + + // Check 4: Search command discussion (if nothing else matched) + if !matchFound && !configuration.discussion.isEmpty + && configuration.discussion.lowercased().contains(term) + { + let snippet = extractSnippet(from: configuration.discussion, around: term) + bestMatchType = .commandDescription(matchedText: snippet) + bestSnippet = snippet + matchFound = true + } + + // Add result if we found a match + if matchFound, let matchType = bestMatchType { + results.append( + SearchResult( + commandPath: currentPath, + matchType: matchType, + contextSnippet: bestSnippet + )) + } + + // Search arguments + searchArguments( + command: command, commandPath: currentPath, term: term, results: &results) + + // Recursively search children + for child in node.children { + let childName = child.element._commandName + traverseTree( + node: child, + currentPath: currentPath + [childName], + term: term, + results: &results + ) + } + } + + /// Search through all arguments of a command. + private func searchArguments( + command: ParsableCommand.Type, + commandPath: [String], + term: String, + results: inout [SearchResult] + ) { + let argSet = ArgumentSet(command, visibility: visibility, parent: nil) + + for arg in argSet { + // Skip if not visible enough + guard arg.help.visibility.isAtLeastAsVisible(as: visibility) else { + continue + } + + let names = arg.names + let displayNames: String + if names.isEmpty { + // Positional argument - use computed value name + displayNames = "<\(arg.valueName)>" + } else { + displayNames = names.map { $0.synopsisString }.joined(separator: ", ") + } + + // Track if we've found any match for this argument + var matchFound = false + var bestMatchType: SearchResult.MatchType? + var bestSnippet = "" + + // Check 1: Search argument names (highest priority) + if names.isEmpty { + // Positional argument - check if term matches value name + if arg.valueName.lowercased().contains(term) { + bestMatchType = .argumentName( + name: displayNames, matchedText: arg.valueName) + bestSnippet = arg.help.abstract + matchFound = true + } + } else { + // Named arguments - check all names + for name in names { + let nameString = name.synopsisString + if nameString.lowercased().contains(term) { + bestMatchType = .argumentName( + name: displayNames, matchedText: nameString) + bestSnippet = arg.help.abstract + matchFound = true + break + } + } + } + + // Check 2: Search argument abstract (if name didn't match) + if !matchFound && !arg.help.abstract.isEmpty + && arg.help.abstract.lowercased().contains(term) + { + let snippet = extractSnippet(from: arg.help.abstract, around: term) + bestMatchType = .argumentDescription( + name: displayNames, matchedText: snippet) + bestSnippet = snippet + matchFound = true + } + + // Check 3: Search argument discussion (if nothing else matched) + if !matchFound, + case .staticText(let discussionText) = arg.help.discussion, + !discussionText.isEmpty && discussionText.lowercased().contains(term) + { + let snippet = extractSnippet(from: discussionText, around: term) + bestMatchType = .argumentDescription( + name: displayNames, matchedText: snippet) + bestSnippet = snippet + matchFound = true + } + + // Check 4: Search possible values (if nothing else matched) + if !matchFound { + for value in arg.help.allValueStrings where !value.isEmpty { + if value.lowercased().contains(term) { + bestMatchType = .argumentValue( + name: displayNames, matchedText: value) + bestSnippet = "possible value: \(value)" + matchFound = true + break + } + } + } + + // Check 5: Search default value (if nothing else matched) + if !matchFound, + let defaultValue = arg.help.defaultValue, + !defaultValue.isEmpty && defaultValue.lowercased().contains(term) + { + bestMatchType = .argumentValue( + name: displayNames, matchedText: defaultValue) + bestSnippet = "default: \(defaultValue)" + matchFound = true + } + + // Add result if we found a match + if matchFound, let matchType = bestMatchType { + results.append( + SearchResult( + commandPath: commandPath, + matchType: matchType, + contextSnippet: bestSnippet + )) + } + } + } + + /// Extract a snippet of text around the matched term. + /// + /// - Parameters: + /// - text: The full text containing the match. + /// - term: The search term (lowercased). + /// - Returns: A snippet showing the match in context (max ~80 chars). + private func extractSnippet(from text: String, around term: String) -> String + { + let maxSnippetLength = 80 + let lowercasedText = text.lowercased() + + guard let matchRange = lowercasedText.rangeOfSubstring(term) else { + // Shouldn't happen, but fall back to truncated text + return String(text.prefix(maxSnippetLength)) + } + + let matchIndex = lowercasedText.distance( + from: lowercasedText.startIndex, to: matchRange.lowerBound) + let matchLength = term.count + + // Calculate context window + let contextBefore = 20 + let contextAfter = maxSnippetLength - contextBefore - matchLength + + let startIndex = + text.index( + text.startIndex, offsetBy: max(0, matchIndex - contextBefore), + limitedBy: text.endIndex) ?? text.startIndex + let endIndex = + text.index( + matchRange.upperBound, offsetBy: contextAfter, limitedBy: text.endIndex) + ?? text.endIndex + + var snippet = String(text[startIndex.. String { + guard !results.isEmpty else { + return + "No matches found for '\(term)'.\nTry '\(toolName) --help' for all options." + } + + //let useHighlighting = useHighlighting ?? Platform.isStdoutTerminal + + var output = + "Found \(results.count) match\(results.count == 1 ? "" : "es") for '\(term)':\n" + + // Group by command vs argument matches + let commandResults = results.filter { $0.isCommandMatch } + let argumentResults = results.filter { !$0.isCommandMatch } + + // Display command matches + if !commandResults.isEmpty { + output += "\nCOMMANDS:\n" + output += formatCommandResults( + commandResults, term: term, screenWidth: screenWidth, + useHighlighting: useHighlighting) + } + + // Display argument matches + if !argumentResults.isEmpty { + output += "\nOPTIONS:\n" + output += formatArgumentResults( + argumentResults, term: term, screenWidth: screenWidth, + useHighlighting: useHighlighting) + } + + output += "\nUse '\(toolName) --help' for detailed information." + + return output + } + + /// Format command search results. + private static func formatCommandResults( + _ results: [SearchResult], + term: String, + screenWidth: Int, + useHighlighting: Bool + ) -> String { + var output = "" + + for result in results { + let pathString = result.commandPath.joined(separator: " ") + + switch result.matchType { + case .commandName(let matched): + // For command name matches, show path with highlighted name and description + let highlightedPath = ANSICode.highlightMatches( + in: pathString, matching: term, enabled: useHighlighting) + output += " \(highlightedPath)\n" + + if !result.contextSnippet.isEmpty && result.contextSnippet != matched { + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) + let wrapped = highlightedSnippet.wrapped( + to: screenWidth, wrappingIndent: 6) + output += " \(wrapped.dropFirst(6))\n" + } + + case .commandDescription: + // For description matches, show path and the matching snippet with highlights + output += " \(pathString)\n" + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) + let wrapped = highlightedSnippet.wrapped( + to: screenWidth, wrappingIndent: 6) + output += " \(wrapped.dropFirst(6))\n" + + default: + break + } + + output += "\n" + } + + return output + } + + /// Format argument search results. + private static func formatArgumentResults( + _ results: [SearchResult], + term: String, + screenWidth: Int, + useHighlighting: Bool + ) -> String { + var output = "" + var lastPath = "" + + for result in results { + let pathString = result.commandPath.joined(separator: " ") + + // Print command path header if changed + if pathString != lastPath { + output += " \(pathString)\n" + lastPath = pathString + } + + // Format the match with highlighting + switch result.matchType { + case .argumentName(let name, _): + let highlightedName = ANSICode.highlightMatches( + in: name, matching: term, enabled: useHighlighting) + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) + let wrapped = highlightedSnippet.wrapped( + to: screenWidth, wrappingIndent: 6) + output += " \(highlightedName): \(wrapped.dropFirst(6))\n" + + case .argumentDescription(let name, _): + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) + let wrapped = highlightedSnippet.wrapped( + to: screenWidth, wrappingIndent: 6) + output += " \(name): \(wrapped.dropFirst(6))\n" + + case .argumentValue(let name, _): + let highlightedSnippet = ANSICode.highlightMatches( + in: result.contextSnippet, matching: term, enabled: useHighlighting) + output += " \(name) (\(highlightedSnippet))\n" + + default: + break + } + } + + return output + } +} diff --git a/Sources/ArgumentParser/Usage/HelpCommand.swift b/Sources/ArgumentParser/Usage/HelpCommand.swift index 80e192ffe..1adda3910 100644 --- a/Sources/ArgumentParser/Usage/HelpCommand.swift +++ b/Sources/ArgumentParser/Usage/HelpCommand.swift @@ -12,7 +12,8 @@ struct HelpCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "help", - abstract: "Show subcommand help information.", + abstract: + "Show subcommand help information. Use --search to find commands and options.", helpNames: []) /// Any subcommand names provided after the `help` subcommand. @@ -24,12 +25,25 @@ struct HelpCommand: ParsableCommand { help: .private) var help = false + /// Search term for finding commands and options. + @Option( + name: [.short, .long], + help: "Search for commands and options matching the term.") + var search: String? + private(set) var commandStack: [ParsableCommand.Type] = [] private(set) var visibility: ArgumentVisibility = .default + private(set) var commandTree: Tree? init() {} mutating func run() throws { + // If search term is provided, perform search instead of showing help + if let searchTerm = search { + performSearch(term: searchTerm) + return + } + throw CommandError( commandStack: commandStack, parserError: .helpRequested(visibility: visibility)) @@ -37,6 +51,55 @@ struct HelpCommand: ParsableCommand { mutating func buildCommandStack(with parser: CommandParser) throws { commandStack = parser.commandStack(for: subcommands) + commandTree = parser.commandTree + + // If subcommands were specified, find the corresponding node in the tree + if !subcommands.isEmpty { + var currentNode = parser.commandTree + for subcommand in subcommands { + if let child = currentNode.firstChild(withName: subcommand) { + currentNode = child + commandTree = currentNode + } + } + } + } + + private func performSearch(term: String) { + guard let tree = commandTree else { + print("Error: Command tree not initialized.") + return + } + + // Get the tool name for display + var toolName = commandStack.map { $0._commandName }.joined(separator: " ") + if toolName.isEmpty { + toolName = tree.element._commandName + } + if let root = commandStack.first, + let superName = root.configuration._superCommandName + { + toolName = "\(superName) \(toolName)" + } + + // Create search engine and perform search + let commandSearcher = CommandSearcher( + rootNode: tree, + commandStack: commandStack.isEmpty ? [tree.element] : commandStack, + visibility: visibility + ) + + let results = commandSearcher.search(for: term) + + // Format and print results + let output = CommandSearcher.formatResults( + results, + term: term, + toolName: toolName, + screenWidth: HelpGenerator.systemScreenWidth + ) + + print(output) } /// Used for testing. @@ -51,12 +114,14 @@ struct HelpCommand: ParsableCommand { enum CodingKeys: CodingKey { case subcommands case help + case search } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.subcommands = try container.decode([String].self, forKey: .subcommands) self.help = try container.decode(Bool.self, forKey: .help) + self.search = try container.decodeIfPresent(String.self, forKey: .search) } init(commandStack: [ParsableCommand.Type], visibility: ArgumentVisibility) { @@ -64,5 +129,6 @@ struct HelpCommand: ParsableCommand { self.visibility = visibility self.subcommands = commandStack.map { $0._commandName } self.help = false + self.search = nil } } diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift index 4fc313042..5e9223efe 100644 --- a/Sources/ArgumentParser/Usage/HelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift @@ -389,10 +389,12 @@ internal struct HelpGenerator { names.insert(superName, at: 0) } names.insert("help", at: 1) + let toolNameWithHelp = names.joined(separator: " ") helpSubcommandMessage = """ - See '\(names.joined(separator: " ")) ' for detailed help. + See '\(toolNameWithHelp) ' for detailed help. + Use '\(toolNameWithHelp) --search ' to search commands and options. """ } diff --git a/Sources/ArgumentParser/Utilities/Platform.swift b/Sources/ArgumentParser/Utilities/Platform.swift index 9364a1590..f1b571499 100644 --- a/Sources/ArgumentParser/Utilities/Platform.swift +++ b/Sources/ArgumentParser/Utilities/Platform.swift @@ -330,4 +330,19 @@ extension Platform { static var terminalWidth: Int { self.terminalSize().width } + + /// Check if stdout is connected to a terminal (TTY). + /// + /// Returns `true` if stdout is a terminal, `false` if it's redirected to a file or pipe. + static var isStdoutTerminal: Bool { + #if os(WASI) + return false + #elseif os(Windows) + // On Windows, check if we can get console info + var csbi = CONSOLE_SCREEN_BUFFER_INFO() + return GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi) + #else + return isatty(STDOUT_FILENO) != 0 + #endif + } } diff --git a/Sources/ArgumentParser/Utilities/StringExtensions.swift b/Sources/ArgumentParser/Utilities/StringExtensions.swift index 0e2076727..b8e9a5f9b 100644 --- a/Sources/ArgumentParser/Utilities/StringExtensions.swift +++ b/Sources/ArgumentParser/Utilities/StringExtensions.swift @@ -255,4 +255,76 @@ extension StringProtocol where SubSequence == Substring { var nonEmpty: Self? { isEmpty ? nil : self } + + func firstMatch( + of match: Self, + at startIndex: Self.Index + ) -> (start: Self.Index, end: Self.Index)? { + guard !match.isEmpty else { return nil } + guard match.count <= self.count else { return nil } + + var startIndex = startIndex + while startIndex < self.endIndex { + // Check if there's a match. + if let endIndex = self.matches(match, at: startIndex) { + // Return the match. + return (startIndex, endIndex) + } + + // Move to the next index. + self.formIndex(after: &startIndex) + } + + return nil + } + + func matches( + _ match: Self, + at startIndex: Self.Index + ) -> Self.Index? { + var selfIndex = startIndex + var matchIndex = match.startIndex + + while true { + // Only continue checking if there is more match to check + guard matchIndex < match.endIndex else { return selfIndex } + + // Exit early if there is no more "self" to check. + guard selfIndex < self.endIndex else { return nil } + + // Check match and self are the same. + guard self[selfIndex] == match[matchIndex] else { return nil } + + // Move to the next pair of indices. + self.formIndex(after: &selfIndex) + match.formIndex(after: &matchIndex) + } + } + +} + +extension String { + func replacing(_ old: Self, with new: Self) -> Self { + guard !old.isEmpty else { return self } + + var result = "" + var startIndex = self.startIndex + + // Look for occurrences of the old string. + while let matchRange = self.firstMatch(of: old, at: startIndex) { + // Add the substring before the match. + result.append(contentsOf: self[startIndex..' for detailed help. + Use 'foo help --search ' to search commands and options. """) AssertEqualStrings( actual: helpA, diff --git a/Tests/ArgumentParserExampleTests/MathExampleTests.swift b/Tests/ArgumentParserExampleTests/MathExampleTests.swift index 64f1ac0e0..aa6bc522d 100644 --- a/Tests/ArgumentParserExampleTests/MathExampleTests.swift +++ b/Tests/ArgumentParserExampleTests/MathExampleTests.swift @@ -41,6 +41,7 @@ final class MathExampleTests: XCTestCase { stats Calculate descriptive statistics. See 'math help ' for detailed help. + Use 'math help --search ' to search commands and options. """ diff --git a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash index dca1d8336..aa0013175 100644 --- a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash +++ b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash @@ -279,8 +279,15 @@ _math_stats_quantiles() { _math_help() { flags=(--version) - options=() + options=(-s --search) __math_offer_flags_options 1 + + # Offer option value completions + case "${prev}" in + '-s'|'--search') + return + ;; + esac } complete -o filenames -F _math math diff --git a/Tests/ArgumentParserExampleTests/Snapshots/testMathFishCompletionScript().fish b/Tests/ArgumentParserExampleTests/Snapshots/testMathFishCompletionScript().fish index 269a72e54..38e805f20 100644 --- a/Tests/ArgumentParserExampleTests/Snapshots/testMathFishCompletionScript().fish +++ b/Tests/ArgumentParserExampleTests/Snapshots/testMathFishCompletionScript().fish @@ -22,7 +22,7 @@ function __math_should_offer_completions_for -a expected_commands -a expected_po __math_parse_subcommand -r 4 'file=' 'directory=' 'shell=' 'custom=' 'custom-deprecated=' 'version' 'h/help' end case 'help' - __math_parse_subcommand -r 1 'version' + __math_parse_subcommand -r 1 's/search=' 'version' end end @@ -83,7 +83,7 @@ complete -c 'math' -n '__math_should_offer_completions_for "math"' -s 'h' -l 'he complete -c 'math' -n '__math_should_offer_completions_for "math" 1' -fa 'add' -d 'Print the sum of the values.' complete -c 'math' -n '__math_should_offer_completions_for "math" 1' -fa 'multiply' -d 'Print the product of the values.' complete -c 'math' -n '__math_should_offer_completions_for "math" 1' -fa 'stats' -d 'Calculate descriptive statistics.' -complete -c 'math' -n '__math_should_offer_completions_for "math" 1' -fa 'help' -d 'Show subcommand help information.' +complete -c 'math' -n '__math_should_offer_completions_for "math" 1' -fa 'help' -d 'Show subcommand help information. Use --search to find commands and options.' complete -c 'math' -n '__math_should_offer_completions_for "math add"' -l 'hex-output' -s 'x' -d 'Use hexadecimal notation for the result.' complete -c 'math' -n '__math_should_offer_completions_for "math add"' -l 'version' -d 'Show the version.' complete -c 'math' -n '__math_should_offer_completions_for "math add"' -s 'h' -l 'help' -d 'Show help information.' @@ -110,4 +110,5 @@ complete -c 'math' -n '__math_should_offer_completions_for "math stats quantiles complete -c 'math' -n '__math_should_offer_completions_for "math stats quantiles"' -l 'custom-deprecated' -rfka '(__math_custom_completion ---completion stats quantiles -- --custom-deprecated)' complete -c 'math' -n '__math_should_offer_completions_for "math stats quantiles"' -l 'version' -d 'Show the version.' complete -c 'math' -n '__math_should_offer_completions_for "math stats quantiles"' -s 'h' -l 'help' -d 'Show help information.' +complete -c 'math' -n '__math_should_offer_completions_for "math help"' -s 's' -l 'search' -d 'Search for commands and options matching the term.' -rfka '' complete -c 'math' -n '__math_should_offer_completions_for "math help"' -l 'version' -d 'Show the version.' diff --git a/Tests/ArgumentParserExampleTests/Snapshots/testMathZshCompletionScript().zsh b/Tests/ArgumentParserExampleTests/Snapshots/testMathZshCompletionScript().zsh index 6e0ab5fbc..0c5015436 100644 --- a/Tests/ArgumentParserExampleTests/Snapshots/testMathZshCompletionScript().zsh +++ b/Tests/ArgumentParserExampleTests/Snapshots/testMathZshCompletionScript().zsh @@ -53,7 +53,7 @@ _math() { 'add:Print the sum of the values.' 'multiply:Print the product of the values.' 'stats:Calculate descriptive statistics.' - 'help:Show subcommand help information.' + 'help:Show subcommand help information. Use --search to find commands and options.' ) _describe -V subcommand subcommands ;; @@ -176,6 +176,7 @@ _math_help() { local -i ret=1 local -ar arg_specs=( '*:subcommands:' + '(-s --search)'{-s,--search}'[Search for commands and options matching the term.]:search:' '--version[Show the version.]' ) _arguments -w -s -S : "${arg_specs[@]}" && ret=0 diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorDoccReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorDoccReference().md index 7e9ea67f2..b21331b4c 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorDoccReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorDoccReference().md @@ -25,15 +25,20 @@ This is optional. ## color.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -color help [...] +color help [...] [--search=] ``` - term **subcommands**: +- term **--search=\**: + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorMarkdownReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorMarkdownReference().md index 6d16c6da3..3cc13b41c 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorMarkdownReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testColorMarkdownReference().md @@ -25,15 +25,20 @@ This is optional. ## color.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -color help [...] +color help [...] [--search=] ``` **subcommands:** +**--search=\:** + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesDoccReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesDoccReference().md index 48d93d7cb..6b09f4015 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesDoccReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesDoccReference().md @@ -29,15 +29,20 @@ count-lines [] [--prefix=] [--verbose] ## count-lines.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -count-lines help [...] +count-lines help [...] [--search=] ``` - term **subcommands**: +- term **--search=\**: + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesMarkdownReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesMarkdownReference().md index 619e81d1c..2e2cf7520 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesMarkdownReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testCountLinesMarkdownReference().md @@ -28,15 +28,20 @@ count-lines [] [--prefix=] [--verbose] [--help] ## count-lines.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -count-lines help [...] +count-lines help [...] [--search=] ``` **subcommands:** +**--search=\:** + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathDoccReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathDoccReference().md index 847a76462..0d99a2852 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathDoccReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathDoccReference().md @@ -211,15 +211,20 @@ math stats quantiles [] [] ## math.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -math help [...] [--version] +math help [...] [--search=] [--version] ``` - term **subcommands**: +- term **--search=\**: + +*Search for commands and options matching the term.* + + - term **--version**: *Show the version.* diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathMarkdownReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathMarkdownReference().md index 00997feb5..04b170037 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathMarkdownReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testMathMarkdownReference().md @@ -207,15 +207,20 @@ math stats quantiles [] [] [] ## math.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -math help [...] [--version] +math help [...] [--search=] [--version] ``` **subcommands:** +**--search=\:** + +*Search for commands and options matching the term.* + + **--version:** *Show the version.* diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatDoccReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatDoccReference().md index 60f104d78..37ec1cd7a 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatDoccReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatDoccReference().md @@ -29,15 +29,20 @@ repeat [--count=] [--include-counter] ## repeat.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -repeat help [...] +repeat help [...] [--search=] ``` - term **subcommands**: +- term **--search=\**: + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatMarkdownReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatMarkdownReference().md index f3ab3e621..b1c94163c 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatMarkdownReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRepeatMarkdownReference().md @@ -28,15 +28,20 @@ repeat [--count=] [--include-counter] [--help] ## repeat.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -repeat help [...] +repeat help [...] [--search=] ``` **subcommands:** +**--search=\:** + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollDoccReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollDoccReference().md index 306fae0d0..d85613227 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollDoccReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollDoccReference().md @@ -36,15 +36,20 @@ Use this option to override the default value of a six-sided die. ## roll.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -roll help [...] +roll help [...] [--search=] ``` - term **subcommands**: +- term **--search=\**: + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollMarkdownReference().md b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollMarkdownReference().md index 8e0bd3874..ca00cfce8 100644 --- a/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollMarkdownReference().md +++ b/Tests/ArgumentParserGenerateDoccReferenceTests/Snapshots/testRollMarkdownReference().md @@ -35,15 +35,20 @@ Use this option to override the default value of a six-sided die. ## roll.help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. ``` -roll help [...] +roll help [...] [--search=] ``` **subcommands:** +**--search=\:** + +*Search for commands and options matching the term.* + + diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorMultiPageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorMultiPageManual().mdoc index 4c6e5041c..83f6a0c40 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorMultiPageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorMultiPageManual().mdoc @@ -60,13 +60,16 @@ and .Os .Sh NAME .Nm "color help" -.Nd "Show subcommand help information." +.Nd "Show subcommand help information. Use --search to find commands and options." .Sh SYNOPSIS .Nm .Op Ar subcommands... +.Op Fl -search Ar search .Sh DESCRIPTION .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .Sh AUTHORS The diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorSinglePageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorSinglePageManual().mdoc index cac149efa..5eaacab90 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorSinglePageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testColorSinglePageManual().mdoc @@ -39,9 +39,11 @@ A yellow color. .It Fl h , -help Show help information. .It Em help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .El .Sh AUTHORS diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesMultiPageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesMultiPageManual().mdoc index e44f70fba..960e13cd0 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesMultiPageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesMultiPageManual().mdoc @@ -43,13 +43,16 @@ and .Os .Sh NAME .Nm "count-lines help" -.Nd "Show subcommand help information." +.Nd "Show subcommand help information. Use --search to find commands and options." .Sh SYNOPSIS .Nm .Op Ar subcommands... +.Op Fl -search Ar search .Sh DESCRIPTION .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .Sh AUTHORS The diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesSinglePageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesSinglePageManual().mdoc index 8ca7f3a06..ff1ea6096 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesSinglePageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testCountLinesSinglePageManual().mdoc @@ -22,9 +22,11 @@ Include extra information in the output. .It Fl h , -help Show help information. .It Em help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .El .Sh AUTHORS diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathMultiPageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathMultiPageManual().mdoc index 99288bd7f..2eaa0d1c6 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathMultiPageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathMultiPageManual().mdoc @@ -273,14 +273,17 @@ and .Os .Sh NAME .Nm "math help" -.Nd "Show subcommand help information." +.Nd "Show subcommand help information. Use --search to find commands and options." .Sh SYNOPSIS .Nm .Op Ar subcommands... +.Op Fl -search Ar search .Op Fl -version .Sh DESCRIPTION .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .It Fl -version Show the version. .El diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManual().mdoc index 1aa384530..d6eac62ef 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManual().mdoc @@ -90,9 +90,11 @@ Show help information. .El .El .It Em help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .It Fl -version Show the version. .El diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatMultiPageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatMultiPageManual().mdoc index d5e4e0555..f8f5cae14 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatMultiPageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatMultiPageManual().mdoc @@ -43,13 +43,16 @@ and .Os .Sh NAME .Nm "repeat help" -.Nd "Show subcommand help information." +.Nd "Show subcommand help information. Use --search to find commands and options." .Sh SYNOPSIS .Nm .Op Ar subcommands... +.Op Fl -search Ar search .Sh DESCRIPTION .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .Sh AUTHORS The diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatSinglePageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatSinglePageManual().mdoc index ddb3cd595..70a7bdda6 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatSinglePageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRepeatSinglePageManual().mdoc @@ -22,9 +22,11 @@ The phrase to repeat. .It Fl h , -help Show help information. .It Em help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .El .Sh AUTHORS diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollMultiPageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollMultiPageManual().mdoc index 1199b9e74..da3d47aa8 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollMultiPageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollMultiPageManual().mdoc @@ -48,13 +48,16 @@ and .Os .Sh NAME .Nm "roll help" -.Nd "Show subcommand help information." +.Nd "Show subcommand help information. Use --search to find commands and options." .Sh SYNOPSIS .Nm .Op Ar subcommands... +.Op Fl -search Ar search .Sh DESCRIPTION .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .Sh AUTHORS The diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollSinglePageManual().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollSinglePageManual().mdoc index 126fb0134..b5fe9affc 100644 --- a/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollSinglePageManual().mdoc +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testRollSinglePageManual().mdoc @@ -27,9 +27,11 @@ Show all roll results. .It Fl h , -help Show help information. .It Em help -Show subcommand help information. +Show subcommand help information. Use --search to find commands and options. .Bl -tag -width 6n .It Ar subcommands... +.It Fl s , -search Ar search +Search for commands and options matching the term. .El .El .Sh AUTHORS diff --git a/Tests/ArgumentParserPackageManagerTests/HelpTests.swift b/Tests/ArgumentParserPackageManagerTests/HelpTests.swift index 62c87a287..48dd3bc5a 100644 --- a/Tests/ArgumentParserPackageManagerTests/HelpTests.swift +++ b/Tests/ArgumentParserPackageManagerTests/HelpTests.swift @@ -67,6 +67,7 @@ extension HelpTests { generate-xcodeproj See 'package help ' for detailed help. + Use 'package help --search ' to search commands and options. """) } @@ -86,6 +87,7 @@ extension HelpTests { generate-xcodeproj See 'package help ' for detailed help. + Use 'package help --search ' to search commands and options. """ ) } @@ -113,6 +115,7 @@ extension HelpTests { unset-mirror See 'package help config ' for detailed help. + Use 'package help config --search ' to search commands and options. """) } diff --git a/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift new file mode 100644 index 000000000..5e41fa1af --- /dev/null +++ b/Tests/ArgumentParserUnitTests/CommandSearcherTests.swift @@ -0,0 +1,845 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 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 CommandSearcherTests: XCTestCase {} + +// MARK: - Test Commands + +private struct SimpleCommand: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "A simple command for testing", + discussion: "This command has a longer discussion about what it does." + ) + + @Option(help: "The user's name") + var name: String + + @Option(help: "The user's age") + var age: Int? + + @Flag(help: "Enable verbose output") + var verbose: Bool = false + + @Argument(help: "Files to process") + var files: [String] = [] +} + +private struct ParentCommand: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Parent command with subcommands", + subcommands: [ChildOne.self, ChildTwo.self] + ) + + struct ChildOne: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "child-one", + abstract: "First child command" + ) + + @Option(help: "Configuration file path") + var config: String? + } + + struct ChildTwo: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "child-two", + abstract: "Second child for searching", + aliases: ["c2", "child2"] + ) + + @Flag(help: "Force the operation") + var force: Bool = false + } +} + +private enum OutputFormat: String, CaseIterable, ExpressibleByArgument { + case json, xml, yaml +} + +private struct CommandWithEnums: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Command with enumeration options" + ) + + @Option(help: "Output format") + var format: OutputFormat = .json +} + +// MARK: - Basic Search Tests +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + // swift-format-ignore: AlwaysUseLowerCamelCase + func testSearch_CommandName() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [ParentCommand.self], + visibility: .default + ) + + let results = engine.search(for: "child") + + // Should find both child commands + XCTAssertEqual(results.count, 2) + XCTAssertTrue(results.allSatisfy { $0.isCommandMatch }) + + // Both should be command name matches + XCTAssertTrue( + results.allSatisfy { + if case .commandName = $0.matchType { return true } + return false + }) + } + + func testSearch_CommandAlias() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [ParentCommand.self], + visibility: .default + ) + + let results = engine.search(for: "c2") + + XCTAssertEqual(results.count, 1) + guard case .commandName(let matched) = results.first?.matchType else { + XCTFail("Expected command name match") + return + } + XCTAssertEqual(matched, "c2") + } + + func testSearch_CommandAbstract() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "testing") + + XCTAssertEqual(results.count, 1) + guard case .commandDescription = results.first?.matchType else { + XCTFail("Expected command description match") + return + } + } + + func testSearch_CommandDiscussion() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "longer discussion") + + XCTAssertEqual(results.count, 1) + guard case .commandDescription = results.first?.matchType else { + XCTFail("Expected command description match") + return + } + } +} + +// MARK: - Argument Search Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSearch_ArgumentName() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "name") + + XCTAssertEqual(results.count, 1) + XCTAssertFalse(results[0].isCommandMatch) + guard case .argumentName(let name, _) = results[0].matchType else { + XCTFail("Expected argument name match") + return + } + XCTAssertEqual(name, "--name") + } + + func testSearch_ArgumentHelp() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "user's") + + XCTAssertGreaterThanOrEqual(results.count, 1) + // Should find matches in help text + let helpMatches = results.filter { + if case .argumentDescription = $0.matchType { return true } + return false + } + XCTAssertGreaterThan(helpMatches.count, 0) + } + + func testSearch_ArgumentValue() { + let tree = CommandParser(CommandWithEnums.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [CommandWithEnums.self], + visibility: .default + ) + + let results = engine.search(for: "json") + + XCTAssertGreaterThanOrEqual(results.count, 1) + // Should find in possible values or default value + let valueMatches = results.filter { + if case .argumentValue = $0.matchType { return true } + return false + } + XCTAssertGreaterThan(valueMatches.count, 0) + } + + func testSearch_PositionalArgument() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "files") + + XCTAssertGreaterThanOrEqual(results.count, 1) + // Positional arguments should be searchable + let positionalMatches = results.filter { + if case .argumentName(let name, _) = $0.matchType { + return name.contains("") + } + return false + } + XCTAssertGreaterThan(positionalMatches.count, 0) + } + + func testSearch_ArgumentDiscussion() { + struct TestCommand: ParsableCommand { + @Option( + help: ArgumentHelp( + "Short help", + discussion: + "This is a much longer discussion that explains the configuration file format in detail." + )) + var config: String? + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "configuration file format") + + XCTAssertGreaterThanOrEqual(results.count, 1) + // Should find match in discussion + let discussionMatches = results.filter { + if case .argumentDescription = $0.matchType { return true } + return false + } + XCTAssertGreaterThan(discussionMatches.count, 0) + // Should match in discussion, not in the short help + XCTAssertTrue( + results[0].contextSnippet.contains("configuration file format")) + } + + func testSearch_ArgumentDefaultValue() { + struct TestCommand: ParsableCommand { + @Option(help: "The output format") + var format: OutputFormat = .json + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "json") + + XCTAssertGreaterThanOrEqual(results.count, 1) + // Should find match in default value + let valueMatches = results.filter { + if case .argumentValue = $0.matchType { return true } + return false + } + XCTAssertGreaterThan( + valueMatches.count, 0, + "Should find 'json' in default value or possible values") + } + + func testSearch_StringDefaultValue() { + // Test specifically for Check 5: default value search on String options + // This ensures we hit the default value code path, not allValueStrings + struct TestCommand: ParsableCommand { + @Option(help: "The target path") + var target: String = "/var/log/myapp" + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + // Search for something that ONLY appears in the default value + let results = engine.search(for: "myapp") + + XCTAssertGreaterThanOrEqual( + results.count, 1, "Should find match in default value") + + // Should find match as argumentValue + let valueMatches = results.filter { + if case .argumentValue(_, let matchedText) = $0.matchType { + // Verify it's matching the default value + return matchedText.contains("/var/log/myapp") + } + return false + } + XCTAssertGreaterThan( + valueMatches.count, 0, + "Should find 'myapp' in the default value '/var/log/myapp'") + + // Verify the snippet indicates it's a default value + let hasDefaultSnippet = results.contains { result in + result.contextSnippet.contains("default:") + } + XCTAssertTrue( + hasDefaultSnippet, + "Context snippet should indicate this is a default value") + } + + func testSearch_PossibleValues_Explicit() { + // Test that all possible enum values are searchable + let tree = CommandParser(CommandWithEnums.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [CommandWithEnums.self], + visibility: .default + ) + + // Test each enum value + for searchTerm in ["json", "xml", "yaml"] { + let results = engine.search(for: searchTerm) + XCTAssertGreaterThanOrEqual( + results.count, 1, "Should find '\(searchTerm)'") + + let valueMatches = results.filter { + if case .argumentValue = $0.matchType { return true } + return false + } + XCTAssertGreaterThan( + valueMatches.count, 0, "'\(searchTerm)' should match as a value") + } + } +} + +// MARK: - Case Sensitivity Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSearch_CaseInsensitive() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let resultsLower = engine.search(for: "verbose") + let resultsUpper = engine.search(for: "VERBOSE") + let resultsMixed = engine.search(for: "VeRbOsE") + + XCTAssertEqual(resultsLower.count, resultsUpper.count) + XCTAssertEqual(resultsLower.count, resultsMixed.count) + XCTAssertGreaterThan(resultsLower.count, 0) + } +} + +// MARK: - Result Ordering Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSearch_ResultOrdering() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [ParentCommand.self], + visibility: .default + ) + + // Search for "command" which appears in both command names and descriptions + let results = engine.search(for: "command") + + // Command matches should come before argument matches + var seenArgumentMatch = false + for result in results { + if !result.isCommandMatch { + seenArgumentMatch = true + } else if seenArgumentMatch { + XCTFail("Command match found after argument match") + } + } + } +} + +// MARK: - Empty and No-Match Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSearch_EmptyTerm() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "") + + XCTAssertEqual(results.count, 0) + } + + func testSearch_NoMatches() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "xyzzynonexistent") + + XCTAssertEqual(results.count, 0) + } +} + +// MARK: - Priority Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSearch_MatchPriority() { + // When a term matches multiple attributes of the same item, + // only the highest priority match should be returned + + struct TestCommand: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "A test command" + ) + + @Option(help: "test option help") + var test: String? + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "test") + + // Should get matches for both command and argument, but not duplicates + // Command should match in abstract, argument should match in name + let commandMatches = results.filter { $0.isCommandMatch } + let argumentMatches = results.filter { !$0.isCommandMatch } + + XCTAssertEqual( + commandMatches.count, 1, "Should have exactly one command match") + XCTAssertEqual( + argumentMatches.count, 1, "Should have exactly one argument match") + + // The argument match should be for the name (higher priority than help) + guard case .argumentName = argumentMatches.first?.matchType else { + XCTFail("Expected argument name match, not help match") + return + } + } +} + +// MARK: - ANSI Highlighting Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testANSI_Highlight() { + let text = "This is a test string" + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: true) + XCTAssertEqual( + highlighted, + "This is a " + ANSICode.bold + "test" + ANSICode.reset + " string") + } + + func testANSI_HighlightDisabled() { + let text = "This is a test string" + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: false) + + XCTAssertEqual(highlighted, text) + XCTAssertFalse(highlighted.contains(ANSICode.bold)) + } + + func testANSI_HighlightMultipleMatches() { + let text = "test this test that test" + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: true) + + // Should highlight all three occurrences + let boldCount = highlighted.components(separatedBy: ANSICode.bold).count - 1 + XCTAssertEqual(boldCount, 3) + } + + func testANSI_HighlightCaseInsensitive() { + let text = "Test this TEST that TeSt" + let highlighted = ANSICode.highlightMatches( + in: text, matching: "test", enabled: true) + + // Should highlight all three occurrences regardless of case + let boldCount = highlighted.components(separatedBy: ANSICode.bold).count - 1 + XCTAssertEqual(boldCount, 3) + } +} + +// MARK: - Snippet Extraction Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testSnippet_CenteredOnMatch() { + struct TestCommand: ParsableCommand { + static let configuration = CommandConfiguration( + discussion: + "This is a very long discussion that contains many words and the word needle appears somewhere in the middle of all this text." + ) + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "needle") + + XCTAssertEqual(results.count, 1) + // Snippet should contain the match and surrounding context + XCTAssertTrue(results[0].contextSnippet.contains("needle")) + // Should be reasonably short (around 80 chars) + XCTAssertLessThan(results[0].contextSnippet.count, 100) + } +} + +// MARK: - Format Results Tests + +// swift-format-ignore: AlwaysUseLowerCamelCase +extension CommandSearcherTests { + func testFormatResults_NoMatches() { + let formatted = CommandSearcher.formatResults( + [], + term: "test", + toolName: "mytool", + screenWidth: 80, + useHighlighting: false + ) + + XCTAssertTrue(formatted.contains("No matches found")) + XCTAssertTrue(formatted.contains("test")) + XCTAssertTrue(formatted.contains("--help")) + } + + func testFormatResults_WithMatches() { + let tree = CommandParser(SimpleCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [SimpleCommand.self], + visibility: .default + ) + + let results = engine.search(for: "name") + let formatted = CommandSearcher.formatResults( + results, + term: "name", + toolName: "simple-command", + screenWidth: 80 + ) + + XCTAssertTrue(formatted.contains("Found")) + XCTAssertTrue(formatted.contains("match")) + XCTAssertTrue(formatted.contains("name")) + } + + func testFormatResults_GroupsByType() { + let tree = CommandParser(ParentCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [ParentCommand.self], + visibility: .default + ) + + let results = engine.search(for: "child") + let formatted = CommandSearcher.formatResults( + results, + term: "child", + toolName: "parent-command", + screenWidth: 80, + useHighlighting: false + ) + + // Should have COMMANDS section for command matches + XCTAssertTrue(formatted.contains("COMMANDS:")) + } + + func testFormatResults_CommandDescriptionFormatting() { + struct TestCommand: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: + "This is a test command that performs various operations on data files." + ) + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "operations") + let formatted = CommandSearcher.formatResults( + results, + term: "operations", + toolName: "test-command", + screenWidth: 80, + useHighlighting: false + ) + + // Should find the command description match + XCTAssertTrue(formatted.contains("Found")) + XCTAssertTrue(formatted.contains("match")) + // Should show the command path + XCTAssertTrue(formatted.contains("test-command")) + // Should contain the matched term + XCTAssertTrue(formatted.contains("operations")) + // Should have the context snippet with description text + XCTAssertTrue( + formatted.contains("performs") || formatted.contains("data files")) + } + + func testFormatResults_CommandDescriptionWrapping() { + struct TestCommand: ParsableCommand { + static let configuration = CommandConfiguration( + discussion: + "This is a very long discussion that contains many words and should wrap when displayed in the search results because it exceeds the screen width limit that we set for formatting purposes." + ) + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "screen width") + let formatted = CommandSearcher.formatResults( + results, + term: "screen width", + toolName: "test-command", + screenWidth: 60, // Narrow width to force wrapping + useHighlighting: false + ) + + // Should find the match + XCTAssertGreaterThan(results.count, 0) + // Should contain the matched terms + XCTAssertTrue(formatted.contains("screen")) + XCTAssertTrue(formatted.contains("width")) + // Should have proper indentation (wrapped lines indented by 6 spaces) + // The formatting adds 4 spaces base indent + wrapping indent + let lines = formatted.split(separator: "\n") + let hasIndentedLine = lines.contains { line in + line.starts(with: " ") // 6 spaces for continuation lines + } + XCTAssertTrue( + hasIndentedLine, "Should have wrapped lines with proper indentation") + } + + func testFormatResults_ArgumentDescriptionFormatting() { + // Test the .argumentDescription case formatting + struct TestCommand: ParsableCommand { + @Option( + help: + "This option controls the maximum retry attempts for network requests" + ) + var maxRetries: Int = 3 + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "network requests") + let formatted = CommandSearcher.formatResults( + results, + term: "network requests", + toolName: "test-command", + screenWidth: 80, + useHighlighting: false + ) + + // Should find the match + XCTAssertGreaterThan(results.count, 0) + // Should have OPTIONS section for argument matches + XCTAssertTrue(formatted.contains("OPTIONS:")) + // Should show argument name with colon format: " --max-retries: " + XCTAssertTrue(formatted.contains("--max-retries:")) + // Should contain the matched terms + XCTAssertTrue(formatted.contains("network")) + XCTAssertTrue(formatted.contains("requests")) + } + + func testFormatResults_ArgumentDescriptionWrapping() { + // Test that argumentDescription wrapping works with 6-space indent + struct TestCommand: ParsableCommand { + @Option( + help: ArgumentHelp( + "Short help", + discussion: + "This is a very long discussion about an option that contains many words and should definitely wrap when displayed because it exceeds our screen width limit for testing purposes." + )) + var config: String? + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "screen width") + let formatted = CommandSearcher.formatResults( + results, + term: "screen width", + toolName: "test-command", + screenWidth: 60, // Narrow width to force wrapping + useHighlighting: false + ) + + // Should find match + XCTAssertGreaterThan(results.count, 0) + // Should have OPTIONS section + XCTAssertTrue(formatted.contains("OPTIONS:")) + // Format should be " --config: " + XCTAssertTrue(formatted.contains("--config:")) + // Should have wrapped lines with proper indentation + let lines = formatted.split(separator: "\n") + let hasIndentedLine = lines.contains { line in + line.starts(with: " ") // 6 spaces for continuation + } + XCTAssertTrue( + hasIndentedLine, "Argument description should wrap with 6-space indent") + } + + func testFormatResults_ArgumentValueFormatting() { + // Test the .argumentValue case formatting + struct TestCommand: ParsableCommand { + @Option(help: "The output format") + var format: OutputFormat = .yaml + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "yaml") + let formatted = CommandSearcher.formatResults( + results, + term: "yaml", + toolName: "test-command", + screenWidth: 80, + useHighlighting: false + ) + + // Should find the match + XCTAssertGreaterThan(results.count, 0) + // Should have OPTIONS section + XCTAssertTrue(formatted.contains("OPTIONS:")) + // Format should be " --format (possible value: yaml)" or " --format (default: yaml)" + XCTAssertTrue(formatted.contains("--format")) + XCTAssertTrue(formatted.contains("yaml")) + // Should have parentheses around the value snippet + XCTAssertTrue(formatted.contains("(") && formatted.contains(")")) + } + + func testFormatResults_DefaultValueFormatting() { + // Test .argumentValue formatting for default values specifically + struct TestCommand: ParsableCommand { + @Option(help: "Log file path") + var logPath: String = "/tmp/app.log" + } + + let tree = CommandParser(TestCommand.self).commandTree + let engine = CommandSearcher( + rootNode: tree, + commandStack: [TestCommand.self], + visibility: .default + ) + + let results = engine.search(for: "app.log") + let formatted = CommandSearcher.formatResults( + results, + term: "app.log", + toolName: "test-command", + screenWidth: 80, + useHighlighting: false + ) + + // Should find the match + XCTAssertGreaterThan(results.count, 0) + // Should have OPTIONS section + XCTAssertTrue(formatted.contains("OPTIONS:")) + // Format should be " --log-path (default: /tmp/app.log)" + XCTAssertTrue(formatted.contains("--log-path")) + XCTAssertTrue(formatted.contains("default:")) + // Check for parts of the path (the matched part may have ANSI codes) + XCTAssertTrue(formatted.contains("/tmp/")) + XCTAssertTrue(formatted.contains("app.log")) + } +} diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift index 9128369b8..761a458fe 100644 --- a/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift +++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift @@ -277,6 +277,7 @@ extension HelpGenerationTests { child-with-groups See 'parent-with-groups help ' for detailed help. + Use 'parent-with-groups help --search ' to search commands and options. """) AssertHelp( @@ -300,6 +301,7 @@ extension HelpGenerationTests { child-with-groups See 'parent-with-groups help ' for detailed help. + Use 'parent-with-groups help --search ' to search commands and options. """) AssertHelp( diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift index cc3973c71..78689f24b 100644 --- a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift +++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift @@ -354,6 +354,7 @@ extension HelpGenerationTests { another-command See 'h help ' for detailed help. + Use 'h help --search ' to search commands and options. """) AssertHelp( @@ -485,6 +486,7 @@ extension HelpGenerationTests { m (default) See 'n help ' for detailed help. + Use 'n help --search ' to search commands and options. """) } @@ -602,6 +604,7 @@ extension HelpGenerationTests { n See 'subgroupings help ' for detailed help. + Use 'subgroupings help --search ' to search commands and options. """) } @@ -639,6 +642,7 @@ extension HelpGenerationTests { n See 'subgroupings help ' for detailed help. + Use 'subgroupings help --search ' to search commands and options. """) } } @@ -926,6 +930,7 @@ extension HelpGenerationTests { example-subcommand See 'non-custom-usage help ' for detailed help. + Use 'non-custom-usage help --search ' to search commands and options. """) AssertEqualStrings( diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json index cb981545d..2b8393381 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testADumpHelp().json @@ -178,7 +178,7 @@ "shouldDisplay" : true, "subcommands" : [ { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -213,6 +213,29 @@ }, "shouldDisplay" : false, "valueName" : "help" + }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" } ], "commandName" : "help", diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json index 5b584df63..1b2455c07 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBDumpHelp().json @@ -67,7 +67,7 @@ "shouldDisplay" : true, "subcommands" : [ { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -102,6 +102,29 @@ }, "shouldDisplay" : false, "valueName" : "help" + }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" } ], "commandName" : "help", diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash index 3c57c8601..7603536c0 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash @@ -251,7 +251,16 @@ _base-test_escaped-command() { } _base-test_help() { - : + flags=() + options=(-s --search) + __base-test_offer_flags_options 1 + + # Offer option value completions + case "${prev}" in + '-s'|'--search') + return + ;; + esac } complete -o filenames -F _base-test base-test \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish index 4f58f7887..4642ee54b 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Fish().fish @@ -12,7 +12,7 @@ function __base-test_should_offer_completions_for -a expected_commands -a expect case 'escaped-command' __base-test_parse_subcommand 1 'o:n[e=' 'h/help' case 'help' - __base-test_parse_subcommand -r 1 + __base-test_parse_subcommand -r 1 's/search=' end end @@ -85,8 +85,9 @@ complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test" complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test"' -s 'h' -l 'help' -d 'Show help information.' complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test" 3' -fa 'sub-command' -d '' complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test" 3' -fa 'escaped-command' -d '' -complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test" 3' -fa 'help' -d 'Show subcommand help information.' +complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test" 3' -fa 'help' -d 'Show subcommand help information. Use --search to find commands and options.' complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test sub-command"' -s 'h' -l 'help' -d 'Show help information.' complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test escaped-command"' -l 'o:n[e' -d 'Escaped chars: \'[]\\.' -rfka '' complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test escaped-command" 1' -fka '(__base-test_custom_completion ---completion escaped-command -- positional@0 (count (__base-test_tokens -pc)) (__base-test_tokens -tC))' -complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test escaped-command"' -s 'h' -l 'help' -d 'Show help information.' \ No newline at end of file +complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test escaped-command"' -s 'h' -l 'help' -d 'Show help information.' +complete -c 'base-test' -n '__base-test_should_offer_completions_for "base-test help"' -s 's' -l 'search' -d 'Search for commands and options matching the term.' -rfka '' \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh index f289a2cc5..fbec926ab 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Zsh().zsh @@ -68,7 +68,7 @@ _base-test() { local -ar subcommands=( 'sub-command:' 'escaped-command:' - 'help:Show subcommand help information.' + 'help:Show subcommand help information. Use --search to find commands and options.' ) _describe -V subcommand subcommands ;; @@ -110,6 +110,7 @@ _base-test_help() { local -i ret=1 local -ar arg_specs=( '*:subcommands:' + '(-s --search)'{-s,--search}'[Search for commands and options matching the term.]:search:' ) _arguments -w -s -S : "${arg_specs[@]}" && ret=0 diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json index 7cbcafeea..14746807d 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testCDumpHelp().json @@ -201,7 +201,7 @@ "shouldDisplay" : false, "subcommands" : [ { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -236,6 +236,29 @@ }, "shouldDisplay" : false, "valueName" : "help" + }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" } ], "commandName" : "help", diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json index 51568de7c..bb5c125e9 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathAddDumpHelp().json @@ -81,7 +81,7 @@ "shouldDisplay" : true, "subcommands" : [ { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -117,6 +117,29 @@ "shouldDisplay" : false, "valueName" : "help" }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" + }, { "abstract" : "Show the version.", "isOptional" : true, diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json index badff7811..b2a3e33d3 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathDumpHelp().json @@ -725,7 +725,7 @@ ] }, { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -761,6 +761,29 @@ "shouldDisplay" : false, "valueName" : "help" }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" + }, { "abstract" : "Show the version.", "isOptional" : true, diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json index 7494decf6..92e9f6bbe 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathMultiplyDumpHelp().json @@ -81,7 +81,7 @@ "shouldDisplay" : true, "subcommands" : [ { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -117,6 +117,29 @@ "shouldDisplay" : false, "valueName" : "help" }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" + }, { "abstract" : "Show the version.", "isOptional" : true, diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json index 0a1e95164..0a5a183cc 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json +++ b/Tests/ArgumentParserUnitTests/Snapshots/testMathStatsDumpHelp().json @@ -503,7 +503,7 @@ ] }, { - "abstract" : "Show subcommand help information.", + "abstract" : "Show subcommand help information. Use --search to find commands and options.", "arguments" : [ { "isOptional" : true, @@ -539,6 +539,29 @@ "shouldDisplay" : false, "valueName" : "help" }, + { + "abstract" : "Search for commands and options matching the term.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "short", + "name" : "s" + }, + { + "kind" : "long", + "name" : "search" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "search" + }, + "shouldDisplay" : true, + "valueName" : "search" + }, { "abstract" : "Show the version.", "isOptional" : true,